@crewhaus/deployment-controller 0.1.3 → 0.1.5
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/dist/index.d.ts +44 -0
- package/dist/index.js +76 -0
- package/package.json +11 -8
- package/src/index.test.ts +0 -221
- package/src/index.ts +0 -122
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
export declare class DeploymentError extends CrewhausError {
|
|
17
|
+
readonly name = "DeploymentError";
|
|
18
|
+
constructor(message: string, cause?: unknown);
|
|
19
|
+
}
|
|
20
|
+
export type DeploymentRecordPayload = {
|
|
21
|
+
readonly action: "promote" | "rollback";
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly fromEnv?: string;
|
|
24
|
+
readonly toEnv?: string;
|
|
25
|
+
readonly env?: string;
|
|
26
|
+
readonly fromVersion?: string;
|
|
27
|
+
readonly toVersion: string;
|
|
28
|
+
readonly tenantId?: string;
|
|
29
|
+
readonly actor?: string;
|
|
30
|
+
readonly ts: number;
|
|
31
|
+
};
|
|
32
|
+
export type DeploymentControllerOptions = {
|
|
33
|
+
readonly registry: RegistryAdapter;
|
|
34
|
+
readonly auditLog?: AuditLog;
|
|
35
|
+
/** Optional tenant scope. */
|
|
36
|
+
readonly tenantId?: string;
|
|
37
|
+
/** Identifier (user / service) audit-logged with each action. */
|
|
38
|
+
readonly actor?: string;
|
|
39
|
+
};
|
|
40
|
+
export interface DeploymentController {
|
|
41
|
+
promote(name: string, fromEnv: string, toEnv: string): Promise<DeploymentRecordPayload>;
|
|
42
|
+
rollback(name: string, env: string, version: string): Promise<DeploymentRecordPayload>;
|
|
43
|
+
}
|
|
44
|
+
export declare function createDeploymentController(opts: DeploymentControllerOptions): DeploymentController;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
2
|
+
export class DeploymentError extends CrewhausError {
|
|
3
|
+
name = "DeploymentError";
|
|
4
|
+
constructor(message, cause) {
|
|
5
|
+
super("config", message, cause);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export function createDeploymentController(opts) {
|
|
9
|
+
async function audit(payload) {
|
|
10
|
+
if (!opts.auditLog)
|
|
11
|
+
return;
|
|
12
|
+
await opts.auditLog.append({
|
|
13
|
+
kind: "deployment_action",
|
|
14
|
+
payload,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
async promote(name, fromEnv, toEnv) {
|
|
19
|
+
const sourceVersion = opts.tenantId
|
|
20
|
+
? await opts.registry.aliasForTenant(opts.tenantId, name, fromEnv)
|
|
21
|
+
: await opts.registry.aliasFor(name, fromEnv);
|
|
22
|
+
if (!sourceVersion) {
|
|
23
|
+
throw new DeploymentError(`cannot promote ${name}: ${fromEnv} has no pin to copy from`);
|
|
24
|
+
}
|
|
25
|
+
const previousTo = opts.tenantId
|
|
26
|
+
? await opts.registry.aliasForTenant(opts.tenantId, name, toEnv)
|
|
27
|
+
: await opts.registry.aliasFor(name, toEnv);
|
|
28
|
+
if (opts.tenantId) {
|
|
29
|
+
await opts.registry.pinForTenant(opts.tenantId, name, toEnv, sourceVersion);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
await opts.registry.pin(name, toEnv, sourceVersion);
|
|
33
|
+
}
|
|
34
|
+
const record = {
|
|
35
|
+
action: "promote",
|
|
36
|
+
name,
|
|
37
|
+
fromEnv,
|
|
38
|
+
toEnv,
|
|
39
|
+
...(previousTo !== undefined ? { fromVersion: previousTo } : {}),
|
|
40
|
+
toVersion: sourceVersion,
|
|
41
|
+
...(opts.tenantId !== undefined ? { tenantId: opts.tenantId } : {}),
|
|
42
|
+
...(opts.actor !== undefined ? { actor: opts.actor } : {}),
|
|
43
|
+
ts: Date.now(),
|
|
44
|
+
};
|
|
45
|
+
await audit(record);
|
|
46
|
+
return record;
|
|
47
|
+
},
|
|
48
|
+
async rollback(name, env, version) {
|
|
49
|
+
const all = await opts.registry.list(name);
|
|
50
|
+
if (!all.includes(version)) {
|
|
51
|
+
throw new DeploymentError(`cannot rollback ${name} ${env} → ${version}: version not in registry`);
|
|
52
|
+
}
|
|
53
|
+
const previous = opts.tenantId
|
|
54
|
+
? await opts.registry.aliasForTenant(opts.tenantId, name, env)
|
|
55
|
+
: await opts.registry.aliasFor(name, env);
|
|
56
|
+
if (opts.tenantId) {
|
|
57
|
+
await opts.registry.pinForTenant(opts.tenantId, name, env, version);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
await opts.registry.pin(name, env, version);
|
|
61
|
+
}
|
|
62
|
+
const record = {
|
|
63
|
+
action: "rollback",
|
|
64
|
+
name,
|
|
65
|
+
env,
|
|
66
|
+
...(previous !== undefined ? { fromVersion: previous } : {}),
|
|
67
|
+
toVersion: version,
|
|
68
|
+
...(opts.tenantId !== undefined ? { tenantId: opts.tenantId } : {}),
|
|
69
|
+
...(opts.actor !== undefined ? { actor: opts.actor } : {}),
|
|
70
|
+
ts: Date.now(),
|
|
71
|
+
};
|
|
72
|
+
await audit(record);
|
|
73
|
+
return record;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/deployment-controller",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Version pinning + promotion: promote(name, fromEnv, toEnv) / rollback. Records every action to audit-log.",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"test": "bun test src"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
|
-
"@crewhaus/audit-log": "0.1.
|
|
16
|
-
"@crewhaus/errors": "0.1.
|
|
17
|
-
"@crewhaus/spec-registry": "0.1.
|
|
18
|
+
"@crewhaus/audit-log": "0.1.5",
|
|
19
|
+
"@crewhaus/errors": "0.1.5",
|
|
20
|
+
"@crewhaus/spec-registry": "0.1.5"
|
|
18
21
|
},
|
|
19
22
|
"license": "Apache-2.0",
|
|
20
23
|
"author": {
|
|
@@ -34,5 +37,5 @@
|
|
|
34
37
|
"publishConfig": {
|
|
35
38
|
"access": "public"
|
|
36
39
|
},
|
|
37
|
-
"files": ["
|
|
40
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
38
41
|
}
|
package/src/index.test.ts
DELETED
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Section 28 — `deployment-controller` tests:
|
|
3
|
-
* - T3 promote + rollback round-trip with audit-log assertions
|
|
4
|
-
* - tenant-scoped promote/rollback overlay isolation
|
|
5
|
-
* - audit-after-pin ordering + payload-shape regression guards (mocked)
|
|
6
|
-
*/
|
|
7
|
-
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
8
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
9
|
-
import { tmpdir } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
import type { AppendInput, AuditLog, AuditRecord } from "@crewhaus/audit-log";
|
|
12
|
-
import { openAuditLog } from "@crewhaus/audit-log";
|
|
13
|
-
import type { RegistryAdapter } from "@crewhaus/spec-registry";
|
|
14
|
-
import { createFileBackedRegistry } from "@crewhaus/spec-registry";
|
|
15
|
-
import { DeploymentError, createDeploymentController } from "./index";
|
|
16
|
-
|
|
17
|
-
let tmpRoot = "";
|
|
18
|
-
|
|
19
|
-
beforeEach(() => {
|
|
20
|
-
tmpRoot = mkdtempSync(join(tmpdir(), "deploy-test-"));
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
afterEach(() => {
|
|
24
|
-
rmSync(tmpRoot, { recursive: true, force: true });
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
async function readAudit(rootDir: string): Promise<AuditRecord[]> {
|
|
28
|
-
const log = await openAuditLog({ rootDir });
|
|
29
|
-
const out: AuditRecord[] = [];
|
|
30
|
-
for await (const r of log.read()) out.push(r);
|
|
31
|
-
return out;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
describe("deployment-controller — T3 promote/rollback", () => {
|
|
35
|
-
test("promote copies pin from staging to prod", async () => {
|
|
36
|
-
const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
|
|
37
|
-
await reg.put("hello", "v1", "x");
|
|
38
|
-
await reg.put("hello", "v2", "y");
|
|
39
|
-
await reg.pin("hello", "staging", "v2");
|
|
40
|
-
const audit = await openAuditLog({ rootDir: join(tmpRoot, "audit") });
|
|
41
|
-
const ctrl = createDeploymentController({ registry: reg, auditLog: audit, actor: "alice" });
|
|
42
|
-
const rec = await ctrl.promote("hello", "staging", "prod");
|
|
43
|
-
expect(rec.action).toBe("promote");
|
|
44
|
-
expect(rec.toVersion).toBe("v2");
|
|
45
|
-
expect(rec.fromVersion).toBeUndefined();
|
|
46
|
-
expect(await reg.aliasFor("hello", "prod")).toBe("v2");
|
|
47
|
-
const records = await readAudit(join(tmpRoot, "audit"));
|
|
48
|
-
expect(records.length).toBe(1);
|
|
49
|
-
expect(records[0]?.kind).toBe("deployment_action");
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("promote captures previous prod pin in fromVersion", async () => {
|
|
53
|
-
const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
|
|
54
|
-
await reg.put("hello", "v1", "x");
|
|
55
|
-
await reg.put("hello", "v2", "y");
|
|
56
|
-
await reg.pin("hello", "staging", "v2");
|
|
57
|
-
await reg.pin("hello", "prod", "v1");
|
|
58
|
-
const ctrl = createDeploymentController({ registry: reg });
|
|
59
|
-
const rec = await ctrl.promote("hello", "staging", "prod");
|
|
60
|
-
expect(rec.fromVersion).toBe("v1");
|
|
61
|
-
expect(rec.toVersion).toBe("v2");
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("rollback re-pins to a known prior version", async () => {
|
|
65
|
-
const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
|
|
66
|
-
await reg.put("hello", "v1", "x");
|
|
67
|
-
await reg.put("hello", "v2", "y");
|
|
68
|
-
await reg.pin("hello", "prod", "v2");
|
|
69
|
-
const audit = await openAuditLog({ rootDir: join(tmpRoot, "audit") });
|
|
70
|
-
const ctrl = createDeploymentController({ registry: reg, auditLog: audit });
|
|
71
|
-
const rec = await ctrl.rollback("hello", "prod", "v1");
|
|
72
|
-
expect(rec.action).toBe("rollback");
|
|
73
|
-
expect(rec.fromVersion).toBe("v2");
|
|
74
|
-
expect(rec.toVersion).toBe("v1");
|
|
75
|
-
expect(await reg.aliasFor("hello", "prod")).toBe("v1");
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("promote without source pin throws", async () => {
|
|
79
|
-
const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
|
|
80
|
-
await reg.put("hello", "v1", "x");
|
|
81
|
-
const ctrl = createDeploymentController({ registry: reg });
|
|
82
|
-
expect(ctrl.promote("hello", "staging", "prod")).rejects.toBeInstanceOf(DeploymentError);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("rollback to unknown version throws", async () => {
|
|
86
|
-
const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
|
|
87
|
-
await reg.put("hello", "v1", "x");
|
|
88
|
-
const ctrl = createDeploymentController({ registry: reg });
|
|
89
|
-
expect(ctrl.rollback("hello", "prod", "v999")).rejects.toBeInstanceOf(DeploymentError);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test("tenant-scoped controller writes only tenant overlay, not global", async () => {
|
|
93
|
-
const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
|
|
94
|
-
await reg.put("hello", "v1", "x");
|
|
95
|
-
await reg.put("hello", "v2", "y");
|
|
96
|
-
await reg.pin("hello", "staging", "v2");
|
|
97
|
-
await reg.pin("hello", "prod", "v1");
|
|
98
|
-
const ctrl = createDeploymentController({ registry: reg, tenantId: "tenant-a" });
|
|
99
|
-
await ctrl.promote("hello", "staging", "prod");
|
|
100
|
-
// Global prod stays at v1; tenant-a overlay points to v2.
|
|
101
|
-
expect(await reg.aliasFor("hello", "prod")).toBe("v1");
|
|
102
|
-
expect(await reg.aliasForTenant("tenant-a", "hello", "prod")).toBe("v2");
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test("tenant-scoped rollback re-pins only the tenant overlay and tags the record", async () => {
|
|
106
|
-
const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
|
|
107
|
-
await reg.put("hello", "v1", "x");
|
|
108
|
-
await reg.put("hello", "v2", "y");
|
|
109
|
-
await reg.pin("hello", "prod", "v2"); // global prod = v2
|
|
110
|
-
await reg.pinForTenant("tenant-a", "hello", "prod", "v2"); // tenant prod = v2
|
|
111
|
-
const ctrl = createDeploymentController({
|
|
112
|
-
registry: reg,
|
|
113
|
-
tenantId: "tenant-a",
|
|
114
|
-
actor: "bob",
|
|
115
|
-
});
|
|
116
|
-
const rec = await ctrl.rollback("hello", "prod", "v1");
|
|
117
|
-
expect(rec.action).toBe("rollback");
|
|
118
|
-
expect(rec.fromVersion).toBe("v2"); // previous tenant alias captured
|
|
119
|
-
expect(rec.toVersion).toBe("v1");
|
|
120
|
-
expect(rec.tenantId).toBe("tenant-a");
|
|
121
|
-
expect(rec.actor).toBe("bob");
|
|
122
|
-
// Global prod is untouched; only the tenant overlay rolled back.
|
|
123
|
-
expect(await reg.aliasFor("hello", "prod")).toBe("v2");
|
|
124
|
-
expect(await reg.aliasForTenant("tenant-a", "hello", "prod")).toBe("v1");
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test("rollback with no prior pin omits fromVersion", async () => {
|
|
128
|
-
const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
|
|
129
|
-
await reg.put("hello", "v1", "x");
|
|
130
|
-
// No pin for `prod` yet.
|
|
131
|
-
const ctrl = createDeploymentController({ registry: reg });
|
|
132
|
-
const rec = await ctrl.rollback("hello", "prod", "v1");
|
|
133
|
-
expect(rec.fromVersion).toBeUndefined();
|
|
134
|
-
expect(rec.toVersion).toBe("v1");
|
|
135
|
-
expect(await reg.aliasFor("hello", "prod")).toBe("v1");
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
test("promote audit payload carries tenantId + actor and the deployment_action kind", async () => {
|
|
139
|
-
const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
|
|
140
|
-
await reg.put("hello", "v1", "x");
|
|
141
|
-
await reg.pin("hello", "staging", "v1");
|
|
142
|
-
const appended: AppendInput[] = [];
|
|
143
|
-
const auditLog: AuditLog = {
|
|
144
|
-
append: mock(async (input: AppendInput): Promise<AuditRecord> => {
|
|
145
|
-
appended.push(input);
|
|
146
|
-
return {
|
|
147
|
-
ts: 0,
|
|
148
|
-
version: 1,
|
|
149
|
-
kind: input.kind,
|
|
150
|
-
seq: 0,
|
|
151
|
-
payload: input.payload,
|
|
152
|
-
prevHash: "",
|
|
153
|
-
hash: "",
|
|
154
|
-
};
|
|
155
|
-
}),
|
|
156
|
-
read: () => {
|
|
157
|
-
throw new Error("read should not be called");
|
|
158
|
-
},
|
|
159
|
-
};
|
|
160
|
-
const ctrl = createDeploymentController({
|
|
161
|
-
registry: reg,
|
|
162
|
-
auditLog,
|
|
163
|
-
tenantId: "tenant-a",
|
|
164
|
-
actor: "carol",
|
|
165
|
-
});
|
|
166
|
-
const rec = await ctrl.promote("hello", "staging", "prod");
|
|
167
|
-
expect(appended.length).toBe(1);
|
|
168
|
-
expect(appended[0]?.kind).toBe("deployment_action");
|
|
169
|
-
expect(appended[0]?.payload).toBe(rec); // the exact record is audit-logged
|
|
170
|
-
expect(rec.tenantId).toBe("tenant-a");
|
|
171
|
-
expect(rec.actor).toBe("carol");
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
test("audit is appended only AFTER the registry pin succeeds (no misleading history)", async () => {
|
|
175
|
-
const calls: string[] = [];
|
|
176
|
-
const pinError = new Error("pin failed: storage down");
|
|
177
|
-
// Minimal RegistryAdapter mock; only the methods promote() touches are real.
|
|
178
|
-
const reg: RegistryAdapter = {
|
|
179
|
-
aliasFor: mock(async (): Promise<string | undefined> => {
|
|
180
|
-
calls.push("aliasFor");
|
|
181
|
-
return "v2";
|
|
182
|
-
}),
|
|
183
|
-
pin: mock(async (): Promise<void> => {
|
|
184
|
-
calls.push("pin");
|
|
185
|
-
throw pinError;
|
|
186
|
-
}),
|
|
187
|
-
// Unused by this path — present to satisfy the interface.
|
|
188
|
-
put: async () => {},
|
|
189
|
-
get: async () => "",
|
|
190
|
-
list: async () => [],
|
|
191
|
-
listSpecs: async () => [],
|
|
192
|
-
delete: async () => {},
|
|
193
|
-
manifest: async () => ({ versions: [], pins: {} }),
|
|
194
|
-
pinForTenant: async () => {},
|
|
195
|
-
aliasForTenant: async () => undefined,
|
|
196
|
-
};
|
|
197
|
-
const auditAppend = mock(async (input: AppendInput): Promise<AuditRecord> => {
|
|
198
|
-
calls.push("audit");
|
|
199
|
-
return {
|
|
200
|
-
ts: 0,
|
|
201
|
-
version: 1,
|
|
202
|
-
kind: input.kind,
|
|
203
|
-
seq: 0,
|
|
204
|
-
payload: input.payload,
|
|
205
|
-
prevHash: "",
|
|
206
|
-
hash: "",
|
|
207
|
-
};
|
|
208
|
-
});
|
|
209
|
-
const auditLog: AuditLog = {
|
|
210
|
-
append: auditAppend,
|
|
211
|
-
read: () => {
|
|
212
|
-
throw new Error("read should not be called");
|
|
213
|
-
},
|
|
214
|
-
};
|
|
215
|
-
const ctrl = createDeploymentController({ registry: reg, auditLog });
|
|
216
|
-
await expect(ctrl.promote("hello", "staging", "prod")).rejects.toBe(pinError);
|
|
217
|
-
// The pin threw, so NO audit record was written for the failed deploy.
|
|
218
|
-
expect(auditAppend).not.toHaveBeenCalled();
|
|
219
|
-
expect(calls).toEqual(["aliasFor", "aliasFor", "pin"]);
|
|
220
|
-
});
|
|
221
|
-
});
|
package/src/index.ts
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
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
|
-
}
|