Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 59 additions & 54 deletions src/compose/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,45 +19,24 @@ import {
import { collectAllNamedVolumes } from "./volumes.ts";
import type { ComposeData, ServiceDef } from "./types.ts";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface GenerateOptions {
/** Stack names to generate (undefined = all discovered). */
stacks?: string[];
/** Repository root path. */
configStackNames?: string[];
repoRoot: string;
/** Output directory for generated stacks (default: <repoRoot>/stacks). */
outputDir?: string;
/** Whether this is a dry run (no files written). */
dryRun?: boolean;
network?: string;
}

export interface GenerateResult {
/** Map of stack name -> YAML string content. */
generated: Record<string, string>;
/** Warnings encountered (non-fatal). */
warnings: string[];
/** Errors encountered (non-fatal — some stacks may still succeed). */
errors: string[];
/** Files that were (or would be) written. */
files: string[];
}

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

const NETWORK_NAME = "traefik-public";

// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
const DEFAULT_NETWORK_NAME = "traefik-public";

/**
* Generate canonical Swarm stack files from per-service Compose sources.
*/
export async function generateStacks(
options: GenerateOptions,
): Promise<GenerateResult> {
Expand All @@ -69,44 +48,46 @@ export async function generateStacks(
files: [],
};

// 1. Discover all compose files
const discovery = await discoverComposeFiles({ repoRoot: options.repoRoot });

for (const err of discovery.errors) {
result.warnings.push(`Discovery error at ${err.path}: ${err.message}`);
result.warnings.push("Discovery error at " + err.path + ": " + err.message);
}

// 2. Determine which stacks to generate
const targetStacks = options.stacks ?? Object.keys(discovery.stacks);
const targetStacks = options.stacks ??
(options.configStackNames && options.configStackNames.length > 0
? options.configStackNames
: Object.keys(discovery.stacks));

if (targetStacks.length === 0) {
result.warnings.push("No stacks discovered");
return result;
}

// 3. Ensure output directory exists
if (!options.dryRun) {
await ensureDir(outputDir);
}

// 4. Generate each stack
const network = options.network || DEFAULT_NETWORK_NAME;

for (const stackName of targetStacks) {
try {
const composePaths = discovery.stacks[stackName];
if (!composePaths || composePaths.length === 0) {
result.errors.push(`No compose files found for stack "${stackName}"`);
result.errors.push('No compose files found for stack "' + stackName + '"');
continue;
}

const output = await generateSingleStack(
stackName,
composePaths,
options.repoRoot,
network,
);

result.generated[stackName] = output;

const outPath = join(outputDir, `${stackName}.yml`);
const outPath = join(outputDir, stackName + ".yml");
if (options.dryRun) {
result.files.push(outPath);
} else {
Expand All @@ -115,24 +96,22 @@ export async function generateStacks(
}
} catch (err: unknown) {
result.errors.push(
`Stack "${stackName}": ${err instanceof Error ? err.message : String(err)}`,
'Stack "' + stackName + '": ' + (err instanceof Error ? err.message : String(err)),
);
}
}

return result;
}

// ---------------------------------------------------------------------------
// Single-stack generation
// ---------------------------------------------------------------------------

async function generateSingleStack(
_stackName: string,
composePaths: string[],
repoRoot: string,
network?: string,
): Promise<string> {
// 1. Load all compose files + fragments
const networkName = network || DEFAULT_NETWORK_NAME;

const sources = await Promise.all(
composePaths.map(async (path) => {
const composeDir = path.substring(0, path.lastIndexOf("/"));
Expand All @@ -142,60 +121,86 @@ async function generateSingleStack(
}),
);

// 2. Merge: compose data + fragment per-source, then merge all into one
const serviceDirMap = new Map<string, string>();
for (const src of sources) {
if (src.data.services) {
for (const svcName of Object.keys(src.data.services)) {
if (!serviceDirMap.has(svcName)) {
serviceDirMap.set(svcName, src.composeDir);
}
}
}
if (src.fragment.services) {
for (const svcName of Object.keys(src.fragment.services)) {
if (!serviceDirMap.has(svcName)) {
serviceDirMap.set(svcName, src.composeDir);
}
}
}
}

let merged: ComposeData = {};
for (const src of sources) {
const combined = composeDeepMerge(src.data, src.fragment);
merged = composeDeepMerge(merged, combined);
}

// 3. Transform services
if (merged.services) {
const transformed: Record<string, ServiceDef> = {};
for (const [svcName, svc] of Object.entries(merged.services)) {
const svcDir = serviceDirMap.get(svcName) ?? sources[0]?.composeDir ?? "";
let t = stripComposeOnlyKeys(svc);
t = applyLoggingDefaults(t);
t = rewriteEnvFile(t, sources[0]?.composeDir ?? "", repoRoot);
t = rewriteBindMountPaths(t, sources[0]?.composeDir ?? "", repoRoot);
t = rewriteEnvFile(t, svcDir, repoRoot);
t = rewriteBindMountPaths(t, svcDir, repoRoot);
transformed[svcName] = t;
}
merged = { ...merged, services: transformed };
}

// 4. Collect named volumes
const namedVolumes = collectAllNamedVolumes(merged.services);

// 5. Assemble output structure
const output: Record<string, unknown> = {};

// Services
if (merged.services && Object.keys(merged.services).length > 0) {
output.services = merged.services;
const svcs: Record<string, unknown> = {};
for (const key of Object.keys(merged.services).sort()) {
svcs[key] = merged.services[key];
}
output.services = svcs;
}

// Networks
output.networks = {
default: {
name: NETWORK_NAME,
name: networkName,
external: true,
},
};

// Volumes (only if named volumes exist)
if (namedVolumes.length > 0) {
const volumes: Record<string, unknown> = {};
for (const name of namedVolumes) {
volumes[name] = { external: true };
const topLevelVolumes = (merged.volumes ?? {}) as Record<string, unknown>;
for (const name of namedVolumes.sort()) {
const existingDef = topLevelVolumes[name];
if (existingDef && typeof existingDef === "object" && existingDef !== null) {
const def = { ...(existingDef as Record<string, unknown>) };
if (def.external === undefined) {
def.external = true;
}
volumes[name] = def;
} else {
volumes[name] = { external: true };
}
}
output.volumes = volumes;
}

// 6. Serialise to YAML
const header = "# Generated by stackctl generate — do not edit manually.\n";
const body = stringifyYaml(output, {
indent: 2,
lineWidth: 120,
noRefs: true,
} as Record<string, unknown>);
useAnchors: false,
sortKeys: true,
});
return header + body;
}
Loading