@bongos/core 1.19.354 → 1.19.356
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/.bongos-core.json +48 -28
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0190-settings-apply-in-place-env-patch.md +59 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +5 -3
- package/modules/provisioning/migrations/provisioning_010_settings_apply.sql +24 -0
- package/modules/provisioning/provisioning.js +65 -23
- package/modules/provisioning/routes/provisioning.js +75 -7
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +15 -2
- package/scripts/gds/provision-units.js +58 -0
- package/scripts/gds/provision.js +142 -12
- package/src/bongos/serve-internal.js +16 -2
- package/src/branding.js +11 -0
- package/src/module-api.js +5 -1
- package/tests/currency_label.mjs +12 -1
- package/tests/instance_manifest.mjs +8 -0
- package/tests/landing_page.mjs +18 -0
- package/tests/projects_hub_pre_uat.mjs +14 -0
- package/tests/provision_settings_apply.mjs +464 -0
- package/tests/provisioning_settings.mjs +8 -4
- package/tests/provisioning_settings_apply.mjs +372 -0
- package/tests/provisioning_settings_env.mjs +26 -9
- package/tests/provisioning_teardown_intent.mjs +5 -4
|
@@ -498,3 +498,17 @@ test('the done panel re-learns the GitHub-App leg from the instance read, yieldi
|
|
|
498
498
|
assert.ok(seedAt > 0 && seedAt < statusAt, 'the manifest seed lands before the status re-render, so one renderSteps() paints both');
|
|
499
499
|
assert.match(fn('enter'), /if \(created\.manifest\) \{ live\.manifest = created\.manifest; live\.appSlug = created\.app_slug \|\| null; \}/, 'the sessionStorage bridge stays as the pre-first-tick seed');
|
|
500
500
|
});
|
|
501
|
+
|
|
502
|
+
/* ── a settings push is named on both surfaces, by state, with no duration promise (task 1003140) ── */
|
|
503
|
+
test('a queued or running settings push is named on the card strip AND the done panel — by state, no "briefly"', () => {
|
|
504
|
+
assert.equal((SCRIPT.match(/settings: \{ names: \['Change saved', 'Restarting to apply it', 'Applied'\], cls: '' \}/g) || []).length, 2,
|
|
505
|
+
'FLOWS (card) and PROG_FLOWS (done panel) both carry the settings flow');
|
|
506
|
+
const strip = fn('stripFor');
|
|
507
|
+
assert.match(strip, /a === 'settings-apply'/, 'the card strip names the intent');
|
|
508
|
+
assert.match(strip, /A settings change is queued — this card updates when it starts\./, 'pending: queued, not "restarting"');
|
|
509
|
+
assert.match(strip, /Applying a settings change — the project restarts to pick it up\./, 'running: the restart is said plainly');
|
|
510
|
+
const prog = fn('renderProgress');
|
|
511
|
+
assert.match(prog, /oi\.action === 'settings-apply'/, 'the done panel has the same branch — a restarting project is not read as "isn’t answering"');
|
|
512
|
+
assert.match(prog, /A settings change is queued — this panel updates when it starts\./);
|
|
513
|
+
assert.doesNotMatch(strip + prog, /briefly|shortly|soon\b|minute|second/i, 'no wall-clock or duration promise (F16)');
|
|
514
|
+
});
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
// tests/provision_settings_apply.mjs
|
|
2
|
+
//
|
|
3
|
+
// The runner half of the live settings push (task 1003140, goal 1000058):
|
|
4
|
+
// scripts/gds/provision.js settingsApplyInstance + the in-place env patch it
|
|
5
|
+
// rides on (provision-units.js upsertEnvVars), the read-back (settingsConsumed),
|
|
6
|
+
// and the run-intents dispatch. Hermetic — exec / writeFile / db are recording
|
|
7
|
+
// fakes; no shell, no DB, no network (the tests/provision.mjs pattern).
|
|
8
|
+
//
|
|
9
|
+
// The load-bearing assertions:
|
|
10
|
+
// • the file is PATCHED, never regenerated — creds, origins, the federation
|
|
11
|
+
// block and comments survive; only the two settings lines change
|
|
12
|
+
// • an unreadable/empty web.env is REFUSED under --apply (never overwritten);
|
|
13
|
+
// dry-run PLANS the patch and writes nothing
|
|
14
|
+
// • a write or restart that THROWS returns ok:false — the drain loop re-pends
|
|
15
|
+
// the intent and never flips a healthy ACTIVE instance to 'error' (ADR 0190)
|
|
16
|
+
// • the verify asks the instance's own manifest whether it READ the values, and
|
|
17
|
+
// records only what it earned (true / false / unknown)
|
|
18
|
+
// • the state gate is a clean no-op; dedicated is refused with guidance
|
|
19
|
+
// • cmdRunIntents routes 'settings-apply' to this leg
|
|
20
|
+
//
|
|
21
|
+
// Run: node tests/provision_settings_apply.mjs
|
|
22
|
+
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const P = require('../scripts/gds/provision.js');
|
|
29
|
+
const { MANIFEST_VERIFY_TRIES } = require('../scripts/gds/provision-config.js');
|
|
30
|
+
|
|
31
|
+
const NEUTRAL_PREFIX =
|
|
32
|
+
JSON.parse(readFileSync(new URL('../config/branding.neutral.json', import.meta.url), 'utf8')).envPrefix || 'CLOUDBONGOS';
|
|
33
|
+
|
|
34
|
+
let passed = 0, failed = 0;
|
|
35
|
+
function t(name, fn) {
|
|
36
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
37
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
38
|
+
}
|
|
39
|
+
async function ta(name, fn) {
|
|
40
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
41
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
console.log('upsertEnvVars — the in-place patch:');
|
|
45
|
+
|
|
46
|
+
const LIVE_ENV = `# /etc/spike/web.env — per-instance secrets (chmod 600, OUTSIDE the repo).
|
|
47
|
+
# Provisioned by scripts/gds/provision.js (ADR 0111). OAuth app creds placed via the
|
|
48
|
+
# GitHub App Manifest flow (ADR 0133) — the owner clicked one link; no secret was pasted.
|
|
49
|
+
${NEUTRAL_PREFIX}_GITHUB_CLIENT_ID=Iv1.0123456789abcdef
|
|
50
|
+
${NEUTRAL_PREFIX}_GITHUB_CLIENT_SECRET=0123456789abcdef0123456789abcdef01234567
|
|
51
|
+
${NEUTRAL_PREFIX}_FIRST_ADMIN=jaxri
|
|
52
|
+
GDS_BRANDING_FILE=config/branding.neutral.json
|
|
53
|
+
${NEUTRAL_PREFIX}_PUBLIC_ORIGIN=https://spike.cloudbongos.com
|
|
54
|
+
GITHUB_APP_ID=12345
|
|
55
|
+
GITHUB_APP_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\\nabc\\n-----END RSA PRIVATE KEY-----
|
|
56
|
+
PORT=3010
|
|
57
|
+
PGDATABASE=spike
|
|
58
|
+
# Per-project policy the platform decides and this instance obeys (task
|
|
59
|
+
# 1003139). Written at standup from the platform's provisioning_instances.settings;
|
|
60
|
+
${NEUTRAL_PREFIX}_PLATFORM_VISIBILITY=public
|
|
61
|
+
${NEUTRAL_PREFIX}_JOINABILITY=apply
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
t('replaces an existing KEY= line where it stands; every other line survives byte-for-byte', () => {
|
|
65
|
+
const out = P.upsertEnvVars(LIVE_ENV, { [`${NEUTRAL_PREFIX}_PLATFORM_VISIBILITY`]: 'gated', [`${NEUTRAL_PREFIX}_JOINABILITY`]: 'invite_only' });
|
|
66
|
+
const before = LIVE_ENV.split('\n'), after = out.split('\n');
|
|
67
|
+
assert.equal(after.length, before.length, 'no line added or lost');
|
|
68
|
+
for (let i = 0; i < before.length; i++) {
|
|
69
|
+
if (/_PLATFORM_VISIBILITY=|_JOINABILITY=/.test(before[i])) continue;
|
|
70
|
+
assert.equal(after[i], before[i], `line ${i + 1} untouched`);
|
|
71
|
+
}
|
|
72
|
+
assert.match(out, new RegExp(`^${NEUTRAL_PREFIX}_PLATFORM_VISIBILITY=gated$`, 'm'));
|
|
73
|
+
assert.match(out, new RegExp(`^${NEUTRAL_PREFIX}_JOINABILITY=invite_only$`, 'm'));
|
|
74
|
+
assert.match(out, /_GITHUB_CLIENT_SECRET=0123456789abcdef0123456789abcdef01234567/, 'the placed creds are NOT blanked');
|
|
75
|
+
assert.match(out, /GITHUB_APP_PRIVATE_KEY=-----BEGIN RSA/, 'the publish credential survives');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
t('appends a MISSING key at the end (an instance provisioned before task 1003139 has no settings lines)', () => {
|
|
79
|
+
const pre1003139 = LIVE_ENV.split('\n').filter((l) => !/_PLATFORM_VISIBILITY=|_JOINABILITY=|task$|provisioning_instances\.settings;$/.test(l)).join('\n');
|
|
80
|
+
assert.doesNotMatch(pre1003139, /_PLATFORM_VISIBILITY=/);
|
|
81
|
+
const out = P.upsertEnvVars(pre1003139, { X_PLATFORM_VISIBILITY: 'gated', X_JOINABILITY: 'apply' });
|
|
82
|
+
assert.ok(out.startsWith(pre1003139.replace(/\n$/, '')), 'existing content is a prefix of the result');
|
|
83
|
+
assert.ok(out.endsWith('X_PLATFORM_VISIBILITY=gated\nX_JOINABILITY=apply\n'), 'both appended, newline-terminated');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
t('mixes replace + append; a body with no trailing newline gets one before the append', () => {
|
|
87
|
+
const out = P.upsertEnvVars('PORT=1\nX_A=old', { X_A: 'new', X_B: 'b' });
|
|
88
|
+
assert.equal(out, 'PORT=1\nX_A=new\nX_B=b\n');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
t('matches the key at line start, exactly — a key that is a PREFIX of another never collides', () => {
|
|
92
|
+
const out = P.upsertEnvVars('X_JOINABILITY_NOTE=keep\nX_JOINABILITY=apply\n', { X_JOINABILITY: 'open' });
|
|
93
|
+
assert.equal(out, 'X_JOINABILITY_NOTE=keep\nX_JOINABILITY=open\n');
|
|
94
|
+
const out2 = P.upsertEnvVars('# X_A=commented\nX_A=1\n', { X_A: '2' });
|
|
95
|
+
assert.equal(out2, '# X_A=commented\nX_A=2\n', 'a commented-out line is not the key');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
t('the two defenses are pinned: a $-bearing value is not re-interpreted, and a CRLF body keeps its endings', () => {
|
|
99
|
+
assert.equal(P.upsertEnvVars('K=old\n', { K: 'a$&b' }), 'K=a$&b\n', 'function-form replacer: $& is literal');
|
|
100
|
+
assert.equal(P.upsertEnvVars('K=old\n', { K: 'a$1b' }), 'K=a$1b\n');
|
|
101
|
+
assert.equal(P.upsertEnvVars('A=1\r\nX_J=apply\r\n', { X_J: 'open' }), 'A=1\r\nX_J=open\r\n', 'CRLF: replaced in place, \\r\\n kept');
|
|
102
|
+
assert.equal(P.upsertEnvVars('A.B=1\nAXB=2\n', { 'A.B': '3' }), 'A.B=3\nAXB=2\n', 'a regex metacharacter in the key is escaped, never a wildcard');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
t('is PURE: same input, same output; empty body yields just the lines', () => {
|
|
106
|
+
assert.equal(P.upsertEnvVars('', { A: '1' }), 'A=1\n');
|
|
107
|
+
assert.equal(P.upsertEnvVars(null, {}), '');
|
|
108
|
+
assert.equal(P.upsertEnvVars(LIVE_ENV, {}), LIVE_ENV, 'nothing asked → nothing changed');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at standup, under the pack prefix', () => {
|
|
112
|
+
const inst = { slug: 'spike', port: 3010, db_name: 'spike', hosting_shape: 'standalone', target_ref: 'jaxri/spike', settings: { platform_visibility: 'gated' } };
|
|
113
|
+
const vars = P.settingsEnvVarsFor(inst);
|
|
114
|
+
assert.deepEqual(vars, { [`${NEUTRAL_PREFIX}_PLATFORM_VISIBILITY`]: 'gated', [`${NEUTRAL_PREFIX}_JOINABILITY`]: 'apply' });
|
|
115
|
+
const body = P.webEnvBody(inst, {});
|
|
116
|
+
for (const [k, v] of Object.entries(vars)) assert.match(body, new RegExp(`^${k}=${v}$`, 'm'), `${k} at standup`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
console.log('\nsettingsConsumed — did the instance READ it? (the manifest read-back):');
|
|
120
|
+
|
|
121
|
+
const GATED = { settings: { platform_visibility: 'gated', joinability: 'invite_only' } };
|
|
122
|
+
const manifestWith = (project) => JSON.stringify({ schema: 'instance-manifest/v1', brand: project === undefined ? {} : { project } });
|
|
123
|
+
|
|
124
|
+
t('true when every pushed key matches brand.project (camelCase, the ENV_OVERRIDES targets)', () => {
|
|
125
|
+
assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'gated', joinability: 'invite_only' }), GATED), true);
|
|
126
|
+
});
|
|
127
|
+
t('false when the instance still reports a different value', () => {
|
|
128
|
+
assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'public', joinability: 'invite_only' }), GATED), false);
|
|
129
|
+
assert.equal(P.settingsConsumed(manifestWith({ joinability: 'invite_only' }), GATED), false, 'a missing key is a mismatch, not a pass');
|
|
130
|
+
});
|
|
131
|
+
t('null (unknown) when the manifest carries no project block — a core that predates task 1003139 — or does not parse', () => {
|
|
132
|
+
assert.equal(P.settingsConsumed(manifestWith(undefined), GATED), null);
|
|
133
|
+
assert.equal(P.settingsConsumed('<html>502</html>', GATED), null);
|
|
134
|
+
assert.equal(P.settingsConsumed('', GATED), null);
|
|
135
|
+
assert.equal(P.settingsConsumed(JSON.stringify({ brand: { project: 'nope' } }), GATED), null);
|
|
136
|
+
});
|
|
137
|
+
t('instanceManifestCmd asks the instance on loopback, like healthzCmd', () => {
|
|
138
|
+
assert.equal(P.instanceManifestCmd({ port: 3010 }), 'curl -sS --max-time 5 http://127.0.0.1:3010/api/gds/instance');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ── the handler ──────────────────────────────────────────────────────────────
|
|
142
|
+
// manifest: 'match' | 'mismatch' | 'none' | 'garbage'
|
|
143
|
+
function recorder({ apply = false, envBody = LIVE_ENV, readFails = false, healthzFails = false, privileged = false,
|
|
144
|
+
manifest = 'match', restartThrows = false, writeThrows = false } = {}) {
|
|
145
|
+
const cmds = [], writes = [], events = [], probes = [], cleared = [], logs = [];
|
|
146
|
+
const manifestOut = manifest === 'match' ? manifestWith({ platformVisibility: 'gated', joinability: 'invite_only' })
|
|
147
|
+
: manifest === 'mismatch' ? manifestWith({ platformVisibility: 'public', joinability: 'apply' })
|
|
148
|
+
: manifest === 'none' ? manifestWith(undefined) : 'nope';
|
|
149
|
+
const exec = (cmd, opts = {}) => {
|
|
150
|
+
cmds.push(cmd);
|
|
151
|
+
if (/\bcat /.test(cmd)) return readFails ? { ok: true, stdout: '', softFailed: true } : { ok: true, stdout: envBody };
|
|
152
|
+
if (/healthz/.test(cmd)) return healthzFails ? { ok: true, stdout: '', softFailed: true } : { ok: true, stdout: '{"ok":true}' };
|
|
153
|
+
if (/api\/gds\/instance/.test(cmd)) return { ok: true, stdout: manifestOut };
|
|
154
|
+
if (/systemctl restart/.test(cmd) && restartThrows && apply) throw new Error(`Command failed: ${cmd}`);
|
|
155
|
+
return { ok: true, stdout: '' };
|
|
156
|
+
};
|
|
157
|
+
const writer = (via) => (p, body, opts) => {
|
|
158
|
+
if (writeThrows && apply) throw new Error(`Command failed: sudo install -D -m 600 /tmp/x ${p}`);
|
|
159
|
+
writes.push({ via, path: p, body, opts }); return { ok: true };
|
|
160
|
+
};
|
|
161
|
+
const deps = {
|
|
162
|
+
apply, privileged, log: (l) => logs.push(String(l)), sleep: async () => {},
|
|
163
|
+
db: { query: async () => ({ rows: [] }) },
|
|
164
|
+
provisioning: {
|
|
165
|
+
recordEvent: async (_db, e) => { events.push(e); },
|
|
166
|
+
recordLivenessProbe: async (_db, id, up) => { probes.push({ id, up }); },
|
|
167
|
+
clearErrorNote: async (_db, id) => { cleared.push(id); },
|
|
168
|
+
},
|
|
169
|
+
exec,
|
|
170
|
+
// a standalone must land on the CONTROL-PLANE writer (task 2074 routing) —
|
|
171
|
+
// both writers record, tagged, so the choice is asserted, not assumed
|
|
172
|
+
writeFile: writer('box'), controlWriteFile: writer('control'),
|
|
173
|
+
doApi: null,
|
|
174
|
+
};
|
|
175
|
+
return { deps, cmds, writes, events, probes, cleared, logs };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const activeStandalone = {
|
|
179
|
+
id: 18, slug: 'spike', hosting_shape: 'standalone', status: 'active', owner_builder_id: 7,
|
|
180
|
+
port: 3010, db_name: 'spike', domain: 'spike.cloudbongos.com', target_ref: 'jaxri/spike-harbor',
|
|
181
|
+
settings: { platform_visibility: 'gated', joinability: 'invite_only' },
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
console.log('\nsettingsApplyInstance — the leg:');
|
|
185
|
+
|
|
186
|
+
await ta('--apply, active standalone: read web.env → patch ONLY the settings lines (control-plane writer) → restart → healthz → manifest; creds survive', async () => {
|
|
187
|
+
const { deps, cmds, writes } = recorder({ apply: true });
|
|
188
|
+
const r = await P.settingsApplyInstance(activeStandalone, deps);
|
|
189
|
+
assert.equal(r.ok, true, JSON.stringify(r));
|
|
190
|
+
const idx = (re) => cmds.findIndex((c) => re.test(c));
|
|
191
|
+
assert.ok(idx(/cat \/etc\/spike\/web\.env/) >= 0, 'reads the live file first');
|
|
192
|
+
assert.equal(writes.length, 1, 'exactly one file write');
|
|
193
|
+
assert.equal(writes[0].via, 'control', 'a standalone lives on the control plane — its file is written THERE');
|
|
194
|
+
assert.equal(writes[0].path, '/etc/spike/web.env');
|
|
195
|
+
assert.equal(writes[0].opts.mode, 0o600, 'stays chmod 600');
|
|
196
|
+
assert.equal(writes[0].opts.sudo, true, 'placed through the privileged path like every /etc write');
|
|
197
|
+
assert.match(writes[0].body, new RegExp(`^${NEUTRAL_PREFIX}_PLATFORM_VISIBILITY=gated$`, 'm'));
|
|
198
|
+
assert.match(writes[0].body, new RegExp(`^${NEUTRAL_PREFIX}_JOINABILITY=invite_only$`, 'm'));
|
|
199
|
+
assert.match(writes[0].body, /_GITHUB_CLIENT_SECRET=0123456789abcdef0123456789abcdef01234567/, 'the placed creds are NOT blanked (the webEnvBody hazard)');
|
|
200
|
+
assert.match(writes[0].body, /_PUBLIC_ORIGIN=https:\/\/spike\.cloudbongos\.com/, 'origins untouched');
|
|
201
|
+
assert.equal(writes[0].body.split('\n').length, LIVE_ENV.split('\n').length, 'no line added or lost');
|
|
202
|
+
assert.ok(idx(/systemctl restart spike/) >= 0, 'RESTART (not reload) so the process re-reads its env');
|
|
203
|
+
assert.ok(idx(/cat /) < idx(/restart spike/), 'read before restart');
|
|
204
|
+
assert.ok(idx(/restart spike/) < idx(/healthz/), 'healthz after the restart');
|
|
205
|
+
assert.ok(idx(/healthz/) < idx(/api\/gds\/instance/), 'the read-back only after it answers');
|
|
206
|
+
assert.ok(!cmds.some((c) => /systemctl (stop|disable)|caddy|box-dns/.test(c)), 'no teardown / DNS / caddy legs — settings only');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await ta('dry-run PLANS the patch: reads nothing real, writes NOTHING (no synthetic two-line body), shows the restart', async () => {
|
|
210
|
+
const { deps, cmds, writes, logs } = recorder({ apply: false, envBody: '' });
|
|
211
|
+
const r = await P.settingsApplyInstance(activeStandalone, deps);
|
|
212
|
+
assert.equal(r.ok, true);
|
|
213
|
+
assert.equal(r.healthy, null, 'nothing ran, nothing observed');
|
|
214
|
+
assert.equal(writes.length, 0, 'a dry-run must not plan a two-line overwrite of web.env');
|
|
215
|
+
assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
|
|
216
|
+
assert.ok(cmds.some((c) => /restart spike/.test(c)), 'the restart is still in the plan');
|
|
217
|
+
assert.ok(logs.some((l) => /dry-run — nothing ran/.test(l)), 'the closing line does not claim a restart happened');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await ta('privileged: the read and the restart escalate via sudo (the /etc file is root:root 0600)', async () => {
|
|
221
|
+
const { deps, cmds } = recorder({ apply: true, privileged: true });
|
|
222
|
+
const r = await P.settingsApplyInstance(activeStandalone, deps);
|
|
223
|
+
assert.equal(r.ok, true);
|
|
224
|
+
assert.ok(cmds.some((c) => /^sudo cat \/etc\/spike\/web\.env$/.test(c)), cmds.join(' | '));
|
|
225
|
+
assert.ok(cmds.some((c) => /^sudo systemctl restart spike$/.test(c)));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await ta('co-tenant rides the BOX executor + writer; standalone rides the control-plane pair (task 2074 routing)', async () => {
|
|
229
|
+
const seen = { box: [], control: [] };
|
|
230
|
+
const mk = (bucket) => (cmd) => { seen[bucket].push(cmd); return /\bcat /.test(cmd) ? { ok: true, stdout: LIVE_ENV } : { ok: true, stdout: '' }; };
|
|
231
|
+
for (const [shape, want] of [['co-tenant', 'box'], ['standalone', 'control']]) {
|
|
232
|
+
seen.box = []; seen.control = [];
|
|
233
|
+
const { deps, writes } = recorder({ apply: true });
|
|
234
|
+
deps.exec = mk('box'); deps.controlExec = mk('control');
|
|
235
|
+
const r = await P.settingsApplyInstance({ ...activeStandalone, hosting_shape: shape }, deps);
|
|
236
|
+
assert.equal(r.ok, true, shape);
|
|
237
|
+
assert.ok(seen[want].some((c) => /restart spike/.test(c)), `${shape}: restart on ${want}`);
|
|
238
|
+
assert.ok(!seen[want === 'box' ? 'control' : 'box'].some((c) => /restart/.test(c)), `${shape}: not on the other`);
|
|
239
|
+
assert.deepEqual(writes.map((w) => w.via), [want], `${shape}: web.env written via the ${want} writer`);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await ta('--apply: an unreadable or EMPTY web.env is refused — ok:false, nothing written, no restart (never overwrite PORT/PGDATABASE)', async () => {
|
|
244
|
+
for (const opts of [{ readFails: true }, { envBody: '' }, { envBody: ' \n' }]) {
|
|
245
|
+
const { deps, cmds, writes } = recorder({ apply: true, ...opts });
|
|
246
|
+
const r = await P.settingsApplyInstance(activeStandalone, deps);
|
|
247
|
+
assert.equal(r.ok, false, JSON.stringify(opts));
|
|
248
|
+
assert.match(r.error, /cannot read/);
|
|
249
|
+
assert.equal(writes.length, 0, `${JSON.stringify(opts)}: nothing written`);
|
|
250
|
+
assert.ok(!cmds.some((c) => /restart/.test(c)), `${JSON.stringify(opts)}: no restart`);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
await ta('--apply: a write that THROWS returns ok:false naming the failure — never a throw (ADR 0190)', async () => {
|
|
255
|
+
const { deps, cmds } = recorder({ apply: true, writeThrows: true });
|
|
256
|
+
const r = await P.settingsApplyInstance(activeStandalone, deps);
|
|
257
|
+
assert.equal(r.ok, false);
|
|
258
|
+
assert.match(r.error, /could not apply the settings on spike/);
|
|
259
|
+
assert.match(r.error, /sudo install/, 'carries the underlying reason');
|
|
260
|
+
assert.ok(!cmds.some((c) => /restart/.test(c)), 'no restart after a failed write');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
await ta('--apply: a restart that exits non-zero returns ok:false — the file is patched, the retry re-reads + restarts again', async () => {
|
|
264
|
+
const { deps, writes, events } = recorder({ apply: true, restartThrows: true });
|
|
265
|
+
const r = await P.settingsApplyInstance(activeStandalone, deps);
|
|
266
|
+
assert.equal(r.ok, false);
|
|
267
|
+
assert.match(r.error, /could not apply the settings on spike/);
|
|
268
|
+
assert.match(r.error, /systemctl restart spike/);
|
|
269
|
+
assert.equal(writes.length, 1, 'the write had already landed');
|
|
270
|
+
assert.equal(events.length, 0, 'no success event for a failed push');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await ta('the state gate: any non-active status is a clean no-op — ok:true noop:true, box untouched (never burns the retry budget)', async () => {
|
|
274
|
+
for (const status of ['requested', 'provisioning', 'tearing_down', 'torn_down', 'error']) {
|
|
275
|
+
const { deps, cmds, writes } = recorder({ apply: true });
|
|
276
|
+
const r = await P.settingsApplyInstance({ ...activeStandalone, status }, deps);
|
|
277
|
+
assert.equal(r.ok, true, status);
|
|
278
|
+
assert.equal(r.noop, true, status);
|
|
279
|
+
assert.equal(cmds.length, 0, `${status}: nothing ran`);
|
|
280
|
+
assert.equal(writes.length, 0);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
await ta('the shape refusal: dedicated → ok:false with guidance naming the values + file; box untouched', async () => {
|
|
285
|
+
const { deps, cmds, writes } = recorder({ apply: true });
|
|
286
|
+
const r = await P.settingsApplyInstance({ ...activeStandalone, hosting_shape: 'dedicated' }, deps);
|
|
287
|
+
assert.equal(r.ok, false);
|
|
288
|
+
assert.match(r.error, /dedicated/);
|
|
289
|
+
assert.match(r.error, new RegExp(`${NEUTRAL_PREFIX}_PLATFORM_VISIBILITY=gated`), 'the operator is told exactly what to set');
|
|
290
|
+
assert.match(r.error, /\/etc\/spike\/web\.env/);
|
|
291
|
+
assert.equal(cmds.length, 0);
|
|
292
|
+
assert.equal(writes.length, 0);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
console.log('\nthe verdict — recorded only when earned:');
|
|
296
|
+
|
|
297
|
+
await ta('--apply success, manifest matches: consumed=true, event says it reads the new values, F19 latch released, domain-bearing PASS records no liveness verdict', async () => {
|
|
298
|
+
const { deps, events, cleared, probes, logs } = recorder({ apply: true, manifest: 'match' });
|
|
299
|
+
const r = await P.settingsApplyInstance(activeStandalone, deps);
|
|
300
|
+
assert.equal(r.ok, true);
|
|
301
|
+
assert.equal(r.healthy, true);
|
|
302
|
+
assert.equal(r.consumed, true);
|
|
303
|
+
assert.equal(events.length, 1);
|
|
304
|
+
assert.equal(events[0].event, 'settings-applied');
|
|
305
|
+
assert.equal(events[0].actor, 'runner');
|
|
306
|
+
assert.match(events[0].detail, new RegExp(`${NEUTRAL_PREFIX}_PLATFORM_VISIBILITY=gated`));
|
|
307
|
+
assert.match(events[0].detail, /reads the new values/);
|
|
308
|
+
assert.deepEqual(cleared, [18], 'a successful push must not leave "we stopped trying" on the card');
|
|
309
|
+
assert.deepEqual(probes, [], 'loopback-up proves the process, not the address — the sweep earns "Answering at"');
|
|
310
|
+
assert.ok(logs.some((l) => /✓ spike restarted and reads the new values/.test(l)));
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
await ta('--apply, manifest still reports the OLD values: consumed=false, the event and the log say so — no success it did not earn', async () => {
|
|
314
|
+
const { deps, events, logs } = recorder({ apply: true, manifest: 'mismatch' });
|
|
315
|
+
const r = await P.settingsApplyInstance(activeStandalone, deps);
|
|
316
|
+
assert.equal(r.ok, true, 'the push itself ran — the intent must not retry forever');
|
|
317
|
+
assert.equal(r.consumed, false);
|
|
318
|
+
assert.match(events[0].detail, /still reports the OLD values/);
|
|
319
|
+
assert.ok(logs.some((l) => /⚠ spike .*OLD values/.test(l)), logs.join('\n'));
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await ta('--apply, manifest carries no project block (a core predating task 1003139): consumed=null and the event says "unknown", not "applied"', async () => {
|
|
323
|
+
for (const manifest of ['none', 'garbage']) {
|
|
324
|
+
const { deps, events } = recorder({ apply: true, manifest });
|
|
325
|
+
const r = await P.settingsApplyInstance(activeStandalone, deps);
|
|
326
|
+
assert.equal(r.ok, true, manifest);
|
|
327
|
+
assert.equal(r.consumed, null, manifest);
|
|
328
|
+
assert.match(events[0].detail, /does not publish its settings yet/, manifest);
|
|
329
|
+
assert.doesNotMatch(events[0].detail, /reads the new values/, manifest);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await ta('--apply: a restart that never answers /healthz is a VERDICT after the WHOLE F23 budget — recorded as down, event says so, still ok:true', async () => {
|
|
334
|
+
const { deps, events, probes, cmds } = recorder({ apply: true, healthzFails: true });
|
|
335
|
+
const r = await P.settingsApplyInstance(activeStandalone, deps);
|
|
336
|
+
assert.equal(r.ok, true);
|
|
337
|
+
assert.equal(r.healthy, false);
|
|
338
|
+
assert.equal(r.consumed, null, 'no read-back attempted on a silent instance');
|
|
339
|
+
assert.deepEqual(probes, [{ id: 18, up: false }]);
|
|
340
|
+
assert.match(events[0].detail, /not answering \/healthz yet/);
|
|
341
|
+
assert.equal(cmds.filter((c) => /healthz/.test(c)).length, MANIFEST_VERIFY_TRIES, 'the same patience as the manifest probe (F23) — a slow boot is not slandered');
|
|
342
|
+
assert.ok(!cmds.some((c) => /api\/gds\/instance/.test(c)), 'no manifest probe when it never answered');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
await ta('--apply: a domainless instance that answers records up=true immediately (provisionInstance step-7 rule)', async () => {
|
|
346
|
+
const { deps, probes } = recorder({ apply: true });
|
|
347
|
+
const r = await P.settingsApplyInstance({ ...activeStandalone, domain: null }, deps);
|
|
348
|
+
assert.equal(r.ok, true);
|
|
349
|
+
assert.deepEqual(probes, [{ id: 18, up: true }]);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
console.log('\nmakeExec / makeWriteFile — the secret never reaches the journal:');
|
|
353
|
+
|
|
354
|
+
await ta('a remote web.env placement logs "write <path> (N bytes)", never the base64 body (task 1003140)', async () => {
|
|
355
|
+
const logs = [];
|
|
356
|
+
const exec = P.makeExec({ apply: false, log: (l) => logs.push(String(l)), remoteHost: 'lars@box', remoteCwd: '/home/lars/cb' });
|
|
357
|
+
const writeFile = P.makeWriteFile({ apply: false, privileged: true, exec, log: (l) => logs.push(String(l)), remoteHost: 'lars@box' });
|
|
358
|
+
const SECRET = 'CLOUDBONGOS_GITHUB_CLIENT_SECRET=0123456789abcdef0123456789abcdef01234567\n';
|
|
359
|
+
writeFile('/etc/spike/web.env', SECRET, { mode: 0o600, sudo: true });
|
|
360
|
+
const joined = logs.join('\n');
|
|
361
|
+
assert.doesNotMatch(joined, new RegExp(Buffer.from(SECRET, 'utf8').toString('base64').slice(0, 24)), 'no base64 of the body in any log line');
|
|
362
|
+
assert.doesNotMatch(joined, /0123456789abcdef0123456789abcdef01234567/, 'no plaintext either');
|
|
363
|
+
assert.match(joined, /\[ssh lars@box\] write \/etc\/spike\/web\.env \(\d+ bytes, mode 600\)/, 'the exec logs the redacted label');
|
|
364
|
+
assert.match(joined, /would write \/etc\/spike\/web\.env/, 'the writer\'s own plan line survives');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
await ta('a `shown` command that FAILS non-fatally logs only its exit status — never "Command failed: <cmd>" (the secret-bearing command text)', async () => {
|
|
368
|
+
const logs = [];
|
|
369
|
+
const exec = P.makeExec({ apply: true, log: (l) => logs.push(String(l)) });
|
|
370
|
+
// a real child that exits 3; the command text stands in for one that embeds a secret
|
|
371
|
+
const cmd = `${JSON.stringify(process.execPath)} -e "process.exit(3)" SECRET_IN_ARGV`;
|
|
372
|
+
const r = exec(cmd, { allowFail: true, shown: 'write /etc/spike/web.env (99 bytes)' });
|
|
373
|
+
assert.equal(r.softFailed, true);
|
|
374
|
+
const joined = logs.join('\n');
|
|
375
|
+
assert.match(joined, /~ write \/etc\/spike\/web\.env \(99 bytes\) \(non-fatal: exit status 3\)/, joined);
|
|
376
|
+
assert.doesNotMatch(joined, /SECRET_IN_ARGV|Command failed/, 'the command text never reaches the journal');
|
|
377
|
+
// …while an ordinary command keeps its diagnostic
|
|
378
|
+
const r2 = exec(`${JSON.stringify(process.execPath)} -e "process.exit(4)" plain`, { allowFail: true });
|
|
379
|
+
assert.equal(r2.softFailed, true);
|
|
380
|
+
assert.match(logs[logs.length - 1], /plain \(non-fatal: /, 'unlabelled commands still log their own reason');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
console.log('\ncmdRunIntents — the dispatch:');
|
|
384
|
+
|
|
385
|
+
await ta("cmdRunIntents (--apply) routes a 'settings-apply' intent to the leg, writes the ROW's current values, resolves it done", async () => {
|
|
386
|
+
let claims = 0; const resolved = []; const cmds = []; const writes = [];
|
|
387
|
+
const deps = {
|
|
388
|
+
apply: true, log: () => {}, sleep: async () => {},
|
|
389
|
+
db: { query: async () => ({ rows: [] }) },
|
|
390
|
+
provisioning: {
|
|
391
|
+
claimNextIntent: async () => (claims++ === 0 ? { id: 11, instance_id: 18, action: 'settings-apply', attempts: 1 } : null),
|
|
392
|
+
// the runner re-fetches the row — a PATCH that landed after the enqueue rides along
|
|
393
|
+
getInstanceById: async () => ({ ...activeStandalone, settings: { platform_visibility: 'gated', joinability: 'open' } }),
|
|
394
|
+
resolveIntent: async (_db, id, state) => { resolved.push({ id, state }); },
|
|
395
|
+
setInstanceStatus: async () => { throw new Error('must not touch the lifecycle'); },
|
|
396
|
+
recordEvent: async () => {},
|
|
397
|
+
claimNextOAuthManifest: async () => null,
|
|
398
|
+
resolveOAuthManifest: async () => {},
|
|
399
|
+
},
|
|
400
|
+
exec: (cmd) => { cmds.push(cmd); return /\bcat /.test(cmd) ? { ok: true, stdout: LIVE_ENV } : /instance/.test(cmd) ? { ok: true, stdout: manifestWith({ platformVisibility: 'gated', joinability: 'open' }) } : { ok: true, stdout: '{"ok":true}' }; },
|
|
401
|
+
writeFile: (p, body) => { writes.push(body); return { ok: true }; }, doApi: null,
|
|
402
|
+
};
|
|
403
|
+
const r = await P.cmdRunIntents(deps);
|
|
404
|
+
assert.equal(r.ran, 1);
|
|
405
|
+
assert.equal(r.ok, 1);
|
|
406
|
+
assert.deepEqual(resolved, [{ id: 11, state: 'done' }]);
|
|
407
|
+
assert.ok(cmds.some((c) => /systemctl restart spike/.test(c)), 'the restart actually ran');
|
|
408
|
+
assert.match(writes[0], new RegExp(`^${NEUTRAL_PREFIX}_JOINABILITY=open$`, 'm'), 'the bytes on disk come from the freshly-fetched row (the "already queued will carry it" receipt is provable)');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
await ta('cmdRunIntents (--apply): a FAILED restart on attempt 1 re-pends the intent — no resolve, and the instance status is NEVER written (ADR 0190)', async () => {
|
|
412
|
+
let claims = 0; const resolved = []; const statusWrites = []; const updates = [];
|
|
413
|
+
const deps = {
|
|
414
|
+
apply: true, log: () => {}, sleep: async () => {},
|
|
415
|
+
db: { query: async (sql, params) => { if (/UPDATE provisioning_intents/.test(sql)) updates.push({ sql, params }); return { rows: [] }; } },
|
|
416
|
+
provisioning: {
|
|
417
|
+
claimNextIntent: async () => (claims++ === 0 ? { id: 12, instance_id: 18, action: 'settings-apply', attempts: 1 } : null),
|
|
418
|
+
getInstanceById: async () => ({ ...activeStandalone }),
|
|
419
|
+
resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
|
|
420
|
+
setInstanceStatus: async (_db, _id, s, patch) => { statusWrites.push({ s, patch }); },
|
|
421
|
+
recordEvent: async () => {},
|
|
422
|
+
claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
|
|
423
|
+
},
|
|
424
|
+
exec: (cmd) => {
|
|
425
|
+
if (/\bcat /.test(cmd)) return { ok: true, stdout: LIVE_ENV };
|
|
426
|
+
if (/systemctl restart/.test(cmd)) throw new Error('Command failed: sudo systemctl restart spike');
|
|
427
|
+
return { ok: true, stdout: '' };
|
|
428
|
+
},
|
|
429
|
+
writeFile: () => ({ ok: true }), doApi: null,
|
|
430
|
+
};
|
|
431
|
+
const r = await P.cmdRunIntents(deps);
|
|
432
|
+
assert.equal(r.errors, 1);
|
|
433
|
+
assert.equal(resolved.length, 0, 'not resolved — it re-pends for a later tick');
|
|
434
|
+
assert.equal(updates.length, 1, 're-pended via the intents table');
|
|
435
|
+
assert.match(updates[0].sql, /state='pending'/);
|
|
436
|
+
assert.match(updates[0].params[1], /could not apply the settings on spike/, 'the reason rides last_error');
|
|
437
|
+
assert.equal(statusWrites.length, 0, 'a healthy ACTIVE instance is never marked broken over a settings push');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
await ta('cmdRunIntents (--apply): a dedicated refusal RETIRES at the cap with the reason on the instance and NO status change', async () => {
|
|
441
|
+
let claims = 0; const resolved = []; const statusWrites = [];
|
|
442
|
+
const deps = {
|
|
443
|
+
apply: true, log: () => {}, sleep: async () => {},
|
|
444
|
+
db: { query: async () => ({ rows: [] }) },
|
|
445
|
+
provisioning: {
|
|
446
|
+
claimNextIntent: async () => (claims++ === 0 ? { id: 13, instance_id: 18, action: 'settings-apply', attempts: P.MAX_INTENT_ATTEMPTS } : null),
|
|
447
|
+
getInstanceById: async () => ({ ...activeStandalone, hosting_shape: 'dedicated' }),
|
|
448
|
+
resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
|
|
449
|
+
setInstanceStatus: async (_db, _id, s, patch) => { statusWrites.push({ s, patch }); },
|
|
450
|
+
recordEvent: async () => {},
|
|
451
|
+
claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
|
|
452
|
+
},
|
|
453
|
+
exec: () => ({ ok: true }), writeFile: () => ({ ok: true }), doApi: null,
|
|
454
|
+
};
|
|
455
|
+
const r = await P.cmdRunIntents(deps);
|
|
456
|
+
assert.equal(r.errors, 1);
|
|
457
|
+
assert.equal(resolved[0].state, 'error');
|
|
458
|
+
assert.equal(statusWrites.length, 1);
|
|
459
|
+
assert.equal(statusWrites[0].s, 'active', 'a healthy instance is never marked broken over a refused push');
|
|
460
|
+
assert.match(statusWrites[0].patch.error_note, /dedicated/);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
console.log(`\nprovision_settings_apply: ${passed} passed, ${failed} failed`);
|
|
464
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -42,6 +42,9 @@ provisioning.updateInstanceSettings = async (_db, id, patch) => {
|
|
|
42
42
|
};
|
|
43
43
|
provisioning.recordEvent = async (_db, e) => { events.push(e); };
|
|
44
44
|
provisioning.getOpenIntent = async () => null;
|
|
45
|
+
// The live push (task 1003140) — stubbed so the reachable path is deterministic here;
|
|
46
|
+
// its own outcomes are covered in tests/provisioning_settings_apply.mjs.
|
|
47
|
+
provisioning.enqueueSettingsApply = async () => ({ intent: { id: 9, action: 'settings-apply', state: 'pending' }, created: true });
|
|
45
48
|
let manifest = null; // the latest manifest attempt the read surfaces (task 1003235)
|
|
46
49
|
provisioning.getLatestOAuthManifestForInstance = async () => manifest;
|
|
47
50
|
|
|
@@ -93,7 +96,7 @@ const patch = (p, b) => call('PATCH', p, b);
|
|
|
93
96
|
|
|
94
97
|
function reset({ inst, as } = {}) {
|
|
95
98
|
instance = inst !== undefined ? inst
|
|
96
|
-
: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', settings: {} };
|
|
99
|
+
: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'co-tenant', domain: 'spike.cloudbongos.com', settings: {} };
|
|
97
100
|
builder = as || { id: 7, github_login: 'owner', rank: 'thetes' };
|
|
98
101
|
updateCalls = [];
|
|
99
102
|
events = [];
|
|
@@ -195,7 +198,8 @@ await ta('owner changes one key — merged, returned effective, audited api:self
|
|
|
195
198
|
reset();
|
|
196
199
|
const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
197
200
|
assert.equal(r.status, 200, r.raw);
|
|
198
|
-
assert.deepEqual(r.body.
|
|
201
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
|
|
202
|
+
assert.equal(r.body.push.queued, true, 'an active co-tenant gets the live push (task 1003140)');
|
|
199
203
|
assert.deepEqual(updateCalls, [{ id: 18, patch: { joinability: 'open' } }], 'exactly the sent key, nothing else');
|
|
200
204
|
assert.equal(events.length, 1);
|
|
201
205
|
assert.equal(events[0].event, 'settings');
|
|
@@ -207,7 +211,7 @@ await ta('owner changes both keys in one call', async () => {
|
|
|
207
211
|
reset();
|
|
208
212
|
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'invite_only' });
|
|
209
213
|
assert.equal(r.status, 200, r.raw);
|
|
210
|
-
assert.deepEqual(r.body.
|
|
214
|
+
assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only' });
|
|
211
215
|
});
|
|
212
216
|
|
|
213
217
|
await ta('the OWN-SCOPE WALL: another builder\'s PATCH → 404 and the write path is never reached', async () => {
|
|
@@ -223,7 +227,7 @@ await ta('an Archon may change ANY instance — audited api:archon', async () =>
|
|
|
223
227
|
reset({ as: { id: 999, github_login: 'archon', rank: 'archon' } });
|
|
224
228
|
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
|
|
225
229
|
assert.equal(r.status, 200, r.raw);
|
|
226
|
-
assert.equal(events[0].actor, 'api:archon', 'the override
|
|
230
|
+
assert.equal(events[0].actor, 'api:archon:999', 'the override names WHICH archon (the force-teardown convention, task 1003140)');
|
|
227
231
|
assert.equal(events[0].ownerBuilderId, 7, 'the event still belongs to the instance owner');
|
|
228
232
|
});
|
|
229
233
|
|