generate a compose file from any container - save as new or append to existing stack
This commit is contained in:
12 files changed
+1939
-4
No files matched your search
@@ -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<DecorationSet>({
|
||||
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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.<key>:` 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 }];
|
||||
}
|
||||
@@ -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<string, string> | 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<string, Array<{ HostIp?: string; HostPort?: string }> | 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<string, string> | null;
|
||||
Tmpfs?: Record<string, string> | null;
|
||||
Ulimits?: Array<{ Name: string; Soft: number; Hard: number }> | null;
|
||||
LogConfig?: { Type?: string; Config?: Record<string, string> | null };
|
||||
};
|
||||
NetworkSettings?: {
|
||||
Networks?: Record<string, { MacAddress?: string } | null> | 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<string, string> | 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<string, unknown>; namedVolumes: string[] } {
|
||||
const config = inspect.Config ?? {};
|
||||
const host = inspect.HostConfig ?? {};
|
||||
const name = options.serviceName ?? cleanName(inspect.Name);
|
||||
const service: Record<string, unknown> = {};
|
||||
|
||||
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<string, unknown> = { 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<string, unknown> = {};
|
||||
// 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<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 }]));
|
||||
}
|
||||
|
||||
if (namedVolumes.length > 0) {
|
||||
doc.volumes = Object.fromEntries(namedVolumes.map((v) => [v, { external: true }]));
|
||||
}
|
||||
|
||||
return yaml.dump(doc, { lineWidth: -1, noRefs: true, sortKeys: false });
|
||||
}
|
||||
@@ -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<string, string> | 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 });
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* "Compose" tab in the container inspect modal (#489): generates a docker-compose
|
||||
* service definition from the container's inspect and lets the user validate it, copy /
|
||||
* download it, save it as a NEW stack, or merge it into an EXISTING internal stack (with
|
||||
* the merged-in lines highlighted before saving).
|
||||
*/
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { ToggleSwitch } from '$lib/components/ui/toggle-pill';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Loader2, Copy, Check, Download, Layers, FileCode, ChevronDown, ListPlus, ShieldCheck, Info, Save } from 'lucide-svelte';
|
||||
import CodeEditor from '$lib/components/CodeEditor.svelte';
|
||||
import StackIcon from '$lib/components/StackIcon.svelte';
|
||||
import StackModal from '../stacks/StackModal.svelte';
|
||||
import ComposeValidatePanel from '../stacks/ComposeValidatePanel.svelte';
|
||||
import { appendEnvParam } from '$lib/stores/environment';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { mergeServiceIntoCompose, computeAddedRange } from '$lib/utils/compose-merge';
|
||||
|
||||
interface Props {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
envId: number | null;
|
||||
}
|
||||
let { containerId, containerName, envId }: Props = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
/** Compose with only user-set env; and the variant that keeps every (image-inherited) env var. */
|
||||
let composeUserEnv = $state('');
|
||||
let composeFullEnv = $state('');
|
||||
let serviceName = $state('');
|
||||
let stackProject = $state<string | null>(null);
|
||||
let envMode = $state<'user' | 'all'>('user');
|
||||
const onlyUserEnv = $derived(envMode === 'user');
|
||||
let copied = $state(false);
|
||||
|
||||
// The editor holds the user's (possibly edited) content. It is seeded from the generated
|
||||
// compose and re-seeded when the env toggle flips (unless the user has edited).
|
||||
let editorContent = $state('');
|
||||
let userEdited = $state(false);
|
||||
let editorRef = $state<CodeEditor | null>(null);
|
||||
let addedLineMarkers = $state<{ line: number; endLine: number }[]>([]);
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
loadError = null;
|
||||
try {
|
||||
const res = await fetch(appendEnvParam(`/api/containers/${containerId}/compose`, envId));
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Failed to generate compose (${res.status})`);
|
||||
}
|
||||
const data = await res.json();
|
||||
composeUserEnv = data.compose ?? '';
|
||||
composeFullEnv = data.composeFullEnv ?? '';
|
||||
serviceName = data.serviceName ?? containerName;
|
||||
stackProject = data.stackProject ?? null;
|
||||
editorContent = onlyUserEnv ? composeUserEnv : composeFullEnv;
|
||||
} catch (e) {
|
||||
loadError = e instanceof Error ? e.message : 'Failed to generate compose';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (containerId) load();
|
||||
});
|
||||
|
||||
// React to the env toggle. Takes the mode explicitly (not the derived `onlyUserEnv`) so
|
||||
// it uses the NEW value synchronously. Works consistently in BOTH modes: a plain compose
|
||||
// re-seeds the editor; a merge-under-review RE-MERGES the service in the new env variant
|
||||
// (so the toggle behaves the same as it does for a new stack). A hand-edit opts out.
|
||||
function reseedEditor(mode: 'user' | 'all') {
|
||||
if (userEdited) return;
|
||||
const variant = mode === 'user' ? composeUserEnv : composeFullEnv;
|
||||
if (mergedIntoStack) {
|
||||
applyMerge(variant, mergedIntoStack); // applyMerge revalidates itself
|
||||
} else {
|
||||
editorContent = variant;
|
||||
editorRef?.setValue(variant);
|
||||
afterContentChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// The editor content just changed programmatically (toggle / merge / cancel). Any open
|
||||
// validate report is now stale (line numbers moved), so re-run it if the panel is open,
|
||||
// otherwise drop the stale findings.
|
||||
function afterContentChanged() {
|
||||
if (validatePanelOpen) runValidate();
|
||||
else validateReport = null;
|
||||
}
|
||||
|
||||
function onEditorChange(v: string) {
|
||||
editorContent = v;
|
||||
userEdited = true;
|
||||
// A hand-edit invalidates the "these are the added lines" highlight.
|
||||
if (addedLineMarkers.length > 0) addedLineMarkers = [];
|
||||
}
|
||||
|
||||
async function doCopy() {
|
||||
const ok = await copyToClipboard(editorContent);
|
||||
if (ok) {
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1500);
|
||||
} else {
|
||||
toast.error('Failed to copy');
|
||||
}
|
||||
}
|
||||
|
||||
function doDownload() {
|
||||
const blob = new Blob([editorContent], { type: 'text/yaml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${serviceName || containerName}.compose.yaml`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// --- Validate (reuses the Compose Validate engine via the stacks endpoint) ---
|
||||
let validatePanelOpen = $state(false);
|
||||
let validateLoading = $state(false);
|
||||
let validateError = $state<string | null>(null);
|
||||
let validateActiveLine = $state<number | null>(null);
|
||||
let validateReport = $state<import('../stacks/ComposeValidatePanel.svelte').ValidateReport | null>(null);
|
||||
let validateSeq = 0;
|
||||
const validateMarkers = $derived(
|
||||
(validateReport?.findings ?? [])
|
||||
.filter((f) => typeof f.line === 'number')
|
||||
.map((f) => ({ line: f.line!, severity: f.severity, ruleId: f.ruleId, message: f.message }))
|
||||
);
|
||||
|
||||
async function runValidate() {
|
||||
if (!editorContent.trim()) return;
|
||||
validateLoading = true;
|
||||
validateError = null;
|
||||
validatePanelOpen = true;
|
||||
const seq = ++validateSeq;
|
||||
try {
|
||||
const res = await fetch(
|
||||
appendEnvParam(`/api/stacks/${encodeURIComponent(serviceName || 'generated')}/validate`, envId),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ compose: editorContent })
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Validation failed (${res.status})`);
|
||||
}
|
||||
const fresh = await res.json();
|
||||
if (seq !== validateSeq) return;
|
||||
validateReport = fresh;
|
||||
} catch (e) {
|
||||
if (seq !== validateSeq) return;
|
||||
validateError = e instanceof Error ? e.message : 'Validation failed';
|
||||
validateReport = null;
|
||||
} finally {
|
||||
if (seq === validateSeq) validateLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function jumpToLine(line: number) {
|
||||
validateActiveLine = line;
|
||||
editorRef?.scrollToLine?.(line);
|
||||
}
|
||||
|
||||
// --- Save as NEW stack (StackModal prefilled) ---
|
||||
let stackModalOpen = $state(false);
|
||||
|
||||
// --- Add to an EXISTING internal stack ---
|
||||
interface StackOption {
|
||||
name: string;
|
||||
icon: string | null;
|
||||
}
|
||||
let existingStacks = $state<StackOption[]>([]);
|
||||
let loadingStacks = $state(false);
|
||||
let merging = $state(false);
|
||||
let mergedIntoStack = $state<string | null>(null);
|
||||
let appendMenuOpen = $state(false);
|
||||
// The target stack's own compose, captured once so the env toggle can RE-merge the
|
||||
// service (in the current env variant) onto a clean base instead of merge-on-merge.
|
||||
let mergeBaseCompose = $state<string>('');
|
||||
|
||||
async function loadInternalStacks() {
|
||||
loadingStacks = true;
|
||||
try {
|
||||
const res = await fetch(appendEnvParam('/api/stacks', envId));
|
||||
const all = res.ok ? await res.json() : [];
|
||||
existingStacks = (all as any[])
|
||||
.filter((s) => s.sourceType === 'internal')
|
||||
.map((s) => ({ name: s.name, icon: s.icon ?? null }));
|
||||
} catch {
|
||||
existingStacks = [];
|
||||
} finally {
|
||||
loadingStacks = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the generated service into an existing stack's compose (parse -> insert -> dump),
|
||||
* auto-suffixing the service key on a name clash, then show the merged doc with the added
|
||||
* lines highlighted. The user reviews/edits before Save writes it.
|
||||
*/
|
||||
async function addToExisting(stack: StackOption) {
|
||||
appendMenuOpen = false; // the async handler doesn't auto-close the menu
|
||||
if (mergedIntoStack) return; // already reviewing a merge; cancel it first
|
||||
merging = true;
|
||||
try {
|
||||
const res = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stack.name)}/compose`, envId));
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Failed to read ${stack.name} compose`);
|
||||
}
|
||||
const { content } = await res.json();
|
||||
mergeBaseCompose = content ?? '';
|
||||
mergedIntoStack = stack.name;
|
||||
// Merge using the CURRENT env variant of the generated service (warn on clash once).
|
||||
applyMerge(onlyUserEnv ? composeUserEnv : composeFullEnv, stack.name, true);
|
||||
toast.info(`Merged into ${stack.name} - review the highlighted service, then Save`);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Merge failed');
|
||||
} finally {
|
||||
merging = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `serviceCompose`'s single service onto the captured base stack compose and show
|
||||
* it with the added lines highlighted. Pure w.r.t. the network - re-run on env toggle to
|
||||
* swap which env variant of the service is merged in.
|
||||
*/
|
||||
function applyMerge(serviceCompose: string, stackName: string, notify = false) {
|
||||
const { merged, key, renamed } = mergeServiceIntoCompose(mergeBaseCompose, serviceCompose);
|
||||
// Only warn on the first merge - re-merges from the env toggle would re-spam the toast.
|
||||
if (renamed && notify) {
|
||||
const original = key.replace(/-\d+$/, '');
|
||||
toast.warning(`"${original}" already exists in ${stackName}; added as "${key}"`);
|
||||
}
|
||||
// Set text + highlight in ONE transaction so the decorations land on the new doc.
|
||||
const ranges = computeAddedRange(merged, key);
|
||||
addedLineMarkers = ranges;
|
||||
editorContent = merged;
|
||||
userEdited = false;
|
||||
editorRef?.setValueWithAddedRanges(merged, ranges);
|
||||
afterContentChanged();
|
||||
}
|
||||
|
||||
async function saveToExisting() {
|
||||
if (!mergedIntoStack) return;
|
||||
merging = true;
|
||||
try {
|
||||
const res = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(mergedIntoStack)}/compose`, envId), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: editorContent })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Failed to save (${res.status})`);
|
||||
}
|
||||
toast.success(`Saved to ${mergedIntoStack}`);
|
||||
mergedIntoStack = null;
|
||||
mergeBaseCompose = '';
|
||||
addedLineMarkers = [];
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Save failed');
|
||||
} finally {
|
||||
merging = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelMerge() {
|
||||
mergedIntoStack = null;
|
||||
mergeBaseCompose = '';
|
||||
addedLineMarkers = [];
|
||||
editorContent = onlyUserEnv ? composeUserEnv : composeFullEnv;
|
||||
userEdited = false;
|
||||
editorRef?.setValue(editorContent);
|
||||
afterContentChanged();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full min-h-0 gap-3">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12 text-muted-foreground">
|
||||
<Loader2 class="h-5 w-5 animate-spin mr-2" /> Generating compose...
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<div class="text-sm text-red-500 py-4">{loadError}</div>
|
||||
{:else}
|
||||
<!-- Part-of-stack banner -->
|
||||
{#if stackProject}
|
||||
<div class="flex items-center gap-2 text-xs rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2">
|
||||
<Layers class="h-3.5 w-3.5 text-amber-600 dark:text-amber-400 shrink-0" />
|
||||
<span>This container already belongs to stack <span class="font-semibold">{stackProject}</span>. The generated compose is a fresh definition you can save separately or merge elsewhere.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-xs text-muted-foreground">Environment</span>
|
||||
<ToggleSwitch
|
||||
value={envMode}
|
||||
leftValue="user"
|
||||
rightValue="all"
|
||||
leftLabel="User-set only"
|
||||
rightLabel="All"
|
||||
onchange={(v) => { const m = v as 'user' | 'all'; envMode = m; reseedEditor(m); }}
|
||||
/>
|
||||
<div class="flex-1"></div>
|
||||
<Button variant="outline" size="sm" onclick={runValidate} disabled={validateLoading}>
|
||||
{#if validateLoading}<Loader2 class="h-3.5 w-3.5 mr-1.5 animate-spin" />{:else}<ShieldCheck class="h-3.5 w-3.5 mr-1.5" />{/if}
|
||||
Validate
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={doCopy}>
|
||||
{#if copied}<Check class="h-3.5 w-3.5 mr-1.5 text-green-500" />{:else}<Copy class="h-3.5 w-3.5 mr-1.5" />{/if}
|
||||
Copy
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onclick={doDownload}>
|
||||
<Download class="h-3.5 w-3.5 mr-1.5" /> Download
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onclick={() => (stackModalOpen = true)}>
|
||||
<FileCode class="h-3.5 w-3.5 mr-1.5" /> Save as new stack
|
||||
</Button>
|
||||
<DropdownMenu.Root bind:open={appendMenuOpen} onOpenChange={(o) => o && loadInternalStacks()}>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button size="sm" variant="outline">
|
||||
<ListPlus class="h-3.5 w-3.5 mr-1.5" /> Append to existing <ChevronDown class="h-3.5 w-3.5 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-64 max-h-72 overflow-auto">
|
||||
{#if loadingStacks}
|
||||
<div class="flex items-center gap-2 px-2 py-2 text-xs text-muted-foreground"><Loader2 class="h-3.5 w-3.5 animate-spin" /> Loading...</div>
|
||||
{:else if existingStacks.length === 0}
|
||||
<div class="px-2 py-2 text-xs text-muted-foreground">No internal stacks</div>
|
||||
{:else}
|
||||
{#each existingStacks as s (s.name)}
|
||||
<DropdownMenu.Item
|
||||
class="flex items-center gap-2 cursor-pointer"
|
||||
onSelect={() => addToExisting(s)}
|
||||
disabled={merging}
|
||||
>
|
||||
<StackIcon icon={s.icon} stackName={s.name} {envId} class="w-4 h-4 shrink-0" />
|
||||
<span class="truncate">{s.name}</span>
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
||||
<!-- Merge review bar -->
|
||||
{#if mergedIntoStack}
|
||||
<div class="flex items-center gap-2 text-xs rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2">
|
||||
<Info class="h-3.5 w-3.5 text-amber-600 dark:text-amber-400 shrink-0" />
|
||||
<span class="flex-1">Reviewing merge into <span class="font-semibold">{mergedIntoStack}</span>. The highlighted service is what will be added.</span>
|
||||
<Button variant="ghost" size="sm" onclick={cancelMerge} disabled={merging}>Cancel</Button>
|
||||
<Button size="sm" onclick={saveToExisting} disabled={merging}>
|
||||
{#if merging}<Loader2 class="h-3.5 w-3.5 mr-1.5 animate-spin" />{:else}<Save class="h-3.5 w-3.5 mr-1.5" />{/if}
|
||||
Save to {mergedIntoStack}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Editor + validate panel -->
|
||||
<div class="flex-1 min-h-0 flex gap-3">
|
||||
<div class="flex-1 min-h-0 border rounded-md overflow-hidden">
|
||||
<CodeEditor
|
||||
bind:this={editorRef}
|
||||
value={editorContent}
|
||||
language="yaml"
|
||||
onchange={onEditorChange}
|
||||
lintMarkers={validateMarkers}
|
||||
{addedLineMarkers}
|
||||
onLintClick={jumpToLine}
|
||||
class="h-full"
|
||||
/>
|
||||
</div>
|
||||
{#if validatePanelOpen}
|
||||
<div class="w-80 shrink-0 min-h-0">
|
||||
<ComposeValidatePanel
|
||||
report={validateReport}
|
||||
loading={validateLoading}
|
||||
error={validateError}
|
||||
activeLine={validateActiveLine}
|
||||
onClose={() => (validatePanelOpen = false)}
|
||||
onJumpToLine={jumpToLine}
|
||||
onRevalidate={runValidate}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<StackModal
|
||||
bind:open={stackModalOpen}
|
||||
mode="create"
|
||||
initialCompose={editorContent}
|
||||
initialStackName={serviceName}
|
||||
onClose={() => (stackModalOpen = false)}
|
||||
onSuccess={() => {
|
||||
stackModalOpen = false;
|
||||
toast.success('Stack created');
|
||||
}}
|
||||
/>
|
||||
@@ -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 @@
|
||||
<Tabs.Trigger value="security" onclick={() => showLogs = false}>Security</Tabs.Trigger>
|
||||
<Tabs.Trigger value="resources" onclick={() => showLogs = false}>Resources</Tabs.Trigger>
|
||||
<Tabs.Trigger value="health" onclick={() => showLogs = false}>Health</Tabs.Trigger>
|
||||
<Tabs.Trigger value="compose" onclick={() => showLogs = false}>Compose</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<!-- Overview Tab -->
|
||||
@@ -1834,6 +1836,17 @@
|
||||
<p class="text-sm text-muted-foreground">No health check configured</p>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Compose Tab (last) -->
|
||||
<Tabs.Content value="compose" class="flex-1 min-h-0">
|
||||
{#if activeTab === 'compose'}
|
||||
<ContainerComposeTab
|
||||
{containerId}
|
||||
containerName={containerName || containerId.slice(0, 12)}
|
||||
envId={$currentEnvironment?.id ?? null}
|
||||
/>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Vendored
+248
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
+299
@@ -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 <docker-maint@nginx.com>",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -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 <docker-maint@nginx.com>' };
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user