akv-manager 0.0.5 → 0.1.1
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 +29 -0
- package/package.json +3 -2
- package/public/index.html +448 -9
- package/public/styles.css +23 -0
- package/server.js +490 -2
package/README.md
CHANGED
|
@@ -34,6 +34,32 @@ If you're not signed in, the UI tells you to run `az login` and press Refresh.
|
|
|
34
34
|
**edit** values (current value pre-filled), and **batch** enable/disable/set-expiry.
|
|
35
35
|
- **Expiry** — scan every readable vault for secrets/certs that are expired or near
|
|
36
36
|
expiry, plotted on a time horizon.
|
|
37
|
+
- **Clusters** — read-only AKS monitoring: browse namespaces and pods, read **pod
|
|
38
|
+
logs** and **configmaps**, all as your own `az` identity. Talks to the Kubernetes
|
|
39
|
+
API directly (no `kubectl`/`kubelogin` needed) via an AAD token for the managed
|
|
40
|
+
AAD server app. Private API servers need you on the right network (e.g. corporate
|
|
41
|
+
VPN); the UI flags it when a cluster is unreachable.
|
|
42
|
+
|
|
43
|
+
### Configuring AKS clusters
|
|
44
|
+
|
|
45
|
+
Clusters you have ARM *Reader* on are auto-discovered. If you only have **data-plane**
|
|
46
|
+
(Kubernetes-RBAC) access — common — list them yourself via either variable:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# inline JSON …
|
|
50
|
+
AZBO_AKS_CLUSTERS='[{"name":"my-aks","resourceGroup":"my-rg","subscription":"My Sub","env":"Dev","label":"My cluster"}]' npx akv-manager
|
|
51
|
+
|
|
52
|
+
# … or a file
|
|
53
|
+
AZBO_AKS_CLUSTERS_FILE=./aks-clusters.json npx akv-manager
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Each entry needs `name`, `resourceGroup`, `subscription` (name or id); `env` and
|
|
57
|
+
`label` are optional. The console never bundles any cluster list — it's yours to provide.
|
|
58
|
+
|
|
59
|
+
You can also **paste the list in the UI** instead of using a file: open **Clusters →
|
|
60
|
+
✎ Edit cluster list**, paste the same JSON array, and Save. It's kept in your browser's
|
|
61
|
+
`localStorage` and registered with the local server for the session — no file needed.
|
|
62
|
+
File/env clusters and pasted clusters are merged.
|
|
37
63
|
|
|
38
64
|
## Configuration (all optional, via environment variables)
|
|
39
65
|
|
|
@@ -44,6 +70,9 @@ If you're not signed in, the UI tells you to run `az login` and press Refresh.
|
|
|
44
70
|
| `AZBO_NO_OPEN` | — | Set to anything to skip auto-opening the browser. |
|
|
45
71
|
| `AZBO_TITLE` | `Keyvault Console` | Custom title shown in the sidebar. |
|
|
46
72
|
| `AZBO_ENV_RULES` | — | JSON `[["pattern","Label"], …]` mapping subscription-name patterns to environment labels. |
|
|
73
|
+
| `AZBO_PROTECTED_TAGS` | governance set | Comma-separated tag names that are read-only in the UI (`*` = prefix wildcard). Set empty to disable. |
|
|
74
|
+
| `AZBO_AKS_CLUSTERS` | — | JSON array of `{name,resourceGroup,subscription,env?,label?}` clusters to monitor. |
|
|
75
|
+
| `AZBO_AKS_CLUSTERS_FILE` | — | Path to a JSON file with the same cluster array. |
|
|
47
76
|
| `AZ_PATH` | `az` | Path to the Azure CLI binary. |
|
|
48
77
|
| `AZ_TIMEOUT` | `120000` | Per-`az`-call timeout (ms). |
|
|
49
78
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akv-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Local web console to browse and manage Azure Key Vaults — secrets, keys, certificates, expiry — using your own az login. No credentials are stored.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "server.js",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"cli"
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"express": "^4.19.2"
|
|
33
|
+
"express": "^4.19.2",
|
|
34
|
+
"ws": "^8.21.0"
|
|
34
35
|
},
|
|
35
36
|
"engines": {
|
|
36
37
|
"node": ">=18"
|
package/public/index.html
CHANGED
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
};
|
|
33
33
|
</script>
|
|
34
34
|
<link rel="stylesheet" href="/styles.css">
|
|
35
|
+
<!-- xterm.js: in-browser terminal for the AKS pod shell -->
|
|
36
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css">
|
|
37
|
+
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
|
38
|
+
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
|
|
35
39
|
</head>
|
|
36
40
|
<body>
|
|
37
41
|
<div class="grid min-h-screen grid-cols-[236px_1fr] max-[900px]:grid-cols-1">
|
|
@@ -65,6 +69,9 @@
|
|
|
65
69
|
<button data-view="expiry">
|
|
66
70
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2"/></svg>
|
|
67
71
|
Expiry</button>
|
|
72
|
+
<button data-view="clusters">
|
|
73
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 2l8 4.5v9L12 20l-8-4.5v-9L12 2z"/><path d="M12 7l4 2.2v4.6L12 16l-4-2.2V9.2L12 7z"/></svg>
|
|
74
|
+
Clusters</button>
|
|
68
75
|
</nav>
|
|
69
76
|
|
|
70
77
|
<div class="navlabel" id="pinLabel">Pinned</div>
|
|
@@ -213,6 +220,7 @@ async function boot(){
|
|
|
213
220
|
renderSidebar();
|
|
214
221
|
router();
|
|
215
222
|
kickExpiryScan(); // warm the dashboard's lifecycle numbers in the background
|
|
223
|
+
registerSessionClusters().then(loadClustersCache); // register pasted clusters, then warm ⌘K
|
|
216
224
|
}
|
|
217
225
|
|
|
218
226
|
async function loadSubs(){
|
|
@@ -237,25 +245,30 @@ const META = {
|
|
|
237
245
|
dashboard:['Operations','Dashboard','Secrets and certificates that need attention, your pinned vaults, and access health.'],
|
|
238
246
|
vaults:['Inventory','Key Vaults','Every readable vault across your subscriptions. Filter by environment and tags; open one to manage it.'],
|
|
239
247
|
expiry:['Lifecycle','Expiring soon','Secrets and certificates that are expired or approaching expiry across every vault you can read.'],
|
|
248
|
+
clusters:['Kubernetes','Clusters','Read-only monitoring of your AKS clusters — browse pods, read logs and configmaps. Configured locally; runs as your az identity.'],
|
|
240
249
|
settings:['Account','Account & subscriptions','Your Azure identity and the subscription the console is pointed at.'],
|
|
241
250
|
};
|
|
242
251
|
let mountedVault = null;
|
|
243
252
|
function setHead(view){
|
|
244
|
-
const
|
|
245
|
-
$('#headblock').style.display =
|
|
246
|
-
$('#search').classList.toggle('hide',
|
|
247
|
-
if(!
|
|
248
|
-
|
|
253
|
+
const detail = view==='vault' || view==='cluster'; // detail pages carry their own breadcrumb
|
|
254
|
+
$('#headblock').style.display = detail ? 'none' : '';
|
|
255
|
+
$('#search').classList.toggle('hide', view!=='vaults' && view!=='dashboard' && view!=='expiry' && view!=='settings');
|
|
256
|
+
if(!detail && META[view]){ $('#eyebrow').textContent=META[view][0]; $('#title').textContent=META[view][1]; $('#subtitle').textContent=META[view][2]; }
|
|
257
|
+
const navActive = view==='vault'?'vaults' : view==='cluster'?'clusters' : view;
|
|
258
|
+
document.querySelectorAll('nav button').forEach(b=>b.classList.toggle('active', b.dataset.view===navActive));
|
|
249
259
|
}
|
|
250
260
|
function router(){
|
|
251
261
|
if(!STATE.account) return;
|
|
252
262
|
const parts = parseHash();
|
|
253
263
|
const route = parts[0]||'dashboard';
|
|
254
264
|
STATE.route = route;
|
|
265
|
+
if(route!=='cluster') teardownTerm(); // close any open pod shell when leaving Clusters
|
|
255
266
|
if(route!=='vault') mountedVault=null;
|
|
256
267
|
if(route==='vaults'){ setHead('vaults'); viewVaults(); }
|
|
257
268
|
else if(route==='vault'){ setHead('vault'); viewVault(parts[1], parts[2]||'secrets'); }
|
|
258
269
|
else if(route==='expiry'){ setHead('expiry'); viewExpiry(); }
|
|
270
|
+
else if(route==='clusters'){ setHead('clusters'); viewClusters(); }
|
|
271
|
+
else if(route==='cluster'){ setHead('cluster'); viewCluster(parts[1], parts[2], parts[3]||'pods'); }
|
|
259
272
|
else if(route==='settings'){ setHead('settings'); viewSettings(); }
|
|
260
273
|
else { setHead('dashboard'); viewDashboard(); }
|
|
261
274
|
renderSidebar();
|
|
@@ -433,7 +446,7 @@ function viewVault(name, kind){
|
|
|
433
446
|
const v = findVault(name) || (name?{name, subscriptionId:'', resourceGroup:'', uri:'', enableRbac:'', tags:{}}:null);
|
|
434
447
|
if(!v){ $('#route').innerHTML=`<div class="panel"><div class="empty">No vault specified. <a href="#/vaults">Back to Key Vaults</a></div></div>`; return; }
|
|
435
448
|
STATE.drawerVault=v; STATE.kind=kind;
|
|
436
|
-
if(mountedVault!==name){
|
|
449
|
+
if(mountedVault!==name || !document.getElementById('vbody')){
|
|
437
450
|
mountedVault=name;
|
|
438
451
|
DRW={vault:name, kind, items:[], filter:'', sel:new Set()};
|
|
439
452
|
const tabs = KIND_TABS.map(([k,l])=>`<button data-kind="${k}" class="${k===kind?'active':''}" onclick="goKind('${esc(name)}','${k}')">${l}</button>`).join('');
|
|
@@ -954,6 +967,425 @@ function card(k,v,sub,mod){
|
|
|
954
967
|
return `<div class="card ${mod?'card--'+mod:''}"><div class="k">${esc(k)}</div><div class="v">${v} <small>${esc(sub)}</small></div></div>`;
|
|
955
968
|
}
|
|
956
969
|
|
|
970
|
+
// ---------- CLUSTERS (AKS — read-only: pods, logs, configmaps) ----------
|
|
971
|
+
let CL = { all:null, name:'', cluster:null, ns:'', kind:'pods', items:[], namespaces:[], logPod:null, filter:'', tier:'all', cm:{} };
|
|
972
|
+
let mountedCluster = null;
|
|
973
|
+
function age(iso){ if(!iso) return ''; const s=(Date.now()-Date.parse(iso))/1000;
|
|
974
|
+
if(s<3600) return Math.max(1,Math.floor(s/60))+'m'; if(s<86400) return Math.floor(s/3600)+'h'; return Math.floor(s/86400)+'d'; }
|
|
975
|
+
function clErr(r){
|
|
976
|
+
if(r.network) return `<div class="empty">🚫 Can't reach this cluster's API server — it's private. Connect to the corporate VPN and retry.<div class="hint" style="margin-top:8px">${esc(r.error||'')}</div></div>`;
|
|
977
|
+
if(r.needLogin) return `<div class="empty">Not signed in — run <code>az login</code>, then Refresh.</div>`;
|
|
978
|
+
if(r.forbidden) return `<div class="empty">🔒 Your identity has no Kubernetes-RBAC permission for this on this cluster.<div class="hint" style="margin-top:8px">${esc(r.error||'')}</div></div>`;
|
|
979
|
+
return `<div class="empty">${esc(r.error||'failed')}</div>`;
|
|
980
|
+
}
|
|
981
|
+
async function loadClustersCache(){ const r=await api('/api/aks/clusters'); if(r.ok) CL.all=r.data.clusters||[]; return CL.all; }
|
|
982
|
+
|
|
983
|
+
// Clusters the user pasted, kept in this browser. Registered with the local server each
|
|
984
|
+
// load so the data-plane endpoints (which validate the cluster) accept them.
|
|
985
|
+
function lsAks(){ try{ return localStorage.getItem('akv.aksClusters')||''; }catch{ return ''; } }
|
|
986
|
+
function setLsAks(t){ try{ if(t) localStorage.setItem('akv.aksClusters',t); else localStorage.removeItem('akv.aksClusters'); }catch{} }
|
|
987
|
+
async function registerSessionClusters(){
|
|
988
|
+
const txt=lsAks().trim(); if(!txt) return;
|
|
989
|
+
let arr; try{ arr=JSON.parse(txt); }catch{ return; }
|
|
990
|
+
if(!Array.isArray(arr)||!arr.length) return;
|
|
991
|
+
try{ await api('/api/aks/clusters',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clusters:arr})}); }catch{}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
async function viewClusters(){
|
|
995
|
+
$('#route').innerHTML = `<div class="panel"><div class="empty"><span class="spin"></span> loading clusters…</div></div>`;
|
|
996
|
+
await registerSessionClusters();
|
|
997
|
+
const r = await api('/api/aks/clusters');
|
|
998
|
+
if(STATE.route!=='clusters') return;
|
|
999
|
+
if(!r.ok){ $('#route').innerHTML = `<div class="panel">${clErr(r)}</div>`; return; }
|
|
1000
|
+
CL.all = r.data.clusters||[];
|
|
1001
|
+
CL.configuredCount = r.data.configured;
|
|
1002
|
+
const envs=[...new Set(CL.all.map(c=>c.env).filter(Boolean))].sort();
|
|
1003
|
+
const envSeg = envs.length>1 ? `<div class="seg" id="clEnvSeg">
|
|
1004
|
+
<button data-e="all" class="${CL.clEnv==='all'||!CL.clEnv?'active':''}">All</button>
|
|
1005
|
+
${envs.map(e=>`<button data-e="${esc(e)}" class="${CL.clEnv===e?'active':''}">${esc(e)}</button>`).join('')}</div>` : '';
|
|
1006
|
+
$('#route').innerHTML = `
|
|
1007
|
+
<div class="cmctl" style="margin-bottom:12px">
|
|
1008
|
+
<input class="search" id="clqIn" placeholder="filter clusters by name, env or resource group…" value="${esc(CL.clq||'')}" autocomplete="off" style="min-width:280px">
|
|
1009
|
+
${envSeg}
|
|
1010
|
+
<button class="btn sm" id="clEdit">+ Add / edit clusters</button>
|
|
1011
|
+
<span class="faint mono" id="clqCount" style="margin-left:auto"></span></div>
|
|
1012
|
+
<div class="createp hide" id="clEditor"></div>
|
|
1013
|
+
<div id="clTable"></div>`;
|
|
1014
|
+
$('#clqIn').oninput=e=>{ CL.clq=e.target.value; renderClusterRows(); };
|
|
1015
|
+
if($('#clEnvSeg')) $('#clEnvSeg').querySelectorAll('button').forEach(b=>b.onclick=()=>{ CL.clEnv=b.dataset.e;
|
|
1016
|
+
$('#clEnvSeg').querySelectorAll('button').forEach(x=>x.classList.toggle('active',x===b)); renderClusterRows(); });
|
|
1017
|
+
$('#clEdit').onclick=()=>{ const el=$('#clEditor'); el.classList.contains('hide')?openClusterEditor():el.classList.add('hide'); };
|
|
1018
|
+
renderClusterRows();
|
|
1019
|
+
}
|
|
1020
|
+
function openClusterEditor(){
|
|
1021
|
+
const el=$('#clEditor');
|
|
1022
|
+
const stored=lsAks();
|
|
1023
|
+
const seed = stored || JSON.stringify((CL.all||[]).filter(c=>c.source==='session').map(c=>({name:c.name,resourceGroup:c.resourceGroup,subscription:c.subscription,env:c.env,label:c.label})),null,2);
|
|
1024
|
+
el.innerHTML = `<h4>Your cluster list · saved in this browser</h4>
|
|
1025
|
+
<div class="sub" style="margin-bottom:10px">Paste a JSON array of <code>{ name, resourceGroup, subscription, env?, label? }</code>. It's stored in this browser's localStorage and registered with the local server — no file needed. Clusters from <code>AZBO_AKS_CLUSTERS(_FILE)</code> still work and are merged in.</div>
|
|
1026
|
+
<div class="fld" style="align-items:flex-start"><textarea id="clJson" spellcheck="false" placeholder='[\n { "name": "my-aks", "resourceGroup": "my-rg", "subscription": "My Sub", "env": "Dev", "label": "My cluster" }\n]' style="min-height:230px">${esc(seed)}</textarea></div>
|
|
1027
|
+
<div class="actions"><button class="btn sm" onclick="saveClusterList()">Save</button>
|
|
1028
|
+
<button class="btn sm ghost" onclick="document.getElementById('clEditor').classList.add('hide')">Cancel</button>
|
|
1029
|
+
<button class="btn sm ghost" onclick="clearClusterList()">Clear stored</button>
|
|
1030
|
+
<span class="hint" id="clJsonMsg"></span></div>`;
|
|
1031
|
+
el.classList.remove('hide');
|
|
1032
|
+
}
|
|
1033
|
+
async function saveClusterList(){
|
|
1034
|
+
const msg=$('#clJsonMsg'), raw=$('#clJson').value.trim();
|
|
1035
|
+
let arr=[];
|
|
1036
|
+
if(raw){ try{ arr=JSON.parse(raw); }catch(e){ msg.textContent='Invalid JSON: '+e.message; return; } }
|
|
1037
|
+
if(!Array.isArray(arr)){ msg.textContent='Must be a JSON array'; return; }
|
|
1038
|
+
for(const c of arr){ if(!c||!c.name||!c.resourceGroup||!c.subscription){ msg.textContent='Each entry needs name, resourceGroup, subscription'; return; } }
|
|
1039
|
+
msg.textContent='saving…';
|
|
1040
|
+
const r=await api('/api/aks/clusters',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clusters:arr})});
|
|
1041
|
+
if(!r.ok){ msg.textContent=r.error||'failed'; return; }
|
|
1042
|
+
setLsAks(raw); CL.all=null; toast(`Saved ${r.data.count} cluster${r.data.count!==1?'s':''}`);
|
|
1043
|
+
loadClustersCache(); viewClusters();
|
|
1044
|
+
}
|
|
1045
|
+
function clearClusterList(){
|
|
1046
|
+
if(!confirm('Remove the clusters stored in this browser? (file / env clusters stay.)')) return;
|
|
1047
|
+
setLsAks('');
|
|
1048
|
+
api('/api/aks/clusters',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clusters:[]})})
|
|
1049
|
+
.then(()=>{ toast('Cleared stored clusters'); CL.all=null; loadClustersCache(); viewClusters(); });
|
|
1050
|
+
}
|
|
1051
|
+
function renderClusterRows(){
|
|
1052
|
+
if(!CL.all.length){
|
|
1053
|
+
$('#clqCount').textContent='';
|
|
1054
|
+
$('#clTable').innerHTML=`<div class="panel"><div class="empty" style="padding:38px;line-height:1.8">No AKS clusters yet.<br>
|
|
1055
|
+
<span class="faint mono" style="font-size:12px">Click <b>✎ Edit cluster list</b> to paste them, or set <b>AZBO_AKS_CLUSTERS(_FILE)</b>.</span></div></div>`;
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
const q=(CL.clq||'').toLowerCase(), env=CL.clEnv||'all';
|
|
1059
|
+
const list=CL.all.filter(c=>{
|
|
1060
|
+
if(env!=='all' && c.env!==env) return false;
|
|
1061
|
+
return !q || [c.label,c.name,c.env,c.resourceGroup,c.subscription].filter(Boolean).some(s=>String(s).toLowerCase().includes(q));
|
|
1062
|
+
});
|
|
1063
|
+
$('#clqCount').textContent = `${list.length}${list.length!==CL.all.length?' / '+CL.all.length:''} cluster${CL.all.length!==1?'s':''}`;
|
|
1064
|
+
if(!list.length){ $('#clTable').innerHTML=`<div class="empty">No clusters match your filter.</div>`; return; }
|
|
1065
|
+
$('#clTable').innerHTML = `<div class="panel"><div class="scroll"><table>
|
|
1066
|
+
<thead><tr><th>Cluster</th><th>Env</th><th>Version</th><th>Network</th><th>Source</th><th class="right">Actions</th></tr></thead>
|
|
1067
|
+
<tbody>${list.map(c=>`<tr>
|
|
1068
|
+
<td class="mono"><b>${esc(c.label||c.name)}</b>${c.label&&c.label!==c.name?`<br><span class="faint" style="font-size:11px">${esc(c.name)}</span>`:''}</td>
|
|
1069
|
+
<td>${c.env?`<span class="tag ${envClass(c.env)}">${esc(c.env)}</span>`:'<span class="faint">—</span>'}</td>
|
|
1070
|
+
<td class="mono muted">${esc(c.version||'—')}</td>
|
|
1071
|
+
<td>${c.private?'<span class="pill policy">private · VPN</span>':c.private===false?'<span class="pill rbac">public</span>':'<span class="faint mono">?</span>'}</td>
|
|
1072
|
+
<td><span class="pill">${c.source==='session'?'pasted':c.source==='file'?'file':'discovered'}</span></td>
|
|
1073
|
+
<td class="right"><button class="iconbtn" onclick="openCluster('${esc(c.name)}')">Open</button></td>
|
|
1074
|
+
</tr>`).join('')}</tbody>
|
|
1075
|
+
</table></div></div>
|
|
1076
|
+
<div class="tablenote">${CL.all.length} cluster${CL.all.length!==1?'s':''} · ${CL.configuredCount} configured locally</div>`;
|
|
1077
|
+
}
|
|
1078
|
+
function openCluster(name){ navigate('cluster/'+encodeURIComponent(name)); }
|
|
1079
|
+
|
|
1080
|
+
function viewCluster(name){
|
|
1081
|
+
CL.name = name;
|
|
1082
|
+
if(mountedCluster!==name || !document.getElementById('clBody')){
|
|
1083
|
+
mountedCluster=name; CL.ns=''; CL.items=[]; CL.namespaces=[]; CL.logPod=null; CL.cluster=null; CL.kind='pods';
|
|
1084
|
+
$('#route').innerHTML = `
|
|
1085
|
+
<div class="vhead">
|
|
1086
|
+
<div class="crumb"><a href="#/clusters">Clusters</a><span class="sep">/</span>
|
|
1087
|
+
<span class="here" id="clName">${esc(name)}</span> <span id="clEnv"></span></div>
|
|
1088
|
+
<div class="vmeta" id="clMeta"><span class="faint">read-only · pods · logs · configmaps</span></div>
|
|
1089
|
+
</div>
|
|
1090
|
+
<div class="cmctl">
|
|
1091
|
+
<div class="seg" id="clKind"><button data-k="pods" class="active">Pods</button><button data-k="configmaps">ConfigMaps</button></div>
|
|
1092
|
+
<button class="btn ghost" id="clRefresh">↻ Refresh</button>
|
|
1093
|
+
</div>
|
|
1094
|
+
<div id="clBody"><div class="empty"><span class="spin"></span> loading…</div></div>`;
|
|
1095
|
+
$('#clKind').querySelectorAll('button').forEach(b=>b.onclick=()=>{ CL.kind=b.dataset.k;
|
|
1096
|
+
$('#clKind').querySelectorAll('button').forEach(x=>x.classList.toggle('active',x===b)); loadClusterContent(); });
|
|
1097
|
+
$('#clRefresh').onclick=()=>{ if(CL.logPod) fetchLogs(); else loadClusterContent(); };
|
|
1098
|
+
enrichClusterMeta(name);
|
|
1099
|
+
loadClusterContent();
|
|
1100
|
+
} else {
|
|
1101
|
+
loadClusterContent();
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
async function enrichClusterMeta(name){
|
|
1105
|
+
const list = CL.all || await loadClustersCache();
|
|
1106
|
+
const c = (list||[]).find(x=>x.name===name); if(!c) return;
|
|
1107
|
+
CL.cluster=c;
|
|
1108
|
+
if(STATE.route!=='cluster' || CL.name!==name) return;
|
|
1109
|
+
if($('#clName')) $('#clName').textContent = c.label||c.name;
|
|
1110
|
+
if($('#clEnv')&&c.env) $('#clEnv').innerHTML = `<span class="tag ${envClass(c.env)}">${esc(c.env)}</span>`;
|
|
1111
|
+
if($('#clMeta')) $('#clMeta').innerHTML = `<span class="mono">${esc(c.name)}</span><span class="sep">·</span>`+
|
|
1112
|
+
`<span class="mono">${esc(c.resourceGroup||'')}</span>${c.private?'<span class="sep">·</span><span class="pill policy">private · VPN</span>':''}`;
|
|
1113
|
+
}
|
|
1114
|
+
function loadClusterContent(){
|
|
1115
|
+
CL.logPod=null; CL.termPod=null; CL.filter=''; teardownTerm();
|
|
1116
|
+
$('#clBody').innerHTML='<div class="empty"><span class="spin"></span></div>';
|
|
1117
|
+
if(CL.kind==='pods') loadPods(); else loadConfigMaps();
|
|
1118
|
+
}
|
|
1119
|
+
function clFilterBar(placeholder, extra){
|
|
1120
|
+
return `<div class="cmctl" style="margin-bottom:10px">
|
|
1121
|
+
<input class="search" id="clFilter" placeholder="${placeholder}" value="${esc(CL.filter)}" autocomplete="off" style="min-width:300px">
|
|
1122
|
+
${extra||''}
|
|
1123
|
+
<span class="faint mono" id="clCount"></span></div>
|
|
1124
|
+
<div id="clList"></div>`;
|
|
1125
|
+
}
|
|
1126
|
+
const TIER_RE = {
|
|
1127
|
+
cache:/(^|-)cache(\d+)?(-|$)|-cache$/,
|
|
1128
|
+
fe:/frontend|-fe-|-fe\b|-web\b|-ui\b|-spa\b/,
|
|
1129
|
+
be:/backend|-be-|-be\b|-api\b|-svc\b|-worker\b|-jobs?\b|-bgjobs?\b/,
|
|
1130
|
+
};
|
|
1131
|
+
function podTier(nm){ nm=(nm||'').toLowerCase(); if(TIER_RE.cache.test(nm))return'cache'; if(TIER_RE.fe.test(nm))return'fe'; if(TIER_RE.be.test(nm))return'be'; return'other'; }
|
|
1132
|
+
async function loadPods(){
|
|
1133
|
+
const name=CL.name;
|
|
1134
|
+
const r = await api(`/api/aks/${name}/pods`);
|
|
1135
|
+
if(STATE.route!=='cluster'||CL.name!==name||CL.kind!=='pods'||CL.logPod||CL.termPod) return;
|
|
1136
|
+
if(!r.ok){ $('#clBody').innerHTML=clErr(r); return; }
|
|
1137
|
+
CL.items=r.data||[];
|
|
1138
|
+
const tier = `<div class="seg" id="clTier">
|
|
1139
|
+
<button data-t="all" class="${CL.tier==='all'?'active':''}">All</button>
|
|
1140
|
+
<button data-t="be" class="${CL.tier==='be'?'active':''}">Backend</button>
|
|
1141
|
+
<button data-t="fe" class="${CL.tier==='fe'?'active':''}">Frontend</button>
|
|
1142
|
+
<button data-t="cache" class="${CL.tier==='cache'?'active':''}">Cache</button></div>`;
|
|
1143
|
+
$('#clBody').innerHTML = clFilterBar('filter pods by name or namespace…', tier);
|
|
1144
|
+
$('#clFilter').oninput=e=>{ CL.filter=e.target.value; renderPodRows(); };
|
|
1145
|
+
$('#clTier').querySelectorAll('button').forEach(b=>b.onclick=()=>{ CL.tier=b.dataset.t;
|
|
1146
|
+
$('#clTier').querySelectorAll('button').forEach(x=>x.classList.toggle('active',x===b)); renderPodRows(); });
|
|
1147
|
+
renderPodRows();
|
|
1148
|
+
}
|
|
1149
|
+
function podMatches(p){
|
|
1150
|
+
if(CL.tier!=='all' && podTier(p.name)!==CL.tier) return false;
|
|
1151
|
+
const q=CL.filter.toLowerCase();
|
|
1152
|
+
const nm=p.name.toLowerCase();
|
|
1153
|
+
return !q || nm.includes(q) || (p.namespace||'').toLowerCase().includes(q);
|
|
1154
|
+
}
|
|
1155
|
+
function renderPodRows(){
|
|
1156
|
+
const list=CL.items.filter(podMatches);
|
|
1157
|
+
$('#clCount').textContent = CL.items.length ? `${list.length}${list.length!==CL.items.length?' / '+CL.items.length:''} pod${CL.items.length!==1?'s':''}` : '';
|
|
1158
|
+
if(!CL.items.length){ $('#clList').innerHTML=`<div class="empty">No pods visible on this cluster.</div>`; return; }
|
|
1159
|
+
if(!list.length){ $('#clList').innerHTML=`<div class="empty">No pods match your filter.</div>`; return; }
|
|
1160
|
+
$('#clList').innerHTML = `<div class="panel"><div class="scroll"><table>
|
|
1161
|
+
<thead><tr><th>Pod</th><th>Namespace</th><th>Build</th><th>Ready</th><th>Status</th><th class="right">Restarts</th><th>Age</th><th class="right">Actions</th></tr></thead>
|
|
1162
|
+
<tbody>${list.map(p=>`<tr>
|
|
1163
|
+
<td class="mono"><b><span class="ell" title="${esc(p.name)}">${esc(p.name)}</span></b></td>
|
|
1164
|
+
<td class="mono muted"><span class="ell ns" title="${esc(p.namespace||'')}">${esc(p.namespace||'')}</span></td>
|
|
1165
|
+
<td class="mono"><span class="ell" style="max-width:140px" title="${esc(p.build||'')}">${esc(p.build||'—')}</span></td>
|
|
1166
|
+
<td class="mono">${esc(p.ready||'')}</td>
|
|
1167
|
+
<td><span class="phase ${esc(p.phase||'')}">${esc(p.phase||'?')}</span></td>
|
|
1168
|
+
<td class="right mono ${p.restarts>0?'':'muted'}">${p.restarts}</td>
|
|
1169
|
+
<td class="mono muted">${age(p.created)}</td>
|
|
1170
|
+
<td class="right" style="white-space:nowrap"><button class="iconbtn" onclick="openPodDetail('${esc(p.namespace)}','${esc(p.name)}')">Details</button>
|
|
1171
|
+
<button class="iconbtn" onclick="showLogs('${esc(p.namespace)}','${esc(p.name)}')">Logs</button>
|
|
1172
|
+
<button class="iconbtn" onclick="openTerminal('${esc(p.namespace)}','${esc(p.name)}')">Terminal</button>
|
|
1173
|
+
<button class="iconbtn" onclick="openPodVaults('${esc(p.namespace)}','${esc(p.name)}')">Vaults</button></td>
|
|
1174
|
+
</tr>`).join('')}</tbody></table></div></div>`;
|
|
1175
|
+
}
|
|
1176
|
+
async function openPodDetail(ns,pod){
|
|
1177
|
+
CL.logPod=null; CL.termPod=null; teardownTerm();
|
|
1178
|
+
$('#clBody').innerHTML = `<div class="cmctl"><button class="iconbtn" onclick="loadClusterContent()">← Pods</button>
|
|
1179
|
+
<span class="mono"><b>${esc(pod)}</b></span><span class="faint mono">${esc(ns)}</span></div>
|
|
1180
|
+
<div id="pdOut"><div class="empty"><span class="spin"></span> loading pod details…</div></div>`;
|
|
1181
|
+
const r=await api(`/api/aks/${CL.name}/${ns}/pods/${pod}/detail`);
|
|
1182
|
+
if(STATE.route!=='cluster') return; const out=$('#pdOut'); if(!out) return;
|
|
1183
|
+
if(!r.ok){ out.innerHTML=clErr(r); return; }
|
|
1184
|
+
const d=r.data; const labels=Object.entries(d.labels||{});
|
|
1185
|
+
const labelChips = labels.length
|
|
1186
|
+
? `<div class="tagchips">${labels.map(([k,v])=>`<span class="tagchip"><b>${esc(k)}</b>${v?' '+esc(v):''}</span>`).join('')}</div>`
|
|
1187
|
+
: '<span class="faint mono">no labels</span>';
|
|
1188
|
+
const cont=(d.containers||[]).map(c=>{
|
|
1189
|
+
const rows=(c.env||[]).map(e=>{
|
|
1190
|
+
const val = e.value!=null ? esc(e.value)
|
|
1191
|
+
: e.from ? `<span class="faint">← ${esc(e.from.kind)} ${esc(e.from.name||'')}${e.from.key?'.'+esc(e.from.key):''}</span>` : '';
|
|
1192
|
+
return `<tr><td class="mono"><b>${esc(e.name)}</b></td><td class="mono" style="word-break:break-all">${val}</td></tr>`;
|
|
1193
|
+
}).join('');
|
|
1194
|
+
const efRows=(c.envFrom||[]).map(ef=>`<tr><td class="mono faint">∗ all keys</td><td class="mono faint">← ${esc(ef.kind)} ${esc(ef.name||'')}${ef.prefix?` (prefix ${esc(ef.prefix)})`:''}${ef.kind==='secret'?' · values not shown':''}</td></tr>`).join('');
|
|
1195
|
+
const body = rows||efRows
|
|
1196
|
+
? `<div class="panel" style="margin-top:6px"><div class="scroll" style="max-height:none"><table>
|
|
1197
|
+
<thead><tr><th>Env var</th><th>Value / source</th></tr></thead><tbody>${rows}${efRows}</tbody></table></div></div>`
|
|
1198
|
+
: '<div class="faint mono" style="margin-top:6px">no env vars declared</div>';
|
|
1199
|
+
return `<div style="margin:16px 0">
|
|
1200
|
+
<div class="kvline" style="padding:4px 0"><span class="nm mono"><b>${esc(c.name)}</b>${c.init?' <span class="pill">init</span>':''}</span>
|
|
1201
|
+
<span class="meta">${c.tag?`<span class="tagchip">build ${esc(c.tag)}</span>`:''}</span></div>
|
|
1202
|
+
<div class="cmval">${esc(c.image||'')}</div>${body}</div>`;
|
|
1203
|
+
}).join('');
|
|
1204
|
+
out.innerHTML = `<div class="secthead">Labels (tags)</div>${labelChips}
|
|
1205
|
+
<div class="secthead" style="margin-top:18px">Containers · image · env vars</div>${cont}`;
|
|
1206
|
+
}
|
|
1207
|
+
function showLogs(ns,pod){
|
|
1208
|
+
const p = CL.items.find(x=>x.name===pod&&x.namespace===ns) || {name:pod, containers:[]};
|
|
1209
|
+
CL.logNs=ns; CL.logPod=pod;
|
|
1210
|
+
const conts = p.containers||[];
|
|
1211
|
+
const contSel = conts.length>1
|
|
1212
|
+
? `<select class="facetsel" id="logC">${conts.map(c=>`<option value="${esc(c)}">${esc(c)}</option>`).join('')}</select>`
|
|
1213
|
+
: conts.length ? `<input type="hidden" id="logC" value="${esc(conts[0])}">` : '';
|
|
1214
|
+
$('#clBody').innerHTML = `
|
|
1215
|
+
<div class="cmctl">
|
|
1216
|
+
<button class="iconbtn" onclick="loadClusterContent()">← Pods</button>
|
|
1217
|
+
<span class="mono"><b>${esc(pod)}</b></span><span class="faint mono">${esc(ns)}</span>
|
|
1218
|
+
${contSel}
|
|
1219
|
+
<select class="facetsel" id="logTail"><option value="200">200 lines</option><option value="1000" selected>1000 lines</option><option value="5000">5000 lines</option><option value="20000">20000 lines</option></select>
|
|
1220
|
+
<label class="selall"><input type="checkbox" id="logTs"> timestamps</label>
|
|
1221
|
+
<label class="selall"><input type="checkbox" id="logPrev"> previous (crash)</label>
|
|
1222
|
+
<button class="btn sm" id="logGo">Load</button>
|
|
1223
|
+
<span class="newbtns"><button class="iconbtn" id="logCopy">⧉ Copy</button><button class="iconbtn" id="logDl">↓ Download</button></span>
|
|
1224
|
+
</div>
|
|
1225
|
+
<div class="logbox" id="logOut"><div class="empty"><span class="spin"></span></div></div>`;
|
|
1226
|
+
$('#logGo').onclick=fetchLogs;
|
|
1227
|
+
$('#logTail').onchange=fetchLogs; $('#logC') && ($('#logC').onchange=fetchLogs);
|
|
1228
|
+
$('#logCopy').onclick=()=>{ const t=$('#logOut').dataset.raw||''; t?copy(t):toast('Nothing to copy'); };
|
|
1229
|
+
$('#logDl').onclick=downloadLog;
|
|
1230
|
+
fetchLogs();
|
|
1231
|
+
}
|
|
1232
|
+
async function fetchLogs(){
|
|
1233
|
+
const out=$('#logOut'); if(!out) return;
|
|
1234
|
+
out.innerHTML='<div class="empty"><span class="spin"></span> fetching logs…</div>';
|
|
1235
|
+
const cEl=$('#logC'); const container=cEl?cEl.value:'';
|
|
1236
|
+
const qs=new URLSearchParams({tailLines:$('#logTail').value});
|
|
1237
|
+
if(container) qs.set('container',container);
|
|
1238
|
+
if($('#logTs').checked) qs.set('timestamps','true');
|
|
1239
|
+
if($('#logPrev').checked) qs.set('previous','true');
|
|
1240
|
+
const pod=CL.logPod;
|
|
1241
|
+
const r=await api(`/api/aks/${CL.name}/${CL.logNs}/pods/${pod}/log?${qs.toString()}`);
|
|
1242
|
+
if(STATE.route!=='cluster' || CL.logPod!==pod) return;
|
|
1243
|
+
if(!r.ok){ out.innerHTML=clErr(r); return; }
|
|
1244
|
+
const text=r.data.log||'';
|
|
1245
|
+
out.dataset.raw=text;
|
|
1246
|
+
out.textContent = text || '(empty log)'; // textContent: safe against log content
|
|
1247
|
+
}
|
|
1248
|
+
function downloadLog(){
|
|
1249
|
+
const text=$('#logOut').dataset.raw||''; if(!text){ toast('Nothing to download'); return; }
|
|
1250
|
+
const env=(CL.cluster&&CL.cluster.env)?CL.cluster.env+'-':'';
|
|
1251
|
+
const blob=new Blob([text],{type:'text/plain'}); const url=URL.createObjectURL(blob);
|
|
1252
|
+
const a=document.createElement('a'); a.href=url; a.download=`log-${env}${CL.logNs}-${CL.logPod}.txt`; a.click(); URL.revokeObjectURL(url);
|
|
1253
|
+
}
|
|
1254
|
+
async function loadConfigMaps(){
|
|
1255
|
+
const name=CL.name;
|
|
1256
|
+
const r=await api(`/api/aks/${name}/configmaps`);
|
|
1257
|
+
if(STATE.route!=='cluster'||CL.name!==name||CL.kind!=='configmaps'||CL.logPod||CL.termPod) return;
|
|
1258
|
+
if(!r.ok){ $('#clBody').innerHTML=clErr(r); return; }
|
|
1259
|
+
CL.items=r.data||[];
|
|
1260
|
+
$('#clBody').innerHTML = clFilterBar('filter configmaps by name or namespace…');
|
|
1261
|
+
$('#clFilter').oninput=e=>{ CL.filter=e.target.value; renderCmRows(); };
|
|
1262
|
+
renderCmRows();
|
|
1263
|
+
}
|
|
1264
|
+
function renderCmRows(){
|
|
1265
|
+
const q=CL.filter.toLowerCase();
|
|
1266
|
+
const list=CL.items.filter(m=>!q||m.name.toLowerCase().includes(q)||(m.namespace||'').toLowerCase().includes(q));
|
|
1267
|
+
$('#clCount').textContent = CL.items.length ? `${list.length}${list.length!==CL.items.length?' / '+CL.items.length:''} configmap${CL.items.length!==1?'s':''}` : '';
|
|
1268
|
+
if(!CL.items.length){ $('#clList').innerHTML=`<div class="empty">No configmaps visible on this cluster.</div>`; return; }
|
|
1269
|
+
if(!list.length){ $('#clList').innerHTML=`<div class="empty">No configmaps match “${esc(CL.filter)}”.</div>`; return; }
|
|
1270
|
+
$('#clList').innerHTML = `<div class="panel"><div class="scroll"><table>
|
|
1271
|
+
<thead><tr><th>ConfigMap</th><th>Namespace</th><th>Keys</th><th class="right">Actions</th></tr></thead>
|
|
1272
|
+
<tbody>${list.map(m=>`<tr>
|
|
1273
|
+
<td class="mono"><b><span class="ell" title="${esc(m.name)}">${esc(m.name)}</span></b></td>
|
|
1274
|
+
<td class="mono muted"><span class="ell ns" title="${esc(m.namespace||'')}">${esc(m.namespace||'')}</span></td>
|
|
1275
|
+
<td class="faint mono ell" style="font-size:11px;max-width:360px" title="${esc((m.keys||[]).join(', '))}">${(m.keys||[]).slice(0,6).map(esc).join(', ')}${m.keys.length>6?` +${m.keys.length-6}`:''}</td>
|
|
1276
|
+
<td class="right"><button class="iconbtn" onclick="viewCm('${esc(m.namespace)}','${esc(m.name)}')">View</button>
|
|
1277
|
+
<button class="iconbtn" onclick="copy('kubectl describe configmap ${esc(m.name)} -n ${esc(m.namespace)}')">⧉</button></td>
|
|
1278
|
+
</tr><tr><td colspan="4" style="padding:0;border:0"><div class="hide" id="cm-${esc(m.namespace)}__${esc(m.name)}"></div></td></tr>`).join('')}</tbody>
|
|
1279
|
+
</table></div></div>`;
|
|
1280
|
+
}
|
|
1281
|
+
async function viewCm(ns,name){
|
|
1282
|
+
const box=document.getElementById('cm-'+ns+'__'+name); if(!box) return;
|
|
1283
|
+
if(!box.classList.contains('hide')){ box.classList.add('hide'); box.innerHTML=''; return; }
|
|
1284
|
+
box.classList.remove('hide'); box.innerHTML='<div class="empty"><span class="spin"></span></div>';
|
|
1285
|
+
const r=await api(`/api/aks/${CL.name}/${ns}/configmaps/${name}`);
|
|
1286
|
+
if(!r.ok){ box.innerHTML=clErr(r); return; }
|
|
1287
|
+
const data=r.data.data||{}; const keys=Object.keys(data); const ckey=ns+'/'+name; CL.cm[ckey]=data;
|
|
1288
|
+
box.innerHTML = `<div style="padding:8px 0 12px">${keys.length? keys.map(k=>`
|
|
1289
|
+
<div class="cmkey"><div class="kvline" style="padding:5px 0"><span class="nm mono"><b>${esc(k)}</b></span>
|
|
1290
|
+
<span class="meta"><button class="iconbtn" onclick='copyCm(${JSON.stringify(ckey)},${JSON.stringify(k)})'>⧉ copy</button></span></div>
|
|
1291
|
+
<pre class="cmval"></pre></div>`).join('') : '<div class="empty">empty configmap</div>'}</div>`;
|
|
1292
|
+
const pres=box.querySelectorAll('pre.cmval'); keys.forEach((k,i)=>{ if(pres[i]) pres[i].textContent=data[k]; });
|
|
1293
|
+
}
|
|
1294
|
+
function copyCm(ckey,k){ const d=CL.cm[ckey]||{}; copy(d[k]!=null?String(d[k]):''); }
|
|
1295
|
+
|
|
1296
|
+
// ---------- which Key Vaults does a pod use ----------
|
|
1297
|
+
async function openPodVaults(ns,pod){
|
|
1298
|
+
CL.logPod=null; CL.termPod=null; teardownTerm();
|
|
1299
|
+
$('#clBody').innerHTML = `<div class="cmctl"><button class="iconbtn" onclick="loadClusterContent()">← Pods</button>
|
|
1300
|
+
<span class="mono"><b>${esc(pod)}</b></span><span class="faint mono">${esc(ns)}</span><span class="faint mono">Key Vault references</span></div>
|
|
1301
|
+
<div id="pkvOut"><div class="empty"><span class="spin"></span> inspecting pod env, configmaps and CSI SecretProviderClasses…</div></div>`;
|
|
1302
|
+
const r = await api(`/api/aks/${CL.name}/${ns}/pods/${pod}/keyvaults`);
|
|
1303
|
+
if(STATE.route!=='cluster') return;
|
|
1304
|
+
const out=$('#pkvOut'); if(!out) return;
|
|
1305
|
+
if(!r.ok){ out.innerHTML=clErr(r); return; }
|
|
1306
|
+
const vaults=r.data.vaults||[];
|
|
1307
|
+
const spcs=r.data.spcs||[];
|
|
1308
|
+
const eso=r.data.externalSecrets||[];
|
|
1309
|
+
const unreadable=spcs.filter(s=>!s.readable);
|
|
1310
|
+
const esoUnresolved=eso.filter(e=>!e.resolvable);
|
|
1311
|
+
const spcNote = spcs.length
|
|
1312
|
+
? `<div style="margin-top:10px">Mounts CSI SecretProviderClass(es): <span class="tagchips" style="display:inline-flex">${spcs.map(s=>`<span class="tagchip">${esc(s.name)}${s.readable?'':' 🔒'}</span>`).join('')}</span>${unreadable.length?`<div class="hint" style="margin-top:6px">🔒 couldn't read ${unreadable.length} SecretProviderClass(es) to resolve the Key Vault — needs read on <code>secretproviderclasses</code>.</div>`:''}</div>`
|
|
1313
|
+
: '';
|
|
1314
|
+
const esoNote = esoUnresolved.length
|
|
1315
|
+
? `<div style="margin-top:10px">Config synced by <b>External Secrets Operator</b> from a Key Vault: <span class="tagchips" style="display:inline-flex">${esoUnresolved.map(e=>`<span class="tagchip">${esc(e.externalSecret)} 🔒</span>`).join('')}</span>
|
|
1316
|
+
<div class="hint" style="margin-top:6px">🔒 the source vault is defined in the ExternalSecret's SecretStore — ${esc(esoUnresolved[0].reason||'not readable')}.</div></div>`
|
|
1317
|
+
: '';
|
|
1318
|
+
if(!vaults.length){
|
|
1319
|
+
out.innerHTML=`<div class="empty">No Key Vault could be resolved for this pod.
|
|
1320
|
+
<div class="hint" style="margin-top:8px">Checked CSI SecretProviderClasses, External Secrets, container env, and ${r.data.configmapsScanned.length} configmap(s).</div>
|
|
1321
|
+
${spcNote}${esoNote}</div>`;
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
out.innerHTML = `<div class="panel"><div class="scroll"><table>
|
|
1325
|
+
<thead><tr><th>Key Vault</th><th>Referenced via</th><th class="right">Actions</th></tr></thead>
|
|
1326
|
+
<tbody>${vaults.map(v=>{ const known=findVault(v.name);
|
|
1327
|
+
return `<tr><td class="mono"><b>${esc(v.name)}</b></td>
|
|
1328
|
+
<td><div class="tagchips">${v.sources.map(s=>`<span class="tagchip">${esc(s)}</span>`).join('')}</div></td>
|
|
1329
|
+
<td class="right">${known
|
|
1330
|
+
? `<button class="iconbtn" onclick="openVault('${esc(known.name)}')">Open vault →</button>`
|
|
1331
|
+
: `<span class="faint mono" title="not in your visible inventory">not visible</span>`}</td></tr>`;
|
|
1332
|
+
}).join('')}</tbody></table></div></div>
|
|
1333
|
+
<div class="tablenote">${vaults.length} vault${vaults.length!==1?'s':''} · scanned ${r.data.configmapsScanned.length} configmap(s), ${spcs.length} SecretProviderClass(es), ${eso.length} ExternalSecret(s)</div>
|
|
1334
|
+
${unreadable.length?`<div class="tablenote">${spcNote}</div>`:''}
|
|
1335
|
+
${esoUnresolved.length?`<div class="tablenote">${esoNote}</div>`:''}`;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// ---------- in-pod terminal (xterm.js over a WS exec bridge) ----------
|
|
1339
|
+
let TERM=null, TERMWS=null, TERMRO=null;
|
|
1340
|
+
function teardownTerm(){
|
|
1341
|
+
try{ TERMRO&&TERMRO.disconnect(); }catch{} TERMRO=null;
|
|
1342
|
+
try{ TERMWS&&TERMWS.close(); }catch{} TERMWS=null;
|
|
1343
|
+
try{ TERM&&TERM.dispose(); }catch{} TERM=null;
|
|
1344
|
+
}
|
|
1345
|
+
function openTerminal(ns,pod){
|
|
1346
|
+
const p = CL.items.find(x=>x.name===pod&&x.namespace===ns) || {name:pod, containers:[]};
|
|
1347
|
+
CL.logPod=null; CL.termNs=ns; CL.termPod=pod;
|
|
1348
|
+
const conts=p.containers||[];
|
|
1349
|
+
const contSel = conts.length>1
|
|
1350
|
+
? `<select class="facetsel" id="tmC">${conts.map(c=>`<option value="${esc(c)}">${esc(c)}</option>`).join('')}</select>`
|
|
1351
|
+
: conts.length ? `<input type="hidden" id="tmC" value="${esc(conts[0])}">` : '';
|
|
1352
|
+
$('#clBody').innerHTML = `
|
|
1353
|
+
<div class="cmctl">
|
|
1354
|
+
<button class="iconbtn" onclick="closeTerminal()">← Pods</button>
|
|
1355
|
+
<span class="mono"><b>${esc(pod)}</b></span><span class="faint mono">${esc(ns)}</span>
|
|
1356
|
+
${contSel}
|
|
1357
|
+
<span class="faint mono" id="tmStatus">connecting…</span>
|
|
1358
|
+
<span class="newbtns"><button class="iconbtn" id="tmReconnect">↻ Reconnect</button></span>
|
|
1359
|
+
</div>
|
|
1360
|
+
<div id="termHost" class="termhost"></div>`;
|
|
1361
|
+
$('#tmReconnect').onclick=()=>startTerm();
|
|
1362
|
+
const cSel=$('#tmC'); if(cSel && cSel.tagName==='SELECT') cSel.onchange=()=>startTerm();
|
|
1363
|
+
startTerm();
|
|
1364
|
+
}
|
|
1365
|
+
function closeTerminal(){ teardownTerm(); CL.termPod=null; loadClusterContent(); }
|
|
1366
|
+
function tmStatus(txt, cls){ const el=$('#tmStatus'); if(!el)return; el.textContent=txt; el.className='mono '+(cls||'faint'); }
|
|
1367
|
+
function startTerm(){
|
|
1368
|
+
if(typeof Terminal==='undefined' || typeof FitAddon==='undefined'){ tmStatus('terminal library failed to load (offline?)','dead'); return; }
|
|
1369
|
+
teardownTerm();
|
|
1370
|
+
const host=$('#termHost'); if(!host) return; host.innerHTML='';
|
|
1371
|
+
TERM=new Terminal({ fontFamily:'"IBM Plex Mono", ui-monospace, monospace', fontSize:12.5, cursorBlink:true,
|
|
1372
|
+
theme:{ background:'#070A11', foreground:'#CDE0F5', cursor:'#5B9DFF' } });
|
|
1373
|
+
const fit=new FitAddon.FitAddon(); TERM.loadAddon(fit); TERM.open(host);
|
|
1374
|
+
try{ fit.fit(); }catch{}
|
|
1375
|
+
const cont=$('#tmC')?$('#tmC').value:'';
|
|
1376
|
+
const proto=location.protocol==='https:'?'wss':'ws';
|
|
1377
|
+
const qs=new URLSearchParams({cluster:CL.name, ns:CL.termNs, pod:CL.termPod}); if(cont) qs.set('container',cont);
|
|
1378
|
+
const ws=new WebSocket(`${proto}://${location.host}/ws/aks/exec?${qs.toString()}`);
|
|
1379
|
+
ws.binaryType='arraybuffer'; TERMWS=ws;
|
|
1380
|
+
const sendResize=()=>{ try{ fit.fit(); if(ws.readyState===1) ws.send(JSON.stringify({type:'resize',cols:TERM.cols,rows:TERM.rows})); }catch{} };
|
|
1381
|
+
ws.onopen=()=>{ tmStatus('connected','live'); sendResize(); TERM.focus(); };
|
|
1382
|
+
ws.onmessage=ev=>{ if(typeof ev.data==='string') TERM.write(ev.data); else TERM.write(new Uint8Array(ev.data)); };
|
|
1383
|
+
ws.onclose=()=>tmStatus('disconnected','dead');
|
|
1384
|
+
ws.onerror=()=>tmStatus('connection error','dead');
|
|
1385
|
+
TERM.onData(d=>{ if(ws.readyState===1) ws.send(new TextEncoder().encode(d)); });
|
|
1386
|
+
TERMRO=new ResizeObserver(()=>sendResize()); TERMRO.observe(host);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
957
1389
|
// ---------- command palette ----------
|
|
958
1390
|
let CMDK = { open:false, items:[], cur:0 };
|
|
959
1391
|
function cmdkActions(){
|
|
@@ -961,6 +1393,7 @@ function cmdkActions(){
|
|
|
961
1393
|
{type:'view', label:'Dashboard', hint:'go', act:()=>navigate('dashboard')},
|
|
962
1394
|
{type:'view', label:'Key Vaults', hint:'go', act:()=>navigate('vaults')},
|
|
963
1395
|
{type:'view', label:'Expiry', hint:'go', act:()=>navigate('expiry')},
|
|
1396
|
+
{type:'view', label:'Clusters', hint:'go', act:()=>navigate('clusters')},
|
|
964
1397
|
{type:'view', label:'Account & subscriptions', hint:'go', act:()=>navigate('settings')},
|
|
965
1398
|
{type:'action', label:'Refresh inventory', hint:'run', act:()=>{ doRefresh(); }},
|
|
966
1399
|
];
|
|
@@ -974,16 +1407,22 @@ function renderCmdk(q){
|
|
|
974
1407
|
.filter(v=>!q || v.name.toLowerCase().includes(q) || (v.service||'').toLowerCase().includes(q) || Object.values(v.tags||{}).join(' ').toLowerCase().includes(q))
|
|
975
1408
|
.slice(0,40)
|
|
976
1409
|
.map(v=>({type:'vault', label:v.name, sub:v.env, env:v.env, act:()=>openVault(v.name)}));
|
|
977
|
-
|
|
1410
|
+
const clusters = (CL.all||[])
|
|
1411
|
+
.filter(c=>!q || (c.label||c.name).toLowerCase().includes(q) || c.name.toLowerCase().includes(q))
|
|
1412
|
+
.slice(0,20)
|
|
1413
|
+
.map(c=>({type:'cluster', label:c.label||c.name, sub:c.env, act:()=>openCluster(c.name)}));
|
|
1414
|
+
CMDK.items = [...acts, ...clusters, ...vaults]; CMDK.cur=0;
|
|
978
1415
|
const list=$('#cmdkList');
|
|
979
1416
|
if(!CMDK.items.length){ list.innerHTML='<div class="cmdk-empty">No matches.</div>'; return; }
|
|
980
1417
|
const vIco='<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>';
|
|
1418
|
+
const cIco='<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 2l8 4.5v9L12 20l-8-4.5v-9L12 2z"/></svg>';
|
|
981
1419
|
const gIco='<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M5 6h14M5 18h14"/></svg>';
|
|
1420
|
+
const secName={vault:'Vaults',cluster:'Clusters',view:'Go to',action:'Actions'};
|
|
982
1421
|
let html=''; let lastType='';
|
|
983
1422
|
CMDK.items.forEach((it,idx)=>{
|
|
984
|
-
if(it.type!==lastType){ html+=`<div class="cmdk-sec">${it.type
|
|
1423
|
+
if(it.type!==lastType){ html+=`<div class="cmdk-sec">${secName[it.type]||''}</div>`; lastType=it.type; }
|
|
985
1424
|
html+=`<div class="cmdk-it ${idx===CMDK.cur?'cur':''}" data-i="${idx}">
|
|
986
|
-
${it.type==='vault'?vIco:gIco}
|
|
1425
|
+
${it.type==='vault'?vIco:it.type==='cluster'?cIco:gIco}
|
|
987
1426
|
<span class="lbl">${esc(it.label)}${it.sub?`<small>${esc(it.sub)}</small>`:''}</span>
|
|
988
1427
|
<span class="hint">${it.hint?esc(it.hint):'open'}</span></div>`;
|
|
989
1428
|
});
|
package/public/styles.css
CHANGED
|
@@ -358,6 +358,29 @@
|
|
|
358
358
|
.stick{position:sticky;top:0;z-index:5}
|
|
359
359
|
.itemwrap{margin-top:8px}
|
|
360
360
|
|
|
361
|
+
/* ---------- clusters (AKS) ---------- */
|
|
362
|
+
.cmctl{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:16px}
|
|
363
|
+
.cmctl .seg{padding:3px}
|
|
364
|
+
.logbox{background:#070A11;border:1px solid var(--line);border-radius:var(--r-sm);padding:13px 15px;
|
|
365
|
+
font-family:var(--mono);font-size:11.5px;line-height:1.55;color:#CDE0F5;white-space:pre;
|
|
366
|
+
overflow:auto;max-height:calc(100vh - 300px);tab-size:2}
|
|
367
|
+
.phase{font-family:var(--mono);font-size:10px;letter-spacing:.05em;padding:2px 8px;border-radius:5px;border:1px solid var(--line);color:var(--muted)}
|
|
368
|
+
.phase.Running{color:#7FE0C8;border-color:rgba(70,214,180,.3)}
|
|
369
|
+
.phase.Pending{color:#F4C977;border-color:rgba(242,181,61,.3)}
|
|
370
|
+
.phase.Failed,.phase.Error,.phase.CrashLoopBackOff{color:#FF8893;border-color:rgba(255,92,108,.32)}
|
|
371
|
+
.phase.Succeeded,.phase.Completed{color:var(--muted)}
|
|
372
|
+
.cmval{background:#070A11;border:1px solid var(--line);border-radius:6px;padding:9px 11px;margin:0 0 4px;
|
|
373
|
+
font-family:var(--mono);font-size:11.5px;color:#A9D2FF;white-space:pre-wrap;word-break:break-word;
|
|
374
|
+
overflow:auto;max-height:320px}
|
|
375
|
+
.cmkey{padding:4px 16px 10px}
|
|
376
|
+
.cmkey .nm{font-size:12.5px}
|
|
377
|
+
.ell{display:inline-block;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom}
|
|
378
|
+
.ell.ns{max-width:200px}
|
|
379
|
+
.termhost{height:calc(100vh - 290px);min-height:340px;background:#070A11;border:1px solid var(--line);
|
|
380
|
+
border-radius:var(--r-sm);padding:8px 4px 8px 10px;overflow:hidden}
|
|
381
|
+
.termhost .xterm{height:100%}
|
|
382
|
+
#tmStatus.live{color:var(--ok)} #tmStatus.dead{color:var(--alert)}
|
|
383
|
+
|
|
361
384
|
@media (max-width:900px){
|
|
362
385
|
/* .app grid + main padding handled by Tailwind max-[900px]: utilities */
|
|
363
386
|
aside{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center}
|
package/server.js
CHANGED
|
@@ -63,9 +63,10 @@ function az(args, { json = true } = {}) {
|
|
|
63
63
|
|
|
64
64
|
function send(res, p) {
|
|
65
65
|
p.then((data) => res.json({ ok: true, data }))
|
|
66
|
-
.catch((err) => res.status(err.needLogin ? 401 : err.forbidden ? 403 : 500).json({
|
|
66
|
+
.catch((err) => res.status(err.status || (err.needLogin ? 401 : err.forbidden ? 403 : err.notFound ? 404 : 500)).json({
|
|
67
67
|
ok: false, error: err.stderr || err.message,
|
|
68
68
|
needLogin: !!err.needLogin, forbidden: !!err.forbidden, firewall: !!err.firewall,
|
|
69
|
+
network: !!err.network, notFound: !!err.notFound,
|
|
69
70
|
}));
|
|
70
71
|
}
|
|
71
72
|
|
|
@@ -600,6 +601,492 @@ app.post('/api/batch', async (req, res) => {
|
|
|
600
601
|
res.json({ ok: true, results });
|
|
601
602
|
});
|
|
602
603
|
|
|
604
|
+
// ---- API: AKS clusters (read-only monitoring: pods, logs, configmaps) -------
|
|
605
|
+
// Data plane goes straight to the Kubernetes API server over HTTPS, authenticated
|
|
606
|
+
// with an AAD token for the well-known managed-AAD server app — the same token
|
|
607
|
+
// `kubelogin` (azurecli mode) injects, so no kubelogin/kubectl binary is needed.
|
|
608
|
+
|
|
609
|
+
const https = require('https');
|
|
610
|
+
const AKS_AAD_SERVER_APP = '6dae42f8-4368-4678-94ff-3960e28e3630'; // Microsoft public constant
|
|
611
|
+
const getAksToken = () => getToken(AKS_AAD_SERVER_APP);
|
|
612
|
+
const K8S_NAME = /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/; // DNS-1123-ish (namespaces, pods, cms)
|
|
613
|
+
const isK8sName = (v) => typeof v === 'string' && K8S_NAME.test(v);
|
|
614
|
+
|
|
615
|
+
// Cluster list — NEVER baked in. Two sources, merged: (1) the operator's file/env
|
|
616
|
+
// (AZBO_AKS_CLUSTERS / AZBO_AKS_CLUSTERS_FILE), and (2) a list the user pastes in the
|
|
617
|
+
// UI, persisted in their browser's localStorage and registered with this local server
|
|
618
|
+
// for the session. Each entry: { name, resourceGroup, subscription, env?, label? }.
|
|
619
|
+
const RG_RE = /^[\w.()-]{1,90}$/;
|
|
620
|
+
function clusterValid(c) {
|
|
621
|
+
return c && isValidName(c.name) && RG_RE.test(c.resourceGroup || '') && SUB_RE.test(c.subscription || '');
|
|
622
|
+
}
|
|
623
|
+
function normalizeClusters(arr, source) {
|
|
624
|
+
return (Array.isArray(arr) ? arr : []).filter((c) => c && c.name && c.resourceGroup && c.subscription)
|
|
625
|
+
.map((c) => ({ name: String(c.name), resourceGroup: String(c.resourceGroup), subscription: String(c.subscription),
|
|
626
|
+
env: c.env ? String(c.env) : '', label: c.label ? String(c.label) : String(c.name), configured: true, source }));
|
|
627
|
+
}
|
|
628
|
+
const AKS_CLUSTERS = (() => {
|
|
629
|
+
let raw = process.env.AZBO_AKS_CLUSTERS;
|
|
630
|
+
if (!raw && process.env.AZBO_AKS_CLUSTERS_FILE) {
|
|
631
|
+
try { raw = require('fs').readFileSync(process.env.AZBO_AKS_CLUSTERS_FILE, 'utf8'); }
|
|
632
|
+
catch (e) { console.warn(' AZBO_AKS_CLUSTERS_FILE could not be read:', e.message); }
|
|
633
|
+
}
|
|
634
|
+
if (!raw) return [];
|
|
635
|
+
try { return normalizeClusters(JSON.parse(raw), 'file'); }
|
|
636
|
+
catch { console.warn(' AZBO_AKS_CLUSTERS is not valid JSON — ignoring.'); return []; }
|
|
637
|
+
})();
|
|
638
|
+
let SESSION_CLUSTERS = []; // set by the client via POST /api/aks/clusters (from its localStorage)
|
|
639
|
+
|
|
640
|
+
async function resolveSubId(nameOrId) {
|
|
641
|
+
if (/^[0-9a-fA-F-]{36}$/.test(nameOrId)) return nameOrId;
|
|
642
|
+
const subs = await listSubscriptions();
|
|
643
|
+
const s = subs.find((x) => x.displayName === nameOrId || x.subscriptionId === nameOrId);
|
|
644
|
+
return s ? s.subscriptionId : nameOrId;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Cache each cluster's API server URL + CA (from listClusterUserCredential) for 30 min.
|
|
648
|
+
const aksCredCache = new Map();
|
|
649
|
+
async function aksCredential(c) {
|
|
650
|
+
const key = `${c.subscription}/${c.resourceGroup}/${c.name}`;
|
|
651
|
+
const hit = aksCredCache.get(key);
|
|
652
|
+
if (hit && Date.now() - hit.at < 30 * 60 * 1000) return hit;
|
|
653
|
+
const subId = await resolveSubId(c.subscription);
|
|
654
|
+
const url = `/subscriptions/${subId}/resourceGroups/${c.resourceGroup}` +
|
|
655
|
+
`/providers/Microsoft.ContainerService/managedClusters/${c.name}` +
|
|
656
|
+
`/listClusterUserCredential?api-version=2024-05-01`;
|
|
657
|
+
const out = await armFetch(url, { method: 'POST' });
|
|
658
|
+
const kc = out.kubeconfigs && out.kubeconfigs[0];
|
|
659
|
+
if (!kc || !kc.value) throw new Error('No kubeconfig returned for this cluster');
|
|
660
|
+
const yaml = Buffer.from(kc.value, 'base64').toString('utf8');
|
|
661
|
+
const server = (yaml.match(/server:\s*(\S+)/) || [])[1];
|
|
662
|
+
const caB64 = (yaml.match(/certificate-authority-data:\s*(\S+)/) || [])[1];
|
|
663
|
+
if (!server) throw new Error('Could not parse API server URL from kubeconfig');
|
|
664
|
+
const cred = { server: server.replace(/\/+$/, ''),
|
|
665
|
+
ca: caB64 ? Buffer.from(caB64, 'base64').toString('utf8') : undefined, at: Date.now() };
|
|
666
|
+
aksCredCache.set(key, cred);
|
|
667
|
+
return cred;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function k8sMsg(body) { try { return JSON.parse(body).message; } catch { return null; } }
|
|
671
|
+
|
|
672
|
+
// The "build" of a pod = its container image tag (e.g. registry/app:11.5.0387 → 11.5.0387).
|
|
673
|
+
// Digest-pinned images (@sha256:…) have no human tag; callers fall back to a version label.
|
|
674
|
+
function imageTag(img) {
|
|
675
|
+
if (!img || img.includes('@')) return '';
|
|
676
|
+
const repoTag = img.slice(img.lastIndexOf('/') + 1);
|
|
677
|
+
const c = repoTag.indexOf(':');
|
|
678
|
+
return c >= 0 ? repoTag.slice(c + 1) : '';
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// One GET against a cluster's Kubernetes API. raw=true returns text (for pod logs).
|
|
682
|
+
function k8sGet(cred, pathQuery, { raw = false, timeout = 25000 } = {}) {
|
|
683
|
+
return new Promise((resolve, reject) => {
|
|
684
|
+
getAksToken().then((token) => {
|
|
685
|
+
const u = new URL(cred.server + pathQuery);
|
|
686
|
+
const req = https.request({
|
|
687
|
+
hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method: 'GET',
|
|
688
|
+
// '*/*' for raw so the log subresource returns text/plain without tripping content negotiation
|
|
689
|
+
headers: { Authorization: `Bearer ${token}`, Accept: raw ? '*/*' : 'application/json' },
|
|
690
|
+
ca: cred.ca, timeout,
|
|
691
|
+
}, (res) => {
|
|
692
|
+
let data = ''; res.setEncoding('utf8');
|
|
693
|
+
res.on('data', (c) => { data += c; });
|
|
694
|
+
res.on('end', () => {
|
|
695
|
+
const s = res.statusCode;
|
|
696
|
+
if (s === 401) { tokenCache.delete(AKS_AAD_SERVER_APP); return reject(Object.assign(new Error('Token rejected — run az login'), { needLogin: true })); }
|
|
697
|
+
if (s === 403) return reject(Object.assign(new Error(k8sMsg(data) || 'Forbidden'), { forbidden: true }));
|
|
698
|
+
if (s === 404) return reject(Object.assign(new Error(k8sMsg(data) || 'Not found'), { notFound: true }));
|
|
699
|
+
if (s >= 400) return reject(new Error(k8sMsg(data) || `Kubernetes returned ${s}`));
|
|
700
|
+
try { resolve(raw ? data : (data ? JSON.parse(data) : null)); }
|
|
701
|
+
catch (e) { reject(new Error('Failed to parse Kubernetes response: ' + e.message)); }
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
req.on('error', (e) => {
|
|
705
|
+
const code = e.code || '';
|
|
706
|
+
const network = /ENOTFOUND|ETIMEDOUT|ECONNREFUSED|EAI_AGAIN|ECONNRESET|UNABLE_TO_VERIFY|SELF_SIGNED/.test(code + e.message);
|
|
707
|
+
reject(Object.assign(new Error(network
|
|
708
|
+
? 'Cannot reach the cluster API server — it is private. Connect to the corporate VPN and retry.'
|
|
709
|
+
: e.message), { network }));
|
|
710
|
+
});
|
|
711
|
+
req.on('timeout', () => req.destroy(Object.assign(new Error('Cluster API request timed out — is the VPN connected?'), { network: true })));
|
|
712
|
+
req.end();
|
|
713
|
+
}).catch(reject);
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Discover clusters you have ARM read on (best-effort) and merge with the configured list.
|
|
718
|
+
const AKS_QUERY =
|
|
719
|
+
"resources | where type =~ 'microsoft.containerservice/managedclusters' " +
|
|
720
|
+
"| project name, resourceGroup, subscriptionId, " +
|
|
721
|
+
"ver=tostring(properties.kubernetesVersion), " +
|
|
722
|
+
"priv=tostring(properties.apiServerAccessProfile.enablePrivateCluster) | order by name asc";
|
|
723
|
+
async function discoverClusters() {
|
|
724
|
+
try {
|
|
725
|
+
const subIds = (await listSubscriptions()).map((s) => s.subscriptionId);
|
|
726
|
+
const out = await armFetch('/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01', {
|
|
727
|
+
method: 'POST', body: { subscriptions: subIds, query: AKS_QUERY, options: { resultFormat: 'objectArray', $top: 1000 } },
|
|
728
|
+
});
|
|
729
|
+
return (out.data || []).map((c) => ({ name: c.name, resourceGroup: c.resourceGroup,
|
|
730
|
+
subscription: c.subscriptionId, version: c.ver, private: c.priv === 'true', configured: false }));
|
|
731
|
+
} catch { return []; }
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function clusterById(list, id) { return list.find((c) => c.name === id); }
|
|
735
|
+
async function aksList() {
|
|
736
|
+
const discovered = await discoverClusters();
|
|
737
|
+
const byKey = new Map();
|
|
738
|
+
for (const c of discovered) byKey.set(c.name.toLowerCase(), c);
|
|
739
|
+
for (const c of [...AKS_CLUSTERS, ...SESSION_CLUSTERS]) { // configured (file then session) win + carry label/env
|
|
740
|
+
const prev = byKey.get(c.name.toLowerCase()) || {};
|
|
741
|
+
byKey.set(c.name.toLowerCase(), { ...prev, ...c });
|
|
742
|
+
}
|
|
743
|
+
return [...byKey.values()].sort((a, b) => (a.label || a.name).localeCompare(b.label || b.name));
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
app.get('/api/aks/clusters', (req, res) =>
|
|
747
|
+
send(res, aksList().then((clusters) => ({ clusters,
|
|
748
|
+
configured: AKS_CLUSTERS.length + SESSION_CLUSTERS.length, file: AKS_CLUSTERS.length, session: SESSION_CLUSTERS.length }))));
|
|
749
|
+
|
|
750
|
+
// Register the user's pasted cluster list for this session (from their localStorage).
|
|
751
|
+
// Replaces the previous session list; validated; localhost-only like the rest of the API.
|
|
752
|
+
app.post('/api/aks/clusters', (req, res) => {
|
|
753
|
+
const { clusters } = req.body || {};
|
|
754
|
+
if (!Array.isArray(clusters)) return res.status(400).json({ ok: false, error: 'clusters must be a JSON array' });
|
|
755
|
+
if (clusters.length > 200) return res.status(400).json({ ok: false, error: 'Too many clusters (max 200)' });
|
|
756
|
+
for (const c of clusters) {
|
|
757
|
+
if (!c || !c.name || !c.resourceGroup || !c.subscription) return res.status(400).json({ ok: false, error: `Each entry needs name, resourceGroup, subscription (got: ${JSON.stringify(c).slice(0, 60)})` });
|
|
758
|
+
if (!clusterValid(c)) return res.status(400).json({ ok: false, error: `Invalid cluster entry "${c.name}" — check name / resourceGroup / subscription` });
|
|
759
|
+
}
|
|
760
|
+
SESSION_CLUSTERS = normalizeClusters(clusters, 'session');
|
|
761
|
+
aksCredCache.clear(); // coordinates may have changed
|
|
762
|
+
res.json({ ok: true, data: { count: SESSION_CLUSTERS.length } });
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
// Resolve a :cluster route param to a known cluster, or throw a typed 400/404.
|
|
766
|
+
async function getClusterChecked(name) {
|
|
767
|
+
if (!isValidName(name)) throw Object.assign(new Error('Invalid cluster name'), { status: 400 });
|
|
768
|
+
const c = clusterById(await aksList(), name);
|
|
769
|
+
if (!c) throw Object.assign(new Error('Cluster is not in your configured/visible list'), { status: 404, notFound: true });
|
|
770
|
+
return c;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
app.get('/api/aks/:cluster/namespaces', (req, res) =>
|
|
774
|
+
send(res, (async () => {
|
|
775
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
776
|
+
const out = await k8sGet(cred, '/api/v1/namespaces?limit=500');
|
|
777
|
+
return (out.items || []).map((n) => ({ name: n.metadata.name, status: n.status && n.status.phase }));
|
|
778
|
+
})()));
|
|
779
|
+
|
|
780
|
+
// Cluster-wide pod list (all namespaces) — each row carries its namespace.
|
|
781
|
+
app.get('/api/aks/:cluster/pods', (req, res) =>
|
|
782
|
+
send(res, (async () => {
|
|
783
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
784
|
+
const out = await k8sGet(cred, '/api/v1/pods?limit=5000');
|
|
785
|
+
return (out.items || []).map((p) => {
|
|
786
|
+
const cs = (p.status && p.status.containerStatuses) || [];
|
|
787
|
+
const spec = (p.spec && p.spec.containers) || [];
|
|
788
|
+
const labels = p.metadata.labels || {};
|
|
789
|
+
const build = imageTag((spec[0] || {}).image) || labels['app.kubernetes.io/version'] || labels.version || labels['tags.datadoghq.com/version'] || '';
|
|
790
|
+
return { name: p.metadata.name, namespace: p.metadata.namespace, phase: p.status && p.status.phase, node: p.spec && p.spec.nodeName,
|
|
791
|
+
build,
|
|
792
|
+
restarts: cs.reduce((a, x) => a + (x.restartCount || 0), 0),
|
|
793
|
+
ready: `${cs.filter((x) => x.ready).length}/${cs.length || spec.length}`,
|
|
794
|
+
containers: spec.map((x) => x.name), created: p.metadata.creationTimestamp };
|
|
795
|
+
});
|
|
796
|
+
})()));
|
|
797
|
+
|
|
798
|
+
// Pod detail: labels (tags), images+tags, and declared env vars (names + inline values +
|
|
799
|
+
// secret/configmap references). Secret VALUES are never read — only the declarations.
|
|
800
|
+
app.get('/api/aks/:cluster/:ns/pods/:pod/detail', (req, res) => {
|
|
801
|
+
const { ns, pod } = req.params;
|
|
802
|
+
if (!isK8sName(ns) || !isK8sName(pod)) return res.status(400).json({ ok: false, error: 'Invalid namespace/pod' });
|
|
803
|
+
send(res, (async () => {
|
|
804
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
805
|
+
const p = await k8sGet(cred, `/api/v1/namespaces/${ns}/pods/${pod}`);
|
|
806
|
+
const md = p.metadata || {}, spec = p.spec || {};
|
|
807
|
+
const inits = spec.initContainers || [];
|
|
808
|
+
const descFrom = (vf) => {
|
|
809
|
+
if (!vf) return null;
|
|
810
|
+
if (vf.secretKeyRef) return { kind: 'secret', name: vf.secretKeyRef.name, key: vf.secretKeyRef.key };
|
|
811
|
+
if (vf.configMapKeyRef) return { kind: 'configMap', name: vf.configMapKeyRef.name, key: vf.configMapKeyRef.key };
|
|
812
|
+
if (vf.fieldRef) return { kind: 'field', key: vf.fieldRef.fieldPath };
|
|
813
|
+
if (vf.resourceFieldRef) return { kind: 'resource', key: vf.resourceFieldRef.resource };
|
|
814
|
+
return { kind: '?' };
|
|
815
|
+
};
|
|
816
|
+
const containers = [...(spec.containers || []), ...inits].map((c) => ({
|
|
817
|
+
name: c.name, init: inits.includes(c), image: c.image, tag: imageTag(c.image),
|
|
818
|
+
env: (c.env || []).map((e) => ({ name: e.name, value: e.value != null ? e.value : undefined, from: descFrom(e.valueFrom) })),
|
|
819
|
+
envFrom: (c.envFrom || []).map((ef) => ({ kind: ef.secretRef ? 'secret' : ef.configMapRef ? 'configMap' : '?', name: (ef.secretRef || ef.configMapRef || {}).name, prefix: ef.prefix || '' })),
|
|
820
|
+
}));
|
|
821
|
+
return { name: md.name, namespace: md.namespace, node: spec.nodeName, labels: md.labels || {}, annotations: md.annotations || {}, containers };
|
|
822
|
+
})());
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
// Cluster-wide configmap list (all namespaces).
|
|
826
|
+
app.get('/api/aks/:cluster/configmaps', (req, res) =>
|
|
827
|
+
send(res, (async () => {
|
|
828
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
829
|
+
const out = await k8sGet(cred, '/api/v1/configmaps?limit=5000');
|
|
830
|
+
return (out.items || []).map((m) => ({ name: m.metadata.name, namespace: m.metadata.namespace,
|
|
831
|
+
keys: Object.keys(m.data || {}), created: m.metadata.creationTimestamp }));
|
|
832
|
+
})()));
|
|
833
|
+
|
|
834
|
+
app.get('/api/aks/:cluster/:ns/pods', (req, res) => {
|
|
835
|
+
if (!isK8sName(req.params.ns)) return res.status(400).json({ ok: false, error: 'Invalid namespace' });
|
|
836
|
+
send(res, (async () => {
|
|
837
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
838
|
+
const out = await k8sGet(cred, `/api/v1/namespaces/${req.params.ns}/pods?limit=1000`);
|
|
839
|
+
return (out.items || []).map((p) => {
|
|
840
|
+
const cs = (p.status && p.status.containerStatuses) || [];
|
|
841
|
+
const spec = (p.spec && p.spec.containers) || [];
|
|
842
|
+
return { name: p.metadata.name, phase: p.status && p.status.phase, node: p.spec && p.spec.nodeName,
|
|
843
|
+
restarts: cs.reduce((a, x) => a + (x.restartCount || 0), 0),
|
|
844
|
+
ready: `${cs.filter((x) => x.ready).length}/${cs.length || spec.length}`,
|
|
845
|
+
containers: spec.map((x) => x.name), created: p.metadata.creationTimestamp };
|
|
846
|
+
});
|
|
847
|
+
})());
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
app.get('/api/aks/:cluster/:ns/pods/:pod/log', (req, res) => {
|
|
851
|
+
const { ns, pod } = req.params;
|
|
852
|
+
if (!isK8sName(ns) || !isK8sName(pod)) return res.status(400).json({ ok: false, error: 'Invalid namespace/pod' });
|
|
853
|
+
const q = new URLSearchParams();
|
|
854
|
+
const tail = Math.min(20000, Math.max(1, Number(req.query.tailLines) || 1000));
|
|
855
|
+
q.set('tailLines', String(tail));
|
|
856
|
+
if (req.query.timestamps === 'true') q.set('timestamps', 'true');
|
|
857
|
+
if (req.query.previous === 'true') q.set('previous', 'true');
|
|
858
|
+
if (req.query.container && isK8sName(req.query.container)) q.set('container', req.query.container);
|
|
859
|
+
send(res, (async () => {
|
|
860
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
861
|
+
const text = await k8sGet(cred, `/api/v1/namespaces/${ns}/pods/${pod}/log?${q.toString()}`, { raw: true });
|
|
862
|
+
return { log: text, lines: text ? text.split('\n').length : 0 };
|
|
863
|
+
})());
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
app.get('/api/aks/:cluster/:ns/configmaps', (req, res) => {
|
|
867
|
+
if (!isK8sName(req.params.ns)) return res.status(400).json({ ok: false, error: 'Invalid namespace' });
|
|
868
|
+
send(res, (async () => {
|
|
869
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
870
|
+
const out = await k8sGet(cred, `/api/v1/namespaces/${req.params.ns}/configmaps?limit=500`);
|
|
871
|
+
return (out.items || []).map((m) => ({ name: m.metadata.name, keys: Object.keys(m.data || {}),
|
|
872
|
+
created: m.metadata.creationTimestamp }));
|
|
873
|
+
})());
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
app.get('/api/aks/:cluster/:ns/configmaps/:cm', (req, res) => {
|
|
877
|
+
const { ns, cm } = req.params;
|
|
878
|
+
if (!isK8sName(ns) || !isK8sName(cm)) return res.status(400).json({ ok: false, error: 'Invalid namespace/configmap' });
|
|
879
|
+
send(res, (async () => {
|
|
880
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
881
|
+
const out = await k8sGet(cred, `/api/v1/namespaces/${ns}/configmaps/${cm}`);
|
|
882
|
+
return { name: out.metadata.name, data: out.data || {} };
|
|
883
|
+
})());
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
// Which Key Vaults does a pod reference? Three signals, best-effort:
|
|
887
|
+
// 1) CSI Secrets Store — pod volume → SecretProviderClass → keyvaultName (exact, declarative)
|
|
888
|
+
// 2) container env values containing a *.vault.azure.net URL
|
|
889
|
+
// 3) configmaps the pod consumes (envFrom / valueFrom / volume) whose values contain one
|
|
890
|
+
const VAULT_URL_RE = /\b([a-z0-9][a-z0-9-]{1,22}[a-z0-9])\.vault\.azure\.(?:net|cn|usgovcloudapi\.net)\b/gi;
|
|
891
|
+
function scanVaultRefs(text, set, source, srcMap) {
|
|
892
|
+
if (typeof text !== 'string') return;
|
|
893
|
+
let m; VAULT_URL_RE.lastIndex = 0;
|
|
894
|
+
while ((m = VAULT_URL_RE.exec(text))) { const n = m[1].toLowerCase(); set.add(n); (srcMap[n] = srcMap[n] || new Set()).add(source); }
|
|
895
|
+
}
|
|
896
|
+
async function fetchSpc(cred, ns, name) {
|
|
897
|
+
for (const v of ['v1', 'v1alpha1']) {
|
|
898
|
+
try { return await k8sGet(cred, `/apis/secrets-store.csi.x-k8s.io/${v}/namespaces/${ns}/secretproviderclasses/${name}`); }
|
|
899
|
+
catch (e) { if (e.notFound) continue; throw e; }
|
|
900
|
+
}
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
app.get('/api/aks/:cluster/:ns/pods/:pod/keyvaults', (req, res) => {
|
|
905
|
+
const { ns, pod } = req.params;
|
|
906
|
+
if (!isK8sName(ns) || !isK8sName(pod)) return res.status(400).json({ ok: false, error: 'Invalid namespace/pod' });
|
|
907
|
+
send(res, (async () => {
|
|
908
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
909
|
+
const p = await k8sGet(cred, `/api/v1/namespaces/${ns}/pods/${pod}`);
|
|
910
|
+
const spec = p.spec || {};
|
|
911
|
+
const containers = [...(spec.containers || []), ...(spec.initContainers || []), ...(spec.ephemeralContainers || [])];
|
|
912
|
+
const set = new Set(), srcMap = {};
|
|
913
|
+
// 2) inline env values
|
|
914
|
+
for (const c of containers) for (const e of (c.env || [])) scanVaultRefs(e.value, set, 'env', srcMap);
|
|
915
|
+
// configmaps the pod directly references (envFrom / valueFrom / configMap & projected volumes)
|
|
916
|
+
const refNames = new Set();
|
|
917
|
+
for (const c of containers) {
|
|
918
|
+
for (const ef of (c.envFrom || [])) if (ef.configMapRef && ef.configMapRef.name) refNames.add(ef.configMapRef.name);
|
|
919
|
+
for (const e of (c.env || [])) if (e.valueFrom && e.valueFrom.configMapKeyRef) refNames.add(e.valueFrom.configMapKeyRef.name);
|
|
920
|
+
}
|
|
921
|
+
for (const v of (spec.volumes || [])) {
|
|
922
|
+
if (v.configMap && v.configMap.name) refNames.add(v.configMap.name);
|
|
923
|
+
for (const s of ((v.projected && v.projected.sources) || [])) if (s.configMap && s.configMap.name) refNames.add(s.configMap.name);
|
|
924
|
+
}
|
|
925
|
+
// 3) List the namespace's configmaps (data included) and scan the ones that are either
|
|
926
|
+
// referenced by the pod, or name-related to it (the "<app>-appsettings" convention).
|
|
927
|
+
const podLc = pod.toLowerCase();
|
|
928
|
+
const cmBase = (n) => n.toLowerCase().replace(/-(appsettings|configmap|config|settings|secrets?|env|vars|values)$/, '');
|
|
929
|
+
const scanned = [];
|
|
930
|
+
try {
|
|
931
|
+
const list = await k8sGet(cred, `/api/v1/namespaces/${ns}/configmaps?limit=500`);
|
|
932
|
+
for (const m of (list.items || [])) {
|
|
933
|
+
const nm = m.metadata.name, base = cmBase(nm);
|
|
934
|
+
const related = refNames.has(nm) || podLc === base || podLc.startsWith(base + '-') || podLc.startsWith(nm.toLowerCase() + '-');
|
|
935
|
+
if (!related) continue;
|
|
936
|
+
scanned.push(nm);
|
|
937
|
+
for (const val of Object.values(m.data || {})) scanVaultRefs(val, set, 'configmap:' + nm, srcMap);
|
|
938
|
+
}
|
|
939
|
+
} catch { /* can't list configmaps — skip that signal */ }
|
|
940
|
+
// 1) CSI SecretProviderClasses referenced by the pod's volumes
|
|
941
|
+
const spcNames = new Set();
|
|
942
|
+
for (const v of (spec.volumes || [])) {
|
|
943
|
+
const drv = (v.csi && v.csi.driver) || '';
|
|
944
|
+
if (/secrets-store\.csi/.test(drv) && v.csi.volumeAttributes && v.csi.volumeAttributes.secretProviderClass)
|
|
945
|
+
spcNames.add(v.csi.volumeAttributes.secretProviderClass);
|
|
946
|
+
}
|
|
947
|
+
const spcs = [];
|
|
948
|
+
for (const spc of spcNames) {
|
|
949
|
+
try {
|
|
950
|
+
const o = await fetchSpc(cred, ns, spc);
|
|
951
|
+
if (!o) { spcs.push({ name: spc, readable: false }); continue; }
|
|
952
|
+
const params = (o.spec && o.spec.parameters) || {};
|
|
953
|
+
const kv = params.keyvaultName || params.keyvaultname || params.keyVaultName;
|
|
954
|
+
if (kv) { const n = String(kv).toLowerCase(); set.add(n); (srcMap[n] = srcMap[n] || new Set()).add('csi:' + spc); }
|
|
955
|
+
for (const val of Object.values(params)) scanVaultRefs(String(val), set, 'csi:' + spc, srcMap); // objects YAML etc.
|
|
956
|
+
spcs.push({ name: spc, readable: true, vault: kv ? String(kv).toLowerCase() : null });
|
|
957
|
+
} catch (e) {
|
|
958
|
+
// The CRD is often unreadable, but a SecretProviderClass is commonly named after its
|
|
959
|
+
// vault — if that name matches a vault you can see, surface it (no false positives).
|
|
960
|
+
let inferred = null;
|
|
961
|
+
try { const mv = await findVaultMeta(spc); if (mv) { const n = mv.name.toLowerCase(); set.add(n); (srcMap[n] = srcMap[n] || new Set()).add('csi:' + spc + ' (by name)'); inferred = mv.name; } } catch { /* ignore */ }
|
|
962
|
+
spcs.push({ name: spc, readable: false, forbidden: !!e.forbidden, inferredVault: inferred });
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
// 4) Secrets the pod consumes that are synced from a Key Vault by External Secrets
|
|
966
|
+
// Operator. We read only secret METADATA (never values) to find the owning
|
|
967
|
+
// ExternalSecret, then resolve its SecretStore's azurekv vaultUrl when readable.
|
|
968
|
+
const secretNames = new Set();
|
|
969
|
+
for (const c of containers) {
|
|
970
|
+
for (const ef of (c.envFrom || [])) if (ef.secretRef && ef.secretRef.name) secretNames.add(ef.secretRef.name);
|
|
971
|
+
for (const e of (c.env || [])) if (e.valueFrom && e.valueFrom.secretKeyRef) secretNames.add(e.valueFrom.secretKeyRef.name);
|
|
972
|
+
}
|
|
973
|
+
for (const v of (spec.volumes || [])) {
|
|
974
|
+
if (v.secret && v.secret.secretName) secretNames.add(v.secret.secretName);
|
|
975
|
+
for (const s of ((v.projected && v.projected.sources) || [])) if (s.secret && s.secret.name) secretNames.add(s.secret.name);
|
|
976
|
+
}
|
|
977
|
+
const esNames = new Set();
|
|
978
|
+
for (const sn of secretNames) {
|
|
979
|
+
try {
|
|
980
|
+
const s = await k8sGet(cred, `/api/v1/namespaces/${ns}/secrets/${sn}`); // metadata only is used below
|
|
981
|
+
const md = s.metadata || {};
|
|
982
|
+
const owner = (md.ownerReferences || []).find((o) => o.kind === 'ExternalSecret');
|
|
983
|
+
const eso = owner || (md.labels && md.labels['reconcile.external-secrets.io/managed'] === 'true') ||
|
|
984
|
+
Object.keys(md.annotations || {}).some((a) => a.startsWith('reconcile.external-secrets.io/'));
|
|
985
|
+
if (eso) esNames.add(owner ? owner.name : sn);
|
|
986
|
+
} catch { /* can't read secret metadata — skip */ }
|
|
987
|
+
}
|
|
988
|
+
const externalSecrets = [];
|
|
989
|
+
const getCRD = async (path) => { for (const ver of ['v1', 'v1beta1']) { try { return await k8sGet(cred, path.replace('{v}', ver)); } catch (e) { if (e.notFound) continue; throw e; } } return null; };
|
|
990
|
+
for (const esn of esNames) {
|
|
991
|
+
const entry = { externalSecret: esn, vault: null, resolvable: false, reason: null };
|
|
992
|
+
try {
|
|
993
|
+
const es = await getCRD(`/apis/external-secrets.io/{v}/namespaces/${ns}/externalsecrets/${esn}`);
|
|
994
|
+
const ref = (es && es.spec && es.spec.secretStoreRef) || {};
|
|
995
|
+
const storePath = ref.kind === 'ClusterSecretStore'
|
|
996
|
+
? `/apis/external-secrets.io/{v}/clustersecretstores/${ref.name}`
|
|
997
|
+
: `/apis/external-secrets.io/{v}/namespaces/${ns}/secretstores/${ref.name}`;
|
|
998
|
+
const store = ref.name ? await getCRD(storePath) : null;
|
|
999
|
+
const akv = store && store.spec && store.spec.provider && store.spec.provider.azurekv;
|
|
1000
|
+
const url = akv && (akv.vaultUrl || akv.vaultUri);
|
|
1001
|
+
const m = url && /([a-z0-9][a-z0-9-]{1,22}[a-z0-9])\.vault\.azure\./i.exec(url);
|
|
1002
|
+
if (m) { const n = m[1].toLowerCase(); set.add(n); (srcMap[n] = srcMap[n] || new Set()).add('eso:' + esn); entry.vault = n; entry.resolvable = true; }
|
|
1003
|
+
else entry.reason = 'store has no azurekv vaultUrl';
|
|
1004
|
+
} catch (e) { entry.reason = e.forbidden ? 'needs read on externalsecrets / secretstores' : (e.message || 'unreadable'); }
|
|
1005
|
+
externalSecrets.push(entry);
|
|
1006
|
+
}
|
|
1007
|
+
const vaults = [...set].sort().map((name) => ({ name, sources: [...(srcMap[name] || [])] }));
|
|
1008
|
+
return { vaults, configmapsScanned: scanned, spcs, externalSecrets };
|
|
1009
|
+
})());
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
// Interactive shell INTO a pod, in the browser. The browser opens a WebSocket to us;
|
|
1013
|
+
// we open an upstream WebSocket to the Kubernetes `exec` endpoint (CA + AAD bearer
|
|
1014
|
+
// token) and bridge the channel-framed streams. No kubectl needed. The API server
|
|
1015
|
+
// enforces Kubernetes RBAC (pods/exec) — this grants nothing the identity lacks.
|
|
1016
|
+
const { WebSocketServer, WebSocket } = require('ws');
|
|
1017
|
+
const execWss = new WebSocketServer({ noServer: true });
|
|
1018
|
+
|
|
1019
|
+
function attachExecWss(server) {
|
|
1020
|
+
server.on('upgrade', (req, socket, head) => {
|
|
1021
|
+
let url; try { url = new URL(req.url, 'http://localhost'); } catch { socket.destroy(); return; }
|
|
1022
|
+
if (url.pathname !== '/ws/aks/exec') { socket.destroy(); return; }
|
|
1023
|
+
execWss.handleUpgrade(req, socket, head, (client) => bridgeExec(client, url.searchParams));
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
async function bridgeExec(client, params) {
|
|
1028
|
+
const clusterName = params.get('cluster'), ns = params.get('ns'), pod = params.get('pod');
|
|
1029
|
+
const container = params.get('container') || null;
|
|
1030
|
+
const tell = (m) => { try { if (client.readyState === 1) client.send(m); } catch { /* ignore */ } };
|
|
1031
|
+
if (!isValidName(clusterName || '') || !isK8sName(ns || '') || !isK8sName(pod || '') || (container && !isK8sName(container))) {
|
|
1032
|
+
tell('\r\n\x1b[31mInvalid cluster/namespace/pod.\x1b[0m\r\n'); client.close(); return;
|
|
1033
|
+
}
|
|
1034
|
+
let cred, token;
|
|
1035
|
+
try {
|
|
1036
|
+
const c = clusterById(await aksList(), clusterName);
|
|
1037
|
+
if (!c) throw new Error('Cluster is not in your configured/visible list');
|
|
1038
|
+
cred = await aksCredential(c);
|
|
1039
|
+
token = await getAksToken();
|
|
1040
|
+
} catch (e) {
|
|
1041
|
+
tell('\r\n\x1b[31m' + (e.network ? 'Cannot reach the cluster — connect to the VPN.' : (e.message || 'auth failed')) + '\x1b[0m\r\n');
|
|
1042
|
+
client.close(); return;
|
|
1043
|
+
}
|
|
1044
|
+
const q = new URLSearchParams();
|
|
1045
|
+
q.set('stdin', 'true'); q.set('stdout', 'true'); q.set('stderr', 'true'); q.set('tty', 'true');
|
|
1046
|
+
if (container) q.set('container', container);
|
|
1047
|
+
// Prefer bash, fall back to ash/sh — one exec, the shell picks what exists.
|
|
1048
|
+
for (const part of ['/bin/sh', '-c', 'clear; (bash || ash || sh)']) q.append('command', part);
|
|
1049
|
+
const wsUrl = cred.server.replace(/^https:/, 'wss:') + `/api/v1/namespaces/${ns}/pods/${pod}/exec?` + q.toString();
|
|
1050
|
+
|
|
1051
|
+
const upstream = new WebSocket(wsUrl, ['v4.channel.k8s.io', 'channel.k8s.io'], {
|
|
1052
|
+
headers: { Authorization: `Bearer ${token}` }, ca: cred.ca,
|
|
1053
|
+
});
|
|
1054
|
+
let handled = false; // so the generic 'error' doesn't double-report a decoded HTTP status
|
|
1055
|
+
|
|
1056
|
+
// The exec handshake is plain HTTP until it upgrades; decode a non-101 status precisely.
|
|
1057
|
+
upstream.on('unexpected-response', (reqU, resU) => {
|
|
1058
|
+
handled = true;
|
|
1059
|
+
let body = ''; resU.on('data', (d) => { body += d; }); resU.on('end', () => {
|
|
1060
|
+
let msg; try { msg = JSON.parse(body).message; } catch { /* ignore */ }
|
|
1061
|
+
const s = resU.statusCode;
|
|
1062
|
+
const friendly = s === 401 ? 'Not authorized — token rejected. Run az login (and check the cluster is managed-AAD).'
|
|
1063
|
+
: s === 403 ? 'Forbidden — your identity lacks Kubernetes "pods/exec" permission in this namespace.'
|
|
1064
|
+
: s === 404 ? 'Pod, namespace or container not found.'
|
|
1065
|
+
: (msg || `Kubernetes returned ${s}`);
|
|
1066
|
+
tell('\r\n\x1b[31m' + friendly + '\x1b[0m\r\n'); try { client.close(); } catch { /* ignore */ }
|
|
1067
|
+
});
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
upstream.on('open', () => tell(`\x1b[32m● connected to ${ns}/${pod}${container ? ' [' + container + ']' : ''}\x1b[0m\r\n`));
|
|
1071
|
+
upstream.on('message', (data) => {
|
|
1072
|
+
if (!data || !data.length) return;
|
|
1073
|
+
const ch = data[0], payload = data.subarray(1); // channel: 1=stdout 2=stderr 3=error/status
|
|
1074
|
+
if (ch === 1 || ch === 2) { if (client.readyState === 1) client.send(payload); }
|
|
1075
|
+
else if (ch === 3) { try { const j = JSON.parse(payload.toString()); if (j.status === 'Failure' && j.message) tell('\r\n\x1b[31m' + j.message + '\x1b[0m\r\n'); } catch { /* ignore */ } }
|
|
1076
|
+
});
|
|
1077
|
+
upstream.on('close', () => { tell('\r\n\x1b[33m● session closed\x1b[0m\r\n'); try { client.close(); } catch { /* ignore */ } });
|
|
1078
|
+
upstream.on('error', (e) => { if (handled) return; tell('\r\n\x1b[31m' + (e.message || 'connection error — is the VPN connected?') + '\x1b[0m\r\n'); try { client.close(); } catch { /* ignore */ } });
|
|
1079
|
+
|
|
1080
|
+
client.on('message', (msg, isBinary) => {
|
|
1081
|
+
if (upstream.readyState !== 1) return;
|
|
1082
|
+
if (isBinary) { upstream.send(Buffer.concat([Buffer.from([0]), msg])); return; } // channel 0 = stdin
|
|
1083
|
+
try { const j = JSON.parse(msg.toString()); // text = control (resize)
|
|
1084
|
+
if (j.type === 'resize') upstream.send(Buffer.concat([Buffer.from([4]), Buffer.from(JSON.stringify({ Width: j.cols, Height: j.rows }))]));
|
|
1085
|
+
} catch { /* ignore */ }
|
|
1086
|
+
});
|
|
1087
|
+
client.on('close', () => { try { upstream.close(); } catch { /* ignore */ } });
|
|
1088
|
+
}
|
|
1089
|
+
|
|
603
1090
|
// ---- fallback --------------------------------------------------------------
|
|
604
1091
|
|
|
605
1092
|
app.get('/api/health', (req, res) => res.json({ ok: true, az: AZ }));
|
|
@@ -613,7 +1100,7 @@ const ENV_RULES = (() => {
|
|
|
613
1100
|
const PKG_VERSION = (() => { try { return require('./package.json').version; } catch { return null; } })();
|
|
614
1101
|
app.get('/api/config', (req, res) =>
|
|
615
1102
|
res.json({ ok: true, data: { envRules: ENV_RULES, title: process.env.AZBO_TITLE || null,
|
|
616
|
-
version: PKG_VERSION, protectedTags: PROTECTED_TAGS } }));
|
|
1103
|
+
version: PKG_VERSION, protectedTags: PROTECTED_TAGS, aksConfigured: AKS_CLUSTERS.length } }));
|
|
617
1104
|
|
|
618
1105
|
function openBrowser(url) {
|
|
619
1106
|
if (process.env.AZBO_NO_OPEN) return;
|
|
@@ -630,6 +1117,7 @@ function start(port, attemptsLeft = 12) {
|
|
|
630
1117
|
console.log(` Stop with Ctrl-C.\n`);
|
|
631
1118
|
openBrowser(url);
|
|
632
1119
|
});
|
|
1120
|
+
attachExecWss(server); // browser ⇆ Kubernetes exec bridge for the in-pod terminal
|
|
633
1121
|
server.on('error', (e) => {
|
|
634
1122
|
if (e.code === 'EADDRINUSE' && attemptsLeft > 0) {
|
|
635
1123
|
console.log(` Port ${port} in use, trying ${port + 1}…`);
|