- Status: done
- Date: 2026-06-13
- Specs touched:
SERVICE_PROVISIONING.md(# Catalog, # Implementation status, # Per-app isolation in shared instances),NEXT.md(# Managed-service lifecycle gaps, # Managed-service per-app key isolation),APP_MANIFEST.md(service types),DECISIONS.md2026-06-13 (both entries),docs/dev/catalog-import-gaps.md(managed-redis — postiz→ implemented)
Closes #159. The manifest schema already accepted services: {cache: {type: redis, version: "7"}}, but the brain provisioned only the SQL families (provisionedKinds = {postgres, mysql, mariadb}), so a redis declaration passed manifest check and then failed at install — a check/install asymmetry the catalog-import ledger kept hitting (managed-redis — postiz: Postiz needs Redis for BullMQ queues). This slice provisions the Redis-compatible substrate the same way Postgres/MySQL are, closing the asymmetry. Follows the MySQL-family slice (docs/progress/managed-services-mysql.md).
The engine is Valkey, not Redis (DECISIONS.md 2026-06-13). The redis:7 image tag now resolves to Redis 7.4+ (RSALv2 + SSPLv1) and Redis 8+ is AGPLv3 — both license tracks are on malmo's avoid-list. So malmo runs Valkey, the Linux Foundation BSD-3-Clause fork of Redis 7.2.4 (RESP/ACL-compatible), and never the upstream Redis image at any version. valkey is a first-class managed type (single version line: 8); redis is kept as a pure compatibility alias that always provisions Valkey underneath — redis: "7" normalizes to the engine identity valkey: "8", so a redis:7 app and a valkey:8 app coalesce onto the one shared malmo-svc-valkey-8 instance. The grant stores the engine identity (Kind: "valkey", Version: "8"), so everything downstream keys off valkey and never sees "redis". Normalization happens once, early (normalizeEngine), so the lifecycle maps are valkey-only — there is deliberately no redis key, so no dead code path could pull the upstream image. The injected DSN keeps the universal redis:// scheme every client understands.
The per-app isolation model — an open question in NEXT.md — is resolved (DECISIONS.md 2026-06-13): a per-app ACL user with full keyspace, not a logical-DB-number split. Valkey has no database to scope a role to, so the per-app unit is an ACL user (ACL SETUSER <app> on >pw ~* &* +@all -@admin -flushall -flushdb -swapdb): every app authenticates as its own revocable credential and unauthenticated access is refused (a real auth boundary the DB-number split lacks), while the keyspace stays shared. -@admin keeps a compromised app off the ACL system / CONFIG / SHUTDOWN / replication, so it can't subvert the shared instance or the control plane, and -flushall -flushdb -swapdb keeps it from wiping the shared keyspace every other app reads from (subtracted by name rather than the blunt -@dangerous, which would also strip INFO/KEYS/SORT ordinary clients call on connect). The credential — not a key namespace — is the boundary; the shared keyspace still lets one app read another's keys, so per-app key confidentiality is the remaining deferred hardening (NEXT.md # Managed-service per-app key isolation), its clean form the isolated: true dedicated-instance flag.
serviceVersionsallowlistsvalkey(8) and keepsredis(7) for ecosystem compatibility; the unknown-type error message lists both.
normalizeEngine(kind, version)— mapsredis→valkey/8, everything else identity. Called at the top of theprovisionServicesper-dep loop and inserviceNetworkNames(so a redis-7 app attaches to the valkey-8 network), before anything touches the maps. Two real consumers, so it's not premature abstraction.- Maps are valkey-only:
servicePort["valkey"]= 6379,serviceImageRepo["valkey"]=valkey/valkey(the BSD-3 image),serviceDSNScheme["valkey"]=redis(the universal RESP scheme),provisionedKinds["valkey"]. Norediskey — redis is normalized away before any map lookup. valkeyServiceCompose(version)— same shape as the SQL service composes (external--internalnetwork, versionedvalkey-8.malmo.internalDNS alias, fixedmalmo-svc-valkey-8exec handle,restart: unless-stopped). Runsvalkey-server --aclfile /data/users.aclso per-app ACL users persist across a restart (ACLs are server config, not keyspace — they aren't in the RDB/AOF).REDISCLI_AUTH(valkey-cli honors the redis env var name) carries the superuser password into the container env so the brain's exec'dvalkey-cliand thevalkey-cli pinghealthcheck authenticate without it ever reaching argv. (valkey/valkey:8also shipsredis-cli/redis-serveras symlinks, but the code uses the honestvalkey-*names.)writeServiceDirbootstrapsdata/users.aclwith the default (superuser) account (user default on >pw ~* &* +@all) so valkey can start; the entrypoint chowns the data dir to thevalkeyuser, so it can rewrite the file onACL SAVE. The superuser env var isVALKEY_SUPERUSER_PASSWORD(matches the compose${...}).provisionValkeyACL—ACL SETUSERthe per-app user thenACL SAVE(persist to the aclfile), both viadocker exec valkey-cli. The superuser auth ridesREDISCLI_AUTHfrom the container env; the per-app password rides argv as the ACL>passwordtoken (base64url, no shell hazards), the same place the SQL families already carry per-app passwords — only the superuser password is kept out of argv.dropServiceGrantsgeneralized to a per-kind command list (onedocker execper command); Valkey runsACL DELUSER+ACL SAVE. Best-effort, unchanged for the SQL engines.provisionServicespersists a Valkey grant with an emptyDBName(the ACL user is the boundary; SQL grants keep db == role).serviceReadyProbegains avalkey-cli ping | grep -q PONGbranch.writeEnv(internal/lifecycle/lifecycle.go) assembles the DSN without the/dbnamesuffix when the grant has no database, so Valkey injectsredis://user:pw@valkey-8.malmo.internal:6379(clients default to logical DB 0); Postgres/MySQL DSNs are unchanged. No code change beyond the maps — it keys entirely off the grant.
- Hermetic (
lifecycle_services_test.go, fake docker):TestInstallProvisionsValkeyViaRedisAlias(declaredtype: redis, "7", asserts avalkey/8grant +malmo-svc-valkey-8instance, no upstream redis-7 instance),TestInstallProvisionsValkeyNative(nativetype: valkey, "8"),TestRedisAndValkeyCoalesce(a redis-7 app and a valkey-8 app share one instance — no secondServiceUp, exactly one valkey-8 row — proving the alias),TestUninstallDropsValkeyACL(ACL DELUSER), andTestRedisProvisioningExecFailures(forces the SETUSER / SAVE execs to fail → install errors + clean rollback). Both install tests shareassertValkeyProvisioned:ACL SETUSER … +@all -@admin -flushall -flushdb -swapdb+ACL SAVEissued; injected family has the valkey-8 host/6379/empty-NAME and aredis://…:6379DSN with no db path; app attached to the svc network. - Live (
dockerlive_test.go, realvalkey/valkey:8):TestLiveRedisProvisioningdeclarestype: redis, "7"to prove the alias end-to-end — lazy spinup ofmalmo-svc-valkey-8with the external aclfile, the grant isvalkey/8, the per-app ACL user is present inACL LIST, authenticates with the injected password and reads/writes the keyspace (SET/GET via theredis://URL form), and is gone fromACL LISTafterACL DELUSERon uninstall. make checkgreen.
- Postiz (#128) is still blocked on its image-internal-path gaps (
nonroot-data-ownership — postiz), not on Redis — itstype: redisdependency is now first-class (served by Valkey) for any redis/valkey-needing app, but the app itself waits on userns-remap or a non-root upstream image. - The deferred Tier-1 pieces remain (
NEXT.md# Managed-service lifecycle gaps): grace-shutdown after the last consumer uninstalls (services stay running today, Valkey included), cross-version migration, and at-rest encryption of the stored superuser + per-app passwords (plaintext today — the Valkey superuser password and per-app ACL passwords fold into the sameNEXT.md# App-secret injection hardening gap). - Per-app Valkey key confidentiality is deferred (
NEXT.md# Managed-service per-app key isolation). This slice subtracts the keyspace-destruction commands (-flushall -flushdb -swapdb) so no app can wipe the shared keyspace, but the keyspace is still shared, so an app can read another app's keys viaGET/SCAN. Command ACLs can't close that (GET/SCANare core); the clean fix is theisolated: truededicated-instance flag (SERVICE_PROVISIONING.md# Per-app isolation). The same shared-keyspace constraint means uninstall drops the credential but cannot reclaim the app's keys — there is no per-app key namespace to flush, so an uninstalled app's keys linger until the instance is wiped; a dedicated instance (theisolated: truepath) is also what makes uninstall reclaim the data. - Uninstall
ACL SAVEis best-effort.dropServiceGrantsrunsACL DELUSERthenACL SAVE; if the SAVE fails, a restart resurrects the (now grant-less, password-unknown) user from the unchanged aclfile. Harmless orphan, symmetric with the provisioning-side window, pruned by the reconcile loop that will own grace-shutdown (NEXT.md# Managed-service lifecycle gaps).