@arcote.tech/arc-cli 0.7.20 → 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 +225 -123
- package/package.json +9 -9
- package/src/builder/access-extractor.ts +11 -15
- package/src/builder/module-builder.ts +210 -35
- package/src/commands/platform-deploy.ts +42 -35
- package/src/deploy/deploy-env.ts +23 -46
- package/src/deploy/observability-configs.ts +14 -0
- package/src/deploy/remote-state.ts +39 -20
- package/src/deploy/ssh.ts +49 -0
- package/src/platform/server.ts +20 -3
- package/src/platform/shared.ts +34 -73
- package/src/platform/startup.ts +2 -2
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
type BuildCache,
|
|
19
19
|
} from "./build-cache";
|
|
20
20
|
import type { ChunkPlan } from "./chunk-planner";
|
|
21
|
-
import { SHELL_EXTERNALS } from "./framework-peers";
|
|
21
|
+
import { SHELL_EXTERNALS, FRAMEWORK_PEERS } from "./framework-peers";
|
|
22
22
|
import {
|
|
23
23
|
readInstalledVersion,
|
|
24
24
|
sha256Hex,
|
|
@@ -125,6 +125,43 @@ function serverExternalsPlugin(): import("bun").BunPlugin {
|
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* SERVER bundles inline their workspace (`@ndt/*`) deps so each flattened
|
|
130
|
+
* `.arc/platform/server/<pkg>.js` is self-contained (the deploy image has no
|
|
131
|
+
* `node_modules/@ndt/*` tree). Bun would resolve a bare `@ndt/x` import through
|
|
132
|
+
* package.json `exports` to that dep's PRE-BUILT `dist/server/main/index.js` —
|
|
133
|
+
* an already-fully-inlined bundle. Nesting one fully-inlined bundle inside
|
|
134
|
+
* every consumer is catastrophic:
|
|
135
|
+
* - diamonds duplicate the shared dep per graph edge (2^depth copies),
|
|
136
|
+
* - dist/ is never wiped, so stale bombs from a previous dep graph keep
|
|
137
|
+
* getting re-inlined across rebuilds.
|
|
138
|
+
* Result: bundles ballooned into the hundreds-of-MB / GB range (content =
|
|
139
|
+
* 1.1 GB, sizes lining up as powers of two by graph depth).
|
|
140
|
+
*
|
|
141
|
+
* This plugin pins every workspace package name to its SOURCE entrypoint
|
|
142
|
+
* (`src/index.ts`). The single Bun.build pass then includes each transitive
|
|
143
|
+
* module exactly once (Bun dedups by resolved path), so a server bundle is the
|
|
144
|
+
* size of its UNIQUE source closure — no nesting, no stale re-inlining.
|
|
145
|
+
*
|
|
146
|
+
* `sideEffects: true` keeps the dep's top-level `module(...).build()`
|
|
147
|
+
* registration from being tree-shaken when it's imported only for a named
|
|
148
|
+
* symbol (workspace packages ship `"sideEffects": false`).
|
|
149
|
+
*/
|
|
150
|
+
function workspaceSourcePlugin(srcByName: Map<string, string>): import("bun").BunPlugin {
|
|
151
|
+
return {
|
|
152
|
+
name: "workspace-source",
|
|
153
|
+
setup(build) {
|
|
154
|
+
// Bare specifiers only (skip relative `./` and absolute `/`). Non-
|
|
155
|
+
// workspace bare imports (react, @arcote.tech/*, npm deps) miss the map
|
|
156
|
+
// and fall through to the `external` list unchanged.
|
|
157
|
+
build.onResolve({ filter: /^[^./]/ }, (args) => {
|
|
158
|
+
const src = srcByName.get(args.path);
|
|
159
|
+
return src ? { path: src, sideEffects: true } : null;
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
128
165
|
function jsxDevShimPlugin(): import("bun").BunPlugin {
|
|
129
166
|
return {
|
|
130
167
|
name: "jsx-dev-runtime-shim",
|
|
@@ -145,12 +182,25 @@ export { Fragment };
|
|
|
145
182
|
};
|
|
146
183
|
}
|
|
147
184
|
|
|
148
|
-
|
|
185
|
+
// Per-package context build is BROWSER-only. The server side is produced by a
|
|
186
|
+
// single combined `buildServerApp` pass (see below) — bundling each package's
|
|
187
|
+
// server context separately and inlining its deps' pre-built dist caused
|
|
188
|
+
// catastrophic nested duplication (content.js hit 1.1 GB) and turned the
|
|
189
|
+
// consumer's undeclared cyclic `@ndt/*` imports into broken init order. One
|
|
190
|
+
// shared pass dedups every module once and is cycle-safe (same as the browser
|
|
191
|
+
// app build).
|
|
149
192
|
const CONTEXT_CLIENTS = [
|
|
150
|
-
{ name: "server", target: "bun" as const, defines: { ONLY_SERVER: "true", ONLY_BROWSER: "false", ONLY_CLIENT: "false" } },
|
|
151
193
|
{ name: "browser", target: "browser" as const, defines: { ONLY_SERVER: "false", ONLY_BROWSER: "true", ONLY_CLIENT: "true" } },
|
|
152
194
|
];
|
|
153
195
|
|
|
196
|
+
/** Server-side build defines — applied by the combined `buildServerApp` pass. */
|
|
197
|
+
const SERVER_DEFINES = { ONLY_SERVER: "true", ONLY_BROWSER: "false", ONLY_CLIENT: "false" } as const;
|
|
198
|
+
|
|
199
|
+
/** Filename of the combined server bundle entry under `<arcDir>/server/`.
|
|
200
|
+
* loadServerContext + the access extractor import exactly this file; Bun's
|
|
201
|
+
* auto-emitted `chunk-<hash>.js` siblings are pulled in transitively. */
|
|
202
|
+
export const SERVER_ENTRY_FILE = "_server.js";
|
|
203
|
+
|
|
154
204
|
export interface WorkspacePackage {
|
|
155
205
|
name: string;
|
|
156
206
|
path: string;
|
|
@@ -305,32 +355,16 @@ async function buildContextClient(
|
|
|
305
355
|
|
|
306
356
|
console.log(` building: ${pkg.name} (${client.name})`);
|
|
307
357
|
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
// - SERVER client: workspace deps MUST be bundled inline. The deploy
|
|
317
|
-
// image flattens each package's server bundle to
|
|
318
|
-
// `.arc/platform/server/<pkg>.js` and runs them via a single
|
|
319
|
-
// `loadServerContext()` import loop. There is no `node_modules/@ndt/*`
|
|
320
|
-
// tree inside the image, so bare `@ndt/workspace` specifiers would
|
|
321
|
-
// fail to resolve at startup. Inline duplication is harmless on the
|
|
322
|
-
// server: it's a single Node/Bun process and Arc modules register via
|
|
323
|
-
// a shared platform registry singleton (registry.ts), so two physical
|
|
324
|
-
// copies of the workspace module still merge into one context.
|
|
358
|
+
// BROWSER client: every dep (workspace + npm + framework peers) is external.
|
|
359
|
+
// Inlining workspace deps per package would make each context package's dist
|
|
360
|
+
// ship its own copy of every workspace dep — the top-level browser Bun.build
|
|
361
|
+
// (`buildBrowserApp`) would then see N pre-inlined copies of context
|
|
362
|
+
// singletons (`WorkspaceContext = createContext`) and could not dedupe them,
|
|
363
|
+
// breaking provider lookup. The server side is handled separately by the
|
|
364
|
+
// combined `buildServerApp` pass.
|
|
325
365
|
const peerDeps = Object.keys(pkg.packageJson.peerDependencies ?? {});
|
|
326
366
|
const allDeps = (pkg.packageJson.dependencies ?? {}) as Record<string, string>;
|
|
327
|
-
const
|
|
328
|
-
const workspaceDeps = isBrowser
|
|
329
|
-
? Object.keys(allDeps)
|
|
330
|
-
: Object.entries(allDeps)
|
|
331
|
-
.filter(([, spec]) => !spec.startsWith("workspace:"))
|
|
332
|
-
.map(([name]) => name);
|
|
333
|
-
const externals = [...peerDeps, ...workspaceDeps];
|
|
367
|
+
const externals = [...peerDeps, ...Object.keys(allDeps)];
|
|
334
368
|
|
|
335
369
|
const result = await Bun.build({
|
|
336
370
|
entrypoints: [pkg.entrypoint],
|
|
@@ -339,9 +373,7 @@ async function buildContextClient(
|
|
|
339
373
|
format: "esm",
|
|
340
374
|
naming: "index.[ext]",
|
|
341
375
|
external: externals,
|
|
342
|
-
plugins:
|
|
343
|
-
? [jsxDevShimPlugin()]
|
|
344
|
-
: [jsxDevShimPlugin(), serverExternalsPlugin()],
|
|
376
|
+
plugins: [jsxDevShimPlugin()],
|
|
345
377
|
define: client.defines,
|
|
346
378
|
});
|
|
347
379
|
|
|
@@ -385,11 +417,13 @@ export async function buildContextPackages(
|
|
|
385
417
|
const contexts = packages.filter((p) => isContextPackage(p.packageJson));
|
|
386
418
|
if (contexts.length === 0) return { declarationErrors: [] };
|
|
387
419
|
|
|
388
|
-
// Topological order
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
420
|
+
// Topological order by declared `workspace:` deps. The browser BUNDLE
|
|
421
|
+
// externalizes every dep (order-independent), but the type-declaration pass
|
|
422
|
+
// (tsc) resolves `@ndt/x` types through that package's emitted
|
|
423
|
+
// `dist/browser/index.d.ts` — so a dep's declarations must exist before a
|
|
424
|
+
// dependent's decl build runs, else tsc reports "Cannot find module @ndt/x".
|
|
425
|
+
// The declared dep graph is acyclic; undeclared cyclic imports live in source
|
|
426
|
+
// only and don't affect this ordering. Inside a layer, packages build in parallel.
|
|
393
427
|
const byName = new Map(contexts.map((p) => [p.name, p]));
|
|
394
428
|
const remaining = new Set(contexts.map((p) => p.name));
|
|
395
429
|
const done = new Set<string>();
|
|
@@ -440,6 +474,147 @@ export async function buildContextPackages(
|
|
|
440
474
|
return { declarationErrors };
|
|
441
475
|
}
|
|
442
476
|
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
// Combined server bundle — ONE Bun.build for ALL context packages' server side.
|
|
479
|
+
//
|
|
480
|
+
// Replaces N per-package server builds that each inlined their deps' PRE-BUILT
|
|
481
|
+
// dist (`@ndt/x` → `x/dist/server/main/index.js`). That nested an already-
|
|
482
|
+
// fully-inlined bundle inside every consumer: shared deps duplicated per graph
|
|
483
|
+
// edge (content's server bundle reached 1.1 GB) and the consumer's undeclared
|
|
484
|
+
// cyclic `@ndt/*` imports turned into broken module-init order.
|
|
485
|
+
//
|
|
486
|
+
// One shared pass with `splitting: true` — the same recipe as buildBrowserApp:
|
|
487
|
+
// - every module is bundled from SOURCE exactly once (Bun dedups by path),
|
|
488
|
+
// - `@ndt/*` workspace deps resolve to their `src/` entry (workspaceSourcePlugin)
|
|
489
|
+
// so nothing pulls a pre-built dist,
|
|
490
|
+
// - npm deps + framework peers stay external — the deploy image installs them
|
|
491
|
+
// into /app/node_modules via `.arc/platform/package.json` (collectFrameworkDeps),
|
|
492
|
+
// - splitting makes Bun emit eager top-level init, which is cycle-safe; the
|
|
493
|
+
// old non-split per-package build emitted lazy `__esm` wrappers that broke
|
|
494
|
+
// under the consumer's import cycles (`workspace.ids` was undefined).
|
|
495
|
+
//
|
|
496
|
+
// Output under `<arcDir>/server/`:
|
|
497
|
+
// _server.js ← entry: side-effect-imports every context module
|
|
498
|
+
// chunk-<hash>.js × N ← auto-shared module chunks
|
|
499
|
+
// loadServerContext + the access extractor import `_server.js`; chunks ride along.
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
|
|
502
|
+
export interface ServerAppResult {
|
|
503
|
+
readonly entryFile: string;
|
|
504
|
+
readonly cached: boolean;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export async function buildServerApp(
|
|
508
|
+
rootDir: string,
|
|
509
|
+
serverDir: string,
|
|
510
|
+
packages: WorkspacePackage[],
|
|
511
|
+
cache: BuildCache,
|
|
512
|
+
noCache: boolean,
|
|
513
|
+
): Promise<ServerAppResult> {
|
|
514
|
+
void rootDir; // resolution is source-based; rootDir kept for signature parity
|
|
515
|
+
const contexts = packages.filter((p) => isContextPackage(p.packageJson));
|
|
516
|
+
mkdirSync(serverDir, { recursive: true });
|
|
517
|
+
|
|
518
|
+
// Every workspace package name → its SOURCE entry. Spans ALL packages, not
|
|
519
|
+
// just context ones — a context package imports non-context workspace libs
|
|
520
|
+
// (e.g. content-core) that must inline from source too.
|
|
521
|
+
const srcByName = new Map(packages.map((p) => [p.name, p.entrypoint]));
|
|
522
|
+
|
|
523
|
+
// External = framework peers + every non-workspace npm dep any package
|
|
524
|
+
// declares. NOT bundled; resolved at runtime from /app/node_modules (the
|
|
525
|
+
// deploy image installs exactly this set — collectFrameworkDeps). Only the
|
|
526
|
+
// `@ndt/*` workspace source is inlined.
|
|
527
|
+
const externalSet = new Set<string>(FRAMEWORK_PEERS);
|
|
528
|
+
for (const p of packages) {
|
|
529
|
+
for (const name of Object.keys(p.packageJson.peerDependencies ?? {})) {
|
|
530
|
+
externalSet.add(name);
|
|
531
|
+
}
|
|
532
|
+
for (const [name, spec] of Object.entries(
|
|
533
|
+
(p.packageJson.dependencies ?? {}) as Record<string, string>,
|
|
534
|
+
)) {
|
|
535
|
+
if (!spec.startsWith("workspace:")) externalSet.add(name);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const external = [...externalSet];
|
|
539
|
+
|
|
540
|
+
const unitId = "server-app";
|
|
541
|
+
const inputHash = sha256OfJson({
|
|
542
|
+
members: packages
|
|
543
|
+
.map((p) => ({ name: p.name, src: pkgSourceHash(p) }))
|
|
544
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
545
|
+
contexts: contexts.map((p) => p.name).sort(),
|
|
546
|
+
external: [...external].sort(),
|
|
547
|
+
defines: SERVER_DEFINES,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
const entryFileAbs = join(serverDir, SERVER_ENTRY_FILE);
|
|
551
|
+
if (!noCache && isCacheHit(cache, unitId, inputHash, [entryFileAbs])) {
|
|
552
|
+
console.log(` ✓ cached: ${unitId}`);
|
|
553
|
+
return { entryFile: SERVER_ENTRY_FILE, cached: true };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
console.log(` building: ${unitId} (${contexts.length} server modules)`);
|
|
557
|
+
|
|
558
|
+
// Wipe stale .js — old per-package flattened bundles AND previous chunks —
|
|
559
|
+
// so a smaller rebuild never leaves orphaned content-addressed chunks behind.
|
|
560
|
+
for (const f of readdirSync(serverDir)) {
|
|
561
|
+
if (f.endsWith(".js")) rmSync(join(serverDir, f), { force: true });
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Entry side-effect-imports every context module so each registers via the
|
|
565
|
+
// platform registry singleton. Written to a tmp subdir so it isn't shipped.
|
|
566
|
+
const tmpDir = join(serverDir, "_entries");
|
|
567
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
568
|
+
const entrySrc = join(tmpDir, SERVER_ENTRY_FILE.replace(/\.js$/, ".ts"));
|
|
569
|
+
writeFileSync(
|
|
570
|
+
entrySrc,
|
|
571
|
+
contexts.map((p) => `import "${p.name}";`).join("\n") + "\n",
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
let result;
|
|
575
|
+
try {
|
|
576
|
+
result = await Bun.build({
|
|
577
|
+
entrypoints: [entrySrc],
|
|
578
|
+
outdir: serverDir,
|
|
579
|
+
target: "bun",
|
|
580
|
+
format: "esm",
|
|
581
|
+
// splitting:true is the whole point — it makes Bun emit eager top-level
|
|
582
|
+
// init (cycle-safe) instead of lazy `__esm` wrappers. Shared modules land
|
|
583
|
+
// in chunk-<hash>.js referenced by the entry.
|
|
584
|
+
splitting: true,
|
|
585
|
+
naming: "[name].[ext]",
|
|
586
|
+
external,
|
|
587
|
+
plugins: [
|
|
588
|
+
jsxDevShimPlugin(),
|
|
589
|
+
serverExternalsPlugin(),
|
|
590
|
+
workspaceSourcePlugin(srcByName),
|
|
591
|
+
],
|
|
592
|
+
define: SERVER_DEFINES,
|
|
593
|
+
});
|
|
594
|
+
} finally {
|
|
595
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (!result.success) {
|
|
599
|
+
for (const log of result.logs) console.error(log);
|
|
600
|
+
throw new Error("Server app build failed");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const entryOut = result.outputs.find((o) => o.kind === "entry-point");
|
|
604
|
+
if (!entryOut) {
|
|
605
|
+
throw new Error("Server app build: entry not found in outputs");
|
|
606
|
+
}
|
|
607
|
+
if (basename(entryOut.path) !== SERVER_ENTRY_FILE) {
|
|
608
|
+
throw new Error(
|
|
609
|
+
`Server app build: unexpected entry name ${basename(entryOut.path)} (wanted ${SERVER_ENTRY_FILE})`,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const outputHash = sha256OfDir(serverDir);
|
|
614
|
+
updateCache(cache, unitId, inputHash, { outputHash });
|
|
615
|
+
return { entryFile: SERVER_ENTRY_FILE, cached: false };
|
|
616
|
+
}
|
|
617
|
+
|
|
443
618
|
|
|
444
619
|
// ---------------------------------------------------------------------------
|
|
445
620
|
// Browser app build — one Bun.build with multiple entrypoints.
|
|
@@ -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 };
|
|
@@ -87,8 +87,13 @@ ${envNames.map((name) => ` - "https://${cfg.envs[name]!.domain}"`).jo
|
|
|
87
87
|
|
|
88
88
|
# Per-container CPU / memory / network / block-IO + restarts straight from
|
|
89
89
|
# the Docker daemon (socket bind-mounted read-only, see compose).
|
|
90
|
+
# api_version pinned: the receiver defaults to Docker API 1.25, which modern
|
|
91
|
+
# daemons (Engine 25+ require >= 1.40) reject — without this the receiver
|
|
92
|
+
# fails to start and takes the whole collector down. Quoted so YAML doesn't
|
|
93
|
+
# parse 1.40 → 1.4. Must be <= the daemon's max; 1.40 is the safe floor.
|
|
90
94
|
docker_stats:
|
|
91
95
|
endpoint: unix:///var/run/docker.sock
|
|
96
|
+
api_version: "1.40"
|
|
92
97
|
collection_interval: 30s
|
|
93
98
|
metrics:
|
|
94
99
|
container.restarts:
|
|
@@ -372,6 +377,15 @@ export function generateAlloyConfig(): string {
|
|
|
372
377
|
discovery.docker "containers" {
|
|
373
378
|
host = "unix:///var/run/docker.sock"
|
|
374
379
|
refresh_interval = "15s"
|
|
380
|
+
|
|
381
|
+
// Only containers managed by a compose project (our stack). Ad-hoc / rogue
|
|
382
|
+
// containers (manual debug runs, other stacks) are excluded — one bad
|
|
383
|
+
// stream (e.g. log entries older than Loki's reject window) otherwise 400s
|
|
384
|
+
// the whole loki.write batch and drops good app logs with it.
|
|
385
|
+
filter {
|
|
386
|
+
name = "label"
|
|
387
|
+
values = ["com.docker.compose.project"]
|
|
388
|
+
}
|
|
375
389
|
}
|
|
376
390
|
|
|
377
391
|
discovery.relabel "containers" {
|
|
@@ -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,
|