125 lines
6.0 KiB
TypeScript
125 lines
6.0 KiB
TypeScript
import { existsSync, readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, it } from "vitest";
|
|
import { BUNDLED_PLUGIN_CATALOG } from "../services/bundled-plugins.js";
|
|
|
|
/**
|
|
* Drift guard for the cloud image variant (Dockerfile `cloud` target).
|
|
*
|
|
* The cloud image builds the sandbox-provider plugins named in the
|
|
* CLOUD_BUNDLED_PLUGINS build arg so managed instances can auto-install
|
|
* them from the bundled catalog at boot. That contract spans three places
|
|
* that nothing else ties together: the Dockerfile ARG default, the docker
|
|
* workflow's build-arg, and BUNDLED_PLUGIN_CATALOG. A rename or removal in
|
|
* any one of them would otherwise surface only when the image build fails
|
|
* on master — or worse, as a silent "bundle not present" skip at instance
|
|
* boot.
|
|
*/
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
const dockerfile = readFileSync(path.join(repoRoot, "Dockerfile"), "utf8");
|
|
const workflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker.yml"), "utf8");
|
|
const cloudWorkflow = readFileSync(path.join(repoRoot, ".github", "workflows", "docker-cloud.yml"), "utf8");
|
|
|
|
function parseList(source: string, pattern: RegExp, label: string): string[] {
|
|
const match = source.match(pattern);
|
|
expect(match, `${label} must declare CLOUD_BUNDLED_PLUGINS`).toBeTruthy();
|
|
const names = (match?.[1] ?? "").trim().split(/\s+/).filter(Boolean);
|
|
expect(names.length, `${label} CLOUD_BUNDLED_PLUGINS must not be empty`).toBeGreaterThan(0);
|
|
return names;
|
|
}
|
|
|
|
const dockerfileDefault = parseList(
|
|
dockerfile,
|
|
/^ARG CLOUD_BUNDLED_PLUGINS="([^"]*)"/m,
|
|
"Dockerfile",
|
|
);
|
|
const workflowArg = parseList(
|
|
cloudWorkflow,
|
|
/^\s*CLOUD_BUNDLED_PLUGINS=(.*)$/m,
|
|
"docker workflow",
|
|
);
|
|
|
|
describe("cloud image bundled plugins", () => {
|
|
it("keeps the Dockerfile default and the workflow build-arg in sync", () => {
|
|
expect(workflowArg).toEqual(dockerfileDefault);
|
|
});
|
|
|
|
it.each([...new Set([...dockerfileDefault, ...workflowArg])])(
|
|
"plugin %s is buildable and resolvable by the auto-installer",
|
|
(name) => {
|
|
const dir = path.join(repoRoot, "packages", "plugins", "sandbox-providers", name);
|
|
expect(existsSync(dir), `${dir} must exist`).toBe(true);
|
|
expect(
|
|
existsSync(path.join(dir, "src", "manifest.ts")),
|
|
`${name} must have src/manifest.ts so the build produces dist/manifest.js`,
|
|
).toBe(true);
|
|
const packageJson = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8")) as {
|
|
scripts?: Record<string, string>;
|
|
};
|
|
expect(packageJson.scripts?.build, `${name} must have a build script`).toBeTruthy();
|
|
|
|
// The auto-installer resolves catalog keys to relative paths; a plugin
|
|
// baked into the image but absent from the catalog (or vice versa)
|
|
// can never be auto-installed.
|
|
const catalogEntry = BUNDLED_PLUGIN_CATALOG.find(
|
|
(entry) => entry.relativePath === `sandbox-providers/${name}`,
|
|
);
|
|
expect(catalogEntry, `${name} must be listed in BUNDLED_PLUGIN_CATALOG`).toBeTruthy();
|
|
},
|
|
);
|
|
|
|
it("pins the default image build to the production target", () => {
|
|
// The Dockerfile's final stage is `cloud`; without an explicit target
|
|
// the workflow's main build would silently publish the cloud variant
|
|
// to the self-hosted tags.
|
|
expect(workflow).toMatch(/^\s*target: production$/m);
|
|
});
|
|
|
|
it("publishes the cloud image in its own job with no needs coupling", () => {
|
|
const caller = workflow.split(" build-and-push-cloud:")[1]?.split(" promote_canary_channel:")[0];
|
|
expect(caller, "tag and manual builds must call the cloud workflow").toContain("uses: ./.github/workflows/docker-cloud.yml");
|
|
expect(caller, "the reusable caller must also remain independent of production").not.toMatch(/^\s*needs:/m);
|
|
// The reusable cloud workflow owns its job and SHA concurrency group.
|
|
// Production publication must not gate, delay, or skip the cloud build.
|
|
const jobsSection = cloudWorkflow.slice(cloudWorkflow.indexOf("\njobs:\n"));
|
|
const headers = [...jobsSection.matchAll(/^ {2}([\w-]+):[^\n]*$/gm)];
|
|
expect(
|
|
headers.length,
|
|
"docker-cloud.yml must declare a cloud build job under jobs:",
|
|
).toBeGreaterThanOrEqual(1);
|
|
|
|
// Locate the job block that carries the cloud build (target: cloud) and
|
|
// assert it declares no `needs:` — coupling it to another job would
|
|
// reintroduce the shared failure the split job exists to remove.
|
|
const cloudHeaderIdx = headers.findIndex((header, i) => {
|
|
const start = header.index ?? 0;
|
|
const end = headers[i + 1]?.index ?? jobsSection.length;
|
|
return jobsSection.slice(start, end).includes("target: cloud");
|
|
});
|
|
expect(cloudHeaderIdx, "one job must build the cloud target").toBeGreaterThanOrEqual(0);
|
|
const start = headers[cloudHeaderIdx].index ?? 0;
|
|
const end = headers[cloudHeaderIdx + 1]?.index ?? jobsSection.length;
|
|
const cloudJobBlock = jobsSection.slice(start, end);
|
|
expect(
|
|
cloudJobBlock,
|
|
"the cloud job must not couple to another job via needs:",
|
|
).not.toMatch(/^\s*needs:/m);
|
|
});
|
|
|
|
it("throttles the docker workflow with cancel-in-progress: false", () => {
|
|
// Concurrency is declared at the workflow (top) level so a single group
|
|
// spans the whole run, and cancel-in-progress is false so an in-flight
|
|
// image build always finishes — a newer push only supersedes the pending
|
|
// slot instead of killing the build that is already publishing.
|
|
expect(workflow).toMatch(/^concurrency:$/m);
|
|
// Pin the per-ref group key: without it the block could keep
|
|
// cancel-in-progress: false yet lose the group that scopes serialization
|
|
// to a single ref, silently changing which builds queue behind each other.
|
|
expect(workflow).toContain("group: docker-${{ github.ref }}");
|
|
expect(workflow).toContain("cancel-in-progress: false");
|
|
expect(workflow).not.toContain("cancel-in-progress: true");
|
|
});
|
|
});
|