diff --git a/.gitignore b/.gitignore index 6198882..16d3c4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ .cache -agentikube 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") + } +}