@bongos/core 1.19.355 → 1.19.357
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 +57 -27
- 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/memory/module.json +11 -2
- package/modules/memory/pollers/search-index.js +201 -0
- 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/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/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
- package/tests/search_index_poller.mjs +161 -0
|
@@ -42,7 +42,11 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
|
|
|
42
42
|
// 'domain-attach' (task 1002692, migration provisioning_005): re-assert the
|
|
43
43
|
// domain-dependent standup legs for an already-ACTIVE instance that was just
|
|
44
44
|
// given its address via POST /provisioning/instances/:id/domain.
|
|
45
|
-
|
|
45
|
+
// 'settings-apply' (task 1003140, migration provisioning_010): push a changed
|
|
46
|
+
// per-project setting to an already-ACTIVE instance — patch its web.env settings
|
|
47
|
+
// lines in place + restart, since the branding pack is memoized for the process
|
|
48
|
+
// lifetime (task 1003139 built only the standup leg).
|
|
49
|
+
const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply'];
|
|
46
50
|
// Every state a teardown can be asked for — i.e. everything except the one
|
|
47
51
|
// state where there is provably nothing left to release. DERIVED from
|
|
48
52
|
// INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
|
|
@@ -69,6 +73,17 @@ const PROVISION_ENQUEUE_FROM = new Set(['requested', 'provisioning', 'error', 't
|
|
|
69
73
|
// 'requested'/'error' only record it — the (re)provision run wires the domain
|
|
70
74
|
// itself. Mid-flight or torn-down states refuse truthfully.
|
|
71
75
|
const DOMAIN_ATTACH_FROM = new Set(['requested', 'error', 'active']);
|
|
76
|
+
// The ONE state a settings push is meaningful from (task 1003140). A pre-active
|
|
77
|
+
// instance needs no push — standup writes the same values from the same
|
|
78
|
+
// effectiveSettings() row — and a torn-down one has nothing to restart. The
|
|
79
|
+
// provision intent cannot stand in for it: PROVISION_ENQUEUE_FROM deliberately
|
|
80
|
+
// excludes 'active' (task 1002691).
|
|
81
|
+
const SETTINGS_APPLY_FROM = new Set(['active']);
|
|
82
|
+
// The hosting shapes the runner has a write path to. A 'dedicated' instance is
|
|
83
|
+
// its own droplet, stood up by cloud-init — the runner has no post-hoc exec path
|
|
84
|
+
// to it (the placeManifestCreds refusal) — so the route refuses the push HONESTLY
|
|
85
|
+
// rather than queue one that never lands.
|
|
86
|
+
const SETTINGS_APPLY_SHAPES = new Set(['co-tenant', 'standalone']);
|
|
72
87
|
|
|
73
88
|
// Per-project settings (task 1003040, goal 1000058): the known keys + their
|
|
74
89
|
// vocabularies, enforced at the API layer — the jsonb `settings` column
|
|
@@ -244,31 +259,39 @@ function publicGithubApp(manifestRow) {
|
|
|
244
259
|
return { status: String(manifestRow.status), app_slug: manifestRow.app_slug || null };
|
|
245
260
|
}
|
|
246
261
|
|
|
247
|
-
// The instance's
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
// at standup (task 1003139). PURE (row + prefix in, string out) and owned HERE, next
|
|
252
|
-
// to SETTINGS_VOCAB, rather than in provision.js — the vocabulary and the thing that
|
|
262
|
+
// The env variables that carry this instance's settings, as the instance reads
|
|
263
|
+
// them: { <PREFIX>_PLATFORM_VISIBILITY, <PREFIX>_JOINABILITY } (task 1003139 /
|
|
264
|
+
// 1003140). PURE (row + prefix in, object out) and owned HERE, next to
|
|
265
|
+
// SETTINGS_VOCAB, rather than in provision.js — the vocabulary and the thing that
|
|
253
266
|
// transmits it must not be able to drift. Values come from effectiveSettings, so a
|
|
254
267
|
// row with no stored settings emits today's behavior EXPLICITLY rather than nothing:
|
|
255
|
-
// an instance must never have to infer a policy from an absent variable.
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
// the process lifetime) — that is the 'settings-apply' intent, still to be built.
|
|
260
|
-
function settingsEnvLines(row, prefix) {
|
|
268
|
+
// an instance must never have to infer a policy from an absent variable. BOTH legs
|
|
269
|
+
// derive from this one function — the standup write (settingsEnvLines → webEnvBody)
|
|
270
|
+
// and the live push (the runner's settings-apply leg patches exactly these lines).
|
|
271
|
+
function settingsEnvVars(row, prefix) {
|
|
261
272
|
const s = effectiveSettings(row);
|
|
273
|
+
return Object.fromEntries(Object.keys(SETTINGS_VOCAB).map((key) => [`${prefix}_${key.toUpperCase()}`, s[key]]));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The web.env block the runner writes at STANDUP (task 1003139): a comment that
|
|
277
|
+
// tells the truth about where the values come from, then the settingsEnvVars
|
|
278
|
+
// lines. Changing one on an ALREADY-RUNNING instance is the 'settings-apply'
|
|
279
|
+
// intent (task 1003140): the runner re-writes these lines in place and restarts
|
|
280
|
+
// the service, because the branding pack is memoized for the process lifetime.
|
|
281
|
+
function settingsEnvLines(row, prefix) {
|
|
282
|
+
const lines = Object.entries(settingsEnvVars(row, prefix)).map(([k, v]) => `${k}=${v}`).join('\n');
|
|
262
283
|
return `# Per-project policy the platform decides and this instance obeys (task
|
|
263
284
|
# 1003139). Written at standup from the platform's provisioning_instances.settings;
|
|
264
|
-
# src/branding.js ENV_OVERRIDES maps these onto project.*.
|
|
265
|
-
#
|
|
266
|
-
#
|
|
267
|
-
${
|
|
268
|
-
${prefix}_JOINABILITY=${s.joinability}
|
|
285
|
+
# src/branding.js ENV_OVERRIDES maps these onto project.*. When the owner changes
|
|
286
|
+
# one, the platform's 'settings-apply' run (task 1003140) re-writes these lines in
|
|
287
|
+
# place and restarts this service — the pack is read once per process.
|
|
288
|
+
${lines}
|
|
269
289
|
`;
|
|
270
290
|
}
|
|
271
291
|
|
|
292
|
+
// The instance's EFFECTIVE settings: the defaults overlaid with the stored,
|
|
293
|
+
// on-vocabulary keys. An off-vocabulary or unknown stored value falls back to
|
|
294
|
+
// its default rather than leaking into the read surface. PURE.
|
|
272
295
|
function effectiveSettings(row) {
|
|
273
296
|
const stored = row && row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings)
|
|
274
297
|
? row.settings : {};
|
|
@@ -607,7 +630,9 @@ async function enqueueIntent(db, instanceId, action, requestedBy) {
|
|
|
607
630
|
// blind enqueue collided with such an intent and the API falsely said "teardown
|
|
608
631
|
// already queued" — the instance leaked). A pending provision is flipped to 'error'
|
|
609
632
|
// (the state CHECK has no 'superseded'; last_error says why) so the runner never
|
|
610
|
-
// stands it up. A
|
|
633
|
+
// stands it up. A PENDING settings push is superseded the same way (task 1003140):
|
|
634
|
+
// a push into a project about to be torn down is dead work, and letting it hold the
|
|
635
|
+
// delete hostage would repeat 1002691. A RUNNING intent is mid-flight and never yanked —
|
|
611
636
|
// the caller gets { conflict: 'open_intent' } with the blocking intent so the route
|
|
612
637
|
// can answer truthfully. Each statement is atomic and the index stays the arbiter:
|
|
613
638
|
// any interleaving with the runner or a concurrent enqueue lands on one of these
|
|
@@ -617,7 +642,7 @@ async function enqueueTeardown(db, instanceId, requestedBy) {
|
|
|
617
642
|
`UPDATE provisioning_intents
|
|
618
643
|
SET state = 'error', last_error = 'superseded: teardown requested',
|
|
619
644
|
updated_at = now(), resolved_at = now()
|
|
620
|
-
WHERE instance_id = $1 AND action
|
|
645
|
+
WHERE instance_id = $1 AND action IN ('provision', 'settings-apply') AND state = 'pending'
|
|
621
646
|
RETURNING id`,
|
|
622
647
|
[instanceId]
|
|
623
648
|
);
|
|
@@ -627,6 +652,22 @@ async function enqueueTeardown(db, instanceId, requestedBy) {
|
|
|
627
652
|
return { intent, created: false, superseded: false, conflict: 'open_intent' };
|
|
628
653
|
}
|
|
629
654
|
|
|
655
|
+
// Queue a settings push for an ACTIVE instance (task 1003140). The runner reads
|
|
656
|
+
// the row's CURRENT settings when it runs, so a change that lands while an
|
|
657
|
+
// identical intent is still PENDING simply rides that intent — idempotent, and
|
|
658
|
+
// truthfully "already queued". Anything else holding the open slot — other work,
|
|
659
|
+
// or a settings push already RUNNING (which may have read the row before this
|
|
660
|
+
// write) — is reported as { conflict: 'open_intent' } so the route can say so
|
|
661
|
+
// instead of claiming a push that will not carry the change. The state gate and
|
|
662
|
+
// the shape gate are the ROUTE's job (SETTINGS_APPLY_FROM / _SHAPES); this only
|
|
663
|
+
// arbitrates the open slot, like enqueueTeardown.
|
|
664
|
+
async function enqueueSettingsApply(db, instanceId, requestedBy) {
|
|
665
|
+
const { intent, created } = await enqueueIntent(db, instanceId, 'settings-apply', requestedBy);
|
|
666
|
+
if (created) return { intent, created: true };
|
|
667
|
+
if (intent.action === 'settings-apply' && intent.state === 'pending') return { intent, created: false };
|
|
668
|
+
return { intent, created: false, conflict: 'open_intent' };
|
|
669
|
+
}
|
|
670
|
+
|
|
630
671
|
// The instance's currently-open (pending|running) intent, if any.
|
|
631
672
|
async function getOpenIntent(db, instanceId) {
|
|
632
673
|
const { rows } = await db.query(
|
|
@@ -817,8 +858,9 @@ async function costLedger(db, { limit = 500 } = {}) {
|
|
|
817
858
|
module.exports = {
|
|
818
859
|
// constants
|
|
819
860
|
INSTANCE_STATES, HOSTING_SHAPES, ONBOARD_MODES, PROJECT_TYPES, INTENT_ACTIONS, TEARDOWN_FROM,
|
|
820
|
-
settingsEnvLines,
|
|
821
|
-
PROVISION_ENQUEUE_FROM, DOMAIN_ATTACH_FROM,
|
|
861
|
+
settingsEnvLines, settingsEnvVars,
|
|
862
|
+
PROVISION_ENQUEUE_FROM, DOMAIN_ATTACH_FROM, SETTINGS_APPLY_FROM, SETTINGS_APPLY_SHAPES,
|
|
863
|
+
TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
|
|
822
864
|
SETTINGS_VOCAB, DEFAULT_SETTINGS,
|
|
823
865
|
// pure
|
|
824
866
|
isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
|
|
@@ -831,7 +873,7 @@ module.exports = {
|
|
|
831
873
|
// data access — verification overlay (task 1002696)
|
|
832
874
|
listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote,
|
|
833
875
|
createInstance, setInstanceStatus, updateInstanceDomain, updateInstanceSettings,
|
|
834
|
-
enqueueIntent, enqueueTeardown, getOpenIntent, claimNextIntent, resolveIntent,
|
|
876
|
+
enqueueIntent, enqueueTeardown, enqueueSettingsApply, getOpenIntent, claimNextIntent, resolveIntent,
|
|
835
877
|
recordEvent, instanceStateForSlug, costLedger,
|
|
836
878
|
// data access — GitHub App Manifest attempts (task 2080)
|
|
837
879
|
createOAuthManifest, getOAuthManifestByState, attachManifestCode,
|
|
@@ -659,6 +659,17 @@ module.exports = function provisioningRoutes() {
|
|
|
659
659
|
// Body: any subset of the known keys; strict validation refuses unknown keys
|
|
660
660
|
// and off-vocabulary values. rank: any authenticated builder (own resource;
|
|
661
661
|
// archon may change any — the override is audited via the event's actor).
|
|
662
|
+
// The platform's row is updated first; then, for an ACTIVE instance on a shape
|
|
663
|
+
// the runner can reach, a 'settings-apply' intent is enqueued (task 1003140):
|
|
664
|
+
// the control-plane runner patches the instance's web.env in place and
|
|
665
|
+
// RESTARTS it, because the branding pack is memoized for the process lifetime.
|
|
666
|
+
// A save that changes nothing neither writes nor restarts. The response is a
|
|
667
|
+
// request receipt — `saved` is what the platform now stores, and `push`
|
|
668
|
+
// { queued, restart, reachable, message } says whether a restart was queued
|
|
669
|
+
// and when the value takes effect — never a claim the instance already runs
|
|
670
|
+
// it; a client reads that back from the instance's own public GET /instance
|
|
671
|
+
// manifest (brand.project). 409 intent_conflict (details.recorded: true) when
|
|
672
|
+
// the open slot is held by other work — the change IS saved.
|
|
662
673
|
router.patch('/provisioning/instances/:id/settings', auth.requireBuilder, async (req, res) => {
|
|
663
674
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
664
675
|
if (!id) return;
|
|
@@ -677,13 +688,70 @@ module.exports = function provisioningRoutes() {
|
|
|
677
688
|
try {
|
|
678
689
|
const inst = await provisioning.getInstanceById(pool, id);
|
|
679
690
|
if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
691
|
+
// The archon override carries WHICH archon (the force-teardown convention) on
|
|
692
|
+
// both the event and the intent it may enqueue below.
|
|
693
|
+
const actor = String(inst.owner_builder_id) === String(req.builder.id) ? 'api:self' : `api:archon:${req.builder.id}`;
|
|
694
|
+
// A save that changes nothing writes nothing and restarts nothing — a
|
|
695
|
+
// whole-form save or a retried request must not bounce a live project. The
|
|
696
|
+
// one exception is the owner re-kick (the domain route's F19 shape): the same
|
|
697
|
+
// values re-sent while a retired push left its error_note re-queue the push.
|
|
698
|
+
const before = provisioning.effectiveSettings(inst);
|
|
699
|
+
const changed = Object.entries(patch).some(([k, v]) => before[k] !== v);
|
|
700
|
+
const updated = changed ? await provisioning.updateInstanceSettings(pool, id, patch) : inst;
|
|
701
|
+
if (changed) {
|
|
702
|
+
await provisioning.recordEvent(pool, {
|
|
703
|
+
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'settings',
|
|
704
|
+
detail: `settings changed: ${Object.entries(patch).map(([k, v]) => `${k}=${v}`).join(', ')}`,
|
|
705
|
+
actor,
|
|
706
|
+
}).catch(() => {});
|
|
707
|
+
}
|
|
708
|
+
// The push (task 1003140). The row is now the platform's truth, but a
|
|
709
|
+
// RUNNING instance reads its settings from web.env through a pack memoized
|
|
710
|
+
// for the process lifetime — so an ACTIVE instance on a reachable shape gets
|
|
711
|
+
// a 'settings-apply' intent (web.env patched in place + RESTART, drained by
|
|
712
|
+
// the runner; the web tier holds no tokens, ADR 0111 §2). The owner decided
|
|
713
|
+
// the restart is acceptable and that the surface must SAY so plainly. Every
|
|
714
|
+
// other state stores the value and says when it takes effect. One receipt
|
|
715
|
+
// shape on every branch — `restart` = a restart is what applies it,
|
|
716
|
+
// `reachable` = the platform can perform that restart — and state-only
|
|
717
|
+
// sentences throughout (the F16 rule): no wall-clock promises.
|
|
718
|
+
const reachable = provisioning.SETTINGS_APPLY_SHAPES.has(inst.hosting_shape);
|
|
719
|
+
const receipt = (fields) => ({ reachable, ...fields });
|
|
720
|
+
let push;
|
|
721
|
+
if (!changed && !inst.error_note) {
|
|
722
|
+
push = receipt({ queued: false, restart: false, message: 'No change — this project already has these settings.' });
|
|
723
|
+
} else if (!provisioning.SETTINGS_APPLY_FROM.has(inst.status)) {
|
|
724
|
+
const offline = inst.status === 'torn_down' || inst.status === 'tearing_down';
|
|
725
|
+
push = receipt({ queued: false, restart: false, message: offline
|
|
726
|
+
? 'Saved. This project is offline, so there is nothing to apply it to yet — it takes effect if the project is brought back.'
|
|
727
|
+
: inst.status === 'error'
|
|
728
|
+
? 'Saved. This project’s standup stopped partway, so nothing is applying changes right now — it takes effect the next time the project is stood up.'
|
|
729
|
+
: 'Saved. It takes effect when the project finishes standing up.' });
|
|
730
|
+
} else if (!reachable) {
|
|
731
|
+
// A dedicated droplet has no runner write path — refuse the push honestly
|
|
732
|
+
// rather than queue one that never lands (the runner refuses it too).
|
|
733
|
+
push = receipt({ queued: false, restart: true, message:
|
|
734
|
+
'Saved on the platform. This project runs on a machine the platform doesn’t manage, so it can’t be restarted from here — whoever runs that machine has to apply it there.' });
|
|
735
|
+
} else {
|
|
736
|
+
let r = null;
|
|
737
|
+
try { r = await provisioning.enqueueSettingsApply(pool, id, actor); }
|
|
738
|
+
catch (err) {
|
|
739
|
+
// The value IS saved — say so; a 500 here would deny a durable write.
|
|
740
|
+
log.error('[provisioning] PATCH /provisioning/instances/:id/settings enqueue', err);
|
|
741
|
+
push = receipt({ queued: false, restart: true, message: 'Saved, but the restart could not be queued just now — send it again to apply it.' });
|
|
742
|
+
}
|
|
743
|
+
if (r && r.conflict) {
|
|
744
|
+
return res.fail('intent_conflict', { status: 409,
|
|
745
|
+
message: `The change was saved, but a '${r.intent.action}' run is currently ${r.intent.state} for this project — send it again once that settles to apply it.`,
|
|
746
|
+
details: { action: r.intent.action, state: r.intent.state, recorded: true } });
|
|
747
|
+
}
|
|
748
|
+
if (r) {
|
|
749
|
+
push = receipt({ queued: r.created, restart: true, message: r.created
|
|
750
|
+
? 'Saved. Your project will restart to pick this up — until then it keeps its current setting.'
|
|
751
|
+
: 'Saved. A restart is already queued for this project and will carry this change.' });
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
res.json({ ok: true, saved: provisioning.effectiveSettings(updated), push });
|
|
687
755
|
} catch (err) {
|
|
688
756
|
log.error('[provisioning] PATCH /provisioning/instances/:id/settings', err);
|
|
689
757
|
res.fail('settings_update_failed', { status: 500, message: 'internal error' });
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.357",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.357",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.357",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -3407,7 +3407,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3407
3407
|
var PROG_FLOWS = {
|
|
3408
3408
|
standup: { names: ['Request filed', 'Standing it up', 'Live'], cls: '' },
|
|
3409
3409
|
revive: { names: ['Revive queued', 'Standing it up', 'Back live'], cls: '' },
|
|
3410
|
-
teardown: { names: ['Teardown queued', 'Winding down', 'Torn down'], cls: 'flowDown' }
|
|
3410
|
+
teardown: { names: ['Teardown queued', 'Winding down', 'Torn down'], cls: 'flowDown' },
|
|
3411
|
+
settings: { names: ['Change saved', 'Restarting to apply it', 'Applied'], cls: '' }
|
|
3411
3412
|
};
|
|
3412
3413
|
/* No live-region attributes here: this strip is destroyed and rebuilt on
|
|
3413
3414
|
every 5s tick, and a live region that is itself replaced never announces.
|
|
@@ -3459,6 +3460,13 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3459
3460
|
setProgress(progressMarkup(0, 0, 'A teardown is queued — this panel updates when it starts.', '', 'teardown'));
|
|
3460
3461
|
return false;
|
|
3461
3462
|
}
|
|
3463
|
+
/* task 1003140: a settings change restarts the project — the panel says so, by state */
|
|
3464
|
+
if (oi.action === 'settings-apply') {
|
|
3465
|
+
setProgress(oi.state === 'running'
|
|
3466
|
+
? progressMarkup(1, 1, 'Applying a settings change — the project restarts to pick it up.', '', 'settings')
|
|
3467
|
+
: progressMarkup(0, 0, 'A settings change is queued — this panel updates when it starts.', '', 'settings'));
|
|
3468
|
+
return false;
|
|
3469
|
+
}
|
|
3462
3470
|
}
|
|
3463
3471
|
if (s === 'requested') {
|
|
3464
3472
|
setProgress(progressMarkup(0, 0, 'Filed — waiting for the platform to pick it up. This updates the moment it starts.', '', revive ? 'revive' : 'standup'));
|
|
@@ -3897,7 +3905,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3897
3905
|
standup: { names: ['Request filed', 'Standing it up', 'Live'], cls: '' },
|
|
3898
3906
|
revive: { names: ['Revive queued', 'Standing it up', 'Back live'], cls: '' },
|
|
3899
3907
|
teardown: { names: ['Teardown queued', 'Winding down', 'Torn down'], cls: 'flowDown' },
|
|
3900
|
-
address: { names: ['Address queued', 'Wiring the address', 'Answering there'], cls: '' }
|
|
3908
|
+
address: { names: ['Address queued', 'Wiring the address', 'Answering there'], cls: '' },
|
|
3909
|
+
settings: { names: ['Change saved', 'Restarting to apply it', 'Applied'], cls: '' }
|
|
3901
3910
|
};
|
|
3902
3911
|
function stripMarkup(flow, stageNow, stagesDone, note) {
|
|
3903
3912
|
var f = FLOWS[flow];
|
|
@@ -3930,6 +3939,10 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
3930
3939
|
var a = inst.open_intent.action;
|
|
3931
3940
|
if (a === 'teardown') return stripMarkup('teardown', 0, 0, 'A teardown is queued — this card updates when it starts.');
|
|
3932
3941
|
if (a === 'domain-attach') return stripMarkup('address', 1, 1, 'Setting up DNS and the secure connection' + (inst.domain ? ' for <strong>' + esc(inst.domain) + '</strong>' : '') + '.');
|
|
3942
|
+
/* task 1003140: a settings change is pushed by restarting the project — say so, plainly */
|
|
3943
|
+
if (a === 'settings-apply') return inst.open_intent.state === 'running'
|
|
3944
|
+
? stripMarkup('settings', 1, 1, 'Applying a settings change — the project restarts to pick it up.')
|
|
3945
|
+
: stripMarkup('settings', 0, 0, 'A settings change is queued — this card updates when it starts.');
|
|
3933
3946
|
if (a === 'provision') return stripMarkup(s === 'torn_down' ? 'revive' : 'standup', 0, 0, (s === 'torn_down' ? 'A revive is queued' : 'A standup is queued') + ' — this card updates when it starts.');
|
|
3934
3947
|
return stripMarkup('standup', 2, 2, 'A ' + esc(a) + ' run is queued — this card updates when it lands.');
|
|
3935
3948
|
}
|
|
@@ -237,6 +237,60 @@ function webEnvPath(inst, { etcBase = CONFIG.etcBase } = {}) {
|
|
|
237
237
|
return path.posix.join(etcBase, inst.slug, 'web.env');
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
// The settings env vars the runner pushes to a RUNNING instance (task 1003140):
|
|
241
|
+
// the module's settingsEnvVars under the prefix the instance actually resolves —
|
|
242
|
+
// the same pair webEnvBody writes at standup, so standup and push cannot drift.
|
|
243
|
+
function settingsEnvVarsFor(inst) {
|
|
244
|
+
const { settingsEnvVars } = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning')); // eslint-disable-line global-require
|
|
245
|
+
return settingsEnvVars(inst, provisionedEnvPrefix());
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// The instance's own public manifest — the settings read-back ADR 0190 names
|
|
249
|
+
// (GET /api/gds/instance → brand.project). Sibling of provision-net's healthzCmd:
|
|
250
|
+
// same loopback + timeout, a different question ("did you READ it?").
|
|
251
|
+
function instanceManifestCmd(inst) {
|
|
252
|
+
return `curl -sS --max-time 5 http://127.0.0.1:${inst.port}/api/gds/instance`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Did the restarted instance CONSUME the pushed settings? Compares each key of
|
|
256
|
+
// effectiveSettings(inst) against the manifest's brand.project (camelCase — the
|
|
257
|
+
// src/branding.js ENV_OVERRIDES targets). true = every key matches; false = it
|
|
258
|
+
// answers with a different value; null = unknown — the manifest carries no
|
|
259
|
+
// project block (a core that predates task 1003139) or did not parse. PURE.
|
|
260
|
+
function settingsConsumed(stdout, inst) {
|
|
261
|
+
const { effectiveSettings } = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning')); // eslint-disable-line global-require
|
|
262
|
+
let j;
|
|
263
|
+
try { j = JSON.parse(String(stdout || '').trim()); } catch { return null; }
|
|
264
|
+
const project = j && j.brand && j.brand.project;
|
|
265
|
+
if (!project || typeof project !== 'object') return null;
|
|
266
|
+
const camel = (k) => k.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
267
|
+
return Object.entries(effectiveSettings(inst)).every(([k, v]) => project[camel(k)] === v);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Upsert KEY=VALUE lines into an env-file body IN PLACE (task 1003140): an
|
|
271
|
+
// existing `KEY=` line is replaced where it stands, a missing one is appended at
|
|
272
|
+
// the end — every other line (creds, origins, the federation block, comments) is
|
|
273
|
+
// untouched. This is how a setting reaches a RUNNING instance: the full-template
|
|
274
|
+
// re-write webEnvBody produces would BLANK the OAuth creds placeManifestCreds
|
|
275
|
+
// placed (this leg has no way to know them), so a live file is patched, never
|
|
276
|
+
// regenerated. The oauth-secret.js fillOAuthEnv shape. PURE; keys are matched at
|
|
277
|
+
// line start, exactly (a KEY that is a prefix of another never collides).
|
|
278
|
+
function upsertEnvVars(body, vars) {
|
|
279
|
+
const esc = (k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
280
|
+
let text = String(body == null ? '' : body);
|
|
281
|
+
const missing = [];
|
|
282
|
+
for (const [key, value] of Object.entries(vars || {})) {
|
|
283
|
+
const re = new RegExp(`^${esc(key)}=.*$`, 'm');
|
|
284
|
+
if (re.test(text)) text = text.replace(re, () => `${key}=${value}`);
|
|
285
|
+
else missing.push(`${key}=${value}`);
|
|
286
|
+
}
|
|
287
|
+
if (missing.length) {
|
|
288
|
+
if (text.length && !text.endsWith('\n')) text += '\n';
|
|
289
|
+
text += `${missing.join('\n')}\n`;
|
|
290
|
+
}
|
|
291
|
+
return text;
|
|
292
|
+
}
|
|
293
|
+
|
|
240
294
|
// ── Cross-site identity: federate a hosted instance's sign-in through the hub ───
|
|
241
295
|
// (ADR 0141 §4, task #1002109). The control plane IS the hub (cloudbongos.com), so
|
|
242
296
|
// registration is a direct in-process call against the hub pool — no HTTP hop.
|
|
@@ -254,9 +308,13 @@ module.exports = {
|
|
|
254
308
|
backupTimer,
|
|
255
309
|
backupTimerPath,
|
|
256
310
|
backupUnitName,
|
|
311
|
+
instanceManifestCmd,
|
|
257
312
|
provisionedEnvPrefix,
|
|
258
313
|
serviceUnit,
|
|
259
314
|
serviceUnitPath,
|
|
315
|
+
settingsConsumed,
|
|
316
|
+
settingsEnvVarsFor,
|
|
317
|
+
upsertEnvVars,
|
|
260
318
|
webEnvBody,
|
|
261
319
|
webEnvPath,
|
|
262
320
|
};
|
package/scripts/gds/provision.js
CHANGED
|
@@ -51,11 +51,14 @@ const crypto = require('node:crypto');
|
|
|
51
51
|
|
|
52
52
|
const { CONFIG, MANIFEST_UA, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
|
|
53
53
|
const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, dbCreateCmd, dbName, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
|
|
54
|
-
const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, serviceUnit, serviceUnitPath, webEnvBody, webEnvPath } = require('./provision-units.js');
|
|
54
|
+
const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
|
|
55
55
|
const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
56
56
|
|
|
57
57
|
function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = null, sshOpts = '' }) {
|
|
58
|
-
|
|
58
|
+
// `shown` (task 1003140): what to LOG in place of the command text — for a command
|
|
59
|
+
// that carries a secret (a remote file placement embeds the base64 body), so the
|
|
60
|
+
// journal records "write <path> (N bytes)" and never the payload.
|
|
61
|
+
return function run(cmd, { allowFail = false, cwd, env, shown: shownAs } = {}) {
|
|
59
62
|
// Extra env is passed to the child (not interpolated into the command), so
|
|
60
63
|
// values like a hostname/IP never need shell quoting. It's shown in the dry-run
|
|
61
64
|
// plan for transparency — DNS env (BOX_HOSTNAME/BOX_IP/CLOUDFLARE_ZONE) is not
|
|
@@ -71,28 +74,37 @@ function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = nul
|
|
|
71
74
|
const wd = cwd || remoteCwd;
|
|
72
75
|
const script = `${wd ? `cd ${wd} && ` : ''}${envPrefix}${cmd}`;
|
|
73
76
|
const sshCmd = `ssh ${sshOpts} ${remoteHost} bash -s`;
|
|
74
|
-
const shown = `[ssh ${remoteHost}] ${script}`;
|
|
77
|
+
const shown = `[ssh ${remoteHost}] ${shownAs || script}`;
|
|
75
78
|
if (!apply) { log(` would run: ${shown}`); return { dryRun: true, ok: true, stdout: '' }; }
|
|
76
79
|
try {
|
|
77
80
|
const stdout = cp.execSync(sshCmd, { input: script, stdio: ['pipe', 'pipe', 'pipe'], env: process.env }).toString();
|
|
78
81
|
log(` ✓ ${shown}`);
|
|
79
82
|
return { ok: true, stdout };
|
|
80
83
|
} catch (e) {
|
|
81
|
-
|
|
84
|
+
// The script rides STDIN, so e.message names only the ssh hop — but a
|
|
85
|
+
// `shown` call site is secret-bearing by declaration: log the exit status
|
|
86
|
+
// alone, never stderr (a remote shell may echo the failing line back).
|
|
87
|
+
const why = shownAs ? `exit status ${e && e.status != null ? e.status : '?'}` : (e.stderr || e.message || '').toString().trim().slice(0, 120);
|
|
88
|
+
if (allowFail) { log(` ~ ${shown} (non-fatal: ${why})`); return { ok: true, stdout: '', softFailed: true }; }
|
|
82
89
|
log(` ✖ ${shown}`);
|
|
83
90
|
throw e;
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
93
|
// Local step (control plane / today's behavior). cwd defaults to REPO_ROOT.
|
|
87
|
-
const shown = `${envPrefix}${cmd}`;
|
|
94
|
+
const shown = shownAs || `${envPrefix}${cmd}`;
|
|
95
|
+
const label = shownAs || cmd;
|
|
88
96
|
if (!apply) { log(` would run: ${shown}`); return { dryRun: true, ok: true, stdout: '' }; }
|
|
89
97
|
try {
|
|
90
98
|
const stdout = cp.execSync(cmd, { cwd: cwd || REPO_ROOT, stdio: ['ignore', 'pipe', 'pipe'], env: env ? { ...process.env, ...env } : process.env }).toString();
|
|
91
|
-
log(` ✓ ${
|
|
99
|
+
log(` ✓ ${label}`);
|
|
92
100
|
return { ok: true, stdout };
|
|
93
101
|
} catch (e) {
|
|
94
|
-
|
|
95
|
-
|
|
102
|
+
// With `shown` set the command text is considered secret-bearing, so the
|
|
103
|
+
// non-fatal line carries only the exit status — never e.message, whose
|
|
104
|
+
// "Command failed: <cmd>" would echo the command back into the journal.
|
|
105
|
+
const why = shownAs ? `exit status ${e && e.status != null ? e.status : '?'}` : (e.stderr || e.message || '').toString().trim().slice(0, 120);
|
|
106
|
+
if (allowFail) { log(` ~ ${label} (non-fatal: ${why})`); return { ok: true, stdout: '', softFailed: true }; }
|
|
107
|
+
log(` ✖ ${label}`);
|
|
96
108
|
throw e;
|
|
97
109
|
}
|
|
98
110
|
};
|
|
@@ -121,12 +133,15 @@ function makeWriteFile({ apply, privileged = false, exec, log = console.log, rem
|
|
|
121
133
|
];
|
|
122
134
|
if (mode) steps.push(`${sp}chmod ${mode.toString(8)} ${p}`);
|
|
123
135
|
const cmd = steps.join(' && ');
|
|
136
|
+
// The command text embeds the (base64) body — a web.env carries the OAuth
|
|
137
|
+
// secret and the App private key — so the exec logs THIS instead (task 1003140).
|
|
138
|
+
const shown = `write ${p} (${body.length} bytes${mode ? `, mode ${mode.toString(8)}` : ''})`;
|
|
124
139
|
if (!apply) {
|
|
125
140
|
log(` would write ${p} (${body.length} bytes${mode ? `, mode ${mode.toString(8)}` : ''}) on ${remoteHost}`);
|
|
126
|
-
if (exec) exec(cmd);
|
|
141
|
+
if (exec) exec(cmd, { shown });
|
|
127
142
|
return { dryRun: true };
|
|
128
143
|
}
|
|
129
|
-
exec(cmd);
|
|
144
|
+
exec(cmd, { shown });
|
|
130
145
|
log(` ✓ wrote ${p} (on ${remoteHost})`);
|
|
131
146
|
return { ok: true };
|
|
132
147
|
}
|
|
@@ -139,7 +154,8 @@ function makeWriteFile({ apply, privileged = false, exec, log = console.log, rem
|
|
|
139
154
|
if (exec) exec(installCmd);
|
|
140
155
|
return { dryRun: true };
|
|
141
156
|
}
|
|
142
|
-
|
|
157
|
+
// Owner-only while staged: the destination is 0600 and the body may carry secrets.
|
|
158
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
143
159
|
try { exec(installCmd); } finally { fs.rmSync(tmp, { force: true }); }
|
|
144
160
|
log(` ✓ wrote ${p} (via sudo install)`);
|
|
145
161
|
return { ok: true };
|
|
@@ -642,6 +658,116 @@ async function domainAttachInstance(inst, deps) {
|
|
|
642
658
|
return { ok: true };
|
|
643
659
|
}
|
|
644
660
|
|
|
661
|
+
// ---------------------------------------------------------------------------
|
|
662
|
+
// Push a changed per-project setting to an ALREADY-ACTIVE instance (task
|
|
663
|
+
// 1003140 — the live-push half of the platform→instance settings channel; task
|
|
664
|
+
// 1003139 built the standup half). The branding pack is memoized + deep-frozen
|
|
665
|
+
// for the process lifetime, so a change reaches a running instance only by
|
|
666
|
+
// re-writing its web.env and RESTARTING the service: the domain-attach shape
|
|
667
|
+
// minus the DNS legs. The settings lines are patched IN PLACE (upsertEnvVars) —
|
|
668
|
+
// a template re-write via webEnvBody would blank the OAuth creds that
|
|
669
|
+
// placeManifestCreds placed, which this leg has no way to know. EVERY expected
|
|
670
|
+
// failure — an unreadable file, a refused write, a restart that exits non-zero —
|
|
671
|
+
// RETURNS { ok:false } instead of throwing: the drain loop's catch flips the
|
|
672
|
+
// instance to 'error', which must never happen to a healthy ACTIVE instance over
|
|
673
|
+
// a settings push (ADR 0190).
|
|
674
|
+
// ---------------------------------------------------------------------------
|
|
675
|
+
async function settingsApplyInstance(inst, deps) {
|
|
676
|
+
const { db, provisioning, exec, writeFile, log, apply, privileged } = deps;
|
|
677
|
+
// Same box routing as domainAttachInstance: a standalone runs control-plane-local.
|
|
678
|
+
const standalone = inst.hosting_shape === 'standalone';
|
|
679
|
+
const boxExec = standalone ? (deps.controlExec || exec) : exec;
|
|
680
|
+
const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
|
|
681
|
+
const sudoP = privileged ? 'sudo ' : '';
|
|
682
|
+
const vars = settingsEnvVarsFor(inst);
|
|
683
|
+
const shown = Object.entries(vars).map(([k, v]) => `${k}=${v}`).join(' ');
|
|
684
|
+
log(`settings-apply ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status}): ${shown}`);
|
|
685
|
+
|
|
686
|
+
// Pre-active states get these values at standup from the same row; post-active
|
|
687
|
+
// ones have nothing to restart. A clean no-op (not an error) so a raced
|
|
688
|
+
// teardown never burns the intent's retry budget.
|
|
689
|
+
if (inst.status !== 'active') {
|
|
690
|
+
log(` instance is '${inst.status}' — nothing to push now (standup writes the same values)`);
|
|
691
|
+
return { ok: true, noop: true };
|
|
692
|
+
}
|
|
693
|
+
// A dedicated droplet is stood up by cloud-init and the runner has no post-hoc
|
|
694
|
+
// exec path to it (the placeManifestCreds refusal) — say so rather than report a
|
|
695
|
+
// push that never landed. The route refuses to enqueue for this shape too.
|
|
696
|
+
if (inst.hosting_shape === 'dedicated') {
|
|
697
|
+
return { ok: false, error: `settings push is not automated for a dedicated droplet — set ${shown} in its ${webEnvPath(inst)} and restart the ${inst.slug} service there` };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Read the file as it stands so ONLY the settings lines change. Dry-run runs no
|
|
701
|
+
// command, so it has no file to patch — it PLANS the patch instead of writing a
|
|
702
|
+
// synthetic two-line body (which would read as the exact overwrite this leg
|
|
703
|
+
// exists to avoid). An unreadable or empty file is refused, never "patched":
|
|
704
|
+
// writing two lines over a missing PORT/PGDATABASE would take the instance down.
|
|
705
|
+
const read = boxExec(`${sudoP}cat ${webEnvPath(inst)}`, { allowFail: true });
|
|
706
|
+
const current = String((read && read.stdout) || '');
|
|
707
|
+
if (!apply) {
|
|
708
|
+
log(` [config] would patch ${Object.keys(vars).join(', ')} in ${webEnvPath(inst)} in place (every other line untouched)`);
|
|
709
|
+
boxExec(`${sudoP}systemctl restart ${inst.slug}`);
|
|
710
|
+
boxExec(healthzCmd(inst), { allowFail: true });
|
|
711
|
+
log(` ✓ would restart ${inst.slug} with the new settings (dry-run — nothing ran)`);
|
|
712
|
+
return { ok: true, healthy: null, consumed: null };
|
|
713
|
+
}
|
|
714
|
+
if (!read || read.softFailed || !current.trim()) {
|
|
715
|
+
return { ok: false, error: `cannot read ${webEnvPath(inst)} — nothing to patch (was this instance provisioned by the runner?)` };
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
log(' [config] patch the settings lines of web.env in place (creds + origins untouched)');
|
|
719
|
+
boxWriteFile(webEnvPath(inst), upsertEnvVars(current, vars), { mode: 0o600, sudo: true });
|
|
720
|
+
log(' [service] restart so the memoized branding pack re-reads them');
|
|
721
|
+
boxExec(`${sudoP}systemctl restart ${inst.slug}`);
|
|
722
|
+
} catch (e) {
|
|
723
|
+
// ok:false, never a throw: the retry re-reads the file (the upsert is
|
|
724
|
+
// idempotent) and restarts again; the instance's status is left alone.
|
|
725
|
+
return { ok: false, error: `could not apply the settings on ${inst.slug}: ${(e && e.message) || e}` };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Verify it came back. PATIENT (the F23 lesson — the manifest probe's budget):
|
|
729
|
+
// the child must boot before it can answer, and a false "down" here is not
|
|
730
|
+
// cosmetic — it arms the owner's re-kick paths. Silence after the WHOLE budget
|
|
731
|
+
// is a verdict on any shape and is recorded immediately (provisionInstance step
|
|
732
|
+
// 7's rule); a loopback PASS proves the process, not the address — the
|
|
733
|
+
// domain-scoped sweep earns that claim.
|
|
734
|
+
log(' [healthz] verify the instance answers after the restart');
|
|
735
|
+
const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
736
|
+
let healthy = false;
|
|
737
|
+
for (let i = 0; i < MANIFEST_VERIFY_TRIES; i++) {
|
|
738
|
+
const res = boxExec(healthzCmd(inst), { allowFail: true });
|
|
739
|
+
if (res && !res.softFailed) { healthy = true; break; }
|
|
740
|
+
if (i < MANIFEST_VERIFY_TRIES - 1) await sleep(MANIFEST_VERIFY_INTERVAL_MS);
|
|
741
|
+
}
|
|
742
|
+
// Answering is not the same as having READ the new values: a core that predates
|
|
743
|
+
// task 1003139 restarts fine and ignores them. Ask the instance's own manifest
|
|
744
|
+
// (the read-back ADR 0190 names) and record what it says — never a success it
|
|
745
|
+
// did not earn. null = the manifest carries no project block (unknown).
|
|
746
|
+
let consumed = null;
|
|
747
|
+
if (healthy) {
|
|
748
|
+
const m = boxExec(instanceManifestCmd(inst), { allowFail: true });
|
|
749
|
+
consumed = settingsConsumed(m && m.stdout, inst);
|
|
750
|
+
}
|
|
751
|
+
const outcome = !healthy ? 'restarted, but not answering /healthz yet'
|
|
752
|
+
: consumed === true ? 'restarted and reads the new values'
|
|
753
|
+
: consumed === false ? 'restarted, but still reports the OLD values — its core may predate the settings channel'
|
|
754
|
+
: 'restarted; its core does not publish its settings yet (pin predates task 1003139), so whether it reads them is unknown';
|
|
755
|
+
const probeIsVerdict = healthy === false || (healthy === true && !inst.domain);
|
|
756
|
+
if (probeIsVerdict && provisioning.recordLivenessProbe) await provisioning.recordLivenessProbe(db, inst.id, healthy).catch(() => {});
|
|
757
|
+
// Release the F19 latch: this run SUCCEEDED, so a "we stopped trying" note
|
|
758
|
+
// from an earlier retired push must not outlive its own recovery.
|
|
759
|
+
if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id).catch(() => {});
|
|
760
|
+
await provisioning.recordEvent(db, {
|
|
761
|
+
instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'settings-applied',
|
|
762
|
+
detail: `settings pushed (${shown}) — web.env patched in place; ${outcome}`,
|
|
763
|
+
actor: 'runner',
|
|
764
|
+
}).catch(() => {});
|
|
765
|
+
log(healthy && consumed !== false
|
|
766
|
+
? ` ✓ ${inst.slug} ${outcome}`
|
|
767
|
+
: ` ⚠ ${inst.slug} ${outcome}${healthy ? '' : ' — its card will read "unreachable"'}`);
|
|
768
|
+
return { ok: true, healthy, consumed };
|
|
769
|
+
}
|
|
770
|
+
|
|
645
771
|
// ---------------------------------------------------------------------------
|
|
646
772
|
// Teardown one instance (reverse the runbook; idempotent).
|
|
647
773
|
// ---------------------------------------------------------------------------
|
|
@@ -922,6 +1048,7 @@ async function cmdRunIntents(deps) {
|
|
|
922
1048
|
if (intent.action === 'provision') result = await provisionInstance(inst, deps);
|
|
923
1049
|
else if (intent.action === 'teardown') result = await teardownInstance(inst, deps);
|
|
924
1050
|
else if (intent.action === 'domain-attach') result = await domainAttachInstance(inst, deps);
|
|
1051
|
+
else if (intent.action === 'settings-apply') result = await settingsApplyInstance(inst, deps);
|
|
925
1052
|
else result = { ok: false, error: `unknown intent action '${intent.action}' — refusing to guess` };
|
|
926
1053
|
if (result && result.ok === false) {
|
|
927
1054
|
if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
|
|
@@ -998,7 +1125,8 @@ Usage:
|
|
|
998
1125
|
|
|
999
1126
|
Commands:
|
|
1000
1127
|
run-intents drain provisioning_intents (the systemd-timer entry point):
|
|
1001
|
-
execute each pending provision/teardown
|
|
1128
|
+
execute each pending provision / teardown / domain-attach /
|
|
1129
|
+
settings-apply intent
|
|
1002
1130
|
provision <slug> provision one instance by slug (composes the standup runbook)
|
|
1003
1131
|
teardown <slug> tear down one instance by slug
|
|
1004
1132
|
reconcile re-enqueue instances stuck mid-flight with no open intent
|
|
@@ -1108,6 +1236,8 @@ module.exports = {
|
|
|
1108
1236
|
// executors + orchestration (injectable deps for tests)
|
|
1109
1237
|
makeExec, makeWriteFile, allocatePort, boundLocalPorts, provisionInstance, teardownInstance,
|
|
1110
1238
|
domainAttachInstance, cmdRunIntents, cmdReconcile, cmdStatus,
|
|
1239
|
+
// live settings push (task 1003140) + the in-place env patch + read-back it rides on
|
|
1240
|
+
settingsApplyInstance, upsertEnvVars, settingsEnvVarsFor, settingsConsumed, instanceManifestCmd,
|
|
1111
1241
|
// GitHub App Manifest flow (task 2080, ADR 0133)
|
|
1112
1242
|
convertManifestCode, placeManifestCreds, drainOAuthManifests, MANIFEST_UA,
|
|
1113
1243
|
// cross-site identity federation (ADR 0141 §4, task #1002109)
|