-
Notifications
You must be signed in to change notification settings - Fork 18
feat: include aggregate verification status in release target state response #883
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,9 @@ | ||
| import { Router } from "express"; | ||
|
|
||
| import { and, count, desc, eq, inArray, sql } from "@ctrlplane/db"; | ||
| import { and, count, desc, eq, inArray } from "@ctrlplane/db"; | ||
| import { db } from "@ctrlplane/db/client"; | ||
| import * as schema from "@ctrlplane/db/schema"; | ||
| import { getClientFor } from "@ctrlplane/workspace-engine-sdk"; | ||
|
|
||
| import type { AsyncTypedHandler } from "../../../types/api.js"; | ||
| import { ApiError, asyncHandler } from "../../../types/api.js"; | ||
|
|
@@ -101,16 +102,6 @@ const buildReleaseResponse = async ( | |
| }; | ||
| }; | ||
|
|
||
| const getMetadataForJob = async (jobId: string) => { | ||
| const rows = await db | ||
| .select() | ||
| .from(schema.jobMetadata) | ||
| .where(eq(schema.jobMetadata.jobId, jobId)); | ||
| const metadata: Record<string, string> = {}; | ||
| for (const row of rows) metadata[row.key] = row.value; | ||
| return metadata; | ||
| }; | ||
|
|
||
| const getMetadataForJobs = async (jobIds: string[]) => { | ||
| if (jobIds.length === 0) return new Map<string, Record<string, string>>(); | ||
| const rows = await db | ||
|
|
@@ -126,106 +117,6 @@ const getMetadataForJobs = async (jobIds: string[]) => { | |
| return map; | ||
| }; | ||
|
|
||
| const computeReleaseTargetState = async ( | ||
| resourceId: string, | ||
| environmentId: string, | ||
| deploymentId: string, | ||
| ) => { | ||
| const [desiredReleaseRow, latestSuccessfulJob, latestJob] = await Promise.all( | ||
| [ | ||
| db | ||
| .select({ | ||
| desiredReleaseId: schema.releaseTargetDesiredRelease.desiredReleaseId, | ||
| }) | ||
| .from(schema.releaseTargetDesiredRelease) | ||
| .where( | ||
| and( | ||
| eq(schema.releaseTargetDesiredRelease.resourceId, resourceId), | ||
| eq(schema.releaseTargetDesiredRelease.environmentId, environmentId), | ||
| eq(schema.releaseTargetDesiredRelease.deploymentId, deploymentId), | ||
| ), | ||
| ) | ||
| .then((rows) => rows[0]), | ||
| db | ||
| .select({ job: schema.job, releaseId: schema.releaseJob.releaseId }) | ||
| .from(schema.job) | ||
| .innerJoin( | ||
| schema.releaseJob, | ||
| eq(schema.releaseJob.jobId, schema.job.id), | ||
| ) | ||
| .innerJoin( | ||
| schema.release, | ||
| eq(schema.release.id, schema.releaseJob.releaseId), | ||
| ) | ||
| .where( | ||
| and( | ||
| eq(schema.release.resourceId, resourceId), | ||
| eq(schema.release.environmentId, environmentId), | ||
| eq(schema.release.deploymentId, deploymentId), | ||
| eq(schema.job.status, "successful"), | ||
| sql`${schema.job.completedAt} IS NOT NULL`, | ||
| ), | ||
| ) | ||
| .orderBy(desc(schema.job.completedAt)) | ||
| .limit(1) | ||
| .then((rows) => rows[0]), | ||
| db | ||
| .select({ job: schema.job, releaseId: schema.releaseJob.releaseId }) | ||
| .from(schema.job) | ||
| .innerJoin( | ||
| schema.releaseJob, | ||
| eq(schema.releaseJob.jobId, schema.job.id), | ||
| ) | ||
| .innerJoin( | ||
| schema.release, | ||
| eq(schema.release.id, schema.releaseJob.releaseId), | ||
| ) | ||
| .where( | ||
| and( | ||
| eq(schema.release.resourceId, resourceId), | ||
| eq(schema.release.environmentId, environmentId), | ||
| eq(schema.release.deploymentId, deploymentId), | ||
| ), | ||
| ) | ||
| .orderBy(desc(schema.job.createdAt)) | ||
| .limit(1) | ||
| .then((rows) => rows[0]), | ||
| ], | ||
| ); | ||
|
|
||
| const state: Record<string, unknown> = {}; | ||
|
|
||
| if (desiredReleaseRow?.desiredReleaseId != null) { | ||
| const release = await db | ||
| .select() | ||
| .from(schema.release) | ||
| .where(eq(schema.release.id, desiredReleaseRow.desiredReleaseId)) | ||
| .then((rows) => rows[0]); | ||
| if (release != null) | ||
| state.desiredRelease = await buildReleaseResponse(release); | ||
| } | ||
|
|
||
| if (latestSuccessfulJob != null) { | ||
| const release = await db | ||
| .select() | ||
| .from(schema.release) | ||
| .where(eq(schema.release.id, latestSuccessfulJob.releaseId)) | ||
| .then((rows) => rows[0]); | ||
| if (release != null) | ||
| state.currentRelease = await buildReleaseResponse(release); | ||
| } | ||
|
|
||
| if (latestJob != null) { | ||
| const metadata = await getMetadataForJob(latestJob.job.id); | ||
| state.latestJob = { | ||
| job: toJobResponseForState(latestJob.job, latestJob.releaseId, metadata), | ||
| verifications: [], | ||
| }; | ||
| } | ||
|
|
||
| return state; | ||
| }; | ||
|
|
||
| const getReleaseTargetJobs: AsyncTypedHandler< | ||
| "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/jobs", | ||
| "get" | ||
|
|
@@ -327,37 +218,29 @@ const getReleaseTargetState: AsyncTypedHandler< | |
| "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/state", | ||
| "get" | ||
| > = async (req, res) => { | ||
| const { releaseTargetKey } = req.params; | ||
| const { resourceId, environmentId, deploymentId } = | ||
| parseReleaseTargetKey(releaseTargetKey); | ||
| const { workspaceId, releaseTargetKey } = req.params; | ||
|
|
||
| const rtExists = await db | ||
| .select({ id: schema.releaseTargetDesiredRelease.id }) | ||
| .from(schema.releaseTargetDesiredRelease) | ||
| .where( | ||
| and( | ||
| eq(schema.releaseTargetDesiredRelease.resourceId, resourceId), | ||
| eq(schema.releaseTargetDesiredRelease.environmentId, environmentId), | ||
| eq(schema.releaseTargetDesiredRelease.deploymentId, deploymentId), | ||
| ), | ||
| ) | ||
| .then((rows) => rows[0]); | ||
|
|
||
| if (rtExists == null) throw new ApiError("Release target not found", 404); | ||
|
|
||
| const state = await computeReleaseTargetState( | ||
| resourceId, | ||
| environmentId, | ||
| deploymentId, | ||
| const { data, error, response } = await getClientFor(workspaceId).GET( | ||
| "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/state", | ||
| { | ||
| params: { path: { workspaceId, releaseTargetKey } }, | ||
| }, | ||
|
Comment on lines
+221
to
+227
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The public handler still accepts
Comment on lines
+223
to
+227
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🤖 Prompt for AI Agents |
||
| ); | ||
|
|
||
| res.status(200).json(state); | ||
| if (error != null) | ||
| throw new ApiError( | ||
| error.error ?? "Failed to get release target state", | ||
| response.status >= 400 && response.status < 500 ? response.status : 502, | ||
| ); | ||
|
|
||
| res.status(200).json(data); | ||
| }; | ||
|
|
||
| const getReleaseTargetStates: AsyncTypedHandler< | ||
| "/v1/workspaces/{workspaceId}/release-targets/state", | ||
| "post" | ||
| > = async (req, res) => { | ||
| const { workspaceId } = req.params; | ||
| const { limit: rawLimit, offset: rawOffset } = req.query; | ||
| const { deploymentId, environmentId } = req.body; | ||
|
|
||
|
|
@@ -385,18 +268,27 @@ const getReleaseTargetStates: AsyncTypedHandler< | |
|
|
||
| const items = await Promise.all( | ||
| releaseTargets.map(async (rt) => { | ||
| const state = await computeReleaseTargetState( | ||
| rt.resourceId, | ||
| rt.environmentId, | ||
| rt.deploymentId, | ||
| const releaseTargetKey = `${rt.resourceId}-${rt.environmentId}-${rt.deploymentId}`; | ||
| const { data, error, response } = await getClientFor(workspaceId).GET( | ||
| "/v1/workspaces/{workspaceId}/release-targets/{releaseTargetKey}/state", | ||
| { params: { path: { workspaceId, releaseTargetKey } } }, | ||
| ); | ||
|
|
||
| if (error != null) | ||
| throw new ApiError( | ||
| error.error ?? "Failed to get release target state", | ||
| response.status >= 400 && response.status < 500 | ||
| ? response.status | ||
| : 502, | ||
| ); | ||
|
|
||
| return { | ||
| releaseTarget: { | ||
| resourceId: rt.resourceId, | ||
| environmentId: rt.environmentId, | ||
| deploymentId: rt.deploymentId, | ||
| }, | ||
| state, | ||
| state: data, | ||
| }; | ||
| }), | ||
| ); | ||
|
Comment on lines
269
to
294
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Check if workspace-engine has or could have a batch endpoint for release target states
# Search for batch-related endpoints in workspace-engine
rg -n 'release.?target' --glob '*.go' --glob '*.jsonnet' apps/workspace-engine/ | grep -i 'batch\|bulk\|multi' || echo "No batch endpoint found"
# Check the current endpoint implementation
rg -nA 5 'GetReleaseTargetState' apps/workspace-engine/Repository: ctrlplanedev/ctrlplane Length of output: 3184 🏁 Script executed: # First, let's verify the actual code at those lines
head -300 apps/api/src/routes/v1/workspaces/release-targets.ts | tail -60Repository: ctrlplanedev/ctrlplane Length of output: 1817 🏁 Script executed: # Check the SDK client initialization and timeout configuration
rg -n 'getClientFor|timeout|HttpClient' apps/api/src/routes/v1/workspaces/release-targets.ts -B 3 -A 3Repository: ctrlplanedev/ctrlplane Length of output: 1162 🏁 Script executed: # Search more broadly for batch/bulk endpoints in the API
rg -n 'batch|bulk|multi' apps/workspace-engine/ --glob '*.go' | grep -i 'release.*target\|endpoint\|handler' | head -20Repository: ctrlplanedev/ctrlplane Length of output: 462 🏁 Script executed: # Check if there's a similar pattern elsewhere in the codebase that might indicate this is intentional
rg -n 'Promise.all.*map.*async.*GET' apps/api/src/routes/ -A 5 | head -40Repository: ctrlplanedev/ctrlplane Length of output: 48 🏁 Script executed: # Check the workspace-engine SDK client configuration for timeouts
find apps -name '*workspace-engine-sdk*' -o -name '*sdk*' | grep -i workspace | head -20Repository: ctrlplanedev/ctrlplane Length of output: 48 🏁 Script executed: # Look for the SDK client source/types
rg -l 'workspace-engine-sdk' apps/api/src --glob '*.ts' | head -5Repository: ctrlplanedev/ctrlplane Length of output: 188 🏁 Script executed: # Check if there's any timeout or concurrency configuration in the SDK
rg -n 'timeout|Timeout|concurrent|Concurrent' apps/api/src/routes/v1/workspaces/release-targets.ts -B 2 -A 2Repository: ctrlplanedev/ctrlplane Length of output: 48 🏁 Script executed: # Check the workspace-engine API definition to understand its capacity/design
find apps/workspace-engine -name '*.yaml' -o -name '*.yml' -o -name '*.json' | xargs grep -l 'release.*target\|openapi' 2>/dev/null | head -5Repository: ctrlplanedev/ctrlplane Length of output: 144 🏁 Script executed: # Search for any load testing, performance notes, or architectural decisions about the GetReleaseTargetState endpoint
rg -n 'GetReleaseTargetState|release.*target.*state' apps/workspace-engine/svc/http/server/openapi/release_targets/ -A 10 | head -50Repository: ctrlplanedev/ctrlplane Length of output: 1179 🏁 Script executed: # Look at the SDK package to understand timeout behavior
find . -path '*/workspace-engine-sdk*' -type f | head -20Repository: ctrlplanedev/ctrlplane Length of output: 389 🏁 Script executed: # Check workspace-engine-sdk package.json or exports
rg -l 'workspace-engine-sdk' packages/ --type json | head -5Repository: ctrlplanedev/ctrlplane Length of output: 135 🏁 Script executed: # Look for the actual SDK source
fd -e ts -e tsx 'workspace-engine-sdk' packages/ | head -10Repository: ctrlplanedev/ctrlplane Length of output: 48 🏁 Script executed: # Check what packages/api depends on or exports for workspace-engine-sdk
cat packages/api/package.json | grep -A 5 -B 5 workspace-engineRepository: ctrlplanedev/ctrlplane Length of output: 123 🏁 Script executed: # Search for how getClientFor is implemented
rg -n 'export.*getClientFor\|function getClientFor' packages/ apps/ -A 10 | head -60Repository: ctrlplanedev/ctrlplane Length of output: 48 🏁 Script executed: # Check the SDK source code
cat packages/workspace-engine-sdk/src/index.tsRepository: ctrlplanedev/ctrlplane Length of output: 978 🏁 Script executed: # Check the SDK config
cat packages/workspace-engine-sdk/src/config.tsRepository: ctrlplanedev/ctrlplane Length of output: 344 🏁 Script executed: # Look for the actual client implementation and any timeout settings
rg -n 'timeout|Timeout' packages/workspace-engine-sdk/src/ -A 2 -B 2Repository: ctrlplanedev/ctrlplane Length of output: 1371 🏁 Script executed: # Check if there are any architectural decisions or comments about the N+1 pattern
rg -n 'N\+1|n\+1|batch' apps/api/src/routes/v1/workspaces/release-targets.ts -B 5 -A 5Repository: ctrlplanedev/ctrlplane Length of output: 48 🏁 Script executed: # Check if there's pagination or limits already in place
rg -n 'limit.*50\|limitVal' apps/api/src/routes/v1/workspaces/release-targets.ts -B 2 -A 2Repository: ctrlplanedev/ctrlplane Length of output: 48 🏁 Script executed: # Check if there's any documentation about workspace-engine capacity or performance
rg -n 'capacity|concurrent|load|perform' apps/workspace-engine/README.md apps/workspace-engine/docs/ 2>/dev/null | head -20Repository: ctrlplanedev/ctrlplane Length of output: 326 🏁 Script executed: # Verify if this endpoint is frequently called and if there are any issues filed about performance
git log --oneline -n 50 -- apps/api/src/routes/v1/workspaces/release-targets.ts 2>/dev/null | head -20Repository: ctrlplanedev/ctrlplane Length of output: 98 🏁 Script executed: # Check if timeout is being set anywhere in the API routes
rg -n 'timeout' apps/api/src/routes/v1/workspaces/ --glob '*.ts' -B 2 -A 2Repository: ctrlplanedev/ctrlplane Length of output: 48 🏁 Script executed: # See what options are available in ClientOptions for openapi-fetch
rg -n 'ClientOptions|interface.*Options' packages/workspace-engine-sdk/src/ -A 5Repository: ctrlplanedev/ctrlplane Length of output: 1036 🏁 Script executed: # Check openapi-fetch documentation or types in node_modules if available
rg -n 'ClientOptions|export.*interface' packages/workspace-engine-sdk/node_modules/openapi-fetch 2>/dev/null | head -20Repository: ctrlplanedev/ctrlplane Length of output: 48 🏁 Script executed: # Look for any timeout handling in the fetch wrapper
cat packages/workspace-engine-sdk/src/index.ts | grep -A 10 "fetch:"Repository: ctrlplanedev/ctrlplane Length of output: 355 🏁 Script executed: # Check if there's any middleware or request interceptor that handles timeouts
rg -n 'AbortController|timeout|abort' apps/api/src/ --glob '*.ts' | grep -i 'client\|fetch\|request' | head -20Repository: ctrlplanedev/ctrlplane Length of output: 175 🏁 Script executed: # Verify the actual endpoint route handler in workspace-engine to understand its performance characteristics
cat apps/workspace-engine/svc/http/server/openapi/release_targets/server.go | head -100Repository: ctrlplanedev/ctrlplane Length of output: 2795 N+1 HTTP calls pattern creates unnecessary load on workspace-engine. This implementation makes one HTTP call to workspace-engine for each release target (up to 50 by default). With Additionally, if any single call fails, Consider:
🤖 Prompt for AI Agents |
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1621,6 +1621,26 @@ export interface components { | |
| desiredRelease?: components["schemas"]["Release"]; | ||
| latestJob?: components["schemas"]["Job"]; | ||
| }; | ||
| ReleaseTargetStateResponse: { | ||
| currentRelease?: components["schemas"]["Release"]; | ||
| desiredRelease?: components["schemas"]["Release"]; | ||
| latestJob?: { | ||
| job: components["schemas"]["Job"]; | ||
| verifications: { | ||
| /** Format: date-time */ | ||
| createdAt: string; | ||
| id: string; | ||
| jobId: string; | ||
| message?: string; | ||
| metrics: components["schemas"]["VerificationMetricStatus"][]; | ||
| /** | ||
| * @description Computed aggregate status of this verification | ||
| * @enum {string} | ||
| */ | ||
| status: "passed" | "running" | "failed"; | ||
| }[]; | ||
| }; | ||
| }; | ||
|
Comment on lines
+1624
to
+1643
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This type is added here, but 🤖 Prompt for AI Agents |
||
| ReleaseTargetWithState: { | ||
| releaseTarget: components["schemas"]["ReleaseTarget"]; | ||
| state: components["schemas"]["ReleaseTargetState"]; | ||
|
|
@@ -4451,7 +4471,7 @@ export interface operations { | |
| [name: string]: unknown; | ||
| }; | ||
| content: { | ||
| "application/json": components["schemas"]["ReleaseTargetState"]; | ||
| "application/json": components["schemas"]["ReleaseTargetStateResponse"]; | ||
| }; | ||
| }; | ||
| /** @description Invalid request */ | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.