Showing 4 changed files with 141 additions and 14 deletions
+7 -3
.doc/host-manager.md
@@ -16,7 +16,7 @@ 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.
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, exprimă intenția operațională și trebuie dus până la capăt înainte să modifice registrul.
20 20
 
21 21
 Endpoint-uri publice:
22 22
 
@@ -39,6 +39,7 @@ Endpoint-uri cu OTP:
39 39
 - `/download/ca.crt`
40 40
 - `POST /api/hosts/upsert`
41 41
 - `POST /api/hosts/delete`
42
+- `POST /api/work-orders/checklist`
42 43
 - `POST /api/work-orders/confirm`
43 44
 - `POST /api/render/local-hosts-tsv`
44 45
 
@@ -96,7 +97,9 @@ Secretul nu se comite în repo. Dacă avem nevoie de integrare cu un manager de
96 97
 
97 98
 ## Work Orders
98 99
 
99
-`config/work-orders.yaml` păstrează operațiuni care trebuie confirmate înainte să atingă registrul.
100
+`config/work-orders.yaml` păstrează operațiuni care trebuie executate și confirmate înainte să atingă registrul.
101
+
102
+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.
100 103
 
101 104
 În MVP, acțiunea suportată este:
102 105
 
@@ -107,6 +110,7 @@ remove_name(host_id, name)
107 110
 Confirmarea unui WO:
108 111
 
109 112
 - cere tastarea exactă a ID-ului WO în interfață
113
+- este blocată dacă există pași de checklist nemarcați `done`
110 114
 - elimină numele declarate din `config/hosts.yaml`
111 115
 - marchează WO-ul ca `confirmed`
112 116
 - regenerează `config/local-hosts.tsv`
@@ -118,7 +122,7 @@ După confirmare, operatorul verifică schimbarea în git și rulează explicit:
118 122
 ./scripts/sync_local_hosts.sh --apply --verify
119 123
 ```
120 124
 
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.
125
+Primul WO curent este pentru retragerea 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, dar rămân publicate până când checklist-ul operațional este complet și WO-ul este confirmat.
122 126
 
123 127
 ## Convenții de nume
124 128
 
+1 -1
README.md
@@ -25,7 +25,7 @@ The web UI is OTP-protected for all registry data, downloads, exports, and write
25 25
 
26 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.
27 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.
28
+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.
29 29
 
30 30
 The local host CA stores private material outside git under `var/ca`. Initialize it on jumper with:
31 31
 
+24 -2
config/work-orders.yaml
@@ -2,9 +2,31 @@ version: 1
2 2
 work_orders:
3 3
   - id: "WO-20260606-001"
4 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."
5
+    title: "Retire 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. This WO records the intent to retire them, but the names stay published until the vhosts, certificates, clients and monitoring are migrated."
7 7
     created_at: "2026-06-06T00:00:00Z"
8
+    checklist:
9
+      - id: "inventory-vhosts"
10
+        text: "Find the nginx vhost files, upstream targets and Let's Encrypt renewal state for all pmx.* and pbs.* names."
11
+        status: "pending"
12
+      - id: "issue-local-certs"
13
+        text: "Create or request local-CA certificates for the canonical internal service names that will replace these vhost aliases."
14
+        status: "pending"
15
+      - id: "install-local-certs"
16
+        text: "Install the local certificates on the service endpoint or replacement nginx vhost and reload the affected services."
17
+        status: "pending"
18
+      - id: "remove-legacy-vhosts"
19
+        text: "Remove the legacy nginx vhosts and Let's Encrypt renewal hooks/configuration for the pmx.* and pbs.* aliases."
20
+        status: "pending"
21
+      - id: "verify-access"
22
+        text: "Verify Proxmox/PBS access through the canonical internal names with the local CA trusted by clients."
23
+        status: "pending"
24
+      - id: "verify-unused"
25
+        text: "Check configs, monitoring, browser bookmarks/runbooks and logs so the retired names are no longer in active use."
26
+        status: "pending"
27
+      - id: "final-operator-approval"
28
+        text: "Operator confirms the task is complete and the aliases can be removed from the host registry."
29
+        status: "pending"
8 30
     actions:
9 31
       - type: "remove_name"
10 32
         host_id: "baobab"
+109 -8
scripts/host_manager.pl
@@ -177,6 +177,10 @@ sub handle_client {
177 177
             my $payload = request_payload(\%headers, $body);
178 178
             return confirm_work_order($client, $payload);
179 179
         }
180
+        if ($path eq '/api/work-orders/checklist') {
181
+            my $payload = request_payload(\%headers, $body);
182
+            return update_work_order_checklist($client, $payload);
183
+        }
180 184
         if ($path eq '/api/render/local-hosts-tsv') {
181 185
             my $registry = load_registry();
182 186
             my $content = render_local_hosts_tsv($registry);
@@ -243,6 +247,11 @@ sub confirm_work_order {
243 247
     }
244 248
     return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
245 249
     return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
250
+    my $incomplete = incomplete_work_order_items($work_order);
251
+    return send_json($client, 409, {
252
+        error => 'work_order_incomplete',
253
+        incomplete => $incomplete,
254
+    }) if @$incomplete;
246 255
 
247 256
     my $registry = load_registry();
248 257
     my $results = apply_work_order($registry, $work_order);
@@ -263,6 +272,52 @@ sub confirm_work_order {
263 272
     });
264 273
 }
265 274
 
275
+sub update_work_order_checklist {
276
+    my ($client, $payload) = @_;
277
+    my $id = clean_scalar($payload->{id} || '');
278
+    my $item_id = clean_scalar($payload->{item_id} || '');
279
+    my $status = clean_scalar($payload->{status} || '');
280
+    my $notes = clean_scalar($payload->{notes} || '');
281
+    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
282
+    return send_json($client, 400, { error => 'invalid_checklist_item' }) unless $item_id =~ /\A[A-Za-z0-9_.-]+\z/;
283
+    return send_json($client, 400, { error => 'invalid_checklist_status' }) unless $status =~ /\A(?:pending|done|blocked)\z/;
284
+
285
+    my $orders = load_work_orders();
286
+    my $work_order;
287
+    for my $wo (@{ $orders->{work_orders} || [] }) {
288
+        if (($wo->{id} || '') eq $id) {
289
+            $work_order = $wo;
290
+            last;
291
+        }
292
+    }
293
+    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
294
+    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
295
+
296
+    my $item;
297
+    for my $candidate (@{ $work_order->{checklist} || [] }) {
298
+        if (($candidate->{id} || '') eq $item_id) {
299
+            $item = $candidate;
300
+            last;
301
+        }
302
+    }
303
+    return send_json($client, 404, { error => 'checklist_item_not_found' }) unless $item;
304
+
305
+    $item->{status} = $status;
306
+    $item->{updated_at} = iso_now();
307
+    $item->{notes} = $notes if length $notes;
308
+    save_work_orders($orders);
309
+    return send_json($client, 200, { ok => json_bool(1), work_order => $work_order });
310
+}
311
+
312
+sub incomplete_work_order_items {
313
+    my ($work_order) = @_;
314
+    my @incomplete;
315
+    for my $item (@{ $work_order->{checklist} || [] }) {
316
+        push @incomplete, $item unless ($item->{status} || 'pending') eq 'done';
317
+    }
318
+    return \@incomplete;
319
+}
320
+
266 321
 sub apply_work_order {
267 322
     my ($registry, $work_order) = @_;
268 323
     my @results;
@@ -586,7 +641,7 @@ sub parse_work_orders_yaml {
586 641
         version => 1,
587 642
         work_orders => [],
588 643
     );
589
-    my ($section, $current, $in_actions, $current_action);
644
+    my ($section, $current, $list_section, $current_action, $current_item);
590 645
     for my $line (split /\n/, $text) {
591 646
         next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
592 647
         if ($line =~ /^version:\s*(\d+)/) {
@@ -597,23 +652,36 @@ sub parse_work_orders_yaml {
597 652
             $current = {
598 653
                 id => yaml_unquote($1),
599 654
                 status => 'pending',
655
+                checklist => [],
600 656
                 actions => [],
601 657
             };
602 658
             push @{ $orders{work_orders} }, $current;
603
-            $in_actions = 0;
659
+            $list_section = '';
604 660
             $current_action = undef;
661
+            $current_item = undef;
662
+        } elsif ($current && $line =~ /^    checklist:\s*$/) {
663
+            $list_section = 'checklist';
664
+            $current->{checklist} ||= [];
665
+        } elsif ($current && $list_section eq 'checklist' && $line =~ /^      - id:\s*(.+)$/) {
666
+            $current_item = { id => yaml_unquote($1), status => 'pending' };
667
+            push @{ $current->{checklist} }, $current_item;
668
+            $current_action = undef;
669
+        } elsif ($current_item && $list_section eq 'checklist' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
670
+            $current_item->{$1} = yaml_unquote($2);
605 671
         } elsif ($current && $line =~ /^    actions:\s*$/) {
606
-            $in_actions = 1;
672
+            $list_section = 'actions';
607 673
             $current->{actions} ||= [];
608
-        } elsif ($current && $in_actions && $line =~ /^      - type:\s*(.+)$/) {
674
+        } elsif ($current && $list_section eq 'actions' && $line =~ /^      - type:\s*(.+)$/) {
609 675
             $current_action = { type => yaml_unquote($1) };
610 676
             push @{ $current->{actions} }, $current_action;
611
-        } elsif ($current_action && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
677
+            $current_item = undef;
678
+        } elsif ($current_action && $list_section eq 'actions' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
612 679
             $current_action->{$1} = yaml_unquote($2);
613 680
         } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
614 681
             $current->{$1} = yaml_unquote($2);
615
-            $in_actions = 0;
682
+            $list_section = '';
616 683
             $current_action = undef;
684
+            $current_item = undef;
617 685
         }
618 686
     }
619 687
     return \%orders;
@@ -629,6 +697,14 @@ sub render_work_orders_yaml {
629 697
             next unless exists $wo->{$key} && length($wo->{$key} || '');
630 698
             $out .= "    $key: " . yq($wo->{$key}) . "\n";
631 699
         }
700
+        $out .= "    checklist:\n";
701
+        for my $item (@{ $wo->{checklist} || [] }) {
702
+            $out .= "      - id: " . yq($item->{id}) . "\n";
703
+            for my $key (qw(text status owner notes updated_at)) {
704
+                next unless exists $item->{$key} && length($item->{$key} || '');
705
+                $out .= "        $key: " . yq($item->{$key}) . "\n";
706
+            }
707
+        }
632 708
         $out .= "    actions:\n";
633 709
         for my $action (@{ $wo->{actions} || [] }) {
634 710
             $out .= "      - type: " . yq($action->{type}) . "\n";
@@ -1142,6 +1218,7 @@ sub app_html {
1142 1218
     button, input, select, textarea { font: inherit; }
1143 1219
     button, .linkbtn { border: 1px solid var(--line); background: #fff; color: var(--ink); border-radius: 6px; padding: 7px 10px; min-height: 34px; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; }
1144 1220
     button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
1221
+    button:disabled { opacity: .45; cursor: not-allowed; }
1145 1222
     button.danger { color: var(--bad); }
1146 1223
     button:disabled, .linkbtn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
1147 1224
     input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; color: var(--ink); }
@@ -1378,31 +1455,55 @@ sub app_html {
1378 1455
         }
1379 1456
 
1380 1457
         $('work-orders').innerHTML = state.workOrders.map(wo => {
1458
+          const checklist = wo.checklist || [];
1459
+          const doneItems = checklist.filter(item => (item.status || 'pending') === 'done').length;
1460
+          const checklistComplete = checklist.length === 0 || doneItems === checklist.length;
1461
+          const checklistHtml = checklist.map(item => {
1462
+            const checked = (item.status || 'pending') === 'done' ? 'checked' : '';
1463
+            return `<label style="display:flex;align-items:flex-start;gap:8px">
1464
+              <input type="checkbox" data-wo-checklist="${escapeHtml(wo.id)}" data-item-id="${escapeHtml(item.id || '')}" ${checked}>
1465
+              <span><strong>${escapeHtml(item.id || '')}</strong> ${escapeHtml(item.text || '')}</span>
1466
+            </label>`;
1467
+          }).join('');
1381 1468
           const actions = (wo.actions || []).map(a => {
1382 1469
             const target = [a.host_id, a.name].filter(Boolean).join(' ');
1383 1470
             return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
1384 1471
           }).join('');
1385 1472
           const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok';
1386 1473
           const button = (wo.status || 'pending') === 'pending'
1387
-            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}">Confirm</button>`
1474
+            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}" ${checklistComplete ? '' : 'disabled'}>Confirm</button>`
1388 1475
             : '';
1389 1476
           return `<div class="problem" style="display:grid;gap:8px">
1390 1477
             <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>
1478
+              <div><strong>${escapeHtml(wo.id || '')}</strong> <span class="pill ${statusClass}">${escapeHtml(wo.status || 'pending')}</span> <span class="pill">${doneItems}/${checklist.length} done</span></div>
1392 1479
               ${button}
1393 1480
             </div>
1394 1481
             <div>${escapeHtml(wo.title || '')}</div>
1395 1482
             <div class="muted">${escapeHtml(wo.reason || '')}</div>
1483
+            <div style="display:grid;gap:6px">${checklistHtml}</div>
1396 1484
             <div style="display:grid;gap:4px">${actions}</div>
1397 1485
             ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
1398 1486
           </div>`;
1399 1487
         }).join('');
1488
+        document.querySelectorAll('[data-wo-checklist]').forEach(input => input.addEventListener('change', () => updateWorkOrderChecklist(input.dataset.woChecklist, input.dataset.itemId, input.checked)));
1400 1489
         document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
1401 1490
       } catch (e) {
1402 1491
         $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
1403 1492
       }
1404 1493
     }
1405 1494
 
1495
+    async function updateWorkOrderChecklist(id, itemId, checked) {
1496
+      try {
1497
+        await api('/api/work-orders/checklist', {
1498
+          method: 'POST',
1499
+          headers: { 'Content-Type': 'application/json' },
1500
+          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
1501
+        });
1502
+        msg('work order updated');
1503
+        await refresh();
1504
+      } catch (e) { msg(e.message); await refresh(); }
1505
+    }
1506
+
1406 1507
     async function confirmWorkOrder(id) {
1407 1508
       const typed = prompt(`Type ${id} to confirm this work order`);
1408 1509
       if (typed !== id) return;