@localctl/cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +431 -0
  3. package/assets/manifests/namespace-addons.yaml +4 -0
  4. package/assets/manifests/tls-store.yaml +8 -0
  5. package/bin/localctl.js +2 -0
  6. package/package.json +54 -0
  7. package/schema/app.schema.json +127 -0
  8. package/src/commands/addons.js +56 -0
  9. package/src/commands/doctor.js +43 -0
  10. package/src/commands/down.js +35 -0
  11. package/src/commands/exec.js +15 -0
  12. package/src/commands/generateManifests.js +12 -0
  13. package/src/commands/hosts.js +23 -0
  14. package/src/commands/logs.js +45 -0
  15. package/src/commands/new.js +55 -0
  16. package/src/commands/profiles.js +180 -0
  17. package/src/commands/prune.js +49 -0
  18. package/src/commands/registryInfo.js +18 -0
  19. package/src/commands/reload.js +44 -0
  20. package/src/commands/secrets.js +75 -0
  21. package/src/commands/setup.js +36 -0
  22. package/src/commands/status.js +136 -0
  23. package/src/commands/uninstall.js +105 -0
  24. package/src/commands/up.js +123 -0
  25. package/src/index.js +36 -0
  26. package/src/lib/addonDirs.js +43 -0
  27. package/src/lib/addonLoader.js +190 -0
  28. package/src/lib/addons/cluster/logging/addon.yaml +46 -0
  29. package/src/lib/addons/cluster/logging/config/all-logs.json +21 -0
  30. package/src/lib/addons/cluster/monitoring/addon.yaml +33 -0
  31. package/src/lib/addons/mongo/addon.yaml +18 -0
  32. package/src/lib/addons/postgres/addon.yaml +39 -0
  33. package/src/lib/addons/redis/addon.yaml +29 -0
  34. package/src/lib/appSecrets.js +60 -0
  35. package/src/lib/clusterAddons.js +59 -0
  36. package/src/lib/clusterProvision.js +170 -0
  37. package/src/lib/configSchema.js +103 -0
  38. package/src/lib/constants.js +16 -0
  39. package/src/lib/engine.js +20 -0
  40. package/src/lib/exec.js +77 -0
  41. package/src/lib/hostsFile.js +79 -0
  42. package/src/lib/loadConfig.js +51 -0
  43. package/src/lib/logger.js +9 -0
  44. package/src/lib/manifestGen.js +210 -0
  45. package/src/lib/paths.js +41 -0
  46. package/src/lib/podman.js +72 -0
  47. package/src/lib/profileStore.js +134 -0
  48. package/src/lib/projectContext.js +32 -0
  49. package/src/lib/prompt.js +97 -0
  50. package/src/lib/toolInstall.js +30 -0
  51. package/src/lib/ui.js +50 -0
  52. package/src/lib/workloadReadiness.js +35 -0
  53. package/templates/Tiltfile.tmpl +35 -0
@@ -0,0 +1,36 @@
1
+ const { provisionCluster } = require('../lib/clusterProvision');
2
+ const { syncHosts } = require('../lib/hostsFile');
3
+ const { setActive } = require('../lib/profileStore');
4
+ const { DEFAULT_CLUSTER_NAME, DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT, DOMAIN, REGISTRY_HOST_PORT } = require('../lib/constants');
5
+ const logger = require('../lib/logger');
6
+
7
+ function setup() {
8
+ provisionCluster({
9
+ clusterName: DEFAULT_CLUSTER_NAME,
10
+ httpPort: DEFAULT_HTTP_PORT,
11
+ httpsPort: DEFAULT_HTTPS_PORT,
12
+ registryPort: REGISTRY_HOST_PORT,
13
+ });
14
+ setActive('default');
15
+
16
+ // Hosts file: seed it now (prompts for sudo here, once, instead of surprising you mid
17
+ // `localctl app up` later). Only adds `local.test` itself at this point - per-app subdomains get
18
+ // added the same way when you run `localctl app new`/`up` for each app.
19
+ logger.step('Syncing hosts file...');
20
+ syncHosts();
21
+
22
+ console.log();
23
+ logger.success('Setup complete.');
24
+ console.log('Next steps:');
25
+ console.log(' 1. cd into an app repo');
26
+ console.log(' 2. localctl app new');
27
+ console.log(' 3. localctl app up');
28
+ console.log(` 4. open https://<subdomain>.${DOMAIN}`);
29
+ }
30
+
31
+ module.exports = (program) => {
32
+ program
33
+ .command('setup')
34
+ .description('One-click local infra setup for the default project: container engine, tools, k3d cluster, TLS, hosts')
35
+ .action(setup);
36
+ };
@@ -0,0 +1,136 @@
1
+ const fs = require('fs');
2
+ const { isRunning } = require('../lib/exec');
3
+ const { runFiles, CONFIG_PATH } = require('../lib/paths');
4
+ const { getList, workloadsWithPrefix } = require('../lib/workloadReadiness');
5
+ const { loadConfig } = require('../lib/loadConfig');
6
+ const { ensureClusterUp } = require('../lib/clusterProvision');
7
+ const { getActive, clusterNameFor } = require('../lib/profileStore');
8
+ const ui = require('../lib/ui');
9
+ const logger = require('../lib/logger');
10
+
11
+ function key(obj) {
12
+ return `${obj.metadata.namespace}/${obj.metadata.name}`;
13
+ }
14
+
15
+ function collect() {
16
+ const deployments = getList(['get', 'deployment', '-A', '-l', 'localctl.dev/managed=true']);
17
+ const ingresses = getList(['get', 'ingress', '-A', '-l', 'localctl.dev/managed=true']);
18
+ const services = getList(['get', 'service', '-A', '-l', 'localctl.dev/managed=true']);
19
+ return {
20
+ deployments,
21
+ ingressByKey: new Map(ingresses.map((i) => [key(i), i])),
22
+ serviceByKey: new Map(services.map((s) => [key(s), s])),
23
+ };
24
+ }
25
+
26
+ function devLoop(name) {
27
+ const { pidFile } = runFiles(name);
28
+ if (!fs.existsSync(pidFile)) return ui.dim('-');
29
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf8'), 10);
30
+ if (Number.isFinite(pid) && isRunning(pid)) return ui.ok(`running (pid ${pid})`);
31
+ return ui.warn('stopped');
32
+ }
33
+
34
+ function readyLabel(dep) {
35
+ return ui.readyLabel(dep.status.readyReplicas || 0, dep.spec.replicas);
36
+ }
37
+
38
+ function urlFor(dep, ingressByKey) {
39
+ const ing = ingressByKey.get(key(dep));
40
+ const rule = ing && ing.spec.rules && ing.spec.rules[0];
41
+ if (!rule) return ui.dim('-');
42
+ const scheme = ing.spec.tls && ing.spec.tls.length ? 'https' : 'http';
43
+ return ui.url(`${scheme}://${rule.host}`);
44
+ }
45
+
46
+ function debugPortFor(dep, serviceByKey) {
47
+ const svc = serviceByKey.get(key(dep));
48
+ const port = svc && svc.spec.ports.find((p) => p.name === 'debug');
49
+ return port ? String(port.port) : null;
50
+ }
51
+
52
+ function dependenciesFor(dep) {
53
+ // Real Helm charts pick whichever workload kind fits them - not every addon is a plain
54
+ // Deployment like the app itself always is (see lib/workloadReadiness.js).
55
+ const prefix = `${dep.metadata.name}-`;
56
+ return workloadsWithPrefix(dep.metadata.namespace, prefix)
57
+ .map((w) => ({ name: w.name, ready: ui.readyLabel(w.ready, w.desired) }));
58
+ }
59
+
60
+ function printSingle(dep, ingressByKey, serviceByKey) {
61
+ const debugPort = debugPortFor(dep, serviceByKey);
62
+ console.log(`${ui.bold(dep.metadata.name)}`);
63
+ console.log(` namespace: ${dep.metadata.namespace}`);
64
+ console.log(` ready: ${readyLabel(dep)}`);
65
+ console.log(` url: ${urlFor(dep, ingressByKey)}`);
66
+ if (debugPort) console.log(` debug: localhost:${debugPort}`);
67
+ console.log(` dev loop: ${devLoop(dep.metadata.name)}`);
68
+
69
+ const deps = dependenciesFor(dep);
70
+ if (deps.length > 0) {
71
+ console.log(' dependencies:');
72
+ deps.forEach((d) => console.log(` ${d.name} ${d.ready}`));
73
+ }
74
+ }
75
+
76
+ function printAll(deployments, ingressByKey, serviceByKey) {
77
+ const rows = deployments.map((dep) => [
78
+ dep.metadata.name,
79
+ dep.metadata.namespace,
80
+ readyLabel(dep),
81
+ urlFor(dep, ingressByKey),
82
+ devLoop(dep.metadata.name),
83
+ ]);
84
+ ui.printTable(['APP', 'NAMESPACE', 'READY', 'URL', 'DEV LOOP'], rows);
85
+
86
+ if (!deployments.some((dep) => devLoop(dep.metadata.name).includes('running'))) {
87
+ console.log('');
88
+ logger.info('Dev loop not running locally for any app - "localctl app up" from an app\'s directory to start one.');
89
+ }
90
+ }
91
+
92
+ module.exports = (program) => {
93
+ program
94
+ .command('status')
95
+ .option('-a, --app <name>', 'show status for one app by name, in the currently active project\'s cluster')
96
+ .option('-A, --all', 'show every app in the currently active project\'s cluster, even from inside an app repo')
97
+ .description('Show every localctl-managed app in a cluster: readiness, URL, local dev-loop state')
98
+ .action((opts) => {
99
+ let targetName = opts.app;
100
+ let targetNamespace = null;
101
+
102
+ // Directory-resolved single-app view: figures out which project (and so which cluster and
103
+ // namespace/subdomain prefix) this app belongs to on its own - see loadConfig.js.
104
+ if (!targetName && !opts.all && fs.existsSync(CONFIG_PATH)) {
105
+ const cfg = loadConfig();
106
+ ensureClusterUp(cfg.clusterName, cfg.profile);
107
+ targetName = cfg.name;
108
+ targetNamespace = cfg.namespace;
109
+ } else {
110
+ // No directory context (elsewhere, -a, or -A): fall back to whichever project is
111
+ // currently active (see "localctl profiles switch").
112
+ const active = getActive();
113
+ ensureClusterUp(clusterNameFor(active), active);
114
+ }
115
+
116
+ const { deployments, ingressByKey, serviceByKey } = collect();
117
+ if (deployments.length === 0) {
118
+ logger.info('No localctl-managed apps found in the cluster.');
119
+ return;
120
+ }
121
+
122
+ if (targetName) {
123
+ const dep = deployments.find(
124
+ (d) => d.metadata.name === targetName && (!targetNamespace || d.metadata.namespace === targetNamespace),
125
+ );
126
+ if (!dep) {
127
+ logger.warn(`No deployed app named "${targetName}" found in the cluster.`);
128
+ return;
129
+ }
130
+ printSingle(dep, ingressByKey, serviceByKey);
131
+ return;
132
+ }
133
+
134
+ printAll(deployments, ingressByKey, serviceByKey);
135
+ });
136
+ };
@@ -0,0 +1,105 @@
1
+ const os = require('os');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { isRunning } = require('../lib/exec');
5
+ const { detectEngine } = require('../lib/engine');
6
+ const { teardownCluster, clusterExists } = require('../lib/clusterProvision');
7
+ const { teardownInsecureRegistry } = require('../lib/podman');
8
+ const { clearHosts } = require('../lib/hostsFile');
9
+ const { listProfiles } = require('../lib/profileStore');
10
+ const { confirm } = require('../lib/prompt');
11
+ const { RUN_DIR, PROFILES_PATH } = require('../lib/paths');
12
+ const logger = require('../lib/logger');
13
+
14
+ // Every Main (cluster-isolation) project this machine knows about - each one owns a real,
15
+ // separate cluster that needs tearing down individually.
16
+ function mainProjects() {
17
+ return Object.entries(listProfiles())
18
+ .filter(([, def]) => def.isolation === 'cluster')
19
+ .map(([name, def]) => ({ name, cluster: def.cluster }));
20
+ }
21
+
22
+ async function uninstall(opts) {
23
+ const projects = mainProjects();
24
+ console.log('This will remove everything "localctl setup"/"localctl profiles" set up:');
25
+ console.log(` - Every project's k3d cluster (${projects.map((p) => p.cluster).join(', ')}) and everything deployed in them`);
26
+ console.log(' - Their local image registry containers');
27
+ console.log(' - Podman VM insecure-registry config (Podman only, if present)');
28
+ console.log(' - The *.local.test block in your hosts file');
29
+ console.log(' - Any background dev loop `localctl app up` left running, and its state');
30
+ console.log(' - ~/.localctl/certs, the app registry state, and the project registry (~/.localctl/profiles.json)');
31
+ console.log('');
32
+ console.log("It will NOT remove: k3d, kubectl, tilt, mkcert, node, the localctl CLI itself, or");
33
+ console.log("mkcert's root CA trust. To remove the CLI too, use whichever matches how you");
34
+ console.log('installed it: `npm uninstall -g @localctl/cli`, or `bin/uninstall.sh` /');
35
+ console.log("`uninstall.ps1` if you used bin/bootstrap.sh's PATH shim instead.");
36
+ console.log('');
37
+
38
+ if (!opts.yes) {
39
+ const answer = await confirm('Continue? [y/N] ');
40
+ if (!/^y(es)?$/i.test(answer)) {
41
+ console.log('Aborted.');
42
+ process.exitCode = 1;
43
+ return;
44
+ }
45
+ }
46
+
47
+ try {
48
+ clearHosts();
49
+ } catch (e) {
50
+ logger.warn(`Could not clear hosts file: ${e.message}`);
51
+ }
52
+
53
+ for (const project of projects) {
54
+ if (clusterExists(project.cluster)) {
55
+ teardownCluster(project.cluster);
56
+ }
57
+ }
58
+
59
+ const detected = detectEngine();
60
+ if (detected && detected.engine === 'podman') {
61
+ teardownInsecureRegistry();
62
+ }
63
+
64
+ // Stop any background dev loops `localctl app up` left running (e.g. if `localctl app down` was
65
+ // never run for them) before deleting their pid/log files out from under them.
66
+ if (fs.existsSync(RUN_DIR)) {
67
+ fs.readdirSync(RUN_DIR)
68
+ .filter((f) => f.endsWith('.pid'))
69
+ .forEach((f) => {
70
+ const pid = parseInt(fs.readFileSync(path.join(RUN_DIR, f), 'utf8'), 10);
71
+ if (Number.isFinite(pid) && isRunning(pid)) {
72
+ logger.step(`Stopping background Tilt process for "${f.replace(/\.pid$/, '')}" (pid ${pid})...`);
73
+ try {
74
+ process.kill(pid, 'SIGTERM');
75
+ } catch (e) {
76
+ // already gone
77
+ }
78
+ }
79
+ });
80
+ fs.rmSync(RUN_DIR, { recursive: true, force: true });
81
+ }
82
+
83
+ const certsDir = path.join(os.homedir(), '.localctl', 'certs');
84
+ const appsRegistry = path.join(os.homedir(), '.localctl', 'apps.json');
85
+ if (fs.existsSync(certsDir)) fs.rmSync(certsDir, { recursive: true, force: true });
86
+ if (fs.existsSync(appsRegistry)) fs.rmSync(appsRegistry, { force: true });
87
+ if (fs.existsSync(PROFILES_PATH)) fs.rmSync(PROFILES_PATH, { force: true });
88
+ logger.success('Removed ~/.localctl state (certs, app registry, project registry, background dev-loop tracking)');
89
+
90
+ console.log();
91
+ logger.success('Uninstall complete.');
92
+ console.log('');
93
+ console.log('Left in place (remove yourself if you want them gone too):');
94
+ console.log(' brew uninstall k3d kubectl tilt mkcert node # only if nothing else needs them');
95
+ console.log(" mkcert -uninstall # removes the root CA from trust stores - affects EVERY");
96
+ console.log(' # mkcert-issued cert on this machine, not just *.local.test');
97
+ }
98
+
99
+ module.exports = (program) => {
100
+ program
101
+ .command('uninstall')
102
+ .option('-y, --yes', 'skip the confirmation prompt')
103
+ .description('Remove everything "localctl setup"/"localctl profiles" set up: every project\'s cluster, registries, hosts entries, state')
104
+ .action(uninstall);
105
+ };
@@ -0,0 +1,123 @@
1
+ const fs = require('fs');
2
+ const { loadConfig } = require('../lib/loadConfig');
3
+ const { syncHosts, registerApp } = require('../lib/hostsFile');
4
+ const { run, capture, sleepSync, spawnDetached, getFreePort, isRunning } = require('../lib/exec');
5
+ const { detectEngine } = require('../lib/engine');
6
+ const { ensureClusterUp } = require('../lib/clusterProvision');
7
+ const { portsFor } = require('../lib/profileStore');
8
+ const { listKeys } = require('../lib/appSecrets');
9
+ const { runFiles } = require('../lib/paths');
10
+ const { DOMAIN, DEFAULT_HTTPS_PORT } = require('../lib/constants');
11
+ const ui = require('../lib/ui');
12
+ const logger = require('../lib/logger');
13
+
14
+ function podmanBuildEnv() {
15
+ const env = { ...process.env };
16
+ const detected = detectEngine();
17
+ if (detected && detected.engine === 'podman') {
18
+ // Podman's Docker-API-compat socket doesn't implement BuildKit's gRPC build protocol
19
+ // (Tilt's docker_build fails with "unable to upgrade to h2c" otherwise).
20
+ env.DOCKER_BUILDKIT = '0';
21
+ logger.info('Podman detected: disabling BuildKit for this build.');
22
+ }
23
+ return env;
24
+ }
25
+
26
+ function printEndpoints(cfg) {
27
+ const { httpsPort } = portsFor(cfg.profile);
28
+ const suffix = httpsPort === DEFAULT_HTTPS_PORT ? '' : `:${httpsPort}`;
29
+ const url = `https://${cfg.subdomain}.${DOMAIN}${suffix}`;
30
+ console.log('');
31
+ console.log(`${ui.check} ${ui.bold(cfg.name)} -> ${ui.url(url)}`);
32
+ if (cfg.debug.enabled && cfg.debug.port) {
33
+ console.log(` debug: localhost:${cfg.debug.port}`);
34
+ }
35
+ console.log(` logs: localctl app logs ${cfg.name} -f`);
36
+ console.log(` status: localctl app status`);
37
+ console.log(` stop: localctl app down`);
38
+ console.log('');
39
+ }
40
+
41
+ function tailLog(logFile, lines) {
42
+ if (!fs.existsSync(logFile)) return null;
43
+ return fs.readFileSync(logFile, 'utf8').trim().split('\n').slice(-lines).join('\n');
44
+ }
45
+
46
+ module.exports = (program) => {
47
+ program
48
+ .command('up')
49
+ .option('-f, --foreground', 'run in the foreground with the interactive Tilt UI (default: detached, survives closing this terminal)')
50
+ .description('Deploy/dev-loop the app in the current directory')
51
+ .action(async (opts) => {
52
+ const cfg = loadConfig();
53
+ ensureClusterUp(cfg.clusterName, cfg.profile);
54
+
55
+ if (cfg.secrets.length > 0) {
56
+ const set = new Set(listKeys(cfg));
57
+ const missing = cfg.secrets.filter((key) => !set.has(key));
58
+ if (missing.length > 0) {
59
+ throw new Error(
60
+ `Missing secret value${missing.length > 1 ? 's' : ''} for: ${missing.join(', ')}. Set ` +
61
+ `${missing.length > 1 ? 'them' : 'it'} first:\n` +
62
+ missing.map((key) => ` localctl app secrets set ${key}`).join('\n'),
63
+ );
64
+ }
65
+ }
66
+
67
+ registerApp(cfg.subdomain);
68
+ syncHosts();
69
+ const env = podmanBuildEnv();
70
+
71
+ // Each app gets its own Tilt web UI port. Tilt's default (10350) is shared/fixed, so two
72
+ // apps running `localctl up` at once - the whole point of detached mode - would otherwise
73
+ // collide outright, and Tilt's resulting error doesn't explain why to someone who never
74
+ // asked to think about Tilt's own dashboard port in the first place.
75
+ const tiltPort = await getFreePort();
76
+
77
+ if (opts.foreground) {
78
+ logger.step(`Starting Tilt for "${cfg.name}" -> https://${cfg.subdomain}.${DOMAIN}`);
79
+ run('tilt', ['up', '--port', String(tiltPort)], { env });
80
+ return;
81
+ }
82
+
83
+ const { pidFile, portFile, logFile } = runFiles(cfg.name);
84
+ const pid = spawnDetached('tilt', ['up', '--port', String(tiltPort)], { cwd: process.cwd(), env, logFile });
85
+ fs.writeFileSync(pidFile, String(pid));
86
+ fs.writeFileSync(portFile, String(tiltPort));
87
+ logger.step(`Starting Tilt for "${cfg.name}" in the background (pid ${pid})...`);
88
+ logger.info(`Full Tilt output: tail -f ${logFile}`);
89
+
90
+ logger.step('Waiting for the deployment to become ready...');
91
+ // Tilt needs a moment to apply the generated manifests - "rollout status" errors outright
92
+ // on a Deployment that doesn't exist yet, it doesn't wait for one to appear.
93
+ let exists = false;
94
+ for (let i = 0; i < 30; i += 1) {
95
+ if (capture('kubectl', ['get', 'deployment', cfg.name, '-n', cfg.namespace]).status === 0) {
96
+ exists = true;
97
+ break;
98
+ }
99
+ if (!isRunning(pid)) break; // Tilt already died - no point waiting out the rest of the poll
100
+ sleepSync(1000);
101
+ }
102
+
103
+ const rollout = exists
104
+ ? run(
105
+ 'kubectl',
106
+ ['rollout', 'status', `deployment/${cfg.name}`, '-n', cfg.namespace, '--timeout=180s'],
107
+ { allowFail: true },
108
+ )
109
+ : { status: 1 };
110
+
111
+ if (rollout.status === 0) {
112
+ printEndpoints(cfg);
113
+ return;
114
+ }
115
+
116
+ if (!isRunning(pid)) {
117
+ logger.warn(`Tilt exited before the deployment came up. Last lines of ${logFile}:`);
118
+ console.log(ui.dim(tailLog(logFile, 15) || '(log file is empty)'));
119
+ } else {
120
+ logger.warn(`Still starting (or something's wrong) - check "localctl app logs ${cfg.name} -f" or "localctl app status".`);
121
+ }
122
+ });
123
+ };
package/src/index.js ADDED
@@ -0,0 +1,36 @@
1
+ const { Command } = require('commander');
2
+ const { version } = require('../package.json');
3
+
4
+ const program = new Command();
5
+ program
6
+ .name('localctl')
7
+ .description('Local Kubernetes dev platform CLI')
8
+ .version(version);
9
+
10
+ // Global/machine-level - not tied to any one app or addon.
11
+ require('./commands/setup')(program);
12
+ require('./commands/uninstall')(program);
13
+ require('./commands/doctor')(program);
14
+ require('./commands/hosts')(program);
15
+
16
+ // Feature-level command groups.
17
+ const app = program.command('app').description('Manage a single app: scaffold, deploy, monitor');
18
+ require('./commands/new')(app);
19
+ require('./commands/up')(app);
20
+ require('./commands/down')(app);
21
+ require('./commands/status')(app);
22
+ require('./commands/logs')(app);
23
+ require('./commands/reload')(app);
24
+ require('./commands/generateManifests')(app);
25
+ require('./commands/registryInfo')(app);
26
+ require('./commands/exec')(app);
27
+ require('./commands/prune')(app);
28
+ require('./commands/secrets')(app); // its own subgroup: set/unset/list/show
29
+
30
+ require('./commands/addons')(program); // already its own group: list/enable/disable/status
31
+ require('./commands/profiles')(program); // already its own group: new/list/switch/status
32
+
33
+ program.parseAsync(process.argv).catch((err) => {
34
+ console.error(err && err.message ? err.message : err);
35
+ process.exit(1);
36
+ });
@@ -0,0 +1,43 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+
5
+ // Addons are searched in two places, in precedence order:
6
+ // 1. USER_DIR - yours, never touched by a `localctl` update/reinstall. Defaults to
7
+ // ~/.localctl/addons, overridable via LOCALCTL_ADDONS_DIR (e.g. to point at a
8
+ // folder checked into your own infra repo instead).
9
+ // 2. BUILTIN_DIR - the addons shipped with this CLI (postgres/redis/mongo/cluster/logging).
10
+ // A user-dir addon of the same name replaces the built-in one; a new name just adds to the list.
11
+ // This is what lets someone install this tool without inheriting its author's addon set, and
12
+ // without forking the tool to add their own - see docs/adding-tools.md.
13
+ const BUILTIN_DIR = path.join(__dirname, 'addons');
14
+ const USER_DIR = process.env.LOCALCTL_ADDONS_DIR || path.join(os.homedir(), '.localctl', 'addons');
15
+
16
+ function roots(...segments) {
17
+ return [path.join(USER_DIR, ...segments), path.join(BUILTIN_DIR, ...segments)];
18
+ }
19
+
20
+ // First matching addon.yaml wins - user dir searched before built-in.
21
+ function findAddonDir(name, ...segments) {
22
+ for (const root of roots(...segments)) {
23
+ const dir = path.join(root, name);
24
+ if (fs.existsSync(path.join(dir, 'addon.yaml'))) return dir;
25
+ }
26
+ return null;
27
+ }
28
+
29
+ // Every addon name available across both locations, user-dir entries deduplicated against
30
+ // built-ins of the same name (a listing, not a "which one wins" answer - see findAddonDir for that).
31
+ function listAddonNames(...segments) {
32
+ const names = new Set();
33
+ for (const root of roots(...segments)) {
34
+ if (!fs.existsSync(root)) continue;
35
+ fs.readdirSync(root, { withFileTypes: true })
36
+ .filter((e) => e.isDirectory())
37
+ .filter((e) => fs.existsSync(path.join(root, e.name, 'addon.yaml')))
38
+ .forEach((e) => names.add(e.name));
39
+ }
40
+ return [...names];
41
+ }
42
+
43
+ module.exports = { BUILTIN_DIR, USER_DIR, findAddonDir, listAddonNames };
@@ -0,0 +1,190 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const crypto = require('crypto');
5
+ const yaml = require('js-yaml');
6
+ const { run, capture } = require('./exec');
7
+
8
+ // Generic Helm-based addon loader. An addon is a folder containing an addon.yaml (what chart to
9
+ // pull and how to configure it) and, optionally, a config/ folder (literal files - dashboards,
10
+ // datasources, init scripts - that don't belong in Helm values but need to reach the chart as a
11
+ // ConfigMap). See docs/adding-tools.md for the full addon.yaml reference and a worked example.
12
+
13
+ function repoNameFor(url) {
14
+ return `localctl-${crypto.createHash('md5').update(url).digest('hex').slice(0, 8)}`;
15
+ }
16
+
17
+ function ensureRepo(url) {
18
+ const name = repoNameFor(url);
19
+ // --force-update: idempotent even if re-pointed at a different URL under the same generated name.
20
+ run('helm', ['repo', 'add', name, url, '--force-update'], { stdio: 'ignore' });
21
+ run('helm', ['repo', 'update', name], { stdio: 'ignore', allowFail: true });
22
+ return name;
23
+ }
24
+
25
+ function substitute(template, vars) {
26
+ return String(template).replace(/\{\{\s*([\w.]+)\s*\}\}/g, (match, key) => {
27
+ if (!(key in vars)) throw new Error(`Addon references unknown template variable "{{${key}}}"`);
28
+ return vars[key];
29
+ });
30
+ }
31
+
32
+ function ensureNamespace(ns) {
33
+ if (capture('kubectl', ['get', 'namespace', ns]).status !== 0) {
34
+ run('kubectl', ['create', 'namespace', ns], { stdio: 'ignore', allowFail: true });
35
+ }
36
+ }
37
+
38
+ // Generates each secret's value once and stores it as a real k8s Secret; a re-run that finds the
39
+ // Secret already there leaves it alone, so app data doesn't lose its password out from under it
40
+ // on every `localctl up`.
41
+ function ensureSecret(name, namespace, keys) {
42
+ ensureNamespace(namespace);
43
+ if (capture('kubectl', ['get', 'secret', name, '-n', namespace]).status === 0) return;
44
+ const args = ['create', 'secret', 'generic', name, '-n', namespace];
45
+ for (const key of keys) {
46
+ args.push(`--from-literal=${key}=${crypto.randomBytes(16).toString('hex')}`);
47
+ }
48
+ run('kubectl', args, { stdio: 'ignore' });
49
+ }
50
+
51
+ // Turns a folder of literal files (dashboards, datasource definitions, init scripts - whatever
52
+ // the chart expects to mount, not a Helm value) into a real ConfigMap.
53
+ function ensureConfigMap(name, namespace, configDir) {
54
+ ensureNamespace(namespace);
55
+ const files = fs.readdirSync(configDir).filter((f) => fs.statSync(path.join(configDir, f)).isFile());
56
+ const args = ['create', 'configmap', name, '-n', namespace, '--dry-run=client', '-o', 'yaml'];
57
+ files.forEach((f) => args.push(`--from-file=${path.join(configDir, f)}`));
58
+ const rendered = capture('kubectl', args);
59
+ if (rendered.status !== 0) throw new Error(`Failed to render ConfigMap for ${configDir}: ${rendered.stderr}`);
60
+ run('kubectl', ['apply', '-f', '-'], { stdio: ['pipe', 'ignore', 'inherit'], input: rendered.stdout });
61
+ }
62
+
63
+ function helmTemplate(release, chart, valuesObj, namespace) {
64
+ const repoName = ensureRepo(chart.repo);
65
+ const tmpValues = path.join(os.tmpdir(), `localctl-addon-values-${process.pid}-${Date.now()}.yaml`);
66
+ fs.writeFileSync(tmpValues, yaml.dump(valuesObj));
67
+ try {
68
+ // --no-hooks: excludes `helm test` pods and similar hook resources from the rendered output -
69
+ // we apply this YAML with plain `kubectl apply`, not `helm install`, so nothing would ever
70
+ // run `helm test` to use them; left in, they'd just sit in the namespace as inert clutter.
71
+ const args = ['template', release, `${repoName}/${chart.name}`, '-f', tmpValues, '-n', namespace, '--no-hooks'];
72
+ if (chart.version) args.push('--version', chart.version);
73
+ const res = capture('helm', args);
74
+ if (res.status !== 0) {
75
+ throw new Error(`helm template failed for chart "${chart.name}":\n${res.stderr}`);
76
+ }
77
+ return res.stdout;
78
+ } finally {
79
+ fs.rmSync(tmpValues, { force: true });
80
+ }
81
+ }
82
+
83
+ // Every chart names its Service differently (Bitnami's own "dedupe release name vs chart name"
84
+ // logic alone means postgresql uses `<release>-postgresql` but redis uses `<release>-master`, no
85
+ // chart name at all, when the release name already contains "redis") - predicting it from a
86
+ // naming convention is fragile per-chart guesswork. Reading it back out of the chart's own
87
+ // rendered output is not: whatever the chart actually created is, by definition, correct.
88
+ function findPrimaryService(manifestYaml) {
89
+ const docs = yaml.loadAll(manifestYaml).filter(Boolean);
90
+ const services = docs.filter((d) => d.kind === 'Service');
91
+ return services.find((s) => !/-headless$|-hl$/.test(s.metadata.name)) || services[0] || null;
92
+ }
93
+
94
+ function findServiceHost(manifestYaml) {
95
+ const svc = findPrimaryService(manifestYaml);
96
+ return svc ? svc.metadata.name : null;
97
+ }
98
+
99
+ // Same "read it back, don't guess" reasoning as findServiceHost - a wait-for-dependency init
100
+ // container (manifestGen.js) needs a real port, and addon.yaml's own `env` block is free to name
101
+ // its port var anything (POSTGRES_PORT, REDIS_PORT, ...), so it's not something to parse generically.
102
+ function findServicePort(manifestYaml) {
103
+ const svc = findPrimaryService(manifestYaml);
104
+ const port = svc && svc.spec.ports && svc.spec.ports[0];
105
+ return port ? port.port : null;
106
+ }
107
+
108
+ // A small tracking ConfigMap bundled into the addon's own manifest output, so its lifecycle is
109
+ // tied to the addon's (created and deleted together, no separate cleanup code needed). This is
110
+ // what `localctl addons status` reads to list every addon instance in the cluster - it doesn't
111
+ // try to guess which workload kind(s) a given chart happens to use (Deployment, StatefulSet,
112
+ // DaemonSet all show up in real charts; guessing which is exactly the fragility that bit
113
+ // `localctl status`'s own dependency listing once already).
114
+ function buildAddonRecord({ release, namespace, addonName, chart, owner }) {
115
+ return {
116
+ apiVersion: 'v1',
117
+ kind: 'ConfigMap',
118
+ metadata: {
119
+ name: `${release}-localctl-addon`,
120
+ namespace,
121
+ labels: { 'localctl.dev/addon': 'true' },
122
+ },
123
+ data: {
124
+ addon: addonName,
125
+ chart: chart.name,
126
+ version: String(chart.version || ''),
127
+ owner: owner || '',
128
+ },
129
+ };
130
+ }
131
+
132
+ // Loads one addon (given its folder) and returns the rendered manifest YAML, plus any env vars
133
+ // it wants injected into the caller's own container (per-app addons only - see manifestGen.js).
134
+ function loadAddon(addonDir, { release, namespace, addonName, owner, extraVars = {}, chartVersionOverride }) {
135
+ const specPath = path.join(addonDir, 'addon.yaml');
136
+ const spec = yaml.load(fs.readFileSync(specPath, 'utf8'));
137
+
138
+ const chart = { ...spec.chart, version: chartVersionOverride || spec.chart.version };
139
+ const vars = { release, namespace, ...extraVars };
140
+
141
+ if (spec.secrets) {
142
+ const secretName = `${release}-secret`;
143
+ ensureSecret(secretName, namespace, Object.keys(spec.secrets));
144
+ vars.secretName = secretName;
145
+ }
146
+
147
+ const configDir = path.join(addonDir, 'config');
148
+ if (spec.config && fs.existsSync(configDir)) {
149
+ const configMapName = `${release}-config`;
150
+ ensureConfigMap(configMapName, namespace, configDir);
151
+ vars.configMapName = configMapName;
152
+ }
153
+
154
+ const valuesYaml = substitute(spec.values, vars);
155
+ const valuesObj = yaml.load(valuesYaml) || {};
156
+ const manifestYaml = helmTemplate(release, chart, valuesObj, namespace);
157
+ vars.serviceHost = findServiceHost(manifestYaml);
158
+ const servicePort = findServicePort(manifestYaml);
159
+
160
+ const env = {};
161
+ if (spec.env) {
162
+ for (const [k, v] of Object.entries(spec.env)) {
163
+ env[k] = substitute(v, vars);
164
+ }
165
+ }
166
+
167
+ // Unlike `env` (plain values), these become a real k8s secretKeyRef in the app's Deployment -
168
+ // the actual password never passes through a plaintext env value, config file, or `helm` value.
169
+ const envFromSecret = {};
170
+ if (spec.envFromSecret) {
171
+ for (const [k, ref] of Object.entries(spec.envFromSecret)) {
172
+ envFromSecret[k] = { secret: substitute(ref.secret, vars), key: substitute(ref.key, vars) };
173
+ }
174
+ }
175
+
176
+ const record = buildAddonRecord({ release, namespace, addonName: addonName || path.basename(addonDir), chart, owner });
177
+ // trimEnd() + explicit '\n' before the separator: `helm template` output doesn't always end
178
+ // with a trailing newline, and joining on a bare '---\n' in that case glues the separator
179
+ // directly onto the last line's content with no line break, corrupting the YAML.
180
+ const fullManifestYaml = [manifestYaml.trimEnd(), yaml.dump(record).trimEnd()].join('\n---\n');
181
+
182
+ // The addon's real, rendered host+port - not env's POSTGRES_HOST/POSTGRES_PORT (those are only
183
+ // as reliable as each addon.yaml's own naming), so a wait-for-dependency init container
184
+ // (manifestGen.js) can work for any addon generically, built-in or your own.
185
+ const connect = vars.serviceHost && servicePort ? { host: vars.serviceHost, port: servicePort } : null;
186
+
187
+ return { manifestYaml: fullManifestYaml, env, envFromSecret, connect };
188
+ }
189
+
190
+ module.exports = { loadAddon, substitute };