fix: add request_upgrades.py to image, add web dashboard
- Dockerfile: add COPY for request_upgrades.py (was missing — root cause of Monday upgrade CronJob failing with 'No such file'), add git (needed for repo cloning in upgrade workflow), copy web.py + templates/ - requirements.txt: add fastapi, uvicorn, jinja2 for web dashboard - web.py: FastAPI dashboard with image inventory and upgrade trigger pages - templates/: base layout, images page (namespace→app→container grouped with live version status), upgrade page (outdated list + trigger button + job history) - kubernetes/web-deployment.yaml: Deployment + LoadBalancer at 192.168.87.13 - kubernetes/rbac.yaml: add version-tracker-web ServiceAccount, ClusterRole (read pods/deployments/replicasets/statefulsets/daemonsets/jobs/cronjobs), and namespace Role to create upgrade Jobs - kubernetes/upgrade-cronjob.yaml: fix YAML indentation bug on GITEA_USERNAME and GITEA_COMMITTER_NAME env vars
This commit is contained in:
@@ -2,6 +2,7 @@ FROM python:3.12-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
skopeo \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
@@ -9,5 +10,8 @@ COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY check_versions.py .
|
||||
COPY request_upgrades.py .
|
||||
COPY web.py .
|
||||
COPY templates/ templates/
|
||||
|
||||
CMD ["python", "check_versions.py"]
|
||||
|
||||
+91
-1
@@ -1,4 +1,5 @@
|
||||
---
|
||||
# ── version-tracker: read-only pod/namespace lister (weekly report) ──────────
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
@@ -6,7 +7,6 @@ metadata:
|
||||
namespace: k8s-version-tracker
|
||||
|
||||
---
|
||||
# Cluster-wide read access to list pods in all namespaces
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
@@ -29,3 +29,93 @@ subjects:
|
||||
- kind: ServiceAccount
|
||||
name: version-tracker
|
||||
namespace: k8s-version-tracker
|
||||
|
||||
---
|
||||
# ── upgrade-requester: read-only pod lister (upgrades go through GitOps/ArgoCD) ─
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: upgrade-requester
|
||||
namespace: k8s-version-tracker
|
||||
|
||||
---
|
||||
# Reuse the same read-only ClusterRole — no direct workload patching needed.
|
||||
# Image updates are committed to argocd-gitops and ArgoCD syncs automatically.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: upgrade-requester
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: version-tracker-reader
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: upgrade-requester
|
||||
namespace: k8s-version-tracker
|
||||
|
||||
---
|
||||
# ── version-tracker-web: dashboard service account ───────────────────────────
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: version-tracker-web
|
||||
namespace: k8s-version-tracker
|
||||
|
||||
---
|
||||
# Cluster-wide read access: pods, namespaces, and workload owners (to map pods → apps)
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: version-tracker-web-reader
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods", "namespaces"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["apps"]
|
||||
resources: ["deployments", "replicasets", "statefulsets", "daemonsets"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["jobs", "cronjobs"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: version-tracker-web-reader
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: version-tracker-web-reader
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: version-tracker-web
|
||||
namespace: k8s-version-tracker
|
||||
|
||||
---
|
||||
# Namespace-scoped: create upgrade Jobs in this namespace only
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: upgrade-trigger
|
||||
namespace: k8s-version-tracker
|
||||
rules:
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["jobs"]
|
||||
verbs: ["create", "get", "list", "watch"]
|
||||
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: upgrade-trigger
|
||||
namespace: k8s-version-tracker
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: upgrade-trigger
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: version-tracker-web
|
||||
namespace: k8s-version-tracker
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: version-tracker-web
|
||||
namespace: k8s-version-tracker
|
||||
labels:
|
||||
app: version-tracker-web
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: version-tracker-web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: version-tracker-web
|
||||
spec:
|
||||
serviceAccountName: version-tracker-web
|
||||
containers:
|
||||
- name: web
|
||||
image: registry.storedbox.net/k8s-version-tracker:latest
|
||||
imagePullPolicy: Always
|
||||
command: ["python", "web.py"]
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
env:
|
||||
- name: MATTERMOST_WEBHOOK_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: version-tracker-secrets
|
||||
key: MATTERMOST_WEBHOOK_URL
|
||||
- name: APPROVAL_MIDDLEWARE_URL
|
||||
value: "http://192.168.87.14:8085"
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/images
|
||||
port: 8080
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: version-tracker-web
|
||||
namespace: k8s-version-tracker
|
||||
labels:
|
||||
app: version-tracker-web
|
||||
annotations:
|
||||
metallb.universe.tf/loadBalancerIPs: 192.168.87.13
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
selector:
|
||||
app: version-tracker-web
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 8080
|
||||
@@ -1,3 +1,6 @@
|
||||
kubernetes==29.0.0
|
||||
requests==2.32.3
|
||||
packaging==24.2
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
jinja2==3.1.4
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}K8s Image Dashboard{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
body { background-color: #0a0f1e; }
|
||||
.status-outdated { @apply bg-red-950 text-red-300 ring-1 ring-red-700; }
|
||||
.status-current { @apply bg-green-950 text-green-300 ring-1 ring-green-800; }
|
||||
.status-floating { @apply bg-yellow-950 text-yellow-300 ring-1 ring-yellow-800; }
|
||||
.status-local { @apply bg-gray-800 text-gray-400 ring-1 ring-gray-700; }
|
||||
.status-skipped { @apply bg-gray-800 text-gray-500 ring-1 ring-gray-700; }
|
||||
.status-pending { @apply bg-blue-950 text-blue-400 ring-1 ring-blue-800; }
|
||||
.status-unknown { @apply bg-gray-800 text-gray-500 ring-1 ring-gray-700; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-full text-gray-100 min-h-screen">
|
||||
|
||||
<nav class="bg-gray-900 border-b border-gray-800 sticky top-0 z-50">
|
||||
<div class="max-w-screen-2xl mx-auto px-6 py-3 flex items-center gap-8">
|
||||
<a href="/" class="flex items-center gap-2 text-blue-400 font-bold text-lg tracking-wide hover:text-blue-300">
|
||||
<span class="text-xl">⎈</span>
|
||||
<span>K8s Image Dashboard</span>
|
||||
</a>
|
||||
<div class="flex gap-1">
|
||||
<a href="/"
|
||||
class="px-3 py-1.5 rounded text-sm transition-colors
|
||||
{% if request.url.path == '/' %}bg-gray-700 text-white font-medium{% else %}text-gray-400 hover:text-white hover:bg-gray-800{% endif %}">
|
||||
Images
|
||||
</a>
|
||||
<a href="/upgrade"
|
||||
class="px-3 py-1.5 rounded text-sm transition-colors
|
||||
{% if request.url.path == '/upgrade' %}bg-gray-700 text-white font-medium{% else %}text-gray-400 hover:text-white hover:bg-gray-800{% endif %}">
|
||||
Upgrade
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-screen-2xl mx-auto px-6 py-6">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,213 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Image Inventory — K8s Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="dashboard()" x-init="init()">
|
||||
|
||||
<!-- Header row -->
|
||||
<div class="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Image Inventory</h1>
|
||||
<p class="text-sm text-gray-500 mt-0.5">
|
||||
All container images deployed to the cluster, grouped by namespace and application.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{% if is_checking %}
|
||||
<span class="flex items-center gap-2 text-blue-400 text-sm bg-blue-950 px-3 py-1.5 rounded-full ring-1 ring-blue-800">
|
||||
<svg class="animate-spin w-3.5 h-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
|
||||
</svg>
|
||||
Checking versions…
|
||||
</span>
|
||||
{% elif last_updated %}
|
||||
<span class="text-xs text-gray-500">Updated {{ last_updated }}</span>
|
||||
{% endif %}
|
||||
<button @click="refresh()"
|
||||
:disabled="refreshing"
|
||||
class="px-3 py-1.5 text-sm bg-gray-800 hover:bg-gray-700 text-gray-200 rounded border border-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-text="refreshing ? 'Refreshing…' : '↻ Refresh'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 mb-8">
|
||||
<div class="bg-gray-900 rounded-lg border border-gray-800 px-4 py-3">
|
||||
<div class="text-2xl font-bold text-white">{{ stats.total }}</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">Total Images</div>
|
||||
</div>
|
||||
<div class="bg-red-950 rounded-lg border border-red-800 px-4 py-3">
|
||||
<div class="text-2xl font-bold text-red-300">{{ stats.outdated }}</div>
|
||||
<div class="text-xs text-red-500 mt-0.5">Outdated</div>
|
||||
</div>
|
||||
<div class="bg-green-950 rounded-lg border border-green-800 px-4 py-3">
|
||||
<div class="text-2xl font-bold text-green-300">{{ stats.current }}</div>
|
||||
<div class="text-xs text-green-600 mt-0.5">Up to Date</div>
|
||||
</div>
|
||||
<div class="bg-yellow-950 rounded-lg border border-yellow-800 px-4 py-3">
|
||||
<div class="text-2xl font-bold text-yellow-300">{{ stats.floating }}</div>
|
||||
<div class="text-xs text-yellow-600 mt-0.5">Floating Tags</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 rounded-lg border border-gray-700 px-4 py-3">
|
||||
<div class="text-2xl font-bold text-gray-400">{{ stats.local }}</div>
|
||||
<div class="text-xs text-gray-600 mt-0.5">Local / Skipped</div>
|
||||
</div>
|
||||
<div class="bg-blue-950 rounded-lg border border-blue-800 px-4 py-3">
|
||||
<div class="text-2xl font-bold text-blue-400">{{ stats.pending }}</div>
|
||||
<div class="text-xs text-blue-700 mt-0.5">Checking…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Namespace sections -->
|
||||
{% if not namespaces %}
|
||||
<div class="text-center py-20 text-gray-500">No cluster data available. Click Refresh to load.</div>
|
||||
{% endif %}
|
||||
|
||||
{% for ns, apps in namespaces.items() %}
|
||||
<div class="mb-6" x-data="{ open: nsOpen('{{ ns }}') }">
|
||||
|
||||
<!-- Namespace header -->
|
||||
<button @click="open = !open; saveNs('{{ ns }}', open)"
|
||||
class="w-full flex items-center justify-between px-4 py-3 bg-gray-900 border border-gray-800 rounded-lg hover:bg-gray-850 transition-colors group">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-blue-400 font-mono text-sm font-medium">{{ ns }}</span>
|
||||
<span class="text-xs text-gray-600 bg-gray-800 px-2 py-0.5 rounded-full">
|
||||
{{ apps | length }} app{% if apps | length != 1 %}s{% endif %}
|
||||
</span>
|
||||
{% set ns_outdated = namespace_loop.loop(apps) %}
|
||||
{% set ns_containers = apps | map(attribute='containers') | sum(start=[]) %}
|
||||
{% set ns_outdated_count = ns_containers | selectattr('status', 'eq', 'outdated') | list | length %}
|
||||
{% if ns_outdated_count > 0 %}
|
||||
<span class="text-xs bg-red-950 text-red-400 ring-1 ring-red-800 px-2 py-0.5 rounded-full">
|
||||
{{ ns_outdated_count }} outdated
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-gray-600 transition-transform group-hover:text-gray-400"
|
||||
:class="open ? 'rotate-180' : ''"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Apps grid -->
|
||||
<div x-show="open" x-cloak class="mt-2 pl-2 border-l-2 border-gray-800 ml-4 space-y-2">
|
||||
{% for app_data in apps %}
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden"
|
||||
x-data="{ appOpen: true }">
|
||||
|
||||
<!-- App header -->
|
||||
<button @click="appOpen = !appOpen"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-gray-800 transition-colors text-left">
|
||||
<!-- Kind badge -->
|
||||
<span class="text-xs font-mono px-1.5 py-0.5 rounded
|
||||
{% if app_data.kind == 'Deployment' %}bg-blue-900 text-blue-300{% elif app_data.kind == 'StatefulSet' %}bg-purple-900 text-purple-300{% elif app_data.kind == 'DaemonSet' %}bg-orange-900 text-orange-300{% elif app_data.kind == 'CronJob' %}bg-teal-900 text-teal-300{% else %}bg-gray-700 text-gray-400{% endif %}">
|
||||
{{ app_data.kind }}
|
||||
</span>
|
||||
<span class="text-sm font-medium text-gray-200">{{ app_data.app }}</span>
|
||||
<span class="text-xs text-gray-600">{{ app_data.pod_count }} pod{% if app_data.pod_count != 1 %}s{% endif %}</span>
|
||||
<!-- Status summary dots -->
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
{% for c in app_data.containers %}
|
||||
<span class="w-2 h-2 rounded-full
|
||||
{% if c.status == 'outdated' %}bg-red-500{% elif c.status == 'current' %}bg-green-500{% elif c.status == 'floating' %}bg-yellow-500{% elif c.status in ('local','skipped') %}bg-gray-600{% else %}bg-blue-600 animate-pulse{% endif %}"
|
||||
title="{{ c.name }}: {{ c.status }}">
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<svg class="w-3.5 h-3.5 text-gray-600 flex-shrink-0 transition-transform"
|
||||
:class="appOpen ? 'rotate-180' : ''"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Container list -->
|
||||
<div x-show="appOpen" x-cloak>
|
||||
<table class="w-full text-xs border-t border-gray-800">
|
||||
<thead>
|
||||
<tr class="bg-gray-950 text-gray-600">
|
||||
<th class="text-left px-4 py-2 font-medium w-32">Container</th>
|
||||
<th class="text-left px-4 py-2 font-medium">Image</th>
|
||||
<th class="text-left px-4 py-2 font-medium w-28">Current Tag</th>
|
||||
<th class="text-left px-4 py-2 font-medium w-28">Latest Tag</th>
|
||||
<th class="text-left px-4 py-2 font-medium w-28">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-800">
|
||||
{% for c in app_data.containers %}
|
||||
<tr class="hover:bg-gray-800/50 transition-colors">
|
||||
<td class="px-4 py-2 text-gray-400 font-mono">{{ c.name }}</td>
|
||||
<td class="px-4 py-2">
|
||||
<span class="text-gray-300 font-mono break-all">
|
||||
{% if c.registry != 'docker.io' and c.registry not in ('registry.storedbox.net', '192.168.87.31:5000', '192.168.87.31') %}
|
||||
<span class="text-gray-500">{{ c.registry }}/</span>
|
||||
{% endif %}
|
||||
{% if c.is_local %}
|
||||
<span class="text-gray-500">{{ c.registry }}/</span>
|
||||
{% endif %}
|
||||
{{ c.image_short.rsplit(':', 1)[0] if ':' in c.image_short else c.image_short }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 font-mono text-gray-400">{{ c.tag }}</td>
|
||||
<td class="px-4 py-2 font-mono
|
||||
{% if c.status == 'outdated' %}text-red-400 font-semibold{% else %}text-gray-500{% endif %}">
|
||||
{{ c.latest_tag or '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-medium status-{{ c.status }}">
|
||||
{% if c.status == 'outdated' %}⬆ Outdated
|
||||
{% elif c.status == 'current' %}✓ Current
|
||||
{% elif c.status == 'floating' %}~ Floating
|
||||
{% elif c.status == 'local' %}⌂ Local
|
||||
{% elif c.status == 'skipped' %}– Skipped
|
||||
{% elif c.status == 'pending' %}… Checking
|
||||
{% else %}? {{ c.status }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function dashboard() {
|
||||
return {
|
||||
refreshing: false,
|
||||
nsOpen(ns) {
|
||||
const saved = localStorage.getItem('ns-' + ns);
|
||||
return saved === null ? true : saved === 'true';
|
||||
},
|
||||
saveNs(ns, val) {
|
||||
localStorage.setItem('ns-' + ns, val);
|
||||
},
|
||||
init() {
|
||||
// Auto-reload if checks are still running
|
||||
{% if is_checking %}
|
||||
setTimeout(() => window.location.reload(), 15000);
|
||||
{% endif %}
|
||||
},
|
||||
async refresh() {
|
||||
this.refreshing = true;
|
||||
try {
|
||||
await fetch('/api/refresh', { method: 'POST' });
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
} catch (e) {
|
||||
this.refreshing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,249 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Upgrade — K8s Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="upgradePage()">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Image Upgrades</h1>
|
||||
<p class="text-sm text-gray-500 mt-0.5">
|
||||
Review outdated images and trigger the GitOps upgrade workflow.
|
||||
</p>
|
||||
</div>
|
||||
{% if is_checking %}
|
||||
<span class="flex items-center gap-2 text-blue-400 text-sm bg-blue-950 px-3 py-1.5 rounded-full ring-1 ring-blue-800">
|
||||
<svg class="animate-spin w-3.5 h-3.5" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
|
||||
</svg>
|
||||
Checking versions…
|
||||
</span>
|
||||
{% elif not last_updated %}
|
||||
<a href="/"
|
||||
class="text-sm text-yellow-400 bg-yellow-950 px-3 py-1.5 rounded ring-1 ring-yellow-800 hover:bg-yellow-900 transition-colors">
|
||||
⚠ No version data — go to Images to start a check
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-xs text-gray-500">Data from {{ last_updated }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Two-column layout -->
|
||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Left: outdated images (2/3 width) -->
|
||||
<div class="xl:col-span-2 space-y-4">
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-gray-200">
|
||||
Outdated Images
|
||||
{% if outdated_apps %}
|
||||
<span class="ml-2 text-xs font-normal bg-red-950 text-red-400 ring-1 ring-red-800 px-2 py-0.5 rounded-full">
|
||||
{{ outdated_apps | map(attribute='containers') | sum(start=[]) | list | length }} images
|
||||
</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{% if not last_updated %}
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-lg px-6 py-12 text-center">
|
||||
<div class="text-4xl mb-3">📋</div>
|
||||
<div class="text-gray-400 font-medium">No version data yet</div>
|
||||
<div class="text-sm text-gray-600 mt-1">
|
||||
Visit the <a href="/" class="text-blue-400 hover:underline">Images page</a> to trigger a version check.
|
||||
</div>
|
||||
</div>
|
||||
{% elif not outdated_apps %}
|
||||
<div class="bg-gray-900 border border-green-900 rounded-lg px-6 py-12 text-center">
|
||||
<div class="text-4xl mb-3">✅</div>
|
||||
<div class="text-green-400 font-medium">All images are up to date</div>
|
||||
<div class="text-sm text-gray-600 mt-1">Last checked: {{ last_updated }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% for app_data in outdated_apps %}
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
|
||||
<!-- App header -->
|
||||
<div class="flex items-center gap-3 px-4 py-2.5 bg-gray-950 border-b border-gray-800">
|
||||
<span class="text-xs font-mono px-1.5 py-0.5 rounded
|
||||
{% if app_data.kind == 'Deployment' %}bg-blue-900 text-blue-300{% elif app_data.kind == 'StatefulSet' %}bg-purple-900 text-purple-300{% elif app_data.kind == 'DaemonSet' %}bg-orange-900 text-orange-300{% elif app_data.kind == 'CronJob' %}bg-teal-900 text-teal-300{% else %}bg-gray-700 text-gray-400{% endif %}">
|
||||
{{ app_data.kind }}
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-gray-100">{{ app_data.app }}</span>
|
||||
<span class="text-xs text-gray-500 font-mono">{{ app_data.namespace }}</span>
|
||||
</div>
|
||||
<!-- Containers -->
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-gray-600">
|
||||
<th class="text-left px-4 py-2 font-medium w-32">Container</th>
|
||||
<th class="text-left px-4 py-2 font-medium">Image</th>
|
||||
<th class="text-left px-4 py-2 font-medium w-28">Current</th>
|
||||
<th class="text-left px-4 py-2 font-medium w-28">Latest</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-800">
|
||||
{% for c in app_data.containers %}
|
||||
<tr>
|
||||
<td class="px-4 py-2.5 text-gray-400 font-mono">{{ c.name }}</td>
|
||||
<td class="px-4 py-2.5 text-gray-300 font-mono break-all">
|
||||
{{ c.image_short.rsplit(':', 1)[0] if ':' in c.image_short else c.image_short }}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 font-mono text-gray-500">{{ c.tag }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-red-400 font-semibold">{{ c.latest_tag or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right: trigger + history (1/3 width) -->
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- Trigger card -->
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-lg p-5">
|
||||
<h2 class="text-base font-semibold text-gray-200 mb-1">Trigger Upgrade</h2>
|
||||
<p class="text-xs text-gray-500 mb-4">
|
||||
Creates a Job from the <code class="text-gray-400">upgrade-requester</code> CronJob.
|
||||
The workflow checks outdated images, requests Mattermost approval, then commits
|
||||
updated image tags to the ArgoCD GitOps repo.
|
||||
</p>
|
||||
|
||||
<!-- Info rows -->
|
||||
<div class="space-y-2 mb-5 text-xs">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Approval timeout</span>
|
||||
<span class="text-gray-300">60 min</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Applies changes via</span>
|
||||
<span class="text-gray-300">GitOps / ArgoCD</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Scheduled run</span>
|
||||
<span class="text-gray-300">Every Monday 09:00 UTC</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="trigger()"
|
||||
:disabled="triggering || triggerDone"
|
||||
class="w-full py-2.5 rounded-lg text-sm font-semibold transition-all
|
||||
bg-blue-600 hover:bg-blue-500 text-white
|
||||
disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!triggering && !triggerDone">⚡ Trigger Upgrade Now</span>
|
||||
<span x-show="triggering" x-cloak class="flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
|
||||
</svg>
|
||||
Creating job…
|
||||
</span>
|
||||
<span x-show="triggerDone" x-cloak>✓ Job Created</span>
|
||||
</button>
|
||||
|
||||
<!-- Result -->
|
||||
<div x-show="triggerResult" x-cloak class="mt-3 p-2.5 rounded text-xs font-mono break-all"
|
||||
:class="triggerError ? 'bg-red-950 text-red-400 ring-1 ring-red-800' : 'bg-green-950 text-green-400 ring-1 ring-green-800'"
|
||||
x-text="triggerResult">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent jobs -->
|
||||
<div class="bg-gray-900 border border-gray-800 rounded-lg p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-base font-semibold text-gray-200">Recent Jobs</h2>
|
||||
<button @click="refreshJobs()"
|
||||
class="text-xs text-gray-500 hover:text-gray-300 transition-colors">
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="!jobs.length" class="text-xs text-gray-600 text-center py-4">
|
||||
No upgrade jobs found
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<template x-for="job in jobs" :key="job.name">
|
||||
<div class="flex items-start gap-3 p-2.5 bg-gray-950 rounded border border-gray-800">
|
||||
<!-- Status dot -->
|
||||
<div class="mt-0.5 w-2 h-2 rounded-full flex-shrink-0"
|
||||
:class="{
|
||||
'bg-green-500': job.status === 'succeeded',
|
||||
'bg-red-500': job.status === 'failed',
|
||||
'bg-blue-400 animate-pulse': job.status === 'running'
|
||||
}">
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xs font-mono text-gray-300 truncate" x-text="job.name"></div>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<span class="text-xs capitalize font-medium"
|
||||
:class="{
|
||||
'text-green-400': job.status === 'succeeded',
|
||||
'text-red-400': job.status === 'failed',
|
||||
'text-blue-400': job.status === 'running'
|
||||
}"
|
||||
x-text="job.status">
|
||||
</span>
|
||||
<span class="text-xs text-gray-600"
|
||||
x-text="job.created ? new Date(job.created).toLocaleString() : ''">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function upgradePage() {
|
||||
return {
|
||||
triggering: false,
|
||||
triggerDone: false,
|
||||
triggerResult: '',
|
||||
triggerError: false,
|
||||
jobs: {{ jobs | tojson }},
|
||||
|
||||
async trigger() {
|
||||
this.triggering = true;
|
||||
this.triggerResult = '';
|
||||
this.triggerError = false;
|
||||
try {
|
||||
const res = await fetch('/api/upgrade/trigger', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
this.triggerError = true;
|
||||
this.triggerResult = data.error || 'Unknown error';
|
||||
} else {
|
||||
this.triggerDone = true;
|
||||
this.triggerResult = 'Job created: ' + data.job_name;
|
||||
setTimeout(() => this.refreshJobs(), 1000);
|
||||
}
|
||||
} catch (e) {
|
||||
this.triggerError = true;
|
||||
this.triggerResult = e.message;
|
||||
} finally {
|
||||
this.triggering = false;
|
||||
}
|
||||
},
|
||||
|
||||
async refreshJobs() {
|
||||
try {
|
||||
const res = await fetch('/api/upgrade/jobs');
|
||||
const data = await res.json();
|
||||
this.jobs = data.jobs;
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh jobs:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
web.py: FastAPI web dashboard for Kubernetes image version tracking.
|
||||
|
||||
Routes:
|
||||
GET / — image inventory grouped by namespace → app → container
|
||||
GET /upgrade — upgrade trigger page with outdated images and job history
|
||||
POST /api/refresh — kick off a background version-check refresh
|
||||
POST /api/upgrade/trigger — create a Job from the upgrade-requester CronJob
|
||||
GET /api/upgrade/jobs — recent upgrade job history (JSON)
|
||||
GET /api/images — full inventory JSON
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import BackgroundTasks, FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from kubernetes import client, config
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from check_versions import get_latest_version, parse_image_ref
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="K8s Image Dashboard")
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
_pool = ThreadPoolExecutor(max_workers=20)
|
||||
|
||||
_cache: dict = {"inventory": None, "last_updated": None, "is_checking": False}
|
||||
|
||||
|
||||
# ── Kubernetes helpers ────────────────────────────────────────────────────────
|
||||
|
||||
def _load_k8s() -> None:
|
||||
try:
|
||||
config.load_incluster_config()
|
||||
except config.ConfigException:
|
||||
config.load_kube_config()
|
||||
|
||||
|
||||
def _build_owner_maps() -> tuple[dict, dict]:
|
||||
apps_v1 = client.AppsV1Api()
|
||||
batch_v1 = client.BatchV1Api()
|
||||
|
||||
rs_map: dict[str, str] = {}
|
||||
for rs in apps_v1.list_replica_set_for_all_namespaces().items:
|
||||
ns = rs.metadata.namespace
|
||||
for ref in rs.metadata.owner_references or []:
|
||||
if ref.kind == "Deployment":
|
||||
rs_map[f"{ns}/{rs.metadata.name}"] = ref.name
|
||||
|
||||
job_map: dict[str, str] = {}
|
||||
for job in batch_v1.list_job_for_all_namespaces().items:
|
||||
ns = job.metadata.namespace
|
||||
for ref in job.metadata.owner_references or []:
|
||||
if ref.kind == "CronJob":
|
||||
job_map[f"{ns}/{job.metadata.name}"] = ref.name
|
||||
|
||||
return rs_map, job_map
|
||||
|
||||
|
||||
def _resolve_owner(pod, rs_map: dict, job_map: dict) -> tuple[str, str]:
|
||||
ns = pod.metadata.namespace
|
||||
for ref in pod.metadata.owner_references or []:
|
||||
if ref.kind == "ReplicaSet":
|
||||
deploy = rs_map.get(f"{ns}/{ref.name}")
|
||||
return (deploy, "Deployment") if deploy else (ref.name, "ReplicaSet")
|
||||
if ref.kind == "StatefulSet":
|
||||
return ref.name, "StatefulSet"
|
||||
if ref.kind == "DaemonSet":
|
||||
return ref.name, "DaemonSet"
|
||||
if ref.kind == "Job":
|
||||
cj = job_map.get(f"{ns}/{ref.name}")
|
||||
return (cj, "CronJob") if cj else (ref.name, "Job")
|
||||
return pod.metadata.name, "Pod"
|
||||
|
||||
|
||||
def _build_inventory() -> list[dict]:
|
||||
"""Query K8s and build an app-grouped inventory. Version statuses start as 'pending'."""
|
||||
_load_k8s()
|
||||
v1 = client.CoreV1Api()
|
||||
pods = v1.list_pod_for_all_namespaces(watch=False)
|
||||
rs_map, job_map = _build_owner_maps()
|
||||
|
||||
apps: dict[tuple, dict] = {}
|
||||
for pod in pods.items:
|
||||
ns = pod.metadata.namespace
|
||||
app_name, kind = _resolve_owner(pod, rs_map, job_map)
|
||||
key = (ns, app_name)
|
||||
if key not in apps:
|
||||
apps[key] = {"namespace": ns, "app": app_name, "kind": kind, "pod_count": 0, "containers": {}}
|
||||
apps[key]["pod_count"] += 1
|
||||
for c in pod.spec.containers or []:
|
||||
if not c.image or c.name in apps[key]["containers"]:
|
||||
continue
|
||||
img = parse_image_ref(c.image)
|
||||
apps[key]["containers"][c.name] = {
|
||||
"name": c.name,
|
||||
"image": c.image,
|
||||
"image_short": img.display_name,
|
||||
"tag": img.tag,
|
||||
"registry": img.registry,
|
||||
"is_local": img.is_local,
|
||||
"is_floating": img.is_floating,
|
||||
"skip_check": img.skip_check,
|
||||
"latest_tag": None,
|
||||
"status": "pending",
|
||||
}
|
||||
|
||||
return sorted(
|
||||
[{**a, "containers": list(a["containers"].values())} for a in apps.values()],
|
||||
key=lambda x: (x["namespace"], x["app"]),
|
||||
)
|
||||
|
||||
|
||||
# ── Version checking ──────────────────────────────────────────────────────────
|
||||
|
||||
def _check_container(container: dict) -> None:
|
||||
if container["is_local"]:
|
||||
container["status"] = "local"
|
||||
return
|
||||
if container["skip_check"]:
|
||||
container["status"] = "skipped"
|
||||
return
|
||||
if container["is_floating"]:
|
||||
container["status"] = "floating"
|
||||
return
|
||||
img = parse_image_ref(container["image"])
|
||||
latest = get_latest_version(img)
|
||||
container["latest_tag"] = latest
|
||||
if latest is None:
|
||||
container["status"] = "unknown"
|
||||
return
|
||||
try:
|
||||
outdated = Version(latest.lstrip("v")) > Version(container["tag"].lstrip("v"))
|
||||
except InvalidVersion:
|
||||
outdated = container["tag"] != latest
|
||||
container["status"] = "outdated" if outdated else "current"
|
||||
|
||||
|
||||
def _run_checks(inventory: list[dict]) -> None:
|
||||
if _cache["is_checking"]:
|
||||
return
|
||||
_cache["is_checking"] = True
|
||||
try:
|
||||
containers = [c for a in inventory for c in a["containers"]]
|
||||
futures = {_pool.submit(_check_container, c): c for c in containers}
|
||||
for f in as_completed(futures):
|
||||
try:
|
||||
f.result()
|
||||
except Exception as e:
|
||||
logger.warning("Version check error: %s", e)
|
||||
_cache["last_updated"] = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
logger.info(
|
||||
"Version check complete: %d outdated, %d current, %d floating",
|
||||
sum(1 for a in inventory for c in a["containers"] if c["status"] == "outdated"),
|
||||
sum(1 for a in inventory for c in a["containers"] if c["status"] == "current"),
|
||||
sum(1 for a in inventory for c in a["containers"] if c["status"] == "floating"),
|
||||
)
|
||||
finally:
|
||||
_cache["is_checking"] = False
|
||||
|
||||
|
||||
def _get_inventory(background_tasks: BackgroundTasks) -> list[dict]:
|
||||
"""Return cached inventory, building it and kicking off version checks if empty."""
|
||||
if _cache["inventory"] is None:
|
||||
inventory = _build_inventory()
|
||||
_cache["inventory"] = inventory
|
||||
background_tasks.add_task(_run_checks, inventory)
|
||||
return _cache["inventory"]
|
||||
|
||||
|
||||
# ── Upgrade job helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def _list_upgrade_jobs() -> list[dict]:
|
||||
try:
|
||||
_load_k8s()
|
||||
batch_v1 = client.BatchV1Api()
|
||||
jobs = batch_v1.list_namespaced_job(
|
||||
"k8s-version-tracker", label_selector="app=upgrade-requester"
|
||||
)
|
||||
result = []
|
||||
for j in sorted(
|
||||
jobs.items,
|
||||
key=lambda x: x.metadata.creation_timestamp or datetime.min,
|
||||
reverse=True,
|
||||
)[:5]:
|
||||
s = j.status
|
||||
status = "running" if (s.active or 0) else ("succeeded" if (s.succeeded or 0) else "failed")
|
||||
result.append({
|
||||
"name": j.metadata.name,
|
||||
"created": j.metadata.creation_timestamp.isoformat() if j.metadata.creation_timestamp else "",
|
||||
"status": status,
|
||||
"active": s.active or 0,
|
||||
"succeeded": s.succeeded or 0,
|
||||
"failed": s.failed or 0,
|
||||
})
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("Failed to list upgrade jobs: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
# ── Stats helper ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _stats(inventory: list[dict]) -> dict:
|
||||
all_c = [c for a in inventory for c in a["containers"]]
|
||||
return {
|
||||
"total": len(all_c),
|
||||
"outdated": sum(1 for c in all_c if c["status"] == "outdated"),
|
||||
"current": sum(1 for c in all_c if c["status"] == "current"),
|
||||
"floating": sum(1 for c in all_c if c["status"] == "floating"),
|
||||
"local": sum(1 for c in all_c if c["status"] in ("local", "skipped")),
|
||||
"pending": sum(1 for c in all_c if c["status"] in ("pending", "unknown")),
|
||||
}
|
||||
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def images_page(request: Request, background_tasks: BackgroundTasks):
|
||||
inventory = _get_inventory(background_tasks)
|
||||
|
||||
ns_map: dict[str, list] = {}
|
||||
for app_data in inventory:
|
||||
ns_map.setdefault(app_data["namespace"], []).append(app_data)
|
||||
|
||||
return templates.TemplateResponse("images.html", {
|
||||
"request": request,
|
||||
"namespaces": dict(sorted(ns_map.items())),
|
||||
"stats": _stats(inventory),
|
||||
"last_updated": _cache["last_updated"],
|
||||
"is_checking": _cache["is_checking"],
|
||||
})
|
||||
|
||||
|
||||
@app.get("/upgrade", response_class=HTMLResponse)
|
||||
async def upgrade_page(request: Request):
|
||||
inventory = _cache["inventory"] or []
|
||||
outdated_apps = [
|
||||
{**a, "containers": [c for c in a["containers"] if c["status"] == "outdated"]}
|
||||
for a in inventory
|
||||
if any(c["status"] == "outdated" for c in a["containers"])
|
||||
]
|
||||
return templates.TemplateResponse("upgrade.html", {
|
||||
"request": request,
|
||||
"outdated_apps": outdated_apps,
|
||||
"jobs": _list_upgrade_jobs(),
|
||||
"last_updated": _cache["last_updated"],
|
||||
"is_checking": _cache["is_checking"],
|
||||
})
|
||||
|
||||
|
||||
@app.post("/api/refresh")
|
||||
async def api_refresh(background_tasks: BackgroundTasks):
|
||||
if _cache["is_checking"]:
|
||||
return {"status": "already_running"}
|
||||
inventory = _build_inventory()
|
||||
_cache["inventory"] = inventory
|
||||
background_tasks.add_task(_run_checks, inventory)
|
||||
return {"status": "started"}
|
||||
|
||||
|
||||
@app.get("/api/images")
|
||||
async def api_images():
|
||||
return {"inventory": _cache["inventory"] or [], "last_updated": _cache["last_updated"]}
|
||||
|
||||
|
||||
@app.post("/api/upgrade/trigger")
|
||||
async def api_trigger_upgrade():
|
||||
try:
|
||||
_load_k8s()
|
||||
batch_v1 = client.BatchV1Api()
|
||||
cj = batch_v1.read_namespaced_cron_job("upgrade-requester", "k8s-version-tracker")
|
||||
stamp = datetime.now(timezone.utc).strftime("%m%d%H%M")
|
||||
job_name = f"upgrade-manual-{stamp}"
|
||||
job = client.V1Job(
|
||||
api_version="batch/v1",
|
||||
kind="Job",
|
||||
metadata=client.V1ObjectMeta(
|
||||
name=job_name,
|
||||
namespace="k8s-version-tracker",
|
||||
labels={"app": "upgrade-requester", "triggered-by": "dashboard"},
|
||||
),
|
||||
spec=cj.spec.job_template.spec,
|
||||
)
|
||||
batch_v1.create_namespaced_job("k8s-version-tracker", job)
|
||||
logger.info("Triggered upgrade job: %s", job_name)
|
||||
return {"status": "created", "job_name": job_name}
|
||||
except Exception as e:
|
||||
logger.error("Trigger failed: %s", e)
|
||||
return JSONResponse(status_code=500, content={"error": str(e)})
|
||||
|
||||
|
||||
@app.get("/api/upgrade/jobs")
|
||||
async def api_upgrade_jobs():
|
||||
return {"jobs": _list_upgrade_jobs()}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8080, log_level="info")
|
||||
Reference in New Issue
Block a user