-
Notifications
You must be signed in to change notification settings - Fork 2
rollback and service logs get their happy paths, on a fixture that actually boots #208
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
base: main
Are you sure you want to change the base?
Changes from all commits
6b13edf
ae2409a
d7d9e78
7f6d4ae
bfb2e8b
368a1b9
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 |
|---|---|---|
|
|
@@ -8,11 +8,19 @@ | |
| * | ||
| * The blocks run in file order and share one service: it is deployed | ||
| * once, read by the middle blocks, then stopped and deleted at the end. | ||
| * Teardown must delete the deployment before the scratch project can go. | ||
| * The rollback block adds a second deployment, promotes it, and rolls | ||
| * back to the first, so the later blocks still act on a live first | ||
| * deployment. Teardown must delete every deployment before the scratch | ||
| * project can go. | ||
| */ | ||
| import { afterAll, expect, it } from "vitest"; | ||
|
|
||
| import { deleteDeployment, deployService } from "./deployed-service"; | ||
| import { | ||
| createDeployment, | ||
| deleteDeployment, | ||
| deployService, | ||
| } from "./deployed-service"; | ||
| import type { CliRun } from "./harness"; | ||
| import { scratchName } from "./harness"; | ||
| import { useScratchProject } from "./scratch"; | ||
| import { describeCommand } from "./suite"; | ||
|
|
@@ -25,6 +33,8 @@ let deployed: | |
| | { serviceId: string; serviceName: string; deploymentId: string } | ||
| | undefined; | ||
|
|
||
| let secondDeployment: { id: string; serviceName: string } | undefined; | ||
|
|
||
| function requireDeployed(): { | ||
| serviceId: string; | ||
| serviceName: string; | ||
|
|
@@ -45,6 +55,9 @@ interface DeploymentRow { | |
| } | ||
|
|
||
| afterAll(async () => { | ||
| if (secondDeployment !== undefined) { | ||
| await deleteDeployment(scratch, secondDeployment); | ||
| } | ||
| if (deployed !== undefined) { | ||
| await deleteDeployment(scratch, { | ||
| id: deployed.deploymentId, | ||
|
|
@@ -145,6 +158,65 @@ describeCommand("service deployment show", () => { | |
| }); | ||
| }); | ||
|
|
||
| describeCommand("service deployment rollback", () => { | ||
| it("rolls production back to the previously live deployment", async () => { | ||
| const existing = requireDeployed(); | ||
| // Rolling back needs somewhere to roll back from: a second | ||
| // deployment, promoted over the first. It is tracked for teardown | ||
| // before anything can throw, because `project remove` refuses while | ||
| // it exists. | ||
| const secondId = await createDeployment(existing.serviceId); | ||
| secondDeployment = { id: secondId, serviceName: existing.serviceName }; | ||
| await scratch.run([ | ||
| "service", | ||
| "deployment", | ||
| "start", | ||
| secondId, | ||
| "--service", | ||
| existing.serviceName, | ||
| ]); | ||
| await scratch.run([ | ||
| "service", | ||
| "deployment", | ||
| "promote", | ||
| secondId, | ||
| "--service", | ||
| existing.serviceName, | ||
| ]); | ||
|
|
||
| // No --to: the default target is the deployment before the live | ||
| // one, which is the first. --confirm must name that target. | ||
| const run = await scratch.run([ | ||
| "service", | ||
| "deployment", | ||
| "rollback", | ||
| "--service", | ||
| existing.serviceName, | ||
| "--confirm", | ||
| existing.deploymentId, | ||
| ]); | ||
| const rolledBack = run.envelope.result as { | ||
| readonly service: { readonly id: string }; | ||
| readonly deployment: DeploymentRow; | ||
| readonly previousLiveDeploymentId: string | null; | ||
| }; | ||
|
|
||
| expect(rolledBack.service.id).toBe(existing.serviceId); | ||
| expect(rolledBack.deployment.id).toBe(existing.deploymentId); | ||
| expect(rolledBack.deployment.live).toBe(true); | ||
| expect(rolledBack.previousLiveDeploymentId).toBe(secondId); | ||
|
|
||
| const shown = await scratch.run([ | ||
| "service", | ||
| "deployment", | ||
| "show", | ||
| existing.deploymentId, | ||
| ]); | ||
| const after = shown.envelope.result as { deployment: DeploymentRow }; | ||
| expect(after.deployment.live).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describeCommand("service open", () => { | ||
| it("answers with the service's URL rather than opening one", async () => { | ||
| const existing = requireDeployed(); | ||
|
|
@@ -169,6 +241,126 @@ describeCommand("service open", () => { | |
| }); | ||
| }); | ||
|
|
||
| /** The log lines of a `--json` run: `output` frames on the `logs` | ||
| * source's data channel, which is where the command reports each line | ||
| * the platform captured from the app. */ | ||
| function logLines(run: CliRun): string[] { | ||
| return run.stdout | ||
| .split("\n") | ||
| .map((line) => line.trim()) | ||
| .filter((line) => line.startsWith("{")) | ||
| .flatMap((line) => { | ||
| try { | ||
| return [ | ||
| JSON.parse(line) as { | ||
| kind?: string; | ||
| source?: string; | ||
| channel?: string; | ||
| line?: string; | ||
| }, | ||
| ]; | ||
| } catch { | ||
| return []; | ||
| } | ||
| }) | ||
| .filter( | ||
| (frame) => | ||
| frame.kind === "output" && | ||
| frame.source === "logs" && | ||
| frame.channel === "data" && | ||
| typeof frame.line === "string", | ||
| ) | ||
| .map((frame) => frame.line as string); | ||
| } | ||
|
|
||
| function sleep(milliseconds: number): Promise<void> { | ||
| return new Promise((resolve) => setTimeout(resolve, milliseconds)); | ||
| } | ||
|
|
||
| /** A fresh hostname does not serve on the first try — the edge is | ||
| * still setting up routing and TLS for it — so the request retries | ||
| * until the app answers. */ | ||
| async function serveProbeRequest(url: string, path: string): Promise<void> { | ||
| const deadline = Date.now() + 60_000; | ||
| let lastAnswer: number | string = "never reached"; | ||
| for (;;) { | ||
| try { | ||
| // biome-ignore lint/performance/noAwaitInLoops: each retry decides from the previous answer; waiting between requests is the point. | ||
| const served = await fetch(`${url}${path}`); | ||
| lastAnswer = served.status; | ||
| if (served.ok) { | ||
| return; | ||
| } | ||
| } catch (failure) { | ||
| lastAnswer = failure instanceof Error ? failure.message : "error"; | ||
| } | ||
| if (Date.now() > deadline) { | ||
| throw new Error( | ||
| `the deployment at ${url} never served the probe request; ` + | ||
| `last answer: ${lastAnswer}`, | ||
| ); | ||
| } | ||
| await sleep(3000); | ||
| } | ||
|
Comment on lines
+283
to
+304
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. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the declared Node.js target and existing repository conventions for
# bounded fetch requests before selecting AbortSignal.timeout or AbortController.
fd -HI -t f '^(package\.json|\.nvmrc|\.node-version|\.tool-versions)$' . \
-x sh -c 'echo "--- $1"; sed -n "1,180p" "$1"' sh {}
rg -n -C 4 --glob '*.ts' \
'AbortController|AbortSignal\.timeout|fetch\s*\(' \
packages/cli/e2e packages/cli/srcRepository: prisma/prisma-cli Length of output: 50373 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo '--- target function ---'
sed -n '260,315p' packages/cli/e2e/service-deployment.e2e.ts
echo '--- tracked runtime manifests ---'
git ls-files '*package.json' ':!:node_modules' | while IFS= read -r file; do
case "$file" in
package.json|packages/*/package.json)
echo "--- $file"
rg -n '"(engines|node|packageManager|type)"|AbortSignal|AbortController' "$file" || true
;;
esac
done
echo '--- bounded fetch conventions in tracked TypeScript ---'
rg -n -C 3 --glob '*.ts' --glob '!node_modules/**' \
'AbortController|AbortSignal\.timeout|fetch\s*\(' \
packages/cli/e2e packages/cli/src || true
echo '--- relevant imports and helper definitions ---'
rg -n -C 3 --glob '*.ts' --glob '!node_modules/**' \
'from "node:timers/promises"|function sleep|const sleep|fetch' \
packages/cli/e2e/service-deployment.e2e.ts packages/cli/e2e packages/cli/src || trueRepository: prisma/prisma-cli Length of output: 24700 Bound each probe request by the retry deadline. Pass 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /** Ingestion lags a request by some unspecified amount, so `service | ||
| * logs` is polled until `wantedLine` arrives (or the deadline passes, | ||
| * leaving the assertions to report what the last read held). */ | ||
| async function pollLogsForLine( | ||
| serviceName: string, | ||
| wantedLine: string, | ||
| ): Promise<string[]> { | ||
| const deadline = Date.now() + 90_000; | ||
| for (;;) { | ||
| // biome-ignore lint/performance/noAwaitInLoops: polling one page at a time is the point, as in the command's own --follow loop. | ||
| const run = await scratch.run([ | ||
| "service", | ||
| "logs", | ||
| "--service", | ||
| serviceName, | ||
| ]); | ||
| const lines = logLines(run); | ||
| if ( | ||
| lines.some((line) => line.includes(wantedLine)) || | ||
| Date.now() > deadline | ||
| ) { | ||
| return lines; | ||
| } | ||
| await sleep(5000); | ||
| } | ||
| } | ||
|
|
||
| describeCommand("service logs", () => { | ||
| it("reads back what the deployment wrote while serving a request", async () => { | ||
| const existing = requireDeployed(); | ||
| // Rollback made the first deployment live again, so it is what | ||
| // `service logs` reads by default. Serve one request against it so | ||
| // there is a line whose ingestion this run can be pinned to. | ||
| const shown = await scratch.run([ | ||
| "service", | ||
| "deployment", | ||
| "show", | ||
| existing.deploymentId, | ||
| ]); | ||
| const url = (shown.envelope.result as { deployment: DeploymentRow }) | ||
| .deployment.url; | ||
| expect(url).toMatch(HTTPS_URL); | ||
| await serveProbeRequest(url as string, "/e2e-logs-probe"); | ||
|
|
||
| const lines = await pollLogsForLine( | ||
| existing.serviceName, | ||
| "e2e-fixture served /e2e-logs-probe", | ||
| ); | ||
| expect(lines.some((line) => line.includes("e2e-fixture listening"))).toBe( | ||
| true, | ||
| ); | ||
| expect( | ||
| lines.some((line) => line.includes("e2e-fixture served /e2e-logs-probe")), | ||
| ).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| describeCommand("service deployment stop", () => { | ||
| it("stops the running deployment", async () => { | ||
| const existing = requireDeployed(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: prisma/prisma-cli
Length of output: 50373
🏁 Script executed:
Repository: prisma/prisma-cli
Length of output: 50373
🏁 Script executed:
Repository: prisma/prisma-cli
Length of output: 50373
Stop
secondDeploymentbefore deleting it.The delete API rejects running deployments, including the previous live deployment after rollback.
rollbackdoes not stopsecondDeployment, and teardown suppresses the failed delete. Stop it beforedeleteDeploymentso the deployment does not block scratch-project cleanup.🤖 Prompt for AI Agents