Skip to content

Commit d6abe8c

Browse files
committed
feat!: allow a custom database data root
- Validate safe host-wide roots during guided and direct initialization - Reject bind source changes to prevent silent database data moves BREAKING CHANGE: `host.data_root` is now required in `config.yml`.
1 parent 0e9ad62 commit d6abe8c

19 files changed

Lines changed: 450 additions & 16 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-08-20
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
## Context
2+
3+
`Paths.databases` currently fixes database files under `<state>/databases`, and `Database.data` derives
4+
every engine bind source from it. Generated assets, backup staging, and locks also derive from `Paths`,
5+
but they must remain in the canonical state tree when database files move to dedicated storage.
6+
7+
The source configuration is strict and human-owned. evdb does not retain configuration history or move
8+
database data, while generated Compose already records the last bind source used for each managed role.
9+
10+
## Goals / Non-Goals
11+
12+
**Goals:**
13+
14+
- Store one explicit host-wide data root and use it for every database role.
15+
- Make fresh guided and direct setup support a mounted-disk directory.
16+
- Reject unsafe roots and prevent an existing generated role from silently switching to an empty path.
17+
- Keep the canonical root as the setup default.
18+
19+
**Non-Goals:**
20+
21+
- No per-project or per-role data paths.
22+
- No mount creation, persistence, startup ordering, or availability monitoring.
23+
- No data copy, relocation, import, compatibility default, or source migration.
24+
- No change to generated assets, backup staging, locks, or repositories.
25+
26+
## Decisions
27+
28+
### Put the root on Host and keep Paths as the canonical default
29+
30+
Add required `Host.data_root: Path` and serialize it as `host.data_root`. `Database.data` uses this field,
31+
while `Paths.databases` remains the default selected by fresh initialization. This keeps operator policy
32+
in source configuration without making all runtime paths configurable.
33+
34+
### Validate a dedicated safe directory
35+
36+
The value must be a normalized absolute path with no symlinked or non-directory existing component. It
37+
must not be a broad filesystem ancestor or overlap evdb configuration, generated assets, Traefik,
38+
backups, locks, or a local Restic repository. For a custom root, the immediate parent must already exist;
39+
initialization creates or converges only the selected leaf as a private managed directory.
40+
41+
The setup review states the selected path. The operator is responsible for mounting storage before evdb
42+
or Docker starts; evdb does not imply that a custom directory is a persistent mount.
43+
44+
### Treat the root as immutable after role generation
45+
46+
Fresh setup and source loading require the field. Existing-host initialization accepts only the stored
47+
value. Before replacing an existing role Compose file, database rendering compares the current primary
48+
data bind with the desired bind. A different source fails before creating the new data directory or
49+
rewriting Compose. This catches both supported command misuse and direct source edits without adding a
50+
machine-state file.
51+
52+
## Risks / Trade-offs
53+
54+
- [A configured disk is not mounted when Docker starts] -> State clearly that mounts and startup ordering
55+
are operator-managed; evdb validates the directory but does not claim mount availability.
56+
- [A source edit points at an empty directory] -> Compare the generated primary data bind before any
57+
render mutation and reject a changed source.
58+
- [Existing source lacks the required field] -> Reject it under the existing strict-schema policy; the
59+
operator must provide current source explicitly.
60+
- [Generated Compose is absent] -> Allow rendering because no prior managed bind exists to preserve.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
## Why
2+
3+
Database files are fixed under `/var/lib/evdb/databases`, which prevents an operator from placing them
4+
on a dedicated mounted disk during host setup. The host needs one explicit storage root while preserving
5+
the existing project and role layout.
6+
7+
## What Changes
8+
9+
- **BREAKING**: require `host.data_root` in `config.yml`; fresh setup writes
10+
`/var/lib/evdb/databases` by default.
11+
- Add guided and direct initialization input for one host-wide database data root.
12+
- Derive every database bind source as `<data_root>/<project>/<role>/data`.
13+
- Validate the selected root as a safe normalized absolute path and reject overlap with other managed or
14+
local repository paths.
15+
- Reject a changed data bind source when an existing generated Compose file records another root. evdb
16+
does not move database data.
17+
- Keep mount configuration and startup ordering under operator control.
18+
19+
## Capabilities
20+
21+
### New Capabilities
22+
23+
None.
24+
25+
### Modified Capabilities
26+
27+
- `config`: Make the host database data root explicit, safe, and immutable after database generation.
28+
- `host-setup`: Collect and prepare the host-wide database data root during initialization.
29+
- `deploy`: Allow database data outside `/var/lib/evdb` while keeping all other production paths fixed.
30+
31+
## Impact
32+
33+
This changes the host source schema, initialization CLI and review, database path derivation, generated
34+
Compose safety checks, documentation, and focused configuration and database tests. It adds no runtime
35+
dependency and does not change backup staging or repositories.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Database data root safety and immutability
4+
`host.data_root` SHALL be a normalized absolute non-symlink path for one dedicated evdb database tree.
5+
It SHALL NOT overlap source, generated assets, Traefik assets, backups, locks, or a local Restic
6+
repository. A custom root's immediate parent SHALL already exist and be safe. evdb SHALL reject a desired
7+
database bind source that differs from the source recorded in an existing generated role Compose file,
8+
and SHALL NOT move database data.
9+
10+
#### Scenario: Custom root uses mounted storage
11+
- **WHEN** `host.data_root` is `/mnt/database-volume/evdb`
12+
- **THEN** project `example-prod-01` Postgres data resolves to `/mnt/database-volume/evdb/example-prod-01/postgres/data`
13+
14+
#### Scenario: Root overlaps backup storage
15+
- **WHEN** `host.data_root` is equal to or contains the managed backup path
16+
- **THEN** validation fails before source, directories, or services change
17+
18+
#### Scenario: Existing generated bind differs
19+
- **WHEN** a role's generated Compose file records a data bind under one root and source selects another
20+
- **THEN** rendering fails before creating the new data directory or replacing Compose
21+
22+
## MODIFIED Requirements
23+
24+
### Requirement: Canonical host paths
25+
Non-secret source SHALL live at `/etc/evdb/config.yml` and secret source at `/etc/evdb/secrets.yml`.
26+
An rclone repository SHALL reference one private native rclone file in place; a local repository SHALL
27+
omit `host.backup.rclone_config`. Generated database assets SHALL live under
28+
`/var/lib/evdb/projects/<project>/<role>`, dedicated Traefik assets under `/var/lib/evdb/traefik`, and
29+
mutable local backups and locks under `/var/lib/evdb`. Database data SHALL live under the required
30+
`host.data_root` using `<project>/<role>/data`; fresh initialization SHALL default that root to
31+
`/var/lib/evdb/databases` and write it explicitly.
32+
33+
#### Scenario: Default Postgres paths are derived
34+
- **WHEN** fresh setup creates project `example-prod-01` with a Postgres role using the default data root
35+
- **THEN** its Compose path is `/var/lib/evdb/projects/example-prod-01/postgres/compose.yaml` and its data path is `/var/lib/evdb/databases/example-prod-01/postgres/data`
36+
37+
#### Scenario: Custom Postgres data path is derived
38+
- **WHEN** `host.data_root` is `/mnt/database-volume/evdb` and project `example-prod-01` has a Postgres role
39+
- **THEN** its data path is `/mnt/database-volume/evdb/example-prod-01/postgres/data` while its Compose path remains canonical
40+
41+
#### Scenario: Local repository is configured
42+
- **WHEN** `host.backup.repository` is a normalized absolute local path
43+
- **THEN** `config.yml` contains no rclone configuration field
44+
45+
#### Scenario: Operator locates host inputs
46+
- **WHEN** an operator reviews `/etc/evdb`, `/var/lib/evdb`, and `host.data_root`
47+
- **THEN** evdb's desired settings and managed credentials are separated from generated runtime and database data
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
## MODIFIED Requirements
2+
3+
### Requirement: Production paths
4+
Canonical evdb source SHALL live under `/etc/evdb`; generated services, Traefik assets, local backups,
5+
and locks SHALL live under `/var/lib/evdb`; database data SHALL live under the explicitly configured
6+
`host.data_root`; and the verified tool SHALL be the regular executable `/usr/local/bin/evdb`. No copied
7+
rclone file, deployment machine state, activity record, restore staging, or transaction tree SHALL be
8+
created.
9+
10+
#### Scenario: Tool version changes
11+
- **WHEN** the verified installer atomically replaces `/usr/local/bin/evdb`
12+
- **THEN** every database continues using stable source, generated, credential, backup, and configured data paths
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Host database root selection
4+
Fresh guided initialization SHALL ask for one host-wide database data root and default it to
5+
`/var/lib/evdb/databases`. Direct initialization SHALL accept `--data-root PATH`. The redacted review
6+
SHALL show the selected root, and initialization SHALL prepare it before database roles are rendered.
7+
evdb SHALL leave filesystem mounting and startup ordering to the operator.
8+
9+
#### Scenario: Guided setup accepts the default
10+
- **WHEN** the operator accepts the database data root default
11+
- **THEN** source records `host.data_root: /var/lib/evdb/databases`
12+
13+
#### Scenario: Guided setup selects a mounted-disk directory
14+
- **WHEN** the operator enters `/mnt/database-volume/evdb` whose immediate parent is safe and present
15+
- **THEN** review shows that path and initialization prepares it as the host database root
16+
17+
#### Scenario: Source omits the data root
18+
- **WHEN** initialization loads `config.yml` without `host.data_root`
19+
- **THEN** it rejects the unsupported source instead of synthesizing a compatibility default
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
## 1. Host Configuration
2+
3+
- [x] 1.1 Add the required host data root to source loading, validation, serialization, guided setup, direct initialization, and review.
4+
5+
## 2. Database Storage
6+
7+
- [x] 2.1 Derive database paths and managed directories from the configured root, and reject changes that conflict with an existing generated Compose bind.
8+
9+
## 3. Verification
10+
11+
- [x] 3.1 Cover default, custom, unsafe, missing, and immutable-root behavior with focused tests and validate the change artifacts.

src/evdb/cli.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
load,
1313
rclone_remotes,
1414
repository_parts,
15+
validate_data_root,
1516
validate_domain,
1617
validate_email,
1718
validate_host_id,
1819
)
1920
from .errors import Error
21+
from .models import Paths
2022
from .run import clean
2123

2224

@@ -40,6 +42,11 @@ def parser() -> argparse.ArgumentParser:
4042
init = commands.add_parser("init", help="initialize or refresh this host")
4143
init.add_argument("--host-id", metavar="NAME", help="lowercase host name, e.g. example-01")
4244
init.add_argument("--domain", metavar="DOMAIN", help="base domain, e.g. storage.example.com")
45+
init.add_argument(
46+
"--data-root",
47+
metavar="PATH",
48+
help="absolute host-wide database data root",
49+
)
4350
init.add_argument("--acme-email", metavar="EMAIL", help="ACME account email")
4451
init.add_argument("--dns-provider", metavar="PROVIDER", help="cataloged lego provider code")
4552
init.add_argument(
@@ -135,6 +142,7 @@ def main(
135142
for name in (
136143
"host_id",
137144
"domain",
145+
"data_root",
138146
"acme_email",
139147
"dns_provider",
140148
"repository",
@@ -322,7 +330,6 @@ def _init_values(
322330
try:
323331
if source is not None:
324332
from .host import _initial
325-
from .models import Paths
326333

327334
_initial(result, Paths(config=source.parent))
328335
except Error as exc:
@@ -369,6 +376,12 @@ def _init_host(result: dict, input_fn, output) -> None:
369376
"DNS zone used for database hostnames; example: storage.example.com",
370377
validate_domain,
371378
),
379+
(
380+
"data_root",
381+
"Database data root",
382+
"Dedicated directory for all database files; mount storage before Apply.",
383+
lambda value: str(validate_data_root(value)),
384+
),
372385
(
373386
"acme_email",
374387
"ACME email",
@@ -385,6 +398,7 @@ def _init_host(result: dict, input_fn, output) -> None:
385398
label,
386399
help_text=help_text,
387400
validate=validate,
401+
default=str(Paths().databases) if name == "data_root" else None,
388402
)
389403
if value is None:
390404
raise KeyboardInterrupt
@@ -550,6 +564,7 @@ def _init_review(result: dict) -> str:
550564
{
551565
"Host": result["host_id"],
552566
"Base domain": result["domain"],
567+
"Database data root": result["data_root"],
553568
"Wildcard": f"*.{result['host_id']}.{result['domain']}",
554569
"ACME email": result["acme_email"],
555570
"DNS provider": result["dns_provider"],
@@ -569,10 +584,11 @@ def _edit_init(result: dict, input_fn, output) -> None:
569584
fields = (
570585
("1", "Host ID", ("host_id",)),
571586
("2", "Base domain", ("domain",)),
572-
("3", "ACME email", ("acme_email",)),
573-
("4", "DNS provider and credentials", ("dns_provider", "dns", "dns_file")),
574-
("5", "Backup repository", ("repository", "rclone_config")),
575-
("6", "Restic password", ("restic_password", "restic_password_file")),
587+
("3", "Database data root", ("data_root",)),
588+
("4", "ACME email", ("acme_email",)),
589+
("5", "DNS provider and credentials", ("dns_provider", "dns", "dns_file")),
590+
("6", "Backup repository", ("repository", "rclone_config")),
591+
("7", "Restic password", ("restic_password", "restic_password_file")),
576592
)
577593
selected = ui.choose(
578594
input_fn,

src/evdb/config.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ def as_dict(config: Config) -> dict[str, Any]:
226226
"host": {
227227
"id": host.id,
228228
"domain": host.domain,
229+
"data_root": str(host.data_root),
229230
"backup": backup,
230231
"routing": {
231232
"acme_email": host.routing.acme_email,
@@ -395,6 +396,10 @@ def require_valid(config: Config) -> None:
395396
validator(value)
396397
except ConfigError as exc:
397398
errors.append(str(exc))
399+
try:
400+
validate_data_root(host.data_root, config.paths, host.backup.repository)
401+
except ConfigError as exc:
402+
errors.append(str(exc))
398403
for managed in (config.paths.config, config.paths.state):
399404
unsafe = _unsafe_directory(managed)
400405
if unsafe is not None:
@@ -514,12 +519,58 @@ def validate_email(value: str) -> str:
514519
return value
515520

516521

522+
def validate_data_root(
523+
value: str | Path,
524+
paths: Paths | None = None,
525+
repository: str | None = None,
526+
) -> Path:
527+
path = Path(value)
528+
try:
529+
resolved = path.resolve(strict=False)
530+
except (OSError, RuntimeError) as exc:
531+
raise ConfigError("host.data_root must be a normalized absolute path") from exc
532+
if not path.is_absolute() or path != resolved:
533+
raise ConfigError("host.data_root must be a normalized absolute path")
534+
unsafe = _unsafe_directory(path)
535+
if unsafe is not None:
536+
raise ConfigError(f"host.data_root is symlinked or unsafe: {unsafe}")
537+
538+
managed = paths or Paths()
539+
reserved = (
540+
managed.config,
541+
managed.projects,
542+
managed.traefik,
543+
managed.backups,
544+
managed.locks,
545+
)
546+
if any(_paths_overlap(path, item) for item in reserved):
547+
raise ConfigError("host.data_root overlaps an evdb managed path")
548+
if (
549+
repository is not None
550+
and repository_parts(repository) is None
551+
and _paths_overlap(path, Path(repository))
552+
):
553+
raise ConfigError("host.data_root overlaps the local backup repository")
554+
if path != managed.databases:
555+
try:
556+
parent = path.parent.lstat()
557+
except OSError as exc:
558+
raise ConfigError(f"host.data_root parent is missing or unsafe: {path.parent}") from exc
559+
if stat.S_ISLNK(parent.st_mode) or not stat.S_ISDIR(parent.st_mode):
560+
raise ConfigError(f"host.data_root parent is missing or unsafe: {path.parent}")
561+
if managed.config == CONFIG_DIR and (parent.st_uid != 0 or parent.st_mode & 0o022):
562+
raise ConfigError(f"host.data_root parent must be safely root-owned: {path.parent}")
563+
if managed.config == CONFIG_DIR and path.exists() and path.lstat().st_uid != 0:
564+
raise ConfigError(f"host.data_root must be root-owned: {path}")
565+
return path
566+
567+
517568
def _config(data: dict[str, Any], secrets: Secrets, paths: Paths) -> Config:
518569
if {"databases", "instances", "releases"} & set(data):
519570
raise ConfigError("unsupported source schema")
520571
_only(data, {"host", "projects"}, "config")
521572
host_data = _object(_required(data, "host", "config"), "host")
522-
_only(host_data, {"id", "domain", "backup", "routing"}, "host")
573+
_only(host_data, {"id", "domain", "data_root", "backup", "routing"}, "host")
523574
backup_data = _object(_required(host_data, "backup", "host"), "host.backup")
524575
_only(
525576
backup_data,
@@ -534,6 +585,7 @@ def _config(data: dict[str, Any], secrets: Secrets, paths: Paths) -> Config:
534585
host = Host(
535586
_string(host_data, "id", "host"),
536587
_string(host_data, "domain", "host"),
588+
Path(_string(host_data, "data_root", "host")),
537589
BackupSettings(
538590
_string(backup_data, "repository", "host.backup"),
539591
Path(rclone_value) if rclone_value is not None else None,
@@ -864,6 +916,19 @@ def _unsafe_directory(path: Path) -> Path | None:
864916
return None
865917

866918

919+
def _paths_overlap(left: Path, right: Path) -> bool:
920+
try:
921+
left.relative_to(right)
922+
return True
923+
except ValueError:
924+
pass
925+
try:
926+
right.relative_to(left)
927+
return True
928+
except ValueError:
929+
return False
930+
931+
867932
def _json_credentials(value: str) -> list[str]:
868933
try:
869934
data = json.loads(value)

0 commit comments

Comments
 (0)