Skip to content

Commit 99c6a35

Browse files
authored
feat(generate): port local-stack stack generation to Deno
Ports generate_stacks.py to Deno: compose discovery, fragment merge, path rewriting, volume collection, config-driven stacks/network.
1 parent 18e8999 commit 99c6a35

2 files changed

Lines changed: 271 additions & 122 deletions

File tree

src/compose/generate.ts

Lines changed: 59 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -19,45 +19,24 @@ import {
1919
import { collectAllNamedVolumes } from "./volumes.ts";
2020
import type { ComposeData, ServiceDef } from "./types.ts";
2121

22-
// ---------------------------------------------------------------------------
23-
// Types
24-
// ---------------------------------------------------------------------------
25-
2622
export interface GenerateOptions {
27-
/** Stack names to generate (undefined = all discovered). */
2823
stacks?: string[];
29-
/** Repository root path. */
24+
configStackNames?: string[];
3025
repoRoot: string;
31-
/** Output directory for generated stacks (default: <repoRoot>/stacks). */
3226
outputDir?: string;
33-
/** Whether this is a dry run (no files written). */
3427
dryRun?: boolean;
28+
network?: string;
3529
}
3630

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

48-
// ---------------------------------------------------------------------------
49-
// Constants
50-
// ---------------------------------------------------------------------------
51-
52-
const NETWORK_NAME = "traefik-public";
53-
54-
// ---------------------------------------------------------------------------
55-
// Entry point
56-
// ---------------------------------------------------------------------------
38+
const DEFAULT_NETWORK_NAME = "traefik-public";
5739

58-
/**
59-
* Generate canonical Swarm stack files from per-service Compose sources.
60-
*/
6140
export async function generateStacks(
6241
options: GenerateOptions,
6342
): Promise<GenerateResult> {
@@ -69,44 +48,46 @@ export async function generateStacks(
6948
files: [],
7049
};
7150

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

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

79-
// 2. Determine which stacks to generate
80-
const targetStacks = options.stacks ?? Object.keys(discovery.stacks);
57+
const targetStacks = options.stacks ??
58+
(options.configStackNames && options.configStackNames.length > 0
59+
? options.configStackNames
60+
: Object.keys(discovery.stacks));
8161

8262
if (targetStacks.length === 0) {
8363
result.warnings.push("No stacks discovered");
8464
return result;
8565
}
8666

87-
// 3. Ensure output directory exists
8867
if (!options.dryRun) {
8968
await ensureDir(outputDir);
9069
}
9170

92-
// 4. Generate each stack
71+
const network = options.network || DEFAULT_NETWORK_NAME;
72+
9373
for (const stackName of targetStacks) {
9474
try {
9575
const composePaths = discovery.stacks[stackName];
9676
if (!composePaths || composePaths.length === 0) {
97-
result.errors.push(`No compose files found for stack "${stackName}"`);
77+
result.errors.push('No compose files found for stack "' + stackName + '"');
9878
continue;
9979
}
10080

10181
const output = await generateSingleStack(
10282
stackName,
10383
composePaths,
10484
options.repoRoot,
85+
network,
10586
);
10687

10788
result.generated[stackName] = output;
10889

109-
const outPath = join(outputDir, `${stackName}.yml`);
90+
const outPath = join(outputDir, stackName + ".yml");
11091
if (options.dryRun) {
11192
result.files.push(outPath);
11293
} else {
@@ -115,24 +96,22 @@ export async function generateStacks(
11596
}
11697
} catch (err: unknown) {
11798
result.errors.push(
118-
`Stack "${stackName}": ${err instanceof Error ? err.message : String(err)}`,
99+
'Stack "' + stackName + '": ' + (err instanceof Error ? err.message : String(err)),
119100
);
120101
}
121102
}
122103

123104
return result;
124105
}
125106

126-
// ---------------------------------------------------------------------------
127-
// Single-stack generation
128-
// ---------------------------------------------------------------------------
129-
130107
async function generateSingleStack(
131108
_stackName: string,
132109
composePaths: string[],
133110
repoRoot: string,
111+
network?: string,
134112
): Promise<string> {
135-
// 1. Load all compose files + fragments
113+
const networkName = network || DEFAULT_NETWORK_NAME;
114+
136115
const sources = await Promise.all(
137116
composePaths.map(async (path) => {
138117
const composeDir = path.substring(0, path.lastIndexOf("/"));
@@ -142,60 +121,86 @@ async function generateSingleStack(
142121
}),
143122
);
144123

145-
// 2. Merge: compose data + fragment per-source, then merge all into one
124+
const serviceDirMap = new Map<string, string>();
125+
for (const src of sources) {
126+
if (src.data.services) {
127+
for (const svcName of Object.keys(src.data.services)) {
128+
if (!serviceDirMap.has(svcName)) {
129+
serviceDirMap.set(svcName, src.composeDir);
130+
}
131+
}
132+
}
133+
if (src.fragment.services) {
134+
for (const svcName of Object.keys(src.fragment.services)) {
135+
if (!serviceDirMap.has(svcName)) {
136+
serviceDirMap.set(svcName, src.composeDir);
137+
}
138+
}
139+
}
140+
}
141+
146142
let merged: ComposeData = {};
147143
for (const src of sources) {
148144
const combined = composeDeepMerge(src.data, src.fragment);
149145
merged = composeDeepMerge(merged, combined);
150146
}
151147

152-
// 3. Transform services
153148
if (merged.services) {
154149
const transformed: Record<string, ServiceDef> = {};
155150
for (const [svcName, svc] of Object.entries(merged.services)) {
151+
const svcDir = serviceDirMap.get(svcName) ?? sources[0]?.composeDir ?? "";
156152
let t = stripComposeOnlyKeys(svc);
157153
t = applyLoggingDefaults(t);
158-
t = rewriteEnvFile(t, sources[0]?.composeDir ?? "", repoRoot);
159-
t = rewriteBindMountPaths(t, sources[0]?.composeDir ?? "", repoRoot);
154+
t = rewriteEnvFile(t, svcDir, repoRoot);
155+
t = rewriteBindMountPaths(t, svcDir, repoRoot);
160156
transformed[svcName] = t;
161157
}
162158
merged = { ...merged, services: transformed };
163159
}
164160

165-
// 4. Collect named volumes
166161
const namedVolumes = collectAllNamedVolumes(merged.services);
167162

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

171-
// Services
172165
if (merged.services && Object.keys(merged.services).length > 0) {
173-
output.services = merged.services;
166+
const svcs: Record<string, unknown> = {};
167+
for (const key of Object.keys(merged.services).sort()) {
168+
svcs[key] = merged.services[key];
169+
}
170+
output.services = svcs;
174171
}
175172

176-
// Networks
177173
output.networks = {
178174
default: {
179-
name: NETWORK_NAME,
175+
name: networkName,
180176
external: true,
181177
},
182178
};
183179

184-
// Volumes (only if named volumes exist)
185180
if (namedVolumes.length > 0) {
186181
const volumes: Record<string, unknown> = {};
187-
for (const name of namedVolumes) {
188-
volumes[name] = { external: true };
182+
const topLevelVolumes = (merged.volumes ?? {}) as Record<string, unknown>;
183+
for (const name of namedVolumes.sort()) {
184+
const existingDef = topLevelVolumes[name];
185+
if (existingDef && typeof existingDef === "object" && existingDef !== null) {
186+
const def = { ...(existingDef as Record<string, unknown>) };
187+
if (def.external === undefined) {
188+
def.external = true;
189+
}
190+
volumes[name] = def;
191+
} else {
192+
volumes[name] = { external: true };
193+
}
189194
}
190195
output.volumes = volumes;
191196
}
192197

193-
// 6. Serialise to YAML
194198
const header = "# Generated by stackctl generate — do not edit manually.\n";
195199
const body = stringifyYaml(output, {
196200
indent: 2,
197201
lineWidth: 120,
198-
noRefs: true,
199-
} as Record<string, unknown>);
202+
useAnchors: false,
203+
sortKeys: true,
204+
});
200205
return header + body;
201206
}

0 commit comments

Comments
 (0)