diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2571fec..ee80484 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,22 @@ importers: specifier: ^0.1.13 version: 0.1.14 + with-k3s: + dependencies: + freestyle-sandboxes: + specifier: ^0.1.14 + version: 0.1.14 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.7 + pkgroll: + specifier: ^2.11.2 + version: 2.21.5(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + with-nodejs: dependencies: '@freestyle-sh/with-type-js': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7df1e1b..f1a604a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,4 +9,5 @@ packages: - "with-web-terminal" - "with-opencode" - "with-sandbox-agent" + - "with-k3s" - "types/*" diff --git a/with-k3s/README.md b/with-k3s/README.md new file mode 100644 index 0000000..e4340d4 --- /dev/null +++ b/with-k3s/README.md @@ -0,0 +1,160 @@ +# @freestyle-sh/with-k3s + +A Freestyle VM extension that adds [k3s](https://k3s.io/) (lightweight Kubernetes) support to your VMs. + +## Installation + +```bash +npm install @freestyle-sh/with-k3s freestyle-sandboxes +# or +pnpm add @freestyle-sh/with-k3s freestyle-sandboxes +``` + +## Usage + +### Basic Example + +```typescript +import { freestyle, VmSpec } from "freestyle-sandboxes"; +import { VmK3s } from "@freestyle-sh/with-k3s"; + +const spec = new VmSpec({ + with: { + k3s: new VmK3s(), + }, +}); + +const { vm, vmId } = await freestyle.vms.create({ spec }); + +// Get cluster info +const clusterInfo = await vm.k3s.getClusterInfo(); +console.log(clusterInfo.stdout); + +// Apply a manifest +const manifest = ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx +spec: + replicas: 1 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:latest +`; + +await vm.k3s.applyManifest(manifest); + +// Get resources +const pods = await vm.k3s.getResources("pods", "default"); +console.log(pods.data); + +await freestyle.vms.delete({ vmId }); +``` + +### Configuration Options + +```typescript +new VmK3s({ + version: "v1.28.5+k3s1", // Optional: specific k3s version + serverArgs: ["--disable=traefik"], // Optional: additional k3s server arguments +}); +``` + +## API + +### `VmK3sInstance` + +The instance provides methods for interacting with the k3s cluster: + +#### `kubectl(args: string[]): Promise` + +Execute a kubectl command. + +```typescript +const result = await vm.k3s.kubectl(["get", "pods", "-A"]); +console.log(result.stdout); +``` + +#### `applyManifest(manifest: string): Promise` + +Apply a Kubernetes manifest. + +```typescript +const result = await vm.k3s.applyManifest(yamlManifest); +``` + +#### `deleteResource(resourceType: string, name: string, namespace?: string): Promise` + +Delete a Kubernetes resource. + +```typescript +await vm.k3s.deleteResource("deployment", "nginx", "default"); +``` + +#### `getResources(resourceType: string, namespace?: string): Promise<{success: boolean; data?: T; error?: string}>` + +Get resources as JSON. + +```typescript +const deployments = await vm.k3s.getResources("deployments", "default"); +if (deployments.success) { + console.log(deployments.data); +} +``` + +#### `getKubeconfig(): Promise<{success: boolean; content?: string; error?: string}>` + +Get the kubeconfig file content. + +```typescript +const config = await vm.k3s.getKubeconfig(); +if (config.success) { + console.log(config.content); +} +``` + +#### `getClusterInfo(): Promise` + +Get cluster information. + +```typescript +const info = await vm.k3s.getClusterInfo(); +``` + +#### `getNodes(): Promise` + +Get all nodes in the cluster. + +```typescript +const nodes = await vm.k3s.getNodes(); +``` + +## Examples + +Check out the [examples](./examples) directory for more detailed usage: + +- [basic.ts](./examples/basic.ts) - Basic usage with nginx deployment +- [advanced.ts](./examples/advanced.ts) - Advanced usage with namespace, service, and deployment + +## Features + +- ✅ Automatic k3s installation via systemd service +- ✅ Support for specific k3s versions +- ✅ Full kubectl command execution +- ✅ Manifest application and deletion +- ✅ JSON resource queries +- ✅ Kubeconfig access +- ✅ Cluster info and node inspection + +## License + +MIT diff --git a/with-k3s/examples/advanced.ts b/with-k3s/examples/advanced.ts new file mode 100644 index 0000000..0530129 --- /dev/null +++ b/with-k3s/examples/advanced.ts @@ -0,0 +1,106 @@ +import "dotenv/config"; +import { freestyle, VmSpec } from "freestyle-sandboxes"; +import { VmK3s } from "../src/index.js"; + +// Create a VM with k3s installed +const spec = new VmSpec({ + with: { + k3s: new VmK3s(), + }, +}); + +const { vm, vmId } = await freestyle.vms.create({ spec }); + +console.log("K3s VM created successfully!"); + +// Create a complete application with deployment and service +const manifest = ` +apiVersion: v1 +kind: Namespace +metadata: + name: demo-app +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hello-world + namespace: demo-app +spec: + replicas: 2 + selector: + matchLabels: + app: hello-world + template: + metadata: + labels: + app: hello-world + spec: + containers: + - name: hello-world + image: gcr.io/google-samples/hello-app:1.0 + ports: + - containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: hello-world-service + namespace: demo-app +spec: + selector: + app: hello-world + ports: + - protocol: TCP + port: 80 + targetPort: 8080 + type: ClusterIP +`; + +console.log("\nApplying complete application manifest..."); +const result = await vm.k3s.applyManifest(manifest); +console.log("Apply result:", result.success ? "Success" : "Failed"); + +// Wait for resources to be created +await new Promise((resolve) => setTimeout(resolve, 3000)); + +// Check namespace +console.log("\nNamespaces:"); +const nsResult = await vm.k3s.kubectl(["get", "namespaces"]); +console.log(nsResult.stdout); + +// Check deployments in demo-app namespace +console.log("\nDeployments in demo-app namespace:"); +const deployments = await vm.k3s.getResources("deployments", "demo-app"); +if (deployments.success && deployments.data) { + const items = (deployments.data as any).items || []; + items.forEach((item: any) => { + console.log(`- ${item.metadata.name}: ${item.status.replicas || 0} replicas`); + }); +} + +// Check services +console.log("\nServices in demo-app namespace:"); +const services = await vm.k3s.getResources("services", "demo-app"); +if (services.success && services.data) { + const items = (services.data as any).items || []; + items.forEach((item: any) => { + console.log(`- ${item.metadata.name}: ${item.spec.clusterIP}`); + }); +} + +// Execute a custom kubectl command +console.log("\nGetting all resources:"); +const allResources = await vm.k3s.kubectl([ + "get", + "all", + "-n", + "demo-app", +]); +console.log(allResources.stdout); + +// Clean up +console.log("\nCleaning up..."); +await vm.k3s.kubectl(["delete", "namespace", "demo-app"]); + +await freestyle.vms.delete({ vmId }); +console.log("VM deleted successfully!"); diff --git a/with-k3s/examples/basic.ts b/with-k3s/examples/basic.ts new file mode 100644 index 0000000..b2e7135 --- /dev/null +++ b/with-k3s/examples/basic.ts @@ -0,0 +1,86 @@ +import "dotenv/config"; +import { freestyle, VmSpec } from "freestyle-sandboxes"; +import { VmK3s } from "../src/index.js"; + +const spec = new VmSpec({ + with: { + k3s: new VmK3s(), + }, +}); + +const { vm, vmId } = await freestyle.vms.create({ spec }); + +console.log("K3s VM created successfully!"); +console.log("VM ID:", vmId); + +// Get cluster info +const clusterInfo = await vm.k3s.getClusterInfo(); +console.log("\nCluster Info:"); +console.log(clusterInfo.stdout); + +// Get nodes +const nodes = await vm.k3s.getNodes(); +console.log("\nNodes:"); +console.log(nodes.stdout); + +// Apply a simple nginx deployment +const nginxManifest = ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 1 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:latest + ports: + - containerPort: 80 +`; + +console.log("\nApplying nginx deployment..."); +const applyResult = await vm.k3s.applyManifest(nginxManifest); +console.log("Apply result:", applyResult.success ? "Success" : "Failed"); +if (applyResult.stdout) { + console.log(applyResult.stdout); +} +if (applyResult.stderr) { + console.error(applyResult.stderr); +} + +// Wait a bit for the deployment to be processed +await new Promise((resolve) => setTimeout(resolve, 2000)); + +// Get deployments +const deployments = await vm.k3s.getResources("deployments", "default"); +console.log("\nDeployments:"); +if (deployments.success && deployments.data) { + console.log(JSON.stringify(deployments.data, null, 2)); +} + +// Get pods +const pods = await vm.k3s.kubectl(["get", "pods", "-o", "wide"]); +console.log("\nPods:"); +console.log(pods.stdout); + +// Get kubeconfig +const kubeconfigResult = await vm.k3s.getKubeconfig(); +console.log("\nKubeconfig available:", kubeconfigResult.success); + +// Cleanup +console.log("\nCleaning up..."); +const deleteResult = await vm.k3s.deleteResource("deployment", "nginx-deployment", "default"); +console.log("Delete result:", deleteResult.success ? "Success" : "Failed"); + +await freestyle.vms.delete({ vmId }); +console.log("VM deleted successfully!"); diff --git a/with-k3s/package.json b/with-k3s/package.json new file mode 100644 index 0000000..6c9e328 --- /dev/null +++ b/with-k3s/package.json @@ -0,0 +1,31 @@ +{ + "name": "@freestyle-sh/with-k3s", + "version": "0.0.1", + "packageManager": "pnpm@10.11.0", + "private": false, + "dependencies": { + "freestyle-sandboxes": "^0.1.14" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "pkgroll": "^2.11.2", + "typescript": "^5.8.3" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "source": "./src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "pkgroll", + "prepublishOnly": "pnpm run build" + } +} diff --git a/with-k3s/src/index.ts b/with-k3s/src/index.ts new file mode 100644 index 0000000..b819809 --- /dev/null +++ b/with-k3s/src/index.ts @@ -0,0 +1,239 @@ +import { + VmSpec, + VmWith, + VmWithInstance, +} from "freestyle-sandboxes"; + +export type VmK3sOptions = { + /** + * K3s version to install (e.g., "v1.28.5+k3s1") + * If not specified, installs the latest stable version + */ + version?: string; + /** + * Additional k3s server arguments + */ + serverArgs?: string[]; +}; + +export type VmK3sResolvedOptions = { + version?: string; + serverArgs: string[]; +}; + +export interface KubectlResult { + success: boolean; + stdout?: string; + stderr?: string; + statusCode: number; +} + +export interface ApplyManifestResult { + success: boolean; + stdout?: string; + stderr?: string; +} + +export class VmK3s extends VmWith { + options: VmK3sResolvedOptions; + + constructor(options?: VmK3sOptions) { + super(); + this.options = { + version: options?.version, + serverArgs: options?.serverArgs ?? [], + }; + } + + override configureSnapshotSpec(spec: VmSpec): VmSpec { + const versionArg = this.options.version ? `INSTALL_K3S_VERSION=${this.options.version}` : ""; + const serverArgsStr = this.options.serverArgs.length > 0 + ? `INSTALL_K3S_EXEC="${this.options.serverArgs.join(" ")}"` + : ""; + + const installScript = `#!/bin/bash +set -e + +# Install k3s using the official installer +${versionArg} ${serverArgsStr} curl -sfL https://get.k3s.io | sh - + +# Wait for k3s to be ready +until kubectl get nodes 2>/dev/null; do + echo "Waiting for k3s to start..." + sleep 2 +done + +echo "K3s is ready!" +kubectl version +`; + + return this.composeSpecs( + spec, + new VmSpec({ + additionalFiles: { + "/opt/install-k3s.sh": { + content: installScript, + }, + }, + systemd: { + services: [ + { + name: "install-k3s", + mode: "oneshot", + deleteAfterSuccess: true, + exec: ["bash /opt/install-k3s.sh"], + timeoutSec: 600, + }, + ], + }, + }), + ); + } + + createInstance(): VmK3sInstance { + return new VmK3sInstance(this); + } + + installServiceName(): string { + return "install-k3s.service"; + } +} + +export class VmK3sInstance extends VmWithInstance { + builder: VmK3s; + + constructor(builder: VmK3s) { + super(); + this.builder = builder; + } + + /** + * Execute a kubectl command + * @param args - kubectl command arguments (e.g., ["get", "pods"]) + * @returns Result with stdout, stderr, and status + */ + async kubectl(args: string[]): Promise { + const command = `kubectl ${args.join(" ")}`; + const result = await this.vm.exec({ command }); + + return { + success: result.statusCode === 0, + stdout: result.stdout ?? undefined, + stderr: result.stderr ?? undefined, + statusCode: result.statusCode ?? -1, + }; + } + + /** + * Apply a Kubernetes manifest + * @param manifest - YAML manifest content + * @returns Result indicating success or failure + */ + async applyManifest(manifest: string): Promise { + // Use a temporary file to avoid shell injection risks + const tmpFile = `/tmp/k3s-manifest-${Date.now()}.yaml`; + const escapedManifest = manifest.replace(/'/g, "'\\''"); + const command = `cat > '${tmpFile}' << 'EOF'\n${manifest}\nEOF\nkubectl apply -f '${tmpFile}' && rm -f '${tmpFile}'`; + + const result = await this.vm.exec({ command }); + + return { + success: result.statusCode === 0, + stdout: result.stdout ?? undefined, + stderr: result.stderr ?? undefined, + }; + } + + /** + * Delete a Kubernetes resource + * @param resourceType - Type of resource (e.g., "pod", "deployment") + * @param name - Name of the resource + * @param namespace - Optional namespace (defaults to "default") + * @returns Result indicating success or failure + */ + async deleteResource( + resourceType: string, + name: string, + namespace = "default" + ): Promise { + return this.kubectl(["delete", resourceType, name, "-n", namespace]); + } + + /** + * Get resources as JSON + * @param resourceType - Type of resource (e.g., "pods", "services") + * @param namespace - Optional namespace (defaults to "default") + * @returns Parsed JSON result + */ + async getResources( + resourceType: string, + namespace = "default" + ): Promise<{ success: boolean; data?: T; error?: string }> { + const result = await this.kubectl([ + "get", + resourceType, + "-n", + namespace, + "-o", + "json", + ]); + + if (!result.success) { + return { + success: false, + error: result.stderr || "Failed to get resources", + }; + } + + try { + const data = JSON.parse(result.stdout || "{}"); + return { + success: true, + data, + }; + } catch (e) { + return { + success: false, + error: `Failed to parse JSON: ${e}`, + }; + } + } + + /** + * Get the kubeconfig content + * @returns Kubeconfig content as string + */ + async getKubeconfig(): Promise<{ success: boolean; content?: string; error?: string }> { + const result = await this.vm.exec({ + command: "cat /etc/rancher/k3s/k3s.yaml", + }); + + if (result.statusCode !== 0) { + return { + success: false, + error: result.stderr || "Failed to read kubeconfig", + }; + } + + return { + success: true, + content: result.stdout ?? undefined, + }; + } + + /** + * Get cluster info + * @returns Cluster information + */ + async getClusterInfo(): Promise { + return this.kubectl(["cluster-info"]); + } + + /** + * Get all nodes in the cluster + * @returns List of nodes + */ + async getNodes(): Promise { + return this.kubectl(["get", "nodes", "-o", "wide"]); + } +} diff --git a/with-k3s/tsconfig.json b/with-k3s/tsconfig.json new file mode 100644 index 0000000..7245985 --- /dev/null +++ b/with-k3s/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist" + }, + "include": [ + "src/index.ts" + ], + "exclude": [ + "node_modules", + "dist", + "examples" + ] +}