Installer and setup ownership
Installer orchestration and first-run setup
src/cli/install/index.ts orchestrates preparation, licence gating, activation and recap.
installArgs.ts owns the shared InstallPlan, unattended flag validation and help text;
both installer front ends execute the same plan. The module parses flags without host I/O.
assertInstallArgs runs first in install() for both front ends and refuses any argument
outside its value and switch lists, so a mistyped flag fails instead of selecting a default.
systemdUnits.ts owns the SYSTEMD_UNITS inventory after its path constants, alongside
the pure unit renderers. Install, privileged refresh and uninstall import that same inventory.
install/updateCoordinator.ts owns provisionSudoers and convergeUpdateCoordinator,
used by installer preparation and privileged refresh. It remains at the install module's
package depth so executable resolution is unchanged. Refresh reads the elowen-update.service unit that
the update timer starts, including its identity and literal environment, validates the staged sudoers
drop-in with visudo -cf before installation, then updates
the coordinator and queue watcher. Identical trusted files are skipped; only a changed
watcher is restarted. There are no contributions or alternative inventory paths. These
operations keep their existing root trust checks, file bytes, command order and cost.
src/cli/setup.ts owns probeSetup(fetchFn, base), the single decoder of the daemon's
/setup response. A successful response must contain boolean needsSetup and
licenceLocked fields. HTTP, JSON and shape failures throw; transport and JSON errors retain
their identity. isFirstRun and the setup command propagate/refuse failures instead of
assuming an admin exists. Installer licence readiness catches failures and polls within its
existing deadline; it accepts only a valid unlocked response. Each attempt uses one HTTP
request and one schema validation, with no persistent state.
The installer's readiness wait is waitForActiveLicence(base) in src/cli/install/index.ts. It
defaults to a 20-second budget and probes /setup every 500 ms, and it returns true only when the
probe succeeds with licenceLocked false. elowen setup (src/cli/setup/command.ts) calls
assertLicenceUnlocked before the wizard. A failed probe or a locked licence stops it with exit
code 1. Probes are made through probeSetup in three places: the installer, isFirstRun and the
setup command.
The rest of src/cli/setup.ts holds the wizard's HTTP helpers. login decodes the
/auth/login discriminated issuance envelope from the shared wire contract, omitting the
unused account projection. HTTP 200 must be authenticated with a nonempty token and
expiry metadata; HTTP 202 must be totp_required with challenge/expiry, never a bearer.
Malformed or status-mismatched responses throw a content-free error. One password request
does not retry or store credentials. The installer's post-bootstrap login smoke check uses
the same decoder. createAdmin posts to /users and then requires a final login.
saveConfig sends a PUT /config
with a brain section only, and does nothing when the plan has no provider. buildSetupPlan maps
the answers to that plan, storing a blank API key as absent, and applySetup runs the account
creation and config save together. Setup writes through the daemon's API, not the database.
Existing protected-account password sign-in in the interactive Account step stops with
browser-approval guidance, clears its in-memory credential and skips account authentication;
it never asks for an OTP or marks a challenge signed in. Run elowen login for the same
instance, approve the normal browser device flow, then use elowen setup --non-interactive
with the desired flags, without --admin-password or ELOWEN_ADMIN_PASSWORD.
Headless setup uses explicit ELOWEN_TOKEN first, an explicitly
supplied admin password next, and the existing per-origin CLI credential when no password
was supplied. An unrelated cached identity never replaces a requested password account.
A protected password challenge fails before configuration writes. Password sign-in
without TOTP and first-admin bootstrap retain their existing behavior. No web-session
token is printed or written to another file; only normal pairing owns credential storage.
The shipped bootstrap install.sh runs its own guard, guard_existing_install, before it changes anything. On a
machine with no elowen command the guard does nothing. A global npm link that does not point into a managed
release (a linked checkout) is refused in every case, and ELOWEN_FORCE does not override that refusal. On a machine
that already runs Elowen, the script asks for confirmation when it has a terminal. Without a terminal it refuses,
unless ELOWEN_FORCE is set, which reinstalls and re-provisions without asking.
PostgreSQL provisioning
src/cli/install/database.ts owns host provisioning behind the existing Runner.
install/index.ts calls provisionDatabase(runner, options) after licence gating. Its
record(database) callback persists ownership before creating the local cluster; its
verify(connectionUrl) callback checks the target without changing it. Provisioning does not
activate a release; the daemon applies the schema migrations on its first boot.
With no --database-url, local mode adds the official PGDG repository and installs
PostgreSQL 18, its matching pgvector package and PgBouncer. It reads pg_lsclusters --json
and TCP listeners before selecting the first free cluster port from 5432. Only the
recorded 18/elowen cluster is configured. An existing cluster with that name but no
ownership record is refused, as are a changed owner, data directory or port. A receipt
whose cluster was not created yet resumes on the reserved port unless another cluster
has taken it. No cluster is dropped.
The local database record contains {mode:'local', version:18, cluster:'elowen', port, socketDir:'/run/postgresql-elowen', poolSocketDir:'/run/elowen-pgbouncer', poolPort:6432}.
The shared InstallInfo contract requires both instanceRoot and database; the installer
passes them to buildInstallInfo together with the deployment facts. The explicit root is
independent of the service HOME and connection URL. Installer paths for the licence,
plugin data and update queue use the shared instancePaths(instanceRoot) contract.
Fresh provisioning creates and starts only the recorded cluster and dedicated pool.
Already-owned installations use read-only preparation followed by explicit policy
reconciliation; neither PostgreSQL nor PgBouncer restarts automatically. Reloadable
changes are verified live; restart-only changes remain staged as restart-required.
Other clusters and the host's default pool configuration are not targets. Package installation temporarily disables automatic creation
of an unrelated default cluster and restores the previous policy even on failure; an unset
create_main_cluster, as stock Ubuntu ships it, is restored to unset. During PostgreSQL package installation it wraps policy-rc.d to deny package-triggered PostgreSQL/PgBouncer actions, preserves the original policy by rename, and suppresses needrestart actions. A rerun recognizes its interrupted wrapper; a conflicting backup is refused. Explicit service operations still target only the recorded cluster and dedicated pool.
Both database processes listen only on Unix sockets. The dedicated
elowen-pgbouncer.service runs as OS user postgres, uses transaction pooling,
max_prepared_statements=100 and auth_type=hba. Its HBA accepts the service role by
peer authentication. Its administrative database separately accepts OS postgres by peer
authentication. auth_file (/etc/elowen/pgbouncer_users.txt) names both users with empty
passwords so the HBA can select them. The admin path verifies SHOW CONFIG and drains old
server connections through RECONNECT/WAIT_CLOSE; the application role gains no
PostgreSQL superuser privilege. The cluster HBA uses local elowen <service-user> peer map=pgbouncer;
the ident map maps both OS postgres and the service user to that database role.
The default application URL points to PgBouncer, never directly to the cluster.
databasePolicy.ts::renderDatabasePolicy is the deterministic renderer used by fresh
provisioning and database.ts::reconcileDatabasePolicy. Updates reuse approved root-recorded
database.policy.resources and decisions, not subsequently observed capacity. Fresh
sizing respects constrained memory, affinity and cgroup-v2 ancestor CPU quotas; automatic
CPU sizing on cgroup v1 requires an explicit approved budget instead. Desired, written and
effective SHA-256 identities are separate; the pool identity covers configuration, HBA and
auth-user bytes separated by NUL. pendingRestart names settings not yet effective.
A local installation without database.policy remains operator-controlled: update
reconciliation returns operator-required without database commands or writes, and
privileged refresh warns on stderr without blocking the core release. Existing owned
installations must explicitly record approved resources, decisions and managed-file
identities before automated policy convergence; updates never recompute over manual
limits. Unexpected file-byte drift or an invalid recorded policy still fails.
Explicit reprovisioning also continues to require approved policy inputs.
Unfinished first provisioning has neither written nor effective receipts; cluster presence alone does not establish completion. A rerun verifies the recorded cluster identity and approved desired profile, rejects drift in present managed policy files, writes only missing policy files and resumes initial starts. PostgreSQL starts only after status explicitly reports not running. Once a written receipt exists, stopped PostgreSQL or pool services are errors, not startup instructions; enabling units does not start them.
With R as approved RAM in MiB and C as approved CPUs, the owned PG18 profile uses:
| Resource | Policy |
|---|---|
| Shared buffers / estimated cache | 25% / 70% of RAM |
| Connections | min(64, floor(R/32)) |
| Parallel workers / gather workers | min(C,8,floor(R/512)) / at most 2 |
work_mem |
15% divided across connections, gather workers and two operations, bounded 1–64 MiB per operation; not a total RAM cap |
| Maintenance memory | 5% of RAM, capped at 1 GiB |
| Vacuum workers / memory each | min(3,C) / 5% divided among workers, bounded 1–256 MiB |
| Maximum WAL | 25% of RAM, bounded 1–8 GiB |
| I/O workers / concurrency | min(3,C) / 16; io_method is not written, so the PG18 default stays |
| Pool server capacity | min(40, connections-8), including at most four reserve connections |
Fresh decisions use WAL compression off and a 300-second checkpoint timeout; updates
preserve recorded decisions. random_page_cost=4, huge_pages=try, durability remains
fully enabled and pg_stat_statements.track=top. Slow statements start at 500 ms;
both parameter-length logging limits are zero. I/O/WAL timing, one-second autovacuum logs,
16-MiB temp-file logs and lock waits are enabled. This suppresses bound parameters, not
literal SQL or arbitrary error text. Table-local vacuum/analyze policy belongs to schema
migrations, not global installer scale factors. The application role retains statement,
lock and idle-transaction timeouts.
Apply verifies native PostgreSQL parsing and trusted ownership before writes, then checks
pg_settings values and sources, pg_file_settings, role/database overrides and
pending_restart, including the actual reload generation. A masked requested setting is
not convergence. Repeated converged or unchanged restart-required runs neither rewrite nor
reload policy files. pendingRestart belongs to the last verified written profile.
A new file generation clears effective state and old pending names before replacing files;
an interrupted reload resumes without rewriting bytes, then records the new pending names.
External mode validates the URL and checks prerequisites before host provisioning.
TCP requires sslmode=verify-full; an explicit Unix socket in the host query
parameter needs no TLS. Duplicate parameters and conflicting TLS overrides are refused.
The external record is {mode:'external'}. Elowen installs no PostgreSQL packages and
writes no server configuration in this mode. The operator owns pooling, server tuning,
role timeouts and extension provisioning. Host-policy reconciliation returns
operator-required without host commands. It does not inspect an actual external GUC diff
or claim that the external server matches the owned-host profile.
databaseProbe.ts invokes the shared elowen-db.checkServerFloor contract and checks
database CONNECT/CREATE, public-schema creation and ownership of existing application
schemas. PostgreSQL 16+, vector 0.7+, pg_trgm and installed/preloaded pg_stat_statements
are required. It does not migrate or modify an external database. Probe credentials
travel to the child on stdin, and failure diagnostics omit URLs and server error text.
installSpec accepts --instance-root and --database-url. Units always load root-owned mode-0600 /etc/elowen/instance.env after an
optional operator environment file. It pins ELOWEN_INSTANCE_ROOT, ELOWEN_LOG_DIR
and ELOWEN_DATABASE_URL. URLs never enter unit text, progress output or install.json.
An existing operator environment file with conflicting pins is refused without echoing
its values. An external reinstall also checks the existing protected file, so changing only the command-line URL cannot repoint a recorded instance. The protected file is persisted before the ownership record, allowing interrupted installations to resume. The licence, plugin data and update queue all follow the explicit state root.
--adopt requires a previously provisioned database, verifies it and prepares application
units without starting database or application services.
Focused installation tests run all host operations against recorded commands. The
scripts/install-smoke/postgres-recorded.mjs host writes only into a caller-supplied
new empty disposable directory and exercises first provision plus rerun. It starts no
database, apt process or service. Real PostgreSQL 18 installation acceptance is a separate
disposable-container gate. databasePolicyPostgres.test.ts additionally requires native
PostgreSQL 18 and /usr/sbin/pgbouncer (verified with 1.22.0), using only private ephemeral
sockets/processes. It checks parsing, effective settings, pending restart, parameter
suppression and pool reconnect. Its unprivileged pool fixture adapts authentication to
the harness user; root peer-auth/systemd equivalence remains the container gate.
The onboarding harness under scripts/tests/installer-e2e now provisions a PostgreSQL 18 fixture inside its own container, supplies both explicit instance-root and database-URL environment values to the installed daemon, and stops that owned fixture during teardown. Its fixture refuses execution outside the dedicated container.
Shared installation contracts
src/shared/installInfo.ts owns ProxyKind, server installation-record sanitization, serialization, reads and webBaseUrl. CLI lifecycle, provisioning, privileged refresh and brain construction import it directly. The PostgreSQL installer records an explicit instanceRoot and database ownership, without connection credentials; absent-record behavior remains unchanged.
The same module owns ClientInstallInfo and readClientInstallInfo(path) for a standalone client's explicit <prefix>/install.json. The strict record contains exactly { kind: 'client', prefix, instanceOrigin }. clientUpdate.ts passes that path during bootstrap and installed startup and checks that the recorded prefix matches the resolved installation. A missing file returns null; malformed JSON, invalid fields and other read failures throw. Reading costs one file read and schema validation, with no discovery or service access. Client records do not change the default system server-record path. See the shared CLI owner for addressing and bootstrap, and update transactions for publication, proof and recovery.
src/cli/install/hostConvergence.ts owns root unit convergence, installed core inventory/record writes and refreshAfterUpdate. Installer preparation, privileged refresh and update coordination import this owner; update.ts retains transaction orchestration and its injected refresh collaborator. Root refresh has no dependency on update.ts or install/index.ts.
convergeRecordedDatabasePolicy validates the root-owned record, serializes policy work
under the instance kernel lock, revalidates database identity and preserves unrelated
record fields. An absent source-checkout record is a no-op; an existing malformed or
untrusted record fails.
replaceUnitFile retains its content, lstat, root-owner, regular-file, symlink and 0644 checks. Required replacements use writeTextAtomic with stagingMode: 0o600, mode: 0o644 and forceMode: true. Staging is exclusive, final mode is applied on the open descriptor, and rename replaces the path. Failures propagate and owned staging files are cleaned up.
Headless setup requires a successful provider probe, validates unknown replies with providerProbeResponseSchema and selects the first chatModelCatalog entry. Missing output metadata remains selectable; explicit non-text outputs, failed/malformed replies and empty chat lists yield no model. Explicit-model, Anthropic and keyless paths retain their policy. Provider upserts preserve settings and order; PUT /config stores one provider/model pair. readinessLines in doctor.ts renders checks and failing hints for doctor, wizard and headless setup, without I/O; only doctor adds its summary.