compose generated from a container now captures network config and resource limits
This commit is contained in:
11 files changed
+831
-39
No files matched your search
@@ -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 <img> load (icon 404'd) falls back to the generic box.
|
||||
// If the <img> 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
|
||||
|
||||
@@ -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<string>();
|
||||
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',
|
||||
|
||||
@@ -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<string, string> | null | undefined,
|
||||
wanted: string[],
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<ReleaseNotesResult & { source: ReleaseSource | null }> {
|
||||
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 };
|
||||
}
|
||||
@@ -12,8 +12,11 @@
|
||||
* `org.opencontainers.image.source` label wins, then a `ghcr.io/<owner>/<repo>`
|
||||
* 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<string, string> | 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<string>();
|
||||
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',
|
||||
|
||||
@@ -26,6 +26,9 @@ export interface DockerInspect {
|
||||
Domainname?: string;
|
||||
OpenStdin?: boolean;
|
||||
Tty?: boolean;
|
||||
StopSignal?: string;
|
||||
StopTimeout?: number | null;
|
||||
ExposedPorts?: Record<string, unknown> | 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<string, string> | null;
|
||||
Tmpfs?: Record<string, string> | null;
|
||||
@@ -54,7 +76,14 @@ export interface DockerInspect {
|
||||
LogConfig?: { Type?: string; Config?: Record<string, string> | null };
|
||||
};
|
||||
NetworkSettings?: {
|
||||
Networks?: Record<string, { MacAddress?: string } | null> | 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<string, string> | null;
|
||||
/** The image's own `Config.ExposedPorts`. Ports the image itself EXPOSEs are dropped from `expose:`. */
|
||||
imageExposedPorts?: Record<string, unknown> | 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<string, unknown> | null {
|
||||
if (!info) return null;
|
||||
const cfg: Record<string, unknown> = {};
|
||||
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<string, unknown> = {};
|
||||
|
||||
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<string, unknown> = { 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<string, unknown> | 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) {
|
||||
|
||||
@@ -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<string, string> | null = null;
|
||||
let imageExposedPorts: Record<string, unknown> | 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 });
|
||||
|
||||
@@ -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<string, string> = 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 });
|
||||
|
||||
Reference in new issue
Block a user