detect newer version tags for pinned images and show them as a badge with release notes
This commit is contained in:
36 files changed
+8820
-98
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { inspectContainer } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import { resolveReleaseSource } from '$lib/server/semver/release-source';
|
||||
import { resolveChangelogUrl } from '$lib/utils/changelog-url';
|
||||
import { fetchReleaseNotes } from '$lib/server/semver/release-notes';
|
||||
|
||||
/**
|
||||
* GET /api/containers/{id}/version-notes - Release notes for a newer version tag
|
||||
*
|
||||
* @openapi
|
||||
* summary: Resolve GitHub release notes for the versions a semver update-check surfaced (requires the 'view' permission)
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* query: versions:string! Comma-separated version tags to fetch notes for (target + skipped), e.g. "16.3-alpine,16.4-alpine"
|
||||
* resp-200: {changelogUrl:string, source:string, notes:array<{version:string!, name:string, githubTag:string, body:string, publishedAt:string, url:string!}>!}
|
||||
* resp-200-example: {"changelogUrl":"https://github.com/go-gitea/gitea/releases","source":"go-gitea/gitea","notes":[{"version":"1.22.0","name":"v1.22.0","githubTag":"v1.22.0","body":"## Changes...","publishedAt":"2024-05-01T00:00:00Z","url":"https://github.com/go-gitea/gitea/releases/tag/v1.22.0"}]}
|
||||
* resp-403: Permission denied
|
||||
* resp-500: Failed to resolve release notes
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
if (auth.authEnabled && !(await auth.can('containers', 'view', envIdNum))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const versions = (url.searchParams.get('versions') ?? '')
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
try {
|
||||
const inspectData = (await inspectContainer(params.id, envIdNum)) as any;
|
||||
const imageName: string | undefined = inspectData.Config?.Image;
|
||||
const labels: Record<string, string> = inspectData.Config?.Labels ?? {};
|
||||
|
||||
const src = resolveReleaseSource(imageName, labels);
|
||||
// Prefer the label-based changelog URL; fall back to the forge's releases page.
|
||||
const changelogUrl = resolveChangelogUrl(imageName, labels) ?? src?.releasesUrl ?? null;
|
||||
|
||||
// No recognizable forge -> no per-version notes, just the changelog link (if any).
|
||||
const notes = src ? await fetchReleaseNotes(src, versions) : [];
|
||||
|
||||
return json({ changelogUrl, source: src?.slug ?? null, notes });
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve version notes:', error);
|
||||
return json({ error: 'Failed to resolve release notes' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -2,10 +2,12 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
|
||||
import { clearPendingContainerUpdates, addPendingContainerUpdate, getPendingContainerUpdates } from '$lib/server/db';
|
||||
import { clearPendingContainerUpdates, addPendingContainerUpdate, getPendingContainerUpdates, getEnvUpdateCheckSettings } from '$lib/server/db';
|
||||
import { isSystemContainer, isPodmanInfraContainer } from '$lib/server/scheduler/tasks/update-utils';
|
||||
import { isUpdateDisabledByLabel, isHiddenByLabel } from '$lib/server/container-labels';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
import { checkNewerVersion } from '$lib/server/semver/check';
|
||||
import type { NewerVersion } from '$lib/server/semver/find-newer';
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
containerId: string;
|
||||
@@ -18,12 +20,23 @@ export interface UpdateCheckResult {
|
||||
isLocalImage?: boolean;
|
||||
systemContainer?: 'dockhand' | 'hawser' | null;
|
||||
updateDisabled?: boolean;
|
||||
/** A newer VERSION tag is published for a pinned image (advisory, never auto-applied). */
|
||||
newerVersion?: NewerVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET = READ the cached result of the last update check (no side effects, does NOT
|
||||
* trigger a new check). Returns the containers currently flagged as having a pending
|
||||
* image update. Use POST (below) to actually run a fresh check. (issue #1266)
|
||||
*
|
||||
* @openapi
|
||||
* summary: Read the cached pending image-update records for an environment (no fresh check; requires the 'view' permission)
|
||||
* description: Returns the containers currently flagged as having a pending image update from the last check. Does not trigger a new check — use POST to run a fresh check.
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* resp-200: {environmentId:integer!, pendingUpdates:array<{containerId:string!, containerName:string!, currentImage:string!, checkedAt:string}>!}
|
||||
* resp-400: Environment ID required
|
||||
* resp-403: Permission denied
|
||||
* resp-500: Failed to get pending updates
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -59,6 +72,12 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
/**
|
||||
* POST = TRIGGER a fresh update check across all containers (job-based).
|
||||
* Returns progress events during checking, final result when done.
|
||||
*
|
||||
* @openapi
|
||||
* summary: Trigger a fresh image-update check across all (non-hidden, non-podman-infra) containers in an environment
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* resp-200: text/event-stream job stream ("progress" events with {checked,total}, final "result" event with {total,updatesFound,results}) — or, with "Accept: application/json", the final result as plain JSON
|
||||
* resp-403: Permission denied
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -87,6 +106,17 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
|
||||
send('progress', { checked: 0, total: containers.length });
|
||||
|
||||
// Semver "newer version tag" detection is an opt-in branch on this same pass.
|
||||
// Read the env's update-check config once; if checkPinnedVersions is on, each
|
||||
// pinned-version container is also compared against the registry's tag list.
|
||||
const envSettings = envIdNum ? await getEnvUpdateCheckSettings(envIdNum) : null;
|
||||
const semverEnabled = !!envSettings?.checkPinnedVersions;
|
||||
const semverOptions = {
|
||||
maxBump: envSettings?.semverMaxBump ?? 'major',
|
||||
matchFlavor: envSettings?.semverMatchFlavor ?? true,
|
||||
includePrerelease: envSettings?.semverIncludePrerelease ?? false
|
||||
} as const;
|
||||
|
||||
// Check container for updates
|
||||
let checked = 0;
|
||||
const checkContainer = async (container: typeof containers[0]): Promise<UpdateCheckResult> => {
|
||||
@@ -120,6 +150,12 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
|
||||
const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum);
|
||||
|
||||
// Newer-version-tag detection (opt-in). Skips instantly for floating tags,
|
||||
// so it only hits the registry for pinned versions. Never throws.
|
||||
const newerVersion = semverEnabled
|
||||
? await checkNewerVersion(imageName, semverOptions).catch(() => null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
containerId: container.id,
|
||||
containerName: container.name,
|
||||
@@ -130,7 +166,8 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
error: result.error,
|
||||
isLocalImage: result.isLocalImage,
|
||||
systemContainer: isSystemContainer(imageName) || null,
|
||||
updateDisabled
|
||||
updateDisabled,
|
||||
newerVersion: newerVersion ?? undefined
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
@@ -160,17 +197,21 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
|
||||
const updatesFound = results.filter(r => r.hasUpdate && !r.systemContainer && !r.updateDisabled).length;
|
||||
|
||||
// Save containers with updates to the database for persistence
|
||||
// Persist a row for anything worth showing on reload: a digest update, a
|
||||
// newer-version-tag (semver) suggestion, or both. A pure-semver row (no
|
||||
// digest update) still persists so the badge survives a page reload.
|
||||
if (envIdNum) {
|
||||
for (const result of results) {
|
||||
if (result.hasUpdate && !result.systemContainer && !result.updateDisabled) {
|
||||
await addPendingContainerUpdate(
|
||||
envIdNum,
|
||||
result.containerId,
|
||||
result.containerName,
|
||||
result.imageName
|
||||
);
|
||||
}
|
||||
if (result.systemContainer || result.updateDisabled) continue;
|
||||
const hasImageUpdate = result.hasUpdate;
|
||||
if (!hasImageUpdate && !result.newerVersion) continue;
|
||||
await addPendingContainerUpdate(
|
||||
envIdNum,
|
||||
result.containerId,
|
||||
result.containerName,
|
||||
result.imageName,
|
||||
{ hasImageUpdate, newerVersion: result.newerVersion ?? null }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,24 @@ import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getPendingContainerUpdates, removePendingContainerUpdate, clearPendingContainerUpdates } from '$lib/server/db';
|
||||
|
||||
function safeParse(json: string): unknown {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending container updates for an environment.
|
||||
*
|
||||
* @openapi
|
||||
* summary: List the containers in an environment that have a pending image update recorded (requires the 'view' permission)
|
||||
* query: env:integer! The target environment ID (required) (from GET /api/environments)
|
||||
* resp-200: {environmentId:integer!, pendingUpdates:array<{containerId:string!, containerName:string!, currentImage:string!, checkedAt:string!, hasImageUpdate:boolean!, newerVersion:object}>!}
|
||||
* resp-400: Environment ID is required
|
||||
* resp-403: Permission denied
|
||||
* resp-500: Failed to get the pending updates
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -29,7 +45,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
containerId: u.containerId,
|
||||
containerName: u.containerName,
|
||||
currentImage: u.currentImage,
|
||||
checkedAt: u.checkedAt
|
||||
checkedAt: u.checkedAt,
|
||||
hasImageUpdate: u.hasImageUpdate,
|
||||
newerVersion: u.newerVersion ? safeParse(u.newerVersion) : null
|
||||
}))
|
||||
});
|
||||
} catch (error: any) {
|
||||
@@ -40,6 +58,16 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
|
||||
/**
|
||||
* Remove a pending container update (e.g., after manual update).
|
||||
*
|
||||
* @openapi
|
||||
* summary: Clear pending-update records for an environment, or for a single container when containerId is given
|
||||
* query: env:integer! The target environment ID (required) (from GET /api/environments)
|
||||
* query: containerId:string The container to clear; omit to clear ALL pending updates in the environment (from GET /api/containers)
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-400: Environment ID is required
|
||||
* resp-403: Permission denied
|
||||
* resp-500: Failed to remove the pending update
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -10,6 +10,15 @@ import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
|
||||
|
||||
/**
|
||||
* Get update check settings for an environment.
|
||||
*
|
||||
* @openapi
|
||||
* summary: Get the automatic container-image update-check schedule for an environment
|
||||
* path: id:integer! Environment id (from GET /api/environments)
|
||||
* resp-200: {settings:{enabled:boolean!, cron:string!, autoUpdate:boolean!, vulnerabilityCriteria:string!}!}
|
||||
* resp-200-example: {"settings":{"enabled":false,"cron":"0 4 * * *","autoUpdate":false,"vulnerabilityCriteria":"never"}}
|
||||
* resp-403: Permission denied (RBAC 'environments:view' missing)
|
||||
* resp-404: Environment not found
|
||||
* resp-500: Unexpected error while loading the settings
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -44,6 +53,16 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
|
||||
/**
|
||||
* Save update check settings for an environment.
|
||||
*
|
||||
* @openapi
|
||||
* summary: Save the automatic image update-check schedule for an environment (registers/unregisters the croner job)
|
||||
* path: id:integer! Environment id (from GET /api/environments)
|
||||
* body: {enabled:boolean, cron:string, autoUpdate:boolean, vulnerabilityCriteria:string, checkPinnedVersions:boolean, semverMaxBump:string, semverMatchFlavor:boolean, semverIncludePrerelease:boolean}
|
||||
* body-example: {"enabled":true,"cron":"0 4 * * *","autoUpdate":false,"vulnerabilityCriteria":"never","checkPinnedVersions":true,"semverMaxBump":"minor","semverMatchFlavor":true,"semverIncludePrerelease":false}
|
||||
* resp-200: {success:boolean!, settings:{enabled:boolean!, cron:string!, autoUpdate:boolean!, vulnerabilityCriteria:string!}!}
|
||||
* resp-403: Permission denied (RBAC 'environments:edit' missing)
|
||||
* resp-404: Environment not found
|
||||
* resp-500: Unexpected error while saving the settings
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -66,7 +85,11 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
|
||||
enabled: data.enabled ?? false,
|
||||
cron: data.cron || '0 4 * * *',
|
||||
autoUpdate: data.autoUpdate ?? false,
|
||||
vulnerabilityCriteria: data.vulnerabilityCriteria || 'never'
|
||||
vulnerabilityCriteria: data.vulnerabilityCriteria || 'never',
|
||||
checkPinnedVersions: data.checkPinnedVersions ?? false,
|
||||
semverMaxBump: data.semverMaxBump || 'major',
|
||||
semverMatchFlavor: data.semverMatchFlavor ?? true,
|
||||
semverIncludePrerelease: data.semverIncludePrerelease ?? false
|
||||
};
|
||||
|
||||
// Save settings to database
|
||||
|
||||
Reference in new issue
Block a user