@supacloud/admin 0.15.4 → 0.16.0

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.
Files changed (3) hide show
  1. package/README.md +28 -0
  2. package/dist/index.js +292 -23
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -100,6 +100,7 @@ npx @supacloud/admin status
100
100
  npx @supacloud/admin ssh ping
101
101
  npx @supacloud/admin ssh versions
102
102
  npx @supacloud/admin ssh diagnose
103
+ npx @supacloud/admin ssh upgrade_status --transaction_id 11111111-1111-4111-8111-111111111111
103
104
  npx @supacloud/admin project create --name my-app --domain example.com \
104
105
  --env_file /secure/path/.env.project-credentials.test --environment test
105
106
  npx @supacloud/admin project list
@@ -193,6 +194,33 @@ remote transaction as failed. The CLI reports the unit, stage, status, log, and
193
194
  upload-drop paths for reconciliation. Inspect that evidence before retrying and
194
195
  do not retry blindly while the remote transaction may still be running.
195
196
 
197
+ When observation ends after 30 minutes, or to safely reconcile a retained
198
+ local-artifact upgrade transaction, use the read-only `ssh upgrade_status`
199
+ command:
200
+
201
+ ```bash
202
+ npx @supacloud/admin ssh upgrade_status \
203
+ --transaction_id 11111111-1111-4111-8111-111111111111
204
+ ```
205
+
206
+ `ssh upgrade_status` is classified as a read-only command and is permitted in
207
+ read-only mode (`SUPACLOUD_READ_ONLY=true`). It requires a strict UUID v4
208
+ transaction ID before performing any SSH access. It never deletes status, log,
209
+ stage, or upload-drop records, and never mutates service state. The command emits
210
+ a strict JSON projection (`supacloud.admin.upgrade-status.v1`) containing the
211
+ normalized transaction ID, lifecycle state (`running`, `succeeded`, or
212
+ `failed`), raw bounded status, systemd active/load states, boolean
213
+ evidence-presence flags, and validated structured receipts:
214
+ - Nonterminal (`running`): no receipts or failure evidence are included.
215
+ - Succeeded: path-free projections of the validated control-plane preflight and
216
+ transaction safety receipts.
217
+ - Failed: validated failure evidence with credentials and remote paths redacted.
218
+
219
+ The command fails closed for missing or inconsistent terminal evidence, stopped
220
+ nonterminal units, malformed or missing structured receipts, redacted or
221
+ truncated SSH output, or unknown status, without emitting raw logs, remote
222
+ filesystem paths, secrets, bearer material, env values, or customer data.
223
+
196
224
  `--artifact_transport local` accepts only `--github_proxy direct` or `none` and
197
225
  clears proxy environment variables on both hosts. The server-download path
198
226
  remains available as `--artifact_transport remote`; it verifies and executes
package/dist/index.js CHANGED
@@ -25000,11 +25000,10 @@ class BoundedOutputCollector {
25000
25000
  output = lastNewline >= 0 ? output.slice(0, lastNewline + 1) : "";
25001
25001
  }
25002
25002
  const redacted = redactSshOutput(output);
25003
- if (!this.truncated)
25004
- return redacted;
25005
- return `${redacted}${redacted && !redacted.endsWith(`
25003
+ const bounded = !this.truncated ? redacted : `${redacted}${redacted && !redacted.endsWith(`
25006
25004
  `) ? `
25007
25005
  ` : ""}[TRUNCATED: output exceeded ${this.limit}-byte limit]`;
25006
+ return { output: bounded, redacted: redacted !== output };
25008
25007
  }
25009
25008
  }
25010
25009
  var BLOCKED_COMMANDS = [
@@ -25025,11 +25024,73 @@ var BLOCKED_COMMANDS = [
25025
25024
  "chmod -R 777 /"
25026
25025
  ];
25027
25026
  function redactSshCommand(command) {
25028
- return command.replace(/(\b[A-Z0-9_]*(?:PASSWORD|PASS|SECRET|TOKEN|KEY|CREDENTIAL)[A-Z0-9_]*=)(?:"[^"]*"|'[^']*'|[^\s;]+)/gi, "$1[REDACTED]").replace(/(Authorization\s*[:=]\s*)(['"]?)Bearer\s+[^'"\s;]+\2/gi, "$1$2Bearer [REDACTED]$2").replace(/(\b--(?:password|token|secret|api-key)\s+)(\S+)/gi, "$1[REDACTED]").replace(/(postgres(?:ql)?:\/\/[^:\s/]+:)[^@\s]+@/gi, "$1[REDACTED]@");
25027
+ return command.replace(/(\b[A-Z0-9_]*(?:PASSWORD|PASS|SECRET|TOKEN|KEY|CREDENTIAL)[A-Z0-9_]*=)(?:"[^"]*"|'[^']*'|[^\s;]+)/gi, "$1[REDACTED]").replace(/(Authorization\s*[:=]\s*)(['"]?)Bearer\s+[^'"\s;]+\2/gi, "$1$2Bearer [REDACTED]$2").replace(/(\b--(?:password|token|secret|api-key)\s+)(\S+)/gi, "$1[REDACTED]").replace(/(postgres(?:ql)?:\/\/[^:\s/]*:)[^@\s]+@/gi, "$1[REDACTED]@");
25028
+ }
25029
+ var SENSITIVE_STRUCTURED_FIELD = /(?:password|pass|secret|token|key|credential|db_uri|database_url|dsn)/i;
25030
+ function decodedStructuredFieldName(encodedName) {
25031
+ let decodedName = encodedName.replace(/\\'/g, "'");
25032
+ for (let decodingPass = 0;decodingPass < 3; decodingPass += 1) {
25033
+ if (decodedName.length > 256)
25034
+ return null;
25035
+ try {
25036
+ const nextName = JSON.parse(`"${decodedName.replace(/"/g, "\\\"")}"`);
25037
+ if (typeof nextName !== "string" || nextName === decodedName)
25038
+ return decodedName;
25039
+ decodedName = nextName;
25040
+ } catch {
25041
+ return null;
25042
+ }
25043
+ }
25044
+ return decodedName;
25045
+ }
25046
+ function containsSensitiveStructuredField(message) {
25047
+ const normalizedQuotes = message.replace(/\\"/g, '"').replace(/\\'/g, "'");
25048
+ const fieldPatterns = [
25049
+ /"((?:\\.|[^"\\\r\n])*)"\s*:/g,
25050
+ /'((?:\\.|[^'\\\r\n])*)'\s*:/g,
25051
+ /(?:^|[{,]\s*)([A-Za-z_$\\][A-Za-z0-9_$\\{}]{0,255})\s*:/g
25052
+ ];
25053
+ return fieldPatterns.some((pattern) => [...normalizedQuotes.matchAll(pattern)].some((match) => {
25054
+ const decodedName = decodedStructuredFieldName(match[1]);
25055
+ return decodedName === null || SENSITIVE_STRUCTURED_FIELD.test(decodedName);
25056
+ }));
25057
+ }
25058
+ function structuredSensitiveFieldsAreRedacted(message) {
25059
+ const normalizedQuotes = message.replace(/\\"/g, '"').replace(/\\'/g, "'");
25060
+ try {
25061
+ const parsedDiagnostic = JSON.parse(normalizedQuotes);
25062
+ if (!parsedDiagnostic || typeof parsedDiagnostic !== "object")
25063
+ return false;
25064
+ let foundSensitiveField = false;
25065
+ const pending = [parsedDiagnostic];
25066
+ while (pending.length > 0) {
25067
+ const current = pending.pop();
25068
+ if (!current || typeof current !== "object")
25069
+ continue;
25070
+ for (const [fieldName, fieldValue] of Object.entries(current)) {
25071
+ if (SENSITIVE_STRUCTURED_FIELD.test(fieldName)) {
25072
+ foundSensitiveField = true;
25073
+ if (fieldValue !== "[REDACTED]")
25074
+ return false;
25075
+ } else if (fieldValue && typeof fieldValue === "object") {
25076
+ pending.push(fieldValue);
25077
+ }
25078
+ }
25079
+ }
25080
+ return foundSensitiveField;
25081
+ } catch {
25082
+ return false;
25083
+ }
25029
25084
  }
25030
25085
  function redactSshOutput(output) {
25031
- const redactedLines = output.replace(/^(\s*(?:export\s+)?[A-Z0-9_]*(?:PASSWORD|PASS|SECRET|TOKEN|KEY|CREDENTIAL|DB_URI|DATABASE_URL|DSN)[A-Z0-9_]*\s*=\s*).*$/gim, "$1[REDACTED]");
25032
- const redactedStructuredFields = redactedLines.replace(/((?:["']?(?:password|pass|secret|token|key|credential|db_uri|database_url|dsn)["']?)\s*:\s*)(?:"[^"]*"|'[^']*'|[^,}\]\r\n]+)/gi, "$1[REDACTED]");
25086
+ const redactedEscapedFields = output.split(`
25087
+ `).map((line) => containsSensitiveStructuredField(line) && !structuredSensitiveFieldsAreRedacted(line) ? "[REDACTED: structured secret output]" : line).join(`
25088
+ `);
25089
+ const redactedLines = redactedEscapedFields.replace(/^(\s*(?:export\s+)?[A-Z0-9_]*(?:PASSWORD|PASS|SECRET|TOKEN|KEY|CREDENTIAL|DB_URI|DATABASE_URL|DSN)[A-Z0-9_]*\s*=\s*).*$/gim, "$1[REDACTED]");
25090
+ const redactedStructuredFields = redactedLines.replace(/((?:["']?(?:password|pass|secret|token|key|credential|db_uri|database_url|dsn)["']?)\s*:\s*)("[^"]*"|'[^']*'|[^,}\]\r\n]+)/gi, (_match, prefix, literal) => {
25091
+ const quote = literal[0];
25092
+ return `${prefix}${quote === '"' || quote === "'" ? `${quote}[REDACTED]${quote}` : "[REDACTED]"}`;
25093
+ });
25033
25094
  return redactSshCommand(redactedStructuredFields);
25034
25095
  }
25035
25096
  function isCommandBlocked(command) {
@@ -25172,13 +25233,17 @@ class SshTransport {
25172
25233
  reject(new SshCommandOutcomeUnknownError("SSH command stream closed without a terminal status; remote outcome is unknown"));
25173
25234
  return;
25174
25235
  }
25236
+ const finalizedStdout = stdout.finalize();
25237
+ const finalizedStderr = stderr.finalize();
25175
25238
  resolve({
25176
25239
  success: code === 0,
25177
- stdout: stdout.finalize(),
25178
- stderr: stderr.finalize(),
25240
+ stdout: finalizedStdout.output,
25241
+ stderr: finalizedStderr.output,
25179
25242
  code: code ?? 128,
25180
25243
  stdoutTruncated: stdout.truncated,
25181
- stderrTruncated: stderr.truncated
25244
+ stderrTruncated: stderr.truncated,
25245
+ stdoutRedacted: finalizedStdout.redacted,
25246
+ stderrRedacted: finalizedStderr.redacted
25182
25247
  });
25183
25248
  }).on("error", () => {
25184
25249
  clearTimeout(timer);
@@ -25837,7 +25902,8 @@ var ACTION_POLICY = {
25837
25902
  "container_logs",
25838
25903
  "tenant_list",
25839
25904
  "tenant_inspect",
25840
- "tenant_diagnose"
25905
+ "tenant_diagnose",
25906
+ "upgrade_status"
25841
25907
  ],
25842
25908
  write: ["setup", "install", "upgrade", "tenant_migrate"]
25843
25909
  }
@@ -27317,8 +27383,10 @@ var REMOTE_LOG_ROOT = "/var/log/supacloud";
27317
27383
  var REMOTE_UPLOAD_ROOT = "/var/tmp";
27318
27384
  var REMOTE_COMMAND_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
27319
27385
  var CONTROL_PLANE_BACKUP_ROOT = "/var/lib/supacloud/backups/control-plane-upgrades";
27386
+ var CONTROL_PLANE_PREFLIGHT_PREFIX = "SUPACLOUD_CONTROL_PLANE_UPGRADE_PREFLIGHT=";
27320
27387
  var CONTROL_PLANE_SAFETY_PREFIX = "SUPACLOUD_CONTROL_PLANE_UPGRADE_SAFETY=";
27321
- var MINIMUM_CONTROL_PLANE_SAFETY_VERSION = [0, 61, 4];
27388
+ var UPGRADE_FAILURE_PREFIX = "SUPACLOUD_UPGRADE_FAILURE=";
27389
+ var MINIMUM_CONTROL_PLANE_SAFETY_VERSION = [0, 61, 7];
27322
27390
  var POLL_INTERVAL_MS = 2000;
27323
27391
  var STATE_READ_ATTEMPTS = 3;
27324
27392
  var REMOTE_STATE_READ_TIMEOUT_MS = 15000;
@@ -27326,6 +27394,34 @@ var UPGRADE_OBSERVATION_TIMEOUT_MS = 30 * 60000;
27326
27394
 
27327
27395
  class RemoteUpgradeReconciliationError extends AggregateError {
27328
27396
  }
27397
+ var UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
27398
+ function assertValidTransactionId(transactionId) {
27399
+ if (typeof transactionId !== "string" || !UUID_V4_PATTERN.test(transactionId)) {
27400
+ throw new Error("Invalid 'transaction_id': must be a valid UUID v4");
27401
+ }
27402
+ return transactionId.toLowerCase();
27403
+ }
27404
+ function upgradeStatusControlPlaneEvidence(receipt) {
27405
+ return {
27406
+ backup_id: receipt.backup_id,
27407
+ bytes: receipt.bytes,
27408
+ candidate_counts: { ...receipt.candidate_counts },
27409
+ completed_at: receipt.completed_at,
27410
+ current_key_checkpoint_present: receipt.current_key_checkpoint_present,
27411
+ receipt_schema: receipt.schema,
27412
+ sha256: receipt.sha256
27413
+ };
27414
+ }
27415
+ function redactRemotePaths(message) {
27416
+ return message.replace(/\bhttps?:\/\/[^\s"'`,;)\]}]+/gi, "[REDACTED_URL]").replace(/\bfile:\/\/\/[^\s"'`,;)\]}]+/gi, "[REDACTED_PATH]").replace(/(^|[^A-Za-z0-9:/])\/(?!\/)[^\s"'`,;)\]}]+/g, "$1[REDACTED_PATH]");
27417
+ }
27418
+ function upgradeStatusFailureEvidence(receipt) {
27419
+ return {
27420
+ causes: receipt.causes.map(redactRemotePaths),
27421
+ receipt_schema: receipt.schema,
27422
+ summary: redactRemotePaths(receipt.summary)
27423
+ };
27424
+ }
27329
27425
  function exactObjectKeys(candidate, expected) {
27330
27426
  const actual = Object.keys(candidate).sort();
27331
27427
  return actual.length === expected.length && actual.every((key, index) => key === [...expected].sort()[index]);
@@ -27342,13 +27438,13 @@ function canonicalTimestamp(candidate) {
27342
27438
  const timestamp = new Date(candidate);
27343
27439
  return Number.isFinite(timestamp.valueOf()) && timestamp.toISOString() === candidate;
27344
27440
  }
27345
- function parseControlPlaneSafetyEvidence(log) {
27346
- const receiptLines = log.split(/\r?\n/).filter((line) => line.startsWith(CONTROL_PLANE_SAFETY_PREFIX));
27441
+ function parseControlPlaneSafetyReceipt(log, prefix) {
27442
+ const receiptLines = log.split(/\r?\n/).filter((line) => line.startsWith(prefix));
27347
27443
  if (receiptLines.length !== 1)
27348
27444
  throw new Error("Remote upgrade did not emit one control-plane safety receipt");
27349
27445
  let candidate;
27350
27446
  try {
27351
- candidate = JSON.parse(receiptLines[0].slice(CONTROL_PLANE_SAFETY_PREFIX.length));
27447
+ candidate = JSON.parse(receiptLines[0].slice(prefix.length));
27352
27448
  } catch {
27353
27449
  throw new Error("Remote control-plane safety receipt is not valid JSON");
27354
27450
  }
@@ -27380,6 +27476,46 @@ function parseControlPlaneSafetyEvidence(log) {
27380
27476
  }
27381
27477
  return receipt;
27382
27478
  }
27479
+ function parseControlPlaneSafetyEvidence(log) {
27480
+ return parseControlPlaneSafetyReceipt(log, CONTROL_PLANE_SAFETY_PREFIX);
27481
+ }
27482
+ function parseControlPlanePreflightEvidence(log) {
27483
+ return parseControlPlaneSafetyReceipt(log, CONTROL_PLANE_PREFLIGHT_PREFIX);
27484
+ }
27485
+ function parsedUpgradeFailureReceipt(log) {
27486
+ const receiptLines = log.split(/\r?\n/).filter((line) => line.startsWith(UPGRADE_FAILURE_PREFIX));
27487
+ if (receiptLines.length !== 1)
27488
+ throw new Error("Remote upgrade did not emit one structured failure receipt");
27489
+ let candidate;
27490
+ try {
27491
+ candidate = JSON.parse(receiptLines[0].slice(UPGRADE_FAILURE_PREFIX.length));
27492
+ } catch {
27493
+ throw new Error("Remote upgrade failure receipt is not valid JSON");
27494
+ }
27495
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
27496
+ throw new Error("Remote upgrade failure receipt is invalid");
27497
+ }
27498
+ return candidate;
27499
+ }
27500
+ function upgradeFailureReceiptIsInvalid(receipt) {
27501
+ const causes = receipt.causes;
27502
+ return !exactObjectKeys(receipt, ["causes", "schema", "summary"]) || receipt.schema !== "supacloud.upgrade-failure.v1" || typeof receipt.summary !== "string" || !receipt.summary || receipt.summary.length > 500 || /[\u0000-\u001f\u007f]/.test(receipt.summary) || !Array.isArray(causes) || causes.length > 8 || causes.some((cause) => typeof cause !== "string" || !cause || cause.length > 500 || /[\u0000-\u001f\u007f]/.test(cause));
27503
+ }
27504
+ function upgradeFailureMessageContainsSensitiveData(message) {
27505
+ const bareBearer = /\bBearer\s+(?!\[REDACTED\](?:\s|$|[,;.)]))\S+/i;
27506
+ return redactSshOutput(message) !== message || bareBearer.test(message);
27507
+ }
27508
+ function parseUpgradeFailureEvidence(log) {
27509
+ const receipt = parsedUpgradeFailureReceipt(log);
27510
+ if (upgradeFailureReceiptIsInvalid(receipt)) {
27511
+ throw new Error("Remote upgrade failure receipt is invalid");
27512
+ }
27513
+ const messages = [receipt.summary, ...receipt.causes];
27514
+ if (messages.some(upgradeFailureMessageContainsSensitiveData)) {
27515
+ throw new Error("Remote upgrade failure receipt contains sensitive data");
27516
+ }
27517
+ return receipt;
27518
+ }
27383
27519
  function assertControlPlaneSafetyVersion(version) {
27384
27520
  const match = version.match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/);
27385
27521
  if (!match)
@@ -27392,7 +27528,7 @@ function assertControlPlaneSafetyVersion(version) {
27392
27528
  if (requested[index] > MINIMUM_CONTROL_PLANE_SAFETY_VERSION[index])
27393
27529
  return;
27394
27530
  if (requested[index] < MINIMUM_CONTROL_PLANE_SAFETY_VERSION[index]) {
27395
- throw new Error("Local artifact upgrades require Management 0.61.4 or newer with control-plane backup safety");
27531
+ throw new Error("Local artifact upgrades require Management 0.61.7 or newer with control-plane backup preflight safety");
27396
27532
  }
27397
27533
  }
27398
27534
  }
@@ -27713,6 +27849,7 @@ function bundledVerifierSetup(paths, architecture) {
27713
27849
  function upgradeScriptExecution(paths, bundle, request) {
27714
27850
  const runnerAsset = `${paths.stage}/bundle/management-api/${bundle.managementBinaryName}`;
27715
27851
  const runner = `${paths.stage}/runner`;
27852
+ const preflight = 'CONTROL_PLANE_PREFLIGHT_RECEIPT=$(env PATH="$VERIFIER_PATH:$PATH" "$RUNNER" --control-plane-upgrade-preflight)';
27716
27853
  return [
27717
27854
  `MANAGEMENT_VERSION=${quoteShell2(request.managementVersion)}`,
27718
27855
  `RUNNER_ASSET=${quoteShell2(runnerAsset)}`,
@@ -27723,7 +27860,9 @@ function upgradeScriptExecution(paths, bundle, request) {
27723
27860
  '"$RUNNER" --version | grep -Eq "(^|[^0-9])${MANAGEMENT_VERSION//./\\.}([^0-9]|$)"',
27724
27861
  `timeout 5s "$RUNNER" --systemd-unit-helper-sha256 | grep -Eq 'SupaCloud systemd-unit helper SHA-256: [0-9a-f]{64}'`,
27725
27862
  `timeout 5s "$RUNNER" --postgrest-launcher-sha256 | grep -Eq 'SupaCloud PostgREST launcher SHA-256: [0-9a-f]{64}'`,
27726
- `env PATH="$VERIFIER_PATH:$PATH" "$RUNNER" upgrade --yes --target-version ${quoteShell2(request.managementVersion)} --edge-runtime-version ${quoteShell2(request.edgeRuntimeVersion)} --asset-bundle-dir "$BUNDLE"`
27863
+ preflight,
27864
+ `env PATH="$VERIFIER_PATH:$PATH" "$RUNNER" upgrade --yes --target-version ${quoteShell2(request.managementVersion)} --edge-runtime-version ${quoteShell2(request.edgeRuntimeVersion)} --asset-bundle-dir "$BUNDLE"`,
27865
+ `printf '%s\\n' "$CONTROL_PLANE_PREFLIGHT_RECEIPT"`
27727
27866
  ];
27728
27867
  }
27729
27868
  function buildLocalUpgradeRunScript(paths, bundle, request, architecture) {
@@ -27939,6 +28078,9 @@ async function remoteLogTail(ssh, paths) {
27939
28078
  const output = await ssh.exec(rootCommand(script), 15000);
27940
28079
  if (!output.success)
27941
28080
  throw remoteFailure("Unable to read the remote upgrade log", output);
28081
+ if (output.stdoutRedacted || output.stderrRedacted || output.stdoutTruncated || output.stderrTruncated) {
28082
+ throw new Error("Remote upgrade log required transport redaction or truncation");
28083
+ }
27942
28084
  return output.stdout.slice(-4000);
27943
28085
  }
27944
28086
  async function cleanupRemoteRecords(ssh, paths) {
@@ -28036,11 +28178,13 @@ async function completedUpgradeOutput(ssh, paths) {
28036
28178
  } catch (error) {
28037
28179
  throw remoteReconciliationFailure("Upgrade succeeded but its retained log could not be read", [error], paths);
28038
28180
  }
28181
+ let preflightEvidence;
28039
28182
  let safetyEvidence;
28040
28183
  try {
28184
+ preflightEvidence = parseControlPlanePreflightEvidence(log);
28041
28185
  safetyEvidence = parseControlPlaneSafetyEvidence(log);
28042
28186
  } catch (error) {
28043
- throw remoteReconciliationFailure("Upgrade succeeded but control-plane safety evidence is invalid", [error], paths);
28187
+ throw remoteReconciliationFailure("Upgrade succeeded but control-plane preflight or safety evidence is invalid", [error], paths);
28044
28188
  }
28045
28189
  try {
28046
28190
  await cleanupRemoteRecords(ssh, paths);
@@ -28048,7 +28192,7 @@ async function completedUpgradeOutput(ssh, paths) {
28048
28192
  throw remoteReconciliationFailure("Upgrade succeeded but remote evidence cleanup could not be confirmed", [error], paths);
28049
28193
  }
28050
28194
  return `✅ Upgrade done
28051
- ${JSON.stringify(safetyEvidence)}
28195
+ ${JSON.stringify({ preflight: preflightEvidence, transaction: safetyEvidence })}
28052
28196
  ${log.slice(-1500)}`;
28053
28197
  }
28054
28198
  async function throwRemoteUpgradeFailure(ssh, paths, status) {
@@ -28058,13 +28202,19 @@ async function throwRemoteUpgradeFailure(ssh, paths, status) {
28058
28202
  } catch (error) {
28059
28203
  throw remoteReconciliationFailure("Remote upgrade failed but its retained log could not be read", [error], paths);
28060
28204
  }
28061
- const failure = new Error(`Remote local upgrade failed (${status}): ${log.slice(-1500)}`);
28062
28205
  if (status.endsWith(":CLEANUP_AFTER_TRANSACTION")) {
28063
- throw remoteReconciliationFailure("Upgrade transaction completed but staging cleanup is incomplete", [failure], paths);
28206
+ throw remoteReconciliationFailure("Upgrade transaction completed but staging cleanup is incomplete", [new Error(`Remote local upgrade ended with ${status}`)], paths);
28064
28207
  }
28065
28208
  if (status.includes("CLEANUP")) {
28066
- throw remoteReconciliationFailure("Upgrade transaction and staging cleanup both failed", [failure], paths);
28209
+ throw remoteReconciliationFailure("Upgrade transaction and staging cleanup both failed", [new Error(`Remote local upgrade ended with ${status}`)], paths);
28210
+ }
28211
+ let failureEvidence;
28212
+ try {
28213
+ failureEvidence = parseUpgradeFailureEvidence(log);
28214
+ } catch (error) {
28215
+ throw remoteReconciliationFailure("Remote upgrade failed without valid structured failure evidence", [error], paths);
28067
28216
  }
28217
+ const failure = new Error(`Remote local upgrade failed (${status}): ${failureEvidence.summary}` + (failureEvidence.causes.length > 0 ? `; causes: ${failureEvidence.causes.join(" | ")}` : ""));
28068
28218
  try {
28069
28219
  await cleanupRemoteRecords(ssh, paths);
28070
28220
  } catch (cleanupError) {
@@ -28151,6 +28301,115 @@ async function executeLocalUpgradeTransfer(ssh, request) {
28151
28301
  }
28152
28302
  }
28153
28303
  }
28304
+ function upgradeStatusLifecycle(status) {
28305
+ if (["PREPARED", "RUNNING", "CLEANING"].includes(status))
28306
+ return "running";
28307
+ if (status === "SUCCEEDED")
28308
+ return "succeeded";
28309
+ const failedStatus = status.match(/^FAILED:([1-9]\d{0,2}):(TRANSACTION|TRANSACTION_AND_CLEANUP|CLEANUP_AFTER_TRANSACTION)$/);
28310
+ if (failedStatus && Number(failedStatus[1]) <= 255)
28311
+ return "failed";
28312
+ throw new Error("Remote upgrade status is unknown or invalid");
28313
+ }
28314
+ function upgradeStatusEvidence(state) {
28315
+ return {
28316
+ drop_exists: state.dropExists,
28317
+ log_exists: state.logExists,
28318
+ stage_exists: state.stageExists,
28319
+ stage_is_directory: state.stageIsDirectory,
28320
+ status_exists: state.statusExists,
28321
+ unit_exists: state.unitExists
28322
+ };
28323
+ }
28324
+ async function readUpgradeStatusState(ssh, paths) {
28325
+ try {
28326
+ return await readRemoteState(ssh, paths);
28327
+ } catch {
28328
+ throw new Error("Unable to read remote upgrade state safely");
28329
+ }
28330
+ }
28331
+ async function readUpgradeStatusLog(ssh, paths) {
28332
+ try {
28333
+ return await remoteLogTail(ssh, paths);
28334
+ } catch {
28335
+ throw new Error("Remote upgrade retained log could not be read safely");
28336
+ }
28337
+ }
28338
+ function assertUpgradeStatusTerminalEvidence(state, paths, lifecycle) {
28339
+ try {
28340
+ if (lifecycle === "succeeded")
28341
+ assertSuccessfulUnitStoppedNormally(state, paths);
28342
+ else
28343
+ assertFailedUnitReachedTerminalState(state, paths);
28344
+ assertTerminalEvidence(state, paths);
28345
+ } catch {
28346
+ throw new Error("Remote upgrade terminal evidence is incomplete or inconsistent");
28347
+ }
28348
+ }
28349
+ function succeededUpgradeStatus(transactionId, state, log) {
28350
+ let preflight;
28351
+ let transaction;
28352
+ try {
28353
+ preflight = parseControlPlanePreflightEvidence(log);
28354
+ transaction = parseControlPlaneSafetyEvidence(log);
28355
+ } catch {
28356
+ throw new Error("Remote upgrade safety receipts are missing or invalid");
28357
+ }
28358
+ return {
28359
+ schema: "supacloud.admin.upgrade-status.v1",
28360
+ transaction_id: transactionId,
28361
+ lifecycle: "succeeded",
28362
+ status: state.status,
28363
+ service_state: state.serviceState,
28364
+ unit_load_state: state.unitLoadState,
28365
+ evidence: upgradeStatusEvidence(state),
28366
+ preflight: upgradeStatusControlPlaneEvidence(preflight),
28367
+ transaction: upgradeStatusControlPlaneEvidence(transaction)
28368
+ };
28369
+ }
28370
+ function failedUpgradeStatus(transactionId, state, log) {
28371
+ let failure;
28372
+ try {
28373
+ failure = parseUpgradeFailureEvidence(log);
28374
+ } catch {
28375
+ throw new Error("Remote upgrade failure receipt is missing or invalid");
28376
+ }
28377
+ return {
28378
+ schema: "supacloud.admin.upgrade-status.v1",
28379
+ transaction_id: transactionId,
28380
+ lifecycle: "failed",
28381
+ status: state.status,
28382
+ service_state: state.serviceState,
28383
+ unit_load_state: state.unitLoadState,
28384
+ evidence: upgradeStatusEvidence(state),
28385
+ failure: upgradeStatusFailureEvidence(failure)
28386
+ };
28387
+ }
28388
+ function runningUpgradeStatus(transactionId, state) {
28389
+ if (!unitIsRunning(state.serviceState) || state.unitLoadState !== "loaded" || !state.statusExists) {
28390
+ throw new Error("Remote upgrade nonterminal state is inconsistent");
28391
+ }
28392
+ return {
28393
+ schema: "supacloud.admin.upgrade-status.v1",
28394
+ transaction_id: transactionId,
28395
+ lifecycle: "running",
28396
+ status: state.status,
28397
+ service_state: state.serviceState,
28398
+ unit_load_state: state.unitLoadState,
28399
+ evidence: upgradeStatusEvidence(state)
28400
+ };
28401
+ }
28402
+ async function inspectRemoteUpgradeStatus(ssh, transactionIdInput) {
28403
+ const transactionId = assertValidTransactionId(transactionIdInput);
28404
+ const paths = buildRemoteUpgradePaths(transactionId);
28405
+ const state = await readUpgradeStatusState(ssh, paths);
28406
+ const lifecycle = upgradeStatusLifecycle(state.status);
28407
+ if (lifecycle === "running")
28408
+ return runningUpgradeStatus(transactionId, state);
28409
+ assertUpgradeStatusTerminalEvidence(state, paths, lifecycle);
28410
+ const log = await readUpgradeStatusLog(ssh, paths);
28411
+ return lifecycle === "succeeded" ? succeededUpgradeStatus(transactionId, state, log) : failedUpgradeStatus(transactionId, state, log);
28412
+ }
28154
28413
 
28155
28414
  // ../../scripts/lib/release_assets.sh
28156
28415
  var release_assets_default = '#!/usr/bin/env bash\n\nSUPACLOUD_GITHUB_REPOSITORY="${SUPACLOUD_GITHUB_REPOSITORY:-vibeunion/supacloud}"\nSUPACLOUD_RELEASES_API="${SUPACLOUD_RELEASES_API:-https://api.github.com/repos/${SUPACLOUD_GITHUB_REPOSITORY}/releases}"\nSUPACLOUD_ATTESTATION_SIGNER_WORKFLOW="${SUPACLOUD_ATTESTATION_SIGNER_WORKFLOW:-${SUPACLOUD_GITHUB_REPOSITORY}/.github/workflows/release-please.yml}"\nSUPACLOUD_GH_VERSION="${SUPACLOUD_GH_VERSION:-2.96.0}"\nSUPACLOUD_GH_MIN_VERSION="${SUPACLOUD_GH_MIN_VERSION:-2.68.0}"\nSUPACLOUD_GH_AMD64_SHA256="${SUPACLOUD_GH_AMD64_SHA256:-83d5c2ccad5498f58bf6368acb1ab32588cf43ab3a4b1c301bf36328b1c8bd60}"\nSUPACLOUD_GH_ARM64_SHA256="${SUPACLOUD_GH_ARM64_SHA256:-06f86ec7103d41993b76cd78072f43595c34aaa56506d971d9860e67140bf909}"\nSUPACLOUD_RELEASE_ASSETS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\nSUPACLOUD_ATTESTATION_TRUSTED_ROOT_DEFAULT="${SUPACLOUD_RELEASE_ASSETS_DIR}/../../packages/management-api/src/assets/sigstore-public-good-trusted-root.jsonl"\nreadonly SUPACLOUD_ATTESTATION_TRUSTED_ROOT_SHA256="3c2cc7f357dc064ec527fdcd78da6e9245c21a381e1abaa0f2b62b186bcac1a1"\nreadonly SUPACLOUD_ATTESTATION_TRUSTED_ROOT_SIZE="5748"\n\nsupacloud_curl_release_json() {\n local url="$1"\n local output="$2"\n curl -fsSL --proto \'=https\' --proto-redir \'=https\' \\\n --retry 1 --retry-delay 2 --retry-max-time 60 \\\n --connect-timeout 15 --max-time 30 --speed-limit 128 --speed-time 10 \\\n -o "$output" "$url"\n}\n\nsupacloud_curl_release_asset() {\n local url="$1"\n local output="$2"\n curl -fL --proto \'=https\' --proto-redir \'=https\' \\\n --retry 1 --retry-delay 2 --retry-max-time 180 \\\n --connect-timeout 15 --max-time 90 --speed-limit 128 --speed-time 60 \\\n -o "$output" "$url"\n}\n\nsupacloud_component_tag() {\n local component="$1"\n local version="$2"\n case "$version" in\n "${component}-v"*) printf \'%s\' "$version" ;;\n v*) printf \'%s-%s\' "$component" "$version" ;;\n *) printf \'%s-v%s\' "$component" "$version" ;;\n esac\n}\n\nsupacloud_select_release() {\n local component="$1"\n shift\n local required_assets_json\n [[ $# -gt 0 ]] || {\n echo "at least one required release asset must be specified" >&2\n return 1\n }\n required_assets_json=$(printf \'%s\\n\' "$@" | jq -Rsc \'split("\\n")[:-1]\')\n jq -ce --arg prefix "${component}-v" --argjson required "$required_assets_json" \'\n map(select(\n (.draft | not)\n and (.prerelease | not)\n and (.tag_name | startswith($prefix))\n and (. as $release | all($required[]; . as $asset | any($release.assets[]?; .name == $asset)))\n and any(.assets[]?; .name == "SHA256SUMS")\n ))\n | first\n // error("no matching component release contains all required assets and SHA256SUMS")\n \'\n}\n\nsupacloud_fetch_component_release() {\n local component="$1"\n local version="${2:-latest}"\n shift 2\n local required_assets=("$@")\n local required_assets_json\n local response\n [[ ${#required_assets[@]} -gt 0 ]] || {\n echo "at least one required release asset must be specified" >&2\n return 1\n }\n required_assets_json=$(printf \'%s\\n\' "${required_assets[@]}" | jq -Rsc \'split("\\n")[:-1]\')\n\n if [[ -n "$version" && "$version" != "latest" ]]; then\n local tag\n tag=$(supacloud_component_tag "$component" "$version")\n response=$(supacloud_fetch_release_json "${SUPACLOUD_RELEASES_API}/tags/${tag}") || return 1\n jq -ce --argjson required "$required_assets_json" \'\n select(\n (.draft | not)\n and (.prerelease | not)\n and (. as $release | all($required[]; . as $asset | any($release.assets[]?; .name == $asset)))\n and any(.assets[]?; .name == "SHA256SUMS")\n )\n // error("release does not contain all required assets and SHA256SUMS")\n \' <<< "$response"\n return\n fi\n\n response=$(supacloud_fetch_release_json "${SUPACLOUD_RELEASES_API}?per_page=100") || return 1\n supacloud_select_release "$component" "${required_assets[@]}" <<< "$response"\n}\n\nsupacloud_fetch_release_json() (\n local url="$1"\n local response_file\n response_file=$(mktemp) || return 1\n trap \'rm -f "$response_file"\' EXIT\n trap \'trap - EXIT HUP INT TERM; rm -f "$response_file"; exit 1\' HUP INT TERM\n supacloud_download_release_metadata_url "$url" "$response_file" || return 1\n cat "$response_file"\n)\n\nsupacloud_release_asset_url() {\n local release_json="$1"\n local asset_name="$2"\n jq -er --arg asset "$asset_name" \'\n first(.assets[]? | select(.name == $asset) | .browser_download_url)\n // error("release asset URL is missing")\n \' <<< "$release_json"\n}\n\nsupacloud_download_url() {\n local url="$1"\n local output="$2"\n local proxy="${SUPACLOUD_GITHUB_PROXY:-${GH_PROXY:-}}"\n\n if supacloud_curl_release_asset "$url" "$output"; then\n return 0\n fi\n if [[ -n "$proxy" ]]; then\n supacloud_curl_release_asset "${proxy%/}/${url}" "$output"\n return\n fi\n return 1\n}\n\nsupacloud_download_release_metadata_url() {\n local url="$1"\n local output="$2"\n local proxy="${SUPACLOUD_GITHUB_PROXY:-${GH_PROXY:-}}"\n\n if supacloud_curl_release_json "$url" "$output"; then\n return 0\n fi\n if [[ -n "$proxy" ]]; then\n supacloud_curl_release_json "${proxy%/}/${url}" "$output"\n return\n fi\n return 1\n}\n\nsupacloud_verify_checksum() {\n local artifact_file="$1"\n local asset_name="$2"\n local checksum_file="$3"\n local expected\n expected=$(awk -v asset="$asset_name" \'$2 == asset || $2 == "*" asset { print $1; exit }\' "$checksum_file")\n if [[ ! "$expected" =~ ^[0-9a-fA-F]{64}$ ]]; then\n echo "SHA256SUMS does not contain a valid checksum for ${asset_name}" >&2\n return 1\n fi\n\n local actual\n actual=$(sha256sum "$artifact_file" | awk \'{print $1}\')\n actual=$(printf \'%s\' "$actual" | tr \'[:upper:]\' \'[:lower:]\')\n expected=$(printf \'%s\' "$expected" | tr \'[:upper:]\' \'[:lower:]\')\n if [[ "$actual" != "$expected" ]]; then\n echo "SHA256 mismatch for ${asset_name}" >&2\n return 1\n fi\n}\n\nsupacloud_validate_binary() {\n local artifact_file="$1"\n local asset_name="$2"\n local description\n description=$(file -b "$artifact_file")\n if [[ "$description" != *ELF* ]]; then\n echo "${asset_name} is not an ELF binary: ${description}" >&2\n return 1\n fi\n\n case "$asset_name" in\n *amd64)\n [[ "$description" == *x86-64* || "$description" == *x86_64* ]] || {\n echo "${asset_name} does not contain an x86-64 ELF binary" >&2\n return 1\n }\n ;;\n *arm64)\n [[ "$description" == *aarch64* || "$description" == *ARM64* ]] || {\n echo "${asset_name} does not contain an arm64 ELF binary" >&2\n return 1\n }\n ;;\n esac\n}\n\nsupacloud_install_pinned_tar_xz_binary() (\n local archive="$1"\n local member="$2"\n local expected_sha256="$3"\n local arch="$4"\n local target="$5"\n local actual_sha256 member_count member_details extract_dir candidate staged_target\n\n actual_sha256=$(sha256sum "$archive" | awk \'{print $1}\')\n actual_sha256=$(printf \'%s\' "$actual_sha256" | tr \'[:upper:]\' \'[:lower:]\')\n expected_sha256=$(printf \'%s\' "$expected_sha256" | tr \'[:upper:]\' \'[:lower:]\')\n if [[ ! "$expected_sha256" =~ ^[0-9a-f]{64}$ || "$actual_sha256" != "$expected_sha256" ]]; then\n echo "SHA256 mismatch for pinned archive" >&2\n return 1\n fi\n\n member_count=$(tar -tJf "$archive" | grep -Fxc "$member" || true)\n if [[ "$member_count" != "1" ]]; then\n echo "Pinned archive must contain the exact member once: $member" >&2\n return 1\n fi\n member_details=$(tar -tvJf "$archive" "$member") || return 1\n if [[ "${member_details:0:1}" != "-" ]]; then\n echo "Pinned archive member is not a regular file: $member" >&2\n return 1\n fi\n\n extract_dir=$(mktemp -d)\n trap \'rm -rf "$extract_dir"; [[ -z "${staged_target:-}" ]] || rm -f "$staged_target"\' EXIT\n trap \'trap - EXIT HUP INT TERM; rm -rf "$extract_dir"; [[ -z "${staged_target:-}" ]] || rm -f "$staged_target"; exit 1\' HUP INT TERM\n if ! tar --no-same-owner --no-same-permissions -xJf "$archive" -C "$extract_dir" "$member"; then\n return 1\n fi\n candidate="${extract_dir}/${member}"\n supacloud_validate_binary "$candidate" "pinned-linux-${arch}" || return 1\n\n mkdir -p "$(dirname "$target")"\n staged_target=$(mktemp "${target}.tmp.XXXXXX")\n install -m 0755 "$candidate" "$staged_target"\n mv -f "$staged_target" "$target"\n staged_target=""\n)\n\nsupacloud_version_at_least() {\n local current="${1#v}"\n local required="${2#v}"\n local current_major=0 current_minor=0 current_patch=0\n local required_major=0 required_minor=0 required_patch=0\n [[ "$current" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+(-.*)?$ ]] || return 1\n [[ "$required" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+(-.*)?$ ]] || return 1\n IFS=. read -r current_major current_minor current_patch <<EOF\n${current%%-*}\nEOF\n IFS=. read -r required_major required_minor required_patch <<EOF\n${required%%-*}\nEOF\n current_major=${current_major:-0}; current_minor=${current_minor:-0}; current_patch=${current_patch:-0}\n required_major=${required_major:-0}; required_minor=${required_minor:-0}; required_patch=${required_patch:-0}\n if (( current_major != required_major )); then (( current_major > required_major )); return; fi\n if (( current_minor != required_minor )); then (( current_minor > required_minor )); return; fi\n (( current_patch >= required_patch ))\n}\n\nsupacloud_gh_version() {\n gh --version 2>/dev/null | awk \'NR == 1 && $1 == "gh" && $2 == "version" { print $3; exit }\'\n}\n\nsupacloud_install_gh_archive() {\n local archive="$1"\n local version="$2"\n local arch="$3"\n local expected_sha256="$4"\n local target="$5"\n local member="gh_${version}_linux_${arch}/bin/gh"\n local actual_sha256 member_count member_details extracted_dir candidate version_output\n\n actual_sha256=$(sha256sum "$archive" | awk \'{print $1}\')\n if [[ "$actual_sha256" != "$expected_sha256" ]]; then\n echo "GitHub CLI archive SHA256 mismatch" >&2\n return 1\n fi\n\n member_count=$(tar -tzf "$archive" | grep -Fxc "$member" || true)\n if [[ "$member_count" != "1" ]]; then\n echo "GitHub CLI archive does not contain the exact expected member: $member" >&2\n return 1\n fi\n member_details=$(tar -tvzf "$archive" "$member") || return 1\n if [[ "${member_details:0:1}" != "-" ]]; then\n echo "GitHub CLI archive member is not a regular file: $member" >&2\n return 1\n fi\n\n extracted_dir=$(mktemp -d)\n candidate="${extracted_dir}/${member}"\n if ! tar --no-same-owner --no-same-permissions -xzf "$archive" -C "$extracted_dir" "$member" \\\n || ! supacloud_validate_binary "$candidate" "gh-linux-${arch}"; then\n rm -rf "$extracted_dir"\n return 1\n fi\n chmod 0755 "$candidate"\n version_output=$("$candidate" --version 2>/dev/null | head -1) || {\n rm -rf "$extracted_dir"\n echo "GitHub CLI bootstrap binary failed its version check" >&2\n return 1\n }\n if [[ "$version_output" != "gh version ${version}"* ]]; then\n rm -rf "$extracted_dir"\n echo "GitHub CLI bootstrap version mismatch: ${version_output}" >&2\n return 1\n fi\n mkdir -p "$(dirname "$target")"\n install -m 0755 "$candidate" "$target"\n rm -rf "$extracted_dir"\n}\n\nsupacloud_install_pinned_gh() {\n local target="${1:-/usr/local/bin/gh}"\n local machine arch expected_sha256 asset url archive\n machine=$(uname -m)\n case "$machine" in\n x86_64|amd64)\n arch="amd64"\n expected_sha256="$SUPACLOUD_GH_AMD64_SHA256"\n ;;\n aarch64|arm64)\n arch="arm64"\n expected_sha256="$SUPACLOUD_GH_ARM64_SHA256"\n ;;\n *)\n echo "Unsupported architecture for GitHub CLI bootstrap: $machine" >&2\n return 1\n ;;\n esac\n asset="gh_${SUPACLOUD_GH_VERSION}_linux_${arch}.tar.gz"\n url="https://github.com/cli/cli/releases/download/v${SUPACLOUD_GH_VERSION}/${asset}"\n archive=$(mktemp)\n if ! supacloud_download_url "$url" "$archive" \\\n || ! supacloud_install_gh_archive "$archive" "$SUPACLOUD_GH_VERSION" "$arch" "$expected_sha256" "$target"; then\n rm -f "$archive"\n return 1\n fi\n rm -f "$archive"\n}\n\nsupacloud_validate_tar() {\n local artifact_file="$1"\n local entries\n entries=$(tar -tzf "$artifact_file") || {\n echo "Web Console archive is not a readable gzip tarball" >&2\n return 1\n }\n if ! printf \'%s\\n\' "$entries" | awk \'\n /^\\// { exit 1 }\n /(^|\\/)\\.\\.($|\\/)/ { exit 1 }\n \'; then\n echo "Web Console archive contains an unsafe path" >&2\n return 1\n fi\n if ! tar -tvzf "$artifact_file" | awk \'substr($1, 1, 1) != "-" && substr($1, 1, 1) != "d" { exit 1 }\'; then\n echo "Web Console archive contains links or special files" >&2\n return 1\n fi\n printf \'%s\\n\' "$entries" | grep -Eq \'(^|/)index\\.html$\' || {\n echo "Web Console archive is invalid or does not contain index.html" >&2\n return 1\n }\n}\n\nsupacloud_record_integrity_mode() {\n local mode="$1"\n local record_file="${SUPACLOUD_INTEGRITY_MODE_RECORD:-/var/lib/supacloud/artifact-integrity-mode}"\n mkdir -p "$(dirname "$record_file")" 2>/dev/null || return 0\n printf \'%s\\n\' "$mode" > "$record_file" 2>/dev/null || return 0\n chmod 600 "$record_file" 2>/dev/null || true\n}\n\nsupacloud_fetch_attestation_bundle() {\n local artifact_file="$1"\n local bundle_file="$2"\n local digest response\n digest=$(sha256sum "$artifact_file" | awk \'{print $1}\') || return 1\n [[ "$digest" =~ ^[0-9a-fA-F]{64}$ ]] || {\n echo "Unable to calculate the artifact digest for attestation lookup" >&2\n return 1\n }\n digest=$(printf \'%s\' "$digest" | tr \'[:upper:]\' \'[:lower:]\')\n response=$(supacloud_fetch_release_json \\\n "https://api.github.com/repos/${SUPACLOUD_GITHUB_REPOSITORY}/attestations/sha256:${digest}") || {\n echo "Unable to download the public GitHub artifact attestation bundle" >&2\n return 1\n }\n if ! jq -ce \'\n .attestations\n | if type != "array" or length == 0 or any(.[]; (.bundle | type) != "object")\n then error("no valid attestation bundles returned")\n else .[].bundle\n end\n \' <<< "$response" > "$bundle_file"; then\n echo "GitHub artifact attestation response did not contain a valid bundle" >&2\n return 1\n fi\n}\n\nsupacloud_attestation_trusted_root_available() {\n local trusted_root="${SUPACLOUD_ATTESTATION_TRUSTED_ROOT:-$SUPACLOUD_ATTESTATION_TRUSTED_ROOT_DEFAULT}"\n local actual_size actual_sha256\n [[ "$trusted_root" == /* && -f "$trusted_root" && ! -L "$trusted_root" ]] || return 1\n actual_size=$(wc -c < "$trusted_root" | tr -d \'[:space:]\') || return 1\n [[ "$actual_size" == "$SUPACLOUD_ATTESTATION_TRUSTED_ROOT_SIZE" ]] || return 1\n actual_sha256=$(sha256sum "$trusted_root" | awk \'{print $1}\') || return 1\n [[ "$actual_sha256" == "$SUPACLOUD_ATTESTATION_TRUSTED_ROOT_SHA256" ]] || return 1\n [[ "$(wc -l < "$trusted_root" | tr -d \'[:space:]\')" == "1" ]] || return 1\n jq -e \'type == "object" and .mediaType == "application/vnd.dev.sigstore.trustedroot+json;version=0.1"\' \\\n "$trusted_root" >/dev/null 2>&1\n}\n\nsupacloud_prepare_attestation_trusted_root() {\n local destination="$1"\n local trusted_root="${SUPACLOUD_ATTESTATION_TRUSTED_ROOT:-$SUPACLOUD_ATTESTATION_TRUSTED_ROOT_DEFAULT}"\n supacloud_attestation_trusted_root_available || {\n echo "Pinned Sigstore Public Good trusted root is missing or invalid" >&2\n return 1\n }\n jq -ce . "$trusted_root" > "$destination" || return 1\n chmod 600 "$destination"\n [[ "$(wc -c < "$destination" | tr -d \'[:space:]\')" == "$SUPACLOUD_ATTESTATION_TRUSTED_ROOT_SIZE" ]] || return 1\n [[ "$(sha256sum "$destination" | awk \'{print $1}\')" == "$SUPACLOUD_ATTESTATION_TRUSTED_ROOT_SHA256" ]]\n}\n\nsupacloud_verify_attestation() (\n local artifact_file="$1"\n if supacloud_attestation_verifier_available; then\n local verification_output bundle_dir bundle_file trusted_root_file\n bundle_dir=$(mktemp -d "${TMPDIR:-/tmp}/supacloud-attestation.XXXXXX") || return 1\n trap \'rm -rf -- "$bundle_dir"\' EXIT\n trap \'trap - EXIT HUP INT TERM; rm -rf -- "$bundle_dir"; exit 1\' HUP INT TERM\n bundle_file="${bundle_dir}/bundle.jsonl"\n trusted_root_file="${bundle_dir}/trusted_root.jsonl"\n if ! supacloud_fetch_attestation_bundle "$artifact_file" "$bundle_file"; then\n return 1\n fi\n supacloud_prepare_attestation_trusted_root "$trusted_root_file" || return 1\n if ! verification_output=$(gh attestation verify "$artifact_file" \\\n --bundle "$bundle_file" \\\n --custom-trusted-root "$trusted_root_file" \\\n --repo "$SUPACLOUD_GITHUB_REPOSITORY" \\\n --signer-workflow "$SUPACLOUD_ATTESTATION_SIGNER_WORKFLOW" \\\n --source-ref "refs/heads/main" \\\n --deny-self-hosted-runners 2>&1); then\n echo "GitHub artifact attestation verification failed: ${verification_output}" >&2\n return 1\n fi\n supacloud_record_integrity_mode "github-attestation+same-release-sha256"\n return\n fi\n\n if [[ "${SUPACLOUD_ALLOW_UNVERIFIED_RELEASE:-false}" == "true" ]]; then\n echo "BREAK-GLASS LIMITED INTEGRITY MODE: artifact attestation verification is unavailable; only the same-release SHA256 checksum was verified." >&2\n supacloud_record_integrity_mode "break-glass:same-release-sha256-only"\n return 0\n fi\n\n echo "Artifact attestation verification is required, but gh attestation verify is unavailable. Install GitHub CLI or explicitly set SUPACLOUD_ALLOW_UNVERIFIED_RELEASE=true for emergency break-glass use." >&2\n return 1\n)\n\nsupacloud_attestation_verifier_available() {\n local version help\n supacloud_attestation_trusted_root_available || return 1\n command -v gh >/dev/null 2>&1 || return 1\n version=$(supacloud_gh_version)\n [[ -n "$version" ]] || return 1\n supacloud_version_at_least "$version" "$SUPACLOUD_GH_MIN_VERSION" || return 1\n help=$(gh attestation verify --help 2>&1) || return 1\n grep -Eq -- \'(^|[[:space:]])--bundle([=[:space:]]|$)\' <<< "$help" || return 1\n grep -Eq -- \'(^|[[:space:]])--signer-workflow([=[:space:]]|$)\' <<< "$help" || return 1\n grep -Eq -- \'(^|[[:space:]])--source-ref([=[:space:]]|$)\' <<< "$help" || return 1\n grep -Eq -- \'(^|[[:space:]])--custom-trusted-root([=[:space:]]|$)\' <<< "$help" || return 1\n grep -Eq -- \'(^|[[:space:]])--deny-self-hosted-runners([=[:space:]]|$)\' <<< "$help"\n}\n\nsupacloud_download_release_asset() (\n local release_json="$1"\n local asset_name="$2"\n local destination="$3"\n local asset_kind="$4"\n local asset_url checksum_url temporary_artifact temporary_checksums\n\n asset_url=$(supacloud_release_asset_url "$release_json" "$asset_name") || return 1\n checksum_url=$(supacloud_release_asset_url "$release_json" SHA256SUMS) || return 1\n mkdir -p "$(dirname "$destination")"\n temporary_artifact=$(mktemp "${destination}.tmp.XXXXXX")\n temporary_checksums=$(mktemp "${destination}.SHA256SUMS.tmp.XXXXXX")\n trap \'rm -f "${temporary_artifact:-}" "${temporary_checksums:-}"\' EXIT\n trap \'trap - EXIT HUP INT TERM; rm -f "${temporary_artifact:-}" "${temporary_checksums:-}"; exit 1\' HUP INT TERM\n\n if ! supacloud_download_url "$asset_url" "$temporary_artifact" \\\n || ! supacloud_download_release_metadata_url "$checksum_url" "$temporary_checksums" \\\n || ! supacloud_verify_checksum "$temporary_artifact" "$asset_name" "$temporary_checksums"; then\n rm -f "$temporary_artifact" "$temporary_checksums"\n return 1\n fi\n\n # Authenticate the digest before parsing archives or inspecting binaries.\n if ! supacloud_verify_attestation "$temporary_artifact"; then\n rm -f "$temporary_artifact" "$temporary_checksums"\n return 1\n fi\n\n case "$asset_kind" in\n binary) supacloud_validate_binary "$temporary_artifact" "$asset_name" ;;\n tar) supacloud_validate_tar "$temporary_artifact" ;;\n *)\n echo "Unknown release asset kind: $asset_kind" >&2\n rm -f "$temporary_artifact" "$temporary_checksums"\n return 1\n ;;\n esac || {\n rm -f "$temporary_artifact" "$temporary_checksums"\n return 1\n }\n\n mv -f "$temporary_artifact" "$destination"\n temporary_artifact=""\n rm -f "$temporary_checksums"\n temporary_checksums=""\n)\n';
@@ -29043,12 +29302,13 @@ function platformVersionsToolResult(report) {
29043
29302
  }
29044
29303
  function registerSshTools(server, ssh) {
29045
29304
  server.tool("ssh", `Server management via SSH. Available before & after SupaCloud installation.
29046
- Actions: ping, setup, install, upgrade, versions, diagnose, exec, troubleshoot, container_logs, tenant_manage, tenant_list, tenant_inspect, tenant_diagnose, tenant_migrate`, {
29305
+ Actions: ping, setup, install, upgrade, upgrade_status, versions, diagnose, exec, troubleshoot, container_logs, tenant_manage, tenant_list, tenant_inspect, tenant_diagnose, tenant_migrate`, {
29047
29306
  action: withDescription(stringEnum([
29048
29307
  "ping",
29049
29308
  "setup",
29050
29309
  "install",
29051
29310
  "upgrade",
29311
+ "upgrade_status",
29052
29312
  "versions",
29053
29313
  "diagnose",
29054
29314
  "exec",
@@ -29068,6 +29328,7 @@ Actions: ping, setup, install, upgrade, versions, diagnose, exec, troubleshoot,
29068
29328
  dashboard_password: optional(secretSchema("dashboard_password"), "[install] Console password"),
29069
29329
  edge_runtime: optional(stringEnum(["bun"]), "[install] Runtime (default: bun)"),
29070
29330
  storage_type: optional(stringEnum(["juicefs", "minio"]), "[install] Storage backend configurable through Admin"),
29331
+ transaction_id: optional(Type.String(), "[upgrade_status] UUID v4 transaction ID of a retained local-artifact upgrade"),
29071
29332
  version: optional(Type.String(), "[upgrade] Specific version"),
29072
29333
  edge_runtime_version: optional(Type.String(), "[upgrade] Exact independent Edge Runtime version"),
29073
29334
  artifact_transport: optional(stringEnum(["local", "remote"]), "[upgrade] Download verified release assets locally or on the server (default: remote)"),
@@ -29226,6 +29487,14 @@ ${result.stderr.slice(-500)}`;
29226
29487
  ${upgradeExecution.stdout.slice(-300)}${edgeBoundary}`;
29227
29488
  break;
29228
29489
  }
29490
+ case "upgrade_status": {
29491
+ if (!args.transaction_id)
29492
+ throw new Error("'transaction_id' required");
29493
+ const transactionId = assertValidTransactionId(args.transaction_id);
29494
+ const projection = await inspectRemoteUpgradeStatus(ssh, transactionId);
29495
+ text = JSON.stringify(projection, null, 2);
29496
+ break;
29497
+ }
29229
29498
  case "versions": {
29230
29499
  return platformVersionsToolResult(await platformVersions(ssh));
29231
29500
  }
@@ -32245,7 +32514,7 @@ Actions: list_releases, get_release, upload_release, activate_release`, {
32245
32514
  // package.json
32246
32515
  var package_default = {
32247
32516
  name: "@supacloud/admin",
32248
- version: "0.15.4",
32517
+ version: "0.16.0",
32249
32518
  description: "Platform administration CLI for SupaCloud operators",
32250
32519
  type: "module",
32251
32520
  main: "./dist/index.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supacloud/admin",
3
- "version": "0.15.4",
3
+ "version": "0.16.0",
4
4
  "description": "Platform administration CLI for SupaCloud operators",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",