fix: add request_upgrades.py to image, add web dashboard
CI/CD / lint-test (push) Failing after 1m40s
CI/CD / build-push (push) Has been skipped

- 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:
claude-code
2026-07-12 16:15:15 +00:00
parent cb7a2abf17
commit 5ac7b2f5c5
9 changed files with 985 additions and 3 deletions
+4
View File
@@ -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
View File
@@ -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
+67
View File
@@ -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
+3
View File
@@ -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
+49
View File
@@ -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>
+213
View File
@@ -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 %}
+249
View File
@@ -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 %}
+307
View File
@@ -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")