From 54f1569e018c6ad954f75c0ddcb5b7fe22c2eead Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Wed, 14 Oct 2026 10:09:11 +0200 Subject: [PATCH 1/3] feat(client): drop the deprecated building block shapes The meshBuildingBlock client sends the parents in spec.parentBuildingBlockRefs alone, which every meshStack from MinMeshStackVersion on reads, so the client drops the deprecated parentBuildingBlocks field and its MarshalJSON and UnmarshalJSON. The v1 building block client (client.BuildingBlock, MeshBuildingBlock and its DTOs) goes as well: only the Terraform provider's removed meshstack_buildingblock resource used it. The client no longer reads the deprecated status.status and drops the status list filter: it drops BuildingBlockStatus, the Status field of MeshBuildingBlockV2Status and MeshBuildingBlockV2ListFilter.Status. Callers read status.action. MeshBuildingBlockRun.IsRunning takes over the run status check of the tfstate lock. The clients keep the v2-preview and v1-preview media types, so they work against the current meshStack and against the release that makes these APIs GA. Co-Authored-By: Claude Opus 5.5 --- client/building_block_definition_version.go | 7 +- client/building_block_run.go | 4 + client/building_block_v2.go | 139 ------------------ client/buildingblock.go | 95 ------------ client/client.go | 2 - client/client_kind_test.go | 1 - client/raw.go | 4 +- client/wire.go | 12 ++ cmd/buildingblock/trigger_run_test.go | 2 +- .../testacc/testdata/bootstrap/main.tf | 8 +- docs/demo/fakemeshstack/meshstack.go | 48 +++--- internal/tfstate/lock.go | 2 +- skills/meshstack-cli/SKILL.md | 3 +- 13 files changed, 52 insertions(+), 275 deletions(-) delete mode 100644 client/buildingblock.go create mode 100644 client/wire.go diff --git a/client/building_block_definition_version.go b/client/building_block_definition_version.go index df33344c..7637b90d 100644 --- a/client/building_block_definition_version.go +++ b/client/building_block_definition_version.go @@ -192,10 +192,9 @@ type MeshBuildingBlockDefinitionVersionSpec struct { VersionNumber *int64 `json:"versionNumber,omitzero" tfsdk:"version_number"` State *MeshBuildingBlockDefinitionVersionState `json:"state,omitzero" tfsdk:"state"` RunnerRef *UuidRef `json:"runnerRef" tfsdk:"runner_ref"` - // Replaces the deprecated bare-UUID dependencyDefinitionUuids; requires a backend serving it. - DependencyDefinitionRefs types.Set[UuidRef] `json:"dependencyDefinitionRefs,omitempty" tfsdk:"dependency_refs"` - Implementation MeshBuildingBlockDefinitionImplementation `json:"implementation" tfsdk:"implementation"` - Inputs map[string]*MeshBuildingBlockDefinitionInput `json:"inputs" tfsdk:"inputs"` + DependencyDefinitionRefs types.Set[UuidRef] `json:"dependencyDefinitionRefs,omitempty" tfsdk:"dependency_refs"` + Implementation MeshBuildingBlockDefinitionImplementation `json:"implementation" tfsdk:"implementation"` + Inputs map[string]*MeshBuildingBlockDefinitionInput `json:"inputs" tfsdk:"inputs"` } type MeshBuildingBlockDefinitionVersionStatus struct { diff --git a/client/building_block_run.go b/client/building_block_run.go index 6ee02b87..565cb7bf 100644 --- a/client/building_block_run.go +++ b/client/building_block_run.go @@ -15,6 +15,10 @@ type MeshBuildingBlockRun struct { Links MeshBuildingBlockRunLinks `json:"_links,omitzero"` } +func (run MeshBuildingBlockRun) IsRunning() bool { + return run.Status == "PENDING" || run.Status == "IN_PROGRESS" +} + type MeshBuildingBlockRunMetadata struct { Uuid string `json:"uuid"` CreatedAt time.Time `json:"createdAt"` diff --git a/client/building_block_v2.go b/client/building_block_v2.go index ae13eafd..ae85cbe4 100644 --- a/client/building_block_v2.go +++ b/client/building_block_v2.go @@ -2,7 +2,6 @@ package client import ( "context" - "encoding/json/jsontext" "encoding/json/v2" "errors" "fmt" @@ -23,21 +22,6 @@ var ( BuildingBlockLifecycleStateDeleted = BuildingBlockLifecycleStates.Entry("DELETED") ) -type BuildingBlockStatus string - -var ( - BuildingBlockStatuses = enum.Enum[BuildingBlockStatus]{} - BuildingBlockStatusWaitingForDependentInput = BuildingBlockStatuses.Entry("WAITING_FOR_DEPENDENT_INPUT") - BuildingBlockStatusWaitingForOperatorInput = BuildingBlockStatuses.Entry("WAITING_FOR_OPERATOR_INPUT") - BuildingBlockStatusWaitingForUserInput = BuildingBlockStatuses.Entry("WAITING_FOR_USER_INPUT") - BuildingBlockStatusWaitingForApproval = BuildingBlockStatuses.Entry("WAITING_FOR_APPROVAL") - BuildingBlockStatusPending = BuildingBlockStatuses.Entry("PENDING") - BuildingBlockStatusInProgress = BuildingBlockStatuses.Entry("IN_PROGRESS") - BuildingBlockStatusSucceeded = BuildingBlockStatuses.Entry("SUCCEEDED") - BuildingBlockStatusFailed = BuildingBlockStatuses.Entry("FAILED") - BuildingBlockStatusAborted = BuildingBlockStatuses.Entry("ABORTED") -) - type BuildingBlockHealthStatus string var ( @@ -84,127 +68,6 @@ type MeshBuildingBlockV2Spec struct { // Inputs as pointer MeshBuildingBlockInput to support mocking secret responses. Inputs map[string]*MeshBuildingBlockInput `json:"inputs" tfsdk:"inputs"` ParentBuildingBlockRefs types.Set[UuidRef] `json:"parentBuildingBlockRefs" tfsdk:"parent_building_block_refs"` - - // ParentBuildingBlocks is the deprecated parentBuildingBlocks field, which MarshalJSON and - // UnmarshalJSON carry. The deprecated meshstack_building_block_v2 surfaces of the Terraform - // provider read the definition uuid they report from it. - ParentBuildingBlocks types.Set[MeshBuildingBlockV2Parent] `json:"-" tfsdk:"-"` -} - -// MeshBuildingBlockV2Parent is an entry of the deprecated parentBuildingBlocks field. -type MeshBuildingBlockV2Parent struct { - UuidRef - - // BuildingBlockUuid always holds the same value as Uuid. - BuildingBlockUuid string `json:"buildingBlockUuid"` - // DefinitionUuid is the parent's building block definition. The backend derives it from the - // referenced block, so every response carries it and a request never does. - DefinitionUuid string `json:"definitionUuid,omitempty"` -} - -// UnmarshalJSON fills Uuid from the deprecated buildingBlockUuid, which is where a response carries -// the parent's identity. -func (p *MeshBuildingBlockV2Parent) UnmarshalJSON(data []byte) error { - type wire MeshBuildingBlockV2Parent - var target wire - if err := json.Unmarshal(data, &target); err != nil { - return err - } - - *p = MeshBuildingBlockV2Parent(target) - if p.Uuid == "" { - p.Uuid = p.BuildingBlockUuid - } - p.BuildingBlockUuid = p.Uuid - if p.Kind == "" { - p.Kind = MeshObjectKind.BuildingBlock - } - - return nil -} - -// wireCompatibility repeats the options internal/json marshals every request with: a v1-style -// MarshalJSON receives none of its caller's, so without it a nested value would go out in a -// different shape than the request around it. -var wireCompatibility = json.JoinOptions( - json.Deterministic(true), - json.FormatNilSliceAsNull(true), - json.FormatNilMapAsNull(true), -) - -// MarshalJSON sends the parents under both field names: parentBuildingBlockRefs, and the deprecated -// parentBuildingBlocks for a backend that does not know the new field yet. A newer backend accepts -// both as long as they name the same building blocks, and an older one ignores the field it does not -// know, because the meshObject API does not reject unknown properties. -// -// Together with UnmarshalJSON this is the whole compatibility window. Once every backend still in use -// knows parentBuildingBlockRefs, both methods can go. -func (s MeshBuildingBlockV2Spec) MarshalJSON() ([]byte, error) { - type wire MeshBuildingBlockV2Spec - w := wire(s) - if len(w.ParentBuildingBlockRefs) == 0 { - w.ParentBuildingBlockRefs = parentRefsFromDeprecated(w.ParentBuildingBlocks) - } - - var fields map[string]jsontext.Value - if encoded, err := json.Marshal(w, wireCompatibility); err != nil { - return nil, err - } else if err := json.Unmarshal(encoded, &fields); err != nil { - return nil, err - } - - // The deprecated entry sends only buildingBlockUuid: every backend in the supported range reads - // the parent from it, and the definition uuid is always derived from the referenced block. - parents := make([]struct { - BuildingBlockUuid string `json:"buildingBlockUuid"` - }, 0, len(s.ParentBuildingBlockRefs)) - for _, ref := range s.ParentBuildingBlockRefs { - parents = append(parents, struct { - BuildingBlockUuid string `json:"buildingBlockUuid"` - }{BuildingBlockUuid: ref.Uuid}) - } - var err error - if fields["parentBuildingBlocks"], err = json.Marshal(parents); err != nil { - return nil, err - } - - return json.Marshal(fields, wireCompatibility) -} - -func parentRefsFromDeprecated(parents types.Set[MeshBuildingBlockV2Parent]) types.Set[UuidRef] { - refs := make(types.Set[UuidRef], 0, len(parents)) - for _, parent := range parents { - refs = append(refs, UuidRef{Uuid: parent.Uuid, Kind: MeshObjectKind.BuildingBlock}) - } - return refs -} - -// UnmarshalJSON reads the parents from parentBuildingBlockRefs, or from the deprecated -// parentBuildingBlocks when a backend does not serve the new field yet. Terraform then sees the same -// elements against either backend and set hashing stays stable. -func (s *MeshBuildingBlockV2Spec) UnmarshalJSON(data []byte) error { - type wire MeshBuildingBlockV2Spec - var target struct { - wire - - ParentBuildingBlocks types.Set[MeshBuildingBlockV2Parent] `json:"parentBuildingBlocks"` - } - if err := json.Unmarshal(data, &target); err != nil { - return err - } - - *s = MeshBuildingBlockV2Spec(target.wire) - s.ParentBuildingBlocks = target.ParentBuildingBlocks - if len(s.ParentBuildingBlockRefs) == 0 { - s.ParentBuildingBlockRefs = parentRefsFromDeprecated(s.ParentBuildingBlocks) - } - for i := range s.ParentBuildingBlockRefs { - if s.ParentBuildingBlockRefs[i].Kind == "" { - s.ParentBuildingBlockRefs[i].Kind = MeshObjectKind.BuildingBlock - } - } - - return nil } type MeshBuildingBlockInput struct { @@ -265,7 +128,6 @@ type MeshBuildingBlockV2Lifecycle struct { } type MeshBuildingBlockV2Status struct { - Status enum.Entry[BuildingBlockStatus] `json:"status" tfsdk:"status"` HealthStatus enum.Entry[BuildingBlockHealthStatus] `json:"healthStatus" tfsdk:"health_status"` Action enum.Entry[BuildingBlockAction] `json:"action" tfsdk:"action"` Outputs map[string]MeshBuildingBlockOutput `json:"outputs" tfsdk:"outputs"` @@ -325,7 +187,6 @@ type MeshBuildingBlockV2ListFilter struct { TenantUuid *string `json:"tenantUuid"` // TargetKind is meshTenant or meshWorkspace. TargetKind *string `json:"targetRefKind"` - Status *string `json:"status"` // HealthStatus and Action match a building block that has any one of the values. HealthStatus []string `json:"healthStatus"` Action []string `json:"action"` diff --git a/client/buildingblock.go b/client/buildingblock.go deleted file mode 100644 index abe02215..00000000 --- a/client/buildingblock.go +++ /dev/null @@ -1,95 +0,0 @@ -package client - -import ( - "context" - - "github.com/meshcloud/meshstack-cli/client/internal" -) - -//nolint:staticcheck // ST1003: deprecated names, kept as published; the v2 building block client and the IO type enum supersede them -const ( - MESH_BUILDING_BLOCK_IO_TYPE_STRING = "STRING" - MESH_BUILDING_BLOCK_IO_TYPE_INTEGER = "INTEGER" - MESH_BUILDING_BLOCK_IO_TYPE_BOOLEAN = "BOOLEAN" - MESH_BUILDING_BLOCK_IO_TYPE_SINGLE_SELECT = "SINGLE_SELECT" - MESH_BUILDING_BLOCK_IO_TYPE_MULTI_SELECT = "MULTI_SELECT" - MESH_BUILDING_BLOCK_IO_TYPE_FILE = "FILE" - MESH_BUILDING_BLOCK_IO_TYPE_LIST = "LIST" - MESH_BUILDING_BLOCK_IO_TYPE_CODE = "CODE" -) - -type MeshBuildingBlock struct { - Metadata MeshBuildingBlockMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshBuildingBlockSpec `json:"spec" tfsdk:"spec"` - Status MeshBuildingBlockStatus `json:"status" tfsdk:"status"` -} - -type MeshBuildingBlockMetadata struct { - Uuid string `json:"uuid" tfsdk:"uuid"` - DefinitionUuid string `json:"definitionUuid" tfsdk:"definition_uuid"` - DefinitionVersion int64 `json:"definitionVersion" tfsdk:"definition_version"` - TenantIdentifier string `json:"tenantIdentifier" tfsdk:"tenant_identifier"` - ForcePurge bool `json:"forcePurge" tfsdk:"force_purge"` - CreatedOn string `json:"createdOn" tfsdk:"created_on"` - MarkedForDeletionOn *string `json:"markedForDeletionOn" tfsdk:"marked_for_deletion_on"` - MarkedForDeletionBy *string `json:"markedForDeletionBy" tfsdk:"marked_for_deletion_by"` -} - -type MeshBuildingBlockSpec struct { - DisplayName string `json:"displayName" tfsdk:"display_name"` - Inputs []MeshBuildingBlockIO `json:"inputs" tfsdk:"inputs"` - ParentBuildingBlocks []MeshBuildingBlockParent `json:"parentBuildingBlocks" tfsdk:"parent_building_blocks"` -} - -type MeshBuildingBlockIO struct { - Key string `json:"key" tfsdk:"key"` - Value any `json:"value" tfsdk:"value"` - ValueType string `json:"valueType" tfsdk:"value_type"` -} - -type MeshBuildingBlockParent struct { - BuildingBlockUuid string `json:"buildingBlockUuid" tfsdk:"buildingblock_uuid"` - DefinitionUuid string `json:"definitionUuid" tfsdk:"definition_uuid"` -} - -type MeshBuildingBlockStatus struct { - Status string `json:"status" tfsdk:"status"` - Outputs []MeshBuildingBlockIO `json:"outputs" tfsdk:"outputs"` -} - -type MeshBuildingBlockCreate struct { - Metadata MeshBuildingBlockCreateMetadata `json:"metadata" tfsdk:"metadata"` - Spec MeshBuildingBlockSpec `json:"spec" tfsdk:"spec"` -} - -type MeshBuildingBlockCreateMetadata struct { - DefinitionUuid string `json:"definitionUuid" tfsdk:"definition_uuid"` - DefinitionVersion int64 `json:"definitionVersion" tfsdk:"definition_version"` - TenantIdentifier string `json:"tenantIdentifier" tfsdk:"tenant_identifier"` -} - -type MeshBuildingBlockClient interface { - Read(ctx context.Context, uuid string) (*MeshBuildingBlock, error) - Create(ctx context.Context, bb *MeshBuildingBlockCreate) (*MeshBuildingBlock, error) - Delete(ctx context.Context, uuid string) error -} - -type meshBuildingBlockClient struct { - meshObject internal.MeshObjectClient[MeshBuildingBlock] -} - -func newBuildingBlockClient(ctx context.Context, httpClient internal.HttpClient) MeshBuildingBlockClient { - return meshBuildingBlockClient{internal.NewMeshObjectClient[MeshBuildingBlock](ctx, httpClient, "v1")} -} - -func (c meshBuildingBlockClient) Read(ctx context.Context, uuid string) (*MeshBuildingBlock, error) { - return c.meshObject.Get(ctx, uuid) -} - -func (c meshBuildingBlockClient) Create(ctx context.Context, bb *MeshBuildingBlockCreate) (*MeshBuildingBlock, error) { - return c.meshObject.Post(ctx, bb) -} - -func (c meshBuildingBlockClient) Delete(ctx context.Context, uuid string) error { - return c.meshObject.Delete(ctx, uuid) -} diff --git a/client/client.go b/client/client.go index ea7e0983..413ac16c 100644 --- a/client/client.go +++ b/client/client.go @@ -21,7 +21,6 @@ type Authorization = http.Authorization type Client struct { ApiKey MeshApiKeyClient - BuildingBlock MeshBuildingBlockClient BuildingBlockV2 MeshBuildingBlockV2Client BuildingBlockRun MeshBuildingBlockRunClient BuildingBlockRunAction *MeshBuildingBlockRunActionClient @@ -62,7 +61,6 @@ func New(ctx context.Context, endpoint xurl.URL, client http.Client, auth Author workspace := newWorkspaceClient(ctx, authorizedClient) return Client{ ApiKey: newApiKeyClient(ctx, authorizedClient), - BuildingBlock: newBuildingBlockClient(ctx, authorizedClient), BuildingBlockV2: buildingBlockV2, BuildingBlockRun: buildingBlockRun, BuildingBlockRunAction: newBuildingBlockRunActionClient(authorizedClient, buildingBlockRun), diff --git a/client/client_kind_test.go b/client/client_kind_test.go index ecc66293..7d43cf6f 100644 --- a/client/client_kind_test.go +++ b/client/client_kind_test.go @@ -10,7 +10,6 @@ import ( func TestKind(t *testing.T) { assert.Equal(t, internal.InferKind[MeshApiKey](), MeshObjectKind.ApiKey) - assert.Equal(t, internal.InferKind[MeshBuildingBlock](), MeshObjectKind.BuildingBlock) assert.Equal(t, internal.InferKind[MeshBuildingBlockV2](), MeshObjectKind.BuildingBlock) assert.Equal(t, internal.InferKind[MeshBuildingBlockDefinition](), MeshObjectKind.BuildingBlockDefinition) assert.Equal(t, internal.InferKind[MeshBuildingBlockDefinitionVersion](), MeshObjectKind.BuildingBlockDefinitionVersion) diff --git a/client/raw.go b/client/raw.go index ddc2e65f..17bdd4c5 100644 --- a/client/raw.go +++ b/client/raw.go @@ -26,9 +26,7 @@ type SortCriteria []string // to the Terraform provider. type RawClient struct { httpClient internal.HttpClient - // apis is keyed by the Go type rather than the kind, because MeshBuildingBlock and - // MeshBuildingBlockV2 share a kind in different API versions. - apis map[reflect.Type]rawApi + apis map[reflect.Type]rawApi } type rawApi struct { diff --git a/client/wire.go b/client/wire.go new file mode 100644 index 00000000..781c4a27 --- /dev/null +++ b/client/wire.go @@ -0,0 +1,12 @@ +package client + +import "encoding/json/v2" + +// wireCompatibility repeats the options internal/json marshals every request with: a v1-style +// MarshalJSON receives none of its caller's, so without it a nested value would go out in a +// different shape than the request around it. +var wireCompatibility = json.JoinOptions( + json.Deterministic(true), + json.FormatNilSliceAsNull(true), + json.FormatNilMapAsNull(true), +) diff --git a/cmd/buildingblock/trigger_run_test.go b/cmd/buildingblock/trigger_run_test.go index 76ec755e..90ea4577 100644 --- a/cmd/buildingblock/trigger_run_test.go +++ b/cmd/buildingblock/trigger_run_test.go @@ -25,7 +25,7 @@ func TestTriggerRunPrintsTheBuildingBlockAndFailsWithTheReasonMeshStackCouldNotS ) failure := func(failedOn string) string { return fmt.Sprintf(`{"metadata": {"uuid": %q, "ownedByWorkspace": "my-workspace"}, "spec": {}, `+ - `"status": {"status": "PENDING", "latestRunUuid": "a0000000-0000-4000-8000-000000000001", "runStartFailure": %q, "runStartFailedOn": %q}}`, + `"status": {"action": "PENDING", "latestRunUuid": "a0000000-0000-4000-8000-000000000001", "runStartFailure": %q, "runStartFailedOn": %q}}`, buildingBlockUuid, reason, failedOn) } meshStack := fakemeshstack.Start(t, fakemeshstack.Options{BuildingBlocks: []any{jsontext.Value(failure("2026-10-01T10:05:00Z"))}}) diff --git a/cmd/internal/testacc/testdata/bootstrap/main.tf b/cmd/internal/testacc/testdata/bootstrap/main.tf index 5ada3eb1..0c9a428c 100644 --- a/cmd/internal/testacc/testdata/bootstrap/main.tf +++ b/cmd/internal/testacc/testdata/bootstrap/main.tf @@ -6,8 +6,8 @@ terraform { required_providers { meshstack = { source = "meshcloud/meshstack" - # The first release that reads a profile of the meshStack CLI. - version = ">= 0.26.0" + # The first release that waits through status.action. + version = ">= 0.27.1" } } } @@ -109,8 +109,8 @@ resource "meshstack_building_block" "noop" { lifecycle { postcondition { - condition = self.status.status == "SUCCEEDED" - error_message = "The run of building block ${self.metadata.uuid} is ${self.status.status}." + condition = self.status.succeeded + error_message = "The run of building block ${self.metadata.uuid} did not succeed." } } } diff --git a/docs/demo/fakemeshstack/meshstack.go b/docs/demo/fakemeshstack/meshstack.go index c460a39c..4515d041 100644 --- a/docs/demo/fakemeshstack/meshstack.go +++ b/docs/demo/fakemeshstack/meshstack.go @@ -75,40 +75,40 @@ type buildingBlock struct { DisplayName string `json:"displayName"` } `json:"spec"` Status struct { - Status string `json:"status"` + Action string `json:"action"` } `json:"status"` } // buildingBlocks are more than a demo's --limit, so that the CLI says it listed only the first ones. func buildingBlocks() (objects []any) { for _, block := range [][3]string{ - {"PostgreSQL Database", "platform-team", "SUCCEEDED"}, - {"Azure Storage Account", "data-analytics", "SUCCEEDED"}, - {"GitHub Repository", "customer-portal", "SUCCEEDED"}, + {"PostgreSQL Database", "platform-team", "NO_ACTION"}, + {"Azure Storage Account", "data-analytics", "NO_ACTION"}, + {"GitHub Repository", "customer-portal", "NO_ACTION"}, {"Kubernetes Namespace", "payments", "IN_PROGRESS"}, - {"AWS S3 Bucket", "machine-learning", "SUCCEEDED"}, - {"Key Vault", "security-ops", "FAILED"}, - {"CI/CD Pipeline", "mobile-apps", "SUCCEEDED"}, - {"Redis Cache", "billing", "SUCCEEDED"}, - {"Budget Alert", "marketing-web", "SUCCEEDED"}, - {"Container Registry", "devex", "SUCCEEDED"}, - {"Log Analytics Workspace", "observability", "SUCCEEDED"}, - {"Service Principal", "identity-access", "SUCCEEDED"}, - {"PostgreSQL Database", "logistics", "SUCCEEDED"}, - {"GitHub Repository", "internal-tools", "SUCCEEDED"}, - {"Kubernetes Namespace", "search", "SUCCEEDED"}, - {"DNS Zone", "customer-portal", "SUCCEEDED"}, - {"AWS S3 Bucket", "hr-systems", "WAITING_FOR_OPERATOR_INPUT"}, - {"Budget Alert", "payments", "SUCCEEDED"}, - {"Key Vault", "platform-team", "SUCCEEDED"}, - {"CI/CD Pipeline", "data-analytics", "SUCCEEDED"}, - {"Redis Cache", "customer-portal", "SUCCEEDED"}, - {"Container Registry", "platform-team", "SUCCEEDED"}, - {"DNS Zone", "marketing-web", "SUCCEEDED"}, + {"AWS S3 Bucket", "machine-learning", "NO_ACTION"}, + {"Key Vault", "security-ops", "REVIEW_ERRORS"}, + {"CI/CD Pipeline", "mobile-apps", "NO_ACTION"}, + {"Redis Cache", "billing", "NO_ACTION"}, + {"Budget Alert", "marketing-web", "NO_ACTION"}, + {"Container Registry", "devex", "NO_ACTION"}, + {"Log Analytics Workspace", "observability", "NO_ACTION"}, + {"Service Principal", "identity-access", "NO_ACTION"}, + {"PostgreSQL Database", "logistics", "NO_ACTION"}, + {"GitHub Repository", "internal-tools", "NO_ACTION"}, + {"Kubernetes Namespace", "search", "NO_ACTION"}, + {"DNS Zone", "customer-portal", "NO_ACTION"}, + {"AWS S3 Bucket", "hr-systems", "PROVIDE_INPUT"}, + {"Budget Alert", "payments", "NO_ACTION"}, + {"Key Vault", "platform-team", "NO_ACTION"}, + {"CI/CD Pipeline", "data-analytics", "NO_ACTION"}, + {"Redis Cache", "customer-portal", "NO_ACTION"}, + {"Container Registry", "platform-team", "NO_ACTION"}, + {"DNS Zone", "marketing-web", "NO_ACTION"}, } { var object buildingBlock object.Metadata.Uuid, object.Metadata.OwnedByWorkspace = stableUuid(block[0]+block[1]), block[1] - object.Spec.DisplayName, object.Status.Status = block[0], block[2] + object.Spec.DisplayName, object.Status.Action = block[0], block[2] objects = append(objects, object) } return objects diff --git a/internal/tfstate/lock.go b/internal/tfstate/lock.go index 3701a580..2cbd234f 100644 --- a/internal/tfstate/lock.go +++ b/internal/tfstate/lock.go @@ -57,7 +57,7 @@ func (l Lock) NoUnfinishedRun(ctx context.Context, raw *client.RawClient) error if err = json.Unmarshal(read, &run); err != nil { return err } - if run.Status == string(client.BuildingBlockStatusPending) || run.Status == string(client.BuildingBlockStatusInProgress) { + if run.IsRunning() { return fmt.Errorf("building block run %s, which holds the lock, is %s and still writes the state", runUuid, run.Status) } return nil diff --git a/skills/meshstack-cli/SKILL.md b/skills/meshstack-cli/SKILL.md index cd5e2702..8fdf40fa 100644 --- a/skills/meshstack-cli/SKILL.md +++ b/skills/meshstack-cli/SKILL.md @@ -409,7 +409,8 @@ jq -n '{apiVersion: "v2-preview", kind: "meshBuildingBlock", spec: { displayName: "my-project", buildingBlockDefinitionVersionRef: {kind: "meshBuildingBlockDefinitionVersion", uuid: ""}, targetRef: {kind: "meshWorkspace", name: ""}, - inputs: {name: {value: "my-project", valueType: "STRING"}}}}' | + inputs: {name: {value: "my-project", valueType: "STRING"}}, + parentBuildingBlockRefs: []}}' | meshstack api -X POST /api/meshobjects/meshbuildingblocks --workspace \ --api-version v2-preview --request-json - | jq -c '{uuid: .metadata.uuid, action: .status.action}' From bef669b477be39c7a05722a478397e0153b3c03c Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Mon, 26 Oct 2026 08:27:07 +0100 Subject: [PATCH 2/3] refactor(client): pass the caller's JSON options through to nested values The custom JSON methods implement json/v2's MarshalJSONTo and UnmarshalJSONFrom, which receive the options of the encoder or decoder. The options internal/json marshals every request with now reach a nested value on their own, so the client and the variant package no longer keep copies of them. Co-Authored-By: Claude Opus 5.5 --- .golangci.yml | 5 +++ client/building_block_definition_version.go | 9 +++--- ...block_definition_version_implementation.go | 11 ++++--- client/building_block_v2.go | 7 ++-- client/integration_config.go | 11 ++++--- client/openapi/object.go | 2 -- client/types/variant/variant.go | 32 ++++++++----------- client/wire.go | 12 ------- 8 files changed, 40 insertions(+), 49 deletions(-) delete mode 100644 client/wire.go diff --git a/.golangci.yml b/.golangci.yml index 19cf480e..71075e74 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -178,6 +178,11 @@ linters: # field order follows what reads well, not what packs tightest - fieldalignment + recvcheck: + # Extends the built-in exclusion of UnmarshalJSON to its json/v2 counterpart. + exclusions: + - "*.UnmarshalJSONFrom" + staticcheck: checks: - all diff --git a/client/building_block_definition_version.go b/client/building_block_definition_version.go index 7637b90d..014d761b 100644 --- a/client/building_block_definition_version.go +++ b/client/building_block_definition_version.go @@ -2,6 +2,7 @@ package client import ( "context" + "encoding/json/jsontext" "encoding/json/v2" "errors" "fmt" @@ -121,8 +122,8 @@ type MeshBuildingBlockDefinitionInput struct { IsEnvironment bool `json:"isEnvironment" tfsdk:"is_environment"` IsSensitive bool `json:"isSensitive" tfsdk:"-"` // Argument and DefaultValue hold a [types.Secret] when IsSensitive is true and a [types.Any] - // otherwise. [types.Variant] decodes into X first, so UnmarshalJSON moves a non-sensitive value - // that also decodes as a Secret over to Y. + // otherwise. [types.Variant] decodes into X first, so UnmarshalJSONFrom moves a non-sensitive + // value that also decodes as a Secret over to Y. Argument types.SecretOrAny `json:"argument" tfsdk:"argument"` DefaultValue types.SecretOrAny `json:"defaultValue" tfsdk:"default_value"` UpdateableByConsumer bool `json:"updateableByConsumer" tfsdk:"updateable_by_consumer"` @@ -141,10 +142,10 @@ type MeshBuildingBlockDefinitionInput struct { DisplayOrder int64 `json:"displayOrder" tfsdk:"display_order"` } -func (m *MeshBuildingBlockDefinitionInput) UnmarshalJSON(bytes []byte) error { +func (m *MeshBuildingBlockDefinitionInput) UnmarshalJSONFrom(dec *jsontext.Decoder) error { type wrapped MeshBuildingBlockDefinitionInput var target wrapped - if err := json.Unmarshal(bytes, &target); err != nil { + if err := json.UnmarshalDecode(dec, &target, dec.Options()); err != nil { return err } *m = MeshBuildingBlockDefinitionInput(target) diff --git a/client/building_block_definition_version_implementation.go b/client/building_block_definition_version_implementation.go index a0ebf028..edc65eee 100644 --- a/client/building_block_definition_version_implementation.go +++ b/client/building_block_definition_version_implementation.go @@ -1,6 +1,7 @@ package client import ( + "encoding/json/jsontext" "encoding/json/v2" "fmt" @@ -90,23 +91,23 @@ func (m MeshBuildingBlockDefinitionImplementation) InferType() (enum.Entry[MeshB return result, nil } -func (m MeshBuildingBlockDefinitionImplementation) MarshalJSON() ([]byte, error) { +func (m MeshBuildingBlockDefinitionImplementation) MarshalJSONTo(enc *jsontext.Encoder) error { type wrapped MeshBuildingBlockDefinitionImplementation w := wrapped(m) if len(w.Type) == 0 { inferred, err := m.InferType() if err != nil { - return nil, err + return err } w.Type = inferred } - return json.Marshal(w, wireCompatibility) + return json.MarshalEncode(enc, w, enc.Options()) } -func (m *MeshBuildingBlockDefinitionImplementation) UnmarshalJSON(bytes []byte) error { +func (m *MeshBuildingBlockDefinitionImplementation) UnmarshalJSONFrom(dec *jsontext.Decoder) error { type wrapped MeshBuildingBlockDefinitionImplementation var target wrapped - if err := json.Unmarshal(bytes, &target); err != nil { + if err := json.UnmarshalDecode(dec, &target, dec.Options()); err != nil { return err } *m = MeshBuildingBlockDefinitionImplementation(target) diff --git a/client/building_block_v2.go b/client/building_block_v2.go index ae85cbe4..01d204dc 100644 --- a/client/building_block_v2.go +++ b/client/building_block_v2.go @@ -2,6 +2,7 @@ package client import ( "context" + "encoding/json/jsontext" "encoding/json/v2" "errors" "fmt" @@ -76,15 +77,15 @@ type MeshBuildingBlockInput struct { AssignmentType enum.Entry[MeshBuildingBlockInputAssignmentType] `json:"assignmentType,omitempty" tfsdk:"-"` // IsSensitive decides the case of Value: [types.Secret] when true, [types.Any] otherwise. - // [types.Variant] decodes into X first, so UnmarshalJSON moves a non-sensitive value that also + // [types.Variant] decodes into X first, so UnmarshalJSONFrom moves a non-sensitive value that also // decodes as a Secret over to Y. IsSensitive bool `json:"isSensitive" tfsdk:"-"` } -func (m *MeshBuildingBlockInput) UnmarshalJSON(bytes []byte) error { +func (m *MeshBuildingBlockInput) UnmarshalJSONFrom(dec *jsontext.Decoder) error { type wrapped MeshBuildingBlockInput var target wrapped - if err := json.Unmarshal(bytes, &target); err != nil { + if err := json.UnmarshalDecode(dec, &target, dec.Options()); err != nil { return err } *m = MeshBuildingBlockInput(target) diff --git a/client/integration_config.go b/client/integration_config.go index 1cce3f57..5b63eb4e 100644 --- a/client/integration_config.go +++ b/client/integration_config.go @@ -1,6 +1,7 @@ package client import ( + "encoding/json/jsontext" "encoding/json/v2" "fmt" @@ -70,7 +71,7 @@ func (m MeshIntegrationConfig) InferType() (enum.Entry[MeshIntegrationConfigType return result, nil } -func (m MeshIntegrationConfig) MarshalJSON() ([]byte, error) { +func (m MeshIntegrationConfig) MarshalJSONTo(enc *jsontext.Encoder) error { type wrapped MeshIntegrationConfig w := wrapped(m) // Built-in integrations (replicator, metering) come with a type but no variant, so the type is only @@ -78,17 +79,17 @@ func (m MeshIntegrationConfig) MarshalJSON() ([]byte, error) { if len(w.Type) == 0 { inferred, err := m.InferType() if err != nil { - return nil, err + return err } w.Type = inferred } - return json.Marshal(w, wireCompatibility) + return json.MarshalEncode(enc, w, enc.Options()) } -func (m *MeshIntegrationConfig) UnmarshalJSON(bytes []byte) error { +func (m *MeshIntegrationConfig) UnmarshalJSONFrom(dec *jsontext.Decoder) error { type wrapped MeshIntegrationConfig var target wrapped - if err := json.Unmarshal(bytes, &target); err != nil { + if err := json.UnmarshalDecode(dec, &target, dec.Options()); err != nil { return err } *m = MeshIntegrationConfig(target) diff --git a/client/openapi/object.go b/client/openapi/object.go index 34ca2337..a6732ff2 100644 --- a/client/openapi/object.go +++ b/client/openapi/object.go @@ -8,8 +8,6 @@ import ( // object keeps the order of its members, which a map would lose, so that a part of the document // prints as the document has it. -// -//nolint:recvcheck // MarshalJSONTo takes a value, so that json.Marshal finds it on an object that is not addressable type object []member type member struct { diff --git a/client/types/variant/variant.go b/client/types/variant/variant.go index 7f18367f..127acb34 100644 --- a/client/types/variant/variant.go +++ b/client/types/variant/variant.go @@ -1,6 +1,7 @@ package variant import ( + "encoding/json/jsontext" "encoding/json/v2" "errors" "fmt" @@ -15,26 +16,17 @@ type Variant[X, Y any] struct { } var ( - _ json.Unmarshaler = (*Variant[int, string])(nil) - _ json.Marshaler = Variant[int, string]{} + _ json.UnmarshalerFrom = (*Variant[int, string])(nil) + _ json.MarshalerTo = Variant[int, string]{} ) -// wireCompatibility repeats the options internal/json marshals every request with: a v1-style -// MarshalJSON receives none of its caller's. A Y decoded from JSON is a map, so without -// Deterministic it would leave this method in a random member order. -var wireCompatibility = json.JoinOptions( - json.Deterministic(true), - json.FormatNilSliceAsNull(true), - json.FormatNilMapAsNull(true), -) - -func (v Variant[X, Y]) MarshalJSON() ([]byte, error) { +func (v Variant[X, Y]) MarshalJSONTo(enc *jsontext.Encoder) error { if v.HasX() { - return json.Marshal(v.X, wireCompatibility) + return json.MarshalEncode(enc, v.X, enc.Options()) } else if v.HasY() { - return json.Marshal(v.Y, wireCompatibility) + return json.MarshalEncode(enc, v.Y, enc.Options()) } - return json.Marshal(nil) + return enc.WriteToken(jsontext.Null) } func has[T any](xy any) bool { @@ -72,9 +64,13 @@ func (v Variant[X, Y]) WithY(action func(y *Y)) { } } -func (v *Variant[X, Y]) UnmarshalJSON(bytes []byte) error { - errX := json.Unmarshal(bytes, &v.X) - errY := json.Unmarshal(bytes, &v.Y) +func (v *Variant[X, Y]) UnmarshalJSONFrom(dec *jsontext.Decoder) error { + bytes, err := dec.ReadValue() + if err != nil { + return err + } + errX := json.Unmarshal(bytes, &v.X, dec.Options()) + errY := json.Unmarshal(bytes, &v.Y, dec.Options()) switch { case v.HasX() && v.HasY(): // Y is cleared because a Y of type any decodes from every JSON value. diff --git a/client/wire.go b/client/wire.go deleted file mode 100644 index 781c4a27..00000000 --- a/client/wire.go +++ /dev/null @@ -1,12 +0,0 @@ -package client - -import "encoding/json/v2" - -// wireCompatibility repeats the options internal/json marshals every request with: a v1-style -// MarshalJSON receives none of its caller's, so without it a nested value would go out in a -// different shape than the request around it. -var wireCompatibility = json.JoinOptions( - json.Deterministic(true), - json.FormatNilSliceAsNull(true), - json.FormatNilMapAsNull(true), -) From b86a5d1912bc554db94940c32240d7983b8d65fd Mon Sep 17 00:00:00 2001 From: Andreas Grub Date: Mon, 26 Oct 2026 08:28:24 +0100 Subject: [PATCH 3/3] chore(lint): forbid encoding/json v1 everywhere One depguard rule over all files denies exactly encoding/json, so the per-package rules no longer repeat that deny. Co-Authored-By: Claude Opus 5.5 --- .golangci.yml | 47 ++++++++++++----------------------------------- 1 file changed, 12 insertions(+), 35 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 71075e74..33a66408 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -237,6 +237,14 @@ linters: depguard: rules: + # The $ keeps encoding/json/v2 and encoding/json/jsontext out of the match. + json: + files: + - $all + deny: + - pkg: encoding/json$ + desc: Use encoding/json/v2 and internal/json instead + # cobra is used in cmd/ and nowhere else. cmd: files: @@ -254,14 +262,12 @@ linters: deny: - pkg: log desc: Use log/slog instead - - pkg: encoding/json - desc: Use encoding/json/v2 and internal/json instead allow: - $gostd - log/slog - # the deny on encoding/json matches its subpackages too; a listing yields raw items + # a listing yields raw items, and reads a field out of one, such as the uuid a run + # listing needs per block - encoding/json/jsontext - # and reads a field out of a raw item, such as the uuid a run listing needs per block - encoding/json/v2 # no direct access to internal/ - github.com/meshcloud/meshstack-cli/pkg @@ -279,8 +285,6 @@ linters: deny: - pkg: log desc: Use log/slog instead - - pkg: encoding/json - desc: Use encoding/json/v2 and internal/json instead allow: - $gostd - log/slog @@ -297,13 +301,10 @@ linters: deny: - pkg: log desc: Use log/slog instead - - pkg: encoding/json - desc: Use encoding/json/v2 and internal/json instead allow: - $gostd - log/slog - # the deny on encoding/json matches its subpackages too; a request body is checked as - # JSON, and api-docs writes a part of the OpenAPI document + # a request body is checked as JSON, and api-docs writes a part of the OpenAPI document - encoding/json/jsontext - encoding/json/v2 - github.com/meshcloud/meshstack-cli/cmd/internal @@ -331,8 +332,6 @@ linters: deny: - pkg: log desc: Use log/slog instead - - pkg: encoding/json - desc: Use encoding/json/v2 and internal/json instead allow: - $gostd - log/slog @@ -354,8 +353,6 @@ linters: deny: - pkg: log desc: Use log/slog instead - - pkg: encoding/json - desc: Use encoding/json/v2 and internal/json instead allow: - $gostd - log/slog @@ -376,8 +373,6 @@ linters: deny: - pkg: log desc: Use log/slog instead - - pkg: encoding/json - desc: Use encoding/json/v2 and internal/json instead allow: - $gostd - log/slog @@ -413,14 +408,11 @@ linters: deny: - pkg: log desc: Use log/slog instead - - pkg: encoding/json - desc: Use encoding/json/v2 and internal/json instead allow: - $gostd - log/slog - # the deny on encoding/json matches its subpackages too; a listing writes raw items + # a listing writes raw items, and a command that shows one object marshals it - encoding/json/jsontext - # and a command that shows one object marshals it, or writes it as Markdown - encoding/json/v2 - github.com/meshcloud/meshstack-cli/cmd/internal/markdown - github.com/meshcloud/meshstack-cli/cmd/internal/color @@ -453,8 +445,6 @@ linters: deny: - pkg: log desc: Use log/slog instead - - pkg: encoding/json - desc: Use encoding/json/v2 and internal/json instead allow: - $gostd - encoding/json/v2 @@ -473,8 +463,6 @@ linters: deny: - pkg: log desc: Use log/slog instead - - pkg: encoding/json - desc: Use encoding/json/v2 and internal/json instead allow: - $gostd - log/slog @@ -489,9 +477,6 @@ linters: - "**/internal/json/*.go" - "!$test" list-mode: strict - deny: - - pkg: encoding/json - desc: Use encoding/json/v2 and internal/json instead allow: - $gostd - encoding/json/v2 @@ -519,8 +504,6 @@ linters: deny: - pkg: log desc: Use log/slog instead - - pkg: encoding/json - desc: Use encoding/json/v2 and internal/json instead allow: - $gostd - encoding/json/v2 @@ -536,9 +519,6 @@ linters: files: - "**/internal/testutil/**/*.go" list-mode: strict - deny: - - pkg: encoding/json - desc: Use encoding/json/v2 and internal/json instead allow: - $gostd - encoding/json/v2 @@ -574,9 +554,6 @@ linters: files: - "$test" list-mode: strict - deny: - - pkg: encoding/json - desc: Use encoding/json/v2 and internal/json instead allow: - $gostd - encoding/json/v2