- Status: done
- Date: 2026-06-05
- Specs touched:
SERVICE_PROVISIONING.md,APP_MANIFEST.md,DECISIONS.md,NEXT.md
Builds the first managed-data-service path end-to-end, following the manifest-field → store-table → lifecycle-step → env-injection shape established by app-secret-injection.md. Until now the services: manifest block was parsed-and-ignored: no Services field on the manifest struct, no provisioning code, no MALMO_SERVICE_* injection anywhere. kan (postgres:15) and docuseal (postgres:16) were both blocked on it (secret + app-url injection landed earlier the same day). Managed Postgres unblocks the database dependency; kan boot then surfaced a second, unrelated blocker — the override force-restarts its one-shot migrate job, so it can't reach the service_completed_successfully gate web waits on (tracked separately in #92). An app that declares services.database: {type: postgres, version: "15"} now gets a real per-app database+role inside a shared, lazily-spun-up Postgres container, with credentials injected as MALMO_SERVICE_DATABASE_*. Design rationale in DECISIONS.md 2026-06-05 (managed-service provisioning).
- New top-level
services: {<name>: {type, version, name?}}field (Services map[string]ServiceDep). The map key is the app's logical name and becomes the env-var suffix (database→MALMO_SERVICE_DATABASE_*).validateServicesrequires a snake_case key (reusing the secret-name rule), a knowntype(postgres|redis), and a version on the per-type allowlist (postgres: 15,16;redis: 7). Redis passes schema validation (forward-valid manifests) but is not provisioned this slice.nameis parsed but unused in v1 (the brain generates the real database name).
- New
service_instancestable (kind, version, superuser_password, state, created_at, PK(kind, version)) — the desired-state record of each shared service container.GetServiceInstance(→ErrNotFound, the lazy-spinup trigger),CreateServiceInstance(→ErrConflicton dup),ListServiceInstances(reconcile). - New
service_grantstable (instance_id, logical_name, kind, version, db_name, role_name, password, PK(instance_id, logical_name),FOREIGN KEY … ON DELETE CASCADE) — the per-app database+role, reclaimed with the instance likeinstance_secrets.SetServiceGrants/GetServiceGrants, mirroring the secrets CRUD.
ServiceUp(dir, project)— compose up for a service project using only the generatedcompose.yml+.env(no per-app override).Exec(container, args)—docker exec, used to provision per-app databases via the service container's ownpsql, so the brain never joins the service network (DECISIONS.md2026-06-02).
ensureServiceInstance(lazy spinup, idempotent): generates a superuser password, writes<stateDir>/services/<kind>-<version>/{compose.yml,.env}, creates the--internalmalmo-svc-<kind>-<version>network,ServiceUps the project, pollspg_isreadyviaExecuntil ready, persists the row. The generated compose pinspostgres:<version>, setscontainer_name: malmo-svc-postgres-15(the exec handle) and the in-network DNS aliaspostgres-15.malmo.internal(the host apps put in their DSN), apg_isreadyhealthcheck, a data volume, andrestart: unless-stopped.provisionServices(install step5c, after secrets): per declared service →ensureServiceInstance, generatedb_name/role_name(<sanitized id>_<rand-hex>) + password,ExecpsqlCREATE ROLE … LOGIN PASSWORD …; CREATE DATABASE … OWNER …. Grants persisted viaSetServiceGrantsbefore override/env.writeOverrideattaches every app service to each requiredmalmo-svc-<kind>-<version>network (kan'smigratejob andwebboth need the DSN) and lists them external.writeEnvre-emits each grant asMALMO_SERVICE_<NAME>_{HOST,PORT,NAME,USER,PASSWORD,DSN}(HOST = the DNS alias; DSN =postgres://role:pw@host:5432/db), read from the store.- Uninstall + install-rollback capture grants before the cascade and best-effort
ExecpsqlDROP DATABASE … WITH (FORCE); DROP ROLE …. The shared service instance is left running — grace-shutdown is deferred. reconcileServicesre-asserts each recorded service instance is up at brain startup (network +ServiceUp); service containers carry themalmo.servicelabel, notmalmo.managed=true, so the app-orphan reaper never touches them.
manifest: services parse happy-path (postgres + redis schema-valid) and rejection (unknown type, bad version, missing version, bad key).store: service-instance CRUD + dup conflict; grants roundtrip + cascade-on-instance-delete.lifecycle: install records the shared instance + callsServiceUp, persists a grant, issues the provisioning psql, writesMALMO_SERVICE_DATABASE_*into.env, attaches the app service to the svc network; a second app reuses the one instance (no secondServiceUp); uninstall issues theDROP DATABASE.malmo manifest check catalog/kan/manifest.ymlpasses.
- Real-system verification (done for the Postgres path).
TestLivePostgresProvisioning(dockerlivebuild tag) installs a folderless app against real Docker and asserts the sharedmalmo-svc-postgres-15container comes up, the per-app DB+role is created, the role connects over TCP with the injected DSN password, and the DB is dropped on uninstall.kanend-to-end boot is not verified — it's blocked on #92 (override force-restarts the one-shotmigratejob →compose up -dhangs on the completion gate);TestLiveKanBootis committed butt.Skip-ped until #92 lands. - Redis provisioning is unbuilt (schema-valid only). A redis declaration currently fails at provisioning with a clear error. ACL-user-vs-logical-DB isolation needs its own pass —
NEXT.md# Redis managed-service provisioning. - Grace-shutdown (stop a service version 12h after its last consumer uninstalls) is deferred — services stay running.
NEXT.md# Managed-service grace-shutdown. - Backup/restore + cross-version migration (
pg_dump/pg_restore, auto-migrate on major bump) are deferred, gated on the backup design —NEXT.md# Backup architecture shape. - At-rest encryption of the superuser + per-app passwords (plaintext in SQLite + service
.env) folds into the existingNEXT.md# App-secret injection hardening. - Service image is tag-pinned (
postgres:15), not digest-pinned like app images.