@arcote.tech/arc-cli 0.7.21 → 0.7.22
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/dist/index.js +109 -68
- package/package.json +9 -9
- package/src/commands/platform-deploy.ts +42 -35
- package/src/deploy/deploy-env.ts +23 -46
- package/src/deploy/remote-state.ts +39 -20
- package/src/deploy/ssh.ts +49 -0
- package/src/platform/server.ts +20 -3
package/dist/index.js
CHANGED
|
@@ -38091,9 +38091,20 @@ async function streamToString(stream2) {
|
|
|
38091
38091
|
return "";
|
|
38092
38092
|
return new Response(stream2).text();
|
|
38093
38093
|
}
|
|
38094
|
+
function sshMuxArgs() {
|
|
38095
|
+
return [
|
|
38096
|
+
"-o",
|
|
38097
|
+
"ControlMaster=auto",
|
|
38098
|
+
"-o",
|
|
38099
|
+
`ControlPath=${join17(homedir3(), ".ssh", "cm-arc-%C")}`,
|
|
38100
|
+
"-o",
|
|
38101
|
+
"ControlPersist=120"
|
|
38102
|
+
];
|
|
38103
|
+
}
|
|
38094
38104
|
function baseSshArgs(target) {
|
|
38095
38105
|
const key = pickSshKey(target);
|
|
38096
38106
|
const args = [
|
|
38107
|
+
...sshMuxArgs(),
|
|
38097
38108
|
"-o",
|
|
38098
38109
|
"BatchMode=yes",
|
|
38099
38110
|
"-o",
|
|
@@ -38157,6 +38168,7 @@ async function canSsh(target) {
|
|
|
38157
38168
|
async function scpUpload(target, localPath, remotePath) {
|
|
38158
38169
|
const key = pickSshKey(target);
|
|
38159
38170
|
const args = [
|
|
38171
|
+
...sshMuxArgs(),
|
|
38160
38172
|
"-o",
|
|
38161
38173
|
"BatchMode=yes",
|
|
38162
38174
|
"-o",
|
|
@@ -38181,6 +38193,22 @@ async function scpUpload(target, localPath, remotePath) {
|
|
|
38181
38193
|
throw new Error(`scp failed (${exitCode}): ${stderr}`);
|
|
38182
38194
|
}
|
|
38183
38195
|
}
|
|
38196
|
+
async function closeSshMaster(target) {
|
|
38197
|
+
try {
|
|
38198
|
+
const proc2 = spawn3({
|
|
38199
|
+
cmd: [
|
|
38200
|
+
"ssh",
|
|
38201
|
+
...baseSshArgs(target),
|
|
38202
|
+
"-O",
|
|
38203
|
+
"exit",
|
|
38204
|
+
`${target.user}@${target.host}`
|
|
38205
|
+
],
|
|
38206
|
+
stdout: "ignore",
|
|
38207
|
+
stderr: "ignore"
|
|
38208
|
+
});
|
|
38209
|
+
await proc2.exited;
|
|
38210
|
+
} catch {}
|
|
38211
|
+
}
|
|
38184
38212
|
|
|
38185
38213
|
// src/deploy/remote-state.ts
|
|
38186
38214
|
var STATE_MARKER_PATH = "/opt/arc/.arc-state.json";
|
|
@@ -38188,38 +38216,55 @@ async function detectRemoteState(cfg) {
|
|
|
38188
38216
|
if (cfg.target.host === "PENDING_TERRAFORM" || !cfg.target.host) {
|
|
38189
38217
|
return { kind: "unreachable", reason: "target.host not yet set" };
|
|
38190
38218
|
}
|
|
38191
|
-
|
|
38219
|
+
const composeDir = cfg.target.remoteDir;
|
|
38220
|
+
const probe = [
|
|
38221
|
+
`command -v docker >/dev/null 2>&1 && echo DOCKER=1 || echo DOCKER=0`,
|
|
38222
|
+
`echo '---PS---'`,
|
|
38223
|
+
`[ -f ${composeDir}/docker-compose.yml ] && (cd ${composeDir} && docker compose ps --format '{{.Service}}' 2>/dev/null) || true`,
|
|
38224
|
+
`echo '---MARKER---'`,
|
|
38225
|
+
`cat ${STATE_MARKER_PATH} 2>/dev/null || true`
|
|
38226
|
+
].join(`
|
|
38227
|
+
`);
|
|
38228
|
+
const res = await sshExec(cfg.target, probe, { quiet: true });
|
|
38229
|
+
if (res.exitCode !== 0) {
|
|
38192
38230
|
if (await canSsh({ ...cfg.target, user: "root" })) {
|
|
38193
38231
|
return { kind: "no-docker" };
|
|
38194
38232
|
}
|
|
38195
38233
|
return { kind: "unreachable", reason: "ssh connection failed" };
|
|
38196
38234
|
}
|
|
38197
|
-
const
|
|
38198
|
-
|
|
38199
|
-
});
|
|
38200
|
-
if (dockerCheck.exitCode !== 0) {
|
|
38235
|
+
const out = res.stdout;
|
|
38236
|
+
if (!out.includes("DOCKER=1")) {
|
|
38201
38237
|
return { kind: "no-docker" };
|
|
38202
38238
|
}
|
|
38203
|
-
const
|
|
38204
|
-
|
|
38205
|
-
if (psCheck.exitCode !== 0 || psCheck.stdout.trim() === "") {
|
|
38239
|
+
const psSection = sectionBetween(out, "---PS---", "---MARKER---").trim();
|
|
38240
|
+
if (psSection === "") {
|
|
38206
38241
|
return { kind: "no-stack" };
|
|
38207
38242
|
}
|
|
38208
|
-
const running =
|
|
38243
|
+
const running = psSection.split(`
|
|
38209
38244
|
`).map((l) => l.trim()).filter((l) => l.startsWith("arc-")).map((l) => l.replace(/^arc-/, ""));
|
|
38210
|
-
const
|
|
38211
|
-
quiet: true
|
|
38212
|
-
});
|
|
38245
|
+
const markerSection = afterMarker(out, "---MARKER---").trim();
|
|
38213
38246
|
let marker = null;
|
|
38214
|
-
if (
|
|
38247
|
+
if (markerSection) {
|
|
38215
38248
|
try {
|
|
38216
|
-
marker = JSON.parse(
|
|
38249
|
+
marker = JSON.parse(markerSection);
|
|
38217
38250
|
} catch {
|
|
38218
38251
|
marker = null;
|
|
38219
38252
|
}
|
|
38220
38253
|
}
|
|
38221
38254
|
return { kind: "ready", runningEnvs: running, marker };
|
|
38222
38255
|
}
|
|
38256
|
+
function sectionBetween(s, start, end) {
|
|
38257
|
+
const i = s.indexOf(start);
|
|
38258
|
+
if (i < 0)
|
|
38259
|
+
return "";
|
|
38260
|
+
const from = i + start.length;
|
|
38261
|
+
const j = s.indexOf(end, from);
|
|
38262
|
+
return s.slice(from, j < 0 ? undefined : j);
|
|
38263
|
+
}
|
|
38264
|
+
function afterMarker(s, marker) {
|
|
38265
|
+
const i = s.indexOf(marker);
|
|
38266
|
+
return i < 0 ? "" : s.slice(i + marker.length);
|
|
38267
|
+
}
|
|
38223
38268
|
async function writeStateMarker(target, marker) {
|
|
38224
38269
|
const json = JSON.stringify(marker, null, 2);
|
|
38225
38270
|
await assertExec(target, `sudo tee ${STATE_MARKER_PATH} > /dev/null <<'JSON'
|
|
@@ -38476,31 +38521,20 @@ async function updateEnvDeployment(opts) {
|
|
|
38476
38521
|
const envVarName = `ARC_IMAGE_${upperEnv}`;
|
|
38477
38522
|
const envPath = `${cfg.target.remoteDir}/.env`;
|
|
38478
38523
|
const escapedRef = fullRef.replace(/"/g, "\\\"");
|
|
38479
|
-
const updateScript = [
|
|
38480
|
-
`touch ${envPath} && `,
|
|
38481
|
-
`awk -v line="${envVarName}=${escapedRef}" -v key="${envVarName}=" '`,
|
|
38482
|
-
` BEGIN { replaced=0 } `,
|
|
38483
|
-
` $0 ~ "^"key { print line; replaced=1; next } `,
|
|
38484
|
-
` { print } `,
|
|
38485
|
-
` END { if (!replaced) print line } `,
|
|
38486
|
-
`' ${envPath} > ${envPath}.tmp && mv ${envPath}.tmp ${envPath}`
|
|
38487
|
-
].join("");
|
|
38488
|
-
await assertExec(target, updateScript);
|
|
38489
|
-
await assertExec(target, `cd ${cfg.target.remoteDir} && docker compose pull arc-${env2}`);
|
|
38490
|
-
await assertExec(target, `cd ${cfg.target.remoteDir} && docker compose up -d arc-${env2}`);
|
|
38491
38524
|
const retain = opts.retainImages ?? 3;
|
|
38492
38525
|
const imageBaseName = imageBaseFromRef(fullRef);
|
|
38493
|
-
|
|
38494
|
-
|
|
38495
|
-
|
|
38496
|
-
|
|
38497
|
-
|
|
38498
|
-
|
|
38499
|
-
|
|
38500
|
-
|
|
38501
|
-
|
|
38502
|
-
|
|
38503
|
-
|
|
38526
|
+
const pruneCmd = imageBaseName ? `docker images "${imageBaseName}" --format "{{.Repository}}:{{.Tag}} {{.CreatedAt}}" | grep -v ":latest " | sort -k2,3 -r | tail -n +${retain + 1} | awk '{print $1}' | xargs -r docker rmi 2>/dev/null || true` : `true`;
|
|
38527
|
+
const script = [
|
|
38528
|
+
`set -e`,
|
|
38529
|
+
`cd ${cfg.target.remoteDir}`,
|
|
38530
|
+
`touch ${envPath}`,
|
|
38531
|
+
`awk -v line="${envVarName}=${escapedRef}" -v key="${envVarName}=" 'BEGIN{replaced=0} $0 ~ "^"key {print line; replaced=1; next} {print} END{if(!replaced) print line}' ${envPath} > ${envPath}.tmp && mv ${envPath}.tmp ${envPath}`,
|
|
38532
|
+
`docker compose pull arc-${env2}`,
|
|
38533
|
+
`docker compose up -d arc-${env2}`,
|
|
38534
|
+
pruneCmd
|
|
38535
|
+
].join(`
|
|
38536
|
+
`);
|
|
38537
|
+
await assertExec(target, script);
|
|
38504
38538
|
const ok2 = await healthCheck(target, env2);
|
|
38505
38539
|
return { env: env2, fullRef, redeployed: ok2 };
|
|
38506
38540
|
}
|
|
@@ -39541,38 +39575,42 @@ async function platformDeploy(envArg, options = {}) {
|
|
|
39541
39575
|
}
|
|
39542
39576
|
}
|
|
39543
39577
|
log2("Inspecting remote server...");
|
|
39544
|
-
|
|
39545
|
-
|
|
39546
|
-
|
|
39547
|
-
|
|
39548
|
-
|
|
39549
|
-
|
|
39550
|
-
rootDir: ws.rootDir,
|
|
39551
|
-
state,
|
|
39552
|
-
cliVersion,
|
|
39553
|
-
configHash,
|
|
39554
|
-
forceAnsible: options.forceBootstrap
|
|
39555
|
-
});
|
|
39556
|
-
if (!options.imageTag) {
|
|
39557
|
-
log2(`Logging in to ${cfg.registry.domain}...`);
|
|
39558
|
-
await dockerLogin(cfg.registry);
|
|
39559
|
-
log2(`Pushing ${fullRef}...`);
|
|
39560
|
-
await dockerPush(fullRef);
|
|
39561
|
-
ok("Image pushed");
|
|
39562
|
-
}
|
|
39563
|
-
for (const env2 of targetEnvs) {
|
|
39564
|
-
log2(`Updating env "${env2}"...`);
|
|
39565
|
-
const outcome = await updateEnvDeployment({
|
|
39566
|
-
target: cfg.target,
|
|
39578
|
+
try {
|
|
39579
|
+
const state = await detectRemoteState(cfg);
|
|
39580
|
+
log2(`Remote state: ${state.kind}`);
|
|
39581
|
+
const cliVersion = readCliVersion2();
|
|
39582
|
+
const configHash = await hashDeployConfig(ws.rootDir);
|
|
39583
|
+
await bootstrap({
|
|
39567
39584
|
cfg,
|
|
39568
|
-
|
|
39569
|
-
|
|
39585
|
+
rootDir: ws.rootDir,
|
|
39586
|
+
state,
|
|
39587
|
+
cliVersion,
|
|
39588
|
+
configHash,
|
|
39589
|
+
forceAnsible: options.forceBootstrap
|
|
39570
39590
|
});
|
|
39571
|
-
if (
|
|
39572
|
-
|
|
39573
|
-
|
|
39574
|
-
|
|
39591
|
+
if (!options.imageTag) {
|
|
39592
|
+
log2(`Logging in to ${cfg.registry.domain}...`);
|
|
39593
|
+
await dockerLogin(cfg.registry);
|
|
39594
|
+
log2(`Pushing ${fullRef}...`);
|
|
39595
|
+
await dockerPush(fullRef);
|
|
39596
|
+
ok("Image pushed");
|
|
39597
|
+
}
|
|
39598
|
+
for (const env2 of targetEnvs) {
|
|
39599
|
+
log2(`Updating env "${env2}"...`);
|
|
39600
|
+
const outcome = await updateEnvDeployment({
|
|
39601
|
+
target: cfg.target,
|
|
39602
|
+
cfg,
|
|
39603
|
+
env: env2,
|
|
39604
|
+
fullRef
|
|
39605
|
+
});
|
|
39606
|
+
if (outcome.redeployed) {
|
|
39607
|
+
ok(`${env2}: live at ${fullRef}`);
|
|
39608
|
+
} else {
|
|
39609
|
+
err(`${env2}: deployed but health check did not pass within retries \u2014 check \`docker logs arc-${env2}\``);
|
|
39610
|
+
}
|
|
39575
39611
|
}
|
|
39612
|
+
} finally {
|
|
39613
|
+
await closeSshMaster(cfg.target);
|
|
39576
39614
|
}
|
|
39577
39615
|
}
|
|
39578
39616
|
function readCliVersion2() {
|
|
@@ -40851,6 +40889,7 @@ async function createArcServer(config) {
|
|
|
40851
40889
|
const cronScheduler = new CronScheduler(contextHandler);
|
|
40852
40890
|
cronScheduler.start();
|
|
40853
40891
|
const connectionManager = new ConnectionManager;
|
|
40892
|
+
const coep = config.coep ?? "unsafe-none";
|
|
40854
40893
|
function buildCorsHeaders(req) {
|
|
40855
40894
|
const origin = req?.headers.get("Origin") || "*";
|
|
40856
40895
|
return {
|
|
@@ -40859,7 +40898,7 @@ async function createArcServer(config) {
|
|
|
40859
40898
|
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Arc-Scope, X-Arc-Tokens",
|
|
40860
40899
|
"Access-Control-Allow-Credentials": "true",
|
|
40861
40900
|
"Cross-Origin-Opener-Policy": "same-origin",
|
|
40862
|
-
"Cross-Origin-Embedder-Policy":
|
|
40901
|
+
"Cross-Origin-Embedder-Policy": coep,
|
|
40863
40902
|
"Cross-Origin-Resource-Policy": "cross-origin"
|
|
40864
40903
|
};
|
|
40865
40904
|
}
|
|
@@ -41320,6 +41359,7 @@ function spaFallbackHandler(getShellHtml) {
|
|
|
41320
41359
|
}
|
|
41321
41360
|
async function startPlatformServer(opts) {
|
|
41322
41361
|
const { ws, port, devMode, context: context2 } = opts;
|
|
41362
|
+
const coep = process.env.ARC_COEP ?? opts.coep ?? "unsafe-none";
|
|
41323
41363
|
ensureModuleSigSecret(ws, !!devMode);
|
|
41324
41364
|
let telemetry;
|
|
41325
41365
|
let telemetryShutdown;
|
|
@@ -41368,7 +41408,7 @@ async function startPlatformServer(opts) {
|
|
|
41368
41408
|
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
|
41369
41409
|
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Arc-Tokens",
|
|
41370
41410
|
"Cross-Origin-Opener-Policy": "same-origin",
|
|
41371
|
-
"Cross-Origin-Embedder-Policy": "
|
|
41411
|
+
"Cross-Origin-Embedder-Policy": coep ?? "unsafe-none",
|
|
41372
41412
|
"Cross-Origin-Resource-Policy": "cross-origin"
|
|
41373
41413
|
};
|
|
41374
41414
|
const server = Bun.serve({
|
|
@@ -41420,6 +41460,7 @@ async function startPlatformServer(opts) {
|
|
|
41420
41460
|
context: context2,
|
|
41421
41461
|
dbAdapterFactory,
|
|
41422
41462
|
port,
|
|
41463
|
+
coep,
|
|
41423
41464
|
httpHandlers: [
|
|
41424
41465
|
apiEndpointsHandler(ws, getManifest, null, moduleAccessMap, telemetry),
|
|
41425
41466
|
devReloadHandler(sseClients),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcote.tech/arc-cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.22",
|
|
4
4
|
"description": "CLI tool for Arc framework",
|
|
5
5
|
"module": "index.ts",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"build": "bun build --target=bun ./src/index.ts --outdir=dist --external @arcote.tech/arc --external @arcote.tech/arc-ds --external @arcote.tech/arc-react --external @arcote.tech/platform --external '@opentelemetry/*' && chmod +x dist/index.js"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@arcote.tech/arc": "^0.7.
|
|
16
|
-
"@arcote.tech/arc-ds": "^0.7.
|
|
17
|
-
"@arcote.tech/arc-react": "^0.7.
|
|
18
|
-
"@arcote.tech/arc-host": "^0.7.
|
|
19
|
-
"@arcote.tech/arc-adapter-db-sqlite": "^0.7.
|
|
20
|
-
"@arcote.tech/arc-adapter-db-postgres": "^0.7.
|
|
21
|
-
"@arcote.tech/arc-otel": "^0.7.
|
|
15
|
+
"@arcote.tech/arc": "^0.7.22",
|
|
16
|
+
"@arcote.tech/arc-ds": "^0.7.22",
|
|
17
|
+
"@arcote.tech/arc-react": "^0.7.22",
|
|
18
|
+
"@arcote.tech/arc-host": "^0.7.22",
|
|
19
|
+
"@arcote.tech/arc-adapter-db-sqlite": "^0.7.22",
|
|
20
|
+
"@arcote.tech/arc-adapter-db-postgres": "^0.7.22",
|
|
21
|
+
"@arcote.tech/arc-otel": "^0.7.22",
|
|
22
22
|
"@opentelemetry/api": "^1.9.0",
|
|
23
23
|
"@opentelemetry/api-logs": "^0.57.0",
|
|
24
24
|
"@opentelemetry/core": "^1.30.0",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"@opentelemetry/sdk-trace-base": "^1.30.0",
|
|
32
32
|
"@opentelemetry/sdk-trace-node": "^1.30.0",
|
|
33
33
|
"@opentelemetry/semantic-conventions": "^1.27.0",
|
|
34
|
-
"@arcote.tech/platform": "^0.7.
|
|
34
|
+
"@arcote.tech/platform": "^0.7.22",
|
|
35
35
|
"@clack/prompts": "^0.9.0",
|
|
36
36
|
"commander": "^11.1.0",
|
|
37
37
|
"chokidar": "^3.5.3",
|
|
@@ -13,6 +13,7 @@ import { ensurePersistedSecret } from "../deploy/env-file";
|
|
|
13
13
|
import { buildImage, sanitizeImageName } from "../deploy/image";
|
|
14
14
|
import { detectRemoteState } from "../deploy/remote-state";
|
|
15
15
|
import { dockerLogin, dockerPush } from "../deploy/registry";
|
|
16
|
+
import { closeSshMaster } from "../deploy/ssh";
|
|
16
17
|
import { runSurvey } from "../deploy/survey";
|
|
17
18
|
import {
|
|
18
19
|
buildAll,
|
|
@@ -155,45 +156,51 @@ export async function platformDeploy(
|
|
|
155
156
|
// before dockerLogin/dockerPush — without registry container + Caddy vhost
|
|
156
157
|
// for it, dockerLogin would TLS-fail.
|
|
157
158
|
log("Inspecting remote server...");
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const cliVersion = readCliVersion();
|
|
162
|
-
const configHash = await hashDeployConfig(ws.rootDir);
|
|
163
|
-
await bootstrap({
|
|
164
|
-
cfg,
|
|
165
|
-
rootDir: ws.rootDir,
|
|
166
|
-
state,
|
|
167
|
-
cliVersion,
|
|
168
|
-
configHash,
|
|
169
|
-
forceAnsible: options.forceBootstrap,
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
// 5. Push the image to the now-running registry.
|
|
173
|
-
if (!options.imageTag) {
|
|
174
|
-
log(`Logging in to ${cfg.registry.domain}...`);
|
|
175
|
-
await dockerLogin(cfg.registry);
|
|
176
|
-
log(`Pushing ${fullRef}...`);
|
|
177
|
-
await dockerPush(fullRef);
|
|
178
|
-
ok("Image pushed");
|
|
179
|
-
}
|
|
159
|
+
try {
|
|
160
|
+
const state = await detectRemoteState(cfg);
|
|
161
|
+
log(`Remote state: ${state.kind}`);
|
|
180
162
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const outcome = await updateEnvDeployment({
|
|
185
|
-
target: cfg.target,
|
|
163
|
+
const cliVersion = readCliVersion();
|
|
164
|
+
const configHash = await hashDeployConfig(ws.rootDir);
|
|
165
|
+
await bootstrap({
|
|
186
166
|
cfg,
|
|
187
|
-
|
|
188
|
-
|
|
167
|
+
rootDir: ws.rootDir,
|
|
168
|
+
state,
|
|
169
|
+
cliVersion,
|
|
170
|
+
configHash,
|
|
171
|
+
forceAnsible: options.forceBootstrap,
|
|
189
172
|
});
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
);
|
|
173
|
+
|
|
174
|
+
// 5. Push the image to the now-running registry.
|
|
175
|
+
if (!options.imageTag) {
|
|
176
|
+
log(`Logging in to ${cfg.registry.domain}...`);
|
|
177
|
+
await dockerLogin(cfg.registry);
|
|
178
|
+
log(`Pushing ${fullRef}...`);
|
|
179
|
+
await dockerPush(fullRef);
|
|
180
|
+
ok("Image pushed");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 6. Update each env — atomic /opt/arc/.env line + pull + up + health
|
|
184
|
+
for (const env of targetEnvs) {
|
|
185
|
+
log(`Updating env "${env}"...`);
|
|
186
|
+
const outcome = await updateEnvDeployment({
|
|
187
|
+
target: cfg.target,
|
|
188
|
+
cfg,
|
|
189
|
+
env,
|
|
190
|
+
fullRef,
|
|
191
|
+
});
|
|
192
|
+
if (outcome.redeployed) {
|
|
193
|
+
ok(`${env}: live at ${fullRef}`);
|
|
194
|
+
} else {
|
|
195
|
+
err(
|
|
196
|
+
`${env}: deployed but health check did not pass within retries — check \`docker logs arc-${env}\``,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
196
199
|
}
|
|
200
|
+
} finally {
|
|
201
|
+
// Tear down the multiplexed SSH master so the control socket doesn't
|
|
202
|
+
// linger for ControlPersist seconds after the process exits.
|
|
203
|
+
await closeSshMaster(cfg.target);
|
|
197
204
|
}
|
|
198
205
|
}
|
|
199
206
|
|
package/src/deploy/deploy-env.ts
CHANGED
|
@@ -48,58 +48,35 @@ export async function updateEnvDeployment(
|
|
|
48
48
|
const { target, cfg, env, fullRef } = opts;
|
|
49
49
|
const upperEnv = env.toUpperCase().replace(/-/g, "_");
|
|
50
50
|
const envVarName = `ARC_IMAGE_${upperEnv}`;
|
|
51
|
-
|
|
52
|
-
// Step 1 — atomic .env line update. Use awk to either replace the existing
|
|
53
|
-
// line or append a new one, write to .env.tmp, then mv. mv on the same fs
|
|
54
|
-
// is atomic, so concurrent reads see either the old or new file, never a
|
|
55
|
-
// partial write.
|
|
56
51
|
const envPath = `${cfg.target.remoteDir}/.env`;
|
|
57
52
|
const escapedRef = fullRef.replace(/"/g, '\\"');
|
|
58
|
-
const updateScript = [
|
|
59
|
-
`touch ${envPath} && `,
|
|
60
|
-
`awk -v line="${envVarName}=${escapedRef}" -v key="${envVarName}=" '`,
|
|
61
|
-
` BEGIN { replaced=0 } `,
|
|
62
|
-
` $0 ~ "^"key { print line; replaced=1; next } `,
|
|
63
|
-
` { print } `,
|
|
64
|
-
` END { if (!replaced) print line } `,
|
|
65
|
-
`' ${envPath} > ${envPath}.tmp && mv ${envPath}.tmp ${envPath}`,
|
|
66
|
-
].join("");
|
|
67
|
-
await assertExec(target, updateScript);
|
|
68
|
-
|
|
69
|
-
// Step 2 — pull. May be a no-op if `fullRef` was already pulled previously.
|
|
70
|
-
await assertExec(
|
|
71
|
-
target,
|
|
72
|
-
`cd ${cfg.target.remoteDir} && docker compose pull arc-${env}`,
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
// Step 3 — recreate. `up -d` is a no-op if the container is already running
|
|
76
|
-
// with the requested image; otherwise it recreates. Either way the container
|
|
77
|
-
// ends in "running" state with the desired image.
|
|
78
|
-
await assertExec(
|
|
79
|
-
target,
|
|
80
|
-
`cd ${cfg.target.remoteDir} && docker compose up -d arc-${env}`,
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
// Step 4 — retention. Find image tags for this workspace, sort by created
|
|
84
|
-
// time (newest first), drop the top N, delete the rest. `:latest` is moved
|
|
85
|
-
// by docker push and stays — we never explicitly delete it.
|
|
86
53
|
const retain = opts.retainImages ?? 3;
|
|
87
54
|
const imageBaseName = imageBaseFromRef(fullRef);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
55
|
+
|
|
56
|
+
// Steps 1-4 batched into ONE SSH round-trip (was four, each a fresh ssh
|
|
57
|
+
// handshake). `set -e` aborts on a real failure (e.g. pull) before recreate.
|
|
58
|
+
// 1. atomic .env line update — awk rewrites to .tmp then `mv` (same-fs mv is
|
|
59
|
+
// atomic, so concurrent reads never see a partial file).
|
|
60
|
+
// 2. pull new layers, 3. recreate (`up -d` no-ops if image unchanged).
|
|
61
|
+
// 4. retention prune — best-effort (`|| true`), never fails the deploy.
|
|
62
|
+
const pruneCmd = imageBaseName
|
|
63
|
+
? `docker images "${imageBaseName}" --format "{{.Repository}}:{{.Tag}} {{.CreatedAt}}" | grep -v ":latest " | sort -k2,3 -r | tail -n +${retain + 1} | awk '{print $1}' | xargs -r docker rmi 2>/dev/null || true`
|
|
64
|
+
: `true`;
|
|
65
|
+
const script = [
|
|
66
|
+
`set -e`,
|
|
67
|
+
`cd ${cfg.target.remoteDir}`,
|
|
68
|
+
`touch ${envPath}`,
|
|
69
|
+
`awk -v line="${envVarName}=${escapedRef}" -v key="${envVarName}=" 'BEGIN{replaced=0} $0 ~ "^"key {print line; replaced=1; next} {print} END{if(!replaced) print line}' ${envPath} > ${envPath}.tmp && mv ${envPath}.tmp ${envPath}`,
|
|
70
|
+
`docker compose pull arc-${env}`,
|
|
71
|
+
`docker compose up -d arc-${env}`,
|
|
72
|
+
pruneCmd,
|
|
73
|
+
].join("\n");
|
|
74
|
+
await assertExec(target, script);
|
|
99
75
|
|
|
100
76
|
// Step 5 — health check. arc-<env> exposes 5005 inside the docker network;
|
|
101
|
-
//
|
|
102
|
-
//
|
|
77
|
+
// we reach it via `docker exec arc-<env> ...`. Polls separately (must retry
|
|
78
|
+
// until the container is up); on a no-op the already-running container passes
|
|
79
|
+
// on the first probe, so this stays a single round-trip.
|
|
103
80
|
const ok = await healthCheck(target, env);
|
|
104
81
|
|
|
105
82
|
return { env, fullRef, redeployed: ok };
|
|
@@ -36,8 +36,22 @@ export async function detectRemoteState(
|
|
|
36
36
|
return { kind: "unreachable", reason: "target.host not yet set" };
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
const composeDir = cfg.target.remoteDir;
|
|
40
|
+
// ONE round-trip instead of four (canSsh + docker check + compose ps + cat
|
|
41
|
+
// marker). The three checks run in a single delimited script; each swallows
|
|
42
|
+
// its own errors (`|| true`), so the script always exits 0 when it RUNS —
|
|
43
|
+
// a non-zero SSH exit therefore means the CONNECTION failed, not the probe.
|
|
44
|
+
const probe = [
|
|
45
|
+
`command -v docker >/dev/null 2>&1 && echo DOCKER=1 || echo DOCKER=0`,
|
|
46
|
+
`echo '---PS---'`,
|
|
47
|
+
`[ -f ${composeDir}/docker-compose.yml ] && (cd ${composeDir} && docker compose ps --format '{{.Service}}' 2>/dev/null) || true`,
|
|
48
|
+
`echo '---MARKER---'`,
|
|
49
|
+
`cat ${STATE_MARKER_PATH} 2>/dev/null || true`,
|
|
50
|
+
].join("\n");
|
|
51
|
+
|
|
52
|
+
const res = await sshExec(cfg.target, probe, { quiet: true });
|
|
53
|
+
if (res.exitCode !== 0) {
|
|
54
|
+
// On a freshly provisioned VM only root exists — ansible creates `deploy`
|
|
41
55
|
// later. If root SSH works but the configured user doesn't, treat as
|
|
42
56
|
// no-docker so bootstrap re-runs ansible (idempotent) instead of spinning
|
|
43
57
|
// up a duplicate server via terraform.
|
|
@@ -47,36 +61,26 @@ export async function detectRemoteState(
|
|
|
47
61
|
return { kind: "unreachable", reason: "ssh connection failed" };
|
|
48
62
|
}
|
|
49
63
|
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
});
|
|
53
|
-
if (dockerCheck.exitCode !== 0) {
|
|
64
|
+
const out = res.stdout;
|
|
65
|
+
if (!out.includes("DOCKER=1")) {
|
|
54
66
|
return { kind: "no-docker" };
|
|
55
67
|
}
|
|
56
68
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
cfg.target,
|
|
60
|
-
`test -f ${composeDir}/docker-compose.yml && cd ${composeDir} && docker compose ps --format '{{.Service}}' || true`,
|
|
61
|
-
{ quiet: true },
|
|
62
|
-
);
|
|
63
|
-
if (psCheck.exitCode !== 0 || psCheck.stdout.trim() === "") {
|
|
69
|
+
const psSection = sectionBetween(out, "---PS---", "---MARKER---").trim();
|
|
70
|
+
if (psSection === "") {
|
|
64
71
|
return { kind: "no-stack" };
|
|
65
72
|
}
|
|
66
|
-
|
|
67
|
-
const running = psCheck.stdout
|
|
73
|
+
const running = psSection
|
|
68
74
|
.split("\n")
|
|
69
75
|
.map((l) => l.trim())
|
|
70
76
|
.filter((l) => l.startsWith("arc-"))
|
|
71
77
|
.map((l) => l.replace(/^arc-/, ""));
|
|
72
78
|
|
|
73
|
-
const
|
|
74
|
-
quiet: true,
|
|
75
|
-
});
|
|
79
|
+
const markerSection = afterMarker(out, "---MARKER---").trim();
|
|
76
80
|
let marker: RemoteStateMarker | null = null;
|
|
77
|
-
if (
|
|
81
|
+
if (markerSection) {
|
|
78
82
|
try {
|
|
79
|
-
marker = JSON.parse(
|
|
83
|
+
marker = JSON.parse(markerSection) as RemoteStateMarker;
|
|
80
84
|
} catch {
|
|
81
85
|
marker = null;
|
|
82
86
|
}
|
|
@@ -85,6 +89,21 @@ export async function detectRemoteState(
|
|
|
85
89
|
return { kind: "ready", runningEnvs: running, marker };
|
|
86
90
|
}
|
|
87
91
|
|
|
92
|
+
/** Substring strictly between two delimiters (empty if either is absent). */
|
|
93
|
+
function sectionBetween(s: string, start: string, end: string): string {
|
|
94
|
+
const i = s.indexOf(start);
|
|
95
|
+
if (i < 0) return "";
|
|
96
|
+
const from = i + start.length;
|
|
97
|
+
const j = s.indexOf(end, from);
|
|
98
|
+
return s.slice(from, j < 0 ? undefined : j);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Everything after the last delimiter (empty if absent). */
|
|
102
|
+
function afterMarker(s: string, marker: string): string {
|
|
103
|
+
const i = s.indexOf(marker);
|
|
104
|
+
return i < 0 ? "" : s.slice(i + marker.length);
|
|
105
|
+
}
|
|
106
|
+
|
|
88
107
|
/** Write the state marker file on the remote host. */
|
|
89
108
|
export async function writeStateMarker(
|
|
90
109
|
target: DeployTarget,
|
package/src/deploy/ssh.ts
CHANGED
|
@@ -43,6 +43,27 @@ export interface SshExecResult {
|
|
|
43
43
|
exitCode: number;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* SSH connection multiplexing. Without this every remote command spawns a
|
|
48
|
+
* fresh `ssh` (full TCP + auth handshake) — a single deploy fires ~15-25 of
|
|
49
|
+
* them, so the handshake overhead alone runs into minutes. `ControlMaster=auto`
|
|
50
|
+
* makes the first connection open a master socket; every later ssh/scp to the
|
|
51
|
+
* same host reuses it with no handshake. `ControlPersist` keeps the master
|
|
52
|
+
* alive for the whole (short) deploy. The socket lives in ~/.ssh (private) and
|
|
53
|
+
* `%C` is ssh's hash of host+port+user — short enough for the ~104-byte socket
|
|
54
|
+
* path limit, and distinct per target (so the root fallback gets its own).
|
|
55
|
+
*/
|
|
56
|
+
function sshMuxArgs(): string[] {
|
|
57
|
+
return [
|
|
58
|
+
"-o",
|
|
59
|
+
"ControlMaster=auto",
|
|
60
|
+
"-o",
|
|
61
|
+
`ControlPath=${join(homedir(), ".ssh", "cm-arc-%C")}`,
|
|
62
|
+
"-o",
|
|
63
|
+
"ControlPersist=120",
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
46
67
|
export function baseSshArgs(target: DeployTarget): string[] {
|
|
47
68
|
// Pin to a single identity to avoid "Too many authentication failures":
|
|
48
69
|
// the server's MaxAuthTries=3 (set by ansible) trips when ssh-agent has
|
|
@@ -50,6 +71,7 @@ export function baseSshArgs(target: DeployTarget): string[] {
|
|
|
50
71
|
// PreferredAuthentications=publickey skips gssapi/keyboard prompts entirely.
|
|
51
72
|
const key = pickSshKey(target);
|
|
52
73
|
const args = [
|
|
74
|
+
...sshMuxArgs(),
|
|
53
75
|
"-o",
|
|
54
76
|
"BatchMode=yes",
|
|
55
77
|
"-o",
|
|
@@ -159,6 +181,8 @@ export async function scpUpload(
|
|
|
159
181
|
): Promise<void> {
|
|
160
182
|
const key = pickSshKey(target);
|
|
161
183
|
const args = [
|
|
184
|
+
// Same ControlPath as baseSshArgs — scp reuses the ssh master socket.
|
|
185
|
+
...sshMuxArgs(),
|
|
162
186
|
"-o",
|
|
163
187
|
"BatchMode=yes",
|
|
164
188
|
"-o",
|
|
@@ -184,3 +208,28 @@ export async function scpUpload(
|
|
|
184
208
|
throw new Error(`scp failed (${exitCode}): ${stderr}`);
|
|
185
209
|
}
|
|
186
210
|
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Close the multiplexed SSH master connection (best-effort). Call once at the
|
|
214
|
+
* end of a deploy so the control socket doesn't linger for ControlPersist
|
|
215
|
+
* seconds after the process exits. Safe to call even if no master was opened —
|
|
216
|
+
* `ssh -O exit` against a missing socket just returns non-zero, which we ignore.
|
|
217
|
+
*/
|
|
218
|
+
export async function closeSshMaster(target: DeployTarget): Promise<void> {
|
|
219
|
+
try {
|
|
220
|
+
const proc = spawn({
|
|
221
|
+
cmd: [
|
|
222
|
+
"ssh",
|
|
223
|
+
...baseSshArgs(target),
|
|
224
|
+
"-O",
|
|
225
|
+
"exit",
|
|
226
|
+
`${target.user}@${target.host}`,
|
|
227
|
+
],
|
|
228
|
+
stdout: "ignore",
|
|
229
|
+
stderr: "ignore",
|
|
230
|
+
});
|
|
231
|
+
await proc.exited;
|
|
232
|
+
} catch {
|
|
233
|
+
// best-effort cleanup — a missing/already-closed master is fine.
|
|
234
|
+
}
|
|
235
|
+
}
|
package/src/platform/server.ts
CHANGED
|
@@ -29,6 +29,13 @@ export interface PlatformServerOptions {
|
|
|
29
29
|
dbPath?: string;
|
|
30
30
|
/** If true, enables SSE reload stream + mutable manifest (dev mode) */
|
|
31
31
|
devMode?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Cross-origin isolation policy. Default `"unsafe-none"`. Apps z SQLite
|
|
34
|
+
* WASM/OPFS lub SharedArrayBuffer ustawiają `"require-corp"` w
|
|
35
|
+
* `deploy.arc.json` — wtedy każdy cross-origin resource (3rd-party
|
|
36
|
+
* widgets) musi mieć `Cross-Origin-Resource-Policy`. Patrz `ArcServerConfig.coep`.
|
|
37
|
+
*/
|
|
38
|
+
coep?: "unsafe-none" | "credentialless" | "require-corp";
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
export interface PlatformServer {
|
|
@@ -507,6 +514,15 @@ export async function startPlatformServer(
|
|
|
507
514
|
opts: PlatformServerOptions,
|
|
508
515
|
): Promise<PlatformServer> {
|
|
509
516
|
const { ws, port, devMode, context } = opts;
|
|
517
|
+
// Default COEP: env var > opts > unsafe-none. Apps z SQLite WASM/OPFS
|
|
518
|
+
// muszą explicit ustawić "require-corp" (przez ARC_COEP w .env / Docker
|
|
519
|
+
// envVars). Default unsafe-none pozwala apps używać 3rd-party widgetów
|
|
520
|
+
// (PayU Secure Form, Stripe Elements, Google/Apple Pay) bez proxy.
|
|
521
|
+
const coep = (process.env.ARC_COEP as
|
|
522
|
+
| "unsafe-none"
|
|
523
|
+
| "credentialless"
|
|
524
|
+
| "require-corp"
|
|
525
|
+
| undefined) ?? opts.coep ?? "unsafe-none";
|
|
510
526
|
ensureModuleSigSecret(ws, !!devMode);
|
|
511
527
|
|
|
512
528
|
// OpenTelemetry — only when explicitly enabled (deploy injects the env
|
|
@@ -567,10 +583,10 @@ export async function startPlatformServer(
|
|
|
567
583
|
"Access-Control-Allow-Methods":
|
|
568
584
|
"GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
|
569
585
|
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Arc-Tokens",
|
|
570
|
-
// Cross-origin isolation —
|
|
571
|
-
//
|
|
586
|
+
// Cross-origin isolation — domyślnie unsafe-none (3rd-party widgets);
|
|
587
|
+
// require-corp tylko dla apps używających SharedArrayBuffer/OPFS.
|
|
572
588
|
"Cross-Origin-Opener-Policy": "same-origin",
|
|
573
|
-
"Cross-Origin-Embedder-Policy": "
|
|
589
|
+
"Cross-Origin-Embedder-Policy": coep ?? "unsafe-none",
|
|
574
590
|
"Cross-Origin-Resource-Policy": "cross-origin",
|
|
575
591
|
};
|
|
576
592
|
|
|
@@ -641,6 +657,7 @@ export async function startPlatformServer(
|
|
|
641
657
|
context,
|
|
642
658
|
dbAdapterFactory,
|
|
643
659
|
port,
|
|
660
|
+
coep,
|
|
644
661
|
httpHandlers: [
|
|
645
662
|
// Platform-specific handlers (checked AFTER arc handlers)
|
|
646
663
|
apiEndpointsHandler(ws, getManifest, null, moduleAccessMap, telemetry),
|