From 49d71c01b65615ddf305e56f431ed6593f3fc65b Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Sun, 23 Aug 2026 12:08:30 +0200 Subject: [PATCH] generate a compose file from any container - save as new or append to existing stack --- src/lib/components/CodeEditor.svelte | 71 ++- src/lib/openapi.generated.json | 75 ++++ src/lib/utils/compose-merge.ts | 75 ++++ src/lib/utils/inspect-to-compose.ts | 316 ++++++++++++++ .../api/containers/[id]/compose/+server.ts | 97 +++++ src/routes/api/stacks/+server.ts | 6 +- .../containers/ContainerComposeTab.svelte | 412 ++++++++++++++++++ .../containers/ContainerInspectModal.svelte | 13 + tests/compose-merge.test.ts | 128 ++++++ tests/fixtures/inspect/gitea.json | 248 +++++++++++ tests/fixtures/inspect/nginx-rich.json | 299 +++++++++++++ tests/inspect-to-compose.test.ts | 203 +++++++++ 12 files changed, 1939 insertions(+), 4 deletions(-) create mode 100644 src/lib/utils/compose-merge.ts create mode 100644 src/lib/utils/inspect-to-compose.ts create mode 100644 src/routes/api/containers/[id]/compose/+server.ts create mode 100644 src/routes/containers/ContainerComposeTab.svelte create mode 100644 tests/compose-merge.test.ts create mode 100644 tests/fixtures/inspect/gitea.json create mode 100644 tests/fixtures/inspect/nginx-rich.json create mode 100644 tests/inspect-to-compose.test.ts diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte index 1b79da4..e7c2ee4 100644 --- a/src/lib/components/CodeEditor.svelte +++ b/src/lib/components/CodeEditor.svelte @@ -231,13 +231,16 @@ lintMarkers?: LintMarker[]; /** Fired when a lint gutter icon / underlined line is clicked (for the mini-modal). */ onLintClick?: (line: number) => void; + /** 1-based inclusive line ranges highlighted as freshly-added (green), e.g. a merged-in service. */ + addedLineMarkers?: { line: number; endLine: number }[]; } - let { value = '', language = 'yaml', readonly = false, theme = 'dark', onchange, class: className = '', variableMarkers: variableMarkersProp = [], lintMarkers: lintMarkersProp = [], onLintClick }: Props = $props(); + let { value = '', language = 'yaml', readonly = false, theme = 'dark', onchange, class: className = '', variableMarkers: variableMarkersProp = [], lintMarkers: lintMarkersProp = [], onLintClick, addedLineMarkers: addedLineMarkersProp = [] }: Props = $props(); // Keep markers reactive - destructured props with defaults lose reactivity const variableMarkers = $derived(variableMarkersProp); const lintMarkers = $derived(lintMarkersProp); + const addedLineMarkers = $derived(addedLineMarkersProp); let container: HTMLDivElement; let view: EditorView | null = null; @@ -640,6 +643,38 @@ provide: (f) => EditorView.decorations.from(f) }); + // Added-line highlight (green background) for a range of lines, e.g. a service merged + // into an existing compose. Reuses the lint decoration pattern; no gutter icon. + const updateAddedEffect = StateEffect.define<{ line: number; endLine: number }[]>(); + const currentAddedField = StateField.define<{ line: number; endLine: number }[]>({ + create: () => addedLineMarkers, + update(v, tr) { + for (const e of tr.effects) if (e.is(updateAddedEffect)) return e.value; + return v; + } + }); + + function buildAddedDecorations(doc: any, ranges: { line: number; endLine: number }[]): DecorationSet { + const decos: { from: number; deco: Decoration }[] = []; + for (const r of ranges) { + for (let line = r.line; line <= r.endLine; line++) { + if (line < 1 || line > doc.lines) continue; + decos.push({ from: doc.line(line).from, deco: Decoration.line({ class: 'cm-added-line' }) }); + } + } + decos.sort((a, b) => a.from - b.from); + return Decoration.set(decos.map((d) => d.deco.range(d.from)), true); + } + + const addedDecorationsField = StateField.define({ + create: (state) => buildAddedDecorations(state.doc, addedLineMarkers), + update(v, tr) { + for (const e of tr.effects) if (e.is(updateAddedEffect)) return buildAddedDecorations(tr.state.doc, e.value); + return tr.docChanged ? buildAddedDecorations(tr.state.doc, tr.state.field(currentAddedField)) : v; + }, + provide: (f) => EditorView.decorations.from(f) + }); + const lintGutter = gutter({ class: 'cm-lint-gutter', markers: (view) => view.state.field(lintGutterField) @@ -872,6 +907,10 @@ extensions.push(currentLintField, lintGutterField, lintDecorationsField, lintGutter); } + // Added-line highlight fields are always wired (empty = no decorations) so a consumer + // can push ranges AFTER mount (e.g. once a stack is picked) via the reactive dispatch. + extensions.push(currentAddedField, addedDecorationsField); + const state = EditorState.create({ doc: value, extensions @@ -919,6 +958,7 @@ if (view) view.dispatch({ effects: updateLintEffect.of(markers) }); }); + function destroyEditor() { if (markerUpdateTimer) { clearTimeout(markerUpdateTimer); @@ -940,13 +980,19 @@ // Set editor content export function setValue(newValue: string) { if (view) { + // Programmatic replace must NOT fire onchange (it is not a user edit). Also clear + // any added-line highlight - a plain setValue (cancel merge, env toggle) replaces + // the whole doc, so a stale range would highlight the wrong lines. + isSyncingExternalValue = true; view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: newValue - } + }, + effects: updateAddedEffect.of([]) }); + isSyncingExternalValue = false; } } @@ -955,6 +1001,21 @@ view?.focus(); } + /** + * Replace the document AND set the added-line highlight in ONE transaction, so the + * green decorations are computed against the new text atomically (a separate reactive + * dispatch races the doc change and lands on the old/empty doc). + */ + export function setValueWithAddedRanges(newValue: string, ranges: { line: number; endLine: number }[]) { + if (!view) return; + isSyncingExternalValue = true; // programmatic replace, not a user edit + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: newValue }, + effects: updateAddedEffect.of(ranges) + }); + isSyncingExternalValue = false; + } + /** Scroll a 1-based line into view and place the cursor on it (used by validate jump-to). */ export function scrollToLine(line: number) { if (!view) return; @@ -1154,6 +1215,12 @@ background-color: rgba(59, 130, 246, 0.06); } + /* Freshly-added lines (e.g. a service merged into an existing compose) - green wash + left rail. */ + div :global(.cm-added-line) { + background-color: rgba(34, 197, 94, 0.12); + box-shadow: inset 3px 0 0 rgba(34, 197, 94, 0.7); + } + /* Variable value overlay widget - base styles */ div :global(.var-value-overlay) { display: inline-block; diff --git a/src/lib/openapi.generated.json b/src/lib/openapi.generated.json index ea6e502..91bb6a7 100644 --- a/src/lib/openapi.generated.json +++ b/src/lib/openapi.generated.json @@ -6617,6 +6617,81 @@ ] } }, + "/api/containers/{id}/compose": { + "get": { + "operationId": "get_api_containers_id_compose", + "tags": [ + "containers" + ], + "summary": "Generate a docker-compose service definition from a running container's inspect (requires the 'view' permission)", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Container ID or name (from GET /api/containers)" + }, + { + "name": "env", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "The target environment ID (omit for the local/default Docker host) (from GET /api/environments)" + } + ], + "responses": { + "200": { + "description": "compose keeps only user-set env; composeFullEnv keeps every env var (incl. image-inherited); stackProject is the compose project the container already belongs to, or null", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "compose": { + "type": "string" + }, + "composeFullEnv": { + "type": "string" + }, + "serviceName": { + "type": "string" + }, + "stackProject": { + "type": "string" + } + } + }, + "example": { + "compose": "string", + "composeFullEnv": "string", + "serviceName": "string", + "stackProject": "string" + } + } + } + }, + "403": { + "description": "Permission denied" + }, + "500": { + "description": "Failed to inspect the container" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/containers/{id}/exec": { "post": { "operationId": "post_api_containers_id_exec", diff --git a/src/lib/utils/compose-merge.ts b/src/lib/utils/compose-merge.ts new file mode 100644 index 0000000..1044826 --- /dev/null +++ b/src/lib/utils/compose-merge.ts @@ -0,0 +1,75 @@ +/** + * Pure helpers for merging a generated single-service compose into an existing stack's + * compose (the "Append to existing" flow, #489). Kept dependency-light (js-yaml only) so + * the merge + highlight logic is unit-testable without a browser. + */ +import yaml from 'js-yaml'; + +export interface MergeResult { + /** The merged compose YAML. */ + merged: string; + /** The service key actually used (may be suffixed on a name clash). */ + key: string; + /** True when the generated key clashed and a numbered suffix was applied. */ + renamed: boolean; +} + +/** + * Insert `serviceCompose`'s single service into `baseCompose`. On a name clash the added + * service gets the lowest free `-N` suffix (never overwrites an existing service). Any + * top-level `networks`/`volumes` the service references are carried over. + */ +export function mergeServiceIntoCompose(baseCompose: string, serviceCompose: string): MergeResult { + const doc = (yaml.load(baseCompose) as any) ?? {}; + if (!doc.services || typeof doc.services !== 'object') doc.services = {}; + + const generated = yaml.load(serviceCompose) as any; + const genServices = generated?.services ?? {}; + const genKey = Object.keys(genServices)[0]; + if (!genKey) throw new Error('Nothing to add'); + + let key = genKey; + let renamed = false; + if (doc.services[key]) { + let n = 2; + while (doc.services[`${genKey}-${n}`]) n++; + key = `${genKey}-${n}`; + renamed = true; + } + doc.services[key] = genServices[genKey]; + if (generated?.networks) doc.networks = { ...(doc.networks ?? {}), ...generated.networks }; + if (generated?.volumes) doc.volumes = { ...(doc.volumes ?? {}), ...generated.volumes }; + + const merged = yaml.dump(doc, { lineWidth: -1, noRefs: true, sortKeys: false }); + return { merged, key, renamed }; +} + +/** + * The 1-based inclusive line range of the `services.:` block in the merged YAML, for + * highlighting the added service. Returns [] if the key isn't found. Trailing blank lines + * are ignored so a service that is the last block doesn't highlight the blank EOF line. + */ +export function computeAddedRange(mergedYaml: string, key: string): { line: number; endLine: number }[] { + const lines = mergedYaml.split('\n'); + while (lines.length && lines[lines.length - 1].trim() === '') lines.pop(); + + let start = -1; + let indent = 0; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(/^(\s*)([\w.-]+):/); + if (start === -1) { + if (m && m[2] === key) { + start = i; + indent = m[1].length; + } + } else { + // End the block at the next sibling key at the same-or-lower indent. + const m2 = lines[i].match(/^(\s*)\S/); + if (m2 && m2[1].length <= indent) { + return [{ line: start + 1, endLine: i }]; + } + } + } + if (start === -1) return []; + return [{ line: start + 1, endLine: lines.length }]; +} diff --git a/src/lib/utils/inspect-to-compose.ts b/src/lib/utils/inspect-to-compose.ts new file mode 100644 index 0000000..d905b67 --- /dev/null +++ b/src/lib/utils/inspect-to-compose.ts @@ -0,0 +1,316 @@ +/** + * Turn a Docker inspect payload into a single-service docker-compose fragment. + * + * Source of truth is the inspect JSON (Config + HostConfig + NetworkSettings + Mounts), + * not a reconstructed `docker run` line. Only fields the user could have set are emitted: + * daemon-injected defaults (empty entrypoint, `NetworkMode: default`, an empty restart + * policy) and compose-managed labels (`com.docker.compose.*`) are dropped so the output + * reads like a compose file a human would write. + * + * Pure and dependency-light (js-yaml only) so it is unit-testable without a daemon. + */ +import yaml from 'js-yaml'; + +/** The slice of a Docker inspect object this mapper reads (loosely typed on purpose). */ +export interface DockerInspect { + Name?: string; + Config?: { + Image?: string; + Cmd?: string[] | null; + Entrypoint?: string[] | null; + Env?: string[] | null; + Labels?: Record | null; + User?: string; + WorkingDir?: string; + Hostname?: string; + Domainname?: string; + OpenStdin?: boolean; + Tty?: boolean; + Healthcheck?: { + Test?: string[]; + Interval?: number; + Timeout?: number; + Retries?: number; + StartPeriod?: number; + } | null; + }; + HostConfig?: { + RestartPolicy?: { Name?: string; MaximumRetryCount?: number }; + PortBindings?: Record | null> | null; + Binds?: string[] | null; + CapAdd?: string[] | null; + CapDrop?: string[] | null; + Privileged?: boolean; + ReadonlyRootfs?: boolean; + NetworkMode?: string; + IpcMode?: string; + ShmSize?: number; + Dns?: string[] | null; + ExtraHosts?: string[] | null; + Devices?: Array<{ PathOnHost: string; PathInContainer: string; CgroupPermissions?: string }> | null; + Sysctls?: Record | null; + Tmpfs?: Record | null; + Ulimits?: Array<{ Name: string; Soft: number; Hard: number }> | null; + LogConfig?: { Type?: string; Config?: Record | null }; + }; + NetworkSettings?: { + Networks?: Record | null; + }; + Mounts?: Array<{ + Type?: string; + Name?: string; + Source?: string; + Destination?: string; + RW?: boolean; + }> | null; +} + +export interface InspectToComposeOptions { + /** + * The image's own `Config.Env` (from inspecting the image). When given, env vars whose + * `KEY=VALUE` matches the image verbatim are dropped, so the compose keeps only the + * vars the user actually set. Omit to keep every env var. + */ + imageEnv?: string[] | null; + /** The image's own `Config.Entrypoint`. When it matches the container's, entrypoint is dropped (image default). */ + imageEntrypoint?: string[] | null; + /** The image's own `Config.Cmd`. When it matches the container's, command is dropped (image default). */ + 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; + /** Override the service key (defaults to the container name). */ + serviceName?: string; +} + +/** 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']); + +/** + * 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 + * name won't reproduce, so it maps to an anonymous compose volume (`- /dest`) instead. + */ +function isAnonymousVolumeName(name: string): boolean { + return /^[0-9a-f]{64}$/.test(name); +} + +/** Element-wise string-array equality (order matters), treating null/undefined as empty. */ +function arraysEqual(a: string[] | null | undefined, b: string[] | null | undefined): boolean { + const x = a ?? []; + const y = b ?? []; + return x.length === y.length && x.every((v, i) => v === y[i]); +} + +/** Strip the leading slash Docker puts on container names. */ +function cleanName(name: string | undefined): string { + if (!name) return 'app'; + return name.startsWith('/') ? name.slice(1) : name; +} + +/** `["80/tcp"] -> "80"`, `"53/udp" -> "53/udp"` (keep the proto only when not tcp). */ +function portSpec(containerPort: string, bindings: Array<{ HostIp?: string; HostPort?: string }> | null): string[] { + const [port, proto] = containerPort.split('/'); + const suffix = proto && proto !== 'tcp' ? `/${proto}` : ''; + if (!bindings || bindings.length === 0) { + // Exposed but not published (no host binding) -> "expose" style single value. + return [`${port}${suffix}`]; + } + return bindings.map((b) => { + const host = b.HostPort ? b.HostPort : port; + const ip = b.HostIp && b.HostIp !== '0.0.0.0' && b.HostIp !== '::' ? `${b.HostIp}:` : ''; + return `${ip}${host}:${port}${suffix}`; + }); +} + +/** Convert a nanosecond duration (Docker healthcheck) to a compose `30s` string. */ +function nsToDuration(ns: number | undefined): string | undefined { + if (!ns || ns <= 0) return undefined; + const seconds = ns / 1e9; + return `${seconds}s`; +} + +/** + * Build the compose object for a single service from a container inspect. + * Returns a plain object keyed by the service name (ready to merge or serialize). + */ +export function inspectToComposeService( + inspect: DockerInspect, + options: InspectToComposeOptions = {} +): { name: string; service: Record; namedVolumes: string[] } { + const config = inspect.Config ?? {}; + const host = inspect.HostConfig ?? {}; + const name = options.serviceName ?? cleanName(inspect.Name); + const service: Record = {}; + + if (config.Image) service.image = config.Image; + service.container_name = name; + + // Entrypoint / command: drop them when they equal the image's own (an image default the + // user never overrode) - keeping them pins the current image's baked-in entrypoint and + // gets in the way of an image bump. + if ( + Array.isArray(config.Entrypoint) && + config.Entrypoint.length > 0 && + !arraysEqual(config.Entrypoint, options.imageEntrypoint) + ) { + service.entrypoint = config.Entrypoint; + } + if (Array.isArray(config.Cmd) && config.Cmd.length > 0 && !arraysEqual(config.Cmd, options.imageCmd)) { + service.command = config.Cmd; + } + + // Environment: drop image-baked vars when the image env is provided. + if (Array.isArray(config.Env) && config.Env.length > 0) { + const imageEnv = new Set(options.imageEnv ?? []); + const env = config.Env.filter((e) => !imageEnv.has(e)); + if (env.length > 0) service.environment = env; + } + + // Labels: never the compose-managed ones (runtime bookkeeping), and drop labels the + // image already sets to the same value (e.g. `maintainer`, `org.opencontainers.*`) so + // only user-set labels remain. + if (config.Labels) { + const imageLabels = options.imageLabels ?? {}; + const labels = Object.entries(config.Labels).filter( + ([k, v]) => !k.startsWith('com.docker.compose.') && imageLabels[k] !== v + ); + if (labels.length > 0) service.labels = Object.fromEntries(labels); + } + + // Ports from published bindings (+ merely-exposed ports with no host binding). + if (host.PortBindings && Object.keys(host.PortBindings).length > 0) { + const ports: string[] = []; + for (const [cp, binds] of Object.entries(host.PortBindings)) { + ports.push(...portSpec(cp, binds ?? null)); + } + if (ports.length > 0) service.ports = ports; + } + + // Volumes from Mounts: named -> `name:dest`, anonymous named -> `dest` (bare), bind -> + // `source:dest`, `:ro` when read-only. Named volumes are collected so the caller can + // declare them at the top level (compose requires it). + const namedVolumes: string[] = []; + if (Array.isArray(inspect.Mounts) && inspect.Mounts.length > 0) { + const volumes: string[] = []; + for (const m of inspect.Mounts) { + if (!m.Destination) continue; + const suffix = m.RW === false ? ':ro' : ''; + if (m.Type === 'volume' && m.Name) { + if (isAnonymousVolumeName(m.Name)) { + volumes.push(`${m.Destination}${suffix}`); // anonymous volume, no reusable name + } else { + volumes.push(`${m.Name}:${m.Destination}${suffix}`); + namedVolumes.push(m.Name); + } + } else if (m.Type === 'bind' && m.Source) { + volumes.push(`${m.Source}:${m.Destination}${suffix}`); + } + } + if (volumes.length > 0) service.volumes = volumes; + } + + // Restart policy: skip the empty default and the explicit "no". + const restart = host.RestartPolicy?.Name; + if (restart && restart !== 'no') { + if (restart === 'on-failure' && host.RestartPolicy?.MaximumRetryCount) { + service.restart = `on-failure:${host.RestartPolicy.MaximumRetryCount}`; + } else { + service.restart = restart; + } + } + + // 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; + } else { + const mode = host.NetworkMode; + if (mode && !DEFAULT_NETWORKS.has(mode)) service.network_mode = mode; + } + + if (host.CapAdd && host.CapAdd.length > 0) service.cap_add = host.CapAdd; + if (host.CapDrop && host.CapDrop.length > 0) service.cap_drop = host.CapDrop; + if (host.Privileged) service.privileged = true; + if (host.ReadonlyRootfs) service.read_only = true; + + if (Array.isArray(host.Devices) && host.Devices.length > 0) { + service.devices = host.Devices.map((d) => { + const perms = d.CgroupPermissions && d.CgroupPermissions !== 'rwm' ? `:${d.CgroupPermissions}` : ''; + return `${d.PathOnHost}:${d.PathInContainer}${perms}`; + }); + } + + 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.ExtraHosts && host.ExtraHosts.length > 0) service.extra_hosts = host.ExtraHosts; + + if (Array.isArray(host.Ulimits) && host.Ulimits.length > 0) { + service.ulimits = Object.fromEntries( + host.Ulimits.map((u) => [u.Name, u.Soft === u.Hard ? u.Soft : { soft: u.Soft, hard: u.Hard }]) + ); + } + + // Logging: skip the default json-file driver with no options (compose default). + const logDriver = host.LogConfig?.Type; + const logOpts = host.LogConfig?.Config; + if (logDriver && (logDriver !== 'json-file' || (logOpts && Object.keys(logOpts).length > 0))) { + const logging: Record = { driver: logDriver }; + if (logOpts && Object.keys(logOpts).length > 0) logging.options = logOpts; + service.logging = logging; + } + + if (config.User) service.user = config.User; + if (config.WorkingDir && config.WorkingDir !== '/') service.working_dir = config.WorkingDir; + // Docker always stamps a hostname; skip the auto-generated one (equals the short + // container id, a 12-hex string) - only a user-set hostname is worth emitting. + if (config.Hostname && config.Hostname !== name && !/^[0-9a-f]{12}$/.test(config.Hostname)) { + 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; + + // 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') { + const healthcheck: Record = {}; + // Test is ["CMD-SHELL", "..."] or ["CMD", "arg", ...]; compose `test` mirrors it. + healthcheck.test = hc.Test; + const interval = nsToDuration(hc.Interval); + const timeout = nsToDuration(hc.Timeout); + const startPeriod = nsToDuration(hc.StartPeriod); + if (interval) healthcheck.interval = interval; + if (timeout) healthcheck.timeout = timeout; + if (hc.Retries) healthcheck.retries = hc.Retries; + if (startPeriod) healthcheck.start_period = startPeriod; + service.healthcheck = healthcheck; + } + + return { name, service, namedVolumes }; +} + +/** + * Full compose document (`services:` + top-level `networks:`/`volumes:` for the named + * networks/volumes the service references) as a YAML string. Both are declared + * `external: true` because they already exist on the host - the user can flip that if + * they want compose to create them. + */ +export function inspectToCompose(inspect: DockerInspect, options: InspectToComposeOptions = {}): string { + 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 }])); + } + + if (namedVolumes.length > 0) { + doc.volumes = Object.fromEntries(namedVolumes.map((v) => [v, { external: true }])); + } + + return yaml.dump(doc, { lineWidth: -1, noRefs: true, sortKeys: false }); +} diff --git a/src/routes/api/containers/[id]/compose/+server.ts b/src/routes/api/containers/[id]/compose/+server.ts new file mode 100644 index 0000000..dd99695 --- /dev/null +++ b/src/routes/api/containers/[id]/compose/+server.ts @@ -0,0 +1,97 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { inspectContainer, inspectImage } from '$lib/server/docker'; +import { getSecretKeysToMask } from '$lib/server/db'; +import { getStackComposeFile } from '$lib/server/stacks'; +import { authorize } from '$lib/server/authorize'; +import { validateDockerIdParam } from '$lib/server/docker-validation'; +import { inspectToCompose, type DockerInspect } from '$lib/utils/inspect-to-compose'; + +/** + * GET /api/containers/{id}/compose - Generate a docker-compose fragment from a container + * + * @openapi + * summary: Generate a docker-compose service definition from a running container's inspect (requires the 'view' permission) + * path: id:string! Container ID or name (from GET /api/containers) + * query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments) + * resp-200: {compose:string, composeFullEnv:string, serviceName:string, stackProject:string} + * resp-200-desc: compose keeps only user-set env; composeFullEnv keeps every env var (incl. image-inherited); stackProject is the compose project the container already belongs to, or null + * resp-403: Permission denied + * resp-500: Failed to inspect the container + */ +export const GET: RequestHandler = async ({ params, url, cookies }) => { + const invalid = validateDockerIdParam(params.id, 'container'); + if (invalid) return invalid; + + const auth = await authorize(cookies); + + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; + + if (auth.authEnabled && !(await auth.can('containers', 'view', envIdNum))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + + try { + const containerData = (await inspectContainer(params.id, envIdNum)) as DockerInspect; + + const labels = containerData.Config?.Labels ?? {}; + const stackProject = labels['com.docker.compose.project'] ?? null; + const serviceName = + labels['com.docker.compose.service'] || + (containerData.Name?.startsWith('/') ? containerData.Name.slice(1) : containerData.Name) || + 'app'; + + // Mask secret env vars for a compose-stack container, exactly like the inspect + // endpoint - `containers:view` must never expose secrets that inspect hides. + if (stackProject && Array.isArray(containerData.Config?.Env)) { + const composeResult = await getStackComposeFile(stackProject, envIdNum).catch(() => null); + const secretKeys = await getSecretKeysToMask(stackProject, envIdNum, composeResult?.content); + if (secretKeys.size > 0) { + containerData.Config!.Env = containerData.Config!.Env!.map((entry: string) => { + const eqIdx = entry.indexOf('='); + if (eqIdx === -1) return entry; + return secretKeys.has(entry.substring(0, eqIdx)) ? `${entry.substring(0, eqIdx)}=***` : entry; + }); + } + } + + // Inspect the image so image-inherited env / entrypoint / cmd / labels can be + // subtracted (best effort - if the image is gone, we keep everything). + let imageEnv: string[] | null = null; + let imageEntrypoint: string[] | null = null; + let imageCmd: string[] | null = null; + let imageLabels: Record | null = null; + try { + const imageRef = containerData.Config?.Image; + if (imageRef) { + const imageData: any = await inspectImage(imageRef, envIdNum); + imageEnv = imageData?.Config?.Env ?? null; + imageEntrypoint = imageData?.Config?.Entrypoint ?? null; + imageCmd = imageData?.Config?.Cmd ?? null; + imageLabels = imageData?.Config?.Labels ?? null; + } + } catch { + // image not present / not pullable - fall back to keeping all values + } + + const compose = inspectToCompose(containerData, { + serviceName, + imageEnv, + imageEntrypoint, + imageCmd, + imageLabels + }); + // Same document but keeping every env var (the "only user-set env" toggle off). + const composeFullEnv = inspectToCompose(containerData, { + serviceName, + imageEntrypoint, + imageCmd, + imageLabels + }); + + return json({ compose, composeFullEnv, serviceName, stackProject }); + } catch (error: any) { + return json({ error: error.message || 'Failed to generate compose' }, { status: 500 }); + } +}; diff --git a/src/routes/api/stacks/+server.ts b/src/routes/api/stacks/+server.ts index f7fdcd9..1678c14 100644 --- a/src/routes/api/stacks/+server.ts +++ b/src/routes/api/stacks/+server.ts @@ -43,11 +43,12 @@ export const GET: RequestHandler = async ({ url, cookies }) => { const stackSources = await getStackSources(envIdNum); const existingNames = new Set(stacks.map((s) => s.name)); - // Enrich Docker-discovered stacks with source type from DB + // Enrich Docker-discovered stacks with source type + icon from DB for (const stack of stacks) { const source = stackSources.find(s => s.stackName === stack.name); if (source) { (stack as any).sourceType = source.sourceType; + (stack as any).icon = source.icon; } } @@ -60,7 +61,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => { containers: [], containerDetails: [], status: 'created' as any, - sourceType: source.sourceType + sourceType: source.sourceType, + icon: source.icon } as any); } } diff --git a/src/routes/containers/ContainerComposeTab.svelte b/src/routes/containers/ContainerComposeTab.svelte new file mode 100644 index 0000000..57fee59 --- /dev/null +++ b/src/routes/containers/ContainerComposeTab.svelte @@ -0,0 +1,412 @@ + + +
+ {#if loading} +
+ Generating compose... +
+ {:else if loadError} +
{loadError}
+ {:else} + + {#if stackProject} +
+ + This container already belongs to stack {stackProject}. The generated compose is a fresh definition you can save separately or merge elsewhere. +
+ {/if} + + +
+ Environment + { const m = v as 'user' | 'all'; envMode = m; reseedEditor(m); }} + /> +
+ + + + + o && loadInternalStacks()}> + + + + + {#if loadingStacks} +
Loading...
+ {:else if existingStacks.length === 0} +
No internal stacks
+ {:else} + {#each existingStacks as s (s.name)} + addToExisting(s)} + disabled={merging} + > + + {s.name} + + {/each} + {/if} +
+
+
+ + + {#if mergedIntoStack} +
+ + Reviewing merge into {mergedIntoStack}. The highlighted service is what will be added. + + +
+ {/if} + + +
+
+ +
+ {#if validatePanelOpen} +
+ (validatePanelOpen = false)} + onJumpToLine={jumpToLine} + onRevalidate={runValidate} + /> +
+ {/if} +
+ {/if} +
+ + (stackModalOpen = false)} + onSuccess={() => { + stackModalOpen = false; + toast.success('Stack created'); + }} +/> diff --git a/src/routes/containers/ContainerInspectModal.svelte b/src/routes/containers/ContainerInspectModal.svelte index 83d4fad..a4d247c 100644 --- a/src/routes/containers/ContainerInspectModal.svelte +++ b/src/routes/containers/ContainerInspectModal.svelte @@ -18,6 +18,7 @@ import { Label } from '$lib/components/ui/label'; import { currentEnvironment, appendEnvParam, environments } from '$lib/stores/environment'; import ImageLayersView from '../images/ImageLayersView.svelte'; + import ContainerComposeTab from './ContainerComposeTab.svelte'; import LogsPanel from '../logs/LogsPanel.svelte'; import FileBrowserPanel from './FileBrowserPanel.svelte'; import { formatDateTime } from '$lib/stores/settings'; @@ -834,6 +835,7 @@ showLogs = false}>Security showLogs = false}>Resources showLogs = false}>Health + showLogs = false}>Compose @@ -1834,6 +1836,17 @@

No health check configured

{/if} + + + + {#if activeTab === 'compose'} + + {/if} + {/if} diff --git a/tests/compose-merge.test.ts b/tests/compose-merge.test.ts new file mode 100644 index 0000000..dd1db47 --- /dev/null +++ b/tests/compose-merge.test.ts @@ -0,0 +1,128 @@ +/** + * Unit tests for the "Append to existing" merge helpers (#489): merging a generated + * single-service compose into an existing stack compose, and computing the highlight range + * of the added service. No browser needed - pure js-yaml logic. + * + * Run with: bun test tests/compose-merge.test.ts + */ +import { describe, test, expect } from 'bun:test'; +import yaml from 'js-yaml'; +import { mergeServiceIntoCompose, computeAddedRange } from '../src/lib/utils/compose-merge'; + +const BASE = `services: + web: + image: nginx:latest + ports: + - '8080:80' +`; + +describe('mergeServiceIntoCompose', () => { + test('adds a new service under services without touching the existing one', () => { + const service = `services: + redis: + image: redis:7 +`; + const { merged, key, renamed } = mergeServiceIntoCompose(BASE, service); + expect(key).toBe('redis'); + expect(renamed).toBe(false); + const doc = yaml.load(merged) as any; + expect(Object.keys(doc.services).sort()).toEqual(['redis', 'web']); + expect(doc.services.web.image).toBe('nginx:latest'); + expect(doc.services.redis.image).toBe('redis:7'); + }); + + test('auto-suffixes on a name clash instead of overwriting', () => { + const service = `services: + web: + image: caddy:2 +`; + const { merged, key, renamed } = mergeServiceIntoCompose(BASE, service); + expect(key).toBe('web-2'); + expect(renamed).toBe(true); + const doc = yaml.load(merged) as any; + // original web is untouched, the added one is web-2 + expect(doc.services.web.image).toBe('nginx:latest'); + expect(doc.services['web-2'].image).toBe('caddy:2'); + }); + + test('picks the lowest free suffix when -2 is also taken', () => { + const base = `services: + web: + image: nginx:latest + web-2: + image: nginx:1.25 +`; + const service = `services: + web: + image: caddy:2 +`; + const { key } = mergeServiceIntoCompose(base, service); + expect(key).toBe('web-3'); + }); + + test('carries over top-level networks and volumes the service references', () => { + const service = `services: + db: + image: postgres:16 + networks: + - backend + volumes: + - pgdata:/var/lib/postgresql/data +networks: + backend: + external: true +volumes: + pgdata: + external: true +`; + const { merged } = mergeServiceIntoCompose(BASE, service); + const doc = yaml.load(merged) as any; + expect(doc.networks.backend).toEqual({ external: true }); + expect(doc.volumes.pgdata).toEqual({ external: true }); + }); + + test('handles an empty base compose (creates services)', () => { + const service = `services: + app: + image: alpine +`; + const { merged, key } = mergeServiceIntoCompose('', service); + expect(key).toBe('app'); + expect((yaml.load(merged) as any).services.app.image).toBe('alpine'); + }); +}); + +describe('computeAddedRange', () => { + test('returns the exact block of the added service', () => { + const service = `services: + redis: + image: redis:7 +`; + const { merged, key } = mergeServiceIntoCompose(BASE, service); + const [range] = computeAddedRange(merged, key); + const lines = merged.split('\n'); + // the highlighted range must start at the ` redis:` line + expect(lines[range.line - 1]).toMatch(/^\s+redis:/); + // and every highlighted line must be inside the redis block (indent > 2) or the header + for (let l = range.line; l <= range.endLine; l++) { + expect(lines[l - 1].startsWith(' ')).toBe(true); + } + }); + + test('does NOT over-highlight the trailing blank line when the service is last', () => { + // merged doc ends with the added service and a trailing newline from yaml.dump. + const service = `services: + last: + image: busybox +`; + const { merged, key } = mergeServiceIntoCompose(BASE, service); + const [range] = computeAddedRange(merged, key); + const lines = merged.split('\n'); + // endLine must be a real content line, not the trailing empty one. + expect(lines[range.endLine - 1].trim()).not.toBe(''); + }); + + test('returns empty when the key is absent', () => { + expect(computeAddedRange(BASE, 'nope')).toEqual([]); + }); +}); diff --git a/tests/fixtures/inspect/gitea.json b/tests/fixtures/inspect/gitea.json new file mode 100644 index 0000000..f14e8d2 --- /dev/null +++ b/tests/fixtures/inspect/gitea.json @@ -0,0 +1,248 @@ +[ + { + "Id": "b4aa30f522a96f195c11749d5aecc9fd4a101dceb9677d5c02e8b9eb8b0dd6f8", + "Created": "2026-08-16T15:05:55.489652712Z", + "Path": "/usr/bin/entrypoint", + "Args": [ + "/bin/s6-svscan", + "/etc/s6" + ], + "State": { + "Status": "running", + "Running": true, + "Paused": false, + "Restarting": false, + "OOMKilled": false, + "Dead": false, + "Pid": 442, + "ExitCode": 0, + "Error": "", + "StartedAt": "2026-08-22T16:40:00.901354Z", + "FinishedAt": "2026-08-21T08:42:47.411649045Z" + }, + "Image": "sha256:1b34baabfe2a23bc4ac5c1db5c2316c683d0afa6bc600867c9aafc02a5f9c959", + "ResolvConfPath": "/var/lib/docker/containers/b4aa30f522a96f195c11749d5aecc9fd4a101dceb9677d5c02e8b9eb8b0dd6f8/resolv.conf", + "HostnamePath": "/var/lib/docker/containers/b4aa30f522a96f195c11749d5aecc9fd4a101dceb9677d5c02e8b9eb8b0dd6f8/hostname", + "HostsPath": "/var/lib/docker/containers/b4aa30f522a96f195c11749d5aecc9fd4a101dceb9677d5c02e8b9eb8b0dd6f8/hosts", + "LogPath": "/var/lib/docker/containers/b4aa30f522a96f195c11749d5aecc9fd4a101dceb9677d5c02e8b9eb8b0dd6f8/b4aa30f522a96f195c11749d5aecc9fd4a101dceb9677d5c02e8b9eb8b0dd6f8-json.log", + "Name": "/semver-demo-stack-gitea-1", + "RestartCount": 0, + "Driver": "overlayfs", + "Platform": "linux", + "MountLabel": "", + "ProcessLabel": "", + "AppArmorProfile": "", + "ExecIDs": null, + "HostConfig": { + "Binds": null, + "ContainerIDFile": "", + "LogConfig": { + "Type": "json-file", + "Config": { + "max-file": "5", + "max-size": "20m" + } + }, + "NetworkMode": "semver-demo-stack_default", + "PortBindings": {}, + "RestartPolicy": { + "Name": "unless-stopped", + "MaximumRetryCount": 0 + }, + "AutoRemove": false, + "VolumeDriver": "", + "VolumesFrom": null, + "ConsoleSize": [ + 0, + 0 + ], + "CapAdd": null, + "CapDrop": null, + "CgroupnsMode": "private", + "Dns": [], + "DnsOptions": [], + "DnsSearch": [], + "ExtraHosts": [], + "GroupAdd": null, + "IpcMode": "private", + "Cgroup": "", + "Links": null, + "OomScoreAdj": 0, + "PidMode": "", + "Privileged": false, + "PublishAllPorts": false, + "ReadonlyRootfs": false, + "SecurityOpt": null, + "UTSMode": "", + "UsernsMode": "", + "ShmSize": 8399093760, + "Runtime": "runc", + "Isolation": "", + "CpuShares": 0, + "Memory": 0, + "NanoCpus": 0, + "CgroupParent": "", + "BlkioWeight": 0, + "BlkioWeightDevice": null, + "BlkioDeviceReadBps": null, + "BlkioDeviceWriteBps": null, + "BlkioDeviceReadIOps": null, + "BlkioDeviceWriteIOps": null, + "CpuPeriod": 0, + "CpuQuota": 0, + "CpuRealtimePeriod": 0, + "CpuRealtimeRuntime": 0, + "CpusetCpus": "", + "CpusetMems": "", + "Devices": null, + "DeviceCgroupRules": null, + "DeviceRequests": null, + "MemoryReservation": 0, + "MemorySwap": 0, + "MemorySwappiness": null, + "OomKillDisable": null, + "PidsLimit": null, + "Ulimits": null, + "CpuCount": 0, + "CpuPercent": 0, + "IOMaximumIOps": 0, + "IOMaximumBandwidth": 0, + "MaskedPaths": [ + "/proc/acpi", + "/proc/asound", + "/proc/interrupts", + "/proc/kcore", + "/proc/keys", + "/proc/latency_stats", + "/proc/sched_debug", + "/proc/scsi", + "/proc/timer_list", + "/proc/timer_stats", + "/sys/devices/virtual/powercap", + "/sys/firmware" + ], + "ReadonlyPaths": [ + "/proc/bus", + "/proc/fs", + "/proc/irq", + "/proc/sys", + "/proc/sysrq-trigger" + ] + }, + "Storage": { + "RootFS": { + "Snapshot": { + "Name": "overlayfs" + } + } + }, + "Mounts": [ + { + "Type": "volume", + "Name": "b93b00146e83907cc09b13ab2dfa145bfd18e232ba37d1e6e1cd77c5c43cfc4c", + "Source": "/var/lib/docker/volumes/b93b00146e83907cc09b13ab2dfa145bfd18e232ba37d1e6e1cd77c5c43cfc4c/_data", + "Destination": "/data", + "Driver": "local", + "Mode": "", + "RW": true, + "Propagation": "" + } + ], + "Config": { + "Hostname": "b4aa30f522a9", + "Domainname": "", + "User": "", + "AttachStdin": false, + "AttachStdout": true, + "AttachStderr": true, + "ExposedPorts": { + "22/tcp": {}, + "3000/tcp": {} + }, + "Tty": false, + "OpenStdin": false, + "StdinOnce": false, + "Env": [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "USER=git", + "GITEA_CUSTOM=/data/gitea" + ], + "Cmd": [ + "/bin/s6-svscan", + "/etc/s6" + ], + "Image": "gitea/gitea:1.21.0", + "Volumes": { + "/data": {} + }, + "WorkingDir": "", + "Entrypoint": [ + "/usr/bin/entrypoint" + ], + "Labels": { + "com.docker.compose.config-hash": "2ec0758dc4a920276a9710ee9d5867e7ba6f0c0da358b3f4f6474a912b80af4c", + "com.docker.compose.container-number": "1", + "com.docker.compose.depends_on": "", + "com.docker.compose.image": "sha256:1b34baabfe2a23bc4ac5c1db5c2316c683d0afa6bc600867c9aafc02a5f9c959", + "com.docker.compose.oneoff": "False", + "com.docker.compose.project": "semver-demo-stack", + "com.docker.compose.project.config_files": "/home/user/stacks/semver-stack/docker-compose.yml", + "com.docker.compose.project.working_dir": "/home/user/stacks/semver-stack", + "com.docker.compose.service": "gitea", + "com.docker.compose.version": "5.1.2", + "maintainer": "maintainers@gitea.io", + "org.opencontainers.image.created": "2023-11-14T14:06:39.037Z", + "org.opencontainers.image.description": "Git with a cup of tea! Painless self-hosted all-in-one software development service, including Git hosting, code review, team collaboration, package registry and CI/CD", + "org.opencontainers.image.licenses": "MIT", + "org.opencontainers.image.revision": "bc6477b36b774b0c4616a0ac7af76ba2d56da081", + "org.opencontainers.image.source": "https://github.com/go-gitea/gitea", + "org.opencontainers.image.title": "gitea", + "org.opencontainers.image.url": "https://github.com/go-gitea/gitea", + "org.opencontainers.image.version": "1" + } + }, + "NetworkSettings": { + "SandboxID": "4db52ddc90824185fc7f9774a46919aee72a3c61f4be8afdb4225197e0a4270c", + "SandboxKey": "/var/run/docker/netns/4db52ddc9082", + "Ports": { + "22/tcp": null, + "3000/tcp": null + }, + "Networks": { + "semver-demo-stack_default": { + "IPAMConfig": null, + "Links": null, + "Aliases": [ + "semver-demo-stack-gitea-1", + "gitea" + ], + "DriverOpts": null, + "GwPriority": 0, + "NetworkID": "69f26cd4ead78d2b954f663eb49c08a04b7027fb080e06d2164e56cbb527b7b2", + "EndpointID": "49804adcbc2e0bca9e4b0f4199c3ab3a35e989bbf77508654be6435e22e3f6f5", + "Gateway": "192.168.117.1", + "IPAddress": "192.168.117.3", + "MacAddress": "f6:1f:4a:64:b6:74", + "IPPrefixLen": 24, + "IPv6Gateway": "", + "GlobalIPv6Address": "", + "GlobalIPv6PrefixLen": 0, + "DNSNames": [ + "semver-demo-stack-gitea-1", + "gitea", + "b4aa30f522a9" + ] + } + } + }, + "ImageManifestDescriptor": { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:54dc2aeb69f37251ef9e6674e9c92c1893e0350cbefa3b0628b5dea4fdfae9da", + "size": 1625, + "platform": { + "architecture": "arm64", + "os": "linux" + } + } + } +] diff --git a/tests/fixtures/inspect/nginx-rich.json b/tests/fixtures/inspect/nginx-rich.json new file mode 100644 index 0000000..ce5a5db --- /dev/null +++ b/tests/fixtures/inspect/nginx-rich.json @@ -0,0 +1,299 @@ +[ + { + "Id": "12ca69229880562b4049dcd2e669ae6c643781e4a70ebcde1ceda9aad2a8c01c", + "Created": "2026-08-23T09:13:15.772313516Z", + "Path": "/docker-entrypoint.sh", + "Args": [ + "nginx", + "-g", + "daemon off;" + ], + "State": { + "Status": "running", + "Running": true, + "Paused": false, + "Restarting": false, + "OOMKilled": false, + "Dead": false, + "Pid": 19337, + "ExitCode": 0, + "Error": "", + "StartedAt": "2026-08-23T09:13:15.902693702Z", + "FinishedAt": "0001-01-01T00:00:00Z", + "Health": { + "Status": "starting", + "FailingStreak": 0, + "Log": [] + } + }, + "Image": "sha256:db35bfc6b2951e7f8a72db5db120288c127ffaeeb4a6d4b95a26fead017d5913", + "ResolvConfPath": "/var/lib/docker/containers/12ca69229880562b4049dcd2e669ae6c643781e4a70ebcde1ceda9aad2a8c01c/resolv.conf", + "HostnamePath": "/var/lib/docker/containers/12ca69229880562b4049dcd2e669ae6c643781e4a70ebcde1ceda9aad2a8c01c/hostname", + "HostsPath": "/var/lib/docker/containers/12ca69229880562b4049dcd2e669ae6c643781e4a70ebcde1ceda9aad2a8c01c/hosts", + "LogPath": "/var/lib/docker/containers/12ca69229880562b4049dcd2e669ae6c643781e4a70ebcde1ceda9aad2a8c01c/12ca69229880562b4049dcd2e669ae6c643781e4a70ebcde1ceda9aad2a8c01c-json.log", + "Name": "/itc-fixture", + "RestartCount": 0, + "Driver": "overlayfs", + "Platform": "linux", + "MountLabel": "", + "ProcessLabel": "", + "AppArmorProfile": "", + "ExecIDs": null, + "HostConfig": { + "Binds": [ + "/tmp/itc-html:/usr/share/nginx/html:ro", + "itc-named:/var/cache/nginx" + ], + "ContainerIDFile": "", + "LogConfig": { + "Type": "json-file", + "Config": { + "max-file": "5", + "max-size": "20m" + } + }, + "NetworkMode": "bridge", + "PortBindings": { + "443/tcp": [ + { + "HostIp": "127.0.0.1", + "HostPort": "8443" + } + ], + "80/tcp": [ + { + "HostIp": "", + "HostPort": "8099" + } + ] + }, + "RestartPolicy": { + "Name": "on-failure", + "MaximumRetryCount": 5 + }, + "AutoRemove": false, + "VolumeDriver": "", + "VolumesFrom": null, + "ConsoleSize": [ + 0, + 0 + ], + "CapAdd": [ + "CAP_NET_ADMIN" + ], + "CapDrop": [ + "CAP_MKNOD" + ], + "CgroupnsMode": "private", + "Dns": null, + "DnsOptions": [], + "DnsSearch": [], + "ExtraHosts": null, + "GroupAdd": null, + "IpcMode": "private", + "Cgroup": "", + "Links": null, + "OomScoreAdj": 0, + "PidMode": "", + "Privileged": false, + "PublishAllPorts": false, + "ReadonlyRootfs": false, + "SecurityOpt": null, + "UTSMode": "", + "UsernsMode": "", + "ShmSize": 8399093760, + "Sysctls": { + "net.core.somaxconn": "1024" + }, + "Runtime": "runc", + "Isolation": "", + "CpuShares": 0, + "Memory": 0, + "NanoCpus": 0, + "CgroupParent": "", + "BlkioWeight": 0, + "BlkioWeightDevice": [], + "BlkioDeviceReadBps": [], + "BlkioDeviceWriteBps": [], + "BlkioDeviceReadIOps": [], + "BlkioDeviceWriteIOps": [], + "CpuPeriod": 0, + "CpuQuota": 0, + "CpuRealtimePeriod": 0, + "CpuRealtimeRuntime": 0, + "CpusetCpus": "", + "CpusetMems": "", + "Devices": [], + "DeviceCgroupRules": null, + "DeviceRequests": null, + "MemoryReservation": 0, + "MemorySwap": 0, + "MemorySwappiness": null, + "OomKillDisable": null, + "PidsLimit": null, + "Ulimits": [], + "CpuCount": 0, + "CpuPercent": 0, + "IOMaximumIOps": 0, + "IOMaximumBandwidth": 0, + "MaskedPaths": [ + "/proc/acpi", + "/proc/asound", + "/proc/interrupts", + "/proc/kcore", + "/proc/keys", + "/proc/latency_stats", + "/proc/sched_debug", + "/proc/scsi", + "/proc/timer_list", + "/proc/timer_stats", + "/sys/devices/virtual/powercap", + "/sys/firmware" + ], + "ReadonlyPaths": [ + "/proc/bus", + "/proc/fs", + "/proc/irq", + "/proc/sys", + "/proc/sysrq-trigger" + ] + }, + "Storage": { + "RootFS": { + "Snapshot": { + "Name": "overlayfs" + } + } + }, + "Mounts": [ + { + "Type": "bind", + "Source": "/tmp/itc-html", + "Destination": "/usr/share/nginx/html", + "Mode": "ro", + "RW": false, + "Propagation": "rprivate" + }, + { + "Type": "volume", + "Name": "itc-named", + "Source": "/var/lib/docker/volumes/itc-named/_data", + "Destination": "/var/cache/nginx", + "Driver": "local", + "Mode": "z", + "RW": true, + "Propagation": "" + } + ], + "Config": { + "Hostname": "12ca69229880", + "Domainname": "", + "User": "", + "AttachStdin": false, + "AttachStdout": false, + "AttachStderr": false, + "ExposedPorts": { + "443/tcp": {}, + "80/tcp": {} + }, + "Tty": false, + "OpenStdin": false, + "StdinOnce": false, + "Env": [ + "MY_VAR=uservalue", + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "NGINX_VERSION=1.31.4", + "PKG_RELEASE=1", + "DYNPKG_RELEASE=1", + "NJS_VERSION=1.0.0", + "NJS_RELEASE=1", + "ACME_VERSION=0.4.1" + ], + "Cmd": [ + "nginx", + "-g", + "daemon off;" + ], + "Healthcheck": { + "Test": [ + "CMD-SHELL", + "curl -f http://localhost/ || exit 1" + ], + "Interval": 30000000000, + "Retries": 3 + }, + "Image": "nginx:alpine", + "Volumes": null, + "WorkingDir": "/", + "Entrypoint": [ + "/docker-entrypoint.sh" + ], + "Labels": { + "maintainer": "NGINX Docker Maintainers ", + "my.custom.label": "hello" + }, + "StopSignal": "SIGQUIT" + }, + "NetworkSettings": { + "SandboxID": "0e595e6e5fbab287f32ddbc517e23632e7bf5bf2003188c8c841aa2de5c24636", + "SandboxKey": "/var/run/docker/netns/0e595e6e5fba", + "Ports": { + "443/tcp": [ + { + "HostIp": "127.0.0.1", + "HostPort": "8443" + } + ], + "80/tcp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "8099" + }, + { + "HostIp": "::", + "HostPort": "8099" + } + ] + }, + "Networks": { + "bridge": { + "IPAMConfig": null, + "Links": null, + "Aliases": null, + "DriverOpts": null, + "GwPriority": 0, + "NetworkID": "5dc6b41da3d9793cee90794c9df870e78db74d01374ff25779b18ff97486a1da", + "EndpointID": "8a736e50be4fc3e44f34b43981d487a8661b70b18179c67c0ba3127e31d8a8ad", + "Gateway": "192.168.215.1", + "IPAddress": "192.168.215.3", + "MacAddress": "56:d6:31:71:d5:06", + "IPPrefixLen": 24, + "IPv6Gateway": "", + "GlobalIPv6Address": "", + "GlobalIPv6PrefixLen": 0, + "DNSNames": null + } + } + }, + "ImageManifestDescriptor": { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:57744b8fa99abc438b1fbde6bd69e4270d0984ccfdee60c661ec22243047373a", + "size": 2497, + "annotations": { + "com.docker.official-images.bashbrew.arch": "arm64v8", + "org.opencontainers.image.base.digest": "sha256:de4f26ec52b4ffb74405922a96a619bb691b70daf8565b0f87eacd74c69bb0ef", + "org.opencontainers.image.base.name": "nginx:1.31.4-alpine-slim", + "org.opencontainers.image.created": "2026-08-19T20:10:31Z", + "org.opencontainers.image.revision": "b8590bd36b4504b9b847fcf2e98a9111dcae85fa", + "org.opencontainers.image.source": "https://github.com/nginx/docker-nginx.git#b8590bd36b4504b9b847fcf2e98a9111dcae85fa:mainline/alpine", + "org.opencontainers.image.url": "https://hub.docker.com/_/nginx", + "org.opencontainers.image.version": "1.31.4-alpine" + }, + "platform": { + "architecture": "arm64", + "os": "linux", + "variant": "v8" + } + } + } +] diff --git a/tests/inspect-to-compose.test.ts b/tests/inspect-to-compose.test.ts new file mode 100644 index 0000000..6ede1bb --- /dev/null +++ b/tests/inspect-to-compose.test.ts @@ -0,0 +1,203 @@ +/** + * Unit tests for inspectToCompose - the container-inspect -> compose YAML mapper (#489). + * + * Uses REAL `docker inspect` payloads captured from running containers + * (tests/fixtures/inspect/*.json), so the mapper is exercised against the exact shapes + * Docker actually emits, not hand-written approximations. Assertions run on the re-parsed + * YAML object so they are independent of formatting. + * + * Run with: bun test tests/inspect-to-compose.test.ts + */ +import { describe, test, expect } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import yaml from 'js-yaml'; +import { inspectToCompose, inspectToComposeService, type DockerInspect } from '../src/lib/utils/inspect-to-compose'; + +function loadFixture(name: string): DockerInspect { + const path = join(import.meta.dir, 'fixtures', 'inspect', name); + const arr = JSON.parse(readFileSync(path, 'utf8')) as DockerInspect[]; + return arr[0]; +} + +function parseCompose(inspect: DockerInspect, options = {}) { + const yamlStr = inspectToCompose(inspect, options); + return { yamlStr, doc: yaml.load(yamlStr) as any }; +} + +describe('inspectToCompose - real gitea inspect (compose-managed, named-vol, custom net)', () => { + const gitea = loadFixture('gitea.json'); + + test('service key is the clean container name', () => { + const { doc } = parseCompose(gitea); + expect(Object.keys(doc.services)).toEqual(['semver-demo-stack-gitea-1']); + }); + + test('image + container_name + restart carry through', () => { + const { doc } = parseCompose(gitea); + const svc = doc.services['semver-demo-stack-gitea-1']; + expect(svc.image).toBe('gitea/gitea:1.21.0'); + expect(svc.container_name).toBe('semver-demo-stack-gitea-1'); + expect(svc.restart).toBe('unless-stopped'); + }); + + test('the com.docker.compose.* labels are stripped', () => { + const { yamlStr, doc } = parseCompose(gitea); + expect(yamlStr).not.toContain('com.docker.compose'); + const svc = doc.services['semver-demo-stack-gitea-1']; + if (svc.labels) { + for (const key of Object.keys(svc.labels)) { + expect(key.startsWith('com.docker.compose.')).toBe(false); + } + } + }); + + 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']); + expect(doc.networks['semver-demo-stack_default']).toEqual({ external: true }); + // bare bridge/default are never emitted + expect(svc.network_mode).toBeUndefined(); + }); + + test('the anonymous (64-hex) volume becomes a bare anonymous mount, not a named one', () => { + const { doc } = parseCompose(gitea); + const svc = doc.services['semver-demo-stack-gitea-1']; + // gitea's /data rides a 64-hex anonymous volume -> `- /data`, no hash name, no top-level volume + expect(svc.volumes).toContain('/data'); + for (const v of svc.volumes as string[]) { + expect(v).not.toMatch(/^[0-9a-f]{64}:/); + } + expect(doc.volumes).toBeUndefined(); + }); +}); + +describe('inspectToCompose - real nginx inspect (published ports, bind :ro, named vol, healthcheck, caps, sysctl)', () => { + const nginx = loadFixture('nginx-rich.json'); + const svcName = 'itc-fixture'; + + test('published ports map to host:container, respecting a bound host IP', () => { + const { doc } = parseCompose(nginx); + const svc = doc.services[svcName]; + expect(svc.ports).toContain('8099:80'); + expect(svc.ports).toContain('127.0.0.1:8443:443'); + }); + + test('bind mount is source:dest with :ro; named volume is name:dest + top-level external', () => { + const { doc } = parseCompose(nginx); + const svc = doc.services[svcName]; + expect(svc.volumes).toContain('/tmp/itc-html:/usr/share/nginx/html:ro'); + expect(svc.volumes).toContain('itc-named:/var/cache/nginx'); + expect(doc.volumes['itc-named']).toEqual({ external: true }); + }); + + test('restart on-failure keeps its retry count', () => { + const { doc } = parseCompose(nginx); + expect(doc.services[svcName].restart).toBe('on-failure:5'); + }); + + test('cap_add/cap_drop and sysctls carry through', () => { + const { doc } = parseCompose(nginx); + const svc = doc.services[svcName]; + expect(svc.cap_add).toContain('CAP_NET_ADMIN'); + expect(svc.cap_drop).toContain('CAP_MKNOD'); + expect(svc.sysctls['net.core.somaxconn']).toBe('1024'); + }); + + test('healthcheck maps Test + interval(ns->s) + retries', () => { + const { doc } = parseCompose(nginx); + const hc = doc.services[svcName].healthcheck; + expect(hc.test).toEqual(['CMD-SHELL', 'curl -f http://localhost/ || exit 1']); + expect(hc.interval).toBe('30s'); + expect(hc.retries).toBe(3); + }); + + test('custom label survives', () => { + const { doc } = parseCompose(nginx); + expect(doc.services[svcName].labels['my.custom.label']).toBe('hello'); + }); + + test('image-baked labels (maintainer) are dropped when imageLabels is provided; user label stays', () => { + // The image sets maintainer; the container adds my.custom.label. With the image labels + // passed, only the user-added one survives. + const imageLabels = { maintainer: 'NGINX Docker Maintainers ' }; + const { doc } = parseCompose(nginx, { imageLabels }); + const labels = doc.services[svcName].labels; + expect(labels.maintainer).toBeUndefined(); + expect(labels['my.custom.label']).toBe('hello'); + }); + + test('auto-generated noise (hostname, working_dir /, shm_size) 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. + expect(svc.hostname).toBeUndefined(); + expect(svc.working_dir).toBeUndefined(); + expect(svc.shm_size).toBeUndefined(); + }); + + test('image-default entrypoint/command are dropped when the image values are provided', () => { + const imageEntrypoint = nginx.Config?.Entrypoint ?? null; + const imageCmd = nginx.Config?.Cmd ?? null; + const { doc } = parseCompose(nginx, { imageEntrypoint, imageCmd }); + const svc = doc.services[svcName]; + // nginx's entrypoint+cmd come from the image, so with the image passed they vanish. + expect(svc.entrypoint).toBeUndefined(); + expect(svc.command).toBeUndefined(); + }); + + test('a user-overridden command is KEPT even when the image command is provided', () => { + const custom = structuredClone(nginx); + custom.Config!.Cmd = ['nginx', '-g', 'daemon off; worker_processes 4;']; + const { doc } = parseCompose(custom, { imageCmd: nginx.Config?.Cmd ?? null }); + expect(doc.services[svcName].command).toEqual(['nginx', '-g', 'daemon off; worker_processes 4;']); + }); +}); + +describe('inspectToCompose - image-env subtraction', () => { + const nginx = loadFixture('nginx-rich.json'); + const svcName = 'itc-fixture'; + + test('without imageEnv, all env (incl. inherited PATH) is kept', () => { + const { doc } = parseCompose(nginx); + const env = (doc.services[svcName].environment as string[]) ?? []; + expect(env.some((e) => e.startsWith('PATH='))).toBe(true); + expect(env).toContain('MY_VAR=uservalue'); + }); + + test('with imageEnv, inherited vars are dropped and only user-set env remains', () => { + const imageEnv = (nginx.Config?.Env ?? []).filter((e) => !e.startsWith('MY_VAR=')); + const { doc } = parseCompose(nginx, { imageEnv }); + const env = (doc.services[svcName].environment as string[]) ?? []; + expect(env).toEqual(['MY_VAR=uservalue']); + }); +}); + +describe('inspectToCompose - default-noise filtering', () => { + test('an explicit restart "no" and a bare bridge network produce no restart/network keys', () => { + const minimal: DockerInspect = { + Name: '/plain', + Config: { Image: 'busybox:latest' }, + HostConfig: { + RestartPolicy: { Name: 'no', MaximumRetryCount: 0 }, + NetworkMode: 'bridge', + LogConfig: { Type: 'json-file', Config: {} } + }, + NetworkSettings: { Networks: { bridge: {} } } + }; + const { doc } = parseCompose(minimal); + const svc = doc.services['plain']; + expect(svc.restart).toBeUndefined(); + expect(svc.networks).toBeUndefined(); + expect(svc.network_mode).toBeUndefined(); + expect(svc.logging).toBeUndefined(); + expect(svc.image).toBe('busybox:latest'); + }); + + test('serviceName option overrides the container name', () => { + const { name } = inspectToComposeService({ Name: '/original', Config: { Image: 'x' } }, { serviceName: 'renamed' }); + expect(name).toBe('renamed'); + }); +});