@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
@@ -0,0 +1,107 @@
1
+ const { capture } = require('./exec');
2
+ const { DOMAIN } = require('./constants');
3
+ const { firstLine } = require('./kubeErrors');
4
+
5
+ // Host names are case-insensitive and may carry a trailing dot; compare them in one form.
6
+ const normalise = (host) => String(host).trim().toLowerCase().replace(/\.$/, '');
7
+
8
+ // A host an existing route serves is in the way of `mine` if it is the same name, or a wildcard
9
+ // (`*.local.test`) that covers it.
10
+ function covers(routeHost, mine) {
11
+ const route = normalise(routeHost);
12
+ if (route === mine) return true;
13
+ return route.startsWith('*.') && mine.endsWith(route.slice(1));
14
+ }
15
+
16
+ function listObjects(resource, optional) {
17
+ const res = capture('kubectl', ['get', resource, '-A', '-o', 'json', '--request-timeout=20s']);
18
+ if (res.status !== 0) {
19
+ const text = `${res.stderr || ''}${res.stdout || ''}`;
20
+ // Traefik's own CRDs may not be installed: then there simply are no such routes.
21
+ if (optional && /the server doesn't have a resource type "ingressroutes"|no matches for kind "IngressRoute"/i.test(text)) return [];
22
+ throw new Error(`Couldn't check whether the hosts are already served by another app (${resource}): ${firstLine(text || 'kubectl failed')}`);
23
+ }
24
+ let items;
25
+ try {
26
+ items = JSON.parse(res.stdout).items;
27
+ } catch (e) {
28
+ items = null;
29
+ }
30
+ if (!Array.isArray(items)) throw new Error(`Couldn't read the list of ${resource} from the cluster.`);
31
+ return items;
32
+ }
33
+
34
+ // Reads a Traefik rule such as Host(`a.local.test`) || HostRegexp(`{sub:.+}.local.test`) with a
35
+ // single linear pass (no regular expression that could backtrack on hostile input). Returns the
36
+ // literal hosts, and `regexps` for HostRegexp patterns, whose reach can't be worked out here.
37
+ function parseRule(rule) {
38
+ const text = String(rule || '');
39
+ const hosts = [];
40
+ const regexps = [];
41
+ let i = 0;
42
+ while (i < text.length) {
43
+ const m = /^(host|hostregexp)\s*\(/i.exec(text.slice(i, i + 24));
44
+ const prev = i === 0 ? ' ' : text[i - 1];
45
+ if (!m || /[A-Za-z0-9_]/.test(prev)) {
46
+ i += 1;
47
+ continue;
48
+ }
49
+ const target = m[1].toLowerCase() === 'host' ? hosts : regexps;
50
+ i += m[0].length;
51
+ // Arguments up to the closing parenthesis, each quoted with ` " or '.
52
+ while (i < text.length && text[i] !== ')') {
53
+ const quote = text[i];
54
+ if (quote === '`' || quote === '"' || quote === "'") {
55
+ const end = text.indexOf(quote, i + 1);
56
+ if (end === -1) { i = text.length; break; }
57
+ target.push(text.slice(i + 1, end));
58
+ i = end + 1;
59
+ } else {
60
+ i += 1;
61
+ }
62
+ }
63
+ }
64
+ return { hosts, regexps };
65
+ }
66
+
67
+ // Could a HostRegexp pattern reach `mine`? It names the domain, or is a catch-all (no literal
68
+ // domain at all, like `.*`). Anything else is a pattern for some other domain.
69
+ function regexpMayCover(pattern, mine) {
70
+ const p = String(pattern).toLowerCase();
71
+ if (p.includes(DOMAIN.toLowerCase()) || p.includes(mine)) return true;
72
+ return !/[a-z0-9]\.[a-z]{2,}/.test(p.replace(/\\\./g, '.'));
73
+ }
74
+
75
+ // Stops an app from taking over a host another app already serves: two routes for the same host
76
+ // would fight over it in Traefik, and a config could use that to shadow another app. Looks at the
77
+ // objects in the cluster (the source of truth, unlike a local registry that can go stale): Ingress
78
+ // objects, including wildcard hosts, and Traefik IngressRoutes. Anything other than a clean answer
79
+ // is an error.
80
+ function assertHostsFree(cfg) {
81
+ const mine = cfg.hosts.map((h) => normalise(`${h}.${DOMAIN}`));
82
+ const claims = [];
83
+ for (const ing of listObjects('ingress', false)) {
84
+ const meta = ing.metadata || {};
85
+ for (const rule of (ing.spec && ing.spec.rules) || []) {
86
+ if (rule && rule.host) claims.push({ host: rule.host, namespace: meta.namespace, name: `ingress ${meta.name}` });
87
+ }
88
+ }
89
+ for (const route of listObjects('ingressroutes.traefik.io', true)) {
90
+ const meta = route.metadata || {};
91
+ for (const r of (route.spec && route.spec.routes) || []) {
92
+ const { hosts, regexps } = parseRule(r && r.match);
93
+ for (const host of hosts) claims.push({ host, namespace: meta.namespace, name: `ingressroute ${meta.name}` });
94
+ for (const pattern of regexps) claims.push({ pattern, namespace: meta.namespace, name: `ingressroute ${meta.name}` });
95
+ }
96
+ }
97
+ for (const claim of claims) {
98
+ if (claim.namespace === cfg.namespace) continue;
99
+ const clash = mine.find((h) => (claim.pattern !== undefined ? regexpMayCover(claim.pattern, h) : covers(claim.host, h)));
100
+ if (clash) {
101
+ const what = claim.pattern !== undefined ? `a HostRegexp pattern (${String(claim.pattern).slice(0, 60)})` : claim.host;
102
+ throw new Error(`The host "${clash}" may already be served by ${claim.namespace}/${claim.name}: ${what}. Pick another "subdomain" or "ingress.hosts" entry in .local/config.yaml.`);
103
+ }
104
+ }
105
+ }
106
+
107
+ module.exports = { assertHostsFree, parseRule };
@@ -8,15 +8,14 @@ const { DOMAIN } = require('./constants');
8
8
 
9
9
  const MARK_START = '# >>> localctl managed block >>>';
10
10
  const MARK_END = '# <<< localctl managed block <<<';
11
- // $LOCALCTL_HOSTS_FILE points it at another file - used by the e2e suite so tests never touch
12
- // (or need sudo for) the real hosts file.
13
- const HOSTS_PATH = process.env.LOCALCTL_HOSTS_FILE ||
14
- (process.platform === 'win32' ? 'C:\\Windows\\System32\\drivers\\etc\\hosts' : '/etc/hosts');
11
+ const HOSTS_PATH =
12
+ process.platform === 'win32' ? 'C:\\Windows\\System32\\drivers\\etc\\hosts' : '/etc/hosts';
15
13
 
16
- function registerApp(subdomain) {
14
+ // Records one or more subdomains with a single read and write of the registry.
15
+ function registerApp(subdomains) {
17
16
  ensureHomeState();
18
17
  const registry = JSON.parse(fs.readFileSync(APPS_REGISTRY_PATH, 'utf8'));
19
- registry[subdomain] = true;
18
+ for (const subdomain of [].concat(subdomains)) registry[subdomain] = true;
20
19
  fs.writeFileSync(APPS_REGISTRY_PATH, JSON.stringify(registry, null, 2));
21
20
  }
22
21
 
@@ -51,8 +50,10 @@ function writeHosts(next, current) {
51
50
  logger.success(`Updated ${HOSTS_PATH}`);
52
51
  } catch (e) {
53
52
  if (e.code !== 'EACCES' && e.code !== 'EPERM') throw e;
54
- const tmp = path.join(os.tmpdir(), 'localctl-hosts-new');
55
- fs.writeFileSync(tmp, next);
53
+ // Private directory: a fixed name in /tmp could be swapped by another user between this write
54
+ // and the `sudo cp` below.
55
+ const tmp = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'localctl-hosts-')), 'hosts');
56
+ fs.writeFileSync(tmp, next, { flag: 'wx', mode: 0o600 });
56
57
  if (process.platform === 'win32') {
57
58
  logger.warn('Need Administrator rights to update the hosts file. Approve the Windows prompt to continue.');
58
59
  if (copyElevatedWindows(tmp) && fs.readFileSync(HOSTS_PATH, 'utf8') === next) {
@@ -0,0 +1,22 @@
1
+ // How to read what kubectl says when a command fails. Matching a loose phrase such as "not found"
2
+ // is dangerous: a Forbidden message naming a missing role, or a missing kubeconfig context, also
3
+ // contains it. These helpers match the exact API error for the exact object.
4
+
5
+ const escapeRegExp = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
6
+
7
+ // "Error from server (NotFound): namespaces "x" not found" (kind is the plural: namespaces, secrets).
8
+ function isNotFound(text, kind, name) {
9
+ return new RegExp(`Error from server \\(NotFound\\): ${escapeRegExp(kind)} "${escapeRegExp(name)}" not found`).test(String(text || ''));
10
+ }
11
+
12
+ // "Error from server (Conflict): ..." / "Error from server (AlreadyExists): ..."
13
+ const isConflict = (text) => /Error from server \((Conflict|AlreadyExists)\)/.test(String(text || ''));
14
+ const isAlreadyExists = (text) => /Error from server \(AlreadyExists\)/.test(String(text || ''));
15
+
16
+ // First line of a message, cut to a sensible size: a hostile or broken kubectl can print megabytes.
17
+ function firstLine(text, max = 300) {
18
+ const line = String(text || '').trim().split('\n')[0];
19
+ return line.length > max ? `${line.slice(0, max)}...` : line;
20
+ }
21
+
22
+ module.exports = { isNotFound, isConflict, isAlreadyExists, firstLine };
@@ -1,25 +1,187 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { appConfigSchema } = require('./configSchema');
3
+ const { loadYaml } = require('./yamlLoad');
4
+ const { appConfigSchema, describeIssues } = require('./configSchema');
5
+ const { readEnvFile } = require('./envFile');
6
+ const { isReservedNamespace } = require('./namespaces');
7
+ const { readRegularFile } = require('./safeRead');
8
+ const { assertPathsInsideProject } = require('./pathGuard');
4
9
  const { CONFIG_PATH } = require('./paths');
5
10
  const { resolveProfileName } = require('./projectContext');
6
11
  const { getProfile, clusterNameFor, namespaceScopeFor } = require('./profileStore');
7
12
 
8
- function loadConfig(configPath = CONFIG_PATH) {
13
+ // YAML turns `PORT: 3000` or `DEBUG: true` into a number or boolean, but environment variables
14
+ // are always strings. Convert scalars instead of rejecting them. (yamlLoad.js already keeps
15
+ // values such as 1.10, 1e3, 0x10, 0777 and dates as text.)
16
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
17
+
18
+ function stringifyEnv(env) {
19
+ if (env === null) return undefined;
20
+ if (!env || typeof env !== 'object' || Array.isArray(env)) return env;
21
+ const out = {};
22
+ for (const [key, value] of Object.entries(env)) {
23
+ if (key === '__proto__') continue; // would change the object's prototype instead of adding a variable
24
+ out[key] = value === null ? '' : ['number', 'boolean'].includes(typeof value) ? String(value) : value;
25
+ }
26
+ return out;
27
+ }
28
+
29
+ const MAX_CONFIG_BYTES = 512 * 1024;
30
+ // Counted in nodes, where a string weighs as many as it has characters, so one long string that is
31
+ // aliased thousands of times can't slip past as "one node each".
32
+ const MAX_EXPANDED_NODES = 2000000;
33
+
34
+ // YAML aliases let a tiny file expand into a huge tree (the "billion laughs" shape), which would
35
+ // make validation burn CPU. Count the size of the tree as it would be expanded, sharing the work
36
+ // for repeated nodes, and refuse documents that are too big or that loop back on themselves.
37
+ function assertReasonableSize(value) {
38
+ const sizes = new Map();
39
+ const inProgress = new Set();
40
+ const size = (node) => {
41
+ if (typeof node === 'string') return Math.max(1, node.length);
42
+ if (node === null || typeof node !== 'object') return 1;
43
+ if (sizes.has(node)) return sizes.get(node);
44
+ if (inProgress.has(node)) throw new Error('Invalid .local/config.yaml: it contains a YAML anchor that refers to itself.');
45
+ inProgress.add(node);
46
+ let total = 1;
47
+ // Keys count by length too: a long key aliased many times is as costly as a long value.
48
+ const children = Array.isArray(node) ? node.map((c) => [0, c]) : Object.entries(node).map(([k, c]) => [k.length, c]);
49
+ for (const [keyWeight, child] of children) {
50
+ total += keyWeight + size(child);
51
+ if (total > MAX_EXPANDED_NODES) {
52
+ throw new Error('Invalid .local/config.yaml: it is far larger than a config should be once YAML anchors/aliases are expanded.');
53
+ }
54
+ }
55
+ inProgress.delete(node);
56
+ sizes.set(node, total);
57
+ return total;
58
+ };
59
+ try {
60
+ size(value);
61
+ } catch (e) {
62
+ if (e instanceof RangeError) throw new Error('Invalid .local/config.yaml: it is nested too deeply.');
63
+ throw e;
64
+ }
65
+ }
66
+
67
+ // Reads and parses the app's YAML config without validating it. Used by loadConfig and by
68
+ // commands that only need a field or two (like the app name).
69
+ function readRawConfig(configPath = CONFIG_PATH) {
9
70
  if (!fs.existsSync(configPath)) {
71
+ const legacy = path.join(path.dirname(configPath), 'config.json');
72
+ if (fs.existsSync(legacy)) {
73
+ throw new Error(
74
+ `Found ${legacy}, but localctl now reads .local/config.yaml. Convert it (JSON is valid ` +
75
+ 'YAML, so renaming the file to config.yaml works as a first step) and delete the old file.',
76
+ );
77
+ }
10
78
  throw new Error(`No config found at ${configPath}. Run "localctl app new" first.`);
11
79
  }
12
- const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
80
+ // The .local folder itself must be a real folder too, not a link to a config somewhere else.
81
+ try {
82
+ if (fs.lstatSync(path.dirname(configPath)).isSymbolicLink()) throw new Error('Invalid .local/config.yaml: the .local folder is a symbolic link; use a real folder.');
83
+ } catch (e) {
84
+ if (e.code !== 'ENOENT') throw e;
85
+ }
86
+ let raw;
87
+ try {
88
+ raw = loadYaml(readRegularFile(configPath, MAX_CONFIG_BYTES, '.local/config.yaml'));
89
+ } catch (e) {
90
+ if (e.name !== 'YAMLException') throw new Error(`Invalid .local/config.yaml: ${e.message}`);
91
+ throw new Error(`Invalid .local/config.yaml (not valid YAML):\n ${e.message.split('\n')[0]}`);
92
+ }
93
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
94
+ throw new Error('Invalid .local/config.yaml: expected a mapping of settings at the top level.');
95
+ }
96
+ assertReasonableSize(raw);
97
+ // A key left empty in YAML (`env:`) is null; treat it as not set so the schema defaults apply.
98
+ for (const key of Object.keys(raw)) {
99
+ if (raw[key] === null) delete raw[key];
100
+ }
101
+ raw.env = stringifyEnv(raw.env);
102
+ for (const list of ['sidecars', 'initContainers']) {
103
+ if (Array.isArray(raw[list])) {
104
+ raw[list] = raw[list].map((c) => (c && typeof c === 'object' ? { ...c, env: stringifyEnv(c.env) } : c));
105
+ }
106
+ }
107
+ return raw;
108
+ }
109
+
110
+ // Fields an environment can't override: they identify the app and where it lives, which other
111
+ // commands (down, secrets, status) resolve without knowing about environments.
112
+ const LOCKED_IN_ENVIRONMENT = ['name', 'namespace', 'environments', 'envFile'];
113
+
114
+ function isPlainObject(value) {
115
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
116
+ }
117
+
118
+ // Objects merge key by key, everything else (lists, scalars) is replaced.
119
+ function mergeConfig(base, overlay) {
120
+ const out = { ...base };
121
+ for (const [key, value] of Object.entries(overlay)) {
122
+ if (UNSAFE_KEYS.has(key)) continue;
123
+ out[key] = isPlainObject(value) && isPlainObject(out[key]) ? mergeConfig(out[key], value) : value;
124
+ }
125
+ return out;
126
+ }
127
+
128
+ // Applies the named entry of `environments` over the rest of the file (or none when no name is
129
+ // given) and removes the `environments` block itself. The name comes from `localctl app up --env`
130
+ // via LOCALCTL_ENV and can only pick an entry that the file itself defines.
131
+ function applyEnvironment(raw, envName = process.env.LOCALCTL_ENV) {
132
+ const { environments, ...base } = raw;
133
+ const available = isPlainObject(environments) ? Object.keys(environments) : [];
134
+ // Names are checked even when no environment is selected, so a bad one fails early.
135
+ const badName = available.find((n) => !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(n) || n.length > 63);
136
+ if (badName !== undefined) throw new Error(`Environment name "${badName}" must be lowercase letters, digits and dashes (like a Kubernetes name).`);
137
+ if (!envName) return base;
138
+ if (!available.includes(envName)) {
139
+ throw new Error(
140
+ `Unknown environment ${JSON.stringify(String(envName).slice(0, 80))}. ` +
141
+ (available.length ? `This app defines: ${available.join(', ')}.` : 'This app\'s config has no "environments" block.'),
142
+ );
143
+ }
144
+ const overlay = environments[envName];
145
+ if (!isPlainObject(overlay)) throw new Error(`Environment "${envName}" must be a mapping of settings.`);
146
+ const unsafe = Object.keys(overlay).find((k) => UNSAFE_KEYS.has(k));
147
+ if (unsafe !== undefined) throw new Error(`Environment "${envName}" uses the reserved key "${unsafe}".`);
148
+ const locked = LOCKED_IN_ENVIRONMENT.filter((k) => k in overlay);
149
+ if (locked.length) {
150
+ throw new Error(`Environment "${envName}" can't override ${locked.map((k) => `"${k}"`).join(', ')}.`);
151
+ }
152
+ return mergeConfig(base, readEnvOverlay(overlay));
153
+ }
154
+
155
+ // Same clean-up readRawConfig does for the base file, applied to the overlay.
156
+ function readEnvOverlay(overlay) {
157
+ const out = { ...overlay };
158
+ for (const key of Object.keys(out)) {
159
+ if (out[key] === null) delete out[key];
160
+ }
161
+ if ('env' in out) out.env = stringifyEnv(out.env);
162
+ for (const list of ['sidecars', 'initContainers']) {
163
+ if (Array.isArray(out[list])) {
164
+ out[list] = out[list].map((c) => (c && typeof c === 'object' ? { ...c, env: stringifyEnv(c.env) } : c));
165
+ }
166
+ }
167
+ return out;
168
+ }
169
+
170
+ function loadConfig(configPath = CONFIG_PATH) {
171
+ const raw = applyEnvironment(readRawConfig(configPath));
172
+ // envFile values go under the explicit `env` keys, so the YAML always wins over the file.
173
+ if (typeof raw.envFile === 'string') {
174
+ raw.env = { ...readEnvFile(path.dirname(path.dirname(configPath)), raw.envFile), ...(raw.env || {}) };
175
+ }
13
176
  const parsed = appConfigSchema.safeParse(raw);
14
177
  if (!parsed.success) {
15
- const issues = parsed.error.issues
16
- .map((i) => ` - ${i.path.join('.') || '(root)'}: ${i.message}`)
17
- .join('\n');
18
- throw new Error(`Invalid .local/config.json:\n${issues}`);
178
+ throw new Error(`Invalid .local/config.yaml:\n${describeIssues(parsed.error.issues)}`);
19
179
  }
20
180
  const cfg = parsed.data;
181
+ assertPathsInsideProject(cfg, path.dirname(path.dirname(configPath)));
21
182
  cfg.namespace = cfg.namespace || cfg.name;
22
183
  cfg.subdomain = cfg.subdomain || cfg.name;
184
+ cfg.hosts = [cfg.subdomain, ...cfg.ingress.hosts];
23
185
 
24
186
  // Which project this app belongs to - walking up from the app's own directory for the nearest
25
187
  // .local/project.json, same lookup as git finding .git (see projectContext.js). No
@@ -40,7 +202,19 @@ function loadConfig(configPath = CONFIG_PATH) {
40
202
  const scope = namespaceScopeFor(profileName);
41
203
  if (scope) {
42
204
  cfg.namespace = `${scope}-${cfg.namespace}`;
43
- cfg.subdomain = `${scope}-${cfg.subdomain}`;
205
+ cfg.hosts = cfg.hosts.map((h) => `${scope}-${h}`);
206
+ cfg.subdomain = cfg.hosts[0];
207
+ }
208
+ // The checks in the schema ran before the project prefix was added, so repeat the ones that
209
+ // depend on the final names (a prefix could turn `system` into `kube-system`, or push a name
210
+ // past the 63-character limit).
211
+ if (isReservedNamespace(cfg.namespace)) {
212
+ throw new Error(`The namespace "${cfg.namespace}" is reserved for Kubernetes and localctl itself; choose another "namespace" or "name".`);
213
+ }
214
+ for (const label of [cfg.namespace, ...cfg.hosts]) {
215
+ if (label.length > 63) {
216
+ throw new Error(`"${label}" is longer than 63 characters once the project prefix "${scope}-" is added; shorten the app name, namespace or hosts.`);
217
+ }
44
218
  }
45
219
 
46
220
  cfg.profile = profileName;
@@ -48,4 +222,4 @@ function loadConfig(configPath = CONFIG_PATH) {
48
222
  return cfg;
49
223
  }
50
224
 
51
- module.exports = { loadConfig };
225
+ module.exports = { loadConfig, readRawConfig, applyEnvironment };
@@ -5,18 +5,28 @@ const { findAddonDir, listAddonNames } = require('./addonDirs');
5
5
  const { secretNameFor } = require('./appSecrets');
6
6
 
7
7
  function buildNamespace(cfg) {
8
- return { apiVersion: 'v1', kind: 'Namespace', metadata: { name: cfg.namespace } };
8
+ return {
9
+ apiVersion: 'v1',
10
+ kind: 'Namespace',
11
+ metadata: {
12
+ name: cfg.namespace,
13
+ labels: { 'localctl.dev/managed': 'true' },
14
+ annotations: { 'localctl.dev/app': cfg.name },
15
+ },
16
+ };
9
17
  }
10
18
 
11
19
  // A sidecar - an extra container sharing the pod's network namespace with the main app container.
12
20
  // No Service needed for the app to reach it: same pod means `localhost:<port>` already works.
13
- function buildSidecarContainer(sidecar) {
21
+ function buildSidecarContainer(sidecar, volumeMounts = []) {
14
22
  const container = { name: sidecar.name, image: sidecar.image };
15
23
  if (sidecar.command) container.command = sidecar.command;
16
24
  if (sidecar.args) container.args = sidecar.args;
17
- if (sidecar.port) container.ports = [{ name: sidecar.name, containerPort: sidecar.port }];
25
+ // No port name: names are limited to 15 characters and must be unique across the pod.
26
+ if (sidecar.port) container.ports = [{ containerPort: sidecar.port }];
18
27
  const envVars = Object.entries(sidecar.env || {}).map(([name, value]) => ({ name, value: String(value) }));
19
28
  if (envVars.length) container.env = envVars;
29
+ if (volumeMounts.length) container.volumeMounts = volumeMounts;
20
30
  return container;
21
31
  }
22
32
 
@@ -25,14 +35,17 @@ function buildSidecarContainer(sidecar) {
25
35
  // started," not "it's actually accepting connections." An explicit `path` upgrades it to an HTTP
26
36
  // check instead.
27
37
  function buildProbe(probeCfg, port, defaults) {
28
- const check = probeCfg && probeCfg.path
29
- ? { httpGet: { path: probeCfg.path, port } }
30
- : { tcpSocket: { port } };
31
- return {
38
+ let check = { tcpSocket: { port } };
39
+ if (probeCfg && probeCfg.path) check = { httpGet: { path: probeCfg.path, port } };
40
+ if (probeCfg && probeCfg.command) check = { exec: { command: probeCfg.command } };
41
+ const probe = {
32
42
  ...check,
33
43
  initialDelaySeconds: (probeCfg && probeCfg.initialDelaySeconds) ?? defaults.initialDelaySeconds,
34
44
  periodSeconds: (probeCfg && probeCfg.periodSeconds) ?? defaults.periodSeconds,
35
45
  };
46
+ if (probeCfg && probeCfg.timeoutSeconds) probe.timeoutSeconds = probeCfg.timeoutSeconds;
47
+ if (probeCfg && probeCfg.failureThreshold) probe.failureThreshold = probeCfg.failureThreshold;
48
+ return probe;
36
49
  }
37
50
 
38
51
  // Blocks the main container from starting until a dependency's real Service is actually accepting
@@ -42,13 +55,45 @@ function buildWaitInitContainer(depType, connect) {
42
55
  return {
43
56
  name: `wait-for-${depType}`,
44
57
  image: 'busybox:1.36',
45
- command: ['sh', '-c', `until nc -z -w1 ${connect.host} ${connect.port}; do echo "waiting for ${depType} (${connect.host}:${connect.port})..."; sleep 1; done`],
58
+ // host and port are passed as arguments ($0, $1, $2), never spliced into the script text.
59
+ command: ['sh', '-c', 'until nc -z -w1 "$0" "$1"; do echo "waiting for $2 ($0:$1)..."; sleep 1; done', String(connect.host), String(connect.port), depType],
46
60
  };
47
61
  }
48
62
 
63
+ // An init container the developer declared (migrations, seeding). Runs in the app's own image
64
+ // unless another is given; Tilt swaps the app image for the freshly built one like it does for the
65
+ // main container.
66
+ function buildUserInitContainer(cfg, init, volumeMounts) {
67
+ const container = { name: init.name, image: init.image || cfg.name };
68
+ if (init.command) container.command = init.command;
69
+ if (init.args) container.args = init.args;
70
+ const envVars = Object.entries(init.env || {}).map(([name, value]) => ({ name, value: String(value) }));
71
+ if (envVars.length) container.env = envVars;
72
+ if (volumeMounts.length) container.volumeMounts = volumeMounts;
73
+ return container;
74
+ }
75
+
49
76
  function buildDeployment(cfg, envVars, initContainers) {
50
77
  const ports = [{ name: 'http', containerPort: cfg.port }];
51
78
  if (cfg.debug.enabled && cfg.debug.port) ports.push({ name: 'debug', containerPort: cfg.debug.port });
79
+ for (const extra of cfg.ports) {
80
+ ports.push({ name: portName(extra), containerPort: extra.port, protocol: extra.protocol });
81
+ }
82
+
83
+ const volumeMounts = cfg.volumes.map((v) => ({ name: v.name, mountPath: v.mountPath }));
84
+ const allInitContainers = [
85
+ ...cfg.initContainers.map((init) => buildUserInitContainer(cfg, init, volumeMounts)),
86
+ ...initContainers,
87
+ ];
88
+ const labels = { ...cfg.labels, app: cfg.name };
89
+ const probes = {};
90
+ probes.readinessProbe = buildProbe(cfg.probes.readiness, cfg.port, { initialDelaySeconds: 3, periodSeconds: 10 });
91
+ probes.livenessProbe = buildProbe(cfg.probes.liveness, cfg.port, { initialDelaySeconds: 10, periodSeconds: 20 });
92
+ if (cfg.probes.startup) probes.startupProbe = buildProbe(cfg.probes.startup, cfg.port, { initialDelaySeconds: 0, periodSeconds: 5 });
93
+
94
+ const podAnnotations = Object.keys(cfg.annotations).length
95
+ ? Object.fromEntries(Object.entries(cfg.annotations).map(([k, v]) => [k, String(v)]))
96
+ : undefined;
52
97
 
53
98
  return {
54
99
  apiVersion: 'apps/v1',
@@ -56,29 +101,40 @@ function buildDeployment(cfg, envVars, initContainers) {
56
101
  metadata: {
57
102
  name: cfg.name,
58
103
  namespace: cfg.namespace,
59
- labels: { app: cfg.name, 'localctl.dev/managed': 'true' },
104
+ labels: { ...labels, 'localctl.dev/managed': 'true' },
60
105
  },
61
106
  spec: {
62
107
  replicas: cfg.replicas,
63
108
  selector: { matchLabels: { app: cfg.name } },
64
109
  template: {
65
- metadata: { labels: { app: cfg.name } },
110
+ metadata: { labels, annotations: podAnnotations },
66
111
  spec: {
67
- initContainers: initContainers.length ? initContainers : undefined,
112
+ terminationGracePeriodSeconds: cfg.terminationGracePeriodSeconds,
113
+ initContainers: allInitContainers.length ? allInitContainers : undefined,
114
+ volumes: cfg.volumes.length
115
+ ? cfg.volumes.map((v) => ({
116
+ name: v.name,
117
+ emptyDir: v.sizeLimit ? { sizeLimit: v.sizeLimit } : {},
118
+ }))
119
+ : undefined,
68
120
  containers: [
69
121
  {
70
122
  name: cfg.name,
71
123
  // Rewritten to the local registry by Tilt's default_registry()/docker_build().
72
124
  image: cfg.name,
125
+ imagePullPolicy: cfg.imagePullPolicy,
126
+ command: cfg.command,
127
+ args: cfg.args,
73
128
  ports,
74
129
  env: envVars,
75
- readinessProbe: buildProbe(cfg.probes.readiness, cfg.port, { initialDelaySeconds: 3, periodSeconds: 10 }),
76
- livenessProbe: buildProbe(cfg.probes.liveness, cfg.port, { initialDelaySeconds: 10, periodSeconds: 20 }),
130
+ volumeMounts: volumeMounts.length ? volumeMounts : undefined,
131
+ securityContext: cfg.securityContext,
132
+ ...probes,
77
133
  resources: cfg.resources
78
134
  ? { requests: cfg.resources.requests, limits: cfg.resources.limits }
79
135
  : undefined,
80
136
  },
81
- ...cfg.sidecars.map(buildSidecarContainer),
137
+ ...cfg.sidecars.map((s) => buildSidecarContainer(s, volumeMounts)),
82
138
  ],
83
139
  },
84
140
  },
@@ -86,11 +142,19 @@ function buildDeployment(cfg, envVars, initContainers) {
86
142
  };
87
143
  }
88
144
 
145
+ // Port names are limited to 15 characters; unnamed ports get "p<number>".
146
+ function portName(extra) {
147
+ return extra.name || (extra.protocol === 'UDP' ? `p${extra.port}-udp` : `p${extra.port}`);
148
+ }
149
+
89
150
  function buildService(cfg) {
90
151
  const ports = [{ name: 'http', port: cfg.port, targetPort: cfg.port }];
91
152
  if (cfg.debug.enabled && cfg.debug.port) {
92
153
  ports.push({ name: 'debug', port: cfg.debug.port, targetPort: cfg.debug.port });
93
154
  }
155
+ for (const extra of cfg.ports) {
156
+ ports.push({ name: portName(extra), port: extra.port, targetPort: extra.port, protocol: extra.protocol });
157
+ }
94
158
  return {
95
159
  apiVersion: 'v1',
96
160
  kind: 'Service',
@@ -104,7 +168,12 @@ function buildService(cfg) {
104
168
  }
105
169
 
106
170
  function buildIngress(cfg) {
107
- const host = `${cfg.subdomain}.${DOMAIN}`;
171
+ const hosts = (cfg.hosts || [cfg.subdomain]).map((h) => `${h}.${DOMAIN}`);
172
+ const paths = (cfg.ingress.paths.length ? cfg.ingress.paths : [{ path: '/' }]).map((p) => ({
173
+ path: p.path,
174
+ pathType: 'Prefix',
175
+ backend: { service: { name: cfg.name, port: { number: p.port || cfg.port } } },
176
+ }));
108
177
  const ingress = {
109
178
  apiVersion: 'networking.k8s.io/v1',
110
179
  kind: 'Ingress',
@@ -113,29 +182,18 @@ function buildIngress(cfg) {
113
182
  namespace: cfg.namespace,
114
183
  labels: { app: cfg.name, 'localctl.dev/managed': 'true' },
115
184
  annotations: {
185
+ ...Object.fromEntries(Object.entries(cfg.ingress.annotations).map(([k, v]) => [k, String(v)])),
186
+ // Set last: whether the route is HTTPS follows `tls`, not an annotation.
116
187
  'traefik.ingress.kubernetes.io/router.entrypoints': cfg.tls ? 'websecure' : 'web',
117
188
  },
118
189
  },
119
190
  spec: {
120
- rules: [
121
- {
122
- host,
123
- http: {
124
- paths: [
125
- {
126
- path: '/',
127
- pathType: 'Prefix',
128
- backend: { service: { name: cfg.name, port: { number: cfg.port } } },
129
- },
130
- ],
131
- },
132
- },
133
- ],
191
+ rules: hosts.map((host) => ({ host, http: { paths } })),
134
192
  },
135
193
  };
136
194
  // Empty secretName: Traefik falls back to the cluster's default TLSStore certificate
137
195
  // (the *.local.test cert installed by bin/bootstrap.sh / bootstrap.ps1).
138
- if (cfg.tls) ingress.spec.tls = [{ hosts: [host] }];
196
+ if (cfg.tls) ingress.spec.tls = [{ hosts }];
139
197
  return ingress;
140
198
  }
141
199
 
@@ -170,6 +228,7 @@ function generateManifests(cfg) {
170
228
  addonName: dep.type,
171
229
  owner: cfg.name,
172
230
  chartVersionOverride: dep.version,
231
+ resources: dep.resources,
173
232
  extraVars: {
174
233
  database: dep.database || cfg.name,
175
234
  storage: dep.storage || '1Gi',
@@ -191,6 +250,11 @@ function generateManifests(cfg) {
191
250
  secretEnv[key] = { secret: secretNameFor(cfg), key };
192
251
  }
193
252
 
253
+ // One definition per variable name: a value the developer wrote in `env` beats an addon's
254
+ // secretKeyRef of the same name, and a name declared in `secrets` beats an addon's plain value.
255
+ for (const key of Object.keys(cfg.env)) delete secretEnv[key];
256
+ for (const key of cfg.secrets) delete literalEnv[key];
257
+
194
258
  const envVars = [
195
259
  ...Object.entries(literalEnv).map(([name, value]) => ({ name, value: String(value) })),
196
260
  ...Object.entries(secretEnv).map(([name, ref]) => ({
@@ -200,11 +264,11 @@ function generateManifests(cfg) {
200
264
  ];
201
265
 
202
266
  const docs = [buildNamespace(cfg), buildDeployment(cfg, envVars, initContainers), buildService(cfg), buildIngress(cfg)];
203
- const rendered = docs.map((d) => yaml.dump(d)).join('---\n');
267
+ const rendered = docs.map((d) => yaml.dump(d, { noRefs: true })).join('---\n');
204
268
  // trimEnd() before joining: addon YAML doesn't reliably end with a trailing newline (see
205
269
  // addonLoader.js), and joining on a bare '---\n' in that case glues the separator directly onto
206
270
  // the previous piece's last line with no line break, corrupting the YAML.
207
271
  return [rendered, ...addonYamls].map((doc) => doc.trimEnd()).join('\n---\n');
208
272
  }
209
273
 
210
- module.exports = { generateManifests, knownAddonTypes };
274
+ module.exports = { generateManifests, knownAddonTypes, buildWaitInitContainer };