diff --git a/src/lib/components/ContainerIcon.svelte b/src/lib/components/ContainerIcon.svelte
index b10d4ca..baddf69 100644
--- a/src/lib/components/ContainerIcon.svelte
+++ b/src/lib/components/ContainerIcon.svelte
@@ -17,6 +17,13 @@
override?: string | null;
/** Environment id, only needed to fetch a 'custom:' override's bytes. */
envId?: number | null;
+ /**
+ * The container name a 'custom:' override's bytes are stored under (its icon key).
+ * Defaults to `name`; pass it when `name` is a display value that differs from the
+ * real container name (e.g. the stacks view uses the compose service for matching
+ * but the override is keyed by the actual container name).
+ */
+ overrideKey?: string | null;
class?: string;
/** Extra classes for the generic-box fallback (e.g. a running/stopped state colour). */
fallbackClass?: string;
@@ -42,6 +49,7 @@
name = '',
override = null,
envId = null,
+ overrideKey = null,
class: className = 'w-4 h-4',
fallbackClass = 'text-muted-foreground',
fallbackIcon = undefined,
@@ -55,9 +63,10 @@
const hasOverride = $derived(!!override);
const overrideSelfhst = $derived(selfhstRef(override));
const overrideCustom = $derived(isCustomIcon(override));
+ const customIconName = $derived(overrideKey ?? name);
const overrideCustomUrl = $derived(
- overrideCustom && name
- ? `/api/container-icons/${encodeURIComponent(name)}${envId != null ? `?env=${envId}` : ''}`
+ overrideCustom && customIconName
+ ? `/api/container-icons/${encodeURIComponent(customIconName)}${envId != null ? `?env=${envId}` : ''}`
: ''
);
// A non-selfhst, non-custom override is a lucide name.
@@ -75,7 +84,8 @@
// Reactive match: recomputes when the matcher store loads or the image changes.
const ref = $derived(enabled ? $selfhstMatcher(image, name) : null);
- // A failed
load (icon 404'd) falls back to the generic box.
+ // If the
can't load at all (a transport error - the endpoint serves a neutral
+ // placeholder SVG rather than a 404 for an unresolved icon), fall back to the glyph.
let imgFailed = $state(false);
$effect(() => {
// reset the failure flag when the resolved ref changes
diff --git a/src/lib/server/compose-validate/rules.ts b/src/lib/server/compose-validate/rules.ts
index 48305ca..496b67d 100644
--- a/src/lib/server/compose-validate/rules.ts
+++ b/src/lib/server/compose-validate/rules.ts
@@ -514,6 +514,50 @@ export const RULES: RuleDefinition[] = [
return out;
}
},
+ {
+ id: 'UNUSED_VOLUME',
+ description: 'A top-level named volume is defined but never mounted by any service',
+ group: 'correctness',
+ defaultSeverity: 'info',
+ check(p) {
+ const definedVolumes = asRecord(p.doc?.volumes) ?? {};
+ const definedNames = Object.keys(definedVolumes);
+ if (definedNames.length === 0) return [];
+
+ // Collect every named-volume source referenced by any service (same extraction
+ // as UNDEFINED_VOLUME_REF: a bare token, or a long-form { type: volume, source }).
+ const used = new Set();
+ for (const svc of Object.values(services(p))) {
+ const vols = Array.isArray(svc.volumes) ? svc.volumes : [];
+ for (const v of vols) {
+ if (typeof v === 'string') {
+ const left = v.split(':')[0];
+ if (left && !left.startsWith('/') && !left.startsWith('.') && !left.startsWith('~')) {
+ used.add(left);
+ }
+ } else {
+ const o = asRecord(v);
+ if (o?.type === 'volume' && typeof o.source === 'string') used.add(o.source);
+ }
+ }
+ }
+
+ const out: RuleFinding[] = [];
+ for (const vol of definedNames) {
+ if (used.has(vol)) continue;
+ // An external volume is often declared just so compose won't try to create it;
+ // it may be mounted elsewhere, so don't flag it as unused.
+ if (asRecord(definedVolumes[vol])?.external) continue;
+ out.push({
+ ruleId: 'UNUSED_VOLUME',
+ message: `Named volume "${vol}" is defined but no service mounts it`,
+ hint: `Mount it from a service under volumes:, or remove it from the top-level volumes: if it is not needed.`,
+ line: p.lineOf(['volumes', vol]) ?? p.lineOf(['volumes'])
+ });
+ }
+ return out;
+ }
+ },
{
id: 'CONTAINER_NAME_COLLISION',
description: 'container_name is already used by a container on the environment',
@@ -1041,6 +1085,31 @@ export const RULES: RuleDefinition[] = [
return out;
}
},
+ {
+ id: 'MISSING_HEALTHCHECK',
+ description: 'A service has no healthcheck, so Docker cannot tell whether it is actually working',
+ group: 'reliability',
+ defaultSeverity: 'info',
+ check(p) {
+ const out: RuleFinding[] = [];
+ for (const [name, svc] of Object.entries(services(p))) {
+ // A compose-declared healthcheck, or one explicitly turned off, is a
+ // deliberate choice - don't nag either way. We only see the compose file,
+ // not the image's own HEALTHCHECK, so this stays an info-level hint.
+ const hc = asRecord(svc.healthcheck);
+ if (hc) continue;
+ if ('healthcheck' in svc) continue;
+ out.push({
+ ruleId: 'MISSING_HEALTHCHECK',
+ service: name,
+ message: `"${name}" has no healthcheck - Docker reports it as running even if the app inside has stopped responding`,
+ hint: 'Add a healthcheck so restart policies and depends_on: condition: service_healthy can react to a hung container. The image may already ship one.',
+ line: p.lineOf(['services', name])
+ });
+ }
+ return out;
+ }
+ },
{
id: 'SENSITIVE_SERVICE_BROAD_EXPOSURE',
description: 'An admin/management UI publishes a port on all interfaces',
diff --git a/src/lib/server/semver/release-notes.ts b/src/lib/server/semver/release-notes.ts
index c0d4179..39ad4d0 100644
--- a/src/lib/server/semver/release-notes.ts
+++ b/src/lib/server/semver/release-notes.ts
@@ -7,6 +7,7 @@
import { parseTag, compareParts } from './tag-parser';
import type { ReleaseSource } from './release-source';
+import { resolveReleaseSourceCandidates } from './release-source';
import { isSafeNotificationUrl } from '../url-safety';
export interface ReleaseNote {
@@ -152,3 +153,40 @@ export async function fetchReleaseNotes(
return { notes: matchReleasesToVersions(collected, wanted), rateLimited };
}
+
+/**
+ * Resolve a release-note source for an image and fetch notes, with a validated
+ * fallback when the image carries no `org.opencontainers.image.source` label.
+ *
+ * A CONFIDENT source (label / ghcr name) is used directly. Otherwise each GUESSED
+ * candidate (image-name slug, github URL in another label) is tried in order and
+ * ACCEPTED only if the forge returns at least one release matching a wanted version
+ * - so a wrong same-named repo yields no notes and is discarded (never guess a repo
+ * blind). Returns the accepted source's slug/releasesUrl too, so the caller can
+ * surface the correct "View releases" link.
+ */
+export async function resolveAndFetchReleaseNotes(
+ imageName: string | null | undefined,
+ labels: Record | null | undefined,
+ wanted: string[],
+ fetchImpl: typeof fetch = fetch
+): Promise {
+ const candidates = resolveReleaseSourceCandidates(imageName, labels);
+
+ // Preserve a rate-limit signal even when every guessed candidate is discarded:
+ // the notes may be hidden purely because GitHub's unauthenticated limit is spent,
+ // and the UI's "set a GitHub token" hint keys off this flag.
+ let sawRateLimit = false;
+ for (const src of candidates) {
+ const result = await fetchReleaseNotes(src, wanted, fetchImpl);
+ sawRateLimit ||= result.rateLimited;
+ // A confident source is authoritative even with no matched notes (the newer
+ // tag may simply predate the API's recent-releases window). A guessed source
+ // is only trusted once a note actually matches a wanted version.
+ if (!src.needsValidation || result.notes.length > 0) {
+ return { ...result, source: src };
+ }
+ }
+
+ return { notes: [], rateLimited: sawRateLimit, source: null };
+}
diff --git a/src/lib/server/semver/release-source.ts b/src/lib/server/semver/release-source.ts
index c62f888..40d2841 100644
--- a/src/lib/server/semver/release-source.ts
+++ b/src/lib/server/semver/release-source.ts
@@ -12,8 +12,11 @@
* `org.opencontainers.image.source` label wins, then a `ghcr.io//`
* image name (always GitHub).
*
- * Pure, no I/O, unit-tested. Images with no recognizable source resolve to null
- * (we never guess a repo - a wrong repo is worse than no notes).
+ * Pure, no I/O, unit-tested. `resolveReleaseSource` returns only a CONFIDENT source
+ * (label or ghcr name) or null - it never guesses. `resolveReleaseSourceCandidates`
+ * additionally offers GUESSED fallbacks (image name / other labels) that the caller
+ * must validate against the wanted version before trusting (a wrong repo is worse
+ * than no notes).
*/
const GHCR_PREFIX = 'ghcr.io/';
@@ -28,6 +31,10 @@ export interface ReleaseSource {
apiBase: string;
/** Human-facing releases page (for the "View releases" fallback link). */
releasesUrl: string;
+ /** True for a GUESSED source (image name / a non-source label). A guessed repo is
+ * only trusted once the API confirms a release matching the wanted version -
+ * the caller drops it if no note matches (a wrong repo is worse than no notes). */
+ needsValidation?: boolean;
}
/** Pull `owner/repo` out of a forge URL, tolerating trailing slash / `.git`. */
@@ -99,6 +106,70 @@ export function resolveReleaseSource(
return null;
}
+/** Pull an `owner/repo` slug from a github.com or ghcr.io URL anywhere in a string. */
+function githubSlugFromText(text: string): string | null {
+ const m = text.match(/(?:github\.com|ghcr\.io)\/([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)/);
+ if (!m || !m[1] || !m[2]) return null;
+ return `${m[1].toLowerCase()}/${m[2].replace(/\.git$/, '').toLowerCase()}`;
+}
+
+/** `owner/repo` from an image name, stripping registry host, tag and digest. */
+function slugFromImageName(imageName: string): string | null {
+ const repo = stripImageTag(imageName);
+ const parts = repo.split('/');
+ // Drop a leading registry host segment (has a dot or a port), e.g. ghcr.io, docker.io.
+ if (parts.length > 2 && /[.:]/.test(parts[0])) parts.shift();
+ if (parts.length < 2 || !parts[0] || !parts[1]) return null;
+ return `${parts[0]}/${parts[1]}`;
+}
+
+/**
+ * Resolve release-note sources for an image, best-first. The first entry (if any)
+ * is the CONFIDENT source (`org.opencontainers.image.source` label, or a ghcr image
+ * name). Any following entries are GUESSED fallbacks (`needsValidation: true`), used
+ * only when no confident source exists: an `owner/repo` slug taken from the image
+ * name treated as a GitHub repo, and any github.com / ghcr.io URL found in OTHER
+ * labels/annotations. Guessed candidates are deduped, capped (a crafted label set
+ * must not fan out into many outbound API calls), and must be validated by the
+ * caller (a release tag matching the wanted version) before their notes are shown.
+ */
+export function resolveReleaseSourceCandidates(
+ imageName: string | null | undefined,
+ labels?: Record | null
+): ReleaseSource[] {
+ const confident = resolveReleaseSource(imageName, labels);
+ if (confident) return [confident];
+
+ // Cap guessed candidates: each becomes one outbound GitHub API call at fetch time,
+ // and a container can carry arbitrarily many labels, so a crafted label set must
+ // not amplify into a fan-out. The real source is almost always the first one or two.
+ const MAX_GUESSES = 5;
+ const seen = new Set();
+ const candidates: ReleaseSource[] = [];
+ const add = (slug: string | null) => {
+ if (!slug || candidates.length >= MAX_GUESSES) return;
+ const key = slug.toLowerCase();
+ if (seen.has(key)) return;
+ seen.add(key);
+ candidates.push({ ...githubSource(key), needsValidation: true });
+ };
+
+ // (a) owner/repo from the image name, as a GitHub repo (covers Docker Hub
+ // images whose name mirrors their GitHub slug, e.g. grafana/grafana).
+ if (imageName) add(slugFromImageName(imageName));
+
+ // (b) any github.com / ghcr.io URL in a NON-source label or annotation
+ // (url, documentation, vendor-specific), first match per label.
+ if (labels) {
+ for (const [k, v] of Object.entries(labels)) {
+ if (k === 'org.opencontainers.image.source' || !v) continue;
+ add(githubSlugFromText(v));
+ }
+ }
+
+ return candidates;
+}
+
function githubSource(slug: string): ReleaseSource {
return {
kind: 'github',
diff --git a/src/lib/utils/inspect-to-compose.ts b/src/lib/utils/inspect-to-compose.ts
index d905b67..39e3708 100644
--- a/src/lib/utils/inspect-to-compose.ts
+++ b/src/lib/utils/inspect-to-compose.ts
@@ -26,6 +26,9 @@ export interface DockerInspect {
Domainname?: string;
OpenStdin?: boolean;
Tty?: boolean;
+ StopSignal?: string;
+ StopTimeout?: number | null;
+ ExposedPorts?: Record | null;
Healthcheck?: {
Test?: string[];
Interval?: number;
@@ -44,9 +47,28 @@ export interface DockerInspect {
ReadonlyRootfs?: boolean;
NetworkMode?: string;
IpcMode?: string;
+ PidMode?: string;
+ UsernsMode?: string;
+ CgroupParent?: string;
+ Runtime?: string;
+ Init?: boolean | null;
ShmSize?: number;
Dns?: string[] | null;
+ DnsSearch?: string[] | null;
+ DnsOptions?: string[] | null;
ExtraHosts?: string[] | null;
+ GroupAdd?: string[] | null;
+ SecurityOpt?: string[] | null;
+ VolumesFrom?: string[] | null;
+ OomKillDisable?: boolean | null;
+ OomScoreAdj?: number;
+ Memory?: number;
+ MemoryReservation?: number;
+ MemorySwap?: number;
+ NanoCpus?: number;
+ CpuShares?: number;
+ CpusetCpus?: string;
+ PidsLimit?: number | null;
Devices?: Array<{ PathOnHost: string; PathInContainer: string; CgroupPermissions?: string }> | null;
Sysctls?: Record | null;
Tmpfs?: Record | null;
@@ -54,7 +76,14 @@ export interface DockerInspect {
LogConfig?: { Type?: string; Config?: Record | null };
};
NetworkSettings?: {
- Networks?: Record | null;
+ Networks?: Record<
+ string,
+ {
+ Aliases?: string[] | null;
+ MacAddress?: string;
+ IPAMConfig?: { IPv4Address?: string; IPv6Address?: string } | null;
+ } | null
+ > | null;
};
Mounts?: Array<{
Type?: string;
@@ -78,6 +107,8 @@ export interface InspectToComposeOptions {
imageCmd?: string[] | null;
/** The image's own `Config.Labels`. Labels whose value matches the image are dropped (image-baked, e.g. `maintainer`). */
imageLabels?: Record | null;
+ /** The image's own `Config.ExposedPorts`. Ports the image itself EXPOSEs are dropped from `expose:`. */
+ imageExposedPorts?: Record | null;
/** Override the service key (defaults to the container name). */
serviceName?: string;
}
@@ -85,6 +116,37 @@ export interface InspectToComposeOptions {
/** Networks Docker always has; a container on one of these alone has no user-set network. */
const DEFAULT_NETWORKS = new Set(['bridge', 'host', 'none', 'default']);
+/**
+ * Per-network compose config from a container's NetworkSettings.Networks entry. Keeps only
+ * user-set detail: static IP (IPAMConfig), a per-network MAC, and EXTRA aliases. Docker/compose
+ * auto-add three aliases that are NOT user-set and would be noise: the service key we emit, the
+ * 12-hex short container id, and (for a compose-managed container) the `com.docker.compose.service`
+ * name. Returns null when the network carries nothing worth emitting (a bare attachment).
+ */
+function networkConfig(
+ info: { Aliases?: string[] | null; MacAddress?: string; IPAMConfig?: { IPv4Address?: string; IPv6Address?: string } | null } | null,
+ serviceName: string,
+ composeServiceName?: string
+): Record | null {
+ if (!info) return null;
+ const cfg: Record = {};
+ const v4 = info.IPAMConfig?.IPv4Address;
+ const v6 = info.IPAMConfig?.IPv6Address;
+ if (v4) cfg.ipv4_address = v4;
+ if (v6) cfg.ipv6_address = v6;
+ const auto = new Set([serviceName, composeServiceName].filter(Boolean) as string[]);
+ const aliases = (info.Aliases ?? []).filter((a) => !auto.has(a) && !/^[0-9a-f]{12}$/.test(a));
+ if (aliases.length > 0) cfg.aliases = aliases;
+ if (info.MacAddress) cfg.mac_address = info.MacAddress;
+ return Object.keys(cfg).length > 0 ? cfg : null;
+}
+
+/**
+ * NetworkMode values that mean "Docker's default bridge networking" - emitting them adds
+ * nothing. `host`/`none`/`container:x`/a service name are deliberate modes and ARE emitted.
+ */
+const DEFAULT_NETWORK_MODES = new Set(['default', 'bridge']);
+
/**
* A 64-char hex name is Docker's auto-generated ANONYMOUS volume id (it carries the
* `com.docker.volume.anonymous` label, not visible in the container inspect). Such a
@@ -140,6 +202,7 @@ export function inspectToComposeService(
const config = inspect.Config ?? {};
const host = inspect.HostConfig ?? {};
const name = options.serviceName ?? cleanName(inspect.Name);
+ const composeServiceName = config.Labels?.['com.docker.compose.service'];
const service: Record = {};
if (config.Image) service.image = config.Image;
@@ -219,14 +282,23 @@ export function inspectToComposeService(
}
}
- // Networks: a container on a real (non-default) network gets `networks: [names]`.
- // A non-default `network_mode` (host / container:x / a service name) is emitted as-is.
- const networks = Object.keys(inspect.NetworkSettings?.Networks ?? {}).filter((n) => !DEFAULT_NETWORKS.has(n));
- if (networks.length > 0) {
- service.networks = networks;
+ // Networks: a container on real (non-default) networks gets `networks:`. Each network keeps
+ // its user-set details (static IP, extra aliases, per-network MAC); a bare network is left
+ // null. If NO network carries any detail we emit the short list form (zero noise); otherwise
+ // the map form. A non-default `network_mode` (host / none / container:x / a service name) is
+ // emitted instead, only when the container has no real networks.
+ const netEntries = Object.entries(inspect.NetworkSettings?.Networks ?? {}).filter(
+ ([n]) => !DEFAULT_NETWORKS.has(n)
+ );
+ if (netEntries.length > 0) {
+ const configs = netEntries.map(([n, info]) => [n, networkConfig(info, name, composeServiceName)] as const);
+ const anyDetail = configs.some(([, cfg]) => cfg !== null);
+ service.networks = anyDetail
+ ? Object.fromEntries(configs)
+ : configs.map(([n]) => n);
} else {
const mode = host.NetworkMode;
- if (mode && !DEFAULT_NETWORKS.has(mode)) service.network_mode = mode;
+ if (mode && !DEFAULT_NETWORK_MODES.has(mode)) service.network_mode = mode;
}
if (host.CapAdd && host.CapAdd.length > 0) service.cap_add = host.CapAdd;
@@ -244,6 +316,8 @@ export function inspectToComposeService(
if (host.Sysctls && Object.keys(host.Sysctls).length > 0) service.sysctls = host.Sysctls;
if (host.Tmpfs && Object.keys(host.Tmpfs).length > 0) service.tmpfs = Object.keys(host.Tmpfs);
if (host.Dns && host.Dns.length > 0) service.dns = host.Dns;
+ if (host.DnsSearch && host.DnsSearch.length > 0) service.dns_search = host.DnsSearch;
+ if (host.DnsOptions && host.DnsOptions.length > 0) service.dns_opt = host.DnsOptions;
if (host.ExtraHosts && host.ExtraHosts.length > 0) service.extra_hosts = host.ExtraHosts;
if (Array.isArray(host.Ulimits) && host.Ulimits.length > 0) {
@@ -269,11 +343,58 @@ export function inspectToComposeService(
service.hostname = config.Hostname;
}
if (host.IpcMode && host.IpcMode !== 'private' && host.IpcMode !== '') service.ipc = host.IpcMode;
- // shm_size deliberately NOT emitted: its default is host-derived (a fraction of RAM),
- // so there's no fixed value to compare against and it would almost always be noise.
if (config.OpenStdin) service.stdin_open = true;
if (config.Tty) service.tty = true;
+ // Resource limits: emit the TOP-LEVEL standalone compose keys (docker compose up honors them
+ // on a plain container; `deploy.resources` is swarm-only). Each skips its daemon default.
+ if (host.Memory && host.Memory > 0) service.mem_limit = host.Memory;
+ if (host.MemoryReservation && host.MemoryReservation > 0) service.mem_reservation = host.MemoryReservation;
+ // MemorySwap: -1 = unlimited swap, 0 = unset; only meaningful alongside a memory limit.
+ if (host.MemorySwap && (host.MemorySwap > 0 || host.MemorySwap === -1)) service.memswap_limit = host.MemorySwap;
+ // NanoCpus is nano-cores; compose `cpus` is a decimal core count.
+ if (host.NanoCpus && host.NanoCpus > 0) service.cpus = host.NanoCpus / 1e9;
+ // CpuShares: default 0, and 1024 is the daemon's neutral weight - neither is user intent.
+ if (host.CpuShares && host.CpuShares !== 1024) service.cpu_shares = host.CpuShares;
+ if (host.CpusetCpus && host.CpusetCpus !== '') service.cpuset = host.CpusetCpus;
+ // PidsLimit: 0/null = unset, -1 = unlimited (the default); emit only a real positive cap.
+ if (host.PidsLimit && host.PidsLimit > 0) service.pids_limit = host.PidsLimit;
+
+ // Lifecycle: stop signal/grace, ports EXPOSEd-but-not-published, supplementary groups, init.
+ if (config.StopSignal && config.StopSignal !== 'SIGTERM') service.stop_signal = config.StopSignal;
+ if (typeof config.StopTimeout === 'number' && config.StopTimeout > 0) {
+ service.stop_grace_period = `${config.StopTimeout}s`;
+ }
+ const exposed = config.ExposedPorts ? Object.keys(config.ExposedPorts) : [];
+ if (exposed.length > 0) {
+ const published = new Set(Object.keys(host.PortBindings ?? {}));
+ const imageExposed = new Set(Object.keys(options.imageExposedPorts ?? {}));
+ // Only ports with NO host binding that the image itself did not already EXPOSE.
+ const expose = exposed
+ .filter((p) => !published.has(p) && !imageExposed.has(p))
+ .map((p) => {
+ const [port, proto] = p.split('/');
+ return proto && proto !== 'tcp' ? `${port}/${proto}` : port;
+ });
+ if (expose.length > 0) service.expose = expose;
+ }
+ if (host.GroupAdd && host.GroupAdd.length > 0) service.group_add = host.GroupAdd;
+ if (host.Init === true) service.init = true;
+
+ // Runtime/security options (niche but real user intent); each skips its daemon default.
+ if (host.SecurityOpt && host.SecurityOpt.length > 0) service.security_opt = host.SecurityOpt;
+ if (host.PidMode && host.PidMode !== '') service.pid = host.PidMode;
+ if (host.UsernsMode && host.UsernsMode !== '') service.userns_mode = host.UsernsMode;
+ if (host.CgroupParent && host.CgroupParent !== '') service.cgroup_parent = host.CgroupParent;
+ // Runtime: runc is the default; nvidia/runsc/kata are deliberate.
+ if (host.Runtime && host.Runtime !== '' && host.Runtime !== 'runc') service.runtime = host.Runtime;
+ // shm_size: Docker's default is a fixed 64 MiB (67108864); emit only a differing value.
+ if (host.ShmSize && host.ShmSize > 0 && host.ShmSize !== 67108864) service.shm_size = host.ShmSize;
+ if (host.VolumesFrom && host.VolumesFrom.length > 0) service.volumes_from = host.VolumesFrom;
+ if (host.OomKillDisable === true) service.oom_kill_disable = true;
+ if (host.OomScoreAdj && host.OomScoreAdj !== 0) service.oom_score_adj = host.OomScoreAdj;
+ if (config.Domainname && config.Domainname !== '') service.domainname = config.Domainname;
+
// Healthcheck: a real Test (not the image-inherited empty / NONE) maps to compose form.
const hc = config.Healthcheck;
if (hc && Array.isArray(hc.Test) && hc.Test.length > 0 && hc.Test[0] !== 'NONE') {
@@ -303,9 +424,16 @@ export function inspectToCompose(inspect: DockerInspect, options: InspectToCompo
const { name, service, namedVolumes } = inspectToComposeService(inspect, options);
const doc: Record = { services: { [name]: service } };
- const networks = service.networks as string[] | undefined;
- if (networks && networks.length > 0) {
- doc.networks = Object.fromEntries(networks.map((n) => [n, { external: true }]));
+ // service.networks is either a string[] (bare) or a map {name: {ipv4_address,...}}; the
+ // top-level declaration just needs the names, each marked external (they already exist).
+ const svcNetworks = service.networks as string[] | Record | undefined;
+ const networkNames = Array.isArray(svcNetworks)
+ ? svcNetworks
+ : svcNetworks
+ ? Object.keys(svcNetworks)
+ : [];
+ if (networkNames.length > 0) {
+ doc.networks = Object.fromEntries(networkNames.map((n) => [n, { external: true }]));
}
if (namedVolumes.length > 0) {
diff --git a/src/routes/api/containers/[id]/compose/+server.ts b/src/routes/api/containers/[id]/compose/+server.ts
index dd99695..be75386 100644
--- a/src/routes/api/containers/[id]/compose/+server.ts
+++ b/src/routes/api/containers/[id]/compose/+server.ts
@@ -62,6 +62,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
let imageEntrypoint: string[] | null = null;
let imageCmd: string[] | null = null;
let imageLabels: Record | null = null;
+ let imageExposedPorts: Record | null = null;
try {
const imageRef = containerData.Config?.Image;
if (imageRef) {
@@ -70,6 +71,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
imageEntrypoint = imageData?.Config?.Entrypoint ?? null;
imageCmd = imageData?.Config?.Cmd ?? null;
imageLabels = imageData?.Config?.Labels ?? null;
+ imageExposedPorts = imageData?.Config?.ExposedPorts ?? null;
}
} catch {
// image not present / not pullable - fall back to keeping all values
@@ -80,14 +82,16 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
imageEnv,
imageEntrypoint,
imageCmd,
- imageLabels
+ imageLabels,
+ imageExposedPorts
});
// Same document but keeping every env var (the "only user-set env" toggle off).
const composeFullEnv = inspectToCompose(containerData, {
serviceName,
imageEntrypoint,
imageCmd,
- imageLabels
+ imageLabels,
+ imageExposedPorts
});
return json({ compose, composeFullEnv, serviceName, stackProject });
diff --git a/src/routes/api/containers/[id]/version-notes/+server.ts b/src/routes/api/containers/[id]/version-notes/+server.ts
index 274ffc0..0628019 100644
--- a/src/routes/api/containers/[id]/version-notes/+server.ts
+++ b/src/routes/api/containers/[id]/version-notes/+server.ts
@@ -3,9 +3,8 @@ 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';
+import { resolveAndFetchReleaseNotes } from '$lib/server/semver/release-notes';
/**
* GET /api/containers/{id}/version-notes - Release notes for a newer version tag
@@ -43,18 +42,20 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const imageName: string | undefined = inspectData.Config?.Image;
const labels: Record = inspectData.Config?.Labels ?? {};
- const src = resolveReleaseSource(imageName, labels);
- // Prefer the label-based changelog URL; fall back to the forge's releases page.
- // `versions` is ordered oldest->newest (the semver path), so the LAST entry is
- // the target tag - feed it to the label so a `{{version}}` template resolves to
- // the version being offered.
+ // Resolve the source and fetch notes together: a confident forge (image.source
+ // label / ghcr name) is used directly; otherwise a guessed repo (image name /
+ // another label) is accepted only once a release matches a wanted version.
+ const result = await resolveAndFetchReleaseNotes(imageName, labels, versions);
+
+ // Prefer the label-based changelog URL; fall back to the accepted forge's
+ // releases page. `versions` is ordered oldest->newest (the semver path), so the
+ // LAST entry is the target tag - feed it to the label so a `{{version}}` template
+ // resolves to the version being offered.
const targetVersion = versions.length ? versions[versions.length - 1] : null;
- const changelogUrl = resolveChangelogUrl(imageName, labels, targetVersion) ?? src?.releasesUrl ?? null;
+ const changelogUrl =
+ resolveChangelogUrl(imageName, labels, targetVersion) ?? result.source?.releasesUrl ?? null;
- // No recognizable forge -> no per-version notes, just the changelog link (if any).
- const result = src ? await fetchReleaseNotes(src, versions) : { notes: [], rateLimited: false };
-
- return json({ changelogUrl, source: src?.slug ?? null, notes: result.notes, rateLimited: result.rateLimited });
+ return json({ changelogUrl, source: result.source?.slug ?? null, notes: result.notes, rateLimited: result.rateLimited });
} catch (error) {
console.error('Failed to resolve version notes:', error);
return json({ error: 'Failed to resolve release notes' }, { status: 500 });
diff --git a/tests/compose-validate/rules.test.ts b/tests/compose-validate/rules.test.ts
index b469615..c890e75 100644
--- a/tests/compose-validate/rules.test.ts
+++ b/tests/compose-validate/rules.test.ts
@@ -13,7 +13,7 @@ function find(src: string, ruleId: string, ctx: ValidateContext = {}) {
describe('parse + line tracking', () => {
test('a clean compose yields no findings', () => {
- const src = `services:\n web:\n image: nginx:1.27\n restart: unless-stopped\n ports:\n - "127.0.0.1:8080:80"\n`;
+ const src = `services:\n web:\n image: nginx:1.27\n restart: unless-stopped\n healthcheck:\n test: ["CMD", "true"]\n ports:\n - "127.0.0.1:8080:80"\n`;
expect(runValidate(src).findings).toEqual([]);
});
@@ -358,6 +358,34 @@ describe('secret + security + reliability rules', () => {
const src = `services:\n a:\n image: x\n deploy:\n restart_policy:\n condition: any\n`;
expect(ids(src)).not.toContain('MISSING_RESTART_POLICY');
});
+
+ test('MISSING_HEALTHCHECK fires when absent, not when present or explicitly disabled', () => {
+ expect(ids(`services:\n a:\n image: x\n`)).toContain('MISSING_HEALTHCHECK');
+ expect(ids(`services:\n a:\n image: x\n healthcheck:\n test: ["CMD", "true"]\n`)).not.toContain('MISSING_HEALTHCHECK');
+ expect(ids(`services:\n a:\n image: x\n healthcheck:\n disable: true\n`)).not.toContain('MISSING_HEALTHCHECK');
+ });
+ test('MISSING_HEALTHCHECK is info severity', () => {
+ const f = find(`services:\n a:\n image: x\n`, 'MISSING_HEALTHCHECK');
+ expect(f?.severity).toBe('info');
+ });
+
+ test('UNUSED_VOLUME fires for a defined-but-unmounted named volume, not for a used one', () => {
+ expect(ids(`services:\n a:\n image: x\nvolumes:\n data: {}\n`)).toContain('UNUSED_VOLUME');
+ expect(ids(`services:\n a:\n image: x\n volumes:\n - data:/d\nvolumes:\n data: {}\n`)).not.toContain('UNUSED_VOLUME');
+ });
+ test('UNUSED_VOLUME ignores an external volume (may be mounted elsewhere)', () => {
+ expect(ids(`services:\n a:\n image: x\nvolumes:\n data:\n external: true\n`)).not.toContain('UNUSED_VOLUME');
+ });
+ test('UNUSED_VOLUME does not fire when no top-level volumes are defined', () => {
+ expect(ids(`services:\n a:\n image: x\n volumes:\n - ./d:/d\n`)).not.toContain('UNUSED_VOLUME');
+ });
+ test('UNUSED_VOLUME recognizes a long-form { type: volume, source } mount as usage', () => {
+ expect(
+ ids(
+ `services:\n a:\n image: x\n volumes:\n - type: volume\n source: data\n target: /d\nvolumes:\n data: {}\n`
+ )
+ ).not.toContain('UNUSED_VOLUME');
+ });
});
describe('review regressions (found by the final audit)', () => {
diff --git a/tests/inspect-to-compose.test.ts b/tests/inspect-to-compose.test.ts
index 6ede1bb..fee0e9f 100644
--- a/tests/inspect-to-compose.test.ts
+++ b/tests/inspect-to-compose.test.ts
@@ -55,7 +55,12 @@ describe('inspectToCompose - real gitea inspect (compose-managed, named-vol, cus
test('the custom stack network is emitted and declared external at top level', () => {
const { doc } = parseCompose(gitea);
const svc = doc.services['semver-demo-stack-gitea-1'];
- expect(svc.networks).toEqual(['semver-demo-stack_default']);
+ // The network carries a per-container MAC, so it renders in the map form. Its auto
+ // aliases (the container name + the compose service name) are stripped, leaving no
+ // user aliases - only the mac_address.
+ expect(svc.networks).toEqual({
+ 'semver-demo-stack_default': { mac_address: 'f6:1f:4a:64:b6:74' }
+ });
expect(doc.networks['semver-demo-stack_default']).toEqual({ external: true });
// bare bridge/default are never emitted
expect(svc.network_mode).toBeUndefined();
@@ -128,14 +133,17 @@ describe('inspectToCompose - real nginx inspect (published ports, bind :ro, name
expect(labels['my.custom.label']).toBe('hello');
});
- test('auto-generated noise (hostname, working_dir /, shm_size) is not emitted', () => {
+ test('auto-generated noise (hostname, working_dir /) is not emitted', () => {
const { doc } = parseCompose(nginx);
const svc = doc.services[svcName];
- // Hostname is the short container id (12-hex) -> dropped; working_dir is "/" -> dropped;
- // shm_size is host-derived -> never emitted.
+ // Hostname is the short container id (12-hex) -> dropped; working_dir is "/" -> dropped.
expect(svc.hostname).toBeUndefined();
expect(svc.working_dir).toBeUndefined();
- expect(svc.shm_size).toBeUndefined();
+ });
+
+ test('a non-default shm_size is emitted (this fixture set --shm-size)', () => {
+ const svc = parseCompose(nginx).doc.services[svcName];
+ expect(svc.shm_size).toBe(8399093760);
});
test('image-default entrypoint/command are dropped when the image values are provided', () => {
@@ -196,8 +204,277 @@ describe('inspectToCompose - default-noise filtering', () => {
expect(svc.image).toBe('busybox:latest');
});
+ test('network_mode: host is emitted (a host-mode container must not fall back to bridge)', () => {
+ // #1464: a host-mode container reports NetworkMode "host" AND a `host` entry in
+ // NetworkSettings.Networks. The Networks entry is filtered (we never want
+ // `networks: [host]`), so the mode must be emitted from the else branch.
+ const inspect: DockerInspect = {
+ Name: '/hostnet',
+ Config: { Image: 'busybox:latest' },
+ HostConfig: { NetworkMode: 'host' },
+ NetworkSettings: { Networks: { host: {} } }
+ };
+ const svc = parseCompose(inspect).doc.services['hostnet'];
+ expect(svc.network_mode).toBe('host');
+ expect(svc.networks).toBeUndefined();
+ });
+
+ test('network_mode: none is emitted (disabled networking is a deliberate choice)', () => {
+ const inspect: DockerInspect = {
+ Name: '/nonet',
+ Config: { Image: 'busybox:latest' },
+ HostConfig: { NetworkMode: 'none' },
+ NetworkSettings: { Networks: { none: {} } }
+ };
+ const svc = parseCompose(inspect).doc.services['nonet'];
+ expect(svc.network_mode).toBe('none');
+ expect(svc.networks).toBeUndefined();
+ });
+
+ test('network_mode: default produces no network_mode key', () => {
+ const inspect: DockerInspect = {
+ Name: '/def',
+ Config: { Image: 'busybox:latest' },
+ HostConfig: { NetworkMode: 'default' },
+ NetworkSettings: { Networks: { bridge: {} } }
+ };
+ const svc = parseCompose(inspect).doc.services['def'];
+ expect(svc.network_mode).toBeUndefined();
+ expect(svc.networks).toBeUndefined();
+ });
+
test('serviceName option overrides the container name', () => {
const { name } = inspectToComposeService({ Name: '/original', Config: { Image: 'x' } }, { serviceName: 'renamed' });
expect(name).toBe('renamed');
});
});
+
+describe('inspectToCompose - full network model (static IP, aliases, MAC)', () => {
+ test('static IPv4/IPv6, user aliases and MAC render as a per-network map', () => {
+ const inspect: DockerInspect = {
+ Name: '/app',
+ Config: { Image: 'nginx:1.27' },
+ HostConfig: {},
+ NetworkSettings: {
+ Networks: {
+ backend: {
+ Aliases: ['app', 'db.internal', 'a1b2c3d4e5f6'],
+ MacAddress: '02:42:ac:11:00:05',
+ IPAMConfig: { IPv4Address: '10.5.0.9', IPv6Address: 'fd00::9' }
+ }
+ }
+ }
+ };
+ const svc = parseCompose(inspect).doc.services['app'];
+ // 'app' (service key) and the 12-hex short id are stripped; 'db.internal' survives.
+ expect(svc.networks).toEqual({
+ backend: {
+ ipv4_address: '10.5.0.9',
+ ipv6_address: 'fd00::9',
+ aliases: ['db.internal'],
+ mac_address: '02:42:ac:11:00:05'
+ }
+ });
+ expect(svc.network_mode).toBeUndefined();
+ });
+
+ test('a compose service alias (com.docker.compose.service) is stripped as auto', () => {
+ const inspect: DockerInspect = {
+ Name: '/proj-web-1',
+ Config: { Image: 'nginx', Labels: { 'com.docker.compose.service': 'web' } },
+ HostConfig: {},
+ NetworkSettings: {
+ Networks: { proj_default: { Aliases: ['proj-web-1', 'web'], IPAMConfig: { IPv4Address: '10.1.0.2' } } }
+ }
+ };
+ const svc = parseCompose(inspect).doc.services['proj-web-1'];
+ // both 'proj-web-1' (name) and 'web' (compose service) are auto; only the static IP is left.
+ expect(svc.networks).toEqual({ proj_default: { ipv4_address: '10.1.0.2' } });
+ });
+
+ test('bare networks with no detail stay in the short list form (zero noise)', () => {
+ const inspect: DockerInspect = {
+ Name: '/app',
+ Config: { Image: 'nginx' },
+ HostConfig: {},
+ NetworkSettings: { Networks: { frontend: { Aliases: ['app'] }, backend: {} } }
+ };
+ const svc = parseCompose(inspect).doc.services['app'];
+ expect(svc.networks).toEqual(['frontend', 'backend']);
+ });
+
+ test('dns_search and dns_opt are emitted alongside dns', () => {
+ const inspect: DockerInspect = {
+ Name: '/app',
+ Config: { Image: 'nginx' },
+ HostConfig: { Dns: ['1.1.1.1'], DnsSearch: ['corp.local'], DnsOptions: ['ndots:2'] }
+ };
+ const svc = parseCompose(inspect).doc.services['app'];
+ expect(svc.dns).toEqual(['1.1.1.1']);
+ expect(svc.dns_search).toEqual(['corp.local']);
+ expect(svc.dns_opt).toEqual(['ndots:2']);
+ });
+});
+
+describe('inspectToCompose - resource limits', () => {
+ test('memory + cpu + pids limits map to the standalone top-level keys', () => {
+ const inspect: DockerInspect = {
+ Name: '/app',
+ Config: { Image: 'nginx' },
+ HostConfig: {
+ Memory: 536870912,
+ MemoryReservation: 268435456,
+ MemorySwap: 1073741824,
+ NanoCpus: 1500000000,
+ CpusetCpus: '0-1',
+ PidsLimit: 200
+ }
+ };
+ const svc = parseCompose(inspect).doc.services['app'];
+ expect(svc.mem_limit).toBe(536870912);
+ expect(svc.mem_reservation).toBe(268435456);
+ expect(svc.memswap_limit).toBe(1073741824);
+ expect(svc.cpus).toBe(1.5); // 1.5e9 nanocpus -> 1.5 cores
+ expect(svc.cpuset).toBe('0-1');
+ expect(svc.pids_limit).toBe(200);
+ });
+
+ test('daemon-default resource values are skipped', () => {
+ const inspect: DockerInspect = {
+ Name: '/app',
+ Config: { Image: 'nginx' },
+ HostConfig: {
+ Memory: 0,
+ MemorySwap: 0,
+ NanoCpus: 0,
+ CpuShares: 1024, // neutral weight - not user intent
+ CpusetCpus: '',
+ PidsLimit: -1 // unlimited (the default)
+ }
+ };
+ const svc = parseCompose(inspect).doc.services['app'];
+ expect(svc.mem_limit).toBeUndefined();
+ expect(svc.memswap_limit).toBeUndefined();
+ expect(svc.cpus).toBeUndefined();
+ expect(svc.cpu_shares).toBeUndefined();
+ expect(svc.cpuset).toBeUndefined();
+ expect(svc.pids_limit).toBeUndefined();
+ });
+
+ test('cpu_shares is emitted for a non-neutral weight', () => {
+ const svc = parseCompose({
+ Name: '/app',
+ Config: { Image: 'nginx' },
+ HostConfig: { CpuShares: 512 }
+ }).doc.services['app'];
+ expect(svc.cpu_shares).toBe(512);
+ });
+
+ test('memswap_limit -1 (unlimited swap) is a real user value and is emitted', () => {
+ const svc = parseCompose({
+ Name: '/app',
+ Config: { Image: 'nginx' },
+ HostConfig: { Memory: 536870912, MemorySwap: -1 }
+ }).doc.services['app'];
+ expect(svc.memswap_limit).toBe(-1);
+ });
+});
+
+describe('inspectToCompose - lifecycle (stop/expose/group_add/init)', () => {
+ test('stop_signal + stop_grace_period from a non-default StopSignal/StopTimeout', () => {
+ const svc = parseCompose({
+ Name: '/app',
+ Config: { Image: 'nginx', StopSignal: 'SIGINT', StopTimeout: 30 },
+ HostConfig: {}
+ }).doc.services['app'];
+ expect(svc.stop_signal).toBe('SIGINT');
+ expect(svc.stop_grace_period).toBe('30s');
+ });
+
+ test('default SIGTERM and null StopTimeout are skipped', () => {
+ const svc = parseCompose({
+ Name: '/app',
+ Config: { Image: 'nginx', StopSignal: 'SIGTERM', StopTimeout: null },
+ HostConfig: {}
+ }).doc.services['app'];
+ expect(svc.stop_signal).toBeUndefined();
+ expect(svc.stop_grace_period).toBeUndefined();
+ });
+
+ test('expose lists ports that are exposed but neither published nor image-baked', () => {
+ const inspect: DockerInspect = {
+ Name: '/app',
+ Config: { Image: 'nginx', ExposedPorts: { '80/tcp': {}, '9000/tcp': {}, '53/udp': {} } },
+ HostConfig: { PortBindings: { '80/tcp': [{ HostPort: '8080' }] } }
+ };
+ // image itself EXPOSEs 9000; that must not leak into expose.
+ const svc = parseCompose(inspect, { imageExposedPorts: { '9000/tcp': {} } }).doc.services['app'];
+ // 80 is published (in ports), 9000 is image-baked -> only 53/udp remains.
+ expect(svc.expose).toEqual(['53/udp']);
+ });
+
+ test('group_add and init: true are emitted', () => {
+ const svc = parseCompose({
+ Name: '/app',
+ Config: { Image: 'nginx' },
+ HostConfig: { GroupAdd: ['docker'], Init: true }
+ }).doc.services['app'];
+ expect(svc.group_add).toEqual(['docker']);
+ expect(svc.init).toBe(true);
+ });
+
+ test('init: null / false is not emitted', () => {
+ const svcNull = parseCompose({ Name: '/a', Config: { Image: 'x' }, HostConfig: { Init: null } }).doc.services['a'];
+ const svcFalse = parseCompose({ Name: '/b', Config: { Image: 'x' }, HostConfig: { Init: false } }).doc.services['b'];
+ expect(svcNull.init).toBeUndefined();
+ expect(svcFalse.init).toBeUndefined();
+ });
+});
+
+describe('inspectToCompose - runtime/security options', () => {
+ test('security_opt, pid, userns, cgroup_parent, runtime, shm_size, volumes_from, oom, domainname', () => {
+ const inspect: DockerInspect = {
+ Name: '/app',
+ Config: { Image: 'nginx', Domainname: 'corp.local' },
+ HostConfig: {
+ SecurityOpt: ['no-new-privileges'],
+ PidMode: 'host',
+ UsernsMode: 'host',
+ CgroupParent: '/custom.slice',
+ Runtime: 'nvidia',
+ ShmSize: 134217728, // 128MiB, differs from the 64MiB default
+ VolumesFrom: ['data-container'],
+ OomKillDisable: true,
+ OomScoreAdj: -500
+ }
+ };
+ const svc = parseCompose(inspect).doc.services['app'];
+ expect(svc.security_opt).toEqual(['no-new-privileges']);
+ expect(svc.pid).toBe('host');
+ expect(svc.userns_mode).toBe('host');
+ expect(svc.cgroup_parent).toBe('/custom.slice');
+ expect(svc.runtime).toBe('nvidia');
+ expect(svc.shm_size).toBe(134217728);
+ expect(svc.volumes_from).toEqual(['data-container']);
+ expect(svc.oom_kill_disable).toBe(true);
+ expect(svc.oom_score_adj).toBe(-500);
+ expect(svc.domainname).toBe('corp.local');
+ });
+
+ test('runc runtime, 64MiB shm_size, and oom defaults are skipped', () => {
+ const svc = parseCompose({
+ Name: '/app',
+ Config: { Image: 'nginx' },
+ HostConfig: {
+ Runtime: 'runc',
+ ShmSize: 67108864, // 64MiB default
+ OomKillDisable: false,
+ OomScoreAdj: 0
+ }
+ }).doc.services['app'];
+ expect(svc.runtime).toBeUndefined();
+ expect(svc.shm_size).toBeUndefined();
+ expect(svc.oom_kill_disable).toBeUndefined();
+ expect(svc.oom_score_adj).toBeUndefined();
+ });
+});
diff --git a/tests/semver-release-notes.test.ts b/tests/semver-release-notes.test.ts
index ee47ead..76e0794 100644
--- a/tests/semver-release-notes.test.ts
+++ b/tests/semver-release-notes.test.ts
@@ -3,7 +3,11 @@
* whose tag is the SAME numeric version, ignoring v-prefix / flavor noise.
*/
import { describe, it, expect, mock } from 'bun:test';
-import { matchReleasesToVersions, fetchReleaseNotes } from '../src/lib/server/semver/release-notes';
+import {
+ matchReleasesToVersions,
+ fetchReleaseNotes,
+ resolveAndFetchReleaseNotes
+} from '../src/lib/server/semver/release-notes';
import type { ReleaseSource } from '../src/lib/server/semver/release-source';
const rel = (tag: string, body = 'notes') => ({
@@ -119,3 +123,107 @@ describe('fetchReleaseNotes rate-limit detection', () => {
expect(r.rateLimited).toBe(false);
});
});
+
+// A fetch double that returns a fixed release list only for a specific api slug,
+// and empty for any other repo (the "wrong same-named repo" case).
+const fetchForSlug = (slug: string, tags: string[]) =>
+ mock(async (url: string) => ({
+ ok: true,
+ status: 200,
+ headers: { get: () => null },
+ json: async () =>
+ url.includes(`/repos/${slug}/`) ? tags.map((t) => rel(t)) : []
+ }));
+
+describe('resolveAndFetchReleaseNotes (validated fallback)', () => {
+ it('uses a confident source label directly', async () => {
+ const f = fetchForSlug('traefik/traefik', ['v3.1']);
+ const r = await resolveAndFetchReleaseNotes(
+ 'traefik:v3.1',
+ { 'org.opencontainers.image.source': 'https://github.com/traefik/traefik' },
+ ['3.1'],
+ f as unknown as typeof fetch
+ );
+ expect(r.source?.slug).toBe('traefik/traefik');
+ expect(r.notes.map((n) => n.version)).toEqual(['3.1']);
+ });
+
+ it('accepts a guessed image-name repo when a release matches the wanted version', async () => {
+ const f = fetchForSlug('grafana/grafana', ['v11.0.0']);
+ const r = await resolveAndFetchReleaseNotes(
+ 'grafana/grafana:11.0.0',
+ {},
+ ['11.0.0'],
+ f as unknown as typeof fetch
+ );
+ expect(r.source?.slug).toBe('grafana/grafana');
+ expect(r.source?.needsValidation).toBe(true);
+ expect(r.notes).toHaveLength(1);
+ });
+
+ it('REJECTS a guessed repo whose releases do not match the wanted version', async () => {
+ // The same-named repo exists and returns releases, but none match 11.0.0.
+ const f = fetchForSlug('grafana/grafana', ['v1.2.3', 'v0.9.0']);
+ const r = await resolveAndFetchReleaseNotes(
+ 'grafana/grafana:11.0.0',
+ {},
+ ['11.0.0'],
+ f as unknown as typeof fetch
+ );
+ expect(r.source).toBeNull();
+ expect(r.notes).toHaveLength(0);
+ });
+
+ it('returns null source for an image with no guessable repo', async () => {
+ const f = fetchForSlug('nobody/nothing', ['v1.0.0']);
+ const r = await resolveAndFetchReleaseNotes('nginx:1.25', {}, ['1.25'], f as unknown as typeof fetch);
+ expect(r.source).toBeNull();
+ expect(r.notes).toHaveLength(0);
+ });
+
+ it('preserves rateLimited when a guessed repo is discarded due to a 403 rate limit', async () => {
+ // grafana/grafana is a correct guess, but GitHub 403s (rate limit) so no notes
+ // match. The source is dropped, yet the rateLimited signal must survive so the UI
+ // can prompt for a token.
+ const f = mock(async () => ({
+ ok: false,
+ status: 403,
+ headers: { get: (k: string) => (k.toLowerCase() === 'x-ratelimit-remaining' ? '0' : null) },
+ json: async () => ({})
+ }));
+ const r = await resolveAndFetchReleaseNotes('grafana/grafana:11.0.0', {}, ['11.0.0'], f as unknown as typeof fetch);
+ expect(r.source).toBeNull();
+ expect(r.notes).toHaveLength(0);
+ expect(r.rateLimited).toBe(true);
+ });
+
+ it('caps guessed candidates so many labels do not fan out unboundedly', async () => {
+ // 8 distinct github URLs in labels + an image-name guess; only the first few are
+ // tried. None match, so we can count fetches via the mock call count staying small.
+ const labels: Record = {};
+ for (let i = 0; i < 8; i++) labels[`org.opencontainers.image.x${i}`] = `https://github.com/o${i}/r${i}`;
+ const f = mock(async () => ({ ok: true, status: 200, headers: { get: () => null }, json: async () => [] }));
+ await resolveAndFetchReleaseNotes('acme/app:1.0.0', labels, ['1.0.0'], f as unknown as typeof fetch);
+ // MAX_GUESSES = 5: image-name guess + 4 label URLs at most.
+ expect(f.mock.calls.length).toBeLessThanOrEqual(5);
+ });
+
+ it('falls through image-name guess to a matching label URL', async () => {
+ // image-name repo (acme/app) has no matching release; the url label (acme/real) does.
+ const f = mock(async (url: string) => ({
+ ok: true,
+ status: 200,
+ headers: { get: () => null },
+ json: async () =>
+ url.includes('/repos/acme/real/') ? [rel('v2.0.0')] : []
+ }));
+ const r = await resolveAndFetchReleaseNotes(
+ 'acme/app:2.0.0',
+ { 'org.opencontainers.image.url': 'https://github.com/acme/real' },
+ ['2.0.0'],
+ f as unknown as typeof fetch
+ );
+ expect(r.source?.slug).toBe('acme/real');
+ expect(r.notes).toHaveLength(1);
+ });
+});
diff --git a/tests/semver-release-source.test.ts b/tests/semver-release-source.test.ts
index 2197e5c..533ba7e 100644
--- a/tests/semver-release-source.test.ts
+++ b/tests/semver-release-source.test.ts
@@ -3,7 +3,10 @@
* forge families semver supports (GitHub, Gitea/Forgejo, GHCR).
*/
import { describe, it, expect } from 'bun:test';
-import { resolveReleaseSource } from '../src/lib/server/semver/release-source';
+import {
+ resolveReleaseSource,
+ resolveReleaseSourceCandidates
+} from '../src/lib/server/semver/release-source';
describe('resolveReleaseSource', () => {
it('resolves a GitHub source label to the GitHub releases API', () => {
@@ -60,3 +63,58 @@ describe('resolveReleaseSource', () => {
expect(resolveReleaseSource('ghcr.io/something:1')).toBeNull();
});
});
+
+describe('resolveReleaseSourceCandidates', () => {
+ it('returns the single confident source (no guesses) when a source label exists', () => {
+ const cands = resolveReleaseSourceCandidates('whatever:1', {
+ 'org.opencontainers.image.source': 'https://github.com/traefik/traefik'
+ });
+ expect(cands).toHaveLength(1);
+ expect(cands[0].slug).toBe('traefik/traefik');
+ expect(cands[0].needsValidation).toBeUndefined();
+ });
+
+ it('guesses a GitHub repo from a Docker Hub image name (grafana/grafana)', () => {
+ const cands = resolveReleaseSourceCandidates('grafana/grafana:11.0.0');
+ expect(cands).toHaveLength(1);
+ expect(cands[0].kind).toBe('github');
+ expect(cands[0].slug).toBe('grafana/grafana');
+ expect(cands[0].needsValidation).toBe(true);
+ expect(cands[0].apiBase).toBe('https://api.github.com/repos/grafana/grafana/releases');
+ });
+
+ it('drops a registry host segment before guessing (registry.io/org/app -> org/app)', () => {
+ const cands = resolveReleaseSourceCandidates('registry.example.com/acme/app:2.1');
+ expect(cands[0].slug).toBe('acme/app');
+ expect(cands[0].needsValidation).toBe(true);
+ });
+
+ it('does NOT guess a slug for a single-name official image (nginx)', () => {
+ expect(resolveReleaseSourceCandidates('nginx:1.25')).toHaveLength(0);
+ });
+
+ it('harvests a github URL from a non-source label', () => {
+ const cands = resolveReleaseSourceCandidates('nginx:1.25', {
+ 'org.opencontainers.image.url': 'https://github.com/acme/proxy'
+ });
+ expect(cands.some((c) => c.slug === 'acme/proxy' && c.needsValidation)).toBe(true);
+ });
+
+ it('dedupes the image-name guess and a matching label URL', () => {
+ const cands = resolveReleaseSourceCandidates('grafana/grafana:11', {
+ 'org.opencontainers.image.documentation': 'https://github.com/grafana/grafana/blob/main/README.md'
+ });
+ expect(cands.filter((c) => c.slug === 'grafana/grafana')).toHaveLength(1);
+ });
+
+ it('ignores the source label key when harvesting other labels', () => {
+ // A source label host that is not github/ghcr yields a confident gitea source,
+ // so candidates is just that one - no guessing happens.
+ const cands = resolveReleaseSourceCandidates('x:1', {
+ 'org.opencontainers.image.source': 'https://git.example.com/team/app'
+ });
+ expect(cands).toHaveLength(1);
+ expect(cands[0].kind).toBe('gitea');
+ expect(cands[0].needsValidation).toBeUndefined();
+ });
+});