akv-manager 0.0.5 → 0.1.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 +24 -0
- package/package.json +3 -2
- package/public/index.html +380 -9
- package/public/styles.css +23 -0
- package/server.js +466 -2
package/README.md
CHANGED
|
@@ -34,6 +34,27 @@ 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.
|
|
37
58
|
|
|
38
59
|
## Configuration (all optional, via environment variables)
|
|
39
60
|
|
|
@@ -44,6 +65,9 @@ If you're not signed in, the UI tells you to run `az login` and press Refresh.
|
|
|
44
65
|
| `AZBO_NO_OPEN` | — | Set to anything to skip auto-opening the browser. |
|
|
45
66
|
| `AZBO_TITLE` | `Keyvault Console` | Custom title shown in the sidebar. |
|
|
46
67
|
| `AZBO_ENV_RULES` | — | JSON `[["pattern","Label"], …]` mapping subscription-name patterns to environment labels. |
|
|
68
|
+
| `AZBO_PROTECTED_TAGS` | governance set | Comma-separated tag names that are read-only in the UI (`*` = prefix wildcard). Set empty to disable. |
|
|
69
|
+
| `AZBO_AKS_CLUSTERS` | — | JSON array of `{name,resourceGroup,subscription,env?,label?}` clusters to monitor. |
|
|
70
|
+
| `AZBO_AKS_CLUSTERS_FILE` | — | Path to a JSON file with the same cluster array. |
|
|
47
71
|
| `AZ_PATH` | `az` | Path to the Azure CLI binary. |
|
|
48
72
|
| `AZ_TIMEOUT` | `120000` | Per-`az`-call timeout (ms). |
|
|
49
73
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akv-manager",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
loadClustersCache(); // so ⌘K can jump to clusters without visiting the section first
|
|
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,357 @@ 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
|
+
async function viewClusters(){
|
|
984
|
+
$('#route').innerHTML = `<div class="panel"><div class="empty"><span class="spin"></span> loading clusters…</div></div>`;
|
|
985
|
+
const r = await api('/api/aks/clusters');
|
|
986
|
+
if(STATE.route!=='clusters') return;
|
|
987
|
+
if(!r.ok){ $('#route').innerHTML = `<div class="panel">${clErr(r)}</div>`; return; }
|
|
988
|
+
CL.all = r.data.clusters||[];
|
|
989
|
+
if(!CL.all.length){
|
|
990
|
+
$('#route').innerHTML = `<div class="panel"><div class="empty" style="padding:42px;line-height:1.8">
|
|
991
|
+
No AKS clusters configured or visible to you.<br>
|
|
992
|
+
<span class="faint mono" style="font-size:12px">Set <b>AZBO_AKS_CLUSTERS</b> (JSON) or <b>AZBO_AKS_CLUSTERS_FILE</b> to a list of<br>
|
|
993
|
+
<code>{ name, resourceGroup, subscription, env, label }</code> and restart.</span></div></div>`;
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
$('#route').innerHTML = `
|
|
997
|
+
<div class="panel"><div class="scroll"><table>
|
|
998
|
+
<thead><tr><th>Cluster</th><th>Env</th><th>Version</th><th>Network</th><th>Source</th><th class="right">Actions</th></tr></thead>
|
|
999
|
+
<tbody>${CL.all.map(c=>`<tr>
|
|
1000
|
+
<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>
|
|
1001
|
+
<td>${c.env?`<span class="tag ${envClass(c.env)}">${esc(c.env)}</span>`:'<span class="faint">—</span>'}</td>
|
|
1002
|
+
<td class="mono muted">${esc(c.version||'—')}</td>
|
|
1003
|
+
<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>
|
|
1004
|
+
<td><span class="pill">${c.configured?'configured':'discovered'}</span></td>
|
|
1005
|
+
<td class="right"><button class="iconbtn" onclick="openCluster('${esc(c.name)}')">Open</button></td>
|
|
1006
|
+
</tr>`).join('')}</tbody>
|
|
1007
|
+
</table></div></div>
|
|
1008
|
+
<div class="tablenote">${CL.all.length} cluster${CL.all.length!==1?'s':''} · ${r.data.configured} configured locally</div>`;
|
|
1009
|
+
}
|
|
1010
|
+
function openCluster(name){ navigate('cluster/'+encodeURIComponent(name)); }
|
|
1011
|
+
|
|
1012
|
+
function viewCluster(name){
|
|
1013
|
+
CL.name = name;
|
|
1014
|
+
if(mountedCluster!==name || !document.getElementById('clBody')){
|
|
1015
|
+
mountedCluster=name; CL.ns=''; CL.items=[]; CL.namespaces=[]; CL.logPod=null; CL.cluster=null; CL.kind='pods';
|
|
1016
|
+
$('#route').innerHTML = `
|
|
1017
|
+
<div class="vhead">
|
|
1018
|
+
<div class="crumb"><a href="#/clusters">Clusters</a><span class="sep">/</span>
|
|
1019
|
+
<span class="here" id="clName">${esc(name)}</span> <span id="clEnv"></span></div>
|
|
1020
|
+
<div class="vmeta" id="clMeta"><span class="faint">read-only · pods · logs · configmaps</span></div>
|
|
1021
|
+
</div>
|
|
1022
|
+
<div class="cmctl">
|
|
1023
|
+
<div class="seg" id="clKind"><button data-k="pods" class="active">Pods</button><button data-k="configmaps">ConfigMaps</button></div>
|
|
1024
|
+
<button class="btn ghost" id="clRefresh">↻ Refresh</button>
|
|
1025
|
+
</div>
|
|
1026
|
+
<div id="clBody"><div class="empty"><span class="spin"></span> loading…</div></div>`;
|
|
1027
|
+
$('#clKind').querySelectorAll('button').forEach(b=>b.onclick=()=>{ CL.kind=b.dataset.k;
|
|
1028
|
+
$('#clKind').querySelectorAll('button').forEach(x=>x.classList.toggle('active',x===b)); loadClusterContent(); });
|
|
1029
|
+
$('#clRefresh').onclick=()=>{ if(CL.logPod) fetchLogs(); else loadClusterContent(); };
|
|
1030
|
+
enrichClusterMeta(name);
|
|
1031
|
+
loadClusterContent();
|
|
1032
|
+
} else {
|
|
1033
|
+
loadClusterContent();
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
async function enrichClusterMeta(name){
|
|
1037
|
+
const list = CL.all || await loadClustersCache();
|
|
1038
|
+
const c = (list||[]).find(x=>x.name===name); if(!c) return;
|
|
1039
|
+
CL.cluster=c;
|
|
1040
|
+
if(STATE.route!=='cluster' || CL.name!==name) return;
|
|
1041
|
+
if($('#clName')) $('#clName').textContent = c.label||c.name;
|
|
1042
|
+
if($('#clEnv')&&c.env) $('#clEnv').innerHTML = `<span class="tag ${envClass(c.env)}">${esc(c.env)}</span>`;
|
|
1043
|
+
if($('#clMeta')) $('#clMeta').innerHTML = `<span class="mono">${esc(c.name)}</span><span class="sep">·</span>`+
|
|
1044
|
+
`<span class="mono">${esc(c.resourceGroup||'')}</span>${c.private?'<span class="sep">·</span><span class="pill policy">private · VPN</span>':''}`;
|
|
1045
|
+
}
|
|
1046
|
+
function loadClusterContent(){
|
|
1047
|
+
CL.logPod=null; CL.termPod=null; CL.filter=''; teardownTerm();
|
|
1048
|
+
$('#clBody').innerHTML='<div class="empty"><span class="spin"></span></div>';
|
|
1049
|
+
if(CL.kind==='pods') loadPods(); else loadConfigMaps();
|
|
1050
|
+
}
|
|
1051
|
+
function clFilterBar(placeholder, extra){
|
|
1052
|
+
return `<div class="cmctl" style="margin-bottom:10px">
|
|
1053
|
+
<input class="search" id="clFilter" placeholder="${placeholder}" value="${esc(CL.filter)}" autocomplete="off" style="min-width:300px">
|
|
1054
|
+
${extra||''}
|
|
1055
|
+
<span class="faint mono" id="clCount"></span></div>
|
|
1056
|
+
<div id="clList"></div>`;
|
|
1057
|
+
}
|
|
1058
|
+
const TIER_RE = {
|
|
1059
|
+
cache:/(^|-)cache(\d+)?(-|$)|-cache$/,
|
|
1060
|
+
fe:/frontend|-fe-|-fe\b|-web\b|-ui\b|-spa\b/,
|
|
1061
|
+
be:/backend|-be-|-be\b|-api\b|-svc\b|-worker\b|-jobs?\b|-bgjobs?\b/,
|
|
1062
|
+
};
|
|
1063
|
+
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'; }
|
|
1064
|
+
async function loadPods(){
|
|
1065
|
+
const name=CL.name;
|
|
1066
|
+
const r = await api(`/api/aks/${name}/pods`);
|
|
1067
|
+
if(STATE.route!=='cluster'||CL.name!==name||CL.kind!=='pods'||CL.logPod||CL.termPod) return;
|
|
1068
|
+
if(!r.ok){ $('#clBody').innerHTML=clErr(r); return; }
|
|
1069
|
+
CL.items=r.data||[];
|
|
1070
|
+
const tier = `<div class="seg" id="clTier">
|
|
1071
|
+
<button data-t="all" class="${CL.tier==='all'?'active':''}">All</button>
|
|
1072
|
+
<button data-t="be" class="${CL.tier==='be'?'active':''}">Backend</button>
|
|
1073
|
+
<button data-t="fe" class="${CL.tier==='fe'?'active':''}">Frontend</button>
|
|
1074
|
+
<button data-t="cache" class="${CL.tier==='cache'?'active':''}">Cache</button></div>`;
|
|
1075
|
+
$('#clBody').innerHTML = clFilterBar('filter pods by name or namespace…', tier);
|
|
1076
|
+
$('#clFilter').oninput=e=>{ CL.filter=e.target.value; renderPodRows(); };
|
|
1077
|
+
$('#clTier').querySelectorAll('button').forEach(b=>b.onclick=()=>{ CL.tier=b.dataset.t;
|
|
1078
|
+
$('#clTier').querySelectorAll('button').forEach(x=>x.classList.toggle('active',x===b)); renderPodRows(); });
|
|
1079
|
+
renderPodRows();
|
|
1080
|
+
}
|
|
1081
|
+
function podMatches(p){
|
|
1082
|
+
if(CL.tier!=='all' && podTier(p.name)!==CL.tier) return false;
|
|
1083
|
+
const q=CL.filter.toLowerCase();
|
|
1084
|
+
const nm=p.name.toLowerCase();
|
|
1085
|
+
return !q || nm.includes(q) || (p.namespace||'').toLowerCase().includes(q);
|
|
1086
|
+
}
|
|
1087
|
+
function renderPodRows(){
|
|
1088
|
+
const list=CL.items.filter(podMatches);
|
|
1089
|
+
$('#clCount').textContent = CL.items.length ? `${list.length}${list.length!==CL.items.length?' / '+CL.items.length:''} pod${CL.items.length!==1?'s':''}` : '';
|
|
1090
|
+
if(!CL.items.length){ $('#clList').innerHTML=`<div class="empty">No pods visible on this cluster.</div>`; return; }
|
|
1091
|
+
if(!list.length){ $('#clList').innerHTML=`<div class="empty">No pods match your filter.</div>`; return; }
|
|
1092
|
+
$('#clList').innerHTML = `<div class="panel"><div class="scroll"><table>
|
|
1093
|
+
<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>
|
|
1094
|
+
<tbody>${list.map(p=>`<tr>
|
|
1095
|
+
<td class="mono"><b><span class="ell" title="${esc(p.name)}">${esc(p.name)}</span></b></td>
|
|
1096
|
+
<td class="mono muted"><span class="ell ns" title="${esc(p.namespace||'')}">${esc(p.namespace||'')}</span></td>
|
|
1097
|
+
<td class="mono"><span class="ell" style="max-width:140px" title="${esc(p.build||'')}">${esc(p.build||'—')}</span></td>
|
|
1098
|
+
<td class="mono">${esc(p.ready||'')}</td>
|
|
1099
|
+
<td><span class="phase ${esc(p.phase||'')}">${esc(p.phase||'?')}</span></td>
|
|
1100
|
+
<td class="right mono ${p.restarts>0?'':'muted'}">${p.restarts}</td>
|
|
1101
|
+
<td class="mono muted">${age(p.created)}</td>
|
|
1102
|
+
<td class="right" style="white-space:nowrap"><button class="iconbtn" onclick="openPodDetail('${esc(p.namespace)}','${esc(p.name)}')">Details</button>
|
|
1103
|
+
<button class="iconbtn" onclick="showLogs('${esc(p.namespace)}','${esc(p.name)}')">Logs</button>
|
|
1104
|
+
<button class="iconbtn" onclick="openTerminal('${esc(p.namespace)}','${esc(p.name)}')">Terminal</button>
|
|
1105
|
+
<button class="iconbtn" onclick="openPodVaults('${esc(p.namespace)}','${esc(p.name)}')">Vaults</button></td>
|
|
1106
|
+
</tr>`).join('')}</tbody></table></div></div>`;
|
|
1107
|
+
}
|
|
1108
|
+
async function openPodDetail(ns,pod){
|
|
1109
|
+
CL.logPod=null; CL.termPod=null; teardownTerm();
|
|
1110
|
+
$('#clBody').innerHTML = `<div class="cmctl"><button class="iconbtn" onclick="loadClusterContent()">← Pods</button>
|
|
1111
|
+
<span class="mono"><b>${esc(pod)}</b></span><span class="faint mono">${esc(ns)}</span></div>
|
|
1112
|
+
<div id="pdOut"><div class="empty"><span class="spin"></span> loading pod details…</div></div>`;
|
|
1113
|
+
const r=await api(`/api/aks/${CL.name}/${ns}/pods/${pod}/detail`);
|
|
1114
|
+
if(STATE.route!=='cluster') return; const out=$('#pdOut'); if(!out) return;
|
|
1115
|
+
if(!r.ok){ out.innerHTML=clErr(r); return; }
|
|
1116
|
+
const d=r.data; const labels=Object.entries(d.labels||{});
|
|
1117
|
+
const labelChips = labels.length
|
|
1118
|
+
? `<div class="tagchips">${labels.map(([k,v])=>`<span class="tagchip"><b>${esc(k)}</b>${v?' '+esc(v):''}</span>`).join('')}</div>`
|
|
1119
|
+
: '<span class="faint mono">no labels</span>';
|
|
1120
|
+
const cont=(d.containers||[]).map(c=>{
|
|
1121
|
+
const rows=(c.env||[]).map(e=>{
|
|
1122
|
+
const val = e.value!=null ? esc(e.value)
|
|
1123
|
+
: e.from ? `<span class="faint">← ${esc(e.from.kind)} ${esc(e.from.name||'')}${e.from.key?'.'+esc(e.from.key):''}</span>` : '';
|
|
1124
|
+
return `<tr><td class="mono"><b>${esc(e.name)}</b></td><td class="mono" style="word-break:break-all">${val}</td></tr>`;
|
|
1125
|
+
}).join('');
|
|
1126
|
+
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('');
|
|
1127
|
+
const body = rows||efRows
|
|
1128
|
+
? `<div class="panel" style="margin-top:6px"><div class="scroll" style="max-height:none"><table>
|
|
1129
|
+
<thead><tr><th>Env var</th><th>Value / source</th></tr></thead><tbody>${rows}${efRows}</tbody></table></div></div>`
|
|
1130
|
+
: '<div class="faint mono" style="margin-top:6px">no env vars declared</div>';
|
|
1131
|
+
return `<div style="margin:16px 0">
|
|
1132
|
+
<div class="kvline" style="padding:4px 0"><span class="nm mono"><b>${esc(c.name)}</b>${c.init?' <span class="pill">init</span>':''}</span>
|
|
1133
|
+
<span class="meta">${c.tag?`<span class="tagchip">build ${esc(c.tag)}</span>`:''}</span></div>
|
|
1134
|
+
<div class="cmval">${esc(c.image||'')}</div>${body}</div>`;
|
|
1135
|
+
}).join('');
|
|
1136
|
+
out.innerHTML = `<div class="secthead">Labels (tags)</div>${labelChips}
|
|
1137
|
+
<div class="secthead" style="margin-top:18px">Containers · image · env vars</div>${cont}`;
|
|
1138
|
+
}
|
|
1139
|
+
function showLogs(ns,pod){
|
|
1140
|
+
const p = CL.items.find(x=>x.name===pod&&x.namespace===ns) || {name:pod, containers:[]};
|
|
1141
|
+
CL.logNs=ns; CL.logPod=pod;
|
|
1142
|
+
const conts = p.containers||[];
|
|
1143
|
+
const contSel = conts.length>1
|
|
1144
|
+
? `<select class="facetsel" id="logC">${conts.map(c=>`<option value="${esc(c)}">${esc(c)}</option>`).join('')}</select>`
|
|
1145
|
+
: conts.length ? `<input type="hidden" id="logC" value="${esc(conts[0])}">` : '';
|
|
1146
|
+
$('#clBody').innerHTML = `
|
|
1147
|
+
<div class="cmctl">
|
|
1148
|
+
<button class="iconbtn" onclick="loadClusterContent()">← Pods</button>
|
|
1149
|
+
<span class="mono"><b>${esc(pod)}</b></span><span class="faint mono">${esc(ns)}</span>
|
|
1150
|
+
${contSel}
|
|
1151
|
+
<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>
|
|
1152
|
+
<label class="selall"><input type="checkbox" id="logTs"> timestamps</label>
|
|
1153
|
+
<label class="selall"><input type="checkbox" id="logPrev"> previous (crash)</label>
|
|
1154
|
+
<button class="btn sm" id="logGo">Load</button>
|
|
1155
|
+
<span class="newbtns"><button class="iconbtn" id="logCopy">⧉ Copy</button><button class="iconbtn" id="logDl">↓ Download</button></span>
|
|
1156
|
+
</div>
|
|
1157
|
+
<div class="logbox" id="logOut"><div class="empty"><span class="spin"></span></div></div>`;
|
|
1158
|
+
$('#logGo').onclick=fetchLogs;
|
|
1159
|
+
$('#logTail').onchange=fetchLogs; $('#logC') && ($('#logC').onchange=fetchLogs);
|
|
1160
|
+
$('#logCopy').onclick=()=>{ const t=$('#logOut').dataset.raw||''; t?copy(t):toast('Nothing to copy'); };
|
|
1161
|
+
$('#logDl').onclick=downloadLog;
|
|
1162
|
+
fetchLogs();
|
|
1163
|
+
}
|
|
1164
|
+
async function fetchLogs(){
|
|
1165
|
+
const out=$('#logOut'); if(!out) return;
|
|
1166
|
+
out.innerHTML='<div class="empty"><span class="spin"></span> fetching logs…</div>';
|
|
1167
|
+
const cEl=$('#logC'); const container=cEl?cEl.value:'';
|
|
1168
|
+
const qs=new URLSearchParams({tailLines:$('#logTail').value});
|
|
1169
|
+
if(container) qs.set('container',container);
|
|
1170
|
+
if($('#logTs').checked) qs.set('timestamps','true');
|
|
1171
|
+
if($('#logPrev').checked) qs.set('previous','true');
|
|
1172
|
+
const pod=CL.logPod;
|
|
1173
|
+
const r=await api(`/api/aks/${CL.name}/${CL.logNs}/pods/${pod}/log?${qs.toString()}`);
|
|
1174
|
+
if(STATE.route!=='cluster' || CL.logPod!==pod) return;
|
|
1175
|
+
if(!r.ok){ out.innerHTML=clErr(r); return; }
|
|
1176
|
+
const text=r.data.log||'';
|
|
1177
|
+
out.dataset.raw=text;
|
|
1178
|
+
out.textContent = text || '(empty log)'; // textContent: safe against log content
|
|
1179
|
+
}
|
|
1180
|
+
function downloadLog(){
|
|
1181
|
+
const text=$('#logOut').dataset.raw||''; if(!text){ toast('Nothing to download'); return; }
|
|
1182
|
+
const env=(CL.cluster&&CL.cluster.env)?CL.cluster.env+'-':'';
|
|
1183
|
+
const blob=new Blob([text],{type:'text/plain'}); const url=URL.createObjectURL(blob);
|
|
1184
|
+
const a=document.createElement('a'); a.href=url; a.download=`log-${env}${CL.logNs}-${CL.logPod}.txt`; a.click(); URL.revokeObjectURL(url);
|
|
1185
|
+
}
|
|
1186
|
+
async function loadConfigMaps(){
|
|
1187
|
+
const name=CL.name;
|
|
1188
|
+
const r=await api(`/api/aks/${name}/configmaps`);
|
|
1189
|
+
if(STATE.route!=='cluster'||CL.name!==name||CL.kind!=='configmaps'||CL.logPod||CL.termPod) return;
|
|
1190
|
+
if(!r.ok){ $('#clBody').innerHTML=clErr(r); return; }
|
|
1191
|
+
CL.items=r.data||[];
|
|
1192
|
+
$('#clBody').innerHTML = clFilterBar('filter configmaps by name or namespace…');
|
|
1193
|
+
$('#clFilter').oninput=e=>{ CL.filter=e.target.value; renderCmRows(); };
|
|
1194
|
+
renderCmRows();
|
|
1195
|
+
}
|
|
1196
|
+
function renderCmRows(){
|
|
1197
|
+
const q=CL.filter.toLowerCase();
|
|
1198
|
+
const list=CL.items.filter(m=>!q||m.name.toLowerCase().includes(q)||(m.namespace||'').toLowerCase().includes(q));
|
|
1199
|
+
$('#clCount').textContent = CL.items.length ? `${list.length}${list.length!==CL.items.length?' / '+CL.items.length:''} configmap${CL.items.length!==1?'s':''}` : '';
|
|
1200
|
+
if(!CL.items.length){ $('#clList').innerHTML=`<div class="empty">No configmaps visible on this cluster.</div>`; return; }
|
|
1201
|
+
if(!list.length){ $('#clList').innerHTML=`<div class="empty">No configmaps match “${esc(CL.filter)}”.</div>`; return; }
|
|
1202
|
+
$('#clList').innerHTML = `<div class="panel"><div class="scroll"><table>
|
|
1203
|
+
<thead><tr><th>ConfigMap</th><th>Namespace</th><th>Keys</th><th class="right">Actions</th></tr></thead>
|
|
1204
|
+
<tbody>${list.map(m=>`<tr>
|
|
1205
|
+
<td class="mono"><b><span class="ell" title="${esc(m.name)}">${esc(m.name)}</span></b></td>
|
|
1206
|
+
<td class="mono muted"><span class="ell ns" title="${esc(m.namespace||'')}">${esc(m.namespace||'')}</span></td>
|
|
1207
|
+
<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>
|
|
1208
|
+
<td class="right"><button class="iconbtn" onclick="viewCm('${esc(m.namespace)}','${esc(m.name)}')">View</button>
|
|
1209
|
+
<button class="iconbtn" onclick="copy('kubectl describe configmap ${esc(m.name)} -n ${esc(m.namespace)}')">⧉</button></td>
|
|
1210
|
+
</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>
|
|
1211
|
+
</table></div></div>`;
|
|
1212
|
+
}
|
|
1213
|
+
async function viewCm(ns,name){
|
|
1214
|
+
const box=document.getElementById('cm-'+ns+'__'+name); if(!box) return;
|
|
1215
|
+
if(!box.classList.contains('hide')){ box.classList.add('hide'); box.innerHTML=''; return; }
|
|
1216
|
+
box.classList.remove('hide'); box.innerHTML='<div class="empty"><span class="spin"></span></div>';
|
|
1217
|
+
const r=await api(`/api/aks/${CL.name}/${ns}/configmaps/${name}`);
|
|
1218
|
+
if(!r.ok){ box.innerHTML=clErr(r); return; }
|
|
1219
|
+
const data=r.data.data||{}; const keys=Object.keys(data); const ckey=ns+'/'+name; CL.cm[ckey]=data;
|
|
1220
|
+
box.innerHTML = `<div style="padding:8px 0 12px">${keys.length? keys.map(k=>`
|
|
1221
|
+
<div class="cmkey"><div class="kvline" style="padding:5px 0"><span class="nm mono"><b>${esc(k)}</b></span>
|
|
1222
|
+
<span class="meta"><button class="iconbtn" onclick='copyCm(${JSON.stringify(ckey)},${JSON.stringify(k)})'>⧉ copy</button></span></div>
|
|
1223
|
+
<pre class="cmval"></pre></div>`).join('') : '<div class="empty">empty configmap</div>'}</div>`;
|
|
1224
|
+
const pres=box.querySelectorAll('pre.cmval'); keys.forEach((k,i)=>{ if(pres[i]) pres[i].textContent=data[k]; });
|
|
1225
|
+
}
|
|
1226
|
+
function copyCm(ckey,k){ const d=CL.cm[ckey]||{}; copy(d[k]!=null?String(d[k]):''); }
|
|
1227
|
+
|
|
1228
|
+
// ---------- which Key Vaults does a pod use ----------
|
|
1229
|
+
async function openPodVaults(ns,pod){
|
|
1230
|
+
CL.logPod=null; CL.termPod=null; teardownTerm();
|
|
1231
|
+
$('#clBody').innerHTML = `<div class="cmctl"><button class="iconbtn" onclick="loadClusterContent()">← Pods</button>
|
|
1232
|
+
<span class="mono"><b>${esc(pod)}</b></span><span class="faint mono">${esc(ns)}</span><span class="faint mono">Key Vault references</span></div>
|
|
1233
|
+
<div id="pkvOut"><div class="empty"><span class="spin"></span> inspecting pod env, configmaps and CSI SecretProviderClasses…</div></div>`;
|
|
1234
|
+
const r = await api(`/api/aks/${CL.name}/${ns}/pods/${pod}/keyvaults`);
|
|
1235
|
+
if(STATE.route!=='cluster') return;
|
|
1236
|
+
const out=$('#pkvOut'); if(!out) return;
|
|
1237
|
+
if(!r.ok){ out.innerHTML=clErr(r); return; }
|
|
1238
|
+
const vaults=r.data.vaults||[];
|
|
1239
|
+
const spcs=r.data.spcs||[];
|
|
1240
|
+
const eso=r.data.externalSecrets||[];
|
|
1241
|
+
const unreadable=spcs.filter(s=>!s.readable);
|
|
1242
|
+
const esoUnresolved=eso.filter(e=>!e.resolvable);
|
|
1243
|
+
const spcNote = spcs.length
|
|
1244
|
+
? `<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>`
|
|
1245
|
+
: '';
|
|
1246
|
+
const esoNote = esoUnresolved.length
|
|
1247
|
+
? `<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>
|
|
1248
|
+
<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>`
|
|
1249
|
+
: '';
|
|
1250
|
+
if(!vaults.length){
|
|
1251
|
+
out.innerHTML=`<div class="empty">No Key Vault could be resolved for this pod.
|
|
1252
|
+
<div class="hint" style="margin-top:8px">Checked CSI SecretProviderClasses, External Secrets, container env, and ${r.data.configmapsScanned.length} configmap(s).</div>
|
|
1253
|
+
${spcNote}${esoNote}</div>`;
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
out.innerHTML = `<div class="panel"><div class="scroll"><table>
|
|
1257
|
+
<thead><tr><th>Key Vault</th><th>Referenced via</th><th class="right">Actions</th></tr></thead>
|
|
1258
|
+
<tbody>${vaults.map(v=>{ const known=findVault(v.name);
|
|
1259
|
+
return `<tr><td class="mono"><b>${esc(v.name)}</b></td>
|
|
1260
|
+
<td><div class="tagchips">${v.sources.map(s=>`<span class="tagchip">${esc(s)}</span>`).join('')}</div></td>
|
|
1261
|
+
<td class="right">${known
|
|
1262
|
+
? `<button class="iconbtn" onclick="openVault('${esc(known.name)}')">Open vault →</button>`
|
|
1263
|
+
: `<span class="faint mono" title="not in your visible inventory">not visible</span>`}</td></tr>`;
|
|
1264
|
+
}).join('')}</tbody></table></div></div>
|
|
1265
|
+
<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>
|
|
1266
|
+
${unreadable.length?`<div class="tablenote">${spcNote}</div>`:''}
|
|
1267
|
+
${esoUnresolved.length?`<div class="tablenote">${esoNote}</div>`:''}`;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// ---------- in-pod terminal (xterm.js over a WS exec bridge) ----------
|
|
1271
|
+
let TERM=null, TERMWS=null, TERMRO=null;
|
|
1272
|
+
function teardownTerm(){
|
|
1273
|
+
try{ TERMRO&&TERMRO.disconnect(); }catch{} TERMRO=null;
|
|
1274
|
+
try{ TERMWS&&TERMWS.close(); }catch{} TERMWS=null;
|
|
1275
|
+
try{ TERM&&TERM.dispose(); }catch{} TERM=null;
|
|
1276
|
+
}
|
|
1277
|
+
function openTerminal(ns,pod){
|
|
1278
|
+
const p = CL.items.find(x=>x.name===pod&&x.namespace===ns) || {name:pod, containers:[]};
|
|
1279
|
+
CL.logPod=null; CL.termNs=ns; CL.termPod=pod;
|
|
1280
|
+
const conts=p.containers||[];
|
|
1281
|
+
const contSel = conts.length>1
|
|
1282
|
+
? `<select class="facetsel" id="tmC">${conts.map(c=>`<option value="${esc(c)}">${esc(c)}</option>`).join('')}</select>`
|
|
1283
|
+
: conts.length ? `<input type="hidden" id="tmC" value="${esc(conts[0])}">` : '';
|
|
1284
|
+
$('#clBody').innerHTML = `
|
|
1285
|
+
<div class="cmctl">
|
|
1286
|
+
<button class="iconbtn" onclick="closeTerminal()">← Pods</button>
|
|
1287
|
+
<span class="mono"><b>${esc(pod)}</b></span><span class="faint mono">${esc(ns)}</span>
|
|
1288
|
+
${contSel}
|
|
1289
|
+
<span class="faint mono" id="tmStatus">connecting…</span>
|
|
1290
|
+
<span class="newbtns"><button class="iconbtn" id="tmReconnect">↻ Reconnect</button></span>
|
|
1291
|
+
</div>
|
|
1292
|
+
<div id="termHost" class="termhost"></div>`;
|
|
1293
|
+
$('#tmReconnect').onclick=()=>startTerm();
|
|
1294
|
+
const cSel=$('#tmC'); if(cSel && cSel.tagName==='SELECT') cSel.onchange=()=>startTerm();
|
|
1295
|
+
startTerm();
|
|
1296
|
+
}
|
|
1297
|
+
function closeTerminal(){ teardownTerm(); CL.termPod=null; loadClusterContent(); }
|
|
1298
|
+
function tmStatus(txt, cls){ const el=$('#tmStatus'); if(!el)return; el.textContent=txt; el.className='mono '+(cls||'faint'); }
|
|
1299
|
+
function startTerm(){
|
|
1300
|
+
if(typeof Terminal==='undefined' || typeof FitAddon==='undefined'){ tmStatus('terminal library failed to load (offline?)','dead'); return; }
|
|
1301
|
+
teardownTerm();
|
|
1302
|
+
const host=$('#termHost'); if(!host) return; host.innerHTML='';
|
|
1303
|
+
TERM=new Terminal({ fontFamily:'"IBM Plex Mono", ui-monospace, monospace', fontSize:12.5, cursorBlink:true,
|
|
1304
|
+
theme:{ background:'#070A11', foreground:'#CDE0F5', cursor:'#5B9DFF' } });
|
|
1305
|
+
const fit=new FitAddon.FitAddon(); TERM.loadAddon(fit); TERM.open(host);
|
|
1306
|
+
try{ fit.fit(); }catch{}
|
|
1307
|
+
const cont=$('#tmC')?$('#tmC').value:'';
|
|
1308
|
+
const proto=location.protocol==='https:'?'wss':'ws';
|
|
1309
|
+
const qs=new URLSearchParams({cluster:CL.name, ns:CL.termNs, pod:CL.termPod}); if(cont) qs.set('container',cont);
|
|
1310
|
+
const ws=new WebSocket(`${proto}://${location.host}/ws/aks/exec?${qs.toString()}`);
|
|
1311
|
+
ws.binaryType='arraybuffer'; TERMWS=ws;
|
|
1312
|
+
const sendResize=()=>{ try{ fit.fit(); if(ws.readyState===1) ws.send(JSON.stringify({type:'resize',cols:TERM.cols,rows:TERM.rows})); }catch{} };
|
|
1313
|
+
ws.onopen=()=>{ tmStatus('connected','live'); sendResize(); TERM.focus(); };
|
|
1314
|
+
ws.onmessage=ev=>{ if(typeof ev.data==='string') TERM.write(ev.data); else TERM.write(new Uint8Array(ev.data)); };
|
|
1315
|
+
ws.onclose=()=>tmStatus('disconnected','dead');
|
|
1316
|
+
ws.onerror=()=>tmStatus('connection error','dead');
|
|
1317
|
+
TERM.onData(d=>{ if(ws.readyState===1) ws.send(new TextEncoder().encode(d)); });
|
|
1318
|
+
TERMRO=new ResizeObserver(()=>sendResize()); TERMRO.observe(host);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
957
1321
|
// ---------- command palette ----------
|
|
958
1322
|
let CMDK = { open:false, items:[], cur:0 };
|
|
959
1323
|
function cmdkActions(){
|
|
@@ -961,6 +1325,7 @@ function cmdkActions(){
|
|
|
961
1325
|
{type:'view', label:'Dashboard', hint:'go', act:()=>navigate('dashboard')},
|
|
962
1326
|
{type:'view', label:'Key Vaults', hint:'go', act:()=>navigate('vaults')},
|
|
963
1327
|
{type:'view', label:'Expiry', hint:'go', act:()=>navigate('expiry')},
|
|
1328
|
+
{type:'view', label:'Clusters', hint:'go', act:()=>navigate('clusters')},
|
|
964
1329
|
{type:'view', label:'Account & subscriptions', hint:'go', act:()=>navigate('settings')},
|
|
965
1330
|
{type:'action', label:'Refresh inventory', hint:'run', act:()=>{ doRefresh(); }},
|
|
966
1331
|
];
|
|
@@ -974,16 +1339,22 @@ function renderCmdk(q){
|
|
|
974
1339
|
.filter(v=>!q || v.name.toLowerCase().includes(q) || (v.service||'').toLowerCase().includes(q) || Object.values(v.tags||{}).join(' ').toLowerCase().includes(q))
|
|
975
1340
|
.slice(0,40)
|
|
976
1341
|
.map(v=>({type:'vault', label:v.name, sub:v.env, env:v.env, act:()=>openVault(v.name)}));
|
|
977
|
-
|
|
1342
|
+
const clusters = (CL.all||[])
|
|
1343
|
+
.filter(c=>!q || (c.label||c.name).toLowerCase().includes(q) || c.name.toLowerCase().includes(q))
|
|
1344
|
+
.slice(0,20)
|
|
1345
|
+
.map(c=>({type:'cluster', label:c.label||c.name, sub:c.env, act:()=>openCluster(c.name)}));
|
|
1346
|
+
CMDK.items = [...acts, ...clusters, ...vaults]; CMDK.cur=0;
|
|
978
1347
|
const list=$('#cmdkList');
|
|
979
1348
|
if(!CMDK.items.length){ list.innerHTML='<div class="cmdk-empty">No matches.</div>'; return; }
|
|
980
1349
|
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>';
|
|
1350
|
+
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
1351
|
const gIco='<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M5 6h14M5 18h14"/></svg>';
|
|
1352
|
+
const secName={vault:'Vaults',cluster:'Clusters',view:'Go to',action:'Actions'};
|
|
982
1353
|
let html=''; let lastType='';
|
|
983
1354
|
CMDK.items.forEach((it,idx)=>{
|
|
984
|
-
if(it.type!==lastType){ html+=`<div class="cmdk-sec">${it.type
|
|
1355
|
+
if(it.type!==lastType){ html+=`<div class="cmdk-sec">${secName[it.type]||''}</div>`; lastType=it.type; }
|
|
985
1356
|
html+=`<div class="cmdk-it ${idx===CMDK.cur?'cur':''}" data-i="${idx}">
|
|
986
|
-
${it.type==='vault'?vIco:gIco}
|
|
1357
|
+
${it.type==='vault'?vIco:it.type==='cluster'?cIco:gIco}
|
|
987
1358
|
<span class="lbl">${esc(it.label)}${it.sub?`<small>${esc(it.sub)}</small>`:''}</span>
|
|
988
1359
|
<span class="hint">${it.hint?esc(it.hint):'open'}</span></div>`;
|
|
989
1360
|
});
|
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,468 @@ 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
|
+
// Operator-supplied cluster list — NEVER baked in. JSON via AZBO_AKS_CLUSTERS, or a
|
|
616
|
+
// path via AZBO_AKS_CLUSTERS_FILE. Each: { name, resourceGroup, subscription, env?, label? }.
|
|
617
|
+
const AKS_CLUSTERS = (() => {
|
|
618
|
+
let raw = process.env.AZBO_AKS_CLUSTERS;
|
|
619
|
+
if (!raw && process.env.AZBO_AKS_CLUSTERS_FILE) {
|
|
620
|
+
try { raw = require('fs').readFileSync(process.env.AZBO_AKS_CLUSTERS_FILE, 'utf8'); }
|
|
621
|
+
catch (e) { console.warn(' AZBO_AKS_CLUSTERS_FILE could not be read:', e.message); }
|
|
622
|
+
}
|
|
623
|
+
if (!raw) return [];
|
|
624
|
+
try {
|
|
625
|
+
const arr = JSON.parse(raw);
|
|
626
|
+
return (Array.isArray(arr) ? arr : []).filter((c) => c && c.name && c.resourceGroup && c.subscription)
|
|
627
|
+
.map((c) => ({ name: c.name, resourceGroup: c.resourceGroup, subscription: c.subscription,
|
|
628
|
+
env: c.env || '', label: c.label || c.name, configured: true }));
|
|
629
|
+
} catch { console.warn(' AZBO_AKS_CLUSTERS is not valid JSON — ignoring.'); return []; }
|
|
630
|
+
})();
|
|
631
|
+
|
|
632
|
+
async function resolveSubId(nameOrId) {
|
|
633
|
+
if (/^[0-9a-fA-F-]{36}$/.test(nameOrId)) return nameOrId;
|
|
634
|
+
const subs = await listSubscriptions();
|
|
635
|
+
const s = subs.find((x) => x.displayName === nameOrId || x.subscriptionId === nameOrId);
|
|
636
|
+
return s ? s.subscriptionId : nameOrId;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Cache each cluster's API server URL + CA (from listClusterUserCredential) for 30 min.
|
|
640
|
+
const aksCredCache = new Map();
|
|
641
|
+
async function aksCredential(c) {
|
|
642
|
+
const key = `${c.subscription}/${c.resourceGroup}/${c.name}`;
|
|
643
|
+
const hit = aksCredCache.get(key);
|
|
644
|
+
if (hit && Date.now() - hit.at < 30 * 60 * 1000) return hit;
|
|
645
|
+
const subId = await resolveSubId(c.subscription);
|
|
646
|
+
const url = `/subscriptions/${subId}/resourceGroups/${c.resourceGroup}` +
|
|
647
|
+
`/providers/Microsoft.ContainerService/managedClusters/${c.name}` +
|
|
648
|
+
`/listClusterUserCredential?api-version=2024-05-01`;
|
|
649
|
+
const out = await armFetch(url, { method: 'POST' });
|
|
650
|
+
const kc = out.kubeconfigs && out.kubeconfigs[0];
|
|
651
|
+
if (!kc || !kc.value) throw new Error('No kubeconfig returned for this cluster');
|
|
652
|
+
const yaml = Buffer.from(kc.value, 'base64').toString('utf8');
|
|
653
|
+
const server = (yaml.match(/server:\s*(\S+)/) || [])[1];
|
|
654
|
+
const caB64 = (yaml.match(/certificate-authority-data:\s*(\S+)/) || [])[1];
|
|
655
|
+
if (!server) throw new Error('Could not parse API server URL from kubeconfig');
|
|
656
|
+
const cred = { server: server.replace(/\/+$/, ''),
|
|
657
|
+
ca: caB64 ? Buffer.from(caB64, 'base64').toString('utf8') : undefined, at: Date.now() };
|
|
658
|
+
aksCredCache.set(key, cred);
|
|
659
|
+
return cred;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function k8sMsg(body) { try { return JSON.parse(body).message; } catch { return null; } }
|
|
663
|
+
|
|
664
|
+
// The "build" of a pod = its container image tag (e.g. registry/app:11.5.0387 → 11.5.0387).
|
|
665
|
+
// Digest-pinned images (@sha256:…) have no human tag; callers fall back to a version label.
|
|
666
|
+
function imageTag(img) {
|
|
667
|
+
if (!img || img.includes('@')) return '';
|
|
668
|
+
const repoTag = img.slice(img.lastIndexOf('/') + 1);
|
|
669
|
+
const c = repoTag.indexOf(':');
|
|
670
|
+
return c >= 0 ? repoTag.slice(c + 1) : '';
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// One GET against a cluster's Kubernetes API. raw=true returns text (for pod logs).
|
|
674
|
+
function k8sGet(cred, pathQuery, { raw = false, timeout = 25000 } = {}) {
|
|
675
|
+
return new Promise((resolve, reject) => {
|
|
676
|
+
getAksToken().then((token) => {
|
|
677
|
+
const u = new URL(cred.server + pathQuery);
|
|
678
|
+
const req = https.request({
|
|
679
|
+
hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method: 'GET',
|
|
680
|
+
// '*/*' for raw so the log subresource returns text/plain without tripping content negotiation
|
|
681
|
+
headers: { Authorization: `Bearer ${token}`, Accept: raw ? '*/*' : 'application/json' },
|
|
682
|
+
ca: cred.ca, timeout,
|
|
683
|
+
}, (res) => {
|
|
684
|
+
let data = ''; res.setEncoding('utf8');
|
|
685
|
+
res.on('data', (c) => { data += c; });
|
|
686
|
+
res.on('end', () => {
|
|
687
|
+
const s = res.statusCode;
|
|
688
|
+
if (s === 401) { tokenCache.delete(AKS_AAD_SERVER_APP); return reject(Object.assign(new Error('Token rejected — run az login'), { needLogin: true })); }
|
|
689
|
+
if (s === 403) return reject(Object.assign(new Error(k8sMsg(data) || 'Forbidden'), { forbidden: true }));
|
|
690
|
+
if (s === 404) return reject(Object.assign(new Error(k8sMsg(data) || 'Not found'), { notFound: true }));
|
|
691
|
+
if (s >= 400) return reject(new Error(k8sMsg(data) || `Kubernetes returned ${s}`));
|
|
692
|
+
try { resolve(raw ? data : (data ? JSON.parse(data) : null)); }
|
|
693
|
+
catch (e) { reject(new Error('Failed to parse Kubernetes response: ' + e.message)); }
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
req.on('error', (e) => {
|
|
697
|
+
const code = e.code || '';
|
|
698
|
+
const network = /ENOTFOUND|ETIMEDOUT|ECONNREFUSED|EAI_AGAIN|ECONNRESET|UNABLE_TO_VERIFY|SELF_SIGNED/.test(code + e.message);
|
|
699
|
+
reject(Object.assign(new Error(network
|
|
700
|
+
? 'Cannot reach the cluster API server — it is private. Connect to the corporate VPN and retry.'
|
|
701
|
+
: e.message), { network }));
|
|
702
|
+
});
|
|
703
|
+
req.on('timeout', () => req.destroy(Object.assign(new Error('Cluster API request timed out — is the VPN connected?'), { network: true })));
|
|
704
|
+
req.end();
|
|
705
|
+
}).catch(reject);
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Discover clusters you have ARM read on (best-effort) and merge with the configured list.
|
|
710
|
+
const AKS_QUERY =
|
|
711
|
+
"resources | where type =~ 'microsoft.containerservice/managedclusters' " +
|
|
712
|
+
"| project name, resourceGroup, subscriptionId, " +
|
|
713
|
+
"ver=tostring(properties.kubernetesVersion), " +
|
|
714
|
+
"priv=tostring(properties.apiServerAccessProfile.enablePrivateCluster) | order by name asc";
|
|
715
|
+
async function discoverClusters() {
|
|
716
|
+
try {
|
|
717
|
+
const subIds = (await listSubscriptions()).map((s) => s.subscriptionId);
|
|
718
|
+
const out = await armFetch('/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01', {
|
|
719
|
+
method: 'POST', body: { subscriptions: subIds, query: AKS_QUERY, options: { resultFormat: 'objectArray', $top: 1000 } },
|
|
720
|
+
});
|
|
721
|
+
return (out.data || []).map((c) => ({ name: c.name, resourceGroup: c.resourceGroup,
|
|
722
|
+
subscription: c.subscriptionId, version: c.ver, private: c.priv === 'true', configured: false }));
|
|
723
|
+
} catch { return []; }
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function clusterById(list, id) { return list.find((c) => c.name === id); }
|
|
727
|
+
async function aksList() {
|
|
728
|
+
const discovered = await discoverClusters();
|
|
729
|
+
const byKey = new Map();
|
|
730
|
+
for (const c of discovered) byKey.set(c.name.toLowerCase(), c);
|
|
731
|
+
for (const c of AKS_CLUSTERS) { // configured entries win + carry their label/env
|
|
732
|
+
const prev = byKey.get(c.name.toLowerCase()) || {};
|
|
733
|
+
byKey.set(c.name.toLowerCase(), { ...prev, ...c });
|
|
734
|
+
}
|
|
735
|
+
return [...byKey.values()].sort((a, b) => (a.label || a.name).localeCompare(b.label || b.name));
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
app.get('/api/aks/clusters', (req, res) =>
|
|
739
|
+
send(res, aksList().then((clusters) => ({ clusters, configured: AKS_CLUSTERS.length }))));
|
|
740
|
+
|
|
741
|
+
// Resolve a :cluster route param to a known cluster, or throw a typed 400/404.
|
|
742
|
+
async function getClusterChecked(name) {
|
|
743
|
+
if (!isValidName(name)) throw Object.assign(new Error('Invalid cluster name'), { status: 400 });
|
|
744
|
+
const c = clusterById(await aksList(), name);
|
|
745
|
+
if (!c) throw Object.assign(new Error('Cluster is not in your configured/visible list'), { status: 404, notFound: true });
|
|
746
|
+
return c;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
app.get('/api/aks/:cluster/namespaces', (req, res) =>
|
|
750
|
+
send(res, (async () => {
|
|
751
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
752
|
+
const out = await k8sGet(cred, '/api/v1/namespaces?limit=500');
|
|
753
|
+
return (out.items || []).map((n) => ({ name: n.metadata.name, status: n.status && n.status.phase }));
|
|
754
|
+
})()));
|
|
755
|
+
|
|
756
|
+
// Cluster-wide pod list (all namespaces) — each row carries its namespace.
|
|
757
|
+
app.get('/api/aks/:cluster/pods', (req, res) =>
|
|
758
|
+
send(res, (async () => {
|
|
759
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
760
|
+
const out = await k8sGet(cred, '/api/v1/pods?limit=5000');
|
|
761
|
+
return (out.items || []).map((p) => {
|
|
762
|
+
const cs = (p.status && p.status.containerStatuses) || [];
|
|
763
|
+
const spec = (p.spec && p.spec.containers) || [];
|
|
764
|
+
const labels = p.metadata.labels || {};
|
|
765
|
+
const build = imageTag((spec[0] || {}).image) || labels['app.kubernetes.io/version'] || labels.version || labels['tags.datadoghq.com/version'] || '';
|
|
766
|
+
return { name: p.metadata.name, namespace: p.metadata.namespace, phase: p.status && p.status.phase, node: p.spec && p.spec.nodeName,
|
|
767
|
+
build,
|
|
768
|
+
restarts: cs.reduce((a, x) => a + (x.restartCount || 0), 0),
|
|
769
|
+
ready: `${cs.filter((x) => x.ready).length}/${cs.length || spec.length}`,
|
|
770
|
+
containers: spec.map((x) => x.name), created: p.metadata.creationTimestamp };
|
|
771
|
+
});
|
|
772
|
+
})()));
|
|
773
|
+
|
|
774
|
+
// Pod detail: labels (tags), images+tags, and declared env vars (names + inline values +
|
|
775
|
+
// secret/configmap references). Secret VALUES are never read — only the declarations.
|
|
776
|
+
app.get('/api/aks/:cluster/:ns/pods/:pod/detail', (req, res) => {
|
|
777
|
+
const { ns, pod } = req.params;
|
|
778
|
+
if (!isK8sName(ns) || !isK8sName(pod)) return res.status(400).json({ ok: false, error: 'Invalid namespace/pod' });
|
|
779
|
+
send(res, (async () => {
|
|
780
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
781
|
+
const p = await k8sGet(cred, `/api/v1/namespaces/${ns}/pods/${pod}`);
|
|
782
|
+
const md = p.metadata || {}, spec = p.spec || {};
|
|
783
|
+
const inits = spec.initContainers || [];
|
|
784
|
+
const descFrom = (vf) => {
|
|
785
|
+
if (!vf) return null;
|
|
786
|
+
if (vf.secretKeyRef) return { kind: 'secret', name: vf.secretKeyRef.name, key: vf.secretKeyRef.key };
|
|
787
|
+
if (vf.configMapKeyRef) return { kind: 'configMap', name: vf.configMapKeyRef.name, key: vf.configMapKeyRef.key };
|
|
788
|
+
if (vf.fieldRef) return { kind: 'field', key: vf.fieldRef.fieldPath };
|
|
789
|
+
if (vf.resourceFieldRef) return { kind: 'resource', key: vf.resourceFieldRef.resource };
|
|
790
|
+
return { kind: '?' };
|
|
791
|
+
};
|
|
792
|
+
const containers = [...(spec.containers || []), ...inits].map((c) => ({
|
|
793
|
+
name: c.name, init: inits.includes(c), image: c.image, tag: imageTag(c.image),
|
|
794
|
+
env: (c.env || []).map((e) => ({ name: e.name, value: e.value != null ? e.value : undefined, from: descFrom(e.valueFrom) })),
|
|
795
|
+
envFrom: (c.envFrom || []).map((ef) => ({ kind: ef.secretRef ? 'secret' : ef.configMapRef ? 'configMap' : '?', name: (ef.secretRef || ef.configMapRef || {}).name, prefix: ef.prefix || '' })),
|
|
796
|
+
}));
|
|
797
|
+
return { name: md.name, namespace: md.namespace, node: spec.nodeName, labels: md.labels || {}, annotations: md.annotations || {}, containers };
|
|
798
|
+
})());
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
// Cluster-wide configmap list (all namespaces).
|
|
802
|
+
app.get('/api/aks/:cluster/configmaps', (req, res) =>
|
|
803
|
+
send(res, (async () => {
|
|
804
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
805
|
+
const out = await k8sGet(cred, '/api/v1/configmaps?limit=5000');
|
|
806
|
+
return (out.items || []).map((m) => ({ name: m.metadata.name, namespace: m.metadata.namespace,
|
|
807
|
+
keys: Object.keys(m.data || {}), created: m.metadata.creationTimestamp }));
|
|
808
|
+
})()));
|
|
809
|
+
|
|
810
|
+
app.get('/api/aks/:cluster/:ns/pods', (req, res) => {
|
|
811
|
+
if (!isK8sName(req.params.ns)) return res.status(400).json({ ok: false, error: 'Invalid namespace' });
|
|
812
|
+
send(res, (async () => {
|
|
813
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
814
|
+
const out = await k8sGet(cred, `/api/v1/namespaces/${req.params.ns}/pods?limit=1000`);
|
|
815
|
+
return (out.items || []).map((p) => {
|
|
816
|
+
const cs = (p.status && p.status.containerStatuses) || [];
|
|
817
|
+
const spec = (p.spec && p.spec.containers) || [];
|
|
818
|
+
return { name: p.metadata.name, phase: p.status && p.status.phase, node: p.spec && p.spec.nodeName,
|
|
819
|
+
restarts: cs.reduce((a, x) => a + (x.restartCount || 0), 0),
|
|
820
|
+
ready: `${cs.filter((x) => x.ready).length}/${cs.length || spec.length}`,
|
|
821
|
+
containers: spec.map((x) => x.name), created: p.metadata.creationTimestamp };
|
|
822
|
+
});
|
|
823
|
+
})());
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
app.get('/api/aks/:cluster/:ns/pods/:pod/log', (req, res) => {
|
|
827
|
+
const { ns, pod } = req.params;
|
|
828
|
+
if (!isK8sName(ns) || !isK8sName(pod)) return res.status(400).json({ ok: false, error: 'Invalid namespace/pod' });
|
|
829
|
+
const q = new URLSearchParams();
|
|
830
|
+
const tail = Math.min(20000, Math.max(1, Number(req.query.tailLines) || 1000));
|
|
831
|
+
q.set('tailLines', String(tail));
|
|
832
|
+
if (req.query.timestamps === 'true') q.set('timestamps', 'true');
|
|
833
|
+
if (req.query.previous === 'true') q.set('previous', 'true');
|
|
834
|
+
if (req.query.container && isK8sName(req.query.container)) q.set('container', req.query.container);
|
|
835
|
+
send(res, (async () => {
|
|
836
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
837
|
+
const text = await k8sGet(cred, `/api/v1/namespaces/${ns}/pods/${pod}/log?${q.toString()}`, { raw: true });
|
|
838
|
+
return { log: text, lines: text ? text.split('\n').length : 0 };
|
|
839
|
+
})());
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
app.get('/api/aks/:cluster/:ns/configmaps', (req, res) => {
|
|
843
|
+
if (!isK8sName(req.params.ns)) return res.status(400).json({ ok: false, error: 'Invalid namespace' });
|
|
844
|
+
send(res, (async () => {
|
|
845
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
846
|
+
const out = await k8sGet(cred, `/api/v1/namespaces/${req.params.ns}/configmaps?limit=500`);
|
|
847
|
+
return (out.items || []).map((m) => ({ name: m.metadata.name, keys: Object.keys(m.data || {}),
|
|
848
|
+
created: m.metadata.creationTimestamp }));
|
|
849
|
+
})());
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
app.get('/api/aks/:cluster/:ns/configmaps/:cm', (req, res) => {
|
|
853
|
+
const { ns, cm } = req.params;
|
|
854
|
+
if (!isK8sName(ns) || !isK8sName(cm)) return res.status(400).json({ ok: false, error: 'Invalid namespace/configmap' });
|
|
855
|
+
send(res, (async () => {
|
|
856
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
857
|
+
const out = await k8sGet(cred, `/api/v1/namespaces/${ns}/configmaps/${cm}`);
|
|
858
|
+
return { name: out.metadata.name, data: out.data || {} };
|
|
859
|
+
})());
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
// Which Key Vaults does a pod reference? Three signals, best-effort:
|
|
863
|
+
// 1) CSI Secrets Store — pod volume → SecretProviderClass → keyvaultName (exact, declarative)
|
|
864
|
+
// 2) container env values containing a *.vault.azure.net URL
|
|
865
|
+
// 3) configmaps the pod consumes (envFrom / valueFrom / volume) whose values contain one
|
|
866
|
+
const VAULT_URL_RE = /\b([a-z0-9][a-z0-9-]{1,22}[a-z0-9])\.vault\.azure\.(?:net|cn|usgovcloudapi\.net)\b/gi;
|
|
867
|
+
function scanVaultRefs(text, set, source, srcMap) {
|
|
868
|
+
if (typeof text !== 'string') return;
|
|
869
|
+
let m; VAULT_URL_RE.lastIndex = 0;
|
|
870
|
+
while ((m = VAULT_URL_RE.exec(text))) { const n = m[1].toLowerCase(); set.add(n); (srcMap[n] = srcMap[n] || new Set()).add(source); }
|
|
871
|
+
}
|
|
872
|
+
async function fetchSpc(cred, ns, name) {
|
|
873
|
+
for (const v of ['v1', 'v1alpha1']) {
|
|
874
|
+
try { return await k8sGet(cred, `/apis/secrets-store.csi.x-k8s.io/${v}/namespaces/${ns}/secretproviderclasses/${name}`); }
|
|
875
|
+
catch (e) { if (e.notFound) continue; throw e; }
|
|
876
|
+
}
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
app.get('/api/aks/:cluster/:ns/pods/:pod/keyvaults', (req, res) => {
|
|
881
|
+
const { ns, pod } = req.params;
|
|
882
|
+
if (!isK8sName(ns) || !isK8sName(pod)) return res.status(400).json({ ok: false, error: 'Invalid namespace/pod' });
|
|
883
|
+
send(res, (async () => {
|
|
884
|
+
const cred = await aksCredential(await getClusterChecked(req.params.cluster));
|
|
885
|
+
const p = await k8sGet(cred, `/api/v1/namespaces/${ns}/pods/${pod}`);
|
|
886
|
+
const spec = p.spec || {};
|
|
887
|
+
const containers = [...(spec.containers || []), ...(spec.initContainers || []), ...(spec.ephemeralContainers || [])];
|
|
888
|
+
const set = new Set(), srcMap = {};
|
|
889
|
+
// 2) inline env values
|
|
890
|
+
for (const c of containers) for (const e of (c.env || [])) scanVaultRefs(e.value, set, 'env', srcMap);
|
|
891
|
+
// configmaps the pod directly references (envFrom / valueFrom / configMap & projected volumes)
|
|
892
|
+
const refNames = new Set();
|
|
893
|
+
for (const c of containers) {
|
|
894
|
+
for (const ef of (c.envFrom || [])) if (ef.configMapRef && ef.configMapRef.name) refNames.add(ef.configMapRef.name);
|
|
895
|
+
for (const e of (c.env || [])) if (e.valueFrom && e.valueFrom.configMapKeyRef) refNames.add(e.valueFrom.configMapKeyRef.name);
|
|
896
|
+
}
|
|
897
|
+
for (const v of (spec.volumes || [])) {
|
|
898
|
+
if (v.configMap && v.configMap.name) refNames.add(v.configMap.name);
|
|
899
|
+
for (const s of ((v.projected && v.projected.sources) || [])) if (s.configMap && s.configMap.name) refNames.add(s.configMap.name);
|
|
900
|
+
}
|
|
901
|
+
// 3) List the namespace's configmaps (data included) and scan the ones that are either
|
|
902
|
+
// referenced by the pod, or name-related to it (the "<app>-appsettings" convention).
|
|
903
|
+
const podLc = pod.toLowerCase();
|
|
904
|
+
const cmBase = (n) => n.toLowerCase().replace(/-(appsettings|configmap|config|settings|secrets?|env|vars|values)$/, '');
|
|
905
|
+
const scanned = [];
|
|
906
|
+
try {
|
|
907
|
+
const list = await k8sGet(cred, `/api/v1/namespaces/${ns}/configmaps?limit=500`);
|
|
908
|
+
for (const m of (list.items || [])) {
|
|
909
|
+
const nm = m.metadata.name, base = cmBase(nm);
|
|
910
|
+
const related = refNames.has(nm) || podLc === base || podLc.startsWith(base + '-') || podLc.startsWith(nm.toLowerCase() + '-');
|
|
911
|
+
if (!related) continue;
|
|
912
|
+
scanned.push(nm);
|
|
913
|
+
for (const val of Object.values(m.data || {})) scanVaultRefs(val, set, 'configmap:' + nm, srcMap);
|
|
914
|
+
}
|
|
915
|
+
} catch { /* can't list configmaps — skip that signal */ }
|
|
916
|
+
// 1) CSI SecretProviderClasses referenced by the pod's volumes
|
|
917
|
+
const spcNames = new Set();
|
|
918
|
+
for (const v of (spec.volumes || [])) {
|
|
919
|
+
const drv = (v.csi && v.csi.driver) || '';
|
|
920
|
+
if (/secrets-store\.csi/.test(drv) && v.csi.volumeAttributes && v.csi.volumeAttributes.secretProviderClass)
|
|
921
|
+
spcNames.add(v.csi.volumeAttributes.secretProviderClass);
|
|
922
|
+
}
|
|
923
|
+
const spcs = [];
|
|
924
|
+
for (const spc of spcNames) {
|
|
925
|
+
try {
|
|
926
|
+
const o = await fetchSpc(cred, ns, spc);
|
|
927
|
+
if (!o) { spcs.push({ name: spc, readable: false }); continue; }
|
|
928
|
+
const params = (o.spec && o.spec.parameters) || {};
|
|
929
|
+
const kv = params.keyvaultName || params.keyvaultname || params.keyVaultName;
|
|
930
|
+
if (kv) { const n = String(kv).toLowerCase(); set.add(n); (srcMap[n] = srcMap[n] || new Set()).add('csi:' + spc); }
|
|
931
|
+
for (const val of Object.values(params)) scanVaultRefs(String(val), set, 'csi:' + spc, srcMap); // objects YAML etc.
|
|
932
|
+
spcs.push({ name: spc, readable: true, vault: kv ? String(kv).toLowerCase() : null });
|
|
933
|
+
} catch (e) {
|
|
934
|
+
// The CRD is often unreadable, but a SecretProviderClass is commonly named after its
|
|
935
|
+
// vault — if that name matches a vault you can see, surface it (no false positives).
|
|
936
|
+
let inferred = null;
|
|
937
|
+
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 */ }
|
|
938
|
+
spcs.push({ name: spc, readable: false, forbidden: !!e.forbidden, inferredVault: inferred });
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
// 4) Secrets the pod consumes that are synced from a Key Vault by External Secrets
|
|
942
|
+
// Operator. We read only secret METADATA (never values) to find the owning
|
|
943
|
+
// ExternalSecret, then resolve its SecretStore's azurekv vaultUrl when readable.
|
|
944
|
+
const secretNames = new Set();
|
|
945
|
+
for (const c of containers) {
|
|
946
|
+
for (const ef of (c.envFrom || [])) if (ef.secretRef && ef.secretRef.name) secretNames.add(ef.secretRef.name);
|
|
947
|
+
for (const e of (c.env || [])) if (e.valueFrom && e.valueFrom.secretKeyRef) secretNames.add(e.valueFrom.secretKeyRef.name);
|
|
948
|
+
}
|
|
949
|
+
for (const v of (spec.volumes || [])) {
|
|
950
|
+
if (v.secret && v.secret.secretName) secretNames.add(v.secret.secretName);
|
|
951
|
+
for (const s of ((v.projected && v.projected.sources) || [])) if (s.secret && s.secret.name) secretNames.add(s.secret.name);
|
|
952
|
+
}
|
|
953
|
+
const esNames = new Set();
|
|
954
|
+
for (const sn of secretNames) {
|
|
955
|
+
try {
|
|
956
|
+
const s = await k8sGet(cred, `/api/v1/namespaces/${ns}/secrets/${sn}`); // metadata only is used below
|
|
957
|
+
const md = s.metadata || {};
|
|
958
|
+
const owner = (md.ownerReferences || []).find((o) => o.kind === 'ExternalSecret');
|
|
959
|
+
const eso = owner || (md.labels && md.labels['reconcile.external-secrets.io/managed'] === 'true') ||
|
|
960
|
+
Object.keys(md.annotations || {}).some((a) => a.startsWith('reconcile.external-secrets.io/'));
|
|
961
|
+
if (eso) esNames.add(owner ? owner.name : sn);
|
|
962
|
+
} catch { /* can't read secret metadata — skip */ }
|
|
963
|
+
}
|
|
964
|
+
const externalSecrets = [];
|
|
965
|
+
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; };
|
|
966
|
+
for (const esn of esNames) {
|
|
967
|
+
const entry = { externalSecret: esn, vault: null, resolvable: false, reason: null };
|
|
968
|
+
try {
|
|
969
|
+
const es = await getCRD(`/apis/external-secrets.io/{v}/namespaces/${ns}/externalsecrets/${esn}`);
|
|
970
|
+
const ref = (es && es.spec && es.spec.secretStoreRef) || {};
|
|
971
|
+
const storePath = ref.kind === 'ClusterSecretStore'
|
|
972
|
+
? `/apis/external-secrets.io/{v}/clustersecretstores/${ref.name}`
|
|
973
|
+
: `/apis/external-secrets.io/{v}/namespaces/${ns}/secretstores/${ref.name}`;
|
|
974
|
+
const store = ref.name ? await getCRD(storePath) : null;
|
|
975
|
+
const akv = store && store.spec && store.spec.provider && store.spec.provider.azurekv;
|
|
976
|
+
const url = akv && (akv.vaultUrl || akv.vaultUri);
|
|
977
|
+
const m = url && /([a-z0-9][a-z0-9-]{1,22}[a-z0-9])\.vault\.azure\./i.exec(url);
|
|
978
|
+
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; }
|
|
979
|
+
else entry.reason = 'store has no azurekv vaultUrl';
|
|
980
|
+
} catch (e) { entry.reason = e.forbidden ? 'needs read on externalsecrets / secretstores' : (e.message || 'unreadable'); }
|
|
981
|
+
externalSecrets.push(entry);
|
|
982
|
+
}
|
|
983
|
+
const vaults = [...set].sort().map((name) => ({ name, sources: [...(srcMap[name] || [])] }));
|
|
984
|
+
return { vaults, configmapsScanned: scanned, spcs, externalSecrets };
|
|
985
|
+
})());
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
// Interactive shell INTO a pod, in the browser. The browser opens a WebSocket to us;
|
|
989
|
+
// we open an upstream WebSocket to the Kubernetes `exec` endpoint (CA + AAD bearer
|
|
990
|
+
// token) and bridge the channel-framed streams. No kubectl needed. The API server
|
|
991
|
+
// enforces Kubernetes RBAC (pods/exec) — this grants nothing the identity lacks.
|
|
992
|
+
const { WebSocketServer, WebSocket } = require('ws');
|
|
993
|
+
const execWss = new WebSocketServer({ noServer: true });
|
|
994
|
+
|
|
995
|
+
function attachExecWss(server) {
|
|
996
|
+
server.on('upgrade', (req, socket, head) => {
|
|
997
|
+
let url; try { url = new URL(req.url, 'http://localhost'); } catch { socket.destroy(); return; }
|
|
998
|
+
if (url.pathname !== '/ws/aks/exec') { socket.destroy(); return; }
|
|
999
|
+
execWss.handleUpgrade(req, socket, head, (client) => bridgeExec(client, url.searchParams));
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
async function bridgeExec(client, params) {
|
|
1004
|
+
const clusterName = params.get('cluster'), ns = params.get('ns'), pod = params.get('pod');
|
|
1005
|
+
const container = params.get('container') || null;
|
|
1006
|
+
const tell = (m) => { try { if (client.readyState === 1) client.send(m); } catch { /* ignore */ } };
|
|
1007
|
+
if (!isValidName(clusterName || '') || !isK8sName(ns || '') || !isK8sName(pod || '') || (container && !isK8sName(container))) {
|
|
1008
|
+
tell('\r\n\x1b[31mInvalid cluster/namespace/pod.\x1b[0m\r\n'); client.close(); return;
|
|
1009
|
+
}
|
|
1010
|
+
let cred, token;
|
|
1011
|
+
try {
|
|
1012
|
+
const c = clusterById(await aksList(), clusterName);
|
|
1013
|
+
if (!c) throw new Error('Cluster is not in your configured/visible list');
|
|
1014
|
+
cred = await aksCredential(c);
|
|
1015
|
+
token = await getAksToken();
|
|
1016
|
+
} catch (e) {
|
|
1017
|
+
tell('\r\n\x1b[31m' + (e.network ? 'Cannot reach the cluster — connect to the VPN.' : (e.message || 'auth failed')) + '\x1b[0m\r\n');
|
|
1018
|
+
client.close(); return;
|
|
1019
|
+
}
|
|
1020
|
+
const q = new URLSearchParams();
|
|
1021
|
+
q.set('stdin', 'true'); q.set('stdout', 'true'); q.set('stderr', 'true'); q.set('tty', 'true');
|
|
1022
|
+
if (container) q.set('container', container);
|
|
1023
|
+
// Prefer bash, fall back to ash/sh — one exec, the shell picks what exists.
|
|
1024
|
+
for (const part of ['/bin/sh', '-c', 'clear; (bash || ash || sh)']) q.append('command', part);
|
|
1025
|
+
const wsUrl = cred.server.replace(/^https:/, 'wss:') + `/api/v1/namespaces/${ns}/pods/${pod}/exec?` + q.toString();
|
|
1026
|
+
|
|
1027
|
+
const upstream = new WebSocket(wsUrl, ['v4.channel.k8s.io', 'channel.k8s.io'], {
|
|
1028
|
+
headers: { Authorization: `Bearer ${token}` }, ca: cred.ca,
|
|
1029
|
+
});
|
|
1030
|
+
let handled = false; // so the generic 'error' doesn't double-report a decoded HTTP status
|
|
1031
|
+
|
|
1032
|
+
// The exec handshake is plain HTTP until it upgrades; decode a non-101 status precisely.
|
|
1033
|
+
upstream.on('unexpected-response', (reqU, resU) => {
|
|
1034
|
+
handled = true;
|
|
1035
|
+
let body = ''; resU.on('data', (d) => { body += d; }); resU.on('end', () => {
|
|
1036
|
+
let msg; try { msg = JSON.parse(body).message; } catch { /* ignore */ }
|
|
1037
|
+
const s = resU.statusCode;
|
|
1038
|
+
const friendly = s === 401 ? 'Not authorized — token rejected. Run az login (and check the cluster is managed-AAD).'
|
|
1039
|
+
: s === 403 ? 'Forbidden — your identity lacks Kubernetes "pods/exec" permission in this namespace.'
|
|
1040
|
+
: s === 404 ? 'Pod, namespace or container not found.'
|
|
1041
|
+
: (msg || `Kubernetes returned ${s}`);
|
|
1042
|
+
tell('\r\n\x1b[31m' + friendly + '\x1b[0m\r\n'); try { client.close(); } catch { /* ignore */ }
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
upstream.on('open', () => tell(`\x1b[32m● connected to ${ns}/${pod}${container ? ' [' + container + ']' : ''}\x1b[0m\r\n`));
|
|
1047
|
+
upstream.on('message', (data) => {
|
|
1048
|
+
if (!data || !data.length) return;
|
|
1049
|
+
const ch = data[0], payload = data.subarray(1); // channel: 1=stdout 2=stderr 3=error/status
|
|
1050
|
+
if (ch === 1 || ch === 2) { if (client.readyState === 1) client.send(payload); }
|
|
1051
|
+
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 */ } }
|
|
1052
|
+
});
|
|
1053
|
+
upstream.on('close', () => { tell('\r\n\x1b[33m● session closed\x1b[0m\r\n'); try { client.close(); } catch { /* ignore */ } });
|
|
1054
|
+
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 */ } });
|
|
1055
|
+
|
|
1056
|
+
client.on('message', (msg, isBinary) => {
|
|
1057
|
+
if (upstream.readyState !== 1) return;
|
|
1058
|
+
if (isBinary) { upstream.send(Buffer.concat([Buffer.from([0]), msg])); return; } // channel 0 = stdin
|
|
1059
|
+
try { const j = JSON.parse(msg.toString()); // text = control (resize)
|
|
1060
|
+
if (j.type === 'resize') upstream.send(Buffer.concat([Buffer.from([4]), Buffer.from(JSON.stringify({ Width: j.cols, Height: j.rows }))]));
|
|
1061
|
+
} catch { /* ignore */ }
|
|
1062
|
+
});
|
|
1063
|
+
client.on('close', () => { try { upstream.close(); } catch { /* ignore */ } });
|
|
1064
|
+
}
|
|
1065
|
+
|
|
603
1066
|
// ---- fallback --------------------------------------------------------------
|
|
604
1067
|
|
|
605
1068
|
app.get('/api/health', (req, res) => res.json({ ok: true, az: AZ }));
|
|
@@ -613,7 +1076,7 @@ const ENV_RULES = (() => {
|
|
|
613
1076
|
const PKG_VERSION = (() => { try { return require('./package.json').version; } catch { return null; } })();
|
|
614
1077
|
app.get('/api/config', (req, res) =>
|
|
615
1078
|
res.json({ ok: true, data: { envRules: ENV_RULES, title: process.env.AZBO_TITLE || null,
|
|
616
|
-
version: PKG_VERSION, protectedTags: PROTECTED_TAGS } }));
|
|
1079
|
+
version: PKG_VERSION, protectedTags: PROTECTED_TAGS, aksConfigured: AKS_CLUSTERS.length } }));
|
|
617
1080
|
|
|
618
1081
|
function openBrowser(url) {
|
|
619
1082
|
if (process.env.AZBO_NO_OPEN) return;
|
|
@@ -630,6 +1093,7 @@ function start(port, attemptsLeft = 12) {
|
|
|
630
1093
|
console.log(` Stop with Ctrl-C.\n`);
|
|
631
1094
|
openBrowser(url);
|
|
632
1095
|
});
|
|
1096
|
+
attachExecWss(server); // browser ⇆ Kubernetes exec bridge for the in-pod terminal
|
|
633
1097
|
server.on('error', (e) => {
|
|
634
1098
|
if (e.code === 'EADDRINUSE' && attemptsLeft > 0) {
|
|
635
1099
|
console.log(` Port ${port} in use, trying ${port + 1}…`);
|