From d1379e67ffa60830191d681502ed538e96d6f781 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Thu, 16 Apr 2026 15:07:17 -0700 Subject: [PATCH 1/3] feat: reference variables can reference related resource's variables --- apps/workspace-engine/oapi/openapi.json | 6 ++ .../oapi/spec/schemas/entities.jsonnet | 4 ++ apps/workspace-engine/pkg/oapi/oapi.gen.go | 1 + .../pkg/workspace/relationships/property.go | 38 ++++++++++++ .../variableresolver/getters_postgres.go | 62 ++++++++++++++++++- .../variableresolver/resolve.go | 3 + .../test/controllers/harness/pipeline_opts.go | 24 ++++--- .../test/controllers/variable_test.go | 32 ++++++++++ 8 files changed, 157 insertions(+), 13 deletions(-) diff --git a/apps/workspace-engine/oapi/openapi.json b/apps/workspace-engine/oapi/openapi.json index 11f877309..2fe7e39d8 100644 --- a/apps/workspace-engine/oapi/openapi.json +++ b/apps/workspace-engine/oapi/openapi.json @@ -2166,6 +2166,12 @@ "format": "date-time", "type": "string" }, + "variables": { + "additionalProperties": { + "$ref": "#/components/schemas/Value" + }, + "type": "object" + }, "version": { "type": "string" }, diff --git a/apps/workspace-engine/oapi/spec/schemas/entities.jsonnet b/apps/workspace-engine/oapi/spec/schemas/entities.jsonnet index 28b7ca65c..d4924e0ed 100644 --- a/apps/workspace-engine/oapi/spec/schemas/entities.jsonnet +++ b/apps/workspace-engine/oapi/spec/schemas/entities.jsonnet @@ -53,6 +53,10 @@ local openapi = import '../lib/openapi.libsonnet'; type: 'object', additionalProperties: { type: 'string' }, }, + variables: { + type: 'object', + additionalProperties: openapi.schemaRef('Value'), + }, }, }, diff --git a/apps/workspace-engine/pkg/oapi/oapi.gen.go b/apps/workspace-engine/pkg/oapi/oapi.gen.go index 595ea43bc..90b569b33 100644 --- a/apps/workspace-engine/pkg/oapi/oapi.gen.go +++ b/apps/workspace-engine/pkg/oapi/oapi.gen.go @@ -1016,6 +1016,7 @@ type Resource struct { Name string `json:"name"` ProviderId *string `json:"providerId,omitempty"` UpdatedAt *time.Time `json:"updatedAt,omitempty"` + Variables *map[string]Value `json:"variables,omitempty"` Version string `json:"version"` WorkspaceId string `json:"workspaceId"` } diff --git a/apps/workspace-engine/pkg/workspace/relationships/property.go b/apps/workspace-engine/pkg/workspace/relationships/property.go index 1568d604d..71eb32157 100644 --- a/apps/workspace-engine/pkg/workspace/relationships/property.go +++ b/apps/workspace-engine/pkg/workspace/relationships/property.go @@ -82,11 +82,49 @@ func getResourceProperty( return nil, err } return convertValue(value) + case "variables": + return getResourceVariableProperty(resource, propertyPath) default: return getPropertyReflection(resource, propertyPath) } } +func getResourceVariableProperty( + resource *oapi.Resource, + propertyPath []string, +) (*oapi.LiteralValue, error) { + if resource.Variables == nil { + return nil, fmt.Errorf("variables not set on resource") + } + if len(propertyPath) < 2 { + return nil, fmt.Errorf("variables path requires a key") + } + + vars := *resource.Variables + v, ok := vars[propertyPath[1]] + if !ok { + return nil, fmt.Errorf("variable %s not found", propertyPath[1]) + } + + lv, err := v.AsLiteralValue() + if err != nil { + return nil, fmt.Errorf("variable %s is not a literal: %w", propertyPath[1], err) + } + if len(propertyPath) == 2 { + return &lv, nil + } + + obj, err := lv.AsObjectValue() + if err != nil { + return nil, fmt.Errorf("cannot traverse into non-object variable %s", propertyPath[1]) + } + value, err := getMapValue(obj.Object, propertyPath[2:]) + if err != nil { + return nil, err + } + return convertValue(value) +} + // getDeploymentProperty gets a property from a Deployment entity. func getDeploymentProperty( deployment *oapi.Deployment, diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/getters_postgres.go b/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/getters_postgres.go index 46a4826a1..d89dc69e0 100644 --- a/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/getters_postgres.go +++ b/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/getters_postgres.go @@ -214,13 +214,17 @@ func (g *PostgresGetter) LoadCandidates( if err != nil { return nil, fmt.Errorf("list resources for workspace %s: %w", workspaceID, err) } + varsByResource, err := loadResourceVariablesByWorkspace(ctx, q, workspaceID) + if err != nil { + return nil, err + } candidates := make([]eval.EntityData, 0, len(rows)) for _, r := range rows { candidates = append(candidates, eval.EntityData{ ID: r.ID, WorkspaceID: r.WorkspaceID, EntityType: "resource", - Raw: resourceRowToMap(r), + Raw: resourceRowToMap(r, varsByResource[r.ID]), }) } return candidates, nil @@ -275,11 +279,15 @@ func (g *PostgresGetter) GetEntityByID( if err != nil { return nil, fmt.Errorf("get resource %s: %w", entityID, err) } + vars, err := loadResourceVariables(ctx, q, r.ID) + if err != nil { + return nil, err + } return &eval.EntityData{ ID: r.ID, WorkspaceID: r.WorkspaceID, EntityType: "resource", - Raw: resourceRowToMap(db.ListActiveResourcesByWorkspaceRow(r)), + Raw: resourceRowToMap(db.ListActiveResourcesByWorkspaceRow(r), vars), }, nil case "deployment": @@ -311,7 +319,10 @@ func (g *PostgresGetter) GetEntityByID( } } -func resourceRowToMap(r db.ListActiveResourcesByWorkspaceRow) map[string]any { +func resourceRowToMap( + r db.ListActiveResourcesByWorkspaceRow, + vars map[string]oapi.Value, +) map[string]any { m := map[string]any{ "type": "resource", "id": r.ID.String(), @@ -325,9 +336,54 @@ func resourceRowToMap(r db.ListActiveResourcesByWorkspaceRow) map[string]any { if r.ProviderID != uuid.Nil { m["providerId"] = r.ProviderID.String() } + if len(vars) > 0 { + m["variables"] = vars + } return m } +func loadResourceVariables( + ctx context.Context, + q *db.Queries, + resourceID uuid.UUID, +) (map[string]oapi.Value, error) { + rows, err := q.ListResourceVariablesByResourceID(ctx, resourceID) + if err != nil { + return nil, fmt.Errorf("list variables for resource %s: %w", resourceID, err) + } + if len(rows) == 0 { + return nil, nil + } + vars := make(map[string]oapi.Value, len(rows)) + for _, row := range rows { + v := db.ToOapiResourceVariable(row) + vars[v.Key] = v.Value + } + return vars, nil +} + +func loadResourceVariablesByWorkspace( + ctx context.Context, + q *db.Queries, + workspaceID uuid.UUID, +) (map[uuid.UUID]map[string]oapi.Value, error) { + rows, err := q.ListResourceVariablesByWorkspaceID(ctx, workspaceID) + if err != nil { + return nil, fmt.Errorf("list variables for workspace %s: %w", workspaceID, err) + } + result := make(map[uuid.UUID]map[string]oapi.Value) + for _, row := range rows { + v := db.ToOapiResourceVariable(row) + m := result[row.ResourceID] + if m == nil { + m = make(map[string]oapi.Value) + result[row.ResourceID] = m + } + m[v.Key] = v.Value + } + return result, nil +} + func deploymentRowToMap(r db.ListDeploymentsByWorkspaceRow) map[string]any { m := map[string]any{ "type": "deployment", diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/resolve.go b/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/resolve.go index 69483ccc4..be9c164d3 100644 --- a/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/resolve.go +++ b/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/resolve.go @@ -410,6 +410,9 @@ func mapToResource(data *eval.EntityData) oapi.Resource { } r.Metadata = md } + if v, ok := raw["variables"].(map[string]oapi.Value); ok && len(v) > 0 { + r.Variables = &v + } return r } diff --git a/apps/workspace-engine/test/controllers/harness/pipeline_opts.go b/apps/workspace-engine/test/controllers/harness/pipeline_opts.go index 0b8988ff9..e53196d58 100644 --- a/apps/workspace-engine/test/controllers/harness/pipeline_opts.go +++ b/apps/workspace-engine/test/controllers/harness/pipeline_opts.go @@ -630,20 +630,24 @@ func WithRelatedResource(reference string, res *oapi.Resource) PipelineOption { for k, v := range res.Metadata { metadata[k] = v } + raw := map[string]any{ + "type": "resource", + "id": relatedID.String(), + "name": res.Name, + "kind": res.Kind, + "version": res.Version, + "identifier": res.Identifier, + "config": res.Config, + "metadata": metadata, + } + if res.Variables != nil && len(*res.Variables) > 0 { + raw["variables"] = *res.Variables + } sc.Candidates["resource"] = append(sc.Candidates["resource"], eval.EntityData{ ID: relatedID, WorkspaceID: sc.WorkspaceID, EntityType: "resource", - Raw: map[string]any{ - "type": "resource", - "id": relatedID.String(), - "name": res.Name, - "kind": res.Kind, - "version": res.Version, - "identifier": res.Identifier, - "config": res.Config, - "metadata": metadata, - }, + Raw: raw, }) } } diff --git a/apps/workspace-engine/test/controllers/variable_test.go b/apps/workspace-engine/test/controllers/variable_test.go index 34d9e4702..23216efea 100644 --- a/apps/workspace-engine/test/controllers/variable_test.go +++ b/apps/workspace-engine/test/controllers/variable_test.go @@ -520,6 +520,38 @@ func TestVariable_ReferenceVariable_NestedConfig(t *testing.T) { p.AssertReleaseVariableEquals(t, 0, "k8s_endpoint", "https://k8s.internal:6443") } +func TestVariable_ReferenceVariable_ResolvesFromRelatedResourceVariables(t *testing.T) { + dbResource := &oapi.Resource{ + Id: uuid.New().String(), + Name: "db-primary", + Kind: "Database", + Version: "v1", + Identifier: "db-primary", + WorkspaceId: uuid.New().String(), + Metadata: map[string]string{}, + Config: map[string]any{}, + Variables: &map[string]oapi.Value{ + "db_url": LiteralValue("postgres://db.internal/app"), + }, + } + + p := NewTestPipeline(t, + WithDeployment(DeploymentSelector("true")), + WithEnvironment(EnvironmentName("production")), + WithResource(ResourceName("srv"), ResourceKind("Server")), + WithVersion(VersionTag("v1.0.0")), + WithDeploymentVariable("db_url", + WithVariableValue(ReferenceValue("database", "variables", "db_url")), + ), + WithRelatedResource("database", dbResource), + ) + + p.Run() + + p.AssertReleaseCreated(t) + p.AssertReleaseVariableEquals(t, 0, "db_url", "postgres://db.internal/app") +} + // --------------------------------------------------------------------------- // Multiple related resources — each referenced by different variables // --------------------------------------------------------------------------- From e0f56dc2e19fae0544f8b39f1bd589c68470acef Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Thu, 16 Apr 2026 15:11:29 -0700 Subject: [PATCH 2/3] more test --- .../test/controllers/variable_test.go | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/apps/workspace-engine/test/controllers/variable_test.go b/apps/workspace-engine/test/controllers/variable_test.go index 23216efea..3db8ccf1b 100644 --- a/apps/workspace-engine/test/controllers/variable_test.go +++ b/apps/workspace-engine/test/controllers/variable_test.go @@ -552,6 +552,45 @@ func TestVariable_ReferenceVariable_ResolvesFromRelatedResourceVariables(t *test p.AssertReleaseVariableEquals(t, 0, "db_url", "postgres://db.internal/app") } +// --------------------------------------------------------------------------- +// Reference variable walks into a nested object variable on a related resource +// --------------------------------------------------------------------------- + +func TestVariable_ReferenceVariable_NestedObjectResourceVariable(t *testing.T) { + dbResource := &oapi.Resource{ + Id: uuid.New().String(), + Name: "db-primary", + Kind: "Database", + Version: "v1", + Identifier: "db-primary", + WorkspaceId: uuid.New().String(), + Metadata: map[string]string{}, + Config: map[string]any{}, + Variables: &map[string]oapi.Value{ + "connection": LiteralValue(map[string]any{ + "host": "db.internal", + "port": 5432, + }), + }, + } + + p := NewTestPipeline(t, + WithDeployment(DeploymentSelector("true")), + WithEnvironment(EnvironmentName("production")), + WithResource(ResourceName("srv"), ResourceKind("Server")), + WithVersion(VersionTag("v1.0.0")), + WithDeploymentVariable("db_host", + WithVariableValue(ReferenceValue("database", "variables", "connection", "host")), + ), + WithRelatedResource("database", dbResource), + ) + + p.Run() + + p.AssertReleaseCreated(t) + p.AssertReleaseVariableEquals(t, 0, "db_host", "db.internal") +} + // --------------------------------------------------------------------------- // Multiple related resources — each referenced by different variables // --------------------------------------------------------------------------- From 8d01cdf55087ca17836ee04911f5df48d6a42547 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Thu, 16 Apr 2026 15:27:48 -0700 Subject: [PATCH 3/3] add more tests --- .../pkg/db/queries/resource_variables.sql | 2 +- .../pkg/db/resource_variables.sql.go | 2 +- .../pkg/workspace/relationships/property.go | 6 +- .../workspace/relationships/property_test.go | 98 +++++++++++++++++ .../variableresolver/getters_postgres_test.go | 104 ++++++++++++++++++ 5 files changed, 209 insertions(+), 3 deletions(-) diff --git a/apps/workspace-engine/pkg/db/queries/resource_variables.sql b/apps/workspace-engine/pkg/db/queries/resource_variables.sql index e8d49ff8c..85370f574 100644 --- a/apps/workspace-engine/pkg/db/queries/resource_variables.sql +++ b/apps/workspace-engine/pkg/db/queries/resource_variables.sql @@ -26,4 +26,4 @@ WHERE resource_id = $1; SELECT rv.resource_id, rv.key, rv.value FROM resource_variable rv INNER JOIN resource r ON r.id = rv.resource_id -WHERE r.workspace_id = $1; +WHERE r.workspace_id = $1 AND r.deleted_at IS NULL; diff --git a/apps/workspace-engine/pkg/db/resource_variables.sql.go b/apps/workspace-engine/pkg/db/resource_variables.sql.go index 3dc9b7b41..7e63a835f 100644 --- a/apps/workspace-engine/pkg/db/resource_variables.sql.go +++ b/apps/workspace-engine/pkg/db/resource_variables.sql.go @@ -84,7 +84,7 @@ const listResourceVariablesByWorkspaceID = `-- name: ListResourceVariablesByWork SELECT rv.resource_id, rv.key, rv.value FROM resource_variable rv INNER JOIN resource r ON r.id = rv.resource_id -WHERE r.workspace_id = $1 +WHERE r.workspace_id = $1 AND r.deleted_at IS NULL ` func (q *Queries) ListResourceVariablesByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]ResourceVariable, error) { diff --git a/apps/workspace-engine/pkg/workspace/relationships/property.go b/apps/workspace-engine/pkg/workspace/relationships/property.go index 71eb32157..b3a9a93c7 100644 --- a/apps/workspace-engine/pkg/workspace/relationships/property.go +++ b/apps/workspace-engine/pkg/workspace/relationships/property.go @@ -116,7 +116,11 @@ func getResourceVariableProperty( obj, err := lv.AsObjectValue() if err != nil { - return nil, fmt.Errorf("cannot traverse into non-object variable %s", propertyPath[1]) + return nil, fmt.Errorf( + "cannot traverse into non-object variable %s: %w", + propertyPath[1], + err, + ) } value, err := getMapValue(obj.Object, propertyPath[2:]) if err != nil { diff --git a/apps/workspace-engine/pkg/workspace/relationships/property_test.go b/apps/workspace-engine/pkg/workspace/relationships/property_test.go index 721b698e8..30492158f 100644 --- a/apps/workspace-engine/pkg/workspace/relationships/property_test.go +++ b/apps/workspace-engine/pkg/workspace/relationships/property_test.go @@ -106,6 +106,104 @@ func TestPropertyValueExtraction_Resource(t *testing.T) { } } +func TestPropertyValueExtraction_Resource_Variables(t *testing.T) { + stringVar := *oapi.NewValueFromLiteral(oapi.NewLiteralValue("postgres://db.internal/app")) + intVar := *oapi.NewValueFromLiteral(oapi.NewLiteralValue(5432)) + objectVar := *oapi.NewValueFromLiteral(oapi.NewLiteralValue(map[string]any{ + "host": "db.internal", + "port": 5432, + "meta": map[string]any{"primary": true}, + })) + + variables := map[string]oapi.Value{ + "db_url": stringVar, + "db_port": intVar, + "connection": objectVar, + } + + resource := &oapi.Resource{ + Id: "res-1", + Name: "srv", + Kind: "Server", + WorkspaceId: "ws-1", + Variables: &variables, + } + entity := makeResourceEntity(resource) + + t.Run("string variable by key", func(t *testing.T) { + val, err := GetPropertyValue(entity, []string{"variables", "db_url"}) + require.NoError(t, err) + require.NotNil(t, val) + s, err := val.AsStringValue() + require.NoError(t, err) + assert.Equal(t, "postgres://db.internal/app", s) + }) + + t.Run("integer variable by key", func(t *testing.T) { + val, err := GetPropertyValue(entity, []string{"variables", "db_port"}) + require.NoError(t, err) + require.NotNil(t, val) + i, err := val.AsIntegerValue() + require.NoError(t, err) + assert.Equal(t, 5432, i) + }) + + t.Run("nested object variable - one level deep", func(t *testing.T) { + val, err := GetPropertyValue(entity, []string{"variables", "connection", "host"}) + require.NoError(t, err) + require.NotNil(t, val) + s, err := val.AsStringValue() + require.NoError(t, err) + assert.Equal(t, "db.internal", s) + }) + + t.Run("nested object variable - two levels deep", func(t *testing.T) { + val, err := GetPropertyValue(entity, []string{"variables", "connection", "meta", "primary"}) + require.NoError(t, err) + require.NotNil(t, val) + b, err := val.AsBooleanValue() + require.NoError(t, err) + assert.True(t, b) + }) + + t.Run("missing key returns not-found error", func(t *testing.T) { + _, err := GetPropertyValue(entity, []string{"variables", "does_not_exist"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) + + t.Run("variables path without key errors", func(t *testing.T) { + _, err := GetPropertyValue(entity, []string{"variables"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a key") + }) + + t.Run("cannot traverse into non-object variable", func(t *testing.T) { + _, err := GetPropertyValue(entity, []string{"variables", "db_url", "host"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "non-object") + }) + + t.Run("missing nested key in object variable errors", func(t *testing.T) { + _, err := GetPropertyValue(entity, []string{"variables", "connection", "missing"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) +} + +func TestPropertyValueExtraction_Resource_Variables_NilMap(t *testing.T) { + resource := &oapi.Resource{ + Id: "res-1", + Name: "srv", + Variables: nil, + } + entity := makeResourceEntity(resource) + + _, err := GetPropertyValue(entity, []string{"variables", "anything"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not set") +} + func TestPropertyValueExtraction_Resource_NilProviderId(t *testing.T) { resource := &oapi.Resource{ Id: "res-1", diff --git a/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/getters_postgres_test.go b/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/getters_postgres_test.go index 2cd9ca676..794425e4e 100644 --- a/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/getters_postgres_test.go +++ b/apps/workspace-engine/svc/controllers/desiredrelease/variableresolver/getters_postgres_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "workspace-engine/pkg/db" + "workspace-engine/pkg/oapi" "workspace-engine/svc/controllers/desiredrelease/variableresolver" ) @@ -394,3 +395,106 @@ func TestPostgresGetter_GetEntityByID(t *testing.T) { assert.Equal(t, "a test environment", desc) }) } + +func TestPostgresGetter_ResourceVariablesAttachedToRaw(t *testing.T) { + pool := requireTestDB(t) + f := setupFixture(t, pool) + ctx := context.Background() + + getter := variableresolver.NewPostgresGetter(nil) + + _, err := pool.Exec(ctx, + `INSERT INTO resource_variable (resource_id, key, value) VALUES ($1, $2, $3)`, + f.resourceID, "db_url", []byte(`"postgres://db.internal/app"`)) + require.NoError(t, err) + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), + `DELETE FROM resource_variable WHERE resource_id = $1 AND key = $2`, + f.resourceID, "db_url") + }) + + assertDBURL := func(t *testing.T, raw map[string]any) { + t.Helper() + vars, ok := raw["variables"].(map[string]oapi.Value) + require.True( + t, + ok, + "Raw[\"variables\"] should be map[string]oapi.Value, got %T", + raw["variables"], + ) + require.Contains(t, vars, "db_url") + + lv, err := vars["db_url"].AsLiteralValue() + require.NoError(t, err) + s, err := lv.AsStringValue() + require.NoError(t, err) + assert.Equal(t, "postgres://db.internal/app", s) + } + + t.Run("GetEntityByID attaches resource variables", func(t *testing.T) { + entity, err := getter.GetEntityByID(ctx, f.resourceID, "resource") + require.NoError(t, err) + assertDBURL(t, entity.Raw) + }) + + t.Run("LoadCandidates attaches resource variables", func(t *testing.T) { + candidates, err := getter.LoadCandidates(ctx, f.workspaceID, "resource") + require.NoError(t, err) + + var found bool + for _, c := range candidates { + if c.ID == f.resourceID { + found = true + assertDBURL(t, c.Raw) + } + } + assert.True(t, found, "fixture resource should be in candidates") + }) + + t.Run("LoadCandidates excludes variables from soft-deleted resources", func(t *testing.T) { + deletedID := uuid.New() + metadata, _ := json.Marshal(map[string]string{}) + _, err := pool.Exec( + ctx, + `INSERT INTO resource (id, version, name, kind, identifier, provider_id, workspace_id, config, metadata, deleted_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, '{}'::jsonb, $8::jsonb, NOW())`, + deletedID, + "v1", + "deleted-with-var", + "Server", + "urn:test:deleted-var", + f.providerID, + f.workspaceID, + metadata, + ) + require.NoError(t, err) + + _, err = pool.Exec(ctx, + `INSERT INTO resource_variable (resource_id, key, value) VALUES ($1, $2, $3)`, + deletedID, "ghost", []byte(`"should-not-appear"`)) + require.NoError(t, err) + + t.Cleanup(func() { + cleanCtx := context.Background() + _, _ = pool.Exec( + cleanCtx, + `DELETE FROM resource_variable WHERE resource_id = $1`, + deletedID, + ) + _, _ = pool.Exec(cleanCtx, `DELETE FROM resource WHERE id = $1`, deletedID) + }) + + candidates, err := getter.LoadCandidates(ctx, f.workspaceID, "resource") + require.NoError(t, err) + + for _, c := range candidates { + assert.NotEqual(t, deletedID, c.ID, + "soft-deleted resource should not appear in candidates") + if vars, ok := c.Raw["variables"].(map[string]oapi.Value); ok { + _, hasGhost := vars["ghost"] + assert.False(t, hasGhost, + "variables from soft-deleted resources should not leak into active ones") + } + } + }) +}