Skip to content
Open
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
20 changes: 20 additions & 0 deletions src/commands/clusters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Command } from 'commander';

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

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

cluster
.command('init')
.description('Initialize a new Kubernetes cluster with FluxCD GitOps and infrastructure components')
.option('--cluster-name <name>', 'Cluster name (skip interactive prompt)')
.option('--domain <domain>', 'Base domain for the cluster (skip interactive prompt)')
.option('--repo-url <url>', 'Git repository URL for FluxCD')
.option('--template-repo', 'Use the official berget-k8s-template repository')
.option(
'--components <components>',
'Comma-separated list of components to install (e.g., cert-manager,external-dns,ingress-nginx)',
)
.action(async (options) => {
try {
await runClusterInitCommand(options);
} catch (error) {
handleError('Failed to initialize cluster', error);
}
});

cluster
.command(ClusterService.COMMANDS.LIST)
.description('List all Berget clusters')
Expand Down
257 changes: 257 additions & 0 deletions src/commands/clusters/__tests__/init.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import { describe, expect, it } from 'vitest';

import { FakeCommandRunner } from '../../code/__tests__/fake-command-runner';
import { FakeFileStore } from '../../code/__tests__/fake-file-store';
import { CANCEL, confirm, FakePrompter, multiselect, select, text } from '../../code/__tests__/fake-prompter';

import { CancelledError, PrerequisiteError } from '../../code/errors';

import { runClusterInit } from '../init';

describe('runClusterInit', () => {
// Helper to create a FakeCommandRunner with all prerequisites installed
function createCommands(): FakeCommandRunner {
return new FakeCommandRunner()
.handle('kubectl --version', '')
.handle('flux --version', '')
.handle('git --version', '');
}

it('completes full wizard with template repo', async () => {
const commands = createCommands()
.handle(/git clone/, '')
.handle(/rm -rf/, '')
.handle(/flux bootstrap/, '');

const files = new FakeFileStore();
const prompter = new FakePrompter([
text('my-cluster'),
text('example.com'),
select('template'),
multiselect(['cert-manager', 'external-dns']),
confirm(true),
confirm(true),
]);

await runClusterInit(
{ commands, cwd: '/test', files, prompter },
{},
);

prompter.assertExhausted();

// Verify directories created
expect(await files.exists('/test/clusters')).toBe(true);
expect(await files.exists('/test/clusters/flux-system')).toBe(true);
expect(await files.exists('/test/clusters/infrastructure/cert-manager')).toBe(true);
expect(await files.exists('/test/clusters/infrastructure/external-dns')).toBe(true);

// Verify gotk-sync.yaml written
const gotkSync = await files.readFile('/test/clusters/flux-system/gotk-sync.yaml');
expect(gotkSync).toContain('name: flux-system');
expect(gotkSync).toContain('url: https://github.com/berget-ai/berget-k8s-template');

// Verify component manifests written
const certManager = await files.readFile('/test/clusters/infrastructure/cert-manager/cert-manager.yaml');
expect(certManager).toContain('name: cert-manager');
expect(certManager).toContain('namespace: cert-manager');

const externalDns = await files.readFile('/test/clusters/infrastructure/external-dns/external-dns.yaml');
expect(externalDns).toContain('external-dns.my-cluster');
expect(externalDns).toContain('example.com');
});

it('completes wizard with existing repo', async () => {
const commands = createCommands();

const files = new FakeFileStore();
const prompter = new FakePrompter([
text('prod-cluster'),
text('berget.ai'),
select('existing'),
text('git@github.com:my-org/infra.git'),
multiselect(['ingress-nginx']),
confirm(true),
confirm(false), // Don't run flux bootstrap
]);

await runClusterInit(
{ commands, cwd: '/test', files, prompter },
{},
);

prompter.assertExhausted();

const gotkSync = await files.readFile('/test/clusters/flux-system/gotk-sync.yaml');
expect(gotkSync).toContain('git@github.com:my-org/infra.git');

const ingress = await files.readFile('/test/clusters/infrastructure/ingress-nginx.yaml');
expect(ingress).toContain('name: ingress-nginx');
});

it('skips interactive prompts when all options provided', async () => {
const commands = createCommands()
.handle(/git clone/, '')
.handle(/rm -rf/, '')
.handle(/flux bootstrap/, '');

const files = new FakeFileStore();
const prompter = new FakePrompter([
confirm(true),
confirm(false),
]);

await runClusterInit(
{ commands, cwd: '/test', files, prompter },
{
clusterName: 'auto-cluster',
components: ['prometheus', 'grafana'],
domain: 'auto.example.com',
templateRepo: true,
},
);

prompter.assertExhausted();

const prometheus = await files.readFile('/test/clusters/infrastructure/monitoring/prometheus.yaml');
expect(prometheus).toContain('name: prometheus');

const grafana = await files.readFile('/test/clusters/infrastructure/monitoring/grafana.yaml');
expect(grafana).toContain('grafana.auto-cluster.auto.example.com');
});

it('throws PrerequisiteError when kubectl is missing', async () => {
const commands = new FakeCommandRunner();
// No handlers = nothing is installed

const files = new FakeFileStore();
const prompter = new FakePrompter([]);

await expect(
runClusterInit({ commands, cwd: '/test', files, prompter }, {}),
).rejects.toThrow(PrerequisiteError);
});

it('throws CancelledError when user cancels at confirmation', async () => {
const commands = createCommands();

const files = new FakeFileStore();
const prompter = new FakePrompter([
text('my-cluster'),
text('example.com'),
select('template'),
multiselect(['cert-manager']),
confirm(false), // Cancel at confirmation
]);

await expect(
runClusterInit({ commands, cwd: '/test', files, prompter }, {}),
).rejects.toThrow(CancelledError);
});

it('throws CancelledError when user cancels cluster name prompt', async () => {
const commands = createCommands();

const files = new FakeFileStore();
const prompter = new FakePrompter([
text(CANCEL),
]);

await expect(
runClusterInit({ commands, cwd: '/test', files, prompter }, {}),
).rejects.toThrow(CancelledError);
});

it('handles all available components', async () => {
const commands = createCommands()
.handle(/git clone/, '')
.handle(/rm -rf/, '')
.handle(/flux bootstrap/, '');

const files = new FakeFileStore();
const prompter = new FakePrompter([
text('full-cluster'),
text('test.com'),
select('template'),
multiselect([
'cert-manager',
'external-dns',
'ingress-nginx',
'cloudnative-pg',
'redis-operator',
'prometheus',
'grafana',
]),
confirm(true),
confirm(false),
]);

await runClusterInit(
{ commands, cwd: '/test', files, prompter },
{},
);

prompter.assertExhausted();

// Verify all components were written
expect(await files.readFile('/test/clusters/infrastructure/cert-manager/cert-manager.yaml')).toBeTruthy();
expect(await files.readFile('/test/clusters/infrastructure/external-dns/external-dns.yaml')).toBeTruthy();
expect(await files.readFile('/test/clusters/infrastructure/ingress-nginx/ingress-nginx.yaml')).toBeTruthy();
expect(await files.readFile('/test/clusters/infrastructure/operators/cloudnative-pg/cloudnative-pg.yaml')).toBeTruthy();
expect(await files.readFile('/test/clusters/infrastructure/operators/redis/redis-operator.yaml')).toBeTruthy();
expect(await files.readFile('/test/clusters/infrastructure/monitoring/prometheus.yaml')).toBeTruthy();
expect(await files.readFile('/test/clusters/infrastructure/monitoring/grafana.yaml')).toBeTruthy();
});

it('exits gracefully when no components selected', async () => {
const commands = createCommands();

const files = new FakeFileStore();
const prompter = new FakePrompter([
text('empty-cluster'),
text('example.com'),
select('template'),
multiselect([]),
]);

await runClusterInit(
{ commands, cwd: '/test', files, prompter },
{},
);

prompter.assertExhausted();

// Should not create any component files
const writtenFiles = files.getWrittenFiles();
expect(writtenFiles.size).toBe(0);
});

it('validates cluster name format', async () => {
const commands = createCommands()
.handle(/git clone/, '')
.handle(/rm -rf/, '')
.handle(/flux bootstrap/, '');

const files = new FakeFileStore();
const prompter = new FakePrompter([
text('Invalid_Name'), // Invalid: contains underscore and uppercase
text('valid-name'),
text('example.com'),
select('template'),
multiselect(['cert-manager']),
confirm(true),
confirm(false),
]);

await runClusterInit(
{ commands, cwd: '/test', files, prompter },
{},
);

prompter.assertExhausted();

// Should show validation note and ask again
const noteCall = prompter.calls.find((c) => c.method === 'note' && c.args.title === 'Invalid name');
expect(noteCall).toBeDefined();
});
});
70 changes: 70 additions & 0 deletions src/commands/clusters/__tests__/yaml-generator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';

import {
generateComponentManifest,
getAvailableComponents,
getComponentDescription,
} from '../yaml-generator';

describe('yaml-generator', () => {
describe('getAvailableComponents', () => {
it('returns all available components', () => {
const components = getAvailableComponents();
expect(components).toContain('cert-manager');
expect(components).toContain('external-dns');
expect(components).toContain('ingress-nginx');
expect(components).toContain('cloudnative-pg');
expect(components).toContain('redis-operator');
expect(components).toContain('prometheus');
expect(components).toContain('grafana');
});
});

describe('getComponentDescription', () => {
it('returns description for cert-manager', () => {
const desc = getComponentDescription('cert-manager');
expect(desc).toContain('TLS');
});

it('returns component name for unknown component', () => {
const desc = getComponentDescription('unknown');
expect(desc).toBe('unknown');
});
});

describe('generateComponentManifest', () => {
it('generates cert-manager manifest with correct placeholders', () => {
const manifest = generateComponentManifest('cert-manager', {
clusterName: 'test-cluster',
domain: 'test.com',
});

expect(manifest.filename).toBe('cert-manager.yaml');
expect(manifest.content).toContain('name: cert-manager');
expect(manifest.content).toContain('namespace: cert-manager');
expect(manifest.content).not.toContain('CLUSTER-NAME');
});

it('generates external-dns manifest with replaced placeholders', () => {
const manifest = generateComponentManifest('external-dns', {
clusterName: 'prod-cluster',
domain: 'prod.com',
dnsServer: '10.0.0.1',
});

expect(manifest.content).toContain('external-dns.prod-cluster');
expect(manifest.content).toContain('prod.com');
expect(manifest.content).toContain('10.0.0.1');
expect(manifest.content).not.toContain('example.com');
});

it('throws error for unknown component', () => {
expect(() =>
generateComponentManifest('unknown-component', {
clusterName: 'test',
domain: 'test.com',
}),
).toThrow('Unknown component');
});
});
});
Loading