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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 105 additions & 4 deletions controllers/browser/browser_reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package browser

import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}
165 changes: 160 additions & 5 deletions controllers/browser/browser_reconciler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"reflect"
"strings"
"testing"
"time"
"unsafe"
Expand Down Expand Up @@ -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}}
Expand Down Expand Up @@ -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")
}
Expand All @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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")
}
Expand Down