@arcote.tech/arc-cli 0.6.2 → 0.7.1
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 +1696 -1663
- package/package.json +7 -7
- package/src/builder/access-extractor.ts +64 -46
- package/src/builder/build-cache.ts +3 -1
- package/src/builder/chunk-planner.ts +107 -0
- package/src/builder/dependency-collector.ts +83 -41
- package/src/builder/framework-peers.ts +81 -0
- package/src/builder/module-builder.ts +322 -106
- package/src/commands/platform-build.ts +2 -1
- package/src/commands/platform-deploy.ts +121 -64
- package/src/commands/platform-dev.ts +11 -100
- package/src/commands/platform-start.ts +4 -90
- package/src/deploy/ansible.ts +23 -3
- package/src/deploy/assets/ansible/site.yml +23 -7
- package/src/deploy/assets.ts +23 -7
- package/src/deploy/bootstrap.ts +270 -10
- package/src/deploy/caddyfile.ts +19 -23
- package/src/deploy/compose.ts +44 -27
- package/src/deploy/config.ts +67 -3
- package/src/deploy/deploy-env.ts +129 -0
- package/src/deploy/env-file.ts +103 -0
- package/src/deploy/htpasswd.ts +28 -0
- package/src/deploy/image-template.ts +74 -0
- package/src/deploy/image.ts +243 -0
- package/src/deploy/registry.ts +79 -0
- package/src/deploy/ssh.ts +52 -122
- package/src/deploy/survey.ts +64 -0
- package/src/index.ts +20 -13
- package/src/platform/server.ts +119 -94
- package/src/platform/shared.ts +139 -292
- package/src/platform/startup.ts +159 -0
- package/runtime/Dockerfile +0 -29
- package/runtime/build-and-push.sh +0 -23
- package/runtime/entrypoint.sh +0 -58
- package/src/commands/build-shell.ts +0 -152
- package/src/deploy/remote-sync.ts +0 -321
- package/src/platform/deploy-api.ts +0 -400
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "fs";
|
|
2
2
|
import { dirname, join } from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
3
4
|
import { bootstrap } from "../deploy/bootstrap";
|
|
4
5
|
import {
|
|
5
6
|
deployConfigExists,
|
|
6
7
|
loadDeployConfig,
|
|
7
8
|
saveDeployConfig,
|
|
8
9
|
} from "../deploy/config";
|
|
10
|
+
import { updateEnvDeployment } from "../deploy/deploy-env";
|
|
11
|
+
import { buildImage, sanitizeImageName } from "../deploy/image";
|
|
9
12
|
import { detectRemoteState } from "../deploy/remote-state";
|
|
10
|
-
import {
|
|
13
|
+
import { dockerLogin, dockerPush } from "../deploy/registry";
|
|
11
14
|
import { runSurvey } from "../deploy/survey";
|
|
12
15
|
import {
|
|
13
16
|
buildAll,
|
|
@@ -15,6 +18,7 @@ import {
|
|
|
15
18
|
log,
|
|
16
19
|
ok,
|
|
17
20
|
resolveWorkspace,
|
|
21
|
+
type WorkspaceInfo,
|
|
18
22
|
} from "../platform/shared";
|
|
19
23
|
|
|
20
24
|
interface PlatformDeployOptions {
|
|
@@ -24,17 +28,34 @@ interface PlatformDeployOptions {
|
|
|
24
28
|
skipBuild?: boolean;
|
|
25
29
|
/** Force rebuild before deploy. */
|
|
26
30
|
rebuild?: boolean;
|
|
31
|
+
/** Build the Docker image locally, then exit. Does NOT touch the remote. */
|
|
32
|
+
buildOnly?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Rollback / pin to a specific image tag. Skips build + push, only updates
|
|
35
|
+
* /opt/arc/.env on the host and triggers `docker compose pull/up`.
|
|
36
|
+
* Format: bare content hash (e.g. `abc123def456`) or full ref.
|
|
37
|
+
*/
|
|
38
|
+
imageTag?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Force the Ansible host-bootstrap step to run even when the marker says
|
|
41
|
+
* the host is already configured. Default behavior skips Ansible whenever
|
|
42
|
+
* the server is reachable and has Docker — use this after editing the
|
|
43
|
+
* embedded playbook or to recover from a corrupted host config.
|
|
44
|
+
*/
|
|
45
|
+
forceBootstrap?: boolean;
|
|
27
46
|
}
|
|
28
47
|
|
|
29
48
|
// ---------------------------------------------------------------------------
|
|
30
|
-
// Entry point for `arc platform deploy [env]
|
|
49
|
+
// Entry point for `arc platform deploy [env]`.
|
|
31
50
|
//
|
|
32
|
-
//
|
|
51
|
+
// Flow:
|
|
33
52
|
// 1. resolveWorkspace
|
|
34
|
-
// 2.
|
|
35
|
-
// 3.
|
|
36
|
-
// 4.
|
|
37
|
-
// 5.
|
|
53
|
+
// 2. Load or survey deploy.arc.json
|
|
54
|
+
// 3. Ensure local build (buildAll unless --skip-build)
|
|
55
|
+
// 4. Build Docker image (or accept --image-tag for rollback)
|
|
56
|
+
// 5. dockerLogin + dockerPush
|
|
57
|
+
// 6. Detect remote state → bootstrap if needed
|
|
58
|
+
// 7. For each env: updateEnvDeployment (atomic .env line + pull + up + health)
|
|
38
59
|
// ---------------------------------------------------------------------------
|
|
39
60
|
|
|
40
61
|
export async function platformDeploy(
|
|
@@ -63,59 +84,92 @@ export async function platformDeploy(
|
|
|
63
84
|
})()
|
|
64
85
|
: Object.keys(cfg.envs);
|
|
65
86
|
|
|
66
|
-
// 2. Ensure local build
|
|
67
|
-
const manifestPath = join(ws.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
87
|
+
// 2. Ensure local build (unless --image-tag rollback skips build+push entirely)
|
|
88
|
+
const manifestPath = join(ws.arcDir, "manifest.json");
|
|
89
|
+
if (!options.imageTag) {
|
|
90
|
+
const needBuild = options.rebuild || !existsSync(manifestPath);
|
|
91
|
+
if (needBuild && !options.skipBuild) {
|
|
92
|
+
log("Building platform...");
|
|
93
|
+
await buildAll(ws, { noCache: options.rebuild });
|
|
94
|
+
ok("Build complete");
|
|
95
|
+
} else if (!existsSync(manifestPath)) {
|
|
96
|
+
err("No build found and --skip-build was set.");
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 3. Build the image locally. Push happens AFTER bootstrap so the registry
|
|
102
|
+
// container exists when we try to push to it (chicken-and-egg otherwise).
|
|
103
|
+
const imageName = sanitizeImageName(ws.rootPkg.name ?? ws.appName);
|
|
104
|
+
let fullRef: string;
|
|
105
|
+
let contentHash: string;
|
|
106
|
+
|
|
107
|
+
if (options.imageTag) {
|
|
108
|
+
contentHash = options.imageTag.includes(":")
|
|
109
|
+
? options.imageTag.split(":").pop()!
|
|
110
|
+
: options.imageTag;
|
|
111
|
+
fullRef = `${cfg.registry.domain}/${imageName}:${contentHash}`;
|
|
112
|
+
log(`Pinning to existing image ${fullRef} (skipping build + push)`);
|
|
113
|
+
} else {
|
|
114
|
+
log(`Building Docker image ${imageName}...`);
|
|
115
|
+
const result = await buildImage(ws, {
|
|
116
|
+
imageName,
|
|
117
|
+
registryDomain: cfg.registry.domain,
|
|
118
|
+
});
|
|
119
|
+
fullRef = result.fullRef;
|
|
120
|
+
contentHash = result.contentHash;
|
|
121
|
+
ok(`Image built: ${fullRef}`);
|
|
122
|
+
|
|
123
|
+
// 3b. --build-only: produce image, log, exit before push/deploy.
|
|
124
|
+
if (options.buildOnly) {
|
|
125
|
+
log(`contentHash: ${contentHash}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
76
128
|
}
|
|
77
129
|
|
|
78
|
-
//
|
|
130
|
+
// 4. Detect remote state + bootstrap. This brings up caddy + registry on
|
|
131
|
+
// first deploy (and regenerates the stack if config changed). MUST run
|
|
132
|
+
// before dockerLogin/dockerPush — without registry container + Caddy vhost
|
|
133
|
+
// for it, dockerLogin would TLS-fail.
|
|
79
134
|
log("Inspecting remote server...");
|
|
80
135
|
const state = await detectRemoteState(cfg);
|
|
81
136
|
log(`Remote state: ${state.kind}`);
|
|
82
137
|
|
|
83
|
-
// 4. Bootstrap if needed
|
|
84
138
|
const cliVersion = readCliVersion();
|
|
85
139
|
const configHash = await hashDeployConfig(ws.rootDir);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
140
|
+
await bootstrap({
|
|
141
|
+
cfg,
|
|
142
|
+
rootDir: ws.rootDir,
|
|
143
|
+
state,
|
|
144
|
+
cliVersion,
|
|
145
|
+
configHash,
|
|
146
|
+
forceAnsible: options.forceBootstrap,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// 5. Push the image to the now-running registry.
|
|
150
|
+
if (!options.imageTag) {
|
|
151
|
+
log(`Logging in to ${cfg.registry.domain}...`);
|
|
152
|
+
await dockerLogin(cfg.registry);
|
|
153
|
+
log(`Pushing ${fullRef}...`);
|
|
154
|
+
await dockerPush(fullRef);
|
|
155
|
+
ok("Image pushed");
|
|
94
156
|
}
|
|
95
157
|
|
|
96
|
-
//
|
|
158
|
+
// 6. Update each env — atomic /opt/arc/.env line + pull + up + health
|
|
97
159
|
for (const env of targetEnvs) {
|
|
98
|
-
log(`
|
|
99
|
-
const outcome = await
|
|
160
|
+
log(`Updating env "${env}"...`);
|
|
161
|
+
const outcome = await updateEnvDeployment({
|
|
162
|
+
target: cfg.target,
|
|
100
163
|
cfg,
|
|
101
164
|
env,
|
|
102
|
-
|
|
103
|
-
projectDir: ws.rootDir,
|
|
165
|
+
fullRef,
|
|
104
166
|
});
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
!outcome.shellChanged &&
|
|
108
|
-
!outcome.stylesChanged
|
|
109
|
-
) {
|
|
110
|
-
ok(`${env}: already up to date`);
|
|
167
|
+
if (outcome.redeployed) {
|
|
168
|
+
ok(`${env}: live at ${fullRef}`);
|
|
111
169
|
} else {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
if (outcome.shellChanged) parts.push("shell");
|
|
117
|
-
if (outcome.stylesChanged) parts.push("styles");
|
|
118
|
-
ok(`${env}: updated ${parts.join(", ")}`);
|
|
170
|
+
err(
|
|
171
|
+
`${env}: deployed but health check did not pass within retries — check \`docker logs arc-${env}\``,
|
|
172
|
+
);
|
|
119
173
|
}
|
|
120
174
|
}
|
|
121
175
|
}
|
|
@@ -124,30 +178,33 @@ export async function platformDeploy(
|
|
|
124
178
|
// Helpers
|
|
125
179
|
// ---------------------------------------------------------------------------
|
|
126
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Read the arc-cli package version by walking up from this file until we
|
|
183
|
+
* find a package.json with `name: "@arcote.tech/arc-cli"`. Source and
|
|
184
|
+
* bundled layouts have different depths (source: src/commands/, bundle:
|
|
185
|
+
* dist/), so a fixed `..` count doesn't work — walk until we hit the
|
|
186
|
+
* canonical manifest.
|
|
187
|
+
*/
|
|
127
188
|
function readCliVersion(): string {
|
|
128
|
-
// import.meta.dir gets mangled by `bun build` — derive from process.argv[1]
|
|
129
|
-
// (the bundled dist/index.js path) which is stable across run modes.
|
|
130
|
-
const candidates: string[] = [];
|
|
131
|
-
const entry = process.argv[1];
|
|
132
|
-
if (entry) {
|
|
133
|
-
candidates.push(join(dirname(entry), "..", "package.json"));
|
|
134
|
-
}
|
|
135
189
|
try {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
190
|
+
let cur = dirname(fileURLToPath(import.meta.url));
|
|
191
|
+
const root = dirname(cur).startsWith("/") ? "/" : ".";
|
|
192
|
+
while (cur !== root && cur !== "") {
|
|
193
|
+
const candidate = join(cur, "package.json");
|
|
194
|
+
if (existsSync(candidate)) {
|
|
195
|
+
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
|
|
196
|
+
if (pkg.name === "@arcote.tech/arc-cli") {
|
|
197
|
+
return pkg.version ?? "unknown";
|
|
198
|
+
}
|
|
145
199
|
}
|
|
146
|
-
|
|
147
|
-
|
|
200
|
+
const parent = dirname(cur);
|
|
201
|
+
if (parent === cur) break;
|
|
202
|
+
cur = parent;
|
|
148
203
|
}
|
|
204
|
+
return "unknown";
|
|
205
|
+
} catch {
|
|
206
|
+
return "unknown";
|
|
149
207
|
}
|
|
150
|
-
return "unknown";
|
|
151
208
|
}
|
|
152
209
|
|
|
153
210
|
async function hashDeployConfig(rootDir: string): Promise<string> {
|
|
@@ -1,103 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { startPlatformServer } from "../platform/server";
|
|
4
|
-
import {
|
|
5
|
-
buildAll,
|
|
6
|
-
collectArcPeerDeps,
|
|
7
|
-
loadServerContext,
|
|
8
|
-
log,
|
|
9
|
-
ok,
|
|
10
|
-
resolveWorkspace,
|
|
11
|
-
} from "../platform/shared";
|
|
1
|
+
import { startPlatform } from "../platform/startup";
|
|
2
|
+
import { resolveWorkspace } from "../platform/shared";
|
|
12
3
|
|
|
13
|
-
|
|
4
|
+
/** `arc platform dev` — dev mode (watcher + SSE reload + no-cache headers). */
|
|
5
|
+
export async function platformDev(
|
|
6
|
+
opts: { noCache?: boolean } = {},
|
|
7
|
+
): Promise<void> {
|
|
14
8
|
const ws = resolveWorkspace();
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
log("Loading server context...");
|
|
22
|
-
const { context, moduleAccess } = await loadServerContext(ws.packages);
|
|
23
|
-
if (context) {
|
|
24
|
-
ok("Context loaded");
|
|
25
|
-
} else {
|
|
26
|
-
log("No context — server endpoints skipped");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const arcEntries = collectArcPeerDeps(ws.packages);
|
|
30
|
-
const platform = await startPlatformServer({
|
|
31
|
-
ws,
|
|
32
|
-
port,
|
|
33
|
-
manifest,
|
|
34
|
-
context,
|
|
35
|
-
moduleAccess,
|
|
36
|
-
dbPath: join(ws.rootDir, ".arc", "data", "dev.db"),
|
|
37
|
-
devMode: true,
|
|
38
|
-
arcEntries,
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
ok(`Server on http://localhost:${port}`);
|
|
42
|
-
if (platform.contextHandler) ok("Commands, queries, WebSocket — all on same port");
|
|
43
|
-
|
|
44
|
-
// Watch for changes — full buildAll on debounce; cache makes it cheap when
|
|
45
|
-
// only one package changed.
|
|
46
|
-
log("Watching for changes...");
|
|
47
|
-
let rebuildTimer: ReturnType<typeof setTimeout> | null = null;
|
|
48
|
-
let isRebuilding = false;
|
|
49
|
-
|
|
50
|
-
const triggerRebuild = () => {
|
|
51
|
-
if (rebuildTimer) clearTimeout(rebuildTimer);
|
|
52
|
-
rebuildTimer = setTimeout(async () => {
|
|
53
|
-
if (isRebuilding) return;
|
|
54
|
-
isRebuilding = true;
|
|
55
|
-
log("Rebuilding...");
|
|
56
|
-
try {
|
|
57
|
-
manifest = await buildAll(ws);
|
|
58
|
-
platform.setManifest(manifest);
|
|
59
|
-
platform.notifyReload(manifest);
|
|
60
|
-
ok(`Rebuilt — ${manifest.modules.length} module(s)`);
|
|
61
|
-
} catch (e) {
|
|
62
|
-
console.error(`Rebuild failed: ${e}`);
|
|
63
|
-
} finally {
|
|
64
|
-
isRebuilding = false;
|
|
65
|
-
}
|
|
66
|
-
}, 300);
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
for (const pkg of ws.packages) {
|
|
70
|
-
const srcDir = join(pkg.path, "src");
|
|
71
|
-
if (!existsSync(srcDir)) continue;
|
|
72
|
-
|
|
73
|
-
watch(srcDir, { recursive: true }, (_event, filename) => {
|
|
74
|
-
if (
|
|
75
|
-
!filename ||
|
|
76
|
-
filename.includes(".arc") ||
|
|
77
|
-
filename.endsWith(".d.ts") ||
|
|
78
|
-
filename.includes("node_modules") ||
|
|
79
|
-
filename.includes("dist")
|
|
80
|
-
) return;
|
|
81
|
-
|
|
82
|
-
if (!filename.endsWith(".ts") && !filename.endsWith(".tsx")) return;
|
|
83
|
-
|
|
84
|
-
triggerRebuild();
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// .po files — trigger rebuild so the `translations` cache unit picks them up.
|
|
89
|
-
const localesDir = join(ws.rootDir, "locales");
|
|
90
|
-
if (existsSync(localesDir)) {
|
|
91
|
-
watch(localesDir, { recursive: false }, (_event, filename) => {
|
|
92
|
-
if (!filename?.endsWith(".po")) return;
|
|
93
|
-
triggerRebuild();
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const cleanup = () => {
|
|
98
|
-
platform.stop();
|
|
99
|
-
process.exit(0);
|
|
100
|
-
};
|
|
101
|
-
process.on("SIGTERM", cleanup);
|
|
102
|
-
process.on("SIGINT", cleanup);
|
|
9
|
+
// noCache is consumed by buildAll inside startPlatform when devMode=true.
|
|
10
|
+
// For now the dev startup always uses the cache after the first build;
|
|
11
|
+
// explicit --no-cache here only matters if we wire it through later.
|
|
12
|
+
void opts;
|
|
13
|
+
await startPlatform({ ws, devMode: true });
|
|
103
14
|
}
|
|
@@ -1,94 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { startPlatformServer } from "../platform/server";
|
|
4
|
-
import {
|
|
5
|
-
collectArcPeerDeps,
|
|
6
|
-
err,
|
|
7
|
-
loadServerContext,
|
|
8
|
-
log,
|
|
9
|
-
ok,
|
|
10
|
-
resolveWorkspace,
|
|
11
|
-
type BuildManifest,
|
|
12
|
-
} from "../platform/shared";
|
|
1
|
+
import { startPlatform } from "../platform/startup";
|
|
2
|
+
import { resolveWorkspace } from "../platform/shared";
|
|
13
3
|
|
|
4
|
+
/** `arc platform start` — production mode (no watcher, immutable cache). */
|
|
14
5
|
export async function platformStart(): Promise<void> {
|
|
15
6
|
const ws = resolveWorkspace();
|
|
16
|
-
|
|
17
|
-
const deployApi = process.env.ARC_DEPLOY_API === "1";
|
|
18
|
-
|
|
19
|
-
// Pre-deploy mode: container started with empty volume (first boot of an
|
|
20
|
-
// arcote/runtime container — manifest hasn't been pushed yet). Boot a
|
|
21
|
-
// minimal server so the deploy CLI can reach /api/deploy/* to push the
|
|
22
|
-
// initial framework + modules. Container restart (after first manifest
|
|
23
|
-
// commit) re-enters this function with manifest present → full mode.
|
|
24
|
-
const manifestPath = join(ws.modulesDir, "manifest.json");
|
|
25
|
-
if (!existsSync(manifestPath)) {
|
|
26
|
-
if (!deployApi) {
|
|
27
|
-
err("No build found. Run `arc platform build` first.");
|
|
28
|
-
process.exit(1);
|
|
29
|
-
}
|
|
30
|
-
log("Pre-deploy mode — no manifest yet, awaiting first /api/deploy/*");
|
|
31
|
-
const emptyManifest: BuildManifest = {
|
|
32
|
-
modules: [],
|
|
33
|
-
shellHash: "",
|
|
34
|
-
stylesHash: "",
|
|
35
|
-
buildTime: new Date().toISOString(),
|
|
36
|
-
};
|
|
37
|
-
const platform = await startPlatformServer({
|
|
38
|
-
ws,
|
|
39
|
-
port,
|
|
40
|
-
manifest: emptyManifest,
|
|
41
|
-
context: null,
|
|
42
|
-
moduleAccess: new Map(),
|
|
43
|
-
dbPath: join(ws.rootDir, ".arc", "data", "prod.db"),
|
|
44
|
-
devMode: false,
|
|
45
|
-
deployApi: true,
|
|
46
|
-
arcEntries: [],
|
|
47
|
-
});
|
|
48
|
-
ok(`Pre-deploy server on http://localhost:${port}`);
|
|
49
|
-
registerSignalCleanup(platform);
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const manifest: BuildManifest = JSON.parse(
|
|
54
|
-
readFileSync(manifestPath, "utf-8"),
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
// Load server context
|
|
58
|
-
log("Loading server context...");
|
|
59
|
-
const { context, moduleAccess } = await loadServerContext(ws.packages);
|
|
60
|
-
if (context) {
|
|
61
|
-
ok("Context loaded");
|
|
62
|
-
} else {
|
|
63
|
-
log("No context — server endpoints skipped");
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Start server (production mode = aggressive caching, SSE only used for deploy hot-swap)
|
|
67
|
-
const arcEntries = collectArcPeerDeps(ws.packages);
|
|
68
|
-
if (deployApi) ok("Deploy API enabled (/api/deploy/*)");
|
|
69
|
-
const platform = await startPlatformServer({
|
|
70
|
-
ws,
|
|
71
|
-
port,
|
|
72
|
-
manifest,
|
|
73
|
-
context,
|
|
74
|
-
moduleAccess,
|
|
75
|
-
dbPath: join(ws.rootDir, ".arc", "data", "prod.db"),
|
|
76
|
-
devMode: false,
|
|
77
|
-
deployApi,
|
|
78
|
-
arcEntries,
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
ok(`Server on http://localhost:${port}`);
|
|
82
|
-
if (platform.contextHandler) ok("Commands, queries, WebSocket — all on same port");
|
|
83
|
-
|
|
84
|
-
registerSignalCleanup(platform);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function registerSignalCleanup(platform: { stop: () => void }): void {
|
|
88
|
-
const cleanup = () => {
|
|
89
|
-
platform.stop();
|
|
90
|
-
process.exit(0);
|
|
91
|
-
};
|
|
92
|
-
process.on("SIGTERM", cleanup);
|
|
93
|
-
process.on("SIGINT", cleanup);
|
|
7
|
+
await startPlatform({ ws, devMode: false });
|
|
94
8
|
}
|
package/src/deploy/ansible.ts
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
-
import { mkdirSync, writeFileSync } from "fs";
|
|
3
|
-
import { tmpdir } from "os";
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
3
|
+
import { homedir, tmpdir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { ASSETS, materializeAssets } from "./assets";
|
|
6
6
|
import type { DeployProvisionAnsible, DeployTarget } from "./config";
|
|
7
7
|
|
|
8
|
+
function pickSshKeyForAnsible(configured?: string): string | null {
|
|
9
|
+
if (configured) {
|
|
10
|
+
const expanded = configured.startsWith("~")
|
|
11
|
+
? join(homedir(), configured.slice(1))
|
|
12
|
+
: configured;
|
|
13
|
+
return existsSync(expanded) ? expanded : null;
|
|
14
|
+
}
|
|
15
|
+
for (const name of ["id_ed25519", "id_ecdsa", "id_rsa"]) {
|
|
16
|
+
const path = join(homedir(), ".ssh", name);
|
|
17
|
+
if (existsSync(path)) return path;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
// ---------------------------------------------------------------------------
|
|
9
23
|
// Runs Ansible from embedded assets. Inventory is generated on the fly,
|
|
10
24
|
// targeting the single host described by DeployTarget. Runs as root on the
|
|
@@ -27,12 +41,18 @@ export async function runAnsible(inputs: AnsibleInputs): Promise<void> {
|
|
|
27
41
|
const user = inputs.asRoot ? "root" : inputs.target.user;
|
|
28
42
|
const port = inputs.ansible?.sshPort ?? inputs.target.port;
|
|
29
43
|
|
|
44
|
+
// IdentitiesOnly=yes + explicit -i prevent ssh from walking every key in
|
|
45
|
+
// ssh-agent (the server's MaxAuthTries=3 trips when the agent holds >3 keys
|
|
46
|
+
// and ours isn't first). PreferredAuthentications=publickey skips gssapi
|
|
47
|
+
// prompts that also count against MaxAuthTries.
|
|
48
|
+
const sshKey = pickSshKeyForAnsible(inputs.target.sshKey);
|
|
49
|
+
const sshKeyArg = sshKey ? ` -o IdentitiesOnly=yes -i ${sshKey}` : "";
|
|
30
50
|
const inventory = [
|
|
31
51
|
"[arc]",
|
|
32
52
|
`${inputs.target.host} ansible_user=${user} ansible_port=${port}`,
|
|
33
53
|
"",
|
|
34
54
|
"[arc:vars]",
|
|
35
|
-
|
|
55
|
+
`ansible_ssh_common_args='-o StrictHostKeyChecking=accept-new -o BatchMode=yes -o PreferredAuthentications=publickey${sshKeyArg}'`,
|
|
36
56
|
"ansible_python_interpreter=/usr/bin/python3",
|
|
37
57
|
"",
|
|
38
58
|
].join("\n");
|
|
@@ -115,7 +115,22 @@
|
|
|
115
115
|
- { policy: deny, dir: incoming }
|
|
116
116
|
- { policy: allow, dir: outgoing }
|
|
117
117
|
|
|
118
|
-
- name:
|
|
118
|
+
- name: Remove legacy ufw limit rule on SSH (replaced by plain allow)
|
|
119
|
+
# If a prior bootstrap installed `ufw limit 22/tcp`, drop it — otherwise
|
|
120
|
+
# the limit rule shadows the allow rule and rate-throttles deploy flows.
|
|
121
|
+
ufw:
|
|
122
|
+
rule: limit
|
|
123
|
+
port: "{{ ssh_port }}"
|
|
124
|
+
proto: tcp
|
|
125
|
+
delete: true
|
|
126
|
+
ignore_errors: true
|
|
127
|
+
|
|
128
|
+
- name: Open firewall ports (SSH key-only auth, no brute-force surface)
|
|
129
|
+
# SSH on port 22: PasswordAuthentication=no + key-only means brute force
|
|
130
|
+
# is impossible without the operator's private key. Rate-limiting (ufw
|
|
131
|
+
# limit / fail2ban sshd jail) breaks legitimate deploy flows that open
|
|
132
|
+
# many short SSH connections in sequence (canSsh → sshExec → scp → ...).
|
|
133
|
+
# 80/443: Caddy ACME + app traffic, never rate-limited.
|
|
119
134
|
ufw:
|
|
120
135
|
rule: allow
|
|
121
136
|
port: "{{ item }}"
|
|
@@ -129,17 +144,18 @@
|
|
|
129
144
|
ufw:
|
|
130
145
|
state: enabled
|
|
131
146
|
|
|
132
|
-
- name:
|
|
147
|
+
- name: Disable fail2ban sshd jail
|
|
148
|
+
# Key-only SSH + ufw rate-limit make fail2ban for sshd redundant and
|
|
149
|
+
# actively harmful when the operator's IP roams. Keep fail2ban installed
|
|
150
|
+
# for future jails (web/db) but turn off the sshd jail explicitly.
|
|
133
151
|
copy:
|
|
134
152
|
dest: /etc/fail2ban/jail.local
|
|
135
153
|
content: |
|
|
136
154
|
[sshd]
|
|
137
|
-
enabled =
|
|
138
|
-
|
|
139
|
-
maxretry = 5
|
|
140
|
-
findtime = 600
|
|
141
|
-
bantime = 3600
|
|
155
|
+
enabled = false
|
|
156
|
+
{% if extra_allowed_ips %}
|
|
142
157
|
ignoreip = 127.0.0.1/8 ::1 {{ extra_allowed_ips | join(' ') }}
|
|
158
|
+
{% endif %}
|
|
143
159
|
mode: "0644"
|
|
144
160
|
notify: restart fail2ban
|
|
145
161
|
|
package/src/deploy/assets.ts
CHANGED
|
@@ -201,7 +201,22 @@ const ANSIBLE_SITE_YML = `---
|
|
|
201
201
|
- { policy: deny, dir: incoming }
|
|
202
202
|
- { policy: allow, dir: outgoing }
|
|
203
203
|
|
|
204
|
-
- name:
|
|
204
|
+
- name: Remove legacy ufw limit rule on SSH (replaced by plain allow)
|
|
205
|
+
# If a prior bootstrap installed \`ufw limit 22/tcp\`, drop it — otherwise
|
|
206
|
+
# the limit rule shadows the allow rule and rate-throttles deploy flows.
|
|
207
|
+
ufw:
|
|
208
|
+
rule: limit
|
|
209
|
+
port: "{{ ssh_port }}"
|
|
210
|
+
proto: tcp
|
|
211
|
+
delete: true
|
|
212
|
+
ignore_errors: true
|
|
213
|
+
|
|
214
|
+
- name: Open firewall ports (SSH key-only auth, no brute-force surface)
|
|
215
|
+
# SSH on port 22: PasswordAuthentication=no + key-only means brute force
|
|
216
|
+
# is impossible without the operator's private key. Rate-limiting (ufw
|
|
217
|
+
# limit / fail2ban sshd jail) breaks legitimate deploy flows that open
|
|
218
|
+
# many short SSH connections in sequence (canSsh -> sshExec -> scp -> ...).
|
|
219
|
+
# 80/443: Caddy ACME + app traffic, never rate-limited.
|
|
205
220
|
ufw:
|
|
206
221
|
rule: allow
|
|
207
222
|
port: "{{ item }}"
|
|
@@ -215,17 +230,18 @@ const ANSIBLE_SITE_YML = `---
|
|
|
215
230
|
ufw:
|
|
216
231
|
state: enabled
|
|
217
232
|
|
|
218
|
-
- name:
|
|
233
|
+
- name: Disable fail2ban sshd jail
|
|
234
|
+
# Key-only SSH + ufw rate-limit make fail2ban for sshd redundant and
|
|
235
|
+
# actively harmful when the operator's IP roams. Keep fail2ban installed
|
|
236
|
+
# for future jails (web/db) but turn off the sshd jail explicitly.
|
|
219
237
|
copy:
|
|
220
238
|
dest: /etc/fail2ban/jail.local
|
|
221
239
|
content: |
|
|
222
240
|
[sshd]
|
|
223
|
-
enabled =
|
|
224
|
-
|
|
225
|
-
maxretry = 5
|
|
226
|
-
findtime = 600
|
|
227
|
-
bantime = 3600
|
|
241
|
+
enabled = false
|
|
242
|
+
{% if extra_allowed_ips %}
|
|
228
243
|
ignoreip = 127.0.0.1/8 ::1 {{ extra_allowed_ips | join(' ') }}
|
|
244
|
+
{% endif %}
|
|
229
245
|
mode: "0644"
|
|
230
246
|
notify: restart fail2ban
|
|
231
247
|
|