Homelab 2 - Deno and cdk8s

This is part of a series on my homelab. You can view my code on GitHub. I’m also happy to answer any questions you might have.

cdk and cdk8s

If you’ve used CloudFormation, then you know how much it sucks. You use a weird dialect of YAML to define your AWS resources. Back in 2017 AWS introduced the cdk library. It allows you to generate your CloudFormation YAML using a real language like Go, Python, Java, or TypeScript.

This idea turned out to be execellent, so they did the same thing for Kubernetes with cdk8s. cdk8s seems to be abandonded, but it still works quite well. Since the TypeScript defitions are generated from Kubernetes’ resources (including third-party custom resource definitions!), the library should continue to work for quite a while longer.

Here’s a “hello world” program from cdk8s’ documentation:

import { import ConstructConstruct } from "constructs";
import { import AppApp, import ChartChart } from "cdk8s";
import { import KubeDeploymentKubeDeployment } from "./imports/k8s";

class class MyChartMyChart extends import ChartChart {
  constructor(scope: Constructscope: import ConstructConstruct, ns: stringns: string, appLabel: stringappLabel: string) {
    super(scope: Constructscope, ns: stringns);

    // Define a Kubernetes Deployment
    new import KubeDeploymentKubeDeployment(this, "my-deployment", {
      
spec: {
    replicas: number;
    selector: {
        matchLabels: {
            app: string;
        };
    };
    template: {
        metadata: {
            labels: {
                app: string;
            };
        };
        spec: {
            containers: {
                name: string;
                image: string;
                ports: {
                    containerPort: number;
                }[];
            }[];
        };
    };
}
spec
: {
replicas: numberreplicas: 3,
selector: {
    matchLabels: {
        app: string;
    };
}
selector
: {
matchLabels: {
    app: string;
}
matchLabels
: { app: stringapp: appLabel: stringappLabel } },
template: {
    metadata: {
        labels: {
            app: string;
        };
    };
    spec: {
        containers: {
            name: string;
            image: string;
            ports: {
                containerPort: number;
            }[];
        }[];
    };
}
template
: {
metadata: {
    labels: {
        app: string;
    };
}
metadata
: {
labels: {
    app: string;
}
labels
: { app: stringapp: appLabel: stringappLabel } },
spec: {
    containers: {
        name: string;
        image: string;
        ports: {
            containerPort: number;
        }[];
    }[];
}
spec
: {
containers: {
    name: string;
    image: string;
    ports: {
        containerPort: number;
    }[];
}[]
containers
: [
{ name: stringname: "app-container", image: stringimage: "nginx:1.19.10",
ports: {
    containerPort: number;
}[]
ports
: [{ containerPort: numbercontainerPort: 80 }],
}, ], }, }, }, }); } } const const app: anyapp = new import AppApp(); new constructor MyChart(scope: Construct, ns: string, appLabel: string): MyChartMyChart(const app: anyapp, "getting-started", "my-app"); const app: anyapp.synth();

The result of running this program is a Kubernetes YAML file that you can deploy using kubectl apply:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: getting-started-my-deployment-c85252a6
spec:
  replicas: 3
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      containers:
        - image: nginx:1.19.10
          name: app-container
          ports:
            - containerPort: 80

Why is this useful? Static typing! cdk8s can inform guide you as you write your Kubernetes resources. For example, it can let you know what properties are valid when you’re creating a resource or let you know when you’ve specifiy an invalid property.

Inspired by Xe's blog

cdk8s has support for all of Kubernete’s resources. These definitions are generated by the cdk8s import command, which generates types for every Kubernetes resource on your server including CRDs (custom resource definitions). Here’s an example of a generated definition for 1Password, which I use to handle all of the secrets in my Kubernetes cluster:

export class class OnePasswordItemOnePasswordItem extends ApiObject {
  public constructor(scope: Constructscope: type Construct = /*unresolved*/ anyConstruct, id: stringid: string, props: OnePasswordItemPropsprops: OnePasswordItemProps = {}) {
    super(scope: Constructscope, id: stringid, {
      ...class OnePasswordItemOnePasswordItem.GVK,
      ...props: OnePasswordItemPropsprops,
    });
  }
}

export interface OnePasswordItemProps {
  readonly OnePasswordItemProps.metadata?: anymetadata?: type ApiObjectMetadata = /*unresolved*/ anyApiObjectMetadata;
  readonly OnePasswordItemProps.spec?: OnePasswordItemSpec | undefinedspec?: OnePasswordItemSpec;
  readonly OnePasswordItemProps.type?: string | undefinedtype?: string;
}

export interface OnePasswordItemSpec {
  readonly OnePasswordItemSpec.itemPath?: string | undefineditemPath?: string;
}

Here’s how I use it to store my Tailscale key:

new OnePasswordItem(chart, "tailscale-operator-oauth-onepassword", {
  
spec: {
    itemPath: string;
}
spec
: {
itemPath: stringitemPath: "vaults/v64ocnykdqju4ui6j6pua56xw4/items/mboftvs4fyptyqvg3anrfjy6vu", },
metadata: {
    name: string;
    namespace: string;
}
metadata
: {
name: stringname: "operator-oauth", namespace: stringnamespace: "tailscale", }, });

Takeaway: cdk8s supports all Kubernetes resources, including third-party resources from 1Password, Tailscale, Traefik, etc.

Deno

Okay, so we have a way to write our all of our Kubernetes definitions in TypeScript. How do we actually compile our TypeScript to YAML?

The traditional way would be to use NodeJS, install TypeScript, compile the TypeScript to JavaScript, and then execute the result. This would work just fine!

However, I’m not a big fan of Node and would prefer to use a tool with TypeScript support built-in. I love the modern toolchains that languages like Rust and Go have. You can get something similar for TypeScript with Deno and Bun which are alternatives to NodeJS with native TypeScript support.

I’ve only really used Deno, so that’s what I’ll be showing in this post. I’m sure that Bun would work similarly well!

You’ll need to install Deno — this will include everything you need to compile and run a TypeScript program.

Deno has a few quirks — most of them are around imports. With Node you declare your dependencies in a package.json and your code will pull from node_modules. With Deno you declare your dependencies (and their versions) in your code and Deno will take care of downloading at runtime.

Because of this, the import format is a bit different. Rather than directly calling cdk8s import, we can use this script to create the correct imports for our generated Kubernetes types.

#!/usr/bin/env -S deno run --allow-run --allow-read --allow-write

// delete the imports directory
await Deno.remove("imports", { recursive: booleanrecursive: true });

// run "cdk8s import k8s --language=typescript"
let let command: anycommand = new Deno.Command("cdk8s", {
  args: string[]args: ["import", "k8s", "--language=typescript"],
});
var console: Console
The `console` module provides a simple debugging console that is similar to the JavaScript console mechanism provided by web browsers. The module exports two specific components: * A `Console` class with methods such as `console.log()`, `console.error()` and `console.warn()` that can be used to write to any Node.js stream. * A global `console` instance configured to write to [`process.stdout`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstdout) and [`process.stderr`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstderr). The global `console` can be used without importing the `node:console` module. _**Warning**_: The global console object's methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams. See the [`note on process I/O`](https://nodejs.org/docs/latest-v22.x/api/process.html#a-note-on-process-io) for more information. Example using the global `console`: ```js console.log('hello world'); // Prints: hello world, to stdout console.log('hello %s', 'world'); // Prints: hello world, to stdout console.error(new Error('Whoops, something bad happened')); // Prints error message and stack trace to stderr: // Error: Whoops, something bad happened // at [eval]:5:15 // at Script.runInThisContext (node:vm:132:18) // at Object.runInThisContext (node:vm:309:38) // at node:internal/process/execution:77:19 // at [eval]-wrapper:6:22 // at evalScript (node:internal/process/execution:76:60) // at node:internal/main/eval_string:23:3 const name = 'Will Robinson'; console.warn(`Danger ${name}! Danger!`); // Prints: Danger Will Robinson! Danger!, to stderr ``` Example using the `Console` class: ```js const out = getStreamSomehow(); const err = getStreamSomehow(); const myConsole = new console.Console(out, err); myConsole.log('hello world'); // Prints: hello world, to out myConsole.log('hello %s', 'world'); // Prints: hello world, to out myConsole.error(new Error('Whoops, something bad happened')); // Prints: [Error: Whoops, something bad happened], to err const name = 'Will Robinson'; myConsole.warn(`Danger ${name}! Danger!`); // Prints: Danger Will Robinson! Danger!, to err ```
@see[source](https://github.com/nodejs/node/blob/v22.x/lib/console.js)
console
.Console.log(message?: any, ...optionalParams: any[]): void (+1 overload)
Prints to `stdout` with newline. Multiple arguments can be passed, with the first used as the primary message and all additional used as substitution values similar to [`printf(3)`](http://man7.org/linux/man-pages/man3/printf.3.html) (the arguments are all passed to [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args)). ```js const count = 5; console.log('count: %d', count); // Prints: count: 5, to stdout console.log('count:', count); // Prints: count: 5, to stdout ``` See [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args) for more information.
@sincev0.1.100
log
(new var TextDecoder: new (label?: string, options?: TextDecoderOptions) => TextDecoder
A decoder for a specific method, that is a specific character encoding, like utf-8, iso-8859-2, koi8, cp1261, gbk, etc. A decoder takes a stream of bytes as input and emits a stream of code points. For a more scalable, non-native library, see StringView – a C-like representation of strings based on typed arrays. [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder) `TextDecoder` class is a global reference for `import { TextDecoder } from 'node:util'` https://nodejs.org/api/globals.html#textdecoder
@sincev11.0.0
TextDecoder
().TextDecoder.decode(input?: AllowSharedBufferSource, options?: TextDecodeOptions): string
Returns the result of running encoding's decoder. The method can be invoked zero or more times with options's stream set to true, and then once without options's stream (or set to false), to process a fragmented input. If the invocation without options's stream (or set to false) has no input, it's clearest to omit both arguments. ``` var string = "", decoder = new TextDecoder(encoding), buffer; while(buffer = next_chunk()) { string += decoder.decode(buffer, {stream:true}); } string += decoder.decode(); // end-of-queue ``` If the error mode is "fatal" and encoding's decoder returns error, throws a TypeError. [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/decode)
decode
((await let command: anycommand.output()).stdout));
// run "kubectl get crds -o json | cdk8s import /dev/stdin --language=typescript" let command: anycommand = new Deno.Command("bash", { args: string[]args: [ "-c", "kubectl get crds -o json | cdk8s import /dev/stdin --language=typescript", ], }); var console: Console
The `console` module provides a simple debugging console that is similar to the JavaScript console mechanism provided by web browsers. The module exports two specific components: * A `Console` class with methods such as `console.log()`, `console.error()` and `console.warn()` that can be used to write to any Node.js stream. * A global `console` instance configured to write to [`process.stdout`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstdout) and [`process.stderr`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstderr). The global `console` can be used without importing the `node:console` module. _**Warning**_: The global console object's methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams. See the [`note on process I/O`](https://nodejs.org/docs/latest-v22.x/api/process.html#a-note-on-process-io) for more information. Example using the global `console`: ```js console.log('hello world'); // Prints: hello world, to stdout console.log('hello %s', 'world'); // Prints: hello world, to stdout console.error(new Error('Whoops, something bad happened')); // Prints error message and stack trace to stderr: // Error: Whoops, something bad happened // at [eval]:5:15 // at Script.runInThisContext (node:vm:132:18) // at Object.runInThisContext (node:vm:309:38) // at node:internal/process/execution:77:19 // at [eval]-wrapper:6:22 // at evalScript (node:internal/process/execution:76:60) // at node:internal/main/eval_string:23:3 const name = 'Will Robinson'; console.warn(`Danger ${name}! Danger!`); // Prints: Danger Will Robinson! Danger!, to stderr ``` Example using the `Console` class: ```js const out = getStreamSomehow(); const err = getStreamSomehow(); const myConsole = new console.Console(out, err); myConsole.log('hello world'); // Prints: hello world, to out myConsole.log('hello %s', 'world'); // Prints: hello world, to out myConsole.error(new Error('Whoops, something bad happened')); // Prints: [Error: Whoops, something bad happened], to err const name = 'Will Robinson'; myConsole.warn(`Danger ${name}! Danger!`); // Prints: Danger Will Robinson! Danger!, to err ```
@see[source](https://github.com/nodejs/node/blob/v22.x/lib/console.js)
console
.Console.log(message?: any, ...optionalParams: any[]): void (+1 overload)
Prints to `stdout` with newline. Multiple arguments can be passed, with the first used as the primary message and all additional used as substitution values similar to [`printf(3)`](http://man7.org/linux/man-pages/man3/printf.3.html) (the arguments are all passed to [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args)). ```js const count = 5; console.log('count: %d', count); // Prints: count: 5, to stdout console.log('count:', count); // Prints: count: 5, to stdout ``` See [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args) for more information.
@sincev0.1.100
log
(new var TextDecoder: new (label?: string, options?: TextDecoderOptions) => TextDecoder
A decoder for a specific method, that is a specific character encoding, like utf-8, iso-8859-2, koi8, cp1261, gbk, etc. A decoder takes a stream of bytes as input and emits a stream of code points. For a more scalable, non-native library, see StringView – a C-like representation of strings based on typed arrays. [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder) `TextDecoder` class is a global reference for `import { TextDecoder } from 'node:util'` https://nodejs.org/api/globals.html#textdecoder
@sincev11.0.0
TextDecoder
().TextDecoder.decode(input?: AllowSharedBufferSource, options?: TextDecodeOptions): string
Returns the result of running encoding's decoder. The method can be invoked zero or more times with options's stream set to true, and then once without options's stream (or set to false), to process a fragmented input. If the invocation without options's stream (or set to false) has no input, it's clearest to omit both arguments. ``` var string = "", decoder = new TextDecoder(encoding), buffer; while(buffer = next_chunk()) { string += decoder.decode(buffer, {stream:true}); } string += decoder.decode(); // end-of-queue ``` If the error mode is "fatal" and encoding's decoder returns error, throws a TypeError. [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/decode)
decode
((await let command: anycommand.output()).stdout));
const const files: anyfiles = Deno.readDir("imports"); // add "// deno-lint-ignore-file" to the top of each file in the imports directory for await (const const file: anyfile of const files: anyfiles) { if (const file: anyfile.isFile) { const const filePath: stringfilePath = `imports/${const file: anyfile.name}`; const const content: anycontent = await Deno.readTextFile(const filePath: stringfilePath); await Deno.writeTextFile( const filePath: stringfilePath, `// deno-lint-ignore-file\n${const content: anycontent}`, ); } } // look for "public toJson(): any {", change this to "public override toJson(): any {" // fixes This member must have an 'override' modifier because it overrides a member in the base class 'ApiObject'. for await (const const file: anyfile of const files: anyfiles) { if (const file: anyfile.isFile) { const const filePath: stringfilePath = `imports/${const file: anyfile.name}`; let let content: anycontent = await Deno.readTextFile(const filePath: stringfilePath); let content: anycontent = let content: anycontent.replaceAll( "public toJson(): any {", "public override toJson(): any {", ); await Deno.writeTextFile( const filePath: stringfilePath, let content: anycontent, ); } } // replace the npm import with the deno import for await (const const file: anyfile of const files: anyfiles) { if (const file: anyfile.isFile) { const const filePath: stringfilePath = `imports/${const file: anyfile.name}`; let let content: anycontent = await Deno.readTextFile(const filePath: stringfilePath); let content: anycontent = let content: anycontent.replaceAll( "from 'cdk8s'", "from 'https://esm.sh/[email protected]'", ); let content: anycontent = let content: anycontent.replaceAll( "from 'constructs'", "from 'https://esm.sh/[email protected]'", ); await Deno.writeTextFile( const filePath: stringfilePath, let content: anycontent, ); } } // run deno fmt let command: anycommand = new Deno.Command("deno", { args: string[]args: ["fmt", "imports"], }); var console: Console
The `console` module provides a simple debugging console that is similar to the JavaScript console mechanism provided by web browsers. The module exports two specific components: * A `Console` class with methods such as `console.log()`, `console.error()` and `console.warn()` that can be used to write to any Node.js stream. * A global `console` instance configured to write to [`process.stdout`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstdout) and [`process.stderr`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstderr). The global `console` can be used without importing the `node:console` module. _**Warning**_: The global console object's methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams. See the [`note on process I/O`](https://nodejs.org/docs/latest-v22.x/api/process.html#a-note-on-process-io) for more information. Example using the global `console`: ```js console.log('hello world'); // Prints: hello world, to stdout console.log('hello %s', 'world'); // Prints: hello world, to stdout console.error(new Error('Whoops, something bad happened')); // Prints error message and stack trace to stderr: // Error: Whoops, something bad happened // at [eval]:5:15 // at Script.runInThisContext (node:vm:132:18) // at Object.runInThisContext (node:vm:309:38) // at node:internal/process/execution:77:19 // at [eval]-wrapper:6:22 // at evalScript (node:internal/process/execution:76:60) // at node:internal/main/eval_string:23:3 const name = 'Will Robinson'; console.warn(`Danger ${name}! Danger!`); // Prints: Danger Will Robinson! Danger!, to stderr ``` Example using the `Console` class: ```js const out = getStreamSomehow(); const err = getStreamSomehow(); const myConsole = new console.Console(out, err); myConsole.log('hello world'); // Prints: hello world, to out myConsole.log('hello %s', 'world'); // Prints: hello world, to out myConsole.error(new Error('Whoops, something bad happened')); // Prints: [Error: Whoops, something bad happened], to err const name = 'Will Robinson'; myConsole.warn(`Danger ${name}! Danger!`); // Prints: Danger Will Robinson! Danger!, to err ```
@see[source](https://github.com/nodejs/node/blob/v22.x/lib/console.js)
console
.Console.log(message?: any, ...optionalParams: any[]): void (+1 overload)
Prints to `stdout` with newline. Multiple arguments can be passed, with the first used as the primary message and all additional used as substitution values similar to [`printf(3)`](http://man7.org/linux/man-pages/man3/printf.3.html) (the arguments are all passed to [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args)). ```js const count = 5; console.log('count: %d', count); // Prints: count: 5, to stdout console.log('count:', count); // Prints: count: 5, to stdout ``` See [`util.format()`](https://nodejs.org/docs/latest-v22.x/api/util.html#utilformatformat-args) for more information.
@sincev0.1.100
log
(new var TextDecoder: new (label?: string, options?: TextDecoderOptions) => TextDecoder
A decoder for a specific method, that is a specific character encoding, like utf-8, iso-8859-2, koi8, cp1261, gbk, etc. A decoder takes a stream of bytes as input and emits a stream of code points. For a more scalable, non-native library, see StringView – a C-like representation of strings based on typed arrays. [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder) `TextDecoder` class is a global reference for `import { TextDecoder } from 'node:util'` https://nodejs.org/api/globals.html#textdecoder
@sincev11.0.0
TextDecoder
().TextDecoder.decode(input?: AllowSharedBufferSource, options?: TextDecodeOptions): string
Returns the result of running encoding's decoder. The method can be invoked zero or more times with options's stream set to true, and then once without options's stream (or set to false), to process a fragmented input. If the invocation without options's stream (or set to false) has no input, it's clearest to omit both arguments. ``` var string = "", decoder = new TextDecoder(encoding), buffer; while(buffer = next_chunk()) { string += decoder.decode(buffer, {stream:true}); } string += decoder.decode(); // end-of-queue ``` If the error mode is "fatal" and encoding's decoder returns error, throws a TypeError. [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/decode)
decode
((await let command: anycommand.output()).stdout));

We can save this file and then run it with the deno command line, e.g. deno <file>.

Next, we’ll need to update our app’s imports. We can take the program we wrote up above and change the imports to use esm.sh.

import { import ConstructConstruct } from "https://esm.sh/[email protected]";
import { import AppApp, import ChartChart } from "https://esm.sh/[email protected]";
import { import KubeDeploymentKubeDeployment } from "./imports/k8s";

Now, assuming our code is stored in app.ts, we can run deno run app.ts. This will compile our TypeScript code to Kubernetes YAML using Deno. Just like before, we can use kubectl apply to actually create these resources.

Conclusion

In the past two posts I’ve shown you how to create a Kubernetes cluster using k3s and use cdk8s + Deno to deploy resources to your cluster. In my next post I’ll cover automating deployments using ArgoCD.

Recent posts from blogs that I like

Inglorious mud: 1 On the move

How the rich paid to walk on planks to cross muddy streets, and hussars helped ladies over mud ruts, children at play, roads in London and Leeds, and a cheeky ploughboy.

via The Eclectic Light Company

The case for sans-io

via fasterthanlime

Using pip to install a Large Language Model that's under 100MB

via Simon Willison