diff --git a/.woodpecker/build.yml b/.woodpecker/build.yml index 77b69fc..9c181f7 100644 --- a/.woodpecker/build.yml +++ b/.woodpecker/build.yml @@ -1,5 +1,5 @@ when: - - event: [push, pull_request] + - event: pull_request steps: - name: build diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - uvx pre-commit run --all-files + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/test.yml b/.woodpecker/test.yml index 4a56955..bb94e07 100644 --- a/.woodpecker/test.yml +++ b/.woodpecker/test.yml @@ -1,5 +1,5 @@ when: - - event: [push, pull_request] + - event: pull_request steps: - name: lint diff --git a/Makefile b/Makefile index aabeb4f..308f327 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,10 @@ -.PHONY: build install test lint fmt clean tidy +.PHONY: build install test lint fmt clean tidy patch minor major BINARY := terraform-provider-artifactapi -VERSION ?= 0.0.1 +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev") OS_ARCH := linux_amd64 -INSTALL_DIR := ~/.terraform.d/plugins/git.unkin.net/unkin/artifactapi/$(VERSION)/$(OS_ARCH) +INSTALL_VERSION := $(shell echo $(VERSION) | sed 's/^v//') +INSTALL_DIR := ~/.terraform.d/plugins/git.unkin.net/unkin/artifactapi/$(INSTALL_VERSION)/$(OS_ARCH) GO_VERSION_REQUIRED := 1.23 GO_VERSION_ACTUAL := $(shell go version | sed 's/go version go\([0-9]*\.[0-9]*\).*/\1/') @@ -34,3 +35,21 @@ clean: tidy: go mod tidy + +_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1) +_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0) +_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1) +_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2) +_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3) + +patch: + @NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +minor: + @NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW + +major: + @NEW=v$(shell expr $(_MAJ) + 1).0.0; \ + git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW diff --git a/internal/provider/helpers.go b/internal/provider/helpers.go index 1564e36..4a1a0f4 100644 --- a/internal/provider/helpers.go +++ b/internal/provider/helpers.go @@ -16,7 +16,7 @@ func listToStrings(ctx context.Context, l types.List) []string { } func stringsToList(ctx context.Context, ss []string) types.List { - if len(ss) == 0 { + if ss == nil { return types.ListNull(types.StringType) } elems := make([]types.String, len(ss)) @@ -26,3 +26,14 @@ func stringsToList(ctx context.Context, ss []string) types.List { list, _ := types.ListValueFrom(ctx, types.StringType, elems) return list } + +// preserveListNullEmptySemantics keeps the prior null/empty distinction when +// the API returns null for a field that was previously an empty list. +// Without this, OpenTofu reports "inconsistent result after apply" because +// the plan/state had [] but the provider returned null. +func preserveListNullEmptySemantics(prior, current types.List) types.List { + if current.IsNull() && !prior.IsNull() && len(prior.Elements()) == 0 { + return prior + } + return current +} diff --git a/internal/provider/helpers_test.go b/internal/provider/helpers_test.go index 619aaa7..8a06ed8 100644 --- a/internal/provider/helpers_test.go +++ b/internal/provider/helpers_test.go @@ -63,8 +63,11 @@ func TestListToStrings_WithValues(t *testing.T) { func TestStringsToList_EmptySlice(t *testing.T) { ctx := context.Background() result := stringsToList(ctx, []string{}) - if !result.IsNull() { - t.Fatalf("expected null list for empty slice, got %v", result) + if result.IsNull() { + t.Fatalf("expected empty list for empty slice, got null") + } + if len(result.Elements()) != 0 { + t.Fatalf("expected 0 elements, got %d", len(result.Elements())) } } @@ -103,3 +106,42 @@ func TestStringsToList_SingleValue(t *testing.T) { t.Fatalf("expected [\"solo\"], got %v", back) } } + +func TestPreserveListNullEmptySemantics(t *testing.T) { + ctx := context.Background() + emptyList, _ := types.ListValueFrom(ctx, types.StringType, []types.String{}) + nullList := types.ListNull(types.StringType) + populatedList := stringsToList(ctx, []string{"a"}) + + t.Run("prior empty + current null → preserves empty", func(t *testing.T) { + result := preserveListNullEmptySemantics(emptyList, nullList) + if result.IsNull() { + t.Fatal("expected empty list, got null") + } + if len(result.Elements()) != 0 { + t.Fatalf("expected 0 elements, got %d", len(result.Elements())) + } + }) + + t.Run("prior null + current null → stays null", func(t *testing.T) { + result := preserveListNullEmptySemantics(nullList, nullList) + if !result.IsNull() { + t.Fatal("expected null, got non-null") + } + }) + + t.Run("prior empty + current populated → uses current", func(t *testing.T) { + result := preserveListNullEmptySemantics(emptyList, populatedList) + elems := listToStrings(ctx, result) + if len(elems) != 1 || elems[0] != "a" { + t.Fatalf("expected [a], got %v", elems) + } + }) + + t.Run("prior populated + current null → stays null", func(t *testing.T) { + result := preserveListNullEmptySemantics(populatedList, nullList) + if !result.IsNull() { + t.Fatal("expected null (prior was populated, not empty — no preservation)") + } + }) +} diff --git a/internal/provider/resource_remote.go b/internal/provider/resource_remote.go index edfbcc7..25587c2 100644 --- a/internal/provider/resource_remote.go +++ b/internal/provider/resource_remote.go @@ -181,6 +181,7 @@ func (r *remoteResource) Create(ctx context.Context, req resource.CreateRequest, } state := r.apiToModel(ctx, created) + reconcileOptionalLists(&plan, &state) resp.Diagnostics.Append(resp.State.Set(ctx, state)...) } @@ -203,6 +204,7 @@ func (r *remoteResource) Read(ctx context.Context, req resource.ReadRequest, res } newState := r.apiToModel(ctx, remote) + reconcileOptionalLists(&state, &newState) resp.Diagnostics.Append(resp.State.Set(ctx, newState)...) } @@ -223,6 +225,7 @@ func (r *remoteResource) Update(ctx context.Context, req resource.UpdateRequest, } state := r.apiToModel(ctx, updated) + reconcileOptionalLists(&plan, &state) resp.Diagnostics.Append(resp.State.Set(ctx, state)...) } @@ -243,6 +246,14 @@ func (r *remoteResource) ImportState(ctx context.Context, req resource.ImportSta resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) } +func reconcileOptionalLists(prior, current *remoteResourceModel) { + current.Patterns = preserveListNullEmptySemantics(prior.Patterns, current.Patterns) + current.Blocklist = preserveListNullEmptySemantics(prior.Blocklist, current.Blocklist) + current.MutablePatterns = preserveListNullEmptySemantics(prior.MutablePatterns, current.MutablePatterns) + current.ImmutablePatterns = preserveListNullEmptySemantics(prior.ImmutablePatterns, current.ImmutablePatterns) + current.BanTags = preserveListNullEmptySemantics(prior.BanTags, current.BanTags) +} + func (r *remoteResource) modelToAPI(ctx context.Context, m remoteResourceModel) remoteAPI { api := remoteAPI{ Name: m.Name.ValueString(),