Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,8 @@ cover.out
# Local config
config.toml
!config.example.toml
.env
bte-promo-script/.env
bte-promo-script/secrets.env
cmd/kubectl-gt/kubectl-gt
cmd/kubectl-gt/bin/kubectl-gt-*
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ demo: setup-test-e2e docker-build-e2e ## Deploy to Kind and show a sample Poleca
@echo "Cleanup:"
@echo " make cleanup-test-e2e"

.PHONY: demo-docker-desktop
demo-docker-desktop: ## Deploy BTE infra (operator + madeye-proxy + Rig) to Docker Desktop K8s.
./bte-promo-script/deploy/deploy-infra.sh

.PHONY: smoke-test
smoke-test: ## Smoke test published release artifacts in Kind (requires VERSION).
./scripts/smoke-test.sh --version $(VERSION)
Expand Down Expand Up @@ -373,7 +377,7 @@ endef

.PHONY: polecat-agent-build
polecat-agent-build: ## Build polecat-agent image locally (single arch).
docker build -t polecat-agent:local images/polecat-agent/
docker build -f images/polecat-agent/Dockerfile -t polecat-agent:local .

.PHONY: polecat-agent-release
polecat-agent-release: ## Build and push polecat-agent with SBOM and Trivy scan.
Expand Down
22 changes: 17 additions & 5 deletions api/v1alpha1/polecat_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,9 @@ type AgentConfig struct {
// KubernetesSpec defines configuration for kubernetes execution mode
type KubernetesSpec struct {
// GitRepository is the git repo URL to clone (SSH or HTTPS format)
// +kubebuilder:validation:Required
// +kubebuilder:validation:Pattern=`^(git@[a-zA-Z0-9._-]+:|https?://[a-zA-Z0-9._-]+/)[a-zA-Z0-9._/-]+(\.git)?$`
GitRepository string `json:"gitRepository"`
// Required unless SkipGitInit is true. Format validated by admission webhook.
// +optional
GitRepository string `json:"gitRepository,omitempty"`

// GitBranch is the branch to checkout
// +kubebuilder:default=main
Expand All @@ -167,8 +167,20 @@ type KubernetesSpec struct {
WorkBranch string `json:"workBranch,omitempty"`

// GitSecretRef references a Secret containing SSH key for git
// +kubebuilder:validation:Required
GitSecretRef SecretReference `json:"gitSecretRef"`
// Required unless SkipGitInit is true.
// +optional
GitSecretRef SecretReference `json:"gitSecretRef,omitempty"`

// SkipGitInit skips git clone/checkout and uses a static workspace baked into the agent image.
// A workspace-init container copies /opt/bte-promo-tool into WorkspacePath on an emptyDir volume.
// +optional
SkipGitInit bool `json:"skipGitInit,omitempty"`

// WorkspacePath is the working directory for the agent when SkipGitInit is true.
// Defaults to /workspace/promo-tool.
// +kubebuilder:validation:Pattern=`^/[a-zA-Z0-9._/-]+$`
// +optional
WorkspacePath string `json:"workspacePath,omitempty"`

// ClaudeCredsSecretRef references a Secret containing ~/.claude/ contents
// Required unless ApiKeySecretRef is provided
Expand Down
57 changes: 38 additions & 19 deletions api/v1alpha1/polecat_webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func (v *PolecatCustomValidator) validatePolecat(polecat *Polecat) (admission.Wa
if polecat.Spec.Kubernetes == nil {
allErrs = append(allErrs, "spec.kubernetes: is required when executionMode is 'kubernetes'")
} else {
errs := validateKubernetesSpec(polecat.Spec.Kubernetes)
errs := validateKubernetesSpec(polecat.Spec.Kubernetes, polecat.Spec.AgentConfig)
allErrs = append(allErrs, errs...)
}
}
Expand Down Expand Up @@ -163,34 +163,48 @@ func containsPathTraversal(s string) bool {
}

// validateKubernetesSpec validates the kubernetes execution spec.
func validateKubernetesSpec(k *KubernetesSpec) []string {
func validateKubernetesSpec(k *KubernetesSpec, agentConfig *AgentConfig) []string {
var errs []string

// GitRepository is required (validated by CRD, but double-check)
if k.GitRepository == "" {
errs = append(errs, "spec.kubernetes.gitRepository: is required")
}
if k.SkipGitInit {
if k.WorkspacePath == "" {
errs = append(errs, "spec.kubernetes.workspacePath: is required when skipGitInit is true")
} else if containsPathTraversal(k.WorkspacePath) {
errs = append(errs, "spec.kubernetes.workspacePath: must not contain path traversal sequences")
} else if !strings.HasPrefix(k.WorkspacePath, "/") {
errs = append(errs, "spec.kubernetes.workspacePath: must be an absolute path")
}
} else {
// GitRepository is required unless skipGitInit
if k.GitRepository == "" {
errs = append(errs, "spec.kubernetes.gitRepository: is required")
}

// Reject path traversal in GitRepository
if k.GitRepository != "" && containsPathTraversal(k.GitRepository) {
errs = append(errs, "spec.kubernetes.gitRepository: must not contain path traversal sequences")
}

// Reject path traversal in GitRepository
if k.GitRepository != "" && containsPathTraversal(k.GitRepository) {
errs = append(errs, "spec.kubernetes.gitRepository: must not contain path traversal sequences")
// GitSecretRef is required unless skipGitInit
if k.GitSecretRef.Name == "" {
errs = append(errs, "spec.kubernetes.gitSecretRef.name: is required")
}
}

// Reject path traversal in GitBranch
if k.GitBranch != "" && containsPathTraversal(k.GitBranch) {
errs = append(errs, "spec.kubernetes.gitBranch: must not contain path traversal sequences")
}

// GitSecretRef is required
if k.GitSecretRef.Name == "" {
errs = append(errs, "spec.kubernetes.gitSecretRef.name: is required")
}

// Either ClaudeCredsSecretRef or ApiKeySecretRef is required for authentication
// Auth: OAuth creds, direct API key, or LiteLLM/gateway key via agentConfig
hasOAuth := k.ClaudeCredsSecretRef != nil && k.ClaudeCredsSecretRef.Name != ""
hasAPIKey := k.ApiKeySecretRef != nil && k.ApiKeySecretRef.Name != ""
if !hasOAuth && !hasAPIKey {
errs = append(errs, "spec.kubernetes: either claudeCredsSecretRef or apiKeySecretRef is required")
hasGateway := agentConfig != nil &&
agentConfig.ModelProvider != nil &&
agentConfig.ModelProvider.APIKeySecretRef != nil &&
agentConfig.ModelProvider.APIKeySecretRef.Name != ""
if !hasOAuth && !hasAPIKey && !hasGateway {
errs = append(errs, "spec.kubernetes: either claudeCredsSecretRef, apiKeySecretRef, or agentConfig.modelProvider.apiKeySecretRef is required")
}

// Validate ActiveDeadlineSeconds
Expand All @@ -210,11 +224,13 @@ func validateKubernetesSpec(k *KubernetesSpec) []string {

// validateImageRegistry checks that the image is from an allowed registry.
// This prevents attackers from running arbitrary container images in the cluster.
// Local Docker Desktop builds (polecat-agent:local) are allowed in addition to published registries.
var allowedRegistries = []string{
"ghcr.io/boshu2/", // Official Gas Town images
"docker.io/boshu2/", // Docker Hub official images
"registry.access.redhat.com/", // Red Hat UBI images for FIPS
"quay.io/boshu2/", // Quay official images
"polecat-agent:", // Local Docker Desktop image (name:tag, no registry)
}

func validateImageRegistry(image string) error {
Expand Down Expand Up @@ -344,8 +360,11 @@ func (d *PolecatCustomDefaulter) Default(ctx context.Context, polecat *Polecat)

// Set kubernetes defaults
if polecat.Spec.ExecutionMode == ExecutionModeKubernetes && polecat.Spec.Kubernetes != nil {
// Set default git branch
if polecat.Spec.Kubernetes.GitBranch == "" {
if polecat.Spec.Kubernetes.SkipGitInit {
if polecat.Spec.Kubernetes.WorkspacePath == "" {
polecat.Spec.Kubernetes.WorkspacePath = "/workspace/promo-tool"
}
} else if polecat.Spec.Kubernetes.GitBranch == "" {
polecat.Spec.Kubernetes.GitBranch = "main"
}

Expand Down
30 changes: 27 additions & 3 deletions api/v1alpha1/polecat_webhook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func TestPolecatCustomValidator_ValidateCreate(t *testing.T) {
},
},
wantErr: true,
errContains: "spec.kubernetes: either claudeCredsSecretRef or apiKeySecretRef is required",
errContains: "spec.kubernetes: either claudeCredsSecretRef, apiKeySecretRef, or agentConfig.modelProvider.apiKeySecretRef is required",
},
{
name: "high resource usage warning",
Expand Down Expand Up @@ -521,6 +521,30 @@ func TestValidateKubernetesSpec(t *testing.T) {
},
wantErrs: 0,
},
{
name: "skip git init valid",
spec: &KubernetesSpec{
SkipGitInit: true,
WorkspacePath: "/workspace/promo-tool",
ApiKeySecretRef: &SecretKeyRef{
Name: "litellm-auth",
Key: "master-key",
},
},
wantErrs: 0,
},
{
name: "skip git init missing workspace path",
spec: &KubernetesSpec{
SkipGitInit: true,
ApiKeySecretRef: &SecretKeyRef{
Name: "litellm-auth",
Key: "master-key",
},
},
wantErrs: 1,
errContains: []string{"spec.kubernetes.workspacePath: is required when skipGitInit is true"},
},
{
name: "missing git repository",
spec: &KubernetesSpec{
Expand All @@ -537,7 +561,7 @@ func TestValidateKubernetesSpec(t *testing.T) {
errContains: []string{
"spec.kubernetes.gitRepository: is required",
"spec.kubernetes.gitSecretRef.name: is required",
"spec.kubernetes: either claudeCredsSecretRef or apiKeySecretRef is required",
"spec.kubernetes: either claudeCredsSecretRef, apiKeySecretRef, or agentConfig.modelProvider.apiKeySecretRef is required",
},
},
{
Expand Down Expand Up @@ -586,7 +610,7 @@ func TestValidateKubernetesSpec(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validateKubernetesSpec(tt.spec)
errs := validateKubernetesSpec(tt.spec, nil)
assert.Len(t, errs, tt.wantErrs)
for _, expected := range tt.errContains {
found := false
Expand Down
12 changes: 12 additions & 0 deletions bte-promo-script/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Local reference — copy values as needed. Not read by pods at runtime.
#
# Secrets → secrets.env + deploy/apply-secrets.sh (K8s secretKeyRef)
# MADEYE_BASE_URL → madeye-proxy Deployment env (see deploy/madeye-proxy.yaml)

# --- K8s secrets (apply-secrets.sh) ---
GENAXIS_API_KEY=replace-with-genaxis-api-key
MADEYE_API_KEY=replace-with-madeye-bearer-token

# --- madeye-proxy pod env (K8s Deployment, not defaults.go) ---
# Host only — proxy appends /v1/chat/completions
MADEYE_BASE_URL=https://madeye-dev.internal.pocketfm.org
Loading
Loading