Skip to content

Commit 062825c

Browse files
feat: add cel filtering to v1 deployment list endpoint (#984)
1 parent e4f31e9 commit 062825c

20 files changed

Lines changed: 981 additions & 56 deletions

File tree

‎.github/workflows/apps-api.yaml‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,26 @@ jobs:
7777
- name: Seed test data
7878
run: pnpm -F @ctrlplane/e2e seed
7979

80+
- name: Setup Go
81+
uses: actions/setup-go@v5
82+
with:
83+
go-version-file: apps/workspace-engine/go.mod
84+
85+
- name: Build workspace-engine
86+
run: go build -o ./bin/workspace-engine .
87+
working-directory: apps/workspace-engine
88+
89+
- name: Start workspace-engine
90+
run: ./bin/workspace-engine &
91+
env:
92+
POSTGRES_URL: "postgresql://ctrlplane_test:test_password@localhost:5432/ctrlplane_test?sslmode=disable"
93+
PORT: "8081"
94+
working-directory: apps/workspace-engine
95+
96+
- name: Wait for workspace-engine
97+
run: |
98+
timeout 60s bash -c 'until curl -sf http://localhost:8081/healthz; do sleep 2; done'
99+
80100
- name: Start API server
81101
run: pnpm -F @ctrlplane/web-api dev &
82102
env:

‎apps/api/openapi/lib/openapi.libsonnet‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
name: 'cel',
6868
'in': 'query',
6969
required: false,
70+
allowReserved: true,
7071
description: 'CEL expression to filter the results',
7172
schema: {
7273
type: 'string',

‎apps/api/openapi/openapi.json‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4458,6 +4458,16 @@
44584458
"minimum": 0,
44594459
"type": "integer"
44604460
}
4461+
},
4462+
{
4463+
"allowReserved": true,
4464+
"description": "CEL expression to filter the results",
4465+
"in": "query",
4466+
"name": "cel",
4467+
"required": false,
4468+
"schema": {
4469+
"type": "string"
4470+
}
44614471
}
44624472
],
44634473
"responses": {
@@ -6939,6 +6949,7 @@
69396949
}
69406950
},
69416951
{
6952+
"allowReserved": true,
69426953
"description": "CEL expression to filter the results",
69436954
"in": "query",
69446955
"name": "cel",
@@ -7480,6 +7491,7 @@
74807491
}
74817492
},
74827493
{
7494+
"allowReserved": true,
74837495
"description": "CEL expression to filter the results",
74847496
"in": "query",
74857497
"name": "cel",

‎apps/api/openapi/paths/deployments.jsonnet‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ local openapi = import '../lib/openapi.libsonnet';
99
openapi.workspaceIdParam(),
1010
openapi.limitParam(),
1111
openapi.offsetParam(),
12+
openapi.celParam(),
1213
],
1314
responses: openapi.paginatedResponse(openapi.schemaRef('DeploymentAndSystems')),
1415
},

‎apps/api/src/routes/v1/workspaces/deployments.ts‎

Lines changed: 18 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
enqueueReleaseTargetsForDeployment,
1212
} from "@ctrlplane/db/reconcilers";
1313
import * as schema from "@ctrlplane/db/schema";
14+
import { getClientFor } from "@ctrlplane/workspace-engine-sdk";
1415

1516
// 1 hour
1617

@@ -66,54 +67,25 @@ const listDeployments: AsyncTypedHandler<
6667
const { workspaceId } = req.params;
6768
const limit = req.query.limit ?? 50;
6869
const offset = req.query.offset ?? 0;
70+
const cel = req.query.cel;
71+
72+
const { data, error, response } = await getClientFor().GET(
73+
"/v1/workspaces/{workspaceId}/deployments",
74+
{
75+
params: {
76+
path: { workspaceId },
77+
query: { limit, offset, cel },
78+
},
79+
},
80+
);
6981

70-
const [countResult] = await db
71-
.select({ total: count() })
72-
.from(schema.deployment)
73-
.where(eq(schema.deployment.workspaceId, workspaceId));
74-
75-
const total = countResult?.total ?? 0;
76-
77-
const deployments = await db
78-
.select()
79-
.from(schema.deployment)
80-
.where(eq(schema.deployment.workspaceId, workspaceId))
81-
.limit(limit)
82-
.offset(offset);
83-
84-
const deploymentIds = deployments.map((d) => d.id);
85-
86-
const systemLinks =
87-
deploymentIds.length > 0
88-
? await db
89-
.select({
90-
deploymentId: schema.systemDeployment.deploymentId,
91-
system: schema.system,
92-
})
93-
.from(schema.systemDeployment)
94-
.innerJoin(
95-
schema.system,
96-
eq(schema.systemDeployment.systemId, schema.system.id),
97-
)
98-
.where(inArray(schema.systemDeployment.deploymentId, deploymentIds))
99-
: [];
100-
101-
const systemsByDeploymentId = new Map<
102-
string,
103-
(typeof schema.system.$inferSelect)[]
104-
>();
105-
for (const link of systemLinks) {
106-
const arr = systemsByDeploymentId.get(link.deploymentId) ?? [];
107-
arr.push(link.system);
108-
systemsByDeploymentId.set(link.deploymentId, arr);
109-
}
110-
111-
const items = deployments.map((dep) => ({
112-
deployment: formatDeployment(dep),
113-
systems: (systemsByDeploymentId.get(dep.id) ?? []).map(formatSystem),
114-
}));
82+
if (error != null)
83+
throw new ApiError(
84+
error.error ?? "Failed to list deployments",
85+
response.status >= 400 && response.status < 500 ? response.status : 502,
86+
);
11587

116-
res.status(200).json({ items, total, limit, offset });
88+
res.status(200).json(data);
11789
};
11890

11991
const getDeployment: AsyncTypedHandler<

‎apps/api/src/types/openapi.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2959,6 +2959,8 @@ export interface operations {
29592959
limit?: number;
29602960
/** @description Number of items to skip */
29612961
offset?: number;
2962+
/** @description CEL expression to filter the results */
2963+
cel?: string;
29622964
};
29632965
header?: never;
29642966
path: {

‎apps/workspace-engine/oapi/openapi.json‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3395,6 +3395,104 @@
33953395
"summary": "Validate a resource selector"
33963396
}
33973397
},
3398+
"/v1/workspaces/{workspaceId}/deployments": {
3399+
"get": {
3400+
"description": "Returns a paginated list of deployments for a workspace. Optionally filter with a CEL expression using the \"deployment\" variable.",
3401+
"operationId": "listDeployments",
3402+
"parameters": [
3403+
{
3404+
"description": "ID of the workspace",
3405+
"in": "path",
3406+
"name": "workspaceId",
3407+
"required": true,
3408+
"schema": {
3409+
"type": "string"
3410+
}
3411+
},
3412+
{
3413+
"description": "Maximum number of items to return",
3414+
"in": "query",
3415+
"name": "limit",
3416+
"required": false,
3417+
"schema": {
3418+
"default": 50,
3419+
"maximum": 1000,
3420+
"minimum": 1,
3421+
"type": "integer"
3422+
}
3423+
},
3424+
{
3425+
"description": "Number of items to skip",
3426+
"in": "query",
3427+
"name": "offset",
3428+
"required": false,
3429+
"schema": {
3430+
"default": 0,
3431+
"minimum": 0,
3432+
"type": "integer"
3433+
}
3434+
},
3435+
{
3436+
"description": "CEL expression to filter the results",
3437+
"in": "query",
3438+
"name": "cel",
3439+
"required": false,
3440+
"schema": {
3441+
"type": "string"
3442+
}
3443+
}
3444+
],
3445+
"responses": {
3446+
"200": {
3447+
"content": {
3448+
"application/json": {
3449+
"schema": {
3450+
"properties": {
3451+
"items": {
3452+
"items": {
3453+
"$ref": "#/components/schemas/DeploymentAndSystems"
3454+
},
3455+
"type": "array"
3456+
},
3457+
"limit": {
3458+
"description": "Maximum number of items returned",
3459+
"type": "integer"
3460+
},
3461+
"offset": {
3462+
"description": "Number of items skipped",
3463+
"type": "integer"
3464+
},
3465+
"total": {
3466+
"description": "Total number of items available",
3467+
"type": "integer"
3468+
}
3469+
},
3470+
"required": [
3471+
"items",
3472+
"total",
3473+
"limit",
3474+
"offset"
3475+
],
3476+
"type": "object"
3477+
}
3478+
}
3479+
},
3480+
"description": "Paginated list of items"
3481+
},
3482+
"400": {
3483+
"content": {
3484+
"application/json": {
3485+
"schema": {
3486+
"$ref": "#/components/schemas/ErrorResponse"
3487+
}
3488+
}
3489+
},
3490+
"description": "Invalid request"
3491+
}
3492+
},
3493+
"summary": "List deployments"
3494+
}
3495+
},
33983496
"/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/state": {
33993497
"get": {
34003498
"operationId": "getReleaseTargetState",

‎apps/workspace-engine/oapi/spec/paths/deployment.jsonnet‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
local openapi = import '../lib/openapi.libsonnet';
22

33
{
4+
'/v1/workspaces/{workspaceId}/deployments': {
5+
get: {
6+
summary: 'List deployments',
7+
operationId: 'listDeployments',
8+
description: 'Returns a paginated list of deployments for a workspace. Optionally filter with a CEL expression using the "deployment" variable.',
9+
parameters: [
10+
openapi.workspaceIdParam(),
11+
openapi.limitParam(),
12+
openapi.offsetParam(),
13+
openapi.celParam(),
14+
],
15+
responses: openapi.paginatedResponse(openapi.schemaRef('DeploymentAndSystems'))
16+
+ openapi.badRequestResponse(),
17+
},
18+
},
419
'/v1/deployments/{deploymentId}/job-agents': {
520
get: {
621
summary: 'Get job agents matching a deployment selector',

‎apps/workspace-engine/pkg/db/convert.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@ func ToOapiDeployment(row Deployment) *oapi.Deployment {
1212
d := &oapi.Deployment{
1313
Id: row.ID.String(),
1414
Name: row.Name,
15+
Slug: row.Name,
1516
Metadata: row.Metadata,
1617
JobAgentConfig: oapi.JobAgentConfig(row.JobAgentConfig),
1718
JobAgentSelector: row.JobAgentSelector,
1819
}
1920
if row.Description != "" {
2021
d.Description = &row.Description
2122
}
23+
if row.ResourceSelector.Valid && row.ResourceSelector.String != "" {
24+
d.ResourceSelector = &row.ResourceSelector.String
25+
}
2226
return d
2327
}
2428

‎apps/workspace-engine/pkg/db/convert_test.go‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,37 +65,45 @@ func TestToOapiEnvironment_NilOptionalFields(t *testing.T) {
6565

6666
func TestToOapiDeployment(t *testing.T) {
6767
depID := uuid.New()
68+
celExpr := `resource.kind == "Service"`
6869

6970
row := Deployment{
70-
ID: depID,
71-
Name: "api-server",
72-
Description: "Main API deployment",
73-
Metadata: map[string]string{"team": "platform"},
71+
ID: depID,
72+
Name: "api-server",
73+
Description: "Main API deployment",
74+
ResourceSelector: pgtype.Text{String: celExpr, Valid: true},
75+
Metadata: map[string]string{"team": "platform"},
7476
}
7577

7678
dep := ToOapiDeployment(row)
7779

7880
assert.Equal(t, depID.String(), dep.Id)
7981
assert.Equal(t, "api-server", dep.Name)
82+
assert.Equal(t, "api-server", dep.Slug)
8083
assert.NotNil(t, dep.Description)
8184
assert.Equal(t, "Main API deployment", *dep.Description)
8285
assert.Equal(t, map[string]string{"team": "platform"}, dep.Metadata)
8386
assert.Empty(t, dep.JobAgentSelector, "empty selector when not set")
87+
require.NotNil(t, dep.ResourceSelector)
88+
assert.Equal(t, celExpr, *dep.ResourceSelector)
8489
}
8590

8691
func TestToOapiDeployment_NilOptionalFields(t *testing.T) {
8792
depID := uuid.New()
8893

8994
row := Deployment{
90-
ID: depID,
91-
Name: "worker",
92-
Metadata: map[string]string{},
95+
ID: depID,
96+
Name: "worker",
97+
ResourceSelector: pgtype.Text{Valid: false},
98+
Metadata: map[string]string{},
9399
}
94100

95101
dep := ToOapiDeployment(row)
96102

97103
assert.Equal(t, depID.String(), dep.Id)
104+
assert.Equal(t, "worker", dep.Slug)
98105
assert.Nil(t, dep.Description, "empty description should not be set")
106+
assert.Nil(t, dep.ResourceSelector, "invalid selector should not be set")
99107
}
100108

101109
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)