initial
Build & Push / build (push) Failing after 45s

This commit is contained in:
dev
2026-06-07 10:52:17 +02:00
commit 566c122044
10 changed files with 2019 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
name: Build & Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Login to Gitea Registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login gitea.it-microevo.com -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Build & Push
run: |
docker build -t gitea.it-microevo.com/Dev/k8s-pilot:latest .
docker push gitea.it-microevo.com/Dev/k8s-pilot:latest
- name: Rolling Restart
run: |
kubectl rollout restart deployment/k8s-pilot -n k8s-pilot
+15
View File
@@ -0,0 +1,15 @@
# Multi-arch (ARM64 / AMD64) — works on Pi5 + Pi4
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src/ ./src/
EXPOSE 3000
USER node
CMD ["node", "src/server.js"]
+64
View File
@@ -0,0 +1,64 @@
# K8s Pilot
All-in-one Kubernetes management tool for your homelab cluster.
**Features:** Live logs · Auto-update · Crash history · Health monitoring · Manual restarts · Node status · WebSocket live updates
---
## Deploy
### 1. Build & push to Gitea Container Registry
```bash
# On your Mac (or directly on Pi5 via SSH)
docker buildx build \
--platform linux/arm64 \
-t git.it-microevo.com/<your-user>/k8s-pilot:latest \
--push .
```
### 2. Update the image in `k8s/deployment.yaml`
```yaml
image: git.it-microevo.com/<your-user>/k8s-pilot:latest
```
### 3. Apply manifests
```bash
kubectl apply -f k8s/rbac.yaml
kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/service.yaml
```
### 4. Add to Nginx Proxy Manager
Point `pilot.it-microevo.com``k8s-pilot.k8s-pilot.svc.cluster.local:80`
Then add the Cloudflare Tunnel rule as usual.
---
## Local dev (on Mac with kubeconfig)
```bash
npm install
npm run dev
# open http://localhost:3000
```
---
## Auto-Update notes
**Force Update** button triggers a rolling restart and pulls a new image if `imagePullPolicy: Always` is set (default in the deployment manifest).
For Docker Hub images, **Check Update** queries the registry API for the latest digest.
For GHCR / Gitea registry images, use **Force Update** directly.
---
## RBAC scope
The service account has cluster-wide read access to pods, events, nodes, and namespaces, plus patch/update on deployments, and delete on pods. It cannot access secrets or config maps.
+80
View File
@@ -0,0 +1,80 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: k8s-pilot
namespace: k8s-pilot
labels:
app: k8s-pilot
spec:
replicas: 2
selector:
matchLabels:
app: k8s-pilot
# Recreate one pod at a time — always keep 1 running
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 0
template:
metadata:
labels:
app: k8s-pilot
spec:
serviceAccountName: k8s-pilot
# Spread across nodes — one pod on Pi5, one on Pi4
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: k8s-pilot
# Hard anti-affinity: never two pods on the same node
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: k8s-pilot
topologyKey: kubernetes.io/hostname
# Must tolerate control-plane taint so Pi5 is eligible
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: k8s-pilot
# Replace with your Gitea registry path:
# image: git.it-microevo.com/<user>/k8s-pilot:latest
image: k8s-pilot:latest
imagePullPolicy: Always
ports:
- containerPort: 3000
env:
- name: PORT
value: "3000"
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
livenessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 10
periodSeconds: 30
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
+10
View File
@@ -0,0 +1,10 @@
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: k8s-pilot
namespace: k8s-pilot
spec:
minAvailable: 1
selector:
matchLabels:
app: k8s-pilot
+45
View File
@@ -0,0 +1,45 @@
apiVersion: v1
kind: Namespace
metadata:
name: k8s-pilot
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: k8s-pilot
namespace: k8s-pilot
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: k8s-pilot
rules:
- apiGroups: [""]
resources: ["namespaces", "nodes"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods", "pods/log"]
verbs: ["get", "list", "watch", "delete"]
- apiGroups: [""]
resources: ["events"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments", "replicasets"]
verbs: ["get", "list", "watch", "patch", "update"]
# Trivy Operator CRDs (optional — only needed if Trivy Operator is installed)
- apiGroups: ["aquasecurity.github.io"]
resources: ["vulnerabilityreports", "configauditreports", "exposedsecretreports"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: k8s-pilot
subjects:
- kind: ServiceAccount
name: k8s-pilot
namespace: k8s-pilot
roleRef:
kind: ClusterRole
name: k8s-pilot
apiGroup: rbac.authorization.k8s.io
+18
View File
@@ -0,0 +1,18 @@
apiVersion: v1
kind: Service
metadata:
name: k8s-pilot
namespace: k8s-pilot
spec:
selector:
app: k8s-pilot
ports:
- port: 80
targetPort: 3000
type: ClusterIP
# Sticky sessions — keeps SSE log streams and WebSocket
# connections on the same pod after initial connection
sessionAffinity: ClientIP
sessionAffinityConfig:
clientIP:
timeoutSeconds: 3600
+16
View File
@@ -0,0 +1,16 @@
{
"name": "k8s-pilot",
"version": "1.0.0",
"description": "All-in-one K8s management: logs, restarts, updates, monitoring, crash handling",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js"
},
"dependencies": {
"@kubernetes/client-node": "^0.21.0",
"express": "^4.19.2",
"ws": "^8.18.0",
"node-fetch": "^2.7.0"
}
}
File diff suppressed because it is too large Load Diff
+456
View File
@@ -0,0 +1,456 @@
const express = require('express');
const k8s = require('@kubernetes/client-node');
const { WebSocketServer } = require('ws');
const http = require('http');
const path = require('path');
const { PassThrough } = require('stream');
const fetch = require('node-fetch');
const app = express();
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// ── K8s Client Setup ─────────────────────────────────────────────────────────
const kc = new k8s.KubeConfig();
try {
kc.loadFromCluster();
console.log('[k8s] Loaded in-cluster config');
} catch {
kc.loadFromDefault();
console.log('[k8s] Loaded default (local) config');
}
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
const appsApi = kc.makeApiClient(k8s.AppsV1Api);
const customApi = kc.makeApiClient(k8s.CustomObjectsApi);
const logger = new k8s.Log(kc);
// ── Helpers ───────────────────────────────────────────────────────────────────
const PATCH_HEADERS = { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } };
function podRestarts(pod) {
return (pod.status?.containerStatuses || [])
.reduce((s, c) => s + (c.restartCount || 0), 0);
}
function podPhase(pod) {
// Check container states for more granular status
const cs = pod.status?.containerStatuses || [];
if (cs.some(c => c.state?.waiting?.reason === 'CrashLoopBackOff')) return 'CrashLoop';
if (cs.some(c => c.state?.waiting?.reason === 'OOMKilled')) return 'OOMKilled';
if (cs.some(c => c.state?.waiting?.reason === 'Error')) return 'Error';
return pod.status?.phase || 'Unknown';
}
// ── Routes ────────────────────────────────────────────────────────────────────
// Overview: all deployments with aggregated pod info
app.get('/api/overview', async (req, res) => {
try {
const [depsRes, podsRes, nodesRes] = await Promise.all([
appsApi.listDeploymentForAllNamespaces(),
coreApi.listPodForAllNamespaces(),
coreApi.listNode(),
]);
const pods = podsRes.body.items;
const nodes = nodesRes.body.items.map(n => ({
name: n.metadata.name,
ready: (n.status?.conditions || []).find(c => c.type === 'Ready')?.status === 'True',
roles: Object.keys(n.metadata?.labels || {})
.filter(k => k.startsWith('node-role.kubernetes.io/'))
.map(k => k.replace('node-role.kubernetes.io/', '')),
cpu: n.status?.capacity?.cpu,
memory: n.status?.capacity?.memory,
}));
const deployments = depsRes.body.items.map(d => {
const ns = d.metadata.namespace;
const name = d.metadata.name;
const sel = d.spec.selector.matchLabels || {};
const dPods = pods.filter(p => {
if (p.metadata.namespace !== ns) return false;
const labels = p.metadata.labels || {};
return Object.entries(sel).every(([k, v]) => labels[k] === v);
});
return {
name,
namespace: ns,
replicas: d.spec.replicas || 0,
readyReplicas: d.status?.readyReplicas || 0,
availableReplicas: d.status?.availableReplicas || 0,
updatedReplicas: d.status?.updatedReplicas || 0,
containers: (d.spec.template.spec.containers || []).map(c => ({
name: c.name,
image: c.image,
imagePullPolicy: c.imagePullPolicy || 'IfNotPresent',
})),
totalRestarts: dPods.reduce((s, p) => s + podRestarts(p), 0),
podCount: dPods.length,
conditions: (d.status?.conditions || []).map(c => ({
type: c.type, status: c.status, message: c.message,
})),
createdAt: d.metadata.creationTimestamp,
};
});
res.json({ deployments, nodes });
} catch (err) {
console.error('[overview]', err.message);
res.status(500).json({ error: err.message });
}
});
// Pods list
app.get('/api/pods', async (req, res) => {
try {
const { namespace } = req.query;
const result = namespace && namespace !== 'all'
? await coreApi.listNamespacedPod(namespace)
: await coreApi.listPodForAllNamespaces();
const pods = result.body.items.map(p => ({
name: p.metadata.name,
namespace: p.metadata.namespace,
phase: podPhase(p),
nodeName: p.spec.nodeName,
startTime: p.status?.startTime,
ip: p.status?.podIP,
containers: (p.status?.containerStatuses || []).map(c => ({
name: c.name,
ready: c.ready,
restartCount: c.restartCount,
image: c.image,
state: Object.keys(c.state || {})[0] || 'unknown',
stateReason: c.state?.waiting?.reason || c.state?.terminated?.reason || null,
})),
}));
res.json(pods);
} catch (err) {
console.error('[pods]', err.message);
res.status(500).json({ error: err.message });
}
});
// Namespaces
app.get('/api/namespaces', async (req, res) => {
try {
const result = await coreApi.listNamespace();
res.json(result.body.items.map(n => n.metadata.name).sort());
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Events / crash history
app.get('/api/events', async (req, res) => {
try {
const { namespace } = req.query;
const result = namespace && namespace !== 'all'
? await coreApi.listNamespacedEvent(namespace)
: await coreApi.listEventForAllNamespaces();
const CRASH_REASONS = new Set([
'BackOff', 'OOMKilling', 'Failed', 'Killing', 'Unhealthy',
'CrashLoopBackOff', 'NodeNotReady', 'FailedMount', 'Evicted',
'FailedScheduling', 'FailedCreate', 'ImagePullBackOff',
]);
const events = result.body.items
.filter(e => e.type === 'Warning' || CRASH_REASONS.has(e.reason))
.sort((a, b) => {
const ta = new Date(a.lastTimestamp || a.eventTime || 0);
const tb = new Date(b.lastTimestamp || b.eventTime || 0);
return tb - ta;
})
.slice(0, 200)
.map(e => ({
name: e.metadata.name,
namespace: e.metadata.namespace,
reason: e.reason,
message: e.message,
type: e.type,
count: e.count || 1,
firstTime: e.firstTimestamp,
lastTime: e.lastTimestamp || e.eventTime,
object: {
kind: e.involvedObject.kind,
name: e.involvedObject.name,
namespace: e.involvedObject.namespace,
},
}));
res.json(events);
} catch (err) {
console.error('[events]', err.message);
res.status(500).json({ error: err.message });
}
});
// Rolling restart a deployment
app.post('/api/deployments/:namespace/:name/restart', async (req, res) => {
const { namespace, name } = req.params;
try {
await appsApi.patchNamespacedDeployment(name, namespace, {
spec: {
template: {
metadata: {
annotations: { 'kubectl.kubernetes.io/restartedAt': new Date().toISOString() },
},
},
},
}, undefined, undefined, undefined, undefined, undefined, PATCH_HEADERS);
console.log(`[restart] Deployment ${namespace}/${name}`);
res.json({ success: true, message: `Rolling restart triggered for ${name}` });
} catch (err) {
console.error('[restart]', err.message);
res.status(500).json({ error: err.message });
}
});
// Delete pod (triggers restart by controller)
app.delete('/api/pods/:namespace/:name', async (req, res) => {
const { namespace, name } = req.params;
try {
await coreApi.deleteNamespacedPod(name, namespace);
console.log(`[pod-restart] Deleted pod ${namespace}/${name}`);
res.json({ success: true, message: `Pod ${name} deleted — controller will restart it` });
} catch (err) {
console.error('[pod-restart]', err.message);
res.status(500).json({ error: err.message });
}
});
// Force image update (rolling restart — requires imagePullPolicy: Always)
app.post('/api/deployments/:namespace/:name/update', async (req, res) => {
const { namespace, name } = req.params;
try {
await appsApi.patchNamespacedDeployment(name, namespace, {
spec: {
template: {
metadata: {
annotations: {
'kubectl.kubernetes.io/restartedAt': new Date().toISOString(),
'k8s-pilot/forced-update': new Date().toISOString(),
},
},
},
},
}, undefined, undefined, undefined, undefined, undefined, PATCH_HEADERS);
console.log(`[update] Triggered image update for ${namespace}/${name}`);
res.json({ success: true, message: `Image update triggered for ${name}` });
} catch (err) {
console.error('[update]', err.message);
res.status(500).json({ error: err.message });
}
});
// Check Docker Hub for newer image digest
app.post('/api/deployments/:namespace/:name/check-update', async (req, res) => {
const { namespace, name } = req.params;
try {
const depResult = await appsApi.readNamespacedDeployment(name, namespace);
const containers = depResult.body.spec.template.spec.containers;
const results = await Promise.all(containers.map(async c => {
const image = c.image;
const colonIdx = image.lastIndexOf(':');
const imageName = colonIdx > 0 ? image.substring(0, colonIdx) : image;
const tag = colonIdx > 0 ? image.substring(colonIdx + 1) : 'latest';
// Only Docker Hub images (no external registry prefix)
const parts = imageName.split('/');
const isDockerHub = parts.length <= 2 && !imageName.includes('.');
if (!isDockerHub) {
return { container: c.name, image, updateCheckSupported: false,
reason: 'Only Docker Hub supported for auto-check (GHCR/custom registries: use Force Update)' };
}
const fullName = parts.length === 1 ? `library/${imageName}` : imageName;
try {
const tokenRes = await fetch(
`https://auth.docker.io/token?service=registry.docker.io&scope=repository:${fullName}:pull`
);
const { token } = await tokenRes.json();
const manifestRes = await fetch(
`https://registry-1.docker.io/v2/${fullName}/manifests/${tag}`,
{ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.docker.distribution.manifest.v2+json' } }
);
const remoteDigest = manifestRes.headers.get('docker-content-digest');
return { container: c.name, image, tag, remoteDigest, updateCheckSupported: true };
} catch (fetchErr) {
return { container: c.name, image, updateCheckSupported: false, reason: fetchErr.message };
}
}));
res.json(results);
} catch (err) {
console.error('[check-update]', err.message);
res.status(500).json({ error: err.message });
}
});
// Trivy VulnerabilityReports (requires Trivy Operator installed)
app.get('/api/vulnerabilities', async (req, res) => {
const { namespace } = req.query;
const GROUP = 'aquasecurity.github.io';
const VERSION = 'v1alpha1';
const PLURAL = 'vulnerabilityreports';
try {
const result = namespace && namespace !== 'all'
? await customApi.listNamespacedCustomObject(GROUP, VERSION, namespace, PLURAL)
: await customApi.listClusterCustomObject(GROUP, VERSION, PLURAL);
const reports = result.body.items.map(r => {
const labels = r.metadata?.labels || {};
const report = r.report || {};
const summary = report.summary || {};
return {
name: r.metadata.name,
namespace: r.metadata.namespace,
container: labels['trivy-operator.container-name'],
resource: labels['trivy-operator.resource.name'],
image: report.artifact
? [report.artifact.repository, report.artifact.tag].filter(Boolean).join(':')
: null,
scannedAt: r.metadata.creationTimestamp,
summary: {
critical: summary.criticalCount || 0,
high: summary.highCount || 0,
medium: summary.mediumCount || 0,
low: summary.lowCount || 0,
none: summary.noneCount || 0,
unknown: summary.unknownCount || 0,
},
vulnerabilities: (report.vulnerabilities || [])
.sort((a, b) => {
const order = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3, NONE: 4, UNKNOWN: 5 };
return (order[a.severity] ?? 9) - (order[b.severity] ?? 9);
})
.map(v => ({
id: v.vulnerabilityID,
severity: v.severity,
resource: v.resource,
installedVersion: v.installedVersion,
fixedVersion: v.fixedVersion || '',
title: v.title,
primaryLink: v.primaryLink,
})),
};
});
res.json({ installed: true, reports });
} catch (err) {
// CRD not found = Trivy Operator not installed
const notInstalled =
err.statusCode === 404 ||
err.body?.reason === 'NotFound' ||
(err.message || '').toLowerCase().includes('not found');
if (notInstalled) return res.json({ installed: false, reports: [] });
console.error('[vulnerabilities]', err.message);
res.status(500).json({ error: err.message });
}
});
// ── SSE: Live log stream ──────────────────────────────────────────────────────
app.get('/api/logs/:namespace/:pod/:container', async (req, res) => {
const { namespace, pod, container } = req.params;
const tail = parseInt(req.query.tail || '200', 10);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // disable Nginx buffering
res.flushHeaders();
const logStream = new PassThrough();
let request = null;
logStream.on('data', chunk => {
const lines = chunk.toString().split('\n');
for (const line of lines) {
if (line.trim()) {
res.write(`data: ${JSON.stringify({ line })}\n\n`);
}
}
});
logStream.on('error', err => {
res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
res.end();
});
try {
request = await logger.log(namespace, pod, container, logStream, {
follow: true,
tailLines: tail,
timestamps: true,
pretty: false,
});
} catch (err) {
res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
res.end();
return;
}
req.on('close', () => {
try { request?.abort?.(); } catch {}
logStream.destroy();
console.log(`[logs] Stream closed: ${namespace}/${pod}/${container}`);
});
});
// ── HTTP + WebSocket Server ───────────────────────────────────────────────────
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' });
// Broadcast live cluster status every 8s
async function broadcastStatus() {
if (wss.clients.size === 0) return;
try {
const [depsRes, podsRes] = await Promise.all([
appsApi.listDeploymentForAllNamespaces(),
coreApi.listPodForAllNamespaces(),
]);
const pods = podsRes.body.items;
const unhealthy = pods.filter(p => !['Running', 'Succeeded'].includes(p.status?.phase));
const crashLoops = pods.filter(p => (p.status?.containerStatuses || [])
.some(c => c.state?.waiting?.reason === 'CrashLoopBackOff'));
const totalRestarts = pods.reduce((s, p) => s + podRestarts(p), 0);
const payload = JSON.stringify({
type: 'status',
deployments: depsRes.body.items.length,
pods: pods.length,
unhealthy: unhealthy.length,
crashLoops: crashLoops.length,
totalRestarts,
ts: Date.now(),
});
wss.clients.forEach(ws => {
if (ws.readyState === 1) ws.send(payload);
});
} catch {}
}
setInterval(broadcastStatus, 8000);
wss.on('connection', ws => {
console.log('[ws] Client connected');
broadcastStatus(); // immediate update on connect
ws.on('close', () => console.log('[ws] Client disconnected'));
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => console.log(`🚀 K8s Pilot running on :${PORT}`));