From 09c66dd57587eb8f597e1395366166373a4cea4a Mon Sep 17 00:00:00 2001 From: Brad Sickles Date: Thu, 10 Sep 2026 19:52:21 -0400 Subject: [PATCH 1/4] fix(config): make is_shared tristate so syncs can tell unspecified from false - yaml + config IsShared become *bool; nil means the file did not declare it - ToBlock still collapses nil to false for callers that only need the flat block - add EnvConfiguration.ToBlockDefinitions, which carries the tristate alongside each flattened block; ToBlocks is now a view over it --- config/block_configuration.go | 9 ++-- config/block_configuration_test.go | 70 ++++++++++++++++++++++++++++++ config/env_configuration.go | 47 +++++++++++++++----- yaml/block_configuration.go | 4 +- yaml/block_configuration_test.go | 27 ++++++++++++ 5 files changed, 141 insertions(+), 16 deletions(-) diff --git a/config/block_configuration.go b/config/block_configuration.go index 11703fe..8f08b37 100644 --- a/config/block_configuration.go +++ b/config/block_configuration.go @@ -36,8 +36,9 @@ type BlockConfiguration struct { ModuleConstraint string `json:"moduleConstraint"` Variables VariableConfigurations `json:"vars"` Connections ConnectionConfigurations `json:"connections"` - IsShared bool `json:"isShared"` - Metadata MetadataConfiguration `json:"metadata"` + // IsShared is nil when the file does not specify is_shared. + IsShared *bool `json:"isShared,omitempty"` + Metadata MetadataConfiguration `json:"metadata"` // These fields are populated via Resolve() Module *types.Module `json:"module"` @@ -184,13 +185,15 @@ func (b *BlockConfiguration) Normalize(ctx context.Context, pc core.ObjectPathCo return b.Connections.Normalize(ctx, pc, resolver) } +// ToBlock flattens the configuration into an API block. +// IsShared collapses to false when the file did not specify it; EnvConfiguration.ToBlockDefinitions keeps the distinction. func (b *BlockConfiguration) ToBlock(orgName string, stackId int64) types.Block { block := types.Block{ Type: string(b.Type), OrgName: orgName, StackId: stackId, Name: b.Name, - IsShared: b.IsShared, + IsShared: b.IsShared != nil && *b.IsShared, DnsName: "", ModuleSource: b.ModuleSource, ModuleSourceVersion: b.ModuleConstraint, diff --git a/config/block_configuration_test.go b/config/block_configuration_test.go index 2c50e1b..ebf5d0a 100644 --- a/config/block_configuration_test.go +++ b/config/block_configuration_test.go @@ -88,3 +88,73 @@ func TestBlockConfiguration_ApplyChangesTo_dataClassification(t *testing.T) { assert.Equal(t, types.ClassificationLevel(""), wc.Metadata.DataClassification) }) } + +// is_shared must survive conversion as a tristate so a sync can tell "not declared" from "false". +func TestBlockConfiguration_isSharedFromYaml(t *testing.T) { + tests := []struct { + name string + in *bool + }{ + {name: "absent", in: nil}, + {name: "true", in: ptr(true)}, + {name: "false", in: ptr(false)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + bc := blockConfigFromYaml("db", yaml.BlockConfiguration{ModuleSource: "nullstone/aws-rds-postgres", IsShared: tt.in}, BlockTypeDatastore, types.CategoryDatastore) + assert.Equal(t, tt.in, bc.IsShared) + + block := bc.ToBlock("acme", 100) + assert.Equal(t, tt.in != nil && *tt.in, block.IsShared, "ToBlock collapses unspecified to false") + }) + } +} + +func TestEnvConfiguration_ToBlockDefinitions(t *testing.T) { + ec := &EnvConfiguration{ + Applications: map[string]*AppConfiguration{ + "api": { + BlockConfiguration: BlockConfiguration{Type: BlockTypeApplication, Name: "api", ModuleSource: "nullstone/aws-fargate-service", ModuleConstraint: "latest"}, + Framework: "go", + }, + }, + Datastores: map[string]*DatastoreConfiguration{ + "db": {BlockConfiguration: BlockConfiguration{Type: BlockTypeDatastore, Name: "db", ModuleSource: "nullstone/aws-rds-postgres", IsShared: ptr(true)}}, + }, + Subdomains: map[string]*SubdomainConfiguration{ + "web": { + BlockConfiguration: BlockConfiguration{Type: BlockTypeSubdomain, Name: "web", ModuleSource: "nullstone/aws-subdomain", IsShared: ptr(false)}, + SubdomainNameTemplate: ptr("web.{{ NULLSTONE_ENV }}"), + }, + }, + Blocks: map[string]*BlockConfiguration{ + "queue": {Type: BlockTypeBlock, Name: "queue", ModuleSource: "nullstone/aws-sqs-queue"}, + }, + } + + defs := ec.ToBlockDefinitions("acme", 100) + byName := map[string]BlockDefinition{} + for _, def := range defs { + byName[def.Block.Name] = def + } + require.Len(t, byName, 4) + + assert.Nil(t, byName["api"].IsShared) + assert.Equal(t, "go", byName["api"].Block.Framework) + assert.False(t, byName["api"].Block.IsShared) + + require.NotNil(t, byName["db"].IsShared) + assert.True(t, *byName["db"].IsShared) + assert.True(t, byName["db"].Block.IsShared) + + require.NotNil(t, byName["web"].IsShared) + assert.False(t, *byName["web"].IsShared) + assert.Equal(t, "web.{{ NULLSTONE_ENV }}", byName["web"].Block.DnsName) + + assert.Nil(t, byName["queue"].IsShared) + assert.Equal(t, string(BlockTypeBlock), byName["queue"].Block.Type) + + // ToBlocks is the flattened view of the same definitions + assert.Len(t, ec.ToBlocks("acme", 100), 4) + assert.Empty(t, (*EnvConfiguration)(nil).ToBlockDefinitions("acme", 100)) +} diff --git a/config/env_configuration.go b/config/env_configuration.go index aeac245..89c15a1 100644 --- a/config/env_configuration.go +++ b/config/env_configuration.go @@ -246,41 +246,64 @@ func (e *EnvConfiguration) Normalize(ctx context.Context, resolver core.Normaliz return nil } +// BlockDefinition is a block as declared in config.yml, carrying the fields that +// the flattened types.Block cannot express as "unspecified". +type BlockDefinition struct { + Block types.Block + // IsShared is nil when config.yml does not declare is_shared for this block. + IsShared *bool +} + +// ToBlocks flattens every block in the file into API blocks. +// An unspecified is_shared collapses to false; use ToBlockDefinitions when that matters. func (e *EnvConfiguration) ToBlocks(orgName string, stackId int64) types.Blocks { - blocks := make([]types.Block, 0) + defs := e.ToBlockDefinitions(orgName, stackId) + blocks := make([]types.Block, 0, len(defs)) + for _, def := range defs { + blocks = append(blocks, def.Block) + } + return blocks +} + +// ToBlockDefinitions flattens every block in the file, preserving whether is_shared was specified. +func (e *EnvConfiguration) ToBlockDefinitions(orgName string, stackId int64) []BlockDefinition { + defs := make([]BlockDefinition, 0) if e == nil { - return blocks + return defs + } + add := func(block types.Block, bc BlockConfiguration) { + defs = append(defs, BlockDefinition{Block: block, IsShared: bc.IsShared}) } for _, app := range e.Applications { - blocks = append(blocks, app.ToBlock(orgName, stackId)) + add(app.ToBlock(orgName, stackId), app.BlockConfiguration) } for _, ds := range e.Datastores { - blocks = append(blocks, ds.ToBlock(orgName, stackId)) + add(ds.ToBlock(orgName, stackId), ds.BlockConfiguration) } for _, sub := range e.Subdomains { - blocks = append(blocks, sub.ToBlock(orgName, stackId)) + add(sub.ToBlock(orgName, stackId), sub.BlockConfiguration) } for _, d := range e.Domains { - blocks = append(blocks, d.ToBlock(orgName, stackId)) + add(d.ToBlock(orgName, stackId), d.BlockConfiguration) } for _, i := range e.Ingresses { - blocks = append(blocks, i.ToBlock(orgName, stackId)) + add(i.ToBlock(orgName, stackId), i.BlockConfiguration) } for _, cn := range e.ClusterNamespaces { - blocks = append(blocks, cn.ToBlock(orgName, stackId)) + add(cn.ToBlock(orgName, stackId), cn.BlockConfiguration) } for _, c := range e.Clusters { - blocks = append(blocks, c.ToBlock(orgName, stackId)) + add(c.ToBlock(orgName, stackId), c.BlockConfiguration) } for _, n := range e.Networks { - blocks = append(blocks, n.ToBlock(orgName, stackId)) + add(n.ToBlock(orgName, stackId), n.BlockConfiguration) } for _, b := range e.Blocks { - blocks = append(blocks, b.ToBlock(orgName, stackId)) + add(b.ToBlock(orgName, stackId), *b) } - return blocks + return defs } func (e *EnvConfiguration) ApplyChangesTo(block types.Block, updater core.WorkspaceConfigUpdater) error { diff --git a/yaml/block_configuration.go b/yaml/block_configuration.go index f602d32..7256ea8 100644 --- a/yaml/block_configuration.go +++ b/yaml/block_configuration.go @@ -9,7 +9,9 @@ type BlockConfiguration struct { ModuleConstraint *string `yaml:"module_version,omitempty" json:"moduleVersion"` Variables map[string]any `yaml:"vars,omitempty" json:"vars"` Connections ConnectionConstraints `yaml:"connections,omitempty" json:"connections"` - IsShared bool `yaml:"is_shared,omitempty" json:"isShared"` + // IsShared marks the block as shared across preview environments. + // nil means the file does not specify it, which leaves the block's current setting alone. + IsShared *bool `yaml:"is_shared,omitempty" json:"isShared,omitempty"` // Metadata holds governance/descriptive metadata (e.g. data classification). Metadata *MetadataConfiguration `yaml:"metadata,omitempty" json:"metadata,omitempty"` } diff --git a/yaml/block_configuration_test.go b/yaml/block_configuration_test.go index 76da8ab..68e0e86 100644 --- a/yaml/block_configuration_test.go +++ b/yaml/block_configuration_test.go @@ -40,3 +40,30 @@ func TestMetadataFromWorkspaceConfig(t *testing.T) { assert.Equal(t, "restricted", *got.DataClassification) }) } + +// is_shared is tristate: absent must stay distinguishable from an explicit false. +func TestBlockConfiguration_UnmarshalIsShared(t *testing.T) { + tests := []struct { + name string + yaml string + want *bool + }{ + {name: "absent", yaml: "module: nullstone/aws-rds-postgres\n", want: nil}, + {name: "true", yaml: "module: nullstone/aws-rds-postgres\nis_shared: true\n", want: ptr(true)}, + {name: "false", yaml: "module: nullstone/aws-rds-postgres\nis_shared: false\n", want: ptr(false)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var bc BlockConfiguration + require.NoError(t, goyaml.Unmarshal([]byte(tt.yaml), &bc)) + if tt.want == nil { + assert.Nil(t, bc.IsShared) + } else { + require.NotNil(t, bc.IsShared) + assert.Equal(t, *tt.want, *bc.IsShared) + } + }) + } +} + +func ptr[T any](v T) *T { return &v } From ac02200cecb47b228ac83dc208249d723aff91b2 Mon Sep 17 00:00:00 2001 From: Brad Sickles Date: Thu, 10 Sep 2026 20:08:38 -0400 Subject: [PATCH 2/4] refactor(config): drop EnvConfiguration.ToBlocks in favor of ToBlockDefinitions One entry point for flattening config.yml blocks keeps every consumer on the same view; callers that only need the API shape read Block off each definition. --- config/block_configuration_test.go | 2 -- config/env_configuration.go | 12 +----------- config/env_configuration_test.go | 6 +++--- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/config/block_configuration_test.go b/config/block_configuration_test.go index ebf5d0a..9bfecbb 100644 --- a/config/block_configuration_test.go +++ b/config/block_configuration_test.go @@ -154,7 +154,5 @@ func TestEnvConfiguration_ToBlockDefinitions(t *testing.T) { assert.Nil(t, byName["queue"].IsShared) assert.Equal(t, string(BlockTypeBlock), byName["queue"].Block.Type) - // ToBlocks is the flattened view of the same definitions - assert.Len(t, ec.ToBlocks("acme", 100), 4) assert.Empty(t, (*EnvConfiguration)(nil).ToBlockDefinitions("acme", 100)) } diff --git a/config/env_configuration.go b/config/env_configuration.go index 89c15a1..1f1d10d 100644 --- a/config/env_configuration.go +++ b/config/env_configuration.go @@ -254,18 +254,8 @@ type BlockDefinition struct { IsShared *bool } -// ToBlocks flattens every block in the file into API blocks. -// An unspecified is_shared collapses to false; use ToBlockDefinitions when that matters. -func (e *EnvConfiguration) ToBlocks(orgName string, stackId int64) types.Blocks { - defs := e.ToBlockDefinitions(orgName, stackId) - blocks := make([]types.Block, 0, len(defs)) - for _, def := range defs { - blocks = append(blocks, def.Block) - } - return blocks -} - // ToBlockDefinitions flattens every block in the file, preserving whether is_shared was specified. +// Callers that only need the API shape read Block off each definition. func (e *EnvConfiguration) ToBlockDefinitions(orgName string, stackId int64) []BlockDefinition { defs := make([]BlockDefinition, 0) if e == nil { diff --git a/config/env_configuration_test.go b/config/env_configuration_test.go index cd59a88..b960aa2 100644 --- a/config/env_configuration_test.go +++ b/config/env_configuration_test.go @@ -343,10 +343,10 @@ func TestConvertConfiguration(t *testing.T) { syncBlocks := func(base *EnvConfiguration, resolver *find.ResourceResolver) { backfill := make([]types.Block, 0) - for _, block := range base.ToBlocks(defaults.OrgName, resolver.CurStackId) { - if _, err := resolver.FindBlock(context.TODO(), types.ConnectionTarget{BlockName: block.Name}); err != nil { + for _, def := range base.ToBlockDefinitions(defaults.OrgName, resolver.CurStackId) { + if _, err := resolver.FindBlock(context.TODO(), types.ConnectionTarget{BlockName: def.Block.Name}); err != nil { if find.IsMissingResource(err) { - backfill = append(backfill, block) + backfill = append(backfill, def.Block) } } } From 1cd9f5a9d1ca750b287de8f210fd0c1a37f7c1b2 Mon Sep 17 00:00:00 2001 From: Brad Sickles Date: Thu, 10 Sep 2026 20:12:27 -0400 Subject: [PATCH 3/4] refactor(config): fold ToBlock into an unexported per-type toBlockDefinition ToBlockDefinitions is now the only public way to flatten config.yml blocks; the tristate is_shared is attached where the flat block is built. --- config/app_configuration.go | 8 ++++---- config/block_configuration.go | 32 ++++++++++++++++-------------- config/block_configuration_test.go | 5 +++-- config/domain_configuration.go | 8 ++++---- config/env_configuration.go | 21 +++++++++----------- config/subdomain_configuration.go | 8 ++++---- 6 files changed, 41 insertions(+), 41 deletions(-) diff --git a/config/app_configuration.go b/config/app_configuration.go index 2369270..7a74e8b 100644 --- a/config/app_configuration.go +++ b/config/app_configuration.go @@ -110,10 +110,10 @@ func (a *AppConfiguration) Normalize(ctx context.Context, pc core.ObjectPathCont return errs } -func (a *AppConfiguration) ToBlock(orgName string, stackId int64) types.Block { - block := a.BlockConfiguration.ToBlock(orgName, stackId) - block.Framework = a.Framework - return block +func (a *AppConfiguration) toBlockDefinition(orgName string, stackId int64) BlockDefinition { + def := a.BlockConfiguration.toBlockDefinition(orgName, stackId) + def.Block.Framework = a.Framework + return def } func (a *AppConfiguration) ApplyChangesTo(ic core.IacContext, updater core.WorkspaceConfigUpdater) error { diff --git a/config/block_configuration.go b/config/block_configuration.go index 8f08b37..9d014aa 100644 --- a/config/block_configuration.go +++ b/config/block_configuration.go @@ -185,21 +185,23 @@ func (b *BlockConfiguration) Normalize(ctx context.Context, pc core.ObjectPathCo return b.Connections.Normalize(ctx, pc, resolver) } -// ToBlock flattens the configuration into an API block. -// IsShared collapses to false when the file did not specify it; EnvConfiguration.ToBlockDefinitions keeps the distinction. -func (b *BlockConfiguration) ToBlock(orgName string, stackId int64) types.Block { - block := types.Block{ - Type: string(b.Type), - OrgName: orgName, - StackId: stackId, - Name: b.Name, - IsShared: b.IsShared != nil && *b.IsShared, - DnsName: "", - ModuleSource: b.ModuleSource, - ModuleSourceVersion: b.ModuleConstraint, - Connections: b.Connections.DesiredTargets(), - } - return block +// toBlockDefinition flattens the configuration into an API block plus the tristate is_shared. +// The flat block's IsShared collapses to false when the file did not specify it. +func (b *BlockConfiguration) toBlockDefinition(orgName string, stackId int64) BlockDefinition { + return BlockDefinition{ + Block: types.Block{ + Type: string(b.Type), + OrgName: orgName, + StackId: stackId, + Name: b.Name, + IsShared: b.IsShared != nil && *b.IsShared, + DnsName: "", + ModuleSource: b.ModuleSource, + ModuleSourceVersion: b.ModuleConstraint, + Connections: b.Connections.DesiredTargets(), + }, + IsShared: b.IsShared, + } } func (b *BlockConfiguration) ApplyChangesTo(ic core.IacContext, updater core.WorkspaceConfigUpdater) error { diff --git a/config/block_configuration_test.go b/config/block_configuration_test.go index 9bfecbb..6591389 100644 --- a/config/block_configuration_test.go +++ b/config/block_configuration_test.go @@ -104,8 +104,9 @@ func TestBlockConfiguration_isSharedFromYaml(t *testing.T) { bc := blockConfigFromYaml("db", yaml.BlockConfiguration{ModuleSource: "nullstone/aws-rds-postgres", IsShared: tt.in}, BlockTypeDatastore, types.CategoryDatastore) assert.Equal(t, tt.in, bc.IsShared) - block := bc.ToBlock("acme", 100) - assert.Equal(t, tt.in != nil && *tt.in, block.IsShared, "ToBlock collapses unspecified to false") + def := bc.toBlockDefinition("acme", 100) + assert.Equal(t, tt.in, def.IsShared, "definition keeps the tristate") + assert.Equal(t, tt.in != nil && *tt.in, def.Block.IsShared, "flat block collapses unspecified to false") }) } } diff --git a/config/domain_configuration.go b/config/domain_configuration.go index bc8ed00..da00a46 100644 --- a/config/domain_configuration.go +++ b/config/domain_configuration.go @@ -12,12 +12,12 @@ type DomainConfiguration struct { DomainNameTemplate *string `json:"domainNameTemplate"` } -func (d *DomainConfiguration) ToBlock(orgName string, stackId int64) types.Block { - block := d.BlockConfiguration.ToBlock(orgName, stackId) +func (d *DomainConfiguration) toBlockDefinition(orgName string, stackId int64) BlockDefinition { + def := d.BlockConfiguration.toBlockDefinition(orgName, stackId) if d.DomainNameTemplate != nil { - block.DnsName = *d.DomainNameTemplate + def.Block.DnsName = *d.DomainNameTemplate } - return block + return def } func (d *DomainConfiguration) ApplyChangesTo(ic core.IacContext, updater core.WorkspaceConfigUpdater) error { diff --git a/config/env_configuration.go b/config/env_configuration.go index 1f1d10d..66a760b 100644 --- a/config/env_configuration.go +++ b/config/env_configuration.go @@ -261,36 +261,33 @@ func (e *EnvConfiguration) ToBlockDefinitions(orgName string, stackId int64) []B if e == nil { return defs } - add := func(block types.Block, bc BlockConfiguration) { - defs = append(defs, BlockDefinition{Block: block, IsShared: bc.IsShared}) - } for _, app := range e.Applications { - add(app.ToBlock(orgName, stackId), app.BlockConfiguration) + defs = append(defs, app.toBlockDefinition(orgName, stackId)) } for _, ds := range e.Datastores { - add(ds.ToBlock(orgName, stackId), ds.BlockConfiguration) + defs = append(defs, ds.toBlockDefinition(orgName, stackId)) } for _, sub := range e.Subdomains { - add(sub.ToBlock(orgName, stackId), sub.BlockConfiguration) + defs = append(defs, sub.toBlockDefinition(orgName, stackId)) } for _, d := range e.Domains { - add(d.ToBlock(orgName, stackId), d.BlockConfiguration) + defs = append(defs, d.toBlockDefinition(orgName, stackId)) } for _, i := range e.Ingresses { - add(i.ToBlock(orgName, stackId), i.BlockConfiguration) + defs = append(defs, i.toBlockDefinition(orgName, stackId)) } for _, cn := range e.ClusterNamespaces { - add(cn.ToBlock(orgName, stackId), cn.BlockConfiguration) + defs = append(defs, cn.toBlockDefinition(orgName, stackId)) } for _, c := range e.Clusters { - add(c.ToBlock(orgName, stackId), c.BlockConfiguration) + defs = append(defs, c.toBlockDefinition(orgName, stackId)) } for _, n := range e.Networks { - add(n.ToBlock(orgName, stackId), n.BlockConfiguration) + defs = append(defs, n.toBlockDefinition(orgName, stackId)) } for _, b := range e.Blocks { - add(b.ToBlock(orgName, stackId), *b) + defs = append(defs, b.toBlockDefinition(orgName, stackId)) } return defs diff --git a/config/subdomain_configuration.go b/config/subdomain_configuration.go index 359926f..637f0ee 100644 --- a/config/subdomain_configuration.go +++ b/config/subdomain_configuration.go @@ -24,12 +24,12 @@ type SubdomainConfiguration struct { Reservation *types.SubdomainReservation `json:"reservation,omitempty"` } -func (s *SubdomainConfiguration) ToBlock(orgName string, stackId int64) types.Block { - block := s.BlockConfiguration.ToBlock(orgName, stackId) +func (s *SubdomainConfiguration) toBlockDefinition(orgName string, stackId int64) BlockDefinition { + def := s.BlockConfiguration.toBlockDefinition(orgName, stackId) if s.SubdomainNameTemplate != nil { - block.DnsName = *s.SubdomainNameTemplate + def.Block.DnsName = *s.SubdomainNameTemplate } - return block + return def } func (s *SubdomainConfiguration) Resolve(ctx context.Context, resolver core.ResolveResolver, finder core.IacFinder, ic core.IacContext, pc core.ObjectPathContext) core.ResolveErrors { From b46aaa5fc83ec9fbc54d2fd415d37eb2133dcce0 Mon Sep 17 00:00:00 2001 From: Brad Sickles Date: Thu, 10 Sep 2026 20:18:26 -0400 Subject: [PATCH 4/4] feat(config)!: drop is_shared from IaC; sharing is chosen in the UI only Furion removed isShared from block sync in March 2025 (furion #135), so the yaml key has been parsed-and-ignored since. It was never documented. Removing it from the yaml and config layers makes that explicit; the lenient decoder still accepts a stray is_shared key without error. BlockDefinition stays as the ToBlockDefinitions element type so declared-only information can be added later without another API change. --- config/block_configuration.go | 12 +++----- config/block_configuration_test.go | 46 +++++++++++------------------- config/env_configuration.go | 9 +++--- yaml/block_configuration.go | 3 -- yaml/block_configuration_test.go | 31 ++++---------------- 5 files changed, 30 insertions(+), 71 deletions(-) diff --git a/config/block_configuration.go b/config/block_configuration.go index 9d014aa..01539f2 100644 --- a/config/block_configuration.go +++ b/config/block_configuration.go @@ -36,9 +36,7 @@ type BlockConfiguration struct { ModuleConstraint string `json:"moduleConstraint"` Variables VariableConfigurations `json:"vars"` Connections ConnectionConfigurations `json:"connections"` - // IsShared is nil when the file does not specify is_shared. - IsShared *bool `json:"isShared,omitempty"` - Metadata MetadataConfiguration `json:"metadata"` + Metadata MetadataConfiguration `json:"metadata"` // These fields are populated via Resolve() Module *types.Module `json:"module"` @@ -98,7 +96,6 @@ func blockConfigFromYaml(name string, value yaml.BlockConfiguration, blockType B ModuleConstraint: moduleConstraint, Variables: convertVariables(value.Variables), Connections: convertConnections(value.Connections), - IsShared: value.IsShared, Metadata: convertMetadata(value.Metadata), } } @@ -185,8 +182,9 @@ func (b *BlockConfiguration) Normalize(ctx context.Context, pc core.ObjectPathCo return b.Connections.Normalize(ctx, pc, resolver) } -// toBlockDefinition flattens the configuration into an API block plus the tristate is_shared. -// The flat block's IsShared collapses to false when the file did not specify it. +// toBlockDefinition flattens the configuration into an API block. +// Fields IaC does not govern (IsShared, Repo, Framework for non-apps, ...) stay zero so a +// consumer syncing blocks can tell "not declared" from a declared value. func (b *BlockConfiguration) toBlockDefinition(orgName string, stackId int64) BlockDefinition { return BlockDefinition{ Block: types.Block{ @@ -194,13 +192,11 @@ func (b *BlockConfiguration) toBlockDefinition(orgName string, stackId int64) Bl OrgName: orgName, StackId: stackId, Name: b.Name, - IsShared: b.IsShared != nil && *b.IsShared, DnsName: "", ModuleSource: b.ModuleSource, ModuleSourceVersion: b.ModuleConstraint, Connections: b.Connections.DesiredTargets(), }, - IsShared: b.IsShared, } } diff --git a/config/block_configuration_test.go b/config/block_configuration_test.go index 6591389..c0f758f 100644 --- a/config/block_configuration_test.go +++ b/config/block_configuration_test.go @@ -89,26 +89,16 @@ func TestBlockConfiguration_ApplyChangesTo_dataClassification(t *testing.T) { }) } -// is_shared must survive conversion as a tristate so a sync can tell "not declared" from "false". -func TestBlockConfiguration_isSharedFromYaml(t *testing.T) { - tests := []struct { - name string - in *bool - }{ - {name: "absent", in: nil}, - {name: "true", in: ptr(true)}, - {name: "false", in: ptr(false)}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - bc := blockConfigFromYaml("db", yaml.BlockConfiguration{ModuleSource: "nullstone/aws-rds-postgres", IsShared: tt.in}, BlockTypeDatastore, types.CategoryDatastore) - assert.Equal(t, tt.in, bc.IsShared) - - def := bc.toBlockDefinition("acme", 100) - assert.Equal(t, tt.in, def.IsShared, "definition keeps the tristate") - assert.Equal(t, tt.in != nil && *tt.in, def.Block.IsShared, "flat block collapses unspecified to false") - }) - } +// Sharing is not governed by IaC, so a definition never carries it; a block sync must leave +// the stored flag alone. +func TestBlockConfiguration_toBlockDefinitionOmitsUngovernedFields(t *testing.T) { + bc := blockConfigFromYaml("db", yaml.BlockConfiguration{ModuleSource: "nullstone/aws-rds-postgres"}, BlockTypeDatastore, types.CategoryDatastore) + def := bc.toBlockDefinition("acme", 100) + assert.False(t, def.Block.IsShared) + assert.Empty(t, def.Block.DnsName) + assert.Empty(t, def.Block.Framework) + assert.Empty(t, def.Block.Repo) + assert.Equal(t, "latest", def.Block.ModuleSourceVersion, "omitted module_version resolves to latest") } func TestEnvConfiguration_ToBlockDefinitions(t *testing.T) { @@ -120,11 +110,11 @@ func TestEnvConfiguration_ToBlockDefinitions(t *testing.T) { }, }, Datastores: map[string]*DatastoreConfiguration{ - "db": {BlockConfiguration: BlockConfiguration{Type: BlockTypeDatastore, Name: "db", ModuleSource: "nullstone/aws-rds-postgres", IsShared: ptr(true)}}, + "db": {BlockConfiguration: BlockConfiguration{Type: BlockTypeDatastore, Name: "db", ModuleSource: "nullstone/aws-rds-postgres"}}, }, Subdomains: map[string]*SubdomainConfiguration{ "web": { - BlockConfiguration: BlockConfiguration{Type: BlockTypeSubdomain, Name: "web", ModuleSource: "nullstone/aws-subdomain", IsShared: ptr(false)}, + BlockConfiguration: BlockConfiguration{Type: BlockTypeSubdomain, Name: "web", ModuleSource: "nullstone/aws-subdomain"}, SubdomainNameTemplate: ptr("web.{{ NULLSTONE_ENV }}"), }, }, @@ -140,20 +130,16 @@ func TestEnvConfiguration_ToBlockDefinitions(t *testing.T) { } require.Len(t, byName, 4) - assert.Nil(t, byName["api"].IsShared) + assert.Equal(t, string(BlockTypeApplication), byName["api"].Block.Type) assert.Equal(t, "go", byName["api"].Block.Framework) - assert.False(t, byName["api"].Block.IsShared) - require.NotNil(t, byName["db"].IsShared) - assert.True(t, *byName["db"].IsShared) - assert.True(t, byName["db"].Block.IsShared) + assert.Equal(t, string(BlockTypeDatastore), byName["db"].Block.Type) + assert.Empty(t, byName["db"].Block.Framework) - require.NotNil(t, byName["web"].IsShared) - assert.False(t, *byName["web"].IsShared) assert.Equal(t, "web.{{ NULLSTONE_ENV }}", byName["web"].Block.DnsName) - assert.Nil(t, byName["queue"].IsShared) assert.Equal(t, string(BlockTypeBlock), byName["queue"].Block.Type) + assert.Empty(t, byName["queue"].Block.DnsName) assert.Empty(t, (*EnvConfiguration)(nil).ToBlockDefinitions("acme", 100)) } diff --git a/config/env_configuration.go b/config/env_configuration.go index 66a760b..be0859d 100644 --- a/config/env_configuration.go +++ b/config/env_configuration.go @@ -246,15 +246,14 @@ func (e *EnvConfiguration) Normalize(ctx context.Context, resolver core.Normaliz return nil } -// BlockDefinition is a block as declared in config.yml, carrying the fields that -// the flattened types.Block cannot express as "unspecified". +// BlockDefinition is a block as declared in config.yml. Block carries only the fields IaC +// governs; anything IaC does not declare is left at its zero value. It is a struct so that +// declared-only information which the flat types.Block cannot express can be added later. type BlockDefinition struct { Block types.Block - // IsShared is nil when config.yml does not declare is_shared for this block. - IsShared *bool } -// ToBlockDefinitions flattens every block in the file, preserving whether is_shared was specified. +// ToBlockDefinitions flattens every block in the file into sync definitions. // Callers that only need the API shape read Block off each definition. func (e *EnvConfiguration) ToBlockDefinitions(orgName string, stackId int64) []BlockDefinition { defs := make([]BlockDefinition, 0) diff --git a/yaml/block_configuration.go b/yaml/block_configuration.go index 7256ea8..fe73928 100644 --- a/yaml/block_configuration.go +++ b/yaml/block_configuration.go @@ -9,9 +9,6 @@ type BlockConfiguration struct { ModuleConstraint *string `yaml:"module_version,omitempty" json:"moduleVersion"` Variables map[string]any `yaml:"vars,omitempty" json:"vars"` Connections ConnectionConstraints `yaml:"connections,omitempty" json:"connections"` - // IsShared marks the block as shared across preview environments. - // nil means the file does not specify it, which leaves the block's current setting alone. - IsShared *bool `yaml:"is_shared,omitempty" json:"isShared,omitempty"` // Metadata holds governance/descriptive metadata (e.g. data classification). Metadata *MetadataConfiguration `yaml:"metadata,omitempty" json:"metadata,omitempty"` } diff --git a/yaml/block_configuration_test.go b/yaml/block_configuration_test.go index 68e0e86..23b047c 100644 --- a/yaml/block_configuration_test.go +++ b/yaml/block_configuration_test.go @@ -41,29 +41,10 @@ func TestMetadataFromWorkspaceConfig(t *testing.T) { }) } -// is_shared is tristate: absent must stay distinguishable from an explicit false. -func TestBlockConfiguration_UnmarshalIsShared(t *testing.T) { - tests := []struct { - name string - yaml string - want *bool - }{ - {name: "absent", yaml: "module: nullstone/aws-rds-postgres\n", want: nil}, - {name: "true", yaml: "module: nullstone/aws-rds-postgres\nis_shared: true\n", want: ptr(true)}, - {name: "false", yaml: "module: nullstone/aws-rds-postgres\nis_shared: false\n", want: ptr(false)}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var bc BlockConfiguration - require.NoError(t, goyaml.Unmarshal([]byte(tt.yaml), &bc)) - if tt.want == nil { - assert.Nil(t, bc.IsShared) - } else { - require.NotNil(t, bc.IsShared) - assert.Equal(t, *tt.want, *bc.IsShared) - } - }) - } +// is_shared is not an IaC setting (sharing is chosen in the UI); a stray key parses without error +// and has no effect, matching the lenient decoder used for every other unknown key. +func TestBlockConfiguration_IgnoresIsShared(t *testing.T) { + var bc BlockConfiguration + require.NoError(t, goyaml.Unmarshal([]byte("module: nullstone/aws-rds-postgres\nis_shared: true\n"), &bc)) + assert.Equal(t, "nullstone/aws-rds-postgres", bc.ModuleSource) } - -func ptr[T any](v T) *T { return &v }