@ours.network/install 1.2.1-nightly.3 → 1.2.1-nightly.30
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/GATEWAY_SETUP.md +178 -0
- package/README.md +320 -9
- package/ROOTLESS_PODMAN_VALIDATION.md +210 -0
- package/assets/Dockerfile +17 -13
- package/assets/Dockerfile.gateway +5 -0
- package/assets/docker-compose.yaml +1 -1
- package/assets/fleet-settings.example.json +48 -0
- package/assets/gateway-entrypoint.sh +37 -0
- package/assets/release-lock.json +1122 -4471
- package/assets/release.json +33 -19
- package/assets/scripts/build/build-common.mjs +1 -1
- package/assets/scripts/build/build-sdk.mjs +6 -1
- package/assets/scripts/maintenance/build-context.mjs +1 -1
- package/assets/scripts/maintenance/daemon-owner.mjs +15 -0
- package/assets/scripts/maintenance/docker-layout-conversion.mjs +7 -3
- package/assets/scripts/maintenance/release-graph.mjs +18 -1
- package/assets/scripts/maintenance/state-operation.mjs +4 -2
- package/assets/scripts/runtime/client-setup.mjs +23 -5
- package/assets/scripts/runtime/diagnostics.mjs +72 -0
- package/assets/scripts/runtime/entrypoint.sh +1 -1
- package/assets/sources.json +46 -28
- package/install.mjs +7 -2
- package/lib/build-transition.mjs +1 -0
- package/lib/client-cli.mjs +127 -0
- package/lib/container-engine.mjs +127 -0
- package/lib/diagnostics.mjs +1 -0
- package/lib/docker-conversion-runtime.mjs +3 -2
- package/lib/docker-layout-installation.mjs +2 -1
- package/lib/docker-runtime-repair.mjs +160 -0
- package/lib/effects.mjs +278 -130
- package/lib/gateway-transition.mjs +87 -0
- package/lib/gateway.mjs +142 -0
- package/lib/legacy-migration.mjs +13 -2
- package/lib/orchestrate.mjs +129 -43
- package/lib/plan.mjs +22 -9
- package/lib/podman-preflight.mjs +40 -0
- package/lib/server-onboarding.mjs +154 -16
- package/lib/server-ports.mjs +17 -0
- package/lib/setup-options.mjs +88 -27
- package/lib/setup.mjs +35 -18
- package/lib/target.mjs +24 -8
- package/lib/uninstall.mjs +2 -2
- package/lib/usage.mjs +19 -6
- package/lib/workspace-setup.mjs +83 -0
- package/package.json +4 -2
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { gatewayAddress } from './gateway.mjs';
|
|
4
|
+
import { validateInstallation } from './plan.mjs';
|
|
5
|
+
import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
|
|
6
|
+
|
|
7
|
+
// Called under the installation lock. No stored state or credentials are changed.
|
|
8
|
+
export async function enableGateway(record, args, effects) {
|
|
9
|
+
validateInstallation(record, record.root);
|
|
10
|
+
if (record.mode !== 'docker' || record.schema !== 2 || record.buildTransition || record.layoutConversion)
|
|
11
|
+
throw new Error('Gateway migration requires a settled schema-2 Docker installation');
|
|
12
|
+
const journal = join(record.root, 'gateway-transition.json');
|
|
13
|
+
const recordPath = join(record.root, 'installation.json');
|
|
14
|
+
const profilePath = join(effects.home, '.ours-client', 'profile.json');
|
|
15
|
+
const paths = [recordPath, ...['docker-compose.gateway.yaml', 'nginx.conf', 'Dockerfile.gateway'].map(name => join(record.workDir, name)), profilePath];
|
|
16
|
+
const snapshot = path => {
|
|
17
|
+
if (existsSync(path)) {
|
|
18
|
+
const stat = lstatSync(path);
|
|
19
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid() || (stat.mode & (path === profilePath || path === journal || path === recordPath ? 0o077 : 0o022)))
|
|
20
|
+
throw new Error('Gateway migration requires private owned configuration files');
|
|
21
|
+
}
|
|
22
|
+
return snapshotConfig(path);
|
|
23
|
+
};
|
|
24
|
+
async function rollback(saved) {
|
|
25
|
+
const phase = value => { saved.phase = value; atomicWriteConfig(journal, JSON.stringify(saved, null, 2) + '\n'); };
|
|
26
|
+
if (saved.phase === 'switching') phase('rollback-stopping');
|
|
27
|
+
if (saved.phase === 'rollback-stopping') {
|
|
28
|
+
await effects.serverLifecycle(saved.candidate, 'stop');
|
|
29
|
+
await effects.removeGatewayContainer(saved.candidate);
|
|
30
|
+
phase('rollback-restoring');
|
|
31
|
+
}
|
|
32
|
+
if (saved.phase === 'preparing') phase('rollback-restoring');
|
|
33
|
+
if (saved.phase === 'rollback-restoring') {
|
|
34
|
+
for (let i = 0; i < paths.length; i++) restoreConfig(paths[i], saved.snapshots[i]);
|
|
35
|
+
phase('rollback-restarting');
|
|
36
|
+
}
|
|
37
|
+
await effects.serverLifecycle(saved.original, 'start', saved.running);
|
|
38
|
+
unlinkSync(journal);
|
|
39
|
+
}
|
|
40
|
+
if (existsSync(journal)) {
|
|
41
|
+
snapshot(journal);
|
|
42
|
+
const saved = JSON.parse(readFileSync(journal, 'utf8'));
|
|
43
|
+
validateInstallation(saved.original, record.root);
|
|
44
|
+
validateInstallation(saved.candidate, record.root);
|
|
45
|
+
if (saved.schema !== 1 || !['preparing', 'switching', 'rollback-stopping', 'rollback-restoring', 'rollback-restarting'].includes(saved.phase) || !Array.isArray(saved.snapshots) || saved.snapshots.length !== paths.length
|
|
46
|
+
|| !Array.isArray(saved.running) || saved.running.some(name => !saved.original.services.includes(name)))
|
|
47
|
+
throw new Error('Invalid retained gateway transition; operator recovery required');
|
|
48
|
+
await rollback(saved);
|
|
49
|
+
throw new Error('Interrupted gateway migration rolled back; repeat gateway-enable to begin a new migration');
|
|
50
|
+
}
|
|
51
|
+
if (record.gateway) {
|
|
52
|
+
if (args.serverUrl && args.serverUrl !== gatewayAddress(record).base) throw new Error('Gateway URL is already configured; refusing implicit URL replacement');
|
|
53
|
+
await effects.verifyGateway(record);
|
|
54
|
+
return record;
|
|
55
|
+
}
|
|
56
|
+
const candidate = { ...record, gateway: { version: 1, ...(args.serverUrl ? { serverUrl: args.serverUrl } : {}) }, services: [...record.services, 'gateway'] };
|
|
57
|
+
validateInstallation(candidate, record.root);
|
|
58
|
+
const current = effects.readManagedClientProfile();
|
|
59
|
+
const selected = current?.expectedInstanceId === record.instanceId && current.endpoint === `http://127.0.0.1:${record.port}`;
|
|
60
|
+
const saved = { schema: 1, phase: 'preparing', original: record, candidate, snapshots: paths.map(snapshot), running: await effects.serverLifecycle(record, 'status') };
|
|
61
|
+
// Reject old service artifacts before stopping the working deployment.
|
|
62
|
+
await effects.qualifyGatewayRuntime(candidate);
|
|
63
|
+
if (selected) await effects.qualifyInstalledGatewayClient(current);
|
|
64
|
+
atomicWriteConfig(journal, JSON.stringify(saved, null, 2) + '\n');
|
|
65
|
+
try {
|
|
66
|
+
await effects.prepareGateway(candidate);
|
|
67
|
+
saved.phase = 'switching';
|
|
68
|
+
atomicWriteConfig(journal, JSON.stringify(saved, null, 2) + '\n');
|
|
69
|
+
await effects.serverLifecycle(record, 'stop');
|
|
70
|
+
await effects.serverLifecycle(candidate, 'start', [...new Set([...saved.running, 'daemon', 'cowork', 'gateway'])]);
|
|
71
|
+
await effects.verifyGateway(candidate);
|
|
72
|
+
if (selected) {
|
|
73
|
+
const serverUrl = gatewayAddress(candidate).base;
|
|
74
|
+
atomicWriteConfig(profilePath, JSON.stringify({ ...current, serverUrl, endpoint: serverUrl + '/daemon' }, null, 2) + '\n');
|
|
75
|
+
}
|
|
76
|
+
// Preserve the running/stopped selection; gateway follows a running daemon.
|
|
77
|
+
const keep = [...saved.running, ...(saved.running.includes('daemon') ? ['gateway'] : [])];
|
|
78
|
+
await effects.serverLifecycle(candidate, 'stop', candidate.services.filter(name => !keep.includes(name)));
|
|
79
|
+
atomicWriteConfig(recordPath, JSON.stringify(candidate, null, 2) + '\n');
|
|
80
|
+
unlinkSync(journal);
|
|
81
|
+
return candidate;
|
|
82
|
+
} catch (cause) {
|
|
83
|
+
try { await rollback(saved); }
|
|
84
|
+
catch (rollbackError) { throw new AggregateError([cause, rollbackError], 'Gateway migration and rollback failed; retained journal requires gateway-enable recovery'); }
|
|
85
|
+
throw new Error('Gateway migration failed; previous routing, profile and service selection restored', { cause });
|
|
86
|
+
}
|
|
87
|
+
}
|
package/lib/gateway.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/** One public URL; fixed same-origin service paths are never supplied by a peer. */
|
|
2
|
+
export const GATEWAY_SERVICES = Object.freeze({daemon:'/daemon',cowork:'/cowork',telegram:'/tg-connector',messenger:'/messenger'});
|
|
3
|
+
export function serverBase(value) {
|
|
4
|
+
if(typeof value!=='string'||/[\s\\?#]/.test(value)||!/^https?:\/\//.test(value))throw new Error('Server URL must be an HTTP or HTTPS base URL');
|
|
5
|
+
const url=new URL(value);
|
|
6
|
+
if(url.username||url.password)throw new Error('Server URL cannot contain credentials');
|
|
7
|
+
return url.origin+url.pathname.replace(/\/+$/,'');
|
|
8
|
+
}
|
|
9
|
+
export function gatewayDiscovery(record) {
|
|
10
|
+
return {schema:1,instanceId:record.instanceId,services:GATEWAY_SERVICES,capabilities:['ours.gateway-v1','cowork.http-management-v1']};
|
|
11
|
+
}
|
|
12
|
+
export function validateGatewayDiscovery(url,value,credentialPath) {
|
|
13
|
+
const base=serverBase(url);
|
|
14
|
+
if(value?.schema!==1||!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value.instanceId??'')
|
|
15
|
+
||!Array.isArray(value.capabilities)||!value.capabilities.includes('ours.gateway-v1')||!value.capabilities?.includes('cowork.http-management-v1')
|
|
16
|
+
||Object.keys(GATEWAY_SERVICES).some(key=>value.services?.[key]!==GATEWAY_SERVICES[key]))throw new Error('Incompatible server gateway discovery');
|
|
17
|
+
return {serverUrl:base,endpoint:base+'/daemon',expectedInstanceId:value.instanceId,credentialPath};
|
|
18
|
+
}
|
|
19
|
+
export function gatewayAddress(record) {
|
|
20
|
+
const base = serverBase(record.gateway?.serverUrl ?? `http://127.0.0.1:${record.port}`);
|
|
21
|
+
const url = new URL(base);
|
|
22
|
+
if (!/^\/(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_-]*$/.test(url.pathname)) throw new Error('Gateway base path must contain plain URL path segments');
|
|
23
|
+
return { base, origin: url.origin, prefix: url.pathname.replace(/\/$/, '') };
|
|
24
|
+
}
|
|
25
|
+
export function gatewayCompose(record) {
|
|
26
|
+
const { origin, prefix } = gatewayAddress(record);
|
|
27
|
+
return `services:
|
|
28
|
+
daemon:
|
|
29
|
+
ports: !reset []
|
|
30
|
+
telegram:
|
|
31
|
+
ports: !reset []
|
|
32
|
+
environment:
|
|
33
|
+
OURS_TG_CONTROL_HOST: "0.0.0.0"
|
|
34
|
+
cowork:
|
|
35
|
+
ports: !reset []
|
|
36
|
+
environment:
|
|
37
|
+
OURS_COWORK_HTTP_MANAGEMENT: "1"
|
|
38
|
+
OURS_COWORK_PUBLIC_ORIGIN: ${JSON.stringify(origin)}
|
|
39
|
+
messenger:
|
|
40
|
+
ports: !reset []
|
|
41
|
+
environment:
|
|
42
|
+
OURS_MESSENGER_PUBLIC_ORIGIN: ${JSON.stringify(origin)}
|
|
43
|
+
OURS_MESSENGER_BASE_PATH: ${JSON.stringify(prefix + "/messenger/")}
|
|
44
|
+
gateway:
|
|
45
|
+
image: "\${OURS_GATEWAY_IMAGE:-${record.project}:gateway}"
|
|
46
|
+
build:
|
|
47
|
+
context: .
|
|
48
|
+
dockerfile: Dockerfile.gateway
|
|
49
|
+
user: "101:101"
|
|
50
|
+
read_only: true
|
|
51
|
+
cap_drop: [ALL]
|
|
52
|
+
security_opt: [no-new-privileges:true]
|
|
53
|
+
tmpfs: ["/tmp:rw,nosuid,nodev,noexec,size=32m,mode=1777"]
|
|
54
|
+
networks: [ours]
|
|
55
|
+
ports:
|
|
56
|
+
- {target: 8080, published: "${record.port}", host_ip: "127.0.0.1"}
|
|
57
|
+
restart: unless-stopped
|
|
58
|
+
stop_grace_period: 10s
|
|
59
|
+
healthcheck:
|
|
60
|
+
test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1:8080/healthz]
|
|
61
|
+
interval: 5s
|
|
62
|
+
timeout: 3s
|
|
63
|
+
retries: 6
|
|
64
|
+
`;
|
|
65
|
+
}
|
|
66
|
+
export function gatewayNginx(record) {
|
|
67
|
+
const { prefix: basePath } = gatewayAddress(record);
|
|
68
|
+
const routes=[['daemon',3050],['cowork',record.coworkPort??3052],['telegram',3051],['messenger',8420]];
|
|
69
|
+
const upstream=Object.fromEntries(routes);
|
|
70
|
+
for(const port of Object.values(upstream))if(!Number.isInteger(port)||port<1||port>65535)throw new Error('Invalid gateway upstream port');
|
|
71
|
+
const route=(name,servicePath)=> { const prefix=basePath+servicePath; return`location ${prefix}/ {
|
|
72
|
+
set $upstream_${name} http://${name}:${upstream[name]};
|
|
73
|
+
rewrite ^${prefix}/(.*)$ /$1 break;
|
|
74
|
+
proxy_pass $upstream_${name};
|
|
75
|
+
proxy_set_header Host $http_host;
|
|
76
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
77
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
78
|
+
proxy_set_header Connection $connection_upgrade;
|
|
79
|
+
proxy_http_version 1.1;
|
|
80
|
+
proxy_buffering off;
|
|
81
|
+
proxy_read_timeout 300s;
|
|
82
|
+
}`; };
|
|
83
|
+
return `worker_processes 1;
|
|
84
|
+
pid /tmp/nginx.pid;
|
|
85
|
+
error_log /dev/stderr warn;
|
|
86
|
+
events { worker_connections 1024; }
|
|
87
|
+
http {
|
|
88
|
+
access_log off;
|
|
89
|
+
client_body_temp_path /tmp/client;
|
|
90
|
+
proxy_temp_path /tmp/proxy;
|
|
91
|
+
fastcgi_temp_path /tmp/fastcgi;
|
|
92
|
+
uwsgi_temp_path /tmp/uwsgi;
|
|
93
|
+
scgi_temp_path /tmp/scgi;
|
|
94
|
+
proxy_next_upstream off;
|
|
95
|
+
resolver @@OURS_RESOLVERS@@ valid=5s;
|
|
96
|
+
map $http_upgrade $connection_upgrade { default upgrade; '' close; }
|
|
97
|
+
server {
|
|
98
|
+
listen 8080;
|
|
99
|
+
server_name _;
|
|
100
|
+
client_max_body_size 64m;
|
|
101
|
+
location = /healthz { return 200 'ok'; }
|
|
102
|
+
location = ${basePath}/.well-known/ours { default_type application/json; return 200 '${JSON.stringify(gatewayDiscovery(record))}'; }
|
|
103
|
+
location = / { return 302 ${basePath}/messenger/; }
|
|
104
|
+
location = ${basePath}/daemon { return 308 ${basePath}/daemon/; }
|
|
105
|
+
location = ${basePath}/cowork { return 308 ${basePath}/cowork/; }
|
|
106
|
+
location = ${basePath}/tg-connector { return 308 ${basePath}/tg-connector/; }
|
|
107
|
+
location = ${basePath}/messenger { return 308 ${basePath}/messenger/; }
|
|
108
|
+
# Never publish the unauthenticated loopback browser RPC. Management has its
|
|
109
|
+
# own application-level credential check and rejects browser-origin requests.
|
|
110
|
+
location = ${basePath}/cowork/rpc { return 403; }
|
|
111
|
+
location ${basePath}/cowork/management/ {
|
|
112
|
+
client_max_body_size 1m;
|
|
113
|
+
set $management http://cowork:${upstream.cowork};
|
|
114
|
+
rewrite ^${basePath}/cowork/(.*)$ /$1 break;
|
|
115
|
+
proxy_pass $management;
|
|
116
|
+
proxy_set_header Host $http_host;
|
|
117
|
+
proxy_http_version 1.1;
|
|
118
|
+
proxy_read_timeout 60s;
|
|
119
|
+
}
|
|
120
|
+
${route('daemon','/daemon')}
|
|
121
|
+
${route('cowork','/cowork')}
|
|
122
|
+
# Telegram's control API is local-only; no unauthenticated remote proxy.
|
|
123
|
+
location ${basePath}/tg-connector/ {
|
|
124
|
+
auth_request /_server_auth;
|
|
125
|
+
set $telegram http://telegram:3051;
|
|
126
|
+
rewrite ^${basePath}/tg-connector/(.*)$ /$1 break;
|
|
127
|
+
proxy_pass $telegram;
|
|
128
|
+
}
|
|
129
|
+
location = /_server_auth {
|
|
130
|
+
internal;
|
|
131
|
+
set $auth http://daemon:3050/identities;
|
|
132
|
+
proxy_pass $auth;
|
|
133
|
+
proxy_pass_request_body off;
|
|
134
|
+
proxy_set_header Content-Length "";
|
|
135
|
+
proxy_set_header X-Ours-Api-Token $http_x_ours_api_token;
|
|
136
|
+
}
|
|
137
|
+
${route('messenger','/messenger')}
|
|
138
|
+
location / { return 404; }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
`;
|
|
142
|
+
}
|
package/lib/legacy-migration.mjs
CHANGED
|
@@ -17,6 +17,13 @@ function readJournal(root) {
|
|
|
17
17
|
if (j.schema !== 1 || j.targetRoot !== root || !phases.includes(j.phase) || !Array.isArray(j.identities)) throw new Error('Invalid legacy migration journal');
|
|
18
18
|
return j;
|
|
19
19
|
}
|
|
20
|
+
// Legacy cutover publishes a Docker/package launcher; Podman cutover is not supported.
|
|
21
|
+
function rejectPodmanMigration(options, effects, ...records) {
|
|
22
|
+
if (options.containerEngine === 'podman' || records.some(record => record?.containerEngine === 'podman')
|
|
23
|
+
|| effects.readJson(join(options.stateDir, 'installation.json'))?.containerEngine === 'podman') {
|
|
24
|
+
throw new Error('Legacy migration to Podman is not supported. Use a fresh Podman installation or migrate with Docker/packages.');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
20
27
|
function selection(source) {
|
|
21
28
|
return { env: { OURS_CONFIG: source.configPath, OURS_STATE_DIR: source.stateDir, OURS_PORT: String(source.config.port ?? 3050), OURS_API_TOKEN: undefined, OURS_DAEMON_ID: undefined, OURS_DAEMON_URL: undefined, OURS_DAEMON_CREDENTIAL_PATH: undefined }, sensitive: true };
|
|
22
29
|
}
|
|
@@ -31,10 +38,11 @@ function validateIdentities(rows, source) {
|
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
export async function inspectLegacyMigration(options, effects, deps = {}) {
|
|
41
|
+
const journal = readJournal(options.stateDir);
|
|
42
|
+
rejectPodmanMigration(options, effects, journal?.record, options.legacyPlan?.journal?.record);
|
|
34
43
|
const inspect = deps.inspectLegacyState ?? inspectLegacyState;
|
|
35
44
|
await (deps.ensureLegacyLockSupport ?? ensureLegacyLockSupport)();
|
|
36
45
|
const cliPlan = options.dryRun ? null : await (deps.inspectManagedCli ?? inspectManagedCli)(effects, options.stateDir);
|
|
37
|
-
const journal = readJournal(options.stateDir);
|
|
38
46
|
if (journal) {
|
|
39
47
|
if (journal.sourceConfig !== options.migrateFrom) throw new Error('Another legacy source is already selected for this target');
|
|
40
48
|
return { journal, cliPlan, source: journal.phase === 'complete' ? null : { ...inspect(options.migrateFrom, options.stateDir), originalProgram: cliPlan?.originalProgram } };
|
|
@@ -80,12 +88,15 @@ export async function prepareMigrationCliRuntime(record, effects) {
|
|
|
80
88
|
|
|
81
89
|
/** Called under the normal installation lock; retries never overwrite imported state. */
|
|
82
90
|
export async function executeLegacyMigration(args, effects, install, deps = {}) {
|
|
91
|
+
const retainedJournal = readJournal(args.stateDir);
|
|
92
|
+
rejectPodmanMigration(args, effects, retainedJournal?.record, args.legacyPlan?.journal?.record);
|
|
83
93
|
const stage = deps.stageLegacyState ?? stageLegacyState;
|
|
84
94
|
const lock = deps.withLegacyStateLock ?? withLegacyStateLock;
|
|
85
95
|
const save = (root, value) => (deps.atomicWriteConfig ?? atomicWriteConfig)(journalPath(root), JSON.stringify(value, null, 2) + '\n');
|
|
86
96
|
const prepareCli = deps.prepareMigrationCliRuntime ?? prepareMigrationCliRuntime;
|
|
87
97
|
const inspected = args.legacyPlan ?? await inspectLegacyMigration(args, effects, deps);
|
|
88
|
-
let journal =
|
|
98
|
+
let journal = retainedJournal ?? inspected.journal;
|
|
99
|
+
rejectPodmanMigration(args, effects, journal?.record);
|
|
89
100
|
if (journal.phase === 'complete') {
|
|
90
101
|
const retained = effects.readJson(join(args.stateDir, 'installation.json'));
|
|
91
102
|
inspected.cliPlan.installerPath = await prepareCli(retained, effects);
|