@wtfalch/foundry 0.60.0 → 0.62.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/apps/cloudflare.d.ts +173 -0
- package/dist/apps/cloudflare.js +17 -0
- package/dist/apps/deploy.d.ts +116 -0
- package/dist/apps/deploy.js +791 -0
- package/dist/apps/github.d.ts +47 -0
- package/dist/apps/github.js +201 -0
- package/dist/apps/index.d.ts +9 -0
- package/dist/apps/index.js +9 -0
- package/dist/apps/migrations.d.ts +110 -0
- package/dist/apps/migrations.js +458 -0
- package/dist/audit/connector.d.ts +43 -0
- package/dist/audit/connector.js +22 -0
- package/dist/audit/evidence.d.ts +49 -0
- package/dist/audit/evidence.js +56 -0
- package/dist/audit/index.d.ts +9 -0
- package/dist/audit/index.js +9 -0
- package/dist/audit/migrations.d.ts +5 -0
- package/dist/audit/migrations.js +5 -0
- package/dist/audit/registry.d.ts +49 -0
- package/dist/audit/registry.js +44 -0
- package/dist/audit/seal.d.ts +49 -0
- package/dist/audit/seal.js +131 -0
- package/dist/audit/sealer-role.d.ts +33 -0
- package/dist/audit/sealer-role.js +56 -0
- package/dist/audit/signer.d.ts +40 -0
- package/dist/audit/signer.js +78 -0
- package/dist/audit/tick.d.ts +36 -0
- package/dist/audit/tick.js +87 -0
- package/dist/catalogue/copy-en.d.ts +1 -0
- package/dist/catalogue/copy-en.js +129 -0
- package/dist/catalogue/index.d.ts +9 -0
- package/dist/catalogue/index.js +21 -0
- package/dist/catalogue/integrations.d.ts +4 -0
- package/dist/catalogue/integrations.js +104 -0
- package/dist/catalogue/systems.d.ts +7 -0
- package/dist/catalogue/systems.js +31 -0
- package/dist/catalogue/types.d.ts +110 -0
- package/dist/catalogue/types.js +1 -0
- package/dist/entry-rules.d.ts +53 -0
- package/dist/entry-rules.js +71 -0
- package/dist/hosted-label.d.ts +13 -0
- package/dist/hosted-label.js +81 -0
- package/dist/index.d.ts +14 -5
- package/dist/index.js +7 -3
- package/dist/migrations/0062_hosting_github.sql +6 -0
- package/dist/migrations/0063_boule_apps.sql +7 -0
- package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
- package/dist/migrations/0065_native_build_runs.sql +32 -0
- package/dist/migrations/0066_org_kosmos.sql +10 -0
- package/dist/modules/ai.d.ts +3 -0
- package/dist/modules/ai.js +64 -0
- package/dist/modules/assistants.d.ts +3 -0
- package/dist/modules/assistants.js +72 -0
- package/dist/modules/audit-ledger.d.ts +29 -0
- package/dist/modules/audit-ledger.js +49 -0
- package/dist/modules/audit.d.ts +7 -0
- package/dist/modules/audit.js +44 -0
- package/dist/modules/database.d.ts +23 -0
- package/dist/modules/database.js +189 -0
- package/dist/modules/email.d.ts +3 -0
- package/dist/modules/email.js +18 -0
- package/dist/modules/entry-parts.d.ts +23 -0
- package/dist/modules/entry-parts.js +63 -0
- package/dist/modules/files.d.ts +3 -0
- package/dist/modules/files.js +64 -0
- package/dist/modules/index.d.ts +74 -0
- package/dist/modules/index.js +774 -0
- package/dist/modules/jobs.d.ts +3 -0
- package/dist/modules/jobs.js +23 -0
- package/dist/modules/organisations.d.ts +3 -0
- package/dist/modules/organisations.js +20 -0
- package/dist/modules/outputs.d.ts +9 -0
- package/dist/modules/outputs.js +38 -0
- package/dist/modules/sign-in.d.ts +3 -0
- package/dist/modules/sign-in.js +197 -0
- package/dist/modules/signed-records.d.ts +3 -0
- package/dist/modules/signed-records.js +32 -0
- package/dist/modules/types.d.ts +194 -0
- package/dist/modules/types.js +1 -0
- package/dist/modules/workspaces.d.ts +3 -0
- package/dist/modules/workspaces.js +75 -0
- package/dist/native-build.d.ts +70 -0
- package/dist/native-build.js +92 -0
- package/dist/org.d.ts +184 -24
- package/dist/org.js +444 -49
- package/dist/plan/agora.d.ts +1 -1
- package/dist/plan/agora.js +4 -0
- package/dist/plan/apps-db-route.d.ts +90 -0
- package/dist/plan/apps-db-route.js +168 -0
- package/dist/plan/apps-zones.d.ts +56 -0
- package/dist/plan/apps-zones.js +118 -0
- package/dist/plan/archon-blue-green.js +11 -1
- package/dist/plan/blue-green-apply.d.ts +37 -1
- package/dist/plan/blue-green-apply.js +55 -15
- package/dist/plan/blue-green.d.ts +60 -8
- package/dist/plan/blue-green.js +115 -10
- package/dist/plan/boule.d.ts +23 -1
- package/dist/plan/boule.js +35 -2
- package/dist/plan/broker-entries.d.ts +28 -3
- package/dist/plan/broker-entries.js +74 -3
- package/dist/plan/broker-registrar.d.ts +168 -0
- package/dist/plan/broker-registrar.js +440 -0
- package/dist/plan/broker-workspaces-database.d.ts +48 -0
- package/dist/plan/broker-workspaces-database.js +87 -0
- package/dist/plan/deploy-health.js +9 -2
- package/dist/plan/desktop-callback.js +15 -7
- package/dist/plan/diff.d.ts +8 -2
- package/dist/plan/diff.js +552 -104
- package/dist/plan/edge-access.d.ts +6 -0
- package/dist/plan/edge-access.js +1 -0
- package/dist/plan/edge-dns.d.ts +6 -0
- package/dist/plan/edge-dns.js +9 -7
- package/dist/plan/entry-ai-key.d.ts +1 -1
- package/dist/plan/entry-ai-key.js +2 -2
- package/dist/plan/entry-audit.d.ts +58 -0
- package/dist/plan/entry-audit.js +122 -0
- package/dist/plan/entry-customer-grants.d.ts +75 -0
- package/dist/plan/entry-customer-grants.js +154 -0
- package/dist/plan/entry-edge-key.d.ts +26 -1
- package/dist/plan/entry-edge-key.js +42 -3
- package/dist/plan/entry-jobs.d.ts +64 -0
- package/dist/plan/entry-jobs.js +92 -0
- package/dist/plan/entry-repo-key-apply.d.ts +55 -0
- package/dist/plan/entry-repo-key-apply.js +185 -0
- package/dist/plan/entry-repo-key.d.ts +92 -0
- package/dist/plan/entry-repo-key.js +235 -0
- package/dist/plan/entry-workspaces.d.ts +55 -0
- package/dist/plan/entry-workspaces.js +90 -0
- package/dist/plan/executor.d.ts +231 -19
- package/dist/plan/executor.js +1648 -129
- package/dist/plan/gates.d.ts +1 -1
- package/dist/plan/gates.js +62 -9
- package/dist/plan/github-deploy-keys.d.ts +78 -0
- package/dist/plan/github-deploy-keys.js +305 -0
- package/dist/plan/github-source.d.ts +53 -0
- package/dist/plan/github-source.js +140 -0
- package/dist/plan/held-key-refs.d.ts +12 -0
- package/dist/plan/held-key-refs.js +12 -0
- package/dist/plan/hostnames.d.ts +9 -0
- package/dist/plan/hostnames.js +28 -1
- package/dist/plan/index.d.ts +9 -0
- package/dist/plan/index.js +6 -0
- package/dist/plan/jobs-scheduler-http.js +3 -0
- package/dist/plan/keys-registrar-http.d.ts +32 -0
- package/dist/plan/keys-registrar-http.js +105 -0
- package/dist/plan/keys-registrar.d.ts +67 -0
- package/dist/plan/keys-registrar.js +103 -0
- package/dist/plan/kosmos.d.ts +56 -0
- package/dist/plan/kosmos.js +84 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.d.ts +2 -2
- package/dist/plan/locked-hostnames.js +7 -4
- package/dist/plan/login-host.js +11 -5
- package/dist/plan/machine-paths.d.ts +36 -0
- package/dist/plan/machine-paths.js +137 -0
- package/dist/plan/mcp.d.ts +1 -1
- package/dist/plan/mcp.js +49 -17
- package/dist/plan/org-apps-admin.d.ts +108 -0
- package/dist/plan/org-apps-admin.js +434 -0
- package/dist/plan/org-names.d.ts +3 -3
- package/dist/plan/org-names.js +15 -2
- package/dist/plan/org-postgres-migrate.js +1 -0
- package/dist/plan/own-diff.d.ts +23 -0
- package/dist/plan/own-diff.js +53 -0
- package/dist/plan/own-hostnames.d.ts +2 -0
- package/dist/plan/own-hostnames.js +1 -0
- package/dist/plan/own-jobs-route.d.ts +47 -0
- package/dist/plan/own-jobs-route.js +91 -0
- package/dist/plan/own-limits.d.ts +6 -0
- package/dist/plan/own-limits.js +1 -0
- package/dist/plan/own-services.d.ts +4 -0
- package/dist/plan/own-services.js +4 -0
- package/dist/plan/plan-apply.d.ts +39 -0
- package/dist/plan/plan-apply.js +163 -9
- package/dist/plan/platform-diff.d.ts +2 -2
- package/dist/plan/platform-diff.js +7 -2
- package/dist/plan/platform-org.d.ts +30 -0
- package/dist/plan/platform-org.js +12 -1
- package/dist/plan/platform-providers.d.ts +2 -0
- package/dist/plan/platform-providers.js +13 -2
- package/dist/plan/post-logout-uris.d.ts +1 -1
- package/dist/plan/post-logout-uris.js +12 -3
- package/dist/plan/private-pages.d.ts +26 -0
- package/dist/plan/private-pages.js +39 -0
- package/dist/plan/providers-readers.js +20 -1
- package/dist/plan/proxy-labels.d.ts +12 -1
- package/dist/plan/proxy-labels.js +32 -2
- package/dist/plan/release-host.d.ts +6 -2
- package/dist/plan/release-host.js +36 -8
- package/dist/plan/stage-env.d.ts +19 -1
- package/dist/plan/stage-env.js +25 -1
- package/dist/plan/stage-guards.d.ts +20 -0
- package/dist/plan/stage-guards.js +24 -0
- package/dist/plan/stage-isolation.d.ts +24 -0
- package/dist/plan/stage-isolation.js +29 -1
- package/dist/plan/worker-app-database.d.ts +46 -42
- package/dist/plan/worker-app-database.js +150 -153
- package/dist/plan/worker-app.d.ts +5 -65
- package/dist/plan/worker-app.js +28 -105
- package/dist/plan/worker.d.ts +2 -2
- package/dist/plan/worker.js +2 -1
- package/dist/plan/workspace-client.d.ts +25 -0
- package/dist/plan/workspace-client.js +48 -0
- package/dist/plan/workspace-sign-in.d.ts +55 -0
- package/dist/plan/workspace-sign-in.js +273 -0
- package/dist/plan/workspace-worker.d.ts +4 -0
- package/dist/plan/workspace-worker.js +13 -0
- package/dist/plan/workspace.d.ts +101 -0
- package/dist/plan/workspace.js +305 -0
- package/dist/release/decide.d.ts +30 -1
- package/dist/release/decide.js +73 -11
- package/dist/release/health.d.ts +8 -3
- package/dist/release/health.js +58 -5
- package/dist/release/migration-compatibility.d.ts +66 -0
- package/dist/release/migration-compatibility.js +340 -0
- package/dist/release/run.d.ts +2 -5
- package/dist/release/run.js +4 -0
- package/dist/release/state.d.ts +3 -1
- package/dist/release/state.js +6 -0
- package/dist/release/types.d.ts +20 -0
- package/dist/requested-changes.d.ts +11 -2
- package/dist/requested-changes.js +13 -1
- package/dist/service-labels.d.ts +4 -0
- package/dist/service-labels.js +11 -0
- package/dist/slug.d.ts +2 -0
- package/dist/slug.js +20 -7
- package/dist/store/postgres.js +25 -2
- package/dist/store/schema.d.ts +73 -1
- package/dist/store/schema.js +10 -0
- package/dist/worker-app-config.d.ts +1 -11
- package/dist/worker-app-config.js +4 -17
- package/dist/worker-app-migrations.d.ts +105 -0
- package/dist/worker-app-migrations.js +166 -0
- package/dist/worker-app-upload.d.ts +103 -0
- package/dist/worker-app-upload.js +128 -0
- package/package.json +19 -2
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Worker-app workspace is planned as a second Worker app: a copy of the entry under the key `<key>-work`.
|
|
3
|
+
* The copy is never declared; `withWorkspaceWorkers` (`plan/workspace.ts`) makes it and registers it here, so
|
|
4
|
+
* the name, host and zone helpers can tell it from a declared entry. A leaf module: `hostnames.ts` reads it.
|
|
5
|
+
*/
|
|
6
|
+
const PARENT_OF = new WeakMap();
|
|
7
|
+
export function registerWorkspaceWorker(copy, parentKey) {
|
|
8
|
+
PARENT_OF.set(copy, parentKey);
|
|
9
|
+
}
|
|
10
|
+
/** The key of the entry a workspace Worker copy belongs to, or `null` for a declared entry. */
|
|
11
|
+
export function workspaceWorkerParent(entry) {
|
|
12
|
+
return PARENT_OF.get(entry) ?? null;
|
|
13
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { type DeclaredOrg, type HostingEntry } from '../org.js';
|
|
2
|
+
import { type BlueGreenServer, type BlueGreenSubject } from './blue-green.js';
|
|
3
|
+
import type { DraftStep } from './diff.js';
|
|
4
|
+
/** The scope keys of a workspace, beside the stage's `bg.<key>.…` ones. `.` separated, no `:`. */
|
|
5
|
+
export declare const wsKey: {
|
|
6
|
+
/** The hash of the workspace's shape last applied. Also the sign that its route may be written. */
|
|
7
|
+
applied: (key: string) => string;
|
|
8
|
+
/** The 40 character commit the workspace runs; the live app is made from it. */
|
|
9
|
+
commit: (key: string) => string;
|
|
10
|
+
/** The commit the live app was last made from or moved to by a promote (`HostingEntry.liveCommit`). */
|
|
11
|
+
liveCommit: (key: string) => string;
|
|
12
|
+
/** The non-secret sign-in env the workspace was last applied with (JSON); never a key. */
|
|
13
|
+
signIn: (key: string) => string;
|
|
14
|
+
};
|
|
15
|
+
/** The key of a workspace's Access gate in the company. */
|
|
16
|
+
export declare const workspaceGateKey: (entryKey: string) => string;
|
|
17
|
+
/** The key of the gate over the test inbox. Entry keys cannot start with a hyphen, so it never clashes with `ws-<key>`. */
|
|
18
|
+
export declare const INBOX_GATE_KEY = "ws--inbox";
|
|
19
|
+
/** True when the org has a server workspace, and so a stage mail sink to show. */
|
|
20
|
+
export declare const hasInbox: (org: DeclaredOrg) => boolean;
|
|
21
|
+
/** The people-list hash a gate was last made or changed with. */
|
|
22
|
+
export declare const gatePeopleScopeKey: (gateKey: string) => string;
|
|
23
|
+
/** The entries of an org that declare a workspace. */
|
|
24
|
+
export declare function workspaceEntries(org: DeclaredOrg): HostingEntry[];
|
|
25
|
+
/** True for a workspace that runs as a server (Coolify), false for a Worker app's. */
|
|
26
|
+
export declare const isServerWorkspace: (entry: HostingEntry) => boolean;
|
|
27
|
+
/**
|
|
28
|
+
* The company's people who may open company apps: the owners, by sign-in address and work address. This is the
|
|
29
|
+
* list the stage gate used (owners only); the owners are the only company people the gate inputs hold.
|
|
30
|
+
*/
|
|
31
|
+
export declare function companyPeople(org: Pick<DeclaredOrg, 'owners'>): string[];
|
|
32
|
+
/** Who passes a workspace's gate: the company's people and the entry's testers, lowercased, distinct, sorted. */
|
|
33
|
+
export declare function workspacePeople(org: Pick<DeclaredOrg, 'owners'>, entry: HostingEntry): string[];
|
|
34
|
+
/** The hash recorded when a gate's people were set. A change of testers changes it. */
|
|
35
|
+
export declare const peopleHash: (emails: readonly string[]) => string;
|
|
36
|
+
/** The org without the entries that are not live: nothing that makes an app or its address sees them. */
|
|
37
|
+
export declare function withoutDarkEntries(org: DeclaredOrg): DeclaredOrg;
|
|
38
|
+
/**
|
|
39
|
+
* The org with a second Worker app per Worker-app workspace, keyed `<key>-work`, so the Worker app planner and
|
|
40
|
+
* executor deploy it like any other. The copy has no database and no sign-in env (the workspace gate is its
|
|
41
|
+
* sign-in; a database would be the live one) and is served at the workspace host. `declared` names the org the
|
|
42
|
+
* workspaces are read from when `org` has had dark entries removed.
|
|
43
|
+
*/
|
|
44
|
+
export declare function withWorkspaceWorkers(org: DeclaredOrg, declared?: DeclaredOrg): DeclaredOrg;
|
|
45
|
+
/** A server workspace as the blue/green stage code sees its subject: no colors, no hostnames, no reporting. */
|
|
46
|
+
export declare function workspaceSubject(org: DeclaredOrg, entry: HostingEntry): BlueGreenSubject;
|
|
47
|
+
/** The origin the tunnel sends a server workspace's host to: the stage app on the company network. */
|
|
48
|
+
export declare const workspaceOrigin: (subject: Pick<BlueGreenSubject, "base" | "port">) => string;
|
|
49
|
+
/**
|
|
50
|
+
* The tunnel routes of the server workspaces: the workspace host to the stage app. A route is written only once
|
|
51
|
+
* the workspace was applied and its stage guards hold (isolated, probed, gated, healthy), never before.
|
|
52
|
+
*/
|
|
53
|
+
export declare function workspaceRoutes(org: DeclaredOrg, recorded?: Readonly<Record<string, string>>): Array<{
|
|
54
|
+
hostname: string;
|
|
55
|
+
service: string;
|
|
56
|
+
}>;
|
|
57
|
+
/** Whether `name` is the workspace host of one of the org's server workspaces. */
|
|
58
|
+
export declare function ownsWorkspaceName(org: DeclaredOrg, name: string): boolean;
|
|
59
|
+
/** A short hash of what decides the shape of a server workspace, so a change replans it. */
|
|
60
|
+
export declare function workspaceStructureHash(org: DeclaredOrg, entry: HostingEntry): string;
|
|
61
|
+
export interface PlanWorkspacesInput {
|
|
62
|
+
/** The org as declared, dark entries included. */
|
|
63
|
+
org: DeclaredOrg;
|
|
64
|
+
recordedIds: Readonly<Record<string, string>>;
|
|
65
|
+
/** Every Coolify server the host reports. `undefined`: the host cannot list them. */
|
|
66
|
+
servers: readonly BlueGreenServer[] | undefined;
|
|
67
|
+
/** The workspace's Access gate is recorded or planned in this run. */
|
|
68
|
+
gateReady: (gateKey: string) => boolean;
|
|
69
|
+
/** The company's stage mail sink is recorded and its live app is stopped or gone. */
|
|
70
|
+
stageSinkDown?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* The sign-in env a workspace with `uses.auth` should run with, as `workspaceSignInMarker` writes it, or
|
|
73
|
+
* `null` while it is not all recorded (the workspace is then blocked). Absent: sign-in is not planned.
|
|
74
|
+
*/
|
|
75
|
+
signInWanted?: (entry: HostingEntry) => string | null;
|
|
76
|
+
/** The issuer the workspace's app signs in at; goes to the step for the executor. */
|
|
77
|
+
authIssuer?: string | null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* One `coolify:workspace` step per server workspace whose shape is not applied yet or whose stage needs another
|
|
81
|
+
* try; the Worker-app workspaces are planned by `planWorkerApps` through {@link withWorkspaceWorkers}. Pure.
|
|
82
|
+
* Blocked, not planned, while the host cannot list servers, the primary is unusable, the stage would not be
|
|
83
|
+
* isolated from the company's other apps, or the gate is not there: a workspace nobody can sign in to is not made.
|
|
84
|
+
*/
|
|
85
|
+
export declare function planWorkspaces(input: PlanWorkspacesInput): {
|
|
86
|
+
steps: DraftStep[];
|
|
87
|
+
unmanaged: Array<{
|
|
88
|
+
description: string;
|
|
89
|
+
key: string;
|
|
90
|
+
}>;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* The commit of the newest finished deployment of the workspace's stage app, or `null` when the port cannot
|
|
94
|
+
* list deployments, the read fails or there is none. The live app of a workspace entry is made from it.
|
|
95
|
+
*/
|
|
96
|
+
export declare function readWorkspaceCommit(coolify: {
|
|
97
|
+
listApplicationDeployments?(uuid: string, take?: number): Promise<Array<{
|
|
98
|
+
commit?: string;
|
|
99
|
+
status: string;
|
|
100
|
+
}>>;
|
|
101
|
+
}, stageAppId: string): Promise<string | null>;
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { FULL_COMMIT_SHA, isLiveEntry, normalizeTesters, } from '../org.js';
|
|
3
|
+
import { PRIMARY_SERVER_SCOPE_KEY, bgKey, splitServers, stageAppName, stagePlanViolations, } from './blue-green.js';
|
|
4
|
+
import { withTimeout } from './call-timeout.js';
|
|
5
|
+
import { workspaceHost, workspaceInboxHost } from './hostnames.js';
|
|
6
|
+
import { appNameFor, appStepKey } from './platform-diff.js';
|
|
7
|
+
import { stageGuardsReady } from './stage-guards.js';
|
|
8
|
+
import { STAGE_SINK_APPLIED_SCOPE_KEY, STAGE_SINK_HTTP_PORT, STAGE_SINK_VERSION, stageSinkAppName, } from './stage-sink.js';
|
|
9
|
+
import { registerWorkspaceWorker } from './workspace-worker.js';
|
|
10
|
+
/**
|
|
11
|
+
* Private workspaces (`HostingEntry.workspace`). A workspace is where a company's team and invited testers build
|
|
12
|
+
* and test an app behind sign-in; `live` is a separate step that puts the app on its public address.
|
|
13
|
+
*
|
|
14
|
+
* A server entry's workspace is the blue/green stage (`blue-green-apply.ts`'s `ensureStage`): the stage app, its
|
|
15
|
+
* own empty database, the stage mail sink and the stage guards. It is served at the workspace host and has no
|
|
16
|
+
* colors, so it needs no `blueGreen`. A Worker app's workspace is a second Worker app `<slug>-<key>-work`.
|
|
17
|
+
* The gate over the workspace host is one Access gate per workspace, made by `planGates`.
|
|
18
|
+
*/
|
|
19
|
+
const FULL_SHA = FULL_COMMIT_SHA;
|
|
20
|
+
/** One Coolify read of the workspace's deployments. */
|
|
21
|
+
const COMMIT_READ_TIMEOUT_MS = 15_000;
|
|
22
|
+
/** A Cloudflare Worker script name is at most 63 characters; so is a DNS label. */
|
|
23
|
+
const MAX_NAME = 63;
|
|
24
|
+
/** The scope keys of a workspace, beside the stage's `bg.<key>.…` ones. `.` separated, no `:`. */
|
|
25
|
+
export const wsKey = {
|
|
26
|
+
/** The hash of the workspace's shape last applied. Also the sign that its route may be written. */
|
|
27
|
+
applied: (key) => `ws.${key}.applied`,
|
|
28
|
+
/** The 40 character commit the workspace runs; the live app is made from it. */
|
|
29
|
+
commit: (key) => `ws.${key}.commit`,
|
|
30
|
+
/** The commit the live app was last made from or moved to by a promote (`HostingEntry.liveCommit`). */
|
|
31
|
+
liveCommit: (key) => `ws.${key}.livecommit`,
|
|
32
|
+
/** The non-secret sign-in env the workspace was last applied with (JSON); never a key. */
|
|
33
|
+
signIn: (key) => `ws.${key}.signin`,
|
|
34
|
+
};
|
|
35
|
+
/** The key of a workspace's Access gate in the company. */
|
|
36
|
+
export const workspaceGateKey = (entryKey) => `ws-${entryKey}`;
|
|
37
|
+
/** The key of the gate over the test inbox. Entry keys cannot start with a hyphen, so it never clashes with `ws-<key>`. */
|
|
38
|
+
export const INBOX_GATE_KEY = 'ws--inbox';
|
|
39
|
+
/** True when the org has a server workspace, and so a stage mail sink to show. */
|
|
40
|
+
export const hasInbox = (org) => workspaceEntries(org).some(isServerWorkspace);
|
|
41
|
+
/** The people-list hash a gate was last made or changed with. */
|
|
42
|
+
export const gatePeopleScopeKey = (gateKey) => `gate_people_${gateKey}`;
|
|
43
|
+
/** The entries of an org that declare a workspace. */
|
|
44
|
+
export function workspaceEntries(org) {
|
|
45
|
+
return org.hosting.enabled ? org.hosting.entries.filter((e) => e.workspace !== undefined) : [];
|
|
46
|
+
}
|
|
47
|
+
/** True for a workspace that runs as a server (Coolify), false for a Worker app's. */
|
|
48
|
+
export const isServerWorkspace = (entry) => entry.workspace !== undefined && entry.workerApp === undefined;
|
|
49
|
+
/**
|
|
50
|
+
* The company's people who may open company apps: the owners, by sign-in address and work address. This is the
|
|
51
|
+
* list the stage gate used (owners only); the owners are the only company people the gate inputs hold.
|
|
52
|
+
*/
|
|
53
|
+
export function companyPeople(org) {
|
|
54
|
+
return normalizeTesters(org.owners.flatMap((owner) => owner.mailboxAddress === undefined ? [owner.email] : [owner.email, owner.mailboxAddress])).sort();
|
|
55
|
+
}
|
|
56
|
+
/** Who passes a workspace's gate: the company's people and the entry's testers, lowercased, distinct, sorted. */
|
|
57
|
+
export function workspacePeople(org, entry) {
|
|
58
|
+
return normalizeTesters([...companyPeople(org), ...(entry.workspace?.testers ?? [])]).sort();
|
|
59
|
+
}
|
|
60
|
+
/** The hash recorded when a gate's people were set. A change of testers changes it. */
|
|
61
|
+
export const peopleHash = (emails) => createHash('sha256').update(JSON.stringify(emails)).digest('hex').slice(0, 16);
|
|
62
|
+
/** The org without the entries that are not live: nothing that makes an app or its address sees them. */
|
|
63
|
+
export function withoutDarkEntries(org) {
|
|
64
|
+
if (org.hosting.entries.every(isLiveEntry))
|
|
65
|
+
return org;
|
|
66
|
+
return { ...org, hosting: { ...org.hosting, entries: org.hosting.entries.filter(isLiveEntry) } };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The org with a second Worker app per Worker-app workspace, keyed `<key>-work`, so the Worker app planner and
|
|
70
|
+
* executor deploy it like any other. The copy has no database and no sign-in env (the workspace gate is its
|
|
71
|
+
* sign-in; a database would be the live one) and is served at the workspace host. `declared` names the org the
|
|
72
|
+
* workspaces are read from when `org` has had dark entries removed.
|
|
73
|
+
*/
|
|
74
|
+
export function withWorkspaceWorkers(org, declared = org) {
|
|
75
|
+
if (!org.hosting.enabled)
|
|
76
|
+
return org;
|
|
77
|
+
const copies = [];
|
|
78
|
+
for (const entry of declared.hosting.entries) {
|
|
79
|
+
if (!entry.workspace || !entry.workerApp)
|
|
80
|
+
continue;
|
|
81
|
+
const { workspace: _w, live: _l, hostedName: _h, uses: _u, workerApp, ...rest } = entry;
|
|
82
|
+
const { database: _d, authEnv: _a, ...app } = workerApp;
|
|
83
|
+
const copy = {
|
|
84
|
+
...rest,
|
|
85
|
+
key: `${entry.key}-work`,
|
|
86
|
+
main: false,
|
|
87
|
+
hostnames: [],
|
|
88
|
+
gates: [],
|
|
89
|
+
env: {},
|
|
90
|
+
database: false,
|
|
91
|
+
workerApp: app,
|
|
92
|
+
};
|
|
93
|
+
registerWorkspaceWorker(copy, entry.key);
|
|
94
|
+
copies.push(copy);
|
|
95
|
+
}
|
|
96
|
+
if (copies.length === 0)
|
|
97
|
+
return org;
|
|
98
|
+
return { ...org, hosting: { ...org.hosting, entries: [...org.hosting.entries, ...copies] } };
|
|
99
|
+
}
|
|
100
|
+
/** A server workspace as the blue/green stage code sees its subject: no colors, no hostnames, no reporting. */
|
|
101
|
+
export function workspaceSubject(org, entry) {
|
|
102
|
+
const base = appNameFor(org, entry);
|
|
103
|
+
const isStatic = entry.buildPack === 'static';
|
|
104
|
+
return {
|
|
105
|
+
id: entry.key,
|
|
106
|
+
base,
|
|
107
|
+
primaryBlueName: base,
|
|
108
|
+
port: isStatic ? 80 : (entry.port ?? 80),
|
|
109
|
+
repo: entry.repo,
|
|
110
|
+
folder: entry.folder,
|
|
111
|
+
branch: entry.branch,
|
|
112
|
+
buildPack: entry.buildPack,
|
|
113
|
+
healthPath: entry.healthPath ?? (isStatic ? '/' : '/api/health'),
|
|
114
|
+
database: entry.database,
|
|
115
|
+
hostnames: [],
|
|
116
|
+
config: {},
|
|
117
|
+
blueAppKey: `coolify:app:${appStepKey(org, entry.key)}`,
|
|
118
|
+
reporting: false,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** The origin the tunnel sends a server workspace's host to: the stage app on the company network. */
|
|
122
|
+
export const workspaceOrigin = (subject) => `http://${stageAppName(subject.base)}:${subject.port}`;
|
|
123
|
+
/**
|
|
124
|
+
* The tunnel routes of the server workspaces: the workspace host to the stage app. A route is written only once
|
|
125
|
+
* the workspace was applied and its stage guards hold (isolated, probed, gated, healthy), never before.
|
|
126
|
+
*/
|
|
127
|
+
export function workspaceRoutes(org, recorded = {}) {
|
|
128
|
+
const inbox = hasInbox(org) &&
|
|
129
|
+
recorded[STAGE_SINK_APPLIED_SCOPE_KEY] &&
|
|
130
|
+
recorded[`cloudflare:gate:${org.id}:${INBOX_GATE_KEY}`]
|
|
131
|
+
? [
|
|
132
|
+
{
|
|
133
|
+
hostname: workspaceInboxHost(org.slug),
|
|
134
|
+
service: `http://${stageSinkAppName(org)}:${STAGE_SINK_HTTP_PORT}`,
|
|
135
|
+
},
|
|
136
|
+
]
|
|
137
|
+
: [];
|
|
138
|
+
return [
|
|
139
|
+
...workspaceEntries(org)
|
|
140
|
+
.filter((entry) => isServerWorkspace(entry) &&
|
|
141
|
+
recorded[wsKey.applied(entry.key)] &&
|
|
142
|
+
stageGuardsReady(recorded, entry.key).ready)
|
|
143
|
+
.map((entry) => ({
|
|
144
|
+
hostname: workspaceHost(org.slug, entry.key),
|
|
145
|
+
service: workspaceOrigin(workspaceSubject(org, entry)),
|
|
146
|
+
})),
|
|
147
|
+
...inbox,
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
/** Whether `name` is the workspace host of one of the org's server workspaces. */
|
|
151
|
+
export function ownsWorkspaceName(org, name) {
|
|
152
|
+
const lower = name.toLowerCase();
|
|
153
|
+
if (hasInbox(org) && lower === workspaceInboxHost(org.slug))
|
|
154
|
+
return true;
|
|
155
|
+
return workspaceEntries(org).some((entry) => isServerWorkspace(entry) && workspaceHost(org.slug, entry.key) === lower);
|
|
156
|
+
}
|
|
157
|
+
/** A short hash of what decides the shape of a server workspace, so a change replans it. */
|
|
158
|
+
export function workspaceStructureHash(org, entry) {
|
|
159
|
+
const subject = workspaceSubject(org, entry);
|
|
160
|
+
const auth = entry.uses?.auth;
|
|
161
|
+
return createHash('sha256')
|
|
162
|
+
.update(JSON.stringify({
|
|
163
|
+
base: subject.base,
|
|
164
|
+
port: subject.port,
|
|
165
|
+
repo: subject.repo,
|
|
166
|
+
folder: subject.folder,
|
|
167
|
+
branch: subject.branch,
|
|
168
|
+
buildPack: subject.buildPack,
|
|
169
|
+
healthPath: subject.healthPath,
|
|
170
|
+
database: subject.database,
|
|
171
|
+
host: workspaceHost(org.slug, entry.key),
|
|
172
|
+
stageSink: STAGE_SINK_VERSION,
|
|
173
|
+
...(auth
|
|
174
|
+
? {
|
|
175
|
+
signIn: {
|
|
176
|
+
users: auth.users,
|
|
177
|
+
registration: auth.registration,
|
|
178
|
+
signIn: auth.signIn ?? null,
|
|
179
|
+
workspaces: auth.workspaces ?? null,
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
: {}),
|
|
183
|
+
}))
|
|
184
|
+
.digest('hex')
|
|
185
|
+
.slice(0, 16);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* One `coolify:workspace` step per server workspace whose shape is not applied yet or whose stage needs another
|
|
189
|
+
* try; the Worker-app workspaces are planned by `planWorkerApps` through {@link withWorkspaceWorkers}. Pure.
|
|
190
|
+
* Blocked, not planned, while the host cannot list servers, the primary is unusable, the stage would not be
|
|
191
|
+
* isolated from the company's other apps, or the gate is not there: a workspace nobody can sign in to is not made.
|
|
192
|
+
*/
|
|
193
|
+
export function planWorkspaces(input) {
|
|
194
|
+
const { org, recordedIds } = input;
|
|
195
|
+
const steps = [];
|
|
196
|
+
const unmanaged = [];
|
|
197
|
+
for (const entry of workspaceEntries(org)) {
|
|
198
|
+
if (!isServerWorkspace(entry))
|
|
199
|
+
continue;
|
|
200
|
+
const id = entry.key;
|
|
201
|
+
const at = `${org.slug}'s ${id} workspace`;
|
|
202
|
+
const subject = workspaceSubject(org, entry);
|
|
203
|
+
const blocked = (why, part = 'blocked') => unmanaged.push({
|
|
204
|
+
description: `${at} is blocked, not planned: ${why}`,
|
|
205
|
+
key: `workspace:${id}:${part}`,
|
|
206
|
+
});
|
|
207
|
+
if (entry.buildPack === 'dockercompose')
|
|
208
|
+
continue;
|
|
209
|
+
if (stageAppName(subject.base).length > MAX_NAME ||
|
|
210
|
+
(workspaceHost(org.slug, id).split('.')[0] ?? '').length > MAX_NAME) {
|
|
211
|
+
blocked('its app name or address is too long; shorten the slug or the entry key', 'name');
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const violations = stagePlanViolations(subject, org.hosting.entries.filter((e) => e.key !== id).map((e) => appNameFor(org, e)));
|
|
215
|
+
if (violations.length > 0) {
|
|
216
|
+
blocked(`its stage would not be isolated from the company's other apps (${violations.join(', ')})`, 'stageisolation');
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (!input.servers) {
|
|
220
|
+
blocked('the host cannot list Coolify servers');
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const { up } = splitServers(input.servers);
|
|
224
|
+
const primary = recordedIds[PRIMARY_SERVER_SCOPE_KEY] ?? up[0];
|
|
225
|
+
if (!primary || !up.includes(primary)) {
|
|
226
|
+
blocked(`the primary Coolify server ${primary ?? '(none)'} is not usable and reachable`);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (!input.gateReady(workspaceGateKey(id))) {
|
|
230
|
+
blocked('its Access gate is not recorded or planned yet', 'gate');
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const signInWanted = entry.uses?.auth && input.signInWanted ? input.signInWanted(entry) : undefined;
|
|
234
|
+
if (signInWanted === null) {
|
|
235
|
+
blocked('its sign-in organisation, client or issuer is not recorded or planned yet', 'signin');
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const stageState = recordedIds[bgKey.stageState(id)];
|
|
239
|
+
if (stageState === 'unhealthy') {
|
|
240
|
+
unmanaged.push({
|
|
241
|
+
description: `${at}: the stage app is not healthy yet`,
|
|
242
|
+
key: `workspace:${id}:stage`,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
else if (stageState === 'blocked:sink' || input.stageSinkDown === true) {
|
|
246
|
+
unmanaged.push({
|
|
247
|
+
description: `${at}: the stage mail sink is not running; the workspace gets no mail env`,
|
|
248
|
+
key: `workspace:${id}:stage`,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
else if (stageState?.startsWith('blocked:')) {
|
|
252
|
+
unmanaged.push({
|
|
253
|
+
description: `${at}: the stage is blocked and was not written to (${stageState.slice('blocked:'.length)})`,
|
|
254
|
+
key: `workspace:${id}:stage`,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
const retry = stageState === 'unhealthy' ||
|
|
258
|
+
stageState?.startsWith('blocked:') === true ||
|
|
259
|
+
input.stageSinkDown === true;
|
|
260
|
+
const signInStale = signInWanted !== undefined && recordedIds[wsKey.signIn(id)] !== signInWanted;
|
|
261
|
+
if (recordedIds[wsKey.applied(id)] === workspaceStructureHash(org, entry) &&
|
|
262
|
+
!retry &&
|
|
263
|
+
!signInStale) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
steps.push({
|
|
267
|
+
action: {
|
|
268
|
+
system: 'coolify',
|
|
269
|
+
kind: 'workspace',
|
|
270
|
+
key: appStepKey(org, id),
|
|
271
|
+
desired: {
|
|
272
|
+
entry: id,
|
|
273
|
+
server: primary,
|
|
274
|
+
primary,
|
|
275
|
+
...(signInWanted !== undefined && input.authIssuer
|
|
276
|
+
? { authIssuer: input.authIssuer }
|
|
277
|
+
: {}),
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
step: {
|
|
281
|
+
description: `run ${at} at ${workspaceHost(org.slug, id)}: a stage copy of the app with its own empty database, behind sign-in${recordedIds[wsKey.applied(id)] ? '' : ' (first time)'}`,
|
|
282
|
+
destroys: false,
|
|
283
|
+
migrates: false,
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return { steps, unmanaged };
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* The commit of the newest finished deployment of the workspace's stage app, or `null` when the port cannot
|
|
291
|
+
* list deployments, the read fails or there is none. The live app of a workspace entry is made from it.
|
|
292
|
+
*/
|
|
293
|
+
export async function readWorkspaceCommit(coolify, stageAppId) {
|
|
294
|
+
const list = coolify.listApplicationDeployments?.bind(coolify);
|
|
295
|
+
if (!list)
|
|
296
|
+
return null;
|
|
297
|
+
try {
|
|
298
|
+
const rows = await withTimeout('workspace commit: list deployments', COMMIT_READ_TIMEOUT_MS, () => list(stageAppId));
|
|
299
|
+
const finished = rows.find((row) => row.status === 'finished');
|
|
300
|
+
return finished?.commit && FULL_SHA.test(finished.commit) ? finished.commit : null;
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
package/dist/release/decide.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Color, type ReleaseAction, type ReleaseSignal, type WindowState } from './types.js';
|
|
1
|
+
import { type Color, type ReleaseAction, type ReleaseConfig, type ReleaseSignal, type WindowState } from './types.js';
|
|
2
2
|
/** ADR 0001 rule 5: the default steps and the least time on each. */
|
|
3
3
|
export declare const DEFAULT_STEPS: readonly number[];
|
|
4
4
|
export declare const DEFAULT_STEP_HOLD_MINUTES = 60;
|
|
@@ -15,8 +15,24 @@ export declare const ERROR_RATE_MARGIN = 0.02;
|
|
|
15
15
|
/** Rules 12 and 13: the window at 100% closes after this long, and at most this long. */
|
|
16
16
|
export declare const WINDOW_MIN_HOURS = 24;
|
|
17
17
|
export declare const WINDOW_CAP_DAYS = 7;
|
|
18
|
+
/** A `signal` step that waited this long since it began without a passing check accepts `approve_step` and reports a blocked line. */
|
|
19
|
+
export declare const STEP_SIGNAL_CAP_HOURS = 24;
|
|
18
20
|
/** The new color must be running and answer on its own address within this time of the deploy. */
|
|
19
21
|
export declare const DEPLOY_TIMEOUT_MINUTES = 30;
|
|
22
|
+
/** A `deploy_stage` ref: a branch name or a full 40-hex commit sha. */
|
|
23
|
+
export declare const STAGE_REF_PATTERN: RegExp;
|
|
24
|
+
export declare const DEFAULT_ADVANCE: ReleaseConfig['advance'];
|
|
25
|
+
/** The release kind fields of an entry's `blueGreen`. */
|
|
26
|
+
export interface ReleaseEntryConfig {
|
|
27
|
+
steps?: readonly number[] | undefined;
|
|
28
|
+
stepHoldMinutes?: number | undefined;
|
|
29
|
+
advance?: ReleaseConfig['advance'] | undefined;
|
|
30
|
+
minRequests?: number | undefined;
|
|
31
|
+
errorMargin?: number | undefined;
|
|
32
|
+
staffFirst?: boolean | undefined;
|
|
33
|
+
}
|
|
34
|
+
/** The entry's release kind with every default filled in. */
|
|
35
|
+
export declare function resolveReleaseConfig(entry: ReleaseEntryConfig | undefined): ReleaseConfig;
|
|
20
36
|
export interface ActiveRelease {
|
|
21
37
|
from: Color;
|
|
22
38
|
to: Color;
|
|
@@ -64,6 +80,11 @@ export type ReleaseEffect = {
|
|
|
64
80
|
} | {
|
|
65
81
|
kind: 'stop';
|
|
66
82
|
color: Color;
|
|
83
|
+
}
|
|
84
|
+
/** Deploy stage at this ref. Decide only passes it on; the host executes it. */
|
|
85
|
+
| {
|
|
86
|
+
kind: 'deploy_stage';
|
|
87
|
+
ref: string;
|
|
67
88
|
};
|
|
68
89
|
export interface Decision {
|
|
69
90
|
recorded: Recorded;
|
|
@@ -90,6 +111,14 @@ export declare function decide(input: {
|
|
|
90
111
|
actions: readonly ReleaseAction[];
|
|
91
112
|
steps?: readonly number[] | undefined;
|
|
92
113
|
stepHoldMinutes?: number | undefined;
|
|
114
|
+
/** How a step is left; default `signal`. */
|
|
115
|
+
advance?: ReleaseConfig['advance'] | undefined;
|
|
116
|
+
/** Requests the new color needs before its error rate counts; default RELATIVE_MIN_REQUESTS. */
|
|
117
|
+
minRequests?: number | undefined;
|
|
118
|
+
/** Error rate allowed above the baseline; default ERROR_RATE_MARGIN. */
|
|
119
|
+
errorMargin?: number | undefined;
|
|
120
|
+
/** A promote waits at weight 0 in `staff` until an `open` action. */
|
|
121
|
+
staffFirst?: boolean | undefined;
|
|
93
122
|
now: Date;
|
|
94
123
|
observation: Observation;
|
|
95
124
|
/**
|
package/dist/release/decide.js
CHANGED
|
@@ -15,11 +15,25 @@ export const ERROR_RATE_MARGIN = 0.02;
|
|
|
15
15
|
/** Rules 12 and 13: the window at 100% closes after this long, and at most this long. */
|
|
16
16
|
export const WINDOW_MIN_HOURS = 24;
|
|
17
17
|
export const WINDOW_CAP_DAYS = 7;
|
|
18
|
+
/** A `signal` step that waited this long since it began without a passing check accepts `approve_step` and reports a blocked line. */
|
|
19
|
+
export const STEP_SIGNAL_CAP_HOURS = 24;
|
|
18
20
|
/** The new color must be running and answer on its own address within this time of the deploy. */
|
|
19
21
|
export const DEPLOY_TIMEOUT_MINUTES = 30;
|
|
20
22
|
const MINUTE = 60_000;
|
|
21
23
|
const HOUR = 60 * MINUTE;
|
|
22
24
|
const DAY = 24 * HOUR;
|
|
25
|
+
/** A `deploy_stage` ref: a branch name or a full 40-hex commit sha. */
|
|
26
|
+
export const STAGE_REF_PATTERN = /^(?:[A-Za-z0-9._/-]{1,200}|[0-9a-f]{40})$/;
|
|
27
|
+
export const DEFAULT_ADVANCE = 'signal';
|
|
28
|
+
/** The entry's release kind with every default filled in. */
|
|
29
|
+
export function resolveReleaseConfig(entry) {
|
|
30
|
+
return {
|
|
31
|
+
advance: entry?.advance ?? DEFAULT_ADVANCE,
|
|
32
|
+
steps: normalizeSteps(entry?.steps),
|
|
33
|
+
stepHoldMinutes: entry?.stepHoldMinutes ?? DEFAULT_STEP_HOLD_MINUTES,
|
|
34
|
+
staffFirst: entry?.staffFirst ?? false,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
23
37
|
export function normalizeSteps(steps) {
|
|
24
38
|
const wanted = steps && steps.length > 0 ? steps : DEFAULT_STEPS;
|
|
25
39
|
const valid = [...new Set(wanted.filter((n) => Number.isInteger(n) && n >= 1 && n <= 100))].sort((a, b) => a - b);
|
|
@@ -61,12 +75,41 @@ export function healthRange(r, now) {
|
|
|
61
75
|
}
|
|
62
76
|
return null;
|
|
63
77
|
}
|
|
64
|
-
function applyActions(r, actions, now, out) {
|
|
78
|
+
function applyActions(r, actions, now, out, kind) {
|
|
65
79
|
let cur = r;
|
|
66
80
|
let promoted = false;
|
|
67
81
|
let ended = false;
|
|
82
|
+
let approved = false;
|
|
83
|
+
let opened = false;
|
|
68
84
|
for (const a of actions) {
|
|
69
|
-
if (a.kind === '
|
|
85
|
+
if (a.kind === 'deploy_stage') {
|
|
86
|
+
if (typeof a.ref === 'string' && STAGE_REF_PATTERN.test(a.ref))
|
|
87
|
+
out.effects.push({ kind: 'deploy_stage', ref: a.ref });
|
|
88
|
+
else
|
|
89
|
+
out.refused.push('deploy_stage refused: the ref is not a branch name or a commit sha');
|
|
90
|
+
}
|
|
91
|
+
else if (a.kind === 'approve_step') {
|
|
92
|
+
const w = cur.window;
|
|
93
|
+
const capped = kind.advance === 'signal' &&
|
|
94
|
+
'since' in w &&
|
|
95
|
+
now.getTime() - Date.parse(w.since) >= STEP_SIGNAL_CAP_HOURS * HOUR;
|
|
96
|
+
const waiting = (kind.advance === 'approval' || capped) &&
|
|
97
|
+
cur.release !== null &&
|
|
98
|
+
((w.kind === 'step' && w.percent > 0) || (w.kind === 'inconclusive' && w.percent > 0));
|
|
99
|
+
if (waiting)
|
|
100
|
+
approved = true;
|
|
101
|
+
else
|
|
102
|
+
out.refused.push('approve_step refused: no step is waiting for approval');
|
|
103
|
+
}
|
|
104
|
+
else if (a.kind === 'open') {
|
|
105
|
+
if (cur.window.kind === 'staff' && cur.release) {
|
|
106
|
+
cur = stepTo(cur, cur.release, kind.steps[0], now);
|
|
107
|
+
opened = true;
|
|
108
|
+
}
|
|
109
|
+
else
|
|
110
|
+
out.refused.push('open refused: no release is waiting for staff');
|
|
111
|
+
}
|
|
112
|
+
else if (a.kind === 'clear_held') {
|
|
70
113
|
cur = { ...cur, held: cur.held.filter((d) => d !== a.digest) };
|
|
71
114
|
}
|
|
72
115
|
else if (a.kind === 'promote') {
|
|
@@ -114,7 +157,7 @@ function applyActions(r, actions, now, out) {
|
|
|
114
157
|
}
|
|
115
158
|
}
|
|
116
159
|
}
|
|
117
|
-
return { recorded: cur, promoted, ended };
|
|
160
|
+
return { recorded: cur, promoted, ended, approved, opened };
|
|
118
161
|
}
|
|
119
162
|
/**
|
|
120
163
|
* The whole release decision for one run: from the recorded state, the
|
|
@@ -126,12 +169,15 @@ export function decide(input) {
|
|
|
126
169
|
const steps = normalizeSteps(input.steps);
|
|
127
170
|
const holdMs = (input.stepHoldMinutes ?? DEFAULT_STEP_HOLD_MINUTES) * MINUTE;
|
|
128
171
|
const out = { effects: [], refused: [] };
|
|
129
|
-
const
|
|
172
|
+
const advance = input.advance ?? DEFAULT_ADVANCE;
|
|
173
|
+
const minRequests = input.minRequests ?? RELATIVE_MIN_REQUESTS;
|
|
174
|
+
const errorMargin = input.errorMargin ?? ERROR_RATE_MARGIN;
|
|
175
|
+
const applied = applyActions(input.recorded, input.actions, now, out, { advance, steps });
|
|
130
176
|
let r = applied.recorded;
|
|
131
177
|
let signal = null;
|
|
132
178
|
const blocked = [];
|
|
133
179
|
const rel = r.release;
|
|
134
|
-
if (!applied.promoted && !applied.ended && rel) {
|
|
180
|
+
if (!applied.promoted && !applied.ended && !applied.opened && rel) {
|
|
135
181
|
const since = Date.parse('since' in r.window ? r.window.since : '');
|
|
136
182
|
const elapsed = now.getTime() - since;
|
|
137
183
|
const hard = observation.status === 'crash_loop'
|
|
@@ -143,12 +189,19 @@ export function decide(input) {
|
|
|
143
189
|
if (hard)
|
|
144
190
|
r = rollback(r, now, hard);
|
|
145
191
|
else if (observation.status === 'running' && observation.address === 'ok') {
|
|
146
|
-
|
|
147
|
-
|
|
192
|
+
if (input.staffFirst)
|
|
193
|
+
r = { ...r, window: { kind: 'staff', since: now.toISOString() } };
|
|
194
|
+
else
|
|
195
|
+
r = stepTo(r, rel, steps[0], now);
|
|
148
196
|
}
|
|
149
197
|
else if (elapsed >= DEPLOY_TIMEOUT_MINUTES * MINUTE)
|
|
150
198
|
r = rollback(r, now, 'deploy timeout');
|
|
151
199
|
}
|
|
200
|
+
else if (r.window.kind === 'staff') {
|
|
201
|
+
// Weight 0: only a hard fail (crash loop, failed address) rolls back. `open` leaves it.
|
|
202
|
+
if (hard)
|
|
203
|
+
r = rollback(r, now, hard);
|
|
204
|
+
}
|
|
152
205
|
else {
|
|
153
206
|
const health = input.hardOnly ? null : observation.health;
|
|
154
207
|
const newC = health?.colors[rel.to] ?? { requests: 0, errors: 0 };
|
|
@@ -164,10 +217,10 @@ export function decide(input) {
|
|
|
164
217
|
verdict = 'hard_fail';
|
|
165
218
|
else if (input.hardOnly)
|
|
166
219
|
verdict = 'pass';
|
|
167
|
-
else if (!health || baseline === null || newC.requests <
|
|
220
|
+
else if (!health || baseline === null || newC.requests < minRequests)
|
|
168
221
|
verdict = 'inconclusive';
|
|
169
222
|
else
|
|
170
|
-
verdict = rate(newC) > baseline +
|
|
223
|
+
verdict = rate(newC) > baseline + errorMargin ? 'fail' : 'pass';
|
|
171
224
|
signal = { requests: newC.requests, errors: newC.errors, baselineRate: baseline, verdict };
|
|
172
225
|
if (verdict === 'hard_fail') {
|
|
173
226
|
r = rollback(r, now, hard ?? 'hard fail: error rate');
|
|
@@ -188,7 +241,14 @@ export function decide(input) {
|
|
|
188
241
|
if (!atWindow) {
|
|
189
242
|
const pct = r.window.percent;
|
|
190
243
|
const holdDone = elapsed >= holdMs;
|
|
191
|
-
|
|
244
|
+
const stepUp = advance === 'time'
|
|
245
|
+
? holdDone && verdict !== 'fail'
|
|
246
|
+
: advance === 'signal'
|
|
247
|
+
? (holdDone && verdict === 'pass') || (applied.approved && verdict !== 'fail')
|
|
248
|
+
: applied.approved && verdict !== 'fail';
|
|
249
|
+
if (applied.approved && advance !== 'time' && verdict === 'fail')
|
|
250
|
+
out.refused.push('approve_step refused: the error rate is failing');
|
|
251
|
+
if (stepUp) {
|
|
192
252
|
const next = steps.find((s) => s > pct);
|
|
193
253
|
r = stepTo(r, rel, next, now);
|
|
194
254
|
}
|
|
@@ -198,6 +258,8 @@ export function decide(input) {
|
|
|
198
258
|
...r,
|
|
199
259
|
window: { kind, percent: pct, since: r.window.since },
|
|
200
260
|
};
|
|
261
|
+
if (advance === 'signal' && elapsed >= STEP_SIGNAL_CAP_HOURS * HOUR)
|
|
262
|
+
blocked.push('step waiting for traffic: approve the step or roll back in Archon');
|
|
201
263
|
}
|
|
202
264
|
}
|
|
203
265
|
else {
|
|
@@ -208,7 +270,7 @@ export function decide(input) {
|
|
|
208
270
|
baseline !== null &&
|
|
209
271
|
elapsed >= WINDOW_MIN_HOURS * HOUR &&
|
|
210
272
|
newC.requests >= required &&
|
|
211
|
-
rate(newC) <= baseline +
|
|
273
|
+
rate(newC) <= baseline + errorMargin;
|
|
212
274
|
if (closable) {
|
|
213
275
|
out.effects.push({ kind: 'stop', color: rel.from });
|
|
214
276
|
r = { ...r, window: { kind: 'passed', at: now.toISOString() }, release: null };
|