@bongos/core 1.19.789 → 1.19.791
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 +143 -53
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +6 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0293-the-owners-deploy-door-is-an-intent-the-control-plane-drains.md +124 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +129 -1
- package/docs/api-reference.md +5 -2
- package/docs/copy-inventory.md +89 -72
- package/docs/copy-registry.json +272 -98
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/core-release-pipeline.md +12 -0
- package/migrations/core_241_core_upgrades_actor.sql +65 -0
- package/migrations/core_242_core_version_snapshot.sql +54 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/government/catalog.js +8 -0
- package/modules/government/migrations/government_014_core_pin_move.sql +46 -0
- package/modules/hall-ui/public/deploy.html +64 -0
- package/modules/hall-ui/public/deploy.js +188 -0
- package/modules/hall-ui/public/hall-kit.js +7 -65
- package/modules/hall-ui/public/hall-render.js +1 -1
- package/modules/hall-ui/public/hall-visual.js +85 -0
- package/modules/hall-ui/public/index.html +1 -0
- package/modules/hall-ui/public/profile.html +1 -0
- package/modules/hall-ui/public/profile.js +2 -2
- package/modules/hall-ui/public/shell.js +4 -0
- package/modules/hall-ui/public/studio.css +1 -1
- package/modules/hall-ui/public/studio.html +1 -0
- package/modules/hall-ui/public/studio.js +4 -3
- package/modules/hall-ui/public/style.css +0 -16
- package/modules/hall-ui/public/task.html +1 -0
- package/modules/hall-ui/public/task.js +10 -13
- package/modules/provisioning/control-plane-guard.js +102 -0
- package/modules/provisioning/core-upgrade.js +289 -0
- package/modules/provisioning/migrations/provisioning_018_core_upgrade_intent.sql +98 -0
- package/modules/provisioning/module.json +1 -1
- package/modules/provisioning/provisioning.js +8 -8
- package/modules/provisioning/routes/core-upgrade.js +95 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/accessibility-replay.js +195 -0
- package/scripts/gds/enroll-control-plane.js +101 -0
- package/scripts/gds/provision-core-upgrade.js +311 -0
- package/scripts/gds/provision.js +4 -2
- package/scripts/gds/upgrade.js +53 -14
- package/src/bongos/auth.js +19 -0
- package/src/bongos/serve-internal.js +21 -0
- package/src/module-api.js +1 -1
- package/tests/accessibility_replay.mjs +201 -0
- package/tests/auth_resolve_failure.mjs +4 -2
- package/tests/core_upgrade_door.mjs +408 -0
- package/tests/core_upgrade_runner.mjs +377 -0
- package/tests/fixtures/accessibility-replay.json +93 -0
- package/tests/hall_nav.mjs +4 -1
- package/tests/hall_page_gate_map.mjs +7 -0
- package/tests/hall_studio_world.mjs +19 -11
- package/tests/provisioning_disconnect.mjs +30 -5
- package/tests/provisioning_paid_shape_gate.mjs +14 -4
- package/tests/provisioning_restart.mjs +25 -9
- package/tests/provisioning_settings_apply.mjs +11 -2
- package/tests/provisioning_teardown_intent.mjs +7 -0
- package/tests/task_visuals.mjs +91 -27
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// modules/hall-ui/public/hall-visual.js — the ship-time task visual, in one place
|
|
2
|
+
// (task 1003835; the renderers themselves are tasks 1003109 and 1003829).
|
|
3
|
+
//
|
|
4
|
+
// A satellite of hall-kit.js rather than a part of it, and that is the decision
|
|
5
|
+
// this file records. These three functions lived in the kit, which is 61KB of
|
|
6
|
+
// pager/ledger/filter/chart machinery. The task record page (task.html) renders a
|
|
7
|
+
// visual and needs NOTHING else the kit holds, so converging it onto the kit's
|
|
8
|
+
// renderer would have meant loading 61KB on the hall's most-visited record page
|
|
9
|
+
// for one function — a real cost, paid on the wrong page. The alternative was to
|
|
10
|
+
// let task.js keep its own copy, which is how the hall ended up with two.
|
|
11
|
+
//
|
|
12
|
+
// So the helpers moved OUT. Any page that renders a visual loads this file; the
|
|
13
|
+
// kit no longer carries them and the pages that only use the kit no longer pay
|
|
14
|
+
// for them. The owner's call (2026-09-16) was that the kit may grow satellites
|
|
15
|
+
// like this one.
|
|
16
|
+
//
|
|
17
|
+
// Contract, identical to the kit's own: dependency-free (its own esc(), no
|
|
18
|
+
// window.OTB use) so load order only needs "before the page script";
|
|
19
|
+
// node-require-safe, so tests/task_visuals.mjs can require it with no DOM;
|
|
20
|
+
// string-template builders returning HTML, no state, no render loop.
|
|
21
|
+
//
|
|
22
|
+
// CSS: each caller supplies its own BEM base, because SIZE is the surface's
|
|
23
|
+
// decision and not this file's — the task record caps at 52vh in a record column
|
|
24
|
+
// (.tk-visual, style.css/task.css), the Studio gives the plate the card's whole
|
|
25
|
+
// width (.studio-plate). The markup, the URL guard and the escaping are shared.
|
|
26
|
+
(() => {
|
|
27
|
+
'use strict';
|
|
28
|
+
|
|
29
|
+
// HTML-escape for text interpolated into innerHTML.
|
|
30
|
+
function esc(s) {
|
|
31
|
+
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
|
32
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
33
|
+
}[c]));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Same-origin, SERVER-MINTED paths only. The URL arrives from our own API, but
|
|
37
|
+
// it lands in an href and a src, so it is matched literally against the shape
|
|
38
|
+
// modules/lifecycle/task-visuals.js mints rather than trusted. Anything else
|
|
39
|
+
// renders nothing at all.
|
|
40
|
+
const VISUAL_URL_RE = /^\/api\/(gds|bongos)\/task-visuals\/task-\d+-[0-9a-f]{16}\.(png|jpg|gif|webp)$/;
|
|
41
|
+
function isTaskVisualUrl(url) {
|
|
42
|
+
return VISUAL_URL_RE.test(String(url ?? ''));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The compact thumbnail, for a FEED — where the summary is one line in a list,
|
|
46
|
+
// so the image appears small and links to the full-size file. Three feeds draw
|
|
47
|
+
// it (the hall's ship ledger, a profile's recent ships, the status dashboard).
|
|
48
|
+
// Returns '' when the row carries no visual: a task without one must never look
|
|
49
|
+
// incomplete, so nothing is drawn and there is no placeholder.
|
|
50
|
+
function shipVisualThumbHtml(row) {
|
|
51
|
+
const url = String((row && row.visual_url) ?? '');
|
|
52
|
+
if (!isTaskVisualUrl(url)) return '';
|
|
53
|
+
const alt = String((row && row.visual_alt) || `Visual for task ${(row && row.id) ?? ''}`).trim();
|
|
54
|
+
return `<a href="${esc(url)}" target="_blank" rel="noopener" title="${esc(alt)}">` +
|
|
55
|
+
`<img class="ship-visual-thumb" src="${esc(url)}" alt="${esc(alt)}" loading="lazy"></a>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The FULL-SIZE form: the picture as the thing being looked at, with visual_alt
|
|
59
|
+
// as its caption. For a surface whose subject IS the image — the task record
|
|
60
|
+
// (.tk-visual) and the Studio's review card (.studio-plate).
|
|
61
|
+
//
|
|
62
|
+
// `className` is the caller's BEM base; it is sanitised because it reaches a
|
|
63
|
+
// class attribute. Returns '' with no visual, like the thumbnail.
|
|
64
|
+
function taskVisualFigureHtml(row, { className = 'task-visual' } = {}) {
|
|
65
|
+
const url = String((row && row.visual_url) ?? '');
|
|
66
|
+
if (!isTaskVisualUrl(url)) return '';
|
|
67
|
+
const written = String((row && row.visual_alt) || '').trim();
|
|
68
|
+
const alt = written || `Visual for task ${(row && row.id) ?? ''}`;
|
|
69
|
+
const base = String(className).replace(/[^A-Za-z0-9_-]/g, '');
|
|
70
|
+
// The caption is only drawn when the shipper WROTE one — the fallback alt is
|
|
71
|
+
// a machine sentence for a screen reader, not a caption to show a reader.
|
|
72
|
+
const caption = written ? `<figcaption class="${base}__caption">${esc(written)}</figcaption>` : '';
|
|
73
|
+
return `<figure class="${base}">` +
|
|
74
|
+
`<a href="${esc(url)}" target="_blank" rel="noopener">` +
|
|
75
|
+
`<img class="${base}__img" src="${esc(url)}" alt="${esc(alt)}" loading="lazy">` +
|
|
76
|
+
`</a>${caption}</figure>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const api = { isTaskVisualUrl, shipVisualThumbHtml, taskVisualFigureHtml };
|
|
80
|
+
|
|
81
|
+
// Node-require-safe (the dom-utils.js / hall-kit.js pattern): assignment is the
|
|
82
|
+
// only eval-time side effect, and only when a window exists.
|
|
83
|
+
if (typeof window !== 'undefined') window.OTBVisual = api;
|
|
84
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
85
|
+
})();
|
|
@@ -110,6 +110,7 @@
|
|
|
110
110
|
<script src="/builders/box-panel.js?v=2026-06-14-domutils" defer></script>
|
|
111
111
|
<!-- task 2166: the shared component kit (window.OTBKit) — pager/ledger/rail.
|
|
112
112
|
Loaded BEFORE hall-widgets.js, whose skeletons call OTBKit.pagerBarHtml(). -->
|
|
113
|
+
<script src="/builders/hall-visual.js?v=2026-09-16-visual1" defer></script>
|
|
113
114
|
<script src="/builders/hall-kit.js" defer></script>
|
|
114
115
|
<!-- BV1.R75: the CORE hall widgets register into window.OTB's hall-widget registry.
|
|
115
116
|
Loaded BEFORE builders.js so the registry is populated when builders.js stamps
|
|
@@ -196,6 +196,7 @@
|
|
|
196
196
|
<!-- hall-kit (window.OTBKit) — profile.js renders ship-time task visuals through
|
|
197
197
|
its shared thumbnail helper (task 1003109) and builds the section rail with
|
|
198
198
|
it (task 1003489), so it must load first. -->
|
|
199
|
+
<script src="/builders/hall-visual.js?v=2026-09-16-visual1"></script>
|
|
199
200
|
<script src="/builders/hall-kit.js?v=2026-08-27-activity"></script>
|
|
200
201
|
<!-- Connections (section 6). Loaded BEFORE profile.js so window.OTBConnections
|
|
201
202
|
exists by the time renderProfile runs; profile.js guards on it anyway, so a
|
|
@@ -176,7 +176,7 @@
|
|
|
176
176
|
const summary = s.value_summary
|
|
177
177
|
? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(s.value_summary)}</span>`
|
|
178
178
|
: '';
|
|
179
|
-
const visual = window.
|
|
179
|
+
const visual = window.OTBVisual ? window.OTBVisual.shipVisualThumbHtml(s) : '';
|
|
180
180
|
return `<tr>
|
|
181
181
|
<td class="ledger__num">#${escapeHtml(String(s.id))}</td>
|
|
182
182
|
<td><a href="${taskHref(s.id)}">${escapeHtml(s.title || `Task ${s.id}`)}</a>${summary}${visual ? `<span style="display:block;margin-top:6px">${visual}</span>` : ''}</td>
|
|
@@ -473,7 +473,7 @@
|
|
|
473
473
|
const summary = t.value_summary
|
|
474
474
|
? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(t.value_summary)}</span>`
|
|
475
475
|
: '';
|
|
476
|
-
const visual =
|
|
476
|
+
const visual = window.OTBVisual ? window.OTBVisual.shipVisualThumbHtml(t) : '';
|
|
477
477
|
return `<tr>
|
|
478
478
|
<td class="ledger__num">#${escapeHtml(String(t.id))}</td>
|
|
479
479
|
<td><a href="${taskHref(t.id)}">${escapeHtml(t.title || `Task ${t.id}`)}</a>${summary}${visual ? `<span style="display:block;margin-top:6px">${visual}</span>` : ''}</td>
|
|
@@ -311,6 +311,10 @@
|
|
|
311
311
|
// The project's own fundamentals (task 1003280, goal 1000072). Mirrors the
|
|
312
312
|
// server's requireGovernmentPage floor; cosmetic only, as every item here is.
|
|
313
313
|
{ id: 'project-settings', label: 'Project settings', icon: 'admin', href: '/project-settings', gate: 'government' },
|
|
314
|
+
// The deploy door (task 1003159, ADR 0293). Gated 'archon' here to mirror the
|
|
315
|
+
// `core.pin.move` atom its page and both its routes check — cosmetic only, as
|
|
316
|
+
// every item in this list is; the wall is server-side.
|
|
317
|
+
{ id: 'deploy', label: 'Deploy', icon: 'admin', href: '/deploy', gate: 'archon' },
|
|
314
318
|
// BV1.R113: the roles-and-permissions tab (ADR 0151). The one genuinely
|
|
315
319
|
// Archon-only item the 'government' gate comment anticipated — cosmetic
|
|
316
320
|
// only. The wall is the `government.manage` atom checked on each of the
|
|
@@ -155,7 +155,7 @@
|
|
|
155
155
|
IS the card: an artist is being asked to LOOK at something, so it is given the
|
|
156
156
|
card's full width and as much height as a screen can spare, not the 44px
|
|
157
157
|
thumbnail a ship feed uses. Markup comes from the kit's one renderer
|
|
158
|
-
(
|
|
158
|
+
(OTBVisual.taskVisualFigureHtml); only the size is this room's decision.
|
|
159
159
|
|
|
160
160
|
max-height in vh, and MEASURED at 1440x900 rather than chosen. The size an
|
|
161
161
|
artist can judge it at wins over a zero-scroll card — that is the task's own
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
<!-- The kit, for its ONE renderer: the full-size ship-time visual figure
|
|
73
73
|
(task 1003829). The copy desk — the artist's other room — already loads it,
|
|
74
74
|
and a fourth hand-rolled <figure> is what the kit exists to stop. -->
|
|
75
|
+
<script src="/builders/hall-visual.js?v=2026-09-16-visual1"></script>
|
|
75
76
|
<script src="/builders/hall-kit.js"></script>
|
|
76
77
|
<script src="/builders/studio.js?v=2026-09-10-studio2"></script>
|
|
77
78
|
</body>
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
// ---------------------------------------------------------------------------
|
|
133
133
|
|
|
134
134
|
// THE PLATE — the reviewed work's ship-time visual, at the size the card can
|
|
135
|
-
// give it. Through the
|
|
135
|
+
// give it. Through the one renderer (OTBVisual.taskVisualFigureHtml, which
|
|
136
136
|
// carries the server-minted-URL guard and the escaping) with the Studio's own
|
|
137
137
|
// class, because the size is this surface's decision: here the picture is the
|
|
138
138
|
// subject, not an illustration beside a record.
|
|
@@ -141,8 +141,9 @@
|
|
|
141
141
|
// read, or when the kit is absent — a review with no picture must read as a
|
|
142
142
|
// review, never as an empty frame (the degrade rule).
|
|
143
143
|
function plateHtml(p) {
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
const vis = window.OTBVisual;
|
|
145
|
+
if (!p.shipped || !vis || typeof vis.taskVisualFigureHtml !== 'function') return '';
|
|
146
|
+
return vis.taskVisualFigureHtml(p.shipped, { className: 'studio-plate' });
|
|
146
147
|
}
|
|
147
148
|
|
|
148
149
|
// The three weights, and the one field they share. Offered only to a viewer
|
|
@@ -2841,22 +2841,6 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
|
|
|
2841
2841
|
overflow-y: auto;
|
|
2842
2842
|
}
|
|
2843
2843
|
.td-touches { font-size: 12.5px; color: var(--ink-faint); font-family: var(--font-mono); word-break: break-word; margin: 0 0 12px; }
|
|
2844
|
-
/* task 1003109: the ship-time VISUAL, rendered directly under the value summary
|
|
2845
|
-
it restates. Sized to be READ (this exists so a non-engineer can see a change),
|
|
2846
|
-
capped so a tall screenshot can't push the rest of the record off the page, and
|
|
2847
|
-
never drawn at all when a task has no visual. */
|
|
2848
|
-
.td-visual { margin: 0 0 12px; }
|
|
2849
|
-
.td-visual__img {
|
|
2850
|
-
display: block;
|
|
2851
|
-
max-width: 100%;
|
|
2852
|
-
max-height: 52vh;
|
|
2853
|
-
width: auto;
|
|
2854
|
-
height: auto;
|
|
2855
|
-
border: var(--line) solid var(--rule-soft);
|
|
2856
|
-
border-radius: var(--radius-sm);
|
|
2857
|
-
background: var(--glass);
|
|
2858
|
-
}
|
|
2859
|
-
.td-visual__caption { font-size: 12.5px; color: var(--ink-faint); margin: 6px 0 0; }
|
|
2860
2844
|
/* The compact form: a thumbnail beside a value summary in a ship FEED, where the
|
|
2861
2845
|
summary is one line in a list and a full-width image would drown it. */
|
|
2862
2846
|
.ship-visual-thumb {
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
<script src="/builders/task-detail.js?v=2026-07-03-taskdetail2" defer></script>
|
|
69
69
|
<script src="/builders/brand-apply.js?v=2026-06-19-r56" defer></script>
|
|
70
70
|
<script src="/builders/md-reader.js" defer></script>
|
|
71
|
+
<script src="/builders/hall-visual.js?v=2026-09-16-visual1" defer></script>
|
|
71
72
|
<script src="/builders/task.js?v=2026-07-03-taskpage1" defer></script>
|
|
72
73
|
</body>
|
|
73
74
|
</html>
|
|
@@ -292,20 +292,17 @@
|
|
|
292
292
|
// a detail can be opened full-size. The caption (visual_alt) doubles as the alt
|
|
293
293
|
// text, which is why it is stored: an image with no alt tells a screen reader
|
|
294
294
|
// nothing, and "a picture of the change" is exactly the audience that needs it.
|
|
295
|
+
// The shared renderer (hall-visual.js), not a copy of it — task 1003835. This
|
|
296
|
+
// page hand-rolled the same <figure> until then; the markup it produced is what
|
|
297
|
+
// OTBVisual emits, so .tk-visual's rules are unchanged. `className` keeps the
|
|
298
|
+
// sizing this page's own CSS owns (52vh inside a record column).
|
|
299
|
+
//
|
|
300
|
+
// Guarded rather than assumed: hall-visual.js is a separate <script>, so a page
|
|
301
|
+
// that forgot it should render no picture, never a broken one.
|
|
295
302
|
function taskVisualHtml(t) {
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
// trusting: anything that is not the shape our serve route mints is dropped.
|
|
300
|
-
if (!/^\/api\/(gds|bongos)\/task-visuals\/task-\d+-[0-9a-f]{16}\.(png|jpg|gif|webp)$/.test(url)) return '';
|
|
301
|
-
const alt = String(t.visual_alt || `Visual for task ${t.id}`);
|
|
302
|
-
const caption = t.visual_alt
|
|
303
|
-
? `<figcaption class="tk-visual__caption">${escapeHtml(t.visual_alt)}</figcaption>`
|
|
304
|
-
: '';
|
|
305
|
-
return `<figure class="tk-visual">` +
|
|
306
|
-
`<a href="${escapeHtml(url)}" target="_blank" rel="noopener">` +
|
|
307
|
-
`<img class="tk-visual__img" src="${escapeHtml(url)}" alt="${escapeHtml(alt)}" loading="lazy">` +
|
|
308
|
-
`</a>${caption}</figure>`;
|
|
303
|
+
const vis = window.OTBVisual;
|
|
304
|
+
if (!vis || typeof vis.taskVisualFigureHtml !== 'function') return '';
|
|
305
|
+
return vis.taskVisualFigureHtml(t, { className: 'tk-visual' });
|
|
309
306
|
}
|
|
310
307
|
|
|
311
308
|
async function copyText(text) {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/provisioning/control-plane-guard.js — what may be aimed at the box this
|
|
4
|
+
// software is running on (task 1003159, ADR 0293 D2).
|
|
5
|
+
//
|
|
6
|
+
// WHY THIS EXISTS. `provisioning_intents.instance_id` is NOT NULL REFERENCES
|
|
7
|
+
// provisioning_instances(id), so an intent must name a row. The control plane has
|
|
8
|
+
// never had one — the table holds tenant projects — which is precisely why the
|
|
9
|
+
// owner had no deploy door: there was nothing to point at. Enrolling it as a
|
|
10
|
+
// `hosting_shape='control-plane'` row opens the door.
|
|
11
|
+
//
|
|
12
|
+
// It also puts a 'teardown' button one query away from the platform itself. That
|
|
13
|
+
// is the risk this file exists to close, and it is the reason the shape is a
|
|
14
|
+
// distinct VALUE rather than a reuse of 'standalone': a distinct value is something
|
|
15
|
+
// every destructive leg can refuse BY NAME.
|
|
16
|
+
//
|
|
17
|
+
// AN ALLOW-LIST, NOT A DENY-LIST. Listing today's destructive actions and refusing
|
|
18
|
+
// those would silently admit tomorrow's — a new action added to INTENT_ACTIONS
|
|
19
|
+
// would be permitted on the self row by omission, and the failure mode of
|
|
20
|
+
// "permitted by omission" here is the platform deleting itself. So the rule is
|
|
21
|
+
// inverted: nothing touches the self row unless it is named below.
|
|
22
|
+
//
|
|
23
|
+
// Carved out of provisioning.js rather than living beside INTENT_ACTIONS because
|
|
24
|
+
// that file sits on the oversized-file ratchet; a guard whose whole value is being
|
|
25
|
+
// read before it is edited should not be the thing that reds the build either.
|
|
26
|
+
|
|
27
|
+
// The only acts that cannot destroy the box they run on.
|
|
28
|
+
//
|
|
29
|
+
// core-upgrade — moves the pin, health-gated, auto-rollback on failure. This is
|
|
30
|
+
// the door task 1003159 builds.
|
|
31
|
+
// restart — the liveness remedy (task 1003238). Restarting the platform is
|
|
32
|
+
// recoverable and already owner-initiated elsewhere.
|
|
33
|
+
//
|
|
34
|
+
// Adding to this list is a deliberate edit someone has to argue for. It is pinned
|
|
35
|
+
// by name in tests/core_upgrade_door.mjs so it cannot grow as a side effect.
|
|
36
|
+
const CONTROL_PLANE_ACTIONS = Object.freeze(['core-upgrade', 'restart']);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* May this intent action be aimed at an instance of this hosting shape? PURE.
|
|
40
|
+
*
|
|
41
|
+
* AN UNKNOWN SHAPE IS ORDINARY, NOT CONTROL-PLANE. Deliberate, and the one place
|
|
42
|
+
* this guard fails OPEN. It protects one specific row; treating every
|
|
43
|
+
* unrecognised shape as the self row would refuse ordinary provisioning for any
|
|
44
|
+
* shape added later without touching this file — a far larger blast radius than
|
|
45
|
+
* the thing being guarded, and one that would present as provisioning mysteriously
|
|
46
|
+
* breaking rather than as a guard doing its job.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} action an INTENT_ACTIONS member
|
|
49
|
+
* @param {string} hostingShape a HOSTING_SHAPES member
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
function intentAllowedForShape(action, hostingShape) {
|
|
53
|
+
if (hostingShape !== 'control-plane') return true;
|
|
54
|
+
return CONTROL_PLANE_ACTIONS.includes(action);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Refuse, loudly, any intent aimed at the control-plane row that is not allowed there.
|
|
59
|
+
*
|
|
60
|
+
* CALLED FROM enqueueIntent — the ONE place every intent passes, whichever route or
|
|
61
|
+
* helper queued it. That placement is the point: the core-upgrade route checks the
|
|
62
|
+
* same rule earlier so the owner reads a sentence instead of a 500, but every OTHER
|
|
63
|
+
* action reaches the queue through a route that has never heard of this shape. A
|
|
64
|
+
* guard that must hold for all of them cannot live in each of them.
|
|
65
|
+
*
|
|
66
|
+
* IT THROWS RATHER THAN RETURNING A CONFLICT, because the two are different
|
|
67
|
+
* statements. A conflict means "not now", and a caller may reasonably retry it; this
|
|
68
|
+
* means "never, and if you got here something upstream is already wrong". A retry
|
|
69
|
+
* loop spinning on a refusal that cannot change is the worse failure.
|
|
70
|
+
*
|
|
71
|
+
* AN ABSENT ROW IS NOT REFUSED. The insert that follows carries a NOT NULL FK, so a
|
|
72
|
+
* missing instance fails there with the error that actually describes it; answering
|
|
73
|
+
* "control plane protected" for a row that does not exist would be a lie that sends
|
|
74
|
+
* the reader to the wrong file.
|
|
75
|
+
*
|
|
76
|
+
* One extra SELECT, on a path that runs a handful of times a day.
|
|
77
|
+
*
|
|
78
|
+
* @param {{query: Function}} db
|
|
79
|
+
* @param {number|string} instanceId
|
|
80
|
+
* @param {string} action an INTENT_ACTIONS member
|
|
81
|
+
* @throws {Error} code 'control_plane_protected'
|
|
82
|
+
*/
|
|
83
|
+
async function assertIntentAllowed(db, instanceId, action) {
|
|
84
|
+
const { rows } = await db.query(
|
|
85
|
+
'SELECT hosting_shape FROM provisioning_instances WHERE id = $1', [instanceId]);
|
|
86
|
+
const shape = rows[0] && rows[0].hosting_shape;
|
|
87
|
+
if (!shape || intentAllowedForShape(action, shape)) return;
|
|
88
|
+
// It carries the { code, status, message } shape credential-preflight.js established,
|
|
89
|
+
// so a route that wants to answer 409 with the sentence can, without re-deriving it.
|
|
90
|
+
// NO ROUTE MAPS IT TODAY, and that is the honest state rather than an oversight: every
|
|
91
|
+
// action that can reach this throw has its own earlier refusal, so arriving here means
|
|
92
|
+
// a route forgot one — a bug in us, not a thing the caller did. A 500 plus a logged
|
|
93
|
+
// stack is the right loudness for that, and the fields are here so the day a route
|
|
94
|
+
// legitimately needs to say it to a person, the wording is already written.
|
|
95
|
+
const err = new Error(`'${action}' cannot be aimed at the control plane — only ${CONTROL_PLANE_ACTIONS.join(' and ')} may run against the box the platform itself runs on`);
|
|
96
|
+
err.code = 'control_plane_protected';
|
|
97
|
+
err.status = 409;
|
|
98
|
+
err.publicMessage = `That action cannot be aimed at the machine the platform itself runs on. Only ${CONTROL_PLANE_ACTIONS.join(' and ')} are allowed there.`;
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { CONTROL_PLANE_ACTIONS, intentAllowedForShape, assertIntentAllowed };
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/provisioning/core-upgrade.js — the owner's deploy door, web-tier half
|
|
4
|
+
// (task 1003159, ADR 0293).
|
|
5
|
+
//
|
|
6
|
+
// Moving an instance's core pin is gate 3 of the release pipeline and the only act
|
|
7
|
+
// that makes shipped work visible to users. It was reachable from exactly one
|
|
8
|
+
// place — a shell on the control-plane box with an ssh key — so the owner could not
|
|
9
|
+
// take it, and neither could an agent (the permission classifier blocks a mutating
|
|
10
|
+
// `sudo -u lars` over ssh by design).
|
|
11
|
+
//
|
|
12
|
+
// THIS FILE ONLY ASKS. It validates and queues an intent; the control-plane runner
|
|
13
|
+
// drains it and drives the proven `bongos upgrade` path with its health gate and
|
|
14
|
+
// auto-rollback intact. The web process gains no sudo and no ssh key — that split
|
|
15
|
+
// is ADR 0111 §2's rule and ADR 0293 D1's decision, and it is why a stolen builder
|
|
16
|
+
// token can request a deploy but never perform one.
|
|
17
|
+
//
|
|
18
|
+
// WHY THE REGISTRY CHECK IS NOT HERE, though ADR 0293 D3 demands one. The only
|
|
19
|
+
// tool that can answer "is this version published" is update-channel.js's
|
|
20
|
+
// `listAvailableVersions`, which shells out via spawnSync — it would block the
|
|
21
|
+
// entire event loop for the seconds npm takes, stalling every other request on a
|
|
22
|
+
// single-process server. It also lives in scripts/gds/, which a module may not
|
|
23
|
+
// require (ADR 0083). So publication and channel policy are enforced by the RUNNER,
|
|
24
|
+
// which is a CLI process where both are free. What is enforced here is everything
|
|
25
|
+
// that can be decided without the network: shape, state, format, and the guard.
|
|
26
|
+
//
|
|
27
|
+
// Lives in its own file for the mechanical reason paid-shape-gate.js and
|
|
28
|
+
// credential-preflight.js do: provisioning.js and routes/provisioning.js both sit
|
|
29
|
+
// AT the 1500-line fitness ratchet, and at that ceiling a new function costs more
|
|
30
|
+
// than the build will give back.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
// Exactly the states in which there is a running instance whose pin can move.
|
|
34
|
+
// Narrower than RESTART_FROM on purpose: an instance mid-standup has no settled pin
|
|
35
|
+
// to move, and one that is tearing down is on its way out.
|
|
36
|
+
const CORE_UPGRADE_FROM = Object.freeze(new Set(['active']));
|
|
37
|
+
|
|
38
|
+
// The shapes whose box the runner can actually reach to perform an upgrade.
|
|
39
|
+
// Mirrors RESTART_SHAPES and adds the control plane (which the runner IS on).
|
|
40
|
+
//
|
|
41
|
+
// A 'dedicated' droplet is stood up by cloud-init and the runner has no post-hoc
|
|
42
|
+
// exec path to it — the same fact the restart route refuses on. Queueing an
|
|
43
|
+
// upgrade for one would be the F16 lie: a request that is accepted and never
|
|
44
|
+
// lands. This is a SEPARATE question from the control-plane guard, which asks
|
|
45
|
+
// what may be aimed at the platform itself; a first cut conflated the two and
|
|
46
|
+
// silently accepted upgrades for dedicated droplets.
|
|
47
|
+
const UPGRADE_SHAPES = Object.freeze(new Set(['co-tenant', 'standalone', 'control-plane']));
|
|
48
|
+
|
|
49
|
+
// The shape of a core version as the pin records it. Deliberately strict and
|
|
50
|
+
// three-part: the column's own CHECK carries the same pattern, so a value that
|
|
51
|
+
// slips past one still cannot reach the table. This says the string is WELL-FORMED,
|
|
52
|
+
// never that the version exists — see the header.
|
|
53
|
+
const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/;
|
|
54
|
+
|
|
55
|
+
const isCoreVersion = (v) => typeof v === 'string' && VERSION_RE.test(v);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Why this core-upgrade request must be refused, or null to admit it. PURE.
|
|
59
|
+
*
|
|
60
|
+
* Returns the shape credential-preflight.js established — `{ code, status, message }`
|
|
61
|
+
* for the route to `res.fail` with — so the refusal text lives beside the rule that
|
|
62
|
+
* produced it rather than being re-derived at the call site. Every message names
|
|
63
|
+
* what the caller can actually DO instead, which is the ADR 0190 §7 convention, and
|
|
64
|
+
* none of them promises a duration: how long a deploy takes is exactly what this
|
|
65
|
+
* layer cannot know.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} inst the provisioning_instances row
|
|
68
|
+
* @param {string} to the requested target core version
|
|
69
|
+
* @param {string?} live the version the instance currently serves, when known
|
|
70
|
+
*/
|
|
71
|
+
function coreUpgradeFault(inst, to, live = null) {
|
|
72
|
+
// UPGRADE_SHAPES IS THE WHOLE SHAPE CHECK, and the self-row guard is deliberately
|
|
73
|
+
// NOT consulted here. `intentAllowedForShape('core-upgrade', …)` returns true for
|
|
74
|
+
// every shape there is — for a tenant shape unconditionally, and for the control
|
|
75
|
+
// plane because 'core-upgrade' is permanently a member of CONTROL_PLANE_ACTIONS.
|
|
76
|
+
// A first cut called it anyway, which read as a second gate and could not refuse
|
|
77
|
+
// anything: the same "present but unreachable" failure this file's own header
|
|
78
|
+
// records about `inst.core_version ||`. The guard's real job is refusing the OTHER
|
|
79
|
+
// actions on the self row, and it does that at enqueueIntent, where every intent
|
|
80
|
+
// passes. Naming it a third time here would only make the dead branch look alive.
|
|
81
|
+
if (!UPGRADE_SHAPES.has(inst.hosting_shape)) {
|
|
82
|
+
return {
|
|
83
|
+
code: 'core_upgrade_unavailable',
|
|
84
|
+
status: 409,
|
|
85
|
+
message: `This project runs on a machine the platform doesn’t manage, so its core can’t be moved from here — whoever runs that machine has to upgrade it there.`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (!CORE_UPGRADE_FROM.has(inst.status)) {
|
|
89
|
+
const offline = inst.status === 'torn_down' || inst.status === 'tearing_down';
|
|
90
|
+
return {
|
|
91
|
+
code: 'not_upgradable',
|
|
92
|
+
status: 409,
|
|
93
|
+
message: offline
|
|
94
|
+
? 'This project is offline, so there is no running core to move — stand it up again first.'
|
|
95
|
+
: 'This project hasn’t finished standing up yet, so it has no settled core version to move.',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (!isCoreVersion(to)) {
|
|
99
|
+
return {
|
|
100
|
+
code: 'bad_target_version',
|
|
101
|
+
status: 400,
|
|
102
|
+
message: 'Name the exact core version to move to, as three numbers — for example 1.19.757.',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// Asking for the version already running is not an error worth a 4xx on its own,
|
|
106
|
+
// but queueing it would restart the service to change nothing. Say so instead.
|
|
107
|
+
if (live && String(live) === String(to)) {
|
|
108
|
+
return {
|
|
109
|
+
code: 'already_on_version',
|
|
110
|
+
status: 409,
|
|
111
|
+
message: `This project already runs core ${to}, so there is nothing to move — it would restart and land exactly where it is.`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Queue the upgrade. Mirrors enqueueRestart's arbitration of the single open slot.
|
|
119
|
+
*
|
|
120
|
+
* A core-upgrade already open is NOT reported as success the way a duplicate
|
|
121
|
+
* restart is: a restart carries no payload, so "already under way" is the truth,
|
|
122
|
+
* but this one names a TARGET and the open intent may name a different one.
|
|
123
|
+
* Reporting that as done would tell the owner their version is coming when another
|
|
124
|
+
* is. So a second request is a conflict the route names, including when the open
|
|
125
|
+
* intent is itself a core-upgrade.
|
|
126
|
+
*/
|
|
127
|
+
async function enqueueCoreUpgrade(provisioning, db, instanceId, to, requestedBy) {
|
|
128
|
+
const { intent, created } = await provisioning.enqueueIntent(db, instanceId, 'core-upgrade', requestedBy, to);
|
|
129
|
+
if (created) return { intent, created: true };
|
|
130
|
+
return { intent, created: false, conflict: 'open_intent' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The POST handler, as a factory over its dependencies.
|
|
135
|
+
*
|
|
136
|
+
* It lives here rather than inline in routes/provisioning.js because that file is
|
|
137
|
+
* AT the 1500-line fitness ratchet — the same reason callback-page.js and
|
|
138
|
+
* body-validators.js were carved out of it (see the module's CLAUDE.md). The
|
|
139
|
+
* router keeps the declaration and its gate, which is the part a reader scanning
|
|
140
|
+
* the route table needs to see; the body is one require away.
|
|
141
|
+
*
|
|
142
|
+
* THE SECOND STEP IS THE BODY. The owner previews, then confirms by naming the
|
|
143
|
+
* version they saw. Requiring an explicit `to` IS the confirmation (ADR 0293 D4):
|
|
144
|
+
* a request that cannot fire without naming its target cannot fire from a stray
|
|
145
|
+
* click, and "move to whatever is newest", resolved at drain time, would be a
|
|
146
|
+
* different act from the one that was previewed — main moves between the two.
|
|
147
|
+
*/
|
|
148
|
+
function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond, log }) {
|
|
149
|
+
return async function coreUpgradeHandler(req, res) {
|
|
150
|
+
const id = parseId(req, res);
|
|
151
|
+
if (!id) return;
|
|
152
|
+
if (validateOrRespond(req, res, { to: { type: 'string', maxLength: 32 } }, { strict: true })) return;
|
|
153
|
+
try {
|
|
154
|
+
const inst = await provisioning.getInstanceById(pool, id);
|
|
155
|
+
if (!inst) return res.fail('instance_not_found', 404);
|
|
156
|
+
const to = req.body && typeof req.body.to === 'string' ? req.body.to.trim() : '';
|
|
157
|
+
// THE LIVE VERSION IS NOT KNOWN HERE, and saying so beats pretending.
|
|
158
|
+
// provisioning_instances carries no core_version column, so the first cut's
|
|
159
|
+
// `inst.core_version || null` was always undefined and the already-on-version
|
|
160
|
+
// refusal could never fire from this route — a guard that reads as present
|
|
161
|
+
// and is unreachable, which is worse than no guard at all.
|
|
162
|
+
//
|
|
163
|
+
// What actually refuses a same-version move is `bongos upgrade`'s own
|
|
164
|
+
// same-version guard, in the runner, which is also what makes re-running a
|
|
165
|
+
// settled-but-unconfirmed intent a no-op (ADR 0293 Consequences). The
|
|
166
|
+
// parameter stays because the PREVIEW does know the live version — it reads
|
|
167
|
+
// the box's own /version (ADR 0293 D6) — and passes it.
|
|
168
|
+
const fault = coreUpgradeFault(inst, to, null);
|
|
169
|
+
if (fault) return res.fail(fault.code, { status: fault.status, message: fault.message });
|
|
170
|
+
|
|
171
|
+
// The actor rides the intent, and the runner carries it through to the
|
|
172
|
+
// core_upgrades ledger (ADR 0293 D5): a privileged action with an
|
|
173
|
+
// unattributed row is not acceptable here.
|
|
174
|
+
const actor = `api:archon:${req.builder.id}`;
|
|
175
|
+
const r = await enqueueCoreUpgrade(provisioning, pool, id, to, actor);
|
|
176
|
+
if (r.conflict) {
|
|
177
|
+
return res.fail('intent_conflict', {
|
|
178
|
+
status: 409,
|
|
179
|
+
message: `A '${r.intent.action}' run is currently ${r.intent.state} for this project — the core move could not be queued; try again once that settles.`,
|
|
180
|
+
details: { action: r.intent.action, state: r.intent.state, target_version: r.intent.target_version || null },
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
await provisioning.recordEvent(pool, {
|
|
184
|
+
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'core-upgrade',
|
|
185
|
+
detail: `core pin move to ${to} requested`, actor,
|
|
186
|
+
}).catch(() => {});
|
|
187
|
+
// No duration is promised and no success is claimed: this says the request
|
|
188
|
+
// was RECORDED, which is the only thing true at this moment (ADR 0190 §7).
|
|
189
|
+
return res.json({
|
|
190
|
+
ok: true, queued: true, action: 'core-upgrade', target_version: to,
|
|
191
|
+
message: `Queued a move to core ${to}. The project will restart; this page reports the version it actually serves afterwards, and rolls back on its own if the new one does not answer.`,
|
|
192
|
+
});
|
|
193
|
+
} catch (err) {
|
|
194
|
+
log.error('[provisioning] POST /provisioning/instances/:id/core-upgrade', err);
|
|
195
|
+
return res.fail('core_upgrade_failed', { status: 500, message: 'internal error' });
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* What the control shows BEFORE anything is pressed — step one of the two-step (D4).
|
|
202
|
+
*
|
|
203
|
+
* Reads the snapshot the control-plane runner writes (migration core_242), because
|
|
204
|
+
* neither fact is reachable from here: the served version lives on the box's loopback
|
|
205
|
+
* and the available versions need a blocking `npm view`. The web tier asks; it does not
|
|
206
|
+
* go and look.
|
|
207
|
+
*
|
|
208
|
+
* EVERY FIELD THAT COULD BE STALE CARRIES ITS AGE. `observed_at` is returned, not hidden,
|
|
209
|
+
* and a missing snapshot returns `observed_at: null` rather than a confident-looking
|
|
210
|
+
* empty one — a control that renders a cache as though it were live is how someone is
|
|
211
|
+
* told their box is on a version it left an hour ago.
|
|
212
|
+
*/
|
|
213
|
+
function previewFromSnapshot(inst, snap, openIntent) {
|
|
214
|
+
const served = (snap && snap.served_version) || null;
|
|
215
|
+
const available = (snap && Array.isArray(snap.available) ? snap.available : []);
|
|
216
|
+
const recommended = (snap && snap.recommended) || null;
|
|
217
|
+
// Only what a move could actually go TO: newer than what is running, and admitted by
|
|
218
|
+
// the channel the runner will apply. Listing anything else would offer the owner a
|
|
219
|
+
// button whose press the runner then refuses.
|
|
220
|
+
const behind = served && recommended && recommended !== served;
|
|
221
|
+
return {
|
|
222
|
+
instance_id: inst.id,
|
|
223
|
+
slug: inst.slug,
|
|
224
|
+
hosting_shape: inst.hosting_shape,
|
|
225
|
+
served_version: served,
|
|
226
|
+
recommended_version: recommended,
|
|
227
|
+
available_count: available.length,
|
|
228
|
+
behind: !!behind,
|
|
229
|
+
// Named so the UI cannot accidentally render "up to date" over a missing reading.
|
|
230
|
+
reading: served ? 'ok' : 'no_answer',
|
|
231
|
+
observed_at: (snap && snap.observed_at) || null,
|
|
232
|
+
open_intent: openIntent ? { action: openIntent.action, state: openIntent.state, target_version: openIntent.target_version || null } : null,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function loadPreview(pool, provisioning, inst) {
|
|
237
|
+
const { rows } = await pool.query('SELECT * FROM core_version_snapshot WHERE instance_id = $1', [inst.id]);
|
|
238
|
+
const openIntent = provisioning.getOpenIntent ? await provisioning.getOpenIntent(pool, inst.id) : null;
|
|
239
|
+
return previewFromSnapshot(inst, rows[0], openIntent);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function makeCoreUpgradePreviewHandler({ provisioning, pool, parseId, log }) {
|
|
243
|
+
return async function coreUpgradePreviewHandler(req, res) {
|
|
244
|
+
const id = parseId(req, res);
|
|
245
|
+
if (!id) return;
|
|
246
|
+
try {
|
|
247
|
+
const inst = await provisioning.getInstanceById(pool, id);
|
|
248
|
+
if (!inst) return res.fail('instance_not_found', 404);
|
|
249
|
+
return res.json({ ok: true, preview: await loadPreview(pool, provisioning, inst) });
|
|
250
|
+
} catch (err) {
|
|
251
|
+
log.error('[provisioning] GET /provisioning/instances/:id/core-upgrade', err);
|
|
252
|
+
return res.fail('core_upgrade_preview_failed', { status: 500, message: 'internal error' });
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Where the deploy page starts: "which row is this platform?"
|
|
259
|
+
*
|
|
260
|
+
* THE PAGE CANNOT BE EXPECTED TO KNOW THE ID. The self row is created once, by hand, at
|
|
261
|
+
* install time (scripts/gds/enroll-control-plane.js) — so its id differs per install and
|
|
262
|
+
* is nobody's to memorise. The alternative was to have the page list the fleet and filter
|
|
263
|
+
* it client-side, which would make a page about ONE box read every project row to find it,
|
|
264
|
+
* and would answer nothing at all for an Archon who does not own the control-plane row.
|
|
265
|
+
*
|
|
266
|
+
* A MISSING SELF ROW IS A 404 WITH AN INSTRUCTION, not an error. It is the expected state
|
|
267
|
+
* of an install that has not run the enrolment yet, and the only useful thing to say is
|
|
268
|
+
* which command creates it.
|
|
269
|
+
*/
|
|
270
|
+
function makeControlPlaneHandler({ provisioning, pool, log }) {
|
|
271
|
+
return async function controlPlaneHandler(req, res) {
|
|
272
|
+
try {
|
|
273
|
+
const { rows } = await pool.query("SELECT * FROM provisioning_instances WHERE hosting_shape = 'control-plane' LIMIT 1");
|
|
274
|
+
if (!rows[0]) {
|
|
275
|
+
return res.fail('control_plane_not_enrolled', {
|
|
276
|
+
status: 404,
|
|
277
|
+
message: 'This platform has no deploy door yet. Whoever administers the box runs `node scripts/gds/enroll-control-plane.js --owner <builderId> --apply` once, and it appears here.',
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return res.json({ ok: true, preview: await loadPreview(pool, provisioning, rows[0]) });
|
|
281
|
+
} catch (err) {
|
|
282
|
+
log.error('[provisioning] GET /provisioning/control-plane', err);
|
|
283
|
+
return res.fail('control_plane_read_failed', { status: 500, message: 'internal error' });
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = { CORE_UPGRADE_FROM, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
|
|
289
|
+
|