@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
|
@@ -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 };
|
package/src/lib/hostsFile.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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 };
|
package/src/lib/loadConfig.js
CHANGED
|
@@ -1,25 +1,187 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 };
|
package/src/lib/manifestGen.js
CHANGED
|
@@ -5,18 +5,28 @@ const { findAddonDir, listAddonNames } = require('./addonDirs');
|
|
|
5
5
|
const { secretNameFor } = require('./appSecrets');
|
|
6
6
|
|
|
7
7
|
function buildNamespace(cfg) {
|
|
8
|
-
return {
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
|
110
|
+
metadata: { labels, annotations: podAnnotations },
|
|
66
111
|
spec: {
|
|
67
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
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
|
|
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 };
|