diff --git a/.config/scripts/e2e-run.sh b/.config/scripts/e2e-run.sh index 265064b..e8fe6bb 100755 --- a/.config/scripts/e2e-run.sh +++ b/.config/scripts/e2e-run.sh @@ -72,7 +72,7 @@ OP_CONNECT_TOKEN="$OP_TOKEN" \ OP_VAULT_NAME="$OP_VAULT" \ OP_SECRET_ITEM_NAME="$OP_SECRET_ITEM" \ go test -tags e2e -v -count=1 -timeout 30m \ - -run 'TestLifecycle|TestIronClawLifecycle|TestOpenClawLifecycle|TestPicoClawLifecycle' \ + -run 'TestLifecycle|TestIronClawLifecycle|TestOpenClawLifecycle|TestOpenClawExtraSoftwareInstallsClaude|TestPicoClawLifecycle' \ ./e2e/ echo "✅ Lifecycle E2E tests passed!" diff --git a/control-plane/charts/ironclaw/templates/startup-configmap.yaml b/control-plane/charts/ironclaw/templates/startup-configmap.yaml index ec549db..5da6867 100644 --- a/control-plane/charts/ironclaw/templates/startup-configmap.yaml +++ b/control-plane/charts/ironclaw/templates/startup-configmap.yaml @@ -9,6 +9,10 @@ data: #!/bin/sh set -e + warn() { + echo "WARNING: $*" + } + # Load secrets from volume files into environment SECRETS_DIR="/etc/claw/secrets" if [ -d "$SECRETS_DIR" ]; then @@ -19,6 +23,26 @@ data: done fi + {{- if .Values.extraSoftware.toolVersions }} + WORKSPACE_DIR="${HOME:-/home/ironclaw}/.ironclaw/workspace" + TOOL_VERSIONS_FILE="$WORKSPACE_DIR/.tool-versions" + mkdir -p "$WORKSPACE_DIR" + + echo "Applying extra software tools from $TOOL_VERSIONS_FILE" + cat >"$TOOL_VERSIONS_FILE" <<'CLAWMACHINE_TOOL_VERSIONS' +{{ .Values.extraSoftware.toolVersions | nindent 4 }} + CLAWMACHINE_TOOL_VERSIONS + sed -i 's/^ //' "$TOOL_VERSIONS_FILE" + + export MISE_CACHE_DIR="$WORKSPACE_DIR/.cache/mise" + mkdir -p "$MISE_CACHE_DIR" + if ! command -v mise >/dev/null 2>&1; then + warn "mise is not available in the container; skipping extra software installation" + elif ! (cd "$WORKSPACE_DIR" && mise install); then + warn "mise install failed; continuing startup" + fi + {{- end }} + {{- if .Values.postgresql.enabled }} # Wait for PostgreSQL to accept TCP connections. # Keep this bounded so probes do not kill the pod before app start. diff --git a/control-plane/charts/ironclaw/values.yaml b/control-plane/charts/ironclaw/values.yaml index 464e006..edb0455 100644 --- a/control-plane/charts/ironclaw/values.yaml +++ b/control-plane/charts/ironclaw/values.yaml @@ -141,6 +141,9 @@ workspace: env: {} +extraSoftware: + toolVersions: "" + backup: enabled: false restoreOnStartup: false diff --git a/control-plane/charts/openclaw/templates/deployment.yaml b/control-plane/charts/openclaw/templates/deployment.yaml index 8a0392c..524be43 100644 --- a/control-plane/charts/openclaw/templates/deployment.yaml +++ b/control-plane/charts/openclaw/templates/deployment.yaml @@ -13,6 +13,9 @@ spec: metadata: labels: {{- include "openclaw.labels" . | nindent 8 }} + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/startup: {{ include (print $.Template.BasePath "/startup-configmap.yaml") . | sha256sum }} spec: {{- if and .Values.backup.enabled .Values.backup.restoreOnStartup }} initContainers: diff --git a/control-plane/charts/openclaw/templates/startup-configmap.yaml b/control-plane/charts/openclaw/templates/startup-configmap.yaml index a348990..18d5070 100644 --- a/control-plane/charts/openclaw/templates/startup-configmap.yaml +++ b/control-plane/charts/openclaw/templates/startup-configmap.yaml @@ -33,13 +33,33 @@ data: echo "Applying managed configFile.content to $CONFIG_FILE" cat >"$CONFIG_FILE" <<'CLAWMACHINE_OPENCLAW_CONFIG' {{ .Values.configFile.content | nindent 4 }} -CLAWMACHINE_OPENCLAW_CONFIG + CLAWMACHINE_OPENCLAW_CONFIG {{- end }} warn() { echo "WARNING: $*" } + {{- if .Values.extraSoftware.toolVersions }} + TOOL_VERSIONS_DIR="{{ .Values.openclawConfigDir }}/workspace" + TOOL_VERSIONS_FILE="$TOOL_VERSIONS_DIR/.tool-versions" + mkdir -p "$TOOL_VERSIONS_DIR" + + echo "Applying extra software tools from $TOOL_VERSIONS_FILE" + cat >"$TOOL_VERSIONS_FILE" <<'CLAWMACHINE_TOOL_VERSIONS' +{{ .Values.extraSoftware.toolVersions | nindent 4 }} + CLAWMACHINE_TOOL_VERSIONS + sed -i 's/^ //' "$TOOL_VERSIONS_FILE" + + export MISE_CACHE_DIR="$TOOL_VERSIONS_DIR/.cache/mise" + mkdir -p "$MISE_CACHE_DIR" + if ! command -v mise >/dev/null 2>&1; then + warn "mise is not available in the container; skipping extra software installation" + elif ! (cd "$TOOL_VERSIONS_DIR" && mise install); then + warn "mise install failed; continuing startup" + fi + {{- end }} + set_cfg_json() { local path="$1" local value="$2" diff --git a/control-plane/charts/openclaw/values.yaml b/control-plane/charts/openclaw/values.yaml index 827ac68..05b1e27 100644 --- a/control-plane/charts/openclaw/values.yaml +++ b/control-plane/charts/openclaw/values.yaml @@ -112,6 +112,9 @@ networkPolicy: env: {} +extraSoftware: + toolVersions: "" + backup: enabled: false restoreOnStartup: false diff --git a/control-plane/charts/picoclaw/templates/deployment.yaml b/control-plane/charts/picoclaw/templates/deployment.yaml index 320e362..e3b4cc1 100644 --- a/control-plane/charts/picoclaw/templates/deployment.yaml +++ b/control-plane/charts/picoclaw/templates/deployment.yaml @@ -96,6 +96,8 @@ spec: - name: picoclaw image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" imagePullPolicy: {{ .Values.image.pullPolicy }} + command: ["/bin/sh", "/scripts/start-picoclaw.sh"] + args: ["gateway"] ports: - name: http containerPort: {{ .Values.service.port }} @@ -128,9 +130,15 @@ spec: resources: {{- toYaml .Values.resources | nindent 12 }} volumeMounts: + - name: startup-script + mountPath: /scripts - name: workspace mountPath: {{ .Values.configFile.mountPath }} volumes: + - name: startup-script + configMap: + name: {{ include "picoclaw.fullname" . }}-startup + defaultMode: 0755 - name: workspace {{- if .Values.persistence.enabled }} persistentVolumeClaim: diff --git a/control-plane/charts/picoclaw/templates/startup-configmap.yaml b/control-plane/charts/picoclaw/templates/startup-configmap.yaml index c5d4c25..34010a9 100644 --- a/control-plane/charts/picoclaw/templates/startup-configmap.yaml +++ b/control-plane/charts/picoclaw/templates/startup-configmap.yaml @@ -9,6 +9,10 @@ data: #!/bin/sh set -e + warn() { + echo "WARNING: $*" + } + # Load secrets from volume files into environment SECRETS_DIR="/etc/claw/secrets" if [ -d "$SECRETS_DIR" ]; then @@ -19,5 +23,29 @@ data: done fi + {{- if .Values.extraSoftware.toolVersions }} + WORKSPACE_DIR="${HOME:-/home/picoclaw}/.picoclaw/workspace" + TOOL_VERSIONS_FILE="$WORKSPACE_DIR/.tool-versions" + mkdir -p "$WORKSPACE_DIR" + + echo "Applying extra software tools from $TOOL_VERSIONS_FILE" + cat >"$TOOL_VERSIONS_FILE" <<'CLAWMACHINE_TOOL_VERSIONS' +{{ .Values.extraSoftware.toolVersions | nindent 4 }} + CLAWMACHINE_TOOL_VERSIONS + sed -i 's/^ //' "$TOOL_VERSIONS_FILE" + + export MISE_CACHE_DIR="$WORKSPACE_DIR/.cache/mise" + mkdir -p "$MISE_CACHE_DIR" + if ! command -v mise >/dev/null 2>&1; then + warn "mise is not available in the container; skipping extra software installation" + elif ! (cd "$WORKSPACE_DIR" && mise install); then + warn "mise install failed; continuing startup" + fi + {{- end }} + + # Bind gateway on all interfaces for Kubernetes probes/services unless overridden. + export PICOCLAW_GATEWAY_HOST="${PICOCLAW_GATEWAY_HOST:-0.0.0.0}" + export PICOCLAW_GATEWAY_PORT="${PICOCLAW_GATEWAY_PORT:-{{ .Values.service.port }}}" + # Exec the picoclaw binary exec /usr/local/bin/picoclaw "$@" diff --git a/control-plane/charts/picoclaw/values.yaml b/control-plane/charts/picoclaw/values.yaml index c742195..4413956 100644 --- a/control-plane/charts/picoclaw/values.yaml +++ b/control-plane/charts/picoclaw/values.yaml @@ -47,6 +47,9 @@ networkPolicy: env: {} +extraSoftware: + toolVersions: "" + # Config file mount — generates a Secret with bot config and mounts it as a file. configFile: enabled: false diff --git a/control-plane/e2e/openclaw_extra_software_test.go b/control-plane/e2e/openclaw_extra_software_test.go new file mode 100644 index 0000000..573a920 --- /dev/null +++ b/control-plane/e2e/openclaw_extra_software_test.go @@ -0,0 +1,102 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "io" + "strings" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func waitForPodReadyBySelector(t *testing.T, namespace, labelSelector string, timeout time.Duration) string { + t.Helper() + clientset, _ := getK8sClients(t) + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + pods, err := clientset.CoreV1().Pods(namespace).List(context.Background(), metav1.ListOptions{ + LabelSelector: labelSelector, + }) + if err == nil && len(pods.Items) > 0 { + for _, pod := range pods.Items { + for _, cond := range pod.Status.Conditions { + if cond.Type == "Ready" && cond.Status == "True" { + return pod.Name + } + } + } + } + time.Sleep(3 * time.Second) + } + + t.Fatalf("timed out waiting for ready pod with selector %q", labelSelector) + return "" +} + +func TestOpenClawExtraSoftwareInstallsClaude(t *testing.T) { + skipIfNoServer(t) + + clientset, config := getK8sClients(t) + const namespace = "claw-machine" + const testBotName = "e2e-openclaw-mise" + + doDelete(t, baseURL+"/bots/"+testBotName) + time.Sleep(2 * time.Second) + + t.Cleanup(func() { + doDelete(t, baseURL+"/bots/"+testBotName) + }) + + body := map[string]any{ + "releaseName": testBotName, + "botType": "openclaw", + "values": map[string]any{ + "persistence": map[string]any{ + "enabled": true, + "size": "1Gi", + }, + "networkPolicy": map[string]any{ + "ingress": false, + "egress": true, + }, + "extraSoftware": map[string]any{ + "toolVersions": "claude latest", + }, + }, + } + + resp := doPost(t, baseURL+"/bots", body) + if resp.StatusCode != 201 && resp.StatusCode != 200 && resp.StatusCode != 204 && resp.StatusCode != 202 { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("create bot failed: %d — %s", resp.StatusCode, string(b)) + } + resp.Body.Close() + + selector := fmt.Sprintf("app.kubernetes.io/instance=%s", testBotName) + waitForPodRunning(t, clientset, namespace, selector, 4*time.Minute) + podName := waitForPodReadyBySelector(t, namespace, selector, 4*time.Minute) + t.Logf("openclaw pod ready: %s", podName) + + stdout, stderr, err := execInPod(t, clientset, config, namespace, podName, []string{ + "sh", "-lc", "cat /root/.openclaw/workspace/.tool-versions", + }) + if err != nil { + t.Fatalf("read .tool-versions failed: %v\nstderr=%s", err, stderr) + } + if !strings.Contains(stdout, "claude latest") { + t.Fatalf(".tool-versions missing expected entry; got:\n%s", stdout) + } + + stdout, stderr, err = execInPod(t, clientset, config, namespace, podName, []string{ + "sh", "-lc", "cd /root/.openclaw/workspace && CLAUDE_BIN=\"$(mise which claude 2>/dev/null)\" && [ -n \"$CLAUDE_BIN\" ] && [ -x \"$CLAUDE_BIN\" ] && echo \"$CLAUDE_BIN\"", + }) + if err != nil { + t.Fatalf("claude binary lookup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout) + } + t.Logf("claude binary found: %s", strings.TrimSpace(stdout)) +} diff --git a/control-plane/e2e/openclaw_lifecycle_test.go b/control-plane/e2e/openclaw_lifecycle_test.go index 5a9cb43..3d4d27e 100644 --- a/control-plane/e2e/openclaw_lifecycle_test.go +++ b/control-plane/e2e/openclaw_lifecycle_test.go @@ -368,7 +368,7 @@ func TestOpenClawLifecycle(t *testing.T) { "anthropicApiKey": "1p:e2e-anthropic-key", "discordBotToken": "1p:e2e-discord-token", "discordEnabled": "true", - "defaultModel": "anthropic/claude-haiku-4-5-20251001", // Changed model + "defaultModel": "anthropic/claude-haiku-4-5", // Changed model }, } resp := doPut(t, baseURL+"/bots/"+testBotName+"/config", body) @@ -409,7 +409,7 @@ func TestOpenClawLifecycle(t *testing.T) { }) t.Run("Step10_VerifyUpdatedRuntimeConfig", func(t *testing.T) { - assertRuntimeConfig(t, "anthropic/claude-haiku-4-5-20251001") + assertRuntimeConfig(t, "anthropic/claude-haiku-4-5") }) // --- Step 11: Delete bot --- diff --git a/control-plane/internal/handler/helm.go b/control-plane/internal/handler/helm.go index 019b3b8..a9ca900 100644 --- a/control-plane/internal/handler/helm.go +++ b/control-plane/internal/handler/helm.go @@ -500,6 +500,23 @@ func (h *HelmHandler) NewConfigPage(w http.ResponseWriter, r *http.Request) { h.renderInstallConfigPage(w, r, botType, allFormValues(r)) } +// NewSoftwarePage renders step 3 after validating step 2. +func (h *HelmHandler) NewSoftwarePage(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + htmxError(w, r, "Invalid form: "+err.Error(), http.StatusBadRequest) + return + } + + botType := formValue(r, "botType") + releaseName := strings.TrimSpace(formValue(r, "releaseName")) + if !middleware.ValidName(releaseName) { + htmxError(w, r, "Invalid release name", http.StatusBadRequest) + return + } + + h.renderInstallSoftwarePage(w, r, botType, allFormValues(r)) +} + // AvailableSecret is a synced ExternalSecret for template rendering. type AvailableSecret struct { Name string @@ -631,6 +648,45 @@ func (h *HelmHandler) renderInstallConfigPage(w http.ResponseWriter, r *http.Req } } +func (h *HelmHandler) renderInstallSoftwarePage(w http.ResponseWriter, r *http.Request, botType string, values map[string]string) { + allowed := slices.Contains(h.allowedBotTypes(), botType) + if !allowed { + http.NotFound(w, r) + return + } + + var botConfig *botenv.BotConfig + if h.bots != nil { + botConfig = h.bots.Get(botType) + } + if botConfig == nil { + botConfig = &botenv.BotConfig{ + Name: botType, + DisplayName: botType, + } + } + + if values == nil { + values = make(map[string]string) + } + values["botType"] = botType + if values["onboardingVersion"] == "" { + values["onboardingVersion"] = onboarding.ProfileVersion + } + + data := struct { + BotConfig *botenv.BotConfig + Values map[string]string + }{ + BotConfig: botConfig, + Values: values, + } + + if !renderOrError(w, r, h.tmpl, "bot-form-software", data, isHTMX(r)) { + return + } +} + func (h *HelmHandler) availableSecrets(ctx context.Context) []AvailableSecret { return availableSecretsForTemplates(ctx, h.secrets) } @@ -1518,6 +1574,13 @@ func parseInstallForm(r *http.Request, bots *botenv.Registry) (service.InstallOp } } + extraToolVersions := strings.TrimSpace(formValue(r, "extraToolVersions")) + if extraToolVersions != "" { + opts.Values["extraSoftware"] = map[string]any{ + "toolVersions": extraToolVersions, + } + } + // Config fields (cfg:key form names) configFields := make(map[string]string) for key, vals := range r.Form { diff --git a/control-plane/internal/handler/helm_test.go b/control-plane/internal/handler/helm_test.go index 6defe0c..0b7f54f 100644 --- a/control-plane/internal/handler/helm_test.go +++ b/control-plane/internal/handler/helm_test.go @@ -774,6 +774,61 @@ func TestHelmHandler_NewPage_WithType_SetsPersistenceDefaults(t *testing.T) { } } +func TestHelmHandler_NewSoftwarePage_RendersStep3(t *testing.T) { + tmpl := &mockTemplate{} + botReg, err := botenv.NewRegistry() + if err != nil { + t.Fatalf("new registry: %v", err) + } + h := NewHelmHandler(&mockHelm{}, tmpl, nil, nil, nil, botReg, false) + + form := "releaseName=my-bot&botType=openclaw&extraToolVersions=node+22%0Ajq+1.8.1" + req := httptest.NewRequest(http.MethodPost, "/bots/new/software", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + h.NewSoftwarePage(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if len(tmpl.calls) != 1 { + t.Fatalf("expected 1 template call, got %d", len(tmpl.calls)) + } + if tmpl.calls[0].name != "bot-form-software" { + t.Fatalf("template = %q, want bot-form-software", tmpl.calls[0].name) + } + + data, ok := tmpl.calls[0].data.(struct { + BotConfig *botenv.BotConfig + Values map[string]string + }) + if !ok { + t.Fatalf("unexpected data type: %T", tmpl.calls[0].data) + } + if got := data.Values["extraToolVersions"]; got != "node 22\njq 1.8.1" { + t.Fatalf("values[extraToolVersions] = %q, want multiline tool versions", got) + } +} + +func TestHelmHandler_NewSoftwarePage_InvalidReleaseName(t *testing.T) { + tmpl := &mockTemplate{} + h := NewHelmHandler(&mockHelm{}, tmpl, nil, nil, nil, nil, false) + + form := "releaseName=Bad_Name&botType=openclaw" + req := httptest.NewRequest(http.MethodPost, "/bots/new/software", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("HX-Request", "true") + rec := httptest.NewRecorder() + h.NewSoftwarePage(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } + if len(tmpl.calls) != 0 { + t.Fatalf("expected no template calls, got %d", len(tmpl.calls)) + } +} + func TestHelmHandler_Install_FormPost(t *testing.T) { done := make(chan struct{}) installed := &service.ReleaseInfo{Name: "form-bot", Status: "deployed"} @@ -781,7 +836,7 @@ func TestHelmHandler_Install_FormPost(t *testing.T) { reg := &botenv.Registry{} h := NewHelmHandler(mock, &mockTemplate{}, nil, nil, nil, reg, false) - form := "releaseName=form-bot&botType=picoclaw&persistence=on&persistenceSize=2Gi&ingress=on" + form := "releaseName=form-bot&botType=picoclaw&persistence=on&persistenceSize=2Gi&ingress=on&extraToolVersions=node+22%0Ajq+1.8.1" req := httptest.NewRequest(http.MethodPost, "/bots", strings.NewReader(form)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("HX-Request", "true") @@ -819,6 +874,13 @@ func TestHelmHandler_Install_FormPost(t *testing.T) { if np == nil || np["ingress"] != true { t.Errorf("networkPolicy.ingress = %v, want true", np) } + extraSoftware, _ := mock.installOpts.Values["extraSoftware"].(map[string]any) + if extraSoftware == nil { + t.Fatalf("extraSoftware missing from install values: %#v", mock.installOpts.Values) + } + if got := extraSoftware["toolVersions"]; got != "node 22\njq 1.8.1" { + t.Errorf("extraSoftware.toolVersions = %v, want multiline tool versions", got) + } } func TestHelmHandler_ListPage_TemplateError(t *testing.T) { @@ -1004,6 +1066,19 @@ func TestParseInstallForm(t *testing.T) { } }, }, + { + name: "extra software tool versions", + form: "releaseName=x&botType=picoclaw&extraToolVersions=node+22%0Ajq+1.8.1", + check: func(t *testing.T, opts service.InstallOptions) { + extraSoftware, ok := opts.Values["extraSoftware"].(map[string]any) + if !ok { + t.Fatalf("extraSoftware missing from values: %#v", opts.Values) + } + if got := extraSoftware["toolVersions"]; got != "node 22\njq 1.8.1" { + t.Errorf("toolVersions = %v, want multiline tool versions", got) + } + }, + }, } for _, tt := range tests { diff --git a/control-plane/internal/routes.go b/control-plane/internal/routes.go index b801c9d..9f7e885 100644 --- a/control-plane/internal/routes.go +++ b/control-plane/internal/routes.go @@ -33,6 +33,7 @@ func Setup(mux *http.ServeMux, handlers *Handlers) { mux.HandleFunc("GET /bots/new", handlers.Helm.NewPage) mux.HandleFunc("POST /bots/new/infra", handlers.Helm.NewInfraPage) mux.HandleFunc("POST /bots/new/config", handlers.Helm.NewConfigPage) + mux.HandleFunc("POST /bots/new/software", handlers.Helm.NewSoftwarePage) mux.HandleFunc("GET /bots/{name}/page", handlers.Helm.DetailPage) // Bot management API diff --git a/control-plane/internal/routes_test.go b/control-plane/internal/routes_test.go index 26f185f..b170da2 100644 --- a/control-plane/internal/routes_test.go +++ b/control-plane/internal/routes_test.go @@ -120,6 +120,7 @@ func TestSetup_AllRoutesRegistered(t *testing.T) { {"GET", "/bots/new", ""}, {"POST", "/bots/new/infra", "botType=picoclaw&releaseName=my-bot"}, {"POST", "/bots/new/config", "botType=picoclaw&releaseName=my-bot"}, + {"POST", "/bots/new/software", "botType=picoclaw&releaseName=my-bot"}, {"GET", "/bots", ""}, {"GET", "/bots/my-bot/cli", ""}, {"GET", "/settings", ""}, diff --git a/control-plane/templates/pages/bot-form-config.html b/control-plane/templates/pages/bot-form-config.html index 515f57e..6b948d8 100644 --- a/control-plane/templates/pages/bot-form-config.html +++ b/control-plane/templates/pages/bot-form-config.html @@ -3,14 +3,14 @@ {{ end }} {{ define "title" }} - - Install {{ .BotConfig.DisplayName }} (2/2) + - Install {{ .BotConfig.DisplayName }} (2/3) {{ end }} {{ define "content" }}