Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/workspace-engine/oapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -2166,6 +2166,12 @@
"format": "date-time",
"type": "string"
},
"variables": {
"additionalProperties": {
"$ref": "#/components/schemas/Value"
},
"type": "object"
},
"version": {
"type": "string"
},
Expand Down
4 changes: 4 additions & 0 deletions apps/workspace-engine/oapi/spec/schemas/entities.jsonnet
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ local openapi = import '../lib/openapi.libsonnet';
type: 'object',
additionalProperties: { type: 'string' },
},
variables: {
type: 'object',
additionalProperties: openapi.schemaRef('Value'),
},
},
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
2 changes: 1 addition & 1 deletion apps/workspace-engine/pkg/db/resource_variables.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/workspace-engine/pkg/oapi/oapi.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 42 additions & 0 deletions apps/workspace-engine/pkg/workspace/relationships/property.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,53 @@ func getResourceProperty(
return nil, err
}
return convertValue(value)
case "variables":
return getResourceVariableProperty(resource, propertyPath)
default:
return getPropertyReflection(resource, propertyPath)
}
Comment on lines +85 to 89

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New variables path handling for resources (getResourceVariableProperty) isn’t covered by the existing property extraction unit tests. Adding tests for variables/<key> and nested object traversal (plus key-missing / non-literal cases) would help prevent regressions.

Copilot uses AI. Check for mistakes.
}

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: %w",
propertyPath[1],
err,
)
}
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,
Expand Down
98 changes: 98 additions & 0 deletions apps/workspace-engine/pkg/workspace/relationships/property_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
})
Comment on lines +217 to 228

Copilot AI Apr 16, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new behavior of loading and attaching variables into resource candidates (via loadResourceVariablesByWorkspace/resourceRowToMap) isn’t covered by the existing PostgresGetter tests. Adding a test that inserts rows into resource_variable and asserts LoadCandidates includes a variables entry in EntityData.Raw would help prevent regressions and ensure DB-backed reference-path resolution works.

Copilot uses AI. Check for mistakes.
}
return candidates, nil
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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(),
Expand All @@ -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",
Expand Down
Loading
Loading