Skip to content

Commit f4770c1

Browse files
committed
feat: add interactive cluster initialization wizard
Add berget clusters init command for setting up new Kubernetes clusters with FluxCD GitOps and infrastructure components. Features: - Interactive wizard with clack prompts - Automatic YAML manifest generation from berget-k8s-template - Support for official template repo or custom repositories - Component selection: cert-manager, external-dns, ingress-nginx, cloudnative-pg, redis-operator, prometheus, grafana - Flux bootstrap integration - Prerequisites checking (kubectl, flux, git) New files: - src/commands/clusters/init.ts (wizard logic) - src/commands/clusters/init-command.ts (command adapter) - src/commands/clusters/yaml-generator.ts (YAML generation) - src/commands/clusters/__tests__/yaml-generator.test.ts Modified: - src/commands/clusters.ts (added init command) - src/constants/command-structure.ts (added INIT subcommand)
1 parent 4152155 commit f4770c1

7 files changed

Lines changed: 1384 additions & 0 deletions

File tree

src/commands/clusters.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Command } from 'commander';
22

33
import { Cluster, ClusterService } from '../services/cluster-service';
44
import { handleError } from '../utils/error-handler';
5+
import { runClusterInitCommand } from './clusters/init-command';
56

67
/**
78
* Register cluster commands
@@ -11,6 +12,25 @@ export function registerClusterCommands(program: Command): void {
1112
.command(ClusterService.COMMAND_GROUP)
1213
.description('Manage Berget clusters');
1314

15+
cluster
16+
.command('init')
17+
.description('Initialize a new Kubernetes cluster with FluxCD GitOps and infrastructure components')
18+
.option('--cluster-name <name>', 'Cluster name (skip interactive prompt)')
19+
.option('--domain <domain>', 'Base domain for the cluster (skip interactive prompt)')
20+
.option('--repo-url <url>', 'Git repository URL for FluxCD')
21+
.option('--template-repo', 'Use the official berget-k8s-template repository')
22+
.option(
23+
'--components <components>',
24+
'Comma-separated list of components to install (e.g., cert-manager,external-dns,ingress-nginx)',
25+
)
26+
.action(async (options) => {
27+
try {
28+
await runClusterInitCommand(options);
29+
} catch (error) {
30+
handleError('Failed to initialize cluster', error);
31+
}
32+
});
33+
1434
cluster
1535
.command(ClusterService.COMMANDS.LIST)
1636
.description('List all Berget clusters')
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { FakeCommandRunner } from '../../code/__tests__/fake-command-runner';
4+
import { FakeFileStore } from '../../code/__tests__/fake-file-store';
5+
import { CANCEL, confirm, FakePrompter, multiselect, select, text } from '../../code/__tests__/fake-prompter';
6+
7+
import { CancelledError, PrerequisiteError } from '../../code/errors';
8+
9+
import { runClusterInit } from '../init';
10+
11+
describe('runClusterInit', () => {
12+
// Helper to create a FakeCommandRunner with all prerequisites installed
13+
function createCommands(): FakeCommandRunner {
14+
return new FakeCommandRunner()
15+
.handle('kubectl --version', '')
16+
.handle('flux --version', '')
17+
.handle('git --version', '');
18+
}
19+
20+
it('completes full wizard with template repo', async () => {
21+
const commands = createCommands()
22+
.handle(/git clone/, '')
23+
.handle(/rm -rf/, '')
24+
.handle(/flux bootstrap/, '');
25+
26+
const files = new FakeFileStore();
27+
const prompter = new FakePrompter([
28+
text('my-cluster'),
29+
text('example.com'),
30+
select('template'),
31+
multiselect(['cert-manager', 'external-dns']),
32+
confirm(true),
33+
confirm(true),
34+
]);
35+
36+
await runClusterInit(
37+
{ commands, cwd: '/test', files, prompter },
38+
{},
39+
);
40+
41+
prompter.assertExhausted();
42+
43+
// Verify directories created
44+
expect(await files.exists('/test/clusters')).toBe(true);
45+
expect(await files.exists('/test/clusters/flux-system')).toBe(true);
46+
expect(await files.exists('/test/clusters/infrastructure/cert-manager')).toBe(true);
47+
expect(await files.exists('/test/clusters/infrastructure/external-dns')).toBe(true);
48+
49+
// Verify gotk-sync.yaml written
50+
const gotkSync = await files.readFile('/test/clusters/flux-system/gotk-sync.yaml');
51+
expect(gotkSync).toContain('name: flux-system');
52+
expect(gotkSync).toContain('url: https://github.com/berget-ai/berget-k8s-template');
53+
54+
// Verify component manifests written
55+
const certManager = await files.readFile('/test/clusters/infrastructure/cert-manager/cert-manager.yaml');
56+
expect(certManager).toContain('name: cert-manager');
57+
expect(certManager).toContain('namespace: cert-manager');
58+
59+
const externalDns = await files.readFile('/test/clusters/infrastructure/external-dns/external-dns.yaml');
60+
expect(externalDns).toContain('external-dns.my-cluster');
61+
expect(externalDns).toContain('example.com');
62+
});
63+
64+
it('completes wizard with existing repo', async () => {
65+
const commands = createCommands();
66+
67+
const files = new FakeFileStore();
68+
const prompter = new FakePrompter([
69+
text('prod-cluster'),
70+
text('berget.ai'),
71+
select('existing'),
72+
text('git@github.com:my-org/infra.git'),
73+
multiselect(['ingress-nginx']),
74+
confirm(true),
75+
confirm(false), // Don't run flux bootstrap
76+
]);
77+
78+
await runClusterInit(
79+
{ commands, cwd: '/test', files, prompter },
80+
{},
81+
);
82+
83+
prompter.assertExhausted();
84+
85+
const gotkSync = await files.readFile('/test/clusters/flux-system/gotk-sync.yaml');
86+
expect(gotkSync).toContain('git@github.com:my-org/infra.git');
87+
88+
const ingress = await files.readFile('/test/clusters/infrastructure/ingress-nginx.yaml');
89+
expect(ingress).toContain('name: ingress-nginx');
90+
});
91+
92+
it('skips interactive prompts when all options provided', async () => {
93+
const commands = createCommands()
94+
.handle(/git clone/, '')
95+
.handle(/rm -rf/, '')
96+
.handle(/flux bootstrap/, '');
97+
98+
const files = new FakeFileStore();
99+
const prompter = new FakePrompter([
100+
confirm(true),
101+
confirm(false),
102+
]);
103+
104+
await runClusterInit(
105+
{ commands, cwd: '/test', files, prompter },
106+
{
107+
clusterName: 'auto-cluster',
108+
components: ['prometheus', 'grafana'],
109+
domain: 'auto.example.com',
110+
templateRepo: true,
111+
},
112+
);
113+
114+
prompter.assertExhausted();
115+
116+
const prometheus = await files.readFile('/test/clusters/infrastructure/monitoring/prometheus.yaml');
117+
expect(prometheus).toContain('name: prometheus');
118+
119+
const grafana = await files.readFile('/test/clusters/infrastructure/monitoring/grafana.yaml');
120+
expect(grafana).toContain('grafana.auto-cluster.auto.example.com');
121+
});
122+
123+
it('throws PrerequisiteError when kubectl is missing', async () => {
124+
const commands = new FakeCommandRunner();
125+
// No handlers = nothing is installed
126+
127+
const files = new FakeFileStore();
128+
const prompter = new FakePrompter([]);
129+
130+
await expect(
131+
runClusterInit({ commands, cwd: '/test', files, prompter }, {}),
132+
).rejects.toThrow(PrerequisiteError);
133+
});
134+
135+
it('throws CancelledError when user cancels at confirmation', async () => {
136+
const commands = createCommands();
137+
138+
const files = new FakeFileStore();
139+
const prompter = new FakePrompter([
140+
text('my-cluster'),
141+
text('example.com'),
142+
select('template'),
143+
multiselect(['cert-manager']),
144+
confirm(false), // Cancel at confirmation
145+
]);
146+
147+
await expect(
148+
runClusterInit({ commands, cwd: '/test', files, prompter }, {}),
149+
).rejects.toThrow(CancelledError);
150+
});
151+
152+
it('throws CancelledError when user cancels cluster name prompt', async () => {
153+
const commands = createCommands();
154+
155+
const files = new FakeFileStore();
156+
const prompter = new FakePrompter([
157+
text(CANCEL),
158+
]);
159+
160+
await expect(
161+
runClusterInit({ commands, cwd: '/test', files, prompter }, {}),
162+
).rejects.toThrow(CancelledError);
163+
});
164+
165+
it('handles all available components', async () => {
166+
const commands = createCommands()
167+
.handle(/git clone/, '')
168+
.handle(/rm -rf/, '')
169+
.handle(/flux bootstrap/, '');
170+
171+
const files = new FakeFileStore();
172+
const prompter = new FakePrompter([
173+
text('full-cluster'),
174+
text('test.com'),
175+
select('template'),
176+
multiselect([
177+
'cert-manager',
178+
'external-dns',
179+
'ingress-nginx',
180+
'cloudnative-pg',
181+
'redis-operator',
182+
'prometheus',
183+
'grafana',
184+
]),
185+
confirm(true),
186+
confirm(false),
187+
]);
188+
189+
await runClusterInit(
190+
{ commands, cwd: '/test', files, prompter },
191+
{},
192+
);
193+
194+
prompter.assertExhausted();
195+
196+
// Verify all components were written
197+
expect(await files.readFile('/test/clusters/infrastructure/cert-manager/cert-manager.yaml')).toBeTruthy();
198+
expect(await files.readFile('/test/clusters/infrastructure/external-dns/external-dns.yaml')).toBeTruthy();
199+
expect(await files.readFile('/test/clusters/infrastructure/ingress-nginx/ingress-nginx.yaml')).toBeTruthy();
200+
expect(await files.readFile('/test/clusters/infrastructure/operators/cloudnative-pg/cloudnative-pg.yaml')).toBeTruthy();
201+
expect(await files.readFile('/test/clusters/infrastructure/operators/redis/redis-operator.yaml')).toBeTruthy();
202+
expect(await files.readFile('/test/clusters/infrastructure/monitoring/prometheus.yaml')).toBeTruthy();
203+
expect(await files.readFile('/test/clusters/infrastructure/monitoring/grafana.yaml')).toBeTruthy();
204+
});
205+
206+
it('exits gracefully when no components selected', async () => {
207+
const commands = createCommands();
208+
209+
const files = new FakeFileStore();
210+
const prompter = new FakePrompter([
211+
text('empty-cluster'),
212+
text('example.com'),
213+
select('template'),
214+
multiselect([]),
215+
]);
216+
217+
await runClusterInit(
218+
{ commands, cwd: '/test', files, prompter },
219+
{},
220+
);
221+
222+
prompter.assertExhausted();
223+
224+
// Should not create any component files
225+
const writtenFiles = files.getWrittenFiles();
226+
expect(writtenFiles.size).toBe(0);
227+
});
228+
229+
it('validates cluster name format', async () => {
230+
const commands = createCommands()
231+
.handle(/git clone/, '')
232+
.handle(/rm -rf/, '')
233+
.handle(/flux bootstrap/, '');
234+
235+
const files = new FakeFileStore();
236+
const prompter = new FakePrompter([
237+
text('Invalid_Name'), // Invalid: contains underscore and uppercase
238+
text('valid-name'),
239+
text('example.com'),
240+
select('template'),
241+
multiselect(['cert-manager']),
242+
confirm(true),
243+
confirm(false),
244+
]);
245+
246+
await runClusterInit(
247+
{ commands, cwd: '/test', files, prompter },
248+
{},
249+
);
250+
251+
prompter.assertExhausted();
252+
253+
// Should show validation note and ask again
254+
const noteCall = prompter.calls.find((c) => c.method === 'note' && c.args.title === 'Invalid name');
255+
expect(noteCall).toBeDefined();
256+
});
257+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import {
4+
generateComponentManifest,
5+
getAvailableComponents,
6+
getComponentDescription,
7+
} from '../yaml-generator';
8+
9+
describe('yaml-generator', () => {
10+
describe('getAvailableComponents', () => {
11+
it('returns all available components', () => {
12+
const components = getAvailableComponents();
13+
expect(components).toContain('cert-manager');
14+
expect(components).toContain('external-dns');
15+
expect(components).toContain('ingress-nginx');
16+
expect(components).toContain('cloudnative-pg');
17+
expect(components).toContain('redis-operator');
18+
expect(components).toContain('prometheus');
19+
expect(components).toContain('grafana');
20+
});
21+
});
22+
23+
describe('getComponentDescription', () => {
24+
it('returns description for cert-manager', () => {
25+
const desc = getComponentDescription('cert-manager');
26+
expect(desc).toContain('TLS');
27+
});
28+
29+
it('returns component name for unknown component', () => {
30+
const desc = getComponentDescription('unknown');
31+
expect(desc).toBe('unknown');
32+
});
33+
});
34+
35+
describe('generateComponentManifest', () => {
36+
it('generates cert-manager manifest with correct placeholders', () => {
37+
const manifest = generateComponentManifest('cert-manager', {
38+
clusterName: 'test-cluster',
39+
domain: 'test.com',
40+
});
41+
42+
expect(manifest.filename).toBe('cert-manager.yaml');
43+
expect(manifest.content).toContain('name: cert-manager');
44+
expect(manifest.content).toContain('namespace: cert-manager');
45+
expect(manifest.content).not.toContain('CLUSTER-NAME');
46+
});
47+
48+
it('generates external-dns manifest with replaced placeholders', () => {
49+
const manifest = generateComponentManifest('external-dns', {
50+
clusterName: 'prod-cluster',
51+
domain: 'prod.com',
52+
dnsServer: '10.0.0.1',
53+
});
54+
55+
expect(manifest.content).toContain('external-dns.prod-cluster');
56+
expect(manifest.content).toContain('prod.com');
57+
expect(manifest.content).toContain('10.0.0.1');
58+
expect(manifest.content).not.toContain('example.com');
59+
});
60+
61+
it('throws error for unknown component', () => {
62+
expect(() =>
63+
generateComponentManifest('unknown-component', {
64+
clusterName: 'test',
65+
domain: 'test.com',
66+
}),
67+
).toThrow('Unknown component');
68+
});
69+
});
70+
});

0 commit comments

Comments
 (0)