@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,83 @@
|
|
|
1
|
+
const { capture } = require('./exec');
|
|
2
|
+
const { isNotFound, isAlreadyExists, firstLine } = require('./kubeErrors');
|
|
3
|
+
|
|
4
|
+
const MANAGED_LABEL = 'localctl.dev/managed';
|
|
5
|
+
const APP_ANNOTATION = 'localctl.dev/app';
|
|
6
|
+
|
|
7
|
+
// Namespaces a config may never claim: Kubernetes' own, and the ones localctl's addons use.
|
|
8
|
+
const RESERVED_NAMESPACES = ['default', 'kube-system', 'kube-public', 'kube-node-lease', 'observability', 'cert-manager', 'traefik', 'ingress-nginx', 'localctl'];
|
|
9
|
+
|
|
10
|
+
// Also anything starting with kube-, and the common cluster add-ons, which a config has no business
|
|
11
|
+
// deploying into.
|
|
12
|
+
const RESERVED_PATTERN = /^kube-/;
|
|
13
|
+
const isReservedNamespace = (ns) => RESERVED_NAMESPACES.includes(ns) || RESERVED_PATTERN.test(ns);
|
|
14
|
+
|
|
15
|
+
// Creates a namespace that is marked as managed by localctl and records which app owns it, in one
|
|
16
|
+
// request, so there is no moment where it exists without an owner. Does nothing when it already
|
|
17
|
+
// exists: assertNamespaceOwnership decides whether that one may be used.
|
|
18
|
+
function ensureManagedNamespace(namespace, owner) {
|
|
19
|
+
const existing = capture('kubectl', ['get', 'namespace', namespace, '--request-timeout=20s']);
|
|
20
|
+
if (existing.status === 0) return;
|
|
21
|
+
if (!isNotFound(existing.stderr, 'namespaces', namespace)) {
|
|
22
|
+
throw new Error(`Couldn't check namespace "${namespace}": ${firstLine(existing.stderr || existing.stdout || 'kubectl failed')}`);
|
|
23
|
+
}
|
|
24
|
+
const manifest = {
|
|
25
|
+
apiVersion: 'v1',
|
|
26
|
+
kind: 'Namespace',
|
|
27
|
+
metadata: {
|
|
28
|
+
name: namespace,
|
|
29
|
+
labels: { [MANAGED_LABEL]: 'true' },
|
|
30
|
+
annotations: owner ? { [APP_ANNOTATION]: owner } : undefined,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
// `create` (not apply) fails if someone created it a moment ago instead of silently adopting it.
|
|
34
|
+
const created = capture('kubectl', ['create', '-f', '-'], { input: JSON.stringify(manifest) });
|
|
35
|
+
if (created.status !== 0 && !isAlreadyExists(created.stderr)) {
|
|
36
|
+
throw new Error(`Couldn't create namespace "${namespace}": ${firstLine(created.stderr || created.stdout || 'kubectl failed')}`);
|
|
37
|
+
}
|
|
38
|
+
// If someone else created it first, what is there must be ours before anything goes into it.
|
|
39
|
+
if (owner) assertNamespaceOwnership({ name: owner, namespace });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Refuses to touch a namespace that exists but was not created by localctl for this app. Without
|
|
43
|
+
// this, a config that names `namespace: shop` could make `app up` overwrite objects in someone
|
|
44
|
+
// else's namespace and `app down` or `app prune` delete it. A namespace that doesn't exist yet is
|
|
45
|
+
// fine: it gets created, marked and annotated on first deploy. Any answer other than "found" or
|
|
46
|
+
// "not found" (API unreachable, no permission, odd output) is an error, never a pass.
|
|
47
|
+
function assertNamespaceOwnership(cfg) {
|
|
48
|
+
const res = capture('kubectl', ['get', 'namespace', cfg.namespace, '-o', 'json', '--request-timeout=20s']);
|
|
49
|
+
if (res.status !== 0) {
|
|
50
|
+
if (isNotFound(res.stderr, 'namespaces', cfg.namespace)) return;
|
|
51
|
+
throw new Error(`Couldn't check whether namespace "${cfg.namespace}" is yours: ${firstLine(res.stderr || res.stdout || 'kubectl failed')}`);
|
|
52
|
+
}
|
|
53
|
+
let meta;
|
|
54
|
+
try {
|
|
55
|
+
meta = JSON.parse(res.stdout).metadata;
|
|
56
|
+
} catch (e) {
|
|
57
|
+
meta = null;
|
|
58
|
+
}
|
|
59
|
+
if (!meta || typeof meta !== 'object') {
|
|
60
|
+
throw new Error(`Couldn't read namespace "${cfg.namespace}" from the cluster, so it won't be touched.`);
|
|
61
|
+
}
|
|
62
|
+
if (meta.deletionTimestamp) {
|
|
63
|
+
throw new Error(`Namespace "${cfg.namespace}" is being deleted; wait a moment and try again.`);
|
|
64
|
+
}
|
|
65
|
+
const labels = meta.labels || {};
|
|
66
|
+
const owner = (meta.annotations || {})[APP_ANNOTATION];
|
|
67
|
+
if (labels[MANAGED_LABEL] !== 'true') {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Namespace "${cfg.namespace}" already exists and wasn't created by localctl, so it won't be touched. ` +
|
|
70
|
+
`Pick another "namespace" in .local/config.yaml, or if it is yours: kubectl label namespace ${cfg.namespace} ${MANAGED_LABEL}=true && ` +
|
|
71
|
+
`kubectl annotate namespace ${cfg.namespace} ${APP_ANNOTATION}=${cfg.name}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (owner !== cfg.name) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
owner
|
|
77
|
+
? `Namespace "${cfg.namespace}" belongs to the app "${owner}", not "${cfg.name}". Use a different "namespace" so the two apps stay apart.`
|
|
78
|
+
: `Namespace "${cfg.namespace}" is managed by localctl but has no owner recorded. Claim it with: kubectl annotate namespace ${cfg.namespace} ${APP_ANNOTATION}=${cfg.name}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { ensureManagedNamespace, assertNamespaceOwnership, isReservedNamespace, RESERVED_NAMESPACES, MANAGED_LABEL, APP_ANNOTATION };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
// The folder a config's relative paths may reach: the nearest ancestor of the app that holds a
|
|
6
|
+
// `.git`, so a monorepo can use `context: ..` for a shared folder, but never leave the repository.
|
|
7
|
+
// Without a git repository it is the app folder itself. A `.git` in the home directory (a dotfiles
|
|
8
|
+
// repo) is not a project root, or every app under $HOME would be allowed to reach all of it.
|
|
9
|
+
function projectRoot(appDir) {
|
|
10
|
+
const app = fs.realpathSync(appDir);
|
|
11
|
+
let home = null;
|
|
12
|
+
try { home = fs.realpathSync(os.homedir()); } catch (e) { /* no home: nothing to exclude */ }
|
|
13
|
+
let dir = app;
|
|
14
|
+
for (let i = 0; i < 12; i += 1) {
|
|
15
|
+
if (dir === home) break;
|
|
16
|
+
if (fs.existsSync(path.join(dir, '.git'))) return dir;
|
|
17
|
+
const parent = path.dirname(dir);
|
|
18
|
+
if (parent === dir) break;
|
|
19
|
+
dir = parent;
|
|
20
|
+
}
|
|
21
|
+
return app;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isInside(root, target) {
|
|
25
|
+
const rel = path.relative(root, target);
|
|
26
|
+
return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Resolves `value` from `base` the way the filesystem would: every symlink is followed as it is
|
|
30
|
+
// met, so `link/..` goes to the parent of the link's TARGET (not of the link), and a path that
|
|
31
|
+
// does not exist yet is judged by its deepest existing ancestor. A dangling symlink is an error.
|
|
32
|
+
function physicalResolve(base, value) {
|
|
33
|
+
let current = fs.realpathSync(base);
|
|
34
|
+
// On POSIX a backslash is an ordinary character in a file name, so only `/` separates there.
|
|
35
|
+
for (const segment of value.split(path.sep === '\\' ? /[\\/]+/ : /\/+/)) {
|
|
36
|
+
if (segment === '' || segment === '.') continue;
|
|
37
|
+
if (segment === '..') {
|
|
38
|
+
current = path.dirname(current);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const next = path.join(current, segment);
|
|
42
|
+
let info = null;
|
|
43
|
+
try { info = fs.lstatSync(next); } catch (e) { info = null; }
|
|
44
|
+
if (!info) {
|
|
45
|
+
current = next; // does not exist yet: keep going lexically from here
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
current = fs.realpathSync(next);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
throw new Error(`"${value}" goes through a symbolic link that points nowhere (${segment}).`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return current;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// build.context, build.dockerfile and sync[].localPath are resolved from the app folder and must
|
|
58
|
+
// stay inside the project. `..` is allowed only as far as the repository root.
|
|
59
|
+
function assertPathsInsideProject(cfg, appDir) {
|
|
60
|
+
const root = projectRoot(appDir);
|
|
61
|
+
const entries = [
|
|
62
|
+
['build.context', cfg.build.context],
|
|
63
|
+
['build.dockerfile', cfg.build.dockerfile],
|
|
64
|
+
...cfg.sync.map((s, i) => [`sync.${i}.localPath`, s.localPath]),
|
|
65
|
+
];
|
|
66
|
+
for (const [field, value] of entries) {
|
|
67
|
+
let resolved;
|
|
68
|
+
try {
|
|
69
|
+
resolved = physicalResolve(appDir, value);
|
|
70
|
+
} catch (e) {
|
|
71
|
+
throw new Error(`${field}: ${e.message}`);
|
|
72
|
+
}
|
|
73
|
+
if (!isInside(root, resolved)) {
|
|
74
|
+
throw new Error(`${field} "${value}" points outside the project (${root}). Paths can go up with ".." only as far as the repository root.`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { assertPathsInsideProject, projectRoot };
|
package/src/lib/paths.js
CHANGED
|
@@ -4,11 +4,8 @@ const fs = require('fs');
|
|
|
4
4
|
|
|
5
5
|
const APP_DIR = process.cwd();
|
|
6
6
|
const LOCAL_DIR = path.join(APP_DIR, '.local');
|
|
7
|
-
const CONFIG_PATH = path.join(LOCAL_DIR, 'config.
|
|
8
|
-
|
|
9
|
-
// suite uses it to run against a throwaway directory instead of your real ~/.localctl.
|
|
10
|
-
const HOME_STATE_DIR = process.env.LOCALCTL_HOME || path.join(os.homedir(), '.localctl');
|
|
11
|
-
const CERTS_DIR = path.join(HOME_STATE_DIR, 'certs');
|
|
7
|
+
const CONFIG_PATH = path.join(LOCAL_DIR, 'config.yaml');
|
|
8
|
+
const HOME_STATE_DIR = path.join(os.homedir(), '.localctl');
|
|
12
9
|
const APPS_REGISTRY_PATH = path.join(HOME_STATE_DIR, 'apps.json');
|
|
13
10
|
const PROFILES_PATH = path.join(HOME_STATE_DIR, 'profiles.json');
|
|
14
11
|
const RUN_DIR = path.join(HOME_STATE_DIR, 'run');
|
|
@@ -36,7 +33,6 @@ module.exports = {
|
|
|
36
33
|
LOCAL_DIR,
|
|
37
34
|
CONFIG_PATH,
|
|
38
35
|
HOME_STATE_DIR,
|
|
39
|
-
CERTS_DIR,
|
|
40
36
|
APPS_REGISTRY_PATH,
|
|
41
37
|
PROFILES_PATH,
|
|
42
38
|
RUN_DIR,
|
package/src/lib/podman.js
CHANGED
|
@@ -1,46 +1,8 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const os = require('os');
|
|
3
|
-
const path = require('path');
|
|
4
1
|
const { run, capture } = require('./exec');
|
|
5
2
|
const logger = require('./logger');
|
|
6
3
|
|
|
7
4
|
const CONF_PATH = '/etc/containers/registries.conf.d/999-local-dev.conf';
|
|
8
5
|
|
|
9
|
-
// Native Podman (Linux) has no VM: its registries.conf lives on the host. Rootless Podman reads
|
|
10
|
-
// the per-user drop-in directory (no sudo needed); root reads the system one.
|
|
11
|
-
function nativeConfPath() {
|
|
12
|
-
if (typeof process.getuid === 'function' && process.getuid() === 0) return CONF_PATH;
|
|
13
|
-
const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
14
|
-
return path.join(configHome, 'containers', 'registries.conf.d', '999-local-dev.conf');
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
// Linux native Podman: no `podman machine`. Containers-image re-reads registries.conf on every
|
|
18
|
-
// invocation, so unlike the VM case there's nothing to restart.
|
|
19
|
-
function ensureInsecureRegistryNative(hostPort) {
|
|
20
|
-
const file = nativeConfPath();
|
|
21
|
-
const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
22
|
-
if (current.includes(`location = "localhost:${hostPort}"`)) {
|
|
23
|
-
logger.success(`Podman already trusts localhost:${hostPort} as an insecure registry`);
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
logger.step(`Marking localhost:${hostPort} as an insecure registry for Podman (${file})...`);
|
|
27
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
28
|
-
const next = `${current.trimEnd()}\n\n${registryBlock(hostPort)}`.trim().concat('\n');
|
|
29
|
-
fs.writeFileSync(file, next);
|
|
30
|
-
logger.success('Podman registry config updated');
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function teardownInsecureRegistryNative() {
|
|
34
|
-
const file = nativeConfPath();
|
|
35
|
-
if (!fs.existsSync(file)) return;
|
|
36
|
-
fs.rmSync(file, { force: true });
|
|
37
|
-
logger.success(`Removed insecure-registry config ${file}`);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function isNativePodman() {
|
|
41
|
-
return process.platform === 'linux';
|
|
42
|
-
}
|
|
43
|
-
|
|
44
6
|
function machineName() {
|
|
45
7
|
const res = capture('podman', ['machine', 'list', '--format', '{{.Name}}']);
|
|
46
8
|
const [first] = res.stdout.split('\n').filter(Boolean);
|
|
@@ -72,10 +34,7 @@ function registryBlock(hostPort) {
|
|
|
72
34
|
// something tried to use it. One file, one block per port ever provisioned on this machine.
|
|
73
35
|
function ensureInsecureRegistry(hostPort) {
|
|
74
36
|
const machine = machineName();
|
|
75
|
-
if (!machine)
|
|
76
|
-
if (isNativePodman()) ensureInsecureRegistryNative(hostPort);
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
37
|
+
if (!machine) return;
|
|
79
38
|
|
|
80
39
|
const current = capture('podman', ['machine', 'ssh', machine, `cat ${CONF_PATH} 2>/dev/null`]).stdout;
|
|
81
40
|
if (current.includes(`location = "localhost:${hostPort}"`)) {
|
|
@@ -98,10 +57,7 @@ function ensureInsecureRegistry(hostPort) {
|
|
|
98
57
|
|
|
99
58
|
function teardownInsecureRegistry() {
|
|
100
59
|
const machine = machineName();
|
|
101
|
-
if (!machine)
|
|
102
|
-
if (isNativePodman()) teardownInsecureRegistryNative();
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
60
|
+
if (!machine) return;
|
|
105
61
|
|
|
106
62
|
const exists = capture('podman', ['machine', 'ssh', machine, `test -f ${CONF_PATH} && echo yes`]).stdout;
|
|
107
63
|
if (exists.trim() !== 'yes') return;
|
|
@@ -113,50 +69,4 @@ function teardownInsecureRegistry() {
|
|
|
113
69
|
logger.success('Podman VM config removed and machine restarted');
|
|
114
70
|
}
|
|
115
71
|
|
|
116
|
-
|
|
117
|
-
// Podman only when its socket service is running, and k3d finds it through DOCKER_HOST. Points
|
|
118
|
-
// DOCKER_HOST at Podman's socket when it exists, and returns a problem description (or null).
|
|
119
|
-
function ensurePodmanSocket() {
|
|
120
|
-
if (process.env.DOCKER_HOST) return null;
|
|
121
|
-
const res = capture('podman', ['info', '--format', '{{.Host.RemoteSocket.Path}}']);
|
|
122
|
-
const socket = res.status === 0 ? res.stdout.replace(/^unix:\/\//, '') : '';
|
|
123
|
-
if (socket && fs.existsSync(socket)) {
|
|
124
|
-
process.env.DOCKER_HOST = `unix://${socket}`;
|
|
125
|
-
logger.info(`Using the Podman socket at ${socket} (DOCKER_HOST)`);
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
|
-
return 'Podman\'s API socket isn\'t running, which k3d needs. Start it with: systemctl --user enable --now podman.socket';
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Rootless Podman can't bind host ports below net.ipv4.ip_unprivileged_port_start (1024 by
|
|
132
|
-
// default), and the cluster maps host :80/:443. Returns the ports that will fail to bind.
|
|
133
|
-
function blockedPrivilegedPorts(ports) {
|
|
134
|
-
const rootless = capture('podman', ['info', '--format', '{{.Host.Security.Rootless}}']).stdout === 'true';
|
|
135
|
-
if (!rootless) return [];
|
|
136
|
-
let start = 1024;
|
|
137
|
-
try {
|
|
138
|
-
start = parseInt(fs.readFileSync('/proc/sys/net/ipv4/ip_unprivileged_port_start', 'utf8'), 10);
|
|
139
|
-
} catch (e) {
|
|
140
|
-
// keep the default
|
|
141
|
-
}
|
|
142
|
-
return ports.filter((p) => p < start);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Memory (MB) available to containers: the Podman VM on macOS/Windows, the host on native Linux.
|
|
146
|
-
// Returns null when it can't be determined. Docker Desktop's VM size comes from `docker info`.
|
|
147
|
-
function engineMemoryMb(engineBin) {
|
|
148
|
-
const res = capture(engineBin, ['info', '--format', '{{.MemTotal}}']);
|
|
149
|
-
const bytes = parseInt(res.stdout, 10);
|
|
150
|
-
if (res.status === 0 && Number.isFinite(bytes) && bytes > 0) return Math.round(bytes / 1024 / 1024);
|
|
151
|
-
const info = capture(engineBin, ['info', '--format', '{{.Host.MemTotal}}']);
|
|
152
|
-
const hostBytes = parseInt(info.stdout, 10);
|
|
153
|
-
return info.status === 0 && Number.isFinite(hostBytes) && hostBytes > 0 ? Math.round(hostBytes / 1024 / 1024) : null;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
module.exports = {
|
|
157
|
-
engineMemoryMb,
|
|
158
|
-
ensureInsecureRegistry,
|
|
159
|
-
teardownInsecureRegistry,
|
|
160
|
-
ensurePodmanSocket,
|
|
161
|
-
blockedPrivilegedPorts,
|
|
162
|
-
};
|
|
72
|
+
module.exports = { ensureInsecureRegistry, teardownInsecureRegistry };
|
|
@@ -4,7 +4,7 @@ const path = require('path');
|
|
|
4
4
|
// Same lookup shape as git finding .git, or Claude finding .claude: walk up from a directory
|
|
5
5
|
// looking for the nearest `.local/project.json`, at that directory or any ancestor. This lets a
|
|
6
6
|
// single project.json at a parent folder govern every app subfolder underneath it, or a single
|
|
7
|
-
// `.local` folder hold both the project.json and an app's own config.
|
|
7
|
+
// `.local` folder hold both the project.json and an app's own config.yaml together - whichever
|
|
8
8
|
// layout a given repo uses.
|
|
9
9
|
function findProjectFile(startDir) {
|
|
10
10
|
let dir = startDir;
|
|
@@ -19,7 +19,7 @@ function findProjectFile(startDir) {
|
|
|
19
19
|
|
|
20
20
|
// Which profile governs `startDir` - the name from the nearest ancestor's project.json, or
|
|
21
21
|
// "default" if none exists anywhere above it. This is independent of app config resolution
|
|
22
|
-
// (CONFIG_PATH is always just cwd's own .local/config.
|
|
22
|
+
// (CONFIG_PATH is always just cwd's own .local/config.yaml) - only the profile/cluster scope
|
|
23
23
|
// walks up.
|
|
24
24
|
function resolveProfileName(startDir = process.cwd()) {
|
|
25
25
|
const file = findProjectFile(startDir);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Kubernetes resource quantities, parsed exactly with BigInt. cpu is in millicores (up to 1024
|
|
2
|
+
// cores), memory in bytes (up to 1 TiB). Neither can be zero, memory has no milli suffix, and a
|
|
3
|
+
// request can't exceed its limit.
|
|
4
|
+
const CPU=/^(\d{1,7})(\.\d{1,3})?(m)?$/, MEM=/^(\d{1,13})(\.\d{1,3})?(k|Ki|Mi|Gi|Ti|M|G|T)?$/;
|
|
5
|
+
const MUL={'':1n,k:10n**3n,M:10n**6n,G:10n**9n,T:10n**12n,Ki:1n<<10n,Mi:1n<<20n,Gi:1n<<30n,Ti:1n<<40n};
|
|
6
|
+
const MAX_CPU_M=1024n*1000n, MAX_MEM=1n<<40n; // 1024 cores, 1 TiB
|
|
7
|
+
function scaled(int,frac,mult){ // exact: (int.frac)*mult, rejects sub-unit remainders
|
|
8
|
+
const f=(frac||'').slice(1), den=10n**BigInt(f.length), num=BigInt(int+f)*mult;
|
|
9
|
+
if(num%den!==0n) throw new Error('too precise'); return num/den; }
|
|
10
|
+
function parseCpu(v){const m=CPU.exec(String(v)); if(!m) throw new Error(`cpu "${v}": use 500m, 1 or 0.5`);
|
|
11
|
+
const milli=m[3]? scaled(m[1],m[2],1n) : scaled(m[1],m[2],1000n);
|
|
12
|
+
if(milli<=0n) throw new Error('cpu must be > 0'); if(milli>MAX_CPU_M) throw new Error('cpu max 1024 cores'); return milli;}
|
|
13
|
+
function parseMem(v){const m=MEM.exec(String(v)); if(!m) throw new Error(`memory "${v}": use 256Mi, 2Gi, 1G`);
|
|
14
|
+
const b=scaled(m[1],m[2],MUL[m[3]||'']); if(b<=0n) throw new Error('memory must be > 0'); if(b>MAX_MEM) throw new Error('memory max 1Ti'); return b;}
|
|
15
|
+
function checkResources({requests={},limits={}}={}){
|
|
16
|
+
for(const [k,p] of [['cpu',parseCpu],['memory',parseMem]]){
|
|
17
|
+
const r=requests[k]!==undefined?p(requests[k]):null, l=limits[k]!==undefined?p(limits[k]):null;
|
|
18
|
+
if(r!==null&&l!==null&&r>l) throw new Error(`${k} request ${requests[k]} exceeds limit ${limits[k]}`);}
|
|
19
|
+
return true;}
|
|
20
|
+
module.exports={parseCpu,parseMem,checkResources};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
|
|
3
|
+
// Reads a text file that must be a plain file, up to `maxBytes`. Checking `stat.size` alone is not
|
|
4
|
+
// enough: a FIFO or /dev/zero reports size 0 and would block or read forever. The file is opened
|
|
5
|
+
// first (without following a final symlink) and the checks use that open handle, so the file that
|
|
6
|
+
// was checked is the file that is read.
|
|
7
|
+
function readRegularFile(file, maxBytes, label = file) {
|
|
8
|
+
if (fs.lstatSync(file).isSymbolicLink()) throw new Error(`${label} is a symbolic link; use a plain file.`);
|
|
9
|
+
const before = fs.statSync(file);
|
|
10
|
+
if (!before.isFile()) throw new Error(`${label} is not a regular file.`);
|
|
11
|
+
const noFollow = fs.constants.O_NOFOLLOW || 0;
|
|
12
|
+
let fd;
|
|
13
|
+
try {
|
|
14
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | noFollow);
|
|
15
|
+
} catch (e) {
|
|
16
|
+
if (e.code === 'ELOOP') throw new Error(`${label} is a symbolic link (or was swapped for one while being read); use a plain file.`);
|
|
17
|
+
throw e;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const info = fs.fstatSync(fd);
|
|
21
|
+
if (!info.isFile()) throw new Error(`${label} is not a regular file.`);
|
|
22
|
+
if (info.nlink > 1) throw new Error(`${label} has several hard links; use a plain file inside the app folder.`);
|
|
23
|
+
if (info.size > maxBytes) throw new Error(`${label} is larger than ${Math.round(maxBytes / 1024)} KB.`);
|
|
24
|
+
const buffer = Buffer.alloc(info.size);
|
|
25
|
+
let offset = 0;
|
|
26
|
+
while (offset < info.size) {
|
|
27
|
+
const n = fs.readSync(fd, buffer, offset, info.size - offset, offset);
|
|
28
|
+
if (n === 0) break;
|
|
29
|
+
offset += n;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, offset));
|
|
33
|
+
} catch (e) {
|
|
34
|
+
throw new Error(`${label} is not valid UTF-8 text.`);
|
|
35
|
+
}
|
|
36
|
+
} finally {
|
|
37
|
+
fs.closeSync(fd);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { readRegularFile };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// What the Tiltfile needs from the app's config, already resolved (defaults applied, derived
|
|
2
|
+
// values computed). The Tiltfile reads this instead of parsing .local/config.yaml itself, so a new
|
|
3
|
+
// config field only needs a change here and never an edit to every app's generated Tiltfile.
|
|
4
|
+
function tiltConfig(cfg) {
|
|
5
|
+
const portForwards = [`${cfg.port}:${cfg.port}`];
|
|
6
|
+
if (cfg.debug.enabled && cfg.debug.port) portForwards.push(`${cfg.debug.port}:${cfg.debug.port}`);
|
|
7
|
+
for (const extra of cfg.ports) {
|
|
8
|
+
if (extra.forward && extra.protocol === 'TCP') portForwards.push(`${extra.port}:${extra.port}`);
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
name: cfg.name,
|
|
12
|
+
build: {
|
|
13
|
+
context: cfg.build.context,
|
|
14
|
+
dockerfile: cfg.build.dockerfile,
|
|
15
|
+
target: cfg.build.target || '',
|
|
16
|
+
args: cfg.build.args,
|
|
17
|
+
ignore: cfg.build.ignore,
|
|
18
|
+
},
|
|
19
|
+
sync: cfg.sync.map((s) => ({ localPath: s.localPath, remotePath: s.remotePath })),
|
|
20
|
+
run: cfg.run.map((r) => ({ command: r.command, trigger: r.trigger || [] })),
|
|
21
|
+
portForwards,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = { tiltConfig };
|
package/src/lib/toolInstall.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
|
-
const os = require('os');
|
|
3
2
|
const path = require('path');
|
|
4
|
-
const { commandExists, run
|
|
3
|
+
const { commandExists, run } = require('./exec');
|
|
5
4
|
const logger = require('./logger');
|
|
6
|
-
const { detectDistro } = require('./linuxDistro');
|
|
7
5
|
const { HOME_STATE_DIR } = require('./paths');
|
|
8
6
|
|
|
9
7
|
// Tools that have no package-manager package on a platform get downloaded here instead. The
|
|
@@ -67,141 +65,6 @@ function installTiltWindows() {
|
|
|
67
65
|
logger.success(`tilt installed in ${TOOLS_DIR} (added to your PATH for new terminals)`);
|
|
68
66
|
}
|
|
69
67
|
|
|
70
|
-
|
|
71
|
-
// ---- Linux ----
|
|
72
|
-
// There's no single package manager to lean on across distros, so missing tools are fetched as
|
|
73
|
-
// the vendors' own release binaries straight into TOOLS_DIR (no sudo, no distro packages). That
|
|
74
|
-
// directory is on this process's PATH via addToolsDirToPath(), so setup carries on immediately.
|
|
75
|
-
// Needs only curl (and tar for tilt/helm), which every mainstream distro ships.
|
|
76
|
-
|
|
77
|
-
const LINUX_ARCH = { x64: 'amd64', arm64: 'arm64' };
|
|
78
|
-
|
|
79
|
-
function linuxArch() {
|
|
80
|
-
const arch = LINUX_ARCH[process.arch];
|
|
81
|
-
if (!arch) throw new Error(`Unsupported CPU architecture "${process.arch}". Install the tool manually.`);
|
|
82
|
-
return arch;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function requireLinuxDownloadTools(...tools) {
|
|
86
|
-
const missing = tools.filter((t) => !commandExists(t));
|
|
87
|
-
if (missing.length) {
|
|
88
|
-
throw new Error(`${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} needed to download missing tools. Install ${missing.length > 1 ? 'them' : 'it'} with your package manager and re-run.`);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function download(url, dest) {
|
|
93
|
-
logger.info(`Downloading ${url}`);
|
|
94
|
-
run('curl', ['-fsSL', '--retry', '3', '-o', dest, url]);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Final path segment of the URL a redirect lands on, e.g. ".../releases/tag/v0.33.0" -> "v0.33.0".
|
|
98
|
-
function latestGitHubTag(repo) {
|
|
99
|
-
const res = capture('curl', [
|
|
100
|
-
'-fsSLI', '-o', '/dev/null', '-w', '%{url_effective}',
|
|
101
|
-
`https://github.com/${repo}/releases/latest`,
|
|
102
|
-
]);
|
|
103
|
-
const tag = res.stdout.split('/').pop();
|
|
104
|
-
if (res.status !== 0 || !/^v?\d/.test(tag)) throw new Error(`Couldn't resolve the latest ${repo} release.`);
|
|
105
|
-
return tag;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function makeExecutable(file) {
|
|
109
|
-
fs.chmodSync(file, 0o755);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function installBinaryLinux(bin, url) {
|
|
113
|
-
fs.mkdirSync(TOOLS_DIR, { recursive: true });
|
|
114
|
-
const dest = path.join(TOOLS_DIR, bin);
|
|
115
|
-
download(url, dest);
|
|
116
|
-
makeExecutable(dest);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Downloads a .tar.gz and moves one file out of it into TOOLS_DIR.
|
|
120
|
-
function installFromTarballLinux(bin, url, innerPath) {
|
|
121
|
-
fs.mkdirSync(TOOLS_DIR, { recursive: true });
|
|
122
|
-
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), `localctl-${bin}-`));
|
|
123
|
-
try {
|
|
124
|
-
const archive = path.join(tmp, 'archive.tar.gz');
|
|
125
|
-
download(url, archive);
|
|
126
|
-
run('tar', ['-xzf', archive, '-C', tmp]);
|
|
127
|
-
const dest = path.join(TOOLS_DIR, bin);
|
|
128
|
-
fs.copyFileSync(path.join(tmp, innerPath), dest);
|
|
129
|
-
makeExecutable(dest);
|
|
130
|
-
} finally {
|
|
131
|
-
fs.rmSync(tmp, { recursive: true, force: true });
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const LINUX_INSTALLERS = {
|
|
136
|
-
k3d() {
|
|
137
|
-
installBinaryLinux('k3d', `https://github.com/k3d-io/k3d/releases/latest/download/k3d-linux-${linuxArch()}`);
|
|
138
|
-
},
|
|
139
|
-
kubectl() {
|
|
140
|
-
const version = capture('curl', ['-fsSL', 'https://dl.k8s.io/release/stable.txt']).stdout;
|
|
141
|
-
if (!/^v\d/.test(version)) throw new Error("Couldn't resolve the latest kubectl release.");
|
|
142
|
-
installBinaryLinux('kubectl', `https://dl.k8s.io/release/${version}/bin/linux/${linuxArch()}/kubectl`);
|
|
143
|
-
},
|
|
144
|
-
mkcert() {
|
|
145
|
-
installBinaryLinux('mkcert', `https://dl.filippo.io/mkcert/latest?for=linux/${linuxArch()}`);
|
|
146
|
-
},
|
|
147
|
-
helm() {
|
|
148
|
-
const version = capture('curl', ['-fsSL', 'https://get.helm.sh/helm-latest-version']).stdout;
|
|
149
|
-
if (!/^v\d/.test(version)) throw new Error("Couldn't resolve the latest helm release.");
|
|
150
|
-
const arch = linuxArch();
|
|
151
|
-
installFromTarballLinux('helm', `https://get.helm.sh/helm-${version}-linux-${arch}.tar.gz`, `linux-${arch}/helm`);
|
|
152
|
-
},
|
|
153
|
-
tilt() {
|
|
154
|
-
const version = latestGitHubTag('tilt-dev/tilt').replace(/^v/, '');
|
|
155
|
-
const arch = process.arch === 'arm64' ? 'arm64' : 'x86_64';
|
|
156
|
-
installFromTarballLinux('tilt', `https://github.com/tilt-dev/tilt/releases/download/v${version}/tilt.${version}.linux.${arch}.tar.gz`, 'tilt');
|
|
157
|
-
},
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
// Tools land in TOOLS_DIR, which localctl puts on its own PATH, but a terminal opened later (to
|
|
161
|
-
// run `kubectl` by hand, say) wouldn't see them. Persist it in the shell's rc file, with the same
|
|
162
|
-
// marker bin/bootstrap.sh uses so bin/uninstall.sh cleans it up. bash and zsh only: other shells
|
|
163
|
-
// get the line to add themselves.
|
|
164
|
-
function persistToolsDirOnPath() {
|
|
165
|
-
const shell = path.basename(process.env.SHELL || '');
|
|
166
|
-
const rcName = { zsh: '.zshrc', bash: '.bashrc' }[shell];
|
|
167
|
-
const line = 'export PATH="$HOME/.localctl/tools:$PATH"';
|
|
168
|
-
if (!rcName) {
|
|
169
|
-
logger.info(`Add ${TOOLS_DIR} to your PATH to use these tools from your own terminal: ${line}`);
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
const rc = path.join(os.homedir(), rcName);
|
|
173
|
-
const current = fs.existsSync(rc) ? fs.readFileSync(rc, 'utf8') : '';
|
|
174
|
-
if (current.includes('.localctl/tools')) return;
|
|
175
|
-
fs.appendFileSync(rc, `\n# Added by localctl bootstrap\n${line}\n`);
|
|
176
|
-
logger.info(`Added ${TOOLS_DIR} to PATH in ~/${rcName} - open a new terminal to pick it up.`);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function installLinux(bin) {
|
|
180
|
-
const installer = LINUX_INSTALLERS[bin];
|
|
181
|
-
if (!installer) {
|
|
182
|
-
throw new Error(`"${bin}" is missing and automatic install isn't supported for it on Linux. Install it with your package manager.`);
|
|
183
|
-
}
|
|
184
|
-
requireLinuxDownloadTools('curl', ...(bin === 'helm' || bin === 'tilt' ? ['tar'] : []));
|
|
185
|
-
logger.step(`Installing ${bin} from its official release (no sudo, into ${TOOLS_DIR})...`);
|
|
186
|
-
installer();
|
|
187
|
-
addToolsDirToPath();
|
|
188
|
-
persistToolsDirOnPath();
|
|
189
|
-
logger.success(`${bin} installed in ${TOOLS_DIR}`);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Distro-specific command for the NSS tools (certutil), which mkcert needs to trust its CA in
|
|
193
|
-
// Firefox/Chromium. Not installed automatically: it needs sudo, and it's optional.
|
|
194
|
-
function nssInstallHint() {
|
|
195
|
-
const { family } = detectDistro();
|
|
196
|
-
return {
|
|
197
|
-
debian: 'sudo apt install libnss3-tools',
|
|
198
|
-
rhel: 'sudo dnf install nss-tools',
|
|
199
|
-
arch: 'sudo pacman -S nss',
|
|
200
|
-
suse: 'sudo zypper install mozilla-nss-tools',
|
|
201
|
-
alpine: 'sudo apk add nss-tools',
|
|
202
|
-
}[family] || "your distro's NSS tools package (provides certutil)";
|
|
203
|
-
}
|
|
204
|
-
|
|
205
68
|
// macId: Homebrew formula name. winId: winget package id, or a function that installs the tool
|
|
206
69
|
// when winget doesn't have it.
|
|
207
70
|
function installIfMissing(bin, macId, winId) {
|
|
@@ -229,11 +92,9 @@ function installIfMissing(bin, macId, winId) {
|
|
|
229
92
|
'--accept-package-agreements', '--accept-source-agreements',
|
|
230
93
|
]);
|
|
231
94
|
addWingetLinksToPath();
|
|
232
|
-
} else if (process.platform === 'linux') {
|
|
233
|
-
installLinux(bin);
|
|
234
95
|
} else {
|
|
235
96
|
throw new Error(`"${bin}" is missing and automatic install isn't supported on this platform. Install it manually.`);
|
|
236
97
|
}
|
|
237
98
|
}
|
|
238
99
|
|
|
239
|
-
module.exports = { installIfMissing, installTiltWindows, addToolsDirToPath,
|
|
100
|
+
module.exports = { installIfMissing, installTiltWindows, addToolsDirToPath, TOOLS_DIR };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const yaml = require('js-yaml');
|
|
2
|
+
|
|
3
|
+
// A YAML loader that keeps text as text. The default schema turns `1e3` into 1000, `0x10` into 16,
|
|
4
|
+
// `0777` into 511, `1.10` into 1.1, `+1` into 1, `2001-12-14` into a Date and a 20-digit number
|
|
5
|
+
// into a rounded one: all silently, in fields like environment variables where the exact text
|
|
6
|
+
// matters. Here a plain scalar becomes a number only when writing the number back gives the same
|
|
7
|
+
// text (`3000`, `0.5`, `-2`); everything else stays a string. Booleans (true/false only), null,
|
|
8
|
+
// anchors, aliases and merge keys work as usual; timestamps are plain strings.
|
|
9
|
+
const keep = yaml.DEFAULT_SCHEMA.implicit.filter((t) => !/:(int|float|timestamp)$/.test(t.tag));
|
|
10
|
+
const plain = (d) => /^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$/.test(d) && String(Number(d)) === d;
|
|
11
|
+
const intType = new yaml.Type('tag:yaml.org,2002:int', {
|
|
12
|
+
kind: 'scalar',
|
|
13
|
+
resolve: (d) => plain(d) && !d.includes('.') && Number.isSafeInteger(Number(d)),
|
|
14
|
+
construct: Number,
|
|
15
|
+
predicate: Number.isInteger,
|
|
16
|
+
});
|
|
17
|
+
const floatType = new yaml.Type('tag:yaml.org,2002:float', {
|
|
18
|
+
kind: 'scalar',
|
|
19
|
+
resolve: (d) => plain(d) && d.includes('.'),
|
|
20
|
+
construct: Number,
|
|
21
|
+
predicate: (o) => typeof o === 'number',
|
|
22
|
+
});
|
|
23
|
+
// `explicit` (not `include`) so the !!str tag keeps working.
|
|
24
|
+
const STRICT = new yaml.Schema({ explicit: [...yaml.FAILSAFE_SCHEMA.explicit], implicit: [...keep, intType, floatType] });
|
|
25
|
+
|
|
26
|
+
const MAX_ALIASES = 200;
|
|
27
|
+
|
|
28
|
+
// `?` as the YAML "complex key" indicator: at the start of a line (also after `- ` list markers),
|
|
29
|
+
// or right after `{`, `[` or `,` in flow style, followed by whitespace or the end of the line.
|
|
30
|
+
const COMPLEX_KEY = /(?:^|[\r\n])[ \t]*(?:-[ \t]+)*\?(?:[ \t\r\n]|$)|[{[,][ \t]*\?(?:[ \t\r\n]|$)/;
|
|
31
|
+
|
|
32
|
+
// An alias is `*` followed by a name, and a name can be almost anything (js-yaml accepts é, `.`, `+`),
|
|
33
|
+
// so match any non-space, non-flow character after a `*` that starts a token.
|
|
34
|
+
const ALIAS = /(?:^|[\s,[{:(-])\*[^\s,[\]{}]/g;
|
|
35
|
+
|
|
36
|
+
// A config uses a handful of anchors at most. Complex keys (`? [a, b]`) and masses of aliases are
|
|
37
|
+
// the way to make a small file expensive to parse (each alias copies its anchor), so refuse them
|
|
38
|
+
// before parsing.
|
|
39
|
+
function loadYaml(text) {
|
|
40
|
+
if (COMPLEX_KEY.test(text)) {
|
|
41
|
+
throw new Error('complex mapping keys ("? ...") are not supported in a config (if this is a line of text inside a quoted or block string that starts with "? ", reword it).');
|
|
42
|
+
}
|
|
43
|
+
const aliases = text.match(ALIAS);
|
|
44
|
+
if (aliases && aliases.length > MAX_ALIASES) throw new Error(`too many YAML aliases (${aliases.length}); a config can use at most ${MAX_ALIASES}.`);
|
|
45
|
+
return yaml.load(text, { schema: STRICT });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { loadYaml };
|