@crewhaus/deployment-controller 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@crewhaus/deployment-controller",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Version pinning + promotion: promote(name, fromEnv, toEnv) / rollback. Records every action to audit-log.",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test src"
13
+ },
14
+ "dependencies": {
15
+ "@crewhaus/audit-log": "0.0.0",
16
+ "@crewhaus/errors": "0.0.0",
17
+ "@crewhaus/spec-registry": "0.0.0"
18
+ },
19
+ "license": "Apache-2.0",
20
+ "author": {
21
+ "name": "Max Meier",
22
+ "email": "max@studiomax.io",
23
+ "url": "https://studiomax.io"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/crewhaus/factory.git",
28
+ "directory": "packages/deployment-controller"
29
+ },
30
+ "homepage": "https://github.com/crewhaus/factory/tree/main/packages/deployment-controller#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/crewhaus/factory/issues"
33
+ },
34
+ "publishConfig": {
35
+ "access": "restricted"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "README.md",
40
+ "LICENSE",
41
+ "NOTICE"
42
+ ]
43
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Section 28 — `deployment-controller` tests:
3
+ * - T3 promote + rollback round-trip with audit-log assertions
4
+ */
5
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
6
+ import { mkdtempSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { type AuditRecord, openAuditLog } from "@crewhaus/audit-log";
10
+ import { createFileBackedRegistry } from "@crewhaus/spec-registry";
11
+ import { DeploymentError, createDeploymentController } from "./index";
12
+
13
+ let tmpRoot = "";
14
+
15
+ beforeEach(() => {
16
+ tmpRoot = mkdtempSync(join(tmpdir(), "deploy-test-"));
17
+ });
18
+
19
+ afterEach(() => {
20
+ rmSync(tmpRoot, { recursive: true, force: true });
21
+ });
22
+
23
+ async function readAudit(rootDir: string): Promise<AuditRecord[]> {
24
+ const log = await openAuditLog({ rootDir });
25
+ const out: AuditRecord[] = [];
26
+ for await (const r of log.read()) out.push(r);
27
+ return out;
28
+ }
29
+
30
+ describe("deployment-controller — T3 promote/rollback", () => {
31
+ test("promote copies pin from staging to prod", async () => {
32
+ const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
33
+ await reg.put("hello", "v1", "x");
34
+ await reg.put("hello", "v2", "y");
35
+ await reg.pin("hello", "staging", "v2");
36
+ const audit = await openAuditLog({ rootDir: join(tmpRoot, "audit") });
37
+ const ctrl = createDeploymentController({ registry: reg, auditLog: audit, actor: "alice" });
38
+ const rec = await ctrl.promote("hello", "staging", "prod");
39
+ expect(rec.action).toBe("promote");
40
+ expect(rec.toVersion).toBe("v2");
41
+ expect(rec.fromVersion).toBeUndefined();
42
+ expect(await reg.aliasFor("hello", "prod")).toBe("v2");
43
+ const records = await readAudit(join(tmpRoot, "audit"));
44
+ expect(records.length).toBe(1);
45
+ expect(records[0]?.kind).toBe("deployment_action");
46
+ });
47
+
48
+ test("promote captures previous prod pin in fromVersion", async () => {
49
+ const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
50
+ await reg.put("hello", "v1", "x");
51
+ await reg.put("hello", "v2", "y");
52
+ await reg.pin("hello", "staging", "v2");
53
+ await reg.pin("hello", "prod", "v1");
54
+ const ctrl = createDeploymentController({ registry: reg });
55
+ const rec = await ctrl.promote("hello", "staging", "prod");
56
+ expect(rec.fromVersion).toBe("v1");
57
+ expect(rec.toVersion).toBe("v2");
58
+ });
59
+
60
+ test("rollback re-pins to a known prior version", async () => {
61
+ const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
62
+ await reg.put("hello", "v1", "x");
63
+ await reg.put("hello", "v2", "y");
64
+ await reg.pin("hello", "prod", "v2");
65
+ const audit = await openAuditLog({ rootDir: join(tmpRoot, "audit") });
66
+ const ctrl = createDeploymentController({ registry: reg, auditLog: audit });
67
+ const rec = await ctrl.rollback("hello", "prod", "v1");
68
+ expect(rec.action).toBe("rollback");
69
+ expect(rec.fromVersion).toBe("v2");
70
+ expect(rec.toVersion).toBe("v1");
71
+ expect(await reg.aliasFor("hello", "prod")).toBe("v1");
72
+ });
73
+
74
+ test("promote without source pin throws", async () => {
75
+ const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
76
+ await reg.put("hello", "v1", "x");
77
+ const ctrl = createDeploymentController({ registry: reg });
78
+ expect(ctrl.promote("hello", "staging", "prod")).rejects.toBeInstanceOf(DeploymentError);
79
+ });
80
+
81
+ test("rollback to unknown version throws", async () => {
82
+ const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
83
+ await reg.put("hello", "v1", "x");
84
+ const ctrl = createDeploymentController({ registry: reg });
85
+ expect(ctrl.rollback("hello", "prod", "v999")).rejects.toBeInstanceOf(DeploymentError);
86
+ });
87
+
88
+ test("tenant-scoped controller writes only tenant overlay, not global", async () => {
89
+ const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
90
+ await reg.put("hello", "v1", "x");
91
+ await reg.put("hello", "v2", "y");
92
+ await reg.pin("hello", "staging", "v2");
93
+ await reg.pin("hello", "prod", "v1");
94
+ const ctrl = createDeploymentController({ registry: reg, tenantId: "tenant-a" });
95
+ await ctrl.promote("hello", "staging", "prod");
96
+ // Global prod stays at v1; tenant-a overlay points to v2.
97
+ expect(await reg.aliasFor("hello", "prod")).toBe("v1");
98
+ expect(await reg.aliasForTenant("tenant-a", "hello", "prod")).toBe("v2");
99
+ });
100
+ });
package/src/index.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Section 28 — `deployment-controller`. Promote / rollback over the
3
+ * §28 spec-registry. Records every action to the §20 audit-log under
4
+ * `kind: "deployment_action"` so the chain becomes the deploy history.
5
+ *
6
+ * promote(name, fromEnv, toEnv) copies the source-env's pinned
7
+ * version to the destination env.
8
+ * rollback(name, env, version) re-pins the env to a known prior version.
9
+ *
10
+ * Both throw cleanly when the source pin doesn't exist or the rollback
11
+ * version isn't in the registry.
12
+ */
13
+ import type { AuditLog } from "@crewhaus/audit-log";
14
+ import { CrewhausError } from "@crewhaus/errors";
15
+ import type { RegistryAdapter } from "@crewhaus/spec-registry";
16
+
17
+ export class DeploymentError extends CrewhausError {
18
+ override readonly name = "DeploymentError";
19
+ constructor(message: string, cause?: unknown) {
20
+ super("config", message, cause);
21
+ }
22
+ }
23
+
24
+ export type DeploymentRecordPayload = {
25
+ readonly action: "promote" | "rollback";
26
+ readonly name: string;
27
+ readonly fromEnv?: string;
28
+ readonly toEnv?: string;
29
+ readonly env?: string;
30
+ readonly fromVersion?: string;
31
+ readonly toVersion: string;
32
+ readonly tenantId?: string;
33
+ readonly actor?: string;
34
+ readonly ts: number;
35
+ };
36
+
37
+ export type DeploymentControllerOptions = {
38
+ readonly registry: RegistryAdapter;
39
+ readonly auditLog?: AuditLog;
40
+ /** Optional tenant scope. */
41
+ readonly tenantId?: string;
42
+ /** Identifier (user / service) audit-logged with each action. */
43
+ readonly actor?: string;
44
+ };
45
+
46
+ export interface DeploymentController {
47
+ promote(name: string, fromEnv: string, toEnv: string): Promise<DeploymentRecordPayload>;
48
+ rollback(name: string, env: string, version: string): Promise<DeploymentRecordPayload>;
49
+ }
50
+
51
+ export function createDeploymentController(
52
+ opts: DeploymentControllerOptions,
53
+ ): DeploymentController {
54
+ async function audit(payload: DeploymentRecordPayload): Promise<void> {
55
+ if (!opts.auditLog) return;
56
+ await opts.auditLog.append({
57
+ kind: "deployment_action",
58
+ payload,
59
+ });
60
+ }
61
+
62
+ return {
63
+ async promote(name, fromEnv, toEnv): Promise<DeploymentRecordPayload> {
64
+ const sourceVersion = opts.tenantId
65
+ ? await opts.registry.aliasForTenant(opts.tenantId, name, fromEnv)
66
+ : await opts.registry.aliasFor(name, fromEnv);
67
+ if (!sourceVersion) {
68
+ throw new DeploymentError(`cannot promote ${name}: ${fromEnv} has no pin to copy from`);
69
+ }
70
+ const previousTo = opts.tenantId
71
+ ? await opts.registry.aliasForTenant(opts.tenantId, name, toEnv)
72
+ : await opts.registry.aliasFor(name, toEnv);
73
+ if (opts.tenantId) {
74
+ await opts.registry.pinForTenant(opts.tenantId, name, toEnv, sourceVersion);
75
+ } else {
76
+ await opts.registry.pin(name, toEnv, sourceVersion);
77
+ }
78
+ const record: DeploymentRecordPayload = {
79
+ action: "promote",
80
+ name,
81
+ fromEnv,
82
+ toEnv,
83
+ ...(previousTo !== undefined ? { fromVersion: previousTo } : {}),
84
+ toVersion: sourceVersion,
85
+ ...(opts.tenantId !== undefined ? { tenantId: opts.tenantId } : {}),
86
+ ...(opts.actor !== undefined ? { actor: opts.actor } : {}),
87
+ ts: Date.now(),
88
+ };
89
+ await audit(record);
90
+ return record;
91
+ },
92
+
93
+ async rollback(name, env, version): Promise<DeploymentRecordPayload> {
94
+ const all = await opts.registry.list(name);
95
+ if (!all.includes(version)) {
96
+ throw new DeploymentError(
97
+ `cannot rollback ${name} ${env} → ${version}: version not in registry`,
98
+ );
99
+ }
100
+ const previous = opts.tenantId
101
+ ? await opts.registry.aliasForTenant(opts.tenantId, name, env)
102
+ : await opts.registry.aliasFor(name, env);
103
+ if (opts.tenantId) {
104
+ await opts.registry.pinForTenant(opts.tenantId, name, env, version);
105
+ } else {
106
+ await opts.registry.pin(name, env, version);
107
+ }
108
+ const record: DeploymentRecordPayload = {
109
+ action: "rollback",
110
+ name,
111
+ env,
112
+ ...(previous !== undefined ? { fromVersion: previous } : {}),
113
+ toVersion: version,
114
+ ...(opts.tenantId !== undefined ? { tenantId: opts.tenantId } : {}),
115
+ ...(opts.actor !== undefined ? { actor: opts.actor } : {}),
116
+ ts: Date.now(),
117
+ };
118
+ await audit(record);
119
+ return record;
120
+ },
121
+ };
122
+ }