Showing 4 changed files with 287 additions and 3 deletions
+31 -0
.doc/host-manager.md
@@ -16,6 +16,8 @@ MVP-ul curent nu are dependențe CPAN externe.
16 16
 
17 17
 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ă.
18 18
 
19
+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 și trebuie confirmat explicit înainte să modifice registrul.
20
+
19 21
 Endpoint-uri publice:
20 22
 
21 23
 - `/` — pagina de login/aplicație, fără date de host până la autentificare
@@ -28,6 +30,7 @@ Healthcheck-ul `/healthz` este disponibil doar pe backend-ul local (`127.0.0.1:8
28 30
 Endpoint-uri cu OTP:
29 31
 
30 32
 - `/api/hosts`
33
+- `/api/work-orders`
31 34
 - `/api/ca/status`
32 35
 - `/api/ca/certificates`
33 36
 - `/download/hosts.yaml`
@@ -36,6 +39,7 @@ Endpoint-uri cu OTP:
36 39
 - `/download/ca.crt`
37 40
 - `POST /api/hosts/upsert`
38 41
 - `POST /api/hosts/delete`
42
+- `POST /api/work-orders/confirm`
39 43
 - `POST /api/render/local-hosts-tsv`
40 44
 
41 45
 ## Pornire locală
@@ -90,6 +94,32 @@ Secretul nu se comite în repo. Dacă avem nevoie de integrare cu un manager de
90 94
 ./scripts/sync_local_hosts.sh --apply --verify
91 95
 ```
92 96
 
97
+## Work Orders
98
+
99
+`config/work-orders.yaml` păstrează operațiuni care trebuie confirmate înainte să atingă registrul.
100
+
101
+În MVP, acțiunea suportată este:
102
+
103
+```text
104
+remove_name(host_id, name)
105
+```
106
+
107
+Confirmarea unui WO:
108
+
109
+- cere tastarea exactă a ID-ului WO în interfață
110
+- elimină numele declarate din `config/hosts.yaml`
111
+- marchează WO-ul ca `confirmed`
112
+- regenerează `config/local-hosts.tsv`
113
+- nu rulează automat sync-ul către resolvere
114
+
115
+După confirmare, operatorul verifică schimbarea în git și rulează explicit:
116
+
117
+```bash
118
+./scripts/sync_local_hosts.sh --apply --verify
119
+```
120
+
121
+Primul WO curent este pentru eliminarea numelor locale `pmx.*`/`pbs.*` create istoric pentru vhosturi nginx cu certificate Let's Encrypt. Odată cu CA-ul local, aceste nume nu mai trebuie să existe ca vhosturi separate pentru interfețele Proxmox/PBS.
122
+
93 123
 ## Convenții de nume
94 124
 
95 125
 `madagascar.xdev.ro` este domeniul implicit al rețelei interne. În `config/hosts.yaml` se declară doar numele canonice/FQDN-urile necesare.
@@ -122,6 +152,7 @@ Modelul recomandat:
122 152
 git repo
123 153
   config/hosts.yaml        sursă versionată
124 154
   config/local-hosts.tsv   manifest generat/versionat pentru DNS local
155
+  config/work-orders.yaml  operațiuni confirmabile/versionate
125 156
 
126 157
 jumper
127 158
   host-manager             editează working tree cu OTP
+3 -0
README.md
@@ -6,6 +6,7 @@ This project lives on jumper and is the local source for:
6 6
 
7 7
 - `config/hosts.yaml` - git-versioned host registry
8 8
 - `config/local-hosts.tsv` - DNS manifest exported for local resolvers
9
+- `config/work-orders.yaml` - confirmable operational changes
9 10
 - `scripts/host_manager.pl` - Perl-only web app
10 11
 - `scripts/sync_local_hosts.sh` - local DNS sync to jumper and as01
11 12
 - `scripts/ca_manager.sh` - local OpenSSL CA helper for host certificates
@@ -24,6 +25,8 @@ The web UI is OTP-protected for all registry data, downloads, exports, and write
24 25
 
25 26
 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.
26 27
 
28
+Name removals with operational impact go through a Work Order. Confirming a WO updates `hosts.yaml`, marks the WO as confirmed, and regenerates `local-hosts.tsv`; resolver sync remains an explicit operator step.
29
+
27 30
 The local host CA stores private material outside git under `var/ca`. Initialize it on jumper with:
28 31
 
29 32
 ```bash
+23 -0
config/work-orders.yaml
@@ -0,0 +1,23 @@
1
+version: 1
2
+work_orders:
3
+  - id: "WO-20260606-001"
4
+    status: "pending"
5
+    title: "Remove legacy public-cert local vhost names"
6
+    reason: "These names were introduced for Let's Encrypt-backed nginx vhosts for local Proxmox/PBS web interfaces. The local CA replaces public certificates for local-only resources."
7
+    created_at: "2026-06-06T00:00:00Z"
8
+    actions:
9
+      - type: "remove_name"
10
+        host_id: "baobab"
11
+        name: "pmx.baobab.madagascar.xdev.ro"
12
+      - type: "remove_name"
13
+        host_id: "ebony"
14
+        name: "pmx.ebony.madagascar.xdev.ro"
15
+      - type: "remove_name"
16
+        host_id: "tapia"
17
+        name: "pmx.tapia.madagascar.xdev.ro"
18
+      - type: "remove_name"
19
+        host_id: "anjothibe"
20
+        name: "pbs.anjothibe.madagascar.xdev.ro"
21
+      - type: "remove_name"
22
+        host_id: "andrafiabe"
23
+        name: "pbs.andrafiabe.madagascar.xdev.ro"
+230 -3
scripts/host_manager.pl
@@ -22,6 +22,7 @@ my %opt = (
22 22
     port => $ENV{HOST_MANAGER_PORT} || 8088,
23 23
     data => $ENV{HOST_MANAGER_DATA} || "$project_dir/config/hosts.yaml",
24 24
     local_hosts_tsv => $ENV{HOST_MANAGER_LOCAL_HOSTS_TSV} || "$project_dir/config/local-hosts.tsv",
25
+    work_orders => $ENV{HOST_MANAGER_WORK_ORDERS} || "$project_dir/config/work-orders.yaml",
25 26
 );
26 27
 
27 28
 while (@ARGV) {
@@ -34,6 +35,8 @@ while (@ARGV) {
34 35
         $opt{data} = shift @ARGV;
35 36
     } elsif ($arg eq '--local-hosts-tsv') {
36 37
         $opt{local_hosts_tsv} = shift @ARGV;
38
+    } elsif ($arg eq '--work-orders') {
39
+        $opt{work_orders} = shift @ARGV;
37 40
     } elsif ($arg eq '--help' || $arg eq '-h') {
38 41
         usage();
39 42
         exit 0;
@@ -77,8 +80,9 @@ Environment:
77 80
   HOST_MANAGER_SESSION_SECRET   Optional session signing secret.
78 81
   HOST_MANAGER_DATA             Defaults to config/hosts.yaml.
79 82
   HOST_MANAGER_LOCAL_HOSTS_TSV  Defaults to config/local-hosts.tsv.
83
+  HOST_MANAGER_WORK_ORDERS      Defaults to config/work-orders.yaml.
80 84
 
81
-Read-only endpoints do not require authentication.
85
+The nginx vhost keeps registry, CA, work order and download endpoints behind OTP.
82 86
 EOF
83 87
 }
84 88
 
@@ -136,6 +140,9 @@ sub handle_client {
136 140
         my $registry = load_registry();
137 141
         return send_json($client, 200, registry_payload($registry));
138 142
     }
143
+    if ($method eq 'GET' && $path eq '/api/work-orders') {
144
+        return send_json($client, 200, work_orders_payload(load_work_orders()));
145
+    }
139 146
     if ($method eq 'GET' && $path eq '/download/hosts.yaml') {
140 147
         return send_file($client, $opt{data}, 'application/x-yaml; charset=utf-8', 'hosts.yaml');
141 148
     }
@@ -166,6 +173,10 @@ sub handle_client {
166 173
             my $payload = request_payload(\%headers, $body);
167 174
             return delete_host($client, $payload->{id} || '');
168 175
         }
176
+        if ($path eq '/api/work-orders/confirm') {
177
+            my $payload = request_payload(\%headers, $body);
178
+            return confirm_work_order($client, $payload);
179
+        }
169 180
         if ($path eq '/api/render/local-hosts-tsv') {
170 181
             my $registry = load_registry();
171 182
             my $content = render_local_hosts_tsv($registry);
@@ -189,6 +200,98 @@ sub save_registry {
189 200
     write_file($opt{data}, render_hosts_yaml($registry));
190 201
 }
191 202
 
203
+sub load_work_orders {
204
+    return { version => 1, work_orders => [] } unless -f $opt{work_orders};
205
+    return parse_work_orders_yaml(read_file($opt{work_orders}));
206
+}
207
+
208
+sub save_work_orders {
209
+    my ($orders) = @_;
210
+    backup_file($opt{work_orders});
211
+    write_file($opt{work_orders}, render_work_orders_yaml($orders));
212
+}
213
+
214
+sub work_orders_payload {
215
+    my ($orders) = @_;
216
+    my $pending = 0;
217
+    for my $wo (@{ $orders->{work_orders} || [] }) {
218
+        $pending++ if ($wo->{status} || 'pending') eq 'pending';
219
+    }
220
+    return {
221
+        version => $orders->{version},
222
+        work_orders => $orders->{work_orders} || [],
223
+        counts => {
224
+            work_orders => scalar @{ $orders->{work_orders} || [] },
225
+            pending => $pending,
226
+        },
227
+    };
228
+}
229
+
230
+sub confirm_work_order {
231
+    my ($client, $payload) = @_;
232
+    my $id = clean_scalar($payload->{id} || '');
233
+    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
234
+    return send_json($client, 400, { error => 'confirmation_required' }) unless clean_scalar($payload->{confirm} || '') eq $id;
235
+
236
+    my $orders = load_work_orders();
237
+    my $work_order;
238
+    for my $wo (@{ $orders->{work_orders} || [] }) {
239
+        if (($wo->{id} || '') eq $id) {
240
+            $work_order = $wo;
241
+            last;
242
+        }
243
+    }
244
+    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
245
+    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
246
+
247
+    my $registry = load_registry();
248
+    my $results = apply_work_order($registry, $work_order);
249
+    $work_order->{status} = 'confirmed';
250
+    $work_order->{confirmed_at} = iso_now();
251
+    $work_order->{result} = scalar(@$results) . ' action(s) applied';
252
+
253
+    save_registry($registry);
254
+    save_work_orders($orders);
255
+    backup_file($opt{local_hosts_tsv});
256
+    write_file($opt{local_hosts_tsv}, render_local_hosts_tsv($registry));
257
+
258
+    return send_json($client, 200, {
259
+        ok => json_bool(1),
260
+        work_order => $work_order,
261
+        results => $results,
262
+        local_hosts_tsv => $opt{local_hosts_tsv},
263
+    });
264
+}
265
+
266
+sub apply_work_order {
267
+    my ($registry, $work_order) = @_;
268
+    my @results;
269
+    for my $action (@{ $work_order->{actions} || [] }) {
270
+        my $type = $action->{type} || '';
271
+        if ($type eq 'remove_name') {
272
+            my $host_id = $action->{host_id} || '';
273
+            my $name = $action->{name} || '';
274
+            my $removed = 0;
275
+            for my $host (@{ $registry->{hosts} || [] }) {
276
+                next unless ($host->{id} || '') eq $host_id;
277
+                my @kept = grep { $_ ne $name } @{ $host->{names} || [] };
278
+                $removed = @kept != @{ $host->{names} || [] };
279
+                $host->{names} = \@kept;
280
+                last;
281
+            }
282
+            push @results, {
283
+                type => $type,
284
+                host_id => $host_id,
285
+                name => $name,
286
+                removed => json_bool($removed),
287
+            };
288
+        } else {
289
+            die "Unsupported work order action: $type\n";
290
+        }
291
+    }
292
+    return \@results;
293
+}
294
+
192 295
 sub registry_payload {
193 296
     my ($registry) = @_;
194 297
     my $problems = analyze_hosts($registry->{hosts});
@@ -477,6 +580,67 @@ sub render_hosts_yaml {
477 580
     return $out;
478 581
 }
479 582
 
583
+sub parse_work_orders_yaml {
584
+    my ($text) = @_;
585
+    my %orders = (
586
+        version => 1,
587
+        work_orders => [],
588
+    );
589
+    my ($section, $current, $in_actions, $current_action);
590
+    for my $line (split /\n/, $text) {
591
+        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
592
+        if ($line =~ /^version:\s*(\d+)/) {
593
+            $orders{version} = int($1);
594
+        } elsif ($line =~ /^work_orders:\s*$/) {
595
+            $section = 'work_orders';
596
+        } elsif (($section || '') eq 'work_orders' && $line =~ /^  - id:\s*(.+)$/) {
597
+            $current = {
598
+                id => yaml_unquote($1),
599
+                status => 'pending',
600
+                actions => [],
601
+            };
602
+            push @{ $orders{work_orders} }, $current;
603
+            $in_actions = 0;
604
+            $current_action = undef;
605
+        } elsif ($current && $line =~ /^    actions:\s*$/) {
606
+            $in_actions = 1;
607
+            $current->{actions} ||= [];
608
+        } elsif ($current && $in_actions && $line =~ /^      - type:\s*(.+)$/) {
609
+            $current_action = { type => yaml_unquote($1) };
610
+            push @{ $current->{actions} }, $current_action;
611
+        } elsif ($current_action && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
612
+            $current_action->{$1} = yaml_unquote($2);
613
+        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
614
+            $current->{$1} = yaml_unquote($2);
615
+            $in_actions = 0;
616
+            $current_action = undef;
617
+        }
618
+    }
619
+    return \%orders;
620
+}
621
+
622
+sub render_work_orders_yaml {
623
+    my ($orders) = @_;
624
+    my $out = "version: " . int($orders->{version} || 1) . "\n";
625
+    $out .= "work_orders:\n";
626
+    for my $wo (@{ $orders->{work_orders} || [] }) {
627
+        $out .= "  - id: " . yq($wo->{id}) . "\n";
628
+        for my $key (qw(status title reason created_at confirmed_at result)) {
629
+            next unless exists $wo->{$key} && length($wo->{$key} || '');
630
+            $out .= "    $key: " . yq($wo->{$key}) . "\n";
631
+        }
632
+        $out .= "    actions:\n";
633
+        for my $action (@{ $wo->{actions} || [] }) {
634
+            $out .= "      - type: " . yq($action->{type}) . "\n";
635
+            for my $key (qw(host_id name)) {
636
+                next unless exists $action->{$key} && length($action->{$key} || '');
637
+                $out .= "        $key: " . yq($action->{$key}) . "\n";
638
+            }
639
+        }
640
+    }
641
+    return $out;
642
+}
643
+
480 644
 sub request_payload {
481 645
     my ($headers, $body) = @_;
482 646
     my $type = $headers->{'content-type'} || '';
@@ -827,7 +991,7 @@ sub send_file {
827 991
 
828 992
 sub send_response {
829 993
     my ($client, $status, $body, $type, $extra_headers) = @_;
830
-    my %reason = (200 => 'OK', 400 => 'Bad Request', 401 => 'Unauthorized', 404 => 'Not Found', 500 => 'Internal Server Error', 503 => 'Service Unavailable');
994
+    my %reason = (200 => 'OK', 400 => 'Bad Request', 401 => 'Unauthorized', 404 => 'Not Found', 409 => 'Conflict', 500 => 'Internal Server Error', 503 => 'Service Unavailable');
831 995
     $body = '' unless defined $body;
832 996
     print $client "HTTP/1.1 $status " . ($reason{$status} || 'OK') . "\r\n";
833 997
     print $client "Content-Type: $type\r\n";
@@ -1070,6 +1234,14 @@ sub app_html {
1070 1234
         <div class="problems" id="ca-status"></div>
1071 1235
       </section>
1072 1236
 
1237
+      <section class="panel">
1238
+        <div class="panel-head">
1239
+          <h2>Work Orders</h2>
1240
+          <div class="stats" id="wo-stats"></div>
1241
+        </div>
1242
+        <div class="problems" id="work-orders"></div>
1243
+      </section>
1244
+
1073 1245
       <section class="panel">
1074 1246
         <div class="panel-head">
1075 1247
           <h2>Hosts</h2>
@@ -1117,7 +1289,7 @@ sub app_html {
1117 1289
   </div>
1118 1290
 
1119 1291
   <script>
1120
-    let state = { hosts: [], problems: [], authenticated: false };
1292
+    let state = { hosts: [], problems: [], workOrders: [], authenticated: false };
1121 1293
 
1122 1294
     const $ = (id) => document.getElementById(id);
1123 1295
     const msg = (text) => { $('message').textContent = text || ''; };
@@ -1153,6 +1325,7 @@ sub app_html {
1153 1325
       state.problems = data.problems || [];
1154 1326
       render(data);
1155 1327
       await renderCa();
1328
+      await renderWorkOrders();
1156 1329
     }
1157 1330
 
1158 1331
     function render(data) {
@@ -1190,6 +1363,60 @@ sub app_html {
1190 1363
       }
1191 1364
     }
1192 1365
 
1366
+    async function renderWorkOrders() {
1367
+      try {
1368
+        const data = await api('/api/work-orders');
1369
+        state.workOrders = data.work_orders || [];
1370
+        $('wo-stats').innerHTML = [
1371
+          ['pending', data.counts.pending],
1372
+          ['total', data.counts.work_orders],
1373
+        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
1374
+
1375
+        if (!state.workOrders.length) {
1376
+          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
1377
+          return;
1378
+        }
1379
+
1380
+        $('work-orders').innerHTML = state.workOrders.map(wo => {
1381
+          const actions = (wo.actions || []).map(a => {
1382
+            const target = [a.host_id, a.name].filter(Boolean).join(' ');
1383
+            return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
1384
+          }).join('');
1385
+          const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok';
1386
+          const button = (wo.status || 'pending') === 'pending'
1387
+            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}">Confirm</button>`
1388
+            : '';
1389
+          return `<div class="problem" style="display:grid;gap:8px">
1390
+            <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
1391
+              <div><strong>${escapeHtml(wo.id || '')}</strong> <span class="pill ${statusClass}">${escapeHtml(wo.status || 'pending')}</span></div>
1392
+              ${button}
1393
+            </div>
1394
+            <div>${escapeHtml(wo.title || '')}</div>
1395
+            <div class="muted">${escapeHtml(wo.reason || '')}</div>
1396
+            <div style="display:grid;gap:4px">${actions}</div>
1397
+            ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
1398
+          </div>`;
1399
+        }).join('');
1400
+        document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
1401
+      } catch (e) {
1402
+        $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
1403
+      }
1404
+    }
1405
+
1406
+    async function confirmWorkOrder(id) {
1407
+      const typed = prompt(`Type ${id} to confirm this work order`);
1408
+      if (typed !== id) return;
1409
+      try {
1410
+        await api('/api/work-orders/confirm', {
1411
+          method: 'POST',
1412
+          headers: { 'Content-Type': 'application/json' },
1413
+          body: JSON.stringify({ id, confirm: typed })
1414
+        });
1415
+        msg('work order confirmed; local-hosts.tsv written');
1416
+        await refresh();
1417
+      } catch (e) { msg(e.message); }
1418
+    }
1419
+
1193 1420
     function renderHosts() {
1194 1421
       const filter = $('filter').value.toLowerCase();
1195 1422
       $('hosts').innerHTML = state.hosts