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