diff --git a/README.md b/README.md index 26591811..9c85efa0 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Every request is validated against the instance's allowlist policy. Protected co | **Scalable** | Auto-scaling | HPA integration with CPU and memory metrics, min/max replica bounds, automatic StatefulSet replica management | | **Resilient** | Self-healing lifecycle | PodDisruptionBudgets, health probes, automatic config rollouts via content hashing, 5-minute drift detection | | **Backup/Restore** | S3-backed snapshots | Automatic backup to S3-compatible storage on deletion, pre-update, and on a cron schedule; restore into a new instance from any snapshot | -| **Workspace Seeding** | Initial files & dirs | Pre-populate the workspace with files and directories before the agent starts | +| **Workspace Seeding** | Initial files & dirs | Pre-populate the workspace with files and directories before the agent starts; reference an external ConfigMap for GitOps workflows | | **Gateway Auth** | Auto-generated tokens | Automatic gateway token Secret per instance, bypassing mDNS pairing (unusable in k8s) | | **Tailscale** | Tailnet access | Expose via Tailscale Serve or Funnel with SSO auth - no Ingress needed | | **Extensible** | Sidecars & init containers | Chromium for browser automation, Ollama for local LLMs, Tailscale for tailnet access, plus custom init containers and sidecars | @@ -477,6 +477,83 @@ spec: npm lifecycle scripts are disabled globally on the init container (`NPM_CONFIG_IGNORE_SCRIPTS=true`) to mitigate supply chain attacks. Plugins are installed into the PVC-backed `~/.openclaw/node_modules` directory and persist across pod restarts. +### Workspace seeding + +Pre-populate the agent workspace with files and directories before the agent starts. Files can be provided inline or referenced from an external ConfigMap -- ideal for GitOps workflows where workspace content is managed alongside your manifests. + +**Inline files:** + +```yaml +spec: + workspace: + initialDirectories: + - tools/scripts + initialFiles: + README.md: | + # My Workspace + This workspace is managed by OpenClaw. +``` + +**External ConfigMap reference:** + +```yaml +spec: + workspace: + configMapRef: + name: agent-alpha-workspace # all keys become workspace files + initialFiles: # inline files (override configMapRef) + EXTRA.md: "additional content" +``` + +All keys in the referenced ConfigMap are written as files into the workspace directory. When both `configMapRef` and `initialFiles` are specified, inline files take precedence over ConfigMap entries with the same filename. + +**Merge priority** (highest wins): operator-injected files > inline `initialFiles` > external `configMapRef` > skill packs. + +The operator sets a `WorkspaceReady` status condition to `False` when the referenced ConfigMap is missing or contains invalid filenames, and `True` once workspace files are seeded successfully. The controller watches external ConfigMaps for changes and re-reconciles automatically. + +**GitOps example with Kustomize:** + +```yaml +# kustomization.yaml +configMapGenerator: + - name: agent-alpha-workspace + files: + - SOUL.md + - AGENT.md +``` + +> **Note:** When using Kustomize `configMapGenerator`, disable the default name suffix hashing (`generatorOptions: { disableNameSuffixHash: true }`) or use the generated name in `configMapRef.name`, since the operator looks up the ConfigMap by exact name. + +**Additional workspaces (multi-agent):** + +When running multiple agents with isolated workspaces, use `additionalWorkspaces` to seed files for each agent. Each entry seeds to `~/.openclaw/workspace-/` -- set matching paths in `spec.config.raw.agents.list[].workspace`. + +```yaml +spec: + workspace: + initialFiles: + SOUL.md: "I am the default agent" + additionalWorkspaces: + - name: work + configMapRef: + name: work-agent-files + initialFiles: + SOUL.md: "I am the work agent" + initialDirectories: + - tools + config: + raw: + agents: + list: + - id: home + default: true + workspace: "~/.openclaw/workspace" + - id: work + workspace: "~/.openclaw/workspace-work" +``` + +Each additional workspace supports the same `configMapRef`, `initialFiles`, and `initialDirectories` as the default workspace. Operator-injected `ENVIRONMENT.md` is included; `BOOTSTRAP.md` is not (only the default agent runs onboarding). Max 10 additional workspaces. + ### Self-configure Allow agents to modify their own configuration by creating `OpenClawSelfConfig` resources via the K8s API. The operator validates each request against the instance's `allowedActions` policy before applying changes: diff --git a/api/v1alpha1/openclawinstance_types.go b/api/v1alpha1/openclawinstance_types.go index 3da4bcb8..0600e5be 100644 --- a/api/v1alpha1/openclawinstance_types.go +++ b/api/v1alpha1/openclawinstance_types.go @@ -240,6 +240,19 @@ type RawConfig struct { // Files listed in InitialFiles are seeded once (only if they don't already // exist on the PVC), so agent modifications survive pod restarts. type WorkspaceSpec struct { + // ConfigMapRef references an external ConfigMap whose keys become workspace files. + // All keys in the referenced ConfigMap are included as workspace files. + // This is useful for GitOps workflows where workspace files (AGENT.md, SOUL.md, etc.) + // are managed as standalone files and bundled via Kustomize configMapGenerator or similar. + // + // Merge priority (highest wins): + // 1. Operator-injected files (ENVIRONMENT.md, BOOTSTRAP.md, SELFCONFIG.md, selfconfig.sh) + // 2. Inline initialFiles + // 3. External configMapRef entries + // 4. Skill pack files + // +optional + ConfigMapRef *ConfigMapNameSelector `json:"configMapRef,omitempty"` + // InitialFiles maps filenames to their content. Each file is written // to the workspace directory only if it does not already exist. // +kubebuilder:validation:MaxProperties=50 @@ -251,6 +264,47 @@ type WorkspaceSpec struct { // +kubebuilder:validation:MaxItems=20 // +optional InitialDirectories []string `json:"initialDirectories,omitempty"` + + // AdditionalWorkspaces configures workspace files for secondary agents. + // Each entry seeds files to ~/.openclaw/workspace-/, matching the + // workspace path configured in spec.config.raw.agents.list[].workspace. + // +kubebuilder:validation:MaxItems=10 + // +optional + AdditionalWorkspaces []AdditionalWorkspace `json:"additionalWorkspaces,omitempty"` +} + +// AdditionalWorkspace defines a named workspace for a secondary agent. +// The operator seeds files to ~/.openclaw/workspace-/. +type AdditionalWorkspace struct { + // Name identifies this workspace. The operator seeds files to + // ~/.openclaw/workspace-/. Must match the workspace path + // configured in spec.config.raw.agents.list[].workspace. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` + Name string `json:"name"` + + // ConfigMapRef references an external ConfigMap whose keys become workspace files. + // +optional + ConfigMapRef *ConfigMapNameSelector `json:"configMapRef,omitempty"` + + // InitialFiles maps filenames to their content (same as spec.workspace.initialFiles). + // +kubebuilder:validation:MaxProperties=50 + // +optional + InitialFiles map[string]string `json:"initialFiles,omitempty"` + + // InitialDirectories is a list of directories to create inside this workspace. + // +kubebuilder:validation:MaxItems=20 + // +optional + InitialDirectories []string `json:"initialDirectories,omitempty"` +} + +// ConfigMapNameSelector references a ConfigMap by name. +// Unlike ConfigMapKeySelector, all keys in the ConfigMap are used. +type ConfigMapNameSelector struct { + // Name is the name of the ConfigMap to reference. + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` } // ResourcesSpec defines compute resource requirements @@ -1524,6 +1578,10 @@ const ( // ConditionTypeSkillPacksReady indicates skill packs were resolved successfully ConditionTypeSkillPacksReady = "SkillPacksReady" + + // ConditionTypeWorkspaceReady indicates the workspace configuration is valid + // and any external ConfigMap referenced by spec.workspace.configMapRef exists + ConditionTypeWorkspaceReady = "WorkspaceReady" ) // Phase constants diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 8f7e4e46..1e6f87fd 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -27,6 +27,38 @@ import ( "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AdditionalWorkspace) DeepCopyInto(out *AdditionalWorkspace) { + *out = *in + if in.ConfigMapRef != nil { + in, out := &in.ConfigMapRef, &out.ConfigMapRef + *out = new(ConfigMapNameSelector) + **out = **in + } + if in.InitialFiles != nil { + in, out := &in.InitialFiles, &out.InitialFiles + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.InitialDirectories != nil { + in, out := &in.InitialDirectories, &out.InitialDirectories + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AdditionalWorkspace. +func (in *AdditionalWorkspace) DeepCopy() *AdditionalWorkspace { + if in == nil { + return nil + } + out := new(AdditionalWorkspace) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AutoScalingSpec) DeepCopyInto(out *AutoScalingSpec) { *out = *in @@ -301,6 +333,21 @@ func (in *ConfigMapKeySelector) DeepCopy() *ConfigMapKeySelector { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConfigMapNameSelector) DeepCopyInto(out *ConfigMapNameSelector) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigMapNameSelector. +func (in *ConfigMapNameSelector) DeepCopy() *ConfigMapNameSelector { + if in == nil { + return nil + } + out := new(ConfigMapNameSelector) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ConfigSpec) DeepCopyInto(out *ConfigSpec) { *out = *in @@ -1740,6 +1787,11 @@ func (in *WebTerminalSpec) DeepCopy() *WebTerminalSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WorkspaceSpec) DeepCopyInto(out *WorkspaceSpec) { *out = *in + if in.ConfigMapRef != nil { + in, out := &in.ConfigMapRef, &out.ConfigMapRef + *out = new(ConfigMapNameSelector) + **out = **in + } if in.InitialFiles != nil { in, out := &in.InitialFiles, &out.InitialFiles *out = make(map[string]string, len(*in)) @@ -1752,6 +1804,13 @@ func (in *WorkspaceSpec) DeepCopyInto(out *WorkspaceSpec) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.AdditionalWorkspaces != nil { + in, out := &in.AdditionalWorkspaces, &out.AdditionalWorkspaces + *out = make([]AdditionalWorkspace, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceSpec. diff --git a/charts/openclaw-operator/templates/crds/openclaw.rocks_openclawinstances.yaml b/charts/openclaw-operator/templates/crds/openclaw.rocks_openclawinstances.yaml index 144fe17f..76b4ae70 100644 --- a/charts/openclaw-operator/templates/crds/openclaw.rocks_openclawinstances.yaml +++ b/charts/openclaw-operator/templates/crds/openclaw.rocks_openclawinstances.yaml @@ -9465,6 +9465,75 @@ spec: Files are copied once on first boot and never overwritten, so agent modifications survive pod restarts. properties: + additionalWorkspaces: + description: |- + AdditionalWorkspaces configures workspace files for secondary agents. + Each entry seeds files to ~/.openclaw/workspace-/, matching the + workspace path configured in spec.config.raw.agents.list[].workspace. + items: + description: |- + AdditionalWorkspace defines a named workspace for a secondary agent. + The operator seeds files to ~/.openclaw/workspace-/. + properties: + configMapRef: + description: ConfigMapRef references an external ConfigMap + whose keys become workspace files. + properties: + name: + description: Name is the name of the ConfigMap to reference. + minLength: 1 + type: string + required: + - name + type: object + initialDirectories: + description: InitialDirectories is a list of directories + to create inside this workspace. + items: + type: string + maxItems: 20 + type: array + initialFiles: + additionalProperties: + type: string + description: InitialFiles maps filenames to their content + (same as spec.workspace.initialFiles). + maxProperties: 50 + type: object + name: + description: |- + Name identifies this workspace. The operator seeds files to + ~/.openclaw/workspace-/. Must match the workspace path + configured in spec.config.raw.agents.list[].workspace. + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ + type: string + required: + - name + type: object + maxItems: 10 + type: array + configMapRef: + description: |- + ConfigMapRef references an external ConfigMap whose keys become workspace files. + All keys in the referenced ConfigMap are included as workspace files. + This is useful for GitOps workflows where workspace files (AGENT.md, SOUL.md, etc.) + are managed as standalone files and bundled via Kustomize configMapGenerator or similar. + + Merge priority (highest wins): + 1. Operator-injected files (ENVIRONMENT.md, BOOTSTRAP.md, SELFCONFIG.md, selfconfig.sh) + 2. Inline initialFiles + 3. External configMapRef entries + 4. Skill pack files + properties: + name: + description: Name is the name of the ConfigMap to reference. + minLength: 1 + type: string + required: + - name + type: object initialDirectories: description: |- InitialDirectories is a list of directories to create (mkdir -p) diff --git a/config/crd/bases/openclaw.rocks_openclawinstances.yaml b/config/crd/bases/openclaw.rocks_openclawinstances.yaml index b3e04a64..00ced068 100644 --- a/config/crd/bases/openclaw.rocks_openclawinstances.yaml +++ b/config/crd/bases/openclaw.rocks_openclawinstances.yaml @@ -9459,6 +9459,75 @@ spec: Files are copied once on first boot and never overwritten, so agent modifications survive pod restarts. properties: + additionalWorkspaces: + description: |- + AdditionalWorkspaces configures workspace files for secondary agents. + Each entry seeds files to ~/.openclaw/workspace-/, matching the + workspace path configured in spec.config.raw.agents.list[].workspace. + items: + description: |- + AdditionalWorkspace defines a named workspace for a secondary agent. + The operator seeds files to ~/.openclaw/workspace-/. + properties: + configMapRef: + description: ConfigMapRef references an external ConfigMap + whose keys become workspace files. + properties: + name: + description: Name is the name of the ConfigMap to reference. + minLength: 1 + type: string + required: + - name + type: object + initialDirectories: + description: InitialDirectories is a list of directories + to create inside this workspace. + items: + type: string + maxItems: 20 + type: array + initialFiles: + additionalProperties: + type: string + description: InitialFiles maps filenames to their content + (same as spec.workspace.initialFiles). + maxProperties: 50 + type: object + name: + description: |- + Name identifies this workspace. The operator seeds files to + ~/.openclaw/workspace-/. Must match the workspace path + configured in spec.config.raw.agents.list[].workspace. + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ + type: string + required: + - name + type: object + maxItems: 10 + type: array + configMapRef: + description: |- + ConfigMapRef references an external ConfigMap whose keys become workspace files. + All keys in the referenced ConfigMap are included as workspace files. + This is useful for GitOps workflows where workspace files (AGENT.md, SOUL.md, etc.) + are managed as standalone files and bundled via Kustomize configMapGenerator or similar. + + Merge priority (highest wins): + 1. Operator-injected files (ENVIRONMENT.md, BOOTSTRAP.md, SELFCONFIG.md, selfconfig.sh) + 2. Inline initialFiles + 3. External configMapRef entries + 4. Skill pack files + properties: + name: + description: Name is the name of the ConfigMap to reference. + minLength: 1 + type: string + required: + - name + type: object initialDirectories: description: |- InitialDirectories is a list of directories to create (mkdir -p) diff --git a/docs/api-reference.md b/docs/api-reference.md index b4fab06c..179be609 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -83,18 +83,45 @@ Configuration for the OpenClaw application (`openclaw.json`). Configures initial workspace files seeded into the instance. Files are copied once on first boot and never overwritten, so agent modifications survive pod restarts. -| Field | Type | Default | Description | -|----------------------|-----------------------|---------|---------------------------------------------------------------------------------------------------| -| `initialFiles` | `map[string]string` | -- | Maps filenames to their content. Each file is written to the workspace directory only if it does not already exist. Max 50 entries. | -| `initialDirectories` | `[]string` | -- | Directories to create (`mkdir -p`) inside the workspace directory. Nested paths like `tools/scripts` are allowed. Max 20 items. | +| Field | Type | Default | Description | +|------------------------|---------------------------|---------|---------------------------------------------------------------------------------------------------| +| `configMapRef` | `ConfigMapNameSelector` | -- | Reference to an external ConfigMap whose keys become workspace files. See sub-fields below. | +| `initialFiles` | `map[string]string` | -- | Maps filenames to their content. Each file is written to the workspace directory only if it does not already exist. Max 50 entries. | +| `initialDirectories` | `[]string` | -- | Directories to create (`mkdir -p`) inside the workspace directory. Nested paths like `tools/scripts` are allowed. Max 20 items. | +| `additionalWorkspaces` | `[]AdditionalWorkspace` | -- | Additional agent workspaces for multi-agent setups. Each entry seeds files to `~/.openclaw/workspace-/`. Max 10 items. See sub-fields below. | + +#### spec.workspace.configMapRef + +| Field | Type | Default | Description | +|--------|----------|---------|------------------------------------------------------------------| +| `name` | `string` | -- | **(Required)** Name of the ConfigMap in the same namespace as the instance. All keys in the ConfigMap are written as files to the workspace directory. | + +**Merge priority** (highest wins): operator-injected files > inline `initialFiles` > external `configMapRef` > skill packs. + +The controller watches the referenced ConfigMap for changes and re-reconciles automatically. If the ConfigMap is missing or contains invalid filenames, the `WorkspaceReady` status condition is set to `False`. + +#### spec.workspace.additionalWorkspaces[] + +Each entry configures a named workspace for a secondary agent. The operator seeds files to `~/.openclaw/workspace-/`. + +| Field | Type | Default | Description | +|--------------------|-------------------------|---------|---------------------------------------------------------------------------------------------------| +| `name` | `string` | -- | **(Required)** Workspace identifier. Must be a DNS label (`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`), max 63 chars. Seeds to `~/.openclaw/workspace-/`. | +| `configMapRef` | `ConfigMapNameSelector` | -- | Reference to an external ConfigMap whose keys become workspace files. | +| `initialFiles` | `map[string]string` | -- | Maps filenames to their content. Max 50 entries. | +| `initialDirectories` | `[]string` | -- | Directories to create inside this workspace. Max 20 items. | + +Per-workspace merge priority (highest wins): operator-injected `ENVIRONMENT.md` > inline `initialFiles` > external `configMapRef`. Note: `BOOTSTRAP.md`, self-configure files, and skill packs are only injected into the default workspace. ```yaml spec: workspace: + configMapRef: + name: agent-alpha-workspace # all keys become workspace files initialDirectories: - tools/scripts - data - initialFiles: + initialFiles: # inline files override configMapRef README.md: | # My Workspace This workspace is managed by OpenClaw. @@ -103,6 +130,17 @@ spec: echo "Hello from setup" ``` +**GitOps example with Kustomize:** + +```yaml +# kustomization.yaml +configMapGenerator: + - name: agent-alpha-workspace + files: + - SOUL.md + - AGENT.md +``` + ### spec.skills | Field | Type | Default | Description | @@ -905,6 +943,7 @@ Standard `metav1.Condition` array. Condition types: | `AutoUpdateAvailable` | A newer version is available in the OCI registry. | | `SecretsReady` | All referenced Secrets exist and are accessible. | | `SkillPacksReady` | Skill packs resolved successfully from GitHub. `False` with reason `ResolutionFailed` when GitHub is unreachable - instance runs without skill packs (phase `Degraded`). Retried on next reconcile. | +| `WorkspaceReady` | Workspace files seeded successfully. `False` when an external ConfigMap referenced by `spec.workspace.configMapRef` is missing or contains invalid filenames. `True` once all workspace files (from configMapRef, initialFiles, and skill packs) are seeded. | ### status.endpoints diff --git a/internal/controller/openclawinstance_controller.go b/internal/controller/openclawinstance_controller.go index 7ae18d3a..555307c0 100644 --- a/internal/controller/openclawinstance_controller.go +++ b/internal/controller/openclawinstance_controller.go @@ -309,7 +309,8 @@ func (r *OpenClawInstanceReconciler) reconcileResources(ctx context.Context, ins // 2c. Reconcile Tailscale state Secret (must precede StatefulSet) if instance.Spec.Tailscale.Enabled { - if err := r.reconcileTailscaleStateSecret(ctx, instance); err != nil { + err = r.reconcileTailscaleStateSecret(ctx, instance) + if err != nil { return fmt.Errorf("failed to reconcile Tailscale state secret: %w", err) } logger.V(1).Info("Tailscale state secret reconciled") @@ -319,7 +320,8 @@ func (r *OpenClawInstanceReconciler) reconcileResources(ctx context.Context, ins var skillPacks *resources.ResolvedSkillPacks packNames := resources.ExtractPackSkills(instance.Spec.Skills) if len(packNames) > 0 && r.SkillPackResolver != nil { - resolved, err := r.SkillPackResolver.Resolve(ctx, packNames) + var resolved *resources.ResolvedSkillPacks + resolved, err = r.SkillPackResolver.Resolve(ctx, packNames) if err != nil { logger.Error(err, "Failed to resolve skill packs, continuing without them", "packs", packNames) r.Recorder.Event(instance, corev1.EventTypeWarning, "SkillPackResolutionFailed", @@ -346,7 +348,8 @@ func (r *OpenClawInstanceReconciler) reconcileResources(ctx context.Context, ins } // 3. Reconcile ConfigMap (always - enrichment pipeline runs on all config sources) - if err := r.reconcileConfigMap(ctx, instance, gatewayToken, skillPacks); err != nil { + err = r.reconcileConfigMap(ctx, instance, gatewayToken, skillPacks) + if err != nil { return fmt.Errorf("failed to reconcile ConfigMap: %w", err) } logger.V(1).Info("ConfigMap reconciled") @@ -357,7 +360,8 @@ func (r *OpenClawInstanceReconciler) reconcileResources(ctx context.Context, ins } // 3b. Reconcile Workspace ConfigMap (seed files for workspace) - if err := r.reconcileWorkspaceConfigMap(ctx, instance, skillPacks); err != nil { + wsFiles, err := r.reconcileWorkspaceConfigMap(ctx, instance, skillPacks) + if err != nil { return fmt.Errorf("failed to reconcile Workspace ConfigMap: %w", err) } logger.V(1).Info("Workspace ConfigMap reconciled") @@ -401,7 +405,7 @@ func (r *OpenClawInstanceReconciler) reconcileResources(ctx context.Context, ins if err := r.migrateDeploymentToStatefulSet(ctx, instance); err != nil { return fmt.Errorf("failed to migrate Deployment to StatefulSet: %w", err) } - if err := r.reconcileStatefulSet(ctx, instance, gatewayToken, skillPacks); err != nil { + if err := r.reconcileStatefulSet(ctx, instance, gatewayToken, skillPacks, wsFiles); err != nil { return fmt.Errorf("failed to reconcile StatefulSet: %w", err) } logger.V(1).Info("StatefulSet reconciled") @@ -728,18 +732,141 @@ func (r *OpenClawInstanceReconciler) reconcileConfigMap(ctx context.Context, ins // reconcileWorkspaceConfigMap reconciles the ConfigMap containing workspace seed files. // If the instance has no workspace files, any existing workspace ConfigMap is cleaned up. -func (r *OpenClawInstanceReconciler) reconcileWorkspaceConfigMap(ctx context.Context, instance *openclawv1alpha1.OpenClawInstance, skillPacks *resources.ResolvedSkillPacks) error { - desired := resources.BuildWorkspaceConfigMap(instance, skillPacks) +// Returns the resolved external workspace files so callers (e.g. reconcileStatefulSet) +// can use them for config hash calculation and init script generation. +// resolvedWorkspaceFiles holds the resolved external files for default and additional workspaces. +type resolvedWorkspaceFiles struct { + // defaultFiles are the resolved contents of spec.workspace.configMapRef. + defaultFiles map[string]string + // additionalFiles maps workspace name to resolved configMapRef contents. + additionalFiles map[string]map[string]string +} + +func (r *OpenClawInstanceReconciler) reconcileWorkspaceConfigMap(ctx context.Context, instance *openclawv1alpha1.OpenClawInstance, skillPacks *resources.ResolvedSkillPacks) (*resolvedWorkspaceFiles, error) { + logger := log.FromContext(ctx) + resolved := &resolvedWorkspaceFiles{} + + // Resolve external workspace ConfigMap if referenced + if instance.Spec.Workspace != nil && instance.Spec.Workspace.ConfigMapRef != nil { + ref := instance.Spec.Workspace.ConfigMapRef + extCM := &corev1.ConfigMap{} + if err := r.Get(ctx, types.NamespacedName{Name: ref.Name, Namespace: instance.Namespace}, extCM); err != nil { + if apierrors.IsNotFound(err) { + meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{ + Type: openclawv1alpha1.ConditionTypeWorkspaceReady, + Status: metav1.ConditionFalse, + Reason: "ConfigMapNotFound", + Message: fmt.Sprintf("Workspace ConfigMap %q not found", ref.Name), + ObservedGeneration: instance.Generation, + }) + r.Recorder.Eventf(instance, corev1.EventTypeWarning, "WorkspaceConfigMapNotFound", + "Workspace ConfigMap %q referenced by spec.workspace.configMapRef not found", ref.Name) + return nil, fmt.Errorf("workspace configMapRef %q not found: %w", ref.Name, err) + } + return nil, fmt.Errorf("fetching workspace configMapRef %q: %w", ref.Name, err) + } + + // Validate all keys from the external ConfigMap + for key := range extCM.Data { + if err := resources.ValidateWorkspaceFilename(key); err != nil { + meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{ + Type: openclawv1alpha1.ConditionTypeWorkspaceReady, + Status: metav1.ConditionFalse, + Reason: "InvalidFilename", + Message: fmt.Sprintf("Workspace ConfigMap %q key %q: %v", ref.Name, key, err), + ObservedGeneration: instance.Generation, + }) + return nil, fmt.Errorf("workspace configMapRef %q key %q: %w", ref.Name, key, err) + } + } + + resolved.defaultFiles = extCM.Data + logger.V(1).Info("Resolved external workspace ConfigMap", "configMap", ref.Name, "keys", len(resolved.defaultFiles)) + } + + // Resolve additional workspace ConfigMaps + if instance.Spec.Workspace != nil { + for i := range instance.Spec.Workspace.AdditionalWorkspaces { + aw := &instance.Spec.Workspace.AdditionalWorkspaces[i] + if aw.ConfigMapRef == nil { + continue + } + extCM := &corev1.ConfigMap{} + if err := r.Get(ctx, types.NamespacedName{Name: aw.ConfigMapRef.Name, Namespace: instance.Namespace}, extCM); err != nil { + if apierrors.IsNotFound(err) { + meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{ + Type: openclawv1alpha1.ConditionTypeWorkspaceReady, + Status: metav1.ConditionFalse, + Reason: "ConfigMapNotFound", + Message: fmt.Sprintf("Additional workspace %q ConfigMap %q not found", aw.Name, aw.ConfigMapRef.Name), + ObservedGeneration: instance.Generation, + }) + r.Recorder.Eventf(instance, corev1.EventTypeWarning, "WorkspaceConfigMapNotFound", + "Additional workspace %q ConfigMap %q not found", aw.Name, aw.ConfigMapRef.Name) + return nil, fmt.Errorf("additional workspace %q configMapRef %q not found: %w", aw.Name, aw.ConfigMapRef.Name, err) + } + return nil, fmt.Errorf("fetching additional workspace %q configMapRef %q: %w", aw.Name, aw.ConfigMapRef.Name, err) + } + + // Validate all keys + for key := range extCM.Data { + if err := resources.ValidateWorkspaceFilename(key); err != nil { + meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{ + Type: openclawv1alpha1.ConditionTypeWorkspaceReady, + Status: metav1.ConditionFalse, + Reason: "InvalidFilename", + Message: fmt.Sprintf("Additional workspace %q ConfigMap %q key %q: %v", aw.Name, aw.ConfigMapRef.Name, key, err), + ObservedGeneration: instance.Generation, + }) + return nil, fmt.Errorf("additional workspace %q configMapRef %q key %q: %w", aw.Name, aw.ConfigMapRef.Name, key, err) + } + } + + if resolved.additionalFiles == nil { + resolved.additionalFiles = make(map[string]map[string]string) + } + resolved.additionalFiles[aw.Name] = extCM.Data + logger.V(1).Info("Resolved additional workspace ConfigMap", "workspace", aw.Name, "configMap", aw.ConfigMapRef.Name, "keys", len(extCM.Data)) + } + } + + // Determine if any configMapRef is in use (default or additional) + hasConfigMapRef := instance.Spec.Workspace != nil && instance.Spec.Workspace.ConfigMapRef != nil + if !hasConfigMapRef && instance.Spec.Workspace != nil { + for i := range instance.Spec.Workspace.AdditionalWorkspaces { + if instance.Spec.Workspace.AdditionalWorkspaces[i].ConfigMapRef != nil { + hasConfigMapRef = true + break + } + } + } + + // Set WorkspaceReady=True when any configMapRef is used and all resolved successfully + if hasConfigMapRef { + totalFiles := len(resolved.defaultFiles) + for _, files := range resolved.additionalFiles { + totalFiles += len(files) + } + meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{ + Type: openclawv1alpha1.ConditionTypeWorkspaceReady, + Status: metav1.ConditionTrue, + Reason: "Resolved", + Message: fmt.Sprintf("All workspace ConfigMaps resolved with %d total file(s)", totalFiles), + ObservedGeneration: instance.Generation, + }) + } + + desired := resources.BuildWorkspaceConfigMap(instance, resolved.defaultFiles, resolved.additionalFiles, skillPacks) if desired == nil { - // No workspace files — clean up existing ConfigMap if present + // No workspace files - clean up existing ConfigMap if present existing := &corev1.ConfigMap{} existing.Name = resources.WorkspaceConfigMapName(instance) existing.Namespace = instance.Namespace if err := r.Delete(ctx, existing); err != nil && !apierrors.IsNotFound(err) { - return err + return nil, err } - return nil + return resolved, nil } cm := &corev1.ConfigMap{ @@ -753,10 +880,10 @@ func (r *OpenClawInstanceReconciler) reconcileWorkspaceConfigMap(ctx context.Con cm.Data = desired.Data return controllerutil.SetControllerReference(instance, cm, r.Scheme) }); err != nil { - return err + return nil, err } - return nil + return resolved, nil } // reconcilePVC reconciles the PersistentVolumeClaim @@ -1010,7 +1137,7 @@ func (r *OpenClawInstanceReconciler) migrateDeploymentToStatefulSet(ctx context. } // reconcileStatefulSet reconciles the StatefulSet -func (r *OpenClawInstanceReconciler) reconcileStatefulSet(ctx context.Context, instance *openclawv1alpha1.OpenClawInstance, gatewayToken string, skillPacks *resources.ResolvedSkillPacks) error { +func (r *OpenClawInstanceReconciler) reconcileStatefulSet(ctx context.Context, instance *openclawv1alpha1.OpenClawInstance, gatewayToken string, skillPacks *resources.ResolvedSkillPacks, wsFiles *resolvedWorkspaceFiles) error { // Compute secret hash for rollout trigger on secret rotation secretHash, missingSecrets, err := r.computeSecretHash(ctx, instance) if err != nil { @@ -1051,7 +1178,7 @@ func (r *OpenClawInstanceReconciler) reconcileStatefulSet(ctx context.Context, i // Build the desired StatefulSet once and reuse for both VCT comparison // and the CreateOrUpdate mutate func. - desired := resources.BuildStatefulSet(instance, gwSecretName, skillPacks) + desired := resources.BuildStatefulSet(instance, gwSecretName, skillPacks, wsFiles.defaultFiles, wsFiles.additionalFiles) resources.NormalizeStatefulSet(desired) sts := &appsv1.StatefulSet{ @@ -1630,5 +1757,56 @@ func (r *OpenClawInstanceReconciler) SetupWithManager(mgr ctrl.Manager) error { Owns(&policyv1.PodDisruptionBudget{}). Owns(&autoscalingv2.HorizontalPodAutoscaler{}). Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(r.findInstancesForSecret)). + Watches(&corev1.ConfigMap{}, handler.EnqueueRequestsFromMapFunc(r.findInstancesForConfigMap)). Complete(r) } + +// findInstancesForConfigMap maps an external ConfigMap change to the OpenClawInstances +// that reference it via spec.config.configMapRef or spec.workspace.configMapRef. +func (r *OpenClawInstanceReconciler) findInstancesForConfigMap(ctx context.Context, obj client.Object) []reconcile.Request { + cm, ok := obj.(*corev1.ConfigMap) + if !ok { + return nil + } + + instanceList := &openclawv1alpha1.OpenClawInstanceList{} + if err := r.List(ctx, instanceList, client.InNamespace(cm.Namespace)); err != nil { + log.FromContext(ctx).Error(err, "Failed to list OpenClawInstances for ConfigMap watch") + return nil + } + + var requests []reconcile.Request + for i := range instanceList.Items { + instance := &instanceList.Items[i] + matched := false + // Check spec.config.configMapRef + if instance.Spec.Config.ConfigMapRef != nil && instance.Spec.Config.ConfigMapRef.Name == cm.Name { + matched = true + } + // Check spec.workspace.configMapRef + if !matched && instance.Spec.Workspace != nil && + instance.Spec.Workspace.ConfigMapRef != nil && + instance.Spec.Workspace.ConfigMapRef.Name == cm.Name { + matched = true + } + // Check spec.workspace.additionalWorkspaces[].configMapRef + if !matched && instance.Spec.Workspace != nil { + for i := range instance.Spec.Workspace.AdditionalWorkspaces { + if instance.Spec.Workspace.AdditionalWorkspaces[i].ConfigMapRef != nil && + instance.Spec.Workspace.AdditionalWorkspaces[i].ConfigMapRef.Name == cm.Name { + matched = true + break + } + } + } + if matched { + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: instance.Name, + Namespace: instance.Namespace, + }, + }) + } + } + return requests +} diff --git a/internal/controller/openclawinstance_controller_test.go b/internal/controller/openclawinstance_controller_test.go index 25819587..82c67920 100644 --- a/internal/controller/openclawinstance_controller_test.go +++ b/internal/controller/openclawinstance_controller_test.go @@ -262,7 +262,7 @@ var _ = Describe("OpenClawInstance Controller", func() { Spec: openclawv1alpha1.OpenClawInstanceSpec{}, } - sts := resources.BuildStatefulSet(instance, "", nil) + sts := resources.BuildStatefulSet(instance, "", nil, nil, nil) // Verify pod security context Expect(sts.Spec.Template.Spec.SecurityContext).NotTo(BeNil()) diff --git a/internal/resources/common_test.go b/internal/resources/common_test.go index 99d4a0a1..84ff30e1 100644 --- a/internal/resources/common_test.go +++ b/internal/resources/common_test.go @@ -202,7 +202,7 @@ func TestBuildStatefulSet_WithRegistry(t *testing.T) { instance := newTestInstance("test") instance.Spec.Registry = "my-registry.example.com" - sts := BuildStatefulSet(instance, "test-secret", nil) + sts := BuildStatefulSet(instance, "test-secret", nil, nil, nil) // Check main container image var mainContainer *corev1.Container diff --git a/internal/resources/resources_bench_test.go b/internal/resources/resources_bench_test.go index 31f838b9..b2bf3a50 100644 --- a/internal/resources/resources_bench_test.go +++ b/internal/resources/resources_bench_test.go @@ -136,7 +136,7 @@ func BenchmarkBuildStatefulSet_Minimal(b *testing.B) { instance := newBenchInstance() b.ResetTimer() for i := 0; i < b.N; i++ { - BuildStatefulSet(instance, "", nil) + BuildStatefulSet(instance, "", nil, nil, nil) } } @@ -144,7 +144,7 @@ func BenchmarkBuildStatefulSet_FullyLoaded(b *testing.B) { instance := newFullBenchInstance() b.ResetTimer() for i := 0; i < b.N; i++ { - BuildStatefulSet(instance, "", nil) + BuildStatefulSet(instance, "", nil, nil, nil) } } diff --git a/internal/resources/resources_test.go b/internal/resources/resources_test.go index 52be4c0d..2e6185d0 100644 --- a/internal/resources/resources_test.go +++ b/internal/resources/resources_test.go @@ -230,7 +230,7 @@ func TestPtr(t *testing.T) { func TestBuildStatefulSet_Defaults(t *testing.T) { instance := newTestInstance("test-deploy") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // ObjectMeta if sts.Name != "test-deploy" { @@ -430,7 +430,7 @@ func TestBuildStatefulSet_WithChromium(t *testing.T) { instance := newTestInstance("chromium-test") instance.Spec.Chromium.Enabled = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) containers := sts.Spec.Template.Spec.Containers initContainers := sts.Spec.Template.Spec.InitContainers @@ -589,7 +589,7 @@ func TestBuildStatefulSet_ChromiumExtraArgs(t *testing.T) { "--user-agent=CustomAgent/1.0", } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var chromium *corev1.Container for i := range sts.Spec.Template.Spec.InitContainers { @@ -623,7 +623,7 @@ func TestBuildStatefulSet_ChromiumExtraEnv(t *testing.T) { {Name: "CUSTOM_VAR", Value: "hello"}, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var chromium *corev1.Container for i := range sts.Spec.Template.Spec.InitContainers { @@ -660,7 +660,7 @@ func TestBuildStatefulSet_ChromiumNoExtraArgs(t *testing.T) { instance.Spec.Chromium.Enabled = true // No ExtraArgs - should still have default launch args - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var chromium *corev1.Container for i := range sts.Spec.Template.Spec.InitContainers { @@ -696,7 +696,7 @@ func TestBuildStatefulSet_CustomResources(t *testing.T) { }, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] cpuReq := main.Resources.Requests[corev1.ResourceCPU] @@ -725,7 +725,7 @@ func TestBuildStatefulSet_ImageDigest(t *testing.T) { Digest: "sha256:abcdef1234567890", } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] expected := "my-registry.io/openclaw@sha256:abcdef1234567890" @@ -748,7 +748,7 @@ func TestBuildStatefulSet_ProbesDisabled(t *testing.T) { }, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] if main.LivenessProbe != nil { @@ -773,7 +773,7 @@ func TestBuildStatefulSet_CustomProbeValues(t *testing.T) { }, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) probe := sts.Spec.Template.Spec.Containers[0].LivenessProbe if probe == nil { @@ -797,7 +797,7 @@ func TestBuildStatefulSet_PersistenceDisabled(t *testing.T) { instance := newTestInstance("no-pvc") instance.Spec.Storage.Persistence.Enabled = Ptr(false) - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) volumes := sts.Spec.Template.Spec.Volumes dataVol := findVolume(volumes, "data") @@ -816,7 +816,7 @@ func TestBuildStatefulSet_ExistingClaim(t *testing.T) { instance := newTestInstance("existing-pvc") instance.Spec.Storage.Persistence.ExistingClaim = "my-existing-pvc" - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) volumes := sts.Spec.Template.Spec.Volumes dataVol := findVolume(volumes, "data") @@ -839,7 +839,7 @@ func TestBuildStatefulSet_ConfigVolume_RawConfig(t *testing.T) { }, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] // Main container should have config volume mounted read-only at /operator-config @@ -892,7 +892,7 @@ func TestBuildStatefulSet_ConfigVolume_ConfigMapRef(t *testing.T) { Key: "my-config.json", } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // Init container should copy openclaw.json (operator-managed key) from ConfigMap to data volume. // The controller reads the external CM and writes enriched content into the @@ -929,7 +929,7 @@ func TestBuildStatefulSet_ConfigMapRef_DefaultKey(t *testing.T) { // Key not set - operator-managed CM always uses "openclaw.json" } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // Init container should use "openclaw.json" (operator-managed key) initContainers := sts.Spec.Template.Spec.InitContainers @@ -955,7 +955,7 @@ func TestBuildStatefulSet_VanillaDeployment_HasInitContainer(t *testing.T) { instance := newTestInstance("no-config") // No config set at all — vanilla deployment - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // Vanilla deployments get init-config + init-uv + init-pip if len(sts.Spec.Template.Spec.InitContainers) != 3 { @@ -988,7 +988,7 @@ func TestBuildStatefulSet_PostStart_OverwriteMode(t *testing.T) { RawExtension: runtime.RawExtension{Raw: []byte(`{}`)}, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] if main.Lifecycle == nil || main.Lifecycle.PostStart == nil { @@ -1012,7 +1012,7 @@ func TestBuildStatefulSet_PostStart_MergeMode(t *testing.T) { } instance.Spec.Config.MergeMode = ConfigMergeModeMerge - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] if main.Lifecycle == nil || main.Lifecycle.PostStart == nil { @@ -1045,7 +1045,7 @@ func TestBuildStatefulSet_PostStart_JSON5Mode_NoHook(t *testing.T) { } instance.Spec.Config.Format = ConfigFormatJSON5 - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] // JSON5 mode can't use postStart (needs npx, too slow) @@ -1061,7 +1061,7 @@ func TestBuildStatefulSet_PostStart_ConfigMapRef(t *testing.T) { Key: "my-config.json", } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] if main.Lifecycle == nil || main.Lifecycle.PostStart == nil { @@ -1083,7 +1083,7 @@ func TestBuildStatefulSet_PostStart_VanillaDeployment(t *testing.T) { // No config set at all - vanilla deployment still gets postStart // because operator always creates a ConfigMap with gateway.bind=lan - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] if main.Lifecycle == nil || main.Lifecycle.PostStart == nil { @@ -1098,7 +1098,7 @@ func TestBuildStatefulSet_PostStart_VanillaDeployment(t *testing.T) { func TestBuildStatefulSet_ServiceAccountName(t *testing.T) { instance := newTestInstance("sa-test") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) if sts.Spec.Template.Spec.ServiceAccountName != "sa-test" { t.Errorf("serviceAccountName = %q, want %q", sts.Spec.Template.Spec.ServiceAccountName, "sa-test") } @@ -1106,7 +1106,7 @@ func TestBuildStatefulSet_ServiceAccountName(t *testing.T) { func TestBuildStatefulSet_AutomountServiceAccountTokenDisabled(t *testing.T) { instance := newTestInstance("automount-test") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) token := sts.Spec.Template.Spec.AutomountServiceAccountToken if token == nil || *token != false { t.Errorf("AutomountServiceAccountToken = %v, want false", token) @@ -1120,7 +1120,7 @@ func TestBuildStatefulSet_ImagePullSecrets(t *testing.T) { {Name: "other-secret"}, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) secrets := sts.Spec.Template.Spec.ImagePullSecrets if len(secrets) != 2 { t.Fatalf("expected 2 pull secrets, got %d", len(secrets)) @@ -1141,7 +1141,7 @@ func TestBuildStatefulSet_ChromiumCustomImage(t *testing.T) { Tag: "v120", } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var chromium *corev1.Container for i := range sts.Spec.Template.Spec.InitContainers { if sts.Spec.Template.Spec.InitContainers[i].Name == "chromium" { @@ -1166,7 +1166,7 @@ func TestBuildStatefulSet_ChromiumDigest(t *testing.T) { Digest: "sha256:chromiumhash", } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var chromium *corev1.Container for i := range sts.Spec.Template.Spec.InitContainers { if sts.Spec.Template.Spec.InitContainers[i].Name == "chromium" { @@ -1197,7 +1197,7 @@ func TestBuildStatefulSet_NodeSelectorAndTolerations(t *testing.T) { }, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) podSpec := sts.Spec.Template.Spec if podSpec.NodeSelector["node-type"] != "gpu" { @@ -1223,7 +1223,7 @@ func TestBuildStatefulSet_TopologySpreadConstraints(t *testing.T) { }, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) podSpec := sts.Spec.Template.Spec if len(podSpec.TopologySpreadConstraints) != 1 { @@ -1244,7 +1244,7 @@ func TestBuildStatefulSet_TopologySpreadConstraints(t *testing.T) { func TestBuildStatefulSet_TopologySpreadConstraints_Empty(t *testing.T) { instance := newTestInstance("tsc-empty") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) podSpec := sts.Spec.Template.Spec if podSpec.TopologySpreadConstraints != nil { @@ -1256,7 +1256,7 @@ func TestBuildStatefulSet_RuntimeClassName(t *testing.T) { instance := newTestInstance("rtc-test") instance.Spec.Availability.RuntimeClassName = Ptr("kata-fc") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) podSpec := sts.Spec.Template.Spec if podSpec.RuntimeClassName == nil { @@ -1270,7 +1270,7 @@ func TestBuildStatefulSet_RuntimeClassName(t *testing.T) { func TestBuildStatefulSet_RuntimeClassName_Unset(t *testing.T) { instance := newTestInstance("rtc-unset") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) podSpec := sts.Spec.Template.Spec if podSpec.RuntimeClassName != nil { @@ -1285,7 +1285,7 @@ func TestBuildStatefulSet_PodAnnotations_UserAnnotationsPresent(t *testing.T) { "custom.io/label": "value", } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) ann := sts.Spec.Template.Annotations if ann["cluster-autoscaler.kubernetes.io/safe-to-evict"] != "false" { @@ -1302,7 +1302,7 @@ func TestBuildStatefulSet_PodAnnotations_OperatorKeyWins(t *testing.T) { "openclaw.rocks/config-hash": "user-supplied-value", } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) ann := sts.Spec.Template.Annotations if ann["openclaw.rocks/config-hash"] == "user-supplied-value" { @@ -1316,7 +1316,7 @@ func TestBuildStatefulSet_PodAnnotations_OperatorKeyWins(t *testing.T) { func TestBuildStatefulSet_PodAnnotations_NilStillHasConfigHash(t *testing.T) { instance := newTestInstance("pod-ann-nil") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) ann := sts.Spec.Template.Annotations if _, ok := ann["openclaw.rocks/config-hash"]; !ok { @@ -1337,7 +1337,7 @@ func TestBuildStatefulSet_EnvAndEnvFrom(t *testing.T) { }, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] names := envNames(main.Env) @@ -2375,7 +2375,7 @@ func TestBuildConfigMap_MetricsDisabled_NoOTelConfig(t *testing.T) { func TestBuildStatefulSet_OTelCollectorContainer(t *testing.T) { instance := newTestInstance("sts-otel-collector") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var found bool for _, c := range sts.Spec.Template.Spec.Containers { @@ -2412,7 +2412,7 @@ func TestBuildStatefulSet_MetricsDisabled_NoOTelCollector(t *testing.T) { instance := newTestInstance("sts-no-otel-collector") disabled := false instance.Spec.Observability.Metrics.Enabled = &disabled - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) for _, c := range sts.Spec.Template.Spec.Containers { if c.Name == "otel-collector" { @@ -2423,7 +2423,7 @@ func TestBuildStatefulSet_MetricsDisabled_NoOTelCollector(t *testing.T) { func TestBuildStatefulSet_MainContainerNoMetricsPort(t *testing.T) { instance := newTestInstance("sts-main-no-metrics") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] for _, p := range main.Ports { @@ -2635,7 +2635,7 @@ func TestHandshakeTimeoutEnvVar_UserOverrideWins(t *testing.T) { instance.Spec.Env = []corev1.EnvVar{ {Name: "OPENCLAW_GATEWAY_HANDSHAKE_TIMEOUT_MS", Value: "5000"}, } - sts := BuildStatefulSet(instance, "test-token-secret", nil) + sts := BuildStatefulSet(instance, "test-token-secret", nil, nil, nil) mainContainer := sts.Spec.Template.Spec.Containers[0] var count int @@ -3976,7 +3976,7 @@ func TestAllBuilders_ConsistentLabels(t *testing.T) { name string labels map[string]string }{ - {"Deployment", BuildStatefulSet(instance, "", nil).Labels}, + {"Deployment", BuildStatefulSet(instance, "", nil, nil, nil).Labels}, {"Service", BuildService(instance).Labels}, {"NetworkPolicy", BuildNetworkPolicy(instance).Labels}, {"ServiceAccount", BuildServiceAccount(instance).Labels}, @@ -4016,7 +4016,7 @@ func TestAllBuilders_ConsistentNamespace(t *testing.T) { name string namespace string }{ - {"Deployment", BuildStatefulSet(instance, "", nil).Namespace}, + {"Deployment", BuildStatefulSet(instance, "", nil, nil, nil).Namespace}, {"Service", BuildService(instance).Namespace}, {"NetworkPolicy", BuildNetworkPolicy(instance).Namespace}, {"ServiceAccount", BuildServiceAccount(instance).Namespace}, @@ -4051,7 +4051,7 @@ func TestBuildStatefulSet_ChromiumCustomResources(t *testing.T) { }, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var chromium *corev1.Container for i := range sts.Spec.Template.Spec.InitContainers { if sts.Spec.Template.Spec.InitContainers[i].Name == "chromium" { @@ -4089,7 +4089,7 @@ func TestBuildStatefulSet_CustomPodSecurityContext(t *testing.T) { FSGroup: Ptr(int64(4000)), } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) psc := sts.Spec.Template.Spec.SecurityContext if *psc.RunAsUser != 2000 { @@ -4111,7 +4111,7 @@ func TestBuildStatefulSet_CustomPullPolicy(t *testing.T) { instance := newTestInstance("pull-policy") instance.Spec.Image.PullPolicy = corev1.PullAlways - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] if main.ImagePullPolicy != corev1.PullAlways { @@ -4227,7 +4227,7 @@ func findVolume(volumes []corev1.Volume, name string) *corev1.Volume { // every reconcile, causing an endless update loop. func TestBuildStatefulSet_KubernetesDefaults(t *testing.T) { instance := newTestInstance("k8s-defaults") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // StatefulSetSpec defaults if sts.Spec.RevisionHistoryLimit == nil || *sts.Spec.RevisionHistoryLimit != 10 { @@ -4287,7 +4287,7 @@ func TestBuildStatefulSet_InitContainerDefaults(t *testing.T) { RawExtension: runtime.RawExtension{Raw: []byte(`{}`)}, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) if len(sts.Spec.Template.Spec.InitContainers) == 0 { t.Fatal("expected init container when raw config is set") } @@ -4310,7 +4310,7 @@ func TestBuildStatefulSet_ChromiumContainerDefaults(t *testing.T) { instance := newTestInstance("chromium-defaults") instance.Spec.Chromium.Enabled = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var chromium *corev1.Container for i := range sts.Spec.Template.Spec.InitContainers { @@ -4336,7 +4336,7 @@ func TestBuildStatefulSet_ChromiumPersistenceEnabled(t *testing.T) { instance.Spec.Chromium.Enabled = true instance.Spec.Chromium.Persistence.Enabled = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // chromium-data volume should be a PVC dataVol := findVolume(sts.Spec.Template.Spec.Volumes, "chromium-data") @@ -4377,7 +4377,7 @@ func TestBuildStatefulSet_ChromiumPersistenceExistingClaim(t *testing.T) { instance.Spec.Chromium.Persistence.Enabled = true instance.Spec.Chromium.Persistence.ExistingClaim = "my-chromium-pvc" - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) dataVol := findVolume(sts.Spec.Template.Spec.Volumes, "chromium-data") if dataVol == nil { @@ -4396,7 +4396,7 @@ func TestBuildStatefulSet_ChromiumPersistenceDisabled(t *testing.T) { instance.Spec.Chromium.Enabled = true instance.Spec.Chromium.Persistence.Enabled = false - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) dataVol := findVolume(sts.Spec.Template.Spec.Volumes, "chromium-data") if dataVol == nil { @@ -4418,7 +4418,7 @@ func TestBuildStatefulSet_ConfigMapDefaultMode(t *testing.T) { RawExtension: runtime.RawExtension{Raw: []byte(`{}`)}, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) configVol := findVolume(sts.Spec.Template.Spec.Volumes, "config") if configVol == nil { t.Fatal("config volume not found") @@ -4452,8 +4452,8 @@ func TestBuildStatefulSet_Idempotent(t *testing.T) { } instance.Spec.Chromium.Enabled = true - dep1 := BuildStatefulSet(instance, "", nil) - dep2 := BuildStatefulSet(instance, "", nil) + dep1 := BuildStatefulSet(instance, "", nil, nil, nil) + dep2 := BuildStatefulSet(instance, "", nil, nil, nil) b1, _ := json.Marshal(dep1.Spec) b2, _ := json.Marshal(dep2.Spec) @@ -4471,7 +4471,7 @@ func TestBuildWorkspaceConfigMap_Nil(t *testing.T) { instance := newTestInstance("ws-nil") instance.Spec.Workspace = nil - cm := BuildWorkspaceConfigMap(instance, nil) + cm := BuildWorkspaceConfigMap(instance, nil, nil, nil) // Operator files are always injected if cm == nil { t.Fatal("expected non-nil ConfigMap (operator files are always injected)") @@ -4489,7 +4489,7 @@ func TestBuildWorkspaceConfigMap_EmptyFiles(t *testing.T) { InitialDirectories: []string{"memory"}, } - cm := BuildWorkspaceConfigMap(instance, nil) + cm := BuildWorkspaceConfigMap(instance, nil, nil, nil) // Operator files are always injected if cm == nil { t.Fatal("expected non-nil ConfigMap (operator files are always injected)") @@ -4510,7 +4510,7 @@ func TestBuildWorkspaceConfigMap_WithFiles(t *testing.T) { }, } - cm := BuildWorkspaceConfigMap(instance, nil) + cm := BuildWorkspaceConfigMap(instance, nil, nil, nil) if cm == nil { t.Fatal("expected non-nil ConfigMap when files are set") } @@ -4537,6 +4537,140 @@ func TestBuildWorkspaceConfigMap_WithFiles(t *testing.T) { } } +func TestBuildWorkspaceConfigMap_WithExternalFiles(t *testing.T) { + instance := newTestInstance("ws-ext") + externalFiles := map[string]string{ + "AGENT.md": "# External agent file", + "SOUL.md": "# External soul", + } + + cm := BuildWorkspaceConfigMap(instance, externalFiles, nil, nil) + if cm == nil { + t.Fatal("expected non-nil ConfigMap") + } + // 2 external + ENVIRONMENT.md + BOOTSTRAP.md = 4 + if len(cm.Data) != 4 { + t.Fatalf("expected 4 data entries, got %d", len(cm.Data)) + } + if cm.Data["AGENT.md"] != "# External agent file" { + t.Errorf("AGENT.md content mismatch: got %q", cm.Data["AGENT.md"]) + } + if cm.Data["SOUL.md"] != "# External soul" { + t.Errorf("SOUL.md content mismatch: got %q", cm.Data["SOUL.md"]) + } +} + +func TestBuildWorkspaceConfigMap_MergePriority(t *testing.T) { + instance := newTestInstance("ws-merge") + instance.Spec.Workspace = &openclawv1alpha1.WorkspaceSpec{ + InitialFiles: map[string]string{ + "SOUL.md": "# Inline soul wins", + "EXTRA.md": "# Only inline", + }, + } + externalFiles := map[string]string{ + "SOUL.md": "# External soul loses", + "REMOTE.md": "# Only external", + } + skillPacks := &ResolvedSkillPacks{ + Files: map[string]string{ + "SOUL.md": "# Skill pack soul loses", + "SKILL.md": "# Only skill pack", + }, + } + + cm := BuildWorkspaceConfigMap(instance, externalFiles, nil, skillPacks) + if cm == nil { + t.Fatal("expected non-nil ConfigMap") + } + + // Inline should override external and skill pack + if cm.Data["SOUL.md"] != "# Inline soul wins" { + t.Errorf("SOUL.md should be inline value, got %q", cm.Data["SOUL.md"]) + } + // External-only file should be present + if cm.Data["REMOTE.md"] != "# Only external" { + t.Errorf("REMOTE.md should be from external, got %q", cm.Data["REMOTE.md"]) + } + // Skill pack-only file should be present + if cm.Data["SKILL.md"] != "# Only skill pack" { + t.Errorf("SKILL.md should be from skill pack, got %q", cm.Data["SKILL.md"]) + } + // Inline-only file should be present + if cm.Data["EXTRA.md"] != "# Only inline" { + t.Errorf("EXTRA.md should be from inline, got %q", cm.Data["EXTRA.md"]) + } + // Operator files always present and override everything + if cm.Data["ENVIRONMENT.md"] != EnvironmentSkillContent { + t.Error("ENVIRONMENT.md should be operator-injected content") + } +} + +func TestBuildWorkspaceConfigMap_ExternalOnly(t *testing.T) { + instance := newTestInstance("ws-ext-only") + // No workspace spec set, but external files provided + externalFiles := map[string]string{ + "AGENT.md": "# External agent", + } + + cm := BuildWorkspaceConfigMap(instance, externalFiles, nil, nil) + if cm == nil { + t.Fatal("expected non-nil ConfigMap") + } + // 1 external + ENVIRONMENT.md + BOOTSTRAP.md = 3 + if len(cm.Data) != 3 { + t.Fatalf("expected 3 data entries, got %d", len(cm.Data)) + } + if cm.Data["AGENT.md"] != "# External agent" { + t.Errorf("AGENT.md content mismatch") + } +} + +func TestBuildInitScript_WithExternalFiles(t *testing.T) { + instance := newTestInstance("init-ext") + externalFiles := map[string]string{ + "AGENT.md": "# External agent", + "SOUL.md": "# External soul", + } + + script := BuildInitScript(instance, externalFiles, nil, nil) + // Should have copy commands for external files + if !strings.Contains(script, "AGENT.md") { + t.Error("expected init script to reference AGENT.md from external files") + } + if !strings.Contains(script, "SOUL.md") { + t.Error("expected init script to reference SOUL.md from external files") + } +} + +func TestConfigHash_ChangesWithExternalWorkspace(t *testing.T) { + instance := newTestInstance("hash-ext") + + sts1 := BuildStatefulSet(instance, "", nil, nil, nil) + hash1 := sts1.Spec.Template.Annotations["openclaw.rocks/config-hash"] + + externalFiles := map[string]string{ + "AGENT.md": "# External agent content", + } + sts2 := BuildStatefulSet(instance, "", nil, externalFiles, nil) + hash2 := sts2.Spec.Template.Annotations["openclaw.rocks/config-hash"] + + if hash1 == hash2 { + t.Error("config hash should change when external workspace files are added") + } + + // Changing content should also change hash + externalFiles2 := map[string]string{ + "AGENT.md": "# Modified agent content", + } + sts3 := BuildStatefulSet(instance, "", nil, externalFiles2, nil) + hash3 := sts3.Spec.Template.Annotations["openclaw.rocks/config-hash"] + + if hash2 == hash3 { + t.Error("config hash should change when external workspace file content changes") + } +} + func TestWorkspaceConfigMapName(t *testing.T) { instance := newTestInstance("foo") if got := WorkspaceConfigMapName(instance); got != "foo-workspace" { @@ -4575,7 +4709,7 @@ func TestBuildInitScript_ConfigOnly(t *testing.T) { RawExtension: runtime.RawExtension{Raw: []byte(`{}`)}, } - script := BuildInitScript(instance, nil) + script := BuildInitScript(instance, nil, nil, nil) expected := "cp /config/'openclaw.json' /data/openclaw.json\n" + operatorSeedLines if script != expected { t.Errorf("unexpected script:\ngot: %q\nwant: %q", script, expected) @@ -4591,7 +4725,7 @@ func TestBuildInitScript_WorkspaceOnly(t *testing.T) { InitialDirectories: []string{"memory"}, } - script := BuildInitScript(instance, nil) + script := BuildInitScript(instance, nil, nil, nil) expected := "cp /config/'openclaw.json' /data/openclaw.json\nmkdir -p /data/workspace/'memory'\nmkdir -p /data/workspace\n[ -f /data/workspace/'BOOTSTRAP.md' ] || cp /workspace-init/'BOOTSTRAP.md' /data/workspace/'BOOTSTRAP.md'\n[ -f /data/workspace/'ENVIRONMENT.md' ] || cp /workspace-init/'ENVIRONMENT.md' /data/workspace/'ENVIRONMENT.md'\n[ -f /data/workspace/'SOUL.md' ] || cp /workspace-init/'SOUL.md' /data/workspace/'SOUL.md'" if script != expected { t.Errorf("unexpected script:\ngot: %q\nwant: %q", script, expected) @@ -4611,7 +4745,7 @@ func TestBuildInitScript_Both(t *testing.T) { InitialDirectories: []string{"memory", "tools"}, } - script := BuildInitScript(instance, nil) + script := BuildInitScript(instance, nil, nil, nil) // Verify all expected lines are present (sorted order, operator files included) lines := strings.Split(script, "\n") @@ -4650,7 +4784,7 @@ func TestBuildInitScript_DirsOnly(t *testing.T) { InitialDirectories: []string{"memory", "tools/scripts"}, } - script := BuildInitScript(instance, nil) + script := BuildInitScript(instance, nil, nil, nil) expected := "cp /config/'openclaw.json' /data/openclaw.json\nmkdir -p /data/workspace/'memory'\nmkdir -p /data/workspace/'tools/scripts'\n" + operatorSeedLines if script != expected { t.Errorf("unexpected script:\ngot: %q\nwant: %q", script, expected) @@ -4665,7 +4799,7 @@ func TestBuildInitScript_ShellQuotesSpecialChars(t *testing.T) { }, } - script := BuildInitScript(instance, nil) + script := BuildInitScript(instance, nil, nil, nil) expected := "cp /config/'openclaw.json' /data/openclaw.json\nmkdir -p /data/workspace\n[ -f /data/workspace/'BOOTSTRAP.md' ] || cp /workspace-init/'BOOTSTRAP.md' /data/workspace/'BOOTSTRAP.md'\n[ -f /data/workspace/'ENVIRONMENT.md' ] || cp /workspace-init/'ENVIRONMENT.md' /data/workspace/'ENVIRONMENT.md'\n[ -f /data/workspace/'it'\\''s a file.md' ] || cp /workspace-init/'it'\\''s a file.md' /data/workspace/'it'\\''s a file.md'" if script != expected { t.Errorf("unexpected script:\ngot: %q\nwant: %q", script, expected) @@ -4682,7 +4816,7 @@ func TestBuildInitScript_FilesOnly_MkdirWorkspace(t *testing.T) { }, } - script := BuildInitScript(instance, nil) + script := BuildInitScript(instance, nil, nil, nil) if !strings.Contains(script, "mkdir -p /data/workspace\n") { t.Errorf("script should contain mkdir -p /data/workspace, got:\n%s", script) } @@ -4690,7 +4824,7 @@ func TestBuildInitScript_FilesOnly_MkdirWorkspace(t *testing.T) { func TestBuildInitScript_VanillaDeployment(t *testing.T) { instance := newTestInstance("init-empty") - script := BuildInitScript(instance, nil) + script := BuildInitScript(instance, nil, nil, nil) // Vanilla deployments get config copy + ENVIRONMENT.md seeding expected := "cp /config/'openclaw.json' /data/openclaw.json\n" + operatorSeedLines if script != expected { @@ -4708,14 +4842,14 @@ func TestConfigHash_ChangesWithWorkspace(t *testing.T) { RawExtension: runtime.RawExtension{Raw: []byte(`{}`)}, } - dep1 := BuildStatefulSet(instance, "", nil) + dep1 := BuildStatefulSet(instance, "", nil, nil, nil) hash1 := dep1.Spec.Template.Annotations["openclaw.rocks/config-hash"] instance.Spec.Workspace = &openclawv1alpha1.WorkspaceSpec{ InitialFiles: map[string]string{"SOUL.md": "hello"}, } - dep2 := BuildStatefulSet(instance, "", nil) + dep2 := BuildStatefulSet(instance, "", nil, nil, nil) hash2 := dep2.Spec.Template.Annotations["openclaw.rocks/config-hash"] if hash1 == hash2 { @@ -4729,12 +4863,12 @@ func TestConfigHash_ChangesWithFileContent(t *testing.T) { InitialFiles: map[string]string{"SOUL.md": "v1"}, } - dep1 := BuildStatefulSet(instance, "", nil) + dep1 := BuildStatefulSet(instance, "", nil, nil, nil) hash1 := dep1.Spec.Template.Annotations["openclaw.rocks/config-hash"] instance.Spec.Workspace.InitialFiles["SOUL.md"] = "v2" - dep2 := BuildStatefulSet(instance, "", nil) + dep2 := BuildStatefulSet(instance, "", nil, nil, nil) hash2 := dep2.Spec.Template.Annotations["openclaw.rocks/config-hash"] if hash1 == hash2 { @@ -4755,7 +4889,7 @@ func TestBuildStatefulSet_WorkspaceVolume(t *testing.T) { InitialFiles: map[string]string{"SOUL.md": "hello"}, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // Verify workspace-init volume exists wsVol := findVolume(sts.Spec.Template.Spec.Volumes, "workspace-init") @@ -4780,7 +4914,7 @@ func TestBuildStatefulSet_AlwaysHasWorkspaceVolume(t *testing.T) { RawExtension: runtime.RawExtension{Raw: []byte(`{}`)}, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // workspace-init volume always exists because ENVIRONMENT.md is always injected wsVol := findVolume(sts.Spec.Template.Spec.Volumes, "workspace-init") @@ -4798,7 +4932,7 @@ func TestBuildStatefulSet_WorkspaceDirsOnly_StillHasVolume(t *testing.T) { InitialDirectories: []string{"memory"}, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // workspace-init volume always exists because ENVIRONMENT.md is always injected wsVol := findVolume(sts.Spec.Template.Spec.Volumes, "workspace-init") @@ -4822,8 +4956,8 @@ func TestBuildStatefulSet_Idempotent_WithWorkspace(t *testing.T) { InitialDirectories: []string{"memory", "tools"}, } - dep1 := BuildStatefulSet(instance, "", nil) - dep2 := BuildStatefulSet(instance, "", nil) + dep1 := BuildStatefulSet(instance, "", nil, nil, nil) + dep2 := BuildStatefulSet(instance, "", nil, nil, nil) b1, _ := json.Marshal(dep1.Spec) b2, _ := json.Marshal(dep2.Spec) @@ -4839,7 +4973,7 @@ func TestBuildStatefulSet_Idempotent_WithWorkspace(t *testing.T) { func TestBuildStatefulSet_ReadOnlyRootFilesystem_Default(t *testing.T) { instance := newTestInstance("rorfs-default") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] csc := main.SecurityContext @@ -4854,7 +4988,7 @@ func TestBuildStatefulSet_ReadOnlyRootFilesystem_ExplicitFalse(t *testing.T) { ReadOnlyRootFilesystem: Ptr(false), } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] if main.SecurityContext.ReadOnlyRootFilesystem == nil || *main.SecurityContext.ReadOnlyRootFilesystem { @@ -4864,7 +4998,7 @@ func TestBuildStatefulSet_ReadOnlyRootFilesystem_ExplicitFalse(t *testing.T) { func TestBuildStatefulSet_WritablePVCSubPaths(t *testing.T) { instance := newTestInstance("writable-subpaths") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] // Verify ~/.local and ~/.cache are mounted as PVC subPaths for pip/package installs @@ -4894,7 +5028,7 @@ func TestBuildStatefulSet_WritablePVCSubPaths(t *testing.T) { func TestBuildStatefulSet_TmpVolumeAndMount(t *testing.T) { instance := newTestInstance("tmp-vol") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // Check /tmp volume mount on main container main := sts.Spec.Template.Spec.Containers[0] @@ -4921,7 +5055,7 @@ func TestBuildInitScript_OverwriteMode(t *testing.T) { } instance.Spec.Config.MergeMode = "overwrite" - script := BuildInitScript(instance, nil) + script := BuildInitScript(instance, nil, nil, nil) if !strings.Contains(script, "cp /config/") { t.Errorf("overwrite mode should use cp, got: %q", script) } @@ -4937,7 +5071,7 @@ func TestBuildInitScript_MergeMode(t *testing.T) { } instance.Spec.Config.MergeMode = ConfigMergeModeMerge - script := BuildInitScript(instance, nil) + script := BuildInitScript(instance, nil, nil, nil) // Merge now uses Node.js deep merge instead of jq (jq image is distroless, no shell) if !strings.Contains(script, "node -e") { t.Errorf("merge mode should use node deep merge, got: %q", script) @@ -4969,7 +5103,7 @@ func TestBuildStatefulSet_MergeMode_OpenClawImage(t *testing.T) { } instance.Spec.Config.MergeMode = ConfigMergeModeMerge - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) initContainers := sts.Spec.Template.Spec.InitContainers if len(initContainers) == 0 { t.Fatal("expected init container for merge mode") @@ -5014,7 +5148,7 @@ func TestBuildStatefulSet_OverwriteMode_BusyboxImage(t *testing.T) { } instance.Spec.Config.MergeMode = "overwrite" - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) initContainers := sts.Spec.Template.Spec.InitContainers if len(initContainers) == 0 { t.Fatal("expected init container for overwrite mode") @@ -5033,7 +5167,7 @@ func TestBuildStatefulSet_MergeMode_InitTmpVolume(t *testing.T) { } instance.Spec.Config.MergeMode = ConfigMergeModeMerge - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) initTmpVol := findVolume(sts.Spec.Template.Spec.Volumes, "init-tmp") if initTmpVol == nil { t.Fatal("init-tmp volume not found in merge mode") @@ -5050,7 +5184,7 @@ func TestBuildStatefulSet_OverwriteMode_NoInitTmpVolume(t *testing.T) { } instance.Spec.Config.MergeMode = "overwrite" - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) initTmpVol := findVolume(sts.Spec.Template.Spec.Volumes, "init-tmp") if initTmpVol != nil { t.Error("init-tmp volume should not exist in overwrite mode") @@ -5062,7 +5196,7 @@ func TestBuildInitScript_MergeMode_NoConfig(t *testing.T) { instance.Spec.Config.MergeMode = ConfigMergeModeMerge // No raw config set — but operator always creates a ConfigMap (gateway.bind) - script := BuildInitScript(instance, nil) + script := BuildInitScript(instance, nil, nil, nil) // Should produce a merge script since configMapKey() now always returns "openclaw.json" if !strings.Contains(script, "node -e") { t.Errorf("merge mode should produce node deep merge script, got: %q", script) @@ -5257,7 +5391,7 @@ func TestBuildSkillsScript_WrapperOrdering(t *testing.T) { func TestBuildStatefulSet_NoSkills_NoInitSkillsContainer(t *testing.T) { instance := newTestInstance("no-skills-sts") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) for _, c := range sts.Spec.Template.Spec.InitContainers { if c.Name == "init-skills" { t.Error("init-skills container should not exist without skills") @@ -5269,7 +5403,7 @@ func TestBuildStatefulSet_WithSkills_InitSkillsContainer(t *testing.T) { instance := newTestInstance("skills-sts") instance.Spec.Skills = []string{"@anthropic/mcp-server-fetch"} - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var skillsContainer *corev1.Container for i := range sts.Spec.Template.Spec.InitContainers { @@ -5326,7 +5460,7 @@ func TestBuildStatefulSet_WithNpmSkill_InitSkillsScript(t *testing.T) { instance := newTestInstance("npm-skill-sts") instance.Spec.Skills = []string{"npm:@openclaw/matrix"} - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var skillsContainer *corev1.Container for i := range sts.Spec.Template.Spec.InitContainers { @@ -5363,7 +5497,7 @@ func TestBuildStatefulSet_WithSkills_EnvAndEnvFromPropagated(t *testing.T) { }, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var skillsContainer *corev1.Container for i := range sts.Spec.Template.Spec.InitContainers { @@ -5403,7 +5537,7 @@ func TestBuildStatefulSet_WithSkills_SkillsTmpVolume(t *testing.T) { instance := newTestInstance("skills-vol") instance.Spec.Skills = []string{"some-skill"} - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) skillsTmpVol := findVolume(sts.Spec.Template.Spec.Volumes, "skills-tmp") if skillsTmpVol == nil { t.Fatal("skills-tmp volume not found") @@ -5416,7 +5550,7 @@ func TestBuildStatefulSet_WithSkills_SkillsTmpVolume(t *testing.T) { func TestBuildStatefulSet_NoSkills_NoSkillsTmpVolume(t *testing.T) { instance := newTestInstance("no-skills-vol") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) skillsTmpVol := findVolume(sts.Spec.Template.Spec.Volumes, "skills-tmp") if skillsTmpVol != nil { t.Error("skills-tmp volume should not exist without skills") @@ -5427,7 +5561,7 @@ func TestBuildStatefulSet_ClawHubSkills_MainContainerSkillsMount(t *testing.T) { instance := newTestInstance("clawhub-skills-mount") instance.Spec.Skills = []string{"@anthropic/mcp-server-fetch"} - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var mainContainer *corev1.Container for i := range sts.Spec.Template.Spec.Containers { @@ -5463,7 +5597,7 @@ func TestBuildStatefulSet_NpmSkills_PathIncludesLocalBin(t *testing.T) { instance := newTestInstance("npm-path") instance.Spec.Skills = []string{"npm:mcporter"} - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] var pathVar *corev1.EnvVar @@ -5485,7 +5619,7 @@ func TestBuildStatefulSet_ClawHubOnlySkills_NoPathOverride(t *testing.T) { instance := newTestInstance("clawhub-no-path") instance.Spec.Skills = []string{"@anthropic/mcp-server-fetch"} - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] for i := range main.Env { @@ -5500,7 +5634,7 @@ func TestBuildStatefulSet_MixedSkills_PathIncludesLocalBin(t *testing.T) { instance := newTestInstance("mixed-skills-path") instance.Spec.Skills = []string{"@anthropic/mcp-server-fetch", "npm:mcporter"} - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] var pathVar *corev1.EnvVar @@ -5522,7 +5656,7 @@ func TestBuildStatefulSet_NpmSkills_InitContainerUsesGlobalInstall(t *testing.T) instance := newTestInstance("npm-global") instance.Spec.Skills = []string{"npm:mcporter"} - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var skillsContainer *corev1.Container for i := range sts.Spec.Template.Spec.InitContainers { @@ -5576,7 +5710,7 @@ func TestBuildStatefulSet_NpmOnlySkills_NoMainSkillsMount(t *testing.T) { instance := newTestInstance("npm-only-mount") instance.Spec.Skills = []string{"npm:@openclaw/matrix"} - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var mainContainer *corev1.Container for i := range sts.Spec.Template.Spec.Containers { @@ -5599,12 +5733,12 @@ func TestBuildStatefulSet_NpmOnlySkills_NoMainSkillsMount(t *testing.T) { func TestConfigHash_ChangesWithSkills(t *testing.T) { instance := newTestInstance("hash-skills") - dep1 := BuildStatefulSet(instance, "", nil) + dep1 := BuildStatefulSet(instance, "", nil, nil, nil) hash1 := dep1.Spec.Template.Annotations["openclaw.rocks/config-hash"] instance.Spec.Skills = []string{"new-skill"} - dep2 := BuildStatefulSet(instance, "", nil) + dep2 := BuildStatefulSet(instance, "", nil, nil, nil) hash2 := dep2.Spec.Template.Annotations["openclaw.rocks/config-hash"] if hash1 == hash2 { @@ -5617,7 +5751,7 @@ func TestBuildStatefulSet_SkillsOnly_HasBothInitContainers(t *testing.T) { instance.Spec.Skills = []string{"some-skill"} // No raw config set — but operator always creates config (gateway.bind) - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // Should have init-config container (gateway.bind=lan config) foundConfig := false @@ -5712,7 +5846,7 @@ func TestBuildPluginsScript_Deterministic(t *testing.T) { func TestBuildStatefulSet_NoPlugins_NoInitPluginsContainer(t *testing.T) { instance := newTestInstance("no-plugins-sts") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) for _, c := range sts.Spec.Template.Spec.InitContainers { if c.Name == "init-plugins" { t.Error("init-plugins container should not exist without plugins") @@ -5724,7 +5858,7 @@ func TestBuildStatefulSet_WithPlugins_InitPluginsContainer(t *testing.T) { instance := newTestInstance("plugins-sts") instance.Spec.Plugins = []string{"@martian-engineering/lossless-claw"} - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var pluginsContainer *corev1.Container for i := range sts.Spec.Template.Spec.InitContainers { @@ -5800,7 +5934,7 @@ func TestBuildStatefulSet_WithPlugins_EnvAndEnvFromPropagated(t *testing.T) { }, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var pluginsContainer *corev1.Container for i := range sts.Spec.Template.Spec.InitContainers { @@ -5840,7 +5974,7 @@ func TestBuildStatefulSet_WithPlugins_PluginsTmpVolume(t *testing.T) { instance := newTestInstance("plugins-vol") instance.Spec.Plugins = []string{"some-plugin"} - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) pluginsTmpVol := findVolume(sts.Spec.Template.Spec.Volumes, "plugins-tmp") if pluginsTmpVol == nil { t.Fatal("plugins-tmp volume not found") @@ -5853,7 +5987,7 @@ func TestBuildStatefulSet_WithPlugins_PluginsTmpVolume(t *testing.T) { func TestBuildStatefulSet_NoPlugins_NoPluginsTmpVolume(t *testing.T) { instance := newTestInstance("no-plugins-vol") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) pluginsTmpVol := findVolume(sts.Spec.Template.Spec.Volumes, "plugins-tmp") if pluginsTmpVol != nil { t.Error("plugins-tmp volume should not exist without plugins") @@ -5863,12 +5997,12 @@ func TestBuildStatefulSet_NoPlugins_NoPluginsTmpVolume(t *testing.T) { func TestConfigHash_ChangesWithPlugins(t *testing.T) { instance := newTestInstance("hash-plugins") - dep1 := BuildStatefulSet(instance, "", nil) + dep1 := BuildStatefulSet(instance, "", nil, nil, nil) hash1 := dep1.Spec.Template.Annotations["openclaw.rocks/config-hash"] instance.Spec.Plugins = []string{"some-plugin"} - dep2 := BuildStatefulSet(instance, "", nil) + dep2 := BuildStatefulSet(instance, "", nil, nil, nil) hash2 := dep2.Spec.Template.Annotations["openclaw.rocks/config-hash"] if hash1 == hash2 { @@ -5884,7 +6018,7 @@ func TestBuildStatefulSet_InitContainerOrdering_SkillsThenPlugins(t *testing.T) {Name: "user-init", Image: "busybox:1.37"}, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) initContainers := sts.Spec.Template.Spec.InitContainers // Find indices @@ -5923,7 +6057,7 @@ func TestBuildStatefulSet_CABundle_InitPlugins(t *testing.T) { Key: "ca.crt", } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // Find init-plugins container var initPlugins *corev1.Container @@ -6174,7 +6308,7 @@ func TestBuildConfigMap_EmptyGatewayToken(t *testing.T) { func TestBuildStatefulSet_DisableBonjour(t *testing.T) { instance := newTestInstance("bonjour-test") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] found := false @@ -6196,7 +6330,7 @@ func TestBuildStatefulSet_GatewayTokenEnv(t *testing.T) { instance := newTestInstance("gw-env-test") secretName := "gw-env-test-gateway-token" - sts := BuildStatefulSet(instance, secretName, nil) + sts := BuildStatefulSet(instance, secretName, nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] var gwEnv *corev1.EnvVar @@ -6228,7 +6362,7 @@ func TestBuildStatefulSet_GatewayTokenEnv_UserOverride(t *testing.T) { } secretName := "gw-override-gateway-token" - sts := BuildStatefulSet(instance, secretName, nil) + sts := BuildStatefulSet(instance, secretName, nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] // Count occurrences of OPENCLAW_GATEWAY_TOKEN @@ -6255,7 +6389,7 @@ func TestBuildStatefulSet_ExistingSecret(t *testing.T) { instance.Spec.Gateway.ExistingSecret = "my-custom-secret" existingSecretName := "my-custom-secret" - sts := BuildStatefulSet(instance, existingSecretName, nil) + sts := BuildStatefulSet(instance, existingSecretName, nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] var gwEnv *corev1.EnvVar @@ -6287,7 +6421,7 @@ func TestBuildStatefulSet_ExistingSecret_UserOverride(t *testing.T) { {Name: "OPENCLAW_GATEWAY_TOKEN", Value: "user-provided-token"}, } - sts := BuildStatefulSet(instance, "my-custom-secret", nil) + sts := BuildStatefulSet(instance, "my-custom-secret", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] count := 0 @@ -6310,7 +6444,7 @@ func TestBuildStatefulSet_ExistingSecret_UserOverride(t *testing.T) { func TestBuildStatefulSet_NoGatewayTokenSecretName(t *testing.T) { instance := newTestInstance("no-gw") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] for _, env := range main.Env { @@ -6326,7 +6460,7 @@ func TestBuildStatefulSet_NoGatewayTokenSecretName(t *testing.T) { func TestBuildStatefulSet_FSGroupChangePolicy_Default(t *testing.T) { instance := newTestInstance("fsgcp-default") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) psc := sts.Spec.Template.Spec.SecurityContext if psc.FSGroupChangePolicy != nil { t.Errorf("FSGroupChangePolicy should be nil by default, got %v", *psc.FSGroupChangePolicy) @@ -6340,7 +6474,7 @@ func TestBuildStatefulSet_FSGroupChangePolicy_OnRootMismatch(t *testing.T) { FSGroupChangePolicy: &policy, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) psc := sts.Spec.Template.Spec.SecurityContext if psc.FSGroupChangePolicy == nil { t.Fatal("FSGroupChangePolicy should not be nil") @@ -6357,7 +6491,7 @@ func TestBuildStatefulSet_FSGroupChangePolicy_Always(t *testing.T) { FSGroupChangePolicy: &policy, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) psc := sts.Spec.Template.Spec.SecurityContext if psc.FSGroupChangePolicy == nil { t.Fatal("FSGroupChangePolicy should not be nil") @@ -6416,7 +6550,7 @@ func TestBuildServiceAccount_AnnotationsDoNotAffectLabels(t *testing.T) { func TestBuildStatefulSet_ExtraVolumes_None(t *testing.T) { instance := newTestInstance("no-extras") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) for _, v := range sts.Spec.Template.Spec.Volumes { if v.Name == "my-extra" { t.Error("should not have extra volumes when none configured") @@ -6438,7 +6572,7 @@ func TestBuildStatefulSet_ExtraVolumes(t *testing.T) { {Name: "ssh-keys", MountPath: "/home/openclaw/.ssh", ReadOnly: true}, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // Check volume exists vol := findVolume(sts.Spec.Template.Spec.Volumes, "ssh-keys") @@ -6465,7 +6599,7 @@ func TestBuildStatefulSet_ExtraVolumes_DontInterfereWithExisting(t *testing.T) { }, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) volumes := sts.Spec.Template.Spec.Volumes // Existing volumes should still be present @@ -6486,7 +6620,7 @@ func TestBuildStatefulSet_ExtraVolumes_DontInterfereWithExisting(t *testing.T) { func TestBuildStatefulSet_CABundle_Nil(t *testing.T) { instance := newTestInstance("no-ca") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) if findVolume(sts.Spec.Template.Spec.Volumes, "ca-bundle") != nil { t.Error("ca-bundle volume should not exist when CABundle is nil") @@ -6500,7 +6634,7 @@ func TestBuildStatefulSet_CABundle_ConfigMap(t *testing.T) { Key: "custom-ca.crt", } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // Volume vol := findVolume(sts.Spec.Template.Spec.Volumes, "ca-bundle") @@ -6538,7 +6672,7 @@ func TestBuildStatefulSet_CABundle_Secret(t *testing.T) { SecretName: "ca-secret", } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) vol := findVolume(sts.Spec.Template.Spec.Volumes, "ca-bundle") if vol == nil { @@ -6559,7 +6693,7 @@ func TestBuildStatefulSet_CABundle_DefaultKey(t *testing.T) { // Key not set — should default to "ca-bundle.crt" } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] // Find the ca-bundle mount and check subPath @@ -6582,7 +6716,7 @@ func TestBuildStatefulSet_CABundle_WithChromium(t *testing.T) { Key: "ca.crt", } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // Find chromium in init containers (native sidecar) var chromium *corev1.Container @@ -6615,7 +6749,7 @@ func TestBuildStatefulSet_CABundle_InitSkills(t *testing.T) { Key: "ca.crt", } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // Find init-skills container var initSkills *corev1.Container @@ -6650,7 +6784,7 @@ func TestBuildStatefulSet_CABundle_InitSkills(t *testing.T) { func TestBuildStatefulSet_NoCustomInitContainers(t *testing.T) { instance := newTestInstance("no-custom-init") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) for _, c := range sts.Spec.Template.Spec.InitContainers { if c.Name == "my-init" { t.Error("should not have custom init containers when none configured") @@ -6668,7 +6802,7 @@ func TestBuildStatefulSet_CustomInitContainers(t *testing.T) { }, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // Custom init container should be last initContainers := sts.Spec.Template.Spec.InitContainers @@ -6694,7 +6828,7 @@ func TestBuildStatefulSet_CustomInitContainers_AfterOperatorManaged(t *testing.T {Name: "user-init", Image: "busybox:1.37"}, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) initContainers := sts.Spec.Template.Spec.InitContainers if len(initContainers) != 5 { @@ -6720,14 +6854,14 @@ func TestBuildStatefulSet_CustomInitContainers_AfterOperatorManaged(t *testing.T func TestConfigHash_ChangesWithInitContainers(t *testing.T) { instance := newTestInstance("hash-ic") - dep1 := BuildStatefulSet(instance, "", nil) + dep1 := BuildStatefulSet(instance, "", nil, nil, nil) hash1 := dep1.Spec.Template.Annotations["openclaw.rocks/config-hash"] instance.Spec.InitContainers = []corev1.Container{ {Name: "my-init", Image: "busybox:1.37"}, } - dep2 := BuildStatefulSet(instance, "", nil) + dep2 := BuildStatefulSet(instance, "", nil, nil, nil) hash2 := dep2.Spec.Template.Annotations["openclaw.rocks/config-hash"] if hash1 == hash2 { @@ -6747,7 +6881,7 @@ func TestBuildInitScript_JSON5_Overwrite(t *testing.T) { } instance.Spec.Config.Format = ConfigFormatJSON5 - script := BuildInitScript(instance, nil) + script := BuildInitScript(instance, nil, nil, nil) if !strings.Contains(script, "npx -y json5") { t.Errorf("JSON5 overwrite should use npx json5, got: %q", script) } @@ -6764,7 +6898,7 @@ func TestBuildStatefulSet_JSON5_UsesOpenClawImage(t *testing.T) { } instance.Spec.Config.Format = ConfigFormatJSON5 - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) initContainers := sts.Spec.Template.Spec.InitContainers if len(initContainers) == 0 { t.Fatal("expected init container for JSON5 mode") @@ -6784,7 +6918,7 @@ func TestBuildStatefulSet_JSON5_InitTmpVolume(t *testing.T) { } instance.Spec.Config.Format = ConfigFormatJSON5 - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // Should have init-tmp volume initTmpVol := findVolume(sts.Spec.Template.Spec.Volumes, "init-tmp") @@ -6804,7 +6938,7 @@ func TestBuildStatefulSet_JSON5_WritableRootFS(t *testing.T) { } instance.Spec.Config.Format = ConfigFormatJSON5 - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) initC := sts.Spec.Template.Spec.InitContainers[0] if initC.SecurityContext.ReadOnlyRootFilesystem == nil || *initC.SecurityContext.ReadOnlyRootFilesystem { @@ -6819,7 +6953,7 @@ func TestBuildInitScript_JSON_Overwrite_NoBusyboxRegression(t *testing.T) { } instance.Spec.Config.Format = "json" - script := BuildInitScript(instance, nil) + script := BuildInitScript(instance, nil, nil, nil) if strings.Contains(script, "npx") { t.Errorf("JSON overwrite should not use npx, got: %q", script) } @@ -6836,7 +6970,7 @@ func TestBuildStatefulSet_RuntimeDeps_Pnpm(t *testing.T) { instance := newTestInstance("pnpm") instance.Spec.RuntimeDeps.Pnpm = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) initContainers := sts.Spec.Template.Spec.InitContainers // Should have init-pnpm container @@ -6908,7 +7042,7 @@ func TestBuildStatefulSet_RuntimeDeps_Python(t *testing.T) { instance := newTestInstance("python") instance.Spec.RuntimeDeps.Python = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) initContainers := sts.Spec.Template.Spec.InitContainers // Should have init-python container @@ -6978,7 +7112,7 @@ func TestBuildStatefulSet_RuntimeDeps_Both(t *testing.T) { instance.Spec.RuntimeDeps.Pnpm = true instance.Spec.RuntimeDeps.Python = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) initContainers := sts.Spec.Template.Spec.InitContainers var hasPnpm, hasPython bool @@ -7016,7 +7150,7 @@ func TestBuildStatefulSet_RuntimeDeps_None(t *testing.T) { instance := newTestInstance("no-deps") // RuntimeDeps defaults to zero value (both false) - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) initContainers := sts.Spec.Template.Spec.InitContainers for _, c := range initContainers { @@ -7054,7 +7188,7 @@ func TestBuildStatefulSet_RuntimeDeps_InitContainerOrder(t *testing.T) { {Name: "user-init", Image: "busybox:1.37"}, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) initContainers := sts.Spec.Template.Spec.InitContainers expected := []string{"init-config", "init-uv", "init-pip", "init-pnpm", "init-python", "init-skills", "user-init"} @@ -7083,7 +7217,7 @@ func TestBuildStatefulSet_RuntimeDeps_Pnpm_CABundle(t *testing.T) { Key: "ca.crt", } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var pnpmContainer *corev1.Container for i := range sts.Spec.Template.Spec.InitContainers { if sts.Spec.Template.Spec.InitContainers[i].Name == "init-pnpm" { @@ -7114,12 +7248,12 @@ func TestBuildStatefulSet_RuntimeDeps_Pnpm_CABundle(t *testing.T) { func TestConfigHash_ChangesWithRuntimeDeps(t *testing.T) { instance := newTestInstance("hash-rd") - sts1 := BuildStatefulSet(instance, "", nil) + sts1 := BuildStatefulSet(instance, "", nil, nil, nil) hash1 := sts1.Spec.Template.Annotations["openclaw.rocks/config-hash"] instance.Spec.RuntimeDeps.Pnpm = true - sts2 := BuildStatefulSet(instance, "", nil) + sts2 := BuildStatefulSet(instance, "", nil, nil, nil) hash2 := sts2.Spec.Template.Annotations["openclaw.rocks/config-hash"] if hash1 == hash2 { @@ -7284,7 +7418,7 @@ func TestBuildStatefulSet_TailscaleSidecar(t *testing.T) { instance.Spec.Tailscale.Enabled = true instance.Spec.Tailscale.AuthKeySecretRef = &corev1.LocalObjectReference{Name: "ts-auth"} - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) containers := sts.Spec.Template.Spec.Containers // Find the tailscale sidecar @@ -7359,7 +7493,7 @@ func TestBuildStatefulSet_TailscaleAuthKeyOnSidecar_NotMain(t *testing.T) { instance.Spec.Tailscale.Enabled = true instance.Spec.Tailscale.AuthKeySecretRef = &corev1.LocalObjectReference{Name: "ts-auth"} - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] // TS_AUTHKEY and TS_HOSTNAME should NOT be on the main container @@ -7392,7 +7526,7 @@ func TestBuildStatefulSet_TailscaleCustomHostname(t *testing.T) { instance.Spec.Tailscale.Enabled = true instance.Spec.Tailscale.Hostname = "my-custom-host" - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // Find sidecar var tsSidecar *corev1.Container @@ -7423,7 +7557,7 @@ func TestBuildStatefulSet_TailscaleCustomSecretKey(t *testing.T) { instance.Spec.Tailscale.AuthKeySecretRef = &corev1.LocalObjectReference{Name: "ts-secret"} instance.Spec.Tailscale.AuthKeySecretKey = "my-key" - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // Find sidecar var tsSidecar *corev1.Container @@ -7451,7 +7585,7 @@ func TestBuildStatefulSet_TailscaleCustomSecretKey(t *testing.T) { func TestBuildStatefulSet_TailscaleDisabled(t *testing.T) { instance := newTestInstance("ts-disabled") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] for _, env := range main.Env { @@ -7479,7 +7613,7 @@ func TestBuildStatefulSet_TailscaleInitContainer(t *testing.T) { instance := newTestInstance("ts-init") instance.Spec.Tailscale.Enabled = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var initContainer *corev1.Container for i := range sts.Spec.Template.Spec.InitContainers { @@ -7501,7 +7635,7 @@ func TestBuildStatefulSet_TailscaleVolumes(t *testing.T) { instance := newTestInstance("ts-vols") instance.Spec.Tailscale.Enabled = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) volumes := sts.Spec.Template.Spec.Volumes for _, name := range []string{"tailscale-socket", "tailscale-bin", "tailscale-tmp"} { @@ -7520,7 +7654,7 @@ func TestBuildStatefulSet_TailscaleMainContainerMounts(t *testing.T) { instance := newTestInstance("ts-mounts") instance.Spec.Tailscale.Enabled = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] assertVolumeMount(t, main.VolumeMounts, "tailscale-socket", TailscaleSocketDir) @@ -7541,7 +7675,7 @@ func TestBuildStatefulSet_TailscalePATH(t *testing.T) { instance := newTestInstance("ts-path") instance.Spec.Tailscale.Enabled = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] var pathVar *corev1.EnvVar @@ -7564,7 +7698,7 @@ func TestBuildStatefulSet_TailscalePATH_WithRuntimeDeps(t *testing.T) { instance.Spec.Tailscale.Enabled = true instance.Spec.RuntimeDeps.Pnpm = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] var pathVar *corev1.EnvVar @@ -7592,7 +7726,7 @@ func TestBuildStatefulSet_TailscaleCustomImage(t *testing.T) { instance.Spec.Tailscale.Image.Repository = "my-registry/tailscale" instance.Spec.Tailscale.Image.Tag = "v1.60.0" - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // Check sidecar var tsSidecar *corev1.Container @@ -7626,7 +7760,7 @@ func TestBuildStatefulSet_TailscaleImageDigest(t *testing.T) { instance.Spec.Tailscale.Enabled = true instance.Spec.Tailscale.Image.Digest = "sha256:abc123" - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var tsSidecar *corev1.Container for i := range sts.Spec.Template.Spec.Containers { @@ -7720,8 +7854,8 @@ func TestBuildStatefulSet_Idempotent_WithTailscale(t *testing.T) { instance.Spec.Tailscale.AuthKeySecretRef = &corev1.LocalObjectReference{Name: "ts-auth"} instance.Spec.Tailscale.Hostname = "my-ts-host" - dep1 := BuildStatefulSet(instance, "", nil) - dep2 := BuildStatefulSet(instance, "", nil) + dep1 := BuildStatefulSet(instance, "", nil, nil, nil) + dep2 := BuildStatefulSet(instance, "", nil, nil, nil) b1, _ := json.Marshal(dep1.Spec) b2, _ := json.Marshal(dep2.Spec) @@ -7734,13 +7868,13 @@ func TestBuildStatefulSet_Idempotent_WithTailscale(t *testing.T) { func TestConfigHash_ChangesWithTailscale(t *testing.T) { instance := newTestInstance("hash-ts") - sts1 := BuildStatefulSet(instance, "", nil) + sts1 := BuildStatefulSet(instance, "", nil, nil, nil) hash1 := sts1.Spec.Template.Annotations["openclaw.rocks/config-hash"] instance.Spec.Tailscale.Enabled = true instance.Spec.Tailscale.Mode = "serve" - sts2 := BuildStatefulSet(instance, "", nil) + sts2 := BuildStatefulSet(instance, "", nil, nil, nil) hash2 := sts2.Spec.Template.Annotations["openclaw.rocks/config-hash"] if hash1 == hash2 { @@ -7898,7 +8032,7 @@ func TestBuildStatefulSet_TailscaleServe_UsesHTTPProbes(t *testing.T) { instance.Spec.Tailscale.Enabled = true instance.Spec.Tailscale.Mode = "serve" - sts := BuildStatefulSet(instance, "hash", nil) + sts := BuildStatefulSet(instance, "hash", nil, nil, nil) container := sts.Spec.Template.Spec.Containers[0] if container.LivenessProbe == nil { @@ -7935,7 +8069,7 @@ func TestBuildStatefulSet_AlwaysUsesHTTPProbes(t *testing.T) { instance := newTestInstance("always-http-probes") // Tailscale not enabled - probes should still use HTTPGet via proxy - sts := BuildStatefulSet(instance, "hash", nil) + sts := BuildStatefulSet(instance, "hash", nil, nil, nil) container := sts.Spec.Template.Spec.Containers[0] if container.LivenessProbe == nil { @@ -7961,7 +8095,7 @@ func TestBuildStatefulSet_TailscaleFunnel_UsesHTTPProbes(t *testing.T) { instance.Spec.Tailscale.Enabled = true instance.Spec.Tailscale.Mode = "funnel" - sts := BuildStatefulSet(instance, "hash", nil) + sts := BuildStatefulSet(instance, "hash", nil, nil, nil) container := sts.Spec.Template.Spec.Containers[0] if container.LivenessProbe.HTTPGet == nil { @@ -8003,7 +8137,7 @@ func TestBuildStatefulSet_TailscaleAutoMountToken(t *testing.T) { instance := newTestInstance("ts-automount") instance.Spec.Tailscale.Enabled = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) token := sts.Spec.Template.Spec.AutomountServiceAccountToken if token == nil || !*token { t.Error("AutomountServiceAccountToken should be true when Tailscale is enabled") @@ -8072,7 +8206,7 @@ func TestBuildRole_NoTailscaleRule_WhenDisabled(t *testing.T) { func TestBuildStatefulSet_ProbeEndpointPaths(t *testing.T) { instance := newTestInstance("probe-paths") - sts := BuildStatefulSet(instance, "hash", nil) + sts := BuildStatefulSet(instance, "hash", nil, nil, nil) container := sts.Spec.Template.Spec.Containers[0] tests := []struct { @@ -8321,7 +8455,7 @@ func TestBuildStatefulSet_OllamaEnabled(t *testing.T) { instance := newTestInstance("ollama-test") instance.Spec.Ollama.Enabled = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) containers := sts.Spec.Template.Spec.Containers if len(containers) != 4 { @@ -8434,7 +8568,7 @@ func TestBuildStatefulSet_OllamaEnabled_WithModels(t *testing.T) { instance.Spec.Ollama.Enabled = true instance.Spec.Ollama.Models = []string{"llama3.2", "nomic-embed-text"} - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) initContainers := sts.Spec.Template.Spec.InitContainers var initOllama *corev1.Container @@ -8476,7 +8610,7 @@ func TestBuildStatefulSet_OllamaEnabled_NoModels(t *testing.T) { instance := newTestInstance("ollama-no-models") instance.Spec.Ollama.Enabled = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // Sidecar should be present found := false @@ -8503,7 +8637,7 @@ func TestBuildStatefulSet_OllamaEnabled_GPU(t *testing.T) { instance.Spec.Ollama.Enabled = true instance.Spec.Ollama.GPU = Ptr(int32(2)) - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var ollama *corev1.Container for i := range sts.Spec.Template.Spec.Containers { @@ -8542,7 +8676,7 @@ func TestBuildStatefulSet_OllamaEnabled_ExistingClaim(t *testing.T) { instance.Spec.Ollama.Enabled = true instance.Spec.Ollama.Storage.ExistingClaim = "my-model-pvc" - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) volumes := sts.Spec.Template.Spec.Volumes var ollamaVol *corev1.Volume @@ -8571,7 +8705,7 @@ func TestBuildStatefulSet_OllamaEnabled_CustomImage(t *testing.T) { Tag: "v0.3.0", } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var ollama *corev1.Container for i := range sts.Spec.Template.Spec.Containers { @@ -8597,7 +8731,7 @@ func TestBuildStatefulSet_OllamaEnabled_CustomImageDigest(t *testing.T) { Digest: "sha256:ollamahash", } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var ollama *corev1.Container for i := range sts.Spec.Template.Spec.Containers { @@ -8628,7 +8762,7 @@ func TestBuildStatefulSet_OllamaEnabled_CustomResources(t *testing.T) { }, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var ollama *corev1.Container for i := range sts.Spec.Template.Spec.Containers { @@ -8657,7 +8791,7 @@ func TestBuildStatefulSet_OllamaAndChromiumEnabled(t *testing.T) { instance.Spec.Ollama.Enabled = true instance.Spec.Ollama.Models = []string{"llama3.2"} - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) containers := sts.Spec.Template.Spec.Containers initContainers := sts.Spec.Template.Spec.InitContainers @@ -8736,7 +8870,7 @@ func TestBuildStatefulSet_OllamaAndChromiumEnabled(t *testing.T) { func TestBuildStatefulSet_OllamaDisabled(t *testing.T) { instance := newTestInstance("no-ollama") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // No ollama container for _, c := range sts.Spec.Template.Spec.Containers { @@ -8766,7 +8900,7 @@ func TestBuildStatefulSet_OllamaEnabled_CustomStorageSize(t *testing.T) { instance.Spec.Ollama.Enabled = true instance.Spec.Ollama.Storage.SizeLimit = "50Gi" - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var ollamaVol *corev1.Volume for i := range sts.Spec.Template.Spec.Volumes { @@ -8790,7 +8924,7 @@ func TestBuildStatefulSet_OllamaContainerDefaults(t *testing.T) { instance := newTestInstance("ollama-defaults") instance.Spec.Ollama.Enabled = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) if len(sts.Spec.Template.Spec.Containers) < 2 { t.Fatal("expected ollama sidecar container") } @@ -8832,7 +8966,7 @@ func TestBuildStatefulSet_OllamaEnabled_InitContainerUsesCustomImage(t *testing. Tag: "v0.3.0", } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var initOllama *corev1.Container for i := range sts.Spec.Template.Spec.InitContainers { @@ -9108,7 +9242,7 @@ func TestBuildStatefulSet_SelfConfigureEnvVars(t *testing.T) { Enabled: true, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) mainContainer := sts.Spec.Template.Spec.Containers[0] envMap := map[string]string{} @@ -9127,7 +9261,7 @@ func TestBuildStatefulSet_SelfConfigureEnvVars(t *testing.T) { func TestBuildStatefulSet_SelfConfigureDisabledNoEnvVars(t *testing.T) { instance := newTestInstance("sc-noenv") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) mainContainer := sts.Spec.Template.Spec.Containers[0] for _, ev := range mainContainer.Env { @@ -9143,7 +9277,7 @@ func TestBuildStatefulSet_SelfConfigureAutoMount(t *testing.T) { Enabled: true, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) if sts.Spec.Template.Spec.AutomountServiceAccountToken == nil || !*sts.Spec.Template.Spec.AutomountServiceAccountToken { @@ -9194,7 +9328,7 @@ func TestBuildWorkspaceConfigMap_SelfConfigureSkillInjected(t *testing.T) { Enabled: true, } - cm := BuildWorkspaceConfigMap(instance, nil) + cm := BuildWorkspaceConfigMap(instance, nil, nil, nil) if cm == nil { t.Fatal("BuildWorkspaceConfigMap returned nil when self-configure is enabled") @@ -9220,7 +9354,7 @@ func TestBuildWorkspaceConfigMap_SelfConfigureMergedWithUserFiles(t *testing.T) }, } - cm := BuildWorkspaceConfigMap(instance, nil) + cm := BuildWorkspaceConfigMap(instance, nil, nil, nil) if cm == nil { t.Fatal("BuildWorkspaceConfigMap returned nil") @@ -9246,7 +9380,7 @@ func TestBuildWorkspaceConfigMap_SelfConfigureMergedWithUserFiles(t *testing.T) func TestBuildWorkspaceConfigMap_SelfConfigureDisabledNoFiles(t *testing.T) { instance := newTestInstance("sc-ws-off") - cm := BuildWorkspaceConfigMap(instance, nil) + cm := BuildWorkspaceConfigMap(instance, nil, nil, nil) // Operator files are always injected, so ConfigMap is never nil if cm == nil { @@ -9279,7 +9413,7 @@ func TestBuildStatefulSet_SelfConfigureWorkspaceVolume(t *testing.T) { Enabled: true, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // Should have workspace-init volume foundVol := false @@ -9634,7 +9768,7 @@ func TestStatefulSetReplicas_HPAEnabled(t *testing.T) { Enabled: Ptr(true), } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) if sts.Spec.Replicas != nil { t.Errorf("replicas should be nil when HPA is enabled, got %d", *sts.Spec.Replicas) } @@ -9643,7 +9777,7 @@ func TestStatefulSetReplicas_HPAEnabled(t *testing.T) { func TestStatefulSetReplicas_HPADisabled(t *testing.T) { instance := newTestInstance("my-app") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) if sts.Spec.Replicas == nil || *sts.Spec.Replicas != 1 { t.Errorf("replicas should be 1 when HPA is disabled") } @@ -9761,7 +9895,7 @@ func TestBuildStatefulSet_MetricsPortEnabled(t *testing.T) { instance := newTestInstance("sts-metrics-enabled") // Metrics enabled by default (nil) - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // Metrics port should be on the otel-collector container, not main main := sts.Spec.Template.Spec.Containers[0] @@ -9788,7 +9922,7 @@ func TestBuildStatefulSet_MetricsPortDisabled(t *testing.T) { instance := newTestInstance("sts-metrics-disabled") instance.Spec.Observability.Metrics.Enabled = Ptr(false) - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) for _, c := range sts.Spec.Template.Spec.Containers { if c.Name == "otel-collector" { @@ -9806,7 +9940,7 @@ func TestBuildStatefulSet_MetricsPortCustom(t *testing.T) { instance := newTestInstance("sts-metrics-custom") instance.Spec.Observability.Metrics.Port = Ptr(int32(8080)) - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) for _, c := range sts.Spec.Template.Spec.Containers { if c.Name == "otel-collector" { @@ -9825,7 +9959,7 @@ func TestBuildStatefulSet_WebTerminalEnabled(t *testing.T) { instance := newTestInstance("web-terminal-test") instance.Spec.WebTerminal.Enabled = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) containers := sts.Spec.Template.Spec.Containers if len(containers) != 4 { @@ -9951,7 +10085,7 @@ func TestBuildStatefulSet_WebTerminalCustomImage(t *testing.T) { Tag: "v1.7.0", } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var wt corev1.Container for _, c := range sts.Spec.Template.Spec.Containers { @@ -9974,7 +10108,7 @@ func TestBuildStatefulSet_WebTerminalDigest(t *testing.T) { Digest: "sha256:abcdef1234567890", } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var wt corev1.Container for _, c := range sts.Spec.Template.Spec.Containers { @@ -9997,7 +10131,7 @@ func TestBuildStatefulSet_WebTerminalCustomResources(t *testing.T) { Limits: openclawv1alpha1.ResourceList{CPU: "500m", Memory: "256Mi"}, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var wt corev1.Container for _, c := range sts.Spec.Template.Spec.Containers { @@ -10030,7 +10164,7 @@ func TestBuildStatefulSet_WebTerminalReadOnly(t *testing.T) { instance.Spec.WebTerminal.Enabled = true instance.Spec.WebTerminal.ReadOnly = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var wt corev1.Container for _, c := range sts.Spec.Template.Spec.Containers { @@ -10064,7 +10198,7 @@ func TestBuildStatefulSet_WebTerminalCredential(t *testing.T) { SecretRef: corev1.LocalObjectReference{Name: "wt-secret"}, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var wt corev1.Container for _, c := range sts.Spec.Template.Spec.Containers { @@ -10124,7 +10258,7 @@ func TestBuildStatefulSet_WebTerminalCredential(t *testing.T) { func TestBuildStatefulSet_WebTerminalDisabled(t *testing.T) { instance := newTestInstance("no-web-terminal") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // No web-terminal container for _, c := range sts.Spec.Template.Spec.Containers { @@ -10145,7 +10279,7 @@ func TestBuildStatefulSet_WebTerminalContainerDefaults(t *testing.T) { instance := newTestInstance("wt-defaults") instance.Spec.WebTerminal.Enabled = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var wt corev1.Container for _, c := range sts.Spec.Template.Spec.Containers { @@ -10293,7 +10427,7 @@ func TestBuildStatefulSet_WebTerminalReadOnlyWithCredential(t *testing.T) { SecretRef: corev1.LocalObjectReference{Name: "cred-secret"}, } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var wt corev1.Container for _, c := range sts.Spec.Template.Spec.Containers { @@ -10318,7 +10452,7 @@ func TestBuildStatefulSet_WebTerminalReadOnlyWithCredential(t *testing.T) { func TestBuildStatefulSet_HasGatewayProxyContainer(t *testing.T) { instance := newTestInstance("gw-proxy") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var proxy *corev1.Container for i := range sts.Spec.Template.Spec.Containers { @@ -10404,7 +10538,7 @@ func TestBuildStatefulSet_HasGatewayProxyContainer(t *testing.T) { func TestBuildStatefulSet_GatewayProxyTmpVolume(t *testing.T) { instance := newTestInstance("gw-proxy-vol") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) found := false for _, v := range sts.Spec.Template.Spec.Volumes { @@ -10928,8 +11062,8 @@ func TestBuildWorkspaceConfigMap_Idempotent(t *testing.T) { "SOUL.md": "# Personality\nBe helpful.", }, } - w1 := BuildWorkspaceConfigMap(instance, nil) - w2 := BuildWorkspaceConfigMap(instance, nil) + w1 := BuildWorkspaceConfigMap(instance, nil, nil, nil) + w2 := BuildWorkspaceConfigMap(instance, nil, nil, nil) b1, _ := json.Marshal(w1.Data) b2, _ := json.Marshal(w2.Data) if !bytes.Equal(b1, b2) { @@ -10972,7 +11106,7 @@ func TestBuildRBAC_Idempotent(t *testing.T) { func TestBuildStatefulSet_NilAvailability(t *testing.T) { instance := newTestInstance("nil-avail") // Zero-value AvailabilitySpec - should not panic - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) if sts == nil { t.Fatal("BuildStatefulSet returned nil for zero-value availability") } @@ -11032,7 +11166,7 @@ func TestBuildIngress_NoHosts_ReturnsEmpty(t *testing.T) { func TestNormalizeStatefulSet_DeprecatedServiceAccount(t *testing.T) { instance := newTestInstance("norm-test") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) NormalizeStatefulSet(sts) podSpec := sts.Spec.Template.Spec @@ -11048,7 +11182,7 @@ func TestNormalizeStatefulSet_DeprecatedServiceAccount(t *testing.T) { func TestNormalizeStatefulSet_FieldRefAPIVersion(t *testing.T) { instance := newTestInstance("norm-fieldref") instance.Spec.Chromium.Enabled = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) NormalizeStatefulSet(sts) for _, c := range sts.Spec.Template.Spec.Containers { @@ -11067,7 +11201,7 @@ func TestNormalizeStatefulSet_Idempotent(t *testing.T) { // Verifies that normalizing twice produces the same result (stability). instance := newTestInstance("norm-idem") instance.Spec.Chromium.Enabled = true - sts := BuildStatefulSet(instance, "gw-secret", nil) + sts := BuildStatefulSet(instance, "gw-secret", nil, nil, nil) NormalizeStatefulSet(sts) // JSON-serialize as first snapshot @@ -11090,7 +11224,7 @@ func TestNormalizeStatefulSet_NoSpuriousDiff(t *testing.T) { instance.Spec.Chromium.Enabled = true // First build (simulates "existing" after initial create + K8s defaulting) - sts1 := BuildStatefulSet(instance, "gw-secret", nil) + sts1 := BuildStatefulSet(instance, "gw-secret", nil, nil, nil) NormalizeStatefulSet(sts1) // Simulate K8s round-trip: JSON marshal/unmarshal (like reading from API) @@ -11104,7 +11238,7 @@ func TestNormalizeStatefulSet_NoSpuriousDiff(t *testing.T) { } // Second build (simulates next reconcile's "desired") - sts2 := BuildStatefulSet(instance, "gw-secret", nil) + sts2 := BuildStatefulSet(instance, "gw-secret", nil, nil, nil) NormalizeStatefulSet(sts2) // Apply mutation like the controller does: replace spec @@ -11125,7 +11259,7 @@ func TestNormalizeStatefulSet_NoSpuriousDiff(t *testing.T) { func TestNormalizeStatefulSet_ProbeDefaults(t *testing.T) { instance := newTestInstance("norm-probe") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) NormalizeStatefulSet(sts) main := sts.Spec.Template.Spec.Containers[0] @@ -11146,7 +11280,7 @@ func TestNormalizeStatefulSet_ProbeDefaults(t *testing.T) { func TestBuildWorkspaceConfigMap_NilWorkspace(t *testing.T) { instance := newTestInstance("ws-nil") instance.Spec.Workspace = nil - cm := BuildWorkspaceConfigMap(instance, nil) + cm := BuildWorkspaceConfigMap(instance, nil, nil, nil) // Operator files are always injected, so the ConfigMap is never nil if cm == nil { t.Fatal("expected non-nil ConfigMap (operator files are always injected)") @@ -11174,7 +11308,7 @@ func TestBuildStatefulSet_RunAsNonRoot_DefaultBehavior(t *testing.T) { instance.Spec.WebTerminal.Enabled = true instance.Spec.Skills = []string{"@test/skill"} - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) containers := sts.Spec.Template.Spec.Containers initContainers := sts.Spec.Template.Spec.InitContainers @@ -11230,7 +11364,7 @@ func TestBuildStatefulSet_PodLevelRunAsNonRootFalse_Propagation(t *testing.T) { instance.Spec.RuntimeDeps.Pnpm = true instance.Spec.RuntimeDeps.Python = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) containers := sts.Spec.Template.Spec.Containers initContainers := sts.Spec.Template.Spec.InitContainers @@ -11312,7 +11446,7 @@ func TestBuildStatefulSet_ContainerLevelRunAsNonRootOverride(t *testing.T) { instance.Spec.Tailscale.Enabled = true instance.Spec.Tailscale.AuthKeySecretRef = &corev1.LocalObjectReference{Name: "ts-secret"} - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) containers := sts.Spec.Template.Spec.Containers initContainers := sts.Spec.Template.Spec.InitContainers @@ -11354,7 +11488,7 @@ func TestBuildStatefulSet_ContainerLevelRunAsUser(t *testing.T) { RunAsUser: Ptr(int64(2000)), } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) main := sts.Spec.Template.Spec.Containers[0] if main.SecurityContext.RunAsUser == nil || *main.SecurityContext.RunAsUser != 2000 { @@ -11387,7 +11521,7 @@ func TestBuildStatefulSet_FullNonRootFalseScenario(t *testing.T) { instance.Spec.RuntimeDeps.Pnpm = true instance.Spec.RuntimeDeps.Python = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var allContainers []corev1.Container allContainers = append(allContainers, sts.Spec.Template.Spec.InitContainers...) allContainers = append(allContainers, sts.Spec.Template.Spec.Containers...) @@ -11521,7 +11655,7 @@ func TestBuildStatefulSet_NoChromiumProxy(t *testing.T) { instance := newTestInstance("no-proxy") instance.Spec.Chromium.Enabled = true - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) for _, c := range sts.Spec.Template.Spec.InitContainers { if c.Name == "chromium-proxy" { t.Error("chromium-proxy should not exist - Chrome runs via run.sh") @@ -11575,7 +11709,7 @@ func TestChromiumArgs_PersistenceAddsUserDataDir(t *testing.T) { func TestBuildStatefulSet_GatewayProxyDisabled_NoProxyContainer(t *testing.T) { instance := newTestInstance("gw-disabled") instance.Spec.Gateway.Enabled = Ptr(false) - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) for _, c := range sts.Spec.Template.Spec.Containers { if c.Name == "gateway-proxy" { @@ -11587,7 +11721,7 @@ func TestBuildStatefulSet_GatewayProxyDisabled_NoProxyContainer(t *testing.T) { func TestBuildStatefulSet_GatewayProxyDisabled_NoProxyTmpVolume(t *testing.T) { instance := newTestInstance("gw-disabled-vol") instance.Spec.Gateway.Enabled = Ptr(false) - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) for _, v := range sts.Spec.Template.Spec.Volumes { if v.Name == "gateway-proxy-tmp" { @@ -11599,7 +11733,7 @@ func TestBuildStatefulSet_GatewayProxyDisabled_NoProxyTmpVolume(t *testing.T) { func TestBuildStatefulSet_GatewayProxyDisabled_ProbesTargetGatewayPort(t *testing.T) { instance := newTestInstance("gw-disabled-probes") instance.Spec.Gateway.Enabled = Ptr(false) - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var main *corev1.Container for i := range sts.Spec.Template.Spec.Containers { @@ -11637,7 +11771,7 @@ func TestBuildStatefulSet_GatewayProxyDisabled_ProbesTargetGatewayPort(t *testin func TestBuildStatefulSet_GatewayProxyEnabled_ProbesTargetProxyPort(t *testing.T) { instance := newTestInstance("gw-enabled-probes") // Default (nil) means enabled - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) var main *corev1.Container for i := range sts.Spec.Template.Spec.Containers { @@ -11737,7 +11871,7 @@ func TestBuildStatefulSet_VCT_PersistenceAndHPA(t *testing.T) { } instance.Spec.Storage.Persistence.Size = "20Gi" - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // VolumeClaimTemplates should be set if len(sts.Spec.VolumeClaimTemplates) != 1 { @@ -11774,7 +11908,7 @@ func TestBuildStatefulSet_VCT_CustomAccessModes(t *testing.T) { } instance.Spec.Storage.Persistence.AccessModes = []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany} - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) if len(sts.Spec.VolumeClaimTemplates) != 1 { t.Fatalf("VolumeClaimTemplates length = %d, want 1", len(sts.Spec.VolumeClaimTemplates)) @@ -11792,7 +11926,7 @@ func TestBuildStatefulSet_VCT_StorageClass(t *testing.T) { sc := "fast-ssd" instance.Spec.Storage.Persistence.StorageClass = &sc - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) if len(sts.Spec.VolumeClaimTemplates) != 1 { t.Fatalf("VolumeClaimTemplates length = %d, want 1", len(sts.Spec.VolumeClaimTemplates)) @@ -11809,7 +11943,7 @@ func TestBuildStatefulSet_VCT_NoStorageClass(t *testing.T) { Enabled: Ptr(true), } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) if len(sts.Spec.VolumeClaimTemplates) != 1 { t.Fatalf("VolumeClaimTemplates length = %d, want 1", len(sts.Spec.VolumeClaimTemplates)) @@ -11822,7 +11956,7 @@ func TestBuildStatefulSet_VCT_NoStorageClass(t *testing.T) { func TestBuildStatefulSet_NoVCT_HPADisabled(t *testing.T) { instance := newTestInstance("no-vct") - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // No VCTs when HPA is disabled if len(sts.Spec.VolumeClaimTemplates) != 0 { @@ -11846,7 +11980,7 @@ func TestBuildStatefulSet_NoVCT_PersistenceDisabledWithHPA(t *testing.T) { } instance.Spec.Storage.Persistence.Enabled = Ptr(false) - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) // No VCTs when persistence is disabled if len(sts.Spec.VolumeClaimTemplates) != 0 { @@ -11870,7 +12004,7 @@ func TestBuildStatefulSet_VCT_DefaultSize(t *testing.T) { } // Don't set size - should default to 10Gi - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) if len(sts.Spec.VolumeClaimTemplates) != 1 { t.Fatalf("VolumeClaimTemplates length = %d, want 1", len(sts.Spec.VolumeClaimTemplates)) @@ -11887,7 +12021,7 @@ func TestBuildStatefulSet_VCT_HasLabels(t *testing.T) { Enabled: Ptr(true), } - sts := BuildStatefulSet(instance, "", nil) + sts := BuildStatefulSet(instance, "", nil, nil, nil) if len(sts.Spec.VolumeClaimTemplates) != 1 { t.Fatalf("VolumeClaimTemplates length = %d, want 1", len(sts.Spec.VolumeClaimTemplates)) @@ -11913,8 +12047,8 @@ func TestBuildStatefulSet_Idempotent_WithHPAAndPersistence(t *testing.T) { sc := "fast-ssd" instance.Spec.Storage.Persistence.StorageClass = &sc - sts1 := BuildStatefulSet(instance, "", nil) - sts2 := BuildStatefulSet(instance, "", nil) + sts1 := BuildStatefulSet(instance, "", nil, nil, nil) + sts2 := BuildStatefulSet(instance, "", nil, nil, nil) b1, _ := json.Marshal(sts1.Spec) b2, _ := json.Marshal(sts2.Spec) @@ -11923,3 +12057,227 @@ func TestBuildStatefulSet_Idempotent_WithHPAAndPersistence(t *testing.T) { t.Error("BuildStatefulSet with HPA and persistence is not idempotent - two calls with the same input produce different specs") } } + +// --------------------------------------------------------------------------- +// Additional workspaces (multi-agent support) +// --------------------------------------------------------------------------- + +func TestBuildWorkspaceConfigMap_WithAdditionalWorkspaces(t *testing.T) { + instance := newTestInstance("ws-addl") + instance.Spec.Workspace = &openclawv1alpha1.WorkspaceSpec{ + AdditionalWorkspaces: []openclawv1alpha1.AdditionalWorkspace{ + { + Name: "work", + InitialFiles: map[string]string{ + "SOUL.md": "I am the work agent", + }, + }, + }, + } + + cm := BuildWorkspaceConfigMap(instance, nil, nil, nil) + if cm == nil { + t.Fatal("expected non-nil ConfigMap") + } + + // Check namespaced key for inline file + key := AdditionalWorkspaceCMKey("work", "SOUL.md") + if cm.Data[key] != "I am the work agent" { + t.Errorf("expected work workspace SOUL.md, got %q", cm.Data[key]) + } + + // ENVIRONMENT.md should be injected for additional workspace + envKey := AdditionalWorkspaceCMKey("work", "ENVIRONMENT.md") + if cm.Data[envKey] != EnvironmentSkillContent { + t.Error("expected ENVIRONMENT.md injected for additional workspace") + } + + // BOOTSTRAP.md should NOT be injected for additional workspace + bootstrapKey := AdditionalWorkspaceCMKey("work", "BOOTSTRAP.md") + if _, ok := cm.Data[bootstrapKey]; ok { + t.Error("BOOTSTRAP.md should not be injected for additional workspaces") + } + + // Default workspace files should still be present + if cm.Data["ENVIRONMENT.md"] != EnvironmentSkillContent { + t.Error("default workspace ENVIRONMENT.md missing") + } + if cm.Data["BOOTSTRAP.md"] != BootstrapContent { + t.Error("default workspace BOOTSTRAP.md missing") + } +} + +func TestBuildWorkspaceConfigMap_AdditionalWorkspaceMergePriority(t *testing.T) { + instance := newTestInstance("ws-addl-merge") + instance.Spec.Workspace = &openclawv1alpha1.WorkspaceSpec{ + AdditionalWorkspaces: []openclawv1alpha1.AdditionalWorkspace{ + { + Name: "work", + InitialFiles: map[string]string{ + "SOUL.md": "inline wins", + }, + }, + }, + } + additionalExt := map[string]map[string]string{ + "work": { + "SOUL.md": "external loses", + "REMOTE.md": "only from external", + }, + } + + cm := BuildWorkspaceConfigMap(instance, nil, additionalExt, nil) + if cm == nil { + t.Fatal("expected non-nil ConfigMap") + } + + // Inline should override external + key := AdditionalWorkspaceCMKey("work", "SOUL.md") + if cm.Data[key] != "inline wins" { + t.Errorf("SOUL.md should be inline value, got %q", cm.Data[key]) + } + + // External-only file should still be present + remoteKey := AdditionalWorkspaceCMKey("work", "REMOTE.md") + if cm.Data[remoteKey] != "only from external" { + t.Errorf("REMOTE.md should be from external, got %q", cm.Data[remoteKey]) + } + + // ENVIRONMENT.md overrides everything (operator-injected) + envKey := AdditionalWorkspaceCMKey("work", "ENVIRONMENT.md") + if cm.Data[envKey] != EnvironmentSkillContent { + t.Error("ENVIRONMENT.md should be operator-injected content") + } +} + +func TestBuildWorkspaceConfigMap_AdditionalWorkspaceOperatorFiles(t *testing.T) { + instance := newTestInstance("ws-addl-ops") + instance.Spec.SelfConfigure.Enabled = true + instance.Spec.Workspace = &openclawv1alpha1.WorkspaceSpec{ + AdditionalWorkspaces: []openclawv1alpha1.AdditionalWorkspace{ + {Name: "research"}, + }, + } + + cm := BuildWorkspaceConfigMap(instance, nil, nil, nil) + + // Default workspace gets SELFCONFIG.md and selfconfig.sh + if _, ok := cm.Data["SELFCONFIG.md"]; !ok { + t.Error("default workspace should have SELFCONFIG.md") + } + + // Additional workspace should NOT get SELFCONFIG.md + scKey := AdditionalWorkspaceCMKey("research", "SELFCONFIG.md") + if _, ok := cm.Data[scKey]; ok { + t.Error("additional workspace should not have SELFCONFIG.md") + } + + // Additional workspace should get ENVIRONMENT.md + envKey := AdditionalWorkspaceCMKey("research", "ENVIRONMENT.md") + if cm.Data[envKey] != EnvironmentSkillContent { + t.Error("additional workspace should have ENVIRONMENT.md") + } +} + +func TestBuildInitScript_AdditionalWorkspaces(t *testing.T) { + instance := newTestInstance("ws-addl-init") + instance.Spec.Workspace = &openclawv1alpha1.WorkspaceSpec{ + AdditionalWorkspaces: []openclawv1alpha1.AdditionalWorkspace{ + { + Name: "work", + InitialFiles: map[string]string{ + "SOUL.md": "work soul", + }, + InitialDirectories: []string{"tools"}, + }, + }, + } + + script := BuildInitScript(instance, nil, nil, nil) + + // shellQuote wraps each path segment in single quotes + // Should create the workspace directory + if !strings.Contains(script, "mkdir -p /data/'workspace-work'") { + t.Errorf("init script should create workspace-work directory, got:\n%s", script) + } + + // Should create the tools subdirectory + if !strings.Contains(script, "mkdir -p /data/'workspace-work'/'tools'") { + t.Errorf("init script should create workspace-work/tools directory, got:\n%s", script) + } + + // Should copy SOUL.md using namespaced key + cmKey := AdditionalWorkspaceCMKey("work", "SOUL.md") + if !strings.Contains(script, cmKey) { + t.Errorf("init script should reference namespaced key %q, got:\n%s", cmKey, script) + } + + // Should use seed-once semantics ([ -f ... ] || cp ...) + if !strings.Contains(script, "[ -f /data/'workspace-work'/'SOUL.md' ] || cp") { + t.Errorf("init script should use seed-once for SOUL.md, got:\n%s", script) + } + + // Should also copy ENVIRONMENT.md for additional workspace + envKey := AdditionalWorkspaceCMKey("work", "ENVIRONMENT.md") + if !strings.Contains(script, envKey) { + t.Errorf("init script should reference ENVIRONMENT.md namespaced key, got:\n%s", script) + } +} + +func TestConfigHash_ChangesWithAdditionalWorkspace(t *testing.T) { + instance := newTestInstance("ws-addl-hash") + instance.Spec.Workspace = &openclawv1alpha1.WorkspaceSpec{ + AdditionalWorkspaces: []openclawv1alpha1.AdditionalWorkspace{ + {Name: "work"}, + }, + } + + sts1 := BuildStatefulSet(instance, "", nil, nil, nil) + hash1 := sts1.Spec.Template.Annotations["openclaw.rocks/config-hash"] + + // Add additional external files + additionalExt := map[string]map[string]string{ + "work": {"SOUL.md": "work soul"}, + } + sts2 := BuildStatefulSet(instance, "", nil, nil, additionalExt) + hash2 := sts2.Spec.Template.Annotations["openclaw.rocks/config-hash"] + + if hash1 == hash2 { + t.Error("config hash should change when additional workspace external files are added") + } + + // Same content should produce same hash + sts3 := BuildStatefulSet(instance, "", nil, nil, additionalExt) + hash3 := sts3.Spec.Template.Annotations["openclaw.rocks/config-hash"] + if hash2 != hash3 { + t.Error("config hash should be deterministic for same additional workspace content") + } +} + +func TestAdditionalWorkspaceCMKey_Roundtrip(t *testing.T) { + tests := []struct { + wsName string + filename string + }{ + {"work", "SOUL.md"}, + {"research", "ENVIRONMENT.md"}, + {"my-agent", "deep-nested-file.txt"}, + } + for _, tt := range tests { + key := AdditionalWorkspaceCMKey(tt.wsName, tt.filename) + gotName, gotFile, ok := ParseAdditionalWorkspaceCMKey(key) + if !ok { + t.Errorf("ParseAdditionalWorkspaceCMKey(%q) returned !ok", key) + continue + } + if gotName != tt.wsName || gotFile != tt.filename { + t.Errorf("roundtrip failed: got (%q, %q), want (%q, %q)", gotName, gotFile, tt.wsName, tt.filename) + } + } + + // Non-additional-workspace key should return false + _, _, ok := ParseAdditionalWorkspaceCMKey("SOUL.md") + if ok { + t.Error("ParseAdditionalWorkspaceCMKey should return false for non-namespaced key") + } +} diff --git a/internal/resources/skillpacks_test.go b/internal/resources/skillpacks_test.go index de3bce1e..50396ed6 100644 --- a/internal/resources/skillpacks_test.go +++ b/internal/resources/skillpacks_test.go @@ -137,7 +137,7 @@ func TestBuildInitScript_WithSkillPacks(t *testing.T) { Directories: []string{"skills/image-gen/scripts"}, } - script := BuildInitScript(instance, resolved) + script := BuildInitScript(instance, nil, nil, resolved) // Should create directories if !strings.Contains(script, "mkdir -p /data/workspace/'skills/image-gen/scripts'") { @@ -162,7 +162,7 @@ func TestBuildWorkspaceConfigMap_WithSkillPacks(t *testing.T) { }, } - cm := BuildWorkspaceConfigMap(instance, resolved) + cm := BuildWorkspaceConfigMap(instance, nil, nil, resolved) if cm == nil { t.Fatal("expected non-nil ConfigMap") } diff --git a/internal/resources/statefulset.go b/internal/resources/statefulset.go index 25d683d8..995f6f0c 100644 --- a/internal/resources/statefulset.go +++ b/internal/resources/statefulset.go @@ -37,7 +37,9 @@ import ( // BuildStatefulSet creates a StatefulSet for the OpenClawInstance. // If gatewayTokenSecretName is non-empty and the user hasn't already set // OPENCLAW_GATEWAY_TOKEN in spec.env, the env var is injected via SecretKeyRef. -func BuildStatefulSet(instance *openclawv1alpha1.OpenClawInstance, gatewayTokenSecretName string, skillPacks *ResolvedSkillPacks) *appsv1.StatefulSet { +// externalWorkspaceFiles are the resolved contents of spec.workspace.configMapRef (may be nil). +// additionalExternalFiles maps workspace name to resolved configMapRef contents (may be nil). +func BuildStatefulSet(instance *openclawv1alpha1.OpenClawInstance, gatewayTokenSecretName string, skillPacks *ResolvedSkillPacks, externalWorkspaceFiles map[string]string, additionalExternalFiles map[string]map[string]string) *appsv1.StatefulSet { labels := Labels(instance) selectorLabels := SelectorLabels(instance) @@ -67,14 +69,14 @@ func BuildStatefulSet(instance *openclawv1alpha1.OpenClawInstance, gatewayTokenS Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: labels, - Annotations: buildPodAnnotations(instance), + Annotations: buildPodAnnotations(instance, externalWorkspaceFiles, additionalExternalFiles), }, Spec: corev1.PodSpec{ ServiceAccountName: ServiceAccountName(instance), DeprecatedServiceAccount: ServiceAccountName(instance), AutomountServiceAccountToken: Ptr(instance.Spec.SelfConfigure.Enabled || instance.Spec.Tailscale.Enabled), SecurityContext: buildPodSecurityContext(instance), - InitContainers: buildInitContainers(instance, skillPacks), + InitContainers: buildInitContainers(instance, externalWorkspaceFiles, additionalExternalFiles, skillPacks), Containers: buildContainers(instance, gwSecretName), Volumes: buildVolumes(instance, skillPacks), NodeSelector: instance.Spec.Availability.NodeSelector, @@ -129,12 +131,12 @@ func BuildStatefulSet(instance *openclawv1alpha1.OpenClawInstance, gatewayTokenS } // buildPodAnnotations builds the pod annotations for the pod template -func buildPodAnnotations(instance *openclawv1alpha1.OpenClawInstance) map[string]string { +func buildPodAnnotations(instance *openclawv1alpha1.OpenClawInstance, externalWorkspaceFiles map[string]string, additionalExternalFiles map[string]map[string]string) map[string]string { annotations := make(map[string]string, len(instance.Spec.PodAnnotations)+1) for k, v := range instance.Spec.PodAnnotations { annotations[k] = v } - annotations["openclaw.rocks/config-hash"] = calculateConfigHash(instance) + annotations["openclaw.rocks/config-hash"] = calculateConfigHash(instance, externalWorkspaceFiles, additionalExternalFiles) return annotations } @@ -517,11 +519,11 @@ func hasUserEnv(instance *openclawv1alpha1.OpenClawInstance, name string) bool { // files into the data volume. Config is always overwritten (operator-managed), // while workspace files use seed-once semantics (only copied if not present). // Skills are installed via a separate init container using the OpenClaw image. -func buildInitContainers(instance *openclawv1alpha1.OpenClawInstance, skillPacks *ResolvedSkillPacks) []corev1.Container { +func buildInitContainers(instance *openclawv1alpha1.OpenClawInstance, externalWorkspaceFiles map[string]string, additionalExternalFiles map[string]map[string]string, skillPacks *ResolvedSkillPacks) []corev1.Container { var initContainers []corev1.Container // Config/workspace init container (only if there's something to do) - if script := BuildInitScript(instance, skillPacks); script != "" { + if script := BuildInitScript(instance, externalWorkspaceFiles, additionalExternalFiles, skillPacks); script != "" { mounts := []corev1.VolumeMount{ {Name: "data", MountPath: "/data"}, } @@ -653,7 +655,7 @@ func shellQuote(s string) string { // It handles config copy or merge, directory creation (idempotent), // workspace file seeding (only if not present), and skill pack file mapping. // Returns "" if there is nothing to do. -func BuildInitScript(instance *openclawv1alpha1.OpenClawInstance, skillPacks *ResolvedSkillPacks) string { +func BuildInitScript(instance *openclawv1alpha1.OpenClawInstance, externalWorkspaceFiles map[string]string, additionalExternalFiles map[string]map[string]string, skillPacks *ResolvedSkillPacks) string { var lines []string // 1. Config handling — overwrite or merge, with optional JSON5 conversion @@ -711,6 +713,10 @@ func BuildInitScript(instance *openclawv1alpha1.OpenClawInstance, skillPacks *Re hasFiles := hasWorkspaceFiles(instance, skillPacks) if hasFiles { allFiles := make(map[string]bool) + // External configMapRef files + for name := range externalWorkspaceFiles { + allFiles[name] = true + } if ws != nil { for name := range ws.InitialFiles { allFiles[name] = true @@ -752,6 +758,59 @@ func BuildInitScript(instance *openclawv1alpha1.OpenClawInstance, skillPacks *Re } } + // Additional workspaces - create dirs and seed files for each + if ws != nil { + // Sort additional workspaces for deterministic output + addlWs := make([]openclawv1alpha1.AdditionalWorkspace, len(ws.AdditionalWorkspaces)) + copy(addlWs, ws.AdditionalWorkspaces) + sort.Slice(addlWs, func(i, j int) bool { return addlWs[i].Name < addlWs[j].Name }) + + for _, aw := range addlWs { + wsDir := fmt.Sprintf("workspace-%s", aw.Name) + + // Create the workspace directory + lines = append(lines, fmt.Sprintf("mkdir -p /data/%s", shellQuote(wsDir))) + + // Create initialDirectories + dirs := make([]string, len(aw.InitialDirectories)) + copy(dirs, aw.InitialDirectories) + sort.Strings(dirs) + for _, dir := range dirs { + lines = append(lines, fmt.Sprintf("mkdir -p /data/%s/%s", shellQuote(wsDir), shellQuote(dir))) + } + + // Collect all file names for this workspace + awFiles := make(map[string]bool) + + // External configMapRef files + if extFiles, ok := additionalExternalFiles[aw.Name]; ok { + for name := range extFiles { + awFiles[name] = true + } + } + // Inline initialFiles + for name := range aw.InitialFiles { + awFiles[name] = true + } + // Operator-injected ENVIRONMENT.md + awFiles["ENVIRONMENT.md"] = true + + // Seed files (only if not present) + sorted := make([]string, 0, len(awFiles)) + for name := range awFiles { + sorted = append(sorted, name) + } + sort.Strings(sorted) + for _, name := range sorted { + cmKey := AdditionalWorkspaceCMKey(aw.Name, name) + lines = append(lines, fmt.Sprintf("[ -f /data/%s/%s ] || cp /workspace-init/%s /data/%s/%s", + shellQuote(wsDir), shellQuote(name), + shellQuote(cmKey), + shellQuote(wsDir), shellQuote(name))) + } + } + } + if len(lines) == 0 { return "" } @@ -2333,7 +2392,7 @@ func getPullPolicy(instance *openclawv1alpha1.OpenClawInstance) corev1.PullPolic // calculateConfigHash computes a hash of the config, workspace, and skills for rollout detection. // Changes to any of these trigger a pod restart. -func calculateConfigHash(instance *openclawv1alpha1.OpenClawInstance) string { +func calculateConfigHash(instance *openclawv1alpha1.OpenClawInstance, externalWorkspaceFiles map[string]string, additionalExternalFiles map[string]map[string]string) string { h := sha256.New() configData, _ := json.Marshal(instance.Spec.Config) h.Write(configData) @@ -2341,6 +2400,40 @@ func calculateConfigHash(instance *openclawv1alpha1.OpenClawInstance) string { wsData, _ := json.Marshal(instance.Spec.Workspace) h.Write(wsData) } + // Hash external workspace files content for pod restart detection + if len(externalWorkspaceFiles) > 0 { + // Sort keys for deterministic hashing + keys := make([]string, 0, len(externalWorkspaceFiles)) + for k := range externalWorkspaceFiles { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + h.Write([]byte(k)) + h.Write([]byte(externalWorkspaceFiles[k])) + } + } + // Hash additional workspace external files + if len(additionalExternalFiles) > 0 { + wsNames := make([]string, 0, len(additionalExternalFiles)) + for name := range additionalExternalFiles { + wsNames = append(wsNames, name) + } + sort.Strings(wsNames) + for _, name := range wsNames { + h.Write([]byte(name)) + files := additionalExternalFiles[name] + fKeys := make([]string, 0, len(files)) + for k := range files { + fKeys = append(fKeys, k) + } + sort.Strings(fKeys) + for _, k := range fKeys { + h.Write([]byte(k)) + h.Write([]byte(files[k])) + } + } + } if len(instance.Spec.Skills) > 0 { skillsData, _ := json.Marshal(instance.Spec.Skills) h.Write(skillsData) diff --git a/internal/resources/validation.go b/internal/resources/validation.go new file mode 100644 index 00000000..52ed2961 --- /dev/null +++ b/internal/resources/validation.go @@ -0,0 +1,71 @@ +/* +Copyright 2026 OpenClaw.rocks + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package resources + +import ( + "fmt" + "strings" +) + +// ValidateWorkspaceFilename checks a single workspace filename. +// Exported so both the webhook and the controller can validate filenames +// (e.g. keys from an external ConfigMap referenced by spec.workspace.configMapRef). +func ValidateWorkspaceFilename(name string) error { + if name == "" { + return fmt.Errorf("filename must not be empty") + } + if len(name) > 253 { + return fmt.Errorf("filename must be at most 253 characters") + } + if strings.Contains(name, "/") { + return fmt.Errorf("filename must not contain '/'") + } + if strings.Contains(name, "\\") { + return fmt.Errorf("filename must not contain '\\'") + } + if strings.Contains(name, "..") { + return fmt.Errorf("filename must not contain '..'") + } + if strings.HasPrefix(name, ".") { + return fmt.Errorf("filename must not start with '.'") + } + if name == "openclaw.json" { + return fmt.Errorf("filename 'openclaw.json' is reserved for config") + } + return nil +} + +// ValidateWorkspaceDirectory checks a single workspace directory path. +// Exported so both the webhook and the controller can validate directory names. +func ValidateWorkspaceDirectory(dir string) error { + if dir == "" { + return fmt.Errorf("directory must not be empty") + } + if len(dir) > 253 { + return fmt.Errorf("directory must be at most 253 characters") + } + if strings.Contains(dir, "\\") { + return fmt.Errorf("directory must not contain '\\'") + } + if strings.Contains(dir, "..") { + return fmt.Errorf("directory must not contain '..'") + } + if strings.HasPrefix(dir, "/") { + return fmt.Errorf("directory must not be an absolute path") + } + return nil +} diff --git a/internal/resources/workspace_configmap.go b/internal/resources/workspace_configmap.go index 203a7947..176686a2 100644 --- a/internal/resources/workspace_configmap.go +++ b/internal/resources/workspace_configmap.go @@ -17,27 +17,53 @@ limitations under the License. package resources import ( + "fmt" + "strings" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" openclawv1alpha1 "github.com/openclawrocks/k8s-operator/api/v1alpha1" ) +const additionalWorkspaceKeySep = "--ws--" + // BuildWorkspaceConfigMap creates a ConfigMap containing workspace seed files. // Returns nil if the instance has no workspace files (user-defined, operator-injected, or skill packs). // Skill pack files use ConfigMap-safe keys (/ replaced with --); the init script // maps them back to the correct workspace paths. -func BuildWorkspaceConfigMap(instance *openclawv1alpha1.OpenClawInstance, skillPacks *ResolvedSkillPacks) *corev1.ConfigMap { +// +// externalFiles are the resolved contents of spec.workspace.configMapRef (may be nil). +// additionalExternalFiles maps workspace name to resolved configMapRef contents (may be nil). +// +// Merge priority (highest wins): +// 1. Operator-injected (ENVIRONMENT.md, BOOTSTRAP.md, SELFCONFIG.md, selfconfig.sh) +// 2. Inline initialFiles +// 3. External configMapRef entries +// 4. Skill pack files +func BuildWorkspaceConfigMap(instance *openclawv1alpha1.OpenClawInstance, externalFiles map[string]string, additionalExternalFiles map[string]map[string]string, skillPacks *ResolvedSkillPacks) *corev1.ConfigMap { files := make(map[string]string) - // User-defined workspace files + // 4. Skill pack files (lowest priority, ConfigMap-safe keys) + if skillPacks != nil { + for cmKey, content := range skillPacks.Files { + files[cmKey] = content + } + } + + // 3. External configMapRef entries + for k, v := range externalFiles { + files[k] = v + } + + // 2. User-defined inline workspace files if instance.Spec.Workspace != nil { for k, v := range instance.Spec.Workspace.InitialFiles { files[k] = v } } - // Operator-injected files (always present) + // 1. Operator-injected files (highest priority - always present) files["ENVIRONMENT.md"] = EnvironmentSkillContent files["BOOTSTRAP.md"] = BootstrapContent @@ -47,10 +73,29 @@ func BuildWorkspaceConfigMap(instance *openclawv1alpha1.OpenClawInstance, skillP files["selfconfig.sh"] = SelfConfigureHelperScript } - // Skill pack files (ConfigMap-safe keys) - if skillPacks != nil { - for cmKey, content := range skillPacks.Files { - files[cmKey] = content + // Additional workspaces - each gets namespaced keys in the same ConfigMap. + // Merge priority per workspace (highest wins): + // 1. Operator-injected (ENVIRONMENT.md only - no BOOTSTRAP.md for secondary agents) + // 2. Inline initialFiles + // 3. External configMapRef entries + if instance.Spec.Workspace != nil { + for i := range instance.Spec.Workspace.AdditionalWorkspaces { + ws := &instance.Spec.Workspace.AdditionalWorkspaces[i] + + // 3. External configMapRef entries (lowest) + if extFiles, ok := additionalExternalFiles[ws.Name]; ok { + for k, v := range extFiles { + files[AdditionalWorkspaceCMKey(ws.Name, k)] = v + } + } + + // 2. Inline initialFiles (overrides external) + for k, v := range ws.InitialFiles { + files[AdditionalWorkspaceCMKey(ws.Name, k)] = v + } + + // 1. ENVIRONMENT.md only (no BOOTSTRAP.md for secondary agents) + files[AdditionalWorkspaceCMKey(ws.Name, "ENVIRONMENT.md")] = EnvironmentSkillContent } } @@ -63,3 +108,24 @@ func BuildWorkspaceConfigMap(instance *openclawv1alpha1.OpenClawInstance, skillP Data: files, } } + +// AdditionalWorkspaceCMKey returns the ConfigMap key for a file in an additional workspace. +// Uses a namespaced format: "--ws----" to avoid collisions with default workspace keys. +func AdditionalWorkspaceCMKey(workspaceName, filename string) string { + return fmt.Sprintf("%s%s--%s", additionalWorkspaceKeySep, workspaceName, filename) +} + +// ParseAdditionalWorkspaceCMKey extracts the workspace name and filename from a namespaced key. +// Returns ("", "", false) if the key is not an additional workspace key. +func ParseAdditionalWorkspaceCMKey(cmKey string) (workspaceName, filename string, ok bool) { + if !strings.HasPrefix(cmKey, additionalWorkspaceKeySep) { + return "", "", false + } + rest := cmKey[len(additionalWorkspaceKeySep):] + // Split on first "--" to get workspace name and filename + idx := strings.Index(rest, "--") + if idx < 0 { + return "", "", false + } + return rest[:idx], rest[idx+2:], true +} diff --git a/internal/webhook/openclawinstance_webhook.go b/internal/webhook/openclawinstance_webhook.go index 1ceea250..f95f0515 100644 --- a/internal/webhook/openclawinstance_webhook.go +++ b/internal/webhook/openclawinstance_webhook.go @@ -31,6 +31,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" openclawv1alpha1 "github.com/openclawrocks/k8s-operator/api/v1alpha1" + "github.com/openclawrocks/k8s-operator/internal/resources" ) const imageTagLatest = "latest" @@ -286,16 +287,48 @@ func (v *OpenClawInstanceValidator) validate(instance *openclawv1alpha1.OpenClaw // validateWorkspaceSpec validates workspace file and directory names. func validateWorkspaceSpec(ws *openclawv1alpha1.WorkspaceSpec) error { + // Validate configMapRef + if ws.ConfigMapRef != nil && ws.ConfigMapRef.Name == "" { + return fmt.Errorf("workspace configMapRef.name must not be empty") + } + for name := range ws.InitialFiles { - if err := validateWorkspaceFilename(name); err != nil { + if err := resources.ValidateWorkspaceFilename(name); err != nil { return fmt.Errorf("workspace initialFiles key %q: %w", name, err) } } for _, dir := range ws.InitialDirectories { - if err := validateWorkspaceDirectory(dir); err != nil { + if err := resources.ValidateWorkspaceDirectory(dir); err != nil { return fmt.Errorf("workspace initialDirectories entry %q: %w", dir, err) } } + + // Validate additional workspaces + seen := make(map[string]bool, len(ws.AdditionalWorkspaces)) + for i, aw := range ws.AdditionalWorkspaces { + if aw.Name == "" { + return fmt.Errorf("additionalWorkspaces[%d].name must not be empty", i) + } + if seen[aw.Name] { + return fmt.Errorf("additionalWorkspaces[%d].name %q is duplicated", i, aw.Name) + } + seen[aw.Name] = true + + if aw.ConfigMapRef != nil && aw.ConfigMapRef.Name == "" { + return fmt.Errorf("additionalWorkspaces[%d] %q configMapRef.name must not be empty", i, aw.Name) + } + for name := range aw.InitialFiles { + if err := resources.ValidateWorkspaceFilename(name); err != nil { + return fmt.Errorf("additionalWorkspaces[%d] %q initialFiles key %q: %w", i, aw.Name, name, err) + } + } + for _, dir := range aw.InitialDirectories { + if err := resources.ValidateWorkspaceDirectory(dir); err != nil { + return fmt.Errorf("additionalWorkspaces[%d] %q initialDirectories entry %q: %w", i, aw.Name, dir, err) + } + } + } + return nil } @@ -405,51 +438,8 @@ func validateResourceQuantities(instance *openclawv1alpha1.OpenClawInstance) err return nil } -// validateWorkspaceFilename checks a single workspace filename. -func validateWorkspaceFilename(name string) error { - if name == "" { - return fmt.Errorf("filename must not be empty") - } - if len(name) > 253 { - return fmt.Errorf("filename must be at most 253 characters") - } - if strings.Contains(name, "/") { - return fmt.Errorf("filename must not contain '/'") - } - if strings.Contains(name, "\\") { - return fmt.Errorf("filename must not contain '\\'") - } - if strings.Contains(name, "..") { - return fmt.Errorf("filename must not contain '..'") - } - if strings.HasPrefix(name, ".") { - return fmt.Errorf("filename must not start with '.'") - } - if name == "openclaw.json" { - return fmt.Errorf("filename 'openclaw.json' is reserved for config") - } - return nil -} - -// validateWorkspaceDirectory checks a single workspace directory path. -func validateWorkspaceDirectory(dir string) error { - if dir == "" { - return fmt.Errorf("directory must not be empty") - } - if len(dir) > 253 { - return fmt.Errorf("directory must be at most 253 characters") - } - if strings.Contains(dir, "\\") { - return fmt.Errorf("directory must not contain '\\'") - } - if strings.Contains(dir, "..") { - return fmt.Errorf("directory must not contain '..'") - } - if strings.HasPrefix(dir, "/") { - return fmt.Errorf("directory must not be an absolute path") - } - return nil -} +// validateWorkspaceFilename and validateWorkspaceDirectory are in +// internal/resources/validation.go (exported for use by both webhook and controller). // validateSkillName checks a single skill identifier. // Entries may use the "npm:" prefix to install npm packages instead of ClawHub diff --git a/internal/webhook/openclawinstance_webhook_test.go b/internal/webhook/openclawinstance_webhook_test.go index 541a71c3..8407c15d 100644 --- a/internal/webhook/openclawinstance_webhook_test.go +++ b/internal/webhook/openclawinstance_webhook_test.go @@ -1093,6 +1093,39 @@ func TestValidateCreate_WorkspaceNestedDirAllowed(t *testing.T) { } } +func TestValidateCreate_WorkspaceConfigMapRefValid(t *testing.T) { + v := &OpenClawInstanceValidator{} + instance := newTestInstance() + instance.Spec.Workspace = &openclawv1alpha1.WorkspaceSpec{ + ConfigMapRef: &openclawv1alpha1.ConfigMapNameSelector{ + Name: "my-workspace-cm", + }, + } + + _, err := v.ValidateCreate(context.Background(), instance) + if err != nil { + t.Fatalf("expected no error for valid configMapRef, got: %v", err) + } +} + +func TestValidateCreate_WorkspaceConfigMapRefEmptyName(t *testing.T) { + v := &OpenClawInstanceValidator{} + instance := newTestInstance() + instance.Spec.Workspace = &openclawv1alpha1.WorkspaceSpec{ + ConfigMapRef: &openclawv1alpha1.ConfigMapNameSelector{ + Name: "", + }, + } + + _, err := v.ValidateCreate(context.Background(), instance) + if err == nil { + t.Fatal("expected error for empty configMapRef.name") + } + if !strings.Contains(err.Error(), "configMapRef.name must not be empty") { + t.Errorf("unexpected error message: %v", err) + } +} + // --------------------------------------------------------------------------- // CA bundle validation tests // --------------------------------------------------------------------------- @@ -1575,3 +1608,120 @@ func TestValidateCreate_NoWarnWebTerminalDisabled(t *testing.T) { t.Fatalf("expected no web terminal warning when disabled, got: %v", warnings) } } + +// --------------------------------------------------------------------------- +// Additional workspaces validation tests +// --------------------------------------------------------------------------- + +func TestValidateCreate_AdditionalWorkspaceValid(t *testing.T) { + v := &OpenClawInstanceValidator{} + instance := newTestInstance() + instance.Spec.Workspace = &openclawv1alpha1.WorkspaceSpec{ + AdditionalWorkspaces: []openclawv1alpha1.AdditionalWorkspace{ + { + Name: "work", + ConfigMapRef: &openclawv1alpha1.ConfigMapNameSelector{ + Name: "work-files", + }, + InitialFiles: map[string]string{ + "SOUL.md": "work soul", + }, + InitialDirectories: []string{"tools"}, + }, + { + Name: "research", + InitialFiles: map[string]string{ + "AGENT.md": "research agent", + }, + }, + }, + } + + _, err := v.ValidateCreate(context.Background(), instance) + if err != nil { + t.Fatalf("expected no error for valid additional workspaces, got: %v", err) + } +} + +func TestValidateCreate_AdditionalWorkspaceDuplicateName(t *testing.T) { + v := &OpenClawInstanceValidator{} + instance := newTestInstance() + instance.Spec.Workspace = &openclawv1alpha1.WorkspaceSpec{ + AdditionalWorkspaces: []openclawv1alpha1.AdditionalWorkspace{ + {Name: "work"}, + {Name: "work"}, + }, + } + + _, err := v.ValidateCreate(context.Background(), instance) + if err == nil { + t.Fatal("expected error for duplicate additional workspace names") + } + if !strings.Contains(err.Error(), "duplicated") { + t.Errorf("expected duplicate error, got: %v", err) + } +} + +func TestValidateCreate_AdditionalWorkspaceEmptyName(t *testing.T) { + v := &OpenClawInstanceValidator{} + instance := newTestInstance() + instance.Spec.Workspace = &openclawv1alpha1.WorkspaceSpec{ + AdditionalWorkspaces: []openclawv1alpha1.AdditionalWorkspace{ + {Name: ""}, + }, + } + + _, err := v.ValidateCreate(context.Background(), instance) + if err == nil { + t.Fatal("expected error for empty additional workspace name") + } + if !strings.Contains(err.Error(), "must not be empty") { + t.Errorf("expected empty name error, got: %v", err) + } +} + +func TestValidateCreate_AdditionalWorkspaceInvalidFilename(t *testing.T) { + v := &OpenClawInstanceValidator{} + instance := newTestInstance() + instance.Spec.Workspace = &openclawv1alpha1.WorkspaceSpec{ + AdditionalWorkspaces: []openclawv1alpha1.AdditionalWorkspace{ + { + Name: "work", + InitialFiles: map[string]string{ + "../evil.md": "path traversal", + }, + }, + }, + } + + _, err := v.ValidateCreate(context.Background(), instance) + if err == nil { + t.Fatal("expected error for invalid filename in additional workspace") + } + if !strings.Contains(err.Error(), "initialFiles") { + t.Errorf("expected initialFiles error, got: %v", err) + } +} + +func TestValidateCreate_AdditionalWorkspaceConfigMapRefEmptyName(t *testing.T) { + v := &OpenClawInstanceValidator{} + instance := newTestInstance() + instance.Spec.Workspace = &openclawv1alpha1.WorkspaceSpec{ + AdditionalWorkspaces: []openclawv1alpha1.AdditionalWorkspace{ + { + Name: "work", + ConfigMapRef: &openclawv1alpha1.ConfigMapNameSelector{ + Name: "", + }, + }, + }, + } + + _, err := v.ValidateCreate(context.Background(), instance) + if err == nil { + t.Fatal("expected error for empty configMapRef.name in additional workspace") + } + if !strings.Contains(err.Error(), "configMapRef.name must not be empty") { + t.Errorf("expected configMapRef error, got: %v", err) + } +} diff --git a/test/e2e/e2e_workspace_additional_test.go b/test/e2e/e2e_workspace_additional_test.go new file mode 100644 index 00000000..a29059f8 --- /dev/null +++ b/test/e2e/e2e_workspace_additional_test.go @@ -0,0 +1,227 @@ +/* +Copyright 2026 OpenClaw.rocks + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "os" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + openclawv1alpha1 "github.com/openclawrocks/k8s-operator/api/v1alpha1" + "github.com/openclawrocks/k8s-operator/internal/resources" +) + +var _ = Describe("Additional Workspaces", func() { + Context("When creating an instance with additionalWorkspaces", func() { + var namespace string + + BeforeEach(func() { + namespace = "test-ws-addl-" + time.Now().Format("20060102150405") + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}} + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + }) + + AfterEach(func() { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}} + _ = k8sClient.Delete(ctx, ns) + }) + + It("Should create namespaced keys in the workspace ConfigMap and init script", func() { + if os.Getenv("E2E_SKIP_RESOURCE_VALIDATION") == "true" { + Skip("Skipping resource validation in minimal mode") + } + + // 1. Create external ConfigMap for the "work" agent workspace + externalCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "work-agent-files", + Namespace: namespace, + }, + Data: map[string]string{ + "SOUL.md": "# Work agent soul from ConfigMap", + }, + } + Expect(k8sClient.Create(ctx, externalCM)).Should(Succeed()) + + // 2. Create instance with additionalWorkspaces + instanceName := "ws-addl-test" + instance := &openclawv1alpha1.OpenClawInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: instanceName, + Namespace: namespace, + Annotations: map[string]string{ + "openclaw.rocks/skip-backup": "true", + }, + }, + Spec: openclawv1alpha1.OpenClawInstanceSpec{ + Image: openclawv1alpha1.ImageSpec{ + Repository: "ghcr.io/openclaw/openclaw", + Tag: "latest", + }, + Workspace: &openclawv1alpha1.WorkspaceSpec{ + AdditionalWorkspaces: []openclawv1alpha1.AdditionalWorkspace{ + { + Name: "work", + ConfigMapRef: &openclawv1alpha1.ConfigMapNameSelector{ + Name: "work-agent-files", + }, + InitialFiles: map[string]string{ + "AGENT.md": "# Inline work agent file", + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, instance)).Should(Succeed()) + + // 3. Wait for StatefulSet + statefulSet := &appsv1.StatefulSet{} + Eventually(func() error { + return k8sClient.Get(ctx, types.NamespacedName{ + Name: resources.StatefulSetName(instance), + Namespace: namespace, + }, statefulSet) + }, 60*time.Second, 2*time.Second).Should(Succeed()) + + // 4. Verify operator-managed workspace ConfigMap has namespaced keys + workspaceCM := &corev1.ConfigMap{} + Eventually(func() error { + return k8sClient.Get(ctx, types.NamespacedName{ + Name: resources.WorkspaceConfigMapName(instance), + Namespace: namespace, + }, workspaceCM) + }, 30*time.Second, 2*time.Second).Should(Succeed()) + + // External file should be present with namespaced key + soulKey := resources.AdditionalWorkspaceCMKey("work", "SOUL.md") + Expect(workspaceCM.Data).To(HaveKey(soulKey), + "workspace ConfigMap should contain namespaced SOUL.md from external ConfigMap") + Expect(workspaceCM.Data[soulKey]).To(Equal("# Work agent soul from ConfigMap")) + + // Inline file should be present with namespaced key + agentKey := resources.AdditionalWorkspaceCMKey("work", "AGENT.md") + Expect(workspaceCM.Data).To(HaveKey(agentKey), + "workspace ConfigMap should contain namespaced AGENT.md from inline initialFiles") + Expect(workspaceCM.Data[agentKey]).To(Equal("# Inline work agent file")) + + // ENVIRONMENT.md should be injected for additional workspace + envKey := resources.AdditionalWorkspaceCMKey("work", "ENVIRONMENT.md") + Expect(workspaceCM.Data).To(HaveKey(envKey), + "workspace ConfigMap should contain ENVIRONMENT.md for additional workspace") + + // Default workspace operator files should also be present + Expect(workspaceCM.Data).To(HaveKey("ENVIRONMENT.md")) + Expect(workspaceCM.Data).To(HaveKey("BOOTSTRAP.md")) + + // 5. Verify init script has mkdir and copy commands for additional workspace + var initConfig *corev1.Container + for i := range statefulSet.Spec.Template.Spec.InitContainers { + if statefulSet.Spec.Template.Spec.InitContainers[i].Name == "init-config" { + initConfig = &statefulSet.Spec.Template.Spec.InitContainers[i] + break + } + } + Expect(initConfig).NotTo(BeNil(), "init-config container should exist") + script := initConfig.Command[2] + Expect(script).To(ContainSubstring("workspace-work"), + "init script should reference workspace-work directory") + Expect(script).To(ContainSubstring(soulKey), + "init script should reference namespaced SOUL.md key") + + // 6. Verify WorkspaceReady condition is True + updatedInstance := &openclawv1alpha1.OpenClawInstance{} + Eventually(func() bool { + if err := k8sClient.Get(ctx, types.NamespacedName{ + Name: instanceName, + Namespace: namespace, + }, updatedInstance); err != nil { + return false + } + for _, c := range updatedInstance.Status.Conditions { + if c.Type == openclawv1alpha1.ConditionTypeWorkspaceReady { + return c.Status == metav1.ConditionTrue + } + } + return false + }, 30*time.Second, 2*time.Second).Should(BeTrue(), + "WorkspaceReady condition should be True") + + Expect(k8sClient.Delete(ctx, instance)).Should(Succeed()) + }) + + It("Should set WorkspaceReady=False when additional workspace ConfigMap is missing", func() { + if os.Getenv("E2E_SKIP_RESOURCE_VALIDATION") == "true" { + Skip("Skipping resource validation in minimal mode") + } + + instanceName := "ws-addl-missing" + instance := &openclawv1alpha1.OpenClawInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: instanceName, + Namespace: namespace, + Annotations: map[string]string{ + "openclaw.rocks/skip-backup": "true", + }, + }, + Spec: openclawv1alpha1.OpenClawInstanceSpec{ + Image: openclawv1alpha1.ImageSpec{ + Repository: "ghcr.io/openclaw/openclaw", + Tag: "latest", + }, + Workspace: &openclawv1alpha1.WorkspaceSpec{ + AdditionalWorkspaces: []openclawv1alpha1.AdditionalWorkspace{ + { + Name: "work", + ConfigMapRef: &openclawv1alpha1.ConfigMapNameSelector{ + Name: "nonexistent-work-cm", + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, instance)).Should(Succeed()) + + // Verify WorkspaceReady condition is False + updatedInstance := &openclawv1alpha1.OpenClawInstance{} + Eventually(func() bool { + if err := k8sClient.Get(ctx, types.NamespacedName{ + Name: instanceName, + Namespace: namespace, + }, updatedInstance); err != nil { + return false + } + for _, c := range updatedInstance.Status.Conditions { + if c.Type == openclawv1alpha1.ConditionTypeWorkspaceReady { + return c.Status == metav1.ConditionFalse && c.Reason == "ConfigMapNotFound" + } + } + return false + }, 30*time.Second, 2*time.Second).Should(BeTrue(), + "WorkspaceReady condition should be False with reason ConfigMapNotFound") + + Expect(k8sClient.Delete(ctx, instance)).Should(Succeed()) + }) + }) +}) diff --git a/test/e2e/e2e_workspace_configmapref_test.go b/test/e2e/e2e_workspace_configmapref_test.go new file mode 100644 index 00000000..3083cbb2 --- /dev/null +++ b/test/e2e/e2e_workspace_configmapref_test.go @@ -0,0 +1,219 @@ +/* +Copyright 2026 OpenClaw.rocks + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "os" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + openclawv1alpha1 "github.com/openclawrocks/k8s-operator/api/v1alpha1" + "github.com/openclawrocks/k8s-operator/internal/resources" +) + +var _ = Describe("Workspace ConfigMapRef", func() { + Context("When creating an instance with spec.workspace.configMapRef", func() { + var namespace string + + BeforeEach(func() { + namespace = "test-ws-cmref-" + time.Now().Format("20060102150405") + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}} + Expect(k8sClient.Create(ctx, ns)).Should(Succeed()) + }) + + AfterEach(func() { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}} + _ = k8sClient.Delete(ctx, ns) + }) + + It("Should merge external ConfigMap files into the workspace", func() { + if os.Getenv("E2E_SKIP_RESOURCE_VALIDATION") == "true" { + Skip("Skipping resource validation in minimal mode") + } + + // 1. Create the external ConfigMap with workspace files + externalCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "agent-workspace", + Namespace: namespace, + }, + Data: map[string]string{ + "SOUL.md": "# Soul from external ConfigMap", + "AGENT.md": "# Agent from external ConfigMap", + }, + } + Expect(k8sClient.Create(ctx, externalCM)).Should(Succeed()) + + // 2. Create the instance referencing the external ConfigMap + instanceName := "ws-cmref-test" + instance := &openclawv1alpha1.OpenClawInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: instanceName, + Namespace: namespace, + Annotations: map[string]string{ + "openclaw.rocks/skip-backup": "true", + }, + }, + Spec: openclawv1alpha1.OpenClawInstanceSpec{ + Image: openclawv1alpha1.ImageSpec{ + Repository: "ghcr.io/openclaw/openclaw", + Tag: "latest", + }, + Workspace: &openclawv1alpha1.WorkspaceSpec{ + ConfigMapRef: &openclawv1alpha1.ConfigMapNameSelector{ + Name: "agent-workspace", + }, + InitialFiles: map[string]string{ + "EXTRA.md": "# Inline file", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, instance)).Should(Succeed()) + + // 3. Wait for the StatefulSet to be created + statefulSet := &appsv1.StatefulSet{} + Eventually(func() error { + return k8sClient.Get(ctx, types.NamespacedName{ + Name: resources.StatefulSetName(instance), + Namespace: namespace, + }, statefulSet) + }, 60*time.Second, 2*time.Second).Should(Succeed()) + + // 4. Verify the operator-managed workspace ConfigMap contains merged content + workspaceCM := &corev1.ConfigMap{} + Eventually(func() error { + return k8sClient.Get(ctx, types.NamespacedName{ + Name: resources.WorkspaceConfigMapName(instance), + Namespace: namespace, + }, workspaceCM) + }, 30*time.Second, 2*time.Second).Should(Succeed()) + + // External files should be present + Expect(workspaceCM.Data).To(HaveKey("SOUL.md"), + "workspace ConfigMap should contain SOUL.md from external ConfigMap") + Expect(workspaceCM.Data["SOUL.md"]).To(Equal("# Soul from external ConfigMap")) + + Expect(workspaceCM.Data).To(HaveKey("AGENT.md"), + "workspace ConfigMap should contain AGENT.md from external ConfigMap") + Expect(workspaceCM.Data["AGENT.md"]).To(Equal("# Agent from external ConfigMap")) + + // Inline file should be present + Expect(workspaceCM.Data).To(HaveKey("EXTRA.md"), + "workspace ConfigMap should contain EXTRA.md from inline initialFiles") + Expect(workspaceCM.Data["EXTRA.md"]).To(Equal("# Inline file")) + + // Operator-injected files should always be present + Expect(workspaceCM.Data).To(HaveKey("ENVIRONMENT.md"), + "workspace ConfigMap should always contain ENVIRONMENT.md") + Expect(workspaceCM.Data).To(HaveKey("BOOTSTRAP.md"), + "workspace ConfigMap should always contain BOOTSTRAP.md") + + // 5. Verify init container script has copy commands for external files + var initConfig *corev1.Container + for i := range statefulSet.Spec.Template.Spec.InitContainers { + if statefulSet.Spec.Template.Spec.InitContainers[i].Name == "init-config" { + initConfig = &statefulSet.Spec.Template.Spec.InitContainers[i] + break + } + } + Expect(initConfig).NotTo(BeNil(), "init-config container should exist") + script := initConfig.Command[2] + Expect(script).To(ContainSubstring("SOUL.md"), + "init script should reference SOUL.md from external ConfigMap") + Expect(script).To(ContainSubstring("AGENT.md"), + "init script should reference AGENT.md from external ConfigMap") + Expect(script).To(ContainSubstring("EXTRA.md"), + "init script should reference EXTRA.md from inline files") + + // 6. Verify WorkspaceReady condition is True + updatedInstance := &openclawv1alpha1.OpenClawInstance{} + Eventually(func() bool { + if err := k8sClient.Get(ctx, types.NamespacedName{ + Name: instanceName, + Namespace: namespace, + }, updatedInstance); err != nil { + return false + } + for _, c := range updatedInstance.Status.Conditions { + if c.Type == openclawv1alpha1.ConditionTypeWorkspaceReady { + return c.Status == metav1.ConditionTrue + } + } + return false + }, 30*time.Second, 2*time.Second).Should(BeTrue(), + "WorkspaceReady condition should be True") + + Expect(k8sClient.Delete(ctx, instance)).Should(Succeed()) + }) + + It("Should set WorkspaceReady=False when ConfigMap is missing", func() { + if os.Getenv("E2E_SKIP_RESOURCE_VALIDATION") == "true" { + Skip("Skipping resource validation in minimal mode") + } + + instanceName := "ws-cmref-missing" + instance := &openclawv1alpha1.OpenClawInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: instanceName, + Namespace: namespace, + Annotations: map[string]string{ + "openclaw.rocks/skip-backup": "true", + }, + }, + Spec: openclawv1alpha1.OpenClawInstanceSpec{ + Image: openclawv1alpha1.ImageSpec{ + Repository: "ghcr.io/openclaw/openclaw", + Tag: "latest", + }, + Workspace: &openclawv1alpha1.WorkspaceSpec{ + ConfigMapRef: &openclawv1alpha1.ConfigMapNameSelector{ + Name: "nonexistent-cm", + }, + }, + }, + } + Expect(k8sClient.Create(ctx, instance)).Should(Succeed()) + + // Verify WorkspaceReady condition is False + updatedInstance := &openclawv1alpha1.OpenClawInstance{} + Eventually(func() bool { + if err := k8sClient.Get(ctx, types.NamespacedName{ + Name: instanceName, + Namespace: namespace, + }, updatedInstance); err != nil { + return false + } + for _, c := range updatedInstance.Status.Conditions { + if c.Type == openclawv1alpha1.ConditionTypeWorkspaceReady { + return c.Status == metav1.ConditionFalse && c.Reason == "ConfigMapNotFound" + } + } + return false + }, 30*time.Second, 2*time.Second).Should(BeTrue(), + "WorkspaceReady condition should be False with reason ConfigMapNotFound") + + Expect(k8sClient.Delete(ctx, instance)).Should(Succeed()) + }) + }) +})