diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4df1c8..904259d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,22 @@ jobs: with: go-version-file: go.mod + - name: Setup Helm + uses: azure/setup-helm@v4 + - name: Build run: go build ./... - name: Test run: go test ./... + + - name: Helm lint + run: helm lint chart/agentikube/ + + - name: Helm template + run: | + helm template agentikube chart/agentikube/ \ + --namespace sandboxes \ + --set storage.filesystemId=fs-test \ + --set sandbox.image=test:latest \ + --set compute.clusterName=test-cluster diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bbf975f..b754e7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,7 @@ on: permissions: contents: write + packages: write concurrency: group: release-${{ github.ref }} @@ -29,6 +30,9 @@ jobs: with: go-version-file: go.mod + - name: Setup Helm + uses: azure/setup-helm@v4 + - name: Compute next version id: version shell: bash @@ -93,3 +97,17 @@ jobs: name: ${{ steps.version.outputs.next_tag }} generate_release_notes: true files: dist/* + + - name: Set chart version + run: | + sed -i "s/^version:.*/version: ${{ steps.version.outputs.version }}/" chart/agentikube/Chart.yaml + sed -i "s/^appVersion:.*/appVersion: \"${{ steps.version.outputs.version }}\"/" chart/agentikube/Chart.yaml + + - name: Package Helm chart + run: helm package chart/agentikube/ --destination .helm-pkg + + - name: Log in to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin + + - name: Push Helm chart to GHCR + run: helm push .helm-pkg/agentikube-${{ steps.version.outputs.version }}.tgz oci://ghcr.io/${{ github.repository_owner }} diff --git a/.gitignore b/.gitignore index 6198882..16d3c4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ .cache -agentikube diff --git a/Makefile b/Makefile index 160de4a..f68ed93 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build install clean fmt vet lint +.PHONY: build install clean fmt vet lint crds helm-lint helm-template build: go build -o agentikube ./cmd/agentikube @@ -16,3 +16,16 @@ vet: go vet ./... lint: fmt vet + +crds: + ./scripts/download-crds.sh + +helm-lint: + helm lint chart/agentikube/ + +helm-template: + helm template agentikube chart/agentikube/ \ + --namespace sandboxes \ + --set storage.filesystemId=fs-test \ + --set sandbox.image=test:latest \ + --set compute.clusterName=test-cluster diff --git a/README.md b/README.md index a641999..d566e76 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,85 @@ # agentikube [![Go Version](https://img.shields.io/github/go-mod/go-version/harivansh-afk/agentikube)](https://github.com/harivansh-afk/agentikube/blob/main/go.mod) +[![Helm Version](https://img.shields.io/badge/helm%20chart-0.1.0-blue)](https://github.com/harivansh-afk/agentikube/tree/main/chart/agentikube) [![Release](https://img.shields.io/github/v/release/harivansh-afk/agentikube)](https://github.com/harivansh-afk/agentikube/releases/latest) -A small Go CLI that spins up isolated agent sandboxes on Kubernetes. Built for AWS setups (EFS + optional Karpenter). +Isolated stateful agent sandboxes on Kubernetes image -## What it does - -- **`init`** - Installs CRDs, checks prerequisites, ensures your namespace exists -- **`up`** - Renders and applies Kubernetes manifests from templates (`--dry-run` to preview) -- **`create `** - Spins up a sandbox for a user with provider credentials -- **`list`** - Shows all sandboxes with status, age, and pod name -- **`status`** - Warm pool numbers, sandbox count, Karpenter node count -- **`ssh `** - Drops you into a sandbox pod shell -- **`destroy `** - Tears down a single sandbox -- **`down`** - Removes shared infra but keeps existing user sandboxes - -## Quick start +## Install ```bash -# 1. Copy and fill in your config -cp agentikube.example.yaml agentikube.yaml -# Edit: namespace, EFS filesystem ID, sandbox image, compute settings +helm install agentikube oci://ghcr.io/harivansh-afk/agentikube \ + -n sandboxes --create-namespace \ + -f my-values.yaml +``` -# 2. Set things up -agentikube init -agentikube up +Create a `my-values.yaml` with your cluster details: -# 3. Create a sandbox and jump in +```yaml +compute: + clusterName: my-eks-cluster +storage: + filesystemId: fs-0123456789abcdef0 +sandbox: + image: my-registry/sandbox:latest +``` + +See [`values.yaml`](chart/agentikube/values.yaml) for all options. + +## CLI + +The Go CLI handles runtime operations that are inherently imperative: + +```bash agentikube create demo --provider openai --api-key agentikube list agentikube ssh demo +agentikube status +agentikube destroy demo ``` +Build it with `go build ./cmd/agentikube` or `make build`. + ## What gets created -Running `up` applies these to your cluster: +The Helm chart installs: -- Namespace, StorageClass (`efs-sandbox`), SandboxTemplate -- Optionally: SandboxWarmPool, NodePool + EC2NodeClass (Karpenter) +- StorageClass (`efs-sandbox`) backed by your EFS filesystem +- SandboxTemplate defining the pod spec +- NetworkPolicy for ingress/egress rules +- SandboxWarmPool (optional, enabled by default) +- Karpenter NodePool + EC2NodeClass (optional, when `compute.type: karpenter`) -Running `create ` adds: - -- A Secret and SandboxClaim per user -- A workspace PVC backed by EFS +Each `agentikube create ` then adds a Secret, SandboxClaim, and workspace PVC for that user. ## Project layout ``` -cmd/agentikube/main.go # entrypoint -internal/config/ # config structs + validation -internal/manifest/ # template rendering -internal/manifest/templates/ # k8s YAML templates -internal/kube/ # kube client helpers -internal/commands/ # command implementations -agentikube.example.yaml # example config -Makefile # build/install/fmt/vet +cmd/agentikube/ CLI entrypoint +internal/ config, manifest rendering, kube helpers +chart/agentikube/ Helm chart +scripts/ CRD download helper ``` -## Build and test locally +## Development ```bash -go build ./... -go test ./... -go run ./cmd/agentikube --help - -# Smoke test manifest generation -./agentikube up --dry-run --config agentikube.example.yaml +make build # compile CLI +make helm-lint # lint the chart +make helm-template # dry-run render +go test ./... # run tests ``` ## Good to know -- `storage.type` is `efs` only for now -- `kubectl` needs to be installed (used by `init` and `ssh`) -- Fargate is validated in config but templates only cover the Karpenter path so far -- No Go tests written yet - `go test` passes but reports no test files -- [k9s](https://k9scli.io/) is great for browsing sandbox resources (`brew install derailed/k9s/k9s`) +- Storage is EFS-only for now +- `kubectl` must be installed (used by `init` and `ssh`) +- Fargate is validated in config but templates only cover Karpenter so far +- [k9s](https://k9scli.io/) is great for browsing sandbox resources + +## Context + +(https://harivan.sh/thoughts/isolated-long-running-agents-with-kubernetes) diff --git a/agentikube b/agentikube new file mode 100755 index 0000000..83f4562 Binary files /dev/null and b/agentikube differ diff --git a/chart/agentikube/Chart.yaml b/chart/agentikube/Chart.yaml new file mode 100644 index 0000000..293a85d --- /dev/null +++ b/chart/agentikube/Chart.yaml @@ -0,0 +1,12 @@ +apiVersion: v2 +name: agentikube +description: Isolated agent sandboxes on Kubernetes +type: application +version: 0.1.0 +appVersion: "0.1.0" +keywords: + - sandbox + - agents + - kubernetes + - karpenter + - efs diff --git a/chart/agentikube/crds/.gitkeep b/chart/agentikube/crds/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/chart/agentikube/templates/NOTES.txt b/chart/agentikube/templates/NOTES.txt new file mode 100644 index 0000000..1fcd08a --- /dev/null +++ b/chart/agentikube/templates/NOTES.txt @@ -0,0 +1,25 @@ +agentikube has been installed in namespace {{ .Release.Namespace }}. + +Resources created: + - StorageClass: efs-sandbox (EFS filesystem: {{ .Values.storage.filesystemId }}) + - SandboxTemplate: sandbox-template +{{- if .Values.sandbox.warmPool.enabled }} + - SandboxWarmPool: sandbox-warm-pool ({{ .Values.sandbox.warmPool.size }} replicas) +{{- end }} +{{- if eq .Values.compute.type "karpenter" }} + - NodePool: sandbox-pool + - EC2NodeClass: sandbox-nodes +{{- end }} + - NetworkPolicy: sandbox-network-policy + +To create a sandbox: + agentikube create --provider --api-key + +To list sandboxes: + agentikube list + +To SSH into a sandbox: + agentikube ssh + +To destroy a sandbox: + agentikube destroy diff --git a/chart/agentikube/templates/_helpers.tpl b/chart/agentikube/templates/_helpers.tpl new file mode 100644 index 0000000..c5210b9 --- /dev/null +++ b/chart/agentikube/templates/_helpers.tpl @@ -0,0 +1,42 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "agentikube.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "agentikube.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "agentikube.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{ include "agentikube.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "agentikube.selectorLabels" -}} +app.kubernetes.io/name: {{ include "agentikube.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/chart/agentikube/templates/karpenter-ec2nodeclass.yaml b/chart/agentikube/templates/karpenter-ec2nodeclass.yaml new file mode 100644 index 0000000..b2f4959 --- /dev/null +++ b/chart/agentikube/templates/karpenter-ec2nodeclass.yaml @@ -0,0 +1,18 @@ +{{- if eq .Values.compute.type "karpenter" }} +apiVersion: karpenter.k8s.aws/v1 +kind: EC2NodeClass +metadata: + name: sandbox-nodes + labels: + {{- include "agentikube.labels" . | nindent 4 }} +spec: + amiSelectorTerms: + - alias: "al2023@latest" + subnetSelectorTerms: + - tags: + karpenter.sh/discovery: {{ required "compute.clusterName is required for Karpenter" .Values.compute.clusterName | quote }} + securityGroupSelectorTerms: + - tags: + karpenter.sh/discovery: {{ .Values.compute.clusterName | quote }} + role: {{ printf "KarpenterNodeRole-%s" .Values.compute.clusterName | quote }} +{{- end }} diff --git a/chart/agentikube/templates/karpenter-nodepool.yaml b/chart/agentikube/templates/karpenter-nodepool.yaml new file mode 100644 index 0000000..4d55cb4 --- /dev/null +++ b/chart/agentikube/templates/karpenter-nodepool.yaml @@ -0,0 +1,37 @@ +{{- if eq .Values.compute.type "karpenter" }} +apiVersion: karpenter.sh/v1 +kind: NodePool +metadata: + name: sandbox-pool + labels: + {{- include "agentikube.labels" . | nindent 4 }} +spec: + template: + spec: + requirements: + - key: node.kubernetes.io/instance-type + operator: In + values: + {{- range .Values.compute.instanceTypes }} + - {{ . }} + {{- end }} + - key: karpenter.sh/capacity-type + operator: In + values: + {{- range .Values.compute.capacityTypes }} + - {{ . }} + {{- end }} + - key: kubernetes.io/arch + operator: In + values: + - amd64 + nodeClassRef: + name: sandbox-nodes + group: karpenter.k8s.aws + kind: EC2NodeClass + limits: + cpu: {{ .Values.compute.maxCpu }} + memory: {{ .Values.compute.maxMemory }} + disruption: + consolidationPolicy: {{ if .Values.compute.consolidation }}WhenEmptyOrUnderutilized{{ else }}WhenEmpty{{ end }} +{{- end }} diff --git a/chart/agentikube/templates/networkpolicy.yaml b/chart/agentikube/templates/networkpolicy.yaml new file mode 100644 index 0000000..fbad38e --- /dev/null +++ b/chart/agentikube/templates/networkpolicy.yaml @@ -0,0 +1,28 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: sandbox-network-policy + namespace: {{ .Release.Namespace }} + labels: + {{- include "agentikube.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: sandbox + policyTypes: + - Ingress + {{- if .Values.sandbox.networkPolicy.egressAllowAll }} + - Egress + {{- end }} + {{- if .Values.sandbox.networkPolicy.egressAllowAll }} + egress: + - to: + - ipBlock: + cidr: 0.0.0.0/0 + {{- end }} + ingress: + {{- range .Values.sandbox.networkPolicy.ingressPorts }} + - ports: + - port: {{ . }} + protocol: TCP + {{- end }} diff --git a/chart/agentikube/templates/sandbox-template.yaml b/chart/agentikube/templates/sandbox-template.yaml new file mode 100644 index 0000000..2c61361 --- /dev/null +++ b/chart/agentikube/templates/sandbox-template.yaml @@ -0,0 +1,57 @@ +apiVersion: extensions.agents.x-k8s.io/v1alpha1 +kind: SandboxTemplate +metadata: + name: sandbox-template + namespace: {{ .Release.Namespace }} + labels: + {{- include "agentikube.labels" . | nindent 4 }} +spec: + template: + spec: + containers: + - name: sandbox + image: {{ required "sandbox.image is required" .Values.sandbox.image }} + ports: + {{- range .Values.sandbox.ports }} + - containerPort: {{ . }} + {{- end }} + resources: + requests: + cpu: {{ .Values.sandbox.resources.requests.cpu }} + memory: {{ .Values.sandbox.resources.requests.memory }} + limits: + cpu: {{ .Values.sandbox.resources.limits.cpu | quote }} + memory: {{ .Values.sandbox.resources.limits.memory }} + securityContext: + runAsUser: {{ .Values.sandbox.securityContext.runAsUser }} + runAsGroup: {{ .Values.sandbox.securityContext.runAsGroup }} + runAsNonRoot: {{ .Values.sandbox.securityContext.runAsNonRoot }} + {{- if .Values.sandbox.env }} + env: + {{- range $key, $value := .Values.sandbox.env }} + - name: {{ $key }} + value: {{ $value | quote }} + {{- end }} + {{- end }} + startupProbe: + tcpSocket: + port: {{ .Values.sandbox.probes.port }} + failureThreshold: {{ .Values.sandbox.probes.startupFailureThreshold }} + periodSeconds: 10 + readinessProbe: + tcpSocket: + port: {{ .Values.sandbox.probes.port }} + periodSeconds: 10 + volumeMounts: + - name: workspace + mountPath: {{ .Values.sandbox.mountPath }} + volumeClaimTemplates: + - metadata: + name: workspace + spec: + accessModes: + - ReadWriteMany + storageClassName: efs-sandbox + resources: + requests: + storage: "10Gi" diff --git a/chart/agentikube/templates/storageclass-efs.yaml b/chart/agentikube/templates/storageclass-efs.yaml new file mode 100644 index 0000000..8a9c2ff --- /dev/null +++ b/chart/agentikube/templates/storageclass-efs.yaml @@ -0,0 +1,16 @@ +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: efs-sandbox + labels: + {{- include "agentikube.labels" . | nindent 4 }} +provisioner: efs.csi.aws.com +parameters: + provisioningMode: efs-ap + fileSystemId: {{ required "storage.filesystemId is required" .Values.storage.filesystemId }} + directoryPerms: "755" + uid: {{ .Values.storage.uid | quote }} + gid: {{ .Values.storage.gid | quote }} + basePath: {{ .Values.storage.basePath }} +reclaimPolicy: {{ .Values.storage.reclaimPolicy }} +volumeBindingMode: Immediate diff --git a/chart/agentikube/templates/warm-pool.yaml b/chart/agentikube/templates/warm-pool.yaml new file mode 100644 index 0000000..52f726f --- /dev/null +++ b/chart/agentikube/templates/warm-pool.yaml @@ -0,0 +1,14 @@ +{{- if .Values.sandbox.warmPool.enabled }} +apiVersion: extensions.agents.x-k8s.io/v1alpha1 +kind: SandboxWarmPool +metadata: + name: sandbox-warm-pool + namespace: {{ .Release.Namespace }} + labels: + {{- include "agentikube.labels" . | nindent 4 }} +spec: + templateRef: + name: sandbox-template + replicas: {{ .Values.sandbox.warmPool.size }} + ttlMinutes: {{ .Values.sandbox.warmPool.ttlMinutes }} +{{- end }} diff --git a/chart/agentikube/values.yaml b/chart/agentikube/values.yaml new file mode 100644 index 0000000..f40ece8 --- /dev/null +++ b/chart/agentikube/values.yaml @@ -0,0 +1,66 @@ +# Compute configuration for sandbox nodes +compute: + # karpenter or fargate + type: karpenter + instanceTypes: + - m6i.xlarge + - m5.xlarge + - r6i.xlarge + capacityTypes: + - spot + - on-demand + maxCpu: 2000 + maxMemory: 8000Gi + consolidation: true + # EKS cluster name - used for Karpenter subnet/SG/role discovery + clusterName: "" + +# Persistent storage configuration +storage: + # efs is the only supported type + type: efs + # REQUIRED - your EFS filesystem ID + filesystemId: "" + basePath: /sandboxes + uid: 1000 + gid: 1000 + reclaimPolicy: Retain + +# Sandbox pod configuration +sandbox: + # REQUIRED - container image for sandbox pods + image: "" + ports: + - 18789 + - 2222 + - 3000 + - 5173 + - 8080 + mountPath: /home/node/.openclaw + resources: + requests: + cpu: 50m + memory: 512Mi + limits: + cpu: "2" + memory: 4Gi + env: {} + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + runAsNonRoot: true + probes: + port: 18789 + startupFailureThreshold: 30 + warmPool: + enabled: true + size: 5 + ttlMinutes: 120 + networkPolicy: + egressAllowAll: true + ingressPorts: + - 18789 + - 2222 + - 3000 + - 5173 + - 8080 diff --git a/chart/agentikube_test.go b/chart/agentikube_test.go new file mode 100644 index 0000000..9854572 --- /dev/null +++ b/chart/agentikube_test.go @@ -0,0 +1,223 @@ +package chart_test + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +// helmTemplate runs helm template with the given extra args and returns stdout. +func helmTemplate(t *testing.T, extraArgs ...string) string { + t.Helper() + args := []string{ + "template", "agentikube", "chart/agentikube/", + "--namespace", "sandboxes", + "--set", "storage.filesystemId=fs-test", + "--set", "sandbox.image=test:latest", + "--set", "compute.clusterName=test-cluster", + } + args = append(args, extraArgs...) + cmd := exec.Command("helm", args...) + cmd.Dir = repoRoot(t) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("helm template failed: %v\n%s", err, out) + } + return string(out) +} + +func repoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + // This test file lives at chart/agentikube_test.go, so repo root is .. + return dir + "/.." +} + +func TestHelmLint(t *testing.T) { + cmd := exec.Command("helm", "lint", "chart/agentikube/") + cmd.Dir = repoRoot(t) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("helm lint failed: %v\n%s", err, out) + } + if !strings.Contains(string(out), "0 chart(s) failed") { + t.Fatalf("helm lint reported failures:\n%s", out) + } +} + +func TestHelmTemplateDefaultValues(t *testing.T) { + output := helmTemplate(t) + + expected := []string{ + "kind: StorageClass", + "kind: SandboxTemplate", + "kind: SandboxWarmPool", + "kind: NodePool", + "kind: EC2NodeClass", + "kind: NetworkPolicy", + } + for _, want := range expected { + if !strings.Contains(output, want) { + t.Errorf("expected %q in rendered output", want) + } + } +} + +func TestHelmTemplateLabels(t *testing.T) { + output := helmTemplate(t) + + labels := []string{ + "helm.sh/chart: agentikube-0.1.0", + "app.kubernetes.io/name: agentikube", + "app.kubernetes.io/instance: agentikube", + "app.kubernetes.io/managed-by: Helm", + `app.kubernetes.io/version: "0.1.0"`, + } + for _, label := range labels { + if !strings.Contains(output, label) { + t.Errorf("expected label %q in rendered output", label) + } + } +} + +func TestHelmTemplateKarpenterDisabled(t *testing.T) { + output := helmTemplate(t, "--set", "compute.type=fargate") + + if strings.Contains(output, "kind: NodePool") { + t.Error("NodePool should not be rendered when compute.type=fargate") + } + if strings.Contains(output, "kind: EC2NodeClass") { + t.Error("EC2NodeClass should not be rendered when compute.type=fargate") + } + if !strings.Contains(output, "kind: StorageClass") { + t.Error("StorageClass should always be rendered") + } + if !strings.Contains(output, "kind: SandboxTemplate") { + t.Error("SandboxTemplate should always be rendered") + } +} + +func TestHelmTemplateWarmPoolDisabled(t *testing.T) { + output := helmTemplate(t, "--set", "sandbox.warmPool.enabled=false") + + if strings.Contains(output, "kind: SandboxWarmPool") { + t.Error("SandboxWarmPool should not be rendered when warmPool.enabled=false") + } + if !strings.Contains(output, "kind: SandboxTemplate") { + t.Error("SandboxTemplate should always be rendered") + } +} + +func TestHelmTemplateEgressDisabled(t *testing.T) { + output := helmTemplate(t, + "--set", "sandbox.networkPolicy.egressAllowAll=false", + "-s", "templates/networkpolicy.yaml", + ) + + if strings.Contains(output, "0.0.0.0/0") { + t.Error("egress CIDR should not appear when egressAllowAll=false") + } + lines := strings.Split(output, "\n") + for i, line := range lines { + if strings.Contains(line, "policyTypes:") { + block := strings.Join(lines[i:min(i+4, len(lines))], "\n") + if strings.Contains(block, "Egress") { + t.Error("Egress should not be in policyTypes when egressAllowAll=false") + } + } + } +} + +func TestHelmTemplateRequiredValues(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "missing filesystemId", + args: []string{"--set", "sandbox.image=test:latest", "--set", "compute.clusterName=test"}, + wantErr: "storage.filesystemId is required", + }, + { + name: "missing sandbox image", + args: []string{"--set", "storage.filesystemId=fs-test", "--set", "compute.clusterName=test"}, + wantErr: "sandbox.image is required", + }, + { + name: "missing clusterName for karpenter", + args: []string{"--set", "storage.filesystemId=fs-test", "--set", "sandbox.image=test:latest"}, + wantErr: "compute.clusterName is required for Karpenter", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := append([]string{ + "template", "agentikube", "chart/agentikube/", + "--namespace", "sandboxes", + }, tt.args...) + cmd := exec.Command("helm", args...) + cmd.Dir = repoRoot(t) + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatal("expected helm template to fail for missing required value") + } + if !strings.Contains(string(out), tt.wantErr) { + t.Errorf("expected error containing %q, got:\n%s", tt.wantErr, out) + } + }) + } +} + +func TestHelmTemplateEnvVars(t *testing.T) { + output := helmTemplate(t, + "--set", "sandbox.env.MY_VAR=my-value", + "-s", "templates/sandbox-template.yaml", + ) + + if !strings.Contains(output, "MY_VAR") { + t.Error("expected MY_VAR in rendered env") + } + if !strings.Contains(output, "my-value") { + t.Error("expected my-value in rendered env") + } +} + +func TestHelmTemplateNoEnvWhenEmpty(t *testing.T) { + output := helmTemplate(t, "-s", "templates/sandbox-template.yaml") + + lines := strings.Split(output, "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "env:" { + t.Error("env: block should not appear when sandbox.env is empty") + } + } +} + +func TestHelmTemplateNamespace(t *testing.T) { + output := helmTemplate(t, "--namespace", "custom-ns") + + if !strings.Contains(output, "namespace: custom-ns") { + t.Error("expected namespace: custom-ns in rendered output") + } +} + +func TestHelmTemplateConsolidationDisabled(t *testing.T) { + output := helmTemplate(t, + "--set", "compute.consolidation=false", + "-s", "templates/karpenter-nodepool.yaml", + ) + + if !strings.Contains(output, "consolidationPolicy: WhenEmpty") { + t.Error("expected consolidationPolicy: WhenEmpty when consolidation=false") + } + if strings.Contains(output, "WhenEmptyOrUnderutilized") { + t.Error("should not have WhenEmptyOrUnderutilized when consolidation=false") + } +} diff --git a/scripts/download-crds.sh b/scripts/download-crds.sh new file mode 100755 index 0000000..f4090be --- /dev/null +++ b/scripts/download-crds.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Download agent-sandbox CRDs into chart/agentikube/crds/ +# Run this before packaging the chart: make crds + +REPO="kubernetes-sigs/agent-sandbox" +BRANCH="main" +BASE_URL="https://raw.githubusercontent.com/${REPO}/${BRANCH}/k8s/crds" +DEST="$(cd "$(dirname "$0")/.." && pwd)/chart/agentikube/crds" + +CRDS=( + sandboxtemplates.yaml + sandboxclaims.yaml + sandboxwarmpools.yaml +) + +echo "Downloading CRDs from ${REPO}@${BRANCH} ..." +mkdir -p "$DEST" + +for crd in "${CRDS[@]}"; do + echo " ${crd}" + curl -sSfL "${BASE_URL}/${crd}" -o "${DEST}/${crd}" +done + +echo "CRDs written to ${DEST}"