Showing 9 changed files with 172 additions and 52 deletions
+21 -0
.doc/development-log.md
@@ -97,6 +97,8 @@ Pachete de sistem instalate în timpul evoluției:
97 97
 
98 98
 - `ripgrep` pe jumper
99 99
 - `rsync` pe jumper
100
+- `sqlite` pe jumper
101
+- `perl-DBD-SQLite` pe jumper
100 102
 - `sqlite3` pe mazeri/GitPrep
101 103
 
102 104
 ## 2026-06-06 - OTP Login Keeps a Password-Manager-Friendly Form Shape
@@ -140,3 +142,22 @@ Scop:
140 142
 - testul live pe jumper să fie legat de un commit identificabil
141 143
 - GitPrep să păstreze istoria canonică pentru arhivare și recuperare
142 144
 - badge-ul de build și meta tag-ul `xdev-build` devin verificarea rapidă pentru ce rulează
145
+
146
+## 2026-06-09 - SQLite Runtime Source of Truth
147
+
148
+Observație: editările de hosts făcute în aplicația live puteau rămâne în working tree-ul de pe jumper sau puteau fi suprascrise/confuzionate la push-ul de cod. Modelul vechi descria simultan `config/hosts.yaml` ca registry editabil în git și ca dată operațională runtime, ceea ce nu era o sursă de adevăr clară.
149
+
150
+Decizie:
151
+
152
+- `var/host-manager.sqlite` devine sursa de adevăr runtime pentru registry și Work Orders
153
+- `config/hosts.yaml` și `config/work-orders.yaml` rămân seed/snapshot/export compatibility files
154
+- la prima pornire, aplicația seed-uiește documentele lipsă din YAML în SQLite
155
+- download-urile `/download/hosts.yaml`, `/download/local-hosts.tsv` și `/download/monitoring.json` sunt randate din SQLite
156
+- `config/local-hosts.tsv` rămâne manifest generat explicit pentru sync-ul DNS local
157
+- push-urile de cod către jumper nu trebuie să înlocuiască baza runtime din `var/`
158
+
159
+Scop:
160
+
161
+- editările făcute în UI să nu se piardă la deploy/push de cod
162
+- să existe o singură autoritate runtime
163
+- YAML/TSV să rămână utile pentru export, review și bootstrap fără să fie storage-ul live
+16 -20
.doc/host-manager.md
@@ -12,15 +12,15 @@ Perl-ul livrat de distribuție este acceptat ca bază de runtime. Modulele Perl
12 12
 
13 13
 Pachetele CPAN nu se instalează direct pe host cu `cpan`, `cpanm` sau mecanisme similare. Dacă aplicația are nevoie de un modul CPAN, modulul trebuie făcut disponibil prin repo-ul local după audit. Este acceptabilă o versiune mai veche/stabilă din repo-ul local; nu urmărim neapărat ultima versiune upstream.
14 14
 
15
-MVP-ul curent nu are dependențe CPAN externe.
15
+MVP-ul curent nu are dependențe CPAN instalate direct pe host. Pentru store-ul runtime folosește `DBI`/`DBD::SQLite` disponibile din distribuție sau repo-ul local auditat, plus SQLite.
16 16
 
17 17
 ## Rol
18 18
 
19
-`config/hosts.yaml` este registrul editabil și trebuie menținut în git. Aplicația este complet în spatele autentificării OTP pentru orice date de registru, exporturi sau modificări.
19
+`var/host-manager.sqlite` este sursa de adevăr runtime pentru registry și Work Orders. La prima pornire, aplicația seed-uiește baza din `config/hosts.yaml` și `config/work-orders.yaml` dacă documentele lipsesc din SQLite. Aplicația este complet în spatele autentificării OTP pentru orice date de registru, exporturi sau modificări.
20 20
 
21
-Git rămâne mecanismul de audit, istoric și rollback. Aplicația nu înlocuiește repo-ul și nu devine o bază de date separată.
21
+Git rămâne mecanismul pentru cod, seed-uri, exporturi și istoric manual. Aplicația nu mai scrie registry-ul live direct în working tree, ca editările făcute în UI să nu se piardă la push/deploy de cod.
22 22
 
23
-Schimbările cu impact operațional care elimină nume sau schimbă semantica serviciilor locale se fac prin Work Order (WO), nu prin ștergere directă din UI. WO-ul rămâne în git, exprimă intenția operațională și trebuie dus până la capăt înainte să modifice registrul.
23
+Schimbările cu impact operațional care elimină nume sau schimbă semantica serviciilor locale se fac prin Work Order (WO), nu prin ștergere directă din UI. WO-ul rămâne în store-ul runtime, exprimă intenția operațională și trebuie dus până la capăt înainte să modifice registrul.
24 24
 
25 25
 Endpoint-uri publice:
26 26
 
@@ -111,9 +111,9 @@ Secretul nu se comite în repo. Dacă avem nevoie de integrare cu un manager de
111 111
 
112 112
 ## Flux
113 113
 
114
-1. Hosturile se editează în aplicație sau direct în `config/hosts.yaml`.
114
+1. Hosturile se editează în aplicație; store-ul runtime este `var/host-manager.sqlite`.
115 115
 2. Operatorii autentificați pot descărca `/download/hosts.yaml`, `/download/local-hosts.tsv` sau `/download/monitoring.json`.
116
-3. Pentru DNS local, butonul `Write local-hosts.tsv` regenerează `config/local-hosts.tsv`.
116
+3. Pentru DNS local, butonul `Write local-hosts.tsv` regenerează `config/local-hosts.tsv` din SQLite.
117 117
 4. Sincronizarea efectivă către jumper și as01 rămâne:
118 118
 
119 119
 ```bash
@@ -122,7 +122,7 @@ Secretul nu se comite în repo. Dacă avem nevoie de integrare cu un manager de
122 122
 
123 123
 ## Work Orders
124 124
 
125
-`config/work-orders.yaml` păstrează operațiuni care trebuie executate și confirmate înainte să atingă registrul.
125
+Work Orders sunt păstrate în SQLite. `config/work-orders.yaml` rămâne seed/snapshot compatibil pentru instalări noi și export manual.
126 126
 
127 127
 Un WO nu înseamnă că numele nu mai este în uz. Înseamnă doar că vrem să ajungem acolo. Pentru retragerea unui nume, checklist-ul trebuie să acopere pașii reali: ștergerea vhostului, înlocuirea certificatelor publice cu certificate locale, reîncărcarea serviciilor, testarea accesului și verificarea că nu mai există consumatori.
128 128
 
@@ -136,12 +136,12 @@ Confirmarea unui WO:
136 136
 
137 137
 - cere tastarea exactă a ID-ului WO în interfață
138 138
 - este blocată dacă există pași de checklist nemarcați `done`
139
-- elimină numele declarate din `config/hosts.yaml`
139
+- elimină numele declarate din registry-ul SQLite
140 140
 - marchează WO-ul ca `confirmed`
141 141
 - regenerează `config/local-hosts.tsv`
142 142
 - nu rulează automat sync-ul către resolvere
143 143
 
144
-După confirmare, operatorul verifică schimbarea în git și rulează explicit:
144
+După confirmare, operatorul verifică exportul și rulează explicit:
145 145
 
146 146
 ```bash
147 147
 ./scripts/sync_local_hosts.sh --apply --verify
@@ -151,7 +151,7 @@ Primul WO curent este pentru retragerea numelor locale `pmx.*`/`pbs.*` create is
151 151
 
152 152
 ## Convenții de nume
153 153
 
154
-`madagascar.xdev.ro` este domeniul implicit al rețelei interne. În `config/hosts.yaml` se declară doar numele canonice/FQDN-urile necesare.
154
+`madagascar.xdev.ro` este domeniul implicit al rețelei interne. În registry se declară doar numele canonice/FQDN-urile necesare.
155 155
 
156 156
 Pentru orice nume `*.madagascar.xdev.ro`, aplicația derivă automat aliasul scurt prin eliminarea sufixului `.madagascar.xdev.ro`.
157 157
 
@@ -164,7 +164,7 @@ Aliasurile derivate nu se declară separat în `hosts.yaml`. Ele apar în API, m
164 164
 
165 165
 ## Git și managementul cheilor
166 166
 
167
-Varianta obligatorie pentru servicii automate este să citească `config/hosts.yaml` din git, nu să depindă de HTTP neautentificat. Serviciile care sincronizează DNS, monitorizare sau inventare primesc chei dedicate, cu acces minim.
167
+Varianta obligatorie pentru servicii automate este să consume exporturi generate și verificate, nu să depindă de HTTP neautentificat. Serviciile care sincronizează DNS, monitorizare sau inventare primesc chei dedicate, cu acces minim.
168 168
 
169 169
 Reguli:
170 170
 
@@ -178,20 +178,16 @@ Reguli:
178 178
 Modelul recomandat:
179 179
 
180 180
 ```text
181
-git repo
182
-  config/hosts.yaml        sursă versionată
183
-  config/local-hosts.tsv   manifest generat/versionat pentru DNS local
184
-  config/work-orders.yaml  operațiuni confirmabile/versionate
185
-
186 181
 jumper
187
-  host-manager             editează working tree cu OTP
182
+  var/host-manager.sqlite  sursă runtime pentru registry și Work Orders
183
+  host-manager             editează SQLite cu OTP
188 184
   sync_local_hosts.sh      aplică DNS după review/verificare
189 185
 
190 186
 servicii consumatoare
191
-  git pull read-only       citesc hosts.yaml/local-hosts.tsv
187
+  export verificat         citesc hosts.yaml/local-hosts.tsv/monitoring.json
192 188
 ```
193 189
 
194
-Pentru etapa MVP, aplicația nu face commit/push automat. După o modificare, schimbarea rămâne vizibilă în working tree și se comite explicit după review. Automatizarea commit/push poate fi adăugată ulterior, dar numai cu cheie separată și reguli clare de semnare/audit.
190
+Pentru etapa MVP, aplicația nu face commit/push automat. După o modificare, schimbarea rămâne în SQLite și poate fi exportată explicit pentru review/arhivare. Automatizarea commit/push pentru exporturi poate fi adăugată ulterior, dar numai cu cheie separată și reguli clare de semnare/audit.
195 191
 
196 192
 ## Autoritate locală de certificate
197 193
 
@@ -228,5 +224,5 @@ Reguli:
228 224
 ## Limitări MVP
229 225
 
230 226
 - Parserul YAML acceptă schema strictă generată de aplicație, nu YAML arbitrar.
231
-- Conflict engine-ul verifică doar consistența locală din `hosts.yaml`.
227
+- Conflict engine-ul verifică doar consistența locală din registry-ul SQLite.
232 228
 - DHCP, mDNS, `hosts-local.yaml` și `madagascar.json` sunt încă surse pentru audit manual sau pentru următorul collector.
+11 -10
.doc/local-hosts.md
@@ -26,11 +26,12 @@ Implementarea versionată este:
26 26
 
27 27
 | Fișier | Rol |
28 28
 |--------|-----|
29
-| `config/hosts.yaml` | registrul versionat pentru hosturi și FQDN-uri canonice |
29
+| `var/host-manager.sqlite` | sursa de adevăr runtime pentru registry și Work Orders |
30
+| `config/hosts.yaml` | seed/snapshot export pentru hosturi și FQDN-uri canonice |
30 31
 | `config/local-hosts.tsv` | manifest DNS generat, cu aliasuri scurte derivate |
31 32
 | `scripts/sync_local_hosts.sh` | generează și sincronizează `/etc/hosts`, `cloaking-rules.txt` și `/ip dns static` |
32 33
 
33
-`madagascar.xdev.ro` este domeniul implicit. Pentru orice nume `*.madagascar.xdev.ro`, aliasul scurt este derivat automat. De exemplu, `autonas01.madagascar.xdev.ro` publică și `autonas01`, iar `pmx.baobab.madagascar.xdev.ro` publică și `pmx.baobab`. Aliasurile derivate nu se declară separat în `config/hosts.yaml`.
34
+`madagascar.xdev.ro` este domeniul implicit. Pentru orice nume `*.madagascar.xdev.ro`, aliasul scurt este derivat automat. De exemplu, `autonas01.madagascar.xdev.ro` publică și `autonas01`, iar `pmx.baobab.madagascar.xdev.ro` publică și `pmx.baobab`. Aliasurile derivate nu se declară separat în registry.
34 35
 
35 36
 ## Ierarhia surselor
36 37
 
@@ -38,10 +39,11 @@ Când inventarele se contrazic, ordinea de încredere este:
38 39
 
39 40
 1. DHCP lease/reservation pe router (`admin@192.168.2.1`) — autoritatea pentru alocarea IP-urilor pe LAN. Configurațiile statice locale nu au voie să mute un IP peste DHCP; cel mult semnalează o rezervare lipsă sau o intrare veche.
40 41
 2. `cluster/cluster-context/madagascar.json` — autoritatea pentru roluri, topologie și IP-uri de serviciu. Pentru nodurile Proxmox, DNS-ul de serviciu poate folosi interfața `thunderbridge` (`192.168.10.x`) chiar dacă management/WAN este `192.168.2.x`.
41
-3. `config/local-hosts.tsv` — manifestul DNS local publicat pe jumper și as01. Acesta trebuie să fie derivat sau validat din DHCP plus topologia clusterului, nu folosit ca sursă primară de alocare IP.
42
-4. `hosts-local.yaml` — inventar SSH: aliasuri, utilizatori, entrypoint-uri și căi de acces. IP-urile de aici sunt utile pentru audit, dar pot fi stale dacă DHCP spune altceva.
43
-5. mDNS (`*.local`) — sursă observată de descoperire și validare. Confirmă prezența unui host sau propune aliasuri, dar nu creează automat intrări `madagascar.xdev.ro`.
44
-6. DNS public — folosit doar pentru acces extern. Local, numele interne trebuie shadow-uite exact sau lăsate nerezolvate; wildcard-ul public nu este autoritate pentru LAN.
42
+3. `var/host-manager.sqlite` — registry-ul operațional aprobat, editat prin Madagascar Local Authority.
43
+4. `config/local-hosts.tsv` — manifestul DNS local publicat pe jumper și as01. Acesta trebuie să fie derivat sau validat din DHCP plus topologia clusterului, nu folosit ca sursă primară de alocare IP.
44
+5. `hosts-local.yaml` — inventar SSH: aliasuri, utilizatori, entrypoint-uri și căi de acces. IP-urile de aici sunt utile pentru audit, dar pot fi stale dacă DHCP spune altceva.
45
+6. mDNS (`*.local`) — sursă observată de descoperire și validare. Confirmă prezența unui host sau propune aliasuri, dar nu creează automat intrări `madagascar.xdev.ro`.
46
+7. DNS public — folosit doar pentru acces extern. Local, numele interne trebuie shadow-uite exact sau lăsate nerezolvate; wildcard-ul public nu este autoritate pentru LAN.
45 47
 
46 48
 Reguli de împăcare:
47 49
 
@@ -61,7 +63,7 @@ Locația implicită:
61 63
 var/mdns-observations.yaml
62 64
 ```
63 65
 
64
-Regulă importantă: `config/hosts.yaml` este un output generat din surse, nu store-ul primar al listenerului. Listenerul mDNS nu modifică `config/hosts.yaml` și nu publică nimic direct în `config/local-hosts.tsv`.
66
+Regulă importantă: listenerul mDNS nu modifică registry-ul SQLite, `config/hosts.yaml` sau `config/local-hosts.tsv`. Seed-ul mDNS rămâne observație separată până la review.
65 67
 
66 68
 Rulare manuală pentru test:
67 69
 
@@ -102,9 +104,8 @@ Aplică și verifică:
102 104
 ## Pași pentru un hostname nou
103 105
 
104 106
 ```bash
105
-# 1. Adaugă hostul în config/local-hosts.tsv
106
-# Format: hosts_ip<TAB>dns_ip<TAB>name alias...
107
-192.168.2.XXX   192.168.2.XXX   host.madagascar.xdev.ro host
107
+# 1. Adaugă hostul în Madagascar Local Authority.
108
+# Aplicația păstrează registry-ul în var/host-manager.sqlite.
108 109
 
109 110
 # 2. Aplică pe ambele resolvere
110 111
 ./scripts/sync_local_hosts.sh --apply --verify
+6 -5
README.md
@@ -16,9 +16,10 @@ git@192.168.2.102:repositories/bogdan/LocalAuthority.git
16 16
 
17 17
 The runtime instance lives on jumper and remains the local source for operational registry data:
18 18
 
19
-- `config/hosts.yaml` - git-versioned host registry
19
+- `var/host-manager.sqlite` - runtime source of truth for host registry and Work Orders
20
+- `config/hosts.yaml` - seed/snapshot export for host registry compatibility
20 21
 - `config/local-hosts.tsv` - DNS manifest exported for local resolvers
21
-- `config/work-orders.yaml` - confirmable operational changes
22
+- `config/work-orders.yaml` - seed/snapshot export for confirmable operational changes
22 23
 - `scripts/host_manager.pl` - Perl-only web app
23 24
 - `scripts/sync_local_hosts.sh` - local DNS sync to jumper and as01
24 25
 - `scripts/ca_manager.sh` - local OpenSSL CA helper for host certificates
@@ -35,7 +36,7 @@ Secrets live outside git in `/etc/xdev/host-manager.env`.
35 36
 
36 37
 The product name is **Madagascar Local Authority**. The technical service, Unix user, repository path, and environment files still use `host-manager`.
37 38
 
38
-The web UI is OTP-protected for all registry data, downloads, exports, and writes. Automation should consume this repository through git with dedicated read-only keys, not through unauthenticated HTTP.
39
+The web UI is OTP-protected for all registry data, downloads, exports, and writes. Automation should consume generated exports with dedicated read-only access, not unauthenticated HTTP.
39 40
 
40 41
 For agent/operator context, see:
41 42
 
@@ -76,7 +77,7 @@ git push origin main
76 77
 tool, but the normal development loop is commit plus push: `jumper-runtime` for
77 78
 live testing, `origin`/GitPrep for archive and sharing.
78 79
 
79
-`config/` is not deployed by default because `hosts.yaml`, `local-hosts.tsv`, and `work-orders.yaml` are operational data that may be changed on jumper by the application. Deploy config only when intentionally replacing runtime registry data:
80
+`config/` is not deployed by default. The live source of truth is `var/host-manager.sqlite`; `hosts.yaml`, `local-hosts.tsv`, and `work-orders.yaml` are seed/snapshot/export files that should not replace runtime data during normal code pushes. Deploy config only when intentionally replacing seed/export files:
80 81
 
81 82
 ```bash
82 83
 scripts/deploy_to_jumper.sh --include-config
@@ -84,7 +85,7 @@ scripts/deploy_to_jumper.sh --include-config
84 85
 
85 86
 The default internal domain is `madagascar.xdev.ro`. Short aliases are derived automatically from FQDNs, so `autonas01.madagascar.xdev.ro` also publishes `autonas01` without declaring it separately.
86 87
 
87
-Name removals with operational impact go through a Work Order. A WO records intent first; the operational checklist must be completed before confirmation can update `hosts.yaml`, mark the WO as confirmed, and regenerate `local-hosts.tsv`. Resolver sync remains an explicit operator step.
88
+Name removals with operational impact go through a Work Order. A WO records intent first; the operational checklist must be completed before confirmation can update the SQLite registry, mark the WO as confirmed, and regenerate `local-hosts.tsv`. Resolver sync remains an explicit operator step.
88 89
 
89 90
 The local host CA stores private material outside git under `var/ca`. Initialize it on jumper with:
90 91
 
+3 -2
agents.md
@@ -18,8 +18,9 @@ Operational rules:
18 18
 - Push committed code to `jumper-runtime` for live testing on jumper.
19 19
 - Push committed code to `origin`/GitPrep for archival/canonical history.
20 20
 - `scripts/deploy_to_jumper.sh` is available for explicit rsync deploys, but the normal development loop is commit plus push.
21
-- Do not deploy `config/` unless the user explicitly asks to replace runtime registry data.
22
-- Treat `config/hosts.yaml`, `config/local-hosts.tsv`, and `config/work-orders.yaml` as operational data that may be changed by the live app.
21
+- Runtime registry and Work Orders live in `var/host-manager.sqlite` on jumper.
22
+- Do not deploy `config/` unless the user explicitly asks to replace seed/snapshot/export files.
23
+- Treat `config/hosts.yaml`, `config/local-hosts.tsv`, and `config/work-orders.yaml` as compatibility exports/seeds, not the live source of truth.
23 24
 - Do not install npm, pip, or CPAN packages directly on hosts. Distribution packages are acceptable when needed.
24 25
 - Perl from the distribution and core/distribution modules are allowed.
25 26
 - CPAN modules are allowed only after requesting an audit and RPM packaging for the local audited repository.
+2 -1
config/hosts.yaml
@@ -4,7 +4,8 @@ policy:
4 4
   ip_authority: "dhcp"
5 5
   topology_authority: "madagascar.json"
6 6
   dns_manifest: "config/local-hosts.tsv"
7
-  storage_authority: "git"
7
+  storage_authority: "sqlite"
8
+  runtime_database: "var/host-manager.sqlite"
8 9
   consumer_access: "read-only deploy keys"
9 10
 hosts:
10 11
   - id: "baobab"
+8 -1
deploy/jumper/README.md
@@ -15,6 +15,8 @@ Instanța curentă este instalată pe jumper în `/usr/local/xdev-host-manager`
15 15
 Se folosesc doar pachete din distribuție:
16 16
 
17 17
 - `perl`
18
+- `perl-DBI` / `perl-DBD-SQLite` dacă nu sunt deja disponibile
19
+- `sqlite`
18 20
 - `nginx`
19 21
 
20 22
 Nu se instalează npm, pip sau CPAN direct pe host.
@@ -31,6 +33,7 @@ sudo dnf install nginx
31 33
 /usr/local/xdev-host-manager
32 34
   config/hosts.yaml
33 35
   config/local-hosts.tsv
36
+  var/host-manager.sqlite
34 37
   var/mdns-observations.yaml
35 38
   scripts/host_manager.pl
36 39
   scripts/mdns_host_seed.pl
@@ -93,6 +96,10 @@ hosts.madagascar.xdev.ro -> 192.168.2.100
93 96
 
94 97
 Nu se adaugă wildcard local. Doar acest nume exact trebuie publicat.
95 98
 
99
+## Runtime store
100
+
101
+`var/host-manager.sqlite` este sursa de adevăr pentru registry și Work Orders. La prima pornire, aplicația seed-uiește documentele lipsă din `config/hosts.yaml` și `config/work-orders.yaml`; ulterior push-urile de cod nu trebuie să înlocuiască baza runtime.
102
+
96 103
 ## mDNS discovery
97 104
 
98
-`host-manager-mdns` este un listener separat care observă mDNS și seeduiește `var/mdns-observations.yaml`. `config/hosts.yaml` rămâne output generat din surse și nu este modificat direct de listener.
105
+`host-manager-mdns` este un listener separat care observă mDNS și seeduiește `var/mdns-observations.yaml`. Listenerul nu modifică registry-ul SQLite, `config/hosts.yaml` sau `config/local-hosts.tsv`.
+1 -0
deploy/jumper/host-manager.env.example
@@ -3,6 +3,7 @@
3 3
 
4 4
 HOST_MANAGER_BIND=127.0.0.1
5 5
 HOST_MANAGER_PORT=8088
6
+HOST_MANAGER_DB=/usr/local/xdev-host-manager/var/host-manager.sqlite
6 7
 HOST_MANAGER_DATA=/usr/local/xdev-host-manager/config/hosts.yaml
7 8
 HOST_MANAGER_LOCAL_HOSTS_TSV=/usr/local/xdev-host-manager/config/local-hosts.tsv
8 9
 
+104 -13
scripts/host_manager.pl
@@ -7,6 +7,7 @@ use strict;
7 7
 use warnings;
8 8
 
9 9
 use Cwd qw(abs_path);
10
+use DBI;
10 11
 use Digest::SHA qw(hmac_sha1 hmac_sha256_hex sha256_hex);
11 12
 use File::Basename qw(dirname);
12 13
 use File::Path qw(make_path);
@@ -20,6 +21,7 @@ my $project_dir = dirname($script_dir);
20 21
 my %opt = (
21 22
     bind => $ENV{HOST_MANAGER_BIND} || '127.0.0.1',
22 23
     port => $ENV{HOST_MANAGER_PORT} || 8088,
24
+    db => $ENV{HOST_MANAGER_DB} || "$project_dir/var/host-manager.sqlite",
23 25
     data => $ENV{HOST_MANAGER_DATA} || "$project_dir/config/hosts.yaml",
24 26
     local_hosts_tsv => $ENV{HOST_MANAGER_LOCAL_HOSTS_TSV} || "$project_dir/config/local-hosts.tsv",
25 27
     work_orders => $ENV{HOST_MANAGER_WORK_ORDERS} || "$project_dir/config/work-orders.yaml",
@@ -31,6 +33,8 @@ while (@ARGV) {
31 33
         $opt{bind} = shift @ARGV;
32 34
     } elsif ($arg eq '--port') {
33 35
         $opt{port} = shift @ARGV;
36
+    } elsif ($arg eq '--db') {
37
+        $opt{db} = shift @ARGV;
34 38
     } elsif ($arg eq '--data') {
35 39
         $opt{data} = shift @ARGV;
36 40
     } elsif ($arg eq '--local-hosts-tsv') {
@@ -57,7 +61,8 @@ my $server = IO::Socket::INET->new(
57 61
 ) or die "Cannot listen on $opt{bind}:$opt{port}: $!\n";
58 62
 
59 63
 print "host-manager listening on http://$opt{bind}:$opt{port}\n";
60
-print "data file: $opt{data}\n";
64
+print "database: $opt{db}\n";
65
+print "seed/export hosts file: $opt{data}\n";
61 66
 print "OTP login: " . ($ENV{HOST_MANAGER_TOTP_SECRET} ? "enabled\n" : "disabled; set HOST_MANAGER_TOTP_SECRET\n");
62 67
 
63 68
 while (my $client = $server->accept) {
@@ -78,11 +83,14 @@ Usage: perl scripts/host_manager.pl [--bind 127.0.0.1] [--port 8088]
78 83
 Environment:
79 84
   HOST_MANAGER_TOTP_SECRET      Base32 TOTP secret required for write access.
80 85
   HOST_MANAGER_SESSION_SECRET   Optional session signing secret.
86
+  HOST_MANAGER_DB               Defaults to var/host-manager.sqlite.
81 87
   HOST_MANAGER_DATA             Defaults to config/hosts.yaml.
82 88
   HOST_MANAGER_LOCAL_HOSTS_TSV  Defaults to config/local-hosts.tsv.
83 89
   HOST_MANAGER_WORK_ORDERS      Defaults to config/work-orders.yaml.
84 90
 
85
-The nginx vhost keeps registry, CA, work order and download endpoints behind OTP.
91
+SQLite is the runtime source of truth. YAML files seed a new database and remain
92
+download/export compatibility artifacts. The nginx vhost keeps registry, CA,
93
+work order and download endpoints behind OTP.
86 94
 EOF
87 95
 }
88 96
 
@@ -144,7 +152,8 @@ sub handle_client {
144 152
         return send_json($client, 200, work_orders_payload(load_work_orders()));
145 153
     }
146 154
     if ($method eq 'GET' && $path eq '/download/hosts.yaml') {
147
-        return send_file($client, $opt{data}, 'application/x-yaml; charset=utf-8', 'hosts.yaml');
155
+        my $registry = load_registry();
156
+        return send_download($client, 200, render_hosts_yaml($registry), 'application/x-yaml; charset=utf-8', 'hosts.yaml');
148 157
     }
149 158
     if ($method eq 'GET' && $path eq '/download/local-hosts.tsv') {
150 159
         my $registry = load_registry();
@@ -203,25 +212,25 @@ sub app_page_path {
203 212
 }
204 213
 
205 214
 sub load_registry {
206
-    return parse_hosts_yaml(read_file($opt{data}));
215
+    my $registry = parse_hosts_yaml(load_operational_doc('hosts_yaml', $opt{data}, default_hosts_yaml()));
216
+    normalize_registry_policy($registry);
217
+    return $registry;
207 218
 }
208 219
 
209 220
 sub save_registry {
210 221
     my ($registry) = @_;
211 222
     $registry->{updated_at} = iso_now();
212
-    backup_file($opt{data});
213
-    write_file($opt{data}, render_hosts_yaml($registry));
223
+    normalize_registry_policy($registry);
224
+    save_operational_doc('hosts_yaml', render_hosts_yaml($registry));
214 225
 }
215 226
 
216 227
 sub load_work_orders {
217
-    return { version => 1, work_orders => [] } unless -f $opt{work_orders};
218
-    return parse_work_orders_yaml(read_file($opt{work_orders}));
228
+    return parse_work_orders_yaml(load_operational_doc('work_orders_yaml', $opt{work_orders}, default_work_orders_yaml()));
219 229
 }
220 230
 
221 231
 sub save_work_orders {
222 232
     my ($orders) = @_;
223
-    backup_file($opt{work_orders});
224
-    write_file($opt{work_orders}, render_work_orders_yaml($orders));
233
+    save_operational_doc('work_orders_yaml', render_work_orders_yaml($orders));
225 234
 }
226 235
 
227 236
 sub work_orders_payload {
@@ -501,7 +510,7 @@ sub problem {
501 510
 sub render_local_hosts_tsv {
502 511
     my ($registry) = @_;
503 512
     my $out = "# Local DNS manifest for the madagascar network.\n";
504
-    $out .= "# Generated by scripts/host_manager.pl from config/hosts.yaml.\n";
513
+    $out .= "# Generated by scripts/host_manager.pl from the runtime SQLite registry.\n";
505 514
     $out .= "#\n";
506 515
     $out .= "# Format:\n";
507 516
     $out .= "# hosts_ip<TAB>dns_ip<TAB>name [aliases...]\n";
@@ -541,7 +550,7 @@ sub render_monitoring {
541 550
     return {
542 551
         version => $registry->{version},
543 552
         generated_at => iso_now(),
544
-        source => 'config/hosts.yaml',
553
+        source => $opt{db},
545 554
         hosts => \@hosts,
546 555
     };
547 556
 }
@@ -1118,6 +1127,88 @@ sub backup_file {
1118 1127
     write_file("$backup_dir/$name.$stamp.bak", read_file($path));
1119 1128
 }
1120 1129
 
1130
+my $db_handle;
1131
+
1132
+sub dbh {
1133
+    return $db_handle if $db_handle;
1134
+    ensure_parent_dir($opt{db});
1135
+    $db_handle = DBI->connect(
1136
+        "dbi:SQLite:dbname=$opt{db}",
1137
+        '',
1138
+        '',
1139
+        {
1140
+            RaiseError => 1,
1141
+            PrintError => 0,
1142
+            AutoCommit => 1,
1143
+            sqlite_unicode => 1,
1144
+        },
1145
+    ) or die "Cannot open SQLite database $opt{db}\n";
1146
+    $db_handle->do('PRAGMA journal_mode = WAL');
1147
+    $db_handle->do('PRAGMA foreign_keys = ON');
1148
+    $db_handle->do(<<'SQL');
1149
+CREATE TABLE IF NOT EXISTS documents (
1150
+    name TEXT PRIMARY KEY,
1151
+    content TEXT NOT NULL,
1152
+    updated_at TEXT NOT NULL
1153
+)
1154
+SQL
1155
+    return $db_handle;
1156
+}
1157
+
1158
+sub load_operational_doc {
1159
+    my ($name, $seed_path, $default_text) = @_;
1160
+    my $dbh = dbh();
1161
+    my $row = $dbh->selectrow_hashref('SELECT content FROM documents WHERE name = ?', undef, $name);
1162
+    return $row->{content} if $row;
1163
+
1164
+    my $content = -f $seed_path ? read_file($seed_path) : $default_text;
1165
+    save_operational_doc($name, $content);
1166
+    return $content;
1167
+}
1168
+
1169
+sub save_operational_doc {
1170
+    my ($name, $content) = @_;
1171
+    my $dbh = dbh();
1172
+    $dbh->do(
1173
+        'INSERT INTO documents (name, content, updated_at) VALUES (?, ?, ?) '
1174
+        . 'ON CONFLICT(name) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at',
1175
+        undef,
1176
+        $name,
1177
+        $content,
1178
+        iso_now(),
1179
+    );
1180
+}
1181
+
1182
+sub normalize_registry_policy {
1183
+    my ($registry) = @_;
1184
+    $registry->{policy} ||= {};
1185
+    $registry->{policy}{storage_authority} = 'sqlite';
1186
+    $registry->{policy}{runtime_database} = $opt{db};
1187
+}
1188
+
1189
+sub default_hosts_yaml {
1190
+    return <<'YAML';
1191
+version: 1
1192
+updated_at: ""
1193
+policy:
1194
+  storage_authority: "sqlite"
1195
+hosts:
1196
+YAML
1197
+}
1198
+
1199
+sub default_work_orders_yaml {
1200
+    return <<'YAML';
1201
+version: 1
1202
+work_orders:
1203
+YAML
1204
+}
1205
+
1206
+sub ensure_parent_dir {
1207
+    my ($path) = @_;
1208
+    my $dir = dirname($path);
1209
+    make_path($dir) unless -d $dir;
1210
+}
1211
+
1121 1212
 sub url_decode {
1122 1213
     my ($value) = @_;
1123 1214
     $value = '' unless defined $value;
@@ -2266,7 +2357,7 @@ sub app_html {
2266 2357
     });
2267 2358
 
2268 2359
     $('write-tsv').addEventListener('click', async () => {
2269
-      if (!confirm('Write config/local-hosts.tsv from hosts.yaml?')) return;
2360
+      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
2270 2361
       try {
2271 2362
         await api('/api/render/local-hosts-tsv', { method: 'POST' });
2272 2363
         msg('local-hosts.tsv written');