Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ while Deno is a younger runtime, so there's some mismatch in capabilities.
Deno also has a granular permission system controlling what a program can access.
Therefore different Deno flags can be given to your program depending on where you are running it.

(NodeJS 20+ is also partially supported by this library)

## Usage

Here's a basic request, listing all Pods in the `default` namespace.
Expand Down Expand Up @@ -81,7 +83,9 @@ You can also directly instantiate a particular client if you don't want to depen

## Development

Check out `lib/contract.ts` to see the type/API contract.
If you are unsure how to issue a specific request from your own library/code,
or if your usage results in any `TODO: ...` error message from my code,
please feel free to file a Github Issue.

The `kubectl` client logs the issued commands if `--verbose` is passed to the Deno program.

Expand Down
5 changes: 3 additions & 2 deletions deno.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
{
"name": "@cloudydeno/kubernetes-client",
"version": "0.8.0",
"version": "0.8.1",
"imports": {
"@std/path": "jsr:@std/path@^1",
"@std/streams": "jsr:@std/streams@^1",
"@std/yaml": "jsr:@std/yaml@^1"
"@std/yaml": "jsr:@std/yaml@^1",
"undici": "npm:undici@^7"
},
"exports": {
"./lib/contract.ts": "./lib/contract.ts",
Expand Down
11 changes: 9 additions & 2 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 14 additions & 2 deletions examples/pod-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,27 @@
import { autoDetectClient } from '@cloudydeno/kubernetes-client';
const client = await autoDetectClient();

const namespace = client.defaultNamespace ?? 'default';
const containerName = 'srv';

// Find the first pod in the namespace.
// In practice, @cloudydeno/kubernetes-apis would provide a typed binding for this call:
const podName = await client.performRequest({
method: 'GET',
path: `/api/v1/namespaces/${namespace}/pods`,
expectJson: true,
}).then(x => (x as any).items.at(0).metadata.name as string);
if (!podName) throw new Error(`No pod found in namespace ${namespace}`);

const querystring = new URLSearchParams();
querystring.append('command', 'uptime');
querystring.set('container', 'loop');
querystring.set('container', containerName);
querystring.set('stdout', 'true');
querystring.set('stderr', 'true');

const tunnel = await client.performRequest({
method: 'POST',
path: `/api/v1/namespaces/${'dns'}/pods/${'dns-sync-internet-7bb789dd4-7rg9w'}/exec`,
path: `/api/v1/namespaces/${namespace}/pods/${podName}/exec`,
querystring,
expectTunnel: ['v5.channel.k8s.io'],
});
Expand Down
7 changes: 5 additions & 2 deletions lib/kubeconfig/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import type { RawKubeConfig, ContextConfig, ClusterConfig, UserConfig } from "./

export class KubeConfig {
constructor(
public readonly data: RawKubeConfig,
) {}
data: RawKubeConfig,
) {
this.data = data;
}
public readonly data: RawKubeConfig;

static fromRaw(data: RawKubeConfig): KubeConfig {
return new this(data);
Expand Down
27 changes: 18 additions & 9 deletions lib/kubeconfig/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,21 @@ import {
ExecAuthExtensionName,
isExecCredential,
} from "./definitions.ts";
import { readTextFile } from "./os.ts";

export class KubeConfigContext {
constructor(
public readonly context: ContextConfig,
public readonly cluster: ClusterConfig,
public readonly user: UserConfig,
) {}
context: ContextConfig,
cluster: ClusterConfig,
user: UserConfig,
) {
this.context = context;
this.cluster = cluster;
this.user = user;
}
public readonly context: ContextConfig;
public readonly cluster: ClusterConfig;
public readonly user: UserConfig;
private execCred: ExecCredentialStatus | null = null;

get defaultNamespace(): string | null {
Expand All @@ -32,7 +40,7 @@ export class KubeConfigContext {
} | null> {
let serverCert = atob(this.cluster["certificate-authority-data"] ?? '') || null;
if (!serverCert && this.cluster["certificate-authority"]) {
serverCert = await Deno.readTextFile(this.cluster["certificate-authority"], { signal });
serverCert = await readTextFile(this.cluster["certificate-authority"], { signal });
}

if (serverCert) {
Expand All @@ -47,12 +55,12 @@ export class KubeConfigContext {
} | null> {
let userCert = atob(this.user["client-certificate-data"] ?? '') || null;
if (!userCert && this.user["client-certificate"]) {
userCert = await Deno.readTextFile(this.user["client-certificate"], { signal });
userCert = await readTextFile(this.user["client-certificate"], { signal });
}

let userKey = atob(this.user["client-key-data"] ?? '') || null;
if (!userKey && this.user["client-key"]) {
userKey = await Deno.readTextFile(this.user["client-key"], { signal });
userKey = await readTextFile(this.user["client-key"], { signal });
}

if (!userKey && !userCert && this.user.exec) {
Expand Down Expand Up @@ -82,7 +90,7 @@ export class KubeConfigContext {
return `Bearer ${this.user.token}`;

} else if (this.user.tokenFile) {
const token = await Deno.readTextFile(this.user.tokenFile, { signal });
const token = await readTextFile(this.user.tokenFile, { signal });
return `Bearer ${token.trim()}`;

} else if (this.user['auth-provider']) {
Expand Down Expand Up @@ -123,7 +131,7 @@ export class KubeConfigContext {
const execConfig = this.user['exec'];
if (!execConfig) throw new Error(`BUG: execConfig disappeared`);

const isTTY = Deno.stdin.isTerminal();
const isTTY = globalThis?.Deno.stdin.isTerminal();
const stdinPolicy = execConfig.interactiveMode ?? 'IfAvailable';
if (stdinPolicy == 'Always' && !isTTY) {
throw new Error(`KubeConfig exec plugin wants a TTY, but stdin is not a TTY`);
Expand All @@ -145,6 +153,7 @@ export class KubeConfigContext {
};
}

// TODO: add nodejs impl
const proc = new Deno.Command(execConfig.command, {
args: execConfig.args,
stdin: req.spec.interactive ? 'inherit' : 'null',
Expand Down
32 changes: 20 additions & 12 deletions lib/kubeconfig/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,28 @@ import {
type UserConfig,
} from "./definitions.ts";
import { KubeConfig } from "./config.ts";
import { readTextFile, getEnv } from "./os.ts";

export async function createInClusterConfig({
// Using this baseUrl
baseUrl = 'https://kubernetes.default.svc.cluster.local',
secretsPath = '/var/run/secrets/kubernetes.io/serviceaccount',
signal = undefined as undefined | AbortSignal,
}={}): Promise<KubeConfig> {
// Avoid interactive prompting for in-cluster secrets.
// These are not commonly used from an interactive session.
const readPermission = await Deno.permissions.query({name: 'read', path: secretsPath});
if (readPermission.state !== 'granted') {
throw new Error(`Lacking --allow-read=${secretsPath}`);

if (globalThis.Deno) {
// Avoid interactive prompting for in-cluster secrets.
// These are not commonly used from an interactive session.
const readPermission = await Deno.permissions.query({name: 'read', path: secretsPath});
if (readPermission.state !== 'granted') {
throw new Error(`Lacking --allow-read=${secretsPath}`);
}
}

const [namespace, caData, tokenData] = await Promise.all([
Deno.readTextFile(joinPath(secretsPath, 'namespace'), { signal }),
Deno.readTextFile(joinPath(secretsPath, 'ca.crt'), { signal }),
Deno.readTextFile(joinPath(secretsPath, 'token'), { signal }),
readTextFile(joinPath(secretsPath, 'namespace'), { signal }),
readTextFile(joinPath(secretsPath, 'ca.crt'), { signal }),
readTextFile(joinPath(secretsPath, 'token'), { signal }),
]);

return KubeConfig.fromPieces({
Expand All @@ -47,7 +51,7 @@ export async function createInClusterConfig({
}

export async function createConfigFromPath(path: string, signal?: AbortSignal): Promise<KubeConfig> {
const data = parseYaml(await Deno.readTextFile(path, { signal }));
const data = parseYaml(await readTextFile(path, { signal }));
if (isRawKubeConfig(data)) {
resolveKubeConfigPaths(dirname(path), data);
return new KubeConfig(data);
Expand All @@ -56,13 +60,17 @@ export async function createConfigFromPath(path: string, signal?: AbortSignal):
}

export async function createConfigFromEnvironment(signal?: AbortSignal): Promise<KubeConfig> {
const delim = Deno.build.os === 'windows' ? ';' : ':';
const path = Deno.env.get("KUBECONFIG");
const isWindows = globalThis.Deno
? Deno.build.os === 'windows'
: globalThis.process.platform === 'win32';

const delim = isWindows ? ';' : ':';
const path = getEnv("KUBECONFIG");
const paths = path ? path.split(delim) : [];

if (!path) {
// default file is ignored if it's not found
const defaultPath = joinPath(Deno.env.get("HOME") || Deno.env.get("USERPROFILE") || "/root", ".kube", "config");
const defaultPath = joinPath(getEnv("HOME") || getEnv("USERPROFILE") || "/root", ".kube", "config");
try {
return await createConfigFromPath(defaultPath);
} catch (err: unknown) {
Expand Down
17 changes: 17 additions & 0 deletions lib/kubeconfig/os.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Ponyfile several Deno APIs so that this library can possibly work under NodeJS

export async function readTextFile(path: string, options?: Deno.ReadFileOptions) {
if (globalThis.Deno) {
return await Deno.readTextFile(path, options);
}
const fs = await import('node:fs/promises');
const buffer = await fs.readFile(path, options);
return buffer.toString();
}

export function getEnv(key: string) {
if (globalThis.Deno) {
return Deno.env.get(key);
}
return globalThis.process.env[key];
}
9 changes: 6 additions & 3 deletions transports/autodetection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import type { RestClient } from '../lib/contract.ts';
type ClientProvider = () => Promise<RestClient>;
export class ClientProviderChain {
constructor(
public chain: Array<[string, ClientProvider]>,
) {}
chain: Array<[string, ClientProvider]>,
) {
this.chain = chain;
}
public chain: Array<[string, ClientProvider]>;
async getClient(): Promise<RestClient> {
const errors: Array<string> = [];
for (const [label, factory] of this.chain) {
Expand All @@ -24,7 +27,7 @@ export class ClientProviderChain {
} catch (err) {
const srcName = ` - ${label} `;
if (err instanceof Error) {
errors.push(srcName+(err.stack?.split('\n')[0] || err.message));
errors.push(srcName+(err.cause || err.stack?.split('\n')[0] || err.message));
} else if (err) {
errors.push(srcName+err.toString());
}
Expand Down
7 changes: 7 additions & 0 deletions transports/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
interface UndiciDispatcher {
close(): void;
}

export type FetchClient =
| { client: Deno.HttpClient; dispatcher?: undefined }
| { client?: undefined; dispatcher: UndiciDispatcher };
Loading