@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.
- package/LICENSE +21 -0
- package/README.md +431 -0
- package/assets/manifests/namespace-addons.yaml +4 -0
- package/assets/manifests/tls-store.yaml +8 -0
- package/bin/localctl.js +2 -0
- package/package.json +54 -0
- package/schema/app.schema.json +127 -0
- package/src/commands/addons.js +56 -0
- package/src/commands/doctor.js +43 -0
- package/src/commands/down.js +35 -0
- package/src/commands/exec.js +15 -0
- package/src/commands/generateManifests.js +12 -0
- package/src/commands/hosts.js +23 -0
- package/src/commands/logs.js +45 -0
- package/src/commands/new.js +55 -0
- package/src/commands/profiles.js +180 -0
- package/src/commands/prune.js +49 -0
- package/src/commands/registryInfo.js +18 -0
- package/src/commands/reload.js +44 -0
- package/src/commands/secrets.js +75 -0
- package/src/commands/setup.js +36 -0
- package/src/commands/status.js +136 -0
- package/src/commands/uninstall.js +105 -0
- package/src/commands/up.js +123 -0
- package/src/index.js +36 -0
- package/src/lib/addonDirs.js +43 -0
- package/src/lib/addonLoader.js +190 -0
- package/src/lib/addons/cluster/logging/addon.yaml +46 -0
- package/src/lib/addons/cluster/logging/config/all-logs.json +21 -0
- package/src/lib/addons/cluster/monitoring/addon.yaml +33 -0
- package/src/lib/addons/mongo/addon.yaml +18 -0
- package/src/lib/addons/postgres/addon.yaml +39 -0
- package/src/lib/addons/redis/addon.yaml +29 -0
- package/src/lib/appSecrets.js +60 -0
- package/src/lib/clusterAddons.js +59 -0
- package/src/lib/clusterProvision.js +170 -0
- package/src/lib/configSchema.js +103 -0
- package/src/lib/constants.js +16 -0
- package/src/lib/engine.js +20 -0
- package/src/lib/exec.js +77 -0
- package/src/lib/hostsFile.js +79 -0
- package/src/lib/loadConfig.js +51 -0
- package/src/lib/logger.js +9 -0
- package/src/lib/manifestGen.js +210 -0
- package/src/lib/paths.js +41 -0
- package/src/lib/podman.js +72 -0
- package/src/lib/profileStore.js +134 -0
- package/src/lib/projectContext.js +32 -0
- package/src/lib/prompt.js +97 -0
- package/src/lib/toolInstall.js +30 -0
- package/src/lib/ui.js +50 -0
- package/src/lib/workloadReadiness.js +35 -0
- 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 };
|