kubepile 0.0.2 → 0.0.4
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 -0
- package/dist/package.json +1 -1
- package/dist/src/cli.js +31 -0
- package/dist/src/kubepile.d.ts +1 -0
- package/dist/src/kubepile.js +60 -1
- package/dist/src/shell.d.ts +27 -0
- package/dist/src/shell.js +155 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,6 +46,13 @@ This reads `~/.config/kubepile/*.yaml`, then writes `~/.kube/config`. If
|
|
|
46
46
|
`~/.kube/config` already exists, `kubepile compile` prompts before copying it to
|
|
47
47
|
`~/.kube/config.bak`.
|
|
48
48
|
|
|
49
|
+
Kubepile skips that backup prompt for safe rebuilds. A rebuild is safe when the
|
|
50
|
+
existing generated kubeconfig can be reproduced from a subset of the current
|
|
51
|
+
`*.yaml` files. In practice, this means no-op rebuilds and adding a new
|
|
52
|
+
provider file do not ask for a backup. If the existing generated kubeconfig was
|
|
53
|
+
manually edited, was not generated by kubepile, or can no longer be reproduced
|
|
54
|
+
from the current inputs, kubepile asks before replacing it.
|
|
55
|
+
|
|
49
56
|
Explicit command and options:
|
|
50
57
|
|
|
51
58
|
```sh
|
|
@@ -56,6 +63,31 @@ kubepile compile --no-backup
|
|
|
56
63
|
|
|
57
64
|
Running `kubepile` with no command prints help.
|
|
58
65
|
|
|
66
|
+
## Source
|
|
67
|
+
|
|
68
|
+
`kubepile source <context>` switches your current shell to one context by
|
|
69
|
+
creating a temporary kubeconfig containing only that context and exporting
|
|
70
|
+
`KUBECONFIG` to point at it. It also prefixes your shell prompt with the context
|
|
71
|
+
name.
|
|
72
|
+
|
|
73
|
+
Shells do not let child processes modify the parent shell environment, so this
|
|
74
|
+
requires installing a small shell function first:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
kubepile install
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Then start a new shell and run:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
kubepile source prod
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`kubepile install` installs only for your current shell. It supports bash, zsh,
|
|
87
|
+
and fish. The installed function proxies every normal command to the real
|
|
88
|
+
`kubepile` binary with `command kubepile`, so it follows whatever `kubepile` is
|
|
89
|
+
currently on your `PATH` after tools like `nvm` update it.
|
|
90
|
+
|
|
59
91
|
## Split
|
|
60
92
|
|
|
61
93
|
Do you already have a giant unmaintainable mess of a kubeconfig? No worries!
|
package/dist/package.json
CHANGED
package/dist/src/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
4
4
|
import { Command } from "@commander-js/extra-typings";
|
|
5
5
|
import packageJson from "../package.json" with { type: "json" };
|
|
6
6
|
import { compileToKubeConfig, defaultKubeConfigPath, defaultKubepileDir, splitKubeConfigFile, } from "./kubepile.js";
|
|
7
|
+
import { generateShellCommand, installShellIntegration, } from "./shell.js";
|
|
7
8
|
const program = createProgram();
|
|
8
9
|
if (process.argv.slice(2).length === 0) {
|
|
9
10
|
program.outputHelp();
|
|
@@ -73,5 +74,35 @@ Defaults:
|
|
|
73
74
|
});
|
|
74
75
|
process.stdout.write(`Wrote ${result.writtenFiles.length} kubeconfig file(s) into ${result.outputDir}\n`);
|
|
75
76
|
});
|
|
77
|
+
program
|
|
78
|
+
.command("install")
|
|
79
|
+
.description("Install the kubepile shell function for the current shell.")
|
|
80
|
+
.action(async () => {
|
|
81
|
+
const result = await installShellIntegration();
|
|
82
|
+
const action = result.updated ? "Installed" : "Updated";
|
|
83
|
+
process.stdout.write(`${action} kubepile shell integration in ${result.rcFile}\n`);
|
|
84
|
+
process.stdout.write("Start a new shell, then run: kubepile source <context>\n");
|
|
85
|
+
});
|
|
86
|
+
program
|
|
87
|
+
.command("generate-shell-command")
|
|
88
|
+
.description("Generate shell code for kubepile source. Usually called by the installed shell function.")
|
|
89
|
+
.argument("<context>", "context name to source")
|
|
90
|
+
.option("--source <file>", "source kubeconfig path")
|
|
91
|
+
.option("--shell <shell>", "shell command format: bash, zsh, or fish", "bash")
|
|
92
|
+
.action(async (context, options) => {
|
|
93
|
+
const shell = options.shell === "fish" ? "fish" : "posix";
|
|
94
|
+
const result = await generateShellCommand(context, {
|
|
95
|
+
sourcePath: options.source,
|
|
96
|
+
shell,
|
|
97
|
+
});
|
|
98
|
+
process.stdout.write(`${result.shellCommand}\n`);
|
|
99
|
+
});
|
|
100
|
+
program
|
|
101
|
+
.command("source")
|
|
102
|
+
.description("Switch to one kube context in the current shell.")
|
|
103
|
+
.argument("<context>", "context name to source")
|
|
104
|
+
.action(() => {
|
|
105
|
+
throw new Error("kubepile source requires shell integration. Run `kubepile install`, start a new shell, then run `kubepile source <context>`.");
|
|
106
|
+
});
|
|
76
107
|
return program;
|
|
77
108
|
}
|
package/dist/src/kubepile.d.ts
CHANGED
|
@@ -61,6 +61,7 @@ export declare function buildMergedConfig(options?: CompileOptions): Promise<{
|
|
|
61
61
|
export declare function compileToKubeConfig(options?: CompileToFileOptions): Promise<CompileResult>;
|
|
62
62
|
export declare function splitKubeConfigFile(options?: SplitOptions): Promise<SplitResult>;
|
|
63
63
|
export declare function splitKubeConfig(config: KubeConfig, sourceLabel?: string): SplitConfig[];
|
|
64
|
+
export declare function extractContextConfig(config: KubeConfig, contextName: string, sourceLabel?: string): KubeConfig;
|
|
64
65
|
export declare function readKubeConfigFile(filePath: string): Promise<KubeConfig>;
|
|
65
66
|
export declare function parseKubeConfig(source: string, sourceLabel?: string): KubeConfig;
|
|
66
67
|
export declare function serializeKubeConfig(config: KubeConfig): string;
|
package/dist/src/kubepile.js
CHANGED
|
@@ -11,6 +11,9 @@ export function defaultKubeConfigPath() {
|
|
|
11
11
|
export async function buildMergedConfig(options = {}) {
|
|
12
12
|
const inputDir = options.inputDir ?? defaultKubepileDir();
|
|
13
13
|
const inputFiles = await listKubeConfigFiles(inputDir);
|
|
14
|
+
return buildMergedConfigFromFiles(inputFiles);
|
|
15
|
+
}
|
|
16
|
+
async function buildMergedConfigFromFiles(inputFiles) {
|
|
14
17
|
const configs = await Promise.all(inputFiles.map(async (filePath) => ({
|
|
15
18
|
filePath,
|
|
16
19
|
config: await readKubeConfigFile(filePath),
|
|
@@ -41,7 +44,7 @@ export async function compileToKubeConfig(options = {}) {
|
|
|
41
44
|
const { config, inputFiles } = await buildMergedConfig({ ...options, inputDir });
|
|
42
45
|
let backedUpTo;
|
|
43
46
|
if (await pathExists(outputPath)) {
|
|
44
|
-
const shouldBackup = await
|
|
47
|
+
const shouldBackup = await shouldBackUpExistingKubeConfig(outputPath, inputDir, inputFiles, options);
|
|
45
48
|
if (shouldBackup) {
|
|
46
49
|
await mkdir(path.dirname(backupPath), { recursive: true });
|
|
47
50
|
await copyFile(outputPath, backupPath);
|
|
@@ -52,6 +55,52 @@ export async function compileToKubeConfig(options = {}) {
|
|
|
52
55
|
await writeFile(outputPath, serializeGeneratedKubeConfig(config, inputDir), "utf8");
|
|
53
56
|
return { config, inputFiles, outputPath, backedUpTo };
|
|
54
57
|
}
|
|
58
|
+
async function shouldBackUpExistingKubeConfig(outputPath, inputDir, currentInputFiles, options) {
|
|
59
|
+
if (await canSafelyRegenerate(outputPath, inputDir, currentInputFiles)) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return Boolean(await options.shouldBackup?.(outputPath, `${outputPath}.bak`));
|
|
63
|
+
}
|
|
64
|
+
async function canSafelyRegenerate(outputPath, inputDir, currentInputFiles) {
|
|
65
|
+
const existingSource = await readFile(outputPath, "utf8");
|
|
66
|
+
if (!existingSource.startsWith(generatedKubeConfigHeader(inputDir))) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const existingConfig = parseKubeConfig(existingSource, outputPath);
|
|
71
|
+
const previousInputFiles = await findInputFilesRepresentedInConfig(currentInputFiles, existingConfig);
|
|
72
|
+
if (previousInputFiles.length === 0) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
const { config } = await buildMergedConfigFromFiles(previousInputFiles);
|
|
76
|
+
return existingSource === serializeGeneratedKubeConfig(config, inputDir);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function findInputFilesRepresentedInConfig(inputFiles, config) {
|
|
83
|
+
const representedInputFiles = [];
|
|
84
|
+
for (const inputFile of inputFiles) {
|
|
85
|
+
const inputConfig = await readKubeConfigFile(inputFile);
|
|
86
|
+
if (configContainsAllNamedEntries(config, inputConfig, inputFile)) {
|
|
87
|
+
representedInputFiles.push(inputFile);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return representedInputFiles;
|
|
91
|
+
}
|
|
92
|
+
function configContainsAllNamedEntries(haystack, needle, sourceLabel) {
|
|
93
|
+
return hasAllNamedEntries(getNamedClusters(haystack, sourceLabel), getNamedClusters(needle, sourceLabel))
|
|
94
|
+
&& hasAllNamedEntries(getNamedUsers(haystack, sourceLabel), getNamedUsers(needle, sourceLabel))
|
|
95
|
+
&& hasAllNamedEntries(getNamedContexts(haystack, sourceLabel), getNamedContexts(needle, sourceLabel));
|
|
96
|
+
}
|
|
97
|
+
function hasAllNamedEntries(haystack, needles) {
|
|
98
|
+
const entriesByName = new Map(haystack.map((entry) => [entry.name, entry]));
|
|
99
|
+
return needles.every((needle) => deepEqual(entriesByName.get(needle.name), needle));
|
|
100
|
+
}
|
|
101
|
+
function deepEqual(left, right) {
|
|
102
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
103
|
+
}
|
|
55
104
|
async function pathExists(filePath) {
|
|
56
105
|
try {
|
|
57
106
|
await stat(filePath);
|
|
@@ -114,6 +163,16 @@ export function splitKubeConfig(config, sourceLabel = "kubeconfig") {
|
|
|
114
163
|
};
|
|
115
164
|
});
|
|
116
165
|
}
|
|
166
|
+
export function extractContextConfig(config, contextName, sourceLabel = "kubeconfig") {
|
|
167
|
+
const splitConfig = splitKubeConfig(config, sourceLabel).find((candidate) => candidate.contextName === contextName);
|
|
168
|
+
if (!splitConfig) {
|
|
169
|
+
throw new Error(`${sourceLabel} does not contain context "${contextName}"`);
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
...splitConfig.config,
|
|
173
|
+
"current-context": contextName,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
117
176
|
export async function readKubeConfigFile(filePath) {
|
|
118
177
|
const source = await readFile(filePath, "utf8");
|
|
119
178
|
return parseKubeConfig(source, filePath);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type ShellKind = "bash" | "zsh" | "fish";
|
|
2
|
+
export type ShellCommandKind = "posix" | "fish";
|
|
3
|
+
export interface GenerateShellCommandOptions {
|
|
4
|
+
sourcePath?: string;
|
|
5
|
+
shell?: ShellCommandKind;
|
|
6
|
+
tempDir?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface GenerateShellCommandResult {
|
|
9
|
+
kubeConfigPath: string;
|
|
10
|
+
shellCommand: string;
|
|
11
|
+
}
|
|
12
|
+
export interface InstallShellIntegrationOptions {
|
|
13
|
+
shell?: ShellKind;
|
|
14
|
+
rcFile?: string;
|
|
15
|
+
homeDir?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface InstallShellIntegrationResult {
|
|
18
|
+
shell: ShellKind;
|
|
19
|
+
rcFile: string;
|
|
20
|
+
updated: boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare function generateShellCommand(contextName: string, options?: GenerateShellCommandOptions): Promise<GenerateShellCommandResult>;
|
|
23
|
+
export declare function installShellIntegration(options?: InstallShellIntegrationOptions): Promise<InstallShellIntegrationResult>;
|
|
24
|
+
export declare function detectCurrentShell(shellPath?: string | undefined): ShellKind;
|
|
25
|
+
export declare function shellRcFile(shell: ShellKind, homeDir?: string): Promise<string>;
|
|
26
|
+
export declare function shellIntegrationBlock(shell: ShellKind): string;
|
|
27
|
+
export declare function upsertShellIntegrationBlock(existing: string, block: string): string;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { defaultKubeConfigPath, extractContextConfig, readKubeConfigFile, serializeKubeConfig, } from "./kubepile.js";
|
|
5
|
+
const SHELL_BLOCK_START = "# >>> kubepile shell integration >>>";
|
|
6
|
+
const SHELL_BLOCK_END = "# <<< kubepile shell integration <<<";
|
|
7
|
+
export async function generateShellCommand(contextName, options = {}) {
|
|
8
|
+
const sourcePath = options.sourcePath ?? defaultKubeConfigPath();
|
|
9
|
+
const shell = options.shell ?? "posix";
|
|
10
|
+
const sourceConfig = await readKubeConfigFile(sourcePath);
|
|
11
|
+
const contextConfig = extractContextConfig(sourceConfig, contextName, sourcePath);
|
|
12
|
+
const tempRoot = await mkdtemp(path.join(options.tempDir ?? os.tmpdir(), "kubepile-source-"));
|
|
13
|
+
const kubeConfigPath = path.join(tempRoot, "config");
|
|
14
|
+
await writeFile(kubeConfigPath, serializeKubeConfig(contextConfig), {
|
|
15
|
+
encoding: "utf8",
|
|
16
|
+
mode: 0o600,
|
|
17
|
+
});
|
|
18
|
+
return {
|
|
19
|
+
kubeConfigPath,
|
|
20
|
+
shellCommand: shell === "fish"
|
|
21
|
+
? fishSourceCommand(contextName, kubeConfigPath)
|
|
22
|
+
: posixSourceCommand(contextName, kubeConfigPath),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export async function installShellIntegration(options = {}) {
|
|
26
|
+
const shell = options.shell ?? detectCurrentShell();
|
|
27
|
+
const rcFile = options.rcFile ?? await shellRcFile(shell, options.homeDir ?? os.homedir());
|
|
28
|
+
const block = shellIntegrationBlock(shell);
|
|
29
|
+
const existing = await readTextIfExists(rcFile);
|
|
30
|
+
const next = upsertShellIntegrationBlock(existing, block);
|
|
31
|
+
await mkdir(path.dirname(rcFile), { recursive: true });
|
|
32
|
+
await writeFile(rcFile, next, "utf8");
|
|
33
|
+
return {
|
|
34
|
+
shell,
|
|
35
|
+
rcFile,
|
|
36
|
+
updated: next !== existing,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function detectCurrentShell(shellPath = process.env.SHELL) {
|
|
40
|
+
const shellName = shellPath ? path.basename(shellPath) : "";
|
|
41
|
+
if (shellName === "bash" || shellName === "zsh" || shellName === "fish") {
|
|
42
|
+
return shellName;
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`Unsupported shell "${shellPath ?? ""}". Supported shells: bash, zsh, fish.`);
|
|
45
|
+
}
|
|
46
|
+
export async function shellRcFile(shell, homeDir = os.homedir()) {
|
|
47
|
+
if (shell === "zsh") {
|
|
48
|
+
return path.join(homeDir, ".zshrc");
|
|
49
|
+
}
|
|
50
|
+
if (shell === "fish") {
|
|
51
|
+
return path.join(homeDir, ".config", "fish", "config.fish");
|
|
52
|
+
}
|
|
53
|
+
const bashCandidates = [
|
|
54
|
+
path.join(homeDir, ".bashrc"),
|
|
55
|
+
path.join(homeDir, ".bash_profile"),
|
|
56
|
+
path.join(homeDir, ".bash_login"),
|
|
57
|
+
path.join(homeDir, ".profile"),
|
|
58
|
+
];
|
|
59
|
+
return await firstExistingPath(bashCandidates) ?? bashCandidates[0];
|
|
60
|
+
}
|
|
61
|
+
export function shellIntegrationBlock(shell) {
|
|
62
|
+
return [
|
|
63
|
+
SHELL_BLOCK_START,
|
|
64
|
+
shell === "fish" ? fishIntegrationFunction() : posixIntegrationFunction(shell),
|
|
65
|
+
SHELL_BLOCK_END,
|
|
66
|
+
"",
|
|
67
|
+
].join("\n");
|
|
68
|
+
}
|
|
69
|
+
export function upsertShellIntegrationBlock(existing, block) {
|
|
70
|
+
const blockPattern = new RegExp(`${escapeRegExp(SHELL_BLOCK_START)}\\n[\\s\\S]*?\\n${escapeRegExp(SHELL_BLOCK_END)}\\n?`);
|
|
71
|
+
const normalizedBlock = block.endsWith("\n") ? block : `${block}\n`;
|
|
72
|
+
if (blockPattern.test(existing)) {
|
|
73
|
+
return existing.replace(blockPattern, normalizedBlock);
|
|
74
|
+
}
|
|
75
|
+
const separator = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
76
|
+
return `${existing}${separator}${normalizedBlock}`;
|
|
77
|
+
}
|
|
78
|
+
function posixIntegrationFunction(shell) {
|
|
79
|
+
return `kubepile() {
|
|
80
|
+
if [ "$1" = "source" ]; then
|
|
81
|
+
shift
|
|
82
|
+
eval "$(command \\kubepile generate-shell-command --shell ${shell} "$@")"
|
|
83
|
+
else
|
|
84
|
+
command \\kubepile "$@"
|
|
85
|
+
fi
|
|
86
|
+
}`;
|
|
87
|
+
}
|
|
88
|
+
function fishIntegrationFunction() {
|
|
89
|
+
return `function kubepile
|
|
90
|
+
if test (count $argv) -gt 0; and test "$argv[1]" = "source"
|
|
91
|
+
set -e argv[1]
|
|
92
|
+
command kubepile generate-shell-command --shell fish $argv | source
|
|
93
|
+
else
|
|
94
|
+
command kubepile $argv
|
|
95
|
+
end
|
|
96
|
+
end`;
|
|
97
|
+
}
|
|
98
|
+
function posixSourceCommand(contextName, kubeConfigPath) {
|
|
99
|
+
return [
|
|
100
|
+
`export KUBECONFIG=${shellQuote(kubeConfigPath)}`,
|
|
101
|
+
"if [ -z \"${KUBEPILE_OLD_PS1+x}\" ]; then",
|
|
102
|
+
" export KUBEPILE_OLD_PS1=${PS1-}",
|
|
103
|
+
"fi",
|
|
104
|
+
`export PS1=${shellQuote(`(${contextName}) `)}"$KUBEPILE_OLD_PS1"`,
|
|
105
|
+
].join("\n");
|
|
106
|
+
}
|
|
107
|
+
function fishSourceCommand(contextName, kubeConfigPath) {
|
|
108
|
+
return [
|
|
109
|
+
`set -gx KUBECONFIG ${fishQuote(kubeConfigPath)}`,
|
|
110
|
+
"if not set -q KUBEPILE_OLD_PROMPT",
|
|
111
|
+
" functions -c fish_prompt KUBEPILE_OLD_PROMPT",
|
|
112
|
+
"end",
|
|
113
|
+
"function fish_prompt",
|
|
114
|
+
` printf ${fishQuote(`(${contextName}) `)}`,
|
|
115
|
+
" KUBEPILE_OLD_PROMPT",
|
|
116
|
+
"end",
|
|
117
|
+
].join("\n");
|
|
118
|
+
}
|
|
119
|
+
function shellQuote(value) {
|
|
120
|
+
if (/^[A-Za-z0-9_./:=@%+-]+$/.test(value)) {
|
|
121
|
+
return value;
|
|
122
|
+
}
|
|
123
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
124
|
+
}
|
|
125
|
+
function fishQuote(value) {
|
|
126
|
+
return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
|
|
127
|
+
}
|
|
128
|
+
async function readTextIfExists(filePath) {
|
|
129
|
+
try {
|
|
130
|
+
return await readFile(filePath, "utf8");
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (error.code === "ENOENT") {
|
|
134
|
+
return "";
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function firstExistingPath(filePaths) {
|
|
140
|
+
for (const filePath of filePaths) {
|
|
141
|
+
try {
|
|
142
|
+
await readFile(filePath, "utf8");
|
|
143
|
+
return filePath;
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (error.code !== "ENOENT") {
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
function escapeRegExp(value) {
|
|
154
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
155
|
+
}
|