Skip to content

Commit 5357a4b

Browse files
grubmeshiclaude
andcommitted
fix: compute manual building block outputs from the API
Manual building block definitions have their outputs derived from the inputs on the backend (one output per input, assignment type NONE, with SINGLE_SELECT/MULTI_SELECT/LIST translated). Configuring outputs that mirror the inputs caused "Provider produced inconsistent result after apply" whenever the backend added or changed an output the config did not predict, especially when toggling draft false->true together with input changes (#131, #176). version_spec.outputs is now Optional+Computed without a static default: omit it for manual building blocks and it is reconciled from the API. The plan leaves outputs (and the content hash) unknown when the inputs or the configured PLATFORM_TENANT_ID outputs change, and reuses the reconciled state value otherwise. ValidateConfig rejects manual outputs with any assignment_type other than PLATFORM_TENANT_ID, which the backend still honors to mark the tenant-id output. The mock client mirrors the backend's manual output generation so unit tests cover the behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1757f60 commit 5357a4b

9 files changed

Lines changed: 339 additions & 70 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# v0.21.1
22

3+
BREAKING CHANGES:
4+
- `meshstack_building_block_definition`: For manual building blocks, `version_spec.outputs` is now computed from the API and must be omitted from configuration — the backend derives one output per input. Configuring an output with any `assignment_type` other than `PLATFORM_TENANT_ID` is now rejected. Remove `outputs` blocks from manual building block definitions; you may still declare an output with `assignment_type = "PLATFORM_TENANT_ID"` to mark which output carries the tenant id.
5+
36
FIXES:
7+
- `meshstack_building_block_definition`: Fixed "Provider produced inconsistent result after apply" for manual building blocks whose outputs the backend auto-generates from inputs (e.g. `SINGLE_SELECT`/`STATIC` inputs), including when toggling `version_spec.draft` from `false` to `true` together with input changes ([#131](https://github.com/meshcloud/terraform-provider-meshstack/issues/131), [#176](https://github.com/meshcloud/terraform-provider-meshstack/issues/176)). Outputs are now reconciled from the API response.
48
- When no `runner_ref` is provided, the new shared building block runner UUID `98520496-627d-43e6-82da-ce499179ff3f` is used which is suitable for all implementation types.
59
Existing `building_block_definition` resources will see a plan change addressing this migration to a single shared runner.
610
Using the old shared runner UUIDs is deprecated but handled gracefully by the API.

docs/resources/building_block_definition.md

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -215,14 +215,8 @@ resource "meshstack_building_block_definition" "example_03_manual" {
215215
manual = {}
216216
}
217217
218-
# Output keys must match with inputs, as the backend copies over inputs to outputs
219-
outputs = {
220-
approval_required = {
221-
display_name = "Approval Required"
222-
type = "BOOLEAN"
223-
assignment_type = "NONE"
224-
}
225-
}
218+
# Outputs are omitted for manual building blocks: the backend derives them from the inputs
219+
# (one output per input), so version_spec.outputs is computed and must not be set here.
226220
}
227221
}
228222
```
@@ -395,7 +389,7 @@ Optional:
395389
- `dependency_refs` (Attributes Set) Set of refs to building block definitions this definition depends on. Prefer reusable refs from `meshstack_building_block_definition.<name>.ref` or `one(data.meshstack_building_block_definitions.<name>.building_block_definitions).ref`. (see [below for nested schema](#nestedatt--version_spec--dependency_refs))
396390
- `inputs` (Attributes Map) Map of input definitions for the building block. Keys are input names, values are input configuration objects. Inputs define parameters that building blocks can receive. (see [below for nested schema](#nestedatt--version_spec--inputs))
397391
- `only_apply_once_per_tenant` (Boolean) Whether this building block can only be applied once per tenant.
398-
- `outputs` (Attributes Map) Map of output definitions for the building block. Keys are output names, values are output configuration objects. Outputs define values that building blocks produce and can be consumed by other building blocks. (see [below for nested schema](#nestedatt--version_spec--outputs))
392+
- `outputs` (Attributes Map) Map of output definitions for the building block. Keys are output names, values are output configuration objects. Outputs define values that building blocks produce and can be consumed by other building blocks. If implementation type is `manual`, outputs are computed from the API response, so omit this attribute entirely unless you want to specify a static `assignment_type = "PLATFORM_TENANT_ID"` as part of a landing zone. (see [below for nested schema](#nestedatt--version_spec--outputs))
399393
- `permissions` (Set of String) Set of API permissions required by this building block. Will provide building block runs with an ephemeral API token with the specified workspace permissions. See [Workspace Permissions](https://docs.meshcloud.io/api/authentication/api-permissions/) for available values and [documentation on ephemeral API keys](https://docs.dev.meshcloud.io/concepts/building-block/#ephemeral-api-keys).
400394
- `runner_ref` (Attributes) Reference to the runner to run the implementation. If omitted, the pre-defined shared runner is used suitable for the given `implementation` choice (see [below for nested schema](#nestedatt--version_spec--runner_ref))
401395

examples/resources/meshstack_building_block_definition/resource_03_manual.tf

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,7 @@ resource "meshstack_building_block_definition" "example_03_manual" {
2424
manual = {}
2525
}
2626

27-
# Output keys must match with inputs, as the backend copies over inputs to outputs
28-
outputs = {
29-
approval_required = {
30-
display_name = "Approval Required"
31-
type = "BOOLEAN"
32-
assignment_type = "NONE"
33-
}
34-
}
27+
# Outputs are omitted for manual building blocks: the backend derives them from the inputs
28+
# (one output per input), so version_spec.outputs is computed and must not be set here.
3529
}
3630
}

examples/resources/meshstack_building_block_v2/test-support_01_workspace.tf

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -50,33 +50,7 @@ resource "meshstack_building_block_definition" "example" {
5050
manual = {}
5151
}
5252

53-
# Output keys must match with inputs, as the backend copies over inputs to outputs for manual implementations.
54-
outputs = {
55-
name = {
56-
display_name = "Name"
57-
type = "STRING"
58-
assignment_type = "NONE"
59-
}
60-
size = {
61-
display_name = "Size"
62-
type = "INTEGER"
63-
assignment_type = "NONE"
64-
}
65-
environment = {
66-
display_name = "Environment"
67-
type = "STRING"
68-
assignment_type = "NONE"
69-
}
70-
region = {
71-
display_name = "Region"
72-
type = "STRING"
73-
assignment_type = "NONE"
74-
}
75-
enable_monitoring = {
76-
display_name = "Enable Monitoring"
77-
type = "BOOLEAN"
78-
assignment_type = "NONE"
79-
}
80-
}
53+
# Outputs are omitted for manual building blocks: the backend derives them from the inputs, so
54+
# version_spec.outputs is computed and must not be set here.
8155
}
8256
}

examples/resources/meshstack_building_block_v2/test-support_02_tenant.tf

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -40,23 +40,7 @@ resource "meshstack_building_block_definition" "bb_v2_tenant_bbd" {
4040
manual = {}
4141
}
4242

43-
# Output keys must match with inputs, as the backend copies over inputs to outputs for manual implementations.
44-
outputs = {
45-
name = {
46-
display_name = "Name"
47-
type = "STRING"
48-
assignment_type = "NONE"
49-
}
50-
size = {
51-
display_name = "Size"
52-
type = "INTEGER"
53-
assignment_type = "NONE"
54-
}
55-
environment = {
56-
display_name = "Environment"
57-
type = "STRING"
58-
assignment_type = "NONE"
59-
}
60-
}
43+
# Outputs are omitted for manual building blocks: the backend derives them from the inputs, so
44+
# version_spec.outputs is computed and must not be set here.
6145
}
6246
}

internal/clientmock/mock_buildingblock_definition_version.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ func (m meshBuildingBlockDefinitionVersionClient) Create(_ context.Context, owne
2828
versionUuid := uuid.NewString()
2929
// Compute hashes for all secrets in the spec
3030
backendSecretBehavior(true, &versionSpec, nil)
31+
applyManualOutputBehavior(&versionSpec)
3132

3233
// Set version number if not already set
3334
if versionSpec.VersionNumber == nil {
@@ -51,6 +52,7 @@ func (m meshBuildingBlockDefinitionVersionClient) Update(_ context.Context, uuid
5152
if existing, ok := m.Store.Get(uuid); ok {
5253
// Compute hashes for all secrets in the spec
5354
backendSecretBehavior(false, &versionSpec, &existing.Spec)
55+
applyManualOutputBehavior(&versionSpec)
5456
if existing.Metadata.OwnedByWorkspace != ownedByWorkspace {
5557
return nil, fmt.Errorf("mismatching workspace ownership: %s (existing) != %s (expected)", existing.Metadata.OwnedByWorkspace, ownedByWorkspace)
5658
}
@@ -60,6 +62,49 @@ func (m meshBuildingBlockDefinitionVersionClient) Update(_ context.Context, uuid
6062
return nil, fmt.Errorf("building block definition version not found: %s", uuid)
6163
}
6264

65+
// applyManualOutputBehavior mirrors the real backend's ManualBuildingBlockCreationModule: for manual
66+
// building blocks the outputs are derived from the inputs (one output per input, assignment type NONE,
67+
// with SINGLE_SELECT/MULTI_SELECT/LIST input types translated to output-compatible types). Any output the
68+
// caller marked PLATFORM_TENANT_ID keeps that assignment for the matching input; all other supplied
69+
// outputs are ignored, matching the backend.
70+
func applyManualOutputBehavior(versionSpec *client.MeshBuildingBlockDefinitionVersionSpec) {
71+
if versionSpec.Implementation.Manual == nil {
72+
return
73+
}
74+
platformTenantIdKeys := map[string]bool{}
75+
for key, output := range versionSpec.Outputs {
76+
if output.AssignmentType == client.MeshBuildingBlockDefinitionOutputAssignmentTypePlatformTenantID.Unwrap() {
77+
platformTenantIdKeys[key] = true
78+
}
79+
}
80+
outputs := make(map[string]client.MeshBuildingBlockDefinitionOutput, len(versionSpec.Inputs))
81+
for key, input := range versionSpec.Inputs {
82+
assignmentType := client.MeshBuildingBlockDefinitionOutputAssignmentTypeNone.Unwrap()
83+
if platformTenantIdKeys[key] {
84+
assignmentType = client.MeshBuildingBlockDefinitionOutputAssignmentTypePlatformTenantID.Unwrap()
85+
}
86+
outputs[key] = client.MeshBuildingBlockDefinitionOutput{
87+
DisplayName: input.DisplayName,
88+
Type: translateManualInputTypeToOutput(input.Type),
89+
AssignmentType: assignmentType,
90+
}
91+
}
92+
versionSpec.Outputs = outputs
93+
}
94+
95+
// translateManualInputTypeToOutput mirrors backend ManualIOTypeTranslation: SINGLE_SELECT, MULTI_SELECT
96+
// and LIST cannot be output types and are translated; all other types are kept as-is.
97+
func translateManualInputTypeToOutput(inputType client.MeshBuildingBlockIOType) client.MeshBuildingBlockIOType {
98+
switch inputType {
99+
case client.MeshBuildingBlockIOTypeSingleSelect.Unwrap():
100+
return client.MeshBuildingBlockIOTypeString.Unwrap()
101+
case client.MeshBuildingBlockIOTypeMultiSelect.Unwrap(), client.MeshBuildingBlockIOTypeList.Unwrap():
102+
return client.MeshBuildingBlockIOTypeCode.Unwrap()
103+
default:
104+
return inputType
105+
}
106+
}
107+
63108
func (m meshBuildingBlockDefinitionVersionClient) getNextVersionNumber() int {
64109
maxNum := 0
65110
for _, v := range m.Store.Values() {

internal/provider/building_block_definition_resource.go

Lines changed: 141 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package provider
33
import (
44
"context"
55
"fmt"
6+
"maps"
67
"slices"
78

89
"github.com/hashicorp/terraform-plugin-framework/diag"
@@ -16,10 +17,11 @@ import (
1617
)
1718

1819
var (
19-
_ resource.Resource = &buildingBlockDefinitionResource{}
20-
_ resource.ResourceWithConfigure = &buildingBlockDefinitionResource{}
21-
_ resource.ResourceWithImportState = &buildingBlockDefinitionResource{}
22-
_ resource.ResourceWithModifyPlan = &buildingBlockDefinitionResource{}
20+
_ resource.Resource = &buildingBlockDefinitionResource{}
21+
_ resource.ResourceWithConfigure = &buildingBlockDefinitionResource{}
22+
_ resource.ResourceWithImportState = &buildingBlockDefinitionResource{}
23+
_ resource.ResourceWithModifyPlan = &buildingBlockDefinitionResource{}
24+
_ resource.ResourceWithValidateConfig = &buildingBlockDefinitionResource{}
2325
)
2426

2527
func NewBuildingBlockDefinitionResource() resource.Resource {
@@ -86,6 +88,14 @@ func (r *buildingBlockDefinitionResource) Create(ctx context.Context, req resour
8688
// Updating the empty created version with provided configuration to complete creation
8789
versionUuid := createdEmptyVersion.Metadata.Uuid
8890
createVersionSpecDto := plan.VersionSpec.ToClientDto(bbdUuid)
91+
if createVersionSpecDto.Implementation.Manual != nil {
92+
// Manual outputs are computed (left unknown in the plan), so source the configured
93+
// PLATFORM_TENANT_ID hints straight from config to send them to the backend.
94+
createVersionSpecDto.Outputs = manualConfiguredOutputs(ctx, req.Config, &resp.Diagnostics)
95+
if resp.Diagnostics.HasError() {
96+
return
97+
}
98+
}
8999
createdVersionDto, err := r.buildingBlockDefinitionVersionClient.Update(ctx, versionUuid, createdDto.Metadata.OwnedByWorkspace, createVersionSpecDto)
90100
if err != nil {
91101
resp.Diagnostics.AddError("Error updating initial version", fmt.Sprintf(
@@ -144,6 +154,89 @@ func (r *buildingBlockDefinitionResource) Read(ctx context.Context, req resource
144154
buildingBlockDefinitionConverterOptions().Append(buildingBlockDefinitionVersionConverterOptions(ctx, nil, nil, req.State)...)...)...)
145155
}
146156

157+
func (r *buildingBlockDefinitionResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
158+
manualPath := path.Root("version_spec").AtName("implementation").AtName("manual")
159+
outputsPath := path.Root("version_spec").AtName("outputs")
160+
161+
var manual types.Object
162+
resp.Diagnostics.Append(req.Config.GetAttribute(ctx, manualPath, &manual)...)
163+
var outputs types.Map
164+
resp.Diagnostics.Append(req.Config.GetAttribute(ctx, outputsPath, &outputs)...)
165+
if resp.Diagnostics.HasError() {
166+
return
167+
}
168+
169+
// Manual building blocks derive their outputs from the inputs on the backend (one output per input,
170+
// assignment type NONE). The backend only honors outputs the user marks with assignment type
171+
// PLATFORM_TENANT_ID; everything else it regenerates. So configuring any non-PLATFORM_TENANT_ID output
172+
// for a manual building block is rejected here - omit it and let it be computed (see issues #131, #176).
173+
if manual.IsNull() || manual.IsUnknown() || outputs.IsNull() || outputs.IsUnknown() {
174+
return
175+
}
176+
for key, assignmentType := range outputAssignmentTypes(outputs) {
177+
if assignmentType != "" && assignmentType != client.MeshBuildingBlockDefinitionOutputAssignmentTypePlatformTenantID.String() {
178+
resp.Diagnostics.AddAttributeError(
179+
outputsPath.AtMapKey(key),
180+
"manual building block outputs may only assign PLATFORM_TENANT_ID",
181+
fmt.Sprintf("Manual building block definitions derive their outputs from the inputs automatically. "+
182+
"Output %q has assignment_type %q; remove it so it can be computed from the API response. "+
183+
"Only outputs with assignment_type %s may be configured (to mark which output carries the tenant id).",
184+
key, assignmentType, client.MeshBuildingBlockDefinitionOutputAssignmentTypePlatformTenantID),
185+
)
186+
}
187+
}
188+
}
189+
190+
// outputAssignmentTypes returns the assignment_type of each output in the given map (keyed by output name),
191+
// skipping null/unknown maps and elements. Unknown assignment types yield an empty string.
192+
func outputAssignmentTypes(outputs types.Map) map[string]string {
193+
result := map[string]string{}
194+
if outputs.IsNull() || outputs.IsUnknown() {
195+
return result
196+
}
197+
for key, elem := range outputs.Elements() {
198+
obj, ok := elem.(types.Object)
199+
if !ok || obj.IsNull() || obj.IsUnknown() {
200+
continue
201+
}
202+
assignmentType, ok := obj.Attributes()["assignment_type"].(types.String)
203+
if !ok || assignmentType.IsNull() || assignmentType.IsUnknown() {
204+
continue
205+
}
206+
result[key] = assignmentType.ValueString()
207+
}
208+
return result
209+
}
210+
211+
// platformTenantIdOutputKeysEqual reports whether the set of output keys assigned PLATFORM_TENANT_ID is
212+
// the same in both maps. Used to detect whether a manual building block's configured tenant-id output
213+
// changed, which (besides an inputs change) is the only way its reconciled outputs can change.
214+
func platformTenantIdOutputKeysEqual(a, b types.Map) bool {
215+
tenantIdKeys := func(m types.Map) map[string]struct{} {
216+
keys := map[string]struct{}{}
217+
for key, assignmentType := range outputAssignmentTypes(m) {
218+
if assignmentType == client.MeshBuildingBlockDefinitionOutputAssignmentTypePlatformTenantID.String() {
219+
keys[key] = struct{}{}
220+
}
221+
}
222+
return keys
223+
}
224+
return maps.Equal(tenantIdKeys(a), tenantIdKeys(b))
225+
}
226+
227+
// manualConfiguredOutputs reads the user-configured version_spec.outputs (the PLATFORM_TENANT_ID hints)
228+
// from config so they can be sent to the backend even though the planned outputs value is left unknown
229+
// for manual building blocks. Returns an empty map when outputs are omitted.
230+
func manualConfiguredOutputs(ctx context.Context, config generic.AttributeGetter, diags *diag.Diagnostics) map[string]client.MeshBuildingBlockDefinitionOutput {
231+
outputs := generic.GetAttribute[map[string]client.MeshBuildingBlockDefinitionOutput](
232+
ctx, config, path.Root("version_spec").AtName("outputs"), diags, generic.WithSetUnknownValueToZero())
233+
if outputs == nil {
234+
// The backend rejects a null outputs property, so send an empty map when none are configured.
235+
outputs = map[string]client.MeshBuildingBlockDefinitionOutput{}
236+
}
237+
return outputs
238+
}
239+
147240
func (r *buildingBlockDefinitionResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
148241
if req.Plan.Raw.IsNull() {
149242
// do nothing in case of delete
@@ -188,9 +281,44 @@ func (r *buildingBlockDefinitionResource) ModifyPlan(ctx context.Context, req re
188281
return
189282
}
190283

284+
// Manual building blocks have backend-derived outputs: the backend mirrors every input into an output
285+
// (assignment type NONE), preserving only outputs the user marked PLATFORM_TENANT_ID (see issues #131
286+
// and #176, and ValidateConfig). We cannot fully predict the reconciled outputs at plan time, so
287+
// whenever the inputs or the configured PLATFORM_TENANT_ID outputs change we leave outputs - and the
288+
// content hash that includes them - unknown and let the apply reconcile them from the API response.
289+
// Otherwise we reuse the reconciled value from state, which also avoids a perpetual diff between the
290+
// (partial) configured outputs and the (full) stored outputs. Non-manual implementations configure
291+
// outputs explicitly and the backend does not derive them, so they are left untouched here.
292+
outputsPath := path.Root("version_spec").AtName("outputs")
293+
inputsPath := path.Root("version_spec").AtName("inputs")
294+
manualPath := path.Root("version_spec").AtName("implementation").AtName("manual")
295+
versionSpecOutputsUncertain := false
296+
var manual types.Object
297+
resp.Diagnostics.Append(req.Plan.GetAttribute(ctx, manualPath, &manual)...)
298+
if !manual.IsNull() && !manual.IsUnknown() {
299+
var planInputs, stateInputs, configOutputs, stateOutputs types.Map
300+
resp.Diagnostics.Append(req.Plan.GetAttribute(ctx, inputsPath, &planInputs)...)
301+
resp.Diagnostics.Append(req.State.GetAttribute(ctx, inputsPath, &stateInputs)...)
302+
resp.Diagnostics.Append(req.Config.GetAttribute(ctx, outputsPath, &configOutputs)...)
303+
resp.Diagnostics.Append(req.State.GetAttribute(ctx, outputsPath, &stateOutputs)...)
304+
if resp.Diagnostics.HasError() {
305+
return
306+
}
307+
versionSpecOutputsUncertain = !planInputs.Equal(stateInputs) ||
308+
!platformTenantIdOutputKeysEqual(configOutputs, stateOutputs)
309+
if versionSpecOutputsUncertain {
310+
resp.Diagnostics.Append(resp.Plan.SetAttribute(ctx, outputsPath, types.MapUnknown(stateOutputs.ElementType(ctx)))...)
311+
} else {
312+
resp.Diagnostics.Append(resp.Plan.SetAttribute(ctx, outputsPath, stateOutputs)...)
313+
}
314+
if resp.Diagnostics.HasError() {
315+
return
316+
}
317+
}
318+
191319
// Determine this very carefully and leave it unknown if the underlying version_spec has unknown values somewhere deep down
192320
versionSpecContentHash := func() (result generic.NullIsUnknown[string]) {
193-
if versionSpecSecretsChanged {
321+
if versionSpecSecretsChanged || versionSpecOutputsUncertain {
194322
return
195323
}
196324
versionSpecPath := path.Root("version_spec")
@@ -301,6 +429,14 @@ func (r *buildingBlockDefinitionResource) Update(ctx context.Context, req resour
301429
// Handle version_spec update logic
302430

303431
versionSpecDto := plan.VersionSpec.ToClientDto(bbdUuid)
432+
if versionSpecDto.Implementation.Manual != nil {
433+
// Manual outputs are computed (left unknown in the plan), so source the configured
434+
// PLATFORM_TENANT_ID hints straight from config to send them to the backend.
435+
versionSpecDto.Outputs = manualConfiguredOutputs(ctx, req.Config, &resp.Diagnostics)
436+
if resp.Diagnostics.HasError() {
437+
return
438+
}
439+
}
304440

305441
var updatedVersionDto *client.MeshBuildingBlockDefinitionVersion
306442
switch {

0 commit comments

Comments
 (0)