@@ -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 |
|
@@ -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 |
|
@@ -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" |
@@ -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; |