From 0d72013e34b819b91618d48579cfd73009280916 Mon Sep 17 00:00:00 2001 From: Eric Wang <37554696+ericwang401@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:51:36 -0400 Subject: [PATCH] feat(registry): warn anyone choosing an end-of-life template Debian 11 is past LTS and security.debian.org no longer serves its binaries, yet the registry listed it as plain "Debian 11 (Bullseye)". Nothing told a Convoy user, a coport user, or someone logging in to a clone that the guest will never receive another security update. A recipe can now carry `# notice:`, published as the template's `description`. Publish takes `display` and the notice from the recipe rather than the sidecar, so relabelling reaches the registry on the next publish instead of waiting for a rebuild of an image that may never be rebuilt. coport warns with the notice as soon as a template is chosen, before VMIDs are confirmed, in the picker and under --all, --select and --upgrade alike. debian-11 is marked EOL in its display name, the one field every consumer shows, carries a notice, and appends a warning to /etc/motd at build time. --- CHANGELOG.md | 7 +++ coport/src/main.ts | 3 +- coport/src/plan.ts | 11 ++++ docs/recipes.md | 9 +++ recipes/debian-11.pkr.hcl | 13 ++++- src/config.ts | 7 +++ src/manifest.ts | 42 ++++++++++++-- tests/config.test.ts | 2 + .../fixtures/recipes/recipe-with-iso.pkr.hcl | 1 + tests/manifest.test.ts | 56 +++++++++++++++++++ 10 files changed, 144 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0523c9a..f71054d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Template notices.** A template whose registry entry carries a description, + such as a release at end of life, prints it as a warning as soon as the + template is chosen, before VMIDs are confirmed. This covers the picker, + `--all`, `--select` and `--upgrade`. + ## [2.1.0] - 2026-09-06 ### Added diff --git a/coport/src/main.ts b/coport/src/main.ts index b33303d..46533ca 100644 --- a/coport/src/main.ts +++ b/coport/src/main.ts @@ -4,7 +4,7 @@ import pkg from '../package.json' with { type: 'json' } import { resolveConfig, describeSource, RegistryKind } from './config.ts' import { fetchRegistry } from './registry.ts' import { readCache } from './cache.ts' -import { planInstall, staleItems, printInstalled } from './plan.ts' +import { planInstall, staleItems, printInstalled, warnNotices } from './plan.ts' import { runInstalls } from './runner.ts' import { cleanupTempDirSync } from './download.ts' import { @@ -164,6 +164,7 @@ program log.ok('Everything up to date — nothing to upgrade.') return } + warnNotices(items.map(i => i.template)) } else { items = await planInstall( registry, diff --git a/coport/src/plan.ts b/coport/src/plan.ts index e09f704..59a224f 100644 --- a/coport/src/plan.ts +++ b/coport/src/plan.ts @@ -50,6 +50,16 @@ const selectTemplates = async ( // Default flow: pick templates (interactive or via -a/--select), resolve + review // VMIDs, and resolve the storage volume. +/** + * Repeat each chosen template's notice (end of life, say) before anything is + * confirmed. The display name carries a short marker, but the reason and the + * alternative only fit here; the picker's hint is one terminal line. + */ +export const warnNotices = (templates: Template[]): void => { + for (const t of templates) + if (t.description) log.warn(`${t.display}: ${t.description}`) +} + export const planInstall = async ( registry: Registry, cache: Cache, @@ -59,6 +69,7 @@ export const planInstall = async ( ): Promise => { const selected = await selectTemplates(registry, opts) if (selected.length === 0) return [] + warnNotices(selected) const vmidStart = Number(opts.vmidStart) const preferred = new Map( diff --git a/docs/recipes.md b/docs/recipes.md index 1e62d48..4a0581c 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -52,6 +52,15 @@ builds at 4/8192 for the `WU.ps1` rounds — and publishing that would floor eve consumer's plan at the build's size. There is deliberately no minimum disk size; see [Disk images](disk-images.md#sidecar-schema-2). +`# display` and `# notice` are read again at publish time, so changing either +reaches the registry on the next `cf publish` without a rebuild; a sidecar +keeps whatever display name it was built with, but the registry does not. +`# notice` is a one-line warning for anyone choosing the template, such as a +release that no longer receives security updates. It is published as the +template's `description`, and coport prints it before installing. A release at +end of life also says so in its display name, since that is the one field every +consumer shows, and in `/etc/motd`, for whoever logs in to a clone later. + Keep disks small. Linux installers currently fit in 5G. For Windows, retain the temporary build/final shrink design and change the final size only after checking the installed minimum with `qemu-img info --output=json` on the exported image, diff --git a/recipes/debian-11.pkr.hcl b/recipes/debian-11.pkr.hcl index 8c42f0b..79d8eca 100644 --- a/recipes/debian-11.pkr.hcl +++ b/recipes/debian-11.pkr.hcl @@ -1,4 +1,5 @@ -# display: Debian 11 (Bullseye) +# display: Debian 11 (Bullseye, EOL) +# notice: End of life: Debian publishes no more security updates for bullseye. Use Debian 12 or 13 for anything reachable from a network. # group: debian # build_vmid: 4000 # min_cores: 1 @@ -81,7 +82,7 @@ variable "packer_ssh_private_key_file" { locals { build_vmid = var.build_vmid recipe_name = "debian-11" - recipe_display = "Debian 11 (Bullseye)" + recipe_display = "Debian 11 (Bullseye, EOL)" } source "proxmox-iso" "debian-11" { @@ -208,6 +209,14 @@ build { ] } + # The registry notice reaches whoever picks the template; this reaches + # whoever logs in to a clone of it, possibly long after. + provisioner "shell" { + inline = [ + "printf '\\n%s\\n%s\\n\\n' 'Debian 11 (bullseye) is end of life and receives no security updates.' 'Move this machine to Debian 12 or 13.' | sudo tee -a /etc/motd >/dev/null", + ] + } + provisioner "file" { source = "${path.root}/_shared/cloud-init-cleanup.sh" destination = "/tmp/cloud-init-cleanup.sh" diff --git a/src/config.ts b/src/config.ts index 719bf3c..157dc75 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,12 @@ export interface RecipeInfo { path: string /** Display name from `# display: ...` comment */ display: string + /** + * Warning shown to anyone choosing this template, from `# notice: ...` — + * e.g. that the release no longer receives security updates. Published as + * the template's registry `description`. + */ + notice?: string /** VMID from `# build_vmid: ` comment */ buildVmid?: number /** RAM assigned to the Packer build VM, parsed from `memory = `. */ @@ -118,6 +124,7 @@ export const loadRecipe = async ( name, path, display: parseMeta(raw, 'display') ?? name, + notice: parseMeta(raw, 'notice'), buildVmid: parseMetaInt(raw, 'build_vmid'), buildMemoryMb: parseHclInt(raw, 'memory'), buildCores: parseHclInt(raw, 'cores'), diff --git a/src/manifest.ts b/src/manifest.ts index 737e913..6c33e10 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -15,6 +15,7 @@ import { } from '@/registry/schema.ts' import { renderUploadTemplate, uploadVariables } from '@/upload/template.ts' import { LEGACY_PREFIXES } from '@/config-file/upload.ts' +import { listRecipes, type RecipeInfo } from '@/config.ts' interface GroupDef { id: string @@ -66,6 +67,32 @@ export const withPublicUrls = ( } } +/** + * Take a template's presentation from its recipe, not the sidecar. + * + * A sidecar freezes the display name it was built with, so relabelling a + * release (marking it end-of-life, say) would otherwise wait for a rebuild that + * may never come. The recipe is the current truth; the sidecar only knows what + * was true at build time. A sidecar with no matching recipe is left as built. + */ +export const withRecipeMetadata = ( + sidecar: Sidecar, + recipes: ReadonlyMap +): Sidecar => { + const recipe = recipes.get(sidecar.name) + if (!recipe) return sidecar + const { description: _stale, ...rest } = sidecar + return { + ...rest, + display: recipe.display, + ...(recipe.notice ? { description: recipe.notice } : {}), + } +} + +/** Recipes keyed by the template name their sidecars carry. */ +const loadRecipesByTemplate = async (): Promise> => + new Map((await listRecipes()).map(r => [`${r.name}-${r.arch}`, r])) + /** Every `template/slot` still lacking a download URL. */ export const disksMissingUrls = (registry: Registry): string[] => registry.groups.flatMap(group => @@ -78,13 +105,14 @@ export const disksMissingUrls = (registry: Registry): string[] => const assembleRegistry = ( sidecars: Sidecar[], - groupDefs: Map + groupDefs: Map, + recipes: ReadonlyMap ): Registry => { sidecars.sort((a, b) => a.name.localeCompare(b.name)) const groupMap = new Map() for (const raw of sidecars) { - const s = withPublicUrls(raw) + const s = withRecipeMetadata(withPublicUrls(raw), recipes) const gid = s.group ?? 'other' if (!groupMap.has(gid)) groupMap.set(gid, []) // `group` and `schema_version` identify the sidecar, not the template; @@ -194,7 +222,10 @@ export const buildManifest = async ( ) } - return writeRegistry(outPath, assembleRegistry(sidecars, groupDefs)) + return writeRegistry( + outPath, + assembleRegistry(sidecars, groupDefs, await loadRecipesByTemplate()) + ) } export interface R2Object { @@ -312,5 +343,8 @@ export const buildManifestFromR2 = async ( ) } - return writeRegistry(outPath, assembleRegistry(sidecars, groupDefs)) + return writeRegistry( + outPath, + assembleRegistry(sidecars, groupDefs, await loadRecipesByTemplate()) + ) } diff --git a/tests/config.test.ts b/tests/config.test.ts index 0597063..047260f 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -9,6 +9,7 @@ describe('loadRecipe', () => { const r = await loadRecipe('recipe-minimal', FIXTURES) expect(r.name).toBe('recipe-minimal') expect(r.display).toBe('Minimal Recipe') + expect(r.notice).toBeUndefined() expect(r.buildVmid).toBe(9999) expect(r.buildCores).toBe(2) expect(r.buildMemoryMb).toBe(4096) @@ -19,6 +20,7 @@ describe('loadRecipe', () => { test('parses iso_url and iso_target_path from recipe metadata', async () => { const r = await loadRecipe('recipe-with-iso', FIXTURES) expect(r.display).toBe('Recipe With ISO') + expect(r.notice).toBe('Unsupported; do not use.') expect(r.buildVmid).toBe(9101) expect(r.isoUrl).toBe('https://example.com/foo-1.2.3-amd64.iso') expect(r.isoTargetPath).toBe( diff --git a/tests/fixtures/recipes/recipe-with-iso.pkr.hcl b/tests/fixtures/recipes/recipe-with-iso.pkr.hcl index 4845dea..9b052e8 100644 --- a/tests/fixtures/recipes/recipe-with-iso.pkr.hcl +++ b/tests/fixtures/recipes/recipe-with-iso.pkr.hcl @@ -1,4 +1,5 @@ # display: Recipe With ISO +# notice: Unsupported; do not use. # build_vmid: 9101 # final_disk_size: 16G # iso_url: https://example.com/foo-1.2.3-amd64.iso diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index 155777a..61f92ba 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -14,11 +14,13 @@ import { buildManifest, disksMissingUrls, publishPrefixes, + withRecipeMetadata, selectNewestSidecars, withPublicUrls, type R2Sidecar, } from '../src/manifest.ts' import type { Registry, Sidecar } from '../src/registry/schema.ts' +import type { RecipeInfo } from '../src/config.ts' const sc = ( name: string, @@ -326,6 +328,60 @@ describe('selectNewestSidecars', () => { }) }) +describe('withRecipeMetadata', () => { + const built = (): Sidecar => + ({ + name: 'debian-11-amd64', + display: 'Debian 11 (Bullseye)', + arch: 'amd64', + group: 'debian', + }) as Sidecar + const recipes = ( + extra: Partial = {} + ): Map => + new Map([ + [ + 'debian-11-amd64', + { + name: 'debian-11', + path: 'recipes/debian-11.pkr.hcl', + display: 'Debian 11 (Bullseye, EOL)', + arch: 'amd64', + ...extra, + }, + ], + ]) + + // An image built before a release was relabelled must not keep advertising + // the old name until someone rebuilds it. + test('takes the display name from the recipe, not the sidecar', () => { + expect(withRecipeMetadata(built(), recipes()).display).toBe( + 'Debian 11 (Bullseye, EOL)' + ) + }) + + test('publishes the recipe notice as the description', () => { + const out = withRecipeMetadata( + built(), + recipes({ notice: 'End of life.' }) + ) + expect(out.description).toBe('End of life.') + }) + + test('adds no description when the recipe has no notice', () => { + const out = withRecipeMetadata( + { ...built(), description: 'stale' }, + recipes() + ) + expect('description' in out).toBe(false) + }) + + test('leaves a sidecar with no recipe as built', () => { + const sidecar = built() + expect(withRecipeMetadata(sidecar, new Map())).toBe(sidecar) + }) +}) + describe('withPublicUrls', () => { // The shape that actually shipped broken: a Windows template whose system // disk and EFI varstore both carry `"url": ""`.