portal-mojo 0.2.20 → 0.2.21
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/docs/README.md +7 -1
- package/docs/admin-deployments.md +201 -0
- package/docs/admin-dns-certificates.md +3 -3
- package/docs/admin-dns-domains.md +92 -0
- package/docs/admin-dns.md +4 -2
- package/docs/admin-edge.md +200 -196
- package/docs/admin-geolocation.md +100 -0
- package/docs/admin-maintenance.md +133 -0
- package/docs/admin-modals.md +17 -2
- package/docs/admin-operational-fleet.md +206 -0
- package/docs/admin-presentation.md +51 -0
- package/docs/admin-signin.md +12 -2
- package/docs/client.md +7 -0
- package/docs/combobox.md +18 -0
- package/docs/popover.md +5 -4
- package/package.json +1 -1
- package/src/admin/core/OperationsHelp.tsx +10 -0
- package/src/admin/core/index.ts +6 -4
- package/src/admin/core/route-context.ts +6 -0
- package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
- package/src/admin/deployments/DeploymentsPage.tsx +73 -0
- package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
- package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
- package/src/admin/deployments/WebAppDetail.tsx +104 -0
- package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
- package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
- package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
- package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
- package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
- package/src/admin/deployments/api.ts +158 -0
- package/src/admin/deployments/authority-state.ts +9 -0
- package/src/admin/deployments/deployment-state.ts +13 -0
- package/src/admin/deployments/index.ts +4 -0
- package/src/admin/deployments/onboarding-state.ts +11 -0
- package/src/admin/deployments/permissions.ts +37 -0
- package/src/admin/deployments/receipts.ts +78 -0
- package/src/admin/deployments/sanitize.ts +73 -0
- package/src/admin/deployments/shared.tsx +39 -0
- package/src/admin/deployments/types.ts +238 -0
- package/src/admin/deployments/upload.ts +53 -0
- package/src/admin/deployments/useDeploymentObservation.ts +104 -0
- package/src/admin/dns/DomainDetail.tsx +10 -6
- package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
- package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
- package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
- package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
- package/src/admin/dns/DomainsPage.tsx +22 -13
- package/src/admin/dns/SharedValidationDialog.tsx +1 -1
- package/src/admin/dns/api.ts +4 -2
- package/src/admin/dns/domain-navigation.ts +16 -0
- package/src/admin/dns/registrar-api.ts +13 -0
- package/src/admin/domains/assistant.ts +5 -4
- package/src/admin/domains/communications.ts +10 -9
- package/src/admin/domains/identity.ts +8 -7
- package/src/admin/domains/infrastructure.ts +19 -13
- package/src/admin/domains/lazy-page.ts +6 -0
- package/src/admin/domains/observability.ts +6 -5
- package/src/admin/domains/operations.ts +22 -7
- package/src/admin/domains/security.ts +16 -15
- package/src/admin/edge/EdgeHelp.tsx +10 -0
- package/src/admin/edge/ServingReadiness.tsx +54 -0
- package/src/admin/edge/VhostDetail.tsx +145 -139
- package/src/admin/edge/VhostWizard.tsx +204 -115
- package/src/admin/edge/VhostsPage.tsx +27 -28
- package/src/admin/edge/api.ts +24 -10
- package/src/admin/edge/models.ts +4 -4
- package/src/admin/edge/serving-data.ts +78 -0
- package/src/admin/fleet/CapacityEditor.tsx +81 -0
- package/src/admin/fleet/CapacityInspector.tsx +19 -0
- package/src/admin/fleet/CapacityProgress.tsx +9 -0
- package/src/admin/fleet/FleetHelp.tsx +7 -0
- package/src/admin/fleet/FleetOverview.tsx +150 -0
- package/src/admin/fleet/FleetPage.tsx +11 -0
- package/src/admin/fleet/FleetWorkspace.tsx +82 -0
- package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
- package/src/admin/fleet/access.ts +22 -0
- package/src/admin/fleet/api.ts +77 -0
- package/src/admin/fleet/data.ts +148 -0
- package/src/admin/fleet/index.ts +8 -0
- package/src/admin/fleet/operations.ts +179 -0
- package/src/admin/fleet/standalone-data.ts +87 -0
- package/src/admin/fleet/types.ts +208 -0
- package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
- package/src/admin/geolocation/data.ts +138 -0
- package/src/admin/geolocation/index.ts +3 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
- package/src/admin/index.ts +8 -0
- package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
- package/src/admin/maintenance/MaintenancePage.tsx +442 -0
- package/src/admin/maintenance/data.ts +486 -0
- package/src/admin/maintenance/index.ts +3 -0
- package/src/admin/maintenance/operations.ts +208 -0
- package/src/admin/public/infrastructure.ts +1 -0
- package/src/admin/public/operations.ts +3 -0
- package/src/admin/registry.ts +6 -2
- package/src/admin/settings/SettingDetail.tsx +7 -2
- package/src/admin/settings/SettingEditor.tsx +2 -1
- package/src/admin/settings/SettingsPage.tsx +3 -1
- package/src/admin/settings/model.ts +1 -0
- package/src/admin/signin/SigninPage.tsx +17 -3
- package/src/admin/signin/api.ts +2 -0
- package/src/charts/MetricsChartView.tsx +4 -11
- package/src/charts/MetricsMiniWidget.tsx +3 -15
- package/src/client/client.ts +6 -0
- package/src/client/deployments-mock.ts +375 -0
- package/src/client/domain-operations-mock.ts +37 -0
- package/src/client/geo-provider-mock.ts +82 -0
- package/src/client/maintenance-mock.ts +285 -0
- package/src/client/mock.ts +52 -10
- package/src/client/operational-fleet-mock.ts +279 -0
- package/src/client/operations-mock.ts +85 -0
- package/src/client/serving-mock.ts +22 -0
- package/src/styles/components/admin-deployments.css +35 -0
- package/src/styles/components/admin-domains.css +12 -0
- package/src/styles/components/admin-edge.css +125 -1
- package/src/styles/components/admin-geolocation.css +40 -0
- package/src/styles/components/admin-maintenance.css +127 -0
- package/src/styles/components/admin-operational-fleet.css +38 -0
- package/src/styles/components/admin-operations.css +9 -0
- package/src/styles/components/admin-signin.css +2 -0
- package/src/styles/components/combobox.css +12 -2
- package/src/styles/components/fleet-page.css +9 -0
- package/src/styles/index.css +7 -0
- package/src/ui/ComboBox.tsx +39 -8
- package/src/ui/FilterBar.tsx +3 -5
- package/src/ui/ModelTable.tsx +2 -8
- package/src/ui/Popover.tsx +15 -7
- package/src/ui/date/TimePicker.tsx +4 -14
- package/src/ui/image-editor/ImageEditor.tsx +4 -5
- package/src/ui/schema-form-core.tsx +3 -5
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/** Narrow Maintenance protocol boundary; no mutation cache, retry or replay. */
|
|
2
|
+
import { apiOrigin, usingMockTransport } from '../../client/client';
|
|
3
|
+
import { getAuthSessionRevision, getAuthSnapshot, mojoCall, MojoError } from '../../client/runtime';
|
|
4
|
+
import { backendGlobalCan, type BackendGlobalUser } from '../fleet/access';
|
|
5
|
+
export const MAINTENANCE_ENDPOINTS = {
|
|
6
|
+
versions: '/api/aws/maintenance/versions',
|
|
7
|
+
status: '/api/aws/maintenance/status',
|
|
8
|
+
apply: '/api/aws/maintenance/apply',
|
|
9
|
+
framework: '/api/account/admin/platform/framework',
|
|
10
|
+
update: '/api/account/admin/platform/framework/update',
|
|
11
|
+
bootstrap: '/api/account/admin/bootstrap',
|
|
12
|
+
policy: '/api/account/admin/advanced/settings',
|
|
13
|
+
platform: '/api/account/admin/platform',
|
|
14
|
+
} as const;
|
|
15
|
+
export type Kind = 'rds-instance' | 'rds-cluster' | 'elasticache';
|
|
16
|
+
export interface Finding {
|
|
17
|
+
kind: Kind;
|
|
18
|
+
resource_id: string;
|
|
19
|
+
engine: string;
|
|
20
|
+
current_version: string;
|
|
21
|
+
available_major: string;
|
|
22
|
+
deadline: string | null;
|
|
23
|
+
extended_deadline: string | null;
|
|
24
|
+
days_remaining: number | null;
|
|
25
|
+
note: string | null;
|
|
26
|
+
release_notes_url: string | null;
|
|
27
|
+
status: string | null;
|
|
28
|
+
pending_version: string | null;
|
|
29
|
+
settled: boolean | null;
|
|
30
|
+
members?: number;
|
|
31
|
+
engine_versions?: string[];
|
|
32
|
+
}
|
|
33
|
+
export interface Report {
|
|
34
|
+
schema_version: 1;
|
|
35
|
+
generated_at: string | null;
|
|
36
|
+
region: string | null;
|
|
37
|
+
status: 'ok' | 'unavailable';
|
|
38
|
+
level: number;
|
|
39
|
+
scheduled: boolean;
|
|
40
|
+
findings: Finding[];
|
|
41
|
+
warnings: { iam_action: string; aws_code: string | null }[];
|
|
42
|
+
reason: string | null;
|
|
43
|
+
}
|
|
44
|
+
export interface ResourceStatus {
|
|
45
|
+
schema_version: 1;
|
|
46
|
+
kind: Kind;
|
|
47
|
+
resource: string;
|
|
48
|
+
target_version: string;
|
|
49
|
+
found: boolean;
|
|
50
|
+
status: string | null;
|
|
51
|
+
engine_version: string | null;
|
|
52
|
+
engine_versions: string[];
|
|
53
|
+
pending_version: string | null;
|
|
54
|
+
settled: boolean;
|
|
55
|
+
upgraded: boolean;
|
|
56
|
+
}
|
|
57
|
+
export interface Framework {
|
|
58
|
+
schema_version: 1;
|
|
59
|
+
installed: string | null;
|
|
60
|
+
latest: string | null;
|
|
61
|
+
checked_at: string | number | null;
|
|
62
|
+
source: 'pypi' | 'cache' | 'unavailable';
|
|
63
|
+
update_available: boolean;
|
|
64
|
+
pin: { mode: 'latest' | 'hold' | 'pinned'; value: string | null };
|
|
65
|
+
can_update: boolean;
|
|
66
|
+
blocked_reason:
|
|
67
|
+
| 'update_unavailable'
|
|
68
|
+
| 'requires_superuser'
|
|
69
|
+
| 'no_converged_deployment'
|
|
70
|
+
| 'infrastructure_external'
|
|
71
|
+
| null;
|
|
72
|
+
}
|
|
73
|
+
export interface ApplyInput {
|
|
74
|
+
kind: Kind;
|
|
75
|
+
resource: string;
|
|
76
|
+
target_version: string;
|
|
77
|
+
confirm_resource: string;
|
|
78
|
+
apply_immediately: boolean;
|
|
79
|
+
}
|
|
80
|
+
export interface Scope {
|
|
81
|
+
signal?: AbortSignal;
|
|
82
|
+
beforeSend?: () => void;
|
|
83
|
+
}
|
|
84
|
+
export class MaintenanceError extends Error {
|
|
85
|
+
constructor(
|
|
86
|
+
public code: string,
|
|
87
|
+
public mutationState: 'none' | 'attempted' | 'unknown' = 'none',
|
|
88
|
+
public failure?: {
|
|
89
|
+
operation: string;
|
|
90
|
+
provider_code: string;
|
|
91
|
+
retryable: boolean;
|
|
92
|
+
mutation_state: string;
|
|
93
|
+
iam_action?: string;
|
|
94
|
+
},
|
|
95
|
+
) {
|
|
96
|
+
super(
|
|
97
|
+
code === 'fresh_auth_required'
|
|
98
|
+
? 'Authenticate again, then refresh and review a new confirmation. Nothing is replayed.'
|
|
99
|
+
: code === 'incompatible'
|
|
100
|
+
? 'Maintenance returned incompatible evidence. Refresh before continuing.'
|
|
101
|
+
: code === 'denied'
|
|
102
|
+
? 'Current global permission or session does not allow this operation.'
|
|
103
|
+
: `${code.replaceAll('_', ' ')}${mutationState !== 'none' ? ' — the operation may have started; reconcile provider status before another request.' : '. Refresh authoritative evidence before continuing.'}`,
|
|
104
|
+
);
|
|
105
|
+
if (failure) {
|
|
106
|
+
this.message += ` Operation: ${failure.operation}; provider code: ${failure.provider_code}; mutation state: ${failure.mutation_state}; retryable: ${String(failure.retryable)}${failure.iam_action ? `; IAM action: ${failure.iam_action}` : ''}.`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const invalid = (): never => {
|
|
111
|
+
throw new MaintenanceError('incompatible');
|
|
112
|
+
};
|
|
113
|
+
const obj = (v: unknown): Record<string, unknown> =>
|
|
114
|
+
v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : invalid();
|
|
115
|
+
const text = (v: unknown, max = 512): string =>
|
|
116
|
+
typeof v === 'string' && v.length > 0 && v.length <= max && !/[\x00-\x1f\x7f]/.test(v) ? v : invalid();
|
|
117
|
+
const nullableText = (v: unknown) => (v == null ? null : text(v));
|
|
118
|
+
const errorToken = (v: unknown): string =>
|
|
119
|
+
typeof v === 'string' && /^[A-Za-z0-9_.:-]{1,160}$/.test(v) ? v : invalid();
|
|
120
|
+
const bool = (v: unknown): boolean => (typeof v === 'boolean' ? v : invalid());
|
|
121
|
+
const enumeration = <T extends string>(v: unknown, values: readonly T[]): T =>
|
|
122
|
+
values.includes(v as T) ? (v as T) : invalid();
|
|
123
|
+
const array = (v: unknown, max = 1024): unknown[] => (Array.isArray(v) && v.length <= max ? v : invalid());
|
|
124
|
+
const schema = (v: unknown) => {
|
|
125
|
+
const r = obj(v);
|
|
126
|
+
if (r.schema_version !== 1) invalid();
|
|
127
|
+
return r;
|
|
128
|
+
};
|
|
129
|
+
const kind = (v: unknown) => enumeration(v, ['rds-instance', 'rds-cluster', 'elasticache'] as const);
|
|
130
|
+
export function releaseNotesURL(v: unknown): string | null {
|
|
131
|
+
if (v == null) return null;
|
|
132
|
+
const s = text(v, 2048);
|
|
133
|
+
try {
|
|
134
|
+
const u = new URL(s);
|
|
135
|
+
return u.protocol === 'https:' && !u.username && !u.password ? u.href : null;
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export function parseFinding(v: unknown): Finding {
|
|
141
|
+
const r = obj(v);
|
|
142
|
+
return {
|
|
143
|
+
kind: kind(r.kind),
|
|
144
|
+
resource_id: text(r.resource_id),
|
|
145
|
+
engine: text(r.engine),
|
|
146
|
+
current_version: text(r.current_version),
|
|
147
|
+
available_major: text(r.available_major),
|
|
148
|
+
deadline: nullableText(r.deadline),
|
|
149
|
+
extended_deadline: nullableText(r.extended_deadline),
|
|
150
|
+
days_remaining:
|
|
151
|
+
r.days_remaining == null
|
|
152
|
+
? null
|
|
153
|
+
: typeof r.days_remaining === 'number' && Number.isFinite(r.days_remaining)
|
|
154
|
+
? r.days_remaining
|
|
155
|
+
: invalid(),
|
|
156
|
+
note: nullableText(r.note),
|
|
157
|
+
release_notes_url: releaseNotesURL(r.release_notes_url),
|
|
158
|
+
status: nullableText(r.status),
|
|
159
|
+
pending_version: nullableText(r.pending_version),
|
|
160
|
+
settled: r.settled == null ? null : bool(r.settled),
|
|
161
|
+
...(r.members == null
|
|
162
|
+
? {}
|
|
163
|
+
: {
|
|
164
|
+
members:
|
|
165
|
+
Number.isSafeInteger(r.members) && Number(r.members) >= 0
|
|
166
|
+
? Number(r.members)
|
|
167
|
+
: invalid(),
|
|
168
|
+
}),
|
|
169
|
+
...(r.engine_versions == null
|
|
170
|
+
? {}
|
|
171
|
+
: { engine_versions: array(r.engine_versions, 256).map((x) => text(x)) }),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
export function parseReport(v: unknown): Report {
|
|
175
|
+
const r = schema(v);
|
|
176
|
+
const findings = array(r.findings).map(parseFinding);
|
|
177
|
+
if (new Set(findings.map((f) => `${f.kind}:${f.resource_id}`)).size !== findings.length) invalid();
|
|
178
|
+
return {
|
|
179
|
+
schema_version: 1,
|
|
180
|
+
generated_at: nullableText(r.generated_at),
|
|
181
|
+
region: nullableText(r.region),
|
|
182
|
+
status: enumeration(r.status, ['ok', 'unavailable']),
|
|
183
|
+
level: typeof r.level === 'number' && Number.isFinite(r.level) ? r.level : invalid(),
|
|
184
|
+
scheduled: bool(r.scheduled),
|
|
185
|
+
findings,
|
|
186
|
+
warnings: array(r.warnings, 128).map((v) => {
|
|
187
|
+
const w = obj(v);
|
|
188
|
+
return { iam_action: text(w.iam_action), aws_code: nullableText(w.aws_code) };
|
|
189
|
+
}),
|
|
190
|
+
reason: nullableText(r.reason),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
export function parseResourceStatus(
|
|
194
|
+
v: unknown,
|
|
195
|
+
target: Pick<ApplyInput, 'kind' | 'resource' | 'target_version'>,
|
|
196
|
+
): ResourceStatus {
|
|
197
|
+
const r = schema(v);
|
|
198
|
+
if (
|
|
199
|
+
r.kind !== target.kind ||
|
|
200
|
+
r.resource !== target.resource ||
|
|
201
|
+
r.target_version !== target.target_version
|
|
202
|
+
)
|
|
203
|
+
invalid();
|
|
204
|
+
return {
|
|
205
|
+
schema_version: 1,
|
|
206
|
+
kind: kind(r.kind),
|
|
207
|
+
resource: text(r.resource),
|
|
208
|
+
target_version: text(r.target_version),
|
|
209
|
+
found: bool(r.found),
|
|
210
|
+
status: nullableText(r.status),
|
|
211
|
+
engine_version: nullableText(r.engine_version),
|
|
212
|
+
engine_versions: array(r.engine_versions, 256).map((x) => text(x)),
|
|
213
|
+
pending_version: nullableText(r.pending_version),
|
|
214
|
+
settled: bool(r.settled),
|
|
215
|
+
upgraded: bool(r.upgraded),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
export function parseFramework(v: unknown): Framework {
|
|
219
|
+
const r = schema(v),
|
|
220
|
+
p = obj(r.pin);
|
|
221
|
+
const result: Framework = {
|
|
222
|
+
schema_version: 1,
|
|
223
|
+
installed: nullableText(r.installed),
|
|
224
|
+
latest: nullableText(r.latest),
|
|
225
|
+
checked_at:
|
|
226
|
+
r.checked_at == null
|
|
227
|
+
? null
|
|
228
|
+
: typeof r.checked_at === 'number' && Number.isFinite(r.checked_at)
|
|
229
|
+
? r.checked_at
|
|
230
|
+
: text(r.checked_at),
|
|
231
|
+
source: enumeration(r.source, ['pypi', 'cache', 'unavailable']),
|
|
232
|
+
update_available: bool(r.update_available),
|
|
233
|
+
pin: { mode: enumeration(p.mode, ['latest', 'hold', 'pinned']), value: nullableText(p.value) },
|
|
234
|
+
can_update: bool(r.can_update),
|
|
235
|
+
blocked_reason:
|
|
236
|
+
r.blocked_reason === null
|
|
237
|
+
? null
|
|
238
|
+
: enumeration(r.blocked_reason, [
|
|
239
|
+
'update_unavailable',
|
|
240
|
+
'requires_superuser',
|
|
241
|
+
'no_converged_deployment',
|
|
242
|
+
'infrastructure_external',
|
|
243
|
+
] as const),
|
|
244
|
+
};
|
|
245
|
+
if (
|
|
246
|
+
result.can_update &&
|
|
247
|
+
(!result.latest ||
|
|
248
|
+
result.blocked_reason ||
|
|
249
|
+
(!result.update_available && result.pin.mode === 'latest'))
|
|
250
|
+
)
|
|
251
|
+
invalid();
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
export function parseInfrastructure(v: unknown): 'managed' | 'external' {
|
|
255
|
+
const r = obj(v),
|
|
256
|
+
mode = enumeration(obj(r.infrastructure).mode, ['managed', 'external'] as const),
|
|
257
|
+
managed = obj(obj(obj(r.features).platform).capabilities).infrastructure_managed;
|
|
258
|
+
if (managed !== (mode === 'managed')) invalid();
|
|
259
|
+
return mode;
|
|
260
|
+
}
|
|
261
|
+
export function maintenanceAccess(user: BackendGlobalUser | null | undefined) {
|
|
262
|
+
return {
|
|
263
|
+
aws: backendGlobalCan(user, 'manage_aws'),
|
|
264
|
+
framework: backendGlobalCan(user, ['view_platform', 'manage_platform', 'admin']),
|
|
265
|
+
bootstrap: backendGlobalCan(user, ['view_admin', 'manage_users', 'manage_settings', 'admin']),
|
|
266
|
+
manage: backendGlobalCan(user, ['manage_platform', 'admin']),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
export function maintenanceFence(scope: Scope = {}): Scope {
|
|
270
|
+
const revision = getAuthSessionRevision(),
|
|
271
|
+
actor = getAuthSnapshot().uid;
|
|
272
|
+
return {
|
|
273
|
+
signal: scope.signal,
|
|
274
|
+
beforeSend() {
|
|
275
|
+
if (
|
|
276
|
+
scope.signal?.aborted ||
|
|
277
|
+
!actor ||
|
|
278
|
+
!getAuthSnapshot().authenticated ||
|
|
279
|
+
actor !== getAuthSnapshot().uid ||
|
|
280
|
+
revision !== getAuthSessionRevision()
|
|
281
|
+
)
|
|
282
|
+
throw new MaintenanceError('denied');
|
|
283
|
+
scope.beforeSend?.();
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function sameOrigin() {
|
|
288
|
+
if (
|
|
289
|
+
!usingMockTransport() &&
|
|
290
|
+
new URL(apiOrigin(), window.location.origin).origin !== window.location.origin
|
|
291
|
+
)
|
|
292
|
+
throw new MaintenanceError('denied');
|
|
293
|
+
}
|
|
294
|
+
async function call(
|
|
295
|
+
path: string,
|
|
296
|
+
scope: Scope = {},
|
|
297
|
+
body?: Record<string, unknown>,
|
|
298
|
+
params?: Record<string, string | number>,
|
|
299
|
+
) {
|
|
300
|
+
const fence = maintenanceFence(scope);
|
|
301
|
+
try {
|
|
302
|
+
if (body) sameOrigin();
|
|
303
|
+
return (
|
|
304
|
+
await mojoCall(path, {
|
|
305
|
+
...(body ? { method: 'POST', body } : { params }),
|
|
306
|
+
signal: scope.signal,
|
|
307
|
+
beforeSend: () => {
|
|
308
|
+
if (body) sameOrigin();
|
|
309
|
+
fence.beforeSend?.();
|
|
310
|
+
},
|
|
311
|
+
})
|
|
312
|
+
).data;
|
|
313
|
+
} catch (e) {
|
|
314
|
+
if (e instanceof MaintenanceError) throw e;
|
|
315
|
+
if (e instanceof MojoError) {
|
|
316
|
+
const knownCodes = new Set([
|
|
317
|
+
'provider_denied',
|
|
318
|
+
'provider_unavailable',
|
|
319
|
+
'provider_error',
|
|
320
|
+
'cache_unavailable',
|
|
321
|
+
'upgrade_in_progress',
|
|
322
|
+
'upgrade_not_offered',
|
|
323
|
+
'infrastructure_external',
|
|
324
|
+
'permission_denied',
|
|
325
|
+
'invalid_request',
|
|
326
|
+
'update_unavailable',
|
|
327
|
+
'deployment_coordination_unavailable',
|
|
328
|
+
]);
|
|
329
|
+
const code =
|
|
330
|
+
e.status === 440
|
|
331
|
+
? 'fresh_auth_required'
|
|
332
|
+
: typeof e.errorCode === 'string' && knownCodes.has(e.errorCode)
|
|
333
|
+
? e.errorCode
|
|
334
|
+
: [401, 403].includes(e.status)
|
|
335
|
+
? 'denied'
|
|
336
|
+
: 'service_unavailable';
|
|
337
|
+
let failure: MaintenanceError['failure'];
|
|
338
|
+
try {
|
|
339
|
+
const f = obj(obj(e.data).failure);
|
|
340
|
+
failure = {
|
|
341
|
+
operation: errorToken(f.operation),
|
|
342
|
+
provider_code: errorToken(f.provider_code),
|
|
343
|
+
retryable: bool(f.retryable),
|
|
344
|
+
mutation_state: enumeration(f.mutation_state, ['none', 'attempted', 'unknown']),
|
|
345
|
+
...(f.iam_action == null ? {} : { iam_action: errorToken(f.iam_action) }),
|
|
346
|
+
};
|
|
347
|
+
} catch {
|
|
348
|
+
/* No provider evidence. */
|
|
349
|
+
}
|
|
350
|
+
// A generic Framework 409 may follow a successful pin clear. An
|
|
351
|
+
// upgrade_in_progress refusal also proves outstanding provider work.
|
|
352
|
+
// Neither outcome is safe to unlock from a refreshed old version.
|
|
353
|
+
const preProviderCodes = new Set([
|
|
354
|
+
'cache_unavailable',
|
|
355
|
+
'upgrade_not_offered',
|
|
356
|
+
'invalid_request',
|
|
357
|
+
'infrastructure_external',
|
|
358
|
+
'permission_denied',
|
|
359
|
+
'update_unavailable',
|
|
360
|
+
]);
|
|
361
|
+
const safeRefusal =
|
|
362
|
+
e.status === 440 ||
|
|
363
|
+
(!failure &&
|
|
364
|
+
(preProviderCodes.has(code) ||
|
|
365
|
+
([400, 401, 403, 404, 405].includes(e.status) &&
|
|
366
|
+
!['provider_denied', 'provider_error', 'provider_unavailable'].includes(code))));
|
|
367
|
+
throw new MaintenanceError(
|
|
368
|
+
code,
|
|
369
|
+
failure?.mutation_state === 'attempted'
|
|
370
|
+
? 'attempted'
|
|
371
|
+
: failure?.mutation_state === 'unknown' || (body && !safeRefusal)
|
|
372
|
+
? 'unknown'
|
|
373
|
+
: 'none',
|
|
374
|
+
failure,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
throw new MaintenanceError(
|
|
378
|
+
body ? 'operation_uncertain' : 'service_unavailable',
|
|
379
|
+
body ? 'unknown' : 'none',
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
export const readMaintenance = async (scope?: Scope, refresh = false) =>
|
|
384
|
+
parseReport(
|
|
385
|
+
await call(MAINTENANCE_ENDPOINTS.versions, scope, undefined, refresh ? { refresh: 1 } : undefined),
|
|
386
|
+
);
|
|
387
|
+
export const readFramework = async (scope?: Scope, refresh = false) =>
|
|
388
|
+
parseFramework(
|
|
389
|
+
await call(MAINTENANCE_ENDPOINTS.framework, scope, undefined, refresh ? { refresh: 1 } : undefined),
|
|
390
|
+
);
|
|
391
|
+
export const readInfrastructure = async (scope?: Scope) =>
|
|
392
|
+
parseInfrastructure(await call(MAINTENANCE_ENDPOINTS.bootstrap, scope));
|
|
393
|
+
export const readMaintenanceStatus = async (
|
|
394
|
+
target: Pick<ApplyInput, 'kind' | 'resource' | 'target_version'>,
|
|
395
|
+
scope?: Scope,
|
|
396
|
+
) => parseResourceStatus(await call(MAINTENANCE_ENDPOINTS.status, scope, undefined, target), target);
|
|
397
|
+
export async function applyMaintenance(input: ApplyInput, scope?: Scope) {
|
|
398
|
+
kind(input.kind);
|
|
399
|
+
text(input.resource);
|
|
400
|
+
text(input.target_version);
|
|
401
|
+
if (input.confirm_resource !== input.resource || typeof input.apply_immediately !== 'boolean') invalid();
|
|
402
|
+
const raw = await call(MAINTENANCE_ENDPOINTS.apply, scope, { ...input });
|
|
403
|
+
try {
|
|
404
|
+
const r = schema(raw);
|
|
405
|
+
if (
|
|
406
|
+
r.requested !== true ||
|
|
407
|
+
r.kind !== input.kind ||
|
|
408
|
+
r.resource !== input.resource ||
|
|
409
|
+
r.target_version !== input.target_version ||
|
|
410
|
+
r.apply_immediately !== input.apply_immediately
|
|
411
|
+
)
|
|
412
|
+
throw new MaintenanceError('operation_uncertain', 'unknown');
|
|
413
|
+
return { requested: true as const, operation: text(r.operation) };
|
|
414
|
+
} catch {
|
|
415
|
+
throw new MaintenanceError('operation_uncertain', 'unknown');
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
export async function updateFramework(version: string, scope?: Scope) {
|
|
419
|
+
text(version);
|
|
420
|
+
const raw = await call(MAINTENANCE_ENDPOINTS.update, scope, { version, confirm_version: version });
|
|
421
|
+
try {
|
|
422
|
+
const r = schema(raw);
|
|
423
|
+
if (r.requested !== true || r.version !== version)
|
|
424
|
+
throw new MaintenanceError('operation_uncertain', 'unknown');
|
|
425
|
+
const d = obj(r.deployment);
|
|
426
|
+
return {
|
|
427
|
+
requested: true as const,
|
|
428
|
+
version,
|
|
429
|
+
cleared_pin: nullableText(r.cleared_pin),
|
|
430
|
+
deployment: { id: text(d.id), sha: text(d.sha), status: text(d.status) },
|
|
431
|
+
};
|
|
432
|
+
} catch {
|
|
433
|
+
throw new MaintenanceError('operation_uncertain', 'unknown');
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
export const frameworkActionLabel = (f: Framework) =>
|
|
437
|
+
f.pin.mode === 'latest' ? `Update to ${f.latest}` : 'Resume and update';
|
|
438
|
+
|
|
439
|
+
/** These labels do not interpret policy-suppressed update_available as current. */
|
|
440
|
+
export function frameworkStatusLabel(f: Framework) {
|
|
441
|
+
if (!f.latest || f.source === 'unavailable') return 'Latest version unknown';
|
|
442
|
+
if (!f.installed) return 'Installed version unknown';
|
|
443
|
+
if (f.pin.mode !== 'latest') return f.latest === f.installed ? 'Update policy paused' : 'Published version differs · policy paused';
|
|
444
|
+
if (f.update_available) return 'Update available';
|
|
445
|
+
return f.latest === f.installed ? 'Current' : 'Published version differs';
|
|
446
|
+
}
|
|
447
|
+
export function supportUrgency(f: Finding) {
|
|
448
|
+
const days = f.days_remaining;
|
|
449
|
+
return days == null ? null : days < 0 ? `Standard support ended ${Math.abs(days)} days ago` : days === 0 ? 'Standard support ends today' : days <= 30 ? `Standard support ends in ${days} days` : null;
|
|
450
|
+
}
|
|
451
|
+
export function normalizePolicy(value: string) {
|
|
452
|
+
const normalized = value.trim().toLowerCase();
|
|
453
|
+
if (normalized === '' || normalized === 'hold' || /^[0-9][a-z0-9._!+-]{0,63}$/.test(normalized)) return normalized;
|
|
454
|
+
throw new MaintenanceError('invalid_framework_version');
|
|
455
|
+
}
|
|
456
|
+
export function policyMatches(pin: Framework['pin'], value: string) {
|
|
457
|
+
return value === '' ? pin.mode === 'latest' : value === 'hold' ? pin.mode === 'hold' : pin.mode === 'pinned' && pin.value === value;
|
|
458
|
+
}
|
|
459
|
+
export async function saveFrameworkPolicy(value: string, scope?: Scope) {
|
|
460
|
+
const pin = normalizePolicy(value);
|
|
461
|
+
const raw = await call(MAINTENANCE_ENDPOINTS.policy, scope, { framework_pin: pin });
|
|
462
|
+
try { const r = schema(raw); if (r.saved !== true || r.value !== pin) invalid(); return pin; }
|
|
463
|
+
catch { throw new MaintenanceError('operation_uncertain', 'unknown'); }
|
|
464
|
+
}
|
|
465
|
+
export interface FrameworkObservation {
|
|
466
|
+
id: string; sha: string; status: string; framework_version: string | null;
|
|
467
|
+
nodes: { runner: string; state: string; version: string | null }[];
|
|
468
|
+
terminal: boolean; versionProven: boolean;
|
|
469
|
+
}
|
|
470
|
+
export async function readFrameworkDeployment(target: { id: string; sha: string }, version: string, scope?: Scope): Promise<FrameworkObservation> {
|
|
471
|
+
const r = schema(await call(MAINTENANCE_ENDPOINTS.platform, scope, undefined, { sections: 'deployments' }));
|
|
472
|
+
const collector = obj(obj(r.sections).deployments);
|
|
473
|
+
if (!['ok', 'healthy', 'degraded', 'unhealthy'].includes(String(collector.status))) invalid();
|
|
474
|
+
const row = array(obj(collector.data).items, 50).map(obj).find(d => d.id === target.id);
|
|
475
|
+
if (!row || row.sha !== target.sha) throw new MaintenanceError('deployment_evidence_missing');
|
|
476
|
+
const roster = array(row.frozen_roster, 256).map(v => text(v));
|
|
477
|
+
const evidence = array(row.node_evidence, 256).map(obj);
|
|
478
|
+
const nodes = evidence.map(e => ({ runner: text(e.runner), state: text(e.state), version: obj(e.proof ?? {}).django_mojo_version === '' ? null : nullableText(obj(e.proof ?? {}).django_mojo_version) }));
|
|
479
|
+
const status = text(row.status);
|
|
480
|
+
const versionProven = status === 'converged' && row.framework_version === version && roster.length > 0 && new Set(roster).size === roster.length && roster.every(runner => {
|
|
481
|
+
const matches = evidence.filter(e => e.runner === runner); if (matches.length !== 1) return false;
|
|
482
|
+
const e = matches[0]!, p = obj(e.proof ?? {});
|
|
483
|
+
return e.state === 'proven' && typeof e.observed_at === 'string' && Number.isFinite(Date.parse(e.observed_at)) && Date.parse(e.observed_at) <= Date.now()+60_000 && typeof e.stale_after === 'string' && Date.parse(e.stale_after) > Date.now() && p.platform_deployment === target.id && typeof p.platform_sha === 'string' && /^[a-f0-9]{40}$/i.test(p.platform_sha) && p.platform_sha === target.sha && p.django_mojo_version === version;
|
|
484
|
+
});
|
|
485
|
+
return { id: target.id, sha: target.sha, status, framework_version: row.framework_version === '' ? null : nullableText(row.framework_version), nodes, terminal: ['converged','failed','superseded'].includes(status), versionProven };
|
|
486
|
+
}
|