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 +7 -0
- package/package.json +1 -1
- package/public/index.html +97 -5
- package/public/styles.css +10 -0
- package/server.js +127 -12
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.
|
|
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
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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)
|
|
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}],
|
|
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
|
|
930
|
-
|
|
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
|
|
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
|
|
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 () => {
|