Showing 5 changed files with 1188 additions and 184 deletions
+319 -157
.doc/database.md
@@ -1,6 +1,6 @@
1 1
 # SQLite Database
2 2
 
3
-Madagascar Local Authority folosește SQLite ca store runtime pentru registry și Work Orders.
3
+Madagascar Local Authority folosește SQLite ca sursă de adevăr runtime pentru hosturi, aliasuri, vhosturi, Work Orders, workeri de date și certificate.
4 4
 
5 5
 Locația implicită în checkout:
6 6
 
@@ -20,191 +20,377 @@ Path-ul poate fi schimbat cu:
20 20
 HOST_MANAGER_DB=/path/to/host-manager.sqlite
21 21
 ```
22 22
 
23
-## Rol
23
+## Principii
24 24
 
25
-SQLite este sursa de adevăr runtime. Fișierele din `config/` nu mai sunt store-ul live:
25
+Hosturile sunt identificate prin FQDN complet, nu prin short name. Exemplu: `gw.local` și `gw.remote` sunt identități diferite. Coloana compatibilă `legacy_id` păstrează ID-ul scurt folosit de UI-ul curent, dar cheia reală este `hosts.fqdn`.
26 26
 
27
-- `config/hosts.yaml` seed-uiește documentul `hosts_yaml` dacă baza este nouă.
28
-- `config/work-orders.yaml` seed-uiește documentul `work_orders_yaml` dacă baza este nouă.
29
-- `config/local-hosts.tsv` este manifest generat explicit din registry-ul SQLite.
30
-- `/download/hosts.yaml`, `/download/local-hosts.tsv` și `/download/monitoring.json` sunt randate din SQLite.
27
+Schema evită să transforme `hosts` într-un tabel cu prea multe coloane. Datele specializate sunt în tabele separate:
31 28
 
32
-Aplicația nu face commit/push automat după modificări. Exportul și arhivarea rămân pași operaționali separați.
29
+- aliasuri: `host_aliases`
30
+- roluri: `host_roles`
31
+- surse: `host_sources`
32
+- flaguri: `host_flags`
33
+- SSH: `host_ssh`
34
+- vhosturi mutabile: `vhosts`
35
+- certificate: `certificates`, `certificate_dns_names`
36
+- workeri și observații: `data_workers`, `dhcp_leases`, `mdns_observations`
33 37
 
34
-## Schema curentă
38
+`documents` rămâne doar tabel legacy pentru migrarea din modelul vechi document-store. Aplicația nu îl mai folosește ca sursă de adevăr.
35 39
 
36
-Schema este creată automat de `scripts/host_manager.pl` la prima citire/scriere a store-ului.
40
+## Schema Version
41
+
42
+Schema curentă este versiunea `2`.
43
+
44
+```sql
45
+schema_meta('schema_version') = '2'
46
+```
47
+
48
+`schema_meta` păstrează și metadate runtime precum `registry_updated_at`.
49
+
50
+## Catalog
51
+
52
+| Tabel | Rol |
53
+|-------|-----|
54
+| `schema_meta` | metadate de schemă/runtime |
55
+| `documents` | document-store legacy pentru migrare |
56
+| `hosts` | hosturi canonice, identificate prin FQDN |
57
+| `host_aliases` | aliasuri păstrate inclusiv după retragere |
58
+| `host_roles` | roluri active/retrase per host |
59
+| `host_sources` | surse active/retrase per host |
60
+| `host_flags` | flaguri extensibile per host |
61
+| `host_ssh` | profile SSH per host |
62
+| `vhosts` | vhosturi mutabile între hosturi |
63
+| `data_workers` | workeri/surse care colectează date |
64
+| `dhcp_leases` | date observate din DHCP lease/reservation |
65
+| `mdns_observations` | date observate din mDNS |
66
+| `certificates` | certificate emise de CA locală |
67
+| `certificate_dns_names` | SAN DNS names pentru certificate |
68
+| `work_orders` | Work Orders |
69
+| `work_order_checklist` | checklist items pentru Work Orders |
70
+| `work_order_actions` | acțiuni confirmabile pentru Work Orders |
71
+
72
+## Tabele
73
+
74
+### `hosts`
37 75
 
38 76
 ```sql
39
-CREATE TABLE IF NOT EXISTS documents (
40
-    name TEXT PRIMARY KEY,
41
-    content TEXT NOT NULL,
77
+CREATE TABLE hosts (
78
+    fqdn TEXT PRIMARY KEY,
79
+    legacy_id TEXT NOT NULL UNIQUE,
80
+    status TEXT NOT NULL DEFAULT 'active',
81
+    hosts_ip TEXT NOT NULL DEFAULT '',
82
+    dns_ip TEXT NOT NULL DEFAULT '',
83
+    monitoring TEXT NOT NULL DEFAULT 'pending',
84
+    notes TEXT NOT NULL DEFAULT '',
85
+    created_at TEXT NOT NULL,
42 86
     updated_at TEXT NOT NULL
43 87
 );
44 88
 ```
45 89
 
46
-### Catalog
90
+Chei și indexuri:
47 91
 
48
-| Obiect | Tip | Rol |
49
-|--------|-----|-----|
50
-| `documents` | table | Document-store runtime pentru registry și Work Orders |
51
-| `sqlite_autoindex_documents_1` | index intern SQLite | Indexul automat creat pentru `PRIMARY KEY (name)` |
92
+- PK: `hosts(fqdn)`
93
+- UNIQUE: `hosts(legacy_id)`
52 94
 
53
-Nu există alte tabele, view-uri, trigger-e sau indexuri definite de aplicație în schema curentă.
95
+### `host_aliases`
54 96
 
55
-### Tabel: `documents`
56
-
57
-| Coloană | Tip declarat | Null | Default | Cheie | Descriere |
58
-|---------|--------------|------|---------|-------|-----------|
59
-| `name` | `TEXT` | `NOT NULL` implicit prin PK | none | `PRIMARY KEY` | Identificatorul documentului operațional. Valorile cunoscute sunt `hosts_yaml` și `work_orders_yaml`. |
60
-| `content` | `TEXT` | `NOT NULL` | none | none | Payload-ul documentului, serializat ca YAML strict compatibil cu parserul aplicației. |
61
-| `updated_at` | `TEXT` | `NOT NULL` | none | none | Timestamp ISO UTC setat de aplicație la insert/update. |
97
+```sql
98
+CREATE TABLE host_aliases (
99
+    alias_name TEXT NOT NULL,
100
+    host_fqdn TEXT NOT NULL REFERENCES hosts(fqdn)
101
+        ON UPDATE CASCADE ON DELETE RESTRICT,
102
+    alias_kind TEXT NOT NULL DEFAULT 'declared',
103
+    status TEXT NOT NULL DEFAULT 'active',
104
+    is_dns_published INTEGER NOT NULL DEFAULT 1,
105
+    created_at TEXT NOT NULL,
106
+    retired_at TEXT,
107
+    notes TEXT NOT NULL DEFAULT '',
108
+    PRIMARY KEY (alias_name, host_fqdn)
109
+);
110
+```
62 111
 
63
-### Chei
112
+Indexuri:
64 113
 
65
-| Tabel | Cheie | Coloane | Observații |
66
-|-------|------|---------|------------|
67
-| `documents` | Primary key | `name` | Garantează un singur rând pentru fiecare document operațional. |
114
+```sql
115
+CREATE UNIQUE INDEX idx_host_aliases_active_name
116
+ON host_aliases(alias_name)
117
+WHERE status = 'active';
68 118
 
69
-SQLite implementează cheia primară textuală prin indexul intern `sqlite_autoindex_documents_1`.
119
+CREATE INDEX idx_host_aliases_host_status
120
+ON host_aliases(host_fqdn, status);
121
+```
70 122
 
71
-### Indexuri
123
+Reguli:
72 124
 
73
-| Index | Tabel | Coloane | Unic | Creat de | Rol |
74
-|-------|-------|---------|------|----------|-----|
75
-| `sqlite_autoindex_documents_1` | `documents` | `name` | da | SQLite | Lookup rapid pentru `SELECT ... WHERE name = ?` și enforcement pentru primary key. |
125
+- aliasurile retrase nu se șterg; se setează `status = 'retired'`
126
+- un alias activ poate aparține unui singur host
127
+- aliasurile scurte derivate, cum ar fi `baobab`, sunt păstrate cu `alias_kind = 'derived'`
128
+- short aliases derivate din vhosturi, cum ar fi `pmx.baobab`, sunt păstrate cu `alias_kind = 'derived-vhost'`
76 129
 
77
-Nu există indexuri explicite definite de aplicație. Nu există index pe `updated_at`, pentru că aplicația nu face query-uri de tip listare istorică sau filtrare temporală.
130
+### `vhosts`
78 131
 
79
-### Relații
132
+```sql
133
+CREATE TABLE vhosts (
134
+    vhost_fqdn TEXT PRIMARY KEY,
135
+    host_fqdn TEXT NOT NULL REFERENCES hosts(fqdn)
136
+        ON UPDATE CASCADE ON DELETE RESTRICT,
137
+    status TEXT NOT NULL DEFAULT 'active',
138
+    service_name TEXT NOT NULL DEFAULT '',
139
+    upstream_url TEXT NOT NULL DEFAULT '',
140
+    tls_mode TEXT NOT NULL DEFAULT 'local-ca',
141
+    certificate_id TEXT REFERENCES certificates(certificate_id)
142
+        ON UPDATE CASCADE ON DELETE SET NULL,
143
+    notes TEXT NOT NULL DEFAULT '',
144
+    created_at TEXT NOT NULL,
145
+    updated_at TEXT NOT NULL
146
+);
147
+```
80 148
 
81
-Nu există foreign keys între tabele, pentru că schema curentă are o singură tabelă.
149
+Indexuri:
82 150
 
83
-Relațiile de business sunt în interiorul YAML-ului din `content`, nu modelate relational. De exemplu:
151
+```sql
152
+CREATE INDEX idx_vhosts_host_status
153
+ON vhosts(host_fqdn, status);
154
+```
84 155
 
85
-- hosturile, numele, rolurile și sursele sunt structuri YAML în documentul `hosts_yaml`
86
-- checklist-ul și acțiunile unui Work Order sunt structuri YAML în documentul `work_orders_yaml`
87
-- acțiunile Work Order referă `host_id` textual în YAML, nu printr-un `FOREIGN KEY`
156
+Un vhost se mută de pe un host pe altul prin update pe `vhosts.host_fqdn`. Vhosturile retrase rămân în tabel cu `status = 'retired'`.
88 157
 
89
-`PRAGMA foreign_keys = ON` este activat ca pregătire pentru o schemă viitoare cu tabele relaționale, dar în schema curentă nu are efect practic.
158
+### `host_roles`, `host_sources`, `host_flags`, `host_ssh`
90 159
 
91
-### Constrângeri
160
+```sql
161
+CREATE TABLE host_roles (
162
+    host_fqdn TEXT NOT NULL REFERENCES hosts(fqdn)
163
+        ON UPDATE CASCADE ON DELETE RESTRICT,
164
+    role TEXT NOT NULL,
165
+    status TEXT NOT NULL DEFAULT 'active',
166
+    created_at TEXT NOT NULL,
167
+    retired_at TEXT,
168
+    PRIMARY KEY (host_fqdn, role)
169
+);
92 170
 
93
-| Tabel | Constrângere | Efect |
94
-|-------|--------------|-------|
95
-| `documents` | `PRIMARY KEY (name)` | Nu permite două documente cu același `name`. |
96
-| `documents` | `content TEXT NOT NULL` | Nu permite documente fără payload. |
97
-| `documents` | `updated_at TEXT NOT NULL` | Nu permite rânduri fără timestamp de modificare. |
171
+CREATE TABLE host_sources (
172
+    host_fqdn TEXT NOT NULL REFERENCES hosts(fqdn)
173
+        ON UPDATE CASCADE ON DELETE RESTRICT,
174
+    source TEXT NOT NULL,
175
+    status TEXT NOT NULL DEFAULT 'active',
176
+    created_at TEXT NOT NULL,
177
+    retired_at TEXT,
178
+    PRIMARY KEY (host_fqdn, source)
179
+);
98 180
 
99
-Nu există `CHECK` constraints pentru valorile permise în `name`, formatul YAML sau formatul timestamp-ului. Aceste validări sunt făcute în codul Perl prin parserul strict YAML și prin fluxurile API.
181
+CREATE TABLE host_flags (
182
+    host_fqdn TEXT NOT NULL REFERENCES hosts(fqdn)
183
+        ON UPDATE CASCADE ON DELETE RESTRICT,
184
+    flag TEXT NOT NULL,
185
+    value TEXT NOT NULL DEFAULT '1',
186
+    created_at TEXT NOT NULL,
187
+    updated_at TEXT NOT NULL,
188
+    PRIMARY KEY (host_fqdn, flag)
189
+);
100 190
 
101
-### Operațiuni SQL folosite de aplicație
191
+CREATE TABLE host_ssh (
192
+    host_fqdn TEXT NOT NULL REFERENCES hosts(fqdn)
193
+        ON UPDATE CASCADE ON DELETE RESTRICT,
194
+    profile_name TEXT NOT NULL DEFAULT 'default',
195
+    username TEXT NOT NULL DEFAULT '',
196
+    port INTEGER NOT NULL DEFAULT 22,
197
+    identity_file TEXT NOT NULL DEFAULT '',
198
+    address TEXT NOT NULL DEFAULT '',
199
+    local_forward_host TEXT NOT NULL DEFAULT '',
200
+    local_forward_port INTEGER,
201
+    remote_forward_host TEXT NOT NULL DEFAULT '',
202
+    remote_forward_port INTEGER,
203
+    notes TEXT NOT NULL DEFAULT '',
204
+    created_at TEXT NOT NULL,
205
+    updated_at TEXT NOT NULL,
206
+    PRIMARY KEY (host_fqdn, profile_name)
207
+);
208
+```
102 209
 
103
-Citire document:
210
+### `data_workers`
104 211
 
105 212
 ```sql
106
-SELECT content FROM documents WHERE name = ?;
213
+CREATE TABLE data_workers (
214
+    worker_id TEXT PRIMARY KEY,
215
+    worker_type TEXT NOT NULL,
216
+    name TEXT NOT NULL DEFAULT '',
217
+    status TEXT NOT NULL DEFAULT 'active',
218
+    source TEXT NOT NULL DEFAULT '',
219
+    last_run_at TEXT,
220
+    notes TEXT NOT NULL DEFAULT '',
221
+    created_at TEXT NOT NULL,
222
+    updated_at TEXT NOT NULL
223
+);
107 224
 ```
108 225
 
109
-Insert/update document:
226
+Indexuri:
110 227
 
111 228
 ```sql
112
-INSERT INTO documents (name, content, updated_at)
113
-VALUES (?, ?, ?)
114
-ON CONFLICT(name) DO UPDATE
115
-SET content = excluded.content,
116
-    updated_at = excluded.updated_at;
229
+CREATE INDEX idx_data_workers_type_status
230
+ON data_workers(worker_type, status);
117 231
 ```
118 232
 
119
-Creare schemă:
233
+Seed implicit:
234
+
235
+- `dhcp-router`, type `dhcp`
236
+- `mdns-listener`, type `mdns`
237
+
238
+### `dhcp_leases`
120 239
 
121 240
 ```sql
122
-CREATE TABLE IF NOT EXISTS documents (
123
-    name TEXT PRIMARY KEY,
124
-    content TEXT NOT NULL,
125
-    updated_at TEXT NOT NULL
241
+CREATE TABLE dhcp_leases (
242
+    lease_key TEXT PRIMARY KEY,
243
+    worker_id TEXT NOT NULL REFERENCES data_workers(worker_id)
244
+        ON UPDATE CASCADE ON DELETE RESTRICT,
245
+    host_fqdn TEXT REFERENCES hosts(fqdn)
246
+        ON UPDATE CASCADE ON DELETE SET NULL,
247
+    observed_name TEXT NOT NULL DEFAULT '',
248
+    ip_address TEXT NOT NULL,
249
+    mac_address TEXT NOT NULL DEFAULT '',
250
+    lease_state TEXT NOT NULL DEFAULT '',
251
+    first_seen TEXT NOT NULL,
252
+    last_seen TEXT NOT NULL,
253
+    raw TEXT NOT NULL DEFAULT ''
126 254
 );
127 255
 ```
128 256
 
129
-Setări aplicate la conectare:
257
+Indexuri:
130 258
 
131 259
 ```sql
132
-PRAGMA journal_mode = WAL;
133
-PRAGMA foreign_keys = ON;
260
+CREATE INDEX idx_dhcp_leases_ip ON dhcp_leases(ip_address);
261
+CREATE INDEX idx_dhcp_leases_mac ON dhcp_leases(mac_address);
262
+CREATE INDEX idx_dhcp_leases_worker_last_seen
263
+ON dhcp_leases(worker_id, last_seen);
134 264
 ```
135 265
 
136
-### Versiune schemă
137
-
138
-Nu există încă tabelă `schema_migrations` sau `schema_version`. Schema este implicit versiunea 1 și este definită de blocul `CREATE TABLE IF NOT EXISTS documents` din `scripts/host_manager.pl`.
139
-
140
-În schema curentă există o singură tabelă generică, `documents`. Conținutul operațional rămâne serializat în formatul YAML strict deja folosit de aplicație. Alegerea este deliberată pentru migrarea minimă: sursa de adevăr se mută din working tree în SQLite fără să schimbăm încă parserul, UI-ul sau formatul exporturilor.
141
-
142
-## Documente
143
-
144
-### `hosts_yaml`
266
+### `mdns_observations`
145 267
 
146
-Conține registry-ul de hosturi în formatul `config/hosts.yaml`.
147
-
148
-Este citit de:
268
+```sql
269
+CREATE TABLE mdns_observations (
270
+    observation_key TEXT PRIMARY KEY,
271
+    worker_id TEXT NOT NULL REFERENCES data_workers(worker_id)
272
+        ON UPDATE CASCADE ON DELETE RESTRICT,
273
+    host_fqdn TEXT REFERENCES hosts(fqdn)
274
+        ON UPDATE CASCADE ON DELETE SET NULL,
275
+    observed_name TEXT NOT NULL,
276
+    ip_address TEXT NOT NULL,
277
+    rr_type TEXT NOT NULL DEFAULT 'A',
278
+    ttl INTEGER NOT NULL DEFAULT 0,
279
+    first_seen TEXT NOT NULL,
280
+    last_seen TEXT NOT NULL,
281
+    seen_count INTEGER NOT NULL DEFAULT 1,
282
+    last_peer TEXT NOT NULL DEFAULT '',
283
+    raw TEXT NOT NULL DEFAULT ''
284
+);
285
+```
149 286
 
150
-- `GET /api/hosts`
151
-- `GET /download/hosts.yaml`
152
-- `GET /download/local-hosts.tsv`
153
-- `GET /download/monitoring.json`
154
-- `POST /api/render/local-hosts-tsv`
287
+Indexuri:
155 288
 
156
-Este scris de:
289
+```sql
290
+CREATE INDEX idx_mdns_observations_name ON mdns_observations(observed_name);
291
+CREATE INDEX idx_mdns_observations_ip ON mdns_observations(ip_address);
292
+CREATE INDEX idx_mdns_observations_worker_last_seen
293
+ON mdns_observations(worker_id, last_seen);
294
+```
157 295
 
158
-- `POST /api/hosts/upsert`
159
-- `POST /api/hosts/delete`
160
-- `POST /api/work-orders/confirm`, când o acțiune modifică registry-ul
296
+### `certificates` și `certificate_dns_names`
161 297
 
162
-Aplicația normalizează la salvare:
298
+```sql
299
+CREATE TABLE certificates (
300
+    certificate_id TEXT PRIMARY KEY,
301
+    host_fqdn TEXT REFERENCES hosts(fqdn)
302
+        ON UPDATE CASCADE ON DELETE SET NULL,
303
+    common_name TEXT NOT NULL DEFAULT '',
304
+    subject TEXT NOT NULL DEFAULT '',
305
+    issuer TEXT NOT NULL DEFAULT '',
306
+    serial TEXT UNIQUE,
307
+    status TEXT NOT NULL DEFAULT 'issued',
308
+    not_before TEXT NOT NULL DEFAULT '',
309
+    not_after TEXT NOT NULL DEFAULT '',
310
+    fingerprint_sha256 TEXT UNIQUE,
311
+    cert_path TEXT NOT NULL DEFAULT '',
312
+    csr_path TEXT NOT NULL DEFAULT '',
313
+    created_at TEXT NOT NULL,
314
+    updated_at TEXT NOT NULL,
315
+    notes TEXT NOT NULL DEFAULT ''
316
+);
163 317
 
164
-```yaml
165
-policy:
166
-  storage_authority: "sqlite"
167
-  runtime_database: "<HOST_MANAGER_DB>"
318
+CREATE TABLE certificate_dns_names (
319
+    certificate_id TEXT NOT NULL REFERENCES certificates(certificate_id)
320
+        ON UPDATE CASCADE ON DELETE CASCADE,
321
+    dns_name TEXT NOT NULL,
322
+    PRIMARY KEY (certificate_id, dns_name)
323
+);
168 324
 ```
169 325
 
170
-### `work_orders_yaml`
326
+Indexuri:
171 327
 
172
-Conține Work Orders în formatul `config/work-orders.yaml`.
328
+```sql
329
+CREATE INDEX idx_certificate_dns_names_dns_name
330
+ON certificate_dns_names(dns_name);
331
+```
173 332
 
174
-Este citit de:
333
+Certificatele emise sunt sincronizate când aplicația citește lista CA prin `ca_manager.sh list-json`.
175 334
 
176
-- `GET /api/work-orders`
177
-- `POST /api/work-orders/checklist`
178
-- `POST /api/work-orders/confirm`
335
+### Work Orders
179 336
 
180
-Este scris de:
337
+```sql
338
+CREATE TABLE work_orders (
339
+    id TEXT PRIMARY KEY,
340
+    status TEXT NOT NULL DEFAULT 'pending',
341
+    title TEXT NOT NULL DEFAULT '',
342
+    reason TEXT NOT NULL DEFAULT '',
343
+    created_at TEXT NOT NULL,
344
+    confirmed_at TEXT NOT NULL DEFAULT '',
345
+    result TEXT NOT NULL DEFAULT '',
346
+    updated_at TEXT NOT NULL
347
+);
181 348
 
182
-- `POST /api/work-orders/checklist`
183
-- `POST /api/work-orders/confirm`
349
+CREATE TABLE work_order_checklist (
350
+    work_order_id TEXT NOT NULL REFERENCES work_orders(id)
351
+        ON UPDATE CASCADE ON DELETE CASCADE,
352
+    item_id TEXT NOT NULL,
353
+    text TEXT NOT NULL DEFAULT '',
354
+    status TEXT NOT NULL DEFAULT 'pending',
355
+    owner TEXT NOT NULL DEFAULT '',
356
+    notes TEXT NOT NULL DEFAULT '',
357
+    updated_at TEXT NOT NULL DEFAULT '',
358
+    PRIMARY KEY (work_order_id, item_id)
359
+);
184 360
 
185
-## Seed inițial
361
+CREATE TABLE work_order_actions (
362
+    work_order_id TEXT NOT NULL REFERENCES work_orders(id)
363
+        ON UPDATE CASCADE ON DELETE CASCADE,
364
+    position INTEGER NOT NULL,
365
+    type TEXT NOT NULL,
366
+    host_fqdn TEXT REFERENCES hosts(fqdn)
367
+        ON UPDATE CASCADE ON DELETE SET NULL,
368
+    host_legacy_id TEXT NOT NULL DEFAULT '',
369
+    name TEXT NOT NULL DEFAULT '',
370
+    payload TEXT NOT NULL DEFAULT '',
371
+    PRIMARY KEY (work_order_id, position)
372
+);
373
+```
186 374
 
187
-La prima accesare a unui document:
375
+## Migrare și Seed
188 376
 
189
-1. Aplicația caută rândul în `documents`.
190
-2. Dacă rândul există, conținutul din SQLite câștigă.
191
-3. Dacă rândul lipsește, aplicația citește fișierul seed din `config/`.
192
-4. Dacă fișierul seed lipsește, aplicația creează un document gol valid.
193
-5. Documentul este inserat în SQLite și de atunci SQLite devine autoritatea.
377
+La prima inițializare a unei baze fără rânduri în `hosts`, aplicația seed-uiește din:
194 378
 
195
-Important: după seed, modificările ulterioare ale fișierelor `config/hosts.yaml` sau `config/work-orders.yaml` nu schimbă automat baza existentă.
379
+1. `documents.name = 'hosts_yaml'`, dacă există din vechiul model
380
+2. `config/hosts.yaml`, dacă documentul legacy lipsește
381
+3. document gol valid, dacă lipsește și seed-ul
196 382
 
197
-## Fișiere SQLite
383
+Work Orders se seed-uiesc similar din `documents.name = 'work_orders_yaml'` sau `config/work-orders.yaml`.
198 384
 
199
-Cu WAL activ, runtime-ul poate avea trei fișiere:
385
+Seed-ul curent produce:
200 386
 
201
-```text
202
-var/host-manager.sqlite
203
-var/host-manager.sqlite-wal
204
-var/host-manager.sqlite-shm
205
-```
387
+- 11 hosturi cunoscute
388
+- aliasuri scurte derivate pentru hosturi și vhosturi
389
+- vhosturile `hosts.*`, `pmx.*` și `pbs.*`
390
+- workerii `dhcp-router` și `mdns-listener`
391
+- Work Order-ul existent pentru retragerea numelor legacy
206 392
 
207
-Toate trei aparțin store-ului runtime. Nu se comit în git și nu se înlocuiesc prin deploy de cod.
393
+`config/local-hosts.tsv` rămâne manifest generat explicit din tabelele runtime.
208 394
 
209 395
 ## Inspecție
210 396
 
@@ -212,45 +398,37 @@ Pe jumper:
212 398
 
213 399
 ```bash
214 400
 cd /usr/local/xdev-host-manager
215
-sqlite3 var/host-manager.sqlite '.schema'
216 401
 sqlite3 var/host-manager.sqlite '.tables'
217
-sqlite3 var/host-manager.sqlite 'pragma table_info(documents);'
218
-sqlite3 var/host-manager.sqlite 'pragma index_list(documents);'
219
-sqlite3 var/host-manager.sqlite 'pragma index_info(sqlite_autoindex_documents_1);'
220
-sqlite3 var/host-manager.sqlite 'pragma foreign_key_list(documents);'
221
-sqlite3 var/host-manager.sqlite 'select name, updated_at, length(content) from documents order by name;'
222
-sqlite3 var/host-manager.sqlite 'select content from documents where name = "hosts_yaml";'
223
-sqlite3 var/host-manager.sqlite 'select content from documents where name = "work_orders_yaml";'
402
+sqlite3 var/host-manager.sqlite '.schema hosts'
403
+sqlite3 var/host-manager.sqlite '.schema host_aliases'
404
+sqlite3 var/host-manager.sqlite '.schema vhosts'
405
+sqlite3 var/host-manager.sqlite 'pragma foreign_key_list(vhosts);'
406
+sqlite3 var/host-manager.sqlite 'pragma index_list(host_aliases);'
407
+sqlite3 var/host-manager.sqlite 'select fqdn, legacy_id, status, dns_ip from hosts order by legacy_id;'
408
+sqlite3 var/host-manager.sqlite 'select alias_name, host_fqdn, alias_kind, status from host_aliases order by alias_name;'
409
+sqlite3 var/host-manager.sqlite 'select vhost_fqdn, host_fqdn, status from vhosts order by vhost_fqdn;'
224 410
 ```
225 411
 
226
-Pentru export YAML curent:
227
-
228
-```bash
229
-curl -fsS -b cookies.txt https://hosts.madagascar.xdev.ro/download/hosts.yaml
230
-```
231
-
232
-Endpoint-urile de download sunt OTP-protected; exemplul presupune o sesiune validă.
233
-
234 412
 ## Backup
235 413
 
236
-Backup recomandat, cu serviciul oprit sau prin API-ul SQLite:
414
+Backup recomandat:
237 415
 
238 416
 ```bash
239 417
 cd /usr/local/xdev-host-manager
240 418
 sqlite3 var/host-manager.sqlite ".backup 'backups/host-manager/host-manager.sqlite.$(date +%Y%m%d_%H%M%S).bak'"
241 419
 ```
242 420
 
243
-Pentru o copie brută, include și fișierele WAL/SHM sau oprește serviciul înainte:
421
+Cu WAL activ, o copie brută trebuie să trateze fișierele ca set coerent:
244 422
 
245
-```bash
246
-sudo systemctl stop host-manager
247
-sudo cp -a var/host-manager.sqlite* backups/host-manager/
248
-sudo systemctl start host-manager
423
+```text
424
+var/host-manager.sqlite
425
+var/host-manager.sqlite-wal
426
+var/host-manager.sqlite-shm
249 427
 ```
250 428
 
251 429
 ## Restore
252 430
 
253
-Restore-ul înlocuiește sursa de adevăr runtime și trebuie făcut explicit:
431
+Restore-ul înlocuiește sursa de adevăr runtime:
254 432
 
255 433
 ```bash
256 434
 sudo systemctl stop host-manager
@@ -259,19 +437,3 @@ sudo chown host-manager:host-manager var/host-manager.sqlite
259 437
 sudo systemctl start host-manager
260 438
 curl -fsS http://127.0.0.1:8088/healthz >/dev/null
261 439
 ```
262
-
263
-Dacă se restaurează o copie brută, tratează `host-manager.sqlite`, `host-manager.sqlite-wal` și `host-manager.sqlite-shm` ca set coerent.
264
-
265
-## Evoluție probabilă
266
-
267
-Schema generică `documents` este potrivită pentru tranziția curentă. Dacă aplicația are nevoie de query-uri structurale, validări SQL sau merge/audit mai granular, următorul pas ar trebui să fie o migrare versionată către tabele dedicate, de exemplu:
268
-
269
-- `hosts`
270
-- `host_names`
271
-- `host_roles`
272
-- `host_sources`
273
-- `work_orders`
274
-- `work_order_checklist`
275
-- `work_order_actions`
276
-
277
-Până atunci, contractul stabil este numele documentelor din `documents` și formatele YAML randate de aplicație.
+21 -0
.doc/development-log.md
@@ -161,3 +161,24 @@ Scop:
161 161
 - editările făcute în UI să nu se piardă la deploy/push de cod
162 162
 - să existe o singură autoritate runtime
163 163
 - YAML/TSV să rămână utile pentru export, review și bootstrap fără să fie storage-ul live
164
+
165
+## 2026-06-09 - Relational Runtime Schema
166
+
167
+Observație: primul pas SQLite mutase sursa de adevăr din git în baza de date, dar o păstra ca document YAML într-o tabelă generică. Asta rezolva pierderea editărilor la push, dar nu oferea o bază de date operațională reală.
168
+
169
+Decizie:
170
+
171
+- `hosts.fqdn` devine cheia reală pentru hosturi, ca numele complete să evite coliziuni între domenii
172
+- `legacy_id` rămâne doar compatibilitate pentru UI/API-ul curent
173
+- aliasurile sunt păstrate în `host_aliases`, inclusiv după retragere
174
+- vhosturile sunt în `vhosts` și pot fi mutate prin schimbarea `host_fqdn`
175
+- rolurile, sursele, flagurile și SSH sunt în tabele separate, nu coloane adăugate continuu în `hosts`
176
+- workerii de date au `data_workers`, `dhcp_leases` și `mdns_observations`
177
+- certificatele emise au `certificates` și `certificate_dns_names`
178
+- `documents` rămâne doar tabel legacy pentru migrare din modelul vechi document-store
179
+
180
+Scop:
181
+
182
+- schema să poată susține inventar, DNS, vhosturi, observații externe și certificate fără să piardă istoric operațional
183
+- aliasurile/vhosturile retrase să rămână audibile în baza de date
184
+- structura să fie extensibilă fără să supraîncarce tabelul `hosts`
+4 -4
.doc/host-manager.md
@@ -16,7 +16,7 @@ MVP-ul curent nu are dependențe CPAN instalate direct pe host. Pentru store-ul
16 16
 
17 17
 ## Rol
18 18
 
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.
19
+`var/host-manager.sqlite` este sursa de adevăr runtime pentru hosturi, aliasuri, vhosturi, Work Orders, workeri de date și certificate emise. La prima pornire, aplicația seed-uiește tabelele din `config/hosts.yaml` și `config/work-orders.yaml` dacă baza nu are încă rânduri operaționale. Aplicația este complet în spatele autentificării OTP pentru orice date de registru, exporturi sau modificări.
20 20
 
21 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
 
@@ -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 registry se declară doar numele canonice/FQDN-urile necesare.
154
+`madagascar.xdev.ro` este domeniul implicit al rețelei interne. Hosturile sunt identificate în baza de date prin FQDN complet, iar UI-ul păstrează temporar `id` ca identificator compatibil.
155 155
 
156 156
 Pentru orice nume `*.madagascar.xdev.ro`, aplicația derivă automat aliasul scurt prin eliminarea sufixului `.madagascar.xdev.ro`.
157 157
 
@@ -160,7 +160,7 @@ Exemple:
160 160
 - `autonas01.madagascar.xdev.ro` produce automat aliasul `autonas01`
161 161
 - `pmx.baobab.madagascar.xdev.ro` produce automat aliasul `pmx.baobab`
162 162
 
163
-Aliasurile derivate nu se declară separat în `hosts.yaml`. Ele apar în API, monitoring export și `local-hosts.tsv` ca nume efective.
163
+Aliasurile derivate nu se declară separat în `hosts.yaml`. Ele sunt păstrate în tabelul `host_aliases` și apar în API, monitoring export și `local-hosts.tsv` ca nume efective.
164 164
 
165 165
 ## Git și managementul cheilor
166 166
 
@@ -179,7 +179,7 @@ Modelul recomandat:
179 179
 
180 180
 ```text
181 181
 jumper
182
-  var/host-manager.sqlite  sursă runtime pentru registry și Work Orders
182
+  var/host-manager.sqlite  sursă runtime relațională pentru registry și Work Orders
183 183
   host-manager             editează SQLite cu OTP
184 184
   sync_local_hosts.sh      aplică DNS după review/verificare
185 185
 
+1 -1
config/hosts.yaml
@@ -4,7 +4,7 @@ policy:
4 4
   ip_authority: "dhcp"
5 5
   topology_authority: "madagascar.json"
6 6
   dns_manifest: "config/local-hosts.tsv"
7
-  storage_authority: "sqlite"
7
+  storage_authority: "sqlite-relational"
8 8
   runtime_database: "var/host-manager.sqlite"
9 9
   consumer_access: "read-only deploy keys"
10 10
 hosts:
+843 -22
scripts/host_manager.pl
@@ -212,7 +212,7 @@ sub app_page_path {
212 212
 }
213 213
 
214 214
 sub load_registry {
215
-    my $registry = parse_hosts_yaml(load_operational_doc('hosts_yaml', $opt{data}, default_hosts_yaml()));
215
+    my $registry = load_registry_from_db();
216 216
     normalize_registry_policy($registry);
217 217
     return $registry;
218 218
 }
@@ -221,16 +221,16 @@ sub save_registry {
221 221
     my ($registry) = @_;
222 222
     $registry->{updated_at} = iso_now();
223 223
     normalize_registry_policy($registry);
224
-    save_operational_doc('hosts_yaml', render_hosts_yaml($registry));
224
+    save_registry_to_db($registry);
225 225
 }
226 226
 
227 227
 sub load_work_orders {
228
-    return parse_work_orders_yaml(load_operational_doc('work_orders_yaml', $opt{work_orders}, default_work_orders_yaml()));
228
+    return load_work_orders_from_db();
229 229
 }
230 230
 
231 231
 sub save_work_orders {
232 232
     my ($orders) = @_;
233
-    save_operational_doc('work_orders_yaml', render_work_orders_yaml($orders));
233
+    save_work_orders_to_db($orders);
234 234
 }
235 235
 
236 236
 sub work_orders_payload {
@@ -582,7 +582,80 @@ sub ca_manager_json {
582 582
     local $/;
583 583
     my $out = <$fh>;
584 584
     close $fh or die "CA manager failed\n";
585
-    return $out || '{}';
585
+    $out ||= $command eq 'list-json' ? '[]' : '{}';
586
+    sync_certificates_from_json($out) if $command eq 'list-json';
587
+    return $out;
588
+}
589
+
590
+sub sync_certificates_from_json {
591
+    my ($json) = @_;
592
+    my $certs = eval { json_decode($json || '[]') };
593
+    return if $@ || ref($certs) ne 'ARRAY';
594
+    my $dbh = dbh();
595
+    my $now = iso_now();
596
+    with_transaction($dbh, sub {
597
+        for my $cert (@$certs) {
598
+            next unless ref($cert) eq 'HASH';
599
+            my $name = clean_id($cert->{name} || $cert->{serial} || $cert->{fingerprint_sha256} || '');
600
+            next unless $name;
601
+            my @dns_names = map { normalize_dns_name($_) } @{ $cert->{dns_names} || [] };
602
+            my $host_fqdn = infer_certificate_host_fqdn($dbh, \@dns_names);
603
+            my $cert_path = ca_issued_cert_path($name);
604
+            my $csr_path = ca_dir() . "/csr/$name.csr.pem";
605
+            my $serial = clean_scalar($cert->{serial} || '');
606
+            my $fingerprint = clean_scalar($cert->{fingerprint_sha256} || '');
607
+            $dbh->do(
608
+                'INSERT INTO certificates (certificate_id, host_fqdn, common_name, subject, issuer, serial, status, not_before, not_after, fingerprint_sha256, cert_path, csr_path, created_at, updated_at, notes) '
609
+                . "VALUES (?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?, ?, ?, ?, ?, '') "
610
+                . 'ON CONFLICT(certificate_id) DO UPDATE SET host_fqdn = excluded.host_fqdn, common_name = excluded.common_name, '
611
+                . 'subject = excluded.subject, issuer = excluded.issuer, serial = excluded.serial, status = excluded.status, '
612
+                . 'not_before = excluded.not_before, not_after = excluded.not_after, fingerprint_sha256 = excluded.fingerprint_sha256, '
613
+                . 'cert_path = excluded.cert_path, csr_path = excluded.csr_path, updated_at = excluded.updated_at',
614
+                undef,
615
+                $name,
616
+                $host_fqdn || undef,
617
+                $dns_names[0] || '',
618
+                clean_scalar($cert->{subject} || ''),
619
+                clean_scalar($cert->{issuer} || ''),
620
+                length($serial) ? $serial : undef,
621
+                clean_scalar($cert->{not_before} || ''),
622
+                clean_scalar($cert->{not_after} || ''),
623
+                length($fingerprint) ? $fingerprint : undef,
624
+                $cert_path,
625
+                $csr_path,
626
+                $now,
627
+                $now,
628
+            );
629
+            $dbh->do('DELETE FROM certificate_dns_names WHERE certificate_id = ?', undef, $name);
630
+            for my $dns_name (@dns_names) {
631
+                next unless length $dns_name;
632
+                $dbh->do(
633
+                    'INSERT OR IGNORE INTO certificate_dns_names (certificate_id, dns_name) VALUES (?, ?)',
634
+                    undef,
635
+                    $name,
636
+                    $dns_name,
637
+                );
638
+            }
639
+        }
640
+    });
641
+}
642
+
643
+sub infer_certificate_host_fqdn {
644
+    my ($dbh, $dns_names) = @_;
645
+    for my $name (@$dns_names) {
646
+        my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE fqdn = ?', undef, $name);
647
+        return $fqdn if $fqdn;
648
+    }
649
+    for my $name (@$dns_names) {
650
+        my ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = ?', undef, $name, 'active');
651
+        return $fqdn if $fqdn;
652
+        ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = ?', undef, $name, 'active');
653
+        return $fqdn if $fqdn;
654
+    }
655
+    for my $name (@$dns_names) {
656
+        return $name if $name =~ /\./;
657
+    }
658
+    return '';
586 659
 }
587 660
 
588 661
 sub parse_hosts_yaml {
@@ -1128,6 +1201,7 @@ sub backup_file {
1128 1201
 }
1129 1202
 
1130 1203
 my $db_handle;
1204
+my $db_seeded = 0;
1131 1205
 
1132 1206
 sub dbh {
1133 1207
     return $db_handle if $db_handle;
@@ -1145,44 +1219,791 @@ sub dbh {
1145 1219
     ) or die "Cannot open SQLite database $opt{db}\n";
1146 1220
     $db_handle->do('PRAGMA journal_mode = WAL');
1147 1221
     $db_handle->do('PRAGMA foreign_keys = ON');
1148
-    $db_handle->do(<<'SQL');
1222
+    create_database_schema($db_handle);
1223
+    seed_database($db_handle) unless $db_seeded++;
1224
+    return $db_handle;
1225
+}
1226
+
1227
+sub create_database_schema {
1228
+    my ($dbh) = @_;
1229
+    $dbh->do(<<'SQL');
1230
+CREATE TABLE IF NOT EXISTS schema_meta (
1231
+    key TEXT PRIMARY KEY,
1232
+    value TEXT NOT NULL,
1233
+    updated_at TEXT NOT NULL
1234
+)
1235
+SQL
1236
+    $dbh->do(<<'SQL');
1149 1237
 CREATE TABLE IF NOT EXISTS documents (
1150 1238
     name TEXT PRIMARY KEY,
1151 1239
     content TEXT NOT NULL,
1152 1240
     updated_at TEXT NOT NULL
1153 1241
 )
1154 1242
 SQL
1155
-    return $db_handle;
1243
+    $dbh->do(
1244
+        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
1245
+        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
1246
+        undef, 'schema_version', '2', iso_now()
1247
+    );
1248
+    $dbh->do(<<'SQL');
1249
+CREATE TABLE IF NOT EXISTS hosts (
1250
+    fqdn TEXT PRIMARY KEY,
1251
+    legacy_id TEXT NOT NULL UNIQUE,
1252
+    status TEXT NOT NULL DEFAULT 'active',
1253
+    hosts_ip TEXT NOT NULL DEFAULT '',
1254
+    dns_ip TEXT NOT NULL DEFAULT '',
1255
+    monitoring TEXT NOT NULL DEFAULT 'pending',
1256
+    notes TEXT NOT NULL DEFAULT '',
1257
+    created_at TEXT NOT NULL,
1258
+    updated_at TEXT NOT NULL
1259
+)
1260
+SQL
1261
+    $dbh->do(<<'SQL');
1262
+CREATE TABLE IF NOT EXISTS host_aliases (
1263
+    alias_name TEXT NOT NULL,
1264
+    host_fqdn TEXT NOT NULL,
1265
+    alias_kind TEXT NOT NULL DEFAULT 'declared',
1266
+    status TEXT NOT NULL DEFAULT 'active',
1267
+    is_dns_published INTEGER NOT NULL DEFAULT 1,
1268
+    created_at TEXT NOT NULL,
1269
+    retired_at TEXT,
1270
+    notes TEXT NOT NULL DEFAULT '',
1271
+    PRIMARY KEY (alias_name, host_fqdn),
1272
+    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1273
+)
1274
+SQL
1275
+    $dbh->do(<<'SQL');
1276
+CREATE UNIQUE INDEX IF NOT EXISTS idx_host_aliases_active_name
1277
+ON host_aliases(alias_name)
1278
+WHERE status = 'active'
1279
+SQL
1280
+    $dbh->do(<<'SQL');
1281
+CREATE INDEX IF NOT EXISTS idx_host_aliases_host_status
1282
+ON host_aliases(host_fqdn, status)
1283
+SQL
1284
+    $dbh->do(<<'SQL');
1285
+CREATE TABLE IF NOT EXISTS host_roles (
1286
+    host_fqdn TEXT NOT NULL,
1287
+    role TEXT NOT NULL,
1288
+    status TEXT NOT NULL DEFAULT 'active',
1289
+    created_at TEXT NOT NULL,
1290
+    retired_at TEXT,
1291
+    PRIMARY KEY (host_fqdn, role),
1292
+    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1293
+)
1294
+SQL
1295
+    $dbh->do(<<'SQL');
1296
+CREATE TABLE IF NOT EXISTS host_sources (
1297
+    host_fqdn TEXT NOT NULL,
1298
+    source TEXT NOT NULL,
1299
+    status TEXT NOT NULL DEFAULT 'active',
1300
+    created_at TEXT NOT NULL,
1301
+    retired_at TEXT,
1302
+    PRIMARY KEY (host_fqdn, source),
1303
+    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1304
+)
1305
+SQL
1306
+    $dbh->do(<<'SQL');
1307
+CREATE TABLE IF NOT EXISTS host_flags (
1308
+    host_fqdn TEXT NOT NULL,
1309
+    flag TEXT NOT NULL,
1310
+    value TEXT NOT NULL DEFAULT '1',
1311
+    created_at TEXT NOT NULL,
1312
+    updated_at TEXT NOT NULL,
1313
+    PRIMARY KEY (host_fqdn, flag),
1314
+    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1315
+)
1316
+SQL
1317
+    $dbh->do(<<'SQL');
1318
+CREATE TABLE IF NOT EXISTS host_ssh (
1319
+    host_fqdn TEXT NOT NULL,
1320
+    profile_name TEXT NOT NULL DEFAULT 'default',
1321
+    username TEXT NOT NULL DEFAULT '',
1322
+    port INTEGER NOT NULL DEFAULT 22,
1323
+    identity_file TEXT NOT NULL DEFAULT '',
1324
+    address TEXT NOT NULL DEFAULT '',
1325
+    local_forward_host TEXT NOT NULL DEFAULT '',
1326
+    local_forward_port INTEGER,
1327
+    remote_forward_host TEXT NOT NULL DEFAULT '',
1328
+    remote_forward_port INTEGER,
1329
+    notes TEXT NOT NULL DEFAULT '',
1330
+    created_at TEXT NOT NULL,
1331
+    updated_at TEXT NOT NULL,
1332
+    PRIMARY KEY (host_fqdn, profile_name),
1333
+    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1334
+)
1335
+SQL
1336
+    $dbh->do(<<'SQL');
1337
+CREATE TABLE IF NOT EXISTS certificates (
1338
+    certificate_id TEXT PRIMARY KEY,
1339
+    host_fqdn TEXT,
1340
+    common_name TEXT NOT NULL DEFAULT '',
1341
+    subject TEXT NOT NULL DEFAULT '',
1342
+    issuer TEXT NOT NULL DEFAULT '',
1343
+    serial TEXT UNIQUE,
1344
+    status TEXT NOT NULL DEFAULT 'issued',
1345
+    not_before TEXT NOT NULL DEFAULT '',
1346
+    not_after TEXT NOT NULL DEFAULT '',
1347
+    fingerprint_sha256 TEXT UNIQUE,
1348
+    cert_path TEXT NOT NULL DEFAULT '',
1349
+    csr_path TEXT NOT NULL DEFAULT '',
1350
+    created_at TEXT NOT NULL,
1351
+    updated_at TEXT NOT NULL,
1352
+    notes TEXT NOT NULL DEFAULT '',
1353
+    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1354
+)
1355
+SQL
1356
+    $dbh->do(<<'SQL');
1357
+CREATE TABLE IF NOT EXISTS certificate_dns_names (
1358
+    certificate_id TEXT NOT NULL,
1359
+    dns_name TEXT NOT NULL,
1360
+    PRIMARY KEY (certificate_id, dns_name),
1361
+    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE CASCADE
1362
+)
1363
+SQL
1364
+    $dbh->do(<<'SQL');
1365
+CREATE INDEX IF NOT EXISTS idx_certificate_dns_names_dns_name
1366
+ON certificate_dns_names(dns_name)
1367
+SQL
1368
+    $dbh->do(<<'SQL');
1369
+CREATE TABLE IF NOT EXISTS vhosts (
1370
+    vhost_fqdn TEXT PRIMARY KEY,
1371
+    host_fqdn TEXT NOT NULL,
1372
+    status TEXT NOT NULL DEFAULT 'active',
1373
+    service_name TEXT NOT NULL DEFAULT '',
1374
+    upstream_url TEXT NOT NULL DEFAULT '',
1375
+    tls_mode TEXT NOT NULL DEFAULT 'local-ca',
1376
+    certificate_id TEXT,
1377
+    notes TEXT NOT NULL DEFAULT '',
1378
+    created_at TEXT NOT NULL,
1379
+    updated_at TEXT NOT NULL,
1380
+    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT,
1381
+    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE SET NULL
1382
+)
1383
+SQL
1384
+    $dbh->do(<<'SQL');
1385
+CREATE INDEX IF NOT EXISTS idx_vhosts_host_status
1386
+ON vhosts(host_fqdn, status)
1387
+SQL
1388
+    $dbh->do(<<'SQL');
1389
+CREATE TABLE IF NOT EXISTS data_workers (
1390
+    worker_id TEXT PRIMARY KEY,
1391
+    worker_type TEXT NOT NULL,
1392
+    name TEXT NOT NULL DEFAULT '',
1393
+    status TEXT NOT NULL DEFAULT 'active',
1394
+    source TEXT NOT NULL DEFAULT '',
1395
+    last_run_at TEXT,
1396
+    notes TEXT NOT NULL DEFAULT '',
1397
+    created_at TEXT NOT NULL,
1398
+    updated_at TEXT NOT NULL
1399
+)
1400
+SQL
1401
+    $dbh->do(<<'SQL');
1402
+CREATE INDEX IF NOT EXISTS idx_data_workers_type_status
1403
+ON data_workers(worker_type, status)
1404
+SQL
1405
+    $dbh->do(<<'SQL');
1406
+CREATE TABLE IF NOT EXISTS dhcp_leases (
1407
+    lease_key TEXT PRIMARY KEY,
1408
+    worker_id TEXT NOT NULL,
1409
+    host_fqdn TEXT,
1410
+    observed_name TEXT NOT NULL DEFAULT '',
1411
+    ip_address TEXT NOT NULL,
1412
+    mac_address TEXT NOT NULL DEFAULT '',
1413
+    lease_state TEXT NOT NULL DEFAULT '',
1414
+    first_seen TEXT NOT NULL,
1415
+    last_seen TEXT NOT NULL,
1416
+    raw TEXT NOT NULL DEFAULT '',
1417
+    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
1418
+    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1419
+)
1420
+SQL
1421
+    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_ip ON dhcp_leases(ip_address)');
1422
+    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_mac ON dhcp_leases(mac_address)');
1423
+    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_worker_last_seen ON dhcp_leases(worker_id, last_seen)');
1424
+    $dbh->do(<<'SQL');
1425
+CREATE TABLE IF NOT EXISTS mdns_observations (
1426
+    observation_key TEXT PRIMARY KEY,
1427
+    worker_id TEXT NOT NULL,
1428
+    host_fqdn TEXT,
1429
+    observed_name TEXT NOT NULL,
1430
+    ip_address TEXT NOT NULL,
1431
+    rr_type TEXT NOT NULL DEFAULT 'A',
1432
+    ttl INTEGER NOT NULL DEFAULT 0,
1433
+    first_seen TEXT NOT NULL,
1434
+    last_seen TEXT NOT NULL,
1435
+    seen_count INTEGER NOT NULL DEFAULT 1,
1436
+    last_peer TEXT NOT NULL DEFAULT '',
1437
+    raw TEXT NOT NULL DEFAULT '',
1438
+    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
1439
+    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1440
+)
1441
+SQL
1442
+    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_name ON mdns_observations(observed_name)');
1443
+    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_ip ON mdns_observations(ip_address)');
1444
+    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_worker_last_seen ON mdns_observations(worker_id, last_seen)');
1445
+    $dbh->do(<<'SQL');
1446
+CREATE TABLE IF NOT EXISTS work_orders (
1447
+    id TEXT PRIMARY KEY,
1448
+    status TEXT NOT NULL DEFAULT 'pending',
1449
+    title TEXT NOT NULL DEFAULT '',
1450
+    reason TEXT NOT NULL DEFAULT '',
1451
+    created_at TEXT NOT NULL,
1452
+    confirmed_at TEXT NOT NULL DEFAULT '',
1453
+    result TEXT NOT NULL DEFAULT '',
1454
+    updated_at TEXT NOT NULL
1455
+)
1456
+SQL
1457
+    $dbh->do(<<'SQL');
1458
+CREATE TABLE IF NOT EXISTS work_order_checklist (
1459
+    work_order_id TEXT NOT NULL,
1460
+    item_id TEXT NOT NULL,
1461
+    text TEXT NOT NULL DEFAULT '',
1462
+    status TEXT NOT NULL DEFAULT 'pending',
1463
+    owner TEXT NOT NULL DEFAULT '',
1464
+    notes TEXT NOT NULL DEFAULT '',
1465
+    updated_at TEXT NOT NULL DEFAULT '',
1466
+    PRIMARY KEY (work_order_id, item_id),
1467
+    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE
1468
+)
1469
+SQL
1470
+    $dbh->do(<<'SQL');
1471
+CREATE TABLE IF NOT EXISTS work_order_actions (
1472
+    work_order_id TEXT NOT NULL,
1473
+    position INTEGER NOT NULL,
1474
+    type TEXT NOT NULL,
1475
+    host_fqdn TEXT,
1476
+    host_legacy_id TEXT NOT NULL DEFAULT '',
1477
+    name TEXT NOT NULL DEFAULT '',
1478
+    payload TEXT NOT NULL DEFAULT '',
1479
+    PRIMARY KEY (work_order_id, position),
1480
+    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE,
1481
+    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1482
+)
1483
+SQL
1156 1484
 }
1157 1485
 
1158
-sub load_operational_doc {
1159
-    my ($name, $seed_path, $default_text) = @_;
1160
-    my $dbh = dbh();
1486
+sub seed_database {
1487
+    my ($dbh) = @_;
1488
+    seed_default_workers($dbh);
1489
+
1490
+    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM hosts')) {
1491
+        my $registry = parse_hosts_yaml(legacy_document_text($dbh, 'hosts_yaml', $opt{data}, default_hosts_yaml()));
1492
+        normalize_registry_policy($registry);
1493
+        with_transaction($dbh, sub {
1494
+            import_registry_to_db($dbh, $registry, 0);
1495
+        });
1496
+    }
1497
+
1498
+    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM work_orders')) {
1499
+        my $orders = parse_work_orders_yaml(legacy_document_text($dbh, 'work_orders_yaml', $opt{work_orders}, default_work_orders_yaml()));
1500
+        with_transaction($dbh, sub {
1501
+            import_work_orders_to_db($dbh, $orders);
1502
+        });
1503
+    }
1504
+
1505
+    seed_mdns_observations_from_yaml($dbh);
1506
+}
1507
+
1508
+sub with_transaction {
1509
+    my ($dbh, $code) = @_;
1510
+    return $code->() unless $dbh->{AutoCommit};
1511
+    $dbh->begin_work;
1512
+    my $ok = eval {
1513
+        $code->();
1514
+        1;
1515
+    };
1516
+    if (!$ok) {
1517
+        my $err = $@ || 'transaction failed';
1518
+        eval { $dbh->rollback };
1519
+        die $err;
1520
+    }
1521
+    $dbh->commit;
1522
+}
1523
+
1524
+sub db_scalar {
1525
+    my ($dbh, $sql, @bind) = @_;
1526
+    my ($value) = $dbh->selectrow_array($sql, undef, @bind);
1527
+    return $value || 0;
1528
+}
1529
+
1530
+sub legacy_document_text {
1531
+    my ($dbh, $name, $seed_path, $default_text) = @_;
1161 1532
     my $row = $dbh->selectrow_hashref('SELECT content FROM documents WHERE name = ?', undef, $name);
1162
-    return $row->{content} if $row;
1533
+    return $row->{content} if $row && defined $row->{content};
1534
+    return read_file($seed_path) if -f $seed_path;
1535
+    return $default_text;
1536
+}
1537
+
1538
+sub load_registry_from_db {
1539
+    my $dbh = dbh();
1540
+    my $registry = {
1541
+        version => 1,
1542
+        updated_at => db_scalar($dbh, 'SELECT value FROM schema_meta WHERE key = ?', 'registry_updated_at') || '',
1543
+        policy => {},
1544
+        hosts => [],
1545
+    };
1546
+
1547
+    my $sth = $dbh->prepare('SELECT * FROM hosts ORDER BY legacy_id');
1548
+    $sth->execute;
1549
+    while (my $row = $sth->fetchrow_hashref) {
1550
+        my $fqdn = $row->{fqdn};
1551
+        push @{ $registry->{hosts} }, {
1552
+            id => $row->{legacy_id},
1553
+            status => $row->{status},
1554
+            hosts_ip => $row->{hosts_ip},
1555
+            dns_ip => $row->{dns_ip},
1556
+            names => [ active_names_for_host($dbh, $fqdn) ],
1557
+            roles => [ active_values_for_host($dbh, 'host_roles', 'role', $fqdn) ],
1558
+            sources => [ active_values_for_host($dbh, 'host_sources', 'source', $fqdn) ],
1559
+            monitoring => $row->{monitoring},
1560
+            notes => $row->{notes},
1561
+        };
1562
+    }
1563
+
1564
+    return $registry;
1565
+}
1566
+
1567
+sub save_registry_to_db {
1568
+    my ($registry) = @_;
1569
+    my $dbh = dbh();
1570
+    with_transaction($dbh, sub {
1571
+        import_registry_to_db($dbh, $registry, 1);
1572
+        set_schema_meta($dbh, 'registry_updated_at', $registry->{updated_at} || iso_now());
1573
+    });
1574
+}
1575
+
1576
+sub import_registry_to_db {
1577
+    my ($dbh, $registry, $retire_missing) = @_;
1578
+    my %seen;
1579
+    for my $host (@{ $registry->{hosts} || [] }) {
1580
+        my $fqdn = upsert_host_to_db($dbh, $host);
1581
+        $seen{$fqdn} = 1 if $fqdn;
1582
+    }
1583
+
1584
+    return unless $retire_missing;
1585
+    my $sth = $dbh->prepare('SELECT fqdn FROM hosts WHERE status <> ?');
1586
+    $sth->execute('retired');
1587
+    while (my ($fqdn) = $sth->fetchrow_array) {
1588
+        next if $seen{$fqdn};
1589
+        retire_host_in_db($dbh, $fqdn);
1590
+    }
1591
+}
1163 1592
 
1164
-    my $content = -f $seed_path ? read_file($seed_path) : $default_text;
1165
-    save_operational_doc($name, $content);
1166
-    return $content;
1593
+sub upsert_host_to_db {
1594
+    my ($dbh, $host) = @_;
1595
+    my $now = iso_now();
1596
+    my $fqdn = canonical_host_fqdn($host);
1597
+    return '' unless $fqdn;
1598
+    my $legacy_id = clean_id($host->{id} || legacy_id_from_fqdn($fqdn));
1599
+    my $status = clean_scalar($host->{status} || 'active');
1600
+    my $hosts_ip = clean_scalar($host->{hosts_ip} || '');
1601
+    my $dns_ip = clean_scalar($host->{dns_ip} || '');
1602
+    my $monitoring = clean_scalar($host->{monitoring} || 'pending');
1603
+    my $notes = clean_scalar($host->{notes} || '');
1604
+
1605
+    $dbh->do(
1606
+        'INSERT INTO hosts (fqdn, legacy_id, status, hosts_ip, dns_ip, monitoring, notes, created_at, updated_at) '
1607
+        . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) '
1608
+        . 'ON CONFLICT(fqdn) DO UPDATE SET legacy_id = excluded.legacy_id, status = excluded.status, '
1609
+        . 'hosts_ip = excluded.hosts_ip, dns_ip = excluded.dns_ip, monitoring = excluded.monitoring, '
1610
+        . 'notes = excluded.notes, updated_at = excluded.updated_at',
1611
+        undef,
1612
+        $fqdn, $legacy_id, $status, $hosts_ip, $dns_ip, $monitoring, $notes, $now, $now,
1613
+    );
1614
+
1615
+    sync_host_values($dbh, 'host_roles', 'role', $fqdn, [ clean_list($host->{roles}) ]);
1616
+    sync_host_values($dbh, 'host_sources', 'source', $fqdn, [ clean_list($host->{sources}) ]);
1617
+    sync_host_names($dbh, $fqdn, [ clean_list($host->{names}) ]);
1618
+    return $fqdn;
1619
+}
1620
+
1621
+sub sync_host_values {
1622
+    my ($dbh, $table, $column, $fqdn, $values) = @_;
1623
+    my $now = iso_now();
1624
+    my %active = map { $_ => 1 } @$values;
1625
+    for my $value (@$values) {
1626
+        $dbh->do(
1627
+            "INSERT INTO $table (host_fqdn, $column, status, created_at, retired_at) VALUES (?, ?, 'active', ?, '') "
1628
+            . "ON CONFLICT(host_fqdn, $column) DO UPDATE SET status = 'active', retired_at = ''",
1629
+            undef,
1630
+            $fqdn, $value, $now,
1631
+        );
1632
+    }
1633
+
1634
+    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active'");
1635
+    $sth->execute($fqdn);
1636
+    while (my ($value) = $sth->fetchrow_array) {
1637
+        next if $active{$value};
1638
+        $dbh->do("UPDATE $table SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND $column = ?", undef, $now, $fqdn, $value);
1639
+    }
1640
+}
1641
+
1642
+sub sync_host_names {
1643
+    my ($dbh, $fqdn, $names) = @_;
1644
+    my $now = iso_now();
1645
+    my (%aliases, %vhosts);
1646
+    if (my $short = short_alias_for_fqdn($fqdn)) {
1647
+        $aliases{$short} = 1;
1648
+        upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
1649
+    }
1650
+    for my $name (@$names) {
1651
+        $name = normalize_dns_name($name);
1652
+        next unless length $name;
1653
+        next if $name eq $fqdn;
1654
+        if (name_is_vhost($name)) {
1655
+            $vhosts{$name} = 1;
1656
+            upsert_vhost_to_db($dbh, $fqdn, $name, $now);
1657
+            if (my $short = short_alias_for_fqdn($name)) {
1658
+                $aliases{$short} = 1;
1659
+                upsert_alias_to_db($dbh, $fqdn, $short, 'derived-vhost', $now);
1660
+            }
1661
+        } else {
1662
+            $aliases{$name} = 1;
1663
+            upsert_alias_to_db($dbh, $fqdn, $name, 'declared', $now);
1664
+            if (my $short = short_alias_for_fqdn($name)) {
1665
+                $aliases{$short} = 1;
1666
+                upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
1667
+            }
1668
+        }
1669
+    }
1670
+
1671
+    retire_missing_names($dbh, 'host_aliases', 'alias_name', $fqdn, \%aliases, $now);
1672
+    retire_missing_names($dbh, 'vhosts', 'vhost_fqdn', $fqdn, \%vhosts, $now);
1673
+}
1674
+
1675
+sub upsert_alias_to_db {
1676
+    my ($dbh, $fqdn, $alias, $kind, $now) = @_;
1677
+    $dbh->do(
1678
+        'INSERT INTO host_aliases (alias_name, host_fqdn, alias_kind, status, is_dns_published, created_at, retired_at, notes) '
1679
+        . "VALUES (?, ?, ?, 'active', 1, ?, '', '') "
1680
+        . "ON CONFLICT(alias_name, host_fqdn) DO UPDATE SET alias_kind = excluded.alias_kind, status = 'active', is_dns_published = 1, retired_at = ''",
1681
+        undef,
1682
+        $alias, $fqdn, $kind, $now,
1683
+    );
1684
+}
1685
+
1686
+sub upsert_vhost_to_db {
1687
+    my ($dbh, $fqdn, $vhost, $now) = @_;
1688
+    my $service = vhost_service_name($vhost);
1689
+    $dbh->do(
1690
+        'INSERT INTO vhosts (vhost_fqdn, host_fqdn, status, service_name, upstream_url, tls_mode, certificate_id, notes, created_at, updated_at) '
1691
+        . "VALUES (?, ?, 'active', ?, '', 'local-ca', NULL, '', ?, ?) "
1692
+        . "ON CONFLICT(vhost_fqdn) DO UPDATE SET host_fqdn = excluded.host_fqdn, status = 'active', "
1693
+        . 'service_name = excluded.service_name, updated_at = excluded.updated_at',
1694
+        undef,
1695
+        $vhost, $fqdn, $service, $now, $now,
1696
+    );
1697
+}
1698
+
1699
+sub retire_missing_names {
1700
+    my ($dbh, $table, $name_column, $fqdn, $active, $now) = @_;
1701
+    my $sth = $dbh->prepare("SELECT $name_column FROM $table WHERE host_fqdn = ? AND status = 'active'");
1702
+    $sth->execute($fqdn);
1703
+    while (my ($name) = $sth->fetchrow_array) {
1704
+        next if $active->{$name};
1705
+        if ($table eq 'host_aliases') {
1706
+            $dbh->do(
1707
+                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND alias_name = ?",
1708
+                undef, $now, $fqdn, $name,
1709
+            );
1710
+        } else {
1711
+            $dbh->do(
1712
+                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND vhost_fqdn = ?",
1713
+                undef, $now, $fqdn, $name,
1714
+            );
1715
+        }
1716
+    }
1717
+}
1718
+
1719
+sub retire_host_in_db {
1720
+    my ($dbh, $fqdn) = @_;
1721
+    my $now = iso_now();
1722
+    $dbh->do("UPDATE hosts SET status = 'retired', updated_at = ? WHERE fqdn = ?", undef, $now, $fqdn);
1723
+    $dbh->do("UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1724
+    $dbh->do("UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1725
+    $dbh->do("UPDATE host_roles SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1726
+    $dbh->do("UPDATE host_sources SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1167 1727
 }
1168 1728
 
1169
-sub save_operational_doc {
1170
-    my ($name, $content) = @_;
1729
+sub active_names_for_host {
1730
+    my ($dbh, $fqdn) = @_;
1731
+    my @names = ($fqdn);
1732
+    my $aliases = $dbh->prepare("SELECT alias_name FROM host_aliases WHERE host_fqdn = ? AND status = 'active' AND is_dns_published = 1 AND alias_kind NOT LIKE 'derived%' ORDER BY alias_name");
1733
+    $aliases->execute($fqdn);
1734
+    while (my ($name) = $aliases->fetchrow_array) {
1735
+        push @names, $name;
1736
+    }
1737
+    my $vhosts = $dbh->prepare("SELECT vhost_fqdn FROM vhosts WHERE host_fqdn = ? AND status = 'active' ORDER BY vhost_fqdn");
1738
+    $vhosts->execute($fqdn);
1739
+    while (my ($name) = $vhosts->fetchrow_array) {
1740
+        push @names, $name;
1741
+    }
1742
+    return unique_preserve(@names);
1743
+}
1744
+
1745
+sub active_values_for_host {
1746
+    my ($dbh, $table, $column, $fqdn) = @_;
1747
+    my @values;
1748
+    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active' ORDER BY $column");
1749
+    $sth->execute($fqdn);
1750
+    while (my ($value) = $sth->fetchrow_array) {
1751
+        push @values, $value;
1752
+    }
1753
+    return @values;
1754
+}
1755
+
1756
+sub load_work_orders_from_db {
1171 1757
     my $dbh = dbh();
1758
+    my $orders = { version => 1, work_orders => [] };
1759
+    my $sth = $dbh->prepare('SELECT * FROM work_orders ORDER BY id');
1760
+    $sth->execute;
1761
+    while (my $row = $sth->fetchrow_hashref) {
1762
+        my $wo = {
1763
+            id => $row->{id},
1764
+            status => $row->{status},
1765
+            title => $row->{title},
1766
+            reason => $row->{reason},
1767
+            created_at => $row->{created_at},
1768
+            checklist => [],
1769
+            actions => [],
1770
+        };
1771
+        $wo->{confirmed_at} = $row->{confirmed_at} if length($row->{confirmed_at} || '');
1772
+        $wo->{result} = $row->{result} if length($row->{result} || '');
1773
+
1774
+        my $items = $dbh->prepare('SELECT * FROM work_order_checklist WHERE work_order_id = ? ORDER BY item_id');
1775
+        $items->execute($row->{id});
1776
+        while (my $item = $items->fetchrow_hashref) {
1777
+            my %copy = (
1778
+                id => $item->{item_id},
1779
+                text => $item->{text},
1780
+                status => $item->{status},
1781
+            );
1782
+            for my $key (qw(owner notes updated_at)) {
1783
+                $copy{$key} = $item->{$key} if length($item->{$key} || '');
1784
+            }
1785
+            push @{ $wo->{checklist} }, \%copy;
1786
+        }
1787
+
1788
+        my $actions = $dbh->prepare('SELECT * FROM work_order_actions WHERE work_order_id = ? ORDER BY position');
1789
+        $actions->execute($row->{id});
1790
+        while (my $action = $actions->fetchrow_hashref) {
1791
+            my %copy = ( type => $action->{type} );
1792
+            $copy{host_id} = $action->{host_legacy_id} if length($action->{host_legacy_id} || '');
1793
+            $copy{name} = $action->{name} if length($action->{name} || '');
1794
+            push @{ $wo->{actions} }, \%copy;
1795
+        }
1796
+
1797
+        push @{ $orders->{work_orders} }, $wo;
1798
+    }
1799
+    return $orders;
1800
+}
1801
+
1802
+sub save_work_orders_to_db {
1803
+    my ($orders) = @_;
1804
+    my $dbh = dbh();
1805
+    with_transaction($dbh, sub {
1806
+        import_work_orders_to_db($dbh, $orders);
1807
+    });
1808
+}
1809
+
1810
+sub import_work_orders_to_db {
1811
+    my ($dbh, $orders) = @_;
1812
+    my $now = iso_now();
1813
+    my %seen;
1814
+    for my $wo (@{ $orders->{work_orders} || [] }) {
1815
+        my $id = clean_scalar($wo->{id} || '');
1816
+        next unless $id;
1817
+        $seen{$id} = 1;
1818
+        $dbh->do(
1819
+            'INSERT INTO work_orders (id, status, title, reason, created_at, confirmed_at, result, updated_at) '
1820
+            . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?) '
1821
+            . 'ON CONFLICT(id) DO UPDATE SET status = excluded.status, title = excluded.title, reason = excluded.reason, '
1822
+            . 'created_at = excluded.created_at, confirmed_at = excluded.confirmed_at, result = excluded.result, updated_at = excluded.updated_at',
1823
+            undef,
1824
+            $id,
1825
+            clean_scalar($wo->{status} || 'pending'),
1826
+            clean_scalar($wo->{title} || ''),
1827
+            clean_scalar($wo->{reason} || ''),
1828
+            clean_scalar($wo->{created_at} || $now),
1829
+            clean_scalar($wo->{confirmed_at} || ''),
1830
+            clean_scalar($wo->{result} || ''),
1831
+            $now,
1832
+        );
1833
+        $dbh->do('DELETE FROM work_order_checklist WHERE work_order_id = ?', undef, $id);
1834
+        for my $item (@{ $wo->{checklist} || [] }) {
1835
+            $dbh->do(
1836
+                'INSERT INTO work_order_checklist (work_order_id, item_id, text, status, owner, notes, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
1837
+                undef,
1838
+                $id,
1839
+                clean_scalar($item->{id} || ''),
1840
+                clean_scalar($item->{text} || ''),
1841
+                clean_scalar($item->{status} || 'pending'),
1842
+                clean_scalar($item->{owner} || ''),
1843
+                clean_scalar($item->{notes} || ''),
1844
+                clean_scalar($item->{updated_at} || ''),
1845
+            );
1846
+        }
1847
+        $dbh->do('DELETE FROM work_order_actions WHERE work_order_id = ?', undef, $id);
1848
+        my $position = 0;
1849
+        for my $action (@{ $wo->{actions} || [] }) {
1850
+            my $legacy_id = clean_id($action->{host_id} || '');
1851
+            my $host_fqdn = fqdn_for_legacy_id($dbh, $legacy_id);
1852
+            $dbh->do(
1853
+                'INSERT INTO work_order_actions (work_order_id, position, type, host_fqdn, host_legacy_id, name, payload) VALUES (?, ?, ?, ?, ?, ?, ?)',
1854
+                undef,
1855
+                $id,
1856
+                $position++,
1857
+                clean_scalar($action->{type} || ''),
1858
+                $host_fqdn || undef,
1859
+                $legacy_id,
1860
+                normalize_dns_name($action->{name} || ''),
1861
+                '',
1862
+            );
1863
+        }
1864
+    }
1865
+}
1866
+
1867
+sub seed_default_workers {
1868
+    my ($dbh) = @_;
1869
+    my $now = iso_now();
1870
+    my @workers = (
1871
+        [ 'dhcp-router', 'dhcp', 'Router DHCP leases', 'admin@192.168.2.1', 'DHCP lease/reservation collector source.' ],
1872
+        [ 'mdns-listener', 'mdns', 'mDNS listener', 'var/mdns-observations.yaml', 'mDNS observation collector source.' ],
1873
+    );
1874
+    for my $worker (@workers) {
1875
+        $dbh->do(
1876
+            'INSERT INTO data_workers (worker_id, worker_type, name, status, source, last_run_at, notes, created_at, updated_at) '
1877
+            . "VALUES (?, ?, ?, 'active', ?, NULL, ?, ?, ?) "
1878
+            . 'ON CONFLICT(worker_id) DO UPDATE SET worker_type = excluded.worker_type, name = excluded.name, '
1879
+            . 'status = excluded.status, source = excluded.source, notes = excluded.notes, updated_at = excluded.updated_at',
1880
+            undef,
1881
+            @$worker,
1882
+            $now,
1883
+            $now,
1884
+        );
1885
+    }
1886
+}
1887
+
1888
+sub seed_mdns_observations_from_yaml {
1889
+    my ($dbh) = @_;
1890
+    return if db_scalar($dbh, 'SELECT COUNT(*) FROM mdns_observations');
1891
+    my $path = "$project_dir/var/mdns-observations.yaml";
1892
+    return unless -f $path;
1893
+    my $db = parse_mdns_observations_yaml(read_file($path));
1894
+    with_transaction($dbh, sub {
1895
+        for my $observation (@{ $db->{observations} || [] }) {
1896
+            $dbh->do(
1897
+                'INSERT INTO mdns_observations (observation_key, worker_id, host_fqdn, observed_name, ip_address, rr_type, ttl, first_seen, last_seen, seen_count, last_peer, raw) '
1898
+                . "VALUES (?, 'mdns-listener', NULL, ?, ?, 'A', ?, ?, ?, ?, ?, '') "
1899
+                . 'ON CONFLICT(observation_key) DO UPDATE SET observed_name = excluded.observed_name, ip_address = excluded.ip_address, '
1900
+                . 'ttl = excluded.ttl, last_seen = excluded.last_seen, seen_count = excluded.seen_count, last_peer = excluded.last_peer',
1901
+                undef,
1902
+                clean_scalar($observation->{key} || "$observation->{name}|$observation->{ip}"),
1903
+                clean_scalar($observation->{name} || ''),
1904
+                clean_scalar($observation->{ip} || ''),
1905
+                int($observation->{ttl} || 0),
1906
+                clean_scalar($observation->{first_seen} || iso_now()),
1907
+                clean_scalar($observation->{last_seen} || iso_now()),
1908
+                int($observation->{seen_count} || 1),
1909
+                clean_scalar($observation->{last_peer} || ''),
1910
+            );
1911
+        }
1912
+    });
1913
+}
1914
+
1915
+sub parse_mdns_observations_yaml {
1916
+    my ($text) = @_;
1917
+    my %db = ( observations => [] );
1918
+    my ($section, $current);
1919
+    for my $line (split /\n/, $text || '') {
1920
+        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
1921
+        if ($line =~ /^observations:\s*$/) {
1922
+            $section = 'observations';
1923
+        } elsif (($section || '') eq 'observations' && $line =~ /^  - key:\s*(.+)$/) {
1924
+            $current = { key => yaml_unquote($1) };
1925
+            push @{ $db{observations} }, $current;
1926
+        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
1927
+            $current->{$1} = yaml_unquote($2);
1928
+        }
1929
+    }
1930
+    return \%db;
1931
+}
1932
+
1933
+sub set_schema_meta {
1934
+    my ($dbh, $key, $value) = @_;
1172 1935
     $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',
1936
+        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
1937
+        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
1175 1938
         undef,
1176
-        $name,
1177
-        $content,
1939
+        $key,
1940
+        defined $value ? $value : '',
1178 1941
         iso_now(),
1179 1942
     );
1180 1943
 }
1181 1944
 
1945
+sub fqdn_for_legacy_id {
1946
+    my ($dbh, $legacy_id) = @_;
1947
+    return '' unless length($legacy_id || '');
1948
+    my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE legacy_id = ?', undef, $legacy_id);
1949
+    return $fqdn || '';
1950
+}
1951
+
1952
+sub canonical_host_fqdn {
1953
+    my ($host) = @_;
1954
+    my @names = map { normalize_dns_name($_) } @{ $host->{names} || [] };
1955
+    for my $name (@names) {
1956
+        return $name if $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name);
1957
+    }
1958
+    for my $name (@names) {
1959
+        return $name if $name =~ /\./ && !name_is_vhost($name);
1960
+    }
1961
+    for my $name (@names) {
1962
+        return $name if $name =~ /\./;
1963
+    }
1964
+    my $id = clean_id($host->{id} || '');
1965
+    return $id ? "$id.madagascar.xdev.ro" : '';
1966
+}
1967
+
1968
+sub legacy_id_from_fqdn {
1969
+    my ($fqdn) = @_;
1970
+    $fqdn = normalize_dns_name($fqdn);
1971
+    $fqdn =~ s/\.madagascar\.xdev\.ro\z//;
1972
+    $fqdn =~ s/\..*\z//;
1973
+    return clean_id($fqdn);
1974
+}
1975
+
1976
+sub normalize_dns_name {
1977
+    my ($name) = @_;
1978
+    $name = lc clean_scalar($name || '');
1979
+    $name =~ s/\.\z//;
1980
+    return $name;
1981
+}
1982
+
1983
+sub name_is_vhost {
1984
+    my ($name) = @_;
1985
+    $name = normalize_dns_name($name);
1986
+    return $name =~ /\A(?:pmx|pbs|hosts)\./ ? 1 : 0;
1987
+}
1988
+
1989
+sub vhost_service_name {
1990
+    my ($name) = @_;
1991
+    $name = normalize_dns_name($name);
1992
+    return $1 if $name =~ /\A([a-z0-9-]+)\./;
1993
+    return '';
1994
+}
1995
+
1996
+sub short_alias_for_fqdn {
1997
+    my ($name) = @_;
1998
+    $name = normalize_dns_name($name);
1999
+    return $1 if $name =~ /\A(.+)\.madagascar\.xdev\.ro\z/;
2000
+    return '';
2001
+}
2002
+
1182 2003
 sub normalize_registry_policy {
1183 2004
     my ($registry) = @_;
1184 2005
     $registry->{policy} ||= {};
1185
-    $registry->{policy}{storage_authority} = 'sqlite';
2006
+    $registry->{policy}{storage_authority} = 'sqlite-relational';
1186 2007
     $registry->{policy}{runtime_database} = $opt{db};
1187 2008
 }
1188 2009
 
@@ -1191,7 +2012,7 @@ sub default_hosts_yaml {
1191 2012
 version: 1
1192 2013
 updated_at: ""
1193 2014
 policy:
1194
-  storage_authority: "sqlite"
2015
+  storage_authority: "sqlite-relational"
1195 2016
 hosts:
1196 2017
 YAML
1197 2018
 }