diff --git a/.goreleaser.yml b/.goreleaser.yml index 862a0871d..1450fd821 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -21,7 +21,7 @@ builds: flags: - -trimpath mod_timestamp: '{{ .CommitTimestamp }}' - ldflags: + ldflags: &ldflags - >- -X github.com/projectcapsule/capsule/internal/version.Version={{ .Tag }} -X github.com/projectcapsule/capsule/internal/version.GitCommit={{ .Commit }} @@ -29,8 +29,24 @@ builds: -X github.com/projectcapsule/capsule/internal/version.GitDirty={{ .Date }} -X github.com/projectcapsule/capsule/internal/version.BuildTime={{ .Date }} -X github.com/projectcapsule/capsule/internal/version.GitRepo={{ .ProjectName }} + - id: "{{ .ProjectName }}-cli" + main: ./cmd/cli + binary: "{{ .ProjectName }}-cli-{{ .Os }}-{{ .Arch }}" + env: + - CGO_ENABLED=0 + goarch: + - amd64 + - arm64 + goos: + - linux + - windows + - darwin + flags: + - -trimpath + mod_timestamp: '{{ .CommitTimestamp }}' + ldflags: *ldflags # - id: "{{ .ProjectName }}-wasm" - # main: ./cmd/ + # main: ./cmd/controller/ # binary: "{{ .ProjectName }}.wasm" # env: # - CGO_ENABLED=0 @@ -43,12 +59,29 @@ builds: # mod_timestamp: '{{ .CommitTimestamp }}' # ldflags: # - >- - # -X main.Version={{ .Tag }} - # -X main.GitCommit={{ .Commit }} - # -X main.GitTag={{ .Tag }} - # -X main.GitDirty={{ .Date }} - # -X main.BuildTime={{ .Date }} - # -X main.GitRepo={{ .ProjectName }} + # -X github.com/projectcapsule/capsule/internal/version.Version={{ .Tag }} + # -X github.com/projectcapsule/capsule/internal/version.GitCommit={{ .Commit }} + # -X github.com/projectcapsule/capsule/internal/version.GitTag={{ .Tag }} + # -X github.com/projectcapsule/capsule/internal/version.GitDirty={{ .Date }} + # -X github.com/projectcapsule/capsule/internal/version.BuildTime={{ .Date }} + # -X github.com/projectcapsule/capsule/internal/version.GitRepo={{ .ProjectName }} +archives: + - id: controller + ids: + - "{{ .ProjectName }}" + name_template: >- + {{ .ProjectName }}_ + {{- .Version }}_ + {{- .Os }}_ + {{- .Arch }} + - id: cli + name_template: >- + {{ .ProjectName }}-cli_ + {{- .Version }}_ + {{- .Os }}_ + {{- .Arch }} + ids: + - "{{ .ProjectName }}-cli" release: prerelease: auto footer: | diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d9198e5f3..c4dbcd9c7 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -64,7 +64,7 @@ So the TL;DR answer is: ```bash # Create a KinD cluster if not already created -$ make dev-cluster +$ make dev-build dev-install-deps # To retrieve your laptop's IP and execute `make dev-setup` to setup dev env # For example: LAPTOP_HOST_IP=192.168.10.101 make dev-setup diff --git a/Makefile b/Makefile index 4934c74c7..ffd1ef3f3 100644 --- a/Makefile +++ b/Makefile @@ -48,7 +48,7 @@ all: manager # Run tests .PHONY: test -test: gotestsum test-clean generate manifests test-clean +test: gotestsum test-clean generate manifests mocks test-clean @GO111MODULE=on $(GOTEST) \ --format pkgname-and-test-fails \ --packages="$(shell go list ./... | grep -v "e2e")" \ @@ -438,6 +438,11 @@ golint: golangci-lint golint-fix: golangci-lint $(GOLANGCI_LINT) run -c .golangci.yaml --verbose --fix +# generate mocks +.PHONY: mocks +mocks: mockgen + $(MOCKGEN) -destination internal/mocks/client/mock.go sigs.k8s.io/controller-runtime/pkg/client Client,SubResourceWriter,Reader + .PHONY: e2e-openshift e2e-openshift: ginkgo $(MAKE) e2e-build-openshift && $(MAKE) e2e-exec FILTER='&& !skip && !skip-on-openshift' && $(MAKE) e2e-destroy-openshift @@ -675,6 +680,13 @@ apidocs-gen: ## Download crdoc locally if necessary. @test -s $(APIDOCS_GEN) && $(APIDOCS_GEN) --version | grep -q $(APIDOCS_GEN_VERSION) || \ $(call go-install-tool,$(APIDOCS_GEN),fybrik.io/crdoc@$(APIDOCS_GEN_VERSION)) +MOCKGEN := $(LOCALBIN)/mockgen +MOCKGEN_VERSION := v0.6.0 +MOCKGEN_LOOKUP := go.uber.org/mock/mockgen +mockgen: + @test -s $(MOCKGEN) && $(MOCKGEN) -version | grep -q $(MOCKGEN_VERSION) || \ + $(call go-install-tool,$(MOCKGEN),$(MOCKGEN_LOOKUP)@$(MOCKGEN_VERSION)) + GORELEASER := $(LOCALBIN)/goreleaser GORELEASER_VERSION := 2.16.0 GORELEASER_LOOKUP := goreleaser/goreleaser diff --git a/api/v1beta2/breakrequest_func.go b/api/v1beta2/breakrequest_func.go new file mode 100644 index 000000000..a5da62362 --- /dev/null +++ b/api/v1beta2/breakrequest_func.go @@ -0,0 +1,364 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package v1beta2 + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "github.com/projectcapsule/capsule/pkg/api/breaktheglass" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/template" +) + +// InitializeFromTemplate Copies all relevant values from the Template. +func (br *BreakRequest) InitializeFromTemplate(brt *BreakRequestTemplate) { + br.Status.Template = &TemplateProperties{ + Templates: brt.Spec.Templates, + DefaultDuration: brt.Spec.DefaultDuration, + MaxDuration: brt.Spec.MaxDuration, + KeepFor: brt.Spec.KeepFor, + } +} + +// SetRequested sets the BreakRequest phase to Requested (pending review). +func (br *BreakRequest) SetRequested() (err error) { + if err := br.transitionRequestPhase( + RequestPhaseRequested, + "Pending Review", + "PendingReview", + metav1.Now(), + nil, + ); err != nil { + return err + } + + br.Status.Review = &ReviewInfo{ + Verdict: RequestVerdictPending, + } + + return err +} + +// SetPending Sets Requests to pending. +func (br *BreakRequest) SetPending() (err error) { + if err := br.transitionRequestPhase( + RequestPhasePending, + "Access request pending", + "PendingBySystem", + metav1.Now(), + nil, + ); err != nil { + return err + } + + return err +} + +// ApproveRequest Approves the BreakRequest. Depending on the start time, it may also directly activate the request. +func (br *BreakRequest) ApproveRequest( + entity *breaktheglass.AccessEntity, + properties *ApprovedProperties, + reason string, +) (err error) { + if reason == "" { + reason = "Access request approved" + } + + if err := br.transitionRequestPhase( + RequestPhaseApproved, + reason, + "ApprovedBy"+entity.Type.String(), + metav1.Now(), + entity, + ); err != nil { + return err + } + + // items are set by the controller, remove them from the status + properties.Templates = nil + + br.Status.Approved = properties + + br.Status.Review = &ReviewInfo{ + Reviewer: entity, + Verdict: RequestVerdictApproved, + Message: reason, + } + + return err +} + +// DenyRequest Denies the BreakRequest. It may directly transition to the Denied phase or set a reason for denial. +func (br *BreakRequest) DenyRequest(entity *breaktheglass.AccessEntity, reason string) (err error) { + if reason == "" { + reason = "Access request denied" + } + + if err := br.transitionRequestPhase( + RequestPhaseDenied, + reason, + "DeniedByReviewer", + metav1.Now(), + entity, + ); err != nil { + return err + } + + br.Status.Review = &ReviewInfo{ + Reviewer: entity, + Verdict: RequestVerdictDenied, + Message: reason, + } + + return err +} + +// ActiveRequest Activates the BreakRequest, allowing the subject to access the requested resources. +func (br *BreakRequest) ActiveRequest(entity *breaktheglass.AccessEntity) (err error) { + now := metav1.Now() + + if err := br.transitionRequestPhase( + RequestPhaseActive, + "Access request activated", + "ActivatedBySystem", + now, + entity, + ); err != nil { + return err + } + + if br.Status.Active == nil { + br.Status.Active = &ActivePeriod{} + } + + tpl := br.Status.Template + if tpl == nil { + return fmt.Errorf("template not set") + } + + var duration *metav1.Duration + + switch { + case br.Status.Approved != nil && br.Status.Approved.Duration != nil: + // Non-nil approved duration is authoritative; 0 means "unlimited". + duration = br.Status.Approved.Duration + case br.Spec.Duration != nil && br.Spec.Duration.Duration != 0: + duration = br.Spec.Duration + default: + duration = tpl.DefaultDuration + } + + if tpl.MaxDuration.Duration > 0 && duration != nil && + duration.Duration > tpl.MaxDuration.Duration { + return fmt.Errorf("requested duration %s exceeds template maxDuration %s", + duration.Duration, tpl.MaxDuration.Duration) + } + + br.Status.Active.ActiveFrom = now + + keepFor := tpl.KeepFor + + if br.Status.Approved != nil { + keepFor = br.Status.Approved.KeepFor + } + + if keepFor > 0 { + controllerutil.AddFinalizer(br, meta.ControllerFinalizer) + } + + if duration != nil && duration.Duration > 0 { + // If a duration was set, otherwise the lifecycle must be canceled manually + activeUntil := now.Add(duration.Duration) + br.Status.Active.ActiveUntil = metav1.NewTime(activeUntil) + + if keepFor > 0 { + br.Status.KeepUntil = metav1.NewTime(activeUntil.Add(time.Duration(keepFor))) + } + } + + return nil +} + +// ExpireRequest When a request is active, it can be expired. This indicates that the granted access is revoked, however, +// this Request itself may be present longer, for auditing purposes. +func (br *BreakRequest) ExpireRequest(entity *breaktheglass.AccessEntity) (err error) { + now := metav1.Now() + + if err := br.transitionRequestPhase( + RequestPhaseExpired, + "Access request expired", + "ExpiredBySystem", + now, + entity, + ); err != nil { + return err + } + + keepFor := breaktheglass.ExtendedDuration(0) + if br.Status.Approved != nil { + keepFor = br.Status.Approved.KeepFor + } + + if keepFor > 0 { + controllerutil.AddFinalizer(br, meta.ControllerFinalizer) + } + + // If the request had no bounded ActiveUntil (e.g., "unlimited" duration) but keepFor is set, + // compute KeepUntil from the expiration time so the controller can retain the object for auditing. + if br.Status.KeepUntil.IsZero() && keepFor > 0 { + br.Status.KeepUntil = metav1.NewTime(now.Add(time.Duration(keepFor))) + } + + return nil +} + +// DeleteRequest Final stage, delete the request. +func (br *BreakRequest) DeleteRequest() { + controllerutil.RemoveFinalizer(br, meta.ControllerFinalizer) +} + +// GenerateApprovedProperties Get the Properties which are relevant for Review and approval. +func (br *BreakRequest) GenerateApprovedProperties() (*ApprovedProperties, error) { + tpl := br.Status.Template + if tpl == nil { + return nil, errors.New("template not set") + } + + it, err := br.RenderItems(tpl.ParamSchema, tpl.Templates) + if err != nil { + return nil, err + } + + startTime := metav1.Now() + if br.Spec.StartTime != nil && !br.Spec.StartTime.IsZero() { + startTime = *br.Spec.StartTime + } + + return &ApprovedProperties{ + Duration: br.Spec.Duration, + StartTime: startTime, + Templates: it, + KeepFor: tpl.KeepFor, + }, nil +} + +func (br *BreakRequest) RenderItems(schema runtime.RawExtension, templates []runtime.RawExtension) ([]runtime.RawExtension, error) { + var paramBytes []byte + if br.Spec.Params != nil { + paramBytes = br.Spec.Params.Raw + } + + rendered := make([]runtime.RawExtension, 0, len(templates)) + + if err := template.Validate(schema.Raw, paramBytes); err != nil { + return nil, fmt.Errorf("invalid params: %w", err) + } + + params := make(map[string]any) + if len(paramBytes) > 0 { + if err := json.Unmarshal(paramBytes, ¶ms); err != nil { + return nil, fmt.Errorf("error unmarshalling params: %w", err) + } + } + + var rerr error + + for i, tpl := range templates { + r, err := template.RenderTemplateBytes(params, "error", tpl.Raw) + if err != nil { + rerr = errors.Join(rerr, fmt.Errorf("error rendering template item %d: %w", i, err)) + + continue + } + + rendered = append(rendered, runtime.RawExtension{Raw: r}) + } + + return rendered, rerr +} + +// Ensure Phases are valid transitions and handle conditions accordingly. +func (br *BreakRequest) transitionRequestPhase( + newPhase RequestPhase, + conditionMessage string, + reason string, + now metav1.Time, + entity *breaktheglass.AccessEntity, +) error { + // Prevent duplicate condition entries of the same type + for _, cond := range br.Status.Conditions { + if RequestPhase(cond.Type) == newPhase { + return nil + } + } + + // Disallow invalid transitions + switch newPhase { + case RequestPhaseDenied: + if br.Status.Phase == RequestPhaseApproved || br.Status.Phase == RequestPhaseActive { + return fmt.Errorf("cannot deny an already approved or active request") + } + + setReviewer(br, entity, conditionMessage, RequestVerdictDenied) + + case RequestPhaseApproved: + if br.Status.Phase == RequestPhaseDenied { + return fmt.Errorf("cannot approve a denied request") + } + + setReviewer(br, entity, conditionMessage, RequestVerdictApproved) + + case RequestPhaseActive: + if br.Status.Phase != RequestPhaseApproved { + return fmt.Errorf("can only activate an approved request") + } + + case RequestPhaseExpired: + if br.Status.Phase != RequestPhaseActive { + return fmt.Errorf("can only expire an active request") + } + case RequestPhasePending, RequestPhaseRequested: // nothing to do here + } + + // Duplicate condition check already performed above. + + // Add new condition + br.Status.Conditions = append( + []metav1.Condition{{ + Type: string(newPhase), + Status: metav1.ConditionTrue, + Reason: reason, + Message: conditionMessage, + LastTransitionTime: now, + }}, + br.Status.Conditions..., + ) + + // Set the current phase + br.Status.Phase = newPhase + + return nil +} + +func setReviewer( + ar *BreakRequest, + entity *breaktheglass.AccessEntity, + conditionMessage string, + verdict RequestVerdict, +) { + if entity != nil { + ar.Status.Review = &ReviewInfo{ + Reviewer: entity, + Message: conditionMessage, + Verdict: verdict, + } + } +} diff --git a/api/v1beta2/breakrequest_func_test.go b/api/v1beta2/breakrequest_func_test.go new file mode 100644 index 000000000..ec02dac71 --- /dev/null +++ b/api/v1beta2/breakrequest_func_test.go @@ -0,0 +1,294 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package v1beta2 + +import ( + "testing" + "time" + + "github.com/projectcapsule/capsule/pkg/api/breaktheglass" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestSetReviewer(t *testing.T) { + reviewer := &breaktheglass.AccessEntity{Type: breaktheglass.AccessEntityTypeUser, Name: "test-user"} + tests := []struct { + name string + ar *BreakRequest + entity *breaktheglass.AccessEntity + conditionMessage string + verdict RequestVerdict + expectedReview *ReviewInfo + }{ + { + name: "set reviewer successfully", + ar: &BreakRequest{}, + entity: reviewer, + conditionMessage: "Approved", + verdict: RequestVerdictApproved, + expectedReview: &ReviewInfo{ + Reviewer: reviewer, + Message: "Approved", + Verdict: RequestVerdictApproved, + }, + }, + { + name: "nil entity does not set reviewer", + ar: &BreakRequest{}, + entity: nil, + conditionMessage: "No review", + verdict: RequestVerdictDenied, + expectedReview: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setReviewer(tt.ar, tt.entity, tt.conditionMessage, tt.verdict) + assert.Equal(t, tt.expectedReview, tt.ar.Status.Review) + }) + } +} + +func TestTransitionRequestPhase(t *testing.T) { + request := &BreakRequest{} + now := metav1.Now() + tests := []struct { + name string + phase RequestPhase + initPhase RequestPhase + expectError bool + }{ + { + name: "valid transition", + phase: RequestPhaseRequested, + initPhase: "", + expectError: false, + }, + { + name: "deny approved request", + phase: RequestPhaseDenied, + initPhase: RequestPhaseApproved, + expectError: true, + }, + { + name: "activate unapproved request", + phase: RequestPhaseActive, + initPhase: RequestPhaseRequested, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request.Status.Phase = tt.initPhase + err := request.transitionRequestPhase(tt.phase, "test", "reason", now, nil) + if tt.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.phase, request.Status.Phase) + } + }) + } +} + +func TestInitializeFromTemplate(t *testing.T) { + br := &BreakRequest{} + brt := &BreakRequestTemplate{ + Spec: BreakRequestTemplateSpec{ + Templates: []runtime.RawExtension{}, + DefaultDuration: &metav1.Duration{Duration: time.Minute}, + MaxDuration: metav1.Duration{Duration: time.Hour}, + KeepFor: 5, + }, + } + + br.InitializeFromTemplate(brt) + assert.NotNil(t, br.Status.Template) + assert.Equal(t, brt.Spec.Templates, br.Status.Template.Templates) + assert.Equal(t, brt.Spec.DefaultDuration, br.Status.Template.DefaultDuration) + assert.Equal(t, brt.Spec.MaxDuration, br.Status.Template.MaxDuration) + assert.Equal(t, brt.Spec.KeepFor, br.Status.Template.KeepFor) +} + +func TestApproveRequest(t *testing.T) { + br := &BreakRequest{} + entity := &breaktheglass.AccessEntity{Name: "reviewer", Type: breaktheglass.AccessEntityTypeUser} + props := &ApprovedProperties{Duration: &metav1.Duration{Duration: time.Hour}} + err := br.ApproveRequest(entity, props, "Approved") + require.NoError(t, err) + assert.Equal(t, RequestPhaseApproved, br.Status.Phase) + assert.Equal(t, entity, br.Status.Review.Reviewer) + assert.Equal(t, props.Duration, br.Status.Approved.Duration) +} + +func TestDenyRequest(t *testing.T) { + br := &BreakRequest{} + entity := &breaktheglass.AccessEntity{Name: "reviewer", Type: breaktheglass.AccessEntityTypeUser} + err := br.DenyRequest(entity, "Denied") + require.NoError(t, err) + assert.Equal(t, RequestPhaseDenied, br.Status.Phase) + assert.Equal(t, entity, br.Status.Review.Reviewer) + assert.Equal(t, "Denied", br.Status.Review.Message) +} + +func TestRenderItems(t *testing.T) { + br := &BreakRequest{ + Spec: BreakRequestSpec{ + Params: &runtime.RawExtension{Raw: []byte(`{"key":"value"}`)}, + }, + } + schema := runtime.RawExtension{Raw: []byte(`{"type":"object","properties":{"key":{"type":"string"}}}`)} + tpl := runtime.RawExtension{Raw: []byte(`{"kind":"ConfigMap"}`)} + + items, err := br.RenderItems(schema, []runtime.RawExtension{tpl}) + require.NoError(t, err) + assert.Len(t, items, 1) +} +func TestActiveRequest(t *testing.T) { + tests := []struct { + name string + br *BreakRequest + entity *breaktheglass.AccessEntity + wantErr string + expectedPhase RequestPhase + expectActiveNotNil bool + expectActiveUntil bool + }{ + { + name: "activate not approved", + br: &BreakRequest{ + Status: BreakRequestStatus{ + Template: &TemplateProperties{ + MaxDuration: metav1.Duration{Duration: time.Hour}, + DefaultDuration: &metav1.Duration{Duration: time.Minute}, + }, + }, + }, + entity: &breaktheglass.AccessEntity{Name: "user", Type: breaktheglass.AccessEntityTypeUser}, + wantErr: "can only activate an approved request", + expectedPhase: RequestPhaseActive, + expectActiveNotNil: true, + expectActiveUntil: true, + }, + { + name: "activate with approved duration", + br: &BreakRequest{ + Status: BreakRequestStatus{ + Template: &TemplateProperties{ + MaxDuration: metav1.Duration{Duration: time.Hour}, + DefaultDuration: &metav1.Duration{Duration: time.Minute}, + }, + Approved: &ApprovedProperties{ + Duration: &metav1.Duration{Duration: 30 * time.Minute}, + }, + Phase: RequestPhaseApproved, + }, + }, + entity: &breaktheglass.AccessEntity{Name: "user", Type: breaktheglass.AccessEntityTypeUser}, + wantErr: "", + expectedPhase: RequestPhaseActive, + expectActiveNotNil: true, + expectActiveUntil: true, + }, + { + name: "activate with default duration when approved duration is nil", + br: &BreakRequest{ + Status: BreakRequestStatus{ + Template: &TemplateProperties{ + MaxDuration: metav1.Duration{Duration: time.Hour}, + DefaultDuration: &metav1.Duration{Duration: time.Minute}, + }, + Approved: &ApprovedProperties{ + Duration: nil, + }, + Phase: RequestPhaseApproved, + }, + }, + entity: &breaktheglass.AccessEntity{Name: "user", Type: breaktheglass.AccessEntityTypeUser}, + wantErr: "", + expectedPhase: RequestPhaseActive, + expectActiveNotNil: true, + expectActiveUntil: true, + }, + { + name: "activate without approved properties", + br: &BreakRequest{ + Status: BreakRequestStatus{ + Template: &TemplateProperties{ + MaxDuration: metav1.Duration{Duration: time.Hour}, + DefaultDuration: &metav1.Duration{Duration: time.Minute}, + }, + Approved: nil, + Phase: RequestPhaseApproved, + }, + }, + entity: &breaktheglass.AccessEntity{Name: "user", Type: breaktheglass.AccessEntityTypeUser}, + wantErr: "", + expectedPhase: RequestPhaseActive, + expectActiveNotNil: true, + expectActiveUntil: true, + }, + { + name: "activate with nil entity", + br: &BreakRequest{ + Status: BreakRequestStatus{ + Template: &TemplateProperties{ + MaxDuration: metav1.Duration{Duration: time.Hour}, + DefaultDuration: &metav1.Duration{Duration: time.Minute}, + }, + Approved: &ApprovedProperties{ + Duration: &metav1.Duration{Duration: 30 * time.Minute}, + }, + Phase: RequestPhaseApproved, + }, + }, + entity: nil, + wantErr: "", + expectedPhase: RequestPhaseActive, + expectActiveNotNil: true, + expectActiveUntil: true, + }, + { + name: "activate without template", + br: &BreakRequest{ + Status: BreakRequestStatus{ + Template: nil, + Approved: &ApprovedProperties{ + Duration: &metav1.Duration{Duration: 30 * time.Minute}, + }, + Phase: RequestPhaseApproved, + }, + }, + entity: &breaktheglass.AccessEntity{Name: "user", Type: breaktheglass.AccessEntityTypeUser}, + wantErr: "template not set", + expectedPhase: RequestPhaseActive, + expectActiveNotNil: true, + expectActiveUntil: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.br.ActiveRequest(tt.entity) + if tt.wantErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedPhase, tt.br.Status.Phase) + if tt.expectActiveNotNil { + assert.NotNil(t, tt.br.Status.Active) + if tt.expectActiveUntil { + assert.True(t, tt.br.Status.Active.ActiveUntil.Time.After(time.Now())) + } + } + } + }) + } +} diff --git a/api/v1beta2/breakrequest_types.go b/api/v1beta2/breakrequest_types.go new file mode 100644 index 000000000..ad3976a4c --- /dev/null +++ b/api/v1beta2/breakrequest_types.go @@ -0,0 +1,147 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package v1beta2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/projectcapsule/capsule/pkg/api/breaktheglass" +) + +// BreakRequestSpec defines the desired state of BreakRequest. +type BreakRequestSpec struct { + // TemplateName the name of the template to use for this request + // +kubebuilder:validation:Required + TemplateName string `json:"templateName"` + // Params the parameters to use for the template. + Params *runtime.RawExtension `json:"params,omitempty"` + // Requesting actor for the access request. + Requestor breaktheglass.AccessEntity `json:"requestor,omitempty"` + // A reason on why the request is needed + Reason string `json:"reason,omitempty"` + // The duration of this BreakRequest should be valid for. + // If no duration was defined, the lifecycle is bound to the request itself - + // if the request is deleted, it's the end of the duration. + // The Request can also be Terminated by another automation via calling the ExpireRequest() API-Function. + Duration *metav1.Duration `json:"duration,omitempty"` + // Optional point in time when the access should begin. Must be in the future. + // If omitted, this is set to the current time. The Request must already be approved before the start time. + // +optional + // +kubebuilder:validation:Format=date-time + // +kubebuilder:validation:Type=string + StartTime *metav1.Time `json:"startTime,omitempty"` +} + +// BreakRequestStatus defines the observed state of BreakRequest. +type BreakRequestStatus struct { + // Review refers to the subject that either approved or denied the request + Review *ReviewInfo `json:"review,omitempty"` + // Template properties copied from the assigned template + Template *TemplateProperties `json:"template,omitempty"` + // The Approved properties are set when the request is approved. + Approved *ApprovedProperties `json:"approved,omitempty"` + // Shows timestamps between approval and termination of the request. + Active *ActivePeriod `json:"active,omitempty"` + // The time until which the BreakRequest should be retained after it expires (e.g. for auditing). + // If zero, the BreakRequest can be deleted immediately after expiring. + KeepUntil metav1.Time `json:"keepUntil,omitempty"` + // Conditions applied to the request. + // Known conditions are "Requested", "Pending", "Denied", "Approved", "Active" and "Expired". + // The latest condition is reflected in the phase. + Conditions []metav1.Condition `json:"conditions,omitempty"` + // +kubebuilder:validation:Enum=Requested;Pending;Denied;Approved;Active;Expired + Phase RequestPhase `json:"phase,omitempty"` +} + +// ActivePeriod represents the time window when a request is active. +type ActivePeriod struct { + ActiveFrom metav1.Time `json:"from,omitempty"` + ActiveUntil metav1.Time `json:"until,omitempty"` +} + +// TemplateProperties contains properties copied from the assigned template. +type TemplateProperties struct { + // The templates that are created by this request, provided by the template. + Templates []runtime.RawExtension `json:"templates,omitempty"` + // ParamSchema template parameter schema + ParamSchema runtime.RawExtension `json:"paramSchema,omitempty"` + // The default duration of the BreakRequest referencing this template should be valid for. + DefaultDuration *metav1.Duration `json:"defaultDuration,omitempty"` + // The max allowed duration of the BreakRequest referencing this template should be valid for. + MaxDuration metav1.Duration `json:"maxDuration,omitempty"` + // The duration of this BreakRequest will be kept in the system after it has been expired (eg. auditing purposes) + // If not set, the BreakRequest will be deleted after expiring. + KeepFor breaktheglass.ExtendedDuration `json:"keepFor,omitempty"` +} + +// ApprovedProperties contains the properties set when a request is approved. +type ApprovedProperties struct { + KeepFor breaktheglass.ExtendedDuration `json:"keepFor,omitempty"` + Duration *metav1.Duration `json:"duration,omitempty"` + StartTime metav1.Time `json:"startTime,omitempty"` + Templates []runtime.RawExtension `json:"templates,omitempty"` +} + +// ReviewInfo contains information about the review of a request. +type ReviewInfo struct { + // The Entity reviewing this request + Reviewer *breaktheglass.AccessEntity `json:"reviewer,omitempty"` + // The verdict made by the reviewing entity + // +kubebuilder:validation:Enum=Pending;Denied;Approved + Verdict RequestVerdict `json:"verdict,omitempty"` + // Message with the review + Message string `json:"message,omitempty"` +} + +type RequestVerdict string + +const ( + RequestVerdictDenied RequestVerdict = "Denied" + RequestVerdictApproved RequestVerdict = "Approved" + RequestVerdictPending RequestVerdict = "Pending" +) + +type RequestPhase string + +const ( + RequestPhaseRequested RequestPhase = "Requested" + RequestPhasePending RequestPhase = "Pending" + RequestPhaseDenied RequestPhase = "Denied" + RequestPhaseApproved RequestPhase = "Approved" + RequestPhaseActive RequestPhase = "Active" + RequestPhaseExpired RequestPhase = "Expired" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.spec.reason` +// +kubebuilder:printcolumn:name="Verdict",type=string,JSONPath=`.status.review.verdict` +// +kubebuilder:printcolumn:name="ActiveFrom",type=string,JSONPath=`.status.active.from`,priority=10 +// +kubebuilder:printcolumn:name="ActiveUntil",type=string,JSONPath=`.status.active.until`,priority=10 +// +kubebuilder:printcolumn:name="Duration",type=string,JSONPath=`.status.approved.duration`,priority=10 +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` + +// BreakRequest is the Schema for the BreakRequests API. +type BreakRequest struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BreakRequestSpec `json:"spec,omitempty"` + Status BreakRequestStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// BreakRequestList contains a list of BreakRequest. +type BreakRequestList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []BreakRequest `json:"items"` +} + +func init() { + SchemeBuilder.Register(&BreakRequest{}, &BreakRequestList{}) +} diff --git a/api/v1beta2/breakrequesttemplate_types.go b/api/v1beta2/breakrequesttemplate_types.go new file mode 100644 index 000000000..c24c08117 --- /dev/null +++ b/api/v1beta2/breakrequesttemplate_types.go @@ -0,0 +1,72 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package v1beta2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/projectcapsule/capsule/pkg/api/breaktheglass" +) + +// BreakRequestTemplateSpec defines the desired state of BreakRequestTemplate. +type BreakRequestTemplateSpec struct { + // Actual Items being created by this template + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + Templates []runtime.RawExtension `json:"templates"` + + // ParamSchema template parameter schema + ParamSchema runtime.RawExtension `json:"paramSchema,omitempty"` + + // The default duration of the BreakRequest referencing this template should be valid for. If not set, + // the resource will be kept until the request is deleted. + DefaultDuration *metav1.Duration `json:"defaultDuration,omitempty"` + // The max allowed duration of the BreakRequest referencing this template should be valid for. + MaxDuration metav1.Duration `json:"maxDuration,omitempty"` + + // The duration of this BreakRequest will be kept in the system after it has been expired (eg. auditing purposes) + // If not set, the BreakRequest will be deleted after expiring. + KeepFor breaktheglass.ExtendedDuration `json:"keepFor,omitempty"` + + // AutoApprove requests created by this template will be automatically approved. + AutoApprove bool `json:"autoApprove,omitempty"` + + // ApprovalCondition an optional CEL expression that must be successful for the request to be approved. + ApprovalCondition string `json:"approvalCondition,omitempty"` +} + +// BreakRequestTemplateStatus defines the observed state of BreakRequestTemplate. +type BreakRequestTemplateStatus struct { + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster + // Important: Run "make" to regenerate code after modifying this file +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster +// +kubebuilder:printcolumn:name="AutoApprove",type=boolean,JSONPath=`.spec.autoApprove` +// +kubebuilder:printcolumn:name="Condition",type=string,JSONPath=`.spec.approvalCondition`,priority=10 + +// BreakRequestTemplate is the Schema for the breakrequesttemplates API. +type BreakRequestTemplate struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec BreakRequestTemplateSpec `json:"spec,omitempty"` + Status BreakRequestTemplateStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// BreakRequestTemplateList contains a list of BreakRequestTemplate. +type BreakRequestTemplateList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []BreakRequestTemplate `json:"items"` +} + +func init() { + SchemeBuilder.Register(&BreakRequestTemplate{}, &BreakRequestTemplateList{}) +} diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index a5d24bc43..28816ee02 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -9,6 +9,7 @@ package v1beta2 import ( "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api/breaktheglass" "github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/api/rbac" "github.com/projectcapsule/capsule/pkg/api/rules" @@ -21,6 +22,23 @@ 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 *ActivePeriod) DeepCopyInto(out *ActivePeriod) { + *out = *in + in.ActiveFrom.DeepCopyInto(&out.ActiveFrom) + in.ActiveUntil.DeepCopyInto(&out.ActiveUntil) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ActivePeriod. +func (in *ActivePeriod) DeepCopy() *ActivePeriod { + if in == nil { + return nil + } + out := new(ActivePeriod) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AdditionalRoleBindingsSpec) DeepCopyInto(out *AdditionalRoleBindingsSpec) { *out = *in @@ -41,6 +59,269 @@ func (in *AdditionalRoleBindingsSpec) DeepCopy() *AdditionalRoleBindingsSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ApprovedProperties) DeepCopyInto(out *ApprovedProperties) { + *out = *in + if in.Duration != nil { + in, out := &in.Duration, &out.Duration + *out = new(metav1.Duration) + **out = **in + } + in.StartTime.DeepCopyInto(&out.StartTime) + if in.Templates != nil { + in, out := &in.Templates, &out.Templates + *out = make([]runtime.RawExtension, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApprovedProperties. +func (in *ApprovedProperties) DeepCopy() *ApprovedProperties { + if in == nil { + return nil + } + out := new(ApprovedProperties) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BreakRequest) DeepCopyInto(out *BreakRequest) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BreakRequest. +func (in *BreakRequest) DeepCopy() *BreakRequest { + if in == nil { + return nil + } + out := new(BreakRequest) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BreakRequest) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BreakRequestList) DeepCopyInto(out *BreakRequestList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BreakRequest, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BreakRequestList. +func (in *BreakRequestList) DeepCopy() *BreakRequestList { + if in == nil { + return nil + } + out := new(BreakRequestList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BreakRequestList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BreakRequestSpec) DeepCopyInto(out *BreakRequestSpec) { + *out = *in + if in.Params != nil { + in, out := &in.Params, &out.Params + *out = new(runtime.RawExtension) + (*in).DeepCopyInto(*out) + } + out.Requestor = in.Requestor + if in.Duration != nil { + in, out := &in.Duration, &out.Duration + *out = new(metav1.Duration) + **out = **in + } + if in.StartTime != nil { + in, out := &in.StartTime, &out.StartTime + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BreakRequestSpec. +func (in *BreakRequestSpec) DeepCopy() *BreakRequestSpec { + if in == nil { + return nil + } + out := new(BreakRequestSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BreakRequestStatus) DeepCopyInto(out *BreakRequestStatus) { + *out = *in + if in.Review != nil { + in, out := &in.Review, &out.Review + *out = new(ReviewInfo) + (*in).DeepCopyInto(*out) + } + if in.Template != nil { + in, out := &in.Template, &out.Template + *out = new(TemplateProperties) + (*in).DeepCopyInto(*out) + } + if in.Approved != nil { + in, out := &in.Approved, &out.Approved + *out = new(ApprovedProperties) + (*in).DeepCopyInto(*out) + } + if in.Active != nil { + in, out := &in.Active, &out.Active + *out = new(ActivePeriod) + (*in).DeepCopyInto(*out) + } + in.KeepUntil.DeepCopyInto(&out.KeepUntil) + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BreakRequestStatus. +func (in *BreakRequestStatus) DeepCopy() *BreakRequestStatus { + if in == nil { + return nil + } + out := new(BreakRequestStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BreakRequestTemplate) DeepCopyInto(out *BreakRequestTemplate) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + out.Status = in.Status +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BreakRequestTemplate. +func (in *BreakRequestTemplate) DeepCopy() *BreakRequestTemplate { + if in == nil { + return nil + } + out := new(BreakRequestTemplate) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BreakRequestTemplate) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BreakRequestTemplateList) DeepCopyInto(out *BreakRequestTemplateList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]BreakRequestTemplate, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BreakRequestTemplateList. +func (in *BreakRequestTemplateList) DeepCopy() *BreakRequestTemplateList { + if in == nil { + return nil + } + out := new(BreakRequestTemplateList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *BreakRequestTemplateList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BreakRequestTemplateSpec) DeepCopyInto(out *BreakRequestTemplateSpec) { + *out = *in + if in.Templates != nil { + in, out := &in.Templates, &out.Templates + *out = make([]runtime.RawExtension, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + in.ParamSchema.DeepCopyInto(&out.ParamSchema) + if in.DefaultDuration != nil { + in, out := &in.DefaultDuration, &out.DefaultDuration + *out = new(metav1.Duration) + **out = **in + } + out.MaxDuration = in.MaxDuration +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BreakRequestTemplateSpec. +func (in *BreakRequestTemplateSpec) DeepCopy() *BreakRequestTemplateSpec { + if in == nil { + return nil + } + out := new(BreakRequestTemplateSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BreakRequestTemplateStatus) DeepCopyInto(out *BreakRequestTemplateStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BreakRequestTemplateStatus. +func (in *BreakRequestTemplateStatus) DeepCopy() *BreakRequestTemplateStatus { + if in == nil { + return nil + } + out := new(BreakRequestTemplateStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CapsuleConfiguration) DeepCopyInto(out *CapsuleConfiguration) { *out = *in @@ -1585,6 +1866,26 @@ func (in *ResourceSpec) DeepCopy() *ResourceSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReviewInfo) DeepCopyInto(out *ReviewInfo) { + *out = *in + if in.Reviewer != nil { + in, out := &in.Reviewer, &out.Reviewer + *out = new(breaktheglass.AccessEntity) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReviewInfo. +func (in *ReviewInfo) DeepCopy() *ReviewInfo { + if in == nil { + return nil + } + out := new(ReviewInfo) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RuleStatus) DeepCopyInto(out *RuleStatus) { *out = *in @@ -1718,6 +2019,35 @@ func (in *TemplateItemSpec) DeepCopy() *TemplateItemSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TemplateProperties) DeepCopyInto(out *TemplateProperties) { + *out = *in + if in.Templates != nil { + in, out := &in.Templates, &out.Templates + *out = make([]runtime.RawExtension, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + in.ParamSchema.DeepCopyInto(&out.ParamSchema) + if in.DefaultDuration != nil { + in, out := &in.DefaultDuration, &out.DefaultDuration + *out = new(metav1.Duration) + **out = **in + } + out.MaxDuration = in.MaxDuration +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemplateProperties. +func (in *TemplateProperties) DeepCopy() *TemplateProperties { + if in == nil { + return nil + } + out := new(TemplateProperties) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Tenant) DeepCopyInto(out *Tenant) { *out = *in diff --git a/charts/capsule/crds/capsule.clastix.io_breakrequests.yaml b/charts/capsule/crds/capsule.clastix.io_breakrequests.yaml new file mode 100644 index 000000000..43e9e7885 --- /dev/null +++ b/charts/capsule/crds/capsule.clastix.io_breakrequests.yaml @@ -0,0 +1,274 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: breakrequests.capsule.clastix.io +spec: + group: capsule.clastix.io + names: + kind: BreakRequest + listKind: BreakRequestList + plural: breakrequests + singular: breakrequest + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.reason + name: Reason + type: string + - jsonPath: .status.review.verdict + name: Verdict + type: string + - jsonPath: .status.active.from + name: ActiveFrom + priority: 10 + type: string + - jsonPath: .status.active.until + name: ActiveUntil + priority: 10 + type: string + - jsonPath: .status.approved.duration + name: Duration + priority: 10 + type: string + - jsonPath: .status.phase + name: Phase + type: string + name: v1beta2 + schema: + openAPIV3Schema: + description: BreakRequest is the Schema for the BreakRequests API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: BreakRequestSpec defines the desired state of BreakRequest. + properties: + duration: + description: |- + The duration of this BreakRequest should be valid for. + If no duration was defined, the lifecycle is bound to the request itself - + if the request is deleted, it's the end of the duration. + The Request can also be Terminated by another automation via calling the ExpireRequest() API-Function. + type: string + params: + description: Params the parameters to use for the template. + type: object + x-kubernetes-preserve-unknown-fields: true + reason: + description: A reason on why the request is needed + type: string + requestor: + description: Requesting actor for the access request. + properties: + name: + description: The name of the entity + type: string + type: + description: The type of the entity + enum: + - User + - Group + - System + type: string + type: object + startTime: + description: |- + Optional point in time when the access should begin. Must be in the future. + If omitted, this is set to the current time. The Request must already be approved before the start time. + format: date-time + type: string + templateName: + description: TemplateName the name of the template to use for this + request + type: string + required: + - templateName + type: object + status: + description: BreakRequestStatus defines the observed state of BreakRequest. + properties: + active: + description: Shows timestamps between approval and termination of + the request. + properties: + from: + format: date-time + type: string + until: + format: date-time + type: string + type: object + approved: + description: The Approved properties are set when the request is approved. + properties: + duration: + type: string + keepFor: + pattern: ^([0-9]+(\.[0-9]+)?(s|m|h|d|w))+$ + type: string + startTime: + format: date-time + type: string + templates: + items: + type: object + x-kubernetes-preserve-unknown-fields: true + type: array + type: object + conditions: + description: |- + Conditions applied to the request. + Known conditions are "Requested", "Pending", "Denied", "Approved", "Active" and "Expired". + The latest condition is reflected in the phase. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + keepUntil: + description: |- + The time until which the BreakRequest should be retained after it expires (e.g. for auditing). + If zero, the BreakRequest can be deleted immediately after expiring. + format: date-time + type: string + phase: + enum: + - Requested + - Pending + - Denied + - Approved + - Active + - Expired + type: string + review: + description: Review refers to the subject that either approved or + denied the request + properties: + message: + description: Message with the review + type: string + reviewer: + description: The Entity reviewing this request + properties: + name: + description: The name of the entity + type: string + type: + description: The type of the entity + enum: + - User + - Group + - System + type: string + type: object + verdict: + description: The verdict made by the reviewing entity + enum: + - Pending + - Denied + - Approved + type: string + type: object + template: + description: Template properties copied from the assigned template + properties: + defaultDuration: + description: The default duration of the BreakRequest referencing + this template should be valid for. + type: string + keepFor: + description: |- + The duration of this BreakRequest will be kept in the system after it has been expired (eg. auditing purposes) + If not set, the BreakRequest will be deleted after expiring. + pattern: ^([0-9]+(\.[0-9]+)?(s|m|h|d|w))+$ + type: string + maxDuration: + description: The max allowed duration of the BreakRequest referencing + this template should be valid for. + type: string + paramSchema: + description: ParamSchema template parameter schema + type: object + x-kubernetes-preserve-unknown-fields: true + templates: + description: The templates that are created by this request, provided + by the template. + items: + type: object + x-kubernetes-preserve-unknown-fields: true + type: array + type: object + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/charts/capsule/crds/capsule.clastix.io_breakrequesttemplates.yaml b/charts/capsule/crds/capsule.clastix.io_breakrequesttemplates.yaml new file mode 100644 index 000000000..4978f156f --- /dev/null +++ b/charts/capsule/crds/capsule.clastix.io_breakrequesttemplates.yaml @@ -0,0 +1,95 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.21.0 + name: breakrequesttemplates.capsule.clastix.io +spec: + group: capsule.clastix.io + names: + kind: BreakRequestTemplate + listKind: BreakRequestTemplateList + plural: breakrequesttemplates + singular: breakrequesttemplate + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .spec.autoApprove + name: AutoApprove + type: boolean + - jsonPath: .spec.approvalCondition + name: Condition + priority: 10 + type: string + name: v1beta2 + schema: + openAPIV3Schema: + description: BreakRequestTemplate is the Schema for the breakrequesttemplates + API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: BreakRequestTemplateSpec defines the desired state of BreakRequestTemplate. + properties: + approvalCondition: + description: ApprovalCondition an optional CEL expression that must + be successful for the request to be approved. + type: string + autoApprove: + description: AutoApprove requests created by this template will be + automatically approved. + type: boolean + defaultDuration: + description: |- + The default duration of the BreakRequest referencing this template should be valid for. If not set, + the resource will be kept until the request is deleted. + type: string + keepFor: + description: |- + The duration of this BreakRequest will be kept in the system after it has been expired (eg. auditing purposes) + If not set, the BreakRequest will be deleted after expiring. + pattern: ^([0-9]+(\.[0-9]+)?(s|m|h|d|w))+$ + type: string + maxDuration: + description: The max allowed duration of the BreakRequest referencing + this template should be valid for. + type: string + paramSchema: + description: ParamSchema template parameter schema + type: object + x-kubernetes-preserve-unknown-fields: true + templates: + description: Actual Items being created by this template + items: + type: object + x-kubernetes-preserve-unknown-fields: true + minItems: 1 + type: array + required: + - templates + type: object + status: + description: BreakRequestTemplateStatus defines the observed state of + BreakRequestTemplate. + type: object + type: object + served: true + storage: true + subresources: {} diff --git a/charts/capsule/templates/hooks/crd-lifecycle/rbac.yaml b/charts/capsule/templates/hooks/crd-lifecycle/rbac.yaml index 524f964c5..c41ff7c60 100644 --- a/charts/capsule/templates/hooks/crd-lifecycle/rbac.yaml +++ b/charts/capsule/templates/hooks/crd-lifecycle/rbac.yaml @@ -38,6 +38,8 @@ rules: - customquotas.capsule.clastix.io - globalcustomquotas.capsule.clastix.io - quantityledgers.capsule.clastix.io + - breakrequests.capsule.clastix.io + - breakrequesttemplates.capsule.clastix.io verbs: - create - delete diff --git a/charts/capsule/templates/rbac.yaml b/charts/capsule/templates/rbac.yaml index 7d0845f96..7cbef27e7 100644 --- a/charts/capsule/templates/rbac.yaml +++ b/charts/capsule/templates/rbac.yaml @@ -176,6 +176,10 @@ rules: - globalcustomquotas/status - quantityledgers - quantityledgers/status + - breakrequests + - breakrequests/status + - breakrequesttemplates + - breakrequesttemplates/status verbs: - create - delete @@ -204,6 +208,8 @@ rules: - tenants.capsule.clastix.io - tenantowners.capsule.clastix.io - rulestatuses.capsule.clastix.io + - breakrequests.capsule.clastix.io + - breakrequesttemplates.capsule.clastix.io verbs: - get - patch diff --git a/cmd/cli/cmd/breaktheglass/activate.go b/cmd/cli/cmd/breaktheglass/activate.go new file mode 100644 index 000000000..d09064e02 --- /dev/null +++ b/cmd/cli/cmd/breaktheglass/activate.go @@ -0,0 +1,33 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package breaktheglass + +import ( + "github.com/spf13/cobra" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/breaktheglass" +) + +var activateCmd = &cobra.Command{ + Use: "activate", + Short: "activate a BreakRequest", + Args: cobra.ExactArgs(1), + Example: ` + # activate an existing BreakRequest + capsule break-the-glass activate grant-admin --namespace default +`, + RunE: func(cmd *cobra.Command, args []string) error { + name = args[0] + + return runBreakRequestAction( + func( + br *capsulev1beta2.BreakRequest, + user *breaktheglass.AccessEntity, + ) error { + return br.ActiveRequest(user) + }, + ) + }, +} diff --git a/cmd/cli/cmd/breaktheglass/expire.go b/cmd/cli/cmd/breaktheglass/expire.go new file mode 100644 index 000000000..2250aa31d --- /dev/null +++ b/cmd/cli/cmd/breaktheglass/expire.go @@ -0,0 +1,30 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package breaktheglass + +import ( + "github.com/spf13/cobra" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/breaktheglass" +) + +var expireCmd = &cobra.Command{ + Use: "expire", + Short: "expire a BreakRequest", + Args: cobra.ExactArgs(1), + Example: ` + # expire an existing BreakRequest + capsule break-the-glass expire grant-admin --namespace default +`, + RunE: func(cmd *cobra.Command, args []string) error { + name = args[0] + + return runBreakRequestAction( + func(br *capsulev1beta2.BreakRequest, user *breaktheglass.AccessEntity) error { + return br.ExpireRequest(user) + }, + ) + }, +} diff --git a/cmd/cli/cmd/breaktheglass/review.go b/cmd/cli/cmd/breaktheglass/review.go new file mode 100644 index 000000000..d35166f52 --- /dev/null +++ b/cmd/cli/cmd/breaktheglass/review.go @@ -0,0 +1,196 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package breaktheglass + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/xhit/go-str2duration/v2" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/retry" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/breaktheglass" +) + +var ( + approveFlag, denyFlag, noColorFlag bool + message string + startTimeStr, durationStr, keepForStr string +) + +const ( + denyValue = "deny" + approveValue = "approve" +) + +var reviewCmd = &cobra.Command{ + Use: "review", + Short: "Review a BreakRequest", + Args: cobra.ExactArgs(1), + Example: ` + # interactive review + capsule break-the-glass review grant-admin --namespace default + + # non-interactive approve/deny + capsule review grant-admin --namespace default --approve + capsule review grant-admin --namespace default --deny +`, + RunE: func(cmd *cobra.Command, args []string) error { + name = args[0] + + ctx := context.Background() + + cfg, k8sClient, err := newK8sClient() + if err != nil { + return err + } + + br := &v1beta2.BreakRequest{} + if err := k8sClient.Get( + ctx, + ctrlclient.ObjectKey{Name: name, Namespace: namespace}, + br, + ); err != nil { + return err + } + + if br.Status.Phase == "" { + return fmt.Errorf( + "BreakRequest %s is not yet processed, current phase: %q", + name, + br.Status.Phase, + ) + } + + if br.Status.Phase != v1beta2.RequestPhaseRequested { + return fmt.Errorf( + "BreakRequest %s is not in Requested phase (already reviewed), current phase: %q", + name, + br.Status.Phase, + ) + } + + props, err := br.GenerateApprovedProperties() + if err != nil { + return err + } + + // Parse Flags and Overwrite + if keepForStr != "" { + d, err := str2duration.ParseDuration(keepForStr) + if err != nil { + return fmt.Errorf("invalid duration %q: %w", keepForStr, err) + } + + props.KeepFor = breaktheglass.ExtendedDuration(d) + } + + if durationStr != "" { + d, err := str2duration.ParseDuration(durationStr) + if err != nil { + return fmt.Errorf("invalid duration %q: %w", durationStr, err) + } + + props.Duration = &metav1.Duration{Duration: d} + } + + if startTimeStr != "" { + var st metav1.Time + if err := st.UnmarshalJSON(fmt.Appendf(nil, "%q", startTimeStr)); err != nil { + return fmt.Errorf("invalid start time %q: %w", startTimeStr, err) + } + + props.StartTime = st + } + + // Validate Action + if approveFlag && denyFlag { + return fmt.Errorf("--approve and --deny are mutually exclusive") + } + + action := "" + if approveFlag { + action = approveValue + } else if denyFlag { + action = denyValue + } else { + printBreakRequestsApprovalTable(br, props, !noColorFlag) + + var input string + + for { + cmd.Print("Approve this request? [y/n]: ") + + _, _ = fmt.Scanln(&input) + + input = strings.ToLower(strings.TrimSpace(input)) + if input == "y" { + action = approveValue + + break + } else if input == "n" { + action = denyValue + + break + } else { + cmd.Println("Invalid input. Please type 'y' or 'n'.") + } + } + } + + user := &breaktheglass.AccessEntity{ + Type: breaktheglass.AccessEntityTypeUser, + Name: cfg.Username, + } + + return retry.OnError( + retry.DefaultRetry, + apierrors.IsConflict, + func() error { + if err := k8sClient.Get( + ctx, + ctrlclient.ObjectKey{Name: name, Namespace: namespace}, + br, + ); err != nil { + return err + } + + switch action { + case approveValue: + if err := br.ApproveRequest(user, props, message); err != nil { + return err + } + case denyValue: + if err := br.DenyRequest(user, message); err != nil { + return err + } + } + + return k8sClient.Status().Update(ctx, br) + }, + ) + }, +} + +func init() { + // Register the flag to the `approve` subcommand + reviewCmd.Flags().BoolVar(&approveFlag, "approve", false, "Approve the request") + reviewCmd.Flags().BoolVar(&denyFlag, "deny", false, "Deny the request") + reviewCmd.Flags().BoolVar(&noColorFlag, "no-color", false, "Don't colorize the output") + reviewCmd.Flags().StringVarP(&message, "message", "m", "", "Optional review message") + reviewCmd.Flags(). + StringVar(&startTimeStr, "start-time", "", "Start time (RFC3339 format, e.g. 2025-07-15T14:45:00Z)") + reviewCmd.Flags().StringVar(&durationStr, "duration", "", + "The ExtendedDuration this request is available for (e.g. 5m, 1h30m) "+ + "[Overwrites the value from the request, if defined]") + reviewCmd.Flags().StringVar(&keepForStr, "keep-for", "", + "The ExtendedDuration this request is archived for (e.g. 5m, 1h30m) "+ + "[Overwrites the value from the request, if defined]") +} diff --git a/cmd/cli/cmd/breaktheglass/root.go b/cmd/cli/cmd/breaktheglass/root.go new file mode 100644 index 000000000..7f44358ad --- /dev/null +++ b/cmd/cli/cmd/breaktheglass/root.go @@ -0,0 +1,42 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package breaktheglass + +import ( + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" +) + +var ( + name string + namespace string +) + +var RootCmd = &cobra.Command{ + Use: "break-the-glass", + Aliases: []string{"break", "btg"}, + Short: "Manage BreakRequests", +} + +var scheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + + utilruntime.Must(capsulev1beta2.AddToScheme(scheme)) +} + +func init() { + RootCmd.PersistentFlags(). + StringVarP(&namespace, "namespace", "n", "default", "Namespace of the BreakRequests") + + // Add subcommands + RootCmd.AddCommand(reviewCmd) + RootCmd.AddCommand(activateCmd) + RootCmd.AddCommand(expireCmd) +} diff --git a/cmd/cli/cmd/breaktheglass/utils.go b/cmd/cli/cmd/breaktheglass/utils.go new file mode 100644 index 000000000..8eed08890 --- /dev/null +++ b/cmd/cli/cmd/breaktheglass/utils.go @@ -0,0 +1,208 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package breaktheglass + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "k8s.io/client-go/util/retry" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/config" + "sigs.k8s.io/yaml" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/breaktheglass" +) + +func printBreakRequestsApprovalTable( + br *capsulev1beta2.BreakRequest, + app *capsulev1beta2.ApprovedProperties, + color bool, +) { + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.SetStyle(table.StyleRounded) + + t.Style().Title.Align = text.AlignCenter + + approvedDurationStr := "Unlimited" + if app.Duration != nil && app.Duration.Duration != 0 { + approvedDurationStr = app.Duration.Duration.String() + } + + keepForStr := "Undefined" + if app.KeepFor != 0 { + keepForStr = app.KeepFor.String() + } + + effectiveDurationStr := "Unlimited" + + switch { + case app.Duration != nil && app.Duration.Duration != 0: + effectiveDurationStr = app.Duration.Duration.String() + case br.Spec.Duration != nil && br.Spec.Duration.Duration != 0: + effectiveDurationStr = br.Spec.Duration.Duration.String() + case br.Status.Template != nil && br.Status.Template.DefaultDuration != nil && br.Status.Template.DefaultDuration.Duration != 0: + effectiveDurationStr = br.Status.Template.DefaultDuration.Duration.String() + } + + t.AppendHeader(table.Row{"Field", "Value"}) + t.AppendRows([]table.Row{ + {"Name", colorizeValue(br.Name, color)}, + {"Namespace", colorizeValue(br.Namespace, color)}, + {"Duration", colorizeValue(effectiveDurationStr, color)}, + {"ApprovedDuration", colorizeValue(approvedDurationStr, color)}, + {"KeepFor", colorizeValue(keepForStr, color)}, + }) + + // Example: printing .status.items nicely as YAML + for i, item := range app.Templates { + content := prettyRawExtension(item) + if color { + content = colorizeYAML(content) + } + + t.AppendSeparator() + // Multi-line cells are supported; keep them as one cell. + t.AppendRow(table.Row{ + fmt.Sprintf("Status Item %d", i), + content, + }) + } + + t.Render() +} + +// PrettyRawExtension returns human-readable YAML for a RawExtension. +// - If Object is non-nil, it marshals that. +// - Else converts JSON -> YAML. +func prettyRawExtension(re runtime.RawExtension) string { + // Prefer the decoded object when present. + if re.Object != nil { + j, err := json.Marshal(re.Object) + if err != nil { + return "-" + } + + if y, errY := yaml.JSONToYAML(j); errY == nil { + return string(y) + } + + return string(j) + } + + if len(re.Raw) == 0 { + return "-" + } + + if y, err := yaml.JSONToYAML(re.Raw); err == nil { + return string(y) + } + + return string(re.Raw) +} + +// colorizeValue applies ANSI colors for YAML using chroma and returns a string suitable for terminal output. +func colorizeValue(src string, color bool) string { + if !color || src == "" { + return src + } + + return colorize(src, chroma.Literator(chroma.Token{Type: chroma.NameTag, Value: src})) +} + +// colorizeYAML applies ANSI colors for YAML using chroma and returns a string suitable for terminal output. +func colorizeYAML(src string) string { + if src == "" { + return src + } + + lexer := lexers.Get("yaml") + if lexer == nil { + return src + } + + it, err := lexer.Tokenise(nil, src) + if err != nil { + return src + } + + return colorize(src, it) +} + +func colorize(src string, it chroma.Iterator) string { + // Choose a style; "dracula", "native", "github", etc. Fall back to "native". + style := styles.Get("native") + if style == nil { + style = styles.Fallback + } + // Use terminal16m for truecolor; fall back to the standard terminal if not supported. + formatter := formatters.Get("terminal16m") + if formatter == nil { + formatter = formatters.Fallback + } + + var buf strings.Builder + if err := formatter.Format(&buf, style, it); err != nil { + return src + } + + return buf.String() +} + +func newK8sClient() (*rest.Config, ctrlclient.Client, error) { + cfg, err := config.GetConfig() + if err != nil { + return nil, nil, err + } + + cl, err := ctrlclient.New(cfg, ctrlclient.Options{Scheme: scheme}) + + return cfg, cl, err +} + +func runBreakRequestAction( + action func(br *capsulev1beta2.BreakRequest, user *breaktheglass.AccessEntity) error, +) error { + ctx := context.Background() + + cfg, k8sClient, err := newK8sClient() + if err != nil { + return err + } + + user := &breaktheglass.AccessEntity{ + Type: breaktheglass.AccessEntityTypeUser, + Name: cfg.Username, + } + + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + br := &capsulev1beta2.BreakRequest{} + if err := k8sClient.Get( + ctx, + ctrlclient.ObjectKey{Name: name, Namespace: namespace}, + br, + ); err != nil { + return err + } + + if err := action(br, user); err != nil { + return err + } + + return k8sClient.Status().Update(ctx, br) + }) +} diff --git a/cmd/cli/cmd/root.go b/cmd/cli/cmd/root.go new file mode 100644 index 000000000..2308caf82 --- /dev/null +++ b/cmd/cli/cmd/root.go @@ -0,0 +1,33 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/projectcapsule/capsule/cmd/cli/cmd/breaktheglass" + capsuleversion "github.com/projectcapsule/capsule/internal/version" +) + +var rootCmd = &cobra.Command{ + Use: "capsule", + Short: "Capsule CLI", + Version: fmt.Sprintf("%s %s%s", capsuleversion.GitTag, capsuleversion.GitCommit, capsuleversion.GitDirty), +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + //nolint:forbidigo // no other option here + fmt.Println(err) + os.Exit(1) + } +} + +func init() { + // Add subcommands + rootCmd.AddCommand(breaktheglass.RootCmd) +} diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 000000000..213c64de0 --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,12 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "github.com/projectcapsule/capsule/cmd/cli/cmd" +) + +func main() { + cmd.Execute() +} diff --git a/cmd/controller/main.go b/cmd/controller/main.go index ea2e13f91..8f08f369c 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -44,6 +44,7 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/internal/cache" "github.com/projectcapsule/capsule/internal/controllers/admission" + breaktheglasscontroller "github.com/projectcapsule/capsule/internal/controllers/breaktheglass" cacheinvalidator "github.com/projectcapsule/capsule/internal/controllers/cfg/invalidator" configcontroller "github.com/projectcapsule/capsule/internal/controllers/cfg/status" customquotacontroller "github.com/projectcapsule/capsule/internal/controllers/customquotas" @@ -61,6 +62,7 @@ import ( "github.com/projectcapsule/capsule/internal/metrics" capsuleversion "github.com/projectcapsule/capsule/internal/version" "github.com/projectcapsule/capsule/internal/webhook" + "github.com/projectcapsule/capsule/internal/webhook/breaktheglass" cfgvalidation "github.com/projectcapsule/capsule/internal/webhook/cfg" customquotavalidation "github.com/projectcapsule/capsule/internal/webhook/customquota" "github.com/projectcapsule/capsule/internal/webhook/defaults" @@ -802,6 +804,8 @@ func main() { ), ), route.RulesValidating(manager.GetRESTMapper(), cfg), + route.BreakRequestValidation(breaktheglass.BreakRequestValidationHandler(ctrl.Log.WithName("webhooks").WithName("breakrequests"))), + route.BreakRequestTemplateValidation(breaktheglass.BreakRequestTemplateValidationHandler(ctrl.Log.WithName("webhooks").WithName("breakrequesttemplates"))), ) nodeWebhookSupported, _ := utils.NodeWebhookSupported(kubeVersion) @@ -895,6 +899,14 @@ func main() { os.Exit(1) } + if err = (&breaktheglasscontroller.BreakRequestReconciler{ + Log: ctrl.Log.WithName("capsule.ctrl").WithName("breakrequest"), + Metrics: *metrics.MustMakeBreakRequestsRecorder(), + }).SetupWithManager(manager, controllerConfig); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "BreakRequestReconciler") + os.Exit(1) + } + setupLog.Info("initializing controllers") localInvalidator := &cacheinvalidator.CacheInvalidator{ diff --git a/e2e/breaktheglass_test.go b/e2e/breaktheglass_test.go new file mode 100644 index 000000000..dd1435a7c --- /dev/null +++ b/e2e/breaktheglass_test.go @@ -0,0 +1,295 @@ +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "context" + "errors" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/breaktheglass" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" +) + +var _ = Describe("creating a BreakRequestTemplate", Ordered, Label("break-the-glass"), func() { + + var ( + ctx context.Context + brt *capsulev1beta2.BreakRequestTemplate + defaultDuration = 5 * time.Second + ) + + BeforeEach(func() { + ctx = context.TODO() + brt = &capsulev1beta2.BreakRequestTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-btg", + }, + Spec: capsulev1beta2.BreakRequestTemplateSpec{ + AutoApprove: true, + DefaultDuration: &metav1.Duration{ + Duration: defaultDuration, + }, + Templates: []runtime.RawExtension{{ + Object: &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{ + Kind: "ConfigMap", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-btg-cm", + }, + Data: map[string]string{"key": "value"}, + }, + }, + }, + }, + } + + }) + JustBeforeEach(func() { + ctx = context.TODO() + EventuallyCreation(func() error { + brt.ResourceVersion = "" + return k8sClient.Create(ctx, brt) + }).Should(Succeed()) + }) + + JustAfterEach(func() { + EventuallyDeletion(brt) + }) + + Describe("Duration set to "+defaultDuration.String(), func() { + It("should exist", func() { + t := &capsulev1beta2.BreakRequestTemplate{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: brt.GetName()}, t)).Should(Succeed()) + }) + It("should create a ConfigMap and delete it after timeout", func() { + br := &capsulev1beta2.BreakRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-btg-br", + Namespace: "default", + }, + Spec: capsulev1beta2.BreakRequestSpec{ + TemplateName: brt.GetName(), + }, + } + defer EventuallyDeletion(br) + + EventuallyCreation(func() error { + return k8sClient.Create(ctx, br) + }).Should(Succeed()) + + cm := &corev1.ConfigMap{} + Eventually(func() (err error) { + return k8sClient.Get(ctx, types.NamespacedName{Name: "e2e-btg-cm", Namespace: br.Namespace}, cm) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + // should be deleted after duration + Eventually(func() (err error) { + return k8sClient.Get(ctx, types.NamespacedName{Name: "e2e-btg-cm", Namespace: br.Namespace}, cm) + }, defaultTimeoutInterval, defaultPollInterval).ShouldNot(Succeed()) + }) + }) + + Describe("No duration defined", func() { + BeforeEach(func() { + brt.Spec.DefaultDuration = nil + }) + It("should create a ConfigMap and keep it", func() { + br := &capsulev1beta2.BreakRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-btg-br", + Namespace: "default", + }, + Spec: capsulev1beta2.BreakRequestSpec{ + TemplateName: brt.GetName(), + }, + } + defer EventuallyDeletion(br) + + EventuallyCreation(func() error { + return k8sClient.Create(ctx, br) + }).Should(Succeed()) + + cm := &corev1.ConfigMap{} + Eventually(func() (err error) { + return k8sClient.Get(ctx, types.NamespacedName{Name: "e2e-btg-cm", Namespace: br.Namespace}, cm) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + time.Sleep(defaultDuration + 2*time.Second) + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: "e2e-btg-cm", Namespace: br.Namespace}, cm)).Should(Succeed()) + }) + }) + + Describe("Approval required", func() { + BeforeEach(func() { + brt.Spec.AutoApprove = false + }) + It("break request need approval", func() { + + br := &capsulev1beta2.BreakRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-btg-br", + Namespace: "default", + }, + Spec: capsulev1beta2.BreakRequestSpec{ + TemplateName: brt.GetName(), + }, + } + defer EventuallyDeletion(br) + + EventuallyCreation(func() error { + return k8sClient.Create(ctx, br) + }).Should(Succeed()) + + approveBreakRequest(ctx, br) + + cm := &corev1.ConfigMap{} + Eventually(func() (err error) { + return k8sClient.Get(ctx, types.NamespacedName{Name: "e2e-btg-cm", Namespace: br.Namespace}, cm) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + // should be deleted after duration + Eventually(func() (err error) { + return k8sClient.Get(ctx, types.NamespacedName{Name: "e2e-btg-cm", Namespace: br.Namespace}, cm) + }, defaultTimeoutInterval, defaultPollInterval).ShouldNot(Succeed()) + }) + }) + + Describe("Approval required with condition", func() { + BeforeEach(func() { + brt.Spec.AutoApprove = true + brt.Spec.ApprovalCondition = "request.spec.reason == 'open sesame'" + }) + It("break request should be auto approved by condition", func() { + br := &capsulev1beta2.BreakRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-btg-br", + Namespace: "default", + }, + Spec: capsulev1beta2.BreakRequestSpec{ + TemplateName: brt.GetName(), + Reason: "open sesame", + }, + } + defer EventuallyDeletion(br) + + EventuallyCreation(func() error { + return k8sClient.Create(ctx, br) + }).Should(Succeed()) + + cm := &corev1.ConfigMap{} + Eventually(func() (err error) { + return k8sClient.Get(ctx, types.NamespacedName{Name: "e2e-btg-cm", Namespace: br.Namespace}, cm) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + // should be deleted after duration + Eventually(func() (err error) { + return k8sClient.Get(ctx, types.NamespacedName{Name: "e2e-btg-cm", Namespace: br.Namespace}, cm) + }, defaultTimeoutInterval, defaultPollInterval).ShouldNot(Succeed()) + }) + + It("break request needs approval when condition not matches", func() { + + br := &capsulev1beta2.BreakRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-btg-br", + Namespace: "default", + }, + Spec: capsulev1beta2.BreakRequestSpec{ + TemplateName: brt.GetName(), + Reason: "test", + }, + } + defer EventuallyDeletion(br) + + EventuallyCreation(func() error { + return k8sClient.Create(ctx, br) + }).Should(Succeed()) + + approveBreakRequest(ctx, br) + + cm := &corev1.ConfigMap{} + Eventually(func() (err error) { + return k8sClient.Get(ctx, types.NamespacedName{Name: "e2e-btg-cm", Namespace: br.Namespace}, cm) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + // should be deleted after duration + Eventually(func() (err error) { + return k8sClient.Get(ctx, types.NamespacedName{Name: "e2e-btg-cm", Namespace: br.Namespace}, cm) + }, defaultTimeoutInterval, defaultPollInterval).ShouldNot(Succeed()) + }) + }) + + Describe("Template with parameter", func() { + BeforeEach(func() { + brt.Spec.Templates = []runtime.RawExtension{ + { + Object: &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{ + Kind: "ConfigMap", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-btg-cm", + }, + Data: map[string]string{"key": "{{.value}}"}, + }, + }, + } + brt.Spec.ParamSchema = runtime.RawExtension{Raw: []byte(`{"type": "object", "required": ["value"], "properties": {"value": {"type": "string"}}}`)} + }) + It("should create correct a ConfigMap data", func() { + br := &capsulev1beta2.BreakRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-btg-br", + Namespace: "default", + }, + Spec: capsulev1beta2.BreakRequestSpec{ + TemplateName: brt.GetName(), + Params: &runtime.RawExtension{Raw: []byte(`{"value": "test-value"}`)}, + }, + } + defer EventuallyDeletion(br) + + EventuallyCreation(func() error { + err := k8sClient.Create(ctx, br) + return err + }).Should(Succeed()) + + cm := &corev1.ConfigMap{} + Eventually(func() (err error) { + return k8sClient.Get(ctx, types.NamespacedName{Name: "e2e-btg-cm", Namespace: br.Namespace}, cm) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + Expect(cm.Data["key"]).Should(Equal("test-value")) + }) + }) +}) + +func approveBreakRequest(ctx context.Context, br *capsulev1beta2.BreakRequest) { + br2 := &capsulev1beta2.BreakRequest{} + Eventually(func() (err error) { + err = k8sClient.Get(ctx, types.NamespacedName{Name: br.GetName(), Namespace: br.Namespace}, br2) + if err != nil { + return err + } + if br2.Status.Phase != capsulev1beta2.RequestPhaseRequested { + return errors.New("break request not in requested phase") + } + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + Expect(br2.Status.Approved).Should(BeNil()) + + props, err := br2.GenerateApprovedProperties() + Expect(err).ShouldNot(HaveOccurred()) + + Expect(br2.ApproveRequest(&breaktheglass.AccessEntity{Type: breaktheglass.AccessEntityTypeUser, Name: "test-user"}, props, "")).Should(Succeed()) + Expect(k8sClient.Status().Update(ctx, br2)).Should(Succeed()) +} diff --git a/go.mod b/go.mod index fffc8224a..d488f7d8c 100644 --- a/go.mod +++ b/go.mod @@ -5,35 +5,43 @@ go 1.26.4 require ( filippo.io/age v1.3.1 github.com/BurntSushi/toml v1.6.0 + github.com/alecthomas/chroma/v2 v2.27.0 github.com/fluxcd/pkg/apis/kustomize v1.15.0 github.com/fluxcd/pkg/ssa v0.64.0 github.com/go-logr/logr v1.4.3 github.com/go-sprout/sprout v1.0.3 + github.com/google/cel-go v0.26.1 + github.com/jedib0t/go-pretty/v6 v6.8.2 github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.2 + github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 + github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 github.com/valyala/fasttemplate v1.2.2 + github.com/xhit/go-str2duration/v2 v2.1.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 go.uber.org/automaxprocs v1.6.0 + go.uber.org/mock v0.6.0 go.uber.org/zap v1.28.0 go.yaml.in/yaml/v2 v2.4.3 golang.org/x/sync v0.20.0 gomodules.xyz/jsonpatch/v2 v2.5.0 - gomodules.xyz/jsonpatch/v3 v3.0.1 google.golang.org/grpc v1.82.0 + gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.5 k8s.io/apiextensions-apiserver v0.35.5 k8s.io/apimachinery v0.35.5 k8s.io/apiserver v0.35.5 k8s.io/client-go v0.35.5 k8s.io/klog/v2 v2.140.0 + k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e k8s.io/utils v0.0.0-20260108192941-914a6e750570 sigs.k8s.io/cluster-api v1.13.3 sigs.k8s.io/controller-runtime v0.23.3 @@ -49,11 +57,11 @@ require ( github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chai2010/gettext-go v1.0.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dlclark/regexp2/v2 v2.2.1 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -80,7 +88,6 @@ require ( github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gobuffalo/flect v1.0.3 // indirect github.com/google/btree v1.1.3 // indirect - github.com/google/cel-go v0.26.1 // indirect github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect @@ -88,6 +95,7 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/term v0.5.2 // indirect @@ -99,8 +107,8 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/cobra v1.10.2 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect @@ -131,10 +139,8 @@ require ( google.golang.org/protobuf v1.36.11 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/cli-runtime v0.35.0 // indirect k8s.io/component-base v0.35.5 // indirect - k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/kustomize/api v0.21.1 // indirect diff --git a/go.sum b/go.sum index f4faa70b9..a6d443868 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= -cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= @@ -14,41 +14,35 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= -github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= -github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= -github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= +github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chai2010/gettext-go v1.0.3 h1:9liNh8t+u26xl5ddmWLmsOsdNLwkdRTg5AG+JnTiM80= github.com/chai2010/gettext-go v1.0.3/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= -github.com/coredns/caddy v1.1.1 h1:2eYKZT7i6yxIfGP3qLJoJ7HAsDJqYB+X68g4NYjSrE0= -github.com/coredns/caddy v1.1.1/go.mod h1:A6ntJQlAWuQfFlsd9hvigKbo2WS0VUs2l1e2F+BawD4= -github.com/coredns/corefile-migration v1.0.29 h1:g4cPYMXXDDs9uLE2gFYrJaPBuUAR07eEMGyh9JBE13w= -github.com/coredns/corefile-migration v1.0.29/go.mod h1:56DPqONc3njpVPsdilEnfijCwNGC3/kTJLl7i7SPavY= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= -github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0= +github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ2tG6yudJd8LBksgI= github.com/evanphx/json-patch v5.7.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= @@ -139,24 +133,20 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY= -github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= -github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= -github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jedib0t/go-pretty/v6 v6.8.2 h1:FmKNr1GOyot/zqNQplE8HLhFguJaeHJTCArntnI4uxE= +github.com/jedib0t/go-pretty/v6 v6.8.2/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -173,6 +163,8 @@ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhn github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= @@ -193,18 +185,10 @@ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE= -github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= -github.com/onsi/ginkgo/v2 v2.28.0 h1:Rrf+lVLmtlBIKv6KrIGJCjyY8N36vDVcutbGJkyqjJc= -github.com/onsi/ginkgo/v2 v2.28.0/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= -github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= -github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= -github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -222,14 +206,17 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= -github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -245,7 +232,6 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -271,136 +257,74 @@ github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ= github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= +github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= -go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= -go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -gomodules.xyz/jsonpatch/v3 v3.0.1/go.mod h1:CBhndykehEwTOlEfnsfJwvkFQbSN8YZFr9M+cIHAJto= -gomodules.xyz/orderedmap v0.1.0/go.mod h1:g9/TPUCm1t2gwD3j3zfV8uylyYhVdCNSi+xCEIu7yTU= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU= -google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA= -google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= -google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 h1:pmJpJEvT846VzausCQ5d7KreSROcDqmO388w5YbnltA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= -google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= @@ -412,7 +336,6 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -428,12 +351,8 @@ k8s.io/cli-runtime v0.35.0 h1:PEJtYS/Zr4p20PfZSLCbY6YvaoLrfByd6THQzPworUE= k8s.io/cli-runtime v0.35.0/go.mod h1:VBRvHzosVAoVdP3XwUQn1Oqkvaa8facnokNkD7jOTMY= k8s.io/client-go v0.35.5 h1:wUrgqVSmFRw75bgSHY7X0G/hZM/QYpV0Hg7SYYOYpFk= k8s.io/client-go v0.35.5/go.mod h1:Z0mDcAJsX1Y7RQfuQlJipiRtqf8Mhk2VDu1/JvRqdGo= -k8s.io/cluster-bootstrap v0.34.2 h1:oKckPeunVCns37BntcsxaOesDul32yzGd3DFLjW2fc8= -k8s.io/cluster-bootstrap v0.34.2/go.mod h1:f21byPR7X5nt12ivZi+J3pb4sG4SH6VySX8KAAJA8BY= k8s.io/component-base v0.35.5 h1:1y1xxfpFNkNi4RMi6bvPNN4aDr9VhOijtEfrqnhPijs= k8s.io/component-base v0.35.5/go.mod h1:n/+aL98XYINubqIu/Okh6mS/kZT2nMeN4IQkQR4VXRg= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ= @@ -444,18 +363,10 @@ k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKW k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= -sigs.k8s.io/cluster-api v1.12.2 h1:+b+M2IygfvFZJq7bsaloNakimMEVNf81zkGR1IiuxXs= -sigs.k8s.io/cluster-api v1.12.2/go.mod h1:2XuF/dmN3c/1VITb6DB44N5+Ecvsvd5KOWqrY9Q53nU= -sigs.k8s.io/cluster-api v1.13.2 h1:NVdbVLmh6IyfdtENQAi80AijJf/FjfQLODz/6caDjlc= -sigs.k8s.io/cluster-api v1.13.2/go.mod h1:h7cyiUh+N7sIBkSerqU8cDkYMtRlXVO1c5RoJE1p5+g= sigs.k8s.io/cluster-api v1.13.3 h1:BlNVnjg644NnlWnxIWHbkltleFLVQwm8FmjWCSB9wGY= sigs.k8s.io/cluster-api v1.13.3/go.mod h1:7xB2mYn7oOxSlUw7wk6TukNCjR2phn+MI0gRju3TKSk= -sigs.k8s.io/controller-runtime v0.23.0 h1:Ubi7klJWiwEWqDY+odSVZiFA0aDSevOCXpa38yCSYu8= -sigs.k8s.io/controller-runtime v0.23.0/go.mod h1:DBOIr9NsprUqCZ1ZhsuJ0wAnQSIxY/C6VjZbmLgw0j0= sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= -sigs.k8s.io/gateway-api v1.4.1 h1:NPxFutNkKNa8UfLd2CMlEuhIPMQgDQ6DXNKG9sHbJU8= -sigs.k8s.io/gateway-api v1.4.1/go.mod h1:AR5RSqciWP98OPckEjOjh2XJhAe2Na4LHyXD2FUY7Qk= sigs.k8s.io/gateway-api v1.5.1 h1:RqVRIlkhLhUO8wOHKTLnTJA6o/1un4po4/6M1nRzdd0= sigs.k8s.io/gateway-api v1.5.1/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= @@ -466,10 +377,6 @@ sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7 sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= -sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/structured-merge-diff/v6 v6.4.0 h1:qmp2e3ZfFi1/jJbDGpD4mt3wyp6PE1NfKHCYLqgNQJo= sigs.k8s.io/structured-merge-diff/v6 v6.4.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= diff --git a/internal/controllers/breaktheglass/breakrequest_controller.go b/internal/controllers/breaktheglass/breakrequest_controller.go new file mode 100644 index 000000000..32d07a322 --- /dev/null +++ b/internal/controllers/breaktheglass/breakrequest_controller.go @@ -0,0 +1,508 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package breaktheglass + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/client-go/tools/events" + "k8s.io/client-go/util/retry" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/internal/controllers/utils" + "github.com/projectcapsule/capsule/internal/metrics" + "github.com/projectcapsule/capsule/pkg/api/breaktheglass" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/conditions" + evt "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +const ( + controllerName = "breakrequest" + labelKeyManagedBy = "app.kubernetes.io/managed-by" + labelValueManagedBy = "break-the-glass-controller" + annotationActiveUntil = "projectcapsule.dev/active-until" +) + +type BreakRequestReconciler struct { + client.Client + + scheme *runtime.Scheme + Metrics metrics.BreakRequestsRecorder + recorder events.EventRecorder + Log logr.Logger +} + +// SetupWithManager sets up the controller with the Manager. +func (r *BreakRequestReconciler) SetupWithManager(mgr ctrl.Manager, _ utils.ControllerOptions) error { + r.scheme = mgr.GetScheme() + r.Client = mgr.GetClient() + r.recorder = mgr.GetEventRecorder(controllerName) + + return ctrl.NewControllerManagedBy(mgr). + For(&capsulev1beta2.BreakRequest{}). + Named(controllerName). + Complete(r) +} + +// Reconcile the request. +func (r *BreakRequestReconciler) Reconcile( + ctx context.Context, + req ctrl.Request, +) (ctrl.Result, error) { + log := r.Log.WithValues("Request.Name", req.Name).WithValues("Request.Namespace", req.Namespace) + + br := &capsulev1beta2.BreakRequest{} + if err := r.Get(ctx, req.NamespacedName, br); err != nil { + if apierrors.IsNotFound(err) { + // ensure metrics for this object are removed + r.Metrics.DeleteRequestMetrics(&capsulev1beta2.BreakRequest{ObjectMeta: metav1.ObjectMeta{Name: req.Name, Namespace: req.Namespace}}) + log.V(5). + Info("Request object not found, could have been deleted after reconcile request") + + return reconcile.Result{}, nil + } + + r.Log.Error(err, "Error reading the object") + + return reconcile.Result{}, err + } + + defer func() { + r.Metrics.RecordRequestCondition(br) + }() + + return r.reconcile(ctx, log, br) +} + +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.20.4/pkg/reconcile +func (r *BreakRequestReconciler) reconcile( + ctx context.Context, + log logr.Logger, + br *capsulev1beta2.BreakRequest, +) (res ctrl.Result, err error) { + defer r.updateStatus(ctx, log, br)() + + switch br.Status.Phase { + case capsulev1beta2.RequestPhasePending: + log.V(5).Info("BreakRequest is pending, waiting for TTL") + + return ctrl.Result{}, nil + + case capsulev1beta2.RequestPhaseApproved: + log.V(5).Info("BreakRequest is approved, checking if duration can be started") + + if br.Status.Approved == nil { + return ctrl.Result{}, fmt.Errorf("BreakRequest is in Approved phase but status.approved is nil") + } + + if !br.Status.Approved.StartTime.IsZero() { + if wait := time.Until(br.Status.Approved.StartTime.Time); wait > 0 { + log.V(5).Info("BreakRequest is approved, waiting for startTime", "startTime", br.Status.Approved.StartTime.Time) + + return ctrl.Result{RequeueAfter: wait}, nil + } + } + + log.V(5).Info("BreakRequest is approved, activating br") + + // Transition to Active Phase + if err := r.transitionRequestActivation(ctx, br); err != nil { + return ctrl.Result{}, fmt.Errorf( + "failed to activate BreakRequest %s: %w", + br.Name, + err, + ) + } + + log.V(5).Info("BreakRequest activated successfully") + + r.recorder.Eventf( + br, + nil, + corev1.EventTypeNormal, + evt.ReasonBreakRequestActivated, + evt.ActionActivated, + "Break request activated", + ) + + return ctrl.Result{}, nil + + case capsulev1beta2.RequestPhaseDenied: + if err := r.addFinalizer(ctx, log, br); err != nil { + return ctrl.Result{}, err + } + + log.V(5).Info("BreakRequest is denied, handling denied state") + + return ctrl.Result{}, nil + + case capsulev1beta2.RequestPhaseActive: + if err := r.addFinalizer(ctx, log, br); err != nil { + return ctrl.Result{}, err + } + + if br.Status.Active != nil { + if !br.Status.Active.ActiveUntil.IsZero() { + ts := metav1.Now() + if ts.After(br.Status.Active.ActiveUntil.Time) { + r.recorder.Eventf( + br, + nil, + corev1.EventTypeNormal, + evt.ReasonBreakRequestExpired, + evt.ActionExpired, + "Break request expired", + ) + + return ctrl.Result{}, br.ExpireRequest(nil) + } + + log.V(5).Info("Re-queueing when expiration is due") + + return ctrl.Result{ + RequeueAfter: time.Until(br.Status.Active.ActiveUntil.Time), + }, nil + } + } + + return ctrl.Result{}, nil + + // When the BreakRequest has expired + case capsulev1beta2.RequestPhaseExpired: + if br.Status.KeepUntil.Time.IsZero() || + time.Until(br.Status.KeepUntil.Time) <= 0 { + log.V(5).Info("BreakRequest is expired, deleting br") + br.DeleteRequest() + + if err := r.Update(ctx, br); err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{}, r.Delete(ctx, br) + } + + log.V(5).WithValues("keep-date", br.Status.KeepUntil.Time). + Info("BreakRequest is expired, Holding expired state until keep date is reached") + + if err := r.deleteItems(ctx, br); err != nil { + return ctrl.Result{}, err + } + + return ctrl.Result{RequeueAfter: time.Until(br.Status.KeepUntil.Time)}, nil + + // The case when the BreakRequest is newly created + case "": + brt := &capsulev1beta2.BreakRequestTemplate{} + if err := r.Get(ctx, client.ObjectKey{Name: br.Spec.TemplateName}, brt); err != nil { + return ctrl.Result{}, fmt.Errorf( + "failed to get BreakRequest Template %s: %w", + br.Spec.TemplateName, + err, + ) + } + // initialize br with all requirements from brt + br.InitializeFromTemplate(brt) + + if ok, err := conditions.IsApproved(brt, br); ok { + props, err := br.GenerateApprovedProperties() + if err != nil { + return ctrl.Result{}, err + } + + err = br.ApproveRequest(&breaktheglass.AccessEntity{ + Type: breaktheglass.AccessEntityTypeSystem, + }, props, "Auto Approved") + + return ctrl.Result{}, err + } else if err != nil { + return ctrl.Result{}, fmt.Errorf( + "auto approval could not be evaluated for BreakRequest %s: %w", + br.Name, + err, + ) + } + + log.V(5).Info("BreakRequest is newly created, moving to pending phase") + + if err := br.SetRequested(); err != nil { + return ctrl.Result{}, err + } + + r.recorder.Eventf( + br, + nil, + corev1.EventTypeNormal, + evt.ReasonBreakRequestReviewNeeded, + evt.ActionPendingReview, + "Break request review pending", + ) + + return ctrl.Result{}, nil + + case capsulev1beta2.RequestPhaseRequested: + return ctrl.Result{}, nil + default: + log.WithValues("phase", br.Status.Phase).Info("Unhandled phase") + + return ctrl.Result{}, nil + } +} + +func (r *BreakRequestReconciler) updateStatus( + ctx context.Context, + log logr.Logger, + br *capsulev1beta2.BreakRequest, +) func() { + return func() { + err := retry.RetryOnConflict(retry.DefaultBackoff, func() error { + current := &capsulev1beta2.BreakRequest{} + if err := r.Get(ctx, client.ObjectKeyFromObject(br), current); err != nil { + return fmt.Errorf("failed to refetch instance before update: %w", err) + } + + current.Status = br.Status + + log.V(7).Info("updating status", "status", current.Status) + + if err := r.Client.Status().Update(ctx, current); err != nil { + return fmt.Errorf("failed to update status: %w", err) + } + + return nil + }) + if err != nil { + if apierrors.IsNotFound(err) { + // if the br is deleted, we cannot find it anymore + return + } + + log.Error(err, "failed updating status") + } else { + log.V(7).Info("successful update", "status", br.Status) + } + } +} + +// We are adding a finalizer to the BreakRequest to ensure it's not deleted before the request is processed (KeepFor period). +func (r *BreakRequestReconciler) addFinalizer( + ctx context.Context, + log logr.Logger, + br *capsulev1beta2.BreakRequest, +) error { + if br.Status.KeepUntil.Time.IsZero() || time.Until(br.Status.KeepUntil.Time) <= 0 { + return nil + } + + if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, br, func() error { + finalizerName := meta.ControllerFinalizer + if controllerutil.ContainsFinalizer(br, finalizerName) { + log.V(5).Info("Finalizer already exists", "name", br.Name) + + return nil + } + + log.V(5).Info("Adding finalizer to BreakRequest", "name", br.Name) + controllerutil.AddFinalizer(br, finalizerName) + + return nil + }); err != nil { + return fmt.Errorf("failed to add finalizer to BreakRequest %s: %w", br.Name, err) + } + + return r.Get(ctx, client.ObjectKeyFromObject(br), br) +} + +// When a request is approved, it can be activated immediately or after a certain duration. +func (r *BreakRequestReconciler) transitionRequestActivation( + ctx context.Context, + br *capsulev1beta2.BreakRequest, +) error { + // Avoid persisting the Active phase when item reconciliation fails. + brCopy := br.DeepCopy() + + if err := brCopy.ActiveRequest(nil); err != nil { + return err + } + + // Reflect Binding + if err := r.reconcileItems(ctx, brCopy); err != nil { + return fmt.Errorf("failed to create BreakRequest items %s: %w", brCopy.Name, err) + } + + br.Status = brCopy.Status + br.Finalizers = brCopy.Finalizers + + return nil +} + +// Creates the necessary items resources for the BreakRequest. +func (r *BreakRequestReconciler) reconcileItems( + ctx context.Context, + br *capsulev1beta2.BreakRequest, +) (err error) { + var syncErr error + + tpl := br.Status.Template + if tpl == nil { + return errors.New("template is nil") + } + + if br.Status.Approved == nil { + return errors.New("approved status is nil") + } + + if br.Status.Active == nil { + return errors.New("active status is nil") + } + + // reset the approved items; only the effective items should be kept + br.Status.Approved.Templates = nil + + rendered, err := br.RenderItems(tpl.ParamSchema, tpl.Templates) + if err != nil { + return err + } + + codecFactory := serializer.NewCodecFactory(r.Scheme()) + + for _, raw := range rendered { + obj := &unstructured.Unstructured{} + if _, _, decodeErr := codecFactory.UniversalDeserializer(). + Decode(raw.Raw, nil, obj); decodeErr != nil { + syncErr = errors.Join(syncErr, decodeErr) + + continue + } + + obj.SetNamespace(br.Namespace) + + if !br.Status.Active.ActiveUntil.IsZero() { + ann := obj.GetAnnotations() + if ann == nil { + ann = map[string]string{} + } + + ann[annotationActiveUntil] = br.Status.Active.ActiveUntil.Format(time.RFC3339) + obj.SetAnnotations(ann) + } + + if orerr := controllerutil.SetOwnerReference(br, obj, r.scheme); orerr != nil { + syncErr = errors.Join(syncErr, orerr) + + continue + } + + // append the item to the approved items (use deep copy to avoid using the cluster object) + br.Status.Approved.Templates = append(br.Status.Approved.Templates, runtime.RawExtension{Object: obj.DeepCopy()}) + + // Apply the object to the cluster + _, err = controllerutil.CreateOrUpdate(ctx, r.Client, obj, func() error { + // CreateOrUpdate re-fetches the live object before running this function, so any + // metadata that must be enforced needs to be applied here as well. + if !br.Status.Active.ActiveUntil.IsZero() { + ann := obj.GetAnnotations() + if ann == nil { + ann = map[string]string{} + } + + ann[annotationActiveUntil] = br.Status.Active.ActiveUntil.Format(time.RFC3339) + obj.SetAnnotations(ann) + } + + if err := controllerutil.SetOwnerReference(br, obj, r.scheme); err != nil { + return err + } + + labels := obj.GetLabels() + if labels == nil { + labels = map[string]string{} + } + + labels[labelKeyManagedBy] = labelValueManagedBy + obj.SetLabels(labels) + + return nil + }) + if err != nil { + syncErr = errors.Join(syncErr, err) + } + } + + return syncErr +} + +// deletes items of the BreakRequest. +func (r *BreakRequestReconciler) deleteItems( + ctx context.Context, + br *capsulev1beta2.BreakRequest, +) (err error) { + var syncErr error + + if br.Status.Approved == nil { + return errors.New("approved status is nil") + } + + for _, item := range br.Status.Approved.Templates { + obj, err := object(item) + if err != nil { + syncErr = errors.Join(syncErr, err) + + continue + } + + if derr := r.Delete(ctx, obj); derr != nil { + if !apierrors.IsNotFound(derr) { + syncErr = errors.Join(syncErr, derr) + + continue + } + } + } + + return syncErr +} + +func object(re runtime.RawExtension) (client.Object, error) { + // Prefer decoded object when present. + if re.Object != nil { + if co, ok := re.Object.(client.Object); ok { + return co, nil + } + + us, err := runtime.DefaultUnstructuredConverter.ToUnstructured(re.Object) + if err != nil { + return nil, err + } + + return &unstructured.Unstructured{Object: us}, nil + } + + // Fall back to Raw for objects coming back from the API server. + if len(re.Raw) == 0 { + return nil, errors.New("object is nil") + } + + obj := &unstructured.Unstructured{} + if _, _, err := unstructured.UnstructuredJSONScheme.Decode(re.Raw, nil, obj); err != nil { + return nil, err + } + + return obj, nil +} diff --git a/internal/controllers/breaktheglass/breakrequest_controller_reconcile_test.go b/internal/controllers/breaktheglass/breakrequest_controller_reconcile_test.go new file mode 100644 index 000000000..5f38b2817 --- /dev/null +++ b/internal/controllers/breaktheglass/breakrequest_controller_reconcile_test.go @@ -0,0 +1,222 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package breaktheglass + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + gm "go.uber.org/mock/gomock" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/events" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + mc "github.com/projectcapsule/capsule/internal/mocks/client" +) + +const ( + resourceName = "test-resource" + templateName = "test-template" +) + +var ( + mtConfigMapParameterized = runtime.RawExtension{Raw: []byte(` +{ + "kind": "ConfigMap", + "metadata": { + "name": "test-configmap" + }, + "data": { + "test": "{{.testValue}}" + } +}`)} + + psString = runtime.RawExtension{ + Raw: []byte(`{"type": "object", "required": ["testValue"], "properties": {"testValue": {"type": "string"}}}`), + } +) + +func TestBreakRequestReconciler_reconcile(t *testing.T) { + s := scheme.Scheme + _ = capsulev1beta2.AddToScheme(s) + + matchBr := gm.AssignableToTypeOf(&capsulev1beta2.BreakRequest{}) + matchBrt := gm.AssignableToTypeOf(&capsulev1beta2.BreakRequestTemplate{}) + matchUs := gm.AssignableToTypeOf(&unstructured.Unstructured{}) + + tests := []struct { + name string + br *capsulev1beta2.BreakRequest + mocks func(cl *mc.MockClient, scl *mc.MockSubResourceWriter) + verify func(t *testing.T, br *capsulev1beta2.BreakRequest) + wantErr bool + }{ + { + name: "newly created", + br: &capsulev1beta2.BreakRequest{ + ObjectMeta: v1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: capsulev1beta2.BreakRequestSpec{ + TemplateName: templateName, + }, + }, + mocks: func(cl *mc.MockClient, scl *mc.MockSubResourceWriter) { + cl.EXPECT().Get(gm.Any(), gm.Any(), matchBr).Return(nil) + cl.EXPECT().Get(gm.Any(), gm.Any(), matchBrt).Return(nil) + scl.EXPECT().Update(gm.Any(), matchBr, gm.Any()).Return(nil) + }, + verify: func(t *testing.T, br *capsulev1beta2.BreakRequest) { + assert.Len(t, br.Status.Conditions, 1) + assert.Equal(t, capsulev1beta2.RequestPhaseRequested, br.Status.Phase) + }, + }, + { + name: "approved but not yet to start", + br: &capsulev1beta2.BreakRequest{ + ObjectMeta: v1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: capsulev1beta2.BreakRequestSpec{ + TemplateName: templateName, + }, + Status: capsulev1beta2.BreakRequestStatus{ + Phase: capsulev1beta2.RequestPhaseApproved, + Conditions: []v1.Condition{ + { + LastTransitionTime: v1.Now(), + Message: "Access request approved", + Reason: "ApprovedByUser", + Status: "True", + Type: "Approved", + }, + }, + Approved: &capsulev1beta2.ApprovedProperties{ + StartTime: v1.NewTime(time.Now().Add(time.Hour)), + }, + }, + }, + mocks: func(cl *mc.MockClient, scl *mc.MockSubResourceWriter) { + cl.EXPECT().Get(gm.Any(), gm.Any(), matchBr).Return(nil) + scl.EXPECT().Update(gm.Any(), matchBr, gm.Any()).Return(nil) + }, + verify: func(t *testing.T, br *capsulev1beta2.BreakRequest) { + assert.Equal(t, capsulev1beta2.RequestPhaseApproved, br.Status.Phase) + found := false + for _, c := range br.Status.Conditions { + if c.Type == "Approved" { + found = true + break + } + } + assert.True(t, found) + }, + }, + { + name: "approved and ready", + br: &capsulev1beta2.BreakRequest{ + ObjectMeta: v1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: capsulev1beta2.BreakRequestSpec{ + TemplateName: templateName, + Params: &runtime.RawExtension{Raw: []byte(`{"testValue": "test-value"}`)}, + }, + Status: capsulev1beta2.BreakRequestStatus{ + Phase: capsulev1beta2.RequestPhaseApproved, + Conditions: []v1.Condition{ + { + LastTransitionTime: v1.Now(), + Message: "Access request approved", + Reason: "ApprovedByUser", + Status: "True", + Type: "Approved", + }, + }, + Approved: &capsulev1beta2.ApprovedProperties{ + StartTime: v1.Now(), + }, + Template: &capsulev1beta2.TemplateProperties{ + Templates: []runtime.RawExtension{mtConfigMapParameterized}, + ParamSchema: psString, + }, + }, + }, + mocks: func(cl *mc.MockClient, scl *mc.MockSubResourceWriter) { + cl.EXPECT().Get(gm.Any(), gm.Any(), matchBr).Return(nil) + cl.EXPECT().Get(gm.Any(), gm.Any(), matchUs).Return(nil) + cl.EXPECT().Update(gm.Any(), matchUs, gm.Any()).Return(nil) + scl.EXPECT().Update(gm.Any(), matchBr, gm.Any()).Return(nil) + }, + verify: func(t *testing.T, br *capsulev1beta2.BreakRequest) { + assert.Equal(t, capsulev1beta2.RequestPhaseActive, br.Status.Phase) + assert.Len(t, br.Status.Approved.Templates, 1) + + foundApproved := false + foundActive := false + for _, c := range br.Status.Conditions { + if c.Type == "Approved" { + foundApproved = true + } + if c.Type == "Active" { + foundActive = true + } + } + assert.True(t, foundApproved) + assert.True(t, foundActive) + + obj := br.Status.Approved.Templates[0].Object + co, ok := obj.(client.Object) + assert.True(t, ok) + assert.Len(t, co.GetOwnerReferences(), 1) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockCtrl := gm.NewController(t) + defer mockCtrl.Finish() + + cl := mc.NewMockClient(mockCtrl) + scl := mc.NewMockSubResourceWriter(mockCtrl) + + cl.EXPECT().Status().Return(scl).AnyTimes() + cl.EXPECT().Scheme().Return(s).AnyTimes() + + if tt.mocks != nil { + tt.mocks(cl, scl) + } + + r := &BreakRequestReconciler{ + Client: cl, + scheme: s, + recorder: &events.FakeRecorder{}, + Log: ctrl.Log, + } + + _, err := r.reconcile(context.Background(), ctrl.Log, tt.br) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + if tt.verify != nil { + tt.verify(t, tt.br) + } + }) + } +} diff --git a/internal/metrics/breakrequest_recorder.go b/internal/metrics/breakrequest_recorder.go new file mode 100644 index 000000000..c9c56980c --- /dev/null +++ b/internal/metrics/breakrequest_recorder.go @@ -0,0 +1,71 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + crtlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" +) + +type BreakRequestsRecorder struct { + requestConditionGauge *prometheus.GaugeVec +} + +func MustMakeBreakRequestsRecorder() *BreakRequestsRecorder { + metricsRecorder := NewBreakRequestsRecorder() + crtlmetrics.Registry.MustRegister(metricsRecorder.Collectors()...) + + return metricsRecorder +} + +func NewBreakRequestsRecorder() *BreakRequestsRecorder { + return &BreakRequestsRecorder{ + requestConditionGauge: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: metricsPrefix, + Name: "breakrequest_phase", + Help: "The current phase of the BreakRequest.", + }, + []string{"name", "target_namespace", "status"}, + ), + } +} + +func (r *BreakRequestsRecorder) Collectors() []prometheus.Collector { + return []prometheus.Collector{ + r.requestConditionGauge, + } +} + +// RecordRequestCondition records the current phase of the BreakRequest. +func (r *BreakRequestsRecorder) RecordRequestCondition(br *capsulev1beta2.BreakRequest) { + if r == nil || r.requestConditionGauge == nil || br == nil { + return + } + // Remove previous status series for this request. + r.requestConditionGauge.DeletePartialMatch(map[string]string{ + "name": br.GetName(), + "target_namespace": br.GetNamespace(), + }) + + if br.Status.Phase == "" { + return + } + + r.requestConditionGauge.WithLabelValues(br.GetName(), br.GetNamespace(), string(br.Status.Phase)).Set(1) +} + +// DeleteRequestMetrics deletes all metrics series for the given BreakRequest. +func (r *BreakRequestsRecorder) DeleteRequestMetrics(br *capsulev1beta2.BreakRequest) { + if r == nil || r.requestConditionGauge == nil || br == nil { + return + } + + r.requestConditionGauge.DeletePartialMatch(map[string]string{ + "name": br.GetName(), + "target_namespace": br.GetNamespace(), + }) +} diff --git a/internal/mocks/client/mock.go b/internal/mocks/client/mock.go new file mode 100644 index 000000000..6e951af7b --- /dev/null +++ b/internal/mocks/client/mock.go @@ -0,0 +1,445 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: sigs.k8s.io/controller-runtime/pkg/client (interfaces: Client,SubResourceWriter,Reader) +// +// Generated by this command: +// +// mockgen -destination internal/mocks/client/mock.go sigs.k8s.io/controller-runtime/pkg/client Client,SubResourceWriter,Reader +// + +// Package mock_client is a generated GoMock package. +package mock_client + +import ( + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" + meta "k8s.io/apimachinery/pkg/api/meta" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + client "sigs.k8s.io/controller-runtime/pkg/client" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder + isgomock struct{} +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// Apply mocks base method. +func (m *MockClient) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...client.ApplyOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, obj} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Apply", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Apply indicates an expected call of Apply. +func (mr *MockClientMockRecorder) Apply(ctx, obj any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, obj}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Apply", reflect.TypeOf((*MockClient)(nil).Apply), varargs...) +} + +// Create mocks base method. +func (m *MockClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, obj} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Create", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Create indicates an expected call of Create. +func (mr *MockClientMockRecorder) Create(ctx, obj any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, obj}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockClient)(nil).Create), varargs...) +} + +// Delete mocks base method. +func (m *MockClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, obj} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Delete", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockClientMockRecorder) Delete(ctx, obj any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, obj}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockClient)(nil).Delete), varargs...) +} + +// DeleteAllOf mocks base method. +func (m *MockClient) DeleteAllOf(ctx context.Context, obj client.Object, opts ...client.DeleteAllOfOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, obj} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "DeleteAllOf", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAllOf indicates an expected call of DeleteAllOf. +func (mr *MockClientMockRecorder) DeleteAllOf(ctx, obj any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, obj}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllOf", reflect.TypeOf((*MockClient)(nil).DeleteAllOf), varargs...) +} + +// Get mocks base method. +func (m *MockClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, key, obj} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Get", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Get indicates an expected call of Get. +func (mr *MockClientMockRecorder) Get(ctx, key, obj any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, key, obj}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockClient)(nil).Get), varargs...) +} + +// GroupVersionKindFor mocks base method. +func (m *MockClient) GroupVersionKindFor(obj runtime.Object) (schema.GroupVersionKind, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GroupVersionKindFor", obj) + ret0, _ := ret[0].(schema.GroupVersionKind) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GroupVersionKindFor indicates an expected call of GroupVersionKindFor. +func (mr *MockClientMockRecorder) GroupVersionKindFor(obj any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GroupVersionKindFor", reflect.TypeOf((*MockClient)(nil).GroupVersionKindFor), obj) +} + +// IsObjectNamespaced mocks base method. +func (m *MockClient) IsObjectNamespaced(obj runtime.Object) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsObjectNamespaced", obj) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsObjectNamespaced indicates an expected call of IsObjectNamespaced. +func (mr *MockClientMockRecorder) IsObjectNamespaced(obj any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsObjectNamespaced", reflect.TypeOf((*MockClient)(nil).IsObjectNamespaced), obj) +} + +// List mocks base method. +func (m *MockClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, list} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "List", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// List indicates an expected call of List. +func (mr *MockClientMockRecorder) List(ctx, list any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, list}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockClient)(nil).List), varargs...) +} + +// Patch mocks base method. +func (m *MockClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, obj, patch} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Patch", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Patch indicates an expected call of Patch. +func (mr *MockClientMockRecorder) Patch(ctx, obj, patch any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, obj, patch}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Patch", reflect.TypeOf((*MockClient)(nil).Patch), varargs...) +} + +// RESTMapper mocks base method. +func (m *MockClient) RESTMapper() meta.RESTMapper { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RESTMapper") + ret0, _ := ret[0].(meta.RESTMapper) + return ret0 +} + +// RESTMapper indicates an expected call of RESTMapper. +func (mr *MockClientMockRecorder) RESTMapper() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RESTMapper", reflect.TypeOf((*MockClient)(nil).RESTMapper)) +} + +// Scheme mocks base method. +func (m *MockClient) Scheme() *runtime.Scheme { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Scheme") + ret0, _ := ret[0].(*runtime.Scheme) + return ret0 +} + +// Scheme indicates an expected call of Scheme. +func (mr *MockClientMockRecorder) Scheme() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Scheme", reflect.TypeOf((*MockClient)(nil).Scheme)) +} + +// Status mocks base method. +func (m *MockClient) Status() client.SubResourceWriter { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Status") + ret0, _ := ret[0].(client.SubResourceWriter) + return ret0 +} + +// Status indicates an expected call of Status. +func (mr *MockClientMockRecorder) Status() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockClient)(nil).Status)) +} + +// SubResource mocks base method. +func (m *MockClient) SubResource(subResource string) client.SubResourceClient { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SubResource", subResource) + ret0, _ := ret[0].(client.SubResourceClient) + return ret0 +} + +// SubResource indicates an expected call of SubResource. +func (mr *MockClientMockRecorder) SubResource(subResource any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubResource", reflect.TypeOf((*MockClient)(nil).SubResource), subResource) +} + +// Update mocks base method. +func (m *MockClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, obj} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Update", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Update indicates an expected call of Update. +func (mr *MockClientMockRecorder) Update(ctx, obj any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, obj}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockClient)(nil).Update), varargs...) +} + +// MockSubResourceWriter is a mock of SubResourceWriter interface. +type MockSubResourceWriter struct { + ctrl *gomock.Controller + recorder *MockSubResourceWriterMockRecorder + isgomock struct{} +} + +// MockSubResourceWriterMockRecorder is the mock recorder for MockSubResourceWriter. +type MockSubResourceWriterMockRecorder struct { + mock *MockSubResourceWriter +} + +// NewMockSubResourceWriter creates a new mock instance. +func NewMockSubResourceWriter(ctrl *gomock.Controller) *MockSubResourceWriter { + mock := &MockSubResourceWriter{ctrl: ctrl} + mock.recorder = &MockSubResourceWriterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSubResourceWriter) EXPECT() *MockSubResourceWriterMockRecorder { + return m.recorder +} + +// Apply mocks base method. +func (m *MockSubResourceWriter) Apply(ctx context.Context, obj runtime.ApplyConfiguration, opts ...client.SubResourceApplyOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, obj} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Apply", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Apply indicates an expected call of Apply. +func (mr *MockSubResourceWriterMockRecorder) Apply(ctx, obj any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, obj}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Apply", reflect.TypeOf((*MockSubResourceWriter)(nil).Apply), varargs...) +} + +// Create mocks base method. +func (m *MockSubResourceWriter) Create(ctx context.Context, obj, subResource client.Object, opts ...client.SubResourceCreateOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, obj, subResource} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Create", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Create indicates an expected call of Create. +func (mr *MockSubResourceWriterMockRecorder) Create(ctx, obj, subResource any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, obj, subResource}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockSubResourceWriter)(nil).Create), varargs...) +} + +// Patch mocks base method. +func (m *MockSubResourceWriter) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, obj, patch} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Patch", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Patch indicates an expected call of Patch. +func (mr *MockSubResourceWriterMockRecorder) Patch(ctx, obj, patch any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, obj, patch}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Patch", reflect.TypeOf((*MockSubResourceWriter)(nil).Patch), varargs...) +} + +// Update mocks base method. +func (m *MockSubResourceWriter) Update(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, obj} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Update", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Update indicates an expected call of Update. +func (mr *MockSubResourceWriterMockRecorder) Update(ctx, obj any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, obj}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockSubResourceWriter)(nil).Update), varargs...) +} + +// MockReader is a mock of Reader interface. +type MockReader struct { + ctrl *gomock.Controller + recorder *MockReaderMockRecorder + isgomock struct{} +} + +// MockReaderMockRecorder is the mock recorder for MockReader. +type MockReaderMockRecorder struct { + mock *MockReader +} + +// NewMockReader creates a new mock instance. +func NewMockReader(ctrl *gomock.Controller) *MockReader { + mock := &MockReader{ctrl: ctrl} + mock.recorder = &MockReaderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockReader) EXPECT() *MockReaderMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockReader) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, key, obj} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Get", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Get indicates an expected call of Get. +func (mr *MockReaderMockRecorder) Get(ctx, key, obj any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, key, obj}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockReader)(nil).Get), varargs...) +} + +// List mocks base method. +func (m *MockReader) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + m.ctrl.T.Helper() + varargs := []any{ctx, list} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "List", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// List indicates an expected call of List. +func (mr *MockReaderMockRecorder) List(ctx, list any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, list}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockReader)(nil).List), varargs...) +} diff --git a/internal/webhook/breaktheglass/breakrequest_validating.go b/internal/webhook/breaktheglass/breakrequest_validating.go new file mode 100644 index 000000000..f75efddac --- /dev/null +++ b/internal/webhook/breaktheglass/breakrequest_validating.go @@ -0,0 +1,98 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package breaktheglass + +import ( + "context" + "fmt" + "time" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/handlers" +) + +func BreakRequestValidationHandler(log logr.Logger) handlers.Handler { + return &breakRequestValidationHandler{ + log: log, + } +} + +type breakRequestValidationHandler struct { + log logr.Logger +} + +func (b *breakRequestValidationHandler) OnCreate(_ client.Client, reader client.Reader, decoder admission.Decoder, _ events.EventRecorder) handlers.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + b.log.Info("Validation for BreakRequest upon creation", "name", req.Name) + + br := &capsulev1beta2.BreakRequest{} + if err := decoder.Decode(req, br); err != nil { + return ad.ErroredResponse(fmt.Errorf("failed to decode new object: %w", err)) + } + + brt := &capsulev1beta2.BreakRequestTemplate{} + if err := reader.Get(ctx, client.ObjectKey{Name: br.Spec.TemplateName}, brt); err != nil { + if client.IgnoreNotFound(err) == nil { + return ad.Denyf("template %s not found", br.Spec.TemplateName) + } + + return ad.ErroredResponse(fmt.Errorf("error loading template %s: %w", br.Spec.TemplateName, err)) + } + + if brt.Spec.MaxDuration.Duration > 0 && + br.Spec.Duration != nil && + br.Spec.Duration.Duration > brt.Spec.MaxDuration.Duration { + return ad.Denyf("requested duration %s exceeds template maxDuration %s", + br.Spec.Duration.Duration, brt.Spec.MaxDuration.Duration) + } + + if br.Spec.StartTime != nil && + !br.Spec.StartTime.After(time.Now()) { + return ad.Denyf("start time %s must be in the future", br.Spec.StartTime.String()) + } + + if _, err := br.RenderItems(brt.Spec.ParamSchema, brt.Spec.Templates); err != nil { + return ad.Denyf("invalid template rendering for %s: %v", br.Spec.TemplateName, err) + } + + return nil + } +} + +func (b *breakRequestValidationHandler) OnDelete(_ client.Client, _ client.Reader, _ admission.Decoder, _ events.EventRecorder) handlers.Func { + return func(_ context.Context, _ admission.Request) *admission.Response { + return nil + } +} + +func (b *breakRequestValidationHandler) OnUpdate(_ client.Client, _ client.Reader, decoder admission.Decoder, _ events.EventRecorder) handlers.Func { + return func(_ context.Context, req admission.Request) *admission.Response { + oldBr := &capsulev1beta2.BreakRequest{} + newBr := &capsulev1beta2.BreakRequest{} + + if err := decoder.DecodeRaw(req.OldObject, oldBr); err != nil { + return ad.ErroredResponse(err) + } + + if err := decoder.Decode(req, newBr); err != nil { + return ad.ErroredResponse(err) + } + + if oldBr.Spec.TemplateName != newBr.Spec.TemplateName { + return ad.Denyf( + "templateName cannot be changed. old: %s, new: %s", + oldBr.Spec.TemplateName, + newBr.Spec.TemplateName, + ) + } + + return nil + } +} diff --git a/internal/webhook/breaktheglass/breakrequest_validating_test.go b/internal/webhook/breaktheglass/breakrequest_validating_test.go new file mode 100644 index 000000000..daf04d91d --- /dev/null +++ b/internal/webhook/breaktheglass/breakrequest_validating_test.go @@ -0,0 +1,200 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package breaktheglass + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + gm "go.uber.org/mock/gomock" + apierr "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + ctrl "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + mc "github.com/projectcapsule/capsule/internal/mocks/client" + "github.com/projectcapsule/capsule/internal/webhook/test" +) + +func TestBreakRequestValidationHandler(t *testing.T) { + defaultTemplateName := "foo" + alternateTemplateName := "bar" + ctx := context.Background() + log := ctrl.Log.WithName("test") + + t.Run("OnCreate", func(t *testing.T) { + tests := []struct { + name string + br *capsulev1beta2.BreakRequest + setup func(reader *mc.MockReader) + expected int32 + errMsg string + }{ + { + name: "deny if template not found", + br: &capsulev1beta2.BreakRequest{ + Spec: capsulev1beta2.BreakRequestSpec{ + TemplateName: defaultTemplateName, + }, + }, + setup: func(reader *mc.MockReader) { + reader.EXPECT(). + Get(gm.Any(), client.ObjectKey{Name: defaultTemplateName}, gm.Any()). + Return(&apierr.StatusError{ErrStatus: metav1.Status{Reason: metav1.StatusReasonNotFound}}) + }, + expected: http.StatusForbidden, + errMsg: "template foo not found", + }, + { + name: "deny if template can not be loaded", + br: &capsulev1beta2.BreakRequest{ + Spec: capsulev1beta2.BreakRequestSpec{ + TemplateName: defaultTemplateName, + }, + }, + setup: func(reader *mc.MockReader) { + reader.EXPECT(). + Get(gm.Any(), client.ObjectKey{Name: defaultTemplateName}, gm.Any()). + Return(errors.New("error loading template")) + }, + expected: http.StatusInternalServerError, + errMsg: "error loading template foo: error loading template", + }, + { + name: "allow if template found", + br: &capsulev1beta2.BreakRequest{ + Spec: capsulev1beta2.BreakRequestSpec{ + TemplateName: alternateTemplateName, + }, + }, + setup: func(reader *mc.MockReader) { + reader.EXPECT(). + Get(gm.Any(), client.ObjectKey{Name: alternateTemplateName}, gm.Any()). + Return(nil) + }, + expected: 0, // allowed + }, + { + name: "deny if duration exceeds maxDuration", + br: &capsulev1beta2.BreakRequest{ + Spec: capsulev1beta2.BreakRequestSpec{ + TemplateName: defaultTemplateName, + Duration: &metav1.Duration{Duration: time.Hour}, + }, + }, + setup: func(reader *mc.MockReader) { + reader.EXPECT(). + Get(gm.Any(), client.ObjectKey{Name: defaultTemplateName}, gm.Any()). + Do(func(_ any, _ any, brt *capsulev1beta2.BreakRequestTemplate, _ ...any) { + brt.Spec.MaxDuration.Duration = time.Minute + }) + }, + expected: http.StatusForbidden, + errMsg: "requested duration 1h0m0s exceeds template maxDuration 1m0s", + }, + { + name: "deny if startTime is not in the future", + br: &capsulev1beta2.BreakRequest{ + Spec: capsulev1beta2.BreakRequestSpec{ + TemplateName: alternateTemplateName, + StartTime: &metav1.Time{Time: time.Now().Add(-time.Minute)}, + }, + }, + setup: func(reader *mc.MockReader) { + reader.EXPECT(). + Get(gm.Any(), client.ObjectKey{Name: alternateTemplateName}, gm.Any()). + Return(nil) + }, + expected: http.StatusForbidden, + errMsg: "must be in the future", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockCtrl := gm.NewController(t) + defer mockCtrl.Finish() + reader := mc.NewMockReader(mockCtrl) + decoder := &test.Decoder[*capsulev1beta2.BreakRequest]{ + Object: tt.br, + } + validator := BreakRequestValidationHandler(log) + + if tt.setup != nil { + tt.setup(reader) + } + + resp := validator.OnCreate(nil, reader, decoder, nil)(ctx, admission.Request{}) + if tt.expected == 0 { + assert.Nil(t, resp) + } else { + test.VerifyResponse(t, resp, tt.expected, tt.errMsg) + } + }) + } + }) + + t.Run("OnUpdate", func(t *testing.T) { + tests := []struct { + name string + oldBr *capsulev1beta2.BreakRequest + newBr *capsulev1beta2.BreakRequest + expected int32 + errMsg string + }{ + { + name: "allow if templateName not changed", + oldBr: &capsulev1beta2.BreakRequest{ + Spec: capsulev1beta2.BreakRequestSpec{ + TemplateName: defaultTemplateName, + }, + }, + newBr: &capsulev1beta2.BreakRequest{ + Spec: capsulev1beta2.BreakRequestSpec{ + TemplateName: defaultTemplateName, + }, + }, + expected: 0, + }, + { + name: "deny if templateName changed", + oldBr: &capsulev1beta2.BreakRequest{ + Spec: capsulev1beta2.BreakRequestSpec{ + TemplateName: defaultTemplateName, + }, + }, + newBr: &capsulev1beta2.BreakRequest{ + Spec: capsulev1beta2.BreakRequestSpec{ + TemplateName: alternateTemplateName, + }, + }, + expected: http.StatusForbidden, + errMsg: "templateName cannot be changed. old: foo, new: bar", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + decoder := &test.Decoder[*capsulev1beta2.BreakRequest]{ + Object: tt.newBr, + OldObject: tt.oldBr, + } + validator := BreakRequestValidationHandler(log) + + resp := validator.OnUpdate(nil, nil, decoder, nil)(ctx, admission.Request{}) + if tt.expected == 0 { + assert.Nil(t, resp) + } else { + test.VerifyResponse(t, resp, tt.expected, tt.errMsg) + } + }) + } + }) +} diff --git a/internal/webhook/breaktheglass/breakrequesttemplate_validating.go b/internal/webhook/breaktheglass/breakrequesttemplate_validating.go new file mode 100644 index 000000000..b62f2b2f9 --- /dev/null +++ b/internal/webhook/breaktheglass/breakrequesttemplate_validating.go @@ -0,0 +1,84 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package breaktheglass + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/conditions" + ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/handlers" + "github.com/projectcapsule/capsule/pkg/template" +) + +func BreakRequestTemplateValidationHandler(log logr.Logger) handlers.Handler { + return &breakRequestTemplateValidationHandler{ + log: log, + } +} + +type breakRequestTemplateValidationHandler struct { + log logr.Logger +} + +func (b *breakRequestTemplateValidationHandler) OnCreate(_ client.Client, _ client.Reader, decoder admission.Decoder, _ events.EventRecorder) handlers.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + b.log.Info("Validation for BreakRequestTemplate upon creation", "name", req.Name) + + return validate(decoder, req) + } +} + +func (b *breakRequestTemplateValidationHandler) OnDelete(_ client.Client, _ client.Reader, _ admission.Decoder, _ events.EventRecorder) handlers.Func { + return func(_ context.Context, _ admission.Request) *admission.Response { + return nil + } +} + +func (b *breakRequestTemplateValidationHandler) OnUpdate(_ client.Client, _ client.Reader, decoder admission.Decoder, _ events.EventRecorder) handlers.Func { + return func(_ context.Context, req admission.Request) *admission.Response { + b.log.Info("Validation for BreakRequestTemplate upon update", "name", req.Name) + + return validate(decoder, req) + } +} + +func validate(decoder admission.Decoder, req admission.Request) *admission.Response { + brt := &capsulev1beta2.BreakRequestTemplate{} + if err := decoder.Decode(req, brt); err != nil { + return ad.ErroredResponse(fmt.Errorf("failed to decode new object: %w", err)) + } + + if !brt.Spec.AutoApprove { + if brt.Spec.ApprovalCondition != "" { + return ad.Denyf("approvalCondition should not be set when autoApprove is false") + } + } else if brt.Spec.ApprovalCondition != "" { + if _, err := conditions.PrepareCondition(brt); err != nil { + return ad.Denyf("approvalCondition is invalid: %v", err) + } + } + // Ensure the template's own defaults are consistent. + if brt.Spec.MaxDuration.Duration > 0 && brt.Spec.DefaultDuration != nil && + brt.Spec.DefaultDuration.Duration > brt.Spec.MaxDuration.Duration { + return ad.Denyf( + "defaultDuration %s exceeds maxDuration %s", + brt.Spec.DefaultDuration.Duration, + brt.Spec.MaxDuration.Duration, + ) + } + + if err := template.ValidateItems(brt.Spec.ParamSchema, brt.Spec.Templates); err != nil { + return ad.Denyf("invalid templates: %v", err) + } + + return nil +} diff --git a/internal/webhook/breaktheglass/breakrequesttemplate_validating_test.go b/internal/webhook/breaktheglass/breakrequesttemplate_validating_test.go new file mode 100644 index 000000000..6a264da33 --- /dev/null +++ b/internal/webhook/breaktheglass/breakrequesttemplate_validating_test.go @@ -0,0 +1,152 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package breaktheglass + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + gm "go.uber.org/mock/gomock" + authorizationv1 "k8s.io/api/authorization/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + ctrl "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + mc "github.com/projectcapsule/capsule/internal/mocks/client" + "github.com/projectcapsule/capsule/internal/webhook/test" +) + +func TestBreakRequestTemplateValidationHandler(t *testing.T) { + ctx := context.Background() + log := ctrl.Log.WithName("test") + + tests := []struct { + name string + brt *capsulev1beta2.BreakRequestTemplate + setup func(cl *mc.MockClient) + expected int32 + errMsg string + }{ + { + name: "deny if autoApprove is false but approvalCondition is set", + brt: &capsulev1beta2.BreakRequestTemplate{ + Spec: capsulev1beta2.BreakRequestTemplateSpec{ + AutoApprove: false, + ApprovalCondition: "foo", + Templates: []runtime.RawExtension{{Object: &corev1.ConfigMap{}}}, + }, + }, + expected: http.StatusForbidden, + errMsg: "approvalCondition should not be set when autoApprove is false", + }, + { + name: "allow if autoApprove is true and condition is empty", + brt: &capsulev1beta2.BreakRequestTemplate{ + Spec: capsulev1beta2.BreakRequestTemplateSpec{ + AutoApprove: true, + Templates: []runtime.RawExtension{{Object: &corev1.ConfigMap{}}}, + }, + }, + setup: func(cl *mc.MockClient) { + cl.EXPECT().Create(gm.Any(), gm.Any(), gm.Any()).DoAndReturn(func(ctx context.Context, review *authorizationv1.SelfSubjectAccessReview, _ ...client.CreateOption) error { + review.Status.Allowed = true + return nil + }).AnyTimes() + }, + expected: 0, + }, + { + name: "deny if approvalCondition is invalid", + brt: &capsulev1beta2.BreakRequestTemplate{ + Spec: capsulev1beta2.BreakRequestTemplateSpec{ + AutoApprove: true, + ApprovalCondition: "foo.spec.reason == 'test'", + Templates: []runtime.RawExtension{{Object: &corev1.ConfigMap{}}}, + }, + }, + expected: http.StatusForbidden, + errMsg: "approvalCondition is invalid: ERROR: :1:1: undeclared reference to 'foo'", + }, + { + name: "allow if approvalCondition is valid", + brt: &capsulev1beta2.BreakRequestTemplate{ + Spec: capsulev1beta2.BreakRequestTemplateSpec{ + AutoApprove: true, + ApprovalCondition: "request.spec.reason == 'test'", + Templates: []runtime.RawExtension{{Object: &corev1.ConfigMap{}}}, + }, + }, + setup: func(cl *mc.MockClient) { + cl.EXPECT().Create(gm.Any(), gm.Any(), gm.Any()).DoAndReturn(func(ctx context.Context, review *authorizationv1.SelfSubjectAccessReview, _ ...client.CreateOption) error { + review.Status.Allowed = true + return nil + }).AnyTimes() + }, + expected: 0, + }, + { + name: "allow if item schema is valid", + brt: &capsulev1beta2.BreakRequestTemplate{ + Spec: capsulev1beta2.BreakRequestTemplateSpec{ + Templates: []runtime.RawExtension{{Object: &corev1.ConfigMap{}}}, + ParamSchema: runtime.RawExtension{Raw: []byte(`{"type": "string"}`)}, + }, + }, + setup: func(cl *mc.MockClient) { + cl.EXPECT().Create(gm.Any(), gm.Any(), gm.Any()).DoAndReturn(func(ctx context.Context, review *authorizationv1.SelfSubjectAccessReview, _ ...client.CreateOption) error { + review.Status.Allowed = true + return nil + }).AnyTimes() + }, + expected: 0, + }, + { + name: "deny if item schema is invalid", + brt: &capsulev1beta2.BreakRequestTemplate{ + Spec: capsulev1beta2.BreakRequestTemplateSpec{ + Templates: []runtime.RawExtension{{Object: &corev1.ConfigMap{}}}, + ParamSchema: runtime.RawExtension{Raw: []byte(`"type": `)}, + }, + }, + expected: http.StatusForbidden, + errMsg: `invalid templates: paramSchema is invalid: failed to validate OpenAPI schemaData: schema invalid`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockCtrl := gm.NewController(t) + defer mockCtrl.Finish() + + cl := mc.NewMockClient(mockCtrl) + decoder := &test.Decoder[*capsulev1beta2.BreakRequestTemplate]{ + Object: tt.brt, + } + validator := BreakRequestTemplateValidationHandler(log) + + if tt.setup != nil { + tt.setup(cl) + } + + resp := validator.OnCreate(cl, nil, decoder, nil)(ctx, admission.Request{}) + if tt.expected == 0 { + assert.Nil(t, resp) + } else { + test.VerifyResponse(t, resp, tt.expected, tt.errMsg) + } + + resp = validator.OnUpdate(cl, nil, decoder, nil)(ctx, admission.Request{}) + if tt.expected == 0 { + assert.Nil(t, resp) + } else { + test.VerifyResponse(t, resp, tt.expected, tt.errMsg) + } + }) + } +} diff --git a/internal/webhook/route/breakrequests.go b/internal/webhook/route/breakrequests.go new file mode 100644 index 000000000..5dba49ddd --- /dev/null +++ b/internal/webhook/route/breakrequests.go @@ -0,0 +1,22 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package route + +import "github.com/projectcapsule/capsule/pkg/runtime/handlers" + +func BreakRequestValidation(handler ...handlers.Handler) handlers.Webhook { + return &breakRequestValidation{handlers: handler} +} + +type breakRequestValidation struct { + handlers []handlers.Handler +} + +func (v *breakRequestValidation) GetHandlers() []handlers.Handler { + return v.handlers +} + +func (v *breakRequestValidation) GetPath() string { + return "/breakrequests/validating" +} diff --git a/internal/webhook/route/breakrequesttemplates.go b/internal/webhook/route/breakrequesttemplates.go new file mode 100644 index 000000000..44c60835a --- /dev/null +++ b/internal/webhook/route/breakrequesttemplates.go @@ -0,0 +1,22 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package route + +import "github.com/projectcapsule/capsule/pkg/runtime/handlers" + +func BreakRequestTemplateValidation(handler ...handlers.Handler) handlers.Webhook { + return &breakRequestTemplateValidation{handlers: handler} +} + +type breakRequestTemplateValidation struct { + handlers []handlers.Handler +} + +func (v *breakRequestTemplateValidation) GetHandlers() []handlers.Handler { + return v.handlers +} + +func (v *breakRequestTemplateValidation) GetPath() string { + return "/breakrequesttemplates/validating" +} diff --git a/internal/webhook/test/decoder.go b/internal/webhook/test/decoder.go new file mode 100644 index 000000000..5ed72e6a8 --- /dev/null +++ b/internal/webhook/test/decoder.go @@ -0,0 +1,45 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package test + +import ( + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +type Decoder[T interface { + runtime.Object + DeepCopyInto(T) +}] struct { + Object T + OldObject T +} + +func copyInto[T interface { + runtime.Object + DeepCopyInto(T) +}](source T, into runtime.Object) { + if into == nil || any(source) == nil { + return + } + + target, ok := into.(T) + if !ok { + return + } + + source.DeepCopyInto(target) +} + +func (d *Decoder[T]) Decode(_ admission.Request, into runtime.Object) error { + copyInto[T](d.Object, into) + + return nil +} + +func (d *Decoder[T]) DecodeRaw(_ runtime.RawExtension, into runtime.Object) error { + copyInto[T](d.OldObject, into) + + return nil +} diff --git a/internal/webhook/test/webhook.go b/internal/webhook/test/webhook.go new file mode 100644 index 000000000..831a78df0 --- /dev/null +++ b/internal/webhook/test/webhook.go @@ -0,0 +1,21 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// VerifyResponse is a helper function to verify the response of a webhook function. +func VerifyResponse(tb testing.TB, response *admission.Response, status int32, message string) { + tb.Helper() + require.NotNil(tb, response) + require.NotNil(tb, response.Result) + assert.Equal(tb, status, response.Result.Code) + assert.Contains(tb, response.Result.Message, message, "expected message %q to contain %q", response.Result.Message, message) +} diff --git a/pkg/api/breaktheglass/accessentity_types.go b/pkg/api/breaktheglass/accessentity_types.go new file mode 100644 index 000000000..5c514ce0a --- /dev/null +++ b/pkg/api/breaktheglass/accessentity_types.go @@ -0,0 +1,33 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package breaktheglass + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +type AccessEntityType string + +const ( + AccessEntityTypeUser AccessEntityType = "User" + AccessEntityTypeGroup AccessEntityType = "Group" + AccessEntityTypeSystem AccessEntityType = "System" +) + +func (t AccessEntityType) String() string { + return string(t) +} + +type AccessEntity struct { + // The name of the entity + Name string `json:"name,omitempty"` + // The type of the entity + // +kubebuilder:validation:Enum=User;Group;System + Type AccessEntityType `json:"type,omitempty"` +} + +// BreakRequestStatus defines the observed state of BreakRequest. +type BreakRequestStatusConditionItem struct { + metav1.Condition `json:",inline"` + + Reviewer AccessEntity `json:"reviewer,omitempty"` +} diff --git a/pkg/api/breaktheglass/extended-duration_types.go b/pkg/api/breaktheglass/extended-duration_types.go new file mode 100644 index 000000000..48b0d8503 --- /dev/null +++ b/pkg/api/breaktheglass/extended-duration_types.go @@ -0,0 +1,62 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package breaktheglass + +import ( + "encoding/json" + "time" + + "github.com/xhit/go-str2duration/v2" +) + +// ExtendedDuration is a custom duration field type that supports weeks, days, hours, minutes and seconds. +// +k8s:openapi-gen=true +// +kubebuilder:validation:Type=string +// +kubebuilder:validation:Pattern="^([0-9]+(\\.[0-9]+)?(s|m|h|d|w))+$" + +type ExtendedDuration time.Duration + +// UnmarshalJSON implements the json.Unmarshaller interface. +func (d *ExtendedDuration) UnmarshalJSON(b []byte) error { + var str string + + err := json.Unmarshal(b, &str) + if err != nil { + return err + } + + pd, err := str2duration.ParseDuration(str) + if err != nil { + return err + } + + *d = ExtendedDuration(pd) + + return nil +} + +// String returns the duration formatted as an extended duration string. +func (d ExtendedDuration) String() string { + return str2duration.String(time.Duration(d)) +} + +// MarshalJSON implements the json.Marshaler interface. +func (d ExtendedDuration) MarshalJSON() ([]byte, error) { + return json.Marshal(d.String()) +} + +// ToUnstructured implements the value.UnstructuredConverter interface. +func (d ExtendedDuration) ToUnstructured() any { + return d.String() +} + +// OpenAPISchemaType is used by the kube-openapi generator when constructing +// the OpenAPI spec of this type. +// +// See: https://github.com/kubernetes/kube-openapi/tree/master/pkg/generators +func (ExtendedDuration) OpenAPISchemaType() []string { return []string{"string"} } + +// OpenAPISchemaFormat is used by the kube-openapi generator when constructing +// the OpenAPI spec of this type. +func (ExtendedDuration) OpenAPISchemaFormat() string { return "" } diff --git a/pkg/api/breaktheglass/extended-duration_types_test.go b/pkg/api/breaktheglass/extended-duration_types_test.go new file mode 100644 index 000000000..a059e9b68 --- /dev/null +++ b/pkg/api/breaktheglass/extended-duration_types_test.go @@ -0,0 +1,212 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package breaktheglass + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const kubeBuilderType = "+kubebuilder:validation:Type=" + +func TestExtendedDuration_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + input string + expectErr bool + expected ExtendedDuration + }{ + { + name: "valid duration", + input: `"1h30m"`, + expectErr: false, + expected: ExtendedDuration(time.Hour + 30*time.Minute), + }, + { + name: "valid zero duration", + input: `"0s"`, + expectErr: false, + expected: ExtendedDuration(0), + }, + { + name: "invalid format", + input: `"not-a-duration"`, + expectErr: true, + expected: ExtendedDuration(0), + }, + { + name: "empty input", + input: `""`, + expectErr: true, + expected: ExtendedDuration(0), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var d ExtendedDuration + err := d.UnmarshalJSON([]byte(tt.input)) + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, d) + } + }) + } +} + +func TestExtendedDuration_String(t *testing.T) { + tests := []struct { + name string + input ExtendedDuration + expected string + }{ + { + name: "one hour", + input: ExtendedDuration(time.Hour), + expected: "1h", + }, + { + name: "hour and minutes", + input: ExtendedDuration(time.Hour + 30*time.Minute), + expected: "1h30m", + }, + { + name: "zero duration", + input: ExtendedDuration(0), + expected: "0s", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.input.String() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExtendedDuration_MarshalJSON(t *testing.T) { + tests := []struct { + name string + input ExtendedDuration + expectErr bool + expected string + }{ + { + name: "one hour", + input: ExtendedDuration(time.Hour), + expectErr: false, + expected: `"1h"`, + }, + { + name: "hour and minutes", + input: ExtendedDuration(time.Hour + 30*time.Minute), + expectErr: false, + expected: `"1h30m"`, + }, + { + name: "zero duration", + input: ExtendedDuration(0), + expectErr: false, + expected: `"0s"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := tt.input.MarshalJSON() + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, string(result)) + } + }) + } +} + +func TestExtendedDuration_ToUnstructured(t *testing.T) { + tests := []struct { + name string + input ExtendedDuration + expected string + }{ + { + name: "one hour", + input: ExtendedDuration(time.Hour), + expected: "1h", + }, + { + name: "hour and minutes", + input: ExtendedDuration(time.Hour + 30*time.Minute), + expected: "1h30m", + }, + { + name: "zero duration", + input: ExtendedDuration(0), + expected: "0s", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.input.ToUnstructured() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExtendedDuration_OpenAPISchemaType(t *testing.T) { + t.Run("should return correct schema type", func(t *testing.T) { + var d ExtendedDuration + assert.Equal(t, []string{"string"}, d.OpenAPISchemaType()) + }) + + t.Run("should verify the schema type matches the kubebuilder comment", func(t *testing.T) { + fset := token.NewFileSet() + file, err := parser.ParseFile( + fset, + "extended-duration_types.go", + nil, + parser.ParseComments, + ) + require.NoError(t, err) + + var d ExtendedDuration + schemaType := findKubeBuilderComment(file) + require.Len(t, schemaType, 1) + assert.Equal(t, d.OpenAPISchemaType()[0], schemaType[0]) + }) +} + +func TestExtendedDuration_OpenAPISchemaFormat(t *testing.T) { + t.Run("should return correct schema format", func(t *testing.T) { + var d ExtendedDuration + assert.Equal(t, "", d.OpenAPISchemaFormat()) + }) +} + +func findKubeBuilderComment(file *ast.File) []string { + var schemaType []string + for _, cg := range file.Comments { + for _, c := range cg.List { + if strings.Contains(c.Text, kubeBuilderType) { + schemaType = append( + schemaType, + strings.Split(c.Text, kubeBuilderType)[1], + ) + } + } + } + return schemaType +} diff --git a/pkg/conditions/cel.go b/pkg/conditions/cel.go new file mode 100644 index 000000000..515150362 --- /dev/null +++ b/pkg/conditions/cel.go @@ -0,0 +1,67 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package conditions + +import ( + "fmt" + + "github.com/google/cel-go/cel" + "k8s.io/apimachinery/pkg/runtime" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" +) + +func IsApproved(brt *capsulev1beta2.BreakRequestTemplate, br *capsulev1beta2.BreakRequest) (bool, error) { + if !brt.Spec.AutoApprove { + return false, nil + } + + if brt.Spec.ApprovalCondition == "" { + return true, nil + } + + prg, err := PrepareCondition(brt) + if err != nil { + return false, err + } + + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(br) + if err != nil { + return false, err + } + + result, _, err := prg.Eval(map[string]any{ + "request": obj, + }) + if err != nil { + return false, err + } + + // Convert the result to boolean + boolResult, ok := result.Value().(bool) + if !ok { + return false, fmt.Errorf( + "expression did not evaluate to a boolean, got: %T", + result.Value(), + ) + } + + return boolResult, nil +} + +func PrepareCondition(brt *capsulev1beta2.BreakRequestTemplate) (cel.Program, error) { + env, err := cel.NewEnv( + cel.Variable("request", cel.DynType), + ) + if err != nil { + return nil, err + } + + ast, iss := env.Compile(brt.Spec.ApprovalCondition) + if iss != nil && iss.Err() != nil { + return nil, iss.Err() + } + + return env.Program(ast) +} diff --git a/pkg/conditions/cel_test.go b/pkg/conditions/cel_test.go new file mode 100644 index 000000000..7f2ce7ec7 --- /dev/null +++ b/pkg/conditions/cel_test.go @@ -0,0 +1,70 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package conditions + +import ( + "testing" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsApproved(t *testing.T) { + tests := []struct { + name string + spec capsulev1beta2.BreakRequestTemplateSpec + br capsulev1beta2.BreakRequest + approved bool + expectError bool + }{ + { + name: "Not approved if no auto approval case 1", + spec: capsulev1beta2.BreakRequestTemplateSpec{AutoApprove: false}, + br: capsulev1beta2.BreakRequest{}, + approved: false, + expectError: false, + }, + { + name: "Approved if auto approval and no condition", + spec: capsulev1beta2.BreakRequestTemplateSpec{AutoApprove: true, ApprovalCondition: ""}, + br: capsulev1beta2.BreakRequest{}, + approved: true, + expectError: false, + }, + { + name: "Reason is correct", + spec: capsulev1beta2.BreakRequestTemplateSpec{ + AutoApprove: true, + ApprovalCondition: "request.spec.reason == 'test'", + }, + br: capsulev1beta2.BreakRequest{Spec: capsulev1beta2.BreakRequestSpec{Reason: "test"}}, + approved: true, + expectError: false, + }, + { + name: "Reason is incorrect", + spec: capsulev1beta2.BreakRequestTemplateSpec{ + AutoApprove: true, + ApprovalCondition: "request.spec.reason == 'test'", + }, + br: capsulev1beta2.BreakRequest{Spec: capsulev1beta2.BreakRequestSpec{Reason: "TEST"}}, + approved: false, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + brt := &capsulev1beta2.BreakRequestTemplate{Spec: tt.spec} + result, err := IsApproved(brt, &tt.br) + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.approved, result) + } + }) + } +} diff --git a/pkg/runtime/events/actions.go b/pkg/runtime/events/actions.go index 5cb99fec2..793b7ef6d 100644 --- a/pkg/runtime/events/actions.go +++ b/pkg/runtime/events/actions.go @@ -12,4 +12,8 @@ const ( ActionMutated string = "Mutated" ActionValidationDenied string = "ValidationDenied" ActionRuleAudit string = "RuleAudit" + + ActionExpired string = "Expired" + ActionActivated string = "Activated" + ActionPendingReview string = "PendingReview" ) diff --git a/pkg/runtime/events/reasons.go b/pkg/runtime/events/reasons.go index cdefef893..5b1f9c8a7 100644 --- a/pkg/runtime/events/reasons.go +++ b/pkg/runtime/events/reasons.go @@ -73,4 +73,9 @@ const ( // CustomQuotas. ReasonUsageCalculationFailed = "UsageCalculationFailed" ReasonQuotaExceeded = "QuotaExceeded" + + // BreakRequests. + ReasonBreakRequestExpired = "BreakRequestExpired" + ReasonBreakRequestActivated = "BreakRequestActivated" + ReasonBreakRequestReviewNeeded = "BreakRequestReviewNeeded" ) diff --git a/pkg/template/schema.go b/pkg/template/schema.go new file mode 100644 index 000000000..12039eb89 --- /dev/null +++ b/pkg/template/schema.go @@ -0,0 +1,105 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package template + +import ( + "bytes" + "encoding/json" + "fmt" + "text/template" + + "github.com/santhosh-tekuri/jsonschema/v5" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/kube-openapi/pkg/validation/spec" + "k8s.io/kube-openapi/pkg/validation/strfmt" + "k8s.io/kube-openapi/pkg/validation/validate" +) + +func ValidateItems(schema runtime.RawExtension, tis []runtime.RawExtension) error { + if _, err := ValidateSchema(schema.Raw); err != nil { + return fmt.Errorf("paramSchema is invalid: %w", err) + } + + for i, tpl := range tis { + if _, err := validateTemplate(tpl.Raw); err != nil { + return fmt.Errorf("template %d is invalid: %w", i, err) + } + } + + return nil +} + +func validateTemplate(tpl []byte) (*template.Template, error) { + return template.New("item").Option("missingkey=error").Parse(string(tpl)) +} + +func Validate(schemaData []byte, params []byte) error { + schema, err := ValidateSchema(schemaData) + if err != nil || schema == nil { + return err + } + + // Create validator + validator := validate.NewSchemaValidator(schema, nil, "", strfmt.Default) + + p := make(map[string]any) + if len(params) != 0 { + if err := json.Unmarshal(params, &p); err != nil { + return err + } + } + + // Validate the data + result := validator.Validate(p) + if !result.IsValid() { + var errors []string + for _, err := range result.Errors { + errors = append(errors, err.Error()) + } + + return fmt.Errorf("validation failed: %v", errors) + } + + return nil +} + +// ValidateSchema prepares the validation schema. Returns nil if the schema is empty. +func ValidateSchema(schemaData []byte) (*spec.Schema, error) { + if len(schemaData) == 0 { + return nil, nil + } + + err := metaValidateJSONSchema(schemaData) + if err != nil { + return nil, fmt.Errorf("failed to validate OpenAPI schemaData: %w", err) + } + + // Convert to OpenAPI spec schemaData + schema := &spec.Schema{} + if err := schema.UnmarshalJSON(schemaData); err != nil { + return nil, fmt.Errorf("failed to create OpenAPI schemaData: %w", err) + } + + return schema, nil +} + +func metaValidateJSONSchema(schemaBytes []byte) error { + // For OAS 3.1: https://json-schema.org/draft/2020-12/schema + meta := "https://json-schema.org/draft/2020-12/schema" + + c := jsonschema.NewCompiler() + if err := c.AddResource("meta.json", bytes.NewReader([]byte(`{"$ref":"`+meta+`"}`))); err != nil { + return err + } + // Compile the candidate schema using the chosen meta-schema + if err := c.AddResource("candidate.json", bytes.NewReader(schemaBytes)); err != nil { + return err + } + + if _, err := c.Compile("candidate.json"); err != nil { + return fmt.Errorf("schema invalid: %w", err) + } + + return nil +} diff --git a/pkg/template/schema_test.go b/pkg/template/schema_test.go new file mode 100644 index 000000000..b23688705 --- /dev/null +++ b/pkg/template/schema_test.go @@ -0,0 +1,104 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package template + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" +) + +var ( + schemaString = ` +type: object +required: ["key1"] +properties: + key1: + type: string +` + schemaStringNoAdditionalProperties = schemaString + ` +additionalProperties: false` + + paramKey1Key2 = ` +key1: value1 +key2: value2` +) + +func TestValidate(t *testing.T) { + tests := []struct { + name string + schemaJSON string + params string + expectErr bool + }{ + { + name: "valid schema and valid params", + schemaJSON: schemaString, + params: "key1: value1", + expectErr: false, + }, + { + name: "valid schema and valid params (one allowed extra field)", + schemaJSON: schemaString, + params: paramKey1Key2, + expectErr: false, + }, + { + name: "valid schema and invalid params (one additional extra field)", + schemaJSON: schemaStringNoAdditionalProperties, + params: paramKey1Key2, + expectErr: true, + }, + { + name: "valid schema but invalid params", + schemaJSON: schemaString, + params: "key1: 123", + expectErr: true, + }, + { + name: "schema missing required field", + schemaJSON: schemaString, + params: "", + expectErr: true, + }, + { + name: "invalid schema JSON", + schemaJSON: "type:", + params: "key1: value1", + expectErr: true, + }, + { + name: "empty schema and params", + schemaJSON: "", + params: "", + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Validate(y2j(tt.schemaJSON), y2j(tt.params)) + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func y2j(in string) []byte { + m := make(map[string]any) + err := yaml.Unmarshal([]byte(in), &m) + if err != nil { + panic(err) + } + b, err := json.Marshal(m) + if err != nil { + panic(err) + } + return b +}