Skip to content
24 changes: 18 additions & 6 deletions packages/cli/e2e/deployed-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,31 @@ function tarEntry(name: string, contents: string): Buffer {
}

/** The smallest thing the platform will run: an HTTP server that
* answers, so a started deployment reaches `running` rather than
* crash-looping. */
* answers, so a started deployment actually boots and serves rather
* than crash-looping. The layout is what Composer's archiver produces
* and the runner requires — files under `bundle/`, and a root
* `compute.manifest.json` naming the entrypoint; without the manifest
* the runner exits before ever starting the app. The server logs on
* startup and per request, so `service logs` has lines to read; the
* "e2e-fixture" markers are what the logs test looks for. */
function artifact(): Buffer {
const tar = Buffer.concat([
tarEntry(
"package.json",
"compute.manifest.json",
'{"manifestVersion":"1","entrypoint":"bundle/index.js"}',
),
tarEntry(
"bundle/package.json",
'{"name":"e2e-fixture","version":"1.0.0","type":"module","main":"index.js"}',
),
tarEntry(
"index.js",
"bundle/index.js",
'import{createServer}from"node:http";' +
'createServer((_,response)=>{response.writeHead(200);response.end("ok")})' +
".listen(process.env.PORT||3000);",
"createServer((request,response)=>{" +
'console.log("e2e-fixture served "+request.url);' +
'response.writeHead(200);response.end("ok")})' +
".listen(process.env.PORT||3000," +
'()=>console.log("e2e-fixture listening"));',
),
Buffer.alloc(1024), // two zero blocks end the archive
]);
Expand Down
179 changes: 177 additions & 2 deletions packages/cli/e2e/service-deployment.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**

Check failure on line 1 in packages/cli/e2e/service-deployment.e2e.ts

View workflow job for this annotation

GitHub Actions / Lint

format

File content differs from formatting output
* The deployment verbs, against a service this file deploys to.
*
* Every command here needs a deployment to act on, which is why they
Expand All @@ -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";
Expand All @@ -25,6 +33,8 @@
| { serviceId: string; serviceName: string; deploymentId: string }
| undefined;

let secondDeployment: { id: string; serviceName: string } | undefined;

function requireDeployed(): {
serviceId: string;
serviceName: string;
Expand All @@ -45,6 +55,9 @@
}

afterAll(async () => {
if (secondDeployment !== undefined) {
await deleteDeployment(scratch, secondDeployment);
}
if (deployed !== undefined) {
await deleteDeployment(scratch, {
id: deployed.deploymentId,
Expand Down Expand Up @@ -145,6 +158,65 @@
});
});

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();
Expand All @@ -169,6 +241,109 @@
});
});

/** 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);
}

describeCommand("service logs", () => {
it("reads back what the deployment wrote while serving a request", async () => {

Check failure on line 277 in packages/cli/e2e/service-deployment.e2e.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/complexity/noExcessiveCognitiveComplexity

Excessive complexity of 23 detected (max: 15).
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);
// 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.
const serveDeadline = Date.now() + 60_000;
let servedStatus: number | string = "never reached";
for (;;) {
try {
const served = await fetch(`${url}/e2e-logs-probe`);

Check failure on line 298 in packages/cli/e2e/service-deployment.e2e.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/performance/noAwaitInLoops

Avoid using await inside loops.
servedStatus = served.status;
if (served.ok) {
break;
}
} catch (failure) {
servedStatus = failure instanceof Error ? failure.message : "error";
}
if (Date.now() > serveDeadline) {
throw new Error(
`the deployment at ${url} never served the probe request; ` +
`last answer: ${servedStatus}`,
);
}
await new Promise((resolve) => setTimeout(resolve, 3000));
}

// Ingestion lags the request by some unspecified amount, so poll
// until the probe's line arrives rather than asserting on one read.
const deadline = Date.now() + 90_000;
let lines: string[] = [];
for (;;) {
const run = await scratch.run([
"service",
"logs",
"--service",
existing.serviceName,
]);

Check failure on line 325 in packages/cli/e2e/service-deployment.e2e.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/performance/noAwaitInLoops

Avoid using await inside loops.
lines = logLines(run);
if (
lines.some((line) => line.includes("e2e-fixture served /e2e-logs-probe"))
) {
break;
}
if (Date.now() > deadline) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 5000));
}

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);
});
});

describeCommand("service deployment stop", () => {
it("stops the running deployment", async () => {
const existing = requireDeployed();
Expand Down
13 changes: 0 additions & 13 deletions packages/cli/tests/e2e-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,6 @@ const EXCLUSIONS: Readonly<Record<string, string>> = {
* starts and promotes it through the CLI. That covered seven commands,
* and what is left needs something the deployment alone does not give.
*
* `service deployment rollback` needs a SECOND promoted deployment to
* roll back from. The fixture makes one; making two and promoting them
* in order is more run time and more teardown, and is the next thing to
* write.
*
* The five `service domain *` commands need a hostname whose DNS we
* control. With a promoted deployment in place, `service domain add`
* gets all the way to `SERVICE.DOMAIN_DNS_NOT_CONFIGURED` — "DNS
Expand All @@ -127,16 +122,8 @@ const EXCLUSIONS: Readonly<Record<string, string>> = {
*
* `build logs` needs a build, which comes from a git push or a Console
* action, not from anything the CLI can do.
*
* `service logs` arrived while this was being written, excluded because
* "only `composer deploy` produces" a deployment to read logs from. That
* is no longer true, so it is owed rather than excused — it needs a
* deployment that has actually served traffic, which is a little more
* than the fixture does today.
*/
const AWAITING_COVERAGE: readonly string[] = [
"service deployment rollback",
"service logs",
"service domain add",
"service domain show",
"service domain remove",
Expand Down
Loading