@wtfalch/foundry 0.65.0 → 0.67.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 +5 -0
- package/dist/apps/deploy.js +33 -1
- package/dist/apps/migrations.d.ts +3 -0
- package/dist/apps/migrations.js +32 -7
- package/dist/catalogue/copy-en.js +5 -0
- package/dist/catalogue/integrations.js +5 -0
- package/dist/entry-rules.d.ts +11 -0
- package/dist/entry-rules.js +70 -0
- package/dist/index.d.ts +9 -4
- package/dist/index.js +5 -2
- package/dist/migrations/0069_release_events.sql +16 -0
- package/dist/modules/analytics.d.ts +8 -0
- package/dist/modules/analytics.js +28 -0
- package/dist/modules/database.js +22 -3
- package/dist/modules/index.js +2 -0
- package/dist/native-build.d.ts +16 -12
- package/dist/native-build.js +16 -12
- package/dist/org.d.ts +9 -1
- package/dist/org.js +4 -1
- package/dist/plan/analytics-boule.d.ts +17 -0
- package/dist/plan/analytics-boule.js +43 -0
- package/dist/plan/analytics.d.ts +58 -0
- package/dist/plan/analytics.js +89 -0
- package/dist/plan/boule.js +2 -0
- package/dist/plan/company-backups.js +2 -0
- package/dist/plan/diff.d.ts +4 -0
- package/dist/plan/diff.js +103 -3
- package/dist/plan/entry-analytics-key.d.ts +35 -0
- package/dist/plan/entry-analytics-key.js +134 -0
- package/dist/plan/executor.d.ts +79 -6
- package/dist/plan/executor.js +379 -19
- package/dist/plan/hostnames.js +4 -0
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/live.d.ts +1 -1
- package/dist/plan/locked-hostnames.js +3 -0
- package/dist/plan/login-host.d.ts +8 -0
- package/dist/plan/login-host.js +8 -0
- package/dist/plan/org-names.d.ts +3 -3
- package/dist/plan/org-names.js +3 -0
- package/dist/plan/own-limits.d.ts +6 -0
- package/dist/plan/own-limits.js +1 -0
- package/dist/plan/plan-apply.d.ts +12 -0
- package/dist/plan/plan-apply.js +40 -1
- package/dist/plan/platform-diff.d.ts +17 -1
- package/dist/plan/platform-diff.js +40 -2
- package/dist/plan/platform-org.d.ts +6 -0
- package/dist/plan/platform-providers.d.ts +8 -6
- package/dist/plan/platform-providers.js +56 -10
- package/dist/plan/providers-readers.js +16 -1
- package/dist/plan/release-host.d.ts +30 -1
- package/dist/plan/release-host.js +84 -41
- package/dist/plan/release-series-reader.d.ts +26 -0
- package/dist/plan/release-series-reader.js +40 -0
- package/dist/plan/sign-in-archon.d.ts +1 -1
- package/dist/plan/sign-in-archon.js +2 -2
- package/dist/plan/stage-services.d.ts +3 -1
- package/dist/plan/stage-services.js +2 -1
- package/dist/plan/uptime-worker.js +53 -3
- package/dist/plan/worker-app-database.d.ts +12 -1
- package/dist/plan/worker-app-database.js +13 -0
- package/dist/plan/worker-app.d.ts +27 -2
- package/dist/plan/worker-app.js +52 -7
- package/dist/plan/workspace.d.ts +2 -2
- package/dist/plan/workspace.js +3 -3
- package/dist/release/decide.d.ts +2 -0
- package/dist/release/decide.js +67 -10
- package/dist/release/events.d.ts +13 -0
- package/dist/release/events.js +67 -0
- package/dist/release/health.d.ts +5 -0
- package/dist/release/health.js +3 -3
- package/dist/release/run.d.ts +7 -2
- package/dist/release/run.js +8 -0
- package/dist/release/series.d.ts +58 -0
- package/dist/release/series.js +65 -0
- package/dist/release/state.d.ts +58 -1
- package/dist/release/state.js +45 -0
- package/dist/release/types.d.ts +44 -0
- package/dist/slug.d.ts +1 -1
- package/dist/slug.js +5 -2
- package/dist/store/postgres.js +29 -3
- package/dist/store/schema.d.ts +112 -0
- package/dist/store/schema.js +9 -0
- package/dist/worker-app-config.d.ts +22 -1
- package/dist/worker-app-config.js +73 -15
- package/dist/worker-app-migrations.d.ts +6 -2
- package/dist/worker-app-migrations.js +1 -0
- package/package.json +1 -1
package/dist/org.d.ts
CHANGED
|
@@ -301,7 +301,7 @@ export type AccessRule = {
|
|
|
301
301
|
/** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
|
|
302
302
|
export declare const APP_ENTRY_KEY = "app";
|
|
303
303
|
/** Why a validator refused. `code` is stable and never reworded; `message` is for people. */
|
|
304
|
-
export type OrgRefusalCode = 'hosting_tunnel_needs_hosting' | 'issuer_app_name_missing' | 'issuer_app_name_not_unique' | 'issuer_app_kind_unknown' | 'issuer_app_name_reserved_agora' | 'issuer_app_name_reserved_boule' | 'issuer_app_name_reserved_kosmos' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'entry_image_invalid' | 'ai_webhook_path_not_unique' | 'worker_route_not_unique' | 'database_not_unique' | 'access_hostname_not_unique' | 'access_hostname_invalid' | 'access_needs_hosting' | 'access_kind_unknown' | 'access_service_token_name_missing' | 'access_hostname_locked' | 'access_hostname_not_served' | 'gate_limit_invalid' | 'hosting_repo_invalid' | 'hosting_github_invalid' | 'gate_needs_domain' | 'gate_key_invalid' | 'gate_key_not_unique' | 'gate_needs_hostname' | 'gate_hostname_not_entry_hostname' | 'gate_hostname_held_twice' | 'gate_hostname_locked' | 'gate_limit_exceeded' | 'wildcard_needs_domain' | 'wildcard_held_twice' | 'hostnames_need_domain' | 'hostname_needed' | 'hostname_invalid' | 'hostname_not_unique' | 'hostname_locked' | 'hosted_name_invalid' | 'hosted_name_not_unique' | 'hosted_name_worker_app' | 'entry_workspace_invalid' | 'entry_workspace_unsupported' | 'entry_live_needs_workspace' | 'entry_live_commit_invalid' | 'gate_key_taken_by_workspace' | 'gate_key_taken_by_private_page' | 'private_page_invalid' | 'private_page_path_invalid' | 'private_page_duplicate' | 'private_page_limit' | 'private_page_needs_hosting' | 'private_page_roles_invalid' | 'private_page_people_invalid' | 'entry_key_invalid' | 'entry_key_reserved' | 'entry_repo_invalid' | 'entry_folder_invalid' | 'entry_branch_empty' | 'entry_build_pack_unknown' | 'entry_build_pack_unavailable' | 'entry_port_invalid' | 'entry_health_path_invalid' | 'entry_capabilities_invalid' | 'entry_database_needs_hosting' | 'blue_green_needs_hostname' | 'env_name_empty' | 'env_name_reserved' | 'env_source_platform_mail_key' | 'env_source_unknown' | 'env_shared_secret_name_invalid' | 'env_held_secret_name_mismatch' | 'env_held_secret_name_invalid' | 'env_looks_like_secret' | 'builtin_app_invalid' | 'blue_green_not_object' | 'blue_green_steps_length' | 'blue_green_steps_range' | 'blue_green_steps_order' | 'blue_green_steps_end' | 'blue_green_hold_invalid' | 'blue_green_advance_invalid' | 'blue_green_min_requests_invalid' | 'blue_green_error_margin_invalid' | 'blue_green_staff_first_invalid' | 'volumes_not_list' | 'volumes_too_many' | 'volume_name_invalid' | 'volume_name_logs' | 'volume_name_not_unique' | 'volume_mount_path_invalid' | 'volume_mount_path_too_long' | 'volume_mount_path_log_dir' | 'volume_mount_path_forbidden' | 'volume_mount_path_not_unique' | 'auth_needs_hosting' | 'auth_users_invalid' | 'auth_registration_invalid' | 'registration_open_needs_customers' | 'auth_replaces_broker_entry_invalid' | 'auth_replaces_broker_entry_owned' | 'auth_needs_exact_hostname' | 'mcp_needs_open_customers' | 'mcp_path_invalid' | 'mcp_onboarding_path_invalid' | 'auth_sign_in_invalid' | 'email_code_needs_customers' | 'auth_room_invalid' | 'auth_room_needs_customers' | 'auth_room_not_unique' | 'client_name_taken' | 'workspaces_needs_customers' | 'workspaces_needs_server' | 'workspaces_roles_invalid' | 'workspaces_role_reserved' | 'workspaces_invite_path_invalid' | 'workspaces_env_clash' | 'ai_needs_hosting' | 'ai_offerings_invalid' | 'ai_permissions_invalid' | 'ai_webhook_path_invalid' | 'ai_webhook_needs_exact_hostname' | 'ai_env_clash' | 'files_needs_hosting' | 'files_collections_invalid' | 'files_permissions_invalid' | 'files_share_host_invalid' | 'files_share_host_needs_domain' | 'files_env_clash' | 'worker_needs_hosting' | 'worker_needs_repo' | 'worker_needs_own_repo' | 'worker_key_too_long' | 'worker_artifact_invalid' | 'worker_route_invalid' | 'worker_route_off_domain' | 'worker_rate_limit_name_missing' | 'worker_rate_limit_namespace_missing' | 'worker_rate_limit_invalid' | 'worker_rate_limit_period_invalid' | 'worker_app_invalid' | 'edge_not_true' | 'edge_needs_hosting' | 'edge_needs_domain' | 'edge_env_clash' | 'mail_not_true' | 'mail_needs_hosting' | 'mail_env_clash' | 'jobs_not_true' | 'jobs_needs_hosting' | 'jobs_env_clash' | 'customer_grants_not_true' | 'customer_grants_needs_customers' | 'customer_grants_env_clash' | 'repo_use_invalid' | 'repo_use_needs_repo' | 'repo_use_env_clash' | 'audit_not_true' | 'audit_needs_hosting' | 'audit_needs_worker_database' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
|
|
304
|
+
export type OrgRefusalCode = 'hosting_tunnel_needs_hosting' | 'issuer_app_name_missing' | 'issuer_app_name_not_unique' | 'issuer_app_kind_unknown' | 'issuer_app_name_reserved_agora' | 'issuer_app_name_reserved_boule' | 'issuer_app_name_reserved_kosmos' | 'hosting_needs_entry' | 'hosting_main_count' | 'entry_key_not_unique' | 'entry_image_invalid' | 'ai_webhook_path_not_unique' | 'worker_route_not_unique' | 'database_not_unique' | 'access_hostname_not_unique' | 'access_hostname_invalid' | 'access_needs_hosting' | 'access_kind_unknown' | 'access_service_token_name_missing' | 'access_hostname_locked' | 'access_hostname_not_served' | 'gate_limit_invalid' | 'hosting_repo_invalid' | 'hosting_github_invalid' | 'gate_needs_domain' | 'gate_key_invalid' | 'gate_key_not_unique' | 'gate_needs_hostname' | 'gate_hostname_not_entry_hostname' | 'gate_hostname_held_twice' | 'gate_hostname_locked' | 'gate_limit_exceeded' | 'wildcard_needs_domain' | 'wildcard_held_twice' | 'hostnames_need_domain' | 'hostname_needed' | 'hostname_invalid' | 'hostname_not_unique' | 'hostname_locked' | 'hosted_name_invalid' | 'hosted_name_not_unique' | 'hosted_name_worker_app' | 'entry_workspace_invalid' | 'entry_workspace_unsupported' | 'entry_live_needs_workspace' | 'entry_live_commit_invalid' | 'gate_key_taken_by_workspace' | 'gate_key_taken_by_private_page' | 'private_page_invalid' | 'private_page_path_invalid' | 'private_page_duplicate' | 'private_page_limit' | 'private_page_needs_hosting' | 'private_page_roles_invalid' | 'private_page_people_invalid' | 'entry_key_invalid' | 'entry_key_reserved' | 'entry_repo_invalid' | 'entry_folder_invalid' | 'entry_branch_empty' | 'entry_build_pack_unknown' | 'entry_build_pack_unavailable' | 'entry_port_invalid' | 'entry_health_path_invalid' | 'entry_capabilities_invalid' | 'entry_database_needs_hosting' | 'blue_green_needs_hostname' | 'env_name_empty' | 'env_name_reserved' | 'env_source_platform_mail_key' | 'env_source_unknown' | 'env_shared_secret_name_invalid' | 'env_held_secret_name_mismatch' | 'env_held_secret_name_invalid' | 'env_looks_like_secret' | 'builtin_app_invalid' | 'blue_green_not_object' | 'blue_green_steps_length' | 'blue_green_steps_range' | 'blue_green_steps_order' | 'blue_green_steps_end' | 'blue_green_hold_invalid' | 'blue_green_advance_invalid' | 'blue_green_min_requests_invalid' | 'blue_green_error_margin_invalid' | 'blue_green_staff_first_invalid' | 'volumes_not_list' | 'volumes_too_many' | 'volume_name_invalid' | 'volume_name_logs' | 'volume_name_not_unique' | 'volume_mount_path_invalid' | 'volume_mount_path_too_long' | 'volume_mount_path_log_dir' | 'volume_mount_path_forbidden' | 'volume_mount_path_not_unique' | 'auth_needs_hosting' | 'auth_users_invalid' | 'auth_registration_invalid' | 'registration_open_needs_customers' | 'auth_replaces_broker_entry_invalid' | 'auth_replaces_broker_entry_owned' | 'auth_needs_exact_hostname' | 'mcp_needs_open_customers' | 'mcp_path_invalid' | 'mcp_onboarding_path_invalid' | 'auth_sign_in_invalid' | 'email_code_needs_customers' | 'auth_room_invalid' | 'auth_room_needs_customers' | 'auth_room_not_unique' | 'client_name_taken' | 'workspaces_needs_customers' | 'workspaces_needs_server' | 'workspaces_roles_invalid' | 'workspaces_role_reserved' | 'workspaces_invite_path_invalid' | 'workspaces_env_clash' | 'ai_needs_hosting' | 'ai_offerings_invalid' | 'ai_permissions_invalid' | 'ai_webhook_path_invalid' | 'ai_webhook_needs_exact_hostname' | 'ai_env_clash' | 'files_needs_hosting' | 'files_collections_invalid' | 'files_permissions_invalid' | 'files_share_host_invalid' | 'files_share_host_needs_domain' | 'files_env_clash' | 'worker_needs_hosting' | 'worker_needs_repo' | 'worker_needs_own_repo' | 'worker_key_too_long' | 'worker_artifact_invalid' | 'worker_route_invalid' | 'worker_route_off_domain' | 'worker_rate_limit_name_missing' | 'worker_rate_limit_namespace_missing' | 'worker_rate_limit_invalid' | 'worker_rate_limit_period_invalid' | 'worker_app_invalid' | 'edge_not_true' | 'edge_needs_hosting' | 'edge_needs_domain' | 'edge_env_clash' | 'mail_not_true' | 'mail_needs_hosting' | 'mail_env_clash' | 'jobs_not_true' | 'jobs_needs_hosting' | 'jobs_env_clash' | 'analytics_not_true' | 'analytics_needs_hosting' | 'analytics_env_clash' | 'customer_grants_not_true' | 'customer_grants_needs_customers' | 'customer_grants_env_clash' | 'repo_use_invalid' | 'repo_use_needs_repo' | 'repo_use_env_clash' | 'audit_not_true' | 'audit_needs_hosting' | 'audit_needs_worker_database' | 'customers_invalid' | 'customers_sign_up_needs_enabled' | 'registration_open_needs_sign_up';
|
|
305
305
|
/**
|
|
306
306
|
* One refusal: a stable `code`, the dotted `path` into {@link DeclaredOrg} it is about
|
|
307
307
|
* (`hosting.entries.terminal.uses.auth.registration`) and the sentence for people.
|
|
@@ -732,9 +732,17 @@ export interface HostingEntryUses {
|
|
|
732
732
|
repo?: {
|
|
733
733
|
access: 'read' | 'write';
|
|
734
734
|
};
|
|
735
|
+
/**
|
|
736
|
+
* The app counts its visitors in the company's own analytics backend. A server entry gets its own site key
|
|
737
|
+
* (`ANALYTICS_URL`, `ANALYTICS_SITE_KEY`; `plan/entry-analytics-key.ts`), and its stage another. A Worker
|
|
738
|
+
* app gets its keys from Boule, which installs them per copy, so foundry only plans the backend for it.
|
|
739
|
+
*/
|
|
740
|
+
analytics?: true;
|
|
735
741
|
}
|
|
736
742
|
/** The env names `uses.edge` writes; `env` may not name them (#317). */
|
|
737
743
|
export declare const EDGE_ENTRY_ENV_NAMES: readonly ["EDGE_URL", "EDGE_API_KEY"];
|
|
744
|
+
/** The env names `uses.analytics` writes on a server entry; `env` may not name them. */
|
|
745
|
+
export declare const ANALYTICS_ENTRY_ENV_NAMES: readonly ["ANALYTICS_URL", "ANALYTICS_SITE_KEY"];
|
|
738
746
|
/** The env names `uses.mail` writes; `env` may not name them. */
|
|
739
747
|
export declare const MAIL_ENTRY_ENV_NAMES: readonly ["LETTERMINT_API_TOKEN", "MAIL_FROM"];
|
|
740
748
|
/** The env names `uses.jobs` writes; `env` may not name them. */
|
package/dist/org.js
CHANGED
|
@@ -60,6 +60,8 @@ export const FILES_ENTRY_ENV_NAMES = [
|
|
|
60
60
|
export const filesCollectionEnvName = (name) => `FILES_COLLECTION_${name}_ID`;
|
|
61
61
|
/** The env names `uses.edge` writes; `env` may not name them (#317). */
|
|
62
62
|
export const EDGE_ENTRY_ENV_NAMES = ['EDGE_URL', 'EDGE_API_KEY'];
|
|
63
|
+
/** The env names `uses.analytics` writes on a server entry; `env` may not name them. */
|
|
64
|
+
export const ANALYTICS_ENTRY_ENV_NAMES = ['ANALYTICS_URL', 'ANALYTICS_SITE_KEY'];
|
|
63
65
|
/** The env names `uses.mail` writes; `env` may not name them. */
|
|
64
66
|
export const MAIL_ENTRY_ENV_NAMES = ['LETTERMINT_API_TOKEN', 'MAIL_FROM'];
|
|
65
67
|
/** The env names `uses.jobs` writes; `env` may not name them. */
|
|
@@ -1275,11 +1277,12 @@ function validateEntryCustomerGrants(entry) {
|
|
|
1275
1277
|
}
|
|
1276
1278
|
return null;
|
|
1277
1279
|
}
|
|
1278
|
-
/** `uses.mail` and `uses.
|
|
1280
|
+
/** `uses.mail`, `uses.jobs` and `uses.analytics`: `true`, `hosting.enabled`, and no `env` entry that names a value they write. */
|
|
1279
1281
|
function validateEntryMailJobs(entry, hosting) {
|
|
1280
1282
|
for (const [use, written, code] of [
|
|
1281
1283
|
['mail', MAIL_ENTRY_ENV_NAMES, 'mail'],
|
|
1282
1284
|
['jobs', JOBS_ENTRY_ENV_NAMES, 'jobs'],
|
|
1285
|
+
['analytics', ANALYTICS_ENTRY_ENV_NAMES, 'analytics'],
|
|
1283
1286
|
]) {
|
|
1284
1287
|
const value = entry.uses?.[use];
|
|
1285
1288
|
if (value === undefined)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
/**
|
|
4
|
+
* C2: while the company's Boule runs apps (`org.boule.apps`) and its analytics backend is applied, Boule's env
|
|
5
|
+
* holds `ANALYTICS_URL` and `ANALYTICS_OPERATOR_KEY`. Boule mints a site key for each app copy with the
|
|
6
|
+
* operator key and reads each release's visitors and page views with it. The key is read from the backend's own
|
|
7
|
+
* env and written to Boule's env; it is never held, recorded or planned. Without the backend Boule installs
|
|
8
|
+
* apps without analytics.
|
|
9
|
+
*
|
|
10
|
+
* One `coolify:analytics-boule` step keyed by the org id. `analytics_boule_applied` holds
|
|
11
|
+
* `<address>|<backend app id>` and is recorded last, so a new backend app (a new key) writes again. Turning
|
|
12
|
+
* apps off takes both names out of Boule's env.
|
|
13
|
+
*/
|
|
14
|
+
export declare const ANALYTICS_BOULE_APPLIED_SCOPE_KEY = "analytics_boule_applied";
|
|
15
|
+
export declare const ANALYTICS_BOULE_ENV_NAMES: readonly ["ANALYTICS_URL", "ANALYTICS_OPERATOR_KEY"];
|
|
16
|
+
export declare const analyticsBouleMarker: (org: DeclaredOrg, backendAppId: string) => string;
|
|
17
|
+
export declare function planAnalyticsBoule(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[]): DraftStep[];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { appsOn } from '../org.js';
|
|
2
|
+
import { ANALYTICS_DEPLOYED_SCOPE_KEY, analyticsUrlFor } from './analytics.js';
|
|
3
|
+
/**
|
|
4
|
+
* C2: while the company's Boule runs apps (`org.boule.apps`) and its analytics backend is applied, Boule's env
|
|
5
|
+
* holds `ANALYTICS_URL` and `ANALYTICS_OPERATOR_KEY`. Boule mints a site key for each app copy with the
|
|
6
|
+
* operator key and reads each release's visitors and page views with it. The key is read from the backend's own
|
|
7
|
+
* env and written to Boule's env; it is never held, recorded or planned. Without the backend Boule installs
|
|
8
|
+
* apps without analytics.
|
|
9
|
+
*
|
|
10
|
+
* One `coolify:analytics-boule` step keyed by the org id. `analytics_boule_applied` holds
|
|
11
|
+
* `<address>|<backend app id>` and is recorded last, so a new backend app (a new key) writes again. Turning
|
|
12
|
+
* apps off takes both names out of Boule's env.
|
|
13
|
+
*/
|
|
14
|
+
export const ANALYTICS_BOULE_APPLIED_SCOPE_KEY = 'analytics_boule_applied';
|
|
15
|
+
export const ANALYTICS_BOULE_ENV_NAMES = ['ANALYTICS_URL', 'ANALYTICS_OPERATOR_KEY'];
|
|
16
|
+
export const analyticsBouleMarker = (org, backendAppId) => `${analyticsUrlFor(org)}|${backendAppId}`;
|
|
17
|
+
export function planAnalyticsBoule(org, recordedIds, plannedSteps) {
|
|
18
|
+
const draft = (mode, description) => ({
|
|
19
|
+
action: { system: 'coolify', kind: 'analytics-boule', key: org.id, desired: { mode } },
|
|
20
|
+
step: { description, destroys: mode === 'retire', migrates: false },
|
|
21
|
+
});
|
|
22
|
+
const planned = (kind) => plannedSteps.some((p) => p.action.system === 'coolify' && p.action.kind === kind && p.action.key === org.id);
|
|
23
|
+
const bouleKnown = Boolean(recordedIds[`coolify:boule:${org.id}`]) || planned('boule');
|
|
24
|
+
const marker = recordedIds[ANALYTICS_BOULE_APPLIED_SCOPE_KEY];
|
|
25
|
+
if (!appsOn(org)) {
|
|
26
|
+
if (!marker || !recordedIds[`coolify:boule:${org.id}`])
|
|
27
|
+
return [];
|
|
28
|
+
return [
|
|
29
|
+
draft('retire', `take the analytics address and key out of ${org.slug}'s Boule env (the company's Boule runs no apps)`),
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
if (!bouleKnown)
|
|
33
|
+
return [];
|
|
34
|
+
const backendApp = recordedIds[`coolify:analytics:${org.id}`];
|
|
35
|
+
const applied = Boolean(backendApp) && Boolean(recordedIds[ANALYTICS_DEPLOYED_SCOPE_KEY]);
|
|
36
|
+
if (!applied && !planned('analytics'))
|
|
37
|
+
return [];
|
|
38
|
+
if (applied && marker === analyticsBouleMarker(org, backendApp))
|
|
39
|
+
return [];
|
|
40
|
+
return [
|
|
41
|
+
draft('write', `write ANALYTICS_URL and ANALYTICS_OPERATOR_KEY to ${org.slug}'s Boule env, restart Boule`),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type DeclaredOrg } from '../org.js';
|
|
2
|
+
/**
|
|
3
|
+
* A company's own analytics backend (https://github.com/wtfalch/analytics): one Coolify app and one database
|
|
4
|
+
* per company, built from that repo's root Dockerfile. It is planned only while something uses it: a hosting
|
|
5
|
+
* entry declares `uses.analytics`, or the company's Boule runs apps (`org.boule.apps`).
|
|
6
|
+
*
|
|
7
|
+
* What the host (`apps/host`) reads to start: `DATABASE_URL` and `DATABASE_URL_OWNER` (written by
|
|
8
|
+
* `pushDatabaseUrl`), `ANALYTICS_OPERATOR_KEY` (generated once here) and `AUTH_ORGANIZATION_ID` (the company's
|
|
9
|
+
* sign-in organisation; the tenant id of every key and row derives from it, and without it minting answers 503
|
|
10
|
+
* and the release series answers `not_configured`). It reads no other `AUTH_*` name yet, so no sign-in client is
|
|
11
|
+
* made for it. When its screens need `AUTH_ISSUER` and `AUTH_CLIENT_ID`, add the client then.
|
|
12
|
+
*/
|
|
13
|
+
/** True while some entry declares `uses.analytics`, or Boule runs apps: the backend is wanted. */
|
|
14
|
+
export declare const analyticsWanted: (org: Pick<DeclaredOrg, "boule" | "hosting">) => boolean;
|
|
15
|
+
/** `analytics.<domain>`, or `<slug>-analytics.oikos.host` for a company without a domain. */
|
|
16
|
+
export declare function analyticsHostnameFor(org: Pick<DeclaredOrg, 'domain' | 'slug'>): string;
|
|
17
|
+
/** The public address apps and Boule send to, `ANALYTICS_URL`. */
|
|
18
|
+
export declare const analyticsUrlFor: (org: Pick<DeclaredOrg, "domain" | "slug">) => string;
|
|
19
|
+
/** The backend's database uuid, recorded beside the app's own id. */
|
|
20
|
+
export declare const ANALYTICS_DATABASE_SCOPE_KEY = "coolify_analytics_database";
|
|
21
|
+
/** Recorded once the app was deployed, as `coolify_boule_deployed` is for Boule. */
|
|
22
|
+
export declare const ANALYTICS_DEPLOYED_SCOPE_KEY = "coolify_analytics_deployed";
|
|
23
|
+
/** The env name the backend reads its operator key from; also Boule's name for it (C2). */
|
|
24
|
+
export declare const ANALYTICS_OPERATOR_KEY_ENV = "ANALYTICS_OPERATOR_KEY";
|
|
25
|
+
/** The env names `desiredAnalyticsEnv` writes, so the live reader knows which to read back. */
|
|
26
|
+
export declare const ANALYTICS_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ORGANIZATION_ID"];
|
|
27
|
+
export declare function desiredAnalyticsEnv(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): {
|
|
28
|
+
env: Record<string, string>;
|
|
29
|
+
missing: string[];
|
|
30
|
+
};
|
|
31
|
+
export declare function analyticsEnvDrift(desired: Readonly<Record<string, string>>, live: Readonly<Record<string, string>>): string[];
|
|
32
|
+
/** The backend's operator API. Every call carries the company's own base URL and operator key. */
|
|
33
|
+
export interface AnalyticsPort {
|
|
34
|
+
/** Mints a site key; the secret is shown once. The same `name` again replaces the older key. */
|
|
35
|
+
mintSiteKey(input: {
|
|
36
|
+
baseUrl: string;
|
|
37
|
+
operatorKey: string;
|
|
38
|
+
name: string;
|
|
39
|
+
environment: 'production' | 'stage';
|
|
40
|
+
}): Promise<{
|
|
41
|
+
id: string;
|
|
42
|
+
secret: string;
|
|
43
|
+
}>;
|
|
44
|
+
/** Revokes a key. An unknown id is not an error. */
|
|
45
|
+
revokeSiteKey(input: {
|
|
46
|
+
baseUrl: string;
|
|
47
|
+
operatorKey: string;
|
|
48
|
+
keyId: string;
|
|
49
|
+
}): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The real {@link AnalyticsPort}: `POST <baseUrl>/v1/site-keys` with `{ name, environment }` answers 201
|
|
53
|
+
* `{ id, secret }`; `DELETE <baseUrl>/v1/site-keys/<id>` answers 204. Errors carry the status only, never a
|
|
54
|
+
* body, the key or the secret.
|
|
55
|
+
*/
|
|
56
|
+
export declare function httpAnalytics(options?: {
|
|
57
|
+
fetch?: typeof fetch;
|
|
58
|
+
}): AnalyticsPort;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { appsOn } from '../org.js';
|
|
2
|
+
import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
|
|
3
|
+
/**
|
|
4
|
+
* A company's own analytics backend (https://github.com/wtfalch/analytics): one Coolify app and one database
|
|
5
|
+
* per company, built from that repo's root Dockerfile. It is planned only while something uses it: a hosting
|
|
6
|
+
* entry declares `uses.analytics`, or the company's Boule runs apps (`org.boule.apps`).
|
|
7
|
+
*
|
|
8
|
+
* What the host (`apps/host`) reads to start: `DATABASE_URL` and `DATABASE_URL_OWNER` (written by
|
|
9
|
+
* `pushDatabaseUrl`), `ANALYTICS_OPERATOR_KEY` (generated once here) and `AUTH_ORGANIZATION_ID` (the company's
|
|
10
|
+
* sign-in organisation; the tenant id of every key and row derives from it, and without it minting answers 503
|
|
11
|
+
* and the release series answers `not_configured`). It reads no other `AUTH_*` name yet, so no sign-in client is
|
|
12
|
+
* made for it. When its screens need `AUTH_ISSUER` and `AUTH_CLIENT_ID`, add the client then.
|
|
13
|
+
*/
|
|
14
|
+
/** True while some entry declares `uses.analytics`, or Boule runs apps: the backend is wanted. */
|
|
15
|
+
export const analyticsWanted = (org) => appsOn(org) || org.hosting.entries.some((e) => e.uses?.analytics === true);
|
|
16
|
+
/** `analytics.<domain>`, or `<slug>-analytics.oikos.host` for a company without a domain. */
|
|
17
|
+
export function analyticsHostnameFor(org) {
|
|
18
|
+
return org.domain ? `analytics.${org.domain}` : `${org.slug}-analytics.oikos.host`;
|
|
19
|
+
}
|
|
20
|
+
/** The public address apps and Boule send to, `ANALYTICS_URL`. */
|
|
21
|
+
export const analyticsUrlFor = (org) => `https://${analyticsHostnameFor(org)}`;
|
|
22
|
+
/** The backend's database uuid, recorded beside the app's own id. */
|
|
23
|
+
export const ANALYTICS_DATABASE_SCOPE_KEY = 'coolify_analytics_database';
|
|
24
|
+
/** Recorded once the app was deployed, as `coolify_boule_deployed` is for Boule. */
|
|
25
|
+
export const ANALYTICS_DEPLOYED_SCOPE_KEY = 'coolify_analytics_deployed';
|
|
26
|
+
/** The env name the backend reads its operator key from; also Boule's name for it (C2). */
|
|
27
|
+
export const ANALYTICS_OPERATOR_KEY_ENV = 'ANALYTICS_OPERATOR_KEY';
|
|
28
|
+
/** The env names `desiredAnalyticsEnv` writes, so the live reader knows which to read back. */
|
|
29
|
+
export const ANALYTICS_MANAGED_ENV_KEYS = ['APP_URL', 'AUTH_ORGANIZATION_ID'];
|
|
30
|
+
export function desiredAnalyticsEnv(org, recorded) {
|
|
31
|
+
const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
32
|
+
const env = { APP_URL: analyticsUrlFor(org) };
|
|
33
|
+
if (zitadelOrgId)
|
|
34
|
+
env.AUTH_ORGANIZATION_ID = zitadelOrgId;
|
|
35
|
+
return { env, missing: zitadelOrgId ? [] : ['zitadelOrgId'] };
|
|
36
|
+
}
|
|
37
|
+
export function analyticsEnvDrift(desired, live) {
|
|
38
|
+
return Object.entries(desired)
|
|
39
|
+
.filter(([key, value]) => live[key] !== value)
|
|
40
|
+
.map(([key]) => key);
|
|
41
|
+
}
|
|
42
|
+
const CALL_TIMEOUT_MS = 20_000;
|
|
43
|
+
/**
|
|
44
|
+
* The real {@link AnalyticsPort}: `POST <baseUrl>/v1/site-keys` with `{ name, environment }` answers 201
|
|
45
|
+
* `{ id, secret }`; `DELETE <baseUrl>/v1/site-keys/<id>` answers 204. Errors carry the status only, never a
|
|
46
|
+
* body, the key or the secret.
|
|
47
|
+
*/
|
|
48
|
+
export function httpAnalytics(options = {}) {
|
|
49
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
50
|
+
const call = async (url, init, what) => {
|
|
51
|
+
try {
|
|
52
|
+
return await fetchImpl(url, {
|
|
53
|
+
...init,
|
|
54
|
+
redirect: 'error',
|
|
55
|
+
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new Error(`the analytics backend did not answer when asked to ${what}`);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
async mintSiteKey({ baseUrl, operatorKey, name, environment }) {
|
|
64
|
+
const response = await call(`${baseUrl}/v1/site-keys`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { authorization: `Bearer ${operatorKey}`, 'content-type': 'application/json' },
|
|
67
|
+
body: JSON.stringify({ name, environment }),
|
|
68
|
+
}, 'mint a site key');
|
|
69
|
+
if (response.status !== 201) {
|
|
70
|
+
throw Object.assign(new Error(`the analytics backend refused to mint a site key (status ${response.status})`), { status: response.status });
|
|
71
|
+
}
|
|
72
|
+
const body = await response.json().catch(() => undefined);
|
|
73
|
+
const fields = typeof body === 'object' && body !== null ? body : {};
|
|
74
|
+
if (typeof fields.id !== 'string' ||
|
|
75
|
+
!fields.id ||
|
|
76
|
+
typeof fields.secret !== 'string' ||
|
|
77
|
+
!fields.secret) {
|
|
78
|
+
throw new Error('the analytics backend answered 201 but sent no key id and secret');
|
|
79
|
+
}
|
|
80
|
+
return { id: fields.id, secret: fields.secret };
|
|
81
|
+
},
|
|
82
|
+
async revokeSiteKey({ baseUrl, operatorKey, keyId }) {
|
|
83
|
+
const response = await call(`${baseUrl}/v1/site-keys/${encodeURIComponent(keyId)}`, { method: 'DELETE', headers: { authorization: `Bearer ${operatorKey}` } }, 'revoke a site key');
|
|
84
|
+
if (response.status !== 204) {
|
|
85
|
+
throw Object.assign(new Error(`the analytics backend refused to revoke a site key (status ${response.status})`), { status: response.status });
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
package/dist/plan/boule.js
CHANGED
|
@@ -177,6 +177,8 @@ export const BOULE_MANAGED_ENV_KEYS = [
|
|
|
177
177
|
BOULE_PEER_PASSWORD_ENV,
|
|
178
178
|
BOULE_BRIDGE_SECRET_ENV,
|
|
179
179
|
BOULE_OWNER_SEED_SECRET_ENV,
|
|
180
|
+
// Deprecated (foundry#987): kept in the list so the live reader still sees it, which is how
|
|
181
|
+
// `planBoule` finds a stale one to take away. Goes with `NATIVE_BUILD_KEY_ENV` next release.
|
|
180
182
|
NATIVE_BUILD_KEY_ENV,
|
|
181
183
|
BOULE_ARCHON_ORG_ID_ENV,
|
|
182
184
|
BOULE_ARCHON_CREDENTIAL_ENV,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, orgServiceDatabase, } from '../org.js';
|
|
2
|
+
import { ANALYTICS_DATABASE_SCOPE_KEY } from './analytics.js';
|
|
2
3
|
import { BOULE_DATABASE_SCOPE_KEY } from './boule.js';
|
|
3
4
|
import { CHAT_REKEY_MAS_DB_SCOPE_KEY, CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY } from './executor.js';
|
|
4
5
|
import { MAIL_DATABASE_NAME, MAIL_DATABASE_SCOPE_KEY } from './mail-store.js';
|
|
@@ -29,6 +30,7 @@ export function companyDatabaseKeys(org) {
|
|
|
29
30
|
chat_synapse: 'coolify_chat_synapse_database',
|
|
30
31
|
chat_mas: 'coolify_chat_mas_database',
|
|
31
32
|
boule: BOULE_DATABASE_SCOPE_KEY,
|
|
33
|
+
analytics: ANALYTICS_DATABASE_SCOPE_KEY,
|
|
32
34
|
chat_rekey_synapse: CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY,
|
|
33
35
|
chat_rekey_mas: CHAT_REKEY_MAS_DB_SCOPE_KEY,
|
|
34
36
|
postgres: ORG_POSTGRES_SERVER_SCOPE_KEY,
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -83,8 +83,12 @@ export interface PlanOrgOptions {
|
|
|
83
83
|
workerAppKeysUrl?: string;
|
|
84
84
|
/** F7: the keys service's registrar address, when the executor has a `KeysRegistrarPort`. */
|
|
85
85
|
keysRegistrarUrl?: string;
|
|
86
|
+
/** The executor was built with an `analytics` port; without it each `uses.analytics` key is blocked. */
|
|
87
|
+
analyticsPort?: boolean;
|
|
86
88
|
/** C2: true when the executor was built with a `WorkerAppDatabasePort`; a `workerApp.database` is blocked without it. */
|
|
87
89
|
workerAppDatabase?: boolean;
|
|
90
|
+
/** True when that port has `deletableTables`; a `workerApp.database.noDelete` is blocked without it. */
|
|
91
|
+
workerAppNoDeleteCheck?: boolean;
|
|
88
92
|
/** ADR 0002: the platform's Cloudflare account id; a container image must live in its registry. */
|
|
89
93
|
workerAppRegistryAccountId?: string;
|
|
90
94
|
/** The executor was built with an `ownerInviteMail` port; without it each owner's invite is blocked. */
|
package/dist/plan/diff.js
CHANGED
|
@@ -3,6 +3,8 @@ import { NATIVE_BUILD_KEY_ENV } from '../native-build.js';
|
|
|
3
3
|
import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, appsOn, companyGithubFor, heldEnvRef, kosmosOn, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownerNameProblem, ownsServices, } from '../org.js';
|
|
4
4
|
import { entryHasDatabase } from '../worker-app-config.js';
|
|
5
5
|
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, entryClientIdScopeKey, withRecordedMessagingProject, } from './agora.js';
|
|
6
|
+
import { planAnalyticsBoule } from './analytics-boule.js';
|
|
7
|
+
import { analyticsEnvDrift, analyticsWanted, desiredAnalyticsEnv } from './analytics.js';
|
|
6
8
|
import { planAppsDbRoute } from './apps-db-route.js';
|
|
7
9
|
import { planAppsZones } from './apps-zones.js';
|
|
8
10
|
import { bgKey, planBlueGreen, } from './blue-green.js';
|
|
@@ -16,6 +18,7 @@ import { DESKTOP_AUTH_HOME, planDesktopAuth } from './desktop-auth.js';
|
|
|
16
18
|
import { planDesktopCallbacks } from './desktop-callback.js';
|
|
17
19
|
import { onPlatformNetwork } from './destinations.js';
|
|
18
20
|
import { planEntryAiKeys } from './entry-ai-key.js';
|
|
21
|
+
import { planEntryAnalyticsKeys } from './entry-analytics-key.js';
|
|
19
22
|
import { planEntryAudit } from './entry-audit.js';
|
|
20
23
|
import { CUSTOMER_GRANTS_ENV, CUSTOMER_GRANTS_WRITTEN_SCOPE_KEY, customerGrantsEntries, customerGrantsShapeMatches, customerGrantsToDrop, wantedCustomerGrants, } from './entry-customer-grants.js';
|
|
21
24
|
import { planEdgeReservedLabels, planEdgeSlug, planEntryEdgeKeys } from './entry-edge-key.js';
|
|
@@ -30,7 +33,7 @@ import { planKeysRegistrar } from './keys-registrar.js';
|
|
|
30
33
|
import { desiredKosmosEnv, kosmosEnvDrift } from './kosmos.js';
|
|
31
34
|
import { APP_GONE_KIND } from './live.js';
|
|
32
35
|
import { lockedHostnames } from './locked-hostnames.js';
|
|
33
|
-
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, OWN_CUSTOMERS_BASE_URI_SCOPE_KEY, agoraIssuerHostFor, clientOrganizationsState, companyLoginTunnelHost, customersClientOrganizations, inAppApps, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, ownCustomersAppState, pendingRemovalHosts, } from './login-host.js';
|
|
36
|
+
import { LOGIN_BASE_URI_SWEEP_SCOPE_KEY, LOGIN_BASE_URI_SWEEP_VERSION, LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, OWN_CUSTOMERS_BASE_URI_SCOPE_KEY, agoraIssuerHostFor, clientOrganizationsState, companyLoginTunnelHost, customersClientOrganizations, inAppApps, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, ownCustomersAppState, pendingRemovalHosts, } from './login-host.js';
|
|
34
37
|
import { planMachineBypasses } from './machine-paths.js';
|
|
35
38
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
|
|
36
39
|
import { mailStoreSwitchChange } from './mail-store.js';
|
|
@@ -723,6 +726,14 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
|
|
|
723
726
|
const kosmos = planKosmos(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? []);
|
|
724
727
|
if (kosmos.step)
|
|
725
728
|
plannedSteps.push(kosmos.step);
|
|
729
|
+
// The company's analytics backend, only while something uses it (an entry's `uses.analytics`, or Boule's
|
|
730
|
+
// apps); never parked once made. Before the site keys and Boule's env, which need its operator key.
|
|
731
|
+
const analytics = planAnalyticsBackend(org, byKey, claimed, recordedIds, options.otherSlugs ?? []);
|
|
732
|
+
if (analytics.step)
|
|
733
|
+
plannedSteps.push(analytics.step);
|
|
734
|
+
plannedSteps.push(...planAnalyticsBoule(org, recordedIds, plannedSteps));
|
|
735
|
+
const entryAnalyticsKeys = planEntryAnalyticsKeys(org, recordedIds, plannedSteps, options.analyticsPort ?? false);
|
|
736
|
+
plannedSteps.push(...entryAnalyticsKeys.steps);
|
|
726
737
|
// Boule's, Agora's and kosmos's blue/green: after their own steps, which make the app it copies.
|
|
727
738
|
plannedSteps.push(...blueGreen.steps.filter((x) => x.phase === 'last').map(blueGreenDraft()));
|
|
728
739
|
// Boule owners, after the Boule step: needs the app deployed and the seeding secret in its env.
|
|
@@ -835,6 +846,9 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
|
|
|
835
846
|
: {}),
|
|
836
847
|
canHoldSecrets: platformReadiness.canHoldSecrets === true,
|
|
837
848
|
issuer: authIssuer,
|
|
849
|
+
...(platformReadiness.workerAppSecrets
|
|
850
|
+
? { heldSecrets: platformReadiness.workerAppSecrets }
|
|
851
|
+
: {}),
|
|
838
852
|
...(options.workerAppDatabase
|
|
839
853
|
? {
|
|
840
854
|
database: {
|
|
@@ -844,6 +858,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
|
|
|
844
858
|
tunnelReady: Boolean(recordedIds[`cloudflare:tunnel:${org.id}`]) ||
|
|
845
859
|
plannedSteps.some((s) => s.action.system === 'cloudflare' && s.action.kind === 'tunnel'),
|
|
846
860
|
accessReady: edgeNeeded(platformReadiness, ['access', 'tunnels', 'dns', 'zones']) === null,
|
|
861
|
+
noDeleteCheck: options.workerAppNoDeleteCheck === true,
|
|
847
862
|
},
|
|
848
863
|
}
|
|
849
864
|
: {}),
|
|
@@ -991,6 +1006,9 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
|
|
|
991
1006
|
}
|
|
992
1007
|
if (kosmos.blocked)
|
|
993
1008
|
unmanaged.push({ description: kosmos.blocked, key: 'coolify:kosmos' });
|
|
1009
|
+
if (analytics.blocked)
|
|
1010
|
+
unmanaged.push({ description: analytics.blocked, key: 'coolify:analytics' });
|
|
1011
|
+
unmanaged.push(...entryAnalyticsKeys.blocked);
|
|
994
1012
|
// Same for an ai key minted but never delivered: planning a new mint would
|
|
995
1013
|
// orphan a second live key.
|
|
996
1014
|
if (aiKey.blocked)
|
|
@@ -1627,7 +1645,14 @@ function planOwnLoginHost(org, recordedIds, gates) {
|
|
|
1627
1645
|
const customersOwed = Boolean(recordedIds[SIGN_IN_LOGIN_APP_SCOPE_KEY]) &&
|
|
1628
1646
|
ownCustomersAppState(org, recordedIds) !==
|
|
1629
1647
|
(recordedIds[OWN_CUSTOMERS_BASE_URI_SCOPE_KEY] ?? '');
|
|
1630
|
-
|
|
1648
|
+
// With in-app sign-in applied `loginV2.required` is off: every app of the instance owes a base URI until swept.
|
|
1649
|
+
const sweepOwed = Boolean(recordedIds[SIGN_IN_IN_APP_SCOPE_KEY]) &&
|
|
1650
|
+
recordedIds[LOGIN_BASE_URI_SWEEP_SCOPE_KEY] !== LOGIN_BASE_URI_SWEEP_VERSION;
|
|
1651
|
+
if (!inAppSignIn(org) &&
|
|
1652
|
+
!recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] &&
|
|
1653
|
+
!mapOwed &&
|
|
1654
|
+
!customersOwed &&
|
|
1655
|
+
!sweepOwed) {
|
|
1631
1656
|
return none;
|
|
1632
1657
|
}
|
|
1633
1658
|
// The company's own sign-in is reported on its own line (`coolify:own-auth`).
|
|
@@ -1646,6 +1671,7 @@ function planOwnLoginHost(org, recordedIds, gates) {
|
|
|
1646
1671
|
(recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] ?? '') === targets.active &&
|
|
1647
1672
|
!mapOwed &&
|
|
1648
1673
|
!customersOwed &&
|
|
1674
|
+
!sweepOwed &&
|
|
1649
1675
|
pendingRemovalHosts(recordedIds).length === 0) {
|
|
1650
1676
|
return { step: null, blocked: waiting };
|
|
1651
1677
|
}
|
|
@@ -3200,6 +3226,78 @@ function planKosmos(org, byKey, claimed, recordedIds, issuer, otherSlugs) {
|
|
|
3200
3226
|
blocked: null,
|
|
3201
3227
|
};
|
|
3202
3228
|
}
|
|
3229
|
+
/**
|
|
3230
|
+
* The company's analytics backend: a per-org singleton keyed by the org id under its own `analytics` kind, built
|
|
3231
|
+
* from https://github.com/wtfalch/analytics's root Dockerfile (`executor.ts`'s `coolifyAnalytics`) with a
|
|
3232
|
+
* database of its own. Planned only while `analyticsWanted(org)`; once live it is kept and never parked. A live
|
|
3233
|
+
* app is planned again when its `APP_URL` or `AUTH_ORGANIZATION_ID` is missing or different. It needs no
|
|
3234
|
+
* sign-in client and no issuer; only the company's sign-in organisation id (recorded by an earlier step).
|
|
3235
|
+
*/
|
|
3236
|
+
function planAnalyticsBackend(org, byKey, claimed, recordedIds, otherSlugs) {
|
|
3237
|
+
const key = resourceKey('coolify', 'analytics', org.id);
|
|
3238
|
+
const live = byKey.get(key);
|
|
3239
|
+
if (live)
|
|
3240
|
+
claimed.add(key);
|
|
3241
|
+
if (!analyticsWanted(org))
|
|
3242
|
+
return { step: null, blocked: null };
|
|
3243
|
+
const tooLong = [
|
|
3244
|
+
[`${org.slug}-analytics`, 'the analytics host label and app name'],
|
|
3245
|
+
[`${org.slug}_analytics_db_rt`, 'the analytics database role'],
|
|
3246
|
+
].filter(([name]) => name.length > 63);
|
|
3247
|
+
if (tooLong.length > 0) {
|
|
3248
|
+
return {
|
|
3249
|
+
step: null,
|
|
3250
|
+
blocked: `${org.slug}'s analytics backend is blocked, not planned: ${tooLong.map(([n, what]) => `${what} '${n}' is over 63 characters`).join(', ')}; shorten the slug`,
|
|
3251
|
+
};
|
|
3252
|
+
}
|
|
3253
|
+
if (otherSlugs.includes(`${org.slug}-analytics`)) {
|
|
3254
|
+
return {
|
|
3255
|
+
step: null,
|
|
3256
|
+
blocked: `${org.slug}'s analytics backend is blocked, not planned: another declared org has the slug '${org.slug}-analytics', which is this backend's hostname`,
|
|
3257
|
+
};
|
|
3258
|
+
}
|
|
3259
|
+
// `coolifyAnalytics` writes nothing without the sign-in organisation, so the step waits for it here.
|
|
3260
|
+
const { env, missing } = desiredAnalyticsEnv(org, recordedIds);
|
|
3261
|
+
if (missing.length > 0) {
|
|
3262
|
+
return {
|
|
3263
|
+
step: null,
|
|
3264
|
+
blocked: `${org.slug}'s analytics backend is blocked, not planned: the company's sign-in organisation is not recorded yet`,
|
|
3265
|
+
};
|
|
3266
|
+
}
|
|
3267
|
+
const action = {
|
|
3268
|
+
system: 'coolify',
|
|
3269
|
+
kind: 'analytics',
|
|
3270
|
+
key: org.id,
|
|
3271
|
+
desired: {},
|
|
3272
|
+
};
|
|
3273
|
+
if (!live) {
|
|
3274
|
+
return {
|
|
3275
|
+
step: {
|
|
3276
|
+
action,
|
|
3277
|
+
step: {
|
|
3278
|
+
description: `provision ${org.slug}'s analytics backend`,
|
|
3279
|
+
destroys: false,
|
|
3280
|
+
migrates: false,
|
|
3281
|
+
},
|
|
3282
|
+
},
|
|
3283
|
+
blocked: null,
|
|
3284
|
+
};
|
|
3285
|
+
}
|
|
3286
|
+
const drift = analyticsEnvDrift(env, (live.config.env ?? {}));
|
|
3287
|
+
if (drift.length === 0)
|
|
3288
|
+
return { step: null, blocked: null };
|
|
3289
|
+
return {
|
|
3290
|
+
step: {
|
|
3291
|
+
action,
|
|
3292
|
+
step: {
|
|
3293
|
+
description: `update ${org.slug}'s analytics backend config (${drift.join(', ')} missing or different)`,
|
|
3294
|
+
destroys: false,
|
|
3295
|
+
migrates: false,
|
|
3296
|
+
},
|
|
3297
|
+
},
|
|
3298
|
+
blocked: null,
|
|
3299
|
+
};
|
|
3300
|
+
}
|
|
3203
3301
|
/**
|
|
3204
3302
|
* C3: which credentials the org's Boule is given, and why one is not. Archon's credential goes to every
|
|
3205
3303
|
* Boule the host can mint it for; the customers directory credential only while `customers.enabled`. Each
|
|
@@ -3388,7 +3486,9 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
|
|
|
3388
3486
|
drift.push(BOULE_BRIDGE_SECRET_ENV);
|
|
3389
3487
|
if (!liveEnv[BOULE_OWNER_SEED_SECRET_ENV])
|
|
3390
3488
|
drift.push(BOULE_OWNER_SEED_SECRET_ENV);
|
|
3391
|
-
|
|
3489
|
+
// foundry#987: nothing reads this any more (Boule calls Archon's org build route with its
|
|
3490
|
+
// own org credential instead). A live one is drift, so the step removes it rather than minting it.
|
|
3491
|
+
if (liveEnv[NATIVE_BUILD_KEY_ENV])
|
|
3392
3492
|
drift.push(NATIVE_BUILD_KEY_ENV);
|
|
3393
3493
|
// Secrets written but Boule not restarted since: plan the step so it restarts, minting nothing.
|
|
3394
3494
|
if (platformOwner && !recordedIds[BOULE_SECRETS_RESTARTED_SCOPE_KEY]) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
/**
|
|
4
|
+
* `uses.analytics` on a server entry: one site key of its own at the company's analytics backend, minted
|
|
5
|
+
* through `POST /v1/site-keys` with the operator key and written as `ANALYTICS_URL` and `ANALYTICS_SITE_KEY` to
|
|
6
|
+
* the entry's env. The backend shows the secret once, so this follows rule 1 of CLAUDE.md "Platform steps":
|
|
7
|
+
*
|
|
8
|
+
* 1. the key's id is recorded as `analytics_key_undelivered.<entry>` and persisted before the env is written;
|
|
9
|
+
* 2. the env write is tried 3 times with the same secret; the id is the main id `analytics:key:<orgId>:<entry>`
|
|
10
|
+
* and the applied marker only after it worked, and the undelivered marker is cleared then;
|
|
11
|
+
* 3. a marker left behind (every try failed) is reported blocked and never minted again.
|
|
12
|
+
*
|
|
13
|
+
* A Worker app entry is skipped: Boule installs its keys per copy. The entry's stage gets its own key through
|
|
14
|
+
* `plan/stage-services.ts` (service `analytics`, environment `stage`), so the two differ. Removing
|
|
15
|
+
* `uses.analytics` or the entry plans the retire form: revoke the key, take both env names away.
|
|
16
|
+
*/
|
|
17
|
+
export declare const entryAnalyticsUndeliveredScopeKey: (entryKey: string) => string;
|
|
18
|
+
/** The address the entry's env was last written with; a change rewrites the address only. */
|
|
19
|
+
export declare const entryAnalyticsAppliedScopeKey: (entryKey: string) => string;
|
|
20
|
+
/** The id of a key that was revoked, so a stale main id is not revoked again. */
|
|
21
|
+
export declare const entryAnalyticsRetiredScopeKey: (entryKey: string) => string;
|
|
22
|
+
export declare const ENTRY_ANALYTICS_KEY_DELIVERY_ATTEMPTS = 3;
|
|
23
|
+
/** The name the backend knows the key by; its site derives from it. */
|
|
24
|
+
export declare const entryAnalyticsKeyName: (org: Pick<DeclaredOrg, "slug">, entryKey: string) => string;
|
|
25
|
+
/** The stage copy's key name: the backend reads the `-stage` suffix as the stage environment. */
|
|
26
|
+
export declare const stageAnalyticsKeyName: (org: Pick<DeclaredOrg, "slug">, entryKey: string) => string;
|
|
27
|
+
type Blocked = Array<{
|
|
28
|
+
description: string;
|
|
29
|
+
key: string;
|
|
30
|
+
}>;
|
|
31
|
+
export declare function planEntryAnalyticsKeys(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], analyticsPort: boolean): {
|
|
32
|
+
steps: DraftStep[];
|
|
33
|
+
blocked: Blocked;
|
|
34
|
+
};
|
|
35
|
+
export {};
|