@wtfalch/foundry 0.10.0 → 0.11.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 +16 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/migrations/0031_adoptions.sql +37 -0
- package/dist/org.d.ts +19 -0
- package/dist/org.js +52 -0
- package/dist/plan/adopt.d.ts +112 -0
- package/dist/plan/adopt.js +252 -0
- package/dist/plan/adoption-vault.d.ts +9 -0
- package/dist/plan/adoption-vault.js +1 -0
- package/dist/plan/broker-entries.d.ts +5 -1
- package/dist/plan/cloudflared-connector.d.ts +10 -2
- package/dist/plan/cloudflared-connector.js +24 -16
- package/dist/plan/diff.d.ts +12 -0
- package/dist/plan/diff.js +232 -9
- package/dist/plan/executor.d.ts +97 -3
- package/dist/plan/executor.js +918 -96
- package/dist/plan/plan-apply.d.ts +47 -3
- package/dist/plan/plan-apply.js +122 -4
- package/dist/plan/platform-diff.d.ts +6 -0
- package/dist/plan/platform-diff.js +8 -0
- package/dist/plan/providers-readers.d.ts +8 -1
- package/dist/plan/providers-readers.js +58 -4
- package/dist/plan.d.ts +6 -0
- package/dist/store/postgres.d.ts +6 -1
- package/dist/store/postgres.js +69 -2
- package/dist/store/schema.d.ts +162 -0
- package/dist/store/schema.js +19 -1
- package/dist/store.d.ts +37 -0
- package/package.json +1 -1
package/dist/apply.d.ts
CHANGED
|
@@ -11,6 +11,13 @@ import type { AppliedState } from './state.js';
|
|
|
11
11
|
* `@wtfalch/audit` records every plan and apply — who, when, the plan, the
|
|
12
12
|
* result.
|
|
13
13
|
*/
|
|
14
|
+
/** What {@link PlanApply.restore} put back: resource keys and env names, never a value. */
|
|
15
|
+
export interface AdoptionRestored {
|
|
16
|
+
restored: string[];
|
|
17
|
+
envDeleted: string[];
|
|
18
|
+
/** Connector rows whose TUNNEL_TOKEN was left because it is not the one foundry wrote. */
|
|
19
|
+
tokenLeft?: string[];
|
|
20
|
+
}
|
|
14
21
|
export interface PlanApply {
|
|
15
22
|
/** Reads live state from Zitadel, Coolify and Cloudflare and diffs it
|
|
16
23
|
* against the declared org (#19 "Done when"). */
|
|
@@ -38,4 +45,13 @@ export interface PlanApply {
|
|
|
38
45
|
* `destroy` only tears down what's left.
|
|
39
46
|
*/
|
|
40
47
|
destroy(id: OrgId, confirmedOrgName: string): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Puts an adopted app back as the snapshot recorded it (#311): the settings
|
|
50
|
+
* it had, its old env values, and none of the env names foundry added. Marks
|
|
51
|
+
* each adoption `restored`. Refused unless `confirmedOrgName` matches the
|
|
52
|
+
* org's `displayName`, once the snapshot is `confirmed` (the first
|
|
53
|
+
* successful apply after the adopting one), and, before any write, when the
|
|
54
|
+
* snapshot's vault entry is missing.
|
|
55
|
+
*/
|
|
56
|
+
restore(id: OrgId, confirmedOrgName: string): Promise<AdoptionRestored>;
|
|
41
57
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -8,9 +8,10 @@ export type { SlugRejectionReason, SlugRejection } from './slug.js';
|
|
|
8
8
|
export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
|
|
9
9
|
export type { Checkpoint, LastApplied, AppliedState } from './state.js';
|
|
10
10
|
export type { PlanStep, UnmanagedItem, Plan } from './plan.js';
|
|
11
|
-
export type { DeclaredOrgStore, PlatformStore, RequestedChangeStore } from './store.js';
|
|
11
|
+
export type { Adoption, AdoptionKind, AdoptionState, AdoptionStore, DeclaredOrgStore, PlatformStore, RequestedChangeStore, } from './store.js';
|
|
12
|
+
export type { AdoptionVault } from './plan/adoption-vault.js';
|
|
12
13
|
export type { PlanApply } from './apply.js';
|
|
13
|
-
export { createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
|
|
14
|
+
export { createPostgresAdoptionStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
|
|
14
15
|
export { createPostgresAuditLog } from './store/audit.js';
|
|
15
16
|
export type { FoundryStoreDb } from './store/postgres.js';
|
|
16
17
|
export { declaredOrgs, foundryAppliedState, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ export { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, appEntry, validateBuyerIdentity, vali
|
|
|
2
2
|
export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
|
|
3
3
|
export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requested-changes.js';
|
|
4
4
|
export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
|
|
5
|
-
export { createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
|
|
5
|
+
export { createPostgresAdoptionStore, createPostgresDeclaredOrgStore, createPostgresPlatformStore, createPostgresRequestedChangeStore, DeclaredOrgError, PlatformError, } from './store/postgres.js';
|
|
6
6
|
export { createPostgresAuditLog } from './store/audit.js';
|
|
7
7
|
export { declaredOrgs, foundryAppliedState, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
|
|
8
8
|
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- @wtfalch/foundry: what foundry recorded before it first changed a live
|
|
2
|
+
-- resource it took over in place (#311), and the org's declared live
|
|
3
|
+
-- database. Mirrors `src/store/schema.ts`'s `foundryAdoptions` and
|
|
4
|
+
-- `declaredOrgs.hostingAdopt`.
|
|
5
|
+
--
|
|
6
|
+
-- `settings` holds non-secret settings only. The secret part of the snapshot
|
|
7
|
+
-- (env values) lives in the host's vault under `vault_ref`; no env value, URL
|
|
8
|
+
-- or secret is ever written here. `kind` lists every kind the later slices add
|
|
9
|
+
-- (tunnel, connector, access) so none of them needs to change this check.
|
|
10
|
+
-- `state` moves adopted -> confirmed or restored or released. A row goes with
|
|
11
|
+
-- its org (`on delete cascade`), so destroying a test org needs no extra step.
|
|
12
|
+
--
|
|
13
|
+
-- `hosting_adopt` is `HostingConfig.adopt`, null when the org adopts nothing.
|
|
14
|
+
-- An entry's own `adopt` lives inside `hosting_entries` and needs no column.
|
|
15
|
+
--
|
|
16
|
+
-- Safe to run twice: nothing is dropped or rewritten.
|
|
17
|
+
|
|
18
|
+
create table if not exists foundry_adoptions (
|
|
19
|
+
org_id text not null,
|
|
20
|
+
resource_key text not null,
|
|
21
|
+
kind text not null,
|
|
22
|
+
taken_at timestamptz not null,
|
|
23
|
+
settings jsonb not null default '{}'::jsonb,
|
|
24
|
+
vault_ref text,
|
|
25
|
+
state text not null default 'adopted',
|
|
26
|
+
|
|
27
|
+
constraint foundry_adoptions_pkey primary key (org_id, resource_key),
|
|
28
|
+
constraint foundry_adoptions_org_fkey
|
|
29
|
+
foreign key (org_id) references foundry_declared_orgs (id) on delete cascade,
|
|
30
|
+
constraint foundry_adoptions_kind_check
|
|
31
|
+
check (kind in ('app', 'database', 'tunnel', 'connector', 'access')),
|
|
32
|
+
constraint foundry_adoptions_state_check
|
|
33
|
+
check (state in ('adopted', 'confirmed', 'restored', 'released'))
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
alter table foundry_declared_orgs
|
|
37
|
+
add column if not exists hosting_adopt jsonb;
|
package/dist/org.d.ts
CHANGED
|
@@ -208,6 +208,15 @@ export interface HostingConfig {
|
|
|
208
208
|
issuerApps: IssuerApp[];
|
|
209
209
|
/** One entry per app the org runs. */
|
|
210
210
|
entries: HostingEntry[];
|
|
211
|
+
/** Live resources foundry takes over in place instead of creating. */
|
|
212
|
+
adopt?: {
|
|
213
|
+
/** The Coolify uuid of the org's live database, taken over as `<slug>-db`. */
|
|
214
|
+
database?: string;
|
|
215
|
+
/** The id of the org's live Cloudflare tunnel: its routes are merged, never overwritten (#312). Requires `tunnel`. */
|
|
216
|
+
tunnel?: string;
|
|
217
|
+
/** The Coolify uuid of the live `cloudflared` app that dials into `tunnel` (#312). Requires `tunnel`. */
|
|
218
|
+
connectorApp?: string;
|
|
219
|
+
};
|
|
211
220
|
}
|
|
212
221
|
/** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
|
|
213
222
|
export declare const APP_ENTRY_KEY = "app";
|
|
@@ -257,6 +266,10 @@ export interface HostingEntry {
|
|
|
257
266
|
* this map.
|
|
258
267
|
*/
|
|
259
268
|
env: Record<string, EnvValue>;
|
|
269
|
+
/** Takes over the live Coolify app with this uuid instead of creating one. */
|
|
270
|
+
adopt?: {
|
|
271
|
+
app: string;
|
|
272
|
+
};
|
|
260
273
|
/** What the entry signs in through. Absent means it uses nothing. */
|
|
261
274
|
uses?: HostingEntryUses;
|
|
262
275
|
}
|
|
@@ -304,6 +317,12 @@ export declare const APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
|
|
|
304
317
|
* - Every `entries[]` entry follows {@link validateHostingEntry}; keys are
|
|
305
318
|
* unique, and at most one entry has a `database`, because the one
|
|
306
319
|
* `<slug>_rt` runtime role cannot serve two apps.
|
|
320
|
+
* - An adopted uuid (`entries[].adopt.app`, `adopt.database`) is non-empty
|
|
321
|
+
* and only lowercase letters and digits (it goes into a URL path), and no two
|
|
322
|
+
* entries adopt the same app. `adopt.database` needs an entry with `database`.
|
|
323
|
+
* - `adopt.tunnel` is a Cloudflare tunnel id (a UUID), `adopt.connectorApp` a
|
|
324
|
+
* Coolify uuid that no entry or database adopts. `connectorApp` needs
|
|
325
|
+
* `tunnel`, and both need `hosting.tunnel` (#312).
|
|
307
326
|
*/
|
|
308
327
|
export declare function validateHosting(hosting: HostingConfig, names?: {
|
|
309
328
|
domain: string | null;
|
package/dist/org.js
CHANGED
|
@@ -13,6 +13,9 @@ export const APP_ENV_SOURCES = new Set([
|
|
|
13
13
|
'aiOrganisationId',
|
|
14
14
|
'mailDomain',
|
|
15
15
|
]);
|
|
16
|
+
const ADOPT_UUID = /^[a-z0-9]+$/;
|
|
17
|
+
/** A Cloudflare tunnel id: a lowercase UUID with hyphens (#312). */
|
|
18
|
+
const TUNNEL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
16
19
|
const ISSUER_APP_KINDS = new Set(['web', 'native', 'api']);
|
|
17
20
|
const HOSTING_BUILD_PACKS = new Set([
|
|
18
21
|
'dockerfile',
|
|
@@ -31,6 +34,12 @@ const HOSTING_BUILD_PACKS = new Set([
|
|
|
31
34
|
* - Every `entries[]` entry follows {@link validateHostingEntry}; keys are
|
|
32
35
|
* unique, and at most one entry has a `database`, because the one
|
|
33
36
|
* `<slug>_rt` runtime role cannot serve two apps.
|
|
37
|
+
* - An adopted uuid (`entries[].adopt.app`, `adopt.database`) is non-empty
|
|
38
|
+
* and only lowercase letters and digits (it goes into a URL path), and no two
|
|
39
|
+
* entries adopt the same app. `adopt.database` needs an entry with `database`.
|
|
40
|
+
* - `adopt.tunnel` is a Cloudflare tunnel id (a UUID), `adopt.connectorApp` a
|
|
41
|
+
* Coolify uuid that no entry or database adopts. `connectorApp` needs
|
|
42
|
+
* `tunnel`, and both need `hosting.tunnel` (#312).
|
|
34
43
|
*/
|
|
35
44
|
export function validateHosting(hosting, names) {
|
|
36
45
|
if (hosting.tunnel && !hosting.enabled) {
|
|
@@ -60,7 +69,27 @@ export function validateHosting(hosting, names) {
|
|
|
60
69
|
if (hosting.enabled && !appEntry(hosting)) {
|
|
61
70
|
return `hosting.enabled requires an entry keyed '${APP_ENTRY_KEY}'`;
|
|
62
71
|
}
|
|
72
|
+
if (hosting.adopt?.database !== undefined && !hosting.adopt.database) {
|
|
73
|
+
return 'hosting.adopt.database needs a non-empty uuid';
|
|
74
|
+
}
|
|
75
|
+
if (hosting.adopt?.database && !ADOPT_UUID.test(hosting.adopt.database)) {
|
|
76
|
+
return 'hosting.adopt.database must be lowercase letters and digits only';
|
|
77
|
+
}
|
|
78
|
+
const { tunnel: adoptedTunnel, connectorApp } = hosting.adopt ?? {};
|
|
79
|
+
if (adoptedTunnel !== undefined && !TUNNEL_ID.test(adoptedTunnel)) {
|
|
80
|
+
return 'hosting.adopt.tunnel must be a Cloudflare tunnel id (a lowercase UUID with hyphens)';
|
|
81
|
+
}
|
|
82
|
+
if (connectorApp !== undefined && !ADOPT_UUID.test(connectorApp)) {
|
|
83
|
+
return 'hosting.adopt.connectorApp must be a non-empty uuid of lowercase letters and digits only';
|
|
84
|
+
}
|
|
85
|
+
if ((connectorApp === undefined) !== (adoptedTunnel === undefined)) {
|
|
86
|
+
return 'hosting.adopt.tunnel and hosting.adopt.connectorApp must be declared together';
|
|
87
|
+
}
|
|
88
|
+
if ((adoptedTunnel !== undefined || connectorApp !== undefined) && !hosting.tunnel) {
|
|
89
|
+
return 'hosting.adopt.tunnel and hosting.adopt.connectorApp require hosting.tunnel';
|
|
90
|
+
}
|
|
63
91
|
const seenKeys = new Set();
|
|
92
|
+
const seenAdopted = new Set();
|
|
64
93
|
for (const entry of hosting.entries) {
|
|
65
94
|
if (seenKeys.has(entry.key)) {
|
|
66
95
|
return `hosting.entries key '${entry.key}' is not unique`;
|
|
@@ -69,10 +98,33 @@ export function validateHosting(hosting, names) {
|
|
|
69
98
|
const invalid = validateHostingEntry(entry, hosting.enabled);
|
|
70
99
|
if (invalid)
|
|
71
100
|
return invalid;
|
|
101
|
+
if (entry.adopt) {
|
|
102
|
+
if (!entry.adopt.app)
|
|
103
|
+
return `hosting entry '${entry.key}' adopt.app needs a non-empty uuid`;
|
|
104
|
+
if (!ADOPT_UUID.test(entry.adopt.app)) {
|
|
105
|
+
return `hosting entry '${entry.key}' adopt.app must be lowercase letters and digits only`;
|
|
106
|
+
}
|
|
107
|
+
if (seenAdopted.has(entry.adopt.app)) {
|
|
108
|
+
return `hosting entry '${entry.key}' adopts an app another entry already adopts`;
|
|
109
|
+
}
|
|
110
|
+
seenAdopted.add(entry.adopt.app);
|
|
111
|
+
if (entry.adopt.app === hosting.adopt?.database) {
|
|
112
|
+
return `hosting entry '${entry.key}' adopts the same uuid as hosting.adopt.database`;
|
|
113
|
+
}
|
|
114
|
+
if (entry.database && !hosting.adopt?.database) {
|
|
115
|
+
return `hosting entry '${entry.key}' adopts its app and sets database, so hosting.adopt.database must name the live database`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
72
118
|
const invalidAuth = validateEntryAuth(entry, hosting, names?.domain);
|
|
73
119
|
if (invalidAuth)
|
|
74
120
|
return invalidAuth;
|
|
75
121
|
}
|
|
122
|
+
if (hosting.adopt?.database && !hosting.entries.some((entry) => entry.database)) {
|
|
123
|
+
return 'hosting.adopt.database is set, so an entry must set database';
|
|
124
|
+
}
|
|
125
|
+
if (connectorApp && (seenAdopted.has(connectorApp) || connectorApp === hosting.adopt?.database)) {
|
|
126
|
+
return 'hosting.adopt.connectorApp is the uuid of an adopted app or database';
|
|
127
|
+
}
|
|
76
128
|
const invalidNames = validateEntryHostnames(hosting, names);
|
|
77
129
|
if (invalidNames)
|
|
78
130
|
return invalidNames;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { DeclaredOrg, HostingEntry } from '../org.js';
|
|
2
|
+
import type { Adoption } from '../store.js';
|
|
3
|
+
/**
|
|
4
|
+
* Adoption (#311): foundry takes over a hand-made Coolify app or database
|
|
5
|
+
* where it is. Nothing in this file holds an env value: an env var is named,
|
|
6
|
+
* never quoted, in a detail line, an error or a blocked item (rule 6).
|
|
7
|
+
*/
|
|
8
|
+
interface AdoptedBase {
|
|
9
|
+
/** The `foundry_adoptions.resource_key`, and the `coolify:adopt` step's key. */
|
|
10
|
+
resourceKey: string;
|
|
11
|
+
uuid: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* One app (with the entry that adopts it) or database an org declares by uuid,
|
|
15
|
+
* or (#312) its Cloudflare tunnel (`uuid` is the tunnel id) or the Coolify app
|
|
16
|
+
* that runs `cloudflared` for it.
|
|
17
|
+
*/
|
|
18
|
+
export type AdoptedResource = (AdoptedBase & {
|
|
19
|
+
kind: 'database';
|
|
20
|
+
}) | (AdoptedBase & {
|
|
21
|
+
kind: 'app';
|
|
22
|
+
entry: HostingEntry;
|
|
23
|
+
}) | (AdoptedBase & {
|
|
24
|
+
kind: 'tunnel';
|
|
25
|
+
}) | (AdoptedBase & {
|
|
26
|
+
kind: 'connector';
|
|
27
|
+
});
|
|
28
|
+
/** The `foundry_adoptions.resource_key` of the org's adopted tunnel: also its `serviceIds` key (#312). */
|
|
29
|
+
export declare const adoptedTunnelKey: (org: DeclaredOrg) => string;
|
|
30
|
+
/** The `foundry_adoptions.resource_key` of the org's adopted connector app (#312). */
|
|
31
|
+
export declare const adoptedConnectorKey: (org: DeclaredOrg) => string;
|
|
32
|
+
/** Every resource the org adopts, the database first, then the apps, the tunnel and its connector. */
|
|
33
|
+
export declare function adoptedResources(org: DeclaredOrg): AdoptedResource[];
|
|
34
|
+
/**
|
|
35
|
+
* Every uuid the org declares as adopted, whether or not hosting is enabled
|
|
36
|
+
* now: a uuid in this set is never stopped or deleted by foundry (#311).
|
|
37
|
+
*/
|
|
38
|
+
export declare function declaredAdoptUuids(org: DeclaredOrg): Set<string>;
|
|
39
|
+
/**
|
|
40
|
+
* A snapshot is ACTIVE when its row is `adopted` or `confirmed` and the uuid
|
|
41
|
+
* it recorded is the uuid the org declares now (#311). Only then may foundry
|
|
42
|
+
* write to the live resource.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isActiveSnapshot(row: Adoption | null | undefined, uuid: string): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* A row in `adopted`, `confirmed` or `restored` protects its key: foundry never
|
|
47
|
+
* manages, stops or deletes that resource as its own. Only `released` lets go (#311).
|
|
48
|
+
*/
|
|
49
|
+
export declare function protectsKey(row: Adoption | null | undefined): boolean;
|
|
50
|
+
/** Where the secret part of a snapshot lives in the `AdoptionVault`. */
|
|
51
|
+
export declare function adoptionVaultRef(org: DeclaredOrg, resource: AdoptedResource): string;
|
|
52
|
+
export type LiveApp = Record<string, unknown>;
|
|
53
|
+
export declare function snapshotAppSettings(live: LiveApp): Record<string, unknown>;
|
|
54
|
+
export declare const bareName: (name: string) => string;
|
|
55
|
+
export declare const liveNames: (live: LiveApp) => string[];
|
|
56
|
+
/**
|
|
57
|
+
* What an adopted entry changes on its live app: only a setting it declares
|
|
58
|
+
* that differs from live. The entry cannot tell a declared `branch`,
|
|
59
|
+
* `buildPack` or `autoDeploy` from its default, so those stay as they are; a
|
|
60
|
+
* `null` folder or port and an empty name list declare nothing. Never
|
|
61
|
+
* `custom_network_aliases`.
|
|
62
|
+
*/
|
|
63
|
+
export declare function adoptedAppPatch(entry: HostingEntry, names: readonly string[], live: LiveApp): Record<string, unknown>;
|
|
64
|
+
/** One line per setting the patch changes, old value beside new. */
|
|
65
|
+
export declare function adoptedPatchDetails(patch: Record<string, unknown>, live: LiveApp): string[];
|
|
66
|
+
/**
|
|
67
|
+
* The PATCH body that puts an app's snapshot settings back. A field is sent
|
|
68
|
+
* only when the entry's declaration would have changed it from the snapshot
|
|
69
|
+
* (`adoptedAppPatch`), so a setting foundry never touched, or a person edited
|
|
70
|
+
* later, stays. With no entry, a field is sent when `live` differs from the
|
|
71
|
+
* snapshot. Coolify reads `fqdn` but takes `domains`. A field the snapshot had
|
|
72
|
+
* as null is cleared the way the plain app path clears it: a folder back to
|
|
73
|
+
* `/`, domains to the empty list. A port is never cleared, as on that path.
|
|
74
|
+
*/
|
|
75
|
+
export declare function restoreAppPatch(entry: HostingEntry | undefined, names: readonly string[], settings: Record<string, unknown>, live: LiveApp): Record<string, unknown>;
|
|
76
|
+
/** One route of a tunnel's ingress list. Other fields (`originRequest`, `path`) ride along untouched. */
|
|
77
|
+
export interface IngressRoute {
|
|
78
|
+
hostname?: string;
|
|
79
|
+
service: string;
|
|
80
|
+
[field: string]: unknown;
|
|
81
|
+
}
|
|
82
|
+
/** A hostname as compared: lower case, no trailing dot (DNS names ignore both). */
|
|
83
|
+
export declare const normalizeHostname: (hostname: string) => string;
|
|
84
|
+
/**
|
|
85
|
+
* What the merge leaves alone: every live route that is not for a declared
|
|
86
|
+
* name and not foundry's own route for a name it dropped. Whatever its shape:
|
|
87
|
+
* a hostname, only a `path`, or neither.
|
|
88
|
+
*/
|
|
89
|
+
export declare function foreignRoutes(live: readonly IngressRoute[], declared: readonly string[], service: string, written: ReadonlySet<string>): IngressRoute[];
|
|
90
|
+
/** The declared names whose live routes the merge replaces: there are some, and they are not already foundry's one route. */
|
|
91
|
+
export declare function replacedNames(live: readonly IngressRoute[], declared: readonly string[], service: string): string[];
|
|
92
|
+
/** The names foundry removes from `live`: written, no longer declared, still its own route. */
|
|
93
|
+
export declare const removedNames: (live: readonly IngressRoute[], declared: readonly string[], service: string, written: ReadonlySet<string>) => string[];
|
|
94
|
+
/**
|
|
95
|
+
* The ingress list an adopted tunnel gets (#312): the declared exact names,
|
|
96
|
+
* then every foreign live route in its live order and unchanged, with the
|
|
97
|
+
* snapshot's routes for a name foundry dropped put back among them, then the
|
|
98
|
+
* declared wildcards, then the live catch-all as it is. Only a live list that
|
|
99
|
+
* does not end in a catch-all gets `http_status:404`.
|
|
100
|
+
*/
|
|
101
|
+
export declare function mergeIngress(live: readonly IngressRoute[], declared: readonly string[], service: string, written: ReadonlySet<string>, snapshot: readonly IngressRoute[]): IngressRoute[];
|
|
102
|
+
/**
|
|
103
|
+
* The ingress list restore writes (#312): the live list without foundry's own
|
|
104
|
+
* route for a name it wrote (`written` accumulates for the life of the
|
|
105
|
+
* snapshot), plus the snapshot's routes for that name. A name with no live
|
|
106
|
+
* route that is exactly foundry's is left alone, as is a route a person added
|
|
107
|
+
* or edited, and the live catch-all. The `http_status:404` foundry appended is
|
|
108
|
+
* removed only while it is still last and the snapshot had no catch-all; an
|
|
109
|
+
* empty snapshot keeps the live catch-all, or gets one.
|
|
110
|
+
*/
|
|
111
|
+
export declare function restoreIngress(live: readonly IngressRoute[], snapshot: readonly IngressRoute[], service: string, written: ReadonlySet<string>): IngressRoute[];
|
|
112
|
+
export {};
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { appStepKey } from './platform-diff.js';
|
|
2
|
+
/** The `foundry_adoptions.resource_key` of the org's adopted tunnel: also its `serviceIds` key (#312). */
|
|
3
|
+
export const adoptedTunnelKey = (org) => `cloudflare:tunnel:${org.id}`;
|
|
4
|
+
/** The `foundry_adoptions.resource_key` of the org's adopted connector app (#312). */
|
|
5
|
+
export const adoptedConnectorKey = (org) => `cloudflare:connector:${org.id}`;
|
|
6
|
+
/** Every resource the org adopts, the database first, then the apps, the tunnel and its connector. */
|
|
7
|
+
export function adoptedResources(org) {
|
|
8
|
+
if (!org.hosting.enabled)
|
|
9
|
+
return [];
|
|
10
|
+
const database = org.hosting.adopt?.database;
|
|
11
|
+
const resources = [];
|
|
12
|
+
if (database) {
|
|
13
|
+
resources.push({ resourceKey: `coolify:database:${org.id}`, kind: 'database', uuid: database });
|
|
14
|
+
}
|
|
15
|
+
for (const entry of org.hosting.entries) {
|
|
16
|
+
if (!entry.adopt)
|
|
17
|
+
continue;
|
|
18
|
+
resources.push({
|
|
19
|
+
resourceKey: `coolify:app:${appStepKey(org, entry.key)}`,
|
|
20
|
+
kind: 'app',
|
|
21
|
+
uuid: entry.adopt.app,
|
|
22
|
+
entry,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
const { tunnel, connectorApp } = org.hosting.adopt ?? {};
|
|
26
|
+
if (tunnel)
|
|
27
|
+
resources.push({ resourceKey: adoptedTunnelKey(org), kind: 'tunnel', uuid: tunnel });
|
|
28
|
+
if (connectorApp) {
|
|
29
|
+
resources.push({
|
|
30
|
+
resourceKey: adoptedConnectorKey(org),
|
|
31
|
+
kind: 'connector',
|
|
32
|
+
uuid: connectorApp,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return resources;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Every uuid the org declares as adopted, whether or not hosting is enabled
|
|
39
|
+
* now: a uuid in this set is never stopped or deleted by foundry (#311).
|
|
40
|
+
*/
|
|
41
|
+
export function declaredAdoptUuids(org) {
|
|
42
|
+
const uuids = new Set();
|
|
43
|
+
if (org.hosting.adopt?.database)
|
|
44
|
+
uuids.add(org.hosting.adopt.database);
|
|
45
|
+
if (org.hosting.adopt?.tunnel)
|
|
46
|
+
uuids.add(org.hosting.adopt.tunnel);
|
|
47
|
+
if (org.hosting.adopt?.connectorApp)
|
|
48
|
+
uuids.add(org.hosting.adopt.connectorApp);
|
|
49
|
+
for (const entry of org.hosting.entries)
|
|
50
|
+
if (entry.adopt)
|
|
51
|
+
uuids.add(entry.adopt.app);
|
|
52
|
+
return uuids;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A snapshot is ACTIVE when its row is `adopted` or `confirmed` and the uuid
|
|
56
|
+
* it recorded is the uuid the org declares now (#311). Only then may foundry
|
|
57
|
+
* write to the live resource.
|
|
58
|
+
*/
|
|
59
|
+
export function isActiveSnapshot(row, uuid) {
|
|
60
|
+
return (row !== null &&
|
|
61
|
+
row !== undefined &&
|
|
62
|
+
(row.state === 'adopted' || row.state === 'confirmed') &&
|
|
63
|
+
row.settings.uuid === uuid);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A row in `adopted`, `confirmed` or `restored` protects its key: foundry never
|
|
67
|
+
* manages, stops or deletes that resource as its own. Only `released` lets go (#311).
|
|
68
|
+
*/
|
|
69
|
+
export function protectsKey(row) {
|
|
70
|
+
return row?.state === 'adopted' || row?.state === 'confirmed' || row?.state === 'restored';
|
|
71
|
+
}
|
|
72
|
+
/** Where the secret part of a snapshot lives in the `AdoptionVault`. */
|
|
73
|
+
export function adoptionVaultRef(org, resource) {
|
|
74
|
+
return `foundry:adopt:${org.id}:${resource.kind === 'app' ? `app:${resource.entry.key}` : resource.kind}`;
|
|
75
|
+
}
|
|
76
|
+
/** Coolify's field names for the non-secret settings an app snapshot keeps. */
|
|
77
|
+
const APP_SETTING_FIELDS = [
|
|
78
|
+
'fqdn',
|
|
79
|
+
'git_branch',
|
|
80
|
+
'base_directory',
|
|
81
|
+
'publish_directory',
|
|
82
|
+
'build_pack',
|
|
83
|
+
'ports_exposes',
|
|
84
|
+
'health_check_enabled',
|
|
85
|
+
'health_check_path',
|
|
86
|
+
'health_check_port',
|
|
87
|
+
'health_check_method',
|
|
88
|
+
'health_check_return_code',
|
|
89
|
+
'health_check_scheme',
|
|
90
|
+
'health_check_interval',
|
|
91
|
+
'health_check_timeout',
|
|
92
|
+
'health_check_retries',
|
|
93
|
+
'health_check_start_period',
|
|
94
|
+
'custom_network_aliases',
|
|
95
|
+
'is_auto_deploy_enabled',
|
|
96
|
+
'limits_memory',
|
|
97
|
+
'limits_memory_swap',
|
|
98
|
+
'limits_memory_swappiness',
|
|
99
|
+
'limits_memory_reservation',
|
|
100
|
+
'limits_cpus',
|
|
101
|
+
'limits_cpuset',
|
|
102
|
+
'limits_cpu_shares',
|
|
103
|
+
];
|
|
104
|
+
export function snapshotAppSettings(live) {
|
|
105
|
+
return Object.fromEntries(APP_SETTING_FIELDS.map((field) => [field, live[field] ?? null]));
|
|
106
|
+
}
|
|
107
|
+
export const bareName = (name) => name.trim().replace(/^https?:\/\//, '');
|
|
108
|
+
export const liveNames = (live) => typeof live.fqdn === 'string' ? live.fqdn.split(',').filter(Boolean).map(bareName) : [];
|
|
109
|
+
/**
|
|
110
|
+
* What an adopted entry changes on its live app: only a setting it declares
|
|
111
|
+
* that differs from live. The entry cannot tell a declared `branch`,
|
|
112
|
+
* `buildPack` or `autoDeploy` from its default, so those stay as they are; a
|
|
113
|
+
* `null` folder or port and an empty name list declare nothing. Never
|
|
114
|
+
* `custom_network_aliases`.
|
|
115
|
+
*/
|
|
116
|
+
export function adoptedAppPatch(entry, names, live) {
|
|
117
|
+
const patch = {};
|
|
118
|
+
const folderField = entry.buildPack === 'static' ? 'publish_directory' : 'base_directory';
|
|
119
|
+
if (entry.folder !== null && (live[folderField] ?? '/') !== `/${entry.folder}`) {
|
|
120
|
+
patch[folderField] = `/${entry.folder}`;
|
|
121
|
+
}
|
|
122
|
+
if (entry.port !== null &&
|
|
123
|
+
entry.buildPack !== 'static' &&
|
|
124
|
+
`${live.ports_exposes}` !== `${entry.port}`) {
|
|
125
|
+
patch.ports_exposes = String(entry.port);
|
|
126
|
+
}
|
|
127
|
+
if (names.length > 0 && [...names].sort().join() !== liveNames(live).sort().join()) {
|
|
128
|
+
patch.domains = names.join(',');
|
|
129
|
+
}
|
|
130
|
+
return patch;
|
|
131
|
+
}
|
|
132
|
+
/** One line per setting the patch changes, old value beside new. */
|
|
133
|
+
export function adoptedPatchDetails(patch, live) {
|
|
134
|
+
return Object.entries(patch).map(([field, value]) => {
|
|
135
|
+
const old = field === 'domains' ? live.fqdn : live[field];
|
|
136
|
+
return `${field}: ${old ?? 'unset'} -> ${value}`;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The PATCH body that puts an app's snapshot settings back. A field is sent
|
|
141
|
+
* only when the entry's declaration would have changed it from the snapshot
|
|
142
|
+
* (`adoptedAppPatch`), so a setting foundry never touched, or a person edited
|
|
143
|
+
* later, stays. With no entry, a field is sent when `live` differs from the
|
|
144
|
+
* snapshot. Coolify reads `fqdn` but takes `domains`. A field the snapshot had
|
|
145
|
+
* as null is cleared the way the plain app path clears it: a folder back to
|
|
146
|
+
* `/`, domains to the empty list. A port is never cleared, as on that path.
|
|
147
|
+
*/
|
|
148
|
+
export function restoreAppPatch(entry, names, settings, live) {
|
|
149
|
+
const isStatic = (entry?.buildPack ?? settings.build_pack) === 'static';
|
|
150
|
+
const folderField = isStatic ? 'publish_directory' : 'base_directory';
|
|
151
|
+
const changed = new Set(Object.keys(entry ? adoptedAppPatch(entry, names, settings) : {}));
|
|
152
|
+
if (!entry) {
|
|
153
|
+
if ((live[folderField] ?? '/') !== (settings[folderField] ?? '/'))
|
|
154
|
+
changed.add(folderField);
|
|
155
|
+
if (`${live.ports_exposes}` !== `${settings.ports_exposes}`)
|
|
156
|
+
changed.add('ports_exposes');
|
|
157
|
+
if (liveNames(settings).sort().join() !== liveNames(live).sort().join())
|
|
158
|
+
changed.add('domains');
|
|
159
|
+
}
|
|
160
|
+
const patch = {};
|
|
161
|
+
if (changed.has(folderField))
|
|
162
|
+
patch[folderField] = settings[folderField] ?? '/';
|
|
163
|
+
if (changed.has('domains'))
|
|
164
|
+
patch.domains = settings.fqdn ?? '';
|
|
165
|
+
if (!isStatic && settings.ports_exposes != null && changed.has('ports_exposes')) {
|
|
166
|
+
patch.ports_exposes = settings.ports_exposes;
|
|
167
|
+
}
|
|
168
|
+
return patch;
|
|
169
|
+
}
|
|
170
|
+
const isWildcard = (route) => route.hostname?.startsWith('*') ?? false;
|
|
171
|
+
const isCatchAll = (route) => route !== undefined && route.hostname === undefined && route.path === undefined;
|
|
172
|
+
/** A hostname as compared: lower case, no trailing dot (DNS names ignore both). */
|
|
173
|
+
export const normalizeHostname = (hostname) => hostname.toLowerCase().replace(/\.$/, '');
|
|
174
|
+
const sameName = (a, b) => a !== undefined && normalizeHostname(a) === normalizeHostname(b);
|
|
175
|
+
const hasName = (names, hostname) => hostname !== undefined && [...names].some((name) => sameName(hostname, name));
|
|
176
|
+
/** The route foundry writes for a name: nothing but the hostname and the service. */
|
|
177
|
+
const isFoundryRoute = (route, service) => route.service === service && Object.keys(route).length === 2;
|
|
178
|
+
/** Puts `back` before the first wildcard or hostname-less route of `rest`, so a person's order stays. */
|
|
179
|
+
function putBack(rest, back) {
|
|
180
|
+
const at = rest.findIndex((route) => route.hostname === undefined || isWildcard(route));
|
|
181
|
+
return at < 0 ? [...rest, ...back] : [...rest.slice(0, at), ...back, ...rest.slice(at)];
|
|
182
|
+
}
|
|
183
|
+
/** A live route foundry wrote for a name it dropped: written, no longer declared, exactly foundry's own. */
|
|
184
|
+
const isDroppedRoute = (route, declared, service, written) => hasName(written, route.hostname) &&
|
|
185
|
+
!hasName(declared, route.hostname) &&
|
|
186
|
+
isFoundryRoute(route, service);
|
|
187
|
+
/**
|
|
188
|
+
* What the merge leaves alone: every live route that is not for a declared
|
|
189
|
+
* name and not foundry's own route for a name it dropped. Whatever its shape:
|
|
190
|
+
* a hostname, only a `path`, or neither.
|
|
191
|
+
*/
|
|
192
|
+
export function foreignRoutes(live, declared, service, written) {
|
|
193
|
+
return live.filter((route) => !hasName(declared, route.hostname) && !isDroppedRoute(route, declared, service, written));
|
|
194
|
+
}
|
|
195
|
+
/** The declared names whose live routes the merge replaces: there are some, and they are not already foundry's one route. */
|
|
196
|
+
export function replacedNames(live, declared, service) {
|
|
197
|
+
return declared.filter((hostname) => {
|
|
198
|
+
const same = live.filter((route) => sameName(route.hostname, hostname));
|
|
199
|
+
const [only] = same;
|
|
200
|
+
return only !== undefined && !(same.length === 1 && isFoundryRoute(only, service));
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
/** The names foundry removes from `live`: written, no longer declared, still its own route. */
|
|
204
|
+
export const removedNames = (live, declared, service, written) => [
|
|
205
|
+
...new Set(live
|
|
206
|
+
.filter((route) => isDroppedRoute(route, declared, service, written))
|
|
207
|
+
.map((route) => route.hostname)),
|
|
208
|
+
];
|
|
209
|
+
/**
|
|
210
|
+
* The ingress list an adopted tunnel gets (#312): the declared exact names,
|
|
211
|
+
* then every foreign live route in its live order and unchanged, with the
|
|
212
|
+
* snapshot's routes for a name foundry dropped put back among them, then the
|
|
213
|
+
* declared wildcards, then the live catch-all as it is. Only a live list that
|
|
214
|
+
* does not end in a catch-all gets `http_status:404`.
|
|
215
|
+
*/
|
|
216
|
+
export function mergeIngress(live, declared, service, written, snapshot) {
|
|
217
|
+
const dropped = removedNames(live, declared, service, written);
|
|
218
|
+
const tail = live.at(-1);
|
|
219
|
+
const foreign = foreignRoutes(live, declared, service, written);
|
|
220
|
+
const catchAll = isCatchAll(tail) ? tail : { service: 'http_status:404' };
|
|
221
|
+
const middle = isCatchAll(tail) ? foreign.slice(0, -1) : foreign;
|
|
222
|
+
const back = snapshot.filter((route) => hasName(dropped, route.hostname));
|
|
223
|
+
const route = (hostname) => ({ hostname, service });
|
|
224
|
+
return [
|
|
225
|
+
...declared.filter((hostname) => !hostname.startsWith('*')).map(route),
|
|
226
|
+
...putBack(middle, back),
|
|
227
|
+
...declared.filter((hostname) => hostname.startsWith('*')).map(route),
|
|
228
|
+
catchAll,
|
|
229
|
+
];
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The ingress list restore writes (#312): the live list without foundry's own
|
|
233
|
+
* route for a name it wrote (`written` accumulates for the life of the
|
|
234
|
+
* snapshot), plus the snapshot's routes for that name. A name with no live
|
|
235
|
+
* route that is exactly foundry's is left alone, as is a route a person added
|
|
236
|
+
* or edited, and the live catch-all. The `http_status:404` foundry appended is
|
|
237
|
+
* removed only while it is still last and the snapshot had no catch-all; an
|
|
238
|
+
* empty snapshot keeps the live catch-all, or gets one.
|
|
239
|
+
*/
|
|
240
|
+
export function restoreIngress(live, snapshot, service, written) {
|
|
241
|
+
const isOurs = (route) => hasName(written, route.hostname) && isFoundryRoute(route, service);
|
|
242
|
+
const swapped = (route) => hasName(live.filter(isOurs).map((r) => r.hostname), route.hostname);
|
|
243
|
+
const appended = snapshot.length > 0 &&
|
|
244
|
+
!isCatchAll(snapshot.at(-1)) &&
|
|
245
|
+
isCatchAll(live.at(-1)) &&
|
|
246
|
+
live.at(-1)?.service === 'http_status:404';
|
|
247
|
+
const rest = live.filter((route, index) => !isOurs(route) && !(appended && index === live.length - 1));
|
|
248
|
+
const restored = putBack(rest, snapshot.filter(swapped));
|
|
249
|
+
return snapshot.length === 0 && !isCatchAll(restored.at(-1))
|
|
250
|
+
? [...restored, { service: 'http_status:404' }]
|
|
251
|
+
: restored;
|
|
252
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an adoption keeps the secret part of its snapshot (#311): the live
|
|
3
|
+
* app's env values. The row in `foundry_adoptions` holds only the ref. Same
|
|
4
|
+
* shape as the `heldKeys` port (`../bootstrap/bootstrap.ts`).
|
|
5
|
+
*/
|
|
6
|
+
export interface AdoptionVault {
|
|
7
|
+
put(ref: string, value: string): Promise<void>;
|
|
8
|
+
resolve(ref: string): Promise<string | null>;
|
|
9
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -20,7 +20,7 @@ export declare function brokerEntryAppliedScopeKey(entryKey: string): string;
|
|
|
20
20
|
/** The foreign entries the last write found invalid, as a JSON array of names. */
|
|
21
21
|
export declare function brokerEntryWarningsScopeKey(entryKey: string): string;
|
|
22
22
|
/** What the app's env says about who signs in, as recorded so far. Missing values are left out. */
|
|
23
|
-
export declare function entryAuthEnv(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>, issuer: string | null): Record<string, string>;
|
|
23
|
+
export declare function entryAuthEnv(org: DeclaredOrg, entry: Pick<HostingEntry, 'key' | 'uses'>, recorded: Readonly<Record<string, string>>, issuer: string | null): Record<string, string>;
|
|
24
24
|
/** The marker `pushAppEnv` records for an entry's sign-in env; the planner compares against it. */
|
|
25
25
|
export declare function entryAuthEnvAppliedScopeKey(entryKey: string): string;
|
|
26
26
|
/** What a broker entry must say, without its key. */
|
|
@@ -74,9 +74,13 @@ export declare class PlatformBrokerPort implements BrokerPort {
|
|
|
74
74
|
});
|
|
75
75
|
appId(): Promise<string>;
|
|
76
76
|
listEnvs(): Promise<{
|
|
77
|
+
uuid?: string;
|
|
77
78
|
key: string;
|
|
78
79
|
value: string;
|
|
79
80
|
is_preview: boolean;
|
|
81
|
+
is_buildtime?: boolean;
|
|
82
|
+
is_literal?: boolean;
|
|
83
|
+
is_multiline?: boolean;
|
|
80
84
|
}[]>;
|
|
81
85
|
setAuthApps(raw: string): Promise<void>;
|
|
82
86
|
deploy(): Promise<void>;
|
|
@@ -29,12 +29,20 @@ export interface EnsureCloudflaredConnectorInput {
|
|
|
29
29
|
tunnelId: string;
|
|
30
30
|
/** Fixed name, found before created -- so a second apply reuses this app rather than making a second one. */
|
|
31
31
|
appName: string;
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
/** Needed to find the app by name or create it; an `adopted` connector needs neither. */
|
|
33
|
+
projectUuid?: string;
|
|
34
|
+
environment?: {
|
|
34
35
|
id: number;
|
|
35
36
|
name: string;
|
|
36
37
|
uuid: string;
|
|
37
38
|
};
|
|
39
|
+
/**
|
|
40
|
+
* #312: `recordedConnectorAppId` is a live app foundry adopted. It is never
|
|
41
|
+
* looked up by name or created; one that cannot be found is an error.
|
|
42
|
+
*/
|
|
43
|
+
adopted?: boolean;
|
|
44
|
+
/** #312: called with the token just before `TUNNEL_TOKEN` is rewritten, so a later restore knows foundry wrote it. */
|
|
45
|
+
beforeTokenWrite?: (token: string) => Promise<void>;
|
|
38
46
|
/** Called only when the app must be created -- never on the found-by-name or found-by-id paths. */
|
|
39
47
|
resolveServer: () => Promise<{
|
|
40
48
|
uuid: string;
|