@wtfalch/foundry 0.62.0 → 0.63.0
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/bootstrap/declare.js +4 -1
- package/dist/bootstrap/versions.d.ts +2 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +2 -1
- package/dist/migrations/0067_requested_changes_kosmos.sql +6 -0
- package/dist/migrations/0068_platform_registry.sql +17 -0
- package/dist/native-build.d.ts +7 -27
- package/dist/native-build.js +7 -27
- package/dist/plan/archon-blue-green.d.ts +5 -0
- package/dist/plan/archon-blue-green.js +2 -0
- package/dist/plan/blue-green-apply.d.ts +5 -0
- package/dist/plan/blue-green-apply.js +197 -22
- package/dist/plan/blue-green.d.ts +61 -2
- package/dist/plan/blue-green.js +80 -6
- package/dist/plan/boule.d.ts +1 -1
- package/dist/plan/boule.js +2 -0
- package/dist/plan/broker-entries.d.ts +9 -0
- package/dist/plan/broker-entries.js +29 -0
- package/dist/plan/diff.d.ts +5 -0
- package/dist/plan/diff.js +4 -0
- package/dist/plan/edge-access.d.ts +2 -3
- package/dist/plan/edge-access.js +7 -0
- package/dist/plan/executor.d.ts +71 -1
- package/dist/plan/executor.js +426 -24
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/jobs-scheduler-http.d.ts +2 -0
- package/dist/plan/jobs-scheduler-http.js +11 -0
- package/dist/plan/own-limits.d.ts +6 -0
- package/dist/plan/own-limits.js +1 -0
- package/dist/plan/plan-apply.d.ts +51 -1
- package/dist/plan/plan-apply.js +298 -25
- package/dist/plan/platform-diff.d.ts +6 -0
- package/dist/plan/platform-diff.js +74 -0
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-providers.d.ts +46 -1
- package/dist/plan/platform-providers.js +156 -1
- package/dist/plan/platform-registry-keys.d.ts +58 -0
- package/dist/plan/platform-registry-keys.js +69 -0
- package/dist/plan/platform-registry.d.ts +67 -0
- package/dist/plan/platform-registry.js +298 -0
- package/dist/plan/release-host.d.ts +31 -2
- package/dist/plan/release-host.js +114 -6
- package/dist/plan/stage-copy-host.d.ts +104 -0
- package/dist/plan/stage-copy-host.js +259 -0
- package/dist/plan/stage-copy.js +44 -0
- package/dist/plan/stage-env.d.ts +9 -0
- package/dist/plan/stage-env.js +38 -0
- package/dist/plan/stage-guards.d.ts +57 -1
- package/dist/plan/stage-guards.js +106 -0
- package/dist/plan/stage-isolation.js +56 -0
- package/dist/plan/stage-services.d.ts +93 -0
- package/dist/plan/stage-services.js +153 -0
- package/dist/plan/stage-signin.d.ts +152 -0
- package/dist/plan/stage-signin.js +133 -0
- package/dist/plan/stage-sink.d.ts +2 -0
- package/dist/plan/stage-sink.js +6 -1
- package/dist/platform.d.ts +7 -1
- package/dist/platform.js +1 -0
- package/dist/release/decide.d.ts +12 -0
- package/dist/release/decide.js +37 -2
- package/dist/release/run.d.ts +71 -1
- package/dist/release/run.js +92 -8
- package/dist/release/state.d.ts +14 -1
- package/dist/release/state.js +43 -0
- package/dist/release/types.d.ts +20 -0
- package/dist/release/types.js +2 -0
- package/dist/requested-changes.d.ts +11 -2
- package/dist/requested-changes.js +11 -0
- package/dist/store/postgres.js +8 -0
- package/dist/store/schema.d.ts +51 -0
- package/dist/store/schema.js +4 -0
- package/package.json +3 -2
- package/versions.digests.json +1 -0
- package/versions.json +1 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { DeclaredPlatform } from '../platform.js';
|
|
2
|
+
import type { CoolifyPort } from './executor.js';
|
|
3
|
+
import type { PlatformStepExecutionContext } from './plan-apply.js';
|
|
4
|
+
import { type PlatformDnsFactory, type PlatformTunnelsFactory } from './platform-providers.js';
|
|
5
|
+
export interface PlatformRegistryRuntime {
|
|
6
|
+
fetch?: typeof fetch;
|
|
7
|
+
sleep?: (ms: number) => Promise<void>;
|
|
8
|
+
now?: () => number;
|
|
9
|
+
readyTimeoutMs?: number;
|
|
10
|
+
readyIntervalMs?: number;
|
|
11
|
+
/** Tries at handing each password to the host (default 3). */
|
|
12
|
+
deliverAttempts?: number;
|
|
13
|
+
/** Told of every password this step makes or reads, so the host can scrub it from logs and audit rows. */
|
|
14
|
+
onSecret?: (value: string) => void;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Everything the registry steps need beyond {@link PlatformStepExecutionContext}. `deliverSecret`
|
|
18
|
+
* and `heldKeys` are the platform's held-key writes and reads (edge's pattern); `listServers` and
|
|
19
|
+
* `raw` reach every server Coolify manages, not only the platform's.
|
|
20
|
+
*/
|
|
21
|
+
export interface PlatformRegistryDeps {
|
|
22
|
+
coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'listStorages' | 'createPersistentStorage' | 'createFileStorage' | 'updateFileStorage' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'deployAndWait' | 'raw'>;
|
|
23
|
+
/** F4.dns: every DNS record call, through edge at its recorded address. */
|
|
24
|
+
dns: PlatformDnsFactory;
|
|
25
|
+
/** F4.tunnels: every tunnel call, through edge at its recorded address. */
|
|
26
|
+
tunnels: PlatformTunnelsFactory;
|
|
27
|
+
deliverSecret: (ref: string, secret: string) => Promise<void>;
|
|
28
|
+
/** Reads a held key back. The login step reads the `pull` password this way. */
|
|
29
|
+
heldKeys?: {
|
|
30
|
+
resolve(ref: string): Promise<string | null>;
|
|
31
|
+
};
|
|
32
|
+
runtime?: PlatformRegistryRuntime;
|
|
33
|
+
}
|
|
34
|
+
/** One htpasswd line (`user:bcrypt hash`). Docker's `distribution` accepts bcrypt only. */
|
|
35
|
+
export declare function htpasswdLine(user: string, password: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* The registry app: `registry:3` (by digest), the data volume, the htpasswd file, the tunnel and
|
|
38
|
+
* the DNS record. Safe to run again at any point:
|
|
39
|
+
* - The two passwords are made once. The htpasswd file is written before they are recorded as
|
|
40
|
+
* undelivered, so the only window that blocks a re-run is the one the other platform steps
|
|
41
|
+
* have (CLAUDE.md, rule 1).
|
|
42
|
+
* - The app is deployed when it was created, is not running, its env changed, the declared
|
|
43
|
+
* hostname or tag changed, or the passwords were made in this run.
|
|
44
|
+
* - The applied hostname and tag are recorded last.
|
|
45
|
+
*/
|
|
46
|
+
export declare function provisionPlatformRegistry(context: PlatformStepExecutionContext, deps: PlatformRegistryDeps): Promise<{
|
|
47
|
+
resourceId?: string;
|
|
48
|
+
}>;
|
|
49
|
+
/**
|
|
50
|
+
* Logs every Coolify server in to the registry as `pull` (`POST /servers/{uuid}/registries`,
|
|
51
|
+
* which runs `docker login` on the server and replaces an earlier login to the same host).
|
|
52
|
+
* - A server already recorded as logged in to this host with these passwords is left alone.
|
|
53
|
+
* - A server Coolify marks unusable or unreachable is skipped and named under
|
|
54
|
+
* {@link PLATFORM_REGISTRY_LOGINS_SKIPPED_SCOPE_KEY}; `driftedSteps` plans the step again
|
|
55
|
+
* once it is reachable.
|
|
56
|
+
* - A login that fails is counted; the others still run, each success is recorded at once, and
|
|
57
|
+
* the step then throws a text naming the server ids, so the next apply tries only those again.
|
|
58
|
+
* - Every Coolify call has a timeout. The password is read from the held keys and goes only
|
|
59
|
+
* into the request body.
|
|
60
|
+
*/
|
|
61
|
+
export declare function ensureRegistryLogins(context: PlatformStepExecutionContext, deps: PlatformRegistryDeps): Promise<Record<string, never>>;
|
|
62
|
+
/**
|
|
63
|
+
* True when a usable, reachable server has no login recorded for the current registry host and
|
|
64
|
+
* passwords: a server added, or back up, since the last login run. A read that fails counts as
|
|
65
|
+
* not drifted, like the Archon drift reads.
|
|
66
|
+
*/
|
|
67
|
+
export declare function registryLoginsDrifted(deps: Pick<PlatformRegistryDeps, 'coolify'>, platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<boolean>;
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import bcrypt from 'bcryptjs';
|
|
3
|
+
import { coolifyImageFor } from '../bootstrap/versions.js';
|
|
4
|
+
import { CallError, withTimeout } from './call-timeout.js';
|
|
5
|
+
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
6
|
+
import { waitForNewDeployment } from './deploy-health.js';
|
|
7
|
+
import { commandHealthCheckSettings, tcpProbe } from './health-check-settings.js';
|
|
8
|
+
import { envsDiffer, findOrCreatePlatformApp, platformDns, platformTunnels, resolvePlatformServer, } from './platform-providers.js';
|
|
9
|
+
import { PLATFORM_REGISTRY_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_REGISTRY_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_REGISTRY_APP_SCOPE_KEY, PLATFORM_REGISTRY_CONNECTOR_APP_SCOPE_KEY, PLATFORM_REGISTRY_INTERNAL_URL_SCOPE_KEY, PLATFORM_REGISTRY_LOGINS_APPLIED_SCOPE_KEY, PLATFORM_REGISTRY_LOGINS_SKIPPED_SCOPE_KEY, PLATFORM_REGISTRY_PASSWORDS_SET_SCOPE_KEY, PLATFORM_REGISTRY_PASSWORDS_UNDELIVERED_SCOPE_KEY, PLATFORM_REGISTRY_PROJECT_SCOPE_KEY, PLATFORM_REGISTRY_PULL_PASSWORD_REF, PLATFORM_REGISTRY_PULL_USER, PLATFORM_REGISTRY_PUSH_PASSWORD_REF, PLATFORM_REGISTRY_PUSH_USER, PLATFORM_REGISTRY_TUNNEL_SCOPE_KEY, registryImageTagBlocked, registryLoginVersion, registryServerLoginScopeKey, } from './platform-registry-keys.js';
|
|
10
|
+
const REGISTRY_IMAGE = 'registry';
|
|
11
|
+
const REGISTRY_PROJECT = 'platform';
|
|
12
|
+
const REGISTRY_APP_NAME = 'platform-registry';
|
|
13
|
+
const REGISTRY_INTERNAL_NAME = 'platform-registry';
|
|
14
|
+
const REGISTRY_PORT = '5000';
|
|
15
|
+
const REGISTRY_TUNNEL_NAME = 'platform-registry-tunnel';
|
|
16
|
+
const REGISTRY_CLOUDFLARED_APP_NAME = 'platform-registry-cloudflared';
|
|
17
|
+
const REGISTRY_DATA_DIR = '/var/lib/registry';
|
|
18
|
+
const REGISTRY_HTPASSWD_PATH = '/auth/htpasswd';
|
|
19
|
+
/** Coolify's per-call limit on a registry login. It runs `docker login` on the server over SSH. */
|
|
20
|
+
const REGISTRY_LOGIN_TIMEOUT_MS = 60_000;
|
|
21
|
+
const REGISTRY_SERVER_LIST_TIMEOUT_MS = 30_000;
|
|
22
|
+
const REGISTRY_HELD_READ_TIMEOUT_MS = 30_000;
|
|
23
|
+
const DEFAULT_DELIVER_ATTEMPTS = 3;
|
|
24
|
+
/** One htpasswd line (`user:bcrypt hash`). Docker's `distribution` accepts bcrypt only. */
|
|
25
|
+
export function htpasswdLine(user, password) {
|
|
26
|
+
return `${user}:${bcrypt.hashSync(password, 10)}`;
|
|
27
|
+
}
|
|
28
|
+
function newPassword() {
|
|
29
|
+
return randomBytes(32).toString('base64url');
|
|
30
|
+
}
|
|
31
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
32
|
+
async function deliverWithRetry(deliverSecret, ref, secret, attempts, sleep) {
|
|
33
|
+
for (let attempt = 1;; attempt += 1) {
|
|
34
|
+
try {
|
|
35
|
+
await deliverSecret(ref, secret);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
if (attempt >= attempts) {
|
|
40
|
+
throw new Error(`platform:registry: the password held at ${ref} could not be delivered after ${attempts} tries; the next plan reports it as blocked and mints no new one`);
|
|
41
|
+
}
|
|
42
|
+
await sleep(1000 * attempt);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The registry app: `registry:3` (by digest), the data volume, the htpasswd file, the tunnel and
|
|
48
|
+
* the DNS record. Safe to run again at any point:
|
|
49
|
+
* - The two passwords are made once. The htpasswd file is written before they are recorded as
|
|
50
|
+
* undelivered, so the only window that blocks a re-run is the one the other platform steps
|
|
51
|
+
* have (CLAUDE.md, rule 1).
|
|
52
|
+
* - The app is deployed when it was created, is not running, its env changed, the declared
|
|
53
|
+
* hostname or tag changed, or the passwords were made in this run.
|
|
54
|
+
* - The applied hostname and tag are recorded last.
|
|
55
|
+
*/
|
|
56
|
+
export async function provisionPlatformRegistry(context, deps) {
|
|
57
|
+
const { platform } = context;
|
|
58
|
+
const { hostname, gitRef } = platform.registry;
|
|
59
|
+
const tagBlocked = registryImageTagBlocked(gitRef);
|
|
60
|
+
if (tagBlocked)
|
|
61
|
+
throw new Error(tagBlocked);
|
|
62
|
+
const recorded = context.recordedIds();
|
|
63
|
+
const runtime = deps.runtime ?? {};
|
|
64
|
+
const sleep = runtime.sleep ?? defaultSleep;
|
|
65
|
+
const record = async (key, id) => {
|
|
66
|
+
context.recordScopeId(key, id);
|
|
67
|
+
await context.persist?.();
|
|
68
|
+
};
|
|
69
|
+
if (recorded[PLATFORM_REGISTRY_PASSWORDS_UNDELIVERED_SCOPE_KEY] &&
|
|
70
|
+
!recorded[PLATFORM_REGISTRY_PASSWORDS_SET_SCOPE_KEY]) {
|
|
71
|
+
throw new Error(`platform:registry: the passwords were made and their delivery is not confirmed; remove the recorded id ${PLATFORM_REGISTRY_PASSWORDS_UNDELIVERED_SCOPE_KEY} to make new ones`);
|
|
72
|
+
}
|
|
73
|
+
let projectUuid = recorded[PLATFORM_REGISTRY_PROJECT_SCOPE_KEY];
|
|
74
|
+
if (!projectUuid) {
|
|
75
|
+
const found = await deps.coolify.findProjectByName(REGISTRY_PROJECT);
|
|
76
|
+
projectUuid = found ? found.uuid : (await deps.coolify.createProject(REGISTRY_PROJECT)).uuid;
|
|
77
|
+
await record(PLATFORM_REGISTRY_PROJECT_SCOPE_KEY, projectUuid);
|
|
78
|
+
}
|
|
79
|
+
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
80
|
+
const server = await resolvePlatformServer(deps.coolify);
|
|
81
|
+
// ---- the app ---------------------------------------------------------------------------
|
|
82
|
+
const app = await findOrCreatePlatformApp(deps.coolify, REGISTRY_APP_NAME, environment, () => deps.coolify.createDockerImageApplication({
|
|
83
|
+
project_uuid: projectUuid,
|
|
84
|
+
server_uuid: server.uuid,
|
|
85
|
+
environment_name: environment.name,
|
|
86
|
+
environment_uuid: environment.uuid,
|
|
87
|
+
...coolifyImageFor('registry', REGISTRY_IMAGE, gitRef),
|
|
88
|
+
ports_exposes: REGISTRY_PORT,
|
|
89
|
+
name: REGISTRY_APP_NAME,
|
|
90
|
+
description: `The platform's image registry (C5, ${REGISTRY_IMAGE}:${gitRef})`,
|
|
91
|
+
instant_deploy: false,
|
|
92
|
+
}));
|
|
93
|
+
const appId = app.appId;
|
|
94
|
+
await record(PLATFORM_REGISTRY_APP_SCOPE_KEY, appId);
|
|
95
|
+
// On every run: a changed tag reaches an app made on an earlier one.
|
|
96
|
+
await deps.coolify.updateApplication(appId, {
|
|
97
|
+
custom_internal_name: REGISTRY_INTERNAL_NAME,
|
|
98
|
+
...coolifyImageFor('registry', REGISTRY_IMAGE, gitRef),
|
|
99
|
+
// The registry answers 401 on every route without a password, so Coolify's HTTP check cannot pass: a TCP connect.
|
|
100
|
+
...commandHealthCheckSettings(tcpProbe(REGISTRY_PORT)),
|
|
101
|
+
});
|
|
102
|
+
// ---- the data volume ---------------------------------------------------------------------
|
|
103
|
+
const storages = await deps.coolify.listStorages(appId);
|
|
104
|
+
let storageAdded = false;
|
|
105
|
+
if (!(storages.persistent_storages ?? []).some((s) => s.mount_path === REGISTRY_DATA_DIR)) {
|
|
106
|
+
await deps.coolify.createPersistentStorage(appId, {
|
|
107
|
+
name: `${REGISTRY_APP_NAME}-data`,
|
|
108
|
+
mount_path: REGISTRY_DATA_DIR,
|
|
109
|
+
});
|
|
110
|
+
storageAdded = true;
|
|
111
|
+
}
|
|
112
|
+
// ---- the passwords, once -----------------------------------------------------------------
|
|
113
|
+
let mintedNow = false;
|
|
114
|
+
let set = recorded[PLATFORM_REGISTRY_PASSWORDS_SET_SCOPE_KEY] ?? '';
|
|
115
|
+
if (!set) {
|
|
116
|
+
const push = newPassword();
|
|
117
|
+
const pull = newPassword();
|
|
118
|
+
runtime.onSecret?.(push);
|
|
119
|
+
runtime.onSecret?.(pull);
|
|
120
|
+
set = createHash('sha256').update(`${push}\n${pull}`).digest('hex');
|
|
121
|
+
// The file first: with the passwords only in memory, a crash before they are recorded makes new ones
|
|
122
|
+
// and writes the file again, so the file and the held passwords never disagree.
|
|
123
|
+
const content = `${htpasswdLine(PLATFORM_REGISTRY_PUSH_USER, push)}\n${htpasswdLine(PLATFORM_REGISTRY_PULL_USER, pull)}\n`;
|
|
124
|
+
const existing = (storages.file_storages ?? []).find((s) => s.mount_path === REGISTRY_HTPASSWD_PATH);
|
|
125
|
+
if (existing) {
|
|
126
|
+
await deps.coolify.updateFileStorage(appId, existing.uuid, content);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
await deps.coolify.createFileStorage(appId, {
|
|
130
|
+
mount_path: REGISTRY_HTPASSWD_PATH,
|
|
131
|
+
content,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
await record(PLATFORM_REGISTRY_PASSWORDS_UNDELIVERED_SCOPE_KEY, set);
|
|
135
|
+
const attempts = runtime.deliverAttempts ?? DEFAULT_DELIVER_ATTEMPTS;
|
|
136
|
+
await deliverWithRetry(deps.deliverSecret, PLATFORM_REGISTRY_PUSH_PASSWORD_REF, push, attempts, sleep);
|
|
137
|
+
await deliverWithRetry(deps.deliverSecret, PLATFORM_REGISTRY_PULL_PASSWORD_REF, pull, attempts, sleep);
|
|
138
|
+
await record(PLATFORM_REGISTRY_PASSWORDS_SET_SCOPE_KEY, set);
|
|
139
|
+
mintedNow = true;
|
|
140
|
+
}
|
|
141
|
+
// ---- env, no secret in it -----------------------------------------------------------------
|
|
142
|
+
const envs = [
|
|
143
|
+
{ key: 'REGISTRY_AUTH', value: 'htpasswd' },
|
|
144
|
+
{ key: 'REGISTRY_AUTH_HTPASSWD_REALM', value: 'Registry' },
|
|
145
|
+
{ key: 'REGISTRY_AUTH_HTPASSWD_PATH', value: REGISTRY_HTPASSWD_PATH },
|
|
146
|
+
{ key: 'REGISTRY_HTTP_ADDR', value: `:${REGISTRY_PORT}` },
|
|
147
|
+
// The tunnel and Cloudflare sit in front: the registry answers with paths, not with its own address.
|
|
148
|
+
{ key: 'REGISTRY_HTTP_RELATIVEURLS', value: 'true' },
|
|
149
|
+
{ key: 'REGISTRY_STORAGE_DELETE_ENABLED', value: 'false' },
|
|
150
|
+
];
|
|
151
|
+
const envsChanged = await envsDiffer(deps.coolify, appId, envs);
|
|
152
|
+
await deps.coolify.bulkUpsertEnvs(appId, envs);
|
|
153
|
+
const declaredChanged = recorded[PLATFORM_REGISTRY_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname ||
|
|
154
|
+
recorded[PLATFORM_REGISTRY_APPLIED_GIT_REF_SCOPE_KEY] !== gitRef;
|
|
155
|
+
const internalUrl = `http://${REGISTRY_INTERNAL_NAME}:${REGISTRY_PORT}`;
|
|
156
|
+
if (app.justCreated ||
|
|
157
|
+
!app.running ||
|
|
158
|
+
envsChanged ||
|
|
159
|
+
declaredChanged ||
|
|
160
|
+
mintedNow ||
|
|
161
|
+
storageAdded) {
|
|
162
|
+
// The registry answers 401 to a caller with no password, so the deployment record is the check.
|
|
163
|
+
await waitForNewDeployment(deps.coolify, appId, { kind: 'deployment-only' }, runtime, sleep, 'platform:registry', `the registry stays at ${recorded[PLATFORM_REGISTRY_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
164
|
+
}
|
|
165
|
+
await record(PLATFORM_REGISTRY_INTERNAL_URL_SCOPE_KEY, internalUrl);
|
|
166
|
+
// ---- tunnel and DNS ------------------------------------------------------------------------
|
|
167
|
+
const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:registry');
|
|
168
|
+
const existingTunnel = await tunnels.findTunnelByName(REGISTRY_TUNNEL_NAME);
|
|
169
|
+
const tunnel = existingTunnel ?? (await tunnels.createTunnel(REGISTRY_TUNNEL_NAME));
|
|
170
|
+
await record(PLATFORM_REGISTRY_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
171
|
+
await tunnels.putTunnelConfig(tunnel.id, {
|
|
172
|
+
ingress: [{ hostname, service: internalUrl }, { service: 'http_status:404' }],
|
|
173
|
+
});
|
|
174
|
+
await ensureCloudflaredConnector({
|
|
175
|
+
coolify: deps.coolify,
|
|
176
|
+
tunnels,
|
|
177
|
+
tunnelId: tunnel.id,
|
|
178
|
+
appName: REGISTRY_CLOUDFLARED_APP_NAME,
|
|
179
|
+
projectUuid,
|
|
180
|
+
environment,
|
|
181
|
+
resolveServer: async () => server,
|
|
182
|
+
recordedConnectorAppId: recorded[PLATFORM_REGISTRY_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
183
|
+
recordAppId: (connectorAppId) => record(PLATFORM_REGISTRY_CONNECTOR_APP_SCOPE_KEY, connectorAppId),
|
|
184
|
+
});
|
|
185
|
+
const dns = await platformDns(deps, context.recordedIds(), 'platform:registry');
|
|
186
|
+
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
187
|
+
const [dnsRecord] = await dns.findDnsRecord(platform.apex, hostname, 'CNAME');
|
|
188
|
+
if (!dnsRecord) {
|
|
189
|
+
await dns.createDnsRecord(platform.apex, {
|
|
190
|
+
type: 'CNAME',
|
|
191
|
+
name: hostname,
|
|
192
|
+
content: dnsTarget,
|
|
193
|
+
proxied: true,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
else if (dnsRecord.content !== dnsTarget) {
|
|
197
|
+
await dns.updateDnsRecord(platform.apex, dnsRecord.id, {
|
|
198
|
+
type: 'CNAME',
|
|
199
|
+
name: hostname,
|
|
200
|
+
content: dnsTarget,
|
|
201
|
+
proxied: true,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
// Last: a run that failed anywhere above is planned again.
|
|
205
|
+
context.recordScopeId(PLATFORM_REGISTRY_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
|
|
206
|
+
await record(PLATFORM_REGISTRY_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
207
|
+
return { resourceId: appId };
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Logs every Coolify server in to the registry as `pull` (`POST /servers/{uuid}/registries`,
|
|
211
|
+
* which runs `docker login` on the server and replaces an earlier login to the same host).
|
|
212
|
+
* - A server already recorded as logged in to this host with these passwords is left alone.
|
|
213
|
+
* - A server Coolify marks unusable or unreachable is skipped and named under
|
|
214
|
+
* {@link PLATFORM_REGISTRY_LOGINS_SKIPPED_SCOPE_KEY}; `driftedSteps` plans the step again
|
|
215
|
+
* once it is reachable.
|
|
216
|
+
* - A login that fails is counted; the others still run, each success is recorded at once, and
|
|
217
|
+
* the step then throws a text naming the server ids, so the next apply tries only those again.
|
|
218
|
+
* - Every Coolify call has a timeout. The password is read from the held keys and goes only
|
|
219
|
+
* into the request body.
|
|
220
|
+
*/
|
|
221
|
+
export async function ensureRegistryLogins(context, deps) {
|
|
222
|
+
const { hostname } = context.platform.registry;
|
|
223
|
+
const recorded = context.recordedIds();
|
|
224
|
+
const set = recorded[PLATFORM_REGISTRY_PASSWORDS_SET_SCOPE_KEY];
|
|
225
|
+
if (!set) {
|
|
226
|
+
throw new Error('platform:registry-logins: the registry passwords are not set yet; platform:registry runs first');
|
|
227
|
+
}
|
|
228
|
+
if (!deps.heldKeys) {
|
|
229
|
+
throw new Error(`platform:registry-logins: the host cannot read held keys, so the pull password at ${PLATFORM_REGISTRY_PULL_PASSWORD_REF} cannot be read`);
|
|
230
|
+
}
|
|
231
|
+
const heldKeys = deps.heldKeys;
|
|
232
|
+
const pull = await withTimeout('platform:registry-logins: reading the pull password', REGISTRY_HELD_READ_TIMEOUT_MS, () => heldKeys.resolve(PLATFORM_REGISTRY_PULL_PASSWORD_REF));
|
|
233
|
+
if (!pull) {
|
|
234
|
+
throw new Error(`platform:registry-logins: no password is held at ${PLATFORM_REGISTRY_PULL_PASSWORD_REF}`);
|
|
235
|
+
}
|
|
236
|
+
deps.runtime?.onSecret?.(pull);
|
|
237
|
+
const record = async (key, id) => {
|
|
238
|
+
context.recordScopeId(key, id);
|
|
239
|
+
await context.persist?.();
|
|
240
|
+
};
|
|
241
|
+
const version = registryLoginVersion(hostname, set);
|
|
242
|
+
const servers = await withTimeout('platform:registry-logins: listing the servers', REGISTRY_SERVER_LIST_TIMEOUT_MS, () => deps.coolify.listServers());
|
|
243
|
+
const skipped = [];
|
|
244
|
+
const failed = [];
|
|
245
|
+
for (const server of servers) {
|
|
246
|
+
if (!server.is_usable || !server.is_reachable) {
|
|
247
|
+
skipped.push(server.uuid);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const key = registryServerLoginScopeKey(server.uuid);
|
|
251
|
+
if (recorded[key] === version)
|
|
252
|
+
continue;
|
|
253
|
+
try {
|
|
254
|
+
await withTimeout(`platform:registry-logins: logging in server ${server.uuid}`, REGISTRY_LOGIN_TIMEOUT_MS, () => deps.coolify.raw('POST', `/servers/${server.uuid}/registries`, {
|
|
255
|
+
registry: hostname,
|
|
256
|
+
username: PLATFORM_REGISTRY_PULL_USER,
|
|
257
|
+
password: pull,
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
// The text of a CallError is fixed; anything else is dropped without reading it.
|
|
262
|
+
if (!(error instanceof CallError))
|
|
263
|
+
throw new Error('platform:registry-logins: a login call failed');
|
|
264
|
+
failed.push(server.uuid);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
await record(key, version);
|
|
268
|
+
}
|
|
269
|
+
await record(PLATFORM_REGISTRY_LOGINS_SKIPPED_SCOPE_KEY, skipped.join(','));
|
|
270
|
+
if (failed.length > 0) {
|
|
271
|
+
throw new Error(`platform:registry-logins: could not log in ${failed.length} of ${servers.length} servers (${failed.join(', ')}); the next apply tries those again`);
|
|
272
|
+
}
|
|
273
|
+
await record(PLATFORM_REGISTRY_LOGINS_APPLIED_SCOPE_KEY, version);
|
|
274
|
+
return {};
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* True when a usable, reachable server has no login recorded for the current registry host and
|
|
278
|
+
* passwords: a server added, or back up, since the last login run. A read that fails counts as
|
|
279
|
+
* not drifted, like the Archon drift reads.
|
|
280
|
+
*/
|
|
281
|
+
export async function registryLoginsDrifted(deps, platform, recordedIds) {
|
|
282
|
+
const set = recordedIds[PLATFORM_REGISTRY_PASSWORDS_SET_SCOPE_KEY];
|
|
283
|
+
if (!set)
|
|
284
|
+
return false;
|
|
285
|
+
const version = registryLoginVersion(platform.registry.hostname, set);
|
|
286
|
+
// Planned already (never applied for this host and these passwords): not a drift.
|
|
287
|
+
if (recordedIds[PLATFORM_REGISTRY_LOGINS_APPLIED_SCOPE_KEY] !== version)
|
|
288
|
+
return false;
|
|
289
|
+
let servers;
|
|
290
|
+
try {
|
|
291
|
+
servers = await withTimeout('platform:registry-logins: listing the servers', REGISTRY_SERVER_LIST_TIMEOUT_MS, () => deps.coolify.listServers());
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
console.error('platform:registry-logins: could not list the servers; treating the logins as current');
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
return servers.some((s) => s.is_usable && s.is_reachable && recordedIds[registryServerLoginScopeKey(s.uuid)] !== version);
|
|
298
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { type ReleaseDeps } from '../release/run.js';
|
|
2
|
-
import type
|
|
1
|
+
import { type ReleaseDeps, type StageCopyJob, type StageCopyOutcome } from '../release/run.js';
|
|
2
|
+
import { type ReleaseStore } from '../release/state.js';
|
|
3
3
|
import { type BlueGreenSubject, type PinHeldKeys } from './blue-green.js';
|
|
4
4
|
import { type DeployHealthRuntime } from './deploy-health.js';
|
|
5
5
|
import type { CoolifyPort } from './executor.js';
|
|
6
|
+
import { type StageCopyHostPorts } from './stage-copy-host.js';
|
|
6
7
|
/** The recorded ids a release run reads and writes: an org's apply context, or the platform's. */
|
|
7
8
|
export interface ReleaseContext {
|
|
8
9
|
recordedIds(): Readonly<Record<string, string>>;
|
|
@@ -40,7 +41,12 @@ export interface ReleaseHostInput {
|
|
|
40
41
|
/** Extends the mutation lease; called before each outside call that can take long. */
|
|
41
42
|
renew: () => Promise<void>;
|
|
42
43
|
label: string;
|
|
44
|
+
/** What the stage copy needs; absent: copy requests are refused with `no-copy-port`. */
|
|
45
|
+
stageCopy?: StageCopyHostPorts;
|
|
43
46
|
}
|
|
47
|
+
/** A stage deploy waits at most this many polls of this long (10 minutes) for Coolify's deployment to finish. */
|
|
48
|
+
export declare const STAGE_DEPLOY_ATTEMPTS = 60;
|
|
49
|
+
export declare const STAGE_DEPLOY_INTERVAL_MS = 10000;
|
|
44
50
|
export declare function createReleaseHost(input: ReleaseHostInput): ReleaseHost;
|
|
45
51
|
/**
|
|
46
52
|
* One release run for one subject, writing into `context` (the live recorded ids of the run that holds
|
|
@@ -57,7 +63,30 @@ export declare function runSubjectRelease(input: {
|
|
|
57
63
|
context: ReleaseContext;
|
|
58
64
|
now: Date;
|
|
59
65
|
primaryUuid: string;
|
|
66
|
+
/** Leave stage copy requests open: an apply never runs one, and the tick's first phase defers them. */
|
|
67
|
+
deferCopies?: boolean;
|
|
60
68
|
}): Promise<{
|
|
61
69
|
blocked: string[];
|
|
62
70
|
refused: string[];
|
|
71
|
+
deferredCopy: boolean;
|
|
72
|
+
/** The stage copy this run decided on. The caller runs it, after this run's writes and weights are done. */
|
|
73
|
+
copy: StageCopyJob | null;
|
|
74
|
+
}>;
|
|
75
|
+
/**
|
|
76
|
+
* Runs the stage copy a release run decided on and records what came of it. Call it after
|
|
77
|
+
* {@link runSubjectRelease} returned (that run's writes are persisted and its weights written), so a rollback
|
|
78
|
+
* decided in the same run never waits for the copy. The outcome goes into `context`, is persisted, and only then
|
|
79
|
+
* is the request consumed. `refused` is the operator text of a refusal. A host without ports refuses with `no-copy-port`; a lost lock throws and leaves the
|
|
80
|
+
* request open.
|
|
81
|
+
*/
|
|
82
|
+
export declare function finishStageCopy(input: {
|
|
83
|
+
store: Pick<ReleaseStore, 'consumeRequests'>;
|
|
84
|
+
host: ReleaseHost | null | undefined;
|
|
85
|
+
subject: Pick<BlueGreenSubject, 'id'>;
|
|
86
|
+
context: ReleaseContext;
|
|
87
|
+
now: Date;
|
|
88
|
+
job: StageCopyJob;
|
|
89
|
+
}): Promise<{
|
|
90
|
+
outcome: StageCopyOutcome;
|
|
91
|
+
refused: string[];
|
|
63
92
|
}>;
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { checkColorAddress, readReleaseHealth } from '../release/health.js';
|
|
3
|
-
import { runRelease } from '../release/run.js';
|
|
3
|
+
import { runRelease, runStageCopy, settleStageCopy, } from '../release/run.js';
|
|
4
|
+
import { releaseKeys } from '../release/state.js';
|
|
4
5
|
import { applyBlueGreenWeights, copiedEnv } from './blue-green-apply.js';
|
|
5
6
|
import { ACTIONS_KEY_ENV, DEFAULT_WEIGHTS, LB_COLOR_HEADER, LB_PIN_HEADER, bgKey, formatWeights, mintPinKey, parseWeights, readPinKey, reportingEnv, } from './blue-green.js';
|
|
6
|
-
import { waitForNewDeployment } from './deploy-health.js';
|
|
7
|
+
import { waitForDeploymentFinished, waitForNewDeployment, } from './deploy-health.js';
|
|
7
8
|
import { isNotFoundError } from './executor.js';
|
|
9
|
+
import { runStageCopyRequest } from './stage-copy-host.js';
|
|
8
10
|
const COMMIT = /^[0-9a-f]{40}$/;
|
|
11
|
+
/** A stage deploy waits at most this many polls of this long (10 minutes) for Coolify's deployment to finish. */
|
|
12
|
+
export const STAGE_DEPLOY_ATTEMPTS = 60;
|
|
13
|
+
export const STAGE_DEPLOY_INTERVAL_MS = 10_000;
|
|
9
14
|
/** Coolify reports a restarting container as `restarting:...`; a crash loop is that. */
|
|
10
15
|
function statusOf(status) {
|
|
11
16
|
if (status?.startsWith('running'))
|
|
@@ -129,6 +134,65 @@ export function createReleaseHost(input) {
|
|
|
129
134
|
await waitForNewDeployment(coolify, appId, { kind: 'deployment-only' }, input.runtime, input.runtime.sleep ?? defaultSleep, `${input.label} ${color}`, `${color} stays as it was`);
|
|
130
135
|
}
|
|
131
136
|
};
|
|
137
|
+
/**
|
|
138
|
+
* Deploys the stage app at `ref` (a full sha, or a branch's head) and records the outcome as
|
|
139
|
+
* `bg.<id>.stagedeploy`. A failure is recorded, not thrown: the request is then consumed with the run, and the
|
|
140
|
+
* record is what tells the person. The text recorded is fixed, never an error message (it can echo env).
|
|
141
|
+
*/
|
|
142
|
+
const deployStage = async (ref) => {
|
|
143
|
+
const at = new Date((input.runtime.now ?? Date.now)()).toISOString();
|
|
144
|
+
const fail = (step) => {
|
|
145
|
+
console.error(`${input.label}: deploying stage at ${ref} failed at ${step}`);
|
|
146
|
+
context.recordScopeId(bgKey.stageDeploy(id), `${at}|failed|${step}`);
|
|
147
|
+
};
|
|
148
|
+
const stageId = recorded()[bgKey.app(id, input.primaryUuid, 'stage')];
|
|
149
|
+
if (!stageId)
|
|
150
|
+
return fail('no stage app');
|
|
151
|
+
await input.renew();
|
|
152
|
+
try {
|
|
153
|
+
// A sha pins the commit; a branch goes back to its head. Env is not touched.
|
|
154
|
+
await coolify.updateApplication(stageId, COMMIT.test(ref) ? { git_commit_sha: ref } : { git_branch: ref, git_commit_sha: 'HEAD' });
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return fail('select commit');
|
|
158
|
+
}
|
|
159
|
+
let deploymentUuid;
|
|
160
|
+
try {
|
|
161
|
+
({ deploymentUuid } = await waitForDeploymentFinished(coolify, stageId, {
|
|
162
|
+
...input.runtime,
|
|
163
|
+
deployAttempts: input.runtime.deployAttempts ?? STAGE_DEPLOY_ATTEMPTS,
|
|
164
|
+
deployIntervalMs: input.runtime.deployIntervalMs ?? STAGE_DEPLOY_INTERVAL_MS,
|
|
165
|
+
}, `${input.label} stage`, 'stage stays as it was'));
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return fail('deploy');
|
|
169
|
+
}
|
|
170
|
+
let commit = COMMIT.test(ref) ? ref : '';
|
|
171
|
+
if (!commit && coolify.listApplicationDeployments) {
|
|
172
|
+
try {
|
|
173
|
+
const rows = await coolify.listApplicationDeployments(stageId, 5);
|
|
174
|
+
const row = rows.find((r) => r.deployment_uuid === deploymentUuid);
|
|
175
|
+
if (row?.commit && COMMIT.test(row.commit))
|
|
176
|
+
commit = row.commit;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// The deploy worked; the commit is read from the deployment list on the next run.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!commit)
|
|
183
|
+
return fail('select commit');
|
|
184
|
+
if (!COMMIT.test(ref)) {
|
|
185
|
+
// Pinned to what was built, so a later redeploy of stage (an env change) never floats to the branch head.
|
|
186
|
+
try {
|
|
187
|
+
await coolify.updateApplication(stageId, { git_commit_sha: commit });
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return fail('select commit');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
context.recordScopeId(bgKey.stage(id), commit);
|
|
194
|
+
context.recordScopeId(bgKey.stageDeploy(id), `${at}|ok|${commit}`);
|
|
195
|
+
};
|
|
132
196
|
const stop = async (color) => {
|
|
133
197
|
for (const appId of colorApps(color)) {
|
|
134
198
|
await input.renew();
|
|
@@ -174,12 +238,14 @@ export function createReleaseHost(input) {
|
|
|
174
238
|
}),
|
|
175
239
|
};
|
|
176
240
|
},
|
|
241
|
+
stageCopy: (request) => runStageCopyRequest({ subject, context, ports: input.stageCopy, renew: input.renew }, request),
|
|
177
242
|
async effect(effect) {
|
|
178
243
|
if (effect.kind === 'deploy')
|
|
179
244
|
await deployTo(effect.color, effect.digest);
|
|
180
245
|
else if (effect.kind === 'stop')
|
|
181
246
|
await stop(effect.color);
|
|
182
|
-
|
|
247
|
+
else if (effect.kind === 'deploy_stage')
|
|
248
|
+
await deployStage(effect.ref);
|
|
183
249
|
},
|
|
184
250
|
};
|
|
185
251
|
return {
|
|
@@ -244,8 +310,9 @@ export async function runSubjectRelease(input) {
|
|
|
244
310
|
const { subject, context } = input;
|
|
245
311
|
const id = subject.id;
|
|
246
312
|
const ids = () => context.recordedIds();
|
|
247
|
-
if (!ids()[bgKey.applied(id, input.primaryUuid)])
|
|
248
|
-
return { blocked: [], refused: [] };
|
|
313
|
+
if (!ids()[bgKey.applied(id, input.primaryUuid)]) {
|
|
314
|
+
return { blocked: [], refused: [], deferredCopy: false, copy: null };
|
|
315
|
+
}
|
|
249
316
|
if (!ids()[bgKey.live(id)]) {
|
|
250
317
|
context.recordScopeId(bgKey.live(id), 'blue');
|
|
251
318
|
if (!ids()[bgKey.weights(id)])
|
|
@@ -271,6 +338,7 @@ export async function runSubjectRelease(input) {
|
|
|
271
338
|
now: input.now,
|
|
272
339
|
deps: input.host.deps,
|
|
273
340
|
...(subject.reporting ? {} : { hardOnly: true }),
|
|
341
|
+
...(input.deferCopies ? { deferCopies: true } : {}),
|
|
274
342
|
});
|
|
275
343
|
for (const [key, value] of Object.entries(result.writes)) {
|
|
276
344
|
if (value === null)
|
|
@@ -285,5 +353,45 @@ export async function runSubjectRelease(input) {
|
|
|
285
353
|
if (after && (formatWeights(after) !== before || balancerLags(ids(), id, formatWeights(after)))) {
|
|
286
354
|
await input.host.writeWeights();
|
|
287
355
|
}
|
|
288
|
-
return {
|
|
356
|
+
return {
|
|
357
|
+
blocked: result.blocked,
|
|
358
|
+
refused: result.refused,
|
|
359
|
+
deferredCopy: result.deferredCopy,
|
|
360
|
+
copy: result.copy,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Runs the stage copy a release run decided on and records what came of it. Call it after
|
|
365
|
+
* {@link runSubjectRelease} returned (that run's writes are persisted and its weights written), so a rollback
|
|
366
|
+
* decided in the same run never waits for the copy. The outcome goes into `context`, is persisted, and only then
|
|
367
|
+
* is the request consumed. `refused` is the operator text of a refusal. A host without ports refuses with `no-copy-port`; a lost lock throws and leaves the
|
|
368
|
+
* request open.
|
|
369
|
+
*/
|
|
370
|
+
export async function finishStageCopy(input) {
|
|
371
|
+
const { context, job } = input;
|
|
372
|
+
const outcome = input.host
|
|
373
|
+
? await runStageCopy(input.host.deps, job)
|
|
374
|
+
: { outcome: 'refused', reason: 'no-copy-port' };
|
|
375
|
+
const settled = settleStageCopy({
|
|
376
|
+
ids: context.recordedIds(),
|
|
377
|
+
entryKey: input.subject.id,
|
|
378
|
+
now: input.now,
|
|
379
|
+
job,
|
|
380
|
+
outcome,
|
|
381
|
+
});
|
|
382
|
+
for (const [key, value] of Object.entries(settled.writes)) {
|
|
383
|
+
if (value === null)
|
|
384
|
+
context.forgetId(key);
|
|
385
|
+
else
|
|
386
|
+
context.recordScopeId(key, value);
|
|
387
|
+
}
|
|
388
|
+
// A request that never reached the copy (no port) still holds the lock it took.
|
|
389
|
+
const lockKey = releaseKeys(input.subject.id).stageCopyLock;
|
|
390
|
+
if (context.recordedIds()[lockKey]?.split('|')[1] === String(job.requestId)) {
|
|
391
|
+
context.forgetId(lockKey);
|
|
392
|
+
}
|
|
393
|
+
await context.persist();
|
|
394
|
+
if (settled.consume)
|
|
395
|
+
await input.store.consumeRequests([job.requestId]);
|
|
396
|
+
return { outcome, refused: settled.refused };
|
|
289
397
|
}
|