diff --git a/controllers/browser/browser_reconciler.go b/controllers/browser/browser_reconciler.go index 140bc54..a0d4541 100644 --- a/controllers/browser/browser_reconciler.go +++ b/controllers/browser/browser_reconciler.go @@ -2,6 +2,7 @@ package browser import ( "context" + "encoding/json" "fmt" "strconv" "time" @@ -32,8 +33,19 @@ const ( browserContainerName = "browser" sidecarContainerName = "seleniferous" + + selenosisOptionsAnnotationKey = "selenosis.io/options" ) +type SelenosisOptions struct { + Labels map[string]string `json:"labels,omitempty"` + Containers map[string]ContainerOption `json:"containers,omitempty"` +} + +type ContainerOption struct { + Env map[string]string `json:"env,omitempty"` +} + // +kubebuilder:rbac:groups=selenosis.io,resources=browsers,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=selenosis.io,resources=browsers/status,verbs=get;update;patch // +kubebuilder:rbac:groups=selenosis.io,resources=browsers/finalizers,verbs=update @@ -360,8 +372,26 @@ func (r *BrowserReconciler) handleMissingPod(ctx context.Context, browser *brows return ctrl.Result{}, nil } + opts, err := parseSelenosisOptions(browser.Annotations) + if err != nil { + log.Error(err, "invalid selenosis options JSON") + if err := r.retryStatusUpdate(ctx, browser, func(b *browserv1.Browser) { + b.Status.Phase = corev1.PodFailed + b.Status.Reason = "InvalidSelenosisOptions" + b.Status.Message = err.Error() + }); err != nil { + log.Error(err, "Failed to update Browser status") + return ctrl.Result{}, err + } + + log.Info("Invalid selenosis options") + return ctrl.Result{}, nil + } + + log.Info("parsed selenosis options", "hasOptions", opts != nil) + // Create pod from template - if err := r.createPod(ctx, browser, browserSpec); err != nil { + if err := r.createPod(ctx, browser, browserSpec, opts); err != nil { if errors.IsAlreadyExists(err) { log.Info("Browser Pod already exists, will reconcile on next iteration") return ctrl.Result{RequeueAfter: quickCheck}, nil @@ -375,10 +405,10 @@ func (r *BrowserReconciler) handleMissingPod(ctx context.Context, browser *brows } // createPod creates a Pod for Browser with optimized memory usage -func (r *BrowserReconciler) createPod(ctx context.Context, browser *browserv1.Browser, browserSpec *configv1.BrowserVersionConfigSpec) error { +func (r *BrowserReconciler) createPod(ctx context.Context, browser *browserv1.Browser, browserSpec *configv1.BrowserVersionConfigSpec, opts *SelenosisOptions) error { log := logger.FromContext(ctx) - pod := buildBrowserPod(browser, browserSpec) + pod := buildBrowserPod(browser, browserSpec, opts) log.Info("BrowserPodSpec configuration applied") return r.client.Create(ctx, pod) @@ -615,7 +645,7 @@ func (r *BrowserReconciler) deleteBrowser(ctx context.Context, browser *browserv return ctrl.Result{}, nil } -func buildBrowserPod(browser *browserv1.Browser, cfg *configv1.BrowserVersionConfigSpec) *corev1.Pod { +func buildBrowserPod(browser *browserv1.Browser, cfg *configv1.BrowserVersionConfigSpec, opts *SelenosisOptions) *corev1.Pod { pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: browser.GetName(), @@ -778,6 +808,9 @@ func buildBrowserPod(browser *browserv1.Browser, cfg *configv1.BrowserVersionCon pod.Annotations = map[string]string{} } for k, v := range browser.Annotations { + if k == selenosisOptionsAnnotationKey { + continue + } pod.Annotations[k] = v } } @@ -818,6 +851,8 @@ func buildBrowserPod(browser *browserv1.Browser, cfg *configv1.BrowserVersionCon pod.Spec.Hostname = browser.GetName() pod.Spec.RestartPolicy = corev1.RestartPolicyNever + applySelenosisOptions(pod, opts) + return pod } @@ -827,3 +862,69 @@ func lenSidecars(cfg *configv1.BrowserVersionConfigSpec) int { } return 0 } + +func parseSelenosisOptions(ann map[string]string) (*SelenosisOptions, error) { + if ann == nil { + return nil, nil + } + raw := ann[selenosisOptionsAnnotationKey] + if raw == "" { + return nil, nil + } + + var opts SelenosisOptions + if err := json.Unmarshal([]byte(raw), &opts); err != nil { + return nil, fmt.Errorf("unmarshal %s: %w", selenosisOptionsAnnotationKey, err) + } + return &opts, nil +} + +func applySelenosisOptions(pod *corev1.Pod, opts *SelenosisOptions) { + if pod == nil || opts == nil { + return + } + + if len(opts.Containers) > 0 { + for i := range pod.Spec.Containers { + name := pod.Spec.Containers[i].Name + option, ok := opts.Containers[name] + if !ok || len(option.Env) == 0 { + continue + } + pod.Spec.Containers[i].Env = mergeEnvVars(pod.Spec.Containers[i].Env, option.Env) + } + } + + if opts.Labels != nil { + if pod.Labels == nil { + pod.Labels = map[string]string{} + } + for k, v := range opts.Labels { + pod.Labels[k] = v + } + } +} + +func mergeEnvVars(base []corev1.EnvVar, override map[string]string) []corev1.EnvVar { + if len(override) == 0 { + return base + } + + idx := make(map[string]int, len(base)) + out := append([]corev1.EnvVar(nil), base...) + for i := range out { + idx[out[i].Name] = i + } + + for k, v := range override { + ev := corev1.EnvVar{Name: k, Value: v} + if pos, ok := idx[k]; ok { + out[pos] = ev + } else { + idx[k] = len(out) + out = append(out, ev) + } + } + + return out +} diff --git a/controllers/browser/browser_reconciler_test.go b/controllers/browser/browser_reconciler_test.go index 283e717..bbc9822 100644 --- a/controllers/browser/browser_reconciler_test.go +++ b/controllers/browser/browser_reconciler_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "reflect" + "strings" "testing" "time" "unsafe" @@ -55,6 +56,15 @@ func setStoreConfig(t *testing.T, cfgStore *store.BrowserConfigStore, key string m[key] = spec } +func envValue(env []corev1.EnvVar, key string) (string, bool) { + for _, item := range env { + if item.Name == key { + return item.Value, true + } + } + return "", false +} + func TestContainerStateEqual(t *testing.T) { now := metav1.NewTime(time.Now().UTC()) a := corev1.ContainerState{Running: &corev1.ContainerStateRunning{StartedAt: now}} @@ -149,7 +159,7 @@ func TestBuildBrowserPod(t *testing.T) { }, } - pod := buildBrowserPod(brw, cfg) + pod := buildBrowserPod(brw, cfg, nil) if pod.Name != "b1" || pod.Namespace != "ns" { t.Fatalf("unexpected pod identity") } @@ -167,6 +177,96 @@ func TestBuildBrowserPod(t *testing.T) { } } +func TestParseSelenosisOptionsInvalidJSON(t *testing.T) { + ann := map[string]string{ + selenosisOptionsAnnotationKey: "{nope", + } + _, err := parseSelenosisOptions(ann) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), selenosisOptionsAnnotationKey) { + t.Fatalf("expected error to mention annotation key, got %v", err) + } +} + +func TestParseSelenosisOptionsEmpty(t *testing.T) { + opts, err := parseSelenosisOptions(nil) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if opts != nil { + t.Fatalf("expected nil options for nil annotations") + } + + opts, err = parseSelenosisOptions(map[string]string{selenosisOptionsAnnotationKey: ""}) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if opts != nil { + t.Fatalf("expected nil options for empty annotation") + } +} + +func TestParseSelenosisOptionsValidJSON(t *testing.T) { + ann := map[string]string{ + selenosisOptionsAnnotationKey: `{"labels":{"a":"b"},"containers":{"browser":{"env":{"X":"1"}}}}`, + } + opts, err := parseSelenosisOptions(ann) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if opts == nil || opts.Labels["a"] != "b" { + t.Fatalf("expected labels to be parsed") + } + if opts.Containers["browser"].Env["X"] != "1" { + t.Fatalf("expected container env to be parsed") + } +} + +func TestApplySelenosisOptionsMergesEnvAndLabels(t *testing.T) { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"existing": "1"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "browser", + Env: []corev1.EnvVar{ + {Name: "A", Value: "1"}, + {Name: "B", Value: "2"}, + }, + }, + {Name: "sidecar"}, + }, + }, + } + opts := &SelenosisOptions{ + Labels: map[string]string{"from": "options"}, + Containers: map[string]ContainerOption{ + "browser": {Env: map[string]string{"B": "override", "C": "new"}}, + }, + } + + applySelenosisOptions(pod, opts) + + if pod.Labels["existing"] != "1" || pod.Labels["from"] != "options" { + t.Fatalf("expected labels to be merged, got %+v", pod.Labels) + } + + env := pod.Spec.Containers[0].Env + if val, ok := envValue(env, "A"); !ok || val != "1" { + t.Fatalf("expected env A=1") + } + if val, ok := envValue(env, "B"); !ok || val != "override" { + t.Fatalf("expected env B override") + } + if val, ok := envValue(env, "C"); !ok || val != "new" { + t.Fatalf("expected env C new") + } +} + func TestHandleMissingPodConfigNotFound(t *testing.T) { scheme := newBrowserScheme(t) cfgStore := store.NewBrowserConfigStore() @@ -264,6 +364,61 @@ func TestHandleMissingPodCreatesPod(t *testing.T) { } } +func TestHandleMissingPodInvalidSelenosisOptions(t *testing.T) { + scheme := newBrowserScheme(t) + cfgStore := store.NewBrowserConfigStore() + spec := &configv1.BrowserVersionConfigSpec{Image: "img"} + setStoreConfig(t, cfgStore, "ns/chrome:120", spec) + + cl := newBrowserClient(scheme) + r := NewBrowserReconciler(cl, cfgStore, scheme) + + brw := &browserv1.Browser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "b1", + Namespace: "ns", + Annotations: map[string]string{ + selenosisOptionsAnnotationKey: "{bad-json", + }, + }, + Spec: browserv1.BrowserSpec{ + BrowserName: "chrome", + BrowserVersion: "120", + }, + } + if err := cl.Create(context.Background(), brw); err != nil { + t.Fatalf("create browser: %v", err) + } + + res, err := r.handleMissingPod(context.Background(), brw) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if res.RequeueAfter != 0 { + t.Fatalf("expected no requeue, got %v", res.RequeueAfter) + } + + updated := &browserv1.Browser{} + if err := cl.Get(context.Background(), client.ObjectKey{Name: "b1", Namespace: "ns"}, updated); err != nil { + t.Fatalf("get browser: %v", err) + } + if updated.Status.Phase != corev1.PodFailed { + t.Fatalf("expected failed status, got %s", updated.Status.Phase) + } + if updated.Status.Reason != "InvalidSelenosisOptions" { + t.Fatalf("expected reason InvalidSelenosisOptions, got %s", updated.Status.Reason) + } + + pod := &corev1.Pod{} + err = cl.Get(context.Background(), client.ObjectKey{Name: "b1", Namespace: "ns"}, pod) + if err == nil { + t.Fatalf("expected no pod to be created") + } + if !apierrors.IsNotFound(err) { + t.Fatalf("expected not found error, got %v", err) + } +} + func TestUpdateBrowserStatusCriticalContainer(t *testing.T) { scheme := newBrowserScheme(t) cl := newBrowserClient(scheme) @@ -852,7 +1007,7 @@ func TestBuildBrowserPodWithInitContainersAndVolumes(t *testing.T) { }, } - pod := buildBrowserPod(brw, cfg) + pod := buildBrowserPod(brw, cfg, nil) if len(pod.Spec.InitContainers) != 1 { t.Fatalf("expected init container") } @@ -893,7 +1048,7 @@ func TestBuildBrowserPodInitContainerFields(t *testing.T) { }, } - pod := buildBrowserPod(brw, cfg) + pod := buildBrowserPod(brw, cfg, nil) if len(pod.Spec.InitContainers) != 1 { t.Fatalf("expected init container") } @@ -962,7 +1117,7 @@ func TestBuildBrowserPodAllFields(t *testing.T) { }, } - pod := buildBrowserPod(brw, cfg) + pod := buildBrowserPod(brw, cfg, nil) if pod.Spec.NodeSelector["k"] != "v" { t.Fatalf("expected node selector") } @@ -995,7 +1150,7 @@ func TestBuildBrowserPodBrowserLabelsOnly(t *testing.T) { }, } - pod := buildBrowserPod(brw, cfg) + pod := buildBrowserPod(brw, cfg, nil) if pod.Labels["only"] != "browser" { t.Fatalf("expected browser labels to be applied") }