@knowttl/kubernetes-axi 0.0.0-stage → 0.1.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 +306 -2
- package/THIRD_PARTY_NOTICES.md +13 -0
- package/axi-credential.schema.json +31 -0
- package/bin/kubernetes-axi.js +10 -0
- package/command-metadata.json +36947 -0
- package/dist/byte-process.d.ts +9 -0
- package/dist/byte-process.js +56 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +68 -0
- package/dist/credentials.d.ts +28 -0
- package/dist/credentials.js +113 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +11 -0
- package/dist/exec-shim.d.ts +1 -0
- package/dist/exec-shim.js +16 -0
- package/dist/grammar.d.ts +50 -0
- package/dist/grammar.js +248 -0
- package/dist/hosted-session.d.ts +1 -0
- package/dist/hosted-session.js +201 -0
- package/dist/metadata-pin.d.ts +3 -0
- package/dist/metadata-pin.js +4 -0
- package/dist/mutations.d.ts +11 -0
- package/dist/mutations.js +526 -0
- package/dist/native.d.ts +26 -0
- package/dist/native.js +143 -0
- package/dist/private-files.d.ts +9 -0
- package/dist/private-files.js +137 -0
- package/dist/protobuf-schema.d.ts +2 -0
- package/dist/protobuf-schema.js +1 -0
- package/dist/protobuf.d.ts +1 -0
- package/dist/protobuf.js +67 -0
- package/dist/redaction.d.ts +10 -0
- package/dist/redaction.js +50 -0
- package/dist/remote-session.d.ts +22 -0
- package/dist/remote-session.js +90 -0
- package/dist/route-policy.d.ts +1 -0
- package/dist/route-policy.js +19 -0
- package/dist/runner.d.ts +11 -0
- package/dist/runner.js +367 -0
- package/dist/workspace-tar.d.ts +6 -0
- package/dist/workspace-tar.js +109 -0
- package/docs/hooks.md +35 -0
- package/kubectl-pins.json +18 -0
- package/package.json +21 -4
- package/skills/kubernetes-axi/SKILL.md +32 -0
- package/third-party/kubectl-LICENSE.txt +202 -0
- package/vendor/kubectl-linux-amd64 +4 -0
- package/vendor/kubectl-windows-amd64.exe +4 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Redactor } from './redaction.js';
|
|
2
|
+
export interface ByteExecution {
|
|
3
|
+
input: Buffer;
|
|
4
|
+
signal: AbortSignal;
|
|
5
|
+
timeoutMs: number;
|
|
6
|
+
controlStdout?: boolean;
|
|
7
|
+
output: (channel: 'stdout' | 'stderr', bytes: Buffer) => Promise<void>;
|
|
8
|
+
}
|
|
9
|
+
export declare function byteProcess(binary: string, args: string[], env: NodeJS.ProcessEnv, execution: ByteExecution, redactor: Redactor): Promise<number>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { Failure } from './errors.js';
|
|
3
|
+
export async function byteProcess(binary, args, env, execution, redactor) {
|
|
4
|
+
if (execution.signal.aborted)
|
|
5
|
+
throw new Failure('CANCELLED', 'The session was cancelled.');
|
|
6
|
+
const child = spawn(binary, args, { env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
|
|
7
|
+
let killTimer;
|
|
8
|
+
let timedOut = false;
|
|
9
|
+
const stop = () => {
|
|
10
|
+
child.kill('SIGTERM');
|
|
11
|
+
killTimer ??= setTimeout(() => { child.kill('SIGKILL'); }, 1000);
|
|
12
|
+
};
|
|
13
|
+
execution.signal.addEventListener('abort', stop, { once: true });
|
|
14
|
+
const timer = setTimeout(() => { timedOut = true; stop(); }, execution.timeoutMs);
|
|
15
|
+
child.stdin.on('error', () => { });
|
|
16
|
+
child.stdin.end(execution.input);
|
|
17
|
+
const exit = new Promise((resolve, reject) => {
|
|
18
|
+
child.once('error', () => reject(new Failure('UPSTREAM_FAILED', 'The bounded process could not start.')));
|
|
19
|
+
child.once('close', code => resolve(code ?? 1));
|
|
20
|
+
});
|
|
21
|
+
const channels = ['stdout', 'stderr'].map(async (channel) => {
|
|
22
|
+
const guard = redactor.byteStream();
|
|
23
|
+
try {
|
|
24
|
+
for await (const chunk of child[channel]) {
|
|
25
|
+
const safe = channel === 'stdout' && execution.controlStdout ? Buffer.from(chunk) : guard.push(Buffer.from(chunk));
|
|
26
|
+
if (safe.length)
|
|
27
|
+
await execution.output(channel, safe);
|
|
28
|
+
}
|
|
29
|
+
const tail = guard.end();
|
|
30
|
+
if (tail.length)
|
|
31
|
+
await execution.output(channel, tail);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
stop();
|
|
35
|
+
if (error instanceof Failure)
|
|
36
|
+
throw error;
|
|
37
|
+
throw new Failure('CREDENTIAL_OUTPUT_REFUSED', 'Binary output was withheld or its consumer disconnected.');
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
try {
|
|
41
|
+
const outcomes = await Promise.allSettled([exit, ...channels]);
|
|
42
|
+
const failure = outcomes.find(outcome => outcome.status === 'rejected');
|
|
43
|
+
if (failure?.status === 'rejected')
|
|
44
|
+
throw failure.reason;
|
|
45
|
+
if (execution.signal.aborted)
|
|
46
|
+
throw new Failure('CANCELLED', 'The session was cancelled.');
|
|
47
|
+
if (timedOut)
|
|
48
|
+
throw new Failure('UPSTREAM_TIMEOUT', 'The session reached its time limit.');
|
|
49
|
+
return outcomes[0].value;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
clearTimeout(killTimer);
|
|
54
|
+
execution.signal.removeEventListener('abort', stop);
|
|
55
|
+
}
|
|
56
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function main(args: string[]): Promise<void>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { encode } from '@toon-format/toon';
|
|
2
|
+
import { identity, configPath, profiles, validateEnvironment } from './credentials.js';
|
|
3
|
+
import { Failure } from './errors.js';
|
|
4
|
+
import { readCluster } from './runner.js';
|
|
5
|
+
import { parse, commandHelp, clientOnly } from './grammar.js';
|
|
6
|
+
import { guidance } from './metadata-pin.js';
|
|
7
|
+
import { Redactor } from './redaction.js';
|
|
8
|
+
import { preview } from './mutations.js';
|
|
9
|
+
const help = {
|
|
10
|
+
description: guidance,
|
|
11
|
+
commands: ['version', 'get <resource> [name]', 'api-versions', 'api-resources', 'logs <pod>', 'top node', 'top pod', 'describe <resource> <name>', 'explain <resource>', 'auth status', 'profile list', 'config path', 'doctor', 'prepare -- <mutation>', 'preview <request-key>', 'confirm <request-key> --digest <digest>', 'server-dry-run -- <mutation>', 'native -- <arguments>'],
|
|
12
|
+
flags: ['--config <absolute-path>', '--profile <name>', '--namespace <name>', '--native-config <absolute-path|ambient>', '--trust-exec', '--credential-fd <inherited-private-pipe> (confirm only)', '--help'],
|
|
13
|
+
examples: ['kubernetes-axi version --profile <name>', 'kubernetes-axi get pods --profile <name> --namespace <namespace>'],
|
|
14
|
+
};
|
|
15
|
+
export async function main(args) {
|
|
16
|
+
const redactor = new Redactor();
|
|
17
|
+
let native = false;
|
|
18
|
+
let json = false;
|
|
19
|
+
const output = (value) => {
|
|
20
|
+
if ('native' in value && value.native === true) {
|
|
21
|
+
const result = value;
|
|
22
|
+
process.stdout.write(redactor.text(result.stdout));
|
|
23
|
+
process.stderr.write(redactor.text(result.stderr));
|
|
24
|
+
process.exitCode = result.exitCode;
|
|
25
|
+
}
|
|
26
|
+
else
|
|
27
|
+
process.stdout.write(`${json ? JSON.stringify(redactor.value(value)) : encode(redactor.value(value))}\n`);
|
|
28
|
+
};
|
|
29
|
+
try {
|
|
30
|
+
if (args.length === 1 && args[0] === '--help') {
|
|
31
|
+
output(help);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const invocation = parse(args);
|
|
35
|
+
validateEnvironment();
|
|
36
|
+
native = invocation.native;
|
|
37
|
+
json = invocation.flags.get('output') === 'json';
|
|
38
|
+
if (invocation.help) {
|
|
39
|
+
output(commandHelp(invocation.command));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (invocation.workflow?.kind === 'preview') {
|
|
43
|
+
output(await preview(invocation));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (invocation.command.path === 'config path') {
|
|
47
|
+
output({ path: configPath(invocation.config) });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (invocation.command.path === 'profile list') {
|
|
51
|
+
const config = await profiles(invocation.config);
|
|
52
|
+
output({ profiles: Object.keys(config.profiles).map(name => ({ name, default: name === config.defaultProfile })) });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const selected = invocation.nativeConfig || clientOnly(invocation) ? undefined : await identity(invocation.config, invocation.profile, ['auth status', 'doctor'].includes(invocation.command.path));
|
|
56
|
+
redactor.register(selected?.secret, selected?.profile.clientCertificate);
|
|
57
|
+
if (selected && ['auth status', 'doctor'].includes(invocation.command.path)) {
|
|
58
|
+
output({ source: selected.source, origin: selected.profile.origin, auth: selected.profile.auth, secretSource: selected.profile.token ?? selected.profile.privateKey ?? { env: `KUBERNETES_AXI_${selected.profile.auth === 'token' ? 'TOKEN' : 'PRIVATE_KEY'}` }, permissions: 'verified' });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
output(await readCluster(selected, invocation, redactor));
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
const failure = error instanceof Failure ? error : new Failure('CONFIG_INVALID', 'Check the selected profile, protected files and installed package.');
|
|
65
|
+
output({ error: { code: failure.code, message: failure.message, ...failure.details }, ...(native ? { native: false } : {}) });
|
|
66
|
+
process.exitCode = failure.exitCode;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
type SecretRef = {
|
|
2
|
+
env: string;
|
|
3
|
+
} | {
|
|
4
|
+
file: string;
|
|
5
|
+
};
|
|
6
|
+
export interface Profile {
|
|
7
|
+
origin: string;
|
|
8
|
+
auth: 'token' | 'certificate';
|
|
9
|
+
namespace?: string;
|
|
10
|
+
certificateAuthority: string;
|
|
11
|
+
token?: SecretRef;
|
|
12
|
+
clientCertificate?: string;
|
|
13
|
+
privateKey?: SecretRef;
|
|
14
|
+
}
|
|
15
|
+
export interface Identity {
|
|
16
|
+
profile: Profile;
|
|
17
|
+
source: string;
|
|
18
|
+
secret: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function profiles(config?: string, diagnostic?: boolean): Promise<{
|
|
21
|
+
version: 1;
|
|
22
|
+
defaultProfile?: string;
|
|
23
|
+
profiles: Record<string, Profile>;
|
|
24
|
+
}>;
|
|
25
|
+
export declare function configPath(explicit?: string): string;
|
|
26
|
+
export declare function validateEnvironment(): void;
|
|
27
|
+
export declare function identity(config?: string, selected?: string, diagnostic?: boolean): Promise<Identity>;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { isAbsolute, join } from 'node:path';
|
|
4
|
+
import { Ajv } from 'ajv';
|
|
5
|
+
import { Failure } from './errors.js';
|
|
6
|
+
import { readPrivate } from './private-files.js';
|
|
7
|
+
const ajv = new Ajv({ strict: true, strictRequired: false });
|
|
8
|
+
ajv.addKeyword('x-secret');
|
|
9
|
+
const schema = JSON.parse(await readFile(new URL('../axi-credential.schema.json', import.meta.url), 'utf8'));
|
|
10
|
+
const validateProfile = ajv.compile(schema);
|
|
11
|
+
const validateConfig = ajv.compile({
|
|
12
|
+
type: 'object', additionalProperties: false, required: ['version', 'profiles'],
|
|
13
|
+
properties: {
|
|
14
|
+
version: { const: 1 },
|
|
15
|
+
defaultProfile: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$' },
|
|
16
|
+
profiles: {
|
|
17
|
+
type: 'object', propertyNames: { pattern: '^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$' },
|
|
18
|
+
additionalProperties: { $ref: 'urn:kubernetes-axi:credential-profile' },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
export async function profiles(config, diagnostic = false) {
|
|
23
|
+
const value = JSON.parse(await readPrivate(configPath(config), false, { diagnostic }));
|
|
24
|
+
if (!validateConfig(value))
|
|
25
|
+
throw new Failure('CONFIG_INVALID', 'Use strict profiles; token and privateKey fields require SecretRefs, never inline values.');
|
|
26
|
+
for (const profile of Object.values(value.profiles))
|
|
27
|
+
for (const ref of [profile.token, profile.privateKey]) {
|
|
28
|
+
if (ref && 'file' in ref && (!isAbsolute(ref.file) || (process.platform === 'win32' && !/^[A-Za-z]:\\/.test(ref.file))))
|
|
29
|
+
throw new Failure('CONFIG_INVALID', 'SecretRef file paths must be absolute local paths.');
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
export function configPath(explicit) {
|
|
34
|
+
const base = process.platform === 'win32'
|
|
35
|
+
? process.env.APPDATA
|
|
36
|
+
: (process.env.XDG_CONFIG_HOME && isAbsolute(process.env.XDG_CONFIG_HOME) ? process.env.XDG_CONFIG_HOME : join(homedir(), '.config'));
|
|
37
|
+
if (!base)
|
|
38
|
+
throw new Failure('CONFIG_INVALID', 'Set APPDATA to an absolute directory.');
|
|
39
|
+
const path = explicit ?? process.env.KUBERNETES_AXI_CONFIG ?? join(base, 'kubernetes-axi', 'config.json');
|
|
40
|
+
if (!isAbsolute(path))
|
|
41
|
+
throw new Failure('CONFIG_INVALID', 'Use an absolute config path.');
|
|
42
|
+
return path;
|
|
43
|
+
}
|
|
44
|
+
async function resolveSecret(ref, diagnostic) {
|
|
45
|
+
let value;
|
|
46
|
+
if ('env' in ref)
|
|
47
|
+
value = process.env[ref.env];
|
|
48
|
+
else {
|
|
49
|
+
if (!isAbsolute(ref.file) || (process.platform === 'win32' && !/^[A-Za-z]:\\/.test(ref.file))) {
|
|
50
|
+
throw new Failure('CONFIG_INVALID', 'Secret file must use an absolute local path.');
|
|
51
|
+
}
|
|
52
|
+
value = (await readPrivate(ref.file, true, { diagnostic })).replace(/^\uFEFF/, '').trimEnd();
|
|
53
|
+
}
|
|
54
|
+
if (!value)
|
|
55
|
+
throw new Failure('AUTH_REQUIRED', 'Supply the selected secret through its environment or protected-file reference.');
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
export function validateEnvironment() {
|
|
59
|
+
const readOnly = process.env.KUBERNETES_AXI_READ_ONLY;
|
|
60
|
+
if (readOnly !== undefined && !/^(true|false|0|1)$/i.test(readOnly)) {
|
|
61
|
+
throw new Failure('CONFIG_INVALID', 'KUBERNETES_AXI_READ_ONLY must be true, false, 1 or 0.');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function identity(config, selected, diagnostic = false) {
|
|
65
|
+
validateEnvironment();
|
|
66
|
+
const name = selected ?? process.env.KUBERNETES_AXI_PROFILE;
|
|
67
|
+
const fields = ['ORIGIN', 'AUTH', 'NAMESPACE', 'CERTIFICATE_AUTHORITY', 'TOKEN', 'CLIENT_CERTIFICATE', 'PRIVATE_KEY'];
|
|
68
|
+
let profile;
|
|
69
|
+
let source;
|
|
70
|
+
if (!name && fields.some(field => process.env[`KUBERNETES_AXI_${field}`] !== undefined)) {
|
|
71
|
+
const env = process.env;
|
|
72
|
+
profile = Object.fromEntries([
|
|
73
|
+
['origin', env.KUBERNETES_AXI_ORIGIN], ['auth', env.KUBERNETES_AXI_AUTH],
|
|
74
|
+
['namespace', env.KUBERNETES_AXI_NAMESPACE], ['certificateAuthority', env.KUBERNETES_AXI_CERTIFICATE_AUTHORITY],
|
|
75
|
+
['clientCertificate', env.KUBERNETES_AXI_CLIENT_CERTIFICATE],
|
|
76
|
+
['token', env.KUBERNETES_AXI_TOKEN === undefined ? undefined : { env: 'KUBERNETES_AXI_TOKEN' }],
|
|
77
|
+
['privateKey', env.KUBERNETES_AXI_PRIVATE_KEY === undefined ? undefined : { env: 'KUBERNETES_AXI_PRIVATE_KEY' }],
|
|
78
|
+
].filter(([, value]) => value !== undefined));
|
|
79
|
+
source = 'environment';
|
|
80
|
+
if (!(env.KUBERNETES_AXI_AUTH === 'token' ? env.KUBERNETES_AXI_TOKEN : env.KUBERNETES_AXI_PRIVATE_KEY))
|
|
81
|
+
throw new Failure('CONFIG_INVALID', 'Supply every required environment profile field, including its secret.');
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
let file;
|
|
85
|
+
try {
|
|
86
|
+
file = await profiles(config, diagnostic);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error.code === 'ENOENT') {
|
|
90
|
+
throw new Failure('AUTH_REQUIRED', 'Supply a complete environment profile or select a configured profile.');
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
const names = Object.keys(file.profiles);
|
|
95
|
+
const chosen = name ?? file.defaultProfile ?? (names.length === 1 ? names[0] : undefined);
|
|
96
|
+
if (!chosen)
|
|
97
|
+
throw new Failure(names.length ? 'PROFILE_AMBIGUOUS' : 'PROFILE_REQUIRED', 'Select a profile with --profile <name>.');
|
|
98
|
+
if (!Object.hasOwn(file.profiles, chosen))
|
|
99
|
+
throw new Failure('PROFILE_NOT_FOUND', 'Select a profile present in the config file.');
|
|
100
|
+
profile = file.profiles[chosen];
|
|
101
|
+
source = `file:${chosen}`;
|
|
102
|
+
}
|
|
103
|
+
if (!validateProfile(profile))
|
|
104
|
+
throw new Failure('CONFIG_INVALID', 'Supply a complete profile; token and privateKey must be valid SecretRefs.');
|
|
105
|
+
const origin = new URL(profile.origin);
|
|
106
|
+
if (origin.protocol !== 'https:' || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash) {
|
|
107
|
+
throw new Failure('CONFIG_INVALID', 'origin must be an HTTPS origin without credentials, path, query or fragment.');
|
|
108
|
+
}
|
|
109
|
+
const secret = await resolveSecret(profile.auth === 'token'
|
|
110
|
+
? profile.token ?? { env: 'KUBERNETES_AXI_TOKEN' }
|
|
111
|
+
: profile.privateKey ?? { env: 'KUBERNETES_AXI_PRIVATE_KEY' }, diagnostic);
|
|
112
|
+
return { profile, source, secret };
|
|
113
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare class Failure extends Error {
|
|
2
|
+
readonly code: string;
|
|
3
|
+
readonly exitCode: number;
|
|
4
|
+
readonly details: {
|
|
5
|
+
upstreamStatus?: number;
|
|
6
|
+
cause?: string;
|
|
7
|
+
};
|
|
8
|
+
constructor(code: string, message: string, exitCode?: number, details?: {
|
|
9
|
+
upstreamStatus?: number;
|
|
10
|
+
cause?: string;
|
|
11
|
+
});
|
|
12
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { connect } from 'node:net';
|
|
2
|
+
// Only kubectl invokes this shim. Its stdout is the private ExecCredential pipe.
|
|
3
|
+
const socket = connect(process.argv[2]);
|
|
4
|
+
let response = '';
|
|
5
|
+
socket.setEncoding('utf8');
|
|
6
|
+
socket.setTimeout(10_000, () => socket.destroy());
|
|
7
|
+
socket.on('connect', () => socket.end(JSON.stringify({ info: process.env.KUBERNETES_EXEC_INFO })));
|
|
8
|
+
socket.on('data', chunk => { response += chunk; if (response.length > 1024 * 1024)
|
|
9
|
+
socket.destroy(); });
|
|
10
|
+
socket.on('error', () => { process.exitCode = 1; });
|
|
11
|
+
socket.on('end', () => {
|
|
12
|
+
if (response)
|
|
13
|
+
process.stdout.write(response);
|
|
14
|
+
else
|
|
15
|
+
process.exitCode = 1;
|
|
16
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface Flag {
|
|
2
|
+
name: string;
|
|
3
|
+
short: string;
|
|
4
|
+
type: string;
|
|
5
|
+
default: string;
|
|
6
|
+
noOpt: string;
|
|
7
|
+
usage: string;
|
|
8
|
+
effect: string;
|
|
9
|
+
}
|
|
10
|
+
export interface Command {
|
|
11
|
+
path: string;
|
|
12
|
+
aliases: string[];
|
|
13
|
+
flags: Flag[];
|
|
14
|
+
use: string;
|
|
15
|
+
description: string;
|
|
16
|
+
effect: string;
|
|
17
|
+
operations?: string[];
|
|
18
|
+
compatibilityLimit?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare const metadata: {
|
|
21
|
+
version: string;
|
|
22
|
+
commands: Command[];
|
|
23
|
+
};
|
|
24
|
+
export interface Invocation {
|
|
25
|
+
session?: boolean;
|
|
26
|
+
inputSha256?: string;
|
|
27
|
+
workflow?: {
|
|
28
|
+
kind: 'prepare' | 'server-dry-run';
|
|
29
|
+
} | {
|
|
30
|
+
kind: 'preview' | 'confirm';
|
|
31
|
+
key: string;
|
|
32
|
+
digest: string;
|
|
33
|
+
};
|
|
34
|
+
native: boolean;
|
|
35
|
+
config?: string;
|
|
36
|
+
profile?: string;
|
|
37
|
+
nativeConfig?: string;
|
|
38
|
+
trustExec: boolean;
|
|
39
|
+
credentialFd?: number;
|
|
40
|
+
command: Command;
|
|
41
|
+
argv: string[];
|
|
42
|
+
positionals: string[];
|
|
43
|
+
flags: Map<string, string>;
|
|
44
|
+
help: boolean;
|
|
45
|
+
home: boolean;
|
|
46
|
+
}
|
|
47
|
+
export declare function parse(args: string[], session?: boolean): Invocation;
|
|
48
|
+
export declare function requireWrites(): void;
|
|
49
|
+
export declare function clientOnly(invocation: Invocation): boolean;
|
|
50
|
+
export declare function commandHelp(command: Command): object;
|
package/dist/grammar.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { Failure } from './errors.js';
|
|
5
|
+
import { metadataSha256, executionLimit } from './metadata-pin.js';
|
|
6
|
+
import { routeEffect } from './route-policy.js';
|
|
7
|
+
const bytes = await readFile(new URL('../command-metadata.json', import.meta.url));
|
|
8
|
+
export const metadata = JSON.parse(bytes.toString());
|
|
9
|
+
export function parse(args, session = false) {
|
|
10
|
+
if (createHash('sha256').update(bytes).digest('hex') !== metadataSha256 || metadata.version !== 'v1.32.2') {
|
|
11
|
+
throw new Failure('METADATA_STALE', 'Rebuild reviewed command metadata for the installed kubectl pin.');
|
|
12
|
+
}
|
|
13
|
+
const own = { trustExec: false };
|
|
14
|
+
const argv = [];
|
|
15
|
+
let native = session;
|
|
16
|
+
let workflow;
|
|
17
|
+
for (let index = 0; index < args.length; index++) {
|
|
18
|
+
const arg = args[index];
|
|
19
|
+
if (arg === '--credential-fd' || arg.startsWith('--credential-fd=')) {
|
|
20
|
+
const value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : args[++index] ?? '';
|
|
21
|
+
if (!/^(?:[3-9]|[1-9][0-9]+)$/.test(value) || own.credentialFd !== undefined)
|
|
22
|
+
throw new Failure('USAGE', 'Supply --credential-fd once with an inherited private pipe descriptor of at least 3.', 2);
|
|
23
|
+
own.credentialFd = Number(value);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (argv.length === 0 && ['prepare', 'preview', 'confirm', 'server-dry-run'].includes(arg) && args[index + 1] === '--help' && index + 2 === args.length) {
|
|
27
|
+
const uses = { prepare: 'prepare -- <Kubernetes mutation>', preview: 'preview <request-key>', confirm: 'confirm <request-key> --digest <preview-digest>', 'server-dry-run': 'server-dry-run -- <Kubernetes mutation>' };
|
|
28
|
+
return { ...own, native: false, command: { path: arg, aliases: [], flags: [], use: uses[arg], description: 'Prepare frozen targets, preview them offline, confirm their exact digest, or explicitly request server dry run.', effect: arg === 'preview' ? 'localRead' : 'clusterWrite' }, argv: [], positionals: [], flags: new Map(), help: true, home: false };
|
|
29
|
+
}
|
|
30
|
+
if (argv.length === 0 && ['prepare', 'server-dry-run'].includes(arg)) {
|
|
31
|
+
if (args[index + 1] !== '--')
|
|
32
|
+
throw new Failure('USAGE', `Use ${arg} -- <Kubernetes command and arguments>.`, 2);
|
|
33
|
+
workflow = { kind: arg };
|
|
34
|
+
argv.push(...args.slice(index + 2));
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
if (argv.length === 0 && ['preview', 'confirm'].includes(arg)) {
|
|
38
|
+
const key = args[index + 1] ?? '';
|
|
39
|
+
const tail = args.slice(index + 2);
|
|
40
|
+
if (!/^[a-f0-9-]{36}$/.test(key) || (arg === 'preview' ? tail.length !== 0 : tail.length !== 2 || tail[0] !== '--digest' || !/^[a-f0-9]{64}$/.test(tail[1] ?? '')))
|
|
41
|
+
throw new Failure('USAGE', 'Use preview <request-key> or confirm <request-key> --digest <preview-digest>.', 2);
|
|
42
|
+
workflow = { kind: arg, key, digest: tail[1] ?? '' };
|
|
43
|
+
validateSelection(own);
|
|
44
|
+
return { ...own, native: false, workflow, command: { path: arg, aliases: [], flags: [], use: arg, description: 'Inspect or confirm a frozen request.', effect: arg === 'preview' ? 'localRead' : 'clusterWrite' }, argv: [], positionals: [], flags: new Map(), help: false, home: false };
|
|
45
|
+
}
|
|
46
|
+
if (arg === 'native' && argv.length === 0) {
|
|
47
|
+
if (args[index + 1] !== '--')
|
|
48
|
+
throw new Failure('USAGE', 'Use kubernetes-axi [--profile <name>] native -- <kubectl arguments>.', 2);
|
|
49
|
+
native = true;
|
|
50
|
+
argv.push(...args.slice(index + 2));
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
if (arg === '--') {
|
|
54
|
+
argv.push(...args.slice(index));
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
const [name, ...valueParts] = arg.split('=');
|
|
58
|
+
if (arg === '--trust-exec') {
|
|
59
|
+
own.trustExec = true;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (name === '--config' || name === '--profile' || name === '--native-config') {
|
|
63
|
+
const value = valueParts.length ? valueParts.join('=') : args[++index];
|
|
64
|
+
if (!value || value.startsWith('-'))
|
|
65
|
+
throw new Failure('USAGE', `${name} requires a value.`, 2);
|
|
66
|
+
const key = name === '--config' ? 'config' : name === '--profile' ? 'profile' : 'nativeConfig';
|
|
67
|
+
if (own[key] !== undefined)
|
|
68
|
+
throw new Failure('USAGE', `Supply ${name} once.`, 2);
|
|
69
|
+
own[key] = value;
|
|
70
|
+
}
|
|
71
|
+
else
|
|
72
|
+
argv.push(arg);
|
|
73
|
+
}
|
|
74
|
+
if (own.credentialFd !== undefined)
|
|
75
|
+
throw new Failure('USAGE', '--credential-fd is only available before confirm.', 2);
|
|
76
|
+
const localCommand = argv.filter(arg => !['--help', '-h'].includes(arg)).join(' ');
|
|
77
|
+
if (['auth status', 'profile list', 'config path', 'doctor'].includes(localCommand)) {
|
|
78
|
+
if (own.nativeConfig || own.trustExec)
|
|
79
|
+
throw new Failure('USAGE', 'Use native -- for native configuration diagnostics.', 2);
|
|
80
|
+
return { native: false, ...own, command: { path: localCommand, aliases: [], flags: ['config', 'profile', 'help'].map(name => ({ name, short: '', type: name === 'help' ? 'bool' : 'string', default: '', noOpt: name === 'help' ? 'true' : '', usage: '', effect: 'argument' })), use: localCommand, description: 'Inspect standard credential configuration without a cluster request.', effect: 'localRead' }, argv, positionals: [], flags: new Map(), help: argv.some(arg => ['--help', '-h'].includes(arg)), home: false };
|
|
81
|
+
}
|
|
82
|
+
// Resolve the longest command path while respecting values of inherited flags.
|
|
83
|
+
let command = metadata.commands.find(item => item.path === '');
|
|
84
|
+
const positionals = [];
|
|
85
|
+
const flags = new Map();
|
|
86
|
+
const tokens = [...argv];
|
|
87
|
+
let ended = false;
|
|
88
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
89
|
+
const arg = tokens[index];
|
|
90
|
+
if (arg === '--') {
|
|
91
|
+
ended = true;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (ended || !arg.startsWith('-') || arg === '-') {
|
|
95
|
+
const child = !ended && positionals.length === 0 ? metadata.commands.find(item => {
|
|
96
|
+
const words = item.path.split(' ');
|
|
97
|
+
const parent = words.slice(0, -1).join(' ');
|
|
98
|
+
return item.path !== '' && parent === command.path && (words.at(-1) === arg || item.aliases.includes(arg));
|
|
99
|
+
}) : undefined;
|
|
100
|
+
if (child)
|
|
101
|
+
command = child;
|
|
102
|
+
else
|
|
103
|
+
positionals.push(arg);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const long = arg.startsWith('--');
|
|
107
|
+
const name = long ? arg.slice(2).split('=')[0] : arg.slice(1, 2);
|
|
108
|
+
// Command flags may precede their command, as Cobra permits inherited options.
|
|
109
|
+
const flag = command.flags.find(flag => long ? flag.name === name : flag.short === name);
|
|
110
|
+
if (!flag) {
|
|
111
|
+
throw new Failure('USAGE', `Unknown flag for ${command.path || 'home'}. Valid flags: ${command.flags.map(flag => `--${flag.name}`).join(', ')}.`, 2);
|
|
112
|
+
}
|
|
113
|
+
let value = long ? (arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : undefined) : arg.slice(2).replace(/^=/, '') || undefined;
|
|
114
|
+
if (!long && flag.type === 'bool' && arg.length > 2 && arg[2] !== '=') {
|
|
115
|
+
tokens.splice(index + 1, 0, `-${arg.slice(2)}`);
|
|
116
|
+
value = flag.noOpt;
|
|
117
|
+
}
|
|
118
|
+
if (value === undefined)
|
|
119
|
+
value = flag.noOpt || tokens[++index];
|
|
120
|
+
if (value === undefined || (!flag.noOpt && value.startsWith('--')))
|
|
121
|
+
throw new Failure('USAGE', `--${flag.name} requires a ${flag.type} value.`, 2);
|
|
122
|
+
if (flag.type === 'bool' && !/^(true|false|1|0|t|f|TRUE|FALSE|True|False|T|F)$/.test(value))
|
|
123
|
+
throw new Failure('USAGE', `--${flag.name} requires a boolean.`, 2);
|
|
124
|
+
if (/^u?int(32|64)?$/.test(flag.type) && !/^-?\d+$/.test(value))
|
|
125
|
+
throw new Failure('USAGE', `--${flag.name} requires an integer.`, 2);
|
|
126
|
+
if (session && (flag.effect === 'fileRead' || flag.name === 'kustomize')) {
|
|
127
|
+
const workspace = join(process.env.TMPDIR ?? '', 'workspace');
|
|
128
|
+
for (const path of value.split(',')) {
|
|
129
|
+
const selected = relative(workspace, resolve(path));
|
|
130
|
+
if (path !== '-' && (!process.env.TMPDIR || selected.startsWith('..') || isAbsolute(selected)))
|
|
131
|
+
throw new Failure('WORKSPACE_REQUIRED', 'Session file inputs must come from the owned workspace.');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
flags.set(flag.name, flag.type === 'bool' ? (/^(true|1|t)$/i.test(value) ? 'true' : 'false') : value);
|
|
135
|
+
}
|
|
136
|
+
const help = flags.get('help') === 'true' || command.path === 'help';
|
|
137
|
+
if (command.path === '' && positionals.length)
|
|
138
|
+
throw new Failure('USAGE', 'Unknown command. Run kubernetes-axi --help.', 2);
|
|
139
|
+
if (!command.effect)
|
|
140
|
+
throw new Failure('EFFECT_UNCLASSIFIED', 'The command has no reviewed effect classification.');
|
|
141
|
+
if (!help) {
|
|
142
|
+
if (['exec', 'attach', 'debug'].includes(command.path) && (flags.get('tty') === 'true' || !session && flags.get('stdin') === 'true') || command.path === 'debug' && flags.get('attach') !== 'false' || !session && command.path === 'port-forward')
|
|
143
|
+
throw new Failure('LOCAL_TERMINAL_REQUIRED', executionLimit);
|
|
144
|
+
const guarded = ['exec', 'attach', 'create token', ...(session ? ['port-forward'] : [])].includes(command.path);
|
|
145
|
+
if (flags.has('raw')) {
|
|
146
|
+
const effect = routeEffect(command.path === 'get' ? 'GET' : command.path === 'delete' ? 'DELETE' : command.path === 'replace' ? 'PUT' : 'POST', flags.get('raw'));
|
|
147
|
+
if (effect !== 'read')
|
|
148
|
+
requireWrites();
|
|
149
|
+
if (!['read', 'clusterWrite'].includes(effect))
|
|
150
|
+
throw new Failure('CAPABILITY_UNAVAILABLE', `Raw ${effect} routes require the corresponding explicit guarded command.`);
|
|
151
|
+
if (/\/secrets(?:\/|\?|$)/.test(flags.get('raw')))
|
|
152
|
+
throw new Failure('CREDENTIAL_OUTPUT_REFUSED', 'Use compact get secrets for metadata.');
|
|
153
|
+
}
|
|
154
|
+
if (guarded) {
|
|
155
|
+
requireWrites();
|
|
156
|
+
if (!workflow || workflow.kind !== 'prepare')
|
|
157
|
+
throw new Failure('PREVIEW_REQUIRED', 'Use prepare -- <command>, preview <request-key>, then confirm <request-key> --digest <digest>.');
|
|
158
|
+
if (command.path === 'create token' && !/^(true|1)$/i.test(process.env.KUBERNETES_AXI_ACQUIRE_CREDENTIAL ?? ''))
|
|
159
|
+
throw new Failure('CAPABILITY_UNAVAILABLE', 'Token issuance requires KUBERNETES_AXI_ACQUIRE_CREDENTIAL=true and a private delivery pipe on confirmation.');
|
|
160
|
+
}
|
|
161
|
+
if (native && ['get', 'describe'].includes(command.path) && positionals.some((value, index) => (index === 0 || value.includes('/')) && value.split(',').some(resource => /^secrets?(?:$|[/.])/i.test(resource))))
|
|
162
|
+
throw new Failure('CREDENTIAL_OUTPUT_REFUSED', 'Use compact get secrets for metadata; public native Secret output is unavailable.');
|
|
163
|
+
if (own.nativeConfig && ['config set', 'config set-credentials', 'config view'].includes(command.path))
|
|
164
|
+
throw new Failure('USAGE', 'Raw native credential inspection and credential argv writes are unavailable.', 2);
|
|
165
|
+
if (command.compatibilityLimit)
|
|
166
|
+
throw new Failure('NOT_YET_SUPPORTED_STAGED', command.compatibilityLimit);
|
|
167
|
+
for (const flag of command.flags) {
|
|
168
|
+
if (!session && flag.effect === 'stream' && (flags.get(flag.name) ?? flag.default) !== 'false')
|
|
169
|
+
throw new Failure('USAGE', `--${flag.name} must be false in standalone mode. ${executionLimit}`, 2);
|
|
170
|
+
}
|
|
171
|
+
for (const name of flags.keys()) {
|
|
172
|
+
const flag = command.flags.find(flag => flag.name === name);
|
|
173
|
+
if (!flag.effect)
|
|
174
|
+
throw new Failure('EFFECT_UNCLASSIFIED', 'The flag has no reviewed effect classification.');
|
|
175
|
+
if (flag.effect === 'stream')
|
|
176
|
+
continue;
|
|
177
|
+
const writeInput = workflow && ['prepare', 'server-dry-run'].includes(workflow.kind) && (flag.effect === 'fileRead' && ['filename', 'patch-file'].includes(flag.name) || session && flag.name === 'kustomize' || flag.effect === 'clusterWrite' && (['dry-run', 'server-side', 'force-conflicts', 'force', 'prune', 'prune-allowlist'].includes(flag.name) || command.path === 'debug' && flag.name === 'profile'));
|
|
178
|
+
const boundedExecution = (guarded || command.path === 'debug') && ['stdin', 'tty', 'attach'].includes(name) && (flags.get(name) === 'false' || session && name === 'stdin');
|
|
179
|
+
const raw = name === 'raw' && (command.path === 'get' || Boolean(workflow) && command.effect === 'clusterWrite');
|
|
180
|
+
if (!(own.nativeConfig && flag.name === 'context') && !raw && !writeInput && !boundedExecution && !['argument', 'presentation'].includes(flag.effect))
|
|
181
|
+
throw new Failure('USAGE', `--${flag.name} is unavailable under the isolated bounded-read policy (${flag.effect}).`, 2);
|
|
182
|
+
if (flag.name === 'output' && /file[=\s]|template-file/.test(flags.get(flag.name)))
|
|
183
|
+
throw new Failure('USAGE', 'File templates require a later local-file capability.', 2);
|
|
184
|
+
}
|
|
185
|
+
if (command.effect === 'clusterWrite') {
|
|
186
|
+
requireWrites();
|
|
187
|
+
if (!workflow)
|
|
188
|
+
throw new Failure('PREVIEW_REQUIRED', 'Use prepare -- <command>, preview <request-key>, then confirm <request-key> --digest <digest>.');
|
|
189
|
+
if (flags.has('dry-run'))
|
|
190
|
+
throw new Failure('USAGE', 'Use server-dry-run -- <command> for server validation, or prepare -- <command> for a frozen request.', 2);
|
|
191
|
+
}
|
|
192
|
+
else if (workflow && !guarded)
|
|
193
|
+
throw new Failure('USAGE', 'Preparation and server dry run require a resource mutation command.', 2);
|
|
194
|
+
if (!(own.nativeConfig && native && command.path.startsWith('config ')) && !(native && command.path === 'plugin list') && !guarded && !['read', 'local', 'clusterWrite', 'authorization'].includes(command.effect))
|
|
195
|
+
throw new Failure('CAPABILITY_UNAVAILABLE', `The ${command.effect} capability is not available in this milestone.`);
|
|
196
|
+
if (['wait', 'events'].includes(command.path))
|
|
197
|
+
throw new Failure('CAPABILITY_UNAVAILABLE', 'Bounded event reads use get events; wait and streaming sessions require a later capability.');
|
|
198
|
+
if (!native) {
|
|
199
|
+
const allowed = {
|
|
200
|
+
'': ['namespace'], version: [], get: ['namespace', 'all-namespaces', 'selector', 'field-selector', 'output'],
|
|
201
|
+
'api-versions': [], 'api-resources': [],
|
|
202
|
+
'top node': ['namespace', 'selector'], 'top pod': ['namespace', 'all-namespaces', 'selector'],
|
|
203
|
+
};
|
|
204
|
+
if (Object.hasOwn(allowed, command.path)) {
|
|
205
|
+
for (const name of flags.keys())
|
|
206
|
+
if (!allowed[command.path].includes(name))
|
|
207
|
+
throw new Failure('USAGE', `--${name} requires native -- ${command.path} for upstream semantics.`, 2);
|
|
208
|
+
}
|
|
209
|
+
else if (!guarded && !['logs', 'describe', 'explain', 'rollout history', 'rollout status'].includes(command.path) && !['clusterWrite', 'authorization'].includes(command.effect))
|
|
210
|
+
throw new Failure('CAPABILITY_UNAVAILABLE', 'Use native -- for this upstream read.');
|
|
211
|
+
const maxArgs = command.path === 'get' ? 2 : command.path.startsWith('top ') ? 1 : command.path === '' || command.path === 'version' || command.path.startsWith('api-') ? 0 : undefined;
|
|
212
|
+
if (maxArgs !== undefined && (positionals.length > maxArgs || (command.path === 'get' && positionals.length === 0)))
|
|
213
|
+
throw new Failure('USAGE', 'Supply one resource type and at most one resource name.', 2);
|
|
214
|
+
if (command.path === 'get' && flags.has('output') && flags.get('output') !== 'json')
|
|
215
|
+
throw new Failure('USAGE', 'Compact reads support -o json; use native -- for other output formats.', 2);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
validateSelection(own);
|
|
219
|
+
return { native, ...own, ...(session ? { session } : {}), ...(workflow ? { workflow } : {}), command, argv, positionals, flags, help, home: command.path === '' && positionals.length === 0 };
|
|
220
|
+
}
|
|
221
|
+
function validateSelection(own) {
|
|
222
|
+
if (own.trustExec && !own.nativeConfig)
|
|
223
|
+
throw new Failure('USAGE', '--trust-exec requires --native-config.', 2);
|
|
224
|
+
if (own.nativeConfig && own.nativeConfig !== 'ambient' && !isAbsolute(own.nativeConfig))
|
|
225
|
+
throw new Failure('CONFIG_INVALID', 'Select an absolute native kubeconfig path or ambient.');
|
|
226
|
+
if (own.nativeConfig && (own.config || own.profile || ['CONFIG', 'PROFILE', 'ORIGIN', 'AUTH', 'NAMESPACE', 'CERTIFICATE_AUTHORITY', 'TOKEN', 'CLIENT_CERTIFICATE', 'PRIVATE_KEY'].some(field => process.env[`KUBERNETES_AXI_${field}`] !== undefined)))
|
|
227
|
+
throw new Failure('CONFIG_INVALID', 'Native selection cannot override a standard or hosted profile.');
|
|
228
|
+
}
|
|
229
|
+
export function requireWrites() {
|
|
230
|
+
if (!/^(false|0)$/i.test(process.env.KUBERNETES_AXI_READ_ONLY ?? ''))
|
|
231
|
+
throw new Failure('READ_ONLY', 'Writes require KUBERNETES_AXI_READ_ONLY=false and explicit confirmation.');
|
|
232
|
+
}
|
|
233
|
+
export function clientOnly(invocation) {
|
|
234
|
+
return invocation.native && (['completion', 'options', 'plugin list'].includes(invocation.command.path) || invocation.command.path === 'version' && invocation.flags.get('client') === 'true');
|
|
235
|
+
}
|
|
236
|
+
export function commandHelp(command) {
|
|
237
|
+
return {
|
|
238
|
+
command: command.path || 'kubernetes-axi', description: command.description,
|
|
239
|
+
usage: command.use, effect: command.effect,
|
|
240
|
+
...(['exec', 'attach', 'debug', 'get', 'logs', 'port-forward'].includes(command.path) ? { capabilityBoundary: executionLimit } : {}),
|
|
241
|
+
...(command.operations ? { operations: command.operations } : {}),
|
|
242
|
+
...(command.compatibilityLimit ? { compatibilityLimit: command.compatibilityLimit } : {}),
|
|
243
|
+
flags: command.flags.map(flag => ({ name: `--${flag.name}`, short: flag.short ? `-${flag.short}` : '', type: flag.type, default: flag.default })),
|
|
244
|
+
examples: ['prepare', 'preview', 'confirm', 'server-dry-run'].includes(command.path) ?
|
|
245
|
+
['kubernetes-axi --profile <name> prepare -- patch deployment <name> --type=merge -p \'{"spec":{"replicas":3}}\'', 'kubernetes-axi --profile <name> preview <request-key>', 'kubernetes-axi --profile <name> confirm <request-key> --digest <preview-digest>'] :
|
|
246
|
+
['kubernetes-axi --profile <name> get pods -n <namespace>', 'kubernetes-axi --profile <name> native -- get pods -n <namespace> -o json'],
|
|
247
|
+
};
|
|
248
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|