@crewhaus/deployment-controller 0.1.1 → 0.1.3
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 +8 -13
- package/src/index.test.ts +123 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/deployment-controller",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Version pinning + promotion: promote(name, fromEnv, toEnv) / rollback. Records every action to audit-log.",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -12,15 +12,15 @@
|
|
|
12
12
|
"test": "bun test src"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@crewhaus/audit-log": "0.1.
|
|
16
|
-
"@crewhaus/errors": "0.1.
|
|
17
|
-
"@crewhaus/spec-registry": "0.1.
|
|
15
|
+
"@crewhaus/audit-log": "0.1.3",
|
|
16
|
+
"@crewhaus/errors": "0.1.3",
|
|
17
|
+
"@crewhaus/spec-registry": "0.1.3"
|
|
18
18
|
},
|
|
19
19
|
"license": "Apache-2.0",
|
|
20
20
|
"author": {
|
|
21
21
|
"name": "Max Meier",
|
|
22
|
-
"email": "max@
|
|
23
|
-
"url": "https://
|
|
22
|
+
"email": "max@crewhaus.ai",
|
|
23
|
+
"url": "https://crewhaus.ai"
|
|
24
24
|
},
|
|
25
25
|
"repository": {
|
|
26
26
|
"type": "git",
|
|
@@ -32,12 +32,7 @@
|
|
|
32
32
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
|
35
|
-
"access": "
|
|
35
|
+
"access": "public"
|
|
36
36
|
},
|
|
37
|
-
"files": [
|
|
38
|
-
"src",
|
|
39
|
-
"README.md",
|
|
40
|
-
"LICENSE",
|
|
41
|
-
"NOTICE"
|
|
42
|
-
]
|
|
37
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
43
38
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Section 28 — `deployment-controller` tests:
|
|
3
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)
|
|
4
6
|
*/
|
|
5
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
6
8
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
7
9
|
import { tmpdir } from "node:os";
|
|
8
10
|
import { join } from "node:path";
|
|
9
|
-
import {
|
|
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";
|
|
10
14
|
import { createFileBackedRegistry } from "@crewhaus/spec-registry";
|
|
11
15
|
import { DeploymentError, createDeploymentController } from "./index";
|
|
12
16
|
|
|
@@ -97,4 +101,121 @@ describe("deployment-controller — T3 promote/rollback", () => {
|
|
|
97
101
|
expect(await reg.aliasFor("hello", "prod")).toBe("v1");
|
|
98
102
|
expect(await reg.aliasForTenant("tenant-a", "hello", "prod")).toBe("v2");
|
|
99
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
|
+
});
|
|
100
221
|
});
|