commit 0595d93c498403e9ad829f3a1877a8b566fb1bad Author: Harivansh Rathi Date: Sat Feb 7 13:49:11 2026 -0500 init diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..160de4a --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: build install clean fmt vet lint + +build: + go build -o agentikube ./cmd/agentikube + +install: + go install ./cmd/agentikube + +clean: + rm -f agentikube + +fmt: + go fmt ./... + +vet: + go vet ./... + +lint: fmt vet diff --git a/agentikube.example.yaml b/agentikube.example.yaml new file mode 100644 index 0000000..330e1ab --- /dev/null +++ b/agentikube.example.yaml @@ -0,0 +1,98 @@ +# agentikube configuration +# Copy this file to agentikube.yaml and fill in your values. + +# Kubernetes namespace for all sandbox resources +namespace: sandboxes + +# Compute configuration for sandbox nodes +compute: + # karpenter or fargate + type: karpenter + + # EC2 instance types for Karpenter-managed nodes + instanceTypes: + - m6i.xlarge + - m5.xlarge + - r6i.xlarge + + # Capacity types: spot-first for cost savings + capacityTypes: [spot, on-demand] + + # Cluster-wide resource limits + maxCpu: 2000 + maxMemory: 8000Gi + + # Enable aggressive node bin-packing + consolidation: true + + # Fargate selectors (only used when type: fargate) + # fargateSelectors: + # - namespace: sandboxes + +# Persistent storage configuration +storage: + # Storage backend (efs is the only option for now) + type: efs + + # Your EFS filesystem ID + filesystemId: fs-08f7991b1c7f3ded8 + + # Root directory for sandbox access points + basePath: /sandboxes + + # UID/GID for created access points + uid: 1000 + gid: 1000 + + # Retain keeps data when a sandbox is deleted; Delete removes it + reclaimPolicy: Retain + +# Sandbox pod configuration +sandbox: + # Container image for sandboxes + image: openclaw:2026.2.2 + + # Ports exposed by the sandbox container + ports: [18789, 2222, 3000, 5173, 8080] + + # Where EFS storage mounts inside the container + mountPath: /home/node/.openclaw + + # Container resource requests and limits + resources: + requests: + cpu: 50m + memory: 512Mi + limits: + cpu: "2" + memory: 4Gi + + # Extra environment variables injected into every sandbox + env: + LLM_GATEWAY_URL: http://llm-gateway.sandboxes.svc.cluster.local + + # Container security context + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + runAsNonRoot: true + + # Health check probes + probes: + # TCP probe target port + port: 18789 + # Startup grace: 30 * 10s = 5 min + startupFailureThreshold: 30 + + # Warm pool pre-provisions sandbox pods for fast allocation + warmPool: + enabled: true + size: 5 + ttlMinutes: 120 + + # Network policy for sandbox pods + networkPolicy: + # Agents need outbound access for LLM APIs, npm, etc. + egressAllowAll: true + # Ports accessible from within the cluster + ingressPorts: [18789, 2222, 3000, 5173, 8080] diff --git a/cmd/agentikube/main.go b/cmd/agentikube/main.go new file mode 100644 index 0000000..68257c1 --- /dev/null +++ b/cmd/agentikube/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + "os" + + "github.com/rathi/agentikube/internal/commands" + "github.com/spf13/cobra" +) + +var version = "dev" + +func main() { + rootCmd := &cobra.Command{ + Use: "agentikube", + Short: "CLI for long-running agent sandboxes on Kubernetes", + Long: "agentikube provisions and manages long-running agent sandboxes on AWS using Kubernetes.", + } + + rootCmd.PersistentFlags().String("config", "agentikube.yaml", "path to config file") + + rootCmd.AddCommand( + commands.NewInitCmd(), + commands.NewUpCmd(), + commands.NewCreateCmd(), + commands.NewListCmd(), + commands.NewSSHCmd(), + commands.NewDownCmd(), + commands.NewDestroyCmd(), + commands.NewStatusCmd(), + ) + + rootCmd.Version = version + + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6b62635 --- /dev/null +++ b/go.mod @@ -0,0 +1,50 @@ +module github.com/rathi/agentikube + +go 1.25.6 + +require ( + github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.35.0 + k8s.io/apimachinery v0.35.0 + k8s.io/client-go v0.35.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.9.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a25d8f1 --- /dev/null +++ b/go.sum @@ -0,0 +1,135 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= +k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/commands/create.go b/internal/commands/create.go new file mode 100644 index 0000000..1f38d21 --- /dev/null +++ b/internal/commands/create.go @@ -0,0 +1,117 @@ +package commands + +import ( + "context" + "fmt" + "time" + + "github.com/rathi/agentikube/internal/kube" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func NewCreateCmd() *cobra.Command { + var provider string + var apiKey string + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a new sandbox for an agent", + Long: "Creates a Secret and SandboxClaim for the given handle, then waits for it to be ready.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + handle := args[0] + + cfg, err := loadConfig(cmd) + if err != nil { + return err + } + + client, err := kube.NewClient() + if err != nil { + return fmt.Errorf("connecting to cluster: %w", err) + } + + ns := cfg.Namespace + name := "sandbox-" + handle + + // Create the secret with provider credentials + secret := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": name, + "namespace": ns, + }, + "stringData": map[string]interface{}{ + "PROVIDER": provider, + "PROVIDER_KEY": apiKey, + "USER_NAME": handle, + }, + }, + } + + secretGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "secrets"} + _, err = client.Dynamic().Resource(secretGVR).Namespace(ns).Create(ctx, secret, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("creating secret %q: %w", name, err) + } + fmt.Printf("[ok] secret %q created\n", name) + + // Create the SandboxClaim + claim := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "agentsandbox.dev/v1", + "kind": "SandboxClaim", + "metadata": map[string]interface{}{ + "name": name, + "namespace": ns, + }, + "spec": map[string]interface{}{ + "templateRef": map[string]interface{}{ + "name": "sandbox-template", + }, + "secretRef": map[string]interface{}{ + "name": name, + }, + }, + }, + } + + claimGVR := schema.GroupVersionResource{ + Group: "agentsandbox.dev", + Version: "v1", + Resource: "sandboxclaims", + } + _, err = client.Dynamic().Resource(claimGVR).Namespace(ns).Create(ctx, claim, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("creating SandboxClaim %q: %w", name, err) + } + fmt.Printf("[ok] SandboxClaim %q created\n", name) + + // Wait for the sandbox to become ready (3 min timeout) + fmt.Println("waiting for sandbox to be ready...") + waitCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) + defer cancel() + + if err := client.WaitForReady(waitCtx, ns, "sandboxclaims", name); err != nil { + return fmt.Errorf("waiting for sandbox: %w", err) + } + + fmt.Printf("\nsandbox %q is ready\n", handle) + fmt.Printf(" name: %s\n", name) + fmt.Printf(" namespace: %s\n", ns) + fmt.Printf(" ssh: agentikube ssh %s\n", handle) + return nil + }, + } + + cmd.Flags().StringVar(&provider, "provider", "", "LLM provider name (env: SANDBOX_LLM_PROVIDER)") + cmd.Flags().StringVar(&apiKey, "api-key", "", "LLM provider API key (env: SANDBOX_API_KEY)") + + return cmd +} diff --git a/internal/commands/destroy.go b/internal/commands/destroy.go new file mode 100644 index 0000000..0175529 --- /dev/null +++ b/internal/commands/destroy.go @@ -0,0 +1,94 @@ +package commands + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/rathi/agentikube/internal/kube" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/api/errors" +) + +func NewDestroyCmd() *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "destroy ", + Short: "Destroy a sandbox and its resources", + Long: "Deletes the SandboxClaim, Secret, and PVC for the given handle.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + handle := args[0] + + if !yes { + fmt.Printf("are you sure you want to destroy sandbox %q? [y/N] ", handle) + scanner := bufio.NewScanner(os.Stdin) + scanner.Scan() + answer := strings.TrimSpace(strings.ToLower(scanner.Text())) + if answer != "y" && answer != "yes" { + fmt.Println("aborted") + return nil + } + } + + cfg, err := loadConfig(cmd) + if err != nil { + return err + } + + client, err := kube.NewClient() + if err != nil { + return fmt.Errorf("connecting to cluster: %w", err) + } + + ns := cfg.Namespace + name := "sandbox-" + handle + + claimGVR := schema.GroupVersionResource{ + Group: "agentsandbox.dev", + Version: "v1", + Resource: "sandboxclaims", + } + + secretGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "secrets"} + pvcGVR := schema.GroupVersionResource{Group: "", Version: "v1", Resource: "persistentvolumeclaims"} + + // Delete SandboxClaim + err = client.Dynamic().Resource(claimGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("deleting SandboxClaim %q: %w", name, err) + } + fmt.Printf("[ok] SandboxClaim %q deleted\n", name) + + // Delete Secret + err = client.Dynamic().Resource(secretGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil { + return fmt.Errorf("deleting Secret %q: %w", name, err) + } + fmt.Printf("[ok] Secret %q deleted\n", name) + + // Delete PVC (best-effort) + err = client.Dynamic().Resource(pvcGVR).Namespace(ns).Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil { + if !errors.IsNotFound(err) { + fmt.Printf("[warn] could not delete PVC %q: %v\n", name, err) + } + } else { + fmt.Printf("[ok] PVC %q deleted\n", name) + } + + fmt.Printf("\nsandbox %q destroyed\n", handle) + return nil + }, + } + + cmd.Flags().BoolVar(&yes, "yes", false, "skip confirmation prompt") + + return cmd +} diff --git a/internal/commands/down.go b/internal/commands/down.go new file mode 100644 index 0000000..dada100 --- /dev/null +++ b/internal/commands/down.go @@ -0,0 +1,65 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/rathi/agentikube/internal/kube" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func NewDownCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "down", + Short: "Remove sandbox infrastructure (preserves user sandboxes)", + Long: "Deletes the SandboxWarmPool and SandboxTemplate. User sandboxes are preserved.", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + cfg, err := loadConfig(cmd) + if err != nil { + return err + } + + client, err := kube.NewClient() + if err != nil { + return fmt.Errorf("connecting to cluster: %w", err) + } + + ns := cfg.Namespace + + warmPoolGVR := schema.GroupVersionResource{ + Group: "agentsandbox.dev", + Version: "v1", + Resource: "sandboxwarmpools", + } + + templateGVR := schema.GroupVersionResource{ + Group: "agentsandbox.dev", + Version: "v1", + Resource: "sandboxtemplates", + } + + err = client.Dynamic().Resource(warmPoolGVR).Namespace(ns).Delete(ctx, "sandbox-warm-pool", metav1.DeleteOptions{}) + if err != nil { + fmt.Printf("[warn] could not delete SandboxWarmPool: %v\n", err) + } else { + fmt.Println("[ok] SandboxWarmPool deleted") + } + + err = client.Dynamic().Resource(templateGVR).Namespace(ns).Delete(ctx, "sandbox-template", metav1.DeleteOptions{}) + if err != nil { + fmt.Printf("[warn] could not delete SandboxTemplate: %v\n", err) + } else { + fmt.Println("[ok] SandboxTemplate deleted") + } + + fmt.Println("\nwarm pool and template deleted. User sandboxes are preserved.") + return nil + }, + } + + return cmd +} diff --git a/internal/commands/helpers.go b/internal/commands/helpers.go new file mode 100644 index 0000000..c7d1093 --- /dev/null +++ b/internal/commands/helpers.go @@ -0,0 +1,11 @@ +package commands + +import ( + "github.com/rathi/agentikube/internal/config" + "github.com/spf13/cobra" +) + +func loadConfig(cmd *cobra.Command) (*config.Config, error) { + cfgPath, _ := cmd.Flags().GetString("config") + return config.Load(cfgPath) +} diff --git a/internal/commands/init.go b/internal/commands/init.go new file mode 100644 index 0000000..95ff11c --- /dev/null +++ b/internal/commands/init.go @@ -0,0 +1,97 @@ +package commands + +import ( + "context" + "fmt" + "os/exec" + "strings" + + "github.com/rathi/agentikube/internal/kube" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const crdInstallURL = "https://raw.githubusercontent.com/agent-sandbox/agent-sandbox/main/deploy/install.yaml" + +func NewInitCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "init", + Short: "Initialize the cluster for agent sandboxes", + Long: "Checks prerequisites, installs CRDs, and creates the target namespace.", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + cfg, err := loadConfig(cmd) + if err != nil { + return err + } + + // Check kubectl context + client, err := kube.NewClient() + if err != nil { + return fmt.Errorf("connecting to cluster: %w", err) + } + fmt.Println("[ok] connected to Kubernetes cluster") + + // Apply agent-sandbox CRDs + fmt.Println("applying agent-sandbox CRDs...") + out, err := exec.CommandContext(ctx, "kubectl", "apply", "-f", crdInstallURL).CombinedOutput() + if err != nil { + return fmt.Errorf("applying CRDs: %s: %w", strings.TrimSpace(string(out)), err) + } + fmt.Println("[ok] agent-sandbox CRDs applied") + + // Check for EFS CSI driver + dsList, err := client.Clientset().AppsV1().DaemonSets("kube-system").List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("listing daemonsets in kube-system: %w", err) + } + efsFound := false + for _, ds := range dsList.Items { + if strings.Contains(ds.Name, "efs-csi") { + efsFound = true + break + } + } + if efsFound { + fmt.Println("[ok] EFS CSI driver found") + } else { + fmt.Println("[warn] EFS CSI driver not found - install it before using EFS storage") + } + + // Check for Karpenter + karpenterFound := false + for _, ns := range []string{"karpenter", "kube-system"} { + depList, err := client.Clientset().AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{}) + if err != nil { + continue + } + for _, dep := range depList.Items { + if strings.Contains(dep.Name, "karpenter") { + karpenterFound = true + break + } + } + if karpenterFound { + break + } + } + if karpenterFound { + fmt.Println("[ok] Karpenter found") + } else { + fmt.Println("[warn] Karpenter not found - required if compute.type is karpenter") + } + + // Create namespace if it does not exist + if err := client.EnsureNamespace(ctx, cfg.Namespace); err != nil { + return fmt.Errorf("creating namespace %q: %w", cfg.Namespace, err) + } + fmt.Printf("[ok] namespace %q ready\n", cfg.Namespace) + + fmt.Println("\ninit complete") + return nil + }, + } + + return cmd +} diff --git a/internal/commands/list.go b/internal/commands/list.go new file mode 100644 index 0000000..92605ce --- /dev/null +++ b/internal/commands/list.go @@ -0,0 +1,138 @@ +package commands + +import ( + "context" + "fmt" + "os" + "text/tabwriter" + "time" + + "github.com/rathi/agentikube/internal/kube" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func NewListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List all sandboxes", + Long: "Lists all SandboxClaims in the configured namespace.", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + cfg, err := loadConfig(cmd) + if err != nil { + return err + } + + client, err := kube.NewClient() + if err != nil { + return fmt.Errorf("connecting to cluster: %w", err) + } + + claimGVR := schema.GroupVersionResource{ + Group: "agentsandbox.dev", + Version: "v1", + Resource: "sandboxclaims", + } + + list, err := client.Dynamic().Resource(claimGVR).Namespace(cfg.Namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("listing SandboxClaims: %w", err) + } + + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "HANDLE\tSTATUS\tAGE\tPOD") + + for _, item := range list.Items { + name := item.GetName() + handle := name + if len(name) > 8 && name[:8] == "sandbox-" { + handle = name[8:] + } + + status := extractStatus(item.Object) + podName := extractPodName(item.Object) + age := formatAge(item.GetCreationTimestamp().Time) + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", handle, status, age, podName) + } + + w.Flush() + return nil + }, + } + + return cmd +} + +func extractStatus(obj map[string]interface{}) string { + status, ok := obj["status"].(map[string]interface{}) + if !ok { + return "Unknown" + } + + conditions, ok := status["conditions"].([]interface{}) + if !ok || len(conditions) == 0 { + return "Pending" + } + + // Look for the Ready condition + for _, c := range conditions { + cond, ok := c.(map[string]interface{}) + if !ok { + continue + } + condType, _ := cond["type"].(string) + condStatus, _ := cond["status"].(string) + if condType == "Ready" { + if condStatus == "True" { + return "Ready" + } + reason, _ := cond["reason"].(string) + if reason != "" { + return reason + } + return "NotReady" + } + } + + return "Pending" +} + +func extractPodName(obj map[string]interface{}) string { + status, ok := obj["status"].(map[string]interface{}) + if ok { + if podName, ok := status["podName"].(string); ok && podName != "" { + return podName + } + } + + // Fall back to annotations + metadata, ok := obj["metadata"].(map[string]interface{}) + if ok { + annotations, ok := metadata["annotations"].(map[string]interface{}) + if ok { + if podName, ok := annotations["agentsandbox.dev/pod-name"].(string); ok { + return podName + } + } + } + + return "-" +} + +func formatAge(created time.Time) string { + d := time.Since(created) + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + } +} diff --git a/internal/commands/ssh.go b/internal/commands/ssh.go new file mode 100644 index 0000000..c52d1b1 --- /dev/null +++ b/internal/commands/ssh.go @@ -0,0 +1,58 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/rathi/agentikube/internal/kube" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func NewSSHCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "ssh ", + Short: "Open a shell into a sandbox", + Long: "Exec into the sandbox pod for the given handle.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + handle := args[0] + + cfg, err := loadConfig(cmd) + if err != nil { + return err + } + + client, err := kube.NewClient() + if err != nil { + return fmt.Errorf("connecting to cluster: %w", err) + } + + ns := cfg.Namespace + name := "sandbox-" + handle + + claimGVR := schema.GroupVersionResource{ + Group: "agentsandbox.dev", + Version: "v1", + Resource: "sandboxclaims", + } + + claim, err := client.Dynamic().Resource(claimGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("getting SandboxClaim %q: %w", name, err) + } + + podName := extractPodName(claim.Object) + if podName == "-" || podName == "" { + return fmt.Errorf("sandbox %q does not have a pod assigned yet", handle) + } + + fmt.Printf("connecting to pod %s...\n", podName) + return kube.Exec(ns, podName, []string{"/bin/sh"}) + }, + } + + return cmd +} diff --git a/internal/commands/status.go b/internal/commands/status.go new file mode 100644 index 0000000..c87c49f --- /dev/null +++ b/internal/commands/status.go @@ -0,0 +1,108 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/rathi/agentikube/internal/kube" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func NewStatusCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Show cluster and sandbox status", + Long: "Displays warm pool status, sandbox counts, and compute node information.", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + cfg, err := loadConfig(cmd) + if err != nil { + return err + } + + client, err := kube.NewClient() + if err != nil { + return fmt.Errorf("connecting to cluster: %w", err) + } + + ns := cfg.Namespace + + // Warm pool status + warmPoolGVR := schema.GroupVersionResource{ + Group: "agentsandbox.dev", + Version: "v1", + Resource: "sandboxwarmpools", + } + + wp, err := client.Dynamic().Resource(warmPoolGVR).Namespace(ns).Get(ctx, "sandbox-warm-pool", metav1.GetOptions{}) + if err != nil { + fmt.Printf("warm pool: not found (%v)\n", err) + } else { + spec, _ := wp.Object["spec"].(map[string]interface{}) + status, _ := wp.Object["status"].(map[string]interface{}) + + replicas := getInt64(spec, "replicas") + readyReplicas := getInt64(status, "readyReplicas") + pendingReplicas := getInt64(status, "pendingReplicas") + + fmt.Println("warm pool:") + fmt.Printf(" desired: %d\n", replicas) + fmt.Printf(" ready: %d\n", readyReplicas) + fmt.Printf(" pending: %d\n", pendingReplicas) + } + + // Sandbox count + claimGVR := schema.GroupVersionResource{ + Group: "agentsandbox.dev", + Version: "v1", + Resource: "sandboxclaims", + } + + claims, err := client.Dynamic().Resource(claimGVR).Namespace(ns).List(ctx, metav1.ListOptions{}) + if err != nil { + fmt.Printf("\nsandboxes: error listing (%v)\n", err) + } else { + fmt.Printf("\nsandboxes: %d\n", len(claims.Items)) + } + + // Karpenter nodes (if applicable) + if cfg.Compute.Type == "karpenter" { + nodes, err := client.Clientset().CoreV1().Nodes().List(ctx, metav1.ListOptions{ + LabelSelector: "karpenter.sh/nodepool", + }) + if err != nil { + fmt.Printf("\nkarpenter nodes: error listing (%v)\n", err) + } else { + fmt.Printf("\nkarpenter nodes: %d\n", len(nodes.Items)) + } + } + + return nil + }, + } + + return cmd +} + +func getInt64(m map[string]interface{}, key string) int64 { + if m == nil { + return 0 + } + v, ok := m[key] + if !ok { + return 0 + } + switch n := v.(type) { + case int64: + return n + case float64: + return int64(n) + case int: + return int64(n) + default: + return 0 + } +} diff --git a/internal/commands/up.go b/internal/commands/up.go new file mode 100644 index 0000000..ab95a3e --- /dev/null +++ b/internal/commands/up.go @@ -0,0 +1,63 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/rathi/agentikube/internal/kube" + "github.com/rathi/agentikube/internal/manifest" + "github.com/spf13/cobra" +) + +func NewUpCmd() *cobra.Command { + var dryRun bool + + cmd := &cobra.Command{ + Use: "up", + Short: "Apply sandbox infrastructure to the cluster", + Long: "Generates and applies all sandbox manifests (templates, warm pool, storage, compute).", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + cfg, err := loadConfig(cmd) + if err != nil { + return err + } + + manifests, err := manifest.Generate(cfg) + if err != nil { + return fmt.Errorf("generating manifests: %w", err) + } + + if dryRun { + fmt.Print(string(manifests)) + return nil + } + + client, err := kube.NewClient() + if err != nil { + return fmt.Errorf("connecting to cluster: %w", err) + } + + if err := client.ServerSideApply(ctx, manifests); err != nil { + return fmt.Errorf("applying manifests: %w", err) + } + fmt.Println("[ok] manifests applied") + + if cfg.Sandbox.WarmPool.Enabled { + fmt.Println("waiting for warm pool to become ready...") + if err := client.WaitForReady(ctx, cfg.Namespace, "sandboxwarmpools", "sandbox-warm-pool"); err != nil { + return fmt.Errorf("waiting for warm pool: %w", err) + } + fmt.Println("[ok] warm pool ready") + } + + fmt.Println("\ninfrastructure is up") + return nil + }, + } + + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print manifests to stdout without applying") + + return cmd +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..6332ae9 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,102 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// Config is the top-level configuration parsed from agentikube.yaml. +type Config struct { + Namespace string `yaml:"namespace"` + Compute ComputeConfig `yaml:"compute"` + Storage StorageConfig `yaml:"storage"` + Sandbox SandboxConfig `yaml:"sandbox"` +} + +type ComputeConfig struct { + Type string `yaml:"type"` // karpenter | fargate + InstanceTypes []string `yaml:"instanceTypes"` + CapacityTypes []string `yaml:"capacityTypes"` + MaxCPU int `yaml:"maxCpu"` + MaxMemory string `yaml:"maxMemory"` + Consolidation bool `yaml:"consolidation"` + FargateSelectors []FargateSelector `yaml:"fargateSelectors"` +} + +type FargateSelector struct { + Namespace string `yaml:"namespace"` +} + +type StorageConfig struct { + Type string `yaml:"type"` // efs + FilesystemID string `yaml:"filesystemId"` + BasePath string `yaml:"basePath"` + UID int `yaml:"uid"` + GID int `yaml:"gid"` + ReclaimPolicy string `yaml:"reclaimPolicy"` +} + +type SandboxConfig struct { + Image string `yaml:"image"` + Ports []int `yaml:"ports"` + MountPath string `yaml:"mountPath"` + Resources ResourcesConfig `yaml:"resources"` + Env map[string]string `yaml:"env"` + SecurityContext SecurityContext `yaml:"securityContext"` + Probes ProbesConfig `yaml:"probes"` + WarmPool WarmPoolConfig `yaml:"warmPool"` + NetworkPolicy NetworkPolicy `yaml:"networkPolicy"` +} + +type ResourcesConfig struct { + Requests ResourceValues `yaml:"requests"` + Limits ResourceValues `yaml:"limits"` +} + +type ResourceValues struct { + CPU string `yaml:"cpu"` + Memory string `yaml:"memory"` +} + +type SecurityContext struct { + RunAsUser int `yaml:"runAsUser"` + RunAsGroup int `yaml:"runAsGroup"` + RunAsNonRoot bool `yaml:"runAsNonRoot"` +} + +type ProbesConfig struct { + Port int `yaml:"port"` + StartupFailureThreshold int `yaml:"startupFailureThreshold"` +} + +type WarmPoolConfig struct { + Enabled bool `yaml:"enabled"` + Size int `yaml:"size"` + TTLMinutes int `yaml:"ttlMinutes"` +} + +type NetworkPolicy struct { + EgressAllowAll bool `yaml:"egressAllowAll"` + IngressPorts []int `yaml:"ingressPorts"` +} + +// Load reads and parses the config file at the given path. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config file: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config file: %w", err) + } + + if err := Validate(&cfg); err != nil { + return nil, fmt.Errorf("validating config: %w", err) + } + + return &cfg, nil +} diff --git a/internal/config/validate.go b/internal/config/validate.go new file mode 100644 index 0000000..4e82a59 --- /dev/null +++ b/internal/config/validate.go @@ -0,0 +1,99 @@ +package config + +import ( + "fmt" + "strings" +) + +// Validate checks that all required fields are present and values are valid. +func Validate(cfg *Config) error { + var errs []string + + if cfg.Namespace == "" { + errs = append(errs, "namespace is required") + } + + // Compute validation + switch cfg.Compute.Type { + case "karpenter": + if len(cfg.Compute.InstanceTypes) == 0 { + errs = append(errs, "compute.instanceTypes is required when type is karpenter") + } + if len(cfg.Compute.CapacityTypes) == 0 { + errs = append(errs, "compute.capacityTypes is required when type is karpenter") + } + if cfg.Compute.MaxCPU <= 0 { + errs = append(errs, "compute.maxCpu must be > 0") + } + if cfg.Compute.MaxMemory == "" { + errs = append(errs, "compute.maxMemory is required when type is karpenter") + } + case "fargate": + if len(cfg.Compute.FargateSelectors) == 0 { + errs = append(errs, "compute.fargateSelectors is required when type is fargate") + } + case "": + errs = append(errs, "compute.type is required (karpenter or fargate)") + default: + errs = append(errs, fmt.Sprintf("compute.type must be karpenter or fargate, got %q", cfg.Compute.Type)) + } + + // Storage validation + if cfg.Storage.Type == "" { + errs = append(errs, "storage.type is required") + } else if cfg.Storage.Type != "efs" { + errs = append(errs, fmt.Sprintf("storage.type must be efs, got %q", cfg.Storage.Type)) + } + if cfg.Storage.FilesystemID == "" { + errs = append(errs, "storage.filesystemId is required") + } + if cfg.Storage.BasePath == "" { + errs = append(errs, "storage.basePath is required") + } + if cfg.Storage.ReclaimPolicy == "" { + cfg.Storage.ReclaimPolicy = "Retain" + } else if cfg.Storage.ReclaimPolicy != "Retain" && cfg.Storage.ReclaimPolicy != "Delete" { + errs = append(errs, fmt.Sprintf("storage.reclaimPolicy must be Retain or Delete, got %q", cfg.Storage.ReclaimPolicy)) + } + + // Storage defaults + if cfg.Storage.UID == 0 { + cfg.Storage.UID = 1000 + } + if cfg.Storage.GID == 0 { + cfg.Storage.GID = 1000 + } + + // Sandbox validation + if cfg.Sandbox.Image == "" { + errs = append(errs, "sandbox.image is required") + } + if len(cfg.Sandbox.Ports) == 0 { + errs = append(errs, "sandbox.ports is required") + } + if cfg.Sandbox.MountPath == "" { + errs = append(errs, "sandbox.mountPath is required") + } + + // Warm pool defaults + if cfg.Sandbox.WarmPool.Size == 0 && cfg.Sandbox.WarmPool.Enabled { + cfg.Sandbox.WarmPool.Size = 5 + } + if cfg.Sandbox.WarmPool.TTLMinutes == 0 { + cfg.Sandbox.WarmPool.TTLMinutes = 120 + } + + // Probes defaults + if cfg.Sandbox.Probes.Port == 0 && len(cfg.Sandbox.Ports) > 0 { + cfg.Sandbox.Probes.Port = cfg.Sandbox.Ports[0] + } + if cfg.Sandbox.Probes.StartupFailureThreshold == 0 { + cfg.Sandbox.Probes.StartupFailureThreshold = 30 + } + + if len(errs) > 0 { + return fmt.Errorf("config validation errors:\n - %s", strings.Join(errs, "\n - ")) + } + + return nil +} diff --git a/internal/kube/apply.go b/internal/kube/apply.go new file mode 100644 index 0000000..594ad34 --- /dev/null +++ b/internal/kube/apply.go @@ -0,0 +1,92 @@ +package kube + +import ( + "bytes" + "context" + "fmt" + "io" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + k8syaml "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/discovery" + "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/restmapper" + yamlserializer "k8s.io/apimachinery/pkg/runtime/serializer/yaml" +) + +// ServerSideApply splits a multi-document YAML into individual resources +// and applies each one using server-side apply with the "agentikube" field manager. +func (c *Client) ServerSideApply(ctx context.Context, manifests []byte) error { + decoder := k8syaml.NewYAMLOrJSONDecoder(bytes.NewReader(manifests), 4096) + + discoveryClient, ok := c.Clientset().Discovery().(*discovery.DiscoveryClient) + if !ok { + return fmt.Errorf("failed to get discovery client") + } + cachedDiscovery := memory.NewMemCacheClient(discoveryClient) + mapper := restmapper.NewDeferredDiscoveryRESTMapper(cachedDiscovery) + + deserializer := yamlserializer.NewDecodingSerializer(unstructured.UnstructuredJSONScheme) + + for { + var rawObj unstructured.Unstructured + if err := decoder.Decode(&rawObj); err != nil { + if err == io.EOF { + break + } + return fmt.Errorf("decoding YAML document: %w", err) + } + + // Skip empty documents + if len(rawObj.Object) == 0 { + continue + } + + // Re-encode to JSON for the patch body + rawJSON, err := rawObj.MarshalJSON() + if err != nil { + return fmt.Errorf("marshaling to JSON: %w", err) + } + + // Decode to get the GVK + obj := &unstructured.Unstructured{} + _, gvk, err := deserializer.Decode(rawJSON, nil, obj) + if err != nil { + return fmt.Errorf("deserializing object: %w", err) + } + + // Map GVK to GVR using the REST mapper + mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + return fmt.Errorf("mapping GVK %s to GVR: %w", gvk.String(), err) + } + + gvr := mapping.Resource + name := obj.GetName() + namespace := obj.GetNamespace() + + applyOpts := metav1.ApplyOptions{ + FieldManager: "agentikube", + } + + // Apply using the dynamic client - handle namespaced vs cluster-scoped + if namespace != "" { + _, err = c.Dynamic().Resource(gvr).Namespace(namespace).Patch( + ctx, name, types.ApplyPatchType, rawJSON, applyOpts.ToPatchOptions(), + ) + } else { + _, err = c.Dynamic().Resource(gvr).Patch( + ctx, name, types.ApplyPatchType, rawJSON, applyOpts.ToPatchOptions(), + ) + } + if err != nil { + return fmt.Errorf("applying %s/%s: %w", gvk.Kind, name, err) + } + + fmt.Printf("applied %s/%s\n", gvk.Kind, name) + } + + return nil +} diff --git a/internal/kube/client.go b/internal/kube/client.go new file mode 100644 index 0000000..6bd32dc --- /dev/null +++ b/internal/kube/client.go @@ -0,0 +1,76 @@ +package kube + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +// Client wraps the Kubernetes dynamic client, typed clientset, and REST config. +type Client struct { + dynamic dynamic.Interface + clientset kubernetes.Interface + restConfig *rest.Config +} + +func (c *Client) Dynamic() dynamic.Interface { return c.dynamic } +func (c *Client) Clientset() kubernetes.Interface { return c.clientset } +func (c *Client) RestConfig() *rest.Config { return c.restConfig } + +// NewClient creates a Kubernetes client using the default kubeconfig loading +// rules (KUBECONFIG env var or ~/.kube/config). +func NewClient() (*Client, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + configOverrides := &clientcmd.ConfigOverrides{} + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) + + restConfig, err := kubeConfig.ClientConfig() + if err != nil { + return nil, fmt.Errorf("loading kubeconfig: %w", err) + } + + dynamicClient, err := dynamic.NewForConfig(restConfig) + if err != nil { + return nil, fmt.Errorf("creating dynamic client: %w", err) + } + + clientset, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return nil, fmt.Errorf("creating clientset: %w", err) + } + + return &Client{ + dynamic: dynamicClient, + clientset: clientset, + restConfig: restConfig, + }, nil +} + +// EnsureNamespace creates the namespace if it does not already exist. +func (c *Client) EnsureNamespace(ctx context.Context, name string) error { + _, err := c.clientset.CoreV1().Namespaces().Get(ctx, name, metav1.GetOptions{}) + if err == nil { + return nil + } + if !errors.IsNotFound(err) { + return fmt.Errorf("checking namespace %q: %w", name, err) + } + + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } + _, err = c.clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("creating namespace %q: %w", name, err) + } + return nil +} diff --git a/internal/kube/exec.go b/internal/kube/exec.go new file mode 100644 index 0000000..bc21c37 --- /dev/null +++ b/internal/kube/exec.go @@ -0,0 +1,24 @@ +package kube + +import ( + "os" + "os/exec" +) + +// Exec runs kubectl exec to attach an interactive terminal to the specified +// pod. If command is empty, it defaults to /bin/sh. +func Exec(namespace, podName string, command []string) error { + if len(command) == 0 { + command = []string{"/bin/sh"} + } + + args := []string{"exec", "-it", "-n", namespace, podName, "--"} + args = append(args, command...) + + cmd := exec.Command("kubectl", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} diff --git a/internal/kube/wait.go b/internal/kube/wait.go new file mode 100644 index 0000000..4d31ee3 --- /dev/null +++ b/internal/kube/wait.go @@ -0,0 +1,81 @@ +package kube + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/watch" +) + +// WaitForReady watches a resource in the agentsandbox.dev/v1 group until its +// Ready condition becomes True or the context is cancelled/times out. +// The resource parameter is the plural resource name (e.g. "sandboxclaims", "sandboxwarmpools"). +func (c *Client) WaitForReady(ctx context.Context, namespace, resource, name string) error { + gvr := schema.GroupVersionResource{ + Group: "agentsandbox.dev", + Version: "v1", + Resource: resource, + } + + watcher, err := c.Dynamic().Resource(gvr).Namespace(namespace).Watch(ctx, metav1.ListOptions{ + FieldSelector: fmt.Sprintf("metadata.name=%s", name), + }) + if err != nil { + return fmt.Errorf("watching %s %s/%s: %w", resource, namespace, name, err) + } + defer watcher.Stop() + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timed out waiting for %s %s/%s to become ready", resource, namespace, name) + case event, ok := <-watcher.ResultChan(): + if !ok { + return fmt.Errorf("watch channel closed for %s %s/%s", resource, namespace, name) + } + if event.Type == watch.Error { + return fmt.Errorf("watch error for %s %s/%s", resource, namespace, name) + } + + obj, ok := event.Object.(*unstructured.Unstructured) + if !ok { + continue + } + + if isReady(obj) { + return nil + } + } + } +} + +// isReady checks whether an unstructured object has a condition with +// type=Ready and status=True. +func isReady(obj *unstructured.Unstructured) bool { + status, found, err := unstructured.NestedMap(obj.Object, "status") + if err != nil || !found { + return false + } + + conditionsRaw, found, err := unstructured.NestedSlice(status, "conditions") + if err != nil || !found { + return false + } + + for _, c := range conditionsRaw { + condition, ok := c.(map[string]interface{}) + if !ok { + continue + } + condType, _ := condition["type"].(string) + condStatus, _ := condition["status"].(string) + if condType == "Ready" && condStatus == "True" { + return true + } + } + + return false +} diff --git a/internal/manifest/generate.go b/internal/manifest/generate.go new file mode 100644 index 0000000..a5bf742 --- /dev/null +++ b/internal/manifest/generate.go @@ -0,0 +1,51 @@ +package manifest + +import ( + "bytes" + "fmt" + "text/template" + + "github.com/rathi/agentikube/internal/config" +) + +// Generate renders all applicable Kubernetes manifests from the embedded +// templates using the provided configuration. Templates are selected based +// on the compute type and warm pool settings. +func Generate(cfg *config.Config) ([]byte, error) { + tmpl, err := template.ParseFS(templateFS, "templates/*.yaml.tmpl") + if err != nil { + return nil, fmt.Errorf("parsing templates: %w", err) + } + + // Always-rendered templates + names := []string{ + "namespace.yaml.tmpl", + "storageclass-efs.yaml.tmpl", + "sandbox-template.yaml.tmpl", + } + + // Conditionally add Karpenter templates + if cfg.Compute.Type == "karpenter" { + names = append(names, + "karpenter-nodepool.yaml.tmpl", + "karpenter-ec2nodeclass.yaml.tmpl", + ) + } + + // Conditionally add warm pool template + if cfg.Sandbox.WarmPool.Enabled { + names = append(names, "warm-pool.yaml.tmpl") + } + + var out bytes.Buffer + for i, name := range names { + if i > 0 { + out.WriteString("---\n") + } + if err := tmpl.ExecuteTemplate(&out, name, cfg); err != nil { + return nil, fmt.Errorf("rendering template %s: %w", name, err) + } + } + + return out.Bytes(), nil +} diff --git a/internal/manifest/templates.go b/internal/manifest/templates.go new file mode 100644 index 0000000..51a0ba2 --- /dev/null +++ b/internal/manifest/templates.go @@ -0,0 +1,6 @@ +package manifest + +import "embed" + +//go:embed templates/*.yaml.tmpl +var templateFS embed.FS diff --git a/internal/manifest/templates/karpenter-ec2nodeclass.yaml.tmpl b/internal/manifest/templates/karpenter-ec2nodeclass.yaml.tmpl new file mode 100644 index 0000000..7117300 --- /dev/null +++ b/internal/manifest/templates/karpenter-ec2nodeclass.yaml.tmpl @@ -0,0 +1,14 @@ +apiVersion: karpenter.k8s.aws/v1 +kind: EC2NodeClass +metadata: + name: sandbox-nodes +spec: + amiSelectorTerms: + - alias: "al2023@latest" + subnetSelectorTerms: + - tags: + karpenter.sh/discovery: "{{ .Namespace }}-cluster" + securityGroupSelectorTerms: + - tags: + karpenter.sh/discovery: "{{ .Namespace }}-cluster" + role: "KarpenterNodeRole-{{ .Namespace }}-cluster" diff --git a/internal/manifest/templates/karpenter-nodepool.yaml.tmpl b/internal/manifest/templates/karpenter-nodepool.yaml.tmpl new file mode 100644 index 0000000..8b11653 --- /dev/null +++ b/internal/manifest/templates/karpenter-nodepool.yaml.tmpl @@ -0,0 +1,33 @@ +apiVersion: karpenter.sh/v1 +kind: NodePool +metadata: + name: sandbox-pool +spec: + template: + spec: + requirements: + - key: node.kubernetes.io/instance-type + operator: In + values: + {{- range .Compute.InstanceTypes }} + - {{ . }} + {{- end }} + - key: karpenter.sh/capacity-type + operator: In + values: + {{- range .Compute.CapacityTypes }} + - {{ . }} + {{- end }} + - key: kubernetes.io/arch + operator: In + values: + - amd64 + nodeClassRef: + name: sandbox-nodes + group: karpenter.k8s.aws + kind: EC2NodeClass + limits: + cpu: {{ .Compute.MaxCPU }} + memory: {{ .Compute.MaxMemory }} + disruption: + consolidationPolicy: {{ if .Compute.Consolidation }}WhenEmptyOrUnderutilized{{ else }}WhenEmpty{{ end }} diff --git a/internal/manifest/templates/namespace.yaml.tmpl b/internal/manifest/templates/namespace.yaml.tmpl new file mode 100644 index 0000000..d2a8c25 --- /dev/null +++ b/internal/manifest/templates/namespace.yaml.tmpl @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Namespace }} diff --git a/internal/manifest/templates/sandbox-template.yaml.tmpl b/internal/manifest/templates/sandbox-template.yaml.tmpl new file mode 100644 index 0000000..a968a63 --- /dev/null +++ b/internal/manifest/templates/sandbox-template.yaml.tmpl @@ -0,0 +1,66 @@ +apiVersion: agentsandbox.dev/v1 +kind: SandboxTemplate +metadata: + name: sandbox-template + namespace: {{ .Namespace }} +spec: + template: + spec: + containers: + - name: sandbox + image: {{ .Sandbox.Image }} + ports: + {{- range .Sandbox.Ports }} + - containerPort: {{ . }} + {{- end }} + resources: + requests: + cpu: {{ .Sandbox.Resources.Requests.CPU }} + memory: {{ .Sandbox.Resources.Requests.Memory }} + limits: + cpu: {{ .Sandbox.Resources.Limits.CPU }} + memory: {{ .Sandbox.Resources.Limits.Memory }} + securityContext: + runAsUser: {{ .Sandbox.SecurityContext.RunAsUser }} + runAsGroup: {{ .Sandbox.SecurityContext.RunAsGroup }} + runAsNonRoot: {{ .Sandbox.SecurityContext.RunAsNonRoot }} + env: + {{- range $key, $value := .Sandbox.Env }} + - name: {{ $key }} + value: "{{ $value }}" + {{- end }} + startupProbe: + tcpSocket: + port: {{ .Sandbox.Probes.Port }} + failureThreshold: {{ .Sandbox.Probes.StartupFailureThreshold }} + periodSeconds: 10 + readinessProbe: + tcpSocket: + port: {{ .Sandbox.Probes.Port }} + periodSeconds: 10 + volumeMounts: + - name: workspace + mountPath: {{ .Sandbox.MountPath }} + volumeClaimTemplates: + - metadata: + name: workspace + spec: + accessModes: + - ReadWriteMany + storageClassName: efs-sandbox + resources: + requests: + storage: "10Gi" + networkPolicy: + egress: + {{- if .Sandbox.NetworkPolicy.EgressAllowAll }} + - to: + - ipBlock: + cidr: 0.0.0.0/0 + {{- end }} + ingress: + {{- range .Sandbox.NetworkPolicy.IngressPorts }} + - ports: + - port: {{ . }} + protocol: TCP + {{- end }} diff --git a/internal/manifest/templates/storageclass-efs.yaml.tmpl b/internal/manifest/templates/storageclass-efs.yaml.tmpl new file mode 100644 index 0000000..d9adc0e --- /dev/null +++ b/internal/manifest/templates/storageclass-efs.yaml.tmpl @@ -0,0 +1,14 @@ +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: efs-sandbox +provisioner: efs.csi.aws.com +parameters: + provisioningMode: efs-ap + fileSystemId: {{ .Storage.FilesystemID }} + directoryPerms: "755" + uid: "{{ .Storage.UID }}" + gid: "{{ .Storage.GID }}" + basePath: {{ .Storage.BasePath }} +reclaimPolicy: {{ .Storage.ReclaimPolicy }} +volumeBindingMode: Immediate diff --git a/internal/manifest/templates/warm-pool.yaml.tmpl b/internal/manifest/templates/warm-pool.yaml.tmpl new file mode 100644 index 0000000..d030490 --- /dev/null +++ b/internal/manifest/templates/warm-pool.yaml.tmpl @@ -0,0 +1,10 @@ +apiVersion: agentsandbox.dev/v1 +kind: SandboxWarmPool +metadata: + name: sandbox-warm-pool + namespace: {{ .Namespace }} +spec: + templateRef: + name: sandbox-template + replicas: {{ .Sandbox.WarmPool.Size }} + ttlMinutes: {{ .Sandbox.WarmPool.TTLMinutes }}