From c35e66d966ace6cf3eafabcc49817b0e5bdf9a7b Mon Sep 17 00:00:00 2001 From: kjgbot Date: Tue, 21 Jul 2026 10:44:41 +0200 Subject: [PATCH] feat: deploy repository-owned verification stacks --- .github/workflows/ci.yml | 26 + docs/verification-stack.md | 60 ++ package-lock.json | 35 + package.json | 6 +- schemas/verification-stack.schema.json | 253 +++++++ src/__tests__/dist-entrypoints.test.ts | 3 + src/environments/kubernetes-command.ts | 112 ++++ src/environments/kubernetes-provider.test.ts | 51 ++ src/environments/kubernetes-provider.ts | 131 ++++ src/environments/stack-deployer.test.ts | 235 +++++++ src/environments/stack-deployer.ts | 621 ++++++++++++++++++ src/environments/stack-descriptor.test.ts | 139 ++++ src/environments/stack-descriptor.ts | 346 ++++++++++ src/index.ts | 54 ++ src/ports/environment.ts | 47 ++ src/ports/index.ts | 7 + test/e2e/stack-deployer.e2e.ts | 103 +++ test/fixtures/verification-stack/stack.yaml | 100 +++ .../verification-stack.yaml | 50 ++ 19 files changed, 2378 insertions(+), 1 deletion(-) create mode 100644 docs/verification-stack.md create mode 100644 schemas/verification-stack.schema.json create mode 100644 src/environments/kubernetes-command.ts create mode 100644 src/environments/kubernetes-provider.test.ts create mode 100644 src/environments/kubernetes-provider.ts create mode 100644 src/environments/stack-deployer.test.ts create mode 100644 src/environments/stack-deployer.ts create mode 100644 src/environments/stack-descriptor.test.ts create mode 100644 src/environments/stack-descriptor.ts create mode 100644 src/ports/environment.ts create mode 100644 test/e2e/stack-deployer.e2e.ts create mode 100644 test/fixtures/verification-stack/stack.yaml create mode 100644 test/fixtures/verification-stack/verification-stack.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0dc1ce0..627b335a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,3 +56,29 @@ jobs: - name: Verify package contents run: npm pack --dry-run + + verification-stack-e2e: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Create kind cluster + uses: helm/kind-action@v1.14.0 + with: + cluster_name: factory-stack-e2e + + - name: Deploy and verify descriptor stack + run: npm run test:stack-e2e diff --git a/docs/verification-stack.md b/docs/verification-stack.md new file mode 100644 index 00000000..c059901e --- /dev/null +++ b/docs/verification-stack.md @@ -0,0 +1,60 @@ +# Verification-stack descriptor + +Repositories declare their complete verification environment in +`.factory/verification-stack.yaml`. Factory validates the file, deploys it into +an isolated environment, waits for every declared service probe, runs seed +steps, and returns local HTTP endpoint URLs for E2E and load stages. + +The published JSON Schema is available as +`@agent-relay/factory/verification-stack.schema.json`. + +```yaml +apiVersion: factory.agentworkforce.dev/v1alpha1 +kind: VerificationStack +name: my-app +source: + type: manifests # helm, kustomize, manifests, or docker-compose + paths: [deploy/stack.yaml] +secrets: + - name: database + data: + PASSWORD: + ref: resource://production-like/database-password +services: + - name: api + workload: + kind: deployment + readiness: + type: http + port: 8080 + path: /health + timeoutSeconds: 120 +seeds: + - type: exec + name: seed-api + service: api + command: [node, scripts/seed.js] +endpoints: + - name: api + service: api + port: 8080 + path: /health +``` + +Secret and config entries contain only opaque references. The caller supplies a +`VerificationStackReferenceResolver`; a missing required reference aborts before +the stack source is applied. Inline values are rejected by the typed loader. + +`resolveVerificationStackDescriptor({ repoPath, ref?, descriptorPath? })` +selects the default descriptor or a repository-relative override. Passing `ref` +loads the descriptor committed at that branch or SHA instead of the working +tree version. On deployment, Factory materializes stack assets from that same +commit so a dirty or differently checked-out working tree cannot change the +selected stack. + +`VerificationStackDeployer.deploy(descriptor, environment)` supports local or +OCI/HTTP Helm charts, kustomize directories, raw Kubernetes manifests, and +Docker Compose through `kompose`. It waits for Deployment, StatefulSet, or +DaemonSet rollout plus each HTTP, TCP, or exec probe. All waits and seed steps +have descriptor-bounded timeouts. The returned deployment owns any local +port-forward processes; call `dispose()` before destroying the environment. diff --git a/package-lock.json b/package-lock.json index 65285cd8..20a532d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@types/proper-lockfile": "^4.1.4", "esbuild": "^0.28.1", "tsc-alias": "^1.8.17", + "tsx": "^4.20.6", "typescript": "^5.7.0", "vitest": "^4.1.8" }, @@ -6897,6 +6898,40 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", diff --git a/package.json b/package.json index ffe6b1aa..038ee638 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "types": "./dist/index.d.ts", "files": [ "dist", + "schemas", "bin/factory.mjs", "scripts/factory-canary.sh", "scripts/com.agentrelay.factory-canary.plist.example", @@ -56,7 +57,8 @@ "./hosted": { "types": "./dist/hosted/index.d.ts", "import": "./dist/hosted/index.js" - } + }, + "./verification-stack.schema.json": "./schemas/verification-stack.schema.json" }, "publishConfig": { "access": "public" @@ -65,6 +67,7 @@ "build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json", "featuremap:check": "node bin/factory.mjs featuremap check", "test": "vitest run", + "test:stack-e2e": "tsx test/e2e/stack-deployer.e2e.ts", "verify:e2e": "node scripts/verify-packed-e2e.mjs", "factory-build": "factory-build/run-factory-build.sh" }, @@ -90,6 +93,7 @@ "@types/node": "^22.10.2", "@types/proper-lockfile": "^4.1.4", "esbuild": "^0.28.1", + "tsx": "^4.20.6", "tsc-alias": "^1.8.17", "typescript": "^5.7.0", "vitest": "^4.1.8" diff --git a/schemas/verification-stack.schema.json b/schemas/verification-stack.schema.json new file mode 100644 index 00000000..fb496e9a --- /dev/null +++ b/schemas/verification-stack.schema.json @@ -0,0 +1,253 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentworkforce.github.io/factory/schemas/verification-stack.schema.json", + "title": "Factory verification stack", + "description": "Repository-owned contract for deploying and health-gating a verification stack.", + "type": "object", + "additionalProperties": false, + "required": ["apiVersion", "kind", "name", "source", "services"], + "properties": { + "apiVersion": { "const": "factory.agentworkforce.dev/v1alpha1" }, + "kind": { "const": "VerificationStack" }, + "name": { "$ref": "#/$defs/dnsName" }, + "source": { "$ref": "#/$defs/source" }, + "secrets": { + "type": "array", + "default": [], + "items": { "$ref": "#/$defs/referenceGroup" } + }, + "config": { + "type": "array", + "default": [], + "items": { "$ref": "#/$defs/referenceGroup" } + }, + "services": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/service" } + }, + "seeds": { + "type": "array", + "default": [], + "items": { "$ref": "#/$defs/seed" } + }, + "endpoints": { + "type": "array", + "default": [], + "items": { "$ref": "#/$defs/endpoint" } + } + }, + "$defs": { + "dnsName": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$" + }, + "relativePath": { + "type": "string", + "minLength": 1, + "pattern": "^(?![/\\\\])(?![A-Za-z]:)(?!.*(?:^|[/\\\\])\\.\\.(?:[/\\\\]|$)).+$", + "description": "Path relative to the repository root. Absolute paths and '..' segments are forbidden." + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "source": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["type", "chart"], + "properties": { + "type": { "const": "helm" }, + "chart": { "type": "string", "minLength": 1 }, + "release": { "$ref": "#/$defs/dnsName" }, + "valuesFiles": { + "type": "array", + "default": [], + "items": { "$ref": "#/$defs/relativePath" } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "path"], + "properties": { + "type": { "const": "kustomize" }, + "path": { "$ref": "#/$defs/relativePath" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "paths"], + "properties": { + "type": { "const": "manifests" }, + "paths": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/relativePath" } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "path"], + "properties": { + "type": { "const": "docker-compose" }, + "path": { "$ref": "#/$defs/relativePath" } + } + } + ] + }, + "reference": { + "type": "object", + "additionalProperties": false, + "required": ["ref"], + "properties": { + "ref": { "type": "string", "minLength": 1, "pattern": "\\S" }, + "optional": { "type": "boolean", "default": false } + } + }, + "referenceGroup": { + "type": "object", + "additionalProperties": false, + "required": ["name", "data"], + "properties": { + "name": { "$ref": "#/$defs/dnsName" }, + "data": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "minLength": 1, + "maxLength": 253, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "additionalProperties": { "$ref": "#/$defs/reference" } + } + } + }, + "probe": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["type", "port"], + "properties": { + "type": { "const": "http" }, + "service": { "$ref": "#/$defs/dnsName" }, + "port": { "$ref": "#/$defs/port" }, + "path": { "type": "string", "pattern": "^/", "default": "/" }, + "scheme": { "enum": ["http", "https"], "default": "http" }, + "expectedStatuses": { + "type": "array", + "minItems": 1, + "default": [200], + "items": { "type": "integer", "minimum": 100, "maximum": 599 } + }, + "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 3600, "default": 120 }, + "intervalSeconds": { "type": "number", "minimum": 0.1, "maximum": 60, "default": 2 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "port"], + "properties": { + "type": { "const": "tcp" }, + "service": { "$ref": "#/$defs/dnsName" }, + "port": { "$ref": "#/$defs/port" }, + "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 3600, "default": 120 }, + "intervalSeconds": { "type": "number", "minimum": 0.1, "maximum": 60, "default": 2 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "command"], + "properties": { + "type": { "const": "exec" }, + "target": { "type": "string", "minLength": 1 }, + "container": { "$ref": "#/$defs/dnsName" }, + "command": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 3600, "default": 120 }, + "intervalSeconds": { "type": "number", "minimum": 0.1, "maximum": 60, "default": 2 } + } + } + ] + }, + "service": { + "type": "object", + "additionalProperties": false, + "required": ["name", "workload", "readiness"], + "properties": { + "name": { "$ref": "#/$defs/dnsName" }, + "workload": { + "type": "object", + "additionalProperties": false, + "required": ["kind"], + "properties": { + "kind": { "enum": ["deployment", "statefulset", "daemonset"] }, + "name": { "$ref": "#/$defs/dnsName" } + } + }, + "readiness": { "$ref": "#/$defs/probe" }, + "health": { "$ref": "#/$defs/probe" } + } + }, + "seed": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["type", "name", "service", "command"], + "properties": { + "type": { "const": "exec" }, + "name": { "$ref": "#/$defs/dnsName" }, + "service": { "$ref": "#/$defs/dnsName" }, + "container": { "$ref": "#/$defs/dnsName" }, + "command": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 3600, "default": 120 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "name", "manifest", "job"], + "properties": { + "type": { "const": "job" }, + "name": { "$ref": "#/$defs/dnsName" }, + "manifest": { "$ref": "#/$defs/relativePath" }, + "job": { "$ref": "#/$defs/dnsName" }, + "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 3600, "default": 120 } + } + } + ] + }, + "endpoint": { + "type": "object", + "additionalProperties": false, + "required": ["name", "service", "port"], + "properties": { + "name": { "$ref": "#/$defs/dnsName" }, + "service": { "$ref": "#/$defs/dnsName" }, + "port": { "$ref": "#/$defs/port" }, + "protocol": { "enum": ["http", "https"], "default": "http" }, + "path": { "type": "string", "pattern": "^/", "default": "/" } + } + } + } +} diff --git a/src/__tests__/dist-entrypoints.test.ts b/src/__tests__/dist-entrypoints.test.ts index ae6ca34e..501724fb 100644 --- a/src/__tests__/dist-entrypoints.test.ts +++ b/src/__tests__/dist-entrypoints.test.ts @@ -18,6 +18,9 @@ describe('published dist entrypoints', () => { expect(main.FactoryConfigSchema).toBeDefined() expect(main.createFactory).toBeTypeOf('function') expect(main.createFleet).toBeTypeOf('function') + expect(main.VerificationStackDescriptorSchema).toBeDefined() + expect(main.VerificationStackDeployer).toBeTypeOf('function') + expect(main.KubernetesEnvironmentProvider).toBeTypeOf('function') expect(hosted.createHostedFactory).toBeTypeOf('function') expect(hosted.DurableObjectHostedFactoryStateStore).toBeTypeOf('function') expect(testing.FakeFleetClient).toBeTypeOf('function') diff --git a/src/environments/kubernetes-command.ts b/src/environments/kubernetes-command.ts new file mode 100644 index 00000000..f926b37b --- /dev/null +++ b/src/environments/kubernetes-command.ts @@ -0,0 +1,112 @@ +import { spawn } from 'node:child_process' + +export interface CommandResult { + stdout: string + stderr: string +} + +export interface RunCommandOptions { + cwd?: string + input?: string + timeoutMs?: number +} + +export interface CommandRunner { + run(command: string, args: string[], options?: RunCommandOptions): Promise +} + +export interface KubernetesConnection { + kubeconfig?: string + context?: string +} + +export class CommandExecutionError extends Error { + constructor( + message: string, + public readonly command: string, + public readonly args: readonly string[], + public readonly stdout: string, + public readonly stderr: string, + options?: ErrorOptions, + ) { + super(message, options) + this.name = 'CommandExecutionError' + } +} + +export class ProcessCommandRunner implements CommandRunner { + async run(command: string, args: string[], options: RunCommandOptions = {}): Promise { + return await new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + let timedOut = false + let forceTimer: ReturnType | undefined + + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { stdout += chunk }) + child.stderr.on('data', (chunk: string) => { stderr += chunk }) + + const timer = options.timeoutMs === undefined + ? undefined + : setTimeout(() => { + timedOut = true + child.kill('SIGTERM') + forceTimer = setTimeout(() => child.kill('SIGKILL'), 2_000) + }, options.timeoutMs) + + child.on('error', (cause) => { + if (timer) clearTimeout(timer) + if (forceTimer) clearTimeout(forceTimer) + reject(new CommandExecutionError( + `Failed to start ${command}: ${cause.message}`, + command, + args, + stdout, + stderr, + { cause }, + )) + }) + child.on('close', (code, signal) => { + if (timer) clearTimeout(timer) + if (forceTimer) clearTimeout(forceTimer) + if (code === 0 && !timedOut) { + resolve({ stdout, stderr }) + return + } + const detail = timedOut + ? `timed out after ${options.timeoutMs}ms` + : `exited with ${code ?? signal ?? 'unknown status'}` + reject(new CommandExecutionError( + `${command} ${detail}${stderr.trim() ? `: ${stderr.trim()}` : ''}`, + command, + args, + stdout, + stderr, + )) + }) + + if (options.input === undefined) child.stdin.end() + else child.stdin.end(options.input) + }) + } +} + +export function kubectlConnectionArgs(connection: KubernetesConnection): string[] { + return [ + ...(connection.kubeconfig ? ['--kubeconfig', connection.kubeconfig] : []), + ...(connection.context ? ['--context', connection.context] : []), + ] +} + +export function helmConnectionArgs(connection: KubernetesConnection): string[] { + return [ + ...(connection.kubeconfig ? ['--kubeconfig', connection.kubeconfig] : []), + ...(connection.context ? ['--kube-context', connection.context] : []), + ] +} diff --git a/src/environments/kubernetes-provider.test.ts b/src/environments/kubernetes-provider.test.ts new file mode 100644 index 00000000..d8ff0018 --- /dev/null +++ b/src/environments/kubernetes-provider.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' + +import type { CommandRunner, RunCommandOptions } from './kubernetes-command' +import { KubernetesEnvironmentProvider } from './kubernetes-provider' + +class FakeRunner implements CommandRunner { + calls: Array<{ command: string; args: string[]; options?: RunCommandOptions }> = [] + + async run(command: string, args: string[], options?: RunCommandOptions) { + this.calls.push({ command, args, options }) + return { stdout: args.includes('jsonpath={.status.phase}') ? 'Active' : '', stderr: '' } + } +} + +describe('KubernetesEnvironmentProvider', () => { + it('provisions a namespace, reports status, and destroys idempotently', async () => { + const runner = new FakeRunner() + const provider = new KubernetesEnvironmentProvider({ + context: 'kind-factory', + namespacePrefix: 'factory-test', + defaultTtl: 12_345, + commandRunner: runner, + }) + + const environment = await provider.provision({ + id: 'Issue-143', + labels: { purpose: 'verification' }, + bindings: { owner: 'run-1' }, + }) + + expect(environment).toMatchObject({ + id: 'issue-143', + namespace: 'factory-test-issue-143', + status: 'ready', + ttl: 12_345, + bindings: { owner: 'run-1' }, + target: { + type: 'kubernetes', + namespace: 'factory-test-issue-143', + context: 'kind-factory', + }, + }) + await expect(provider.status(environment.id)).resolves.toBe('ready') + await provider.destroy(environment.id) + await provider.destroy(environment.id) + + const deleteCalls = runner.calls.filter((call) => call.args.includes('delete')) + expect(deleteCalls).toHaveLength(2) + expect(deleteCalls[0].args).toContain('--ignore-not-found=true') + }) +}) diff --git a/src/environments/kubernetes-provider.ts b/src/environments/kubernetes-provider.ts new file mode 100644 index 00000000..4bf077b9 --- /dev/null +++ b/src/environments/kubernetes-provider.ts @@ -0,0 +1,131 @@ +import { randomUUID } from 'node:crypto' + +import type { + Environment, + EnvironmentProvider, + EnvironmentSpec, + EnvironmentStatus, +} from '../ports/environment.js' +import { + CommandExecutionError, + type CommandRunner, + type KubernetesConnection, + ProcessCommandRunner, + kubectlConnectionArgs, +} from './kubernetes-command.js' + +export interface KubernetesEnvironmentProviderOptions extends KubernetesConnection { + namespacePrefix?: string + defaultTtl?: number + commandRunner?: CommandRunner +} + +/** A small Kubernetes EnvironmentProvider used by verification-stack deployments. */ +export class KubernetesEnvironmentProvider implements EnvironmentProvider { + private readonly runner: CommandRunner + private readonly namespacePrefix: string + private readonly defaultTtl: number + private readonly connection: KubernetesConnection + private readonly environments = new Map() + + constructor(options: KubernetesEnvironmentProviderOptions = {}) { + this.runner = options.commandRunner ?? new ProcessCommandRunner() + this.namespacePrefix = normalizeDnsLabel(options.namespacePrefix ?? 'factory-verification') + this.defaultTtl = options.defaultTtl ?? 30 * 60_000 + this.connection = { kubeconfig: options.kubeconfig, context: options.context } + } + + async provision(spec: EnvironmentSpec = {}): Promise { + const id = normalizeDnsLabel(spec.id ?? randomUUID()) + const namespace = `${this.namespacePrefix}-${id}`.slice(0, 63).replace(/-+$/u, '') + const labels = { + 'app.kubernetes.io/managed-by': 'factory', + 'factory.agentworkforce.dev/environment-id': id, + ...spec.labels, + } + + await this.runner.run('kubectl', [ + ...kubectlConnectionArgs(this.connection), + 'create', 'namespace', namespace, + ]) + + try { + for (const [key, value] of Object.entries(labels)) { + await this.runner.run('kubectl', [ + ...kubectlConnectionArgs(this.connection), + 'label', 'namespace', namespace, `${key}=${value}`, '--overwrite', + ]) + } + } catch (error) { + await this.deleteNamespace(namespace).catch(() => undefined) + throw error + } + + const environment: Environment = { + id, + status: 'ready', + createdAt: new Date().toISOString(), + ttl: spec.ttl ?? this.defaultTtl, + endpoints: {}, + bindings: { ...spec.bindings }, + namespace, + target: { + type: 'kubernetes', + namespace, + ...this.connection, + }, + } + this.environments.set(id, environment) + return environment + } + + async status(id: string): Promise { + const environment = this.environments.get(normalizeDnsLabel(id)) + const namespace = environment?.namespace ?? this.namespaceFor(id) + try { + const result = await this.runner.run('kubectl', [ + ...kubectlConnectionArgs(this.connection), + 'get', 'namespace', namespace, '-o', 'jsonpath={.status.phase}', + ]) + return result.stdout.trim() === 'Active' ? 'ready' : 'provisioning' + } catch (error) { + if (error instanceof CommandExecutionError && /not found/iu.test(error.stderr)) return 'destroyed' + throw error + } + } + + async endpoints(id: string): Promise> { + return { ...(this.environments.get(normalizeDnsLabel(id))?.endpoints ?? {}) } + } + + async destroy(id: string): Promise { + const normalizedId = normalizeDnsLabel(id) + const environment = this.environments.get(normalizedId) + const namespace = environment?.namespace ?? this.namespaceFor(normalizedId) + if (environment) environment.status = 'destroying' + await this.deleteNamespace(namespace) + if (environment) environment.status = 'destroyed' + this.environments.delete(normalizedId) + } + + private namespaceFor(id: string): string { + return `${this.namespacePrefix}-${normalizeDnsLabel(id)}`.slice(0, 63).replace(/-+$/u, '') + } + + private async deleteNamespace(namespace: string): Promise { + await this.runner.run('kubectl', [ + ...kubectlConnectionArgs(this.connection), + 'delete', 'namespace', namespace, + '--ignore-not-found=true', '--wait=true', '--timeout=2m', + ], { timeoutMs: 125_000 }) + } +} + +function normalizeDnsLabel(value: string): string { + const normalized = value + .toLowerCase() + .replace(/[^a-z0-9-]+/gu, '-') + .replace(/^-+|-+$/gu, '') + if (!normalized) throw new Error(`Environment id ${JSON.stringify(value)} is not a valid DNS label`) + return normalized.slice(0, 40).replace(/-+$/u, '') +} diff --git a/src/environments/stack-deployer.test.ts b/src/environments/stack-deployer.test.ts new file mode 100644 index 00000000..541d5943 --- /dev/null +++ b/src/environments/stack-deployer.test.ts @@ -0,0 +1,235 @@ +import { access } from 'node:fs/promises' + +import { describe, expect, it } from 'vitest' + +import type { Environment } from '../ports/environment' +import type { CommandRunner, RunCommandOptions } from './kubernetes-command' +import { + StackDeploymentError, + VerificationStackDeployer, + type ManagedPortForward, + type PortForwarder, +} from './stack-deployer' +import { loadVerificationStack, type VerificationStackDescriptor } from './stack-descriptor' + +class RecordingRunner implements CommandRunner { + readonly calls: Array<{ command: string; args: string[]; options?: RunCommandOptions }> = [] + + async run(command: string, args: string[], options?: RunCommandOptions) { + this.calls.push({ command, args, options }) + return { stdout: command === 'kompose' ? 'apiVersion: v1\nkind: Service\n' : '', stderr: '' } + } +} + +class FakePortForwarder implements PortForwarder { + readonly calls: string[] = [] + closed = 0 + + async forward(input: Parameters[0]): Promise { + this.calls.push(`${input.service}:${input.remotePort}`) + return { + localPort: 41_234, + close: async () => { this.closed += 1 }, + } + } +} + +function environment(): Environment { + return { + id: 'env-1', + status: 'ready', + createdAt: new Date().toISOString(), + ttl: 60_000, + endpoints: {}, + bindings: {}, + target: { type: 'kubernetes', namespace: 'factory-env-1', context: 'kind-test' }, + } +} + +function descriptor(source: VerificationStackDescriptor['source'] = { + type: 'manifests', paths: ['k8s/stack.yaml'], +}): VerificationStackDescriptor { + return loadVerificationStack({ + apiVersion: 'factory.agentworkforce.dev/v1alpha1', + kind: 'VerificationStack', + name: 'sample', + source, + secrets: [{ + name: 'database', + data: { PASSWORD: { ref: 'resource://database/password' } }, + }], + services: [{ + name: 'web', + workload: { kind: 'deployment' }, + readiness: { type: 'http', port: 8080, path: '/health', timeoutSeconds: 2 }, + }], + seeds: [{ + type: 'exec', + name: 'seed-database', + service: 'web', + command: ['app', 'seed'], + }], + endpoints: [{ name: 'web', service: 'web', port: 8080, path: '/health' }], + }) +} + +describe('VerificationStackDeployer', () => { + it('materializes references, applies, gates readiness, seeds, and resolves endpoints', async () => { + const runner = new RecordingRunner() + const forwards = new FakePortForwarder() + const env = environment() + const deployer = new VerificationStackDeployer({ + commandRunner: runner, + portForwarder: forwards, + referenceResolver: { resolve: async () => 'resolved-password' }, + fetch: async () => new Response('healthy', { status: 200 }), + }) + + const deployment = await deployer.deploy({ + descriptor: descriptor(), + descriptorPath: '.factory/verification-stack.yaml', + rootDir: '/repo', + }, env) + + expect(deployment.endpoints).toEqual({ web: 'http://127.0.0.1:41234/health' }) + expect(env.endpoints).toEqual(deployment.endpoints) + expect(forwards.calls).toEqual(['web:8080']) + const secret = runner.calls.find((call) => call.options?.input?.includes('"kind":"Secret"')) + expect(secret).toBeDefined() + expect(JSON.parse(secret!.options!.input!).data.PASSWORD).toBe( + Buffer.from('resolved-password').toString('base64'), + ) + expect(runner.calls.some((call) => call.args.includes('/repo/k8s/stack.yaml'))).toBe(true) + expect(runner.calls.some((call) => call.args.includes('seed-database'))).toBe(false) + expect(runner.calls.some((call) => call.args.includes('app') && call.args.includes('seed'))).toBe(true) + + await deployment.dispose() + await deployment.dispose() + expect(forwards.closed).toBe(1) + }) + + it('fails before apply with a precise missing-secret diagnostic', async () => { + const runner = new RecordingRunner() + const deployer = new VerificationStackDeployer({ + commandRunner: runner, + referenceResolver: { resolve: async () => undefined }, + }) + + await expect(deployer.deploy(descriptor(), environment())).rejects.toMatchObject({ + name: 'StackDeploymentError', + stage: 'references', + message: expect.stringContaining( + 'Missing required secret reference "resource://database/password" for database.PASSWORD', + ), + }) + expect(runner.calls).toEqual([]) + }) + + it('allows unresolved references only when every entry is optional', async () => { + const runner = new RecordingRunner() + const stack = descriptor() + stack.secrets[0].data.PASSWORD.optional = true + stack.services[0].readiness = { + type: 'exec', command: ['true'], timeoutSeconds: 1, intervalSeconds: 0.1, + } + stack.seeds = [] + stack.endpoints = [] + + await expect(new VerificationStackDeployer({ commandRunner: runner }).deploy( + stack, + environment(), + )).resolves.toBeDefined() + const secret = runner.calls.find((call) => call.options?.input?.includes('"kind":"Secret"')) + expect(JSON.parse(secret!.options!.input!).data).toEqual({}) + }) + + it('materializes descriptor assets from the selected Git ref and removes the checkout', async () => { + const runner = new RecordingRunner() + const stack = descriptor() + stack.secrets = [] + stack.services[0].readiness = { + type: 'exec', command: ['true'], timeoutSeconds: 1, intervalSeconds: 0.1, + } + stack.seeds = [] + stack.endpoints = [] + + await new VerificationStackDeployer({ commandRunner: runner }).deploy({ + descriptor: stack, + descriptorPath: '.factory/verification-stack.yaml', + rootDir: '/repo', + ref: '0123456789abcdef0123456789abcdef01234567', + }, environment()) + + expect(runner.calls[0]).toMatchObject({ + command: 'git', + args: ['clone', '--quiet', '--shared', '--no-checkout', '/repo', expect.any(String)], + }) + const checkout = runner.calls[0].args.at(-1)! + expect(runner.calls[1].args).toEqual([ + '-C', checkout, 'checkout', '--quiet', '--detach', + '0123456789abcdef0123456789abcdef01234567', + ]) + expect(runner.calls.some((call) => call.args.includes(`${checkout}/k8s/stack.yaml`))).toBe(true) + await expect(access(checkout)).rejects.toThrow() + }) + + it('bounds an unsatisfiable health probe and names the failed service', async () => { + const runner = new RecordingRunner() + const forwards = new FakePortForwarder() + const unhealthy = descriptor() + unhealthy.secrets = [] + unhealthy.services[0].readiness.timeoutSeconds = 1 + unhealthy.services[0].readiness.intervalSeconds = 0.1 + const deployer = new VerificationStackDeployer({ + commandRunner: runner, + portForwarder: forwards, + fetch: async () => new Response('no', { status: 503 }), + }) + const started = Date.now() + + await expect(deployer.deploy(unhealthy, environment())).rejects.toEqual(expect.objectContaining({ + name: 'StackDeploymentError', + stage: 'readiness', + service: 'web', + message: expect.stringMatching(/Service web readiness probe never became ready within 1s.*HTTP 503/u), + })) + expect(Date.now() - started).toBeLessThan(2_500) + expect(forwards.closed).toBe(1) + }) + + it.each([ + ['manifests', { type: 'manifests', paths: ['k8s/all.yaml'] }], + ['kustomize', { type: 'kustomize', path: 'k8s/overlays/test' }], + ['helm', { type: 'helm', chart: 'charts/app', release: 'verify', valuesFiles: ['values.test.yaml'] }], + ['docker-compose', { type: 'docker-compose', path: 'docker-compose.yml' }], + ] as const)('applies a %s source without repository-specific logic', async (_name, source) => { + const runner = new RecordingRunner() + const stack = descriptor(source as VerificationStackDescriptor['source']) + stack.secrets = [] + stack.services[0].readiness = { + type: 'exec', command: ['true'], timeoutSeconds: 1, intervalSeconds: 0.1, + } + stack.seeds = [] + stack.endpoints = [] + + const deployment = await new VerificationStackDeployer({ commandRunner: runner }).deploy({ + descriptor: stack, + descriptorPath: '.factory/verification-stack.yaml', + rootDir: '/repo', + }, environment()) + + const commands = runner.calls.map((call) => call.command) + if (source.type === 'helm') expect(commands).toContain('helm') + else if (source.type === 'docker-compose') expect(commands).toEqual(expect.arrayContaining(['kompose', 'kubectl'])) + else expect(commands).toContain('kubectl') + await deployment.dispose() + }) + + it('rejects a non-Kubernetes environment clearly', async () => { + const env = { ...environment(), target: { type: 'cloudflare' } } + + await expect(new VerificationStackDeployer().deploy(descriptor(), env)).rejects.toBeInstanceOf( + StackDeploymentError, + ) + }) +}) diff --git a/src/environments/stack-deployer.ts b/src/environments/stack-deployer.ts new file mode 100644 index 00000000..e8361603 --- /dev/null +++ b/src/environments/stack-deployer.ts @@ -0,0 +1,621 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { connect } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import type { Environment, KubernetesEnvironmentTarget } from '../ports/environment.js' +import { + type CommandRunner, + helmConnectionArgs, + type KubernetesConnection, + kubectlConnectionArgs, + ProcessCommandRunner, +} from './kubernetes-command.js' +import { + type LoadedVerificationStack, + resolveVerificationStackAsset, + type VerificationProbe, + type VerificationStackDescriptor, + type VerificationStackReferenceGroup, + type VerificationStackService, +} from './stack-descriptor.js' + +export interface ReferenceResolutionContext { + kind: 'secret' | 'config' + resource: string + key: string + optional: boolean +} + +export interface VerificationStackReferenceResolver { + resolve(reference: string, context: ReferenceResolutionContext): Promise +} + +export interface ManagedPortForward { + localPort: number + close(): Promise +} + +export interface PortForwarder { + forward(input: { + connection: KubernetesConnection + namespace: string + service: string + remotePort: string | number + timeoutMs: number + }): Promise +} + +export interface StackDeployerOptions { + commandRunner?: CommandRunner + referenceResolver?: VerificationStackReferenceResolver + portForwarder?: PortForwarder + fetch?: typeof globalThis.fetch +} + +export interface StackDeployment { + endpoints: Record + dispose(): Promise +} + +export class StackDeploymentError extends Error { + constructor( + message: string, + public readonly stage: 'references' | 'apply' | 'readiness' | 'seed' | 'endpoints', + public readonly service?: string, + options?: ErrorOptions, + ) { + super(message, options) + this.name = 'StackDeploymentError' + } +} + +/** Deploys a repository-owned verification stack into a provisioned Kubernetes environment. */ +export class VerificationStackDeployer { + private readonly runner: CommandRunner + private readonly referenceResolver?: VerificationStackReferenceResolver + private readonly portForwarder: PortForwarder + private readonly fetchImpl: typeof globalThis.fetch + + constructor(options: StackDeployerOptions = {}) { + this.runner = options.commandRunner ?? new ProcessCommandRunner() + this.referenceResolver = options.referenceResolver + this.portForwarder = options.portForwarder ?? new KubectlPortForwarder() + this.fetchImpl = options.fetch ?? globalThis.fetch + } + + async deploy( + stack: LoadedVerificationStack | VerificationStackDescriptor, + environment: Environment, + ): Promise { + const descriptor = 'descriptor' in stack ? stack.descriptor : stack + const target = kubernetesTarget(environment) + const tunnels = new Map() + let preparedRoot: PreparedStackRoot | undefined + + try { + preparedRoot = await this.prepareStackRoot(stack) + await this.applyReferences(descriptor.secrets, 'secret', target) + await this.applyReferences(descriptor.config, 'config', target) + await this.applySource(descriptor, preparedRoot.rootDir, target) + + for (const service of descriptor.services) { + await this.waitForService(service, target, tunnels) + } + for (const seed of descriptor.seeds) { + await this.runSeed(seed, descriptor, preparedRoot.rootDir, target) + } + + const endpoints: Record = {} + for (const endpoint of descriptor.endpoints) { + const tunnel = await this.tunnelFor( + tunnels, + target, + endpoint.service, + endpoint.port, + 30_000, + ) + endpoints[endpoint.name] = `${endpoint.protocol}://127.0.0.1:${tunnel.localPort}${endpoint.path}` + } + environment.endpoints = { ...endpoints } + + let disposed = false + return { + endpoints, + dispose: async () => { + if (disposed) return + disposed = true + await closeTunnels(tunnels) + }, + } + } catch (error) { + await closeTunnels(tunnels) + if (error instanceof StackDeploymentError) throw error + throw new StackDeploymentError( + `Failed to deploy verification stack ${descriptor.name}: ${errorMessage(error)}`, + 'apply', + undefined, + { cause: error }, + ) + } finally { + await preparedRoot?.cleanup().catch(() => undefined) + } + } + + private async prepareStackRoot( + stack: LoadedVerificationStack | VerificationStackDescriptor, + ): Promise { + if (!('descriptor' in stack)) { + return { rootDir: process.cwd(), cleanup: async () => undefined } + } + if (!stack.ref) { + return { rootDir: stack.rootDir, cleanup: async () => undefined } + } + + const checkout = await mkdtemp(join(tmpdir(), 'factory-verification-stack-')) + try { + await this.runner.run('git', [ + 'clone', '--quiet', '--shared', '--no-checkout', stack.rootDir, checkout, + ], { timeoutMs: 120_000 }) + await this.runner.run('git', [ + '-C', checkout, 'checkout', '--quiet', '--detach', stack.ref, + ], { timeoutMs: 120_000 }) + return { + rootDir: checkout, + cleanup: async () => await rm(checkout, { recursive: true, force: true }), + } + } catch (cause) { + await rm(checkout, { recursive: true, force: true }).catch(() => undefined) + throw new StackDeploymentError( + `Could not materialize verification-stack assets at Git ref ${stack.ref}: ${errorMessage(cause)}`, + 'apply', + undefined, + { cause }, + ) + } + } + + private async applyReferences( + groups: VerificationStackReferenceGroup[], + kind: 'secret' | 'config', + target: KubernetesEnvironmentTarget, + ): Promise { + const requiresResolver = groups.some((group) => ( + Object.values(group.data).some((reference) => !reference.optional) + )) + if (requiresResolver && !this.referenceResolver) { + throw new StackDeploymentError( + `Cannot materialize required ${kind} references: no reference resolver was configured`, + 'references', + ) + } + + for (const group of groups) { + const data: Record = {} + for (const [key, requirement] of Object.entries(group.data)) { + let value: string | undefined + try { + value = await this.referenceResolver?.resolve(requirement.ref, { + kind, + resource: group.name, + key, + optional: requirement.optional, + }) + } catch (cause) { + throw new StackDeploymentError( + `Could not resolve ${kind} reference ${JSON.stringify(requirement.ref)} for ${group.name}.${key}: ${errorMessage(cause)}`, + 'references', + undefined, + { cause }, + ) + } + if (value === undefined) { + if (requirement.optional) continue + throw new StackDeploymentError( + `Missing required ${kind} reference ${JSON.stringify(requirement.ref)} for ${group.name}.${key}`, + 'references', + ) + } + data[key] = value + } + + const resource = kind === 'secret' + ? { + apiVersion: 'v1', + kind: 'Secret', + metadata: { name: group.name, namespace: target.namespace }, + type: 'Opaque', + data: Object.fromEntries(Object.entries(data).map(([key, value]) => [ + key, + Buffer.from(value).toString('base64'), + ])), + } + : { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { name: group.name, namespace: target.namespace }, + data, + } + + try { + await this.runner.run('kubectl', [ + ...kubectlConnectionArgs(target), + 'apply', '-f', '-', + ], { input: JSON.stringify(resource), timeoutMs: 30_000 }) + } catch (cause) { + throw new StackDeploymentError( + `Failed to materialize ${kind} ${group.name} in namespace ${target.namespace}: ${errorMessage(cause)}`, + 'references', + undefined, + { cause }, + ) + } + } + } + + private async applySource( + descriptor: VerificationStackDescriptor, + rootDir: string, + target: KubernetesEnvironmentTarget, + ): Promise { + const source = descriptor.source + try { + if (source.type === 'manifests') { + for (const path of source.paths) { + await this.runner.run('kubectl', [ + ...kubectlConnectionArgs(target), + '--namespace', target.namespace, + 'apply', '-f', resolveVerificationStackAsset(rootDir, path), + ], { timeoutMs: 120_000 }) + } + return + } + if (source.type === 'kustomize') { + await this.runner.run('kubectl', [ + ...kubectlConnectionArgs(target), + '--namespace', target.namespace, + 'apply', '-k', resolveVerificationStackAsset(rootDir, source.path), + ], { timeoutMs: 120_000 }) + return + } + if (source.type === 'helm') { + const chart = isRemoteChart(source.chart) + ? source.chart + : resolveVerificationStackAsset(rootDir, source.chart) + await this.runner.run('helm', [ + ...helmConnectionArgs(target), + 'upgrade', '--install', source.release ?? descriptor.name, chart, + '--namespace', target.namespace, + ...source.valuesFiles.flatMap((path) => [ + '--values', resolveVerificationStackAsset(rootDir, path), + ]), + ], { timeoutMs: 180_000 }) + return + } + + const compose = await this.runner.run('kompose', [ + 'convert', '--file', resolveVerificationStackAsset(rootDir, source.path), '--stdout', + ], { timeoutMs: 120_000 }) + await this.runner.run('kubectl', [ + ...kubectlConnectionArgs(target), + '--namespace', target.namespace, + 'apply', '-f', '-', + ], { input: compose.stdout, timeoutMs: 120_000 }) + } catch (cause) { + throw new StackDeploymentError( + `Failed to apply ${source.type} source for stack ${descriptor.name} in namespace ${target.namespace}: ${errorMessage(cause)}`, + 'apply', + undefined, + { cause }, + ) + } + } + + private async waitForService( + service: VerificationStackService, + target: KubernetesEnvironmentTarget, + tunnels: Map, + ): Promise { + const workload = `${service.workload.kind}/${service.workload.name ?? service.name}` + try { + await this.runner.run('kubectl', [ + ...kubectlConnectionArgs(target), + '--namespace', target.namespace, + 'rollout', 'status', workload, + `--timeout=${service.readiness.timeoutSeconds}s`, + ], { timeoutMs: service.readiness.timeoutSeconds * 1_000 + 5_000 }) + } catch (cause) { + throw new StackDeploymentError( + `Service ${service.name} workload ${workload} never became ready within ${service.readiness.timeoutSeconds}s: ${errorMessage(cause)}`, + 'readiness', + service.name, + { cause }, + ) + } + + await this.waitForProbe(service, service.readiness, 'readiness', target, tunnels) + if (service.health) await this.waitForProbe(service, service.health, 'health', target, tunnels) + } + + private async waitForProbe( + service: VerificationStackService, + probe: VerificationProbe, + label: 'readiness' | 'health', + target: KubernetesEnvironmentTarget, + tunnels: Map, + ): Promise { + const timeoutMs = probe.timeoutSeconds * 1_000 + const deadline = Date.now() + timeoutMs + let lastError = 'probe returned unhealthy' + + while (Date.now() < deadline) { + try { + const remaining = Math.max(1, deadline - Date.now()) + if (probe.type === 'exec') { + const workload = probe.target ?? `${service.workload.kind}/${service.workload.name ?? service.name}` + await this.runner.run('kubectl', [ + ...kubectlConnectionArgs(target), + '--namespace', target.namespace, + 'exec', workload, + ...(probe.container ? ['--container', probe.container] : []), + '--', ...probe.command, + ], { timeoutMs: Math.min(remaining, 10_000) }) + return + } + + const serviceName = probe.service ?? service.name + const tunnel = await this.tunnelFor( + tunnels, + target, + serviceName, + probe.port, + Math.min(remaining, 15_000), + ) + if (probe.type === 'tcp') { + await checkTcp(tunnel.localPort, Math.min(remaining, 5_000)) + return + } + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), Math.min(remaining, 5_000)) + try { + const response = await this.fetchImpl( + `${probe.scheme}://127.0.0.1:${tunnel.localPort}${probe.path}`, + { signal: controller.signal }, + ) + await response.body?.cancel() + if (probe.expectedStatuses.includes(response.status)) return + lastError = `HTTP ${response.status}; expected ${probe.expectedStatuses.join(', ')}` + } finally { + clearTimeout(timer) + } + } catch (error) { + lastError = errorMessage(error) + } + + const delay = Math.min(probe.intervalSeconds * 1_000, Math.max(0, deadline - Date.now())) + if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)) + } + + throw new StackDeploymentError( + `Service ${service.name} ${label} probe never became ready within ${probe.timeoutSeconds}s (last error: ${lastError})`, + 'readiness', + service.name, + ) + } + + private async runSeed( + seed: VerificationStackDescriptor['seeds'][number], + descriptor: VerificationStackDescriptor, + rootDir: string, + target: KubernetesEnvironmentTarget, + ): Promise { + try { + if (seed.type === 'job') { + await this.runner.run('kubectl', [ + ...kubectlConnectionArgs(target), + '--namespace', target.namespace, + 'apply', '-f', resolveVerificationStackAsset(rootDir, seed.manifest), + ], { timeoutMs: 30_000 }) + await this.runner.run('kubectl', [ + ...kubectlConnectionArgs(target), + '--namespace', target.namespace, + 'wait', `job/${seed.job}`, '--for=condition=complete', + `--timeout=${seed.timeoutSeconds}s`, + ], { timeoutMs: seed.timeoutSeconds * 1_000 + 5_000 }) + return + } + + const service = descriptor.services.find((candidate) => candidate.name === seed.service) + if (!service) throw new Error(`service ${seed.service} is not declared`) + const workload = `${service.workload.kind}/${service.workload.name ?? service.name}` + await this.runner.run('kubectl', [ + ...kubectlConnectionArgs(target), + '--namespace', target.namespace, + 'exec', workload, + ...(seed.container ? ['--container', seed.container] : []), + '--', ...seed.command, + ], { timeoutMs: seed.timeoutSeconds * 1_000 }) + } catch (cause) { + throw new StackDeploymentError( + `Seed step ${seed.name} failed in namespace ${target.namespace}: ${errorMessage(cause)}`, + 'seed', + seed.type === 'exec' ? seed.service : undefined, + { cause }, + ) + } + } + + private async tunnelFor( + tunnels: Map, + target: KubernetesEnvironmentTarget, + service: string, + port: string | number, + timeoutMs: number, + ): Promise { + const key = `${service}:${port}` + const existing = tunnels.get(key) + if (existing) return existing + try { + const tunnel = await this.portForwarder.forward({ + connection: target, + namespace: target.namespace, + service, + remotePort: port, + timeoutMs, + }) + tunnels.set(key, tunnel) + return tunnel + } catch (cause) { + throw new StackDeploymentError( + `Could not expose service ${service} port ${port} in namespace ${target.namespace}: ${errorMessage(cause)}`, + 'endpoints', + service, + { cause }, + ) + } + } +} + +export async function deployVerificationStack( + stack: LoadedVerificationStack | VerificationStackDescriptor, + environment: Environment, + options: StackDeployerOptions = {}, +): Promise { + return await new VerificationStackDeployer(options).deploy(stack, environment) +} + +export class KubectlPortForwarder implements PortForwarder { + async forward(input: { + connection: KubernetesConnection + namespace: string + service: string + remotePort: string | number + timeoutMs: number + }): Promise { + const child = spawn('kubectl', [ + ...kubectlConnectionArgs(input.connection), + '--namespace', input.namespace, + 'port-forward', '--address', '127.0.0.1', + `service/${input.service}`, `:${input.remotePort}`, + ], { env: process.env, stdio: ['pipe', 'pipe', 'pipe'] }) + child.stdin.end() + + try { + const localPort = await waitForForwardedPort(child, input.timeoutMs) + let closed = false + return { + localPort, + close: async () => { + if (closed) return + closed = true + await stopChild(child) + }, + } + } catch (error) { + await stopChild(child) + throw error + } + } +} + +function kubernetesTarget(environment: Environment): KubernetesEnvironmentTarget { + if (environment.target?.type === 'kubernetes') { + return environment.target as KubernetesEnvironmentTarget + } + if (environment.namespace) { + return { type: 'kubernetes', namespace: environment.namespace } + } + throw new StackDeploymentError( + `Environment ${environment.id} is not a Kubernetes environment with a namespace`, + 'apply', + ) +} + +async function closeTunnels(tunnels: Map): Promise { + const active = [...tunnels.values()] + tunnels.clear() + await Promise.allSettled(active.map(async (tunnel) => await tunnel.close())) +} + +function isRemoteChart(chart: string): boolean { + return /^(?:https?:|oci:)/u.test(chart) +} + +function checkTcp(port: number, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const socket = connect({ host: '127.0.0.1', port }) + const timer = setTimeout(() => { + socket.destroy() + reject(new Error(`TCP probe timed out after ${timeoutMs}ms`)) + }, timeoutMs) + socket.once('connect', () => { + clearTimeout(timer) + socket.destroy() + resolve() + }) + socket.once('error', (error) => { + clearTimeout(timer) + reject(error) + }) + }) +} + +function waitForForwardedPort(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + let output = '' + let settled = false + const onData = (chunk: Buffer | string) => { + output = `${output}${chunk.toString()}`.slice(-8_000) + const match = /Forwarding from 127\.0\.0\.1:(\d+)/u.exec(output) + if (match) finish(undefined, Number(match[1])) + } + const onError = (error: Error) => finish(error) + const onClose = (code: number | null, signal: NodeJS.Signals | null) => { + finish(new Error( + `kubectl port-forward exited with ${code ?? signal ?? 'unknown status'}${output.trim() ? `: ${output.trim()}` : ''}`, + )) + } + const timer = setTimeout(() => { + finish(new Error(`kubectl port-forward did not become ready within ${timeoutMs}ms${output.trim() ? `: ${output.trim()}` : ''}`)) + }, timeoutMs) + const finish = (error?: Error, port?: number) => { + if (settled) return + settled = true + clearTimeout(timer) + child.stdout.off('data', onData) + child.stderr.off('data', onData) + child.off('error', onError) + child.off('close', onClose) + if (error) reject(error) + else resolve(port!) + } + + child.stdout.on('data', onData) + child.stderr.on('data', onData) + child.once('error', onError) + child.once('close', onClose) + }) +} + +function stopChild(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() + return new Promise((resolve) => { + const force = setTimeout(() => child.kill('SIGKILL'), 2_000) + child.once('close', () => { + clearTimeout(force) + resolve() + }) + child.kill('SIGTERM') + }) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +interface PreparedStackRoot { + rootDir: string + cleanup(): Promise +} diff --git a/src/environments/stack-descriptor.test.ts b/src/environments/stack-descriptor.test.ts new file mode 100644 index 00000000..2c118c15 --- /dev/null +++ b/src/environments/stack-descriptor.test.ts @@ -0,0 +1,139 @@ +import { execFileSync } from 'node:child_process' +import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { + DEFAULT_VERIFICATION_STACK_PATH, + VERIFICATION_STACK_JSON_SCHEMA_URL, + loadVerificationStack, + parseVerificationStack, + resolveVerificationStackDescriptor, + VerificationStackDescriptorError, +} from './stack-descriptor' + +function descriptor(overrides: Record = {}): Record { + return { + apiVersion: 'factory.agentworkforce.dev/v1alpha1', + kind: 'VerificationStack', + name: 'sample', + source: { type: 'manifests', paths: ['k8s/stack.yaml'] }, + services: [{ + name: 'web', + workload: { kind: 'deployment' }, + readiness: { type: 'http', port: 8080, path: '/health' }, + }], + endpoints: [{ name: 'web', service: 'web', port: 8080, path: '/health' }], + ...overrides, + } +} + +describe('verification-stack descriptor', () => { + it('loads a typed descriptor and applies bounded probe defaults', () => { + const loaded = loadVerificationStack(descriptor()) + + expect(loaded.source).toEqual({ type: 'manifests', paths: ['k8s/stack.yaml'] }) + expect(loaded.services[0].readiness).toMatchObject({ + type: 'http', + path: '/health', + scheme: 'http', + expectedStatuses: [200], + timeoutSeconds: 120, + intervalSeconds: 2, + }) + expect(loaded.secrets).toEqual([]) + expect(loaded.seeds).toEqual([]) + }) + + it('ships the public draft-2020-12 JSON Schema', async () => { + const schema = JSON.parse(await readFile(VERIFICATION_STACK_JSON_SCHEMA_URL, 'utf8')) + + expect(schema.$schema).toBe('https://json-schema.org/draft/2020-12/schema') + expect(schema.properties.source.$ref).toBe('#/$defs/source') + expect(schema.$defs.reference.properties).not.toHaveProperty('value') + }) + + it('fails closed with paths for inline secrets, unknown keys, and dangling services', () => { + expect(() => loadVerificationStack(descriptor({ + secrets: [{ name: 'database', data: { PASSWORD: 'inline-secret' } }], + }), 'fixture.yaml')).toThrowError(/fixture\.yaml:[\s\S]*secrets\.0\.data\.PASSWORD/u) + + expect(() => loadVerificationStack(descriptor({ surprise: true }))).toThrowError(/.*Unrecognized key/u) + + expect(() => loadVerificationStack(descriptor({ + endpoints: [{ name: 'api', service: 'missing', port: 80 }], + }))).toThrowError(/endpoints\.0\.service.*undeclared service "missing"/u) + + expect(() => loadVerificationStack(descriptor({ + config: [{ name: 'application', data: { 'invalid/key': { ref: 'config://application/key' } } }], + }))).toThrowError(/config\.0\.data\.invalid\/key.*letters, numbers, dots/u) + }) + + it('reports malformed YAML without obscuring the descriptor source', () => { + expect(() => parseVerificationStack('services: [', 'broken.yaml')).toThrowError( + /Could not parse verification-stack descriptor broken\.yaml/u, + ) + }) + + it('resolves the default path and an explicit repository-relative override', async () => { + const repo = await mkdtemp(join(tmpdir(), 'factory-stack-')) + await mkdir(join(repo, '.factory'), { recursive: true }) + await mkdir(join(repo, 'ops'), { recursive: true }) + await writeFile(join(repo, DEFAULT_VERIFICATION_STACK_PATH), yamlDescriptor('default-stack')) + await writeFile(join(repo, 'ops/verify.yaml'), yamlDescriptor('override-stack')) + + const defaultStack = await resolveVerificationStackDescriptor({ repoPath: repo }) + const overrideStack = await resolveVerificationStackDescriptor({ + repoPath: repo, + descriptorPath: 'ops/verify.yaml', + }) + + expect(defaultStack.descriptor.name).toBe('default-stack') + expect(defaultStack.descriptorPath).toBe(DEFAULT_VERIFICATION_STACK_PATH) + expect(overrideStack.descriptor.name).toBe('override-stack') + await expect(resolveVerificationStackDescriptor({ + repoPath: repo, + descriptorPath: '../outside.yaml', + })).rejects.toThrow(/must stay inside the repository/u) + }) + + it('selects the descriptor committed at the requested branch or SHA', async () => { + const repo = await mkdtemp(join(tmpdir(), 'factory-stack-git-')) + await mkdir(join(repo, '.factory'), { recursive: true }) + execFileSync('git', ['init', '-q', repo]) + execFileSync('git', ['-C', repo, 'config', 'user.name', 'Factory Test']) + execFileSync('git', ['-C', repo, 'config', 'user.email', 'factory@example.test']) + await writeFile(join(repo, DEFAULT_VERIFICATION_STACK_PATH), yamlDescriptor('committed-stack')) + execFileSync('git', ['-C', repo, 'add', DEFAULT_VERIFICATION_STACK_PATH]) + execFileSync('git', ['-C', repo, 'commit', '-qm', 'fixture']) + await writeFile(join(repo, DEFAULT_VERIFICATION_STACK_PATH), yamlDescriptor('working-tree-stack')) + + const selected = await resolveVerificationStackDescriptor({ repoPath: repo, ref: 'HEAD' }) + + expect(selected.descriptor.name).toBe('committed-stack') + expect(selected.ref).toMatch(/^[a-f0-9]{40}$/u) + }) + + it('uses a dedicated actionable error type', () => { + expect(() => loadVerificationStack(null)).toThrow(VerificationStackDescriptorError) + }) +}) + +function yamlDescriptor(name: string): string { + return `apiVersion: factory.agentworkforce.dev/v1alpha1 +kind: VerificationStack +name: ${name} +source: + type: manifests + paths: [k8s/stack.yaml] +services: + - name: web + workload: + kind: deployment + readiness: + type: tcp + port: 8080 +` +} diff --git a/src/environments/stack-descriptor.ts b/src/environments/stack-descriptor.ts new file mode 100644 index 00000000..efa11ef4 --- /dev/null +++ b/src/environments/stack-descriptor.ts @@ -0,0 +1,346 @@ +import { readFile } from 'node:fs/promises' +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' + +import { parse as parseYaml } from 'yaml' +import { z } from 'zod' + +import { ProcessCommandRunner, type CommandRunner } from './kubernetes-command.js' + +export const DEFAULT_VERIFICATION_STACK_PATH = '.factory/verification-stack.yaml' +export const VERIFICATION_STACK_API_VERSION = 'factory.agentworkforce.dev/v1alpha1' +export const VERIFICATION_STACK_KIND = 'VerificationStack' +/** File URL that works from both the source checkout and the published dist. */ +export const VERIFICATION_STACK_JSON_SCHEMA_URL = new URL( + '../../schemas/verification-stack.schema.json', + import.meta.url, +) + +const dnsNameSchema = z.string().trim().min(1).max(63).regex( + /^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/u, + 'must be a lowercase DNS label (letters, numbers, and hyphens)', +) +const relativePathSchema = z.string().trim().min(1).refine( + (value) => !isAbsolute(value) && !value.split(/[\\/]/u).includes('..'), + 'must be a path inside the repository', +) +const durationFields = { + timeoutSeconds: z.number().int().min(1).max(3_600).default(120), + intervalSeconds: z.number().min(0.1).max(60).default(2), +} + +const httpProbeSchema = z.object({ + type: z.literal('http'), + service: dnsNameSchema.optional(), + port: z.number().int().min(1).max(65_535), + path: z.string().startsWith('/').default('/'), + scheme: z.enum(['http', 'https']).default('http'), + expectedStatuses: z.array(z.number().int().min(100).max(599)).min(1).default([200]), + ...durationFields, +}).strict() + +const tcpProbeSchema = z.object({ + type: z.literal('tcp'), + service: dnsNameSchema.optional(), + port: z.number().int().min(1).max(65_535), + ...durationFields, +}).strict() + +const execProbeSchema = z.object({ + type: z.literal('exec'), + target: z.string().trim().min(1).optional(), + container: dnsNameSchema.optional(), + command: z.array(z.string().min(1)).min(1), + ...durationFields, +}).strict() + +export const VerificationProbeSchema = z.discriminatedUnion('type', [ + httpProbeSchema, + tcpProbeSchema, + execProbeSchema, +]) + +const helmSourceSchema = z.object({ + type: z.literal('helm'), + chart: z.string().trim().min(1), + release: dnsNameSchema.optional(), + valuesFiles: z.array(relativePathSchema).default([]), +}).strict() + +const kustomizeSourceSchema = z.object({ + type: z.literal('kustomize'), + path: relativePathSchema, +}).strict() + +const manifestsSourceSchema = z.object({ + type: z.literal('manifests'), + paths: z.array(relativePathSchema).min(1), +}).strict() + +const composeSourceSchema = z.object({ + type: z.literal('docker-compose'), + path: relativePathSchema, +}).strict() + +export const VerificationStackSourceSchema = z.discriminatedUnion('type', [ + helmSourceSchema, + kustomizeSourceSchema, + manifestsSourceSchema, + composeSourceSchema, +]) + +const referenceSchema = z.object({ + ref: z.string().trim().min(1), + optional: z.boolean().default(false), +}).strict() + +const referenceDataKeySchema = z.string().min(1).max(253).regex( + /^[A-Za-z0-9._-]+$/u, + 'must contain only letters, numbers, dots, underscores, and hyphens', +) + +const materializedReferenceSchema = z.object({ + name: dnsNameSchema, + data: z.record(referenceDataKeySchema, referenceSchema).refine( + (data) => Object.keys(data).length > 0, + 'must declare at least one referenced key', + ), +}).strict() + +const serviceSchema = z.object({ + name: dnsNameSchema, + workload: z.object({ + kind: z.enum(['deployment', 'statefulset', 'daemonset']), + name: dnsNameSchema.optional(), + }).strict(), + readiness: VerificationProbeSchema, + health: VerificationProbeSchema.optional(), +}).strict() + +const execSeedSchema = z.object({ + type: z.literal('exec'), + name: dnsNameSchema, + service: dnsNameSchema, + container: dnsNameSchema.optional(), + command: z.array(z.string().min(1)).min(1), + timeoutSeconds: z.number().int().min(1).max(3_600).default(120), +}).strict() + +const jobSeedSchema = z.object({ + type: z.literal('job'), + name: dnsNameSchema, + manifest: relativePathSchema, + job: dnsNameSchema, + timeoutSeconds: z.number().int().min(1).max(3_600).default(120), +}).strict() + +const seedSchema = z.discriminatedUnion('type', [execSeedSchema, jobSeedSchema]) + +const endpointSchema = z.object({ + name: dnsNameSchema, + service: dnsNameSchema, + port: z.number().int().min(1).max(65_535), + protocol: z.enum(['http', 'https']).default('http'), + path: z.string().startsWith('/').default('/'), +}).strict() + +export const VerificationStackDescriptorSchema = z.object({ + apiVersion: z.literal(VERIFICATION_STACK_API_VERSION), + kind: z.literal(VERIFICATION_STACK_KIND), + name: dnsNameSchema, + source: VerificationStackSourceSchema, + secrets: z.array(materializedReferenceSchema).default([]), + config: z.array(materializedReferenceSchema).default([]), + services: z.array(serviceSchema).min(1), + seeds: z.array(seedSchema).default([]), + endpoints: z.array(endpointSchema).default([]), +}).strict().superRefine((descriptor, context) => { + uniqueNames(descriptor.services, 'services', context) + uniqueNames(descriptor.secrets, 'secrets', context) + uniqueNames(descriptor.config, 'config', context) + uniqueNames(descriptor.seeds, 'seeds', context) + uniqueNames(descriptor.endpoints, 'endpoints', context) + + const services = new Set(descriptor.services.map((service) => service.name)) + descriptor.seeds.forEach((seed, index) => { + if (seed.type === 'exec' && !services.has(seed.service)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['seeds', index, 'service'], + message: `references undeclared service ${JSON.stringify(seed.service)}`, + }) + } + }) + descriptor.endpoints.forEach((endpoint, index) => { + if (!services.has(endpoint.service)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['endpoints', index, 'service'], + message: `references undeclared service ${JSON.stringify(endpoint.service)}`, + }) + } + }) +}) + +export type VerificationProbe = z.infer +export type VerificationStackSource = z.infer +export type VerificationStackDescriptor = z.infer +export type VerificationStackService = VerificationStackDescriptor['services'][number] +export type VerificationStackSeed = VerificationStackDescriptor['seeds'][number] +export type VerificationStackEndpoint = VerificationStackDescriptor['endpoints'][number] +export type VerificationStackReferenceGroup = VerificationStackDescriptor['secrets'][number] + +export interface LoadedVerificationStack { + descriptor: VerificationStackDescriptor + descriptorPath: string + rootDir: string + ref?: string +} + +export interface ResolveVerificationStackOptions { + repoPath: string + ref?: string + descriptorPath?: string + commandRunner?: CommandRunner +} + +export class VerificationStackDescriptorError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'VerificationStackDescriptorError' + } +} + +export function loadVerificationStack(input: unknown, source = ''): VerificationStackDescriptor { + const parsed = VerificationStackDescriptorSchema.safeParse(input) + if (parsed.success) return parsed.data + + const details = parsed.error.issues.map((issue) => { + const path = issue.path.length ? issue.path.join('.') : '' + return `- ${path}: ${issue.message}` + }).join('\n') + throw new VerificationStackDescriptorError( + `Invalid verification-stack descriptor ${source}:\n${details}`, + { cause: parsed.error }, + ) +} + +export function parseVerificationStack(text: string, source = ''): VerificationStackDescriptor { + let input: unknown + try { + input = parseYaml(text) + } catch (cause) { + throw new VerificationStackDescriptorError( + `Could not parse verification-stack descriptor ${source}: ${errorMessage(cause)}`, + { cause }, + ) + } + return loadVerificationStack(input, source) +} + +export async function loadVerificationStackFile(filePath: string): Promise { + const absolutePath = resolve(filePath) + let text: string + try { + text = await readFile(absolutePath, 'utf8') + } catch (cause) { + throw new VerificationStackDescriptorError( + `Could not read verification-stack descriptor ${absolutePath}: ${errorMessage(cause)}`, + { cause }, + ) + } + return { + descriptor: parseVerificationStack(text, absolutePath), + descriptorPath: absolutePath, + rootDir: dirname(absolutePath), + } +} + +/** Resolve the default or overridden descriptor from a checkout or an arbitrary Git ref. */ +export async function resolveVerificationStackDescriptor( + options: ResolveVerificationStackOptions, +): Promise { + const repoPath = resolve(options.repoPath) + const descriptorPath = validateDescriptorPath(options.descriptorPath ?? DEFAULT_VERIFICATION_STACK_PATH) + + if (!options.ref) { + const absolutePath = resolveInside(repoPath, descriptorPath) + const loaded = await loadVerificationStackFile(absolutePath) + return { ...loaded, rootDir: repoPath, descriptorPath } + } + + const runner = options.commandRunner ?? new ProcessCommandRunner() + let commit: string + try { + const result = await runner.run('git', [ + '-C', repoPath, 'rev-parse', '--verify', `${options.ref}^{commit}`, + ]) + commit = result.stdout.trim() + } catch (cause) { + throw new VerificationStackDescriptorError( + `Could not resolve verification-stack Git ref ${JSON.stringify(options.ref)} in ${repoPath}: ${errorMessage(cause)}`, + { cause }, + ) + } + + try { + const result = await runner.run('git', ['-C', repoPath, 'show', `${commit}:${descriptorPath}`]) + return { + descriptor: parseVerificationStack(result.stdout, `${options.ref}:${descriptorPath}`), + descriptorPath, + rootDir: repoPath, + ref: commit, + } + } catch (cause) { + if (cause instanceof VerificationStackDescriptorError) throw cause + throw new VerificationStackDescriptorError( + `Could not read verification-stack descriptor ${descriptorPath} at ${JSON.stringify(options.ref)}: ${errorMessage(cause)}`, + { cause }, + ) + } +} + +function uniqueNames( + values: readonly { name: string }[], + field: string, + context: z.RefinementCtx, +): void { + const seen = new Set() + values.forEach((value, index) => { + if (seen.has(value.name)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: [field, index, 'name'], + message: `duplicate name ${JSON.stringify(value.name)}`, + }) + } + seen.add(value.name) + }) +} + +function validateDescriptorPath(value: string): string { + if (!value || isAbsolute(value) || value.split(/[\\/]/u).includes('..')) { + throw new VerificationStackDescriptorError( + `verification-stack descriptor path must stay inside the repository: ${JSON.stringify(value)}`, + ) + } + return value.replaceAll('\\', '/') +} + +export function resolveVerificationStackAsset(rootDir: string, assetPath: string): string { + return resolveInside(rootDir, assetPath) +} + +function resolveInside(root: string, child: string): string { + const absolute = resolve(root, child) + const fromRoot = relative(root, absolute) + if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) { + throw new VerificationStackDescriptorError( + `verification-stack asset path escapes the repository: ${JSON.stringify(child)}`, + ) + } + return absolute +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/index.ts b/src/index.ts index 2f25b135..93ffbf46 100644 --- a/src/index.ts +++ b/src/index.ts @@ -288,7 +288,61 @@ export type { TelemetrySink, FactoryEventReporter, FactoryEventReportResult, + Environment, + EnvironmentProvider, + EnvironmentSpec, + EnvironmentStatus, + KubernetesEnvironmentTarget, } from './ports' +export { + DEFAULT_VERIFICATION_STACK_PATH, + VERIFICATION_STACK_API_VERSION, + VERIFICATION_STACK_JSON_SCHEMA_URL, + VERIFICATION_STACK_KIND, + VerificationProbeSchema, + VerificationStackDescriptorError, + VerificationStackDescriptorSchema, + VerificationStackSourceSchema, + loadVerificationStack, + loadVerificationStackFile, + parseVerificationStack, + resolveVerificationStackAsset, + resolveVerificationStackDescriptor, +} from './environments/stack-descriptor' +export type { + LoadedVerificationStack, + ResolveVerificationStackOptions, + VerificationProbe, + VerificationStackDescriptor, + VerificationStackEndpoint, + VerificationStackReferenceGroup, + VerificationStackSeed, + VerificationStackService, + VerificationStackSource, +} from './environments/stack-descriptor' +export { + KubectlPortForwarder, + StackDeploymentError, + VerificationStackDeployer, + deployVerificationStack, +} from './environments/stack-deployer' +export type { + ManagedPortForward, + PortForwarder, + ReferenceResolutionContext, + StackDeployerOptions, + StackDeployment, + VerificationStackReferenceResolver, +} from './environments/stack-deployer' +export { KubernetesEnvironmentProvider } from './environments/kubernetes-provider' +export type { KubernetesEnvironmentProviderOptions } from './environments/kubernetes-provider' +export { CommandExecutionError, ProcessCommandRunner } from './environments/kubernetes-command' +export type { + CommandResult, + CommandRunner, + KubernetesConnection, + RunCommandOptions, +} from './environments/kubernetes-command' export { FACTORY_CLOUD_EVENT_CONTRACT_V1, FACTORY_CLOUD_EVENT_MAX_BATCH_SIZE, diff --git a/src/ports/environment.ts b/src/ports/environment.ts new file mode 100644 index 00000000..be0e7de0 --- /dev/null +++ b/src/ports/environment.ts @@ -0,0 +1,47 @@ +export type EnvironmentStatus = + | 'provisioning' + | 'ready' + | 'failed' + | 'destroying' + | 'destroyed' + +export interface KubernetesEnvironmentTarget { + type: 'kubernetes' + namespace: string + kubeconfig?: string + context?: string +} + +export interface EnvironmentSpec { + /** Stable caller-provided id. Providers generate one when it is omitted. */ + id?: string + ttl?: number + labels?: Record + bindings?: Record +} + +/** + * A provisioned, isolated verification target. Provider-specific connection + * details live in `target`; callers should otherwise treat environments as + * substrate agnostic. + */ +export interface Environment { + id: string + status: EnvironmentStatus + createdAt: string + /** Environment lifetime in milliseconds. */ + ttl: number + endpoints: Record + bindings: Record + target?: KubernetesEnvironmentTarget | { type: string; [key: string]: unknown } + /** Compatibility fields for providers whose native isolation primitive is named directly. */ + namespace?: string + dispatchNamespace?: string +} + +export interface EnvironmentProvider { + provision(spec: EnvironmentSpec): Promise + status(id: string): Promise + endpoints(id: string): Promise> + destroy(id: string): Promise +} diff --git a/src/ports/index.ts b/src/ports/index.ts index 834eb262..43453110 100644 --- a/src/ports/index.ts +++ b/src/ports/index.ts @@ -47,3 +47,10 @@ export type { AgentWorktree, AgentWorktreeManager, } from './worktree' +export type { + Environment, + EnvironmentProvider, + EnvironmentSpec, + EnvironmentStatus, + KubernetesEnvironmentTarget, +} from './environment' diff --git a/test/e2e/stack-deployer.e2e.ts b/test/e2e/stack-deployer.e2e.ts new file mode 100644 index 00000000..4937d482 --- /dev/null +++ b/test/e2e/stack-deployer.e2e.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict' +import { resolve } from 'node:path' + +import { + KubernetesEnvironmentProvider, + ProcessCommandRunner, + StackDeploymentError, + VerificationStackDeployer, + resolveVerificationStackDescriptor, + type StackDeployment, + type VerificationStackDescriptor, +} from '../../src/index.js' + +const root = resolve(import.meta.dirname, '../..') +const context = process.env.FACTORY_E2E_KUBE_CONTEXT +const kubeconfig = process.env.KUBECONFIG +const suffix = `${process.pid}-${Date.now().toString(36)}` +const provider = new KubernetesEnvironmentProvider({ + context, + kubeconfig, + namespacePrefix: 'factory-stack-e2e', + defaultTtl: 10 * 60_000, +}) +const runner = new ProcessCommandRunner() +const deployer = new VerificationStackDeployer({ + referenceResolver: { + resolve: async (reference) => { + if (reference === 'fixture://postgres/password') return 'factory-e2e-password' + return undefined + }, + }, +}) + +let successEnvironmentId: string | undefined +let failureEnvironmentId: string | undefined +let successDeployment: StackDeployment | undefined + +try { + const loaded = await resolveVerificationStackDescriptor({ + repoPath: root, + descriptorPath: 'test/fixtures/verification-stack/verification-stack.yaml', + }) + + const successEnvironment = await provider.provision({ id: `healthy-${suffix}` }) + successEnvironmentId = successEnvironment.id + assert.equal(await provider.status(successEnvironment.id), 'ready') + + successDeployment = await deployer.deploy(loaded, successEnvironment) + assert.deepEqual(Object.keys(successDeployment.endpoints), ['web']) + for (const [name, url] of Object.entries(successDeployment.endpoints)) { + const response = await fetch(url) + assert.equal(response.status, 200, `${name} returned HTTP ${response.status}`) + assert.match(await response.text(), /healthy/u, `${name} did not return its health body`) + } + + const namespace = successEnvironment.namespace! + const seeded = await runner.run('kubectl', [ + ...(kubeconfig ? ['--kubeconfig', kubeconfig] : []), + ...(context ? ['--context', context] : []), + '--namespace', namespace, + 'exec', 'deployment/postgres', '--', + 'psql', '-U', 'factory', '-d', 'factory', '-tAc', + 'SELECT value FROM verification_seed WHERE id = 1', + ], { timeoutMs: 30_000 }) + assert.equal(seeded.stdout.trim(), 'ran', 'the declared Postgres seed step did not run') + + const failureEnvironment = await provider.provision({ id: `unready-${suffix}` }) + failureEnvironmentId = failureEnvironment.id + const impossible = structuredClone(loaded.descriptor) as VerificationStackDescriptor + const web = impossible.services.find((service) => service.name === 'web')! + web.readiness = { + type: 'http', + port: 5999, + path: '/never-ready', + scheme: 'http', + expectedStatuses: [200], + timeoutSeconds: 4, + intervalSeconds: 0.5, + } + impossible.seeds = [] + impossible.endpoints = [] + const started = Date.now() + let readinessFailure: unknown + try { + await deployer.deploy({ ...loaded, descriptor: impossible }, failureEnvironment) + } catch (error) { + readinessFailure = error + } + const elapsed = Date.now() - started + assert(readinessFailure instanceof StackDeploymentError, 'unready stack unexpectedly deployed') + assert.equal(readinessFailure.stage, 'readiness') + assert.equal(readinessFailure.service, 'web') + assert.match(readinessFailure.message, /Service web readiness probe never became ready within 4s/u) + assert(elapsed < 15_000, `unready stack failure was not bounded (elapsed ${elapsed}ms)`) + + process.stdout.write( + `stack-deployer e2e passed: ${Object.keys(successDeployment.endpoints).length} endpoint(s), seed verified, bounded failure in ${elapsed}ms\n`, + ) +} finally { + await successDeployment?.dispose() + if (failureEnvironmentId) await provider.destroy(failureEnvironmentId) + if (successEnvironmentId) await provider.destroy(successEnvironmentId) +} diff --git a/test/fixtures/verification-stack/stack.yaml b/test/fixtures/verification-stack/stack.yaml new file mode 100644 index 00000000..ff86c2aa --- /dev/null +++ b/test/fixtures/verification-stack/stack.yaml @@ -0,0 +1,100 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres +spec: + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:16-alpine + env: + - name: POSTGRES_USER + value: factory + - name: POSTGRES_DB + value: factory + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-credentials + key: POSTGRES_PASSWORD + ports: + - name: postgres + containerPort: 5432 + readinessProbe: + exec: + command: [pg_isready, -U, factory, -d, factory] + periodSeconds: 1 + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + memory: 256Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres +spec: + selector: + app: postgres + ports: + - name: postgres + port: 5432 + targetPort: postgres +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web +spec: + replicas: 1 + selector: + matchLabels: + app: web + template: + metadata: + labels: + app: web + spec: + containers: + - name: web + image: hashicorp/http-echo:1.0.0 + args: [-listen=:5678, -text=healthy] + env: + - name: DATABASE_HOST + value: postgres + ports: + - name: http + containerPort: 5678 + readinessProbe: + httpGet: + path: /health + port: http + periodSeconds: 1 + resources: + requests: + cpu: 10m + memory: 8Mi + limits: + memory: 32Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: web +spec: + selector: + app: web + ports: + - name: http + port: 5678 + targetPort: http diff --git a/test/fixtures/verification-stack/verification-stack.yaml b/test/fixtures/verification-stack/verification-stack.yaml new file mode 100644 index 00000000..b2668d2c --- /dev/null +++ b/test/fixtures/verification-stack/verification-stack.yaml @@ -0,0 +1,50 @@ +apiVersion: factory.agentworkforce.dev/v1alpha1 +kind: VerificationStack +name: factory-stack-e2e +source: + type: manifests + paths: + - test/fixtures/verification-stack/stack.yaml +secrets: + - name: postgres-credentials + data: + POSTGRES_PASSWORD: + ref: fixture://postgres/password +services: + - name: web + workload: + kind: deployment + readiness: + type: http + port: 5678 + path: /health + timeoutSeconds: 180 + intervalSeconds: 1 + - name: postgres + workload: + kind: deployment + readiness: + type: exec + command: [pg_isready, -U, factory, -d, factory] + timeoutSeconds: 180 + intervalSeconds: 1 +seeds: + - type: exec + name: seed-postgres + service: postgres + command: + - psql + - -U + - factory + - -d + - factory + - -v + - ON_ERROR_STOP=1 + - -c + - "CREATE TABLE verification_seed (id integer PRIMARY KEY, value text NOT NULL); INSERT INTO verification_seed VALUES (1, 'ran');" + timeoutSeconds: 30 +endpoints: + - name: web + service: web + port: 5678 + path: /