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 });
|
||||
}
|
||||
Reference in new issue
Block a user