-
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
691ec4a
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 version before the scratch project can go. | ||
| * The rollback block adds a second version, promotes it, and rolls | ||
| * back to the first, so the later blocks still act on a live first | ||
| * version. Teardown must delete every version 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, | ||
|
|
@@ -142,6 +155,50 @@ describeCommand("service version show", () => { | |
| }); | ||
| }); | ||
|
|
||
| describeCommand("service version rollback", () => { | ||
| it("rolls production back to the previously live version", async () => { | ||
| const existing = requireDeployed(); | ||
| // Rolling back needs somewhere to roll back from: a second | ||
| // version, promoted over the first. It is tracked for teardown | ||
| // before anything can throw, because `project delete` refuses while | ||
| // it exists. | ||
| const secondId = await createDeployment(existing.serviceId); | ||
| secondDeployment = { id: secondId, serviceName: existing.serviceName }; | ||
| await scratch.run(["service", "version", "start", secondId]); | ||
| await scratch.run(["service", "version", "promote", secondId]); | ||
|
|
||
| // No --to: the default target is the version before the live | ||
| // one, which is the first. --confirm must name that target. | ||
| const run = await scratch.run([ | ||
| "service", | ||
| "version", | ||
| "rollback", | ||
| existing.serviceName, | ||
| "--confirm", | ||
| existing.deploymentId, | ||
| ]); | ||
| const rolledBack = run.envelope.result as { | ||
| readonly service: { readonly id: string }; | ||
| readonly version: DeploymentRow; | ||
| readonly previousLiveVersionId: string | null; | ||
| }; | ||
|
|
||
| expect(rolledBack.service.id).toBe(existing.serviceId); | ||
| expect(rolledBack.version.id).toBe(existing.deploymentId); | ||
| expect(rolledBack.version.live).toBe(true); | ||
| expect(rolledBack.previousLiveVersionId).toBe(secondId); | ||
|
|
||
| const shown = await scratch.run([ | ||
| "service", | ||
| "version", | ||
| "show", | ||
| existing.deploymentId, | ||
| ]); | ||
| const after = shown.envelope.result as { version: DeploymentRow }; | ||
| expect(after.version.live).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describeCommand("service open", () => { | ||
| it("answers with the service's URL rather than opening one", async () => { | ||
| const existing = requireDeployed(); | ||
|
|
@@ -161,6 +218,125 @@ 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}`, { | ||
| // A connection that answers nothing must not outlive the | ||
| // retry deadline. | ||
| signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())), | ||
| }); | ||
|
Comment on lines
+260
to
+270
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. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -e
file="packages/cli/e2e/service-version.e2e.ts"
printf '%s\n' '--- target ---'
sed -n '220,330p' "$file"
printf '%s\n' '--- relevant definitions and callers ---'
rg -n -C 5 'serveProbeRequest|pollLogsForLine|DeploymentRow|url:|service-version' "$file"Repository: prisma/prisma-cli Length of output: 9513 🏁 Script executed: #!/bin/bash
set -e
printf '%s\n' '--- probe fixture references ---'
rg -n -C 4 'e2e-logs-probe|e2e-fixture served|listening' packages/cli/e2e packages/cli 2>/dev/null | head -n 160
printf '%s\n' '--- URL construction contracts in nearby e2e code ---'
rg -n -C 3 'new URL\(path|fetch\(`\\$\\{url\\}|fetch\(.*url.*path|DeploymentRow' packages/cli/e2e | head -n 160Repository: prisma/prisma-cli Length of output: 6119 🏁 Script executed: node - <<'JS'
const base = "https://example.test/";
const path = "/e2e-logs-probe";
const concatenated = `${base}${path}`;
const request = new Request(concatenated);
const resolved = new URL(path, base);
console.log(JSON.stringify({
concatenated,
requestUrl: request.url,
requestPath: new URL(request.url).pathname,
resolved: resolved.href,
resolvedPath: resolved.pathname,
}));
JSRepository: prisma/prisma-cli Length of output: 541 Normalize the probe URL before the request. If Use 🤖 Prompt for AI Agents |
||
| 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); | ||
| } | ||
|
wmadden-electric marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** 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", 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 version wrote while serving a request", async () => { | ||
| const existing = requireDeployed(); | ||
| // Rollback made the first version 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", | ||
| "version", | ||
| "show", | ||
| existing.deploymentId, | ||
| ]); | ||
| const url = (shown.envelope.result as { version: DeploymentRow }).version | ||
| .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 version stop", () => { | ||
| it("stops the running version", async () => { | ||
| const existing = requireDeployed(); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.