akv-manager 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,6 +32,10 @@ If you're not signed in, the UI tells you to run `az login` and press Refresh.
32
32
  **secrets, keys and certificates**.
33
33
  - reveal a secret value, **create** secrets (one or in batch with a common expiry),
34
34
  **edit** values (current value pre-filled), and **batch** enable/disable/set-expiry.
35
+ - **batch delete** secrets: tick several (or *select all*) and use *Delete…* in the
36
+ batch bar — confirmed by typing the vault name. On soft-delete vaults (the default)
37
+ the secrets become recoverable until their scheduled purge date; **purging is not
38
+ exposed** in this tool. Needs `secrets/delete` (e.g. *Key Vault Secrets Officer*).
35
39
  - **Expiry** — scan every readable vault for secrets/certs that are expired or near
36
40
  expiry, plotted on a time horizon.
37
41
  - **Clusters** — read-only AKS monitoring: browse namespaces and pods, read **pod
@@ -93,6 +97,9 @@ AZBO_ENV_RULES='[["-prd-","Prod"],["-dev-","Dev"],["-tst-","Test"]]' npx akv-man
93
97
  permission problem.
94
98
  - Write actions (create/edit/enable/disable) only succeed where you have write
95
99
  permission; failures are reported per item.
100
+ - **Delete** is a distinct permission from write: an identity that can `Set` secrets
101
+ may still be refused `Delete`. Deleted secrets stop resolving immediately, so
102
+ anything reading them (AKS CSI mounts, running apps) breaks at once.
96
103
 
97
104
  ## Publishing (for maintainers)
98
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akv-manager",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Local web console to browse and manage Azure Key Vaults — secrets, keys, certificates, expiry — plus read-only AKS monitoring (pods, logs, ingress, key-vault tracing), using your own az login. No credentials are stored.",
5
5
  "type": "commonjs",
6
6
  "main": "server.js",
package/public/index.html CHANGED
@@ -662,7 +662,8 @@ function renderBatchBar(){
662
662
  bar.classList.remove('hide');
663
663
  const secretOnly = DRW.kind==='secrets'
664
664
  ? `<input type="date" id="batchExp"><button class="iconbtn" onclick="batchApply('expiry')">Set expiry</button>
665
- <button class="iconbtn" onclick="batchEditValues()">Edit values…</button>` : '';
665
+ <button class="iconbtn" onclick="batchEditValues()">Edit values…</button>
666
+ <button class="iconbtn danger" onclick="confirmBatchDelete()">Delete…</button>` : '';
666
667
  bar.innerHTML = `<span class="bn">${n} selected</span>
667
668
  <button class="iconbtn" onclick="batchApply('enable')">Enable</button>
668
669
  <button class="iconbtn" onclick="batchApply('disable')">Disable</button>
@@ -671,7 +672,7 @@ function renderBatchBar(){
671
672
  }
672
673
  async function batchEditValues(){
673
674
  const names=[...DRW.sel]; if(!names.length) return;
674
- const el=$('#dCreate'); $('#dBatch').classList.add('hide'); el.classList.remove('hide');
675
+ const el=$('#dCreate'); $('#dBatch').classList.add('hide'); el.classList.remove('hide'); el.classList.remove('danger');
675
676
  el.innerHTML = `<h4>Edit ${names.length} secret value${names.length!==1?'s':''}</h4><div class="empty"><span class="spin"></span> loading current values…</div>`;
676
677
  const vals = await Promise.all(names.map(n=>
677
678
  api(`/api/vaults/${DRW.vault}/secrets/${n}/value`).then(r=>({n, ok:r.ok, val:r.ok?(r.data.value||''):'', err:r.forbidden?'no read permission':(r.error||'failed')}))));
@@ -689,11 +690,58 @@ async function batchEditValues(){
689
690
  <button class="btn sm ghost" onclick="closeCreate()">Cancel</button>
690
691
  <span class="hint" id="cMsg">${esc(notes.join(' · '))}</span></div>`;
691
692
  }
693
+ // Delete is the one batch action that cannot be undone from this UI, so it gets a
694
+ // typed confirmation (the vault name) instead of the usual one-click confirm().
695
+ function confirmBatchDelete(){
696
+ const names=[...DRW.sel]; if(!names.length) return;
697
+ const el=$('#dCreate'); $('#dBatch').classList.add('hide');
698
+ el.classList.remove('hide'); el.classList.add('danger');
699
+ const n=names.length, plural=n!==1?'s':'';
700
+ el.innerHTML = `<h4>Delete ${n} secret${plural} from ${esc(DRW.vault)}</h4>
701
+ <div class="dellist">${names.map(x=>`<div>${esc(x)}</div>`).join('')}</div>
702
+ <div class="hint" style="margin:10px 0">They stop resolving immediately — anything reading them (AKS CSI mounts, running apps) breaks now, not at the next deploy.
703
+ On a soft-delete vault they stay recoverable until their scheduled purge date; purging is not available here.</div>
704
+ <div class="fld"><label>Confirm</label><input type="text" id="delConfirm" placeholder="type the vault name: ${esc(DRW.vault)}" autocomplete="off"></div>
705
+ <div class="actions"><button class="btn sm danger" id="delGo" disabled onclick="batchDelete()">Delete ${n} secret${plural}</button>
706
+ <button class="btn sm ghost" onclick="closeCreate()">Cancel</button>
707
+ <span class="hint" id="cMsg"></span></div>`;
708
+ const inp=$('#delConfirm'), go=$('#delGo');
709
+ inp.oninput=()=>{ go.disabled = inp.value.trim().toLowerCase()!==DRW.vault.toLowerCase(); };
710
+ inp.onkeydown=e=>{ if(e.key==='Enter' && !go.disabled) batchDelete(); };
711
+ inp.focus();
712
+ }
713
+ async function batchDelete(){
714
+ const names=[...DRW.sel]; if(!names.length) return;
715
+ const go=$('#delGo'); if(go){ go.disabled=true; go.textContent='Deleting…'; }
716
+ const items = names.map(n=>({vault:DRW.vault, kind:'secret', name:n}));
717
+ const r = await api('/api/batch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({op:{delete:true}, items})});
718
+ if(!r.ok){
719
+ toast(r.error||'delete failed');
720
+ if(go){ go.disabled=false; go.textContent=`Delete ${names.length} secret${names.length!==1?'s':''}`; }
721
+ return;
722
+ }
723
+ const ok=r.results.filter(x=>x.ok).length, fail=r.results.length-ok;
724
+ toast(`${ok} deleted${fail?`, ${fail} failed`:''}`);
725
+ if(fail){
726
+ const failed=r.results.filter(x=>!x.ok); console.warn('Delete failures:', failed);
727
+ banner(`Delete: ${ok} deleted, ${fail} failed (${failed.slice(0,3).map(f=>esc(f.name+': '+f.error)).join('; ')}${fail>3?'…':''}). See console for all.`);
728
+ setTimeout(()=>banner(''),8000);
729
+ } else {
730
+ const purge=(r.results.find(x=>x.scheduledPurgeDate)||{}).scheduledPurgeDate;
731
+ if(purge){
732
+ banner(`Deleted ${ok} secret${ok!==1?'s':''} — recoverable until ${esc(String(purge).slice(0,10))} (soft-delete), then purged automatically.`);
733
+ setTimeout(()=>banner(''),8000);
734
+ }
735
+ }
736
+ closeCreate();
737
+ DRW.sel.clear(); if($('#dSelAll'))$('#dSelAll').checked=false;
738
+ setTimeout(loadKind, 350);
739
+ }
692
740
  function toggleSel(nm){ DRW.sel.has(nm)?DRW.sel.delete(nm):DRW.sel.add(nm); renderDrawerList(); }
693
741
  function clearSel(){ DRW.sel.clear(); if($('#dSelAll'))$('#dSelAll').checked=false; renderDrawerList(); }
694
742
  function selectAll(on){ DRW.sel.clear(); if(on) drawerFiltered().filter(i=>!i.managed).forEach(i=>DRW.sel.add(i.name)); renderDrawerList(); }
695
743
  function openCreate(mode){
696
- const el=$('#dCreate'); DRW.sel.clear(); $('#dBatch').classList.add('hide');
744
+ const el=$('#dCreate'); DRW.sel.clear(); $('#dBatch').classList.add('hide'); el.classList.remove('danger');
697
745
  if(mode==='single'){
698
746
  el.innerHTML = `<h4>Create a secret</h4>
699
747
  <div class="fld"><label>Name</label><input type="text" id="cName" placeholder="my-secret-name" autocomplete="off"></div>
@@ -711,7 +759,7 @@ function openCreate(mode){
711
759
  }
712
760
  el.classList.remove('hide');
713
761
  }
714
- function closeCreate(){ const el=$('#dCreate'); if(el){ el.classList.add('hide'); el.innerHTML=''; } renderBatchBar(); }
762
+ function closeCreate(){ const el=$('#dCreate'); if(el){ el.classList.add('hide'); el.classList.remove('danger'); el.innerHTML=''; } renderBatchBar(); }
715
763
  async function submitCreate(mode){
716
764
  const msg=$('#cMsg');
717
765
  const expRaw=$('#cExp').value;
@@ -1457,9 +1505,53 @@ function renderPodRows(){
1457
1505
  <td>${kvCell(p)}</td>
1458
1506
  <td class="right" style="white-space:nowrap"><button class="iconbtn" onclick="openPodDetail('${esc(p.namespace)}','${esc(p.name)}')">Details</button>
1459
1507
  <button class="iconbtn" onclick="showLogs('${esc(p.namespace)}','${esc(p.name)}')">Logs</button>
1460
- <button class="iconbtn" onclick="openTerminal('${esc(p.namespace)}','${esc(p.name)}')">Terminal</button></td>
1508
+ <button class="iconbtn" onclick="openTerminal('${esc(p.namespace)}','${esc(p.name)}')">Terminal</button>
1509
+ <button class="iconbtn warn" title="rolling-restart the workload that owns this pod" onclick="restartPod('${esc(p.namespace)}','${esc(p.name)}',this)">Restart</button></td>
1461
1510
  </tr>`).join('')}</tbody></table></div></div>`;
1462
1511
  }
1512
+ // Restart is the only write this app makes to a cluster. Two calls on purpose: the
1513
+ // first resolves the owning workload without touching it, so the confirmation names the
1514
+ // Deployment being rolled rather than the pod that was clicked.
1515
+ async function restartPod(ns,pod,btn){
1516
+ const label = btn ? btn.textContent : '';
1517
+ if(btn){ btn.disabled=true; btn.textContent='…'; }
1518
+ const restore=()=>{ if(btn){ btn.disabled=false; btn.textContent=label||'Restart'; } };
1519
+ const base=`/api/aks/${CL.name}/${ns}/pods/${pod}/restart`;
1520
+ const probe = await api(base+'?dryRun=1',{method:'POST'});
1521
+ if(!probe.ok){ restore(); restartError(probe, ns, pod); return; }
1522
+ const wl=probe.data;
1523
+ const ok = confirm(`Rolling-restart ${wl.kind} "${wl.name}" in ${ns}?\n\n`+
1524
+ `Every pod it owns is replaced, not just ${pod}. New pods start before old ones stop, `+
1525
+ `so there is normally no downtime — but this is a live workload on ${CL.name}.`);
1526
+ if(!ok){ restore(); return; }
1527
+ if(btn) btn.textContent='restarting…';
1528
+ const r = await api(base,{method:'POST'});
1529
+ restore();
1530
+ if(!r.ok){ restartError(r, ns, pod); return; }
1531
+ toast(`${r.data.kind} ${r.data.name} restarting`);
1532
+ banner(`Rolling restart requested on ${esc(r.data.kind)} <b>${esc(r.data.name)}</b> in ${esc(ns)} — watch the Ready/Age columns as replicas cycle.`);
1533
+ setTimeout(()=>banner(''),8000);
1534
+ setTimeout(()=>{ if(STATE.route==='cluster' && CL.kind==='pods') loadClusterContent(); }, 2500);
1535
+ }
1536
+ // Off-VPN the API server simply isn't reachable. Rather than dead-end, hand over the
1537
+ // `az aks command invoke` equivalent — it runs from inside the cluster, so it needs no
1538
+ // network path. It is NOT wired to a button: RunCommand executes as cluster-admin
1539
+ // regardless of the caller's Kubernetes RBAC, which this app otherwise never does.
1540
+ function restartError(r, ns, pod){
1541
+ if(r.network){
1542
+ const c=CL.cluster||{};
1543
+ const cmd=`az aks command invoke -g ${c.resourceGroup||'<rg>'} -n ${CL.name} --command "kubectl rollout restart deploy -n ${ns} --selector=app=<app>"`;
1544
+ banner(`🚫 Can't reach ${esc(CL.name)}'s API server (private — needs the VPN). From anywhere, Azure can run it inside the cluster instead:<br>`+
1545
+ `<code class="mono">${esc(cmd)}</code><br><span class="hint">Note: <code>command invoke</code> runs as cluster-admin, not as your Kubernetes identity.</span>`);
1546
+ setTimeout(()=>banner(''),16000);
1547
+ return;
1548
+ }
1549
+ if(r.forbidden){
1550
+ banner(`🔒 ${esc(r.error||'Forbidden')}`); setTimeout(()=>banner(''),10000); return;
1551
+ }
1552
+ toast(r.error||'restart failed');
1553
+ if(r.error && r.error.length>60){ banner(esc(r.error)); setTimeout(()=>banner(''),10000); }
1554
+ }
1463
1555
  async function openPodDetail(ns,pod){
1464
1556
  CL.logPod=null; CL.termPod=null; teardownTerm();
1465
1557
  $('#clBody').innerHTML = `<div class="cmctl"><button class="iconbtn" onclick="loadClusterContent()">← Pods</button>
package/public/styles.css CHANGED
@@ -221,6 +221,16 @@
221
221
  background:var(--ink);border:1px solid var(--line);color:var(--text);border-radius:6px;padding:8px 10px;flex:1;width:100%;color-scheme:dark}
222
222
  .createp textarea{min-height:120px;resize:vertical;line-height:1.6}
223
223
  .createp .actions{display:flex;gap:8px;align-items:center;margin-top:6px}
224
+ /* destructive variant of the same panel */
225
+ .createp.danger{border-color:rgba(255,92,108,.6);
226
+ background:linear-gradient(180deg,rgba(255,92,108,.08),var(--panel2))}
227
+ .createp.danger h4{color:#ffd9dd}
228
+ .createp .dellist{font-family:var(--mono);font-size:12px;color:var(--text);max-height:180px;overflow:auto;
229
+ background:var(--ink);border:1px solid var(--line);border-radius:6px;padding:8px 10px;line-height:1.7}
230
+ .iconbtn.warn{color:#FBC56B;border-color:rgba(242,181,61,.4)}
231
+ .iconbtn.warn:hover{color:#06101f;background:var(--warn);border-color:var(--warn)}
232
+ .iconbtn.danger{color:#FF8893;border-color:rgba(255,92,108,.45)}
233
+ .iconbtn.danger:hover{color:#fff;background:var(--alert);border-color:var(--alert)}
224
234
  .newbtns{display:flex;gap:6px;margin-left:auto}
225
235
  .editor .fld{display:flex;align-items:center;gap:10px;margin-bottom:10px}
226
236
  .editor label{font-family:var(--mono);font-size:11px;color:var(--muted);width:78px;flex:none}
package/server.js CHANGED
@@ -744,10 +744,14 @@ async function kvWrite(vaultName, method, pathQuery, body) {
744
744
  const timer = setTimeout(() => ctrl.abort(), 20000);
745
745
  let resp;
746
746
  try {
747
- resp = await fetch(`https://${vaultName.toLowerCase()}.vault.azure.net${pathQuery}`, {
748
- method, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
749
- body: JSON.stringify(body), signal: ctrl.signal,
750
- });
747
+ // DELETE carries no body — sending one (or a JSON content-type) is what makes
748
+ // some proxies reject the request, so build the init conditionally.
749
+ const init = { method, headers: { Authorization: `Bearer ${token}` }, signal: ctrl.signal };
750
+ if (body !== undefined) {
751
+ init.headers['Content-Type'] = 'application/json';
752
+ init.body = JSON.stringify(body);
753
+ }
754
+ resp = await fetch(`https://${vaultName.toLowerCase()}.vault.azure.net${pathQuery}`, init);
751
755
  } catch (e) {
752
756
  throw Object.assign(new Error(e.name === 'AbortError' ? 'Vault request timed out' : e.message), {});
753
757
  } finally { clearTimeout(timer); }
@@ -782,6 +786,22 @@ app.put('/api/vaults/:name/secrets/:secret', (req, res) => {
782
786
  send(res, kvWrite(name, 'PUT', `/secrets/${secret}?api-version=${KV_API}`, body));
783
787
  });
784
788
 
789
+ // Delete a secret. Soft-delete vaults keep it recoverable until its scheduled purge;
790
+ // purging is deliberately NOT exposed here — it is irreversible.
791
+ app.delete('/api/vaults/:name/secrets/:secret', async (req, res) => {
792
+ const { name, secret } = req.params;
793
+ if (!isValidName(name) || !isValidName(secret)) return res.status(400).json({ ok: false, error: 'Invalid name' });
794
+ try {
795
+ const d = await kvWrite(name, 'DELETE', `/secrets/${secret}?api-version=${KV_API}`);
796
+ res.json({ ok: true, data: { name: secret, ...deletedInfo(d) } });
797
+ } catch (e) {
798
+ res.status(e.needLogin ? 401 : e.forbidden ? 403 : e.notFound ? 404 : 500).json({
799
+ ok: false, error: writeReason(e, 'delete'),
800
+ needLogin: !!e.needLogin, forbidden: !!e.forbidden, firewall: !!e.firewall, notFound: !!e.notFound,
801
+ });
802
+ }
803
+ });
804
+
785
805
  // Update a certificate's attributes (enabled only — expiry is intrinsic to the cert).
786
806
  app.patch('/api/vaults/:name/certificates/:cert', (req, res) => {
787
807
  const { name, cert } = req.params;
@@ -794,12 +814,27 @@ app.patch('/api/vaults/:name/certificates/:cert', (req, res) => {
794
814
  // Turn a write failure into a precise, actionable reason. A 403 from Key Vault is
795
815
  // either a network/firewall block (fix: VPN) or a real data-plane permission gap
796
816
  // (fix: grant Set on secrets) — never conflate the two as a bare "forbidden".
797
- function writeReason(e) {
817
+ function writeReason(e, action = 'write') {
798
818
  if (e.firewall) return 'blocked by the vault firewall — your IP is not allowed (connect to the corporate VPN)';
799
- if (e.forbidden) return 'no write permission — your identity can read but not Set secrets on this vault';
819
+ if (e.forbidden) {
820
+ return action === 'delete'
821
+ ? 'no delete permission — your identity can read but not Delete secrets on this vault (needs secrets/delete, e.g. the Key Vault Secrets Officer role)'
822
+ : 'no write permission — your identity can read but not Set secrets on this vault';
823
+ }
800
824
  return e.message || 'failed';
801
825
  }
802
826
 
827
+ // A Key Vault DELETE returns a DeletedSecretBundle. On a soft-delete vault (the
828
+ // default, and mandatory since 2020) the secret is NOT erased — it moves to a
829
+ // recoverable state and Azure purges it on `scheduledPurgeDate` (unix seconds).
830
+ function deletedInfo(d) {
831
+ const ts = d && d.scheduledPurgeDate;
832
+ return {
833
+ recoverable: !!(d && d.recoveryId),
834
+ scheduledPurgeDate: typeof ts === 'number' ? new Date(ts * 1000).toISOString() : (ts || null),
835
+ };
836
+ }
837
+
803
838
  // Create one or many secrets in a vault. items: [{name, value}], optional common expires.
804
839
  app.post('/api/vaults/:name/secrets-create', async (req, res) => {
805
840
  const { name } = req.params;
@@ -822,16 +857,26 @@ app.post('/api/vaults/:name/secrets-create', async (req, res) => {
822
857
  res.json({ ok: true, results });
823
858
  });
824
859
 
825
- // Batch update across one or many vaults. items: [{vault, kind, name}], op: {enabled?, expires?}
860
+ // Batch update across one or many vaults. items: [{vault, kind, name}],
861
+ // op: {enabled?, expires?} or {delete: true} (secrets only).
826
862
  app.post('/api/batch', async (req, res) => {
827
863
  const { op = {}, items } = req.body || {};
828
864
  if (!Array.isArray(items) || !items.length) return res.status(400).json({ ok: false, error: 'No items selected' });
829
865
  if (items.length > 500) return res.status(400).json({ ok: false, error: 'Too many items (max 500 per batch)' });
866
+ const isDelete = op.delete === true;
830
867
  const attrs = buildAttrs(op);
831
868
  const results = await pool(items, async (it) => {
832
869
  const r = { vault: it.vault, name: it.name, kind: it.kind };
833
870
  try {
834
871
  if (!isValidName(it.vault) || !isValidName(it.name)) throw new Error('Invalid name');
872
+ if (isDelete) {
873
+ // Certificates and keys are out of scope on purpose: deleting a certificate
874
+ // also drops its managed secret/key pair, which is not what a "delete these
875
+ // rows" gesture in the secrets list should ever do.
876
+ if (it.kind !== 'secret') throw new Error('Only secrets can be deleted here');
877
+ const d = await kvWrite(it.vault, 'DELETE', `/secrets/${it.name}?api-version=${KV_API}`);
878
+ return { ...r, ok: true, ...deletedInfo(d) };
879
+ }
835
880
  if (it.kind === 'certificate') {
836
881
  if (op.expires) throw new Error("A certificate's expiry is fixed by the certificate itself");
837
882
  const a = {}; if (typeof op.enabled === 'boolean') a.enabled = op.enabled;
@@ -842,7 +887,7 @@ app.post('/api/batch', async (req, res) => {
842
887
  await kvWrite(it.vault, 'PATCH', `/secrets/${it.name}?api-version=${KV_API}`, { attributes: attrs });
843
888
  }
844
889
  return { ...r, ok: true };
845
- } catch (e) { return { ...r, ok: false, error: writeReason(e), firewall: !!e.firewall, forbidden: !!e.forbidden }; }
890
+ } catch (e) { return { ...r, ok: false, error: writeReason(e, isDelete ? 'delete' : 'write'), firewall: !!e.firewall, forbidden: !!e.forbidden }; }
846
891
  }, 12);
847
892
  res.json({ ok: true, results });
848
893
  });
@@ -926,15 +971,24 @@ function imageTag(img) {
926
971
  return c >= 0 ? repoTag.slice(c + 1) : '';
927
972
  }
928
973
 
929
- // One GET against a cluster's Kubernetes API. raw=true returns text (for pod logs).
930
- function k8sGet(cred, pathQuery, { raw = false, timeout = 25000 } = {}) {
974
+ // One request against a cluster's Kubernetes API. raw=true returns text (for pod logs).
975
+ // Everything read-only goes through k8sGet; the single mutating caller is the workload
976
+ // restart, which PATCHes a controller's pod template. The AAD token is the user's own,
977
+ // so Kubernetes RBAC still decides what is allowed — this grants nothing they lack.
978
+ function k8sReq(cred, method, pathQuery, { raw = false, timeout = 25000, body, contentType } = {}) {
931
979
  return new Promise((resolve, reject) => {
932
980
  getAksToken(cred.sub).then((token) => {
933
981
  const u = new URL(cred.server + pathQuery);
982
+ const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
983
+ const headers = { Authorization: `Bearer ${token}`, Accept: raw ? '*/*' : 'application/json' };
984
+ if (payload) {
985
+ headers['Content-Type'] = contentType || 'application/json';
986
+ headers['Content-Length'] = payload.length;
987
+ }
934
988
  const req = https.request({
935
- hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method: 'GET',
989
+ hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method,
936
990
  // '*/*' for raw so the log subresource returns text/plain without tripping content negotiation
937
- headers: { Authorization: `Bearer ${token}`, Accept: raw ? '*/*' : 'application/json' },
991
+ headers,
938
992
  ca: cred.ca, timeout,
939
993
  }, (res) => {
940
994
  let data = ''; res.setEncoding('utf8');
@@ -957,10 +1011,12 @@ function k8sGet(cred, pathQuery, { raw = false, timeout = 25000 } = {}) {
957
1011
  : e.message), { network }));
958
1012
  });
959
1013
  req.on('timeout', () => req.destroy(Object.assign(new Error('Cluster API request timed out — is the VPN connected?'), { network: true })));
1014
+ if (payload) req.write(payload);
960
1015
  req.end();
961
1016
  }).catch(reject);
962
1017
  });
963
1018
  }
1019
+ const k8sGet = (cred, pathQuery, opts) => k8sReq(cred, 'GET', pathQuery, opts);
964
1020
 
965
1021
  // Discover clusters you have ARM read on (best-effort) and merge with the configured list.
966
1022
  const AKS_QUERY =
@@ -1087,6 +1143,65 @@ app.get('/api/aks/:cluster/:ns/pods/:pod/detail', (req, res) => {
1087
1143
  })());
1088
1144
  });
1089
1145
 
1146
+ // ---- AKS: restart a workload (the one mutating cluster operation) -----------
1147
+ // A pod is never restarted directly: killing a pod is a hard stop of one replica, and
1148
+ // for a bare (uncontrolled) pod it is unrecoverable. Instead we walk up to the pod's
1149
+ // controller and stamp its pod template with `kubectl.kubernetes.io/restartedAt` —
1150
+ // byte-for-byte what `kubectl rollout restart` does, so the controller rolls replicas
1151
+ // gracefully. Needs Kubernetes RBAC `patch` on that controller in that namespace.
1152
+ const RESTARTABLE = { Deployment: 'deployments', StatefulSet: 'statefulsets', DaemonSet: 'daemonsets' };
1153
+
1154
+ async function resolveWorkload(cred, ns, pod) {
1155
+ const p = await k8sGet(cred, `/api/v1/namespaces/${ns}/pods/${pod}`);
1156
+ const owner = ((p.metadata || {}).ownerReferences || []).find((o) => o.controller) ||
1157
+ ((p.metadata || {}).ownerReferences || [])[0];
1158
+ if (!owner) {
1159
+ throw Object.assign(new Error(
1160
+ 'This pod has no controller (it is a bare pod). Nothing would recreate it, so restarting is not offered.'), { status: 409 });
1161
+ }
1162
+ if (owner.kind === 'ReplicaSet') {
1163
+ const rs = await k8sGet(cred, `/apis/apps/v1/namespaces/${ns}/replicasets/${owner.name}`);
1164
+ const dep = ((rs.metadata || {}).ownerReferences || []).find((o) => o.kind === 'Deployment');
1165
+ if (!dep) {
1166
+ throw Object.assign(new Error(
1167
+ `This pod is owned by ReplicaSet "${owner.name}" with no Deployment above it — a bare ReplicaSet does not roll its pods on a template change.`), { status: 409 });
1168
+ }
1169
+ return { kind: 'Deployment', name: dep.name, plural: 'deployments' };
1170
+ }
1171
+ const plural = RESTARTABLE[owner.kind];
1172
+ if (!plural) {
1173
+ throw Object.assign(new Error(
1174
+ `This pod is owned by a ${owner.kind} ("${owner.name}"), which has no rolling-restart semantics.`), { status: 409 });
1175
+ }
1176
+ return { kind: owner.kind, name: owner.name, plural };
1177
+ }
1178
+
1179
+ // dryRun=1 resolves the owning workload WITHOUT touching it, so the UI can name the
1180
+ // thing in its confirmation instead of asking the user to trust a pod name.
1181
+ app.post('/api/aks/:cluster/:ns/pods/:pod/restart', (req, res) => {
1182
+ const { ns, pod } = req.params;
1183
+ if (!isK8sName(ns) || !isK8sName(pod)) return res.status(400).json({ ok: false, error: 'Invalid namespace/pod' });
1184
+ const dryRun = req.query.dryRun === '1' || req.query.dryRun === 'true';
1185
+ send(res, (async () => {
1186
+ const cred = await aksCredential(await getClusterChecked(req.params.cluster));
1187
+ const wl = await resolveWorkload(cred, ns, pod);
1188
+ if (dryRun) return { ...wl, namespace: ns, dryRun: true };
1189
+ const restartedAt = new Date().toISOString();
1190
+ const patch = { spec: { template: { metadata: { annotations: { 'kubectl.kubernetes.io/restartedAt': restartedAt } } } } };
1191
+ try {
1192
+ await k8sReq(cred, 'PATCH', `/apis/apps/v1/namespaces/${ns}/${wl.plural}/${wl.name}`, {
1193
+ body: patch, contentType: 'application/strategic-merge-patch+json',
1194
+ });
1195
+ } catch (e) {
1196
+ if (e.forbidden) {
1197
+ e.message = `no permission to patch ${wl.plural} in "${ns}" (Kubernetes RBAC) — ${e.message}`;
1198
+ }
1199
+ throw e;
1200
+ }
1201
+ return { ...wl, namespace: ns, restartedAt };
1202
+ })());
1203
+ });
1204
+
1090
1205
  // Cluster-wide configmap list (all namespaces).
1091
1206
  app.get('/api/aks/:cluster/configmaps', (req, res) =>
1092
1207
  send(res, (async () => {