@localctl/cli 0.5.0-beta.2 → 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.
Files changed (45) hide show
  1. package/README.md +32 -76
  2. package/package.json +3 -10
  3. package/schema/app.schema.json +678 -44
  4. package/src/commands/doctor.js +1 -16
  5. package/src/commands/down.js +3 -1
  6. package/src/commands/generateManifests.js +4 -0
  7. package/src/commands/logs.js +1 -0
  8. package/src/commands/new.js +42 -20
  9. package/src/commands/prune.js +4 -0
  10. package/src/commands/reload.js +11 -3
  11. package/src/commands/secrets.js +3 -3
  12. package/src/commands/setup.js +1 -11
  13. package/src/commands/tiltConfig.js +11 -0
  14. package/src/commands/uninstall.js +5 -6
  15. package/src/commands/up.js +9 -1
  16. package/src/commands/validate.js +48 -0
  17. package/src/index.js +2 -0
  18. package/src/lib/addonDirs.js +1 -2
  19. package/src/lib/addonLoader.js +38 -17
  20. package/src/lib/addons/mongo/addon.yaml +2 -0
  21. package/src/lib/addons/postgres/addon.yaml +5 -1
  22. package/src/lib/addons/redis/addon.yaml +2 -0
  23. package/src/lib/appSecrets.js +107 -36
  24. package/src/lib/clusterProvision.js +45 -211
  25. package/src/lib/configSchema.js +412 -41
  26. package/src/lib/engine.js +3 -13
  27. package/src/lib/envFile.js +58 -0
  28. package/src/lib/exec.js +6 -7
  29. package/src/lib/hostGuard.js +107 -0
  30. package/src/lib/hostsFile.js +9 -8
  31. package/src/lib/kubeErrors.js +22 -0
  32. package/src/lib/loadConfig.js +183 -9
  33. package/src/lib/manifestGen.js +96 -32
  34. package/src/lib/namespaces.js +83 -0
  35. package/src/lib/pathGuard.js +79 -0
  36. package/src/lib/paths.js +2 -6
  37. package/src/lib/podman.js +3 -93
  38. package/src/lib/projectContext.js +2 -2
  39. package/src/lib/quantities.js +20 -0
  40. package/src/lib/safeRead.js +41 -0
  41. package/src/lib/tiltConfig.js +25 -0
  42. package/src/lib/toolInstall.js +2 -141
  43. package/src/lib/yamlLoad.js +48 -0
  44. package/templates/Tiltfile.tmpl +22 -14
  45. package/src/lib/linuxDistro.js +0 -122
@@ -1,7 +1,5 @@
1
1
  const { commandExists, capture } = require('../lib/exec');
2
- const { detectEngine, warnIfExperimental, warnIfExperimentalPlatform } = require('../lib/engine');
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
  });
@@ -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.isFinite(pid) && isRunning(pid)) {
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
  };
@@ -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
 
@@ -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.json and a Tiltfile in the current app repo')
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.json');
13
+ const configPath = path.join(localDir, 'config.yaml');
14
14
 
15
15
  if (fs.existsSync(configPath)) {
16
- logger.warn('.local/config.json already exists, aborting.');
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: '../../localctl/schema/app.schema.json',
32
- name,
33
- subdomain: name,
34
- port,
35
- build: { context: '.', dockerfile: 'Dockerfile' },
36
- env: {},
37
- replicas: 1,
38
- debug: { enabled: false },
39
- dependencies: [],
40
- sync: [{ localPath: './src', remotePath: '/app/src' }],
41
- tls: true,
42
- };
43
- fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
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.json and Tiltfile for "${name}".`);
53
- logger.info('Edit .local/config.json (env, dependencies, debug), then run "localctl app up".');
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
  };
@@ -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
  });
@@ -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) return 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
- return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')).name;
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.isFinite(pid) || !isRunning(pid)) {
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
 
@@ -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.json')
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.json's "secrets" array - it won't be injected into the app until you add it there too.`);
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.json)'),
51
+ cfg.secrets.includes(key) ? '' : ui.dim('(not in config.yaml)'),
52
52
  ]);
53
53
  ui.printTable(['KEY', 'STATUS', ''], rows);
54
54
  });
@@ -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(opts) {
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, CERTS_DIR, APPS_REGISTRY_PATH } = require('../lib/paths');
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 = CERTS_DIR;
83
- const appsRegistry = APPS_REGISTRY_PATH;
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
  }
@@ -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.subdomain);
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);
@@ -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(HOME_STATE_DIR, 'addons');
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)];
@@ -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 in vars)) throw new Error(`Addon references unknown template variable "{{${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
- function ensureNamespace(ns) {
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
- const tmpValues = path.join(os.tmpdir(), `localctl-addon-values-${process.pid}-${Date.now()}.yaml`);
66
- fs.writeFileSync(tmpValues, yaml.dump(valuesObj));
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(tmpValues, { force: true });
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 };
@@ -11,6 +11,8 @@ env:
11
11
  MONGO_HOST: "{{serviceHost}}"
12
12
  MONGO_PORT: "27017"
13
13
 
14
+ resourcesPath: resources
15
+
14
16
  values: |
15
17
  auth:
16
18
  enabled: false
@@ -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.json dependency entry (with
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:
@@ -19,6 +19,8 @@ envFromSecret:
19
19
  secret: "{{secretName}}"
20
20
  key: password
21
21
 
22
+ resourcesPath: master.resources
23
+
22
24
  values: |
23
25
  architecture: standalone
24
26
  auth: