@localctl/cli 0.5.0-beta.3 → 0.5.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/README.md +32 -82
- package/package.json +3 -10
- package/schema/app.schema.json +678 -44
- package/src/commands/doctor.js +1 -16
- package/src/commands/down.js +3 -1
- package/src/commands/generateManifests.js +4 -0
- package/src/commands/logs.js +1 -0
- package/src/commands/new.js +42 -20
- package/src/commands/prune.js +4 -0
- package/src/commands/reload.js +11 -3
- package/src/commands/secrets.js +3 -3
- package/src/commands/setup.js +1 -11
- package/src/commands/tiltConfig.js +11 -0
- package/src/commands/uninstall.js +5 -6
- package/src/commands/up.js +9 -1
- package/src/commands/validate.js +48 -0
- package/src/index.js +2 -0
- package/src/lib/addonDirs.js +1 -2
- package/src/lib/addonLoader.js +38 -17
- package/src/lib/addons/mongo/addon.yaml +2 -0
- package/src/lib/addons/postgres/addon.yaml +5 -1
- package/src/lib/addons/redis/addon.yaml +2 -0
- package/src/lib/appSecrets.js +107 -36
- package/src/lib/clusterProvision.js +45 -211
- package/src/lib/configSchema.js +412 -41
- package/src/lib/engine.js +3 -13
- package/src/lib/envFile.js +58 -0
- package/src/lib/exec.js +6 -7
- package/src/lib/hostGuard.js +107 -0
- package/src/lib/hostsFile.js +9 -8
- package/src/lib/kubeErrors.js +22 -0
- package/src/lib/loadConfig.js +183 -9
- package/src/lib/manifestGen.js +96 -32
- package/src/lib/namespaces.js +83 -0
- package/src/lib/pathGuard.js +79 -0
- package/src/lib/paths.js +2 -6
- package/src/lib/podman.js +3 -93
- package/src/lib/projectContext.js +2 -2
- package/src/lib/quantities.js +20 -0
- package/src/lib/safeRead.js +41 -0
- package/src/lib/tiltConfig.js +25 -0
- package/src/lib/toolInstall.js +2 -141
- package/src/lib/yamlLoad.js +48 -0
- package/templates/Tiltfile.tmpl +22 -14
- package/src/lib/linuxDistro.js +0 -122
package/src/commands/doctor.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
const { commandExists, capture } = require('../lib/exec');
|
|
2
|
-
const { detectEngine, warnIfExperimental
|
|
3
|
-
const { ensurePodmanSocket, blockedPrivilegedPorts, engineMemoryMb } = require('../lib/podman');
|
|
4
|
-
const { DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT } = require('../lib/constants');
|
|
2
|
+
const { detectEngine, warnIfExperimental } = require('../lib/engine');
|
|
5
3
|
const { getActive, clusterNameFor } = require('../lib/profileStore');
|
|
6
4
|
const ui = require('../lib/ui');
|
|
7
5
|
const logger = require('../lib/logger');
|
|
@@ -23,7 +21,6 @@ module.exports = (program) => {
|
|
|
23
21
|
.description('Check the local environment for missing/broken pieces')
|
|
24
22
|
.action(() => {
|
|
25
23
|
logger.step('Checking environment...');
|
|
26
|
-
warnIfExperimentalPlatform();
|
|
27
24
|
const detected = detectEngine();
|
|
28
25
|
check(`container engine available${detected ? ` (${detected.engine})` : ''}`, () => Boolean(detected));
|
|
29
26
|
if (detected) warnIfExperimental(detected.engine);
|
|
@@ -41,18 +38,6 @@ module.exports = (program) => {
|
|
|
41
38
|
const res = capture('k3d', ['cluster', 'list']);
|
|
42
39
|
return res.stdout.split('\n').some((line) => line.trim().split(/\s+/)[0] === activeCluster);
|
|
43
40
|
});
|
|
44
|
-
if (process.platform === 'linux' && detected && detected.engine === 'podman') {
|
|
45
|
-
check('podman API socket available (needed by k3d)', () => ensurePodmanSocket() === null);
|
|
46
|
-
check('rootless podman can bind :80/:443', () => blockedPrivilegedPorts([DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT]).length === 0);
|
|
47
|
-
}
|
|
48
|
-
if (detected) {
|
|
49
|
-
// One k3s cluster idles around 0.6-0.7 GB, and apps/addons come on top. Below ~3 GB the
|
|
50
|
-
// engine's VM starts swapping and the API server times out.
|
|
51
|
-
const mb = engineMemoryMb(detected.bin);
|
|
52
|
-
if (mb !== null) {
|
|
53
|
-
check(`engine has at least 3 GB of memory (${(mb / 1024).toFixed(1)} GB available)`, () => mb >= 3000);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
41
|
check('kubectl context reachable', () => capture('kubectl', ['cluster-info']).status === 0);
|
|
57
42
|
logger.step(`Fix any ${ui.cross} above, or run "localctl setup" (or "localctl profiles switch ${active}" for a non-default project) to (re)install/repair.`);
|
|
58
43
|
});
|
package/src/commands/down.js
CHANGED
|
@@ -3,6 +3,7 @@ const { loadConfig } = require('../lib/loadConfig');
|
|
|
3
3
|
const { run, isRunning, sleepSync } = require('../lib/exec');
|
|
4
4
|
const { ensureClusterUp } = require('../lib/clusterProvision');
|
|
5
5
|
const { runFiles } = require('../lib/paths');
|
|
6
|
+
const { assertNamespaceOwnership } = require('../lib/namespaces');
|
|
6
7
|
const logger = require('../lib/logger');
|
|
7
8
|
|
|
8
9
|
module.exports = (program) => {
|
|
@@ -12,6 +13,7 @@ module.exports = (program) => {
|
|
|
12
13
|
.action(() => {
|
|
13
14
|
const cfg = loadConfig();
|
|
14
15
|
ensureClusterUp(cfg.clusterName, cfg.profile);
|
|
16
|
+
assertNamespaceOwnership(cfg);
|
|
15
17
|
const { pidFile, portFile, logFile } = runFiles(cfg.name);
|
|
16
18
|
|
|
17
19
|
// If `localctl up` started a detached Tilt process for this app, it needs to be stopped
|
|
@@ -19,7 +21,7 @@ module.exports = (program) => {
|
|
|
19
21
|
// about to delete out from under it.
|
|
20
22
|
if (fs.existsSync(pidFile)) {
|
|
21
23
|
const pid = parseInt(fs.readFileSync(pidFile, 'utf8'), 10);
|
|
22
|
-
if (Number.
|
|
24
|
+
if (Number.isInteger(pid) && pid > 1 && isRunning(pid)) {
|
|
23
25
|
logger.step(`Stopping background Tilt process (pid ${pid})...`);
|
|
24
26
|
process.kill(pid, 'SIGTERM');
|
|
25
27
|
for (let i = 0; i < 20 && isRunning(pid); i += 1) sleepSync(250);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { loadConfig } = require('../lib/loadConfig');
|
|
2
2
|
const { generateManifests } = require('../lib/manifestGen');
|
|
3
|
+
const { assertNamespaceOwnership } = require('../lib/namespaces');
|
|
3
4
|
|
|
4
5
|
module.exports = (program) => {
|
|
5
6
|
program
|
|
@@ -7,6 +8,9 @@ module.exports = (program) => {
|
|
|
7
8
|
.description('[internal, called by Tiltfile] Render k8s manifests for the app in the current directory to stdout')
|
|
8
9
|
.action(() => {
|
|
9
10
|
const cfg = loadConfig();
|
|
11
|
+
// Rendering creates Secrets and ConfigMaps for dependencies, so check the namespace first:
|
|
12
|
+
// Tilt calls this on every evaluation, whatever `localctl app up` checked earlier.
|
|
13
|
+
assertNamespaceOwnership(cfg);
|
|
10
14
|
process.stdout.write(generateManifests(cfg));
|
|
11
15
|
});
|
|
12
16
|
};
|
package/src/commands/logs.js
CHANGED
|
@@ -12,6 +12,7 @@ module.exports = (program) => {
|
|
|
12
12
|
.option('-f, --follow', 'follow log output')
|
|
13
13
|
.description('Tail logs for a deployed app - run inside the app\'s repo to resolve it (and its project\'s namespace prefix) automatically, or pass a name')
|
|
14
14
|
.action((name, opts) => {
|
|
15
|
+
if (name !== undefined && !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) throw new Error(`"${name}" is not a valid app name.`);
|
|
15
16
|
let targetName = name;
|
|
16
17
|
let namespace = opts.namespace;
|
|
17
18
|
|
package/src/commands/new.js
CHANGED
|
@@ -6,41 +6,63 @@ const logger = require('../lib/logger');
|
|
|
6
6
|
module.exports = (program) => {
|
|
7
7
|
program
|
|
8
8
|
.command('new')
|
|
9
|
-
.description('Scaffold .local/config.
|
|
9
|
+
.description('Scaffold .local/config.yaml and a Tiltfile in the current app repo')
|
|
10
10
|
.action(async () => {
|
|
11
11
|
const cwd = process.cwd();
|
|
12
12
|
const localDir = path.join(cwd, '.local');
|
|
13
|
-
const configPath = path.join(localDir, 'config.
|
|
13
|
+
const configPath = path.join(localDir, 'config.yaml');
|
|
14
14
|
|
|
15
15
|
if (fs.existsSync(configPath)) {
|
|
16
|
-
logger.warn('.local/config.
|
|
16
|
+
logger.warn('.local/config.yaml already exists, aborting.');
|
|
17
17
|
return;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
const defaultName = path.basename(cwd).toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
20
|
+
const defaultName = path.basename(cwd).toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 63) || 'app';
|
|
21
21
|
const [name, portAnswer] = await askAll([
|
|
22
22
|
{ text: 'App name', def: defaultName },
|
|
23
23
|
{ text: 'App port', def: '3000' },
|
|
24
24
|
]);
|
|
25
25
|
|
|
26
26
|
const port = parseInt(portAnswer, 10);
|
|
27
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
28
|
+
throw new Error(`"${portAnswer}" is not a valid port (1-65535).`);
|
|
29
|
+
}
|
|
30
|
+
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name) || name.length > 63) {
|
|
31
|
+
throw new Error(`"${name}" is not a valid app name: use lowercase letters, digits and dashes only.`);
|
|
32
|
+
}
|
|
27
33
|
|
|
28
34
|
fs.mkdirSync(localDir, { recursive: true });
|
|
29
35
|
|
|
30
|
-
const config =
|
|
31
|
-
$schema
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
const config = [
|
|
37
|
+
'# yaml-language-server: $schema=https://raw.githubusercontent.com/psilvmoreira/localctl/master/schema/app.schema.json',
|
|
38
|
+
'# Infrastructure config for this app. Reference: https://psilvmoreira.github.io/localctl/config-schema/',
|
|
39
|
+
`name: ${JSON.stringify(name)}`,
|
|
40
|
+
`subdomain: ${JSON.stringify(name)}`,
|
|
41
|
+
`port: ${port}`,
|
|
42
|
+
'build:',
|
|
43
|
+
' context: .',
|
|
44
|
+
' dockerfile: Dockerfile',
|
|
45
|
+
'# Plain environment variables. Quote values whose exact text matters ("1.10", "no").',
|
|
46
|
+
'# API keys and passwords belong in "secrets" below, never here.',
|
|
47
|
+
'env: {}',
|
|
48
|
+
'# Names only; set values with: localctl app secrets set <NAME>',
|
|
49
|
+
'# secrets:',
|
|
50
|
+
'# - API_KEY',
|
|
51
|
+
'# Other options (see the config schema reference): command / args to override the image',
|
|
52
|
+
'# CMD, envFile: .env.local for non-secret settings, build.target / build.args, run steps after sync.',
|
|
53
|
+
'replicas: 1',
|
|
54
|
+
'debug:',
|
|
55
|
+
' enabled: false',
|
|
56
|
+
'# Databases and caches deployed next to the app: postgres, redis or mongo.',
|
|
57
|
+
'dependencies: []',
|
|
58
|
+
'# Files copied into the running container on save (live update).',
|
|
59
|
+
'sync:',
|
|
60
|
+
' - localPath: ./src',
|
|
61
|
+
' remotePath: /app/src',
|
|
62
|
+
'tls: true',
|
|
63
|
+
'',
|
|
64
|
+
].join('\n');
|
|
65
|
+
fs.writeFileSync(configPath, config);
|
|
44
66
|
|
|
45
67
|
const tiltTemplatePath = path.join(__dirname, '..', '..', 'templates', 'Tiltfile.tmpl');
|
|
46
68
|
const tiltTemplate = fs.readFileSync(tiltTemplatePath, 'utf8');
|
|
@@ -49,7 +71,7 @@ module.exports = (program) => {
|
|
|
49
71
|
fs.writeFileSync(tiltfilePath, tiltTemplate);
|
|
50
72
|
}
|
|
51
73
|
|
|
52
|
-
logger.success(`Created .local/config.
|
|
53
|
-
logger.info('Edit .local/config.
|
|
74
|
+
logger.success(`Created .local/config.yaml and Tiltfile for "${name}".`);
|
|
75
|
+
logger.info('Edit .local/config.yaml (env, dependencies, debug), then run "localctl app up".');
|
|
54
76
|
});
|
|
55
77
|
};
|
package/src/commands/prune.js
CHANGED
|
@@ -4,6 +4,7 @@ const { run, capture } = require('../lib/exec');
|
|
|
4
4
|
const { getList } = require('../lib/workloadReadiness');
|
|
5
5
|
const { confirm } = require('../lib/prompt');
|
|
6
6
|
const logger = require('../lib/logger');
|
|
7
|
+
const { assertNamespaceOwnership } = require('../lib/namespaces');
|
|
7
8
|
|
|
8
9
|
module.exports = (program) => {
|
|
9
10
|
program
|
|
@@ -13,6 +14,7 @@ module.exports = (program) => {
|
|
|
13
14
|
.action(async (opts) => {
|
|
14
15
|
const cfg = loadConfig();
|
|
15
16
|
ensureClusterUp(cfg.clusterName, cfg.profile);
|
|
17
|
+
assertNamespaceOwnership(cfg);
|
|
16
18
|
|
|
17
19
|
if (capture('kubectl', ['get', 'deployment', cfg.name, '-n', cfg.namespace]).status === 0) {
|
|
18
20
|
throw new Error(`"${cfg.name}" is still deployed - run "localctl app down" first, then "localctl app prune" to also wipe its data.`);
|
|
@@ -43,6 +45,8 @@ module.exports = (program) => {
|
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
// The prompt can take a while; make sure the namespace is still ours before deleting it.
|
|
49
|
+
assertNamespaceOwnership(cfg);
|
|
46
50
|
run('kubectl', ['delete', 'namespace', cfg.namespace]);
|
|
47
51
|
logger.success(`Namespace "${cfg.namespace}" and everything in it deleted.`);
|
|
48
52
|
});
|
package/src/commands/reload.js
CHANGED
|
@@ -2,12 +2,20 @@ const fs = require('fs');
|
|
|
2
2
|
const { CONFIG_PATH, runFiles } = require('../lib/paths');
|
|
3
3
|
const { run, isRunning } = require('../lib/exec');
|
|
4
4
|
const logger = require('../lib/logger');
|
|
5
|
+
const { readRawConfig } = require('../lib/loadConfig');
|
|
6
|
+
|
|
7
|
+
const APP_NAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
5
8
|
|
|
6
9
|
function resolveAppName(opts) {
|
|
7
|
-
if (opts.app)
|
|
10
|
+
if (opts.app) {
|
|
11
|
+
if (!APP_NAME.test(opts.app) || opts.app.length > 63) throw new Error(`"${opts.app}" is not a valid app name.`);
|
|
12
|
+
return opts.app;
|
|
13
|
+
}
|
|
8
14
|
if (fs.existsSync(CONFIG_PATH)) {
|
|
9
15
|
try {
|
|
10
|
-
|
|
16
|
+
const name = readRawConfig(CONFIG_PATH).name;
|
|
17
|
+
// Only a plain app name: it becomes part of file names and a tilt argument.
|
|
18
|
+
return typeof name === 'string' && /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name) ? name : null;
|
|
11
19
|
} catch (e) {
|
|
12
20
|
// fall through
|
|
13
21
|
}
|
|
@@ -32,7 +40,7 @@ module.exports = (program) => {
|
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
const pid = parseInt(fs.readFileSync(pidFile, 'utf8'), 10);
|
|
35
|
-
if (!Number.
|
|
43
|
+
if (!Number.isInteger(pid) || pid <= 1 || !isRunning(pid)) {
|
|
36
44
|
throw new Error(`"${name}"'s dev loop isn't running. Run "localctl app up" to start it.`);
|
|
37
45
|
}
|
|
38
46
|
|
package/src/commands/secrets.js
CHANGED
|
@@ -10,12 +10,12 @@ module.exports = (program) => {
|
|
|
10
10
|
|
|
11
11
|
secrets
|
|
12
12
|
.command('set <key> [value]')
|
|
13
|
-
.description('Set a secret value (prompts, hidden, if not given) - stored as a real k8s Secret, never in .local/config.
|
|
13
|
+
.description('Set a secret value (prompts, hidden, if not given) - stored as a real k8s Secret, never in .local/config.yaml')
|
|
14
14
|
.action(async (key, value) => {
|
|
15
15
|
const cfg = loadConfig();
|
|
16
16
|
ensureClusterUp(cfg.clusterName, cfg.profile);
|
|
17
17
|
if (!cfg.secrets.includes(key)) {
|
|
18
|
-
logger.warn(`"${key}" isn't listed in .local/config.
|
|
18
|
+
logger.warn(`"${key}" isn't listed in .local/config.yaml's "secrets" array - it won't be injected into the app until you add it there too.`);
|
|
19
19
|
}
|
|
20
20
|
const finalValue = value || (await promptHidden(`Value for ${key}`));
|
|
21
21
|
if (!finalValue) throw new Error('No value given.');
|
|
@@ -48,7 +48,7 @@ module.exports = (program) => {
|
|
|
48
48
|
const rows = [...new Set([...cfg.secrets, ...set])].map((key) => [
|
|
49
49
|
key,
|
|
50
50
|
set.has(key) ? ui.ok('set') : ui.warn('missing'),
|
|
51
|
-
cfg.secrets.includes(key) ? '' : ui.dim('(not in config.
|
|
51
|
+
cfg.secrets.includes(key) ? '' : ui.dim('(not in config.yaml)'),
|
|
52
52
|
]);
|
|
53
53
|
ui.printTable(['KEY', 'STATUS', ''], rows);
|
|
54
54
|
});
|
package/src/commands/setup.js
CHANGED
|
@@ -3,17 +3,8 @@ const { syncHosts } = require('../lib/hostsFile');
|
|
|
3
3
|
const { setActive } = require('../lib/profileStore');
|
|
4
4
|
const { DEFAULT_CLUSTER_NAME, DEFAULT_HTTP_PORT, DEFAULT_HTTPS_PORT, DOMAIN, REGISTRY_HOST_PORT } = require('../lib/constants');
|
|
5
5
|
const logger = require('../lib/logger');
|
|
6
|
-
const { detectEngine } = require('../lib/engine');
|
|
7
|
-
const { installDocker } = require('../lib/linuxDistro');
|
|
8
6
|
|
|
9
|
-
function setup(
|
|
10
|
-
if (opts.installDocker) {
|
|
11
|
-
if (process.platform !== 'linux') {
|
|
12
|
-
throw new Error('--install-docker is only available on Linux. Install Docker Desktop or Podman Desktop instead.');
|
|
13
|
-
}
|
|
14
|
-
if (detectEngine()) logger.success('A container engine is already running, nothing to install');
|
|
15
|
-
else installDocker();
|
|
16
|
-
}
|
|
7
|
+
function setup() {
|
|
17
8
|
provisionCluster({
|
|
18
9
|
clusterName: DEFAULT_CLUSTER_NAME,
|
|
19
10
|
httpPort: DEFAULT_HTTP_PORT,
|
|
@@ -41,6 +32,5 @@ module.exports = (program) => {
|
|
|
41
32
|
program
|
|
42
33
|
.command('setup')
|
|
43
34
|
.description('One-click local infra setup for the default project: container engine, tools, k3d cluster, TLS, hosts')
|
|
44
|
-
.option('--install-docker', 'Linux only: install Docker Engine first if no container engine is running (needs sudo)')
|
|
45
35
|
.action(setup);
|
|
46
36
|
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const { loadConfig } = require('../lib/loadConfig');
|
|
2
|
+
const { tiltConfig } = require('../lib/tiltConfig');
|
|
3
|
+
|
|
4
|
+
module.exports = (program) => {
|
|
5
|
+
program
|
|
6
|
+
.command('tilt-config')
|
|
7
|
+
.description('[internal, called by Tiltfile] Print the resolved build/sync/port settings of the app in the current directory as JSON')
|
|
8
|
+
.action(() => {
|
|
9
|
+
process.stdout.write(JSON.stringify(tiltConfig(loadConfig())));
|
|
10
|
+
});
|
|
11
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const os = require('os');
|
|
1
2
|
const fs = require('fs');
|
|
2
3
|
const path = require('path');
|
|
3
4
|
const { isRunning } = require('../lib/exec');
|
|
@@ -7,7 +8,7 @@ const { teardownInsecureRegistry } = require('../lib/podman');
|
|
|
7
8
|
const { clearHosts } = require('../lib/hostsFile');
|
|
8
9
|
const { listProfiles } = require('../lib/profileStore');
|
|
9
10
|
const { confirm } = require('../lib/prompt');
|
|
10
|
-
const { RUN_DIR, PROFILES_PATH
|
|
11
|
+
const { RUN_DIR, PROFILES_PATH } = require('../lib/paths');
|
|
11
12
|
const logger = require('../lib/logger');
|
|
12
13
|
|
|
13
14
|
// Every Main (cluster-isolation) project this machine knows about - each one owns a real,
|
|
@@ -23,7 +24,7 @@ async function uninstall(opts) {
|
|
|
23
24
|
console.log('This will remove everything "localctl setup"/"localctl profiles" set up:');
|
|
24
25
|
console.log(` - Every project's k3d cluster (${projects.map((p) => p.cluster).join(', ')}) and everything deployed in them`);
|
|
25
26
|
console.log(' - Their local image registry containers');
|
|
26
|
-
console.log(' - Podman insecure-registry config (Podman only, if present)');
|
|
27
|
+
console.log(' - Podman VM insecure-registry config (Podman only, if present)');
|
|
27
28
|
console.log(' - The *.local.test block in your hosts file');
|
|
28
29
|
console.log(' - Any background dev loop `localctl app up` left running, and its state');
|
|
29
30
|
console.log(' - ~/.localctl/certs, the app registry state, and the project registry (~/.localctl/profiles.json)');
|
|
@@ -79,8 +80,8 @@ async function uninstall(opts) {
|
|
|
79
80
|
fs.rmSync(RUN_DIR, { recursive: true, force: true });
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
const certsDir =
|
|
83
|
-
const appsRegistry =
|
|
83
|
+
const certsDir = path.join(os.homedir(), '.localctl', 'certs');
|
|
84
|
+
const appsRegistry = path.join(os.homedir(), '.localctl', 'apps.json');
|
|
84
85
|
if (fs.existsSync(certsDir)) fs.rmSync(certsDir, { recursive: true, force: true });
|
|
85
86
|
if (fs.existsSync(appsRegistry)) fs.rmSync(appsRegistry, { force: true });
|
|
86
87
|
if (fs.existsSync(PROFILES_PATH)) fs.rmSync(PROFILES_PATH, { force: true });
|
|
@@ -93,8 +94,6 @@ async function uninstall(opts) {
|
|
|
93
94
|
if (process.platform === 'win32') {
|
|
94
95
|
console.log(' winget uninstall k3d.k3d Kubernetes.kubectl FiloSottile.mkcert Helm.Helm # only if nothing else needs them');
|
|
95
96
|
console.log(' Remove-Item -Recurse -Force ~\\.localctl\\tools # tilt, if localctl installed it');
|
|
96
|
-
} else if (process.platform === 'linux') {
|
|
97
|
-
console.log(' rm -rf ~/.localctl/tools # k3d, kubectl, tilt, mkcert and helm, if localctl installed them');
|
|
98
97
|
} else {
|
|
99
98
|
console.log(' brew uninstall k3d kubectl tilt mkcert helm # only if nothing else needs them');
|
|
100
99
|
}
|
package/src/commands/up.js
CHANGED
|
@@ -6,6 +6,8 @@ const { detectEngine } = require('../lib/engine');
|
|
|
6
6
|
const { ensureClusterUp } = require('../lib/clusterProvision');
|
|
7
7
|
const { portsFor } = require('../lib/profileStore');
|
|
8
8
|
const { listKeys } = require('../lib/appSecrets');
|
|
9
|
+
const { assertNamespaceOwnership } = require('../lib/namespaces');
|
|
10
|
+
const { assertHostsFree } = require('../lib/hostGuard');
|
|
9
11
|
const { runFiles } = require('../lib/paths');
|
|
10
12
|
const { DOMAIN, DEFAULT_HTTPS_PORT } = require('../lib/constants');
|
|
11
13
|
const ui = require('../lib/ui');
|
|
@@ -29,6 +31,7 @@ function printEndpoints(cfg) {
|
|
|
29
31
|
const url = `https://${cfg.subdomain}.${DOMAIN}${suffix}`;
|
|
30
32
|
console.log('');
|
|
31
33
|
console.log(`${ui.check} ${ui.bold(cfg.name)} -> ${ui.url(url)}`);
|
|
34
|
+
cfg.hosts.slice(1).forEach((host) => console.log(` also: ${ui.url(`https://${host}.${DOMAIN}${suffix}`)}`));
|
|
32
35
|
if (cfg.debug.enabled && cfg.debug.port) {
|
|
33
36
|
console.log(` debug: localhost:${cfg.debug.port}`);
|
|
34
37
|
}
|
|
@@ -47,10 +50,15 @@ module.exports = (program) => {
|
|
|
47
50
|
program
|
|
48
51
|
.command('up')
|
|
49
52
|
.option('-f, --foreground', 'run in the foreground with the interactive Tilt UI (default: detached, survives closing this terminal)')
|
|
53
|
+
.option('-e, --env <name>', 'apply an entry of "environments" from .local/config.yaml over the base config')
|
|
50
54
|
.description('Deploy/dev-loop the app in the current directory')
|
|
51
55
|
.action(async (opts) => {
|
|
56
|
+
// Tilt (and the localctl calls in the Tiltfile) inherit this, so every step sees the same config.
|
|
57
|
+
if (opts.env) process.env.LOCALCTL_ENV = opts.env;
|
|
52
58
|
const cfg = loadConfig();
|
|
53
59
|
ensureClusterUp(cfg.clusterName, cfg.profile);
|
|
60
|
+
assertNamespaceOwnership(cfg);
|
|
61
|
+
assertHostsFree(cfg);
|
|
54
62
|
|
|
55
63
|
if (cfg.secrets.length > 0) {
|
|
56
64
|
const set = new Set(listKeys(cfg));
|
|
@@ -64,7 +72,7 @@ module.exports = (program) => {
|
|
|
64
72
|
}
|
|
65
73
|
}
|
|
66
74
|
|
|
67
|
-
registerApp(cfg.
|
|
75
|
+
registerApp(cfg.hosts);
|
|
68
76
|
syncHosts();
|
|
69
77
|
const env = podmanBuildEnv();
|
|
70
78
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const { readRawConfig, applyEnvironment } = require('../lib/loadConfig');
|
|
2
|
+
const { appConfigSchema, describeIssues } = require('../lib/configSchema');
|
|
3
|
+
const { CONFIG_PATH } = require('../lib/paths');
|
|
4
|
+
const logger = require('../lib/logger');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { readEnvFile } = require('../lib/envFile');
|
|
7
|
+
const { assertPathsInsideProject } = require('../lib/pathGuard');
|
|
8
|
+
|
|
9
|
+
module.exports = (program) => {
|
|
10
|
+
program
|
|
11
|
+
.command('validate')
|
|
12
|
+
.description('Check .local/config.yaml and each of its environments (unknown fields, wrong types, bad values) without touching the cluster')
|
|
13
|
+
.action(() => {
|
|
14
|
+
const raw = readRawConfig(CONFIG_PATH);
|
|
15
|
+
const names = Object.keys(raw.environments && typeof raw.environments === 'object' ? raw.environments : {});
|
|
16
|
+
const problems = [];
|
|
17
|
+
let base;
|
|
18
|
+
// The base file, then each environment applied over it.
|
|
19
|
+
for (const envName of [null, ...names]) {
|
|
20
|
+
const label = envName ? `environment "${envName}"` : 'base config';
|
|
21
|
+
try {
|
|
22
|
+
const parsed = appConfigSchema.safeParse(applyEnvironment(raw, envName || ''));
|
|
23
|
+
if (parsed.success) {
|
|
24
|
+
// Same path check `app up` makes, so "valid" means it will be accepted there too.
|
|
25
|
+
assertPathsInsideProject(parsed.data, path.dirname(path.dirname(CONFIG_PATH)));
|
|
26
|
+
if (!envName) base = parsed.data;
|
|
27
|
+
} else {
|
|
28
|
+
problems.push(`${label}:\n${describeIssues(parsed.error.issues)}`);
|
|
29
|
+
}
|
|
30
|
+
} catch (e) {
|
|
31
|
+
problems.push(`${label}: ${e.message}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (typeof raw.envFile === 'string') {
|
|
35
|
+
try {
|
|
36
|
+
readEnvFile(path.dirname(path.dirname(CONFIG_PATH)), raw.envFile);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
problems.push(e.message);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (problems.length) throw new Error(`Invalid .local/config.yaml:\n${problems.join('\n')}`);
|
|
42
|
+
const deps = base.dependencies.map((d) => d.type);
|
|
43
|
+
logger.success(
|
|
44
|
+
`.local/config.yaml is valid: "${base.name}" on port ${base.port}` +
|
|
45
|
+
`${deps.length ? `, with ${deps.join(', ')}` : ''}${names.length ? `; environments: ${names.join(', ')}` : ''}.`,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
};
|
package/src/index.js
CHANGED
|
@@ -27,6 +27,8 @@ require('./commands/status')(app);
|
|
|
27
27
|
require('./commands/logs')(app);
|
|
28
28
|
require('./commands/reload')(app);
|
|
29
29
|
require('./commands/generateManifests')(app);
|
|
30
|
+
require('./commands/tiltConfig')(app);
|
|
31
|
+
require('./commands/validate')(app);
|
|
30
32
|
require('./commands/registryInfo')(app);
|
|
31
33
|
require('./commands/exec')(app);
|
|
32
34
|
require('./commands/prune')(app);
|
package/src/lib/addonDirs.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const os = require('os');
|
|
3
3
|
const path = require('path');
|
|
4
|
-
const { HOME_STATE_DIR } = require('./paths');
|
|
5
4
|
|
|
6
5
|
// Addons are searched in two places, in precedence order:
|
|
7
6
|
// 1. USER_DIR - yours, never touched by a `localctl` update/reinstall. Defaults to
|
|
@@ -12,7 +11,7 @@ const { HOME_STATE_DIR } = require('./paths');
|
|
|
12
11
|
// This is what lets someone install this tool without inheriting its author's addon set, and
|
|
13
12
|
// without forking the tool to add their own - see docs/adding-tools.md.
|
|
14
13
|
const BUILTIN_DIR = path.join(__dirname, 'addons');
|
|
15
|
-
const USER_DIR = process.env.LOCALCTL_ADDONS_DIR || path.join(
|
|
14
|
+
const USER_DIR = process.env.LOCALCTL_ADDONS_DIR || path.join(os.homedir(), '.localctl', 'addons');
|
|
16
15
|
|
|
17
16
|
function roots(...segments) {
|
|
18
17
|
return [path.join(USER_DIR, ...segments), path.join(BUILTIN_DIR, ...segments)];
|
package/src/lib/addonLoader.js
CHANGED
|
@@ -24,22 +24,18 @@ function ensureRepo(url) {
|
|
|
24
24
|
|
|
25
25
|
function substitute(template, vars) {
|
|
26
26
|
return String(template).replace(/\{\{\s*([\w.]+)\s*\}\}/g, (match, key) => {
|
|
27
|
-
if (!(key
|
|
27
|
+
if (!Object.prototype.hasOwnProperty.call(vars, key)) throw new Error(`Addon references unknown template variable "{{${key}}}"`);
|
|
28
28
|
return vars[key];
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
if (capture('kubectl', ['get', 'namespace', ns]).status !== 0) {
|
|
34
|
-
run('kubectl', ['create', 'namespace', ns], { stdio: 'ignore', allowFail: true });
|
|
35
|
-
}
|
|
36
|
-
}
|
|
32
|
+
const { ensureManagedNamespace: ensureNamespace } = require('./namespaces');
|
|
37
33
|
|
|
38
34
|
// Generates each secret's value once and stores it as a real k8s Secret; a re-run that finds the
|
|
39
35
|
// Secret already there leaves it alone, so app data doesn't lose its password out from under it
|
|
40
36
|
// on every `localctl up`.
|
|
41
|
-
function ensureSecret(name, namespace, keys) {
|
|
42
|
-
ensureNamespace(namespace);
|
|
37
|
+
function ensureSecret(name, namespace, keys, owner) {
|
|
38
|
+
ensureNamespace(namespace, owner);
|
|
43
39
|
if (capture('kubectl', ['get', 'secret', name, '-n', namespace]).status === 0) return;
|
|
44
40
|
const args = ['create', 'secret', 'generic', name, '-n', namespace];
|
|
45
41
|
for (const key of keys) {
|
|
@@ -50,8 +46,8 @@ function ensureSecret(name, namespace, keys) {
|
|
|
50
46
|
|
|
51
47
|
// Turns a folder of literal files (dashboards, datasource definitions, init scripts - whatever
|
|
52
48
|
// the chart expects to mount, not a Helm value) into a real ConfigMap.
|
|
53
|
-
function ensureConfigMap(name, namespace, configDir) {
|
|
54
|
-
ensureNamespace(namespace);
|
|
49
|
+
function ensureConfigMap(name, namespace, configDir, owner) {
|
|
50
|
+
ensureNamespace(namespace, owner);
|
|
55
51
|
const files = fs.readdirSync(configDir).filter((f) => fs.statSync(path.join(configDir, f)).isFile());
|
|
56
52
|
const args = ['create', 'configmap', name, '-n', namespace, '--dry-run=client', '-o', 'yaml'];
|
|
57
53
|
files.forEach((f) => args.push(`--from-file=${path.join(configDir, f)}`));
|
|
@@ -62,8 +58,10 @@ function ensureConfigMap(name, namespace, configDir) {
|
|
|
62
58
|
|
|
63
59
|
function helmTemplate(release, chart, valuesObj, namespace) {
|
|
64
60
|
const repoName = ensureRepo(chart.repo);
|
|
65
|
-
|
|
66
|
-
fs.
|
|
61
|
+
// A private directory (mode 0700): a predictable file name in /tmp could be swapped by another user.
|
|
62
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'localctl-addon-'));
|
|
63
|
+
const tmpValues = path.join(tmpDir, 'values.yaml');
|
|
64
|
+
fs.writeFileSync(tmpValues, yaml.dump(valuesObj), { flag: 'wx', mode: 0o600 });
|
|
67
65
|
try {
|
|
68
66
|
// --no-hooks: excludes `helm test` pods and similar hook resources from the rendered output -
|
|
69
67
|
// we apply this YAML with plain `kubectl apply`, not `helm install`, so nothing would ever
|
|
@@ -76,7 +74,7 @@ function helmTemplate(release, chart, valuesObj, namespace) {
|
|
|
76
74
|
}
|
|
77
75
|
return res.stdout;
|
|
78
76
|
} finally {
|
|
79
|
-
fs.rmSync(
|
|
77
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
80
78
|
}
|
|
81
79
|
}
|
|
82
80
|
|
|
@@ -129,9 +127,31 @@ function buildAddonRecord({ release, namespace, addonName, chart, owner }) {
|
|
|
129
127
|
};
|
|
130
128
|
}
|
|
131
129
|
|
|
130
|
+
// Writes a dependency's `resources:` into the chart values at the addon's `resourcesPath` (a dotted
|
|
131
|
+
// path such as `primary.resources`). Set as an object, never through text substitution, so the
|
|
132
|
+
// values can't change the YAML structure around them.
|
|
133
|
+
function applyResources(valuesObj, resourcesPath, resources, addonName) {
|
|
134
|
+
if (!resourcesPath) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`The "${addonName}" addon can't take "resources" yet: its addon.yaml has no "resourcesPath" ` +
|
|
137
|
+
'(where the chart keeps its resources, e.g. primary.resources). See docs/adding-tools.md.',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
const keys = resourcesPath.split('.');
|
|
141
|
+
if (keys.some((k) => ['__proto__', 'constructor', 'prototype'].includes(k))) {
|
|
142
|
+
throw new Error(`Invalid resourcesPath "${resourcesPath}" in the ${addonName} addon.`);
|
|
143
|
+
}
|
|
144
|
+
let node = valuesObj;
|
|
145
|
+
for (const key of keys.slice(0, -1)) {
|
|
146
|
+
if (typeof node[key] !== 'object' || node[key] === null) node[key] = {};
|
|
147
|
+
node = node[key];
|
|
148
|
+
}
|
|
149
|
+
node[keys[keys.length - 1]] = JSON.parse(JSON.stringify(resources));
|
|
150
|
+
}
|
|
151
|
+
|
|
132
152
|
// Loads one addon (given its folder) and returns the rendered manifest YAML, plus any env vars
|
|
133
153
|
// 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 }) {
|
|
154
|
+
function loadAddon(addonDir, { release, namespace, addonName, owner, extraVars = {}, chartVersionOverride, resources }) {
|
|
135
155
|
const specPath = path.join(addonDir, 'addon.yaml');
|
|
136
156
|
const spec = yaml.load(fs.readFileSync(specPath, 'utf8'));
|
|
137
157
|
|
|
@@ -140,19 +160,20 @@ function loadAddon(addonDir, { release, namespace, addonName, owner, extraVars =
|
|
|
140
160
|
|
|
141
161
|
if (spec.secrets) {
|
|
142
162
|
const secretName = `${release}-secret`;
|
|
143
|
-
ensureSecret(secretName, namespace, Object.keys(spec.secrets));
|
|
163
|
+
ensureSecret(secretName, namespace, Object.keys(spec.secrets), owner);
|
|
144
164
|
vars.secretName = secretName;
|
|
145
165
|
}
|
|
146
166
|
|
|
147
167
|
const configDir = path.join(addonDir, 'config');
|
|
148
168
|
if (spec.config && fs.existsSync(configDir)) {
|
|
149
169
|
const configMapName = `${release}-config`;
|
|
150
|
-
ensureConfigMap(configMapName, namespace, configDir);
|
|
170
|
+
ensureConfigMap(configMapName, namespace, configDir, owner);
|
|
151
171
|
vars.configMapName = configMapName;
|
|
152
172
|
}
|
|
153
173
|
|
|
154
174
|
const valuesYaml = substitute(spec.values, vars);
|
|
155
175
|
const valuesObj = yaml.load(valuesYaml) || {};
|
|
176
|
+
if (resources) applyResources(valuesObj, spec.resourcesPath, resources, addonName || path.basename(addonDir));
|
|
156
177
|
const manifestYaml = helmTemplate(release, chart, valuesObj, namespace);
|
|
157
178
|
vars.serviceHost = findServiceHost(manifestYaml);
|
|
158
179
|
const servicePort = findServicePort(manifestYaml);
|
|
@@ -187,4 +208,4 @@ function loadAddon(addonDir, { release, namespace, addonName, owner, extraVars =
|
|
|
187
208
|
return { manifestYaml: fullManifestYaml, env, envFromSecret, connect };
|
|
188
209
|
}
|
|
189
210
|
|
|
190
|
-
module.exports = { loadAddon, substitute };
|
|
211
|
+
module.exports = { loadAddon, substitute, applyResources };
|
|
@@ -26,8 +26,12 @@ envFromSecret:
|
|
|
26
26
|
secret: "{{secretName}}"
|
|
27
27
|
key: password
|
|
28
28
|
|
|
29
|
+
# Where in the chart's values a dependency's `resources:` (cpu/memory requests and limits from the
|
|
30
|
+
# app's config) are written. Charts differ, so each addon says where its main workload's live.
|
|
31
|
+
resourcesPath: primary.resources
|
|
32
|
+
|
|
29
33
|
# Passed to `helm template` as -f values.yaml. {{...}} is substituted first: {{database}}/
|
|
30
|
-
# {{storage}}/{{version}} come from the app's own .local/config.
|
|
34
|
+
# {{storage}}/{{version}} come from the app's own .local/config.yaml dependency entry (with
|
|
31
35
|
# fallback defaults), {{secretName}} from the generated Secret above.
|
|
32
36
|
values: |
|
|
33
37
|
auth:
|