diff --git a/service/features/step_definitions/cli.steps.js b/service/features/step_definitions/cli.steps.js deleted file mode 100644 index 2c06065..0000000 --- a/service/features/step_definitions/cli.steps.js +++ /dev/null @@ -1,196 +0,0 @@ -import { Given } from "@cucumber/cucumber"; -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { mkdir, writeFile, readFile } from "node:fs/promises"; -import path from "node:path"; -import { trackNamedValue, substituteNamedValues } from "../support/named_values.js"; - -// Every fixture this suite needs (a registry, a BuildKit instance, a git -// server, TLS/SSH key material) is stood up the same way a human operator -// would from a terminal: helm/kubectl/openssl/ssh-keygen/git invocations, -// spelled out directly in each .feature file's Background. This is the one -// primitive all of that runs through. -function runShell(command) { - return new Promise((resolve, reject) => { - const child = spawn(command, { shell: "/bin/bash", stdio: ["ignore", "pipe", "pipe"] }); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (c) => (stdout += c)); - child.stderr.on("data", (c) => (stderr += c)); - child.on("error", reject); - child.on("exit", (code) => resolve({ command, code, stdout, stderr })); - }); -} - -// A Background re-declares the same setup commands before every single -// Scenario in a file (Gherkin has no "once per file" construct) - a human -// standing these fixtures up manually would run each command once, not -// redo it before every test they then run by hand. Memoizing by the exact -// (already-substituted) command text gives the same effect here: the -// first occurrence of a given command in this process actually runs it; -// every later occurrence - whether a later scenario in the same file or -// the same line repeated verbatim in a different file - reuses that first -// run's result instead of repeating real, sometimes slow (helm --wait) or -// non-idempotent (openssl/ssh-keygen generate fresh key material every -// invocation) work. -const commandCache = new Map(); -function runMemoized(command) { - if (!commandCache.has(command)) { - commandCache.set(command, runShell(command)); - } - return commandCache.get(command); -} - -// `helm upgrade --install ... -n ` is the -// only shape of command this suite uses to actually deploy something - -// tracked here, generically (just string matching, no chart/fixture -// knowledge), purely so the AfterAll teardown hook knows what to -// `helm uninstall` again at the end of the run. -export const helmInstalls = new Set(); -function trackHelmInstall(command) { - const match = command.match(/\bhelm\s+upgrade\s+--install\s+(\S+)\s+\S+.*?-n\s+(\S+)/); - if (match) helmInstalls.add(`${match[1]}::${match[2]}`); -} - -Given("{string} has been run", async function (commandTemplate) { - const command = substituteNamedValues(this, commandTemplate); - trackHelmInstall(command); - const result = await runMemoized(command); - this.lastCommand = result; - assert.equal( - result.code, - 0, - `command failed (exit ${result.code}): ${command}\n${result.stderr || result.stdout}`, - ); -}); - -// Unlike "{string} has been run", never reuses a cached result - for -// commands whose effect must actually happen again even when the literal -// text repeats (e.g. a scenario reverting a Deployment to a config an -// earlier scenario in the same file already used once - the underlying -// live state has moved on in between, so memoization would silently skip -// re-applying it). -Given("{string} has been run again", async function (commandTemplate) { - const command = substituteNamedValues(this, commandTemplate); - trackHelmInstall(command); - const result = await runShell(command); - this.lastCommand = result; - assert.equal( - result.code, - 0, - `command failed (exit ${result.code}): ${command}\n${result.stderr || result.stdout}`, - ); -}); - -// For scenarios where the command failing IS the thing under test (a -// `helm upgrade --install --wait` that must time out because the pod can -// never pass its readiness probe, e.g. a crash-looping bad config) - never -// memoized, since these commands' failure is scenario-specific and always -// needs to actually happen. -Given("{string} is run and is expected to fail", async function (commandTemplate) { - const command = substituteNamedValues(this, commandTemplate); - trackHelmInstall(command); - const result = await runShell(command); - this.lastCommand = result; - assert.notEqual(result.code, 0, `expected command to fail, but it succeeded: ${command}`); -}); - -// Faster, more direct alternative to "is run and is expected to fail" for -// the specific case of a config that crashes the process before -// `server.listen()` - polls the pod's own restart count instead of a -// `helm upgrade --install --wait` that's doomed to time out (the pod can -// never pass readiness), and captures the crashed instance's logs as -// "" in the same step. `` should NOT itself include --wait -// (it's expected to succeed - it just submits the manifest; the actual -// failure is the pod crash-looping, checked here instead). -Given("{string} causes the deployment to crash loop", async function (commandTemplate) { - const command = substituteNamedValues(this, commandTemplate); - trackHelmInstall(command); - const applyResult = await runShell(command); - assert.equal( - applyResult.code, - 0, - `command failed unexpectedly (exit ${applyResult.code}): ${command}\n${applyResult.stderr || applyResult.stdout}`, - ); - - // Resolves and pins the exact crashing pod's name (not just "deploy/X", - // which - if an old scenario's pod briefly lingers before Recreate fully - // clears it - can resolve to the WRONG pod's --previous log, silently - // reading a stale crash from an earlier scenario instead of this one's). - // Also excludes a pod with a deletionTimestamp set: right after - // triggering Recreate, the *previous* scenario's pod can still be the - // only match for a moment, already satisfying "name exists AND - // restarted >= 1" well before the new pod (this scenario's config) is - // even created - same race as health.feature's not-ready polling. - const namespace = substituteNamedValues(this, ""); - const pollResult = await runShell( - `timeout 20 bash -c 'while true; do DT=$(kubectl get pod -n ${namespace} -l app.kubernetes.io/instance=devcontainer-builder -o jsonpath={.items[0].metadata.deletionTimestamp} 2>/dev/null); NAME=$(kubectl get pod -n ${namespace} -l app.kubernetes.io/instance=devcontainer-builder -o jsonpath={.items[0].metadata.name} 2>/dev/null); RC=$(kubectl get pod -n ${namespace} -l app.kubernetes.io/instance=devcontainer-builder -o jsonpath={.items[0].status.containerStatuses[0].restartCount} 2>/dev/null); if [ -z "$DT" ] && [ -n "$NAME" ] && [ "\${RC:-0}" -ge 1 ]; then echo $NAME; break; fi; sleep 0.5; done'`, - ); - assert.equal(pollResult.code, 0, "expected the deployment to crash-loop (restart at least once), but it didn't within 20s"); - // Distinct from `lastCommand` (which "the service should fail to start" - // otherwise reads as its exit-code signal) - here the *poll succeeding* - // means "yes, it crash-looped", the opposite sense of a failed command. - this.crashLoopConfirmed = true; - const podName = pollResult.stdout.trim(); - - // Right at the instant restartCount first ticks up, the container - // runtime sometimes hasn't finished making the terminated container's - // log retrievable yet ("unable to retrieve container logs for - // containerd://...", not the actual crash output) - retry briefly - // rather than tie the poll above to a higher, runtime-dependent restart - // count just to dodge this window. - let logsResult; - for (let attempt = 0; attempt < 20; attempt++) { - logsResult = await runShell(`kubectl logs pod/${podName} -n ${namespace} --previous`); - if (logsResult.code === 0 && logsResult.stdout.trim().startsWith("{")) break; - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - trackNamedValue(this, "", logsResult.stdout + logsResult.stderr); -}); - -// Collapses "kubectl logs deploy/ ... has been run -// again" + "the command output is known as \"\"" into one step - -// specific to the devcontainer-builder-under-test's own release/chart -// name (fixed by this suite's naming convention), for scenarios that need -// to assert on its logs. Always fresh (like "has been run again"), since -// log content is inherently scenario-specific live state. -Given("the devcontainer-builder's logs are captured", async function () { - const namespace = substituteNamedValues(this, ""); - const result = await runShell(`kubectl logs deploy/devcontainer-builder-devcontainer-builder -n ${namespace}`); - this.lastCommand = result; - trackNamedValue(this, "", result.stdout + result.stderr); -}); - -// Same, but for a crash-looping pod's last terminated instance - see the -// service_startup_configuration.feature Background comment for why -// `--previous` (not plain `kubectl logs`) is the reliable choice there. -Given("the devcontainer-builder's crash logs are captured", async function () { - const namespace = substituteNamedValues(this, ""); - const result = await runShell(`kubectl logs deploy/devcontainer-builder-devcontainer-builder -n ${namespace} --previous`); - this.lastCommand = result; - trackNamedValue(this, "", result.stdout + result.stderr); -}); - -Given("the command output is known as {string}", function (name) { - trackNamedValue(this, name, this.lastCommand.stdout.trim()); -}); - -// Plain string aliasing - lets a Background spell out a convention once -// (e.g. a chart's `-..svc.cluster.local:` -// DNS name) and reference the short name everywhere after, the same way -// the "current HEAD short sha of ... is known as ..." family already -// aliases a computed value. -Given("the value {string} is known as {string}", function (value, name) { - trackNamedValue(this, name, substituteNamedValues(this, value)); -}); - -Given("the following is written to {string}:", async function (filePath, content) { - const resolvedPath = substituteNamedValues(this, filePath); - await mkdir(path.dirname(resolvedPath), { recursive: true }); - await writeFile(resolvedPath, substituteNamedValues(this, content)); -}); - -Given("the content of {string} is known as {string}", async function (filePath, name) { - const resolvedPath = substituteNamedValues(this, filePath); - trackNamedValue(this, name, (await readFile(resolvedPath, "utf8")).trim()); -}); diff --git a/service/features/step_definitions/common.steps.js b/service/features/step_definitions/common.steps.js deleted file mode 100644 index bff45cd..0000000 --- a/service/features/step_definitions/common.steps.js +++ /dev/null @@ -1,178 +0,0 @@ -import { When, Then } from "@cucumber/cucumber"; -import assert from "node:assert/strict"; -import { substituteNamedValues } from "../support/named_values.js"; - -// Every migrated feature file's devcontainer-builder Service resolves to -// the *same* DNS name/origin ("http://devcontainer-builder-devcontainer- -// builder..svc.cluster.local:8080"), and undici's global fetch -// agent pools keep-alive connections per origin across the whole -// cucumber-js process - not just within one file. A rollout between two -// scenarios (this file's own Background re-deploying with different -// config, or simply the next feature file in a combined `npm test` run) -// can tear down the specific pod a pooled connection was talking to, so -// reusing that socket for the next request fails outright ("fetch -// failed") rather than transparently falling back to a fresh connection. -// The raw `/dev/tcp` reachability poll every Background does runs over a -// separate, throwaway socket - it never "warms" the specific pooled -// connection fetch() will actually try to reuse, so it doesn't guard -// against this. `Connection: close` forces a fresh connection per -// request, trading a little latency for eliminating the whole class of -// stale-pooled-socket failures. -const NO_KEEPALIVE = { Connection: "close" }; - -// A combined `npm test` run (all ten files, one long-lived Node process) -// occasionally hits a bare `TypeError: fetch failed` - a connection-level -// failure (refused/reset), never a real HTTP response - that never -// reproduces when the same file runs alone. Every individual feature file -// is independently verified reliable in isolation; this only shows up -// under the combined run's sustained, hours-long real network load -// against a real cluster. Retrying purely the connection failure (not -// swallowing a real 4xx/5xx, which is a legitimate assertion target) is -// the correct fix for that class of flake without masking an actual bug. -async function fetchWithRetry(url, options, attempts = 3) { - for (let attempt = 1; ; attempt++) { - try { - return await fetch(url, options); - } catch (err) { - if (attempt >= attempts) throw err; - await new Promise((resolve) => setTimeout(resolve, 500 * attempt)); - } - } -} - -// Every scenario resolves a ""-style named value (from a -// Background/Scenario's "the value ... is known as ..." step, itself -// derived from a Service DNS name or, for a deliberately-not-ready pod, a -// direct pod IP) before ever sending a request - see health.feature and -// service_startup_configuration.feature for how that's built. Substituting -// first always yields a full "http://..." URL here. -When("I send a {word} request to {string}", async function (method, path) { - const url = substituteNamedValues(this, path); - const res = await fetchWithRetry(url, { method, headers: NO_KEEPALIVE }); - this.response = { status: res.status, body: await res.text() }; -}); - -// The doc string is forwarded as the request body - including deliberately -// invalid JSON in validation-negative scenarios - except for registered -// named values (see substituteNamedValues above), substituted with real -// per-run fixture addresses so a scenario can reference them in a request -// body the same way it does anywhere else. -When("I send a POST request to {string} with body:", async function (path, docString) { - const url = substituteNamedValues(this, path); - const body = substituteNamedValues(this, docString); - const res = await fetchWithRetry(url, { - method: "POST", - headers: { "Content-Type": "application/json", ...NO_KEEPALIVE }, - body, - }); - this.response = { status: res.status, body: await res.text() }; -}); - -Then("the service should start successfully", function () { - assert.equal(this.lastCommand?.code, 0, `expected the service to start, but the last command failed: ${this.lastCommand?.stderr || this.lastCommand?.stdout}`); -}); - -Then("the service should fail to start", function () { - if (this.crashLoopConfirmed !== undefined) { - assert.ok(this.crashLoopConfirmed, "expected the deployment to crash-loop, but it didn't"); - } else { - assert.notEqual(this.lastCommand?.code, 0, "expected the service to fail to start, but the last command succeeded"); - } -}); - -// Populated by "the devcontainer-builder's crash logs are captured" (or a -// "kubectl logs ... has been run again" + "the command output is known as -// \"\"" pair) - index.ts's fatal-exception handler prints one JSON -// line, so this decodes it and matches against the *actual* message/stack -// text rather than the raw JSON blob (which has every internal quote -// backslash-escaped, e.g. `\"tofu\"` - never matching a plain-text -// expectation like `"tofu"`). Falls back to raw text if the line isn't -// JSON. -Then("the startup error should mention {string}", function (substring) { - let message = this.namedValues?.[""]; - const jsonLine = message?.split("\n").find((line) => line.trim().startsWith("{")); - if (jsonLine) { - try { - const parsed = JSON.parse(jsonLine); - message = `${parsed.message ?? ""}\n${parsed.stack ?? ""}`; - } catch { - // not JSON - keep the raw text - } - } - assert.ok( - message?.includes(substring), - `expected startup error to mention ${JSON.stringify(substring)}, got: ${message}`, - ); -}); - -Then("the invalid entry should have been logged and skipped", function () { - assert.match(this.namedValues?.[""] ?? "", /skipping invalid .* config entry/); -}); - -Then("the response status should be {int}", function (expectedStatus) { - assert.equal(this.response.status, expectedStatus); -}); - -// Substitutes any namedValues declared by prior "... is known as ..." -// steps - lets a scenario assert against real fixture URLs/hosts and the -// fixture's real, content-derived HEAD sha instead of values it can't -// actually control, with every real query now an explicit prior step -// rather than invisible inside this assertion. -Then("the resolved image should be {string}", function (expected) { - const resolved = substituteNamedValues(this, expected); - const actual = JSON.parse(this.response.body); - assert.equal(actual.image, resolved); -}); - -Then("the response body should equal:", function (docString) { - assert.deepEqual(JSON.parse(this.response.body), JSON.parse(docString)); -}); - -// Partial match: every key/value pair in the doc string must be present -// (and equal) in the actual response body, which may have other fields too. -Then("the response body should include:", function (docString) { - const actual = JSON.parse(this.response.body); - const expected = JSON.parse(docString); - for (const [key, value] of Object.entries(expected)) { - assert.deepEqual(actual[key], value, `expected response body's "${key}" to equal ${JSON.stringify(value)}, got ${JSON.stringify(actual[key])}`); - } -}); - -Then("the response body should contain {string}", function (substring) { - assert.ok( - this.response.body.includes(substring), - `expected response body to contain ${JSON.stringify(substring)}, got: ${this.response.body}`, - ); -}); - -Then("the response body should not contain {string}", function (substring) { - assert.ok( - !this.response.body.includes(substring), - `expected response body NOT to contain ${JSON.stringify(substring)}, got: ${this.response.body}`, - ); -}); - -// git/docker/devcontainer subprocess output is run with stdio:"inherit" -// (see run() in build.ts), so it lands on the pod's own stdout/stderr, -// never in the HTTP response body (which only ever gets the generic -// " ... exited with code N" message) - use these (not "the response -// body should ...") to assert on what those tools actually printed, e.g. -// distinguishing a host-key-verification failure from an auth failure, or -// confirming a push's actual HTTP status. Populated by "the -// devcontainer-builder's logs are captured" (or a "kubectl logs ... has -// been run again" + "the command output is known as \"\"" pair). -Then("the service logs should contain {string}", function (substring) { - const logs = this.namedValues?.[""] ?? ""; - assert.ok( - logs.includes(substring), - `expected service logs to contain ${JSON.stringify(substring)}, got: ${logs}`, - ); -}); - -Then("the service logs should not contain {string}", function (substring) { - const logs = this.namedValues?.[""] ?? ""; - assert.ok( - !logs.includes(substring), - `expected service logs NOT to contain ${JSON.stringify(substring)}, got: ${logs}`, - ); -}); diff --git a/service/features/step_definitions/request_validation.steps.js b/service/features/step_definitions/request_validation.steps.js deleted file mode 100644 index 4209dd0..0000000 --- a/service/features/step_definitions/request_validation.steps.js +++ /dev/null @@ -1,22 +0,0 @@ -import { Then } from "@cucumber/cucumber"; -import assert from "node:assert/strict"; - -// Asserts only the validation boundary, not the eventual build outcome: a -// request that passes validation may still fail later (unresolvable -// registry, a real clone attempt against a nonexistent repo, ...) - this -// step only distinguishes "rejected before any build work started" from -// everything else. -Then("the request should pass validation", function () { - if (this.response.status !== 400) return; - - let parsed; - try { - parsed = JSON.parse(this.response.body); - } catch { - return; - } - - const isValidationError = - parsed && typeof parsed.error === "string" && parsed.error.startsWith("missing or invalid fields"); - assert.ok(!isValidationError, `expected request to pass validation, but got a validation error: ${this.response.body}`); -}); diff --git a/service/features/support/named_values.js b/service/features/support/named_values.js deleted file mode 100644 index 25e527a..0000000 --- a/service/features/support/named_values.js +++ /dev/null @@ -1,16 +0,0 @@ -// Extracted from common.steps.js so cli.steps.js can reuse the same -// substitution mechanism for its command/file-content templating. - -export function trackNamedValue(world, name, value) { - world.namedValues ??= {}; - world.namedValues[name] = value; -} - -// Sorted longest-name-first: some registered names are literal substrings -// of others (e.g. "registry-url" inside "authed-registry-url") - replacing -// the shorter one first would corrupt an unresolved occurrence of the -// longer one before it gets its own turn. -export function substituteNamedValues(world, text) { - const entries = Object.entries(world.namedValues ?? {}).sort((a, b) => b[0].length - a[0].length); - return entries.reduce((result, [name, value]) => result.replaceAll(name, value), text); -} diff --git a/service/features/support/optional/cluster_teardown_hooks.js b/service/features/support/optional/cluster_teardown_hooks.js deleted file mode 100644 index cdedaf8..0000000 --- a/service/features/support/optional/cluster_teardown_hooks.js +++ /dev/null @@ -1,26 +0,0 @@ -import { AfterAll } from "@cucumber/cucumber"; -import { spawn } from "node:child_process"; -import { helmInstalls } from "../../step_definitions/cli.steps.js"; - -// Deliberately NOT part of the shared features/support/*.js glob - only -// suites that actually run real "helm upgrade --install ..." commands -// (via cli.steps.js's "... has been run" step) import this file, for -// teardown. There's no fixture registry to consult any more: this just -// reverses whatever `helm upgrade --install ... -n ` -// commands this process actually ran (tracked generically by cli.steps.js, -// with no chart/fixture-specific knowledge), the same way a human who set -// several things up by hand would `helm uninstall` each of them when done. -AfterAll({ timeout: 120_000 }, async function () { - await Promise.all( - [...helmInstalls].map((key) => { - const [release, namespace] = key.split("::"); - return new Promise((resolve) => { - const child = spawn("helm", ["uninstall", release, "-n", namespace, "--wait", "--timeout", "60s"], { - stdio: "ignore", - }); - child.on("error", resolve); - child.on("exit", resolve); - }); - }), - ); -}); diff --git a/service/features/support/timeouts.js b/service/features/support/timeouts.js deleted file mode 100644 index e6d2d1e..0000000 --- a/service/features/support/timeouts.js +++ /dev/null @@ -1,21 +0,0 @@ -import { setDefaultTimeout } from "@cucumber/cucumber"; - -// Cucumber's built-in default (5000ms) is fine for pure validation-boundary -// checks, but too tight once a step triggers a real clone + BuildKit build + -// registry push - especially the first request in a run, which also has to -// cold-create the local `docker buildx` builder connection AND cold-pull -// whatever base image the fixture's devcontainer.json references onto the -// BuildKit worker node for the first time (observed: a first-ever pull of -// mcr.microsoft.com/devcontainers/base needed more than 30s). Applies -// globally (cheap for fast suites too - it only raises the ceiling before a -// genuinely hung step gets reported, it doesn't slow anything down). -// -// Must stay comfortably above the largest `--timeout` any step's own `helm -// upgrade --install --wait` uses (currently 180s, for test-git-server/ -// buildkit fixtures) - if Cucumber's own step timeout fires first, it only -// abandons the JS-level await; the actual `helm` child process it was -// waiting on keeps running in the background (orphaned), which then holds -// a release lock and makes the *next* scenario's `helm upgrade` on the same -// release fail with "another operation (install/upgrade/rollback) is in -// progress" - a confusing failure one scenario removed from its real cause. -setDefaultTimeout(240_000); diff --git a/service/features/support/world.js b/service/features/support/world.js deleted file mode 100644 index f4fc473..0000000 --- a/service/features/support/world.js +++ /dev/null @@ -1,10 +0,0 @@ -import { setWorldConstructor, World } from "@cucumber/cucumber"; - -export class DevcontainerBuilderWorld extends World { - constructor(options) { - super(options); - this.response = undefined; - } -} - -setWorldConstructor(DevcontainerBuilderWorld);