@wtfalch/foundry 0.16.15 → 0.17.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/apply.d.ts +22 -1
- package/dist/bootstrap/bootstrap.d.ts +6 -1
- package/dist/bootstrap/bootstrap.js +26 -3
- package/dist/bootstrap/declare.d.ts +11 -1
- package/dist/bootstrap/declare.js +12 -2
- package/dist/index.d.ts +9 -4
- package/dist/index.js +7 -0
- package/dist/migrations/0038_calls_version_pin.sql +8 -0
- package/dist/migrations/0039_destroy_record.sql +7 -0
- package/dist/migrations/0040_company_included_uuid.sql +72 -0
- package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
- package/dist/migrations/0042_mutation_leases.sql +12 -0
- package/dist/org.d.ts +82 -5
- package/dist/org.js +62 -1
- package/dist/plan/adopt.d.ts +76 -0
- package/dist/plan/adopt.js +184 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +37 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +13 -5
- package/dist/plan/chat-matrix.d.ts +1 -1
- package/dist/plan/chat-matrix.js +11 -3
- package/dist/plan/core-files.d.ts +25 -0
- package/dist/plan/core-files.js +25 -0
- package/dist/plan/deploy-health.d.ts +136 -0
- package/dist/plan/deploy-health.js +153 -0
- package/dist/plan/diff.d.ts +11 -3
- package/dist/plan/diff.js +77 -71
- package/dist/plan/entry-ai-key.d.ts +3 -3
- package/dist/plan/entry-files-key.d.ts +6 -4
- package/dist/plan/entry-files-key.js +7 -10
- package/dist/plan/executor.d.ts +109 -28
- package/dist/plan/executor.js +213 -56
- package/dist/plan/hostnames.d.ts +6 -0
- package/dist/plan/hostnames.js +13 -0
- package/dist/plan/index.d.ts +5 -1
- package/dist/plan/index.js +3 -1
- package/dist/plan/live.d.ts +5 -0
- package/dist/plan/login-host.d.ts +1 -1
- package/dist/plan/login-host.js +6 -5
- package/dist/plan/plan-apply.d.ts +153 -3
- package/dist/plan/plan-apply.js +584 -69
- package/dist/plan/platform-diff.d.ts +103 -9
- package/dist/plan/platform-diff.js +192 -14
- package/dist/plan/platform-org.d.ts +25 -0
- package/dist/plan/platform-org.js +17 -1
- package/dist/plan/platform-providers.d.ts +38 -5
- package/dist/plan/platform-providers.js +592 -109
- package/dist/plan/providers-readers.d.ts +2 -0
- package/dist/plan/providers-readers.js +4 -0
- package/dist/plan/rollout.d.ts +52 -0
- package/dist/plan/rollout.js +37 -0
- package/dist/plan/step-system.d.ts +22 -0
- package/dist/plan/step-system.js +37 -0
- package/dist/plan.d.ts +3 -0
- package/dist/state.d.ts +13 -0
- package/dist/store/audit.js +15 -1
- package/dist/store/postgres.d.ts +1 -1
- package/dist/store/postgres.js +127 -14
- package/dist/store/schema.d.ts +134 -5
- package/dist/store/schema.js +12 -1
- package/dist/store.d.ts +47 -4
- package/dist/store.js +9 -1
- package/package.json +2 -2
|
@@ -94,6 +94,8 @@ export declare class ProvidersCoolifyReader implements CoolifyReader {
|
|
|
94
94
|
private readonly resolveRecordedIds;
|
|
95
95
|
constructor(client: CoolifyClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
|
|
96
96
|
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
97
|
+
/** The project's resources as Coolify reports them, unmapped: what adopt checks against a declaration. */
|
|
98
|
+
listProject(projectUuid: string): Promise<LiveResource[]>;
|
|
97
99
|
}
|
|
98
100
|
/**
|
|
99
101
|
* Backed by `@wtfalch/providers/cloudflare`'s `CloudflareClient.listResources`:
|
|
@@ -243,6 +243,10 @@ export class ProvidersCoolifyReader {
|
|
|
243
243
|
const withBouleConfig = await Promise.all(withAgoraConfig.map((r) => withBouleEnvConfig(this.client, r, recorded)));
|
|
244
244
|
return applyRecordedIds(withBouleConfig, recorded);
|
|
245
245
|
}
|
|
246
|
+
/** The project's resources as Coolify reports them, unmapped: what adopt checks against a declaration. */
|
|
247
|
+
async listProject(projectUuid) {
|
|
248
|
+
return toFoundryResources('coolify', await this.client.listResources(projectUuid));
|
|
249
|
+
}
|
|
246
250
|
}
|
|
247
251
|
/**
|
|
248
252
|
* Backed by `@wtfalch/providers/cloudflare`'s `CloudflareClient.listResources`:
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { OrgId } from '../org.js';
|
|
2
|
+
/**
|
|
3
|
+
* #259: ordered, stop-on-failure rollout across several orgs' own applies.
|
|
4
|
+
*
|
|
5
|
+
* Where the order itself comes from is still open (#259 "Decide": the
|
|
6
|
+
* platform declaration, one place, vs. spread across each org) -- this
|
|
7
|
+
* takes the order as a plain, caller-supplied list, so it works unchanged
|
|
8
|
+
* whichever way that is answered. `apply` is the caller's own one-org apply
|
|
9
|
+
* (typically `plan` then `PlanApply.apply`, confirmation and all) -- this
|
|
10
|
+
* file knows nothing about that shape, only whether it threw.
|
|
11
|
+
*
|
|
12
|
+
* "Stop at the first failed health check" (the issue's own words) is, today,
|
|
13
|
+
* "stop at the first `apply` that throws" -- the shared health helper
|
|
14
|
+
* (`deploy-health.ts`'s `waitForNewDeployment`) is not yet wired into the
|
|
15
|
+
* org-level calls stack deploy (`executor.ts`'s `coolifyCalls`/
|
|
16
|
+
* `coolifyCallsService` only ever fire-and-forget deploy, and never patch a
|
|
17
|
+
* running app's image tag at all -- see `org.ts`'s `CallsConfig.versionPin`
|
|
18
|
+
* doc comment). Once that step itself throws on a failed health check, a
|
|
19
|
+
* caller's `apply` naturally does too, and this orchestration needs no
|
|
20
|
+
* change.
|
|
21
|
+
*/
|
|
22
|
+
/** One org's outcome from {@link applyOrgsInOrder}. */
|
|
23
|
+
export interface RolloutOrgResult {
|
|
24
|
+
readonly orgId: OrgId;
|
|
25
|
+
readonly outcome: 'applied' | 'skipped' | 'failed';
|
|
26
|
+
/** Set only for `'failed'` -- the thrown error's own message. */
|
|
27
|
+
readonly error?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Set only for `'applied'`, when `soakMs` names one for this org: the ISO
|
|
30
|
+
* time the caller should wait until before applying the next org. Never
|
|
31
|
+
* slept here (CLAUDE.md "Platform steps" rule 5, and the issue's own "Make
|
|
32
|
+
* the soak a declared value the caller supplies, not a sleep inside
|
|
33
|
+
* foundry") -- a caller that wants to honour it re-invokes later, from
|
|
34
|
+
* `orgIds` sliced after this result's own org.
|
|
35
|
+
*/
|
|
36
|
+
readonly soakUntil?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface RolloutOptions {
|
|
39
|
+
/** This org's soak window in milliseconds, or `undefined`/`0` for none (a stateless service). */
|
|
40
|
+
soakMs?: (orgId: OrgId) => number | undefined;
|
|
41
|
+
/** Overridable for tests; defaults to `Date.now`. */
|
|
42
|
+
now?: () => number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Applies every org in `orgIds`, in order, stopping at the first `apply`
|
|
46
|
+
* that throws: every org after it is reported `'skipped'` without `apply`
|
|
47
|
+
* ever being called for it, so it is left exactly as it was. An org whose
|
|
48
|
+
* own `apply` throws is reported `'failed'` with that error's message --
|
|
49
|
+
* `apply`'s own implementation decides what, if anything, it recorded
|
|
50
|
+
* before throwing (this function never inspects or rolls back that state).
|
|
51
|
+
*/
|
|
52
|
+
export declare function applyOrgsInOrder(orgIds: readonly OrgId[], apply: (orgId: OrgId) => Promise<unknown>, options?: RolloutOptions): Promise<RolloutOrgResult[]>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies every org in `orgIds`, in order, stopping at the first `apply`
|
|
3
|
+
* that throws: every org after it is reported `'skipped'` without `apply`
|
|
4
|
+
* ever being called for it, so it is left exactly as it was. An org whose
|
|
5
|
+
* own `apply` throws is reported `'failed'` with that error's message --
|
|
6
|
+
* `apply`'s own implementation decides what, if anything, it recorded
|
|
7
|
+
* before throwing (this function never inspects or rolls back that state).
|
|
8
|
+
*/
|
|
9
|
+
export async function applyOrgsInOrder(orgIds, apply, options = {}) {
|
|
10
|
+
const now = options.now ?? Date.now;
|
|
11
|
+
const results = [];
|
|
12
|
+
let stopped = false;
|
|
13
|
+
for (const orgId of orgIds) {
|
|
14
|
+
if (stopped) {
|
|
15
|
+
results.push({ orgId, outcome: 'skipped' });
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
await apply(orgId);
|
|
20
|
+
const soak = options.soakMs?.(orgId);
|
|
21
|
+
results.push({
|
|
22
|
+
orgId,
|
|
23
|
+
outcome: 'applied',
|
|
24
|
+
...(soak ? { soakUntil: new Date(now() + soak).toISOString() } : {}),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
results.push({
|
|
29
|
+
orgId,
|
|
30
|
+
outcome: 'failed',
|
|
31
|
+
error: error instanceof Error ? error.message : String(error),
|
|
32
|
+
});
|
|
33
|
+
stopped = true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return results;
|
|
37
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PlanStep } from '../plan.js';
|
|
2
|
+
import type { StepAction } from './diff.js';
|
|
3
|
+
import type { LiveResource } from './live.js';
|
|
4
|
+
/**
|
|
5
|
+
* The system a plan step or progress event touches, for Archon to draw as a
|
|
6
|
+
* tag. A superset of {@link LiveResource}'s `system` (where a resource
|
|
7
|
+
* lives): a step that deploys Stalwart, Synapse or a Postgres database
|
|
8
|
+
* through Coolify names that system, not the deployer.
|
|
9
|
+
*
|
|
10
|
+
* `foundry` is for a progress event that covers more than one system, or
|
|
11
|
+
* none (a round of mixed steps, the closing event). No plan step carries it.
|
|
12
|
+
*/
|
|
13
|
+
export type StepSystem = LiveResource['system'] | 'stalwart' | 'matrix' | 'postgres' | 'foundry';
|
|
14
|
+
/** The system `action` touches. The one place that decides it, so no planner sets it by hand. */
|
|
15
|
+
export declare function stepSystemOf(action: StepAction): StepSystem;
|
|
16
|
+
/** The step with its `system` set from `action`. */
|
|
17
|
+
export declare function withStepSystem<T extends {
|
|
18
|
+
step: Omit<PlanStep, 'system'>;
|
|
19
|
+
action: StepAction;
|
|
20
|
+
}>(planned: T): T & {
|
|
21
|
+
step: PlanStep;
|
|
22
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Which system a platform `app` step (keyed `platform:<service>`) runs. */
|
|
2
|
+
const PLATFORM_APP_SYSTEMS = {
|
|
3
|
+
'platform:auth': 'zitadel',
|
|
4
|
+
'platform:mail': 'stalwart',
|
|
5
|
+
'platform:files': 'files',
|
|
6
|
+
'platform:ai': 'ai',
|
|
7
|
+
};
|
|
8
|
+
/** The system `action` touches. The one place that decides it, so no planner sets it by hand. */
|
|
9
|
+
export function stepSystemOf(action) {
|
|
10
|
+
switch (action.kind) {
|
|
11
|
+
case 'mail':
|
|
12
|
+
case 'mail-copy':
|
|
13
|
+
return 'stalwart';
|
|
14
|
+
case 'chat':
|
|
15
|
+
case 'calls':
|
|
16
|
+
case 'calls-service':
|
|
17
|
+
return 'matrix';
|
|
18
|
+
case 'database':
|
|
19
|
+
case 'adopt-database':
|
|
20
|
+
case 'backup':
|
|
21
|
+
return 'postgres';
|
|
22
|
+
// Re-registers the R2 bucket's credentials with Coolify: the bucket is what it writes to.
|
|
23
|
+
case 'backup-storage':
|
|
24
|
+
return 'cloudflare';
|
|
25
|
+
// The platform's jobs scheduler app, like the org's own `jobs` registration.
|
|
26
|
+
case 'jobs':
|
|
27
|
+
return 'jobs';
|
|
28
|
+
case 'app':
|
|
29
|
+
return PLATFORM_APP_SYSTEMS[action.key] ?? action.system;
|
|
30
|
+
default:
|
|
31
|
+
return action.system;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** The step with its `system` set from `action`. */
|
|
35
|
+
export function withStepSystem(planned) {
|
|
36
|
+
return { ...planned, step: { ...planned.step, system: stepSystemOf(planned.action) } };
|
|
37
|
+
}
|
package/dist/plan.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OrgId } from './org.js';
|
|
2
|
+
import type { StepSystem } from './plan/step-system.js';
|
|
2
3
|
/**
|
|
3
4
|
* One step of a {@link Plan}.
|
|
4
5
|
*
|
|
@@ -9,6 +10,8 @@ import type { OrgId } from './org.js';
|
|
|
9
10
|
*/
|
|
10
11
|
export interface PlanStep {
|
|
11
12
|
description: string;
|
|
13
|
+
/** The system this step touches, for Archon to tag it with. Set from the step's action, never by hand (`plan/step-system.ts`). */
|
|
14
|
+
system: StepSystem;
|
|
12
15
|
/**
|
|
13
16
|
* True when this step deletes or replaces data the org cannot get back
|
|
14
17
|
* without foundry's own backup: a chat re-key, an issuer move, a mail
|
package/dist/state.d.ts
CHANGED
|
@@ -12,6 +12,17 @@ export interface Checkpoint {
|
|
|
12
12
|
step: string;
|
|
13
13
|
completedAt: string;
|
|
14
14
|
}
|
|
15
|
+
/** One step of a destroy: `finished`, `failed` (with `error`), `started` (in flight; a crash leaves it so), or `planned` (not reached yet). */
|
|
16
|
+
export interface DestroyStepRecord {
|
|
17
|
+
step: string;
|
|
18
|
+
status: 'planned' | 'started' | 'finished' | 'failed';
|
|
19
|
+
completedAt?: string;
|
|
20
|
+
error?: string;
|
|
21
|
+
}
|
|
22
|
+
/** Foundry's record of the current or most recent destroy of an org, in step order. */
|
|
23
|
+
export interface DestroyRecord {
|
|
24
|
+
steps: DestroyStepRecord[];
|
|
25
|
+
}
|
|
15
26
|
/**
|
|
16
27
|
* When an apply last completed, and against which declared org version.
|
|
17
28
|
*
|
|
@@ -81,4 +92,6 @@ export interface AppliedState {
|
|
|
81
92
|
failedFields: string[];
|
|
82
93
|
/** `null` before this org has ever had a completed apply. */
|
|
83
94
|
lastApplied: LastApplied | null;
|
|
95
|
+
/** The destroy that started for this org, if any. Absent until one has. */
|
|
96
|
+
destroy?: DestroyRecord | null;
|
|
84
97
|
}
|
package/dist/store/audit.js
CHANGED
|
@@ -14,10 +14,11 @@ const FOUNDRY_LEDGER_VOCABULARY = ledgerVocabulary({
|
|
|
14
14
|
'plan.computed': { tenantVisible: false },
|
|
15
15
|
'apply.recorded': { tenantVisible: false },
|
|
16
16
|
'destroy.recorded': { tenantVisible: false },
|
|
17
|
+
'adoption.recorded': { tenantVisible: false },
|
|
17
18
|
},
|
|
18
19
|
actorClasses: ['operator'],
|
|
19
20
|
contexts: ['operator'],
|
|
20
|
-
outcomes: ['recorded', 'applied', 'destroyed', 'refused', 'failed'],
|
|
21
|
+
outcomes: ['recorded', 'applied', 'destroyed', 'refused', 'failed', 'adopted', 'released'],
|
|
21
22
|
});
|
|
22
23
|
/**
|
|
23
24
|
* The ledger table's CHECKs (migration 0024): reason <= 512 chars, `after`
|
|
@@ -110,6 +111,19 @@ export function createPostgresAuditLog(db, options = {}) {
|
|
|
110
111
|
occurredAt: at,
|
|
111
112
|
});
|
|
112
113
|
},
|
|
114
|
+
async recordAdoption({ actor, orgId, action, entryKey, outcome, reason, at }) {
|
|
115
|
+
await ledger.sign(db, {
|
|
116
|
+
action: 'adoption.recorded',
|
|
117
|
+
tenantId: null,
|
|
118
|
+
actor: { class: 'operator', id: actor.id, display: boundDisplay(actor) },
|
|
119
|
+
context: 'operator',
|
|
120
|
+
target: targetFor(orgId),
|
|
121
|
+
outcome,
|
|
122
|
+
reason: boundReason(reason),
|
|
123
|
+
after: { action, entryKey },
|
|
124
|
+
occurredAt: at,
|
|
125
|
+
});
|
|
126
|
+
},
|
|
113
127
|
async recordDestroy({ actor, orgId, outcome, reason, at }) {
|
|
114
128
|
await ledger.sign(db, {
|
|
115
129
|
action: 'destroy.recorded',
|
package/dist/store/postgres.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
|
|
2
|
-
import type
|
|
2
|
+
import { type AppliedVersionsStore, type DeclaredOrgStore, type PlatformStore, type RequestedChangeStore } from '../store.js';
|
|
3
3
|
/**
|
|
4
4
|
* The host's own drizzle handle or a transaction open on it, same shape
|
|
5
5
|
* `@wtfalch/keys`'s `HeldKeysDb` uses: postgres-js in a real host, PGlite in
|
package/dist/store/postgres.js
CHANGED
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
import { and, asc, desc, eq, sql } from 'drizzle-orm';
|
|
2
|
-
import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBuyerIdentity, validateDomain, validateHosting, validateMail, validateOwners, validateSite, } from '../org.js';
|
|
2
|
+
import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, validateBuyerIdentity, validateCalls, validateDomain, validateHosting, validateMail, validateOwners, validateSite, } from '../org.js';
|
|
3
3
|
import { lockedHostnames } from '../plan/locked-hostnames.js';
|
|
4
4
|
import { validatePlatform } from '../platform.js';
|
|
5
5
|
import { RequestedChangeError, classifyChange } from '../requested-changes.js';
|
|
6
6
|
import { validateSlug } from '../slug.js';
|
|
7
|
-
import {
|
|
7
|
+
import { StaleLeaseError, } from '../store.js';
|
|
8
|
+
import { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryDeclaredPlatform, foundryMutationLeases, foundryRequestedChanges, } from './schema.js';
|
|
8
9
|
const UNIQUE_VIOLATION = '23505';
|
|
10
|
+
/**
|
|
11
|
+
* #441: runs `write`, and with a `fence` only if its token is still the
|
|
12
|
+
* scope's current one. The lease row is locked for the write, so a takeover
|
|
13
|
+
* cannot slip between the check and the write.
|
|
14
|
+
*/
|
|
15
|
+
async function writeFenced(db, fence, write) {
|
|
16
|
+
if (!fence)
|
|
17
|
+
return write(db);
|
|
18
|
+
await db.transaction(async (tx) => {
|
|
19
|
+
const [row] = await tx
|
|
20
|
+
.select({ token: foundryMutationLeases.token })
|
|
21
|
+
.from(foundryMutationLeases)
|
|
22
|
+
.where(eq(foundryMutationLeases.scope, fence.scope))
|
|
23
|
+
.for('update');
|
|
24
|
+
if (!row || row.token !== fence.token)
|
|
25
|
+
throw new StaleLeaseError(fence.scope);
|
|
26
|
+
await write(tx);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
9
29
|
/** `migrations/0004_slug_unique.sql` / `migrations/0015_domain_unique.sql`'s index names, read back off a caught unique-violation to tell which one fired. */
|
|
10
30
|
const SLUG_UNIQUE_INDEX = 'foundry_declared_orgs_slug_idx';
|
|
11
31
|
const DOMAIN_UNIQUE_INDEX = 'foundry_declared_orgs_domain_idx';
|
|
@@ -75,7 +95,21 @@ function rowToOrg(row) {
|
|
|
75
95
|
roleMailboxes: row.roleMailboxes,
|
|
76
96
|
},
|
|
77
97
|
chat: { enabled: row.chatEnabled, usesDomain: row.chatUsesDomain },
|
|
78
|
-
calls: {
|
|
98
|
+
calls: {
|
|
99
|
+
enabled: row.callsEnabled,
|
|
100
|
+
usesDomain: row.callsUsesDomain,
|
|
101
|
+
// Omitted, not `null`, while neither column is set: every existing
|
|
102
|
+
// row (and every test fixture that builds a `CallsConfig` literal
|
|
103
|
+
// before this field existed) round-trips unchanged.
|
|
104
|
+
...(row.callsVersionPinSynapse || row.callsVersionPinMas
|
|
105
|
+
? {
|
|
106
|
+
versionPin: {
|
|
107
|
+
...(row.callsVersionPinSynapse ? { synapse: row.callsVersionPinSynapse } : {}),
|
|
108
|
+
...(row.callsVersionPinMas ? { mas: row.callsVersionPinMas } : {}),
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
: {}),
|
|
112
|
+
},
|
|
79
113
|
issuer: { usesDomain: row.issuerUsesDomain },
|
|
80
114
|
site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
|
|
81
115
|
hosting: {
|
|
@@ -119,6 +153,8 @@ function orgToRow(org) {
|
|
|
119
153
|
chatUsesDomain: org.chat.usesDomain,
|
|
120
154
|
callsEnabled: org.calls.enabled,
|
|
121
155
|
callsUsesDomain: org.calls.usesDomain,
|
|
156
|
+
callsVersionPinSynapse: org.calls.versionPin?.synapse ?? null,
|
|
157
|
+
callsVersionPinMas: org.calls.versionPin?.mas ?? null,
|
|
122
158
|
issuerUsesDomain: org.issuer.usesDomain,
|
|
123
159
|
siteEnabled: org.site.enabled,
|
|
124
160
|
siteUsesDomain: org.site.usesDomain,
|
|
@@ -268,11 +304,25 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
268
304
|
// `mail.ownerMailboxAddress` just because mail is on -- `validateMail`'s
|
|
269
305
|
// own doc comment says why. Also covers #34's `mailApps` check, folded
|
|
270
306
|
// into the same `validateMail`.
|
|
271
|
-
|
|
272
|
-
|
|
307
|
+
//
|
|
308
|
+
// #422: `allowExistingTier2` grandfathers an org already stored as a
|
|
309
|
+
// domain plus `mail.mode: 'received'` -- `update` passes it true only
|
|
310
|
+
// when that exact combination was already the row's state before this
|
|
311
|
+
// patch, so an unrelated edit to an already-tier-2 org still goes
|
|
312
|
+
// through. `create` never passes it: a brand new org cannot already be
|
|
313
|
+
// grandfathered.
|
|
314
|
+
function requireValidMail(org, opts) {
|
|
315
|
+
const reason = validateMail(org, opts);
|
|
273
316
|
if (reason)
|
|
274
317
|
throw new DeclaredOrgError(`mail invalid: ${reason} (#162)`);
|
|
275
318
|
}
|
|
319
|
+
// #259: same place `requireValidMail` checks mail, on both `create` and
|
|
320
|
+
// the merged result of `update`.
|
|
321
|
+
function requireValidCalls(org) {
|
|
322
|
+
const reason = validateCalls(org);
|
|
323
|
+
if (reason)
|
|
324
|
+
throw new DeclaredOrgError(`calls invalid: ${reason} (#259)`);
|
|
325
|
+
}
|
|
276
326
|
// `as`: `update` is overloaded in the interface (#351), which one object-literal method cannot express.
|
|
277
327
|
return {
|
|
278
328
|
async create(org) {
|
|
@@ -284,6 +334,7 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
284
334
|
requireValidSite(org);
|
|
285
335
|
requireValidOwner(org);
|
|
286
336
|
requireValidMail(org);
|
|
337
|
+
requireValidCalls(org);
|
|
287
338
|
try {
|
|
288
339
|
await db.insert(declaredOrgs).values(orgToRow(org));
|
|
289
340
|
}
|
|
@@ -339,6 +390,23 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
339
390
|
}
|
|
340
391
|
return null;
|
|
341
392
|
},
|
|
393
|
+
async findOtherOrgRecordingCoolifyId(id, exceptOrgId) {
|
|
394
|
+
const rows = await db
|
|
395
|
+
.select({ orgId: foundryAppliedState.orgId, serviceIds: foundryAppliedState.serviceIds })
|
|
396
|
+
.from(foundryAppliedState);
|
|
397
|
+
for (const row of rows) {
|
|
398
|
+
if (row.orgId === exceptOrgId)
|
|
399
|
+
continue;
|
|
400
|
+
for (const [key, value] of Object.entries(row.serviceIds)) {
|
|
401
|
+
const coolify = key === 'coolify_project' ||
|
|
402
|
+
key.startsWith('coolify:app:') ||
|
|
403
|
+
key.startsWith('coolify:database:');
|
|
404
|
+
if (coolify && value === id)
|
|
405
|
+
return row.orgId;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
},
|
|
342
410
|
async update(id, patch, opts) {
|
|
343
411
|
const current = rowToOrg(await requireRow(id));
|
|
344
412
|
// #351: already behind, so refuse without validating a write that will not happen.
|
|
@@ -366,7 +434,11 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
366
434
|
requireValidHosting(next);
|
|
367
435
|
requireValidSite(next);
|
|
368
436
|
requireValidOwner(next);
|
|
369
|
-
|
|
437
|
+
// #422: grandfather an org already stored as domain + `received` --
|
|
438
|
+
// refuse only when this patch is what newly puts it in that state.
|
|
439
|
+
const wasTier2 = current.domain !== null && current.mail.mode === 'received';
|
|
440
|
+
requireValidMail(next, { allowExistingTier2: wasTier2 });
|
|
441
|
+
requireValidCalls(next);
|
|
370
442
|
try {
|
|
371
443
|
// #351: with `expectedVersion` the version check is part of this one
|
|
372
444
|
// statement, so an edit that lands after the read above makes it match
|
|
@@ -420,10 +492,44 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
420
492
|
declaredOrgVersion: row.lastAppliedDeclaredOrgVersion,
|
|
421
493
|
}
|
|
422
494
|
: null,
|
|
495
|
+
...(row.destroy ? { destroy: row.destroy } : {}),
|
|
423
496
|
};
|
|
424
497
|
return state;
|
|
425
498
|
},
|
|
426
|
-
async
|
|
499
|
+
async acquireMutationLease(scope, holder, ttlMs) {
|
|
500
|
+
const expiresAt = sql `now() + ${ttlMs} * interval '1 millisecond'`;
|
|
501
|
+
const [row] = await db
|
|
502
|
+
.insert(foundryMutationLeases)
|
|
503
|
+
.values({ scope, token: 1, holder, expiresAt })
|
|
504
|
+
.onConflictDoUpdate({
|
|
505
|
+
target: foundryMutationLeases.scope,
|
|
506
|
+
set: { token: sql `${foundryMutationLeases.token} + 1`, holder, expiresAt },
|
|
507
|
+
setWhere: sql `${foundryMutationLeases.expiresAt} <= now()`,
|
|
508
|
+
})
|
|
509
|
+
.returning({ token: foundryMutationLeases.token });
|
|
510
|
+
if (row)
|
|
511
|
+
return { scope, token: row.token };
|
|
512
|
+
const [held] = await db
|
|
513
|
+
.select({ expiresAt: foundryMutationLeases.expiresAt })
|
|
514
|
+
.from(foundryMutationLeases)
|
|
515
|
+
.where(eq(foundryMutationLeases.scope, scope));
|
|
516
|
+
return { heldUntil: held?.expiresAt ?? new Date() };
|
|
517
|
+
},
|
|
518
|
+
async renewMutationLease(lease, ttlMs) {
|
|
519
|
+
const rows = await db
|
|
520
|
+
.update(foundryMutationLeases)
|
|
521
|
+
.set({ expiresAt: sql `now() + ${ttlMs} * interval '1 millisecond'` })
|
|
522
|
+
.where(and(eq(foundryMutationLeases.scope, lease.scope), eq(foundryMutationLeases.token, lease.token)))
|
|
523
|
+
.returning({ token: foundryMutationLeases.token });
|
|
524
|
+
return rows.length > 0;
|
|
525
|
+
},
|
|
526
|
+
async releaseMutationLease(lease) {
|
|
527
|
+
await db
|
|
528
|
+
.update(foundryMutationLeases)
|
|
529
|
+
.set({ expiresAt: sql `now()` })
|
|
530
|
+
.where(and(eq(foundryMutationLeases.scope, lease.scope), eq(foundryMutationLeases.token, lease.token)));
|
|
531
|
+
},
|
|
532
|
+
async putAppliedState(state, fence) {
|
|
427
533
|
const values = {
|
|
428
534
|
orgId: state.orgId,
|
|
429
535
|
serviceIds: state.serviceIds,
|
|
@@ -431,21 +537,25 @@ export function createPostgresDeclaredOrgStore(db) {
|
|
|
431
537
|
failedFields: state.failedFields,
|
|
432
538
|
lastAppliedAt: state.lastApplied ? new Date(state.lastApplied.at) : null,
|
|
433
539
|
lastAppliedDeclaredOrgVersion: state.lastApplied?.declaredOrgVersion ?? null,
|
|
540
|
+
destroy: state.destroy ?? null,
|
|
434
541
|
};
|
|
435
|
-
await db
|
|
542
|
+
await writeFenced(db, fence, (tx) => tx
|
|
436
543
|
.insert(foundryAppliedState)
|
|
437
544
|
.values(values)
|
|
438
|
-
.onConflictDoUpdate({ target: foundryAppliedState.orgId, set: values })
|
|
545
|
+
.onConflictDoUpdate({ target: foundryAppliedState.orgId, set: values })
|
|
546
|
+
.then(() => undefined));
|
|
439
547
|
},
|
|
440
|
-
async delete(id) {
|
|
548
|
+
async delete(id, fence) {
|
|
441
549
|
// Applied state first: `0002_applied_state.sql`'s
|
|
442
550
|
// `foundry_applied_state_org_fkey` references the declared org with no
|
|
443
551
|
// cascade, so deleting the org first would fail while its applied
|
|
444
552
|
// state row still points at it. Both deletes are no-ops (idempotent)
|
|
445
553
|
// when the row is already gone, matching this method's own doc
|
|
446
554
|
// comment on `../store.ts`.
|
|
447
|
-
await db
|
|
448
|
-
|
|
555
|
+
await writeFenced(db, fence, async (tx) => {
|
|
556
|
+
await tx.delete(foundryAppliedState).where(eq(foundryAppliedState.orgId, id));
|
|
557
|
+
await tx.delete(declaredOrgs).where(eq(declaredOrgs.id, id));
|
|
558
|
+
});
|
|
449
559
|
},
|
|
450
560
|
};
|
|
451
561
|
}
|
|
@@ -549,8 +659,11 @@ export function createPostgresPlatformStore(db) {
|
|
|
549
659
|
.limit(1);
|
|
550
660
|
return row?.serviceIds ?? {};
|
|
551
661
|
},
|
|
552
|
-
async putServiceIds(serviceIds) {
|
|
553
|
-
await db
|
|
662
|
+
async putServiceIds(serviceIds, fence) {
|
|
663
|
+
await writeFenced(db, fence, (tx) => tx
|
|
664
|
+
.update(foundryDeclaredPlatform)
|
|
665
|
+
.set({ serviceIds })
|
|
666
|
+
.then(() => undefined));
|
|
554
667
|
},
|
|
555
668
|
};
|
|
556
669
|
}
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AppEnvSource, HostingEntry, IssuerApp, OwnerIdentity } from '../org.js';
|
|
2
2
|
import type { Plan } from '../plan.js';
|
|
3
|
-
import type { Checkpoint } from '../state.js';
|
|
3
|
+
import type { Checkpoint, DestroyRecord } from '../state.js';
|
|
4
4
|
/**
|
|
5
5
|
* Mirrors `migrations/0001_declared_org.sql`, which is the source of truth:
|
|
6
6
|
* the SQL carries the CHECKs drizzle-kit does not generate here (this
|
|
@@ -270,6 +270,40 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
|
|
|
270
270
|
identity: undefined;
|
|
271
271
|
generated: undefined;
|
|
272
272
|
}, {}, {}>;
|
|
273
|
+
callsVersionPinSynapse: import("drizzle-orm/pg-core").PgColumn<{
|
|
274
|
+
name: "calls_version_pin_synapse";
|
|
275
|
+
tableName: "foundry_declared_orgs";
|
|
276
|
+
dataType: "string";
|
|
277
|
+
columnType: "PgText";
|
|
278
|
+
data: string;
|
|
279
|
+
driverParam: string;
|
|
280
|
+
notNull: false;
|
|
281
|
+
hasDefault: false;
|
|
282
|
+
isPrimaryKey: false;
|
|
283
|
+
isAutoincrement: false;
|
|
284
|
+
hasRuntimeDefault: false;
|
|
285
|
+
enumValues: [string, ...string[]];
|
|
286
|
+
baseColumn: never;
|
|
287
|
+
identity: undefined;
|
|
288
|
+
generated: undefined;
|
|
289
|
+
}, {}, {}>;
|
|
290
|
+
callsVersionPinMas: import("drizzle-orm/pg-core").PgColumn<{
|
|
291
|
+
name: "calls_version_pin_mas";
|
|
292
|
+
tableName: "foundry_declared_orgs";
|
|
293
|
+
dataType: "string";
|
|
294
|
+
columnType: "PgText";
|
|
295
|
+
data: string;
|
|
296
|
+
driverParam: string;
|
|
297
|
+
notNull: false;
|
|
298
|
+
hasDefault: false;
|
|
299
|
+
isPrimaryKey: false;
|
|
300
|
+
isAutoincrement: false;
|
|
301
|
+
hasRuntimeDefault: false;
|
|
302
|
+
enumValues: [string, ...string[]];
|
|
303
|
+
baseColumn: never;
|
|
304
|
+
identity: undefined;
|
|
305
|
+
generated: undefined;
|
|
306
|
+
}, {}, {}>;
|
|
273
307
|
issuerUsesDomain: import("drizzle-orm/pg-core").PgColumn<{
|
|
274
308
|
name: "issuer_uses_domain";
|
|
275
309
|
tableName: "foundry_declared_orgs";
|
|
@@ -945,6 +979,101 @@ export declare const foundryAppliedState: import("drizzle-orm/pg-core").PgTableW
|
|
|
945
979
|
identity: undefined;
|
|
946
980
|
generated: undefined;
|
|
947
981
|
}, {}, {}>;
|
|
982
|
+
destroy: import("drizzle-orm/pg-core").PgColumn<{
|
|
983
|
+
name: "destroy";
|
|
984
|
+
tableName: "foundry_applied_state";
|
|
985
|
+
dataType: "json";
|
|
986
|
+
columnType: "PgJsonb";
|
|
987
|
+
data: DestroyRecord;
|
|
988
|
+
driverParam: unknown;
|
|
989
|
+
notNull: false;
|
|
990
|
+
hasDefault: false;
|
|
991
|
+
isPrimaryKey: false;
|
|
992
|
+
isAutoincrement: false;
|
|
993
|
+
hasRuntimeDefault: false;
|
|
994
|
+
enumValues: undefined;
|
|
995
|
+
baseColumn: never;
|
|
996
|
+
identity: undefined;
|
|
997
|
+
generated: undefined;
|
|
998
|
+
}, {}, {
|
|
999
|
+
$type: DestroyRecord;
|
|
1000
|
+
}>;
|
|
1001
|
+
};
|
|
1002
|
+
dialect: "pg";
|
|
1003
|
+
}>;
|
|
1004
|
+
/** Mirrors `migrations/0042_mutation_leases.sql`: one row per lock scope; `token` only ever rises (#441). */
|
|
1005
|
+
export declare const foundryMutationLeases: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
1006
|
+
name: "foundry_mutation_leases";
|
|
1007
|
+
schema: undefined;
|
|
1008
|
+
columns: {
|
|
1009
|
+
scope: import("drizzle-orm/pg-core").PgColumn<{
|
|
1010
|
+
name: "scope";
|
|
1011
|
+
tableName: "foundry_mutation_leases";
|
|
1012
|
+
dataType: "string";
|
|
1013
|
+
columnType: "PgText";
|
|
1014
|
+
data: string;
|
|
1015
|
+
driverParam: string;
|
|
1016
|
+
notNull: true;
|
|
1017
|
+
hasDefault: false;
|
|
1018
|
+
isPrimaryKey: true;
|
|
1019
|
+
isAutoincrement: false;
|
|
1020
|
+
hasRuntimeDefault: false;
|
|
1021
|
+
enumValues: [string, ...string[]];
|
|
1022
|
+
baseColumn: never;
|
|
1023
|
+
identity: undefined;
|
|
1024
|
+
generated: undefined;
|
|
1025
|
+
}, {}, {}>;
|
|
1026
|
+
token: import("drizzle-orm/pg-core").PgColumn<{
|
|
1027
|
+
name: "token";
|
|
1028
|
+
tableName: "foundry_mutation_leases";
|
|
1029
|
+
dataType: "number";
|
|
1030
|
+
columnType: "PgBigInt53";
|
|
1031
|
+
data: number;
|
|
1032
|
+
driverParam: string | number;
|
|
1033
|
+
notNull: true;
|
|
1034
|
+
hasDefault: false;
|
|
1035
|
+
isPrimaryKey: false;
|
|
1036
|
+
isAutoincrement: false;
|
|
1037
|
+
hasRuntimeDefault: false;
|
|
1038
|
+
enumValues: undefined;
|
|
1039
|
+
baseColumn: never;
|
|
1040
|
+
identity: undefined;
|
|
1041
|
+
generated: undefined;
|
|
1042
|
+
}, {}, {}>;
|
|
1043
|
+
holder: import("drizzle-orm/pg-core").PgColumn<{
|
|
1044
|
+
name: "holder";
|
|
1045
|
+
tableName: "foundry_mutation_leases";
|
|
1046
|
+
dataType: "string";
|
|
1047
|
+
columnType: "PgText";
|
|
1048
|
+
data: string;
|
|
1049
|
+
driverParam: string;
|
|
1050
|
+
notNull: true;
|
|
1051
|
+
hasDefault: false;
|
|
1052
|
+
isPrimaryKey: false;
|
|
1053
|
+
isAutoincrement: false;
|
|
1054
|
+
hasRuntimeDefault: false;
|
|
1055
|
+
enumValues: [string, ...string[]];
|
|
1056
|
+
baseColumn: never;
|
|
1057
|
+
identity: undefined;
|
|
1058
|
+
generated: undefined;
|
|
1059
|
+
}, {}, {}>;
|
|
1060
|
+
expiresAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
1061
|
+
name: "expires_at";
|
|
1062
|
+
tableName: "foundry_mutation_leases";
|
|
1063
|
+
dataType: "date";
|
|
1064
|
+
columnType: "PgTimestamp";
|
|
1065
|
+
data: Date;
|
|
1066
|
+
driverParam: string;
|
|
1067
|
+
notNull: true;
|
|
1068
|
+
hasDefault: false;
|
|
1069
|
+
isPrimaryKey: false;
|
|
1070
|
+
isAutoincrement: false;
|
|
1071
|
+
hasRuntimeDefault: false;
|
|
1072
|
+
enumValues: undefined;
|
|
1073
|
+
baseColumn: never;
|
|
1074
|
+
identity: undefined;
|
|
1075
|
+
generated: undefined;
|
|
1076
|
+
}, {}, {}>;
|
|
948
1077
|
};
|
|
949
1078
|
dialect: "pg";
|
|
950
1079
|
}>;
|
|
@@ -1107,7 +1236,7 @@ export declare const foundryAuditEvents: import("drizzle-orm/pg-core").PgTableWi
|
|
|
1107
1236
|
tableName: "foundry_audit_events";
|
|
1108
1237
|
dataType: "string";
|
|
1109
1238
|
columnType: "PgText";
|
|
1110
|
-
data: "plan" | "
|
|
1239
|
+
data: "plan" | "destroy" | "apply";
|
|
1111
1240
|
driverParam: string;
|
|
1112
1241
|
notNull: true;
|
|
1113
1242
|
hasDefault: false;
|
|
@@ -1119,7 +1248,7 @@ export declare const foundryAuditEvents: import("drizzle-orm/pg-core").PgTableWi
|
|
|
1119
1248
|
identity: undefined;
|
|
1120
1249
|
generated: undefined;
|
|
1121
1250
|
}, {}, {
|
|
1122
|
-
$type: "plan" | "
|
|
1251
|
+
$type: "plan" | "destroy" | "apply";
|
|
1123
1252
|
}>;
|
|
1124
1253
|
actorId: import("drizzle-orm/pg-core").PgColumn<{
|
|
1125
1254
|
name: "actor_id";
|
|
@@ -1177,7 +1306,7 @@ export declare const foundryAuditEvents: import("drizzle-orm/pg-core").PgTableWi
|
|
|
1177
1306
|
tableName: "foundry_audit_events";
|
|
1178
1307
|
dataType: "string";
|
|
1179
1308
|
columnType: "PgText";
|
|
1180
|
-
data: "applied" | "
|
|
1309
|
+
data: "applied" | "failed" | "refused" | "destroyed";
|
|
1181
1310
|
driverParam: string;
|
|
1182
1311
|
notNull: false;
|
|
1183
1312
|
hasDefault: false;
|
|
@@ -1189,7 +1318,7 @@ export declare const foundryAuditEvents: import("drizzle-orm/pg-core").PgTableWi
|
|
|
1189
1318
|
identity: undefined;
|
|
1190
1319
|
generated: undefined;
|
|
1191
1320
|
}, {}, {
|
|
1192
|
-
$type: "applied" | "
|
|
1321
|
+
$type: "applied" | "failed" | "refused" | "destroyed";
|
|
1193
1322
|
}>;
|
|
1194
1323
|
reason: import("drizzle-orm/pg-core").PgColumn<{
|
|
1195
1324
|
name: "reason";
|