Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion coport/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions coport/src/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -59,6 +69,7 @@ export const planInstall = async (
): Promise<InstallItem[]> => {
const selected = await selectTemplates(registry, opts)
if (selected.length === 0) return []
warnNotices(selected)

const vmidStart = Number(opts.vmidStart)
const preferred = new Map(
Expand Down
9 changes: 9 additions & 0 deletions docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 11 additions & 2 deletions recipes/debian-11.pkr.hcl
Original file line number Diff line number Diff line change
@@ -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
# mirror: archive.debian.org
# build_vmid: 4000
Expand Down Expand Up @@ -82,7 +83,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" {
Expand Down Expand Up @@ -231,6 +232,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"
Expand Down
7 changes: 7 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <n>` comment */
buildVmid?: number
/** RAM assigned to the Packer build VM, parsed from `memory = <MiB>`. */
Expand Down Expand Up @@ -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'),
Expand Down
42 changes: 38 additions & 4 deletions src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, RecipeInfo>
): 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<Map<string, RecipeInfo>> =>
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 =>
Expand All @@ -78,13 +105,14 @@ export const disksMissingUrls = (registry: Registry): string[] =>

const assembleRegistry = (
sidecars: Sidecar[],
groupDefs: Map<string, GroupDef>
groupDefs: Map<string, GroupDef>,
recipes: ReadonlyMap<string, RecipeInfo>
): Registry => {
sidecars.sort((a, b) => a.name.localeCompare(b.name))

const groupMap = new Map<string, Template[]>()
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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -312,5 +343,8 @@ export const buildManifestFromR2 = async (
)
}

return writeRegistry(outPath, assembleRegistry(sidecars, groupDefs))
return writeRegistry(
outPath,
assembleRegistry(sidecars, groupDefs, await loadRecipesByTemplate())
)
}
2 changes: 2 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/recipes/recipe-with-iso.pkr.hcl
Original file line number Diff line number Diff line change
@@ -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
Expand Down
56 changes: 56 additions & 0 deletions tests/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<RecipeInfo> = {}
): Map<string, RecipeInfo> =>
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": ""`.
Expand Down
Loading