gencow 0.1.242 → 0.1.244
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/core/index.js +1 -0
- package/lib/app-command.mjs +30 -34
- package/lib/app-delete-diagnostic.mjs +359 -5
- package/lib/app-delete-operation.mjs +24 -1
- package/lib/app-response-contract.mjs +9 -0
- package/lib/app-response-error.mjs +42 -0
- package/lib/jobs-command.mjs +22 -1
- package/package.json +3 -3
- package/runtime/server.mjs +4223 -3261
- package/runtime/tooling.mjs +1 -0
package/core/index.js
CHANGED
|
@@ -3636,6 +3636,7 @@ var RESERVED_TENANT_RUNTIME_ENV_KEYS = [
|
|
|
3636
3636
|
"GENCOW_RUNTIME_NODE_MODULES",
|
|
3637
3637
|
"GENCOW_INTERNAL_TOKEN",
|
|
3638
3638
|
"GENCOW_CRON_TOKEN",
|
|
3639
|
+
"GENCOW_SCHEDULED_JOB_PUBLIC_KEY",
|
|
3639
3640
|
"GENCOW_DEPLOY_ID",
|
|
3640
3641
|
"GENCOW_AI_PROXY_URL",
|
|
3641
3642
|
"GENCOW_AI_PROXY_URL_ALT",
|
package/lib/app-command.mjs
CHANGED
|
@@ -10,11 +10,10 @@ import {
|
|
|
10
10
|
import { resolveCreatedAppId, resolveCreatedAppResponse } from "./app-create-response.mjs";
|
|
11
11
|
import {
|
|
12
12
|
appNotFoundDiagnostic,
|
|
13
|
-
|
|
13
|
+
isExactAppDeleteHandoffResponse,
|
|
14
14
|
parseAppListResponse,
|
|
15
15
|
parseAppLookupResponse,
|
|
16
16
|
} from "./app-response-contract.mjs";
|
|
17
|
-
import { pollAppDeleteOperation } from "./app-delete-operation.mjs";
|
|
18
17
|
import { appResponseError } from "./app-response-error.mjs";
|
|
19
18
|
import { readJsonObjectResponse, readJsonValueResponse } from "./http-response-json.mjs";
|
|
20
19
|
import { BOLD, CYAN, DIM, GREEN, RED, RESET, error, info, log, success, warn } from "./output.mjs";
|
|
@@ -22,10 +21,6 @@ import { loadCreds, rpcMutation, rpcQuery, saveCreds, requireCreds } from "./pla
|
|
|
22
21
|
import { resolveProjectMetadataPath, resolveProjectSelection } from "./project-context.mjs";
|
|
23
22
|
import { updateEnvLocalUrl } from "./cli-project-runtime.mjs";
|
|
24
23
|
|
|
25
|
-
// Covers the 60s runtime-observer restart grace, reconciliation scans, and
|
|
26
|
-
// one delayed worker acquisition without reporting a completed deletion as pending.
|
|
27
|
-
const DEFAULT_DELETE_POLL_ATTEMPTS = 120;
|
|
28
|
-
const DELETE_POLL_INTERVAL_MS = 1_000;
|
|
29
24
|
const DEFAULT_APP_CREATE_TIMEOUT_MS = 60_000;
|
|
30
25
|
|
|
31
26
|
function resolveAppCreateTimeoutMs(value) {
|
|
@@ -169,7 +164,7 @@ async function confirmDelete(name) {
|
|
|
169
164
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
170
165
|
const answer = await new Promise((resolveAnswer) =>
|
|
171
166
|
rl.question(
|
|
172
|
-
`\n ${RED}!${RESET}
|
|
167
|
+
`\n ${RED}!${RESET} Remove "${BOLD}${name}${RESET}" from your account? An administrator will stop it and permanently delete its data. The name remains unavailable until then.\n Type the app name to confirm: `,
|
|
173
168
|
resolveAnswer,
|
|
174
169
|
),
|
|
175
170
|
);
|
|
@@ -194,8 +189,6 @@ export function createAppCommand({
|
|
|
194
189
|
rpcQueryImpl = rpcQuery,
|
|
195
190
|
saveCredsImpl = saveCreds,
|
|
196
191
|
setTimeoutImpl = setTimeout,
|
|
197
|
-
sleepImpl = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms)),
|
|
198
|
-
deletePollAttempts = DEFAULT_DELETE_POLL_ATTEMPTS,
|
|
199
192
|
successImpl = success,
|
|
200
193
|
updateEnvLocalUrlImpl = updateEnvLocalUrl,
|
|
201
194
|
warnImpl = warn,
|
|
@@ -365,12 +358,16 @@ ${dashboardLine}
|
|
|
365
358
|
|
|
366
359
|
if (subcmd === "delete") {
|
|
367
360
|
const unsupportedOption = rest.find(
|
|
368
|
-
(value) => value.startsWith("-") && value !== "--force" && value !== "-f"
|
|
361
|
+
(value) => value.startsWith("-") && value !== "--force" && value !== "-f" &&
|
|
362
|
+
!value.startsWith("--request-id="),
|
|
369
363
|
);
|
|
370
364
|
const force = rest.includes("--force") || rest.includes("-f");
|
|
371
365
|
const name = rest.find((value) => !value.startsWith("-")) || loadCredsImpl()?.currentApp;
|
|
372
|
-
|
|
373
|
-
|
|
366
|
+
const requestIdOptions = rest.filter((value) => value.startsWith("--request-id="));
|
|
367
|
+
const suppliedRequestId = requestIdOptions[0]?.slice("--request-id=".length);
|
|
368
|
+
if (!name || unsupportedOption || requestIdOptions.length > 1 ||
|
|
369
|
+
(suppliedRequestId !== undefined && !/^[A-Za-z0-9_-]{8,128}$/u.test(suppliedRequestId))) {
|
|
370
|
+
errorImpl("Usage: gencow app delete <name> [--force] [--request-id=<id>]");
|
|
374
371
|
return 1;
|
|
375
372
|
}
|
|
376
373
|
|
|
@@ -382,31 +379,27 @@ ${dashboardLine}
|
|
|
382
379
|
}
|
|
383
380
|
}
|
|
384
381
|
|
|
385
|
-
infoImpl(`
|
|
386
|
-
const requestId = createDeleteRequestIdImpl();
|
|
387
|
-
const
|
|
388
|
-
let
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
pollAttempts: deletePollAttempts,
|
|
395
|
-
pollIntervalMs: DELETE_POLL_INTERVAL_MS,
|
|
396
|
-
rpcQueryImpl,
|
|
397
|
-
sleepImpl,
|
|
398
|
-
});
|
|
399
|
-
if (operationResult) delData = operationResult;
|
|
382
|
+
infoImpl(`Removing app "${name}" from your account...`);
|
|
383
|
+
const requestId = suppliedRequestId ?? createDeleteRequestIdImpl();
|
|
384
|
+
const retryCommand = `gencow app delete '${name.replaceAll("'", "'\\''")}' --force --request-id=${requestId}`;
|
|
385
|
+
let delRes;
|
|
386
|
+
try {
|
|
387
|
+
delRes = await rpcMutationImpl(creds, "apps.deleteAsync", { name, requestId });
|
|
388
|
+
} catch {
|
|
389
|
+
errorImpl(`APP_DELETE_ASYNC_UNCONFIRMED: The request may have completed. Retry the same request: ${retryCommand}`);
|
|
390
|
+
return 1;
|
|
400
391
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
392
|
+
const delData = await readJsonObjectResponse(delRes);
|
|
393
|
+
if (!isExactAppDeleteHandoffResponse(delData, name, requestId)) {
|
|
394
|
+
const diagnosis = appResponseError(
|
|
404
395
|
delData,
|
|
405
|
-
invalidAppResponse("app delete", delRes, delRes.ok ? "success" : "error"),
|
|
396
|
+
invalidAppResponse("app delete handoff", delRes, delRes.ok ? "success" : "error"),
|
|
406
397
|
delRes,
|
|
407
398
|
{ expectedAppId: name },
|
|
408
|
-
)
|
|
409
|
-
|
|
399
|
+
);
|
|
400
|
+
errorImpl(delRes.ok || delRes.status >= 500
|
|
401
|
+
? `${diagnosis}\nRequest ID: ${requestId}. Retry the same request: ${retryCommand}`
|
|
402
|
+
: diagnosis);
|
|
410
403
|
return 1;
|
|
411
404
|
}
|
|
412
405
|
|
|
@@ -415,7 +408,10 @@ ${dashboardLine}
|
|
|
415
408
|
saveCredsImpl({ ...currentCreds, currentApp: undefined });
|
|
416
409
|
}
|
|
417
410
|
|
|
418
|
-
successImpl(`App "${name}"
|
|
411
|
+
successImpl(`App "${name}" removed from your account. An administrator will permanently delete it. The name remains unavailable until then.`);
|
|
412
|
+
if (delData.stopErrorCode) {
|
|
413
|
+
warnImpl(`App stop needs administrator review. Code: ${delData.stopErrorCode}. Handoff ID: ${delData.handoffId}`);
|
|
414
|
+
}
|
|
419
415
|
return 0;
|
|
420
416
|
}
|
|
421
417
|
|
|
@@ -3,6 +3,99 @@ export const APP_DELETE_OPERATION_PATTERN =
|
|
|
3
3
|
|
|
4
4
|
const APP_ID_PATTERN = /^(?=.{3,63}$)[a-z][a-z0-9]*(?:-[a-z0-9]+){2,7}$/u;
|
|
5
5
|
const CORRELATION_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/u;
|
|
6
|
+
const SAFE_DELETE_STATES = new Set([
|
|
7
|
+
"accepted",
|
|
8
|
+
"stopping_children",
|
|
9
|
+
"draining_runtime",
|
|
10
|
+
"stop_dispatched",
|
|
11
|
+
"runtime_absent",
|
|
12
|
+
"tenant_db_drop_pending",
|
|
13
|
+
"catalog_delete_pending",
|
|
14
|
+
"blocked_ownership_ambiguous",
|
|
15
|
+
"blocked_active_work",
|
|
16
|
+
"blocked_runtime_outcome_unknown",
|
|
17
|
+
"blocked_catalog_outcome_unknown",
|
|
18
|
+
"failed_tenant_db_cleanup",
|
|
19
|
+
"failed_catalog_cleanup",
|
|
20
|
+
"failed_delete_recovery_required",
|
|
21
|
+
"restoring",
|
|
22
|
+
"restored",
|
|
23
|
+
]);
|
|
24
|
+
const RESTORE_ROUTE_ACTION_PRECONDITIONS = new Map([
|
|
25
|
+
[
|
|
26
|
+
"APP_DELETE_RESTORE_ROUTE_RECOVERY_DISABLED",
|
|
27
|
+
"Platform must enable serving-route publication for this app and environment before owner wake can proceed.",
|
|
28
|
+
],
|
|
29
|
+
[
|
|
30
|
+
"APP_DELETE_RESTORE_APP_NOT_IN_ROUTE_ALLOWLIST",
|
|
31
|
+
"Platform must include this app in the serving-route rollout allowlist before owner wake can proceed.",
|
|
32
|
+
],
|
|
33
|
+
[
|
|
34
|
+
"APP_DELETE_RESTORE_ROUTE_CONTROL_UNAVAILABLE",
|
|
35
|
+
"Platform must restore a valid serving-route rollout configuration before owner wake can proceed.",
|
|
36
|
+
],
|
|
37
|
+
]);
|
|
38
|
+
const RESTORE_ADMISSION_ACTIONS = new Map([
|
|
39
|
+
[
|
|
40
|
+
"APP_DELETE_RESTORE_TENANT_DATABASE_ABSENT",
|
|
41
|
+
{
|
|
42
|
+
retryable: false,
|
|
43
|
+
retryClass: "owner-action",
|
|
44
|
+
userAction: "RETRY_DELETE",
|
|
45
|
+
nextAction: "RETRY_DELETE",
|
|
46
|
+
retryPrecondition:
|
|
47
|
+
"The tenant database is absent. Wake cannot restore the app; retry the app deletion so catalog cleanup can finish.",
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
[
|
|
51
|
+
"APP_DELETE_RESTORE_ACTIVE_DEPLOYMENT_ATTEMPT",
|
|
52
|
+
{
|
|
53
|
+
retryable: true,
|
|
54
|
+
retryClass: "platform-action",
|
|
55
|
+
userAction: "WAIT_THEN_RETRY_WAKE",
|
|
56
|
+
nextAction: "WAIT_FOR_PLATFORM_RECONCILIATION",
|
|
57
|
+
retryPrecondition:
|
|
58
|
+
"Wait until the active app deployment attempt finishes and its lease clears; then retry the same owner wake.",
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
...[
|
|
62
|
+
"APP_DELETE_RESTORE_ACTIVE_LEASE",
|
|
63
|
+
"APP_DELETE_RESTORE_ACTIVE_WORK",
|
|
64
|
+
"APP_DELETE_RESTORE_RUNTIME_NOT_ABSENT",
|
|
65
|
+
"APP_DELETE_RESTORE_TOMBSTONE_NOT_ACKNOWLEDGED",
|
|
66
|
+
"APP_DELETE_RESTORE_TENANT_DATABASE_AUTHORITY_UNAVAILABLE",
|
|
67
|
+
].map((code) => [
|
|
68
|
+
code,
|
|
69
|
+
{
|
|
70
|
+
retryable: true,
|
|
71
|
+
retryClass: "platform-action",
|
|
72
|
+
userAction: "WAIT_THEN_RETRY_WAKE",
|
|
73
|
+
nextAction: "WAIT_FOR_PLATFORM_RECONCILIATION",
|
|
74
|
+
retryPrecondition:
|
|
75
|
+
"Wait until the stated runtime or serving-route condition is clear, then retry the same owner wake/resume operation.",
|
|
76
|
+
},
|
|
77
|
+
]),
|
|
78
|
+
...[
|
|
79
|
+
"APP_DELETE_RESTORE_ADMISSION_FAILED",
|
|
80
|
+
"APP_DELETE_RESTORE_CATALOG_MUTATED",
|
|
81
|
+
"APP_DELETE_RESTORE_CHILD_ACTIVE",
|
|
82
|
+
"APP_DELETE_RESTORE_HANDOFF_MISMATCH",
|
|
83
|
+
"APP_DELETE_RESTORE_IDENTITY_MISMATCH",
|
|
84
|
+
"APP_DELETE_RESTORE_RUNTIME_WAKE_MISSING",
|
|
85
|
+
"APP_DELETE_RESTORE_STATE_CONFLICT",
|
|
86
|
+
"APP_DELETE_RESTORE_TOMBSTONE_MISMATCH",
|
|
87
|
+
].map((code) => [
|
|
88
|
+
code,
|
|
89
|
+
{
|
|
90
|
+
retryable: false,
|
|
91
|
+
retryClass: "platform-action",
|
|
92
|
+
userAction: "VIEW_DELETE_STATUS",
|
|
93
|
+
nextAction: "VIEW_DELETE_STATUS",
|
|
94
|
+
retryPrecondition:
|
|
95
|
+
"The failed delete receipt and serving-route identity require platform reconciliation before wake can proceed.",
|
|
96
|
+
},
|
|
97
|
+
]),
|
|
98
|
+
]);
|
|
6
99
|
|
|
7
100
|
export function isAppDeleteDiagnosticAppId(value) {
|
|
8
101
|
return APP_ID_PATTERN.test(value ?? "");
|
|
@@ -33,6 +126,158 @@ function isCanonicalTimestamp(value) {
|
|
|
33
126
|
return Number.isFinite(date.getTime()) && date.toISOString() === value;
|
|
34
127
|
}
|
|
35
128
|
|
|
129
|
+
export function readRestoreRouteActionAppDeleteDiagnostic(value, expectedAppId) {
|
|
130
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
131
|
+
const retryPrecondition = RESTORE_ROUTE_ACTION_PRECONDITIONS.get(value.code);
|
|
132
|
+
if (
|
|
133
|
+
!retryPrecondition ||
|
|
134
|
+
!isAppDeleteDiagnosticAppId(expectedAppId) ||
|
|
135
|
+
!APP_DELETE_OPERATION_PATTERN.test(value.operationId ?? "") ||
|
|
136
|
+
!CORRELATION_ID_PATTERN.test(value.correlationId ?? "") ||
|
|
137
|
+
!SAFE_DELETE_STATES.has(value.state) ||
|
|
138
|
+
value.stage !== "restore-admission" ||
|
|
139
|
+
!["runtime-resume", "runtime-reconcile"].includes(value.capability) ||
|
|
140
|
+
![
|
|
141
|
+
"The failed delete receipt remains unchanged; the app runtime was not started.",
|
|
142
|
+
"The same restore receipt remains active; no replacement delete or restore operation was created.",
|
|
143
|
+
].includes(value.stateImpact) ||
|
|
144
|
+
value.retryable !== false ||
|
|
145
|
+
value.retryPrecondition !== retryPrecondition ||
|
|
146
|
+
value.retryClass !== "platform-action" ||
|
|
147
|
+
value.userAction !== "WAIT_FOR_PLATFORM_RECONCILIATION" ||
|
|
148
|
+
value.nextAction !== "WAIT_FOR_PLATFORM_RECONCILIATION"
|
|
149
|
+
) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
code: value.code,
|
|
154
|
+
operationId: value.operationId,
|
|
155
|
+
correlationId: value.correlationId,
|
|
156
|
+
state: value.state,
|
|
157
|
+
stateImpact: value.stateImpact,
|
|
158
|
+
retryPrecondition,
|
|
159
|
+
statusCommand: `gencow app status ${expectedAppId}`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function isRestoreRouteActionAppDeleteDiagnosticCandidate(value) {
|
|
164
|
+
return Boolean(
|
|
165
|
+
value &&
|
|
166
|
+
typeof value === "object" &&
|
|
167
|
+
!Array.isArray(value) &&
|
|
168
|
+
RESTORE_ROUTE_ACTION_PRECONDITIONS.has(value.code),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function readRestoreAdmissionAppDeleteDiagnostic(value, expectedAppId) {
|
|
173
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
174
|
+
const action = RESTORE_ADMISSION_ACTIONS.get(value.code);
|
|
175
|
+
const stateImpactByState = {
|
|
176
|
+
failed_delete_recovery_required:
|
|
177
|
+
"The failed delete receipt remains unchanged; the app runtime was not started.",
|
|
178
|
+
restoring:
|
|
179
|
+
"The same restore receipt remains active; no replacement delete or restore operation was created.",
|
|
180
|
+
};
|
|
181
|
+
const stateImpact = stateImpactByState[value.state];
|
|
182
|
+
if (
|
|
183
|
+
!action ||
|
|
184
|
+
!isAppDeleteDiagnosticAppId(expectedAppId) ||
|
|
185
|
+
value.status !== 409 ||
|
|
186
|
+
!APP_DELETE_OPERATION_PATTERN.test(value.operationId ?? "") ||
|
|
187
|
+
!CORRELATION_ID_PATTERN.test(value.correlationId ?? "") ||
|
|
188
|
+
!stateImpact ||
|
|
189
|
+
value.stage !== "restore-admission" ||
|
|
190
|
+
!["runtime-resume", "runtime-reconcile"].includes(value.capability) ||
|
|
191
|
+
value.stateImpact !== stateImpact ||
|
|
192
|
+
value.retryable !== action.retryable ||
|
|
193
|
+
value.retryClass !== action.retryClass ||
|
|
194
|
+
value.userAction !== action.userAction ||
|
|
195
|
+
value.nextAction !== action.nextAction ||
|
|
196
|
+
value.retryPrecondition !== action.retryPrecondition
|
|
197
|
+
) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
code: value.code,
|
|
202
|
+
operationId: value.operationId,
|
|
203
|
+
correlationId: value.correlationId,
|
|
204
|
+
state: value.state,
|
|
205
|
+
stateImpact,
|
|
206
|
+
retryable: action.retryable,
|
|
207
|
+
retryPrecondition: action.retryPrecondition,
|
|
208
|
+
nextAction: action.nextAction,
|
|
209
|
+
statusCommand: `gencow app status ${expectedAppId}`,
|
|
210
|
+
nextCommand: action.userAction === "RETRY_DELETE" ? `gencow app delete ${expectedAppId}` : null,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function isRestoreAdmissionAppDeleteDiagnosticCandidate(value) {
|
|
215
|
+
return Boolean(
|
|
216
|
+
value && typeof value === "object" && !Array.isArray(value) && RESTORE_ADMISSION_ACTIONS.has(value.code),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function formatRestoreAdmissionAppDeleteDiagnosticLines(diagnostic) {
|
|
221
|
+
const lines = [
|
|
222
|
+
"App wake is blocked while Gencow checks the failed delete recovery.",
|
|
223
|
+
`Code: ${diagnostic.code}`,
|
|
224
|
+
`Operation ID: ${diagnostic.operationId}`,
|
|
225
|
+
`Correlation ID: ${diagnostic.correlationId}`,
|
|
226
|
+
`State: ${diagnostic.state}`,
|
|
227
|
+
`State impact: ${diagnostic.stateImpact}`,
|
|
228
|
+
`Retryable: ${diagnostic.retryable ? "yes" : "no"}`,
|
|
229
|
+
`Retry precondition: ${diagnostic.retryPrecondition}`,
|
|
230
|
+
`Next action: ${diagnostic.nextAction}`,
|
|
231
|
+
];
|
|
232
|
+
lines.push(`Next command: ${diagnostic.nextCommand ?? diagnostic.statusCommand}`);
|
|
233
|
+
return lines;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function formatInvalidRestoreAdmissionAppDeleteDiagnosticLines(value, expectedAppId) {
|
|
237
|
+
const lines = ["App wake is blocked; the failed delete recovery diagnosis could not be verified safely."];
|
|
238
|
+
if (RESTORE_ADMISSION_ACTIONS.has(value.code)) lines.push(`Code: ${value.code}`);
|
|
239
|
+
if (APP_DELETE_OPERATION_PATTERN.test(value.operationId ?? "")) {
|
|
240
|
+
lines.push(`Operation ID: ${value.operationId}`);
|
|
241
|
+
}
|
|
242
|
+
if (CORRELATION_ID_PATTERN.test(value.correlationId ?? "")) {
|
|
243
|
+
lines.push(`Correlation ID: ${value.correlationId}`);
|
|
244
|
+
}
|
|
245
|
+
if (isAppDeleteDiagnosticAppId(expectedAppId)) {
|
|
246
|
+
lines.push(`Next command: gencow app status ${expectedAppId}`);
|
|
247
|
+
}
|
|
248
|
+
return lines;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function formatRestoreRouteActionAppDeleteDiagnosticLines(diagnostic) {
|
|
252
|
+
return [
|
|
253
|
+
"App wake is blocked by Platform serving-route configuration.",
|
|
254
|
+
`Code: ${diagnostic.code}`,
|
|
255
|
+
`Operation ID: ${diagnostic.operationId}`,
|
|
256
|
+
`Correlation ID: ${diagnostic.correlationId}`,
|
|
257
|
+
`State: ${diagnostic.state}`,
|
|
258
|
+
`State impact: ${diagnostic.stateImpact}`,
|
|
259
|
+
"Retryable: no",
|
|
260
|
+
`Retry precondition: ${diagnostic.retryPrecondition}`,
|
|
261
|
+
"Next action: WAIT_FOR_PLATFORM_RECONCILIATION",
|
|
262
|
+
`Next command: ${diagnostic.statusCommand}`,
|
|
263
|
+
];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function formatInvalidRestoreRouteActionAppDeleteDiagnosticLines(value, expectedAppId) {
|
|
267
|
+
const lines = [
|
|
268
|
+
"App wake is blocked; the Platform serving-route diagnosis could not be verified safely.",
|
|
269
|
+
`Next command: gencow app status ${expectedAppId}`,
|
|
270
|
+
];
|
|
271
|
+
if (RESTORE_ROUTE_ACTION_PRECONDITIONS.has(value.code)) lines.splice(1, 0, `Code: ${value.code}`);
|
|
272
|
+
if (APP_DELETE_OPERATION_PATTERN.test(value.operationId ?? "")) {
|
|
273
|
+
lines.splice(lines.length - 1, 0, `Operation ID: ${value.operationId}`);
|
|
274
|
+
}
|
|
275
|
+
if (CORRELATION_ID_PATTERN.test(value.correlationId ?? "")) {
|
|
276
|
+
lines.splice(lines.length - 1, 0, `Correlation ID: ${value.correlationId}`);
|
|
277
|
+
}
|
|
278
|
+
return lines;
|
|
279
|
+
}
|
|
280
|
+
|
|
36
281
|
// The Platform's blocked receipt is an untrusted transport value until all of
|
|
37
282
|
// its identity, app scope, and safe-action fields agree. Keep that boundary in
|
|
38
283
|
// one module so polling and terminal rendering cannot drift apart.
|
|
@@ -64,14 +309,123 @@ export function readBlockedAppDeleteDiagnostic(value, expectedAppId) {
|
|
|
64
309
|
};
|
|
65
310
|
}
|
|
66
311
|
|
|
312
|
+
export function readRestoreInProgressAppDeleteDiagnostic(value, expectedAppId) {
|
|
313
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
314
|
+
const immediateRpc =
|
|
315
|
+
value.success === undefined &&
|
|
316
|
+
value.error === "APP_DELETE_RESTORE_IN_PROGRESS" &&
|
|
317
|
+
value.stateImpact ===
|
|
318
|
+
"The same restore receipt remains active; no replacement delete or restore operation was created." &&
|
|
319
|
+
value.userAction === "WAIT_FOR_RESTORE" &&
|
|
320
|
+
value.nextAction === "WAIT_FOR_RESTORE";
|
|
321
|
+
const statusReceipt =
|
|
322
|
+
value.success === false &&
|
|
323
|
+
value.deleted === expectedAppId &&
|
|
324
|
+
typeof value.statusPath === "string" &&
|
|
325
|
+
isAllowedAppDeleteStatusPath(value.statusPath, value.operationId, expectedAppId);
|
|
326
|
+
if (
|
|
327
|
+
!isAppDeleteDiagnosticAppId(expectedAppId) ||
|
|
328
|
+
!APP_DELETE_OPERATION_PATTERN.test(value.operationId ?? "") ||
|
|
329
|
+
!CORRELATION_ID_PATTERN.test(value.correlationId ?? "") ||
|
|
330
|
+
value.state !== "restoring" ||
|
|
331
|
+
value.code !== "APP_DELETE_RESTORE_IN_PROGRESS" ||
|
|
332
|
+
value.retryable !== false ||
|
|
333
|
+
value.retryPrecondition !==
|
|
334
|
+
"Wait until this restore receipt leaves restoring; then check the app status." ||
|
|
335
|
+
(!immediateRpc && !statusReceipt)
|
|
336
|
+
) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
code: value.code,
|
|
341
|
+
operationId: value.operationId,
|
|
342
|
+
correlationId: value.correlationId,
|
|
343
|
+
state: value.state,
|
|
344
|
+
stateImpact:
|
|
345
|
+
"The same restore receipt remains active; no replacement delete or restore operation was created.",
|
|
346
|
+
retryPrecondition: "Wait until this restore receipt leaves restoring; then check the app status.",
|
|
347
|
+
statusCommand: `gencow app status ${expectedAppId}`,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function formatRestoreInProgressAppDeleteDiagnosticLines(diagnostic) {
|
|
352
|
+
return [
|
|
353
|
+
"App restoration is still in progress; this delete did not complete.",
|
|
354
|
+
`Code: ${diagnostic.code}`,
|
|
355
|
+
`Operation ID: ${diagnostic.operationId}`,
|
|
356
|
+
`Correlation ID: ${diagnostic.correlationId}`,
|
|
357
|
+
`State: ${diagnostic.state}`,
|
|
358
|
+
`State impact: ${diagnostic.stateImpact}`,
|
|
359
|
+
"Retryable: no",
|
|
360
|
+
`Retry precondition: ${diagnostic.retryPrecondition}`,
|
|
361
|
+
"Next action: WAIT_FOR_RESTORE",
|
|
362
|
+
`Next command: ${diagnostic.statusCommand}`,
|
|
363
|
+
];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function readRestoredAppDeleteDiagnostic(value, expectedAppId) {
|
|
367
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
368
|
+
const immediateRpc =
|
|
369
|
+
value.success === undefined &&
|
|
370
|
+
value.error === "APP_DELETE_RESTORE_COMPLETED_NEW_DELETE_REQUIRED" &&
|
|
371
|
+
value.stateImpact ===
|
|
372
|
+
"The previous restore is complete; this request did not create a delete operation." &&
|
|
373
|
+
value.retryPrecondition ===
|
|
374
|
+
"The old delete request is terminal. Start a new delete command to create a fresh operation." &&
|
|
375
|
+
value.userAction === "RETRY_DELETE_WITH_NEW_REQUEST" &&
|
|
376
|
+
value.nextAction === "RETRY_DELETE_WITH_NEW_REQUEST";
|
|
377
|
+
const statusReceipt =
|
|
378
|
+
value.success === false &&
|
|
379
|
+
value.deleted === expectedAppId &&
|
|
380
|
+
value.failureClass === "OWNER_ACTION_REQUIRED" &&
|
|
381
|
+
value.action === "RETRY_DELETE_WITH_NEW_REQUEST" &&
|
|
382
|
+
isAllowedAppDeleteStatusPath(value.statusPath, value.operationId, expectedAppId);
|
|
383
|
+
if (
|
|
384
|
+
!isAppDeleteDiagnosticAppId(expectedAppId) ||
|
|
385
|
+
!APP_DELETE_OPERATION_PATTERN.test(value.operationId ?? "") ||
|
|
386
|
+
!CORRELATION_ID_PATTERN.test(value.correlationId ?? "") ||
|
|
387
|
+
value.state !== "restored" ||
|
|
388
|
+
value.code !== "APP_DELETE_RESTORE_COMPLETED_NEW_DELETE_REQUIRED" ||
|
|
389
|
+
value.retryable !== false ||
|
|
390
|
+
(!immediateRpc && !statusReceipt)
|
|
391
|
+
) {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
code: value.code,
|
|
396
|
+
operationId: value.operationId,
|
|
397
|
+
correlationId: value.correlationId,
|
|
398
|
+
state: value.state,
|
|
399
|
+
stateImpact: "The previous restore is complete; this request did not create a delete operation.",
|
|
400
|
+
retryPrecondition:
|
|
401
|
+
"The old delete request is terminal. Start a new delete command to create a fresh operation.",
|
|
402
|
+
deleteCommand: `gencow app delete ${expectedAppId}`,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function formatRestoredAppDeleteDiagnosticLines(diagnostic) {
|
|
407
|
+
return [
|
|
408
|
+
"The app was restored; the previous delete request did not delete it.",
|
|
409
|
+
`Code: ${diagnostic.code}`,
|
|
410
|
+
`Operation ID: ${diagnostic.operationId}`,
|
|
411
|
+
`Correlation ID: ${diagnostic.correlationId}`,
|
|
412
|
+
`State: ${diagnostic.state}`,
|
|
413
|
+
`State impact: ${diagnostic.stateImpact}`,
|
|
414
|
+
"Retryable: no",
|
|
415
|
+
`Retry precondition: ${diagnostic.retryPrecondition}`,
|
|
416
|
+
"Next action: RETRY_DELETE_WITH_NEW_REQUEST",
|
|
417
|
+
`Next command: ${diagnostic.deleteCommand}`,
|
|
418
|
+
];
|
|
419
|
+
}
|
|
420
|
+
|
|
67
421
|
export function isBlockedAppDeleteDiagnosticCandidate(value) {
|
|
68
422
|
return Boolean(
|
|
69
423
|
value &&
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
424
|
+
typeof value === "object" &&
|
|
425
|
+
!Array.isArray(value) &&
|
|
426
|
+
(value.code === "PLATFORM_ACTION_REQUIRED" ||
|
|
427
|
+
value.failureClass === "PLATFORM_ACTION_REQUIRED" ||
|
|
428
|
+
value.action === "VIEW_DELETE_STATUS"),
|
|
75
429
|
);
|
|
76
430
|
}
|
|
77
431
|
|
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
APP_DELETE_OPERATION_PATTERN,
|
|
4
4
|
isAllowedAppDeleteStatusPath,
|
|
5
5
|
readBlockedAppDeleteDiagnostic,
|
|
6
|
+
readRestoredAppDeleteDiagnostic,
|
|
7
|
+
readRestoreInProgressAppDeleteDiagnostic,
|
|
6
8
|
} from "./app-delete-diagnostic.mjs";
|
|
7
9
|
import { readJsonObjectResponse } from "./http-response-json.mjs";
|
|
8
10
|
|
|
@@ -21,8 +23,9 @@ const APP_DELETE_ACTIVE_STATES = new Set([
|
|
|
21
23
|
"failed_tenant_db_cleanup",
|
|
22
24
|
"failed_catalog_cleanup",
|
|
23
25
|
"failed_delete_recovery_required",
|
|
26
|
+
"restoring",
|
|
24
27
|
]);
|
|
25
|
-
const APP_DELETE_TERMINAL_FAILURE_STATES = new Set();
|
|
28
|
+
const APP_DELETE_TERMINAL_FAILURE_STATES = new Set(["restored"]);
|
|
26
29
|
const APP_DELETE_RESPONSE_STATES = new Set([
|
|
27
30
|
...APP_DELETE_ACTIVE_STATES,
|
|
28
31
|
...APP_DELETE_TERMINAL_FAILURE_STATES,
|
|
@@ -98,6 +101,12 @@ export async function pollAppDeleteOperation({
|
|
|
98
101
|
rpcQueryImpl,
|
|
99
102
|
sleepImpl,
|
|
100
103
|
}) {
|
|
104
|
+
if (
|
|
105
|
+
readRestoreInProgressAppDeleteDiagnostic(initialBody, name) ||
|
|
106
|
+
readRestoredAppDeleteDiagnostic(initialBody, name)
|
|
107
|
+
) {
|
|
108
|
+
return initialBody;
|
|
109
|
+
}
|
|
101
110
|
const accepted = parseAppDeleteOperationEnvelope(initialBody, name);
|
|
102
111
|
if (!accepted) return null;
|
|
103
112
|
if (isMatchingTerminalFailureResponse(initialBody, accepted, name)) {
|
|
@@ -147,6 +156,20 @@ export async function pollAppDeleteOperation({
|
|
|
147
156
|
}
|
|
148
157
|
|
|
149
158
|
const pollingUnavailable = successfulPollResponses === 0 && transportFailures > 0;
|
|
159
|
+
if (latest.state === "restoring") {
|
|
160
|
+
return {
|
|
161
|
+
success: false,
|
|
162
|
+
deleted: name,
|
|
163
|
+
error: "App restoration is still in progress; this delete did not complete.",
|
|
164
|
+
code: "APP_DELETE_RESTORE_IN_PROGRESS",
|
|
165
|
+
operationId: accepted.operationId,
|
|
166
|
+
correlationId: latest.correlationId ?? accepted.correlationId,
|
|
167
|
+
statusPath: `/api/apps/${encodeURIComponent(name)}/delete/${encodeURIComponent(accepted.operationId)}`,
|
|
168
|
+
state: "restoring",
|
|
169
|
+
retryable: false,
|
|
170
|
+
retryPrecondition: "Wait until this restore receipt leaves restoring; then check the app status.",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
150
173
|
return {
|
|
151
174
|
error: pollingUnavailable
|
|
152
175
|
? "App deletion status is temporarily unavailable. Retry the command with the same app."
|
|
@@ -56,3 +56,12 @@ export function isExactAppDeleteResponse(value, expectedAppId) {
|
|
|
56
56
|
const body = record(value);
|
|
57
57
|
return body?.success === true && body.deleted === expectedAppId;
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
export function isExactAppDeleteHandoffResponse(value, expectedAppId, expectedRequestId) {
|
|
61
|
+
const body = record(value);
|
|
62
|
+
return body?.success === true && body.name === expectedAppId &&
|
|
63
|
+
body.requestId === expectedRequestId &&
|
|
64
|
+
body.ownerTransferredTo === "todelete@gencow.dev" &&
|
|
65
|
+
nonEmptyString(body.handoffId) &&
|
|
66
|
+
Number.isSafeInteger(body.transferredApps) && body.transferredApps >= 1;
|
|
67
|
+
}
|
|
@@ -4,8 +4,20 @@ import {
|
|
|
4
4
|
APP_DELETE_OPERATION_PATTERN,
|
|
5
5
|
formatBlockedAppDeleteDiagnosticLines,
|
|
6
6
|
formatInvalidBlockedAppDeleteDiagnosticLines,
|
|
7
|
+
formatRestoreInProgressAppDeleteDiagnosticLines,
|
|
8
|
+
formatRestoreRouteActionAppDeleteDiagnosticLines,
|
|
9
|
+
formatInvalidRestoreRouteActionAppDeleteDiagnosticLines,
|
|
10
|
+
formatRestoreAdmissionAppDeleteDiagnosticLines,
|
|
11
|
+
formatInvalidRestoreAdmissionAppDeleteDiagnosticLines,
|
|
12
|
+
formatRestoredAppDeleteDiagnosticLines,
|
|
13
|
+
isRestoreAdmissionAppDeleteDiagnosticCandidate,
|
|
14
|
+
isRestoreRouteActionAppDeleteDiagnosticCandidate,
|
|
7
15
|
isBlockedAppDeleteDiagnosticCandidate,
|
|
8
16
|
readBlockedAppDeleteDiagnostic,
|
|
17
|
+
readRestoreInProgressAppDeleteDiagnostic,
|
|
18
|
+
readRestoreRouteActionAppDeleteDiagnostic,
|
|
19
|
+
readRestoreAdmissionAppDeleteDiagnostic,
|
|
20
|
+
readRestoredAppDeleteDiagnostic,
|
|
9
21
|
} from "./app-delete-diagnostic.mjs";
|
|
10
22
|
|
|
11
23
|
export { APP_DELETE_OPERATION_PATTERN } from "./app-delete-diagnostic.mjs";
|
|
@@ -35,6 +47,28 @@ export function appResponseError(body, fallback, response = null, options = {})
|
|
|
35
47
|
if (blockedDeleteDiagnostic) {
|
|
36
48
|
return formatBlockedAppDeleteDiagnosticLines(blockedDeleteDiagnostic).join("\n");
|
|
37
49
|
}
|
|
50
|
+
const restoreInProgressDiagnostic = readRestoreInProgressAppDeleteDiagnostic(body, options.expectedAppId);
|
|
51
|
+
if (restoreInProgressDiagnostic) {
|
|
52
|
+
return formatRestoreInProgressAppDeleteDiagnosticLines(restoreInProgressDiagnostic).join("\n");
|
|
53
|
+
}
|
|
54
|
+
const restoreRouteActionDiagnostic = readRestoreRouteActionAppDeleteDiagnostic(body, options.expectedAppId);
|
|
55
|
+
if (restoreRouteActionDiagnostic) {
|
|
56
|
+
return formatRestoreRouteActionAppDeleteDiagnosticLines(restoreRouteActionDiagnostic).join("\n");
|
|
57
|
+
}
|
|
58
|
+
if (isRestoreRouteActionAppDeleteDiagnosticCandidate(body)) {
|
|
59
|
+
return formatInvalidRestoreRouteActionAppDeleteDiagnosticLines(body, options.expectedAppId).join("\n");
|
|
60
|
+
}
|
|
61
|
+
const restoredDeleteDiagnostic = readRestoredAppDeleteDiagnostic(body, options.expectedAppId);
|
|
62
|
+
if (restoredDeleteDiagnostic) {
|
|
63
|
+
return formatRestoredAppDeleteDiagnosticLines(restoredDeleteDiagnostic).join("\n");
|
|
64
|
+
}
|
|
65
|
+
const restoreAdmissionDiagnostic = readRestoreAdmissionAppDeleteDiagnostic(body, options.expectedAppId);
|
|
66
|
+
if (restoreAdmissionDiagnostic) {
|
|
67
|
+
return formatRestoreAdmissionAppDeleteDiagnosticLines(restoreAdmissionDiagnostic).join("\n");
|
|
68
|
+
}
|
|
69
|
+
if (isRestoreAdmissionAppDeleteDiagnosticCandidate(body)) {
|
|
70
|
+
return formatInvalidRestoreAdmissionAppDeleteDiagnosticLines(body, options.expectedAppId).join("\n");
|
|
71
|
+
}
|
|
38
72
|
if (isBlockedAppDeleteDiagnosticCandidate(body)) {
|
|
39
73
|
return formatInvalidBlockedAppDeleteDiagnosticLines(body, options.expectedAppId).join("\n");
|
|
40
74
|
}
|
|
@@ -62,6 +96,14 @@ export function appResponseError(body, fallback, response = null, options = {})
|
|
|
62
96
|
if (/^[A-Za-z0-9_-]{8,128}$/u.test(body.correlationId ?? "")) {
|
|
63
97
|
lines.push(`Correlation ID: ${body.correlationId}`);
|
|
64
98
|
}
|
|
99
|
+
if (body.stage === "admission") lines.push("Stage: admission");
|
|
100
|
+
if (body.stateImpact === "This deletion request made no ownership or resource change.") {
|
|
101
|
+
lines.push(`State impact: ${body.stateImpact}`);
|
|
102
|
+
}
|
|
103
|
+
if (typeof body.retryPrecondition === "string" &&
|
|
104
|
+
/^[A-Za-z0-9 ,.;:-]{1,200}$/u.test(body.retryPrecondition)) {
|
|
105
|
+
lines.push(`Retry condition: ${body.retryPrecondition}`);
|
|
106
|
+
}
|
|
65
107
|
if (APP_DELETE_OPERATION_PATTERN.test(body.operationId ?? "")) {
|
|
66
108
|
lines.push(`Operation ID: ${body.operationId}`);
|
|
67
109
|
}
|