Skip to content

Commit 9bed522

Browse files
feat: add version check system with update notifications (#195) (#195)
Add a versioning system so users running Docker images can see their current version and be notified when updates are available. - Add VERSION file as single source of truth (1.0.0-beta.1) - Add backend /vyos/version/check endpoint that compares local version against latest GitHub release (1-hour cache, no auth required) - Add frontend version service and display version/update info in the dashboard beta banner - Inject VYMANAGER_VERSION and VYMANAGER_ENV into Docker images via build args in both Dockerfiles and CI workflows - Add GitHub Release workflow that triggers on version tags - Support ALLOWED_DEV_ORIGINS env var in next.config.ts for dev HMR through reverse proxies - Sync package.json versions to 1.0.0-beta.1
1 parent 47e9470 commit 9bed522

14 files changed

Lines changed: 260 additions & 4 deletions

File tree

.github/workflows/docker-publish-backend.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ jobs:
6969
with:
7070
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
7171

72+
- name: Read VERSION file
73+
id: version
74+
run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT"
75+
7276
# Build and push Docker image with Buildx (don't push on PR)
7377
# https://github.com/docker/build-push-action
7478
- name: Build and push Docker image
@@ -82,6 +86,8 @@ jobs:
8286
labels: ${{ steps.meta.outputs.labels }}
8387
cache-from: type=gha
8488
cache-to: type=gha,mode=max
89+
build-args: |
90+
VYMANAGER_VERSION=${{ steps.version.outputs.version }}
8591
8692
# Sign the resulting Docker image digest except on PRs.
8793
# This will only write to the public Rekor transparency log when the Docker

.github/workflows/docker-publish-frontend.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ jobs:
6969
with:
7070
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
7171

72+
- name: Read VERSION file
73+
id: version
74+
run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT"
75+
7276
# Build and push Docker image with Buildx (don't push on PR)
7377
# https://github.com/docker/build-push-action
7478
- name: Build and push Docker image
@@ -82,6 +86,8 @@ jobs:
8286
labels: ${{ steps.meta.outputs.labels }}
8387
cache-from: type=gha
8488
cache-to: type=gha,mode=max
89+
build-args: |
90+
VYMANAGER_VERSION=${{ steps.version.outputs.version }}
8591
8692
# Sign the resulting Docker image digest except on PRs.
8793
# This will only write to the public Rekor transparency log when the Docker

.github/workflows/release.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Create GitHub Release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*.*.*'
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
release:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- name: Checkout repository
16+
uses: actions/checkout@v4
17+
with:
18+
fetch-depth: 0
19+
20+
- name: Check if prerelease
21+
id: prerelease
22+
run: |
23+
TAG="${GITHUB_REF#refs/tags/}"
24+
if echo "$TAG" | grep -qi "beta\|alpha\|rc"; then
25+
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
26+
else
27+
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
28+
fi
29+
30+
- name: Create GitHub Release
31+
uses: softprops/action-gh-release@v2
32+
with:
33+
generate_release_notes: true
34+
prerelease: ${{ steps.prerelease.outputs.is_prerelease == 'true' }}

Dockerfile.backend

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
FROM python:3.11-slim
66

7+
ARG VYMANAGER_VERSION=dev
8+
ARG VYMANAGER_ENV=production
9+
ENV VYMANAGER_VERSION=${VYMANAGER_VERSION}
10+
ENV VYMANAGER_ENV=${VYMANAGER_ENV}
11+
712
# Set working directory
813
WORKDIR /app
914

Dockerfile.frontend

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
# ---- Build Stage ----
66
FROM node:24-alpine AS builder
77

8+
ARG VYMANAGER_VERSION=dev
9+
ARG VYMANAGER_ENV=production
10+
ENV NEXT_PUBLIC_VYMANAGER_VERSION=${VYMANAGER_VERSION}
11+
ENV NEXT_PUBLIC_VYMANAGER_ENV=${VYMANAGER_ENV}
12+
813
WORKDIR /app
914

1015
# Install required system packages

VERSION

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
1.0.0-beta.1

backend/app.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
from routers.load_balancing import load_balancing as load_balancing_router
5656
from routers.isis import isis as isis_router
5757
from routers.mpls import mpls as mpls_router
58+
from routers import version as version_router
5859

5960
# Global variables
6061
db_pool: Optional[asyncpg.Pool] = None
@@ -205,7 +206,7 @@ async def lifespan(app: FastAPI):
205206

206207
app = FastAPI(
207208
title="VyOS Management API",
208-
version="1.0.0",
209+
version=os.environ.get("VYMANAGER_VERSION", "dev"),
209210
description="FastAPI backend for managing VyOS devices with version-aware commands",
210211
lifespan=lifespan,
211212
)
@@ -297,6 +298,7 @@ async def get_permissions(request: Request) -> dict:
297298
app.include_router(load_balancing_router.router)
298299
app.include_router(isis_router.router)
299300
app.include_router(mpls_router.router)
301+
app.include_router(version_router.router)
300302

301303

302304
# ============================================================================

backend/middleware/auth.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class AuthenticationMiddleware(BaseHTTPMiddleware):
3838
"/api/auth/session",
3939
"/session/onboarding-status", # Must be public to check if first-time setup is needed
4040
"/vyos/monitoring/ws/monitor", # WebSocket auth handled inside handler
41+
"/vyos/version/check", # Version check is public
4142
}
4243

4344
# Endpoints that should NOT update activity timestamp

backend/routers/version.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""
2+
Version Check Endpoint
3+
4+
Provides current version info and checks GitHub for available updates.
5+
No authentication required — version info is not sensitive.
6+
"""
7+
8+
import logging
9+
import os
10+
import time
11+
from typing import Optional
12+
13+
import httpx
14+
from fastapi import APIRouter
15+
from pydantic import BaseModel
16+
17+
logger = logging.getLogger(__name__)
18+
19+
router = APIRouter(prefix="/vyos/version", tags=["version"])
20+
21+
GITHUB_REPO = "Community-VyProjects/VyManager"
22+
CACHE_TTL_SECONDS = 3600 # 1 hour
23+
24+
25+
class VersionCheckResponse(BaseModel):
26+
current_version: str
27+
latest_version: Optional[str] = None
28+
update_available: bool = False
29+
release_url: Optional[str] = None
30+
published_at: Optional[str] = None
31+
environment: str
32+
33+
34+
# Simple in-memory cache
35+
_cache: dict[str, object] = {}
36+
_cache_time: float = 0.0
37+
38+
39+
async def _fetch_latest_release() -> Optional[dict[str, str]]:
40+
"""Fetch the latest release from GitHub API with 1-hour caching."""
41+
global _cache, _cache_time
42+
43+
now = time.time()
44+
if _cache and (now - _cache_time) < CACHE_TTL_SECONDS:
45+
return _cache # type: ignore[return-value]
46+
47+
try:
48+
async with httpx.AsyncClient(timeout=10.0) as client:
49+
resp = await client.get(
50+
f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest",
51+
headers={"Accept": "application/vnd.github+json"},
52+
)
53+
if resp.status_code == 200:
54+
data = resp.json()
55+
result = {
56+
"tag_name": data.get("tag_name", ""),
57+
"html_url": data.get("html_url", ""),
58+
"published_at": data.get("published_at", ""),
59+
}
60+
_cache = result
61+
_cache_time = now
62+
return result
63+
logger.warning("GitHub API returned status %d", resp.status_code)
64+
return None
65+
except Exception:
66+
logger.exception("Failed to fetch latest release from GitHub")
67+
return None
68+
69+
70+
def _parse_version(version_str: str) -> Optional[tuple[int, ...]]:
71+
"""Parse a version string like '1.0.0-beta.1' into comparable tuples.
72+
73+
Returns a tuple suitable for comparison. Beta versions sort below their
74+
release counterpart (e.g. 1.0.0-beta.1 < 1.0.0).
75+
"""
76+
clean = version_str.lstrip("v")
77+
try:
78+
# Split on hyphen: "1.0.0-beta.1" -> ["1.0.0", "beta.1"]
79+
parts = clean.split("-", 1)
80+
core = tuple(int(x) for x in parts[0].split("."))
81+
82+
if len(parts) == 1:
83+
# Release version: use a high pre-release marker so it sorts above betas
84+
return core + (1, 0)
85+
86+
# Pre-release: extract the numeric suffix
87+
pre = parts[1] # e.g. "beta.1"
88+
pre_parts = pre.split(".")
89+
pre_num = int(pre_parts[-1]) if pre_parts[-1].isdigit() else 0
90+
# 0 in the fourth position means pre-release (sorts below release's 1)
91+
return core + (0, pre_num)
92+
except (ValueError, IndexError):
93+
return None
94+
95+
96+
def _is_update_available(current: str, latest: str) -> bool:
97+
"""Compare version strings. Returns True if latest > current."""
98+
if current == "dev":
99+
return False
100+
101+
current_tuple = _parse_version(current)
102+
latest_tuple = _parse_version(latest)
103+
104+
if current_tuple is None or latest_tuple is None:
105+
return False
106+
107+
return latest_tuple > current_tuple
108+
109+
110+
@router.get("/check", response_model=VersionCheckResponse)
111+
async def check_version() -> VersionCheckResponse:
112+
"""Check for available updates against the latest GitHub release."""
113+
current_version = os.environ.get("VYMANAGER_VERSION", "dev")
114+
environment = os.environ.get("VYMANAGER_ENV", "dev")
115+
116+
release = await _fetch_latest_release()
117+
118+
if not release:
119+
return VersionCheckResponse(
120+
current_version=current_version,
121+
update_available=False,
122+
environment=environment,
123+
)
124+
125+
tag = release["tag_name"]
126+
latest_version = tag.lstrip("v")
127+
128+
return VersionCheckResponse(
129+
current_version=current_version,
130+
latest_version=latest_version,
131+
update_available=_is_update_available(current_version, latest_version),
132+
release_url=release["html_url"],
133+
published_at=release["published_at"],
134+
environment=environment,
135+
)

frontend/next.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ const nextConfig: NextConfig = {
77
//
88
// This allows users to set BACKEND_URL=http://some-host:8000 in their .env
99
// without needing to rebuild the Docker image.
10+
11+
// Allow dev HMR from custom origins (for reverse proxy / custom domain access).
12+
// Set ALLOWED_DEV_ORIGINS as a comma-separated list in .env, e.g.:
13+
// ALLOWED_DEV_ORIGINS=mysite.example.com,other.example.com
14+
...(process.env.ALLOWED_DEV_ORIGINS
15+
? { allowedDevOrigins: process.env.ALLOWED_DEV_ORIGINS.split(",").map(s => s.trim()) }
16+
: {}),
1017
};
1118

1219
export default nextConfig;

0 commit comments

Comments
 (0)