@supacloud/admin 0.7.6 → 0.7.8
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/README.md +38 -14
- package/dist/index.js +1659 -73
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -24926,10 +24926,12 @@ import { realpathSync } from "node:fs";
|
|
|
24926
24926
|
|
|
24927
24927
|
// src/shared/transports/ssh.ts
|
|
24928
24928
|
var import_ssh2 = __toESM(require_lib3(), 1);
|
|
24929
|
-
import { timingSafeEqual } from "node:crypto";
|
|
24929
|
+
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
24930
24930
|
import { readFileSync } from "node:fs";
|
|
24931
24931
|
var DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
24932
24932
|
var MAX_CONFIGURABLE_OUTPUT_BYTES = 16 * 1024 * 1024;
|
|
24933
|
+
var DEFAULT_UPLOAD_TIMEOUT_MS = 10 * 60000;
|
|
24934
|
+
var DEFAULT_TEXT_UPLOAD_TIMEOUT_MS = 60000;
|
|
24933
24935
|
function normalizeSshHostFingerprint(value) {
|
|
24934
24936
|
const trimmed = value.trim();
|
|
24935
24937
|
const match = trimmed.match(/^SHA256:([A-Za-z0-9+/]{43}=?)$/);
|
|
@@ -25096,6 +25098,11 @@ class SshConnectionPool {
|
|
|
25096
25098
|
} catch {}
|
|
25097
25099
|
}
|
|
25098
25100
|
}
|
|
25101
|
+
discard(conn) {
|
|
25102
|
+
try {
|
|
25103
|
+
conn.end();
|
|
25104
|
+
} catch {}
|
|
25105
|
+
}
|
|
25099
25106
|
closeAll() {
|
|
25100
25107
|
for (const conn of this.pool) {
|
|
25101
25108
|
try {
|
|
@@ -25130,18 +25137,20 @@ class SshTransport {
|
|
|
25130
25137
|
}
|
|
25131
25138
|
auditCommand(command, this.config.host, false);
|
|
25132
25139
|
const conn = await this.pool.acquire();
|
|
25140
|
+
let connectionReusable = true;
|
|
25133
25141
|
try {
|
|
25134
25142
|
return await new Promise((resolve, reject) => {
|
|
25135
25143
|
const outputLimit = this.config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
25136
25144
|
const stdout = new BoundedOutputCollector(outputLimit);
|
|
25137
25145
|
const stderr = new BoundedOutputCollector(outputLimit);
|
|
25138
25146
|
const timer = setTimeout(() => {
|
|
25139
|
-
|
|
25147
|
+
connectionReusable = false;
|
|
25140
25148
|
reject(new Error(`SSH command timed out after ${timeoutMs}ms`));
|
|
25141
25149
|
}, timeoutMs);
|
|
25142
25150
|
conn.exec(command, (err, stream) => {
|
|
25143
25151
|
if (err) {
|
|
25144
25152
|
clearTimeout(timer);
|
|
25153
|
+
connectionReusable = false;
|
|
25145
25154
|
return reject(err);
|
|
25146
25155
|
}
|
|
25147
25156
|
stream.on("close", (code) => {
|
|
@@ -25154,6 +25163,10 @@ class SshTransport {
|
|
|
25154
25163
|
stdoutTruncated: stdout.truncated,
|
|
25155
25164
|
stderrTruncated: stderr.truncated
|
|
25156
25165
|
});
|
|
25166
|
+
}).on("error", (streamError) => {
|
|
25167
|
+
clearTimeout(timer);
|
|
25168
|
+
connectionReusable = false;
|
|
25169
|
+
reject(streamError);
|
|
25157
25170
|
}).on("data", (data) => {
|
|
25158
25171
|
stdout.append(data);
|
|
25159
25172
|
}).stderr.on("data", (data) => {
|
|
@@ -25162,48 +25175,66 @@ class SshTransport {
|
|
|
25162
25175
|
});
|
|
25163
25176
|
});
|
|
25164
25177
|
} finally {
|
|
25165
|
-
|
|
25178
|
+
if (connectionReusable)
|
|
25179
|
+
this.pool.release(conn);
|
|
25180
|
+
else
|
|
25181
|
+
this.pool.discard(conn);
|
|
25166
25182
|
}
|
|
25167
25183
|
}
|
|
25168
|
-
async upload(localPath, remotePath) {
|
|
25169
|
-
const
|
|
25170
|
-
|
|
25171
|
-
|
|
25172
|
-
|
|
25173
|
-
|
|
25174
|
-
|
|
25175
|
-
|
|
25176
|
-
|
|
25177
|
-
|
|
25178
|
-
|
|
25179
|
-
|
|
25180
|
-
|
|
25181
|
-
|
|
25182
|
-
} finally {
|
|
25183
|
-
this.pool.release(conn);
|
|
25184
|
-
}
|
|
25184
|
+
async upload(localPath, remotePath, options = {}) {
|
|
25185
|
+
const mode = normalizeUploadMode(options.mode);
|
|
25186
|
+
const timeoutMs = normalizeUploadTimeout(options.timeoutMs, DEFAULT_UPLOAD_TIMEOUT_MS);
|
|
25187
|
+
const partialPath = `${remotePath}.part-${randomUUID()}`;
|
|
25188
|
+
auditCommand(`upload ${remotePath} (local content redacted)`, this.config.host, false);
|
|
25189
|
+
await this.runSftpOperation(timeoutMs, async (sftp) => {
|
|
25190
|
+
try {
|
|
25191
|
+
await sftpFastPut(sftp, localPath, partialPath);
|
|
25192
|
+
await sftpChmod(sftp, partialPath, mode);
|
|
25193
|
+
await sftpRename(sftp, partialPath, remotePath);
|
|
25194
|
+
} catch (error) {
|
|
25195
|
+
await removePartialUpload(sftp, partialPath, error);
|
|
25196
|
+
}
|
|
25197
|
+
});
|
|
25185
25198
|
}
|
|
25186
25199
|
async uploadText(remotePath, content, mode = 384) {
|
|
25187
25200
|
auditCommand(`upload ${remotePath} (${Buffer.byteLength(content)} bytes; content redacted)`, this.config.host, false);
|
|
25201
|
+
const normalizedMode = normalizeUploadMode(mode);
|
|
25202
|
+
const partialPath = `${remotePath}.part-${randomUUID()}`;
|
|
25203
|
+
await this.runSftpOperation(DEFAULT_TEXT_UPLOAD_TIMEOUT_MS, async (sftp) => {
|
|
25204
|
+
try {
|
|
25205
|
+
await sftpWriteFile(sftp, partialPath, content, normalizedMode);
|
|
25206
|
+
await sftpChmod(sftp, partialPath, normalizedMode);
|
|
25207
|
+
await sftpRename(sftp, partialPath, remotePath);
|
|
25208
|
+
} catch (error) {
|
|
25209
|
+
await removePartialUpload(sftp, partialPath, error);
|
|
25210
|
+
}
|
|
25211
|
+
});
|
|
25212
|
+
}
|
|
25213
|
+
async runSftpOperation(timeoutMs, operation) {
|
|
25188
25214
|
const conn = await this.pool.acquire();
|
|
25215
|
+
let connectionReusable = true;
|
|
25216
|
+
let timeoutHandle;
|
|
25189
25217
|
try {
|
|
25190
|
-
|
|
25191
|
-
|
|
25192
|
-
|
|
25193
|
-
|
|
25194
|
-
|
|
25195
|
-
|
|
25196
|
-
|
|
25197
|
-
|
|
25198
|
-
|
|
25199
|
-
|
|
25200
|
-
|
|
25201
|
-
|
|
25202
|
-
|
|
25203
|
-
});
|
|
25204
|
-
});
|
|
25218
|
+
const operationPromise = openSftp(conn).then(operation);
|
|
25219
|
+
await Promise.race([
|
|
25220
|
+
operationPromise,
|
|
25221
|
+
new Promise((_resolve, reject) => {
|
|
25222
|
+
timeoutHandle = setTimeout(() => {
|
|
25223
|
+
connectionReusable = false;
|
|
25224
|
+
reject(new Error(`SFTP upload timed out after ${timeoutMs}ms`));
|
|
25225
|
+
}, timeoutMs);
|
|
25226
|
+
})
|
|
25227
|
+
]);
|
|
25228
|
+
} catch (error) {
|
|
25229
|
+
connectionReusable = false;
|
|
25230
|
+
throw error;
|
|
25205
25231
|
} finally {
|
|
25206
|
-
|
|
25232
|
+
if (timeoutHandle)
|
|
25233
|
+
clearTimeout(timeoutHandle);
|
|
25234
|
+
if (connectionReusable)
|
|
25235
|
+
this.pool.release(conn);
|
|
25236
|
+
else
|
|
25237
|
+
this.pool.discard(conn);
|
|
25207
25238
|
}
|
|
25208
25239
|
}
|
|
25209
25240
|
async ping() {
|
|
@@ -25214,6 +25245,58 @@ class SshTransport {
|
|
|
25214
25245
|
this.pool.closeAll();
|
|
25215
25246
|
}
|
|
25216
25247
|
}
|
|
25248
|
+
function normalizeUploadMode(mode = 384) {
|
|
25249
|
+
if (!Number.isInteger(mode) || mode < 0 || mode > 511)
|
|
25250
|
+
throw new Error("Upload mode must be an octal permission between 000 and 777");
|
|
25251
|
+
return mode;
|
|
25252
|
+
}
|
|
25253
|
+
function normalizeUploadTimeout(timeoutMs, fallback) {
|
|
25254
|
+
const resolved = timeoutMs ?? fallback;
|
|
25255
|
+
if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > 60 * 60000) {
|
|
25256
|
+
throw new Error("Upload timeout must be between 1ms and 1 hour");
|
|
25257
|
+
}
|
|
25258
|
+
return resolved;
|
|
25259
|
+
}
|
|
25260
|
+
function openSftp(conn) {
|
|
25261
|
+
return new Promise((resolve, reject) => {
|
|
25262
|
+
conn.sftp((error, sftp) => error ? reject(error) : resolve(sftp));
|
|
25263
|
+
});
|
|
25264
|
+
}
|
|
25265
|
+
function sftpFastPut(sftp, localPath, remotePath) {
|
|
25266
|
+
return new Promise((resolve, reject) => {
|
|
25267
|
+
sftp.fastPut(localPath, remotePath, (error) => error ? reject(error) : resolve());
|
|
25268
|
+
});
|
|
25269
|
+
}
|
|
25270
|
+
function sftpWriteFile(sftp, remotePath, content, mode) {
|
|
25271
|
+
return new Promise((resolve, reject) => {
|
|
25272
|
+
sftp.writeFile(remotePath, content, { mode }, (error) => error ? reject(error) : resolve());
|
|
25273
|
+
});
|
|
25274
|
+
}
|
|
25275
|
+
function sftpChmod(sftp, remotePath, mode) {
|
|
25276
|
+
return new Promise((resolve, reject) => {
|
|
25277
|
+
sftp.chmod(remotePath, mode, (error) => error ? reject(error) : resolve());
|
|
25278
|
+
});
|
|
25279
|
+
}
|
|
25280
|
+
function sftpRename(sftp, sourcePath, destinationPath) {
|
|
25281
|
+
return new Promise((resolve, reject) => {
|
|
25282
|
+
sftp.rename(sourcePath, destinationPath, (error) => error ? reject(error) : resolve());
|
|
25283
|
+
});
|
|
25284
|
+
}
|
|
25285
|
+
function sftpUnlink(sftp, remotePath) {
|
|
25286
|
+
return new Promise((resolve, reject) => {
|
|
25287
|
+
sftp.unlink(remotePath, (error) => error ? reject(error) : resolve());
|
|
25288
|
+
});
|
|
25289
|
+
}
|
|
25290
|
+
async function removePartialUpload(sftp, partialPath, uploadError) {
|
|
25291
|
+
try {
|
|
25292
|
+
await sftpUnlink(sftp, partialPath);
|
|
25293
|
+
} catch (cleanupError) {
|
|
25294
|
+
if (cleanupError.code === 2)
|
|
25295
|
+
throw uploadError;
|
|
25296
|
+
throw new AggregateError([uploadError, cleanupError], `SFTP upload failed and partial cleanup did not complete: ${partialPath}`);
|
|
25297
|
+
}
|
|
25298
|
+
throw uploadError;
|
|
25299
|
+
}
|
|
25217
25300
|
|
|
25218
25301
|
// src/shared/cli.ts
|
|
25219
25302
|
function coerceCliValue(value) {
|
|
@@ -25231,7 +25314,10 @@ function sanitizedCliDiagnostic(error) {
|
|
|
25231
25314
|
}
|
|
25232
25315
|
function nestedCliDiagnostics(error) {
|
|
25233
25316
|
if (error instanceof AggregateError) {
|
|
25234
|
-
return
|
|
25317
|
+
return [
|
|
25318
|
+
sanitizedCliDiagnostic(error),
|
|
25319
|
+
...error.errors.flatMap((candidate) => nestedCliDiagnostics(candidate))
|
|
25320
|
+
];
|
|
25235
25321
|
}
|
|
25236
25322
|
return [sanitizedCliDiagnostic(error)];
|
|
25237
25323
|
}
|
|
@@ -25613,7 +25699,1376 @@ class HttpTransport {
|
|
|
25613
25699
|
}
|
|
25614
25700
|
|
|
25615
25701
|
// src/shared/tools/ssh-tools.ts
|
|
25616
|
-
import { randomUUID } from "node:crypto";
|
|
25702
|
+
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
25703
|
+
import { dirname } from "node:path";
|
|
25704
|
+
|
|
25705
|
+
// src/shared/releases/local-upgrade-transfer.ts
|
|
25706
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
25707
|
+
import { basename as basename2 } from "node:path";
|
|
25708
|
+
|
|
25709
|
+
// ../management-api/src/upgrade-lock.ts
|
|
25710
|
+
var SUPACLOUD_UPGRADE_LOCK_PATH = "/run/lock/supacloud-upgrade.lock";
|
|
25711
|
+
var INHERITED_UPGRADE_LOCK_FD = 9;
|
|
25712
|
+
var UPGRADE_LOCK_FD_ENV = "SUPACLOUD_UPGRADE_LOCK_FD";
|
|
25713
|
+
function buildUpgradeLockScript(lockPath) {
|
|
25714
|
+
return [
|
|
25715
|
+
`UPGRADE_LOCK=${quoteShell(lockPath)}`,
|
|
25716
|
+
`test ! -L "$UPGRADE_LOCK" || { echo 'SupaCloud upgrade lock must not be a symlink' >&2; exit 1; }`,
|
|
25717
|
+
'if [ ! -e "$UPGRADE_LOCK" ]; then (umask 077; : >> "$UPGRADE_LOCK"); fi',
|
|
25718
|
+
`test -f "$UPGRADE_LOCK" && test ! -L "$UPGRADE_LOCK" || { echo 'SupaCloud upgrade lock must be a regular file' >&2; exit 1; }`,
|
|
25719
|
+
`test "$(stat -c '%u:%g' "$UPGRADE_LOCK")" = "$(id -u):$(id -g)" || { echo 'SupaCloud upgrade lock has an unexpected owner' >&2; exit 1; }`,
|
|
25720
|
+
`LOCK_MODE=$(stat -c '%a' "$UPGRADE_LOCK")`,
|
|
25721
|
+
`case "$LOCK_MODE" in [0-7]|[0-7][0-7]|[0-7][0-7][0-7]) ;; *) echo 'SupaCloud upgrade lock has special permission bits' >&2; exit 1 ;; esac`,
|
|
25722
|
+
"(( (8#$LOCK_MODE & 0022) == 0 )) || { echo 'SupaCloud upgrade lock is group/other writable' >&2; exit 1; }",
|
|
25723
|
+
`exec ${INHERITED_UPGRADE_LOCK_FD}<>"$UPGRADE_LOCK"`,
|
|
25724
|
+
`flock -E 75 -n ${INHERITED_UPGRADE_LOCK_FD} || { echo 'Another SupaCloud upgrade is already running' >&2; exit 75; }`,
|
|
25725
|
+
`export ${UPGRADE_LOCK_FD_ENV}=${INHERITED_UPGRADE_LOCK_FD}`
|
|
25726
|
+
].join(`
|
|
25727
|
+
`);
|
|
25728
|
+
}
|
|
25729
|
+
function quoteShell(shellText) {
|
|
25730
|
+
return `'${shellText.split("'").join("'\\''")}'`;
|
|
25731
|
+
}
|
|
25732
|
+
|
|
25733
|
+
// src/shared/releases/local-upgrade-bundle.ts
|
|
25734
|
+
import { spawn } from "node:child_process";
|
|
25735
|
+
import { createHash, randomUUID as randomUUID2 } from "node:crypto";
|
|
25736
|
+
import { accessSync, chmodSync, constants as fsConstants, createWriteStream, mkdirSync, mkdtempSync, readFileSync as readFileSync3, rmSync, statSync, writeFileSync } from "node:fs";
|
|
25737
|
+
import { get } from "node:https";
|
|
25738
|
+
import { tmpdir } from "node:os";
|
|
25739
|
+
import { basename, delimiter, join } from "node:path";
|
|
25740
|
+
import { pipeline } from "node:stream/promises";
|
|
25741
|
+
import { Transform as Transform2 } from "node:stream";
|
|
25742
|
+
|
|
25743
|
+
// ../management-api/src/release-manifest.ts
|
|
25744
|
+
var RELEASE_MANIFEST_NAME = "SUPACLOUD-RELEASE.json";
|
|
25745
|
+
var RELEASE_ATTESTATION_NAME = "SUPACLOUD-RELEASE.attestation.jsonl";
|
|
25746
|
+
var RELEASE_CHECKSUMS_NAME = "SHA256SUMS";
|
|
25747
|
+
var RELEASE_REPOSITORY = "zuohuadong/supacloud";
|
|
25748
|
+
var RELEASE_SOURCE_REF = "refs/heads/main";
|
|
25749
|
+
var RELEASE_SIGNER_WORKFLOW = `${RELEASE_REPOSITORY}/.github/workflows/release-please.yml`;
|
|
25750
|
+
var MEBIBYTE = 1024 * 1024;
|
|
25751
|
+
var RELEASE_BUNDLE_SIZE_LIMITS = {
|
|
25752
|
+
manifest: MEBIBYTE,
|
|
25753
|
+
checksums: MEBIBYTE,
|
|
25754
|
+
attestation: 32 * MEBIBYTE,
|
|
25755
|
+
managementBinary: 160 * MEBIBYTE,
|
|
25756
|
+
edgeRuntimeBinary: 160 * MEBIBYTE,
|
|
25757
|
+
webConsole: 64 * MEBIBYTE,
|
|
25758
|
+
caddy: 96 * MEBIBYTE,
|
|
25759
|
+
total: 384 * MEBIBYTE
|
|
25760
|
+
};
|
|
25761
|
+
var RELEASE_ASSETS = {
|
|
25762
|
+
"management-api": [
|
|
25763
|
+
"SHA256SUMS",
|
|
25764
|
+
"SHA256SUMS.caddy",
|
|
25765
|
+
"supacloud-caddy-linux-amd64",
|
|
25766
|
+
"supacloud-caddy-linux-arm64",
|
|
25767
|
+
"supacloud-linux-amd64",
|
|
25768
|
+
"supacloud-linux-arm64",
|
|
25769
|
+
"supacloud-macos-amd64",
|
|
25770
|
+
"supacloud-macos-arm64",
|
|
25771
|
+
"web-console-build.tar.gz"
|
|
25772
|
+
],
|
|
25773
|
+
"edge-runtime": [
|
|
25774
|
+
"SHA256SUMS",
|
|
25775
|
+
"supacloud-edge-runtime-linux-amd64",
|
|
25776
|
+
"supacloud-edge-runtime-linux-arm64",
|
|
25777
|
+
"supacloud-edge-runtime-macos-amd64",
|
|
25778
|
+
"supacloud-edge-runtime-macos-arm64"
|
|
25779
|
+
]
|
|
25780
|
+
};
|
|
25781
|
+
function manifestObject(candidate, label) {
|
|
25782
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
25783
|
+
throw new Error(`${label} must be a JSON object`);
|
|
25784
|
+
}
|
|
25785
|
+
return candidate;
|
|
25786
|
+
}
|
|
25787
|
+
function assertExactKeys(record, keys, label) {
|
|
25788
|
+
const actual = Object.keys(record).sort();
|
|
25789
|
+
const expected = [...keys].sort();
|
|
25790
|
+
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
|
25791
|
+
throw new Error(`${label} contains unsupported or missing fields`);
|
|
25792
|
+
}
|
|
25793
|
+
}
|
|
25794
|
+
function exactStableVersion(version) {
|
|
25795
|
+
if (typeof version !== "string" || !/^\d+\.\d+\.\d+$/.test(version)) {
|
|
25796
|
+
throw new Error("Release manifest version must be an exact stable version");
|
|
25797
|
+
}
|
|
25798
|
+
return version;
|
|
25799
|
+
}
|
|
25800
|
+
function releaseTag(component, version) {
|
|
25801
|
+
exactStableVersion(version);
|
|
25802
|
+
return `${component}-v${version}`;
|
|
25803
|
+
}
|
|
25804
|
+
function releaseAssetNames(component) {
|
|
25805
|
+
return RELEASE_ASSETS[component];
|
|
25806
|
+
}
|
|
25807
|
+
function releaseAssetSizeLimit(component, name) {
|
|
25808
|
+
if (name === "SHA256SUMS" || name === "SHA256SUMS.caddy") {
|
|
25809
|
+
return RELEASE_BUNDLE_SIZE_LIMITS.checksums;
|
|
25810
|
+
}
|
|
25811
|
+
if (name === "web-console-build.tar.gz")
|
|
25812
|
+
return RELEASE_BUNDLE_SIZE_LIMITS.webConsole;
|
|
25813
|
+
if (name.startsWith("supacloud-caddy-"))
|
|
25814
|
+
return RELEASE_BUNDLE_SIZE_LIMITS.caddy;
|
|
25815
|
+
return component === "management-api" ? RELEASE_BUNDLE_SIZE_LIMITS.managementBinary : RELEASE_BUNDLE_SIZE_LIMITS.edgeRuntimeBinary;
|
|
25816
|
+
}
|
|
25817
|
+
function parseSource(candidate) {
|
|
25818
|
+
const source = manifestObject(candidate, "Release manifest source");
|
|
25819
|
+
assertExactKeys(source, ["ref", "commit"], "Release manifest source");
|
|
25820
|
+
if (source.ref !== RELEASE_SOURCE_REF || typeof source.commit !== "string" || !/^[0-9a-f]{40}$/.test(source.commit)) {
|
|
25821
|
+
throw new Error("Release manifest source is invalid");
|
|
25822
|
+
}
|
|
25823
|
+
return { ref: RELEASE_SOURCE_REF, commit: source.commit };
|
|
25824
|
+
}
|
|
25825
|
+
function parseRelease(candidate, expected) {
|
|
25826
|
+
const release = manifestObject(candidate, "Release manifest release");
|
|
25827
|
+
assertExactKeys(release, ["component", "version", "tag"], "Release manifest release");
|
|
25828
|
+
const version = exactStableVersion(release.version);
|
|
25829
|
+
if (release.component !== expected.component || version !== expected.version || release.tag !== releaseTag(expected.component, expected.version)) {
|
|
25830
|
+
throw new Error("Release manifest component, version, or tag does not match the requested release");
|
|
25831
|
+
}
|
|
25832
|
+
return { component: expected.component, version, tag: release.tag };
|
|
25833
|
+
}
|
|
25834
|
+
function parseArtifact(candidate, index) {
|
|
25835
|
+
const artifact = manifestObject(candidate, `Release manifest artifact ${index}`);
|
|
25836
|
+
assertExactKeys(artifact, ["name", "sha256", "size"], `Release manifest artifact ${index}`);
|
|
25837
|
+
if (typeof artifact.name !== "string" || !/^[A-Za-z0-9._-]+$/.test(artifact.name) || typeof artifact.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(artifact.sha256) || !Number.isSafeInteger(artifact.size) || artifact.size <= 0) {
|
|
25838
|
+
throw new Error(`Release manifest artifact ${index} is invalid`);
|
|
25839
|
+
}
|
|
25840
|
+
return artifact;
|
|
25841
|
+
}
|
|
25842
|
+
function parseArtifacts(candidate, component) {
|
|
25843
|
+
if (!Array.isArray(candidate))
|
|
25844
|
+
throw new Error("Release manifest artifacts must be an array");
|
|
25845
|
+
const artifacts = candidate.map(parseArtifact);
|
|
25846
|
+
const expectedNames = releaseAssetNames(component);
|
|
25847
|
+
const actualNames = artifacts.map((artifact) => artifact.name);
|
|
25848
|
+
if (actualNames.length !== expectedNames.length || actualNames.some((name, index) => name !== expectedNames[index])) {
|
|
25849
|
+
throw new Error(`Release manifest artifacts do not match the ${component} allowlist`);
|
|
25850
|
+
}
|
|
25851
|
+
const oversized = artifacts.find((artifact) => artifact.size > releaseAssetSizeLimit(component, artifact.name));
|
|
25852
|
+
if (oversized)
|
|
25853
|
+
throw new Error(`Release manifest artifact ${oversized.name} exceeds its size limit`);
|
|
25854
|
+
return artifacts;
|
|
25855
|
+
}
|
|
25856
|
+
function parseReleaseManifest(text, expected) {
|
|
25857
|
+
let candidate;
|
|
25858
|
+
try {
|
|
25859
|
+
candidate = JSON.parse(text);
|
|
25860
|
+
} catch {
|
|
25861
|
+
throw new Error("Release manifest is not valid JSON");
|
|
25862
|
+
}
|
|
25863
|
+
const manifest = manifestObject(candidate, "Release manifest");
|
|
25864
|
+
assertExactKeys(manifest, ["schemaVersion", "repository", "source", "workflow", "release", "artifacts"], "Release manifest");
|
|
25865
|
+
if (manifest.schemaVersion !== 1 || manifest.repository !== RELEASE_REPOSITORY || manifest.workflow !== RELEASE_SIGNER_WORKFLOW) {
|
|
25866
|
+
throw new Error("Release manifest identity is invalid");
|
|
25867
|
+
}
|
|
25868
|
+
return {
|
|
25869
|
+
schemaVersion: 1,
|
|
25870
|
+
repository: RELEASE_REPOSITORY,
|
|
25871
|
+
source: parseSource(manifest.source),
|
|
25872
|
+
workflow: RELEASE_SIGNER_WORKFLOW,
|
|
25873
|
+
release: parseRelease(manifest.release, expected),
|
|
25874
|
+
artifacts: parseArtifacts(manifest.artifacts, expected.component)
|
|
25875
|
+
};
|
|
25876
|
+
}
|
|
25877
|
+
function manifestArtifact(manifest, name) {
|
|
25878
|
+
const artifact = manifest.artifacts.find((candidate) => candidate.name === name);
|
|
25879
|
+
if (!artifact)
|
|
25880
|
+
throw new Error(`Release manifest does not contain ${name}`);
|
|
25881
|
+
return artifact;
|
|
25882
|
+
}
|
|
25883
|
+
function parseReleaseChecksums(text, manifest) {
|
|
25884
|
+
const lines = text.endsWith(`
|
|
25885
|
+
`) ? text.slice(0, -1).split(`
|
|
25886
|
+
`) : text.split(`
|
|
25887
|
+
`);
|
|
25888
|
+
const expectedNames = releaseAssetNames(manifest.release.component).filter((name) => name !== RELEASE_CHECKSUMS_NAME);
|
|
25889
|
+
if (lines.length !== expectedNames.length)
|
|
25890
|
+
throw new Error("SHA256SUMS has an unexpected number of entries");
|
|
25891
|
+
const checksums = new Map;
|
|
25892
|
+
lines.forEach((line, index) => {
|
|
25893
|
+
const match = line.match(/^([0-9a-f]{64}) ([A-Za-z0-9._-]+)$/);
|
|
25894
|
+
if (!match || match[2] !== expectedNames[index])
|
|
25895
|
+
throw new Error("SHA256SUMS is not strict or sorted");
|
|
25896
|
+
const [digest, name] = [match[1], match[2]];
|
|
25897
|
+
if (manifestArtifact(manifest, name).sha256 !== digest) {
|
|
25898
|
+
throw new Error(`SHA256SUMS and release manifest disagree for ${name}`);
|
|
25899
|
+
}
|
|
25900
|
+
checksums.set(name, digest);
|
|
25901
|
+
});
|
|
25902
|
+
return checksums;
|
|
25903
|
+
}
|
|
25904
|
+
|
|
25905
|
+
// src/shared/releases/local-upgrade-bundle.ts
|
|
25906
|
+
var RELEASES_API = `https://api.github.com/repos/${RELEASE_REPOSITORY}/releases`;
|
|
25907
|
+
var ATTESTATIONS_API = `https://api.github.com/repos/${RELEASE_REPOSITORY}/attestations`;
|
|
25908
|
+
var GH_VERSION = "2.96.0";
|
|
25909
|
+
var GH_ARCHIVE_SHA256 = {
|
|
25910
|
+
amd64: "83d5c2ccad5498f58bf6368acb1ab32588cf43ab3a4b1c301bf36328b1c8bd60",
|
|
25911
|
+
arm64: "06f86ec7103d41993b76cd78072f43595c34aaa56506d971d9860e67140bf909"
|
|
25912
|
+
};
|
|
25913
|
+
var MAX_GH_ARCHIVE_BYTES = 64 * 1024 * 1024;
|
|
25914
|
+
var DOWNLOAD_TIMEOUT_MS = 10 * 60000;
|
|
25915
|
+
var DOWNLOAD_IDLE_TIMEOUT_MS = 30000;
|
|
25916
|
+
var GH_CAPABILITY_TIMEOUT_MS = 30000;
|
|
25917
|
+
var GH_VERIFICATION_TIMEOUT_MS = 2 * 60000;
|
|
25918
|
+
var GH_TERMINATION_GRACE_MS = 2000;
|
|
25919
|
+
var MAX_REDIRECTS = 6;
|
|
25920
|
+
var RETRYABLE_DOWNLOAD_CODES = new Set([
|
|
25921
|
+
"EAI_AGAIN",
|
|
25922
|
+
"ECONNREFUSED",
|
|
25923
|
+
"ECONNRESET",
|
|
25924
|
+
"ENETUNREACH",
|
|
25925
|
+
"EPIPE",
|
|
25926
|
+
"ETIMEDOUT"
|
|
25927
|
+
]);
|
|
25928
|
+
var OFFICIAL_DOWNLOAD_HOSTS = new Set([
|
|
25929
|
+
"api.github.com",
|
|
25930
|
+
"github.com",
|
|
25931
|
+
"release-assets.githubusercontent.com"
|
|
25932
|
+
]);
|
|
25933
|
+
var STRICT_GITHUB_CAPABILITY_FLAGS = [
|
|
25934
|
+
"--bundle",
|
|
25935
|
+
"--signer-workflow",
|
|
25936
|
+
"--source-ref",
|
|
25937
|
+
"--source-digest",
|
|
25938
|
+
"--deny-self-hosted-runners"
|
|
25939
|
+
];
|
|
25940
|
+
|
|
25941
|
+
class RetryableDownloadError extends Error {
|
|
25942
|
+
}
|
|
25943
|
+
function githubCliArchiveIdentity(architecture) {
|
|
25944
|
+
const directory = `gh_${GH_VERSION}_linux_${architecture}`;
|
|
25945
|
+
return {
|
|
25946
|
+
archiveName: `${directory}.tar.gz`,
|
|
25947
|
+
member: `${directory}/bin/gh`,
|
|
25948
|
+
sha256: GH_ARCHIVE_SHA256[architecture],
|
|
25949
|
+
version: GH_VERSION
|
|
25950
|
+
};
|
|
25951
|
+
}
|
|
25952
|
+
function sha256File(filePath) {
|
|
25953
|
+
return createHash("sha256").update(readFileSync3(filePath)).digest("hex");
|
|
25954
|
+
}
|
|
25955
|
+
function assertPrivateLocalFileMode(mode, label) {
|
|
25956
|
+
if (process.platform !== "win32" && (mode & 4095) !== 384) {
|
|
25957
|
+
throw new Error(`${label} must use exact mode 0600 without special permission bits`);
|
|
25958
|
+
}
|
|
25959
|
+
}
|
|
25960
|
+
function localUpgradeFile(localPath, relativePath) {
|
|
25961
|
+
const stats = statSync(localPath);
|
|
25962
|
+
if (!stats.isFile())
|
|
25963
|
+
throw new Error(`Local upgrade artifact is not a regular file: ${relativePath}`);
|
|
25964
|
+
assertPrivateLocalFileMode(stats.mode, relativePath);
|
|
25965
|
+
return { localPath, relativePath, sha256: sha256File(localPath), size: stats.size };
|
|
25966
|
+
}
|
|
25967
|
+
function assertExactStableVersion(version, field) {
|
|
25968
|
+
if (!/^\d+\.\d+\.\d+$/.test(version)) {
|
|
25969
|
+
throw new Error(`${field} must be an exact stable semantic version`);
|
|
25970
|
+
}
|
|
25971
|
+
}
|
|
25972
|
+
function directChildPath(directory, name) {
|
|
25973
|
+
if (basename(name) !== name || !/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
25974
|
+
throw new Error(`Unsafe release asset name: ${name}`);
|
|
25975
|
+
}
|
|
25976
|
+
return join(directory, name);
|
|
25977
|
+
}
|
|
25978
|
+
function assertOfficialDownloadUrl(url) {
|
|
25979
|
+
if (url.protocol !== "https:" || url.username || url.password || url.port || !OFFICIAL_DOWNLOAD_HOSTS.has(url.hostname)) {
|
|
25980
|
+
throw new Error(`Release download URL is not an approved official GitHub endpoint: ${url.hostname}`);
|
|
25981
|
+
}
|
|
25982
|
+
}
|
|
25983
|
+
function boundedWriter(maxBytes) {
|
|
25984
|
+
let receivedBytes = 0;
|
|
25985
|
+
return new Transform2({
|
|
25986
|
+
transform(chunk, _encoding, callback) {
|
|
25987
|
+
receivedBytes += chunk.length;
|
|
25988
|
+
if (receivedBytes > maxBytes)
|
|
25989
|
+
callback(new Error(`Download exceeded ${maxBytes} bytes`));
|
|
25990
|
+
else
|
|
25991
|
+
callback(null, chunk);
|
|
25992
|
+
}
|
|
25993
|
+
});
|
|
25994
|
+
}
|
|
25995
|
+
function responseLocation(currentUrl, location) {
|
|
25996
|
+
if (!location)
|
|
25997
|
+
throw new Error(`HTTPS redirect from ${currentUrl.hostname} did not include Location`);
|
|
25998
|
+
const redirected = new URL(location, currentUrl);
|
|
25999
|
+
assertOfficialDownloadUrl(redirected);
|
|
26000
|
+
return redirected;
|
|
26001
|
+
}
|
|
26002
|
+
function downloadCanRetry(error) {
|
|
26003
|
+
return error instanceof RetryableDownloadError || RETRYABLE_DOWNLOAD_CODES.has(error.code || "");
|
|
26004
|
+
}
|
|
26005
|
+
async function consumeDownloadResponse(response, download) {
|
|
26006
|
+
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400) {
|
|
26007
|
+
response.resume();
|
|
26008
|
+
await downloadHttpsResponse({
|
|
26009
|
+
...download,
|
|
26010
|
+
url: responseLocation(download.url, response.headers.location),
|
|
26011
|
+
redirects: download.redirects + 1
|
|
26012
|
+
});
|
|
26013
|
+
return;
|
|
26014
|
+
}
|
|
26015
|
+
if (response.statusCode !== 200) {
|
|
26016
|
+
response.resume();
|
|
26017
|
+
const message = `HTTPS download returned HTTP ${response.statusCode ?? "unknown"}`;
|
|
26018
|
+
if (response.statusCode && response.statusCode >= 500)
|
|
26019
|
+
throw new RetryableDownloadError(message);
|
|
26020
|
+
throw new Error(message);
|
|
26021
|
+
}
|
|
26022
|
+
const contentLength = Number(response.headers["content-length"] || 0);
|
|
26023
|
+
if (contentLength > download.maxBytes) {
|
|
26024
|
+
response.resume();
|
|
26025
|
+
throw new Error(`Download Content-Length exceeded ${download.maxBytes} bytes`);
|
|
26026
|
+
}
|
|
26027
|
+
response.setTimeout(DOWNLOAD_IDLE_TIMEOUT_MS, () => response.destroy(new RetryableDownloadError("Release download stalled")));
|
|
26028
|
+
await pipeline(response, boundedWriter(download.maxBytes), createWriteStream(download.destination, { flags: "wx", mode: 384 }));
|
|
26029
|
+
}
|
|
26030
|
+
function requestDownloadResponse(download, remainingTime) {
|
|
26031
|
+
return new Promise((resolve2, reject) => {
|
|
26032
|
+
const request = get(download.url, { headers: { "User-Agent": "SupaCloud-Admin" } }, (response) => {
|
|
26033
|
+
consumeDownloadResponse(response, download).then(resolve2, reject);
|
|
26034
|
+
});
|
|
26035
|
+
request.setTimeout(DOWNLOAD_IDLE_TIMEOUT_MS, () => request.destroy(new RetryableDownloadError("Release download connection stalled")));
|
|
26036
|
+
request.on("error", reject);
|
|
26037
|
+
const totalTimer = setTimeout(() => request.destroy(new RetryableDownloadError("Release download timed out")), remainingTime);
|
|
26038
|
+
request.on("close", () => clearTimeout(totalTimer));
|
|
26039
|
+
});
|
|
26040
|
+
}
|
|
26041
|
+
async function downloadHttpsResponse(download) {
|
|
26042
|
+
if (download.redirects > MAX_REDIRECTS)
|
|
26043
|
+
throw new Error("Release download exceeded the redirect limit");
|
|
26044
|
+
const remainingTime = download.deadline - Date.now();
|
|
26045
|
+
if (remainingTime <= 0)
|
|
26046
|
+
throw new Error("Release download timed out");
|
|
26047
|
+
await requestDownloadResponse(download, remainingTime);
|
|
26048
|
+
}
|
|
26049
|
+
async function downloadDirect(url, destination, maxBytes) {
|
|
26050
|
+
const parsed = new URL(url);
|
|
26051
|
+
assertOfficialDownloadUrl(parsed);
|
|
26052
|
+
const deadline = Date.now() + DOWNLOAD_TIMEOUT_MS;
|
|
26053
|
+
const retryFailures = [];
|
|
26054
|
+
for (let attempt = 1;attempt <= 3; attempt += 1) {
|
|
26055
|
+
rmSync(destination, { force: true });
|
|
26056
|
+
try {
|
|
26057
|
+
await downloadHttpsResponse({ url: parsed, destination, maxBytes, redirects: 0, deadline });
|
|
26058
|
+
chmodSync(destination, 384);
|
|
26059
|
+
return;
|
|
26060
|
+
} catch (error) {
|
|
26061
|
+
if (!downloadCanRetry(error))
|
|
26062
|
+
throw error;
|
|
26063
|
+
retryFailures.push(error);
|
|
26064
|
+
}
|
|
26065
|
+
}
|
|
26066
|
+
throw new AggregateError(retryFailures, `Unable to download ${parsed.hostname}${parsed.pathname}`);
|
|
26067
|
+
}
|
|
26068
|
+
function parseJsonFile(filePath, label) {
|
|
26069
|
+
const contents = readFileSync3(filePath, "utf8");
|
|
26070
|
+
try {
|
|
26071
|
+
return JSON.parse(contents);
|
|
26072
|
+
} catch (error) {
|
|
26073
|
+
if (!(error instanceof SyntaxError))
|
|
26074
|
+
throw error;
|
|
26075
|
+
throw new Error(`${label} is not valid JSON`);
|
|
26076
|
+
}
|
|
26077
|
+
}
|
|
26078
|
+
function parseGithubReleaseMetadata(candidate, expectedTag) {
|
|
26079
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
26080
|
+
throw new Error("GitHub release metadata must be an object");
|
|
26081
|
+
}
|
|
26082
|
+
const release = candidate;
|
|
26083
|
+
if (release.tag_name !== expectedTag || release.draft !== false || release.prerelease !== false || !Array.isArray(release.assets)) {
|
|
26084
|
+
throw new Error(`GitHub release metadata does not describe stable release ${expectedTag}`);
|
|
26085
|
+
}
|
|
26086
|
+
const assets = release.assets.map((asset) => githubReleaseAsset(asset, expectedTag));
|
|
26087
|
+
if (new Set(assets.map((asset) => asset.name)).size !== assets.length) {
|
|
26088
|
+
throw new Error(`GitHub release ${expectedTag} contains duplicate asset names`);
|
|
26089
|
+
}
|
|
26090
|
+
return { tag_name: expectedTag, draft: false, prerelease: false, assets };
|
|
26091
|
+
}
|
|
26092
|
+
function githubReleaseAsset(candidate, expectedTag) {
|
|
26093
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
26094
|
+
throw new Error("GitHub release asset metadata must be an object");
|
|
26095
|
+
}
|
|
26096
|
+
const asset = candidate;
|
|
26097
|
+
if (typeof asset.name !== "string" || typeof asset.browser_download_url !== "string") {
|
|
26098
|
+
throw new Error("GitHub release asset metadata is incomplete");
|
|
26099
|
+
}
|
|
26100
|
+
const downloadUrl = new URL(asset.browser_download_url);
|
|
26101
|
+
assertOfficialDownloadUrl(downloadUrl);
|
|
26102
|
+
const expectedPath = `/${RELEASE_REPOSITORY}/releases/download/${expectedTag}/${asset.name}`;
|
|
26103
|
+
if (!/^[A-Za-z0-9._-]+$/.test(asset.name) || downloadUrl.hostname !== "github.com" || downloadUrl.pathname !== expectedPath || downloadUrl.search || downloadUrl.hash) {
|
|
26104
|
+
throw new Error(`Release asset ${asset.name} does not use its official GitHub release path`);
|
|
26105
|
+
}
|
|
26106
|
+
return { name: asset.name, browser_download_url: downloadUrl.toString() };
|
|
26107
|
+
}
|
|
26108
|
+
function releaseAssetUrl(release, name) {
|
|
26109
|
+
const matching = release.assets.find((asset) => asset.name === name);
|
|
26110
|
+
if (!matching)
|
|
26111
|
+
throw new Error(`Release ${release.tag_name} does not contain ${name}`);
|
|
26112
|
+
return matching.browser_download_url;
|
|
26113
|
+
}
|
|
26114
|
+
function serializeAttestationBundles(candidate) {
|
|
26115
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
26116
|
+
throw new Error("GitHub attestation response must be an object");
|
|
26117
|
+
}
|
|
26118
|
+
const attestations = candidate.attestations;
|
|
26119
|
+
if (!Array.isArray(attestations) || attestations.length === 0) {
|
|
26120
|
+
throw new Error("GitHub attestation response did not contain attestations");
|
|
26121
|
+
}
|
|
26122
|
+
const bundles = attestations.map((attestation) => {
|
|
26123
|
+
if (!attestation || typeof attestation !== "object" || Array.isArray(attestation)) {
|
|
26124
|
+
throw new Error("GitHub attestation entry is invalid");
|
|
26125
|
+
}
|
|
26126
|
+
const bundle = attestation.bundle;
|
|
26127
|
+
if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) {
|
|
26128
|
+
throw new Error("GitHub attestation entry did not contain a bundle");
|
|
26129
|
+
}
|
|
26130
|
+
return JSON.stringify(bundle);
|
|
26131
|
+
});
|
|
26132
|
+
return `${bundles.join(`
|
|
26133
|
+
`)}
|
|
26134
|
+
`;
|
|
26135
|
+
}
|
|
26136
|
+
function directEnvironment() {
|
|
26137
|
+
const environment = { ...process.env };
|
|
26138
|
+
for (const key of Object.keys(environment)) {
|
|
26139
|
+
if (/(?:^|_)proxy$/i.test(key) || /^(?:SUPACLOUD_GITHUB_PROXIES|NODE_USE_ENV_PROXY)$/.test(key)) {
|
|
26140
|
+
delete environment[key];
|
|
26141
|
+
}
|
|
26142
|
+
}
|
|
26143
|
+
return environment;
|
|
26144
|
+
}
|
|
26145
|
+
function githubCliExecutable(environment) {
|
|
26146
|
+
for (const directory of (environment.PATH || "").split(delimiter).filter(Boolean)) {
|
|
26147
|
+
const candidate = join(directory, "gh");
|
|
26148
|
+
try {
|
|
26149
|
+
accessSync(candidate, fsConstants.X_OK);
|
|
26150
|
+
const stats = statSync(candidate);
|
|
26151
|
+
if (stats.isFile()) {
|
|
26152
|
+
if (process.platform !== "win32" && (stats.mode & 4095) > 511) {
|
|
26153
|
+
throw new Error(`GitHub CLI executable has special permission bits: ${candidate}`);
|
|
26154
|
+
}
|
|
26155
|
+
return candidate;
|
|
26156
|
+
}
|
|
26157
|
+
} catch (error) {
|
|
26158
|
+
if (error.code !== "ENOENT" && error.code !== "EACCES" && error.code !== "ENOTDIR")
|
|
26159
|
+
throw error;
|
|
26160
|
+
}
|
|
26161
|
+
}
|
|
26162
|
+
throw new Error("GitHub CLI executable was not found in PATH");
|
|
26163
|
+
}
|
|
26164
|
+
async function runGithubCli(arguments_, timeoutMs) {
|
|
26165
|
+
return await new Promise((resolve2, reject) => {
|
|
26166
|
+
const environment = directEnvironment();
|
|
26167
|
+
const child = spawn(githubCliExecutable(environment), arguments_, {
|
|
26168
|
+
env: environment,
|
|
26169
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
26170
|
+
});
|
|
26171
|
+
let stdout = "";
|
|
26172
|
+
let stderr = "";
|
|
26173
|
+
let timedOut = false;
|
|
26174
|
+
let settled = false;
|
|
26175
|
+
let forceKillTimer;
|
|
26176
|
+
const timeout = setTimeout(() => {
|
|
26177
|
+
timedOut = true;
|
|
26178
|
+
child.kill("SIGTERM");
|
|
26179
|
+
forceKillTimer = setTimeout(() => child.kill("SIGKILL"), GH_TERMINATION_GRACE_MS);
|
|
26180
|
+
}, timeoutMs);
|
|
26181
|
+
const settleExecution = (error, exitCode) => {
|
|
26182
|
+
if (settled)
|
|
26183
|
+
return;
|
|
26184
|
+
settled = true;
|
|
26185
|
+
clearTimeout(timeout);
|
|
26186
|
+
if (forceKillTimer)
|
|
26187
|
+
clearTimeout(forceKillTimer);
|
|
26188
|
+
if (error)
|
|
26189
|
+
reject(error);
|
|
26190
|
+
else
|
|
26191
|
+
resolve2({ exitCode: timedOut ? 124 : exitCode, stdout, stderr });
|
|
26192
|
+
};
|
|
26193
|
+
child.stdout.on("data", (chunk) => {
|
|
26194
|
+
stdout = `${stdout}${chunk.toString()}`.slice(-8000);
|
|
26195
|
+
});
|
|
26196
|
+
child.stderr.on("data", (chunk) => {
|
|
26197
|
+
stderr = `${stderr}${chunk.toString()}`.slice(-8000);
|
|
26198
|
+
});
|
|
26199
|
+
child.once("error", (error) => settleExecution(error, 127));
|
|
26200
|
+
child.once("close", (code) => settleExecution(undefined, code ?? 1));
|
|
26201
|
+
});
|
|
26202
|
+
}
|
|
26203
|
+
function supportsStrictGithubVerification(execution) {
|
|
26204
|
+
const tokens = `${execution.stdout}
|
|
26205
|
+
${execution.stderr}`.split(/\s+/);
|
|
26206
|
+
return execution.exitCode === 0 && STRICT_GITHUB_CAPABILITY_FLAGS.every((flag) => tokens.some((token) => token === flag || token.startsWith(`${flag}=`)));
|
|
26207
|
+
}
|
|
26208
|
+
async function assertLocalGithubVerifier() {
|
|
26209
|
+
const help = await runGithubCli(["attestation", "verify", "--help"], GH_CAPABILITY_TIMEOUT_MS);
|
|
26210
|
+
if (!supportsStrictGithubVerification(help)) {
|
|
26211
|
+
throw new Error("Local artifact transport requires a current gh attestation verifier");
|
|
26212
|
+
}
|
|
26213
|
+
}
|
|
26214
|
+
async function verifyManifestAttestation(manifestPath, bundlePath, manifest) {
|
|
26215
|
+
const verification = await runGithubCli([
|
|
26216
|
+
"attestation",
|
|
26217
|
+
"verify",
|
|
26218
|
+
manifestPath,
|
|
26219
|
+
"--bundle",
|
|
26220
|
+
bundlePath,
|
|
26221
|
+
"--repo",
|
|
26222
|
+
RELEASE_REPOSITORY,
|
|
26223
|
+
"--signer-workflow",
|
|
26224
|
+
RELEASE_SIGNER_WORKFLOW,
|
|
26225
|
+
"--source-ref",
|
|
26226
|
+
RELEASE_SOURCE_REF,
|
|
26227
|
+
"--source-digest",
|
|
26228
|
+
manifest.source.commit,
|
|
26229
|
+
"--deny-self-hosted-runners"
|
|
26230
|
+
], GH_VERIFICATION_TIMEOUT_MS);
|
|
26231
|
+
if (verification.exitCode !== 0) {
|
|
26232
|
+
throw new Error(`gh attestation verify failed: ${verification.stderr.trim().slice(-1000) || verification.exitCode}`);
|
|
26233
|
+
}
|
|
26234
|
+
}
|
|
26235
|
+
function assertSignedArtifact(filePath, manifest) {
|
|
26236
|
+
const artifact = manifestArtifact(manifest, basename(filePath));
|
|
26237
|
+
const fileStats = statSync(filePath);
|
|
26238
|
+
if (!fileStats.isFile() || fileStats.size !== artifact.size) {
|
|
26239
|
+
throw new Error(`${artifact.name} does not match the signed release size`);
|
|
26240
|
+
}
|
|
26241
|
+
assertPrivateLocalFileMode(fileStats.mode, artifact.name);
|
|
26242
|
+
const digest = sha256File(filePath);
|
|
26243
|
+
if (digest !== artifact.sha256) {
|
|
26244
|
+
throw new Error(`${artifact.name} does not match the signed release hashes`);
|
|
26245
|
+
}
|
|
26246
|
+
return digest;
|
|
26247
|
+
}
|
|
26248
|
+
function verifyDownloadedFile(filePath, manifest, checksums) {
|
|
26249
|
+
const digest = assertSignedArtifact(filePath, manifest);
|
|
26250
|
+
if (checksums.get(basename(filePath)) !== digest) {
|
|
26251
|
+
throw new Error(`${basename(filePath)} does not match SHA256SUMS`);
|
|
26252
|
+
}
|
|
26253
|
+
}
|
|
26254
|
+
async function downloadReleaseMetadata(component, version, directory) {
|
|
26255
|
+
const expectedTag = releaseTag(component, version);
|
|
26256
|
+
const metadataPath = join(directory, `.release-${randomUUID2()}.json`);
|
|
26257
|
+
try {
|
|
26258
|
+
await downloadDirect(`${RELEASES_API}/tags/${expectedTag}`, metadataPath, RELEASE_BUNDLE_SIZE_LIMITS.manifest);
|
|
26259
|
+
return parseGithubReleaseMetadata(parseJsonFile(metadataPath, "GitHub release metadata"), expectedTag);
|
|
26260
|
+
} finally {
|
|
26261
|
+
rmSync(metadataPath, { force: true });
|
|
26262
|
+
}
|
|
26263
|
+
}
|
|
26264
|
+
async function downloadManifestAttestation(manifestPath, destination) {
|
|
26265
|
+
const responsePath = `${destination}.response`;
|
|
26266
|
+
try {
|
|
26267
|
+
await downloadDirect(`${ATTESTATIONS_API}/sha256:${sha256File(manifestPath)}`, responsePath, RELEASE_BUNDLE_SIZE_LIMITS.attestation);
|
|
26268
|
+
const bundles = serializeAttestationBundles(parseJsonFile(responsePath, "GitHub attestation response"));
|
|
26269
|
+
writeFileSync(destination, bundles, { mode: 384, flag: "wx" });
|
|
26270
|
+
chmodSync(destination, 384);
|
|
26271
|
+
} finally {
|
|
26272
|
+
rmSync(responsePath, { force: true });
|
|
26273
|
+
}
|
|
26274
|
+
}
|
|
26275
|
+
async function downloadComponent(request) {
|
|
26276
|
+
const release = await downloadReleaseMetadata(request.component, request.version, request.destination);
|
|
26277
|
+
const manifestPath = directChildPath(request.destination, RELEASE_MANIFEST_NAME);
|
|
26278
|
+
const attestationPath = directChildPath(request.destination, RELEASE_ATTESTATION_NAME);
|
|
26279
|
+
await downloadDirect(releaseAssetUrl(release, RELEASE_MANIFEST_NAME), manifestPath, RELEASE_BUNDLE_SIZE_LIMITS.manifest);
|
|
26280
|
+
const manifest = parseReleaseManifest(readFileSync3(manifestPath, "utf8"), request);
|
|
26281
|
+
await downloadManifestAttestation(manifestPath, attestationPath);
|
|
26282
|
+
await verifyManifestAttestation(manifestPath, attestationPath, manifest);
|
|
26283
|
+
const checksumsPath = directChildPath(request.destination, RELEASE_CHECKSUMS_NAME);
|
|
26284
|
+
await downloadDirect(releaseAssetUrl(release, RELEASE_CHECKSUMS_NAME), checksumsPath, RELEASE_BUNDLE_SIZE_LIMITS.checksums);
|
|
26285
|
+
assertSignedArtifact(checksumsPath, manifest);
|
|
26286
|
+
const checksums = parseReleaseChecksums(readFileSync3(checksumsPath, "utf8"), manifest);
|
|
26287
|
+
for (const assetName of request.assetNames) {
|
|
26288
|
+
const assetPath = directChildPath(request.destination, assetName);
|
|
26289
|
+
await downloadDirect(releaseAssetUrl(release, assetName), assetPath, releaseAssetSizeLimit(request.component, assetName));
|
|
26290
|
+
verifyDownloadedFile(assetPath, manifest, checksums);
|
|
26291
|
+
}
|
|
26292
|
+
return [RELEASE_MANIFEST_NAME, RELEASE_ATTESTATION_NAME, RELEASE_CHECKSUMS_NAME, ...request.assetNames].map((name) => localUpgradeFile(directChildPath(request.destination, name), `bundle/${request.component}/${name}`));
|
|
26293
|
+
}
|
|
26294
|
+
async function downloadPinnedGithubCli(directory, architecture) {
|
|
26295
|
+
const identity = githubCliArchiveIdentity(architecture);
|
|
26296
|
+
const archivePath = directChildPath(directory, identity.archiveName);
|
|
26297
|
+
const url = `https://github.com/cli/cli/releases/download/v${identity.version}/${identity.archiveName}`;
|
|
26298
|
+
await downloadDirect(url, archivePath, MAX_GH_ARCHIVE_BYTES);
|
|
26299
|
+
if (sha256File(archivePath) !== identity.sha256) {
|
|
26300
|
+
throw new Error("Pinned GitHub CLI archive SHA256 mismatch");
|
|
26301
|
+
}
|
|
26302
|
+
return localUpgradeFile(archivePath, `verifier/${identity.archiveName}`);
|
|
26303
|
+
}
|
|
26304
|
+
function cleanupLocalUpgradeBundle(bundle) {
|
|
26305
|
+
rmSync(bundle.directory, { recursive: true, force: true });
|
|
26306
|
+
}
|
|
26307
|
+
function assertLocalUpgradeBundleSize(files) {
|
|
26308
|
+
const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
26309
|
+
if (!Number.isSafeInteger(totalBytes) || totalBytes > RELEASE_BUNDLE_SIZE_LIMITS.total) {
|
|
26310
|
+
throw new Error("Local upgrade bundle exceeds its total size limit");
|
|
26311
|
+
}
|
|
26312
|
+
}
|
|
26313
|
+
async function observeDownloadFailure(download, failures) {
|
|
26314
|
+
try {
|
|
26315
|
+
return await download;
|
|
26316
|
+
} catch (error) {
|
|
26317
|
+
failures.push(error);
|
|
26318
|
+
throw error;
|
|
26319
|
+
}
|
|
26320
|
+
}
|
|
26321
|
+
function fulfilledDownload(settlement) {
|
|
26322
|
+
if (settlement.status === "rejected")
|
|
26323
|
+
throw settlement.reason;
|
|
26324
|
+
return settlement.value;
|
|
26325
|
+
}
|
|
26326
|
+
async function settleLocalBundleDownloads(downloads) {
|
|
26327
|
+
const failures = [];
|
|
26328
|
+
const settlements = await Promise.allSettled([
|
|
26329
|
+
observeDownloadFailure(downloads[0], failures),
|
|
26330
|
+
observeDownloadFailure(downloads[1], failures),
|
|
26331
|
+
observeDownloadFailure(downloads[2], failures)
|
|
26332
|
+
]);
|
|
26333
|
+
if (failures.length > 0)
|
|
26334
|
+
throw failures[0];
|
|
26335
|
+
return [
|
|
26336
|
+
fulfilledDownload(settlements[0]),
|
|
26337
|
+
fulfilledDownload(settlements[1]),
|
|
26338
|
+
fulfilledDownload(settlements[2])
|
|
26339
|
+
];
|
|
26340
|
+
}
|
|
26341
|
+
function createLocalBundleLayout(request) {
|
|
26342
|
+
const directory = mkdtempSync(join(tmpdir(), "supacloud-admin-upgrade-"));
|
|
26343
|
+
chmodSync(directory, 448);
|
|
26344
|
+
const bundleDirectory = join(directory, "bundle");
|
|
26345
|
+
const managementDirectory = join(bundleDirectory, "management-api");
|
|
26346
|
+
const edgeDirectory = join(bundleDirectory, "edge-runtime");
|
|
26347
|
+
const verifierDirectory = join(directory, "verifier");
|
|
26348
|
+
for (const createdDirectory of [bundleDirectory, managementDirectory, edgeDirectory, verifierDirectory]) {
|
|
26349
|
+
mkdirSync(createdDirectory, { recursive: true, mode: 448 });
|
|
26350
|
+
chmodSync(createdDirectory, 448);
|
|
26351
|
+
}
|
|
26352
|
+
return {
|
|
26353
|
+
directory,
|
|
26354
|
+
managementDirectory,
|
|
26355
|
+
edgeDirectory,
|
|
26356
|
+
verifierDirectory,
|
|
26357
|
+
managementBinaryName: `supacloud-linux-${request.architecture}`,
|
|
26358
|
+
edgeRuntimeBinaryName: `supacloud-edge-runtime-linux-${request.architecture}`
|
|
26359
|
+
};
|
|
26360
|
+
}
|
|
26361
|
+
async function downloadLocalBundle(request, layout) {
|
|
26362
|
+
const { managementBinaryName, edgeRuntimeBinaryName } = layout;
|
|
26363
|
+
const [managementFiles, edgeFiles, verifierArchive] = await settleLocalBundleDownloads([
|
|
26364
|
+
downloadComponent({
|
|
26365
|
+
component: "management-api",
|
|
26366
|
+
version: request.managementVersion,
|
|
26367
|
+
assetNames: [managementBinaryName, "web-console-build.tar.gz"],
|
|
26368
|
+
destination: layout.managementDirectory
|
|
26369
|
+
}),
|
|
26370
|
+
downloadComponent({
|
|
26371
|
+
component: "edge-runtime",
|
|
26372
|
+
version: request.edgeRuntimeVersion,
|
|
26373
|
+
assetNames: [edgeRuntimeBinaryName],
|
|
26374
|
+
destination: layout.edgeDirectory
|
|
26375
|
+
}),
|
|
26376
|
+
request.verifierProvisioning === "bundled" ? downloadPinnedGithubCli(layout.verifierDirectory, request.architecture) : Promise.resolve(null)
|
|
26377
|
+
]);
|
|
26378
|
+
const files = [...managementFiles, ...edgeFiles];
|
|
26379
|
+
assertLocalUpgradeBundleSize([...files, ...verifierArchive ? [verifierArchive] : []]);
|
|
26380
|
+
return { directory: layout.directory, files, verifierArchive, managementBinaryName, edgeRuntimeBinaryName };
|
|
26381
|
+
}
|
|
26382
|
+
async function prepareLocalUpgradeBundle(request) {
|
|
26383
|
+
assertExactStableVersion(request.managementVersion, "version");
|
|
26384
|
+
assertExactStableVersion(request.edgeRuntimeVersion, "edge_runtime_version");
|
|
26385
|
+
await assertLocalGithubVerifier();
|
|
26386
|
+
const layout = createLocalBundleLayout(request);
|
|
26387
|
+
try {
|
|
26388
|
+
return await downloadLocalBundle(request, layout);
|
|
26389
|
+
} catch (error) {
|
|
26390
|
+
rmSync(layout.directory, { recursive: true, force: true });
|
|
26391
|
+
throw error;
|
|
26392
|
+
}
|
|
26393
|
+
}
|
|
26394
|
+
|
|
26395
|
+
// src/shared/releases/local-upgrade-transfer.ts
|
|
26396
|
+
var REMOTE_STAGE_ROOT = "/var/lib/supacloud/upgrade-staging";
|
|
26397
|
+
var REMOTE_RUN_ROOT = "/var/lib/supacloud/upgrade-runs";
|
|
26398
|
+
var REMOTE_LOG_ROOT = "/var/log/supacloud";
|
|
26399
|
+
var POLL_INTERVAL_MS = 2000;
|
|
26400
|
+
var STATE_READ_ATTEMPTS = 3;
|
|
26401
|
+
var REMOTE_STATE_READ_TIMEOUT_MS = 15000;
|
|
26402
|
+
var UPGRADE_OBSERVATION_TIMEOUT_MS = 30 * 60000;
|
|
26403
|
+
|
|
26404
|
+
class RemoteUpgradeReconciliationError extends AggregateError {
|
|
26405
|
+
}
|
|
26406
|
+
function quoteShell2(shellText) {
|
|
26407
|
+
return `'${shellText.split("'").join("'\\''")}'`;
|
|
26408
|
+
}
|
|
26409
|
+
function rootCommand(script) {
|
|
26410
|
+
return [
|
|
26411
|
+
'if [ "$(id -u)" -eq 0 ]; then',
|
|
26412
|
+
` /bin/bash -c ${quoteShell2(script)}`,
|
|
26413
|
+
"else",
|
|
26414
|
+
" sudo -n true",
|
|
26415
|
+
` sudo -n /bin/bash -c ${quoteShell2(script)}`,
|
|
26416
|
+
"fi"
|
|
26417
|
+
].join(`
|
|
26418
|
+
`);
|
|
26419
|
+
}
|
|
26420
|
+
function trustedInstalledGithubFunction() {
|
|
26421
|
+
return [
|
|
26422
|
+
"trusted_installed_gh() {",
|
|
26423
|
+
" local verifier=$1 mode",
|
|
26424
|
+
` test -f "$verifier" && test ! -L "$verifier" && test -x "$verifier" || { echo 'Installed GitHub verifier is not a regular executable file' >&2; return 1; }`,
|
|
26425
|
+
` test "$(stat -c '%u:%g' "$verifier")" = '0:0' || { echo 'Installed GitHub verifier is not owned by root:root' >&2; return 1; }`,
|
|
26426
|
+
` mode=$(stat -c '%a' "$verifier")`,
|
|
26427
|
+
` case "$mode" in [0-7]|[0-7][0-7]|[0-7][0-7][0-7]) ;; *) echo 'Installed GitHub verifier has special permission bits' >&2; return 1 ;; esac`,
|
|
26428
|
+
" (( (8#$mode & 0022) == 0 )) || { echo 'Installed GitHub verifier is group/other writable' >&2; return 1; }",
|
|
26429
|
+
"}"
|
|
26430
|
+
].join(`
|
|
26431
|
+
`);
|
|
26432
|
+
}
|
|
26433
|
+
function remotePaths(runId) {
|
|
26434
|
+
return {
|
|
26435
|
+
drop: `/tmp/.supacloud-upgrade-upload-${runId}`,
|
|
26436
|
+
stage: `${REMOTE_STAGE_ROOT}/${runId}`,
|
|
26437
|
+
status: `${REMOTE_RUN_ROOT}/${runId}.status`,
|
|
26438
|
+
log: `${REMOTE_LOG_ROOT}/upgrade-${runId}.log`,
|
|
26439
|
+
unit: `supacloud-upgrade-${runId}.service`
|
|
26440
|
+
};
|
|
26441
|
+
}
|
|
26442
|
+
function parseRemotePreflight(output) {
|
|
26443
|
+
const architecture = output.match(/^ARCH=(amd64|arm64)$/m)?.[1];
|
|
26444
|
+
const verifierProvisioning = output.match(/^VERIFIER=(installed|bundled)$/m)?.[1];
|
|
26445
|
+
if (!architecture || !verifierProvisioning) {
|
|
26446
|
+
throw new Error("Remote upgrade preflight did not return its architecture and verifier capability");
|
|
26447
|
+
}
|
|
26448
|
+
return {
|
|
26449
|
+
architecture,
|
|
26450
|
+
verifierProvisioning
|
|
26451
|
+
};
|
|
26452
|
+
}
|
|
26453
|
+
function buildRemotePreflightScript() {
|
|
26454
|
+
const capabilityFlags = STRICT_GITHUB_CAPABILITY_FLAGS.join(" ");
|
|
26455
|
+
return [
|
|
26456
|
+
"set -euo pipefail",
|
|
26457
|
+
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
26458
|
+
"export PATH",
|
|
26459
|
+
trustedInstalledGithubFunction(),
|
|
26460
|
+
"test -d /run/systemd/system || { echo 'systemd is not the active init system' >&2; exit 1; }",
|
|
26461
|
+
'for tool in systemctl systemd-run sha256sum stat realpath tar file find sort awk grep timeout flock; do command -v "$tool" >/dev/null 2>&1 || { echo "Required local-upgrade tool is missing: $tool" >&2; exit 127; }; done',
|
|
26462
|
+
"systemd-run --help | grep -Eq -- '(^|[[:space:]])--collect([=[:space:]]|$)' || { echo 'systemd-run --collect is required' >&2; exit 1; }",
|
|
26463
|
+
"test -d /run/lock || { echo '/run/lock is unavailable' >&2; exit 1; }",
|
|
26464
|
+
"test -d /var/lib/supacloud || { echo '/var/lib/supacloud is unavailable' >&2; exit 1; }",
|
|
26465
|
+
"test -f /etc/supabase/management-api.env || { echo 'EDGE_RUNTIME_MODE is unavailable' >&2; exit 1; }",
|
|
26466
|
+
`EDGE_MODE=$(awk -F= '$1 == "EDGE_RUNTIME_MODE" { value=$2 } END { gsub(/^[[:space:]\\"'"']+|[[:space:]\\"'"']+$/, "", value); print value }' /etc/supabase/management-api.env)`,
|
|
26467
|
+
`test "$EDGE_MODE" = external || { echo 'Local component upgrade requires persisted external Edge Runtime mode' >&2; exit 1; }`,
|
|
26468
|
+
`case "$(uname -m)" in x86_64|amd64) echo ARCH=amd64 ;; aarch64|arm64) echo ARCH=arm64 ;; *) echo 'Unsupported remote architecture' >&2; exit 1 ;; esac`,
|
|
26469
|
+
"VERIFIER=bundled",
|
|
26470
|
+
"GH=$(type -P gh || true)",
|
|
26471
|
+
'if [ -n "$GH" ] && trusted_installed_gh "$GH"; then',
|
|
26472
|
+
' if GH_HELP=$(timeout 15s "$GH" attestation verify --help 2>&1); then',
|
|
26473
|
+
" GH_CAPABLE=true",
|
|
26474
|
+
` for flag in ${capabilityFlags}; do printf '%s\\n' "$GH_HELP" | grep -Eq -- "(^|[[:space:]])${"$"}{flag}([=[:space:]]|$)" || GH_CAPABLE=false; done`,
|
|
26475
|
+
' if [ "$GH_CAPABLE" = true ]; then VERIFIER=installed; fi',
|
|
26476
|
+
" fi",
|
|
26477
|
+
"fi",
|
|
26478
|
+
"echo VERIFIER=$VERIFIER"
|
|
26479
|
+
].join(`
|
|
26480
|
+
`);
|
|
26481
|
+
}
|
|
26482
|
+
async function remoteUpgradePreflight(ssh) {
|
|
26483
|
+
const preflight = await ssh.exec(rootCommand(buildRemotePreflightScript()), 30000);
|
|
26484
|
+
if (!preflight.success)
|
|
26485
|
+
throw remoteFailure("Remote local-upgrade preflight failed", preflight);
|
|
26486
|
+
return parseRemotePreflight(preflight.stdout);
|
|
26487
|
+
}
|
|
26488
|
+
function requiredRemoteBytes(bundle) {
|
|
26489
|
+
const transferBytes = [...bundle.files, ...bundle.verifierArchive ? [bundle.verifierArchive] : []].reduce((total, upload) => total + upload.size, 0);
|
|
26490
|
+
return transferBytes * 3 + 512 * 1024 * 1024;
|
|
26491
|
+
}
|
|
26492
|
+
function prepareDropCommand(paths, bundle) {
|
|
26493
|
+
const directories = [
|
|
26494
|
+
paths.drop,
|
|
26495
|
+
`${paths.drop}/bundle`,
|
|
26496
|
+
`${paths.drop}/bundle/management-api`,
|
|
26497
|
+
`${paths.drop}/bundle/edge-runtime`
|
|
26498
|
+
];
|
|
26499
|
+
if (bundle.verifierArchive)
|
|
26500
|
+
directories.push(`${paths.drop}/verifier`);
|
|
26501
|
+
const requiredBytes = requiredRemoteBytes(bundle);
|
|
26502
|
+
return [
|
|
26503
|
+
"set -euo pipefail",
|
|
26504
|
+
"umask 077",
|
|
26505
|
+
`test ! -e ${quoteShell2(paths.drop)} || { echo 'Remote upload drop already exists' >&2; exit 1; }`,
|
|
26506
|
+
"TMP_AVAILABLE_KB=$(df -Pk /tmp | awk 'NR == 2 { print $4 }')",
|
|
26507
|
+
"VAR_AVAILABLE_KB=$(df -Pk /var/lib/supacloud | awk 'NR == 2 { print $4 }')",
|
|
26508
|
+
`test "${"$"}TMP_AVAILABLE_KB" -ge ${Math.ceil(requiredBytes / 1024)} && test "${"$"}VAR_AVAILABLE_KB" -ge ${Math.ceil(requiredBytes / 1024)} || { echo 'Insufficient remote disk space for verified upgrade staging' >&2; exit 1; }`,
|
|
26509
|
+
`install -d -m 700 ${directories.map(quoteShell2).join(" ")}`
|
|
26510
|
+
].join(`
|
|
26511
|
+
`);
|
|
26512
|
+
}
|
|
26513
|
+
async function prepareRemoteDrop(ssh, paths, bundle) {
|
|
26514
|
+
const prepared = await ssh.exec(prepareDropCommand(paths, bundle), 30000);
|
|
26515
|
+
if (!prepared.success)
|
|
26516
|
+
throw remoteFailure("Unable to prepare remote upload drop", prepared);
|
|
26517
|
+
}
|
|
26518
|
+
async function uploadBundleFiles(ssh, paths, bundle) {
|
|
26519
|
+
const uploads = [...bundle.files, ...bundle.verifierArchive ? [bundle.verifierArchive] : []];
|
|
26520
|
+
for (const upload of uploads) {
|
|
26521
|
+
await ssh.upload(upload.localPath, `${paths.drop}/${upload.relativePath}`, { mode: 384, timeoutMs: 10 * 60000 });
|
|
26522
|
+
}
|
|
26523
|
+
}
|
|
26524
|
+
function expectedComponentFiles(bundle) {
|
|
26525
|
+
return {
|
|
26526
|
+
management: [
|
|
26527
|
+
"SHA256SUMS",
|
|
26528
|
+
"SUPACLOUD-RELEASE.attestation.jsonl",
|
|
26529
|
+
"SUPACLOUD-RELEASE.json",
|
|
26530
|
+
bundle.managementBinaryName,
|
|
26531
|
+
"web-console-build.tar.gz"
|
|
26532
|
+
],
|
|
26533
|
+
edge: [
|
|
26534
|
+
"SHA256SUMS",
|
|
26535
|
+
"SUPACLOUD-RELEASE.attestation.jsonl",
|
|
26536
|
+
"SUPACLOUD-RELEASE.json",
|
|
26537
|
+
bundle.edgeRuntimeBinaryName
|
|
26538
|
+
]
|
|
26539
|
+
};
|
|
26540
|
+
}
|
|
26541
|
+
function shellArray(name, entries) {
|
|
26542
|
+
return `${name}=(${entries.map(quoteShell2).join(" ")})`;
|
|
26543
|
+
}
|
|
26544
|
+
function finishUpgradeFunction() {
|
|
26545
|
+
return [
|
|
26546
|
+
"finish_upgrade() {",
|
|
26547
|
+
" local code=$?",
|
|
26548
|
+
" trap '' HUP INT TERM",
|
|
26549
|
+
" trap - EXIT",
|
|
26550
|
+
" set +e",
|
|
26551
|
+
' if [ "$code" -ne 0 ]; then',
|
|
26552
|
+
' write_status "FAILED:${code}:TRANSACTION" || true',
|
|
26553
|
+
' if ! rm -rf -- "$STAGE"; then write_status "FAILED:${code}:TRANSACTION_AND_CLEANUP" || true; fi',
|
|
26554
|
+
' exit "$code"',
|
|
26555
|
+
" fi",
|
|
26556
|
+
" write_status CLEANING || { echo 'Unable to publish cleanup state' >&2; exit 1; }",
|
|
26557
|
+
' if ! rm -rf -- "$STAGE"; then',
|
|
26558
|
+
" write_status 'FAILED:1:CLEANUP_AFTER_TRANSACTION' || true",
|
|
26559
|
+
" exit 1",
|
|
26560
|
+
" fi",
|
|
26561
|
+
" write_status SUCCEEDED || exit 1",
|
|
26562
|
+
" exit 0",
|
|
26563
|
+
"}"
|
|
26564
|
+
].join(`
|
|
26565
|
+
`);
|
|
26566
|
+
}
|
|
26567
|
+
function upgradeScriptSetup(paths) {
|
|
26568
|
+
const bundleDirectory = `${paths.stage}/bundle`;
|
|
26569
|
+
return [
|
|
26570
|
+
"#!/usr/bin/env bash",
|
|
26571
|
+
"set -euo pipefail",
|
|
26572
|
+
"umask 077",
|
|
26573
|
+
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
26574
|
+
"export PATH",
|
|
26575
|
+
`STAGE=${quoteShell2(paths.stage)}`,
|
|
26576
|
+
`STATUS=${quoteShell2(paths.status)}`,
|
|
26577
|
+
`LOG=${quoteShell2(paths.log)}`,
|
|
26578
|
+
`BUNDLE=${quoteShell2(bundleDirectory)}`,
|
|
26579
|
+
`MANAGEMENT_DIR=${quoteShell2(`${bundleDirectory}/management-api`)}`,
|
|
26580
|
+
`EDGE_DIR=${quoteShell2(`${bundleDirectory}/edge-runtime`)}`,
|
|
26581
|
+
`write_status() { local next="\${STATUS}.next"; printf '%s\\n' "$1" > "$next"; chmod 600 "$next"; mv -f "$next" "$STATUS"; }`,
|
|
26582
|
+
finishUpgradeFunction(),
|
|
26583
|
+
"trap finish_upgrade EXIT",
|
|
26584
|
+
"trap 'exit 129' HUP",
|
|
26585
|
+
"trap 'exit 130' INT",
|
|
26586
|
+
"trap 'exit 143' TERM",
|
|
26587
|
+
'exec >>"$LOG" 2>&1',
|
|
26588
|
+
"write_status RUNNING",
|
|
26589
|
+
buildUpgradeLockScript(SUPACLOUD_UPGRADE_LOCK_PATH),
|
|
26590
|
+
`while IFS='=' read -r variable _; do case "$variable" in *PROXY*|*Proxy*|*proxy*) unset "$variable" ;; esac; done < <(env)`,
|
|
26591
|
+
"unset SUPACLOUD_ALLOW_UNVERIFIED_RELEASE SUPACLOUD_GITHUB_REPOSITORY SUPACLOUD_RELEASES_API SUPACLOUD_ATTESTATION_SIGNER_WORKFLOW NODE_USE_ENV_PROXY"
|
|
26592
|
+
];
|
|
26593
|
+
}
|
|
26594
|
+
function upgradeScriptFilesystemChecks(bundle) {
|
|
26595
|
+
const componentFiles = expectedComponentFiles(bundle);
|
|
26596
|
+
const stageEntries = ["bundle", "run.sh", ...bundle.verifierArchive ? ["verifier"] : []];
|
|
26597
|
+
const checks = [
|
|
26598
|
+
shellArray("MANAGEMENT_FILES", componentFiles.management),
|
|
26599
|
+
shellArray("EDGE_FILES", componentFiles.edge),
|
|
26600
|
+
`assert_directory() { local path=$1; test -d "$path" && test ! -L "$path"; test "$(stat -c '%u:%g' "$path")" = '0:0'; test "$(stat -c '%a' "$path")" = '700'; }`,
|
|
26601
|
+
`assert_file() { local path=$1 parent=$2; test -f "$path" && test ! -L "$path"; test "$(stat -c '%u:%g:%h' "$path")" = '0:0:1'; test "$(stat -c '%a' "$path")" = '600'; test "$(dirname "$(realpath "$path")")" = "$parent"; }`,
|
|
26602
|
+
`assert_entries() { local directory=$1; shift; local actual expected; actual=$(find "$directory" -mindepth 1 -maxdepth 1 -printf '%f\\n' | LC_ALL=C sort); expected=$(printf '%s\\n' "$@" | LC_ALL=C sort); test "$actual" = "$expected"; }`,
|
|
26603
|
+
'assert_directory "$STAGE"',
|
|
26604
|
+
shellArray("STAGE_FILES", stageEntries),
|
|
26605
|
+
'assert_entries "$STAGE" "${STAGE_FILES[@]}"',
|
|
26606
|
+
'assert_file "$STAGE/run.sh" "$STAGE"',
|
|
26607
|
+
'assert_directory "$BUNDLE"',
|
|
26608
|
+
'assert_entries "$BUNDLE" edge-runtime management-api',
|
|
26609
|
+
'assert_directory "$MANAGEMENT_DIR"',
|
|
26610
|
+
'assert_directory "$EDGE_DIR"',
|
|
26611
|
+
'assert_entries "$MANAGEMENT_DIR" "${MANAGEMENT_FILES[@]}"',
|
|
26612
|
+
'assert_entries "$EDGE_DIR" "${EDGE_FILES[@]}"',
|
|
26613
|
+
'for name in "${MANAGEMENT_FILES[@]}"; do assert_file "$MANAGEMENT_DIR/$name" "$MANAGEMENT_DIR"; done',
|
|
26614
|
+
'for name in "${EDGE_FILES[@]}"; do assert_file "$EDGE_DIR/$name" "$EDGE_DIR"; done'
|
|
26615
|
+
];
|
|
26616
|
+
if (bundle.verifierArchive) {
|
|
26617
|
+
const archiveName = basename2(bundle.verifierArchive.relativePath);
|
|
26618
|
+
checks.push('assert_directory "$STAGE/verifier"', `assert_entries "$STAGE/verifier" ${quoteShell2(archiveName)}`, `assert_file "$STAGE/verifier/${archiveName}" "$STAGE/verifier"`);
|
|
26619
|
+
}
|
|
26620
|
+
return checks;
|
|
26621
|
+
}
|
|
26622
|
+
function upgradeScriptTransferVerification(bundle) {
|
|
26623
|
+
const files = [...bundle.files, ...bundle.verifierArchive ? [bundle.verifierArchive] : []];
|
|
26624
|
+
return [
|
|
26625
|
+
`verify_transfer() { local relative=$1 expected_size=$2 expected_sha=$3; local path=$STAGE/$relative; test "$(stat -c '%s' "$path")" = "$expected_size"; test "$(sha256sum "$path" | awk '{print $1}')" = "$expected_sha"; }`,
|
|
26626
|
+
...files.map((file) => `verify_transfer ${quoteShell2(file.relativePath)} ${file.size} ${quoteShell2(file.sha256)}`)
|
|
26627
|
+
];
|
|
26628
|
+
}
|
|
26629
|
+
function upgradeScriptVerifier(paths, bundle, architecture) {
|
|
26630
|
+
const setup = bundle.verifierArchive ? bundledVerifierSetup(paths, architecture) : [
|
|
26631
|
+
trustedInstalledGithubFunction(),
|
|
26632
|
+
"GH=$(type -P gh)",
|
|
26633
|
+
`trusted_installed_gh "$GH" || { echo 'Installed GitHub verifier trust check failed' >&2; exit 1; }`
|
|
26634
|
+
];
|
|
26635
|
+
const capabilityFlags = STRICT_GITHUB_CAPABILITY_FLAGS.join(" ");
|
|
26636
|
+
return [
|
|
26637
|
+
...setup,
|
|
26638
|
+
`GH_HELP=$(timeout 15s "$GH" attestation verify --help 2>&1) || { echo 'GitHub verifier capability check failed' >&2; exit 1; }`,
|
|
26639
|
+
`for flag in ${capabilityFlags}; do printf '%s\\n' "$GH_HELP" | grep -Eq -- "(^|[[:space:]])${"$"}{flag}([=[:space:]]|$)" || { echo "GitHub verifier lacks $flag" >&2; exit 1; }; done`,
|
|
26640
|
+
'VERIFIER_PATH=$(dirname "$GH")'
|
|
26641
|
+
];
|
|
26642
|
+
}
|
|
26643
|
+
function bundledVerifierSetup(paths, architecture) {
|
|
26644
|
+
const verifier = githubCliArchiveIdentity(architecture);
|
|
26645
|
+
return [
|
|
26646
|
+
`GH_ARCHIVE=${quoteShell2(`${paths.stage}/verifier/${verifier.archiveName}`)}`,
|
|
26647
|
+
`GH_MEMBER=${quoteShell2(verifier.member)}`,
|
|
26648
|
+
'test "$(tar -tzf "$GH_ARCHIVE" | grep -Fxc "$GH_MEMBER")" = 1',
|
|
26649
|
+
`test "$(tar -tvzf "$GH_ARCHIVE" "$GH_MEMBER" | cut -c1)" = '-'`,
|
|
26650
|
+
'VERIFIER_ROOT=$(mktemp -d "${STAGE}/verifier/gh.XXXXXX")',
|
|
26651
|
+
'tar --no-same-owner --same-permissions -xzf "$GH_ARCHIVE" -C "$VERIFIER_ROOT" "$GH_MEMBER"',
|
|
26652
|
+
`test "$(stat -c '%a' "$VERIFIER_ROOT/$GH_MEMBER")" = '755'`,
|
|
26653
|
+
'install -m 0755 "$VERIFIER_ROOT/$GH_MEMBER" "${STAGE}/verifier/gh"',
|
|
26654
|
+
'rm -rf -- "$VERIFIER_ROOT"',
|
|
26655
|
+
"GH=${STAGE}/verifier/gh",
|
|
26656
|
+
`case "$("$GH" --version | head -1)" in 'gh version ${verifier.version}'*) ;; *) echo 'Pinned GitHub verifier version mismatch' >&2; exit 1 ;; esac`
|
|
26657
|
+
];
|
|
26658
|
+
}
|
|
26659
|
+
function upgradeScriptExecution(paths, bundle, request) {
|
|
26660
|
+
const runnerAsset = `${paths.stage}/bundle/management-api/${bundle.managementBinaryName}`;
|
|
26661
|
+
const runner = `${paths.stage}/runner`;
|
|
26662
|
+
return [
|
|
26663
|
+
`MANAGEMENT_VERSION=${quoteShell2(request.managementVersion)}`,
|
|
26664
|
+
`RUNNER_ASSET=${quoteShell2(runnerAsset)}`,
|
|
26665
|
+
`RUNNER=${quoteShell2(runner)}`,
|
|
26666
|
+
'file -b "$RUNNER_ASSET" | grep -q ELF',
|
|
26667
|
+
'install -m 0755 "$RUNNER_ASSET" "$RUNNER"',
|
|
26668
|
+
`test "$(sha256sum "$RUNNER"|awk '{print $1}')" = "$(sha256sum "$RUNNER_ASSET"|awk '{print $1}')"`,
|
|
26669
|
+
'"$RUNNER" --version | grep -Eq "(^|[^0-9])${MANAGEMENT_VERSION//./\\.}([^0-9]|$)"',
|
|
26670
|
+
`env PATH="$VERIFIER_PATH:$PATH" "$RUNNER" upgrade --yes --target-version ${quoteShell2(request.managementVersion)} --edge-runtime-version ${quoteShell2(request.edgeRuntimeVersion)} --asset-bundle-dir "$BUNDLE"`
|
|
26671
|
+
];
|
|
26672
|
+
}
|
|
26673
|
+
function buildLocalUpgradeRunScript(paths, bundle, request, architecture) {
|
|
26674
|
+
return [
|
|
26675
|
+
...upgradeScriptSetup(paths),
|
|
26676
|
+
...upgradeScriptFilesystemChecks(bundle),
|
|
26677
|
+
...upgradeScriptTransferVerification(bundle),
|
|
26678
|
+
...upgradeScriptVerifier(paths, bundle, architecture),
|
|
26679
|
+
...upgradeScriptExecution(paths, bundle, request)
|
|
26680
|
+
].join(`
|
|
26681
|
+
`);
|
|
26682
|
+
}
|
|
26683
|
+
async function uploadRunScript(ssh, paths, bundle, request, architecture) {
|
|
26684
|
+
await ssh.uploadText(`${paths.drop}/run.sh`, buildLocalUpgradeRunScript(paths, bundle, request, architecture), 384);
|
|
26685
|
+
}
|
|
26686
|
+
function failedAdoptionRollbackFunction() {
|
|
26687
|
+
return [
|
|
26688
|
+
"rollback_failed_adoption() {",
|
|
26689
|
+
" local code=$? cleanup_failed=false",
|
|
26690
|
+
" trap '' HUP INT TERM",
|
|
26691
|
+
" trap - EXIT",
|
|
26692
|
+
' if [ "$ADOPTION_ACTIVE" != true ]; then exit "$code"; fi',
|
|
26693
|
+
" set +e",
|
|
26694
|
+
' rm -rf -- "$STAGE" || cleanup_failed=true',
|
|
26695
|
+
' rm -f -- "$STATUS" "${STATUS}.next" "$LOG" || cleanup_failed=true',
|
|
26696
|
+
' if [ "$cleanup_failed" = true ]; then echo "Upgrade adoption failed (exit $code) and rollback did not complete" >&2; exit 1; fi',
|
|
26697
|
+
' echo "Upgrade adoption failed (exit $code); transferred state was rolled back" >&2',
|
|
26698
|
+
' exit "$code"',
|
|
26699
|
+
"}"
|
|
26700
|
+
].join(`
|
|
26701
|
+
`);
|
|
26702
|
+
}
|
|
26703
|
+
function buildAdoptDropScript(paths) {
|
|
26704
|
+
return [
|
|
26705
|
+
"set -euo pipefail",
|
|
26706
|
+
"umask 077",
|
|
26707
|
+
`DROP=${quoteShell2(paths.drop)}`,
|
|
26708
|
+
`STAGE=${quoteShell2(paths.stage)}`,
|
|
26709
|
+
`STATUS=${quoteShell2(paths.status)}`,
|
|
26710
|
+
`LOG=${quoteShell2(paths.log)}`,
|
|
26711
|
+
`UNIT=${quoteShell2(paths.unit)}`,
|
|
26712
|
+
"ADOPTION_ACTIVE=false",
|
|
26713
|
+
failedAdoptionRollbackFunction(),
|
|
26714
|
+
"trap rollback_failed_adoption EXIT",
|
|
26715
|
+
"trap 'exit 129' HUP",
|
|
26716
|
+
"trap 'exit 130' INT",
|
|
26717
|
+
"trap 'exit 143' TERM",
|
|
26718
|
+
`install -d -o root -g root -m 700 ${quoteShell2(REMOTE_STAGE_ROOT)} ${quoteShell2(REMOTE_RUN_ROOT)} ${quoteShell2(REMOTE_LOG_ROOT)}`,
|
|
26719
|
+
'test ! -e "$STAGE" && test ! -e "$STATUS" && test ! -e "$LOG"',
|
|
26720
|
+
`systemctl status "$UNIT" >/dev/null 2>&1 && { echo 'Upgrade unit already exists' >&2; exit 1; } || true`,
|
|
26721
|
+
"ADOPTION_ACTIVE=true",
|
|
26722
|
+
'mv "$DROP" "$STAGE"',
|
|
26723
|
+
'chown -hR root:root "$STAGE"',
|
|
26724
|
+
': > "$STATUS"; : > "$LOG"; chmod 600 "$STATUS" "$LOG"',
|
|
26725
|
+
'printf \'PREPARED\\n\' > "${STATUS}.next"; chmod 600 "${STATUS}.next"; mv -f "${STATUS}.next" "$STATUS"',
|
|
26726
|
+
"ADOPTION_ACTIVE=false",
|
|
26727
|
+
"trap - EXIT HUP INT TERM"
|
|
26728
|
+
].join(`
|
|
26729
|
+
`);
|
|
26730
|
+
}
|
|
26731
|
+
async function adoptRemoteDrop(ssh, paths) {
|
|
26732
|
+
let adoptionFailure;
|
|
26733
|
+
let adoptionOutcomeUncertain = false;
|
|
26734
|
+
try {
|
|
26735
|
+
const adopted = await ssh.exec(rootCommand(buildAdoptDropScript(paths)), 60000);
|
|
26736
|
+
if (adopted.success)
|
|
26737
|
+
return;
|
|
26738
|
+
adoptionFailure = remoteFailure("Unable to adopt the verified upgrade bundle", adopted);
|
|
26739
|
+
} catch (error) {
|
|
26740
|
+
adoptionOutcomeUncertain = true;
|
|
26741
|
+
adoptionFailure = operationError(error, "Unable to adopt the verified upgrade bundle");
|
|
26742
|
+
}
|
|
26743
|
+
const state = await observeRemoteState(ssh, paths, {
|
|
26744
|
+
failureMessage: "Unable to reconcile the interrupted upgrade adoption",
|
|
26745
|
+
precedingFailures: [adoptionFailure]
|
|
26746
|
+
});
|
|
26747
|
+
if (isFullyPreparedUnstartedState(state))
|
|
26748
|
+
return;
|
|
26749
|
+
if (adoptionOutcomeUncertain) {
|
|
26750
|
+
throw remoteReconciliationFailure("Upgrade adoption outcome is still uncertain after SSH interruption", [adoptionFailure], paths);
|
|
26751
|
+
}
|
|
26752
|
+
const noAdoptedStateRemains = !state.stageExists && !state.statusExists && !state.logExists && !state.unitExists;
|
|
26753
|
+
if (noAdoptedStateRemains)
|
|
26754
|
+
throw adoptionFailure;
|
|
26755
|
+
throw remoteReconciliationFailure("Upgrade adoption stopped in an ambiguous state", [adoptionFailure], paths);
|
|
26756
|
+
}
|
|
26757
|
+
function startUnitScript(paths) {
|
|
26758
|
+
return [
|
|
26759
|
+
"set -euo pipefail",
|
|
26760
|
+
`systemd-run --quiet --collect --unit=${quoteShell2(paths.unit)} --property=Type=exec --property=KillMode=control-group --property=TimeoutStopSec=30s /bin/bash ${quoteShell2(`${paths.stage}/run.sh`)}`,
|
|
26761
|
+
`systemctl is-active --quiet ${quoteShell2(paths.unit)}`
|
|
26762
|
+
].join(`
|
|
26763
|
+
`);
|
|
26764
|
+
}
|
|
26765
|
+
async function startRemoteUpgrade(ssh, paths) {
|
|
26766
|
+
let startFailure;
|
|
26767
|
+
let startOutcomeUncertain = false;
|
|
26768
|
+
try {
|
|
26769
|
+
const started = await ssh.exec(rootCommand(startUnitScript(paths)), 30000);
|
|
26770
|
+
if (started.success)
|
|
26771
|
+
return;
|
|
26772
|
+
startFailure = remoteFailure("Unable to start the transient upgrade unit", started);
|
|
26773
|
+
} catch (error) {
|
|
26774
|
+
startOutcomeUncertain = true;
|
|
26775
|
+
startFailure = operationError(error, "Unable to start the transient upgrade unit");
|
|
26776
|
+
}
|
|
26777
|
+
const state = await observeRemoteState(ssh, paths, {
|
|
26778
|
+
failureMessage: "Unable to reconcile the interrupted upgrade start",
|
|
26779
|
+
precedingFailures: [startFailure]
|
|
26780
|
+
});
|
|
26781
|
+
if (unitIsRunning(state.serviceState) || /^(?:RUNNING|CLEANING|SUCCEEDED|FAILED:)/.test(state.status))
|
|
26782
|
+
return;
|
|
26783
|
+
if (startOutcomeUncertain) {
|
|
26784
|
+
throw remoteReconciliationFailure("Upgrade start outcome is still uncertain after SSH interruption", [startFailure], paths);
|
|
26785
|
+
}
|
|
26786
|
+
if (!isFullyPreparedUnstartedState(state)) {
|
|
26787
|
+
throw remoteReconciliationFailure("Upgrade start stopped in an ambiguous state", [startFailure], paths);
|
|
26788
|
+
}
|
|
26789
|
+
try {
|
|
26790
|
+
await cleanupUnstartedUpgrade(ssh, paths);
|
|
26791
|
+
} catch (cleanupError) {
|
|
26792
|
+
throw remoteReconciliationFailure("Remote upgrade start and cleanup both failed", [startFailure, cleanupError], paths);
|
|
26793
|
+
}
|
|
26794
|
+
throw startFailure;
|
|
26795
|
+
}
|
|
26796
|
+
function readStateScript(paths) {
|
|
26797
|
+
return [
|
|
26798
|
+
"set -euo pipefail",
|
|
26799
|
+
`DROP=${quoteShell2(paths.drop)}`,
|
|
26800
|
+
`LOG=${quoteShell2(paths.log)}`,
|
|
26801
|
+
`STAGE=${quoteShell2(paths.stage)}`,
|
|
26802
|
+
`STATUS=${quoteShell2(paths.status)}`,
|
|
26803
|
+
`UNIT=${quoteShell2(paths.unit)}`,
|
|
26804
|
+
`printf 'STATUS=%s\\n' "$(sed -n '1p' "$STATUS" 2>/dev/null || true)"`,
|
|
26805
|
+
`printf 'UNIT=%s\\n' "$(systemctl is-active "$UNIT" 2>/dev/null || true)"`,
|
|
26806
|
+
'if [ -e "$STAGE" ]; then echo STAGE_EXISTS=yes; else echo STAGE_EXISTS=no; fi',
|
|
26807
|
+
'if [ -d "$STAGE" ] && [ ! -L "$STAGE" ]; then echo STAGE_DIRECTORY=yes; else echo STAGE_DIRECTORY=no; fi',
|
|
26808
|
+
'if [ -e "$DROP" ]; then echo DROP_EXISTS=yes; else echo DROP_EXISTS=no; fi',
|
|
26809
|
+
'if [ -e "$STATUS" ]; then echo STATUS_EXISTS=yes; else echo STATUS_EXISTS=no; fi',
|
|
26810
|
+
'if [ -e "$LOG" ]; then echo LOG_EXISTS=yes; else echo LOG_EXISTS=no; fi',
|
|
26811
|
+
'UNIT_LOAD_STATE=$(systemctl show --property=LoadState --value "$UNIT" 2>/dev/null || true)',
|
|
26812
|
+
`test -n "$UNIT_LOAD_STATE" || { echo 'Unable to read remote upgrade unit load state' >&2; exit 1; }`,
|
|
26813
|
+
`printf 'UNIT_LOAD=%s\\n' "$UNIT_LOAD_STATE"`,
|
|
26814
|
+
'if [ -n "$UNIT_LOAD_STATE" ] && [ "$UNIT_LOAD_STATE" != not-found ]; then echo UNIT_EXISTS=yes; else echo UNIT_EXISTS=no; fi'
|
|
26815
|
+
].join(`
|
|
26816
|
+
`);
|
|
26817
|
+
}
|
|
26818
|
+
function remoteStateValue(output, field) {
|
|
26819
|
+
const match = output.match(new RegExp(`^${field}=(.*)$`, "m"));
|
|
26820
|
+
if (!match)
|
|
26821
|
+
throw new Error(`Remote upgrade state is missing ${field}`);
|
|
26822
|
+
return match[1]?.trim() || "";
|
|
26823
|
+
}
|
|
26824
|
+
function remoteStatePresence(output, field) {
|
|
26825
|
+
const presence = remoteStateValue(output, field);
|
|
26826
|
+
if (presence !== "yes" && presence !== "no") {
|
|
26827
|
+
throw new Error(`Remote upgrade state has invalid ${field}`);
|
|
26828
|
+
}
|
|
26829
|
+
return presence === "yes";
|
|
26830
|
+
}
|
|
26831
|
+
async function readRemoteState(ssh, paths, timeoutMs = REMOTE_STATE_READ_TIMEOUT_MS) {
|
|
26832
|
+
const state = await ssh.exec(rootCommand(readStateScript(paths)), timeoutMs);
|
|
26833
|
+
if (!state.success)
|
|
26834
|
+
throw remoteFailure("Unable to read remote upgrade state", state);
|
|
26835
|
+
return {
|
|
26836
|
+
dropExists: remoteStatePresence(state.stdout, "DROP_EXISTS"),
|
|
26837
|
+
logExists: remoteStatePresence(state.stdout, "LOG_EXISTS"),
|
|
26838
|
+
serviceState: remoteStateValue(state.stdout, "UNIT") || "unknown",
|
|
26839
|
+
stageExists: remoteStatePresence(state.stdout, "STAGE_EXISTS"),
|
|
26840
|
+
stageIsDirectory: remoteStatePresence(state.stdout, "STAGE_DIRECTORY"),
|
|
26841
|
+
status: remoteStateValue(state.stdout, "STATUS"),
|
|
26842
|
+
statusExists: remoteStatePresence(state.stdout, "STATUS_EXISTS"),
|
|
26843
|
+
unitExists: remoteStatePresence(state.stdout, "UNIT_EXISTS"),
|
|
26844
|
+
unitLoadState: remoteStateValue(state.stdout, "UNIT_LOAD")
|
|
26845
|
+
};
|
|
26846
|
+
}
|
|
26847
|
+
function observationDeadlineFailure(paths, failures) {
|
|
26848
|
+
return remoteReconciliationFailure("Remote upgrade is still nonterminal after the observation deadline", failures, paths);
|
|
26849
|
+
}
|
|
26850
|
+
async function observeRemoteState(ssh, paths, observation) {
|
|
26851
|
+
const readFailures = [];
|
|
26852
|
+
for (let attempt = 1;attempt <= STATE_READ_ATTEMPTS; attempt += 1) {
|
|
26853
|
+
const remainingMs = observation.deadline === undefined ? REMOTE_STATE_READ_TIMEOUT_MS : observation.deadline - Date.now();
|
|
26854
|
+
if (remainingMs <= 0) {
|
|
26855
|
+
throw observationDeadlineFailure(paths, readFailures);
|
|
26856
|
+
}
|
|
26857
|
+
try {
|
|
26858
|
+
return await readRemoteState(ssh, paths, Math.min(REMOTE_STATE_READ_TIMEOUT_MS, remainingMs));
|
|
26859
|
+
} catch (error) {
|
|
26860
|
+
readFailures.push(error);
|
|
26861
|
+
if (attempt < STATE_READ_ATTEMPTS) {
|
|
26862
|
+
const retryDelay = observation.deadline === undefined ? POLL_INTERVAL_MS : Math.min(POLL_INTERVAL_MS, observation.deadline - Date.now());
|
|
26863
|
+
if (retryDelay <= 0)
|
|
26864
|
+
throw observationDeadlineFailure(paths, readFailures);
|
|
26865
|
+
await delay(retryDelay);
|
|
26866
|
+
}
|
|
26867
|
+
}
|
|
26868
|
+
}
|
|
26869
|
+
throw remoteReconciliationFailure(observation.failureMessage, [...observation.precedingFailures ?? [], ...readFailures], paths);
|
|
26870
|
+
}
|
|
26871
|
+
async function remoteLogTail(ssh, paths) {
|
|
26872
|
+
const script = [
|
|
26873
|
+
`LOG=${quoteShell2(paths.log)}`,
|
|
26874
|
+
`test -f "$LOG" && test ! -L "$LOG" || { echo 'Remote upgrade log is not a regular file' >&2; exit 1; }`,
|
|
26875
|
+
'tail -80 -- "$LOG"'
|
|
26876
|
+
].join(`
|
|
26877
|
+
`);
|
|
26878
|
+
const output = await ssh.exec(rootCommand(script), 15000);
|
|
26879
|
+
if (!output.success)
|
|
26880
|
+
throw remoteFailure("Unable to read the remote upgrade log", output);
|
|
26881
|
+
return output.stdout.slice(-4000);
|
|
26882
|
+
}
|
|
26883
|
+
async function cleanupRemoteRecords(ssh, paths) {
|
|
26884
|
+
const cleanup = await ssh.exec(rootCommand(`rm -f -- ${quoteShell2(paths.status)} ${quoteShell2(paths.log)}`), 15000);
|
|
26885
|
+
if (!cleanup.success)
|
|
26886
|
+
throw remoteFailure("Unable to remove remote upgrade status records", cleanup);
|
|
26887
|
+
}
|
|
26888
|
+
function buildCleanupUnstartedUpgradeScript(paths) {
|
|
26889
|
+
return [
|
|
26890
|
+
"set -euo pipefail",
|
|
26891
|
+
"cleanup_failed=false",
|
|
26892
|
+
`rm -rf -- ${quoteShell2(paths.stage)} || cleanup_failed=true`,
|
|
26893
|
+
`rm -f -- ${quoteShell2(paths.status)} ${quoteShell2(paths.log)} || cleanup_failed=true`,
|
|
26894
|
+
'test "$cleanup_failed" = false'
|
|
26895
|
+
].join(`
|
|
26896
|
+
`);
|
|
26897
|
+
}
|
|
26898
|
+
async function cleanupUnstartedUpgrade(ssh, paths) {
|
|
26899
|
+
const cleanup = await ssh.exec(rootCommand(buildCleanupUnstartedUpgradeScript(paths)), 15000);
|
|
26900
|
+
if (!cleanup.success)
|
|
26901
|
+
throw remoteFailure("Unable to clean an unstarted local upgrade", cleanup);
|
|
26902
|
+
}
|
|
26903
|
+
async function cleanupRemoteDrop(ssh, paths) {
|
|
26904
|
+
const cleanup = await ssh.exec(`rm -rf -- ${quoteShell2(paths.drop)}`, 15000);
|
|
26905
|
+
if (!cleanup.success)
|
|
26906
|
+
throw remoteFailure("Unable to remove remote upload drop", cleanup);
|
|
26907
|
+
}
|
|
26908
|
+
function remoteFailure(message, execution) {
|
|
26909
|
+
const diagnostic = execution.stderr.trim() || execution.stdout.trim() || `exit ${execution.code}`;
|
|
26910
|
+
return new Error(`${message}: ${diagnostic.slice(-500)}`);
|
|
26911
|
+
}
|
|
26912
|
+
function operationError(error, message) {
|
|
26913
|
+
return error instanceof Error ? error : new Error(`${message}: ${String(error)}`);
|
|
26914
|
+
}
|
|
26915
|
+
function remoteEvidence(paths) {
|
|
26916
|
+
return `unit=${paths.unit} stage=${paths.stage} status=${paths.status} log=${paths.log} drop=${paths.drop}`;
|
|
26917
|
+
}
|
|
26918
|
+
function remoteReconciliationFailure(message, failures, paths) {
|
|
26919
|
+
return new RemoteUpgradeReconciliationError(failures.map((failure) => operationError(failure, message)), `${message}; reconcile remote evidence at ${remoteEvidence(paths)}; do not retry blindly`);
|
|
26920
|
+
}
|
|
26921
|
+
function failureRequiresRemoteReconciliation(error) {
|
|
26922
|
+
return error instanceof RemoteUpgradeReconciliationError;
|
|
26923
|
+
}
|
|
26924
|
+
function delay(milliseconds) {
|
|
26925
|
+
return new Promise((resolve2) => setTimeout(resolve2, milliseconds));
|
|
26926
|
+
}
|
|
26927
|
+
function unitIsRunning(serviceState) {
|
|
26928
|
+
return ["active", "activating", "deactivating", "reloading"].includes(serviceState);
|
|
26929
|
+
}
|
|
26930
|
+
function isFullyPreparedUnstartedState(state) {
|
|
26931
|
+
return state.stageExists && state.stageIsDirectory && state.statusExists && state.logExists && !state.dropExists && !state.unitExists && state.unitLoadState === "not-found" && state.status === "PREPARED";
|
|
26932
|
+
}
|
|
26933
|
+
function unitStoppedNormally(state) {
|
|
26934
|
+
return state.serviceState === "inactive" && ["loaded", "not-found"].includes(state.unitLoadState) || state.serviceState === "unknown" && state.unitLoadState === "not-found";
|
|
26935
|
+
}
|
|
26936
|
+
function assertSuccessfulUnitStoppedNormally(state, paths) {
|
|
26937
|
+
if (unitStoppedNormally(state))
|
|
26938
|
+
return;
|
|
26939
|
+
throw remoteReconciliationFailure(`Remote upgrade published SUCCEEDED but the unit stopped abnormally or ambiguously ` + `(state=${state.serviceState} load=${state.unitLoadState})`, [], paths);
|
|
26940
|
+
}
|
|
26941
|
+
function assertTerminalEvidence(state, paths) {
|
|
26942
|
+
if (state.status !== "SUCCEEDED" && !state.status.startsWith("FAILED:"))
|
|
26943
|
+
return;
|
|
26944
|
+
const evidenceIssues = [];
|
|
26945
|
+
if (!state.statusExists)
|
|
26946
|
+
evidenceIssues.push("status record is missing");
|
|
26947
|
+
if (!state.logExists)
|
|
26948
|
+
evidenceIssues.push("log is missing");
|
|
26949
|
+
if (state.dropExists)
|
|
26950
|
+
evidenceIssues.push("upload drop still exists");
|
|
26951
|
+
if (state.status === "SUCCEEDED" && state.stageExists)
|
|
26952
|
+
evidenceIssues.push("successful stage still exists");
|
|
26953
|
+
if (state.status.startsWith("FAILED:") && !state.status.includes("CLEANUP") && state.stageExists) {
|
|
26954
|
+
evidenceIssues.push("failed stage still exists without a cleanup failure status");
|
|
26955
|
+
}
|
|
26956
|
+
if (evidenceIssues.length === 0)
|
|
26957
|
+
return;
|
|
26958
|
+
throw remoteReconciliationFailure(`Remote upgrade terminal evidence is incomplete or inconsistent (${evidenceIssues.join(", ")})`, [], paths);
|
|
26959
|
+
}
|
|
26960
|
+
async function completedUpgradeOutput(ssh, paths) {
|
|
26961
|
+
let log;
|
|
26962
|
+
try {
|
|
26963
|
+
log = await remoteLogTail(ssh, paths);
|
|
26964
|
+
} catch (error) {
|
|
26965
|
+
throw remoteReconciliationFailure("Upgrade succeeded but its retained log could not be read", [error], paths);
|
|
26966
|
+
}
|
|
26967
|
+
try {
|
|
26968
|
+
await cleanupRemoteRecords(ssh, paths);
|
|
26969
|
+
} catch (error) {
|
|
26970
|
+
throw remoteReconciliationFailure("Upgrade succeeded but remote evidence cleanup could not be confirmed", [error], paths);
|
|
26971
|
+
}
|
|
26972
|
+
return `✅ Upgrade done
|
|
26973
|
+
${log.slice(-1500)}`;
|
|
26974
|
+
}
|
|
26975
|
+
async function throwRemoteUpgradeFailure(ssh, paths, status) {
|
|
26976
|
+
let log;
|
|
26977
|
+
try {
|
|
26978
|
+
log = await remoteLogTail(ssh, paths);
|
|
26979
|
+
} catch (error) {
|
|
26980
|
+
throw remoteReconciliationFailure("Remote upgrade failed but its retained log could not be read", [error], paths);
|
|
26981
|
+
}
|
|
26982
|
+
const failure = new Error(`Remote local upgrade failed (${status}): ${log.slice(-1500)}`);
|
|
26983
|
+
if (status.endsWith(":CLEANUP_AFTER_TRANSACTION")) {
|
|
26984
|
+
throw remoteReconciliationFailure("Upgrade transaction completed but staging cleanup is incomplete", [failure], paths);
|
|
26985
|
+
}
|
|
26986
|
+
if (status.includes("CLEANUP")) {
|
|
26987
|
+
throw remoteReconciliationFailure("Upgrade transaction and staging cleanup both failed", [failure], paths);
|
|
26988
|
+
}
|
|
26989
|
+
try {
|
|
26990
|
+
await cleanupRemoteRecords(ssh, paths);
|
|
26991
|
+
} catch (cleanupError) {
|
|
26992
|
+
throw remoteReconciliationFailure("Remote local upgrade failed and status cleanup did not complete", [failure, cleanupError], paths);
|
|
26993
|
+
}
|
|
26994
|
+
throw failure;
|
|
26995
|
+
}
|
|
26996
|
+
function assertObservableUpgradeState(state, unitRunning, paths) {
|
|
26997
|
+
const knownStatus = ["PREPARED", "RUNNING", "CLEANING", "SUCCEEDED"].includes(state.status) || state.status.startsWith("FAILED:");
|
|
26998
|
+
if (!unitRunning && !knownStatus) {
|
|
26999
|
+
throw remoteReconciliationFailure("Remote upgrade stopped without a terminal status", [], paths);
|
|
27000
|
+
}
|
|
27001
|
+
}
|
|
27002
|
+
async function awaitRemoteUpgrade(ssh, paths) {
|
|
27003
|
+
const observationDeadline = Date.now() + UPGRADE_OBSERVATION_TIMEOUT_MS;
|
|
27004
|
+
let stoppedObservations = 0;
|
|
27005
|
+
while (true) {
|
|
27006
|
+
const state = await observeRemoteState(ssh, paths, {
|
|
27007
|
+
deadline: observationDeadline,
|
|
27008
|
+
failureMessage: "Unable to observe the remote upgrade lifecycle"
|
|
27009
|
+
});
|
|
27010
|
+
const unitRunning = unitIsRunning(state.serviceState);
|
|
27011
|
+
if (!unitRunning)
|
|
27012
|
+
assertTerminalEvidence(state, paths);
|
|
27013
|
+
if (state.status === "SUCCEEDED" && !unitRunning) {
|
|
27014
|
+
assertSuccessfulUnitStoppedNormally(state, paths);
|
|
27015
|
+
return await completedUpgradeOutput(ssh, paths);
|
|
27016
|
+
}
|
|
27017
|
+
if (state.status.startsWith("FAILED:") && !unitRunning) {
|
|
27018
|
+
await throwRemoteUpgradeFailure(ssh, paths, state.status);
|
|
27019
|
+
}
|
|
27020
|
+
if (Date.now() >= observationDeadline) {
|
|
27021
|
+
throw observationDeadlineFailure(paths, []);
|
|
27022
|
+
}
|
|
27023
|
+
assertObservableUpgradeState(state, unitRunning, paths);
|
|
27024
|
+
stoppedObservations = unitRunning ? 0 : stoppedObservations + 1;
|
|
27025
|
+
if (stoppedObservations >= 3 && ["PREPARED", "RUNNING", "CLEANING"].includes(state.status)) {
|
|
27026
|
+
throw remoteReconciliationFailure("Remote upgrade unit stopped before publishing a terminal status", [], paths);
|
|
27027
|
+
}
|
|
27028
|
+
const remainingObservationMs = observationDeadline - Date.now();
|
|
27029
|
+
if (remainingObservationMs <= 0)
|
|
27030
|
+
throw observationDeadlineFailure(paths, []);
|
|
27031
|
+
await delay(Math.min(POLL_INTERVAL_MS, remainingObservationMs));
|
|
27032
|
+
}
|
|
27033
|
+
}
|
|
27034
|
+
async function executeLocalUpgradeTransfer(ssh, request) {
|
|
27035
|
+
const runId = randomUUID3();
|
|
27036
|
+
const paths = remotePaths(runId);
|
|
27037
|
+
const preflight = await remoteUpgradePreflight(ssh);
|
|
27038
|
+
const bundle = await prepareLocalUpgradeBundle({ ...preflight, ...request });
|
|
27039
|
+
let adopted = false;
|
|
27040
|
+
let transferError;
|
|
27041
|
+
try {
|
|
27042
|
+
try {
|
|
27043
|
+
await prepareRemoteDrop(ssh, paths, bundle);
|
|
27044
|
+
await uploadBundleFiles(ssh, paths, bundle);
|
|
27045
|
+
await uploadRunScript(ssh, paths, bundle, request, preflight.architecture);
|
|
27046
|
+
await adoptRemoteDrop(ssh, paths);
|
|
27047
|
+
adopted = true;
|
|
27048
|
+
await startRemoteUpgrade(ssh, paths);
|
|
27049
|
+
return await awaitRemoteUpgrade(ssh, paths);
|
|
27050
|
+
} catch (error) {
|
|
27051
|
+
transferError = error;
|
|
27052
|
+
if (!adopted && !failureRequiresRemoteReconciliation(error)) {
|
|
27053
|
+
try {
|
|
27054
|
+
await cleanupRemoteDrop(ssh, paths);
|
|
27055
|
+
} catch (cleanupError) {
|
|
27056
|
+
transferError = new AggregateError([transferError, cleanupError], "Local upgrade failed and upload-drop cleanup did not complete");
|
|
27057
|
+
}
|
|
27058
|
+
}
|
|
27059
|
+
throw transferError;
|
|
27060
|
+
}
|
|
27061
|
+
} finally {
|
|
27062
|
+
try {
|
|
27063
|
+
cleanupLocalUpgradeBundle(bundle);
|
|
27064
|
+
} catch (cleanupError) {
|
|
27065
|
+
if (transferError) {
|
|
27066
|
+
throw new AggregateError([transferError, cleanupError], "Local upgrade and local bundle cleanup both failed");
|
|
27067
|
+
}
|
|
27068
|
+
throw new AggregateError([cleanupError], "Remote local upgrade completed but local bundle cleanup did not complete");
|
|
27069
|
+
}
|
|
27070
|
+
}
|
|
27071
|
+
}
|
|
25617
27072
|
|
|
25618
27073
|
// ../../scripts/lib/release_assets.sh
|
|
25619
27074
|
var release_assets_default = `#!/usr/bin/env bash
|
|
@@ -25626,6 +27081,24 @@ SUPACLOUD_GH_MIN_VERSION="\${SUPACLOUD_GH_MIN_VERSION:-2.68.0}"
|
|
|
25626
27081
|
SUPACLOUD_GH_AMD64_SHA256="\${SUPACLOUD_GH_AMD64_SHA256:-83d5c2ccad5498f58bf6368acb1ab32588cf43ab3a4b1c301bf36328b1c8bd60}"
|
|
25627
27082
|
SUPACLOUD_GH_ARM64_SHA256="\${SUPACLOUD_GH_ARM64_SHA256:-06f86ec7103d41993b76cd78072f43595c34aaa56506d971d9860e67140bf909}"
|
|
25628
27083
|
|
|
27084
|
+
supacloud_curl_release_json() {
|
|
27085
|
+
local url="$1"
|
|
27086
|
+
local output="$2"
|
|
27087
|
+
curl -fsSL --proto '=https' --proto-redir '=https' \\
|
|
27088
|
+
--retry 1 --retry-delay 2 --retry-max-time 60 \\
|
|
27089
|
+
--connect-timeout 15 --max-time 30 --speed-limit 128 --speed-time 10 \\
|
|
27090
|
+
-o "$output" "$url"
|
|
27091
|
+
}
|
|
27092
|
+
|
|
27093
|
+
supacloud_curl_release_asset() {
|
|
27094
|
+
local url="$1"
|
|
27095
|
+
local output="$2"
|
|
27096
|
+
curl -fL --proto '=https' --proto-redir '=https' \\
|
|
27097
|
+
--retry 1 --retry-delay 2 --retry-max-time 180 \\
|
|
27098
|
+
--connect-timeout 15 --max-time 90 --speed-limit 128 --speed-time 60 \\
|
|
27099
|
+
-o "$output" "$url"
|
|
27100
|
+
}
|
|
27101
|
+
|
|
25629
27102
|
supacloud_component_tag() {
|
|
25630
27103
|
local component="$1"
|
|
25631
27104
|
local version="$2"
|
|
@@ -25691,18 +27164,15 @@ supacloud_fetch_component_release() {
|
|
|
25691
27164
|
supacloud_select_release "$component" "\${required_assets[@]}" <<< "$response"
|
|
25692
27165
|
}
|
|
25693
27166
|
|
|
25694
|
-
supacloud_fetch_release_json()
|
|
27167
|
+
supacloud_fetch_release_json() (
|
|
25695
27168
|
local url="$1"
|
|
25696
|
-
local
|
|
25697
|
-
|
|
25698
|
-
|
|
25699
|
-
|
|
25700
|
-
|
|
25701
|
-
|
|
25702
|
-
|
|
25703
|
-
fi
|
|
25704
|
-
return 1
|
|
25705
|
-
}
|
|
27169
|
+
local response_file
|
|
27170
|
+
response_file=$(mktemp) || return 1
|
|
27171
|
+
trap 'rm -f "$response_file"' EXIT
|
|
27172
|
+
trap 'trap - EXIT HUP INT TERM; rm -f "$response_file"; exit 1' HUP INT TERM
|
|
27173
|
+
supacloud_download_release_metadata_url "$url" "$response_file" || return 1
|
|
27174
|
+
cat "$response_file"
|
|
27175
|
+
)
|
|
25706
27176
|
|
|
25707
27177
|
supacloud_release_asset_url() {
|
|
25708
27178
|
local release_json="$1"
|
|
@@ -25718,11 +27188,26 @@ supacloud_download_url() {
|
|
|
25718
27188
|
local output="$2"
|
|
25719
27189
|
local proxy="\${SUPACLOUD_GITHUB_PROXY:-\${GH_PROXY:-}}"
|
|
25720
27190
|
|
|
25721
|
-
if
|
|
27191
|
+
if supacloud_curl_release_asset "$url" "$output"; then
|
|
25722
27192
|
return 0
|
|
25723
27193
|
fi
|
|
25724
27194
|
if [[ -n "$proxy" ]]; then
|
|
25725
|
-
|
|
27195
|
+
supacloud_curl_release_asset "\${proxy%/}/\${url}" "$output"
|
|
27196
|
+
return
|
|
27197
|
+
fi
|
|
27198
|
+
return 1
|
|
27199
|
+
}
|
|
27200
|
+
|
|
27201
|
+
supacloud_download_release_metadata_url() {
|
|
27202
|
+
local url="$1"
|
|
27203
|
+
local output="$2"
|
|
27204
|
+
local proxy="\${SUPACLOUD_GITHUB_PROXY:-\${GH_PROXY:-}}"
|
|
27205
|
+
|
|
27206
|
+
if supacloud_curl_release_json "$url" "$output"; then
|
|
27207
|
+
return 0
|
|
27208
|
+
fi
|
|
27209
|
+
if [[ -n "$proxy" ]]; then
|
|
27210
|
+
supacloud_curl_release_json "\${proxy%/}/\${url}" "$output"
|
|
25726
27211
|
return
|
|
25727
27212
|
fi
|
|
25728
27213
|
return 1
|
|
@@ -25803,7 +27288,8 @@ supacloud_install_pinned_tar_xz_binary() (
|
|
|
25803
27288
|
fi
|
|
25804
27289
|
|
|
25805
27290
|
extract_dir=$(mktemp -d)
|
|
25806
|
-
trap 'rm -rf "$extract_dir"; [[ -z "\${staged_target:-}" ]] || rm -f "$staged_target"' EXIT
|
|
27291
|
+
trap 'rm -rf "$extract_dir"; [[ -z "\${staged_target:-}" ]] || rm -f "$staged_target"' EXIT
|
|
27292
|
+
trap 'trap - EXIT HUP INT TERM; rm -rf "$extract_dir"; [[ -z "\${staged_target:-}" ]] || rm -f "$staged_target"; exit 1' HUP INT TERM
|
|
25807
27293
|
if ! tar --no-same-owner --no-same-permissions -xJf "$archive" -C "$extract_dir" "$member"; then
|
|
25808
27294
|
return 1
|
|
25809
27295
|
fi
|
|
@@ -25993,7 +27479,8 @@ supacloud_verify_attestation() (
|
|
|
25993
27479
|
--bundle "$bundle_file" \\
|
|
25994
27480
|
--repo "$SUPACLOUD_GITHUB_REPOSITORY" \\
|
|
25995
27481
|
--signer-workflow "$SUPACLOUD_ATTESTATION_SIGNER_WORKFLOW" \\
|
|
25996
|
-
--source-ref "refs/heads/main"
|
|
27482
|
+
--source-ref "refs/heads/main" \\
|
|
27483
|
+
--deny-self-hosted-runners 2>&1); then
|
|
25997
27484
|
echo "GitHub artifact attestation verification failed: \${verification_output}" >&2
|
|
25998
27485
|
return 1
|
|
25999
27486
|
fi
|
|
@@ -26020,7 +27507,8 @@ supacloud_attestation_verifier_available() {
|
|
|
26020
27507
|
help=$(gh attestation verify --help 2>&1) || return 1
|
|
26021
27508
|
grep -Eq -- '(^|[[:space:]])--bundle([=[:space:]]|$)' <<< "$help" || return 1
|
|
26022
27509
|
grep -Eq -- '(^|[[:space:]])--signer-workflow([=[:space:]]|$)' <<< "$help" || return 1
|
|
26023
|
-
grep -Eq -- '(^|[[:space:]])--source-ref([=[:space:]]|$)' <<< "$help"
|
|
27510
|
+
grep -Eq -- '(^|[[:space:]])--source-ref([=[:space:]]|$)' <<< "$help" || return 1
|
|
27511
|
+
grep -Eq -- '(^|[[:space:]])--deny-self-hosted-runners([=[:space:]]|$)' <<< "$help"
|
|
26024
27512
|
}
|
|
26025
27513
|
|
|
26026
27514
|
supacloud_download_release_asset() (
|
|
@@ -26035,10 +27523,11 @@ supacloud_download_release_asset() (
|
|
|
26035
27523
|
mkdir -p "$(dirname "$destination")"
|
|
26036
27524
|
temporary_artifact=$(mktemp "\${destination}.tmp.XXXXXX")
|
|
26037
27525
|
temporary_checksums=$(mktemp "\${destination}.SHA256SUMS.tmp.XXXXXX")
|
|
26038
|
-
trap 'rm -f "\${temporary_artifact:-}" "\${temporary_checksums:-}"' EXIT
|
|
27526
|
+
trap 'rm -f "\${temporary_artifact:-}" "\${temporary_checksums:-}"' EXIT
|
|
27527
|
+
trap 'trap - EXIT HUP INT TERM; rm -f "\${temporary_artifact:-}" "\${temporary_checksums:-}"; exit 1' HUP INT TERM
|
|
26039
27528
|
|
|
26040
27529
|
if ! supacloud_download_url "$asset_url" "$temporary_artifact" \\
|
|
26041
|
-
|| !
|
|
27530
|
+
|| ! supacloud_download_release_metadata_url "$checksum_url" "$temporary_checksums" \\
|
|
26042
27531
|
|| ! supacloud_verify_checksum "$temporary_artifact" "$asset_name" "$temporary_checksums"; then
|
|
26043
27532
|
rm -f "$temporary_artifact" "$temporary_checksums"
|
|
26044
27533
|
return 1
|
|
@@ -26080,6 +27569,8 @@ var SAFE_HOSTNAME = /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9
|
|
|
26080
27569
|
var SAFE_SYSTEMD_UNIT = /^[a-zA-Z0-9][a-zA-Z0-9_.@:-]{0,127}$/;
|
|
26081
27570
|
var SAFE_DB_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_-]{0,62}$/;
|
|
26082
27571
|
var MINIMUM_COMPONENT_UPGRADE_VERSION = "0.50.27";
|
|
27572
|
+
var DIRECT_UPGRADE_SSH_TIMEOUT_MS = 720000;
|
|
27573
|
+
var PROXIED_UPGRADE_SSH_TIMEOUT_MS = 1320000;
|
|
26083
27574
|
function hostnameSchema(fieldName) {
|
|
26084
27575
|
return decodedSchema(Type.String(), Type.String({ minLength: 1, maxLength: 253 }), (value) => {
|
|
26085
27576
|
const normalized = value.trim();
|
|
@@ -26136,7 +27627,7 @@ function assertSafeReleaseTag(value) {
|
|
|
26136
27627
|
}
|
|
26137
27628
|
return value;
|
|
26138
27629
|
}
|
|
26139
|
-
function
|
|
27630
|
+
function assertExactStableVersion2(value, fieldName) {
|
|
26140
27631
|
if (!/^v?\d+\.\d+\.\d+$/.test(value)) {
|
|
26141
27632
|
throw new Error(`${fieldName} must be an exact stable semantic version`);
|
|
26142
27633
|
}
|
|
@@ -26177,8 +27668,8 @@ function componentPreflightCommands(request) {
|
|
|
26177
27668
|
}
|
|
26178
27669
|
function signalSafeCleanupTraps(cleanupCommand) {
|
|
26179
27670
|
return [
|
|
26180
|
-
`trap
|
|
26181
|
-
`trap
|
|
27671
|
+
`trap ${quoteEnvValue(cleanupCommand)} EXIT`,
|
|
27672
|
+
`trap ${quoteEnvValue(`trap - EXIT HUP INT TERM; ${cleanupCommand}; exit 1`)} HUP INT TERM`
|
|
26182
27673
|
];
|
|
26183
27674
|
}
|
|
26184
27675
|
function buildRootUpgradeScript(request) {
|
|
@@ -26190,8 +27681,10 @@ function buildRootUpgradeScript(request) {
|
|
|
26190
27681
|
"export PATH",
|
|
26191
27682
|
"unset SUPACLOUD_ALLOW_UNVERIFIED_RELEASE SUPACLOUD_GITHUB_REPOSITORY SUPACLOUD_RELEASES_API SUPACLOUD_ATTESTATION_SIGNER_WORKFLOW SUPACLOUD_GH_VERSION SUPACLOUD_GH_MIN_VERSION SUPACLOUD_GH_AMD64_SHA256 SUPACLOUD_GH_ARM64_SHA256 GH_PROXY",
|
|
26192
27683
|
request.githubProxy ? `export SUPACLOUD_GITHUB_PROXY=${quoteEnvValue(request.githubProxy)}` : "unset SUPACLOUD_GITHUB_PROXY SUPACLOUD_GITHUB_PROXIES",
|
|
26193
|
-
'for tool in curl jq file sha256sum tar; do command -v "$tool" >/dev/null 2>&1 || { echo "Required upgrade tool is missing: $tool" >&2; exit 127; }; done',
|
|
27684
|
+
'for tool in curl jq file sha256sum stat tar flock; do command -v "$tool" >/dev/null 2>&1 || { echo "Required upgrade tool is missing: $tool" >&2; exit 127; }; done',
|
|
27685
|
+
"test -d /run/lock || { echo '/run/lock is unavailable' >&2; exit 1; }",
|
|
26194
27686
|
"test -x /usr/local/bin/supacloud || { echo 'SupaCloud binary not found at /usr/local/bin/supacloud; run ssh install first.' >&2; exit 127; }",
|
|
27687
|
+
buildUpgradeLockScript(SUPACLOUD_UPGRADE_LOCK_PATH),
|
|
26195
27688
|
...componentPreflightCommands(request),
|
|
26196
27689
|
"STAGED_MANAGEMENT=''",
|
|
26197
27690
|
...signalSafeCleanupTraps('test -z "$STAGED_MANAGEMENT" || rm -f "$STAGED_MANAGEMENT"'),
|
|
@@ -26205,14 +27698,38 @@ function buildRootUpgradeScript(request) {
|
|
|
26205
27698
|
}
|
|
26206
27699
|
function buildOfficialUpgradeCommand(request) {
|
|
26207
27700
|
const rootScript = buildRootUpgradeScript(request);
|
|
27701
|
+
const cleanupCommand = `rm -rf -- ${quoteEnvValue(dirname(request.helperPath))}`;
|
|
26208
27702
|
return [
|
|
26209
27703
|
"set -e",
|
|
26210
|
-
...signalSafeCleanupTraps(
|
|
27704
|
+
...signalSafeCleanupTraps(cleanupCommand),
|
|
26211
27705
|
'if [ "$(id -u)" -eq 0 ]; then ' + `bash -c ${quoteEnvValue(rootScript)}; ` + "else sudo -n true; " + `sudo -n bash -c ${quoteEnvValue(rootScript)}; fi`
|
|
26212
27706
|
].join("; ");
|
|
26213
27707
|
}
|
|
27708
|
+
async function prepareRemoteUpgradeHelperDirectory(ssh, helperPath) {
|
|
27709
|
+
const helperDirectory = dirname(helperPath);
|
|
27710
|
+
const command = [
|
|
27711
|
+
"set -e",
|
|
27712
|
+
"umask 077",
|
|
27713
|
+
`test ! -e ${quoteEnvValue(helperDirectory)}`,
|
|
27714
|
+
`install -d -m 700 -- ${quoteEnvValue(helperDirectory)}`
|
|
27715
|
+
].join("; ");
|
|
27716
|
+
const preparation = await ssh.exec(command, 30000);
|
|
27717
|
+
if (!preparation.success) {
|
|
27718
|
+
throw new Error(`Failed to prepare remote upgrade helper directory (exit ${preparation.code}): ${preparation.stderr.slice(-300)}`);
|
|
27719
|
+
}
|
|
27720
|
+
}
|
|
26214
27721
|
async function removeRemoteUpgradeHelper(ssh, helperPath) {
|
|
26215
|
-
const
|
|
27722
|
+
const helperDirectory = dirname(helperPath);
|
|
27723
|
+
const removeCommand = `rm -rf -- ${quoteEnvValue(helperDirectory)}`;
|
|
27724
|
+
const command = [
|
|
27725
|
+
'if [ "$(id -u)" -eq 0 ]; then',
|
|
27726
|
+
` ${removeCommand}`,
|
|
27727
|
+
"else",
|
|
27728
|
+
` ${removeCommand} || sudo -n ${removeCommand}`,
|
|
27729
|
+
"fi"
|
|
27730
|
+
].join(`
|
|
27731
|
+
`);
|
|
27732
|
+
const cleanup = await ssh.exec(command, 30000);
|
|
26216
27733
|
if (!cleanup.success) {
|
|
26217
27734
|
throw new Error(`Failed to remove remote upgrade helper (exit ${cleanup.code}): ${cleanup.stderr.slice(-300)}`);
|
|
26218
27735
|
}
|
|
@@ -26238,20 +27755,25 @@ function officialUpgradeOutcome(execution, executionError, cleanupError) {
|
|
|
26238
27755
|
throw remoteUpgradeFailure(execution);
|
|
26239
27756
|
return execution;
|
|
26240
27757
|
}
|
|
26241
|
-
async function executeOfficialUpgrade(ssh, helperPath, command) {
|
|
27758
|
+
async function executeOfficialUpgrade(ssh, helperPath, command, timeoutMs) {
|
|
26242
27759
|
let execution;
|
|
26243
27760
|
let executionError;
|
|
27761
|
+
let helperPrepared = false;
|
|
26244
27762
|
try {
|
|
27763
|
+
await prepareRemoteUpgradeHelperDirectory(ssh, helperPath);
|
|
27764
|
+
helperPrepared = true;
|
|
26245
27765
|
await ssh.uploadText(helperPath, release_assets_default, 384);
|
|
26246
|
-
execution = await ssh.exec(command,
|
|
27766
|
+
execution = await ssh.exec(command, timeoutMs);
|
|
26247
27767
|
} catch (error) {
|
|
26248
27768
|
executionError = error;
|
|
26249
27769
|
}
|
|
26250
27770
|
let cleanupError;
|
|
26251
|
-
|
|
26252
|
-
|
|
26253
|
-
|
|
26254
|
-
|
|
27771
|
+
if (helperPrepared) {
|
|
27772
|
+
try {
|
|
27773
|
+
await removeRemoteUpgradeHelper(ssh, helperPath);
|
|
27774
|
+
} catch (error) {
|
|
27775
|
+
cleanupError = error;
|
|
27776
|
+
}
|
|
26255
27777
|
}
|
|
26256
27778
|
return officialUpgradeOutcome(execution, executionError, cleanupError);
|
|
26257
27779
|
}
|
|
@@ -26420,6 +27942,7 @@ Actions: ping, setup, install, upgrade, diagnose, exec, troubleshoot, container_
|
|
|
26420
27942
|
storage_type: optional(stringEnum(["juicefs", "minio"]), "[install] Storage backend configurable through Admin"),
|
|
26421
27943
|
version: optional(Type.String(), "[upgrade] Specific version"),
|
|
26422
27944
|
edge_runtime_version: optional(Type.String(), "[upgrade] Exact independent Edge Runtime version"),
|
|
27945
|
+
artifact_transport: optional(stringEnum(["local", "remote"]), "[upgrade] Download verified release assets locally or on the server (default: remote)"),
|
|
26423
27946
|
github_proxy: optional(Type.String(), "[install/upgrade] Explicit GitHub proxy prefix, or direct/none"),
|
|
26424
27947
|
focus: optional(stringEnum(["all", "containers", "database", "network", "disk", "logs"]), "[troubleshoot] Focus area"),
|
|
26425
27948
|
container: optional(Type.String(), "[container_logs] Container name"),
|
|
@@ -26456,7 +27979,7 @@ Actions: ping, setup, install, upgrade, diagnose, exec, troubleshoot, container_
|
|
|
26456
27979
|
case "install": {
|
|
26457
27980
|
if (!args.public_domain)
|
|
26458
27981
|
throw new Error("'public_domain' required");
|
|
26459
|
-
const installId =
|
|
27982
|
+
const installId = randomUUID4();
|
|
26460
27983
|
const DIR = "/opt/supacloud";
|
|
26461
27984
|
const LOG = `/var/log/supacloud/install-${installId}.log`;
|
|
26462
27985
|
const STATUS = `/var/log/supacloud/install-${installId}.status`;
|
|
@@ -26546,19 +28069,35 @@ ${result.stderr.slice(-500)}`;
|
|
|
26546
28069
|
throw new Error("'version' is required with 'edge_runtime_version'");
|
|
26547
28070
|
}
|
|
26548
28071
|
if (edgeRuntimeVersion && version) {
|
|
26549
|
-
|
|
26550
|
-
|
|
28072
|
+
assertExactStableVersion2(version, "version");
|
|
28073
|
+
assertExactStableVersion2(edgeRuntimeVersion, "edge_runtime_version");
|
|
26551
28074
|
}
|
|
26552
28075
|
const validatedProxy = args.github_proxy ? assertSafeGithubProxy(args.github_proxy) : undefined;
|
|
28076
|
+
if (args.artifact_transport === "local") {
|
|
28077
|
+
if (!version || !edgeRuntimeVersion) {
|
|
28078
|
+
throw new Error("Local artifact transport requires exact 'version' and 'edge_runtime_version'");
|
|
28079
|
+
}
|
|
28080
|
+
assertExactStableVersion2(version, "version");
|
|
28081
|
+
assertExactStableVersion2(edgeRuntimeVersion, "edge_runtime_version");
|
|
28082
|
+
if (validatedProxy && !["direct", "none"].includes(validatedProxy.toLowerCase())) {
|
|
28083
|
+
throw new Error("Local artifact transport only supports direct GitHub downloads");
|
|
28084
|
+
}
|
|
28085
|
+
text = await executeLocalUpgradeTransfer(ssh, {
|
|
28086
|
+
managementVersion: version.replace(/^v/, ""),
|
|
28087
|
+
edgeRuntimeVersion: edgeRuntimeVersion.replace(/^v/, "")
|
|
28088
|
+
});
|
|
28089
|
+
break;
|
|
28090
|
+
}
|
|
26553
28091
|
const githubProxy = validatedProxy && !["direct", "none"].includes(validatedProxy.toLowerCase()) ? validatedProxy : undefined;
|
|
26554
|
-
const helperPath = `/tmp/.supacloud-release-assets-${
|
|
28092
|
+
const helperPath = `/tmp/.supacloud-release-assets-${randomUUID4()}/release_assets.sh`;
|
|
26555
28093
|
const cmd = buildOfficialUpgradeCommand({
|
|
26556
28094
|
version,
|
|
26557
28095
|
edgeRuntimeVersion,
|
|
26558
28096
|
githubProxy,
|
|
26559
28097
|
helperPath
|
|
26560
28098
|
});
|
|
26561
|
-
const
|
|
28099
|
+
const upgradeTimeoutMs = githubProxy ? PROXIED_UPGRADE_SSH_TIMEOUT_MS : DIRECT_UPGRADE_SSH_TIMEOUT_MS;
|
|
28100
|
+
const upgradeExecution = await executeOfficialUpgrade(ssh, helperPath, cmd, upgradeTimeoutMs);
|
|
26562
28101
|
const edgeBoundary = edgeRuntimeVersion ? "" : `
|
|
26563
28102
|
⚠️ Edge Runtime was not upgraded; provide --edge_runtime_version for a component transaction.`;
|
|
26564
28103
|
text = `✅ Upgrade done
|
|
@@ -27407,6 +28946,48 @@ Actions: routes, upsert_route, update_route, delete_route, config, get_certifica
|
|
|
27407
28946
|
return { content: [{ type: "text", text }] };
|
|
27408
28947
|
});
|
|
27409
28948
|
}
|
|
28949
|
+
// package.json
|
|
28950
|
+
var package_default = {
|
|
28951
|
+
name: "@supacloud/admin",
|
|
28952
|
+
version: "0.7.8",
|
|
28953
|
+
description: "Platform administration CLI for SupaCloud operators",
|
|
28954
|
+
type: "module",
|
|
28955
|
+
main: "./dist/index.js",
|
|
28956
|
+
bin: {
|
|
28957
|
+
"supacloud-admin": "dist/index.js"
|
|
28958
|
+
},
|
|
28959
|
+
files: [
|
|
28960
|
+
"dist",
|
|
28961
|
+
"README.md"
|
|
28962
|
+
],
|
|
28963
|
+
scripts: {
|
|
28964
|
+
dev: "bun run --watch src/index.ts",
|
|
28965
|
+
build: "bun build src/index.ts --outdir dist --target node",
|
|
28966
|
+
prepublishOnly: "bun run build",
|
|
28967
|
+
typecheck: "tsc --noEmit"
|
|
28968
|
+
},
|
|
28969
|
+
keywords: [
|
|
28970
|
+
"supacloud",
|
|
28971
|
+
"admin",
|
|
28972
|
+
"ssh",
|
|
28973
|
+
"ops"
|
|
28974
|
+
],
|
|
28975
|
+
license: "MIT",
|
|
28976
|
+
repository: {
|
|
28977
|
+
type: "git",
|
|
28978
|
+
url: "https://github.com/zuohuadong/supacloud.git",
|
|
28979
|
+
directory: "packages/admin"
|
|
28980
|
+
},
|
|
28981
|
+
dependencies: {
|
|
28982
|
+
"@sinclair/typebox": "^0.34.52",
|
|
28983
|
+
ssh2: "^1.17.0"
|
|
28984
|
+
},
|
|
28985
|
+
devDependencies: {
|
|
28986
|
+
"@types/bun": "^1.3.14",
|
|
28987
|
+
"@types/ssh2": "^1.15.5",
|
|
28988
|
+
typescript: "^7.0.2"
|
|
28989
|
+
}
|
|
28990
|
+
};
|
|
27410
28991
|
|
|
27411
28992
|
// src/index.ts
|
|
27412
28993
|
var adminProjectActionSchema = stringEnum([
|
|
@@ -27474,6 +29055,7 @@ USAGE
|
|
|
27474
29055
|
supacloud-admin <module> <action> [--flags]
|
|
27475
29056
|
supacloud-admin status
|
|
27476
29057
|
supacloud-admin --help
|
|
29058
|
+
supacloud-admin --version
|
|
27477
29059
|
|
|
27478
29060
|
EXPECTED CONTEXT
|
|
27479
29061
|
|
|
@@ -27659,6 +29241,10 @@ async function main() {
|
|
|
27659
29241
|
printHelp();
|
|
27660
29242
|
process.exit(0);
|
|
27661
29243
|
}
|
|
29244
|
+
if (args.length === 1 && args[0] === "--version") {
|
|
29245
|
+
console.log(package_default.version);
|
|
29246
|
+
return;
|
|
29247
|
+
}
|
|
27662
29248
|
const cliTools = createAdminTools();
|
|
27663
29249
|
if (args.length === 1 && cliTools[args[0]]) {
|
|
27664
29250
|
const result = await cliTools[args[0]].callback({});
|