diff --git a/config/base/generated-crds/operator.tekton.dev_manualapprovalgates.yaml b/config/base/generated-crds/operator.tekton.dev_manualapprovalgates.yaml index 5ea1358bc4..eb923f7267 100644 --- a/config/base/generated-crds/operator.tekton.dev_manualapprovalgates.yaml +++ b/config/base/generated-crds/operator.tekton.dev_manualapprovalgates.yaml @@ -50,6 +50,9 @@ spec: type: object spec: properties: + disabled: + description: enable or disable manual approval gate feature + type: boolean options: description: options holds additions fields and these fields will be updated on the manifests diff --git a/config/base/generated-crds/operator.tekton.dev_tektonconfigs.yaml b/config/base/generated-crds/operator.tekton.dev_tektonconfigs.yaml index 8ea3ec1158..0cf94acb2b 100644 --- a/config/base/generated-crds/operator.tekton.dev_tektonconfigs.yaml +++ b/config/base/generated-crds/operator.tekton.dev_tektonconfigs.yaml @@ -531,6 +531,49 @@ spec: type: string type: object type: array + required: + - options + type: object + manualApproval: + description: ManualApproval holds the customizable options for the + ManualApprovalGate component + properties: + disabled: + description: enable or disable manual approval gate feature + type: boolean + options: + description: options holds additions fields and these fields will + be updated on the manifests + properties: + configMaps: + x-kubernetes-preserve-unknown-fields: true + deployments: + x-kubernetes-preserve-unknown-fields: true + disabled: + type: boolean + horizontalPodAutoscalers: + x-kubernetes-preserve-unknown-fields: true + statefulSets: + x-kubernetes-preserve-unknown-fields: true + webhookConfigurationOptions: + additionalProperties: + description: WebhookOptions defines options for webhooks + properties: + failurePolicy: + description: FailurePolicyType specifies a failure policy + that defines how unrecognized errors from the admission + endpoint are handled. + type: string + sideEffects: + description: SideEffectClass specifies the types of + side effects a webhook may have. + type: string + timeoutSeconds: + format: int32 + type: integer + type: object + type: object + type: object type: object multiclusterProxyAAE: description: MulticlusterProxyAAE holds the customizable options for diff --git a/docs/TektonConfig.md b/docs/TektonConfig.md index cb19ad4dd8..9bd055bf30 100644 --- a/docs/TektonConfig.md +++ b/docs/TektonConfig.md @@ -19,6 +19,7 @@ Other than the above components depending on the platform operator also provides - On both Kubernetes and OpenShift - [TektonChain](./TektonChain.md) - [TektonResult](./TektonResult.md) + - [ManualApprovalGate](./ManualApprovalGate.md) - On Kubernetes - [TektonDashboard](./TektonDashboard.md) - [OpenShiftPipelinesAsCode](./OpenShiftPipelinesAsCode.md) (installed via `spec.platforms.kubernetes.pipelinesAsCode`; same CRD/kind as on OpenShift) @@ -47,6 +48,8 @@ spec: priorityClassName: system-cluster-critical chain: disabled: false + manualApproval: + disabled: true pipeline: await-sidecar-readiness: true coschedule: workspaces @@ -305,6 +308,23 @@ chain: transparency.url: #value ``` +### Manual Approval Gate + +Manual Approval Gate section allows user to enable or disable the [ManualApprovalGate](./ManualApprovalGate.md) component through TektonConfig. When enabled, the operator installs and manages the ManualApprovalGate CR automatically. + +Example: + +```yaml +manualApproval: + disabled: true # - `disabled` : if the value set as `true`, ManualApprovalGate will not be installed (default: `true`) + options: + disabled: false + deployments: {} +``` + +- `disabled`: if set to `true`, the ManualApprovalGate component will not be installed. Default is `true` (disabled). +- `options`: allows customizing the ManualApprovalGate deployments and configmaps. See [Additional fields as options](#additional-fields-as-options) for details. + ### Result Result section allows user to customize the Tekton Result component, Refer to [Result Spec](https://github.com/tektoncd/operator/blob/main/docs/TektonResult.md#spec) section in TektonResult for available options. diff --git a/pkg/apis/operator/v1alpha1/manualapprovalgate_defaults.go b/pkg/apis/operator/v1alpha1/manualapprovalgate_defaults.go new file mode 100644 index 0000000000..93664def94 --- /dev/null +++ b/pkg/apis/operator/v1alpha1/manualapprovalgate_defaults.go @@ -0,0 +1,34 @@ +/* +Copyright 2026 The Tekton Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import "context" + +func (mag *ManualApprovalGate) SetDefaults(_ context.Context) { + mag.Spec.ManualApproval.setDefaults() +} + +func (m *ManualApproval) setDefaults() { + if m.Disabled == nil { + disabled := true + m.Disabled = &disabled + } +} + +func (m *ManualApproval) IsDisabled() bool { + return m.Disabled == nil || *m.Disabled +} diff --git a/pkg/apis/operator/v1alpha1/manualapprovalgate_defaults_test.go b/pkg/apis/operator/v1alpha1/manualapprovalgate_defaults_test.go new file mode 100644 index 0000000000..22243706db --- /dev/null +++ b/pkg/apis/operator/v1alpha1/manualapprovalgate_defaults_test.go @@ -0,0 +1,109 @@ +/* +Copyright 2026 The Tekton Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestSetDefaultsManualApprovalGate(t *testing.T) { + mag := &ManualApprovalGate{ + ObjectMeta: metav1.ObjectMeta{ + Name: ManualApprovalGates, + }, + Spec: ManualApprovalGateSpec{ + CommonSpec: CommonSpec{ + TargetNamespace: "tekton-pipelines", + }, + }, + } + + mag.SetDefaults(context.TODO()) + + if mag.Spec.ManualApproval.Disabled == nil { + t.Error("expected Disabled to be set, got nil") + } + if !*mag.Spec.ManualApproval.Disabled { + t.Error("expected Disabled to default to true, got false") + } +} + +func TestSetDefaultsManualApprovalGate_DisabledAlreadySet(t *testing.T) { + disabled := false + mag := &ManualApprovalGate{ + ObjectMeta: metav1.ObjectMeta{ + Name: ManualApprovalGates, + }, + Spec: ManualApprovalGateSpec{ + CommonSpec: CommonSpec{ + TargetNamespace: "tekton-pipelines", + }, + ManualApproval: ManualApproval{ + Disabled: &disabled, + }, + }, + } + + mag.SetDefaults(context.TODO()) + + if mag.Spec.ManualApproval.Disabled == nil { + t.Error("expected Disabled to remain set, got nil") + } + if *mag.Spec.ManualApproval.Disabled { + t.Error("expected Disabled to remain false, got true") + } +} + +func TestManualApprovalIsDisabled(t *testing.T) { + tests := []struct { + name string + disabled *bool + want bool + }{ + { + name: "nil defaults to disabled", + disabled: nil, + want: true, + }, + { + name: "explicitly disabled", + disabled: boolPtr(true), + want: true, + }, + { + name: "explicitly enabled", + disabled: boolPtr(false), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &ManualApproval{Disabled: tt.disabled} + if got := m.IsDisabled(); got != tt.want { + t.Errorf("IsDisabled() = %v, want %v", got, tt.want) + } + }) + } +} + +func boolPtr(b bool) *bool { + return &b +} diff --git a/pkg/apis/operator/v1alpha1/manualapprovalgate_types.go b/pkg/apis/operator/v1alpha1/manualapprovalgate_types.go index e6b054c298..1e948e816f 100644 --- a/pkg/apis/operator/v1alpha1/manualapprovalgate_types.go +++ b/pkg/apis/operator/v1alpha1/manualapprovalgate_types.go @@ -51,6 +51,9 @@ type ManualApprovalGateSpec struct { } type ManualApproval struct { + // enable or disable manual approval gate feature + // +optional + Disabled *bool `json:"disabled,omitempty"` // options holds additions fields and these fields will be updated on the manifests // +optional Options AdditionalOptions `json:"options"` diff --git a/pkg/apis/operator/v1alpha1/manualapprovalgate_validation.go b/pkg/apis/operator/v1alpha1/manualapprovalgate_validation.go new file mode 100644 index 0000000000..e9f8efb564 --- /dev/null +++ b/pkg/apis/operator/v1alpha1/manualapprovalgate_validation.go @@ -0,0 +1,39 @@ +/* +Copyright 2026 The Tekton Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + "context" + "fmt" + + "knative.dev/pkg/apis" +) + +func (mag *ManualApprovalGate) Validate(ctx context.Context) (errs *apis.FieldError) { + if apis.IsInDelete(ctx) { + return nil + } + + if mag.GetName() != ManualApprovalGates { + errMsg := fmt.Sprintf("metadata.name, Only one instance of ManualApprovalGate is allowed by name, %s", ManualApprovalGates) + errs = errs.Also(apis.ErrInvalidValue(mag.GetName(), errMsg)) + } + + errs = errs.Also(mag.Spec.CommonSpec.validate("spec")) + + return errs +} diff --git a/pkg/apis/operator/v1alpha1/manualapprovalgate_validation_test.go b/pkg/apis/operator/v1alpha1/manualapprovalgate_validation_test.go new file mode 100644 index 0000000000..5469cc7ec9 --- /dev/null +++ b/pkg/apis/operator/v1alpha1/manualapprovalgate_validation_test.go @@ -0,0 +1,85 @@ +/* +Copyright 2026 The Tekton Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + "context" + "testing" + + "gotest.tools/v3/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/pkg/apis" +) + +func TestValidateManualApprovalGate_ValidConfig(t *testing.T) { + mag := &ManualApprovalGate{ + ObjectMeta: metav1.ObjectMeta{ + Name: ManualApprovalGates, + }, + Spec: ManualApprovalGateSpec{ + CommonSpec: CommonSpec{ + TargetNamespace: "tekton-pipelines", + }, + }, + } + + err := mag.Validate(context.TODO()) + if err != nil { + t.Errorf("expected no error, got: %v", err) + } +} + +func TestValidateManualApprovalGate_InvalidResourceName(t *testing.T) { + mag := &ManualApprovalGate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-name", + }, + Spec: ManualApprovalGateSpec{ + CommonSpec: CommonSpec{ + TargetNamespace: "tekton-pipelines", + }, + }, + } + + err := mag.Validate(context.TODO()) + assert.ErrorContains(t, err, "Only one instance of ManualApprovalGate is allowed") +} + +func TestValidateManualApprovalGate_MissingTargetNamespace(t *testing.T) { + mag := &ManualApprovalGate{ + ObjectMeta: metav1.ObjectMeta{ + Name: ManualApprovalGates, + }, + Spec: ManualApprovalGateSpec{}, + } + + err := mag.Validate(context.TODO()) + assert.ErrorContains(t, err, "missing field(s): spec.targetNamespace") +} + +func TestValidateManualApprovalGate_SkipOnDelete(t *testing.T) { + mag := &ManualApprovalGate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-name", + }, + } + + err := mag.Validate(apis.WithinDelete(context.TODO())) + if err != nil { + t.Errorf("expected no error on delete, got: %v", err) + } +} diff --git a/pkg/apis/operator/v1alpha1/tektonconfig_defaults.go b/pkg/apis/operator/v1alpha1/tektonconfig_defaults.go index 6dcc879221..563272b105 100644 --- a/pkg/apis/operator/v1alpha1/tektonconfig_defaults.go +++ b/pkg/apis/operator/v1alpha1/tektonconfig_defaults.go @@ -34,6 +34,7 @@ func (tc *TektonConfig) SetDefaults(ctx context.Context) { tc.Spec.Result.setDefaults() tc.Spec.TektonPruner.SetDefaults() tc.Spec.Scheduler.SetDefaults() + tc.Spec.ManualApproval.setDefaults() if IsOpenShiftPlatform() { // PAC may appear under spec.platforms.kubernetes if the mutating webhook ran without diff --git a/pkg/apis/operator/v1alpha1/tektonconfig_types.go b/pkg/apis/operator/v1alpha1/tektonconfig_types.go index 68b22d461b..fb5262abd8 100644 --- a/pkg/apis/operator/v1alpha1/tektonconfig_types.go +++ b/pkg/apis/operator/v1alpha1/tektonconfig_types.go @@ -116,6 +116,9 @@ type TektonConfigSpec struct { // Chain holds the customizable option for chains component // +optional Chain Chain `json:"chain,omitempty"` + // ManualApproval holds the customizable options for the ManualApprovalGate component + // +optional + ManualApproval ManualApproval `json:"manualApproval,omitempty"` // Result holds the customize option for results component // +optional Result Result `json:"result,omitempty"` diff --git a/pkg/apis/operator/v1alpha1/tektonconfig_validation.go b/pkg/apis/operator/v1alpha1/tektonconfig_validation.go index ea4242a731..b52fe9fbe5 100644 --- a/pkg/apis/operator/v1alpha1/tektonconfig_validation.go +++ b/pkg/apis/operator/v1alpha1/tektonconfig_validation.go @@ -142,6 +142,7 @@ func (tc *TektonConfig) Validate(ctx context.Context) (errs *apis.FieldError) { errs = errs.Also(tc.Spec.Trigger.Options.validate("spec.trigger.options")) errs = errs.Also(tc.Spec.Result.Options.validate("spec.result.options")) errs = errs.Also(tc.Spec.MulticlusterProxyAAE.Options.validate("spec.multiclusterProxyAAE.options")) + errs = errs.Also(tc.Spec.ManualApproval.Options.validate("spec.manualApproval.options")) return errs.Also(tc.Spec.Trigger.TriggersProperties.validate("spec.trigger")) } diff --git a/pkg/apis/operator/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/operator/v1alpha1/zz_generated.deepcopy.go index 6326fbd88d..4c827a882c 100644 --- a/pkg/apis/operator/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/operator/v1alpha1/zz_generated.deepcopy.go @@ -381,6 +381,11 @@ func (in *LokiStackProperties) DeepCopy() *LokiStackProperties { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ManualApproval) DeepCopyInto(out *ManualApproval) { *out = *in + if in.Disabled != nil { + in, out := &in.Disabled, &out.Disabled + *out = new(bool) + **out = **in + } in.Options.DeepCopyInto(&out.Options) return } @@ -1743,6 +1748,7 @@ func (in *TektonConfigSpec) DeepCopyInto(out *TektonConfigSpec) { in.Pipeline.DeepCopyInto(&out.Pipeline) in.Trigger.DeepCopyInto(&out.Trigger) in.Chain.DeepCopyInto(&out.Chain) + in.ManualApproval.DeepCopyInto(&out.ManualApproval) in.Result.DeepCopyInto(&out.Result) in.Dashboard.DeepCopyInto(&out.Dashboard) in.MulticlusterProxyAAE.DeepCopyInto(&out.MulticlusterProxyAAE) diff --git a/pkg/reconciler/shared/tektonconfig/manualapprovalgate/manualapprovalgate.go b/pkg/reconciler/shared/tektonconfig/manualapprovalgate/manualapprovalgate.go new file mode 100644 index 0000000000..c22cd73daa --- /dev/null +++ b/pkg/reconciler/shared/tektonconfig/manualapprovalgate/manualapprovalgate.go @@ -0,0 +1,160 @@ +/* +Copyright 2026 The Tekton Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package manualapprovalgate + +import ( + "context" + "fmt" + "reflect" + "strings" + + "github.com/tektoncd/operator/pkg/apis/operator/v1alpha1" + op "github.com/tektoncd/operator/pkg/client/clientset/versioned/typed/operator/v1alpha1" + apierrs "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "knative.dev/pkg/apis" +) + +func EnsureManualApprovalGateExists(ctx context.Context, clients op.ManualApprovalGateInterface, mag *v1alpha1.ManualApprovalGate) (*v1alpha1.ManualApprovalGate, error) { + magCR, err := GetManualApprovalGate(ctx, clients, v1alpha1.ManualApprovalGates) + if err != nil { + if !apierrs.IsNotFound(err) { + return nil, err + } + if err := CreateManualApprovalGate(ctx, clients, mag); err != nil { + return nil, err + } + return nil, v1alpha1.RECONCILE_AGAIN_ERR + } + + magCR, err = UpdateManualApprovalGate(ctx, magCR, mag, clients) + if err != nil { + return nil, err + } + + ready, err := isManualApprovalGateReady(magCR) + if err != nil { + return nil, err + } + if !ready { + return nil, v1alpha1.RECONCILE_AGAIN_ERR + } + + return magCR, err +} + +func EnsureManualApprovalGateCRNotExists(ctx context.Context, clients op.ManualApprovalGateInterface) error { + if _, err := GetManualApprovalGate(ctx, clients, v1alpha1.ManualApprovalGates); err != nil { + if apierrs.IsNotFound(err) { + return nil + } + return err + } + if err := clients.Delete(ctx, v1alpha1.ManualApprovalGates, metav1.DeleteOptions{}); err != nil { + if apierrs.IsNotFound(err) { + return nil + } + return fmt.Errorf("ManualApprovalGate %q failed to delete: %v", v1alpha1.ManualApprovalGates, err) + } + return v1alpha1.RECONCILE_AGAIN_ERR +} + +func GetManualApprovalGate(ctx context.Context, clients op.ManualApprovalGateInterface, name string) (*v1alpha1.ManualApprovalGate, error) { + return clients.Get(ctx, name, metav1.GetOptions{}) +} + +func CreateManualApprovalGate(ctx context.Context, clients op.ManualApprovalGateInterface, mag *v1alpha1.ManualApprovalGate) error { + _, err := clients.Create(ctx, mag, metav1.CreateOptions{}) + return err +} + +func UpdateManualApprovalGate(ctx context.Context, old *v1alpha1.ManualApprovalGate, new *v1alpha1.ManualApprovalGate, clients op.ManualApprovalGateInterface) (*v1alpha1.ManualApprovalGate, error) { + updated := false + + if old.ObjectMeta.Labels == nil { + old.ObjectMeta.Labels = map[string]string{} + } + + if new.Spec.TargetNamespace != old.Spec.TargetNamespace { + old.Spec.TargetNamespace = new.Spec.TargetNamespace + updated = true + } + + if !reflect.DeepEqual(old.Spec.ManualApproval, new.Spec.ManualApproval) { + old.Spec.ManualApproval = new.Spec.ManualApproval + updated = true + } + + if len(old.ObjectMeta.OwnerReferences) == 0 { + old.ObjectMeta.OwnerReferences = new.ObjectMeta.OwnerReferences + updated = true + } + + oldLabels, oldHasLabels := old.ObjectMeta.Labels[v1alpha1.ReleaseVersionKey] + newLabels, newHasLabels := new.ObjectMeta.Labels[v1alpha1.ReleaseVersionKey] + if !oldHasLabels || (newHasLabels && oldLabels != newLabels) { + old.ObjectMeta.Labels[v1alpha1.ReleaseVersionKey] = newLabels + updated = true + } + + oldPlatformData := old.ObjectMeta.Annotations[v1alpha1.PlatformDataHashKey] + newPlatformData := new.ObjectMeta.Annotations[v1alpha1.PlatformDataHashKey] + if oldPlatformData != newPlatformData { + if old.ObjectMeta.Annotations == nil { + old.ObjectMeta.Annotations = map[string]string{} + } + old.ObjectMeta.Annotations[v1alpha1.PlatformDataHashKey] = newPlatformData + updated = true + } + + if updated { + _, err := clients.Update(ctx, old, metav1.UpdateOptions{}) + if err != nil { + return nil, err + } + return nil, v1alpha1.RECONCILE_AGAIN_ERR + } + return old, nil +} + +func isManualApprovalGateReady(mag *v1alpha1.ManualApprovalGate) (bool, error) { + if mag.GetStatus() != nil && mag.GetStatus().GetCondition(apis.ConditionReady) != nil { + if strings.Contains(mag.GetStatus().GetCondition(apis.ConditionReady).Message, v1alpha1.UpgradePending) { + return false, v1alpha1.DEPENDENCY_UPGRADE_PENDING_ERR + } + } + return mag.Status.IsReady(), nil +} + +func GetManualApprovalGateCR(config *v1alpha1.TektonConfig, operatorVersion string) *v1alpha1.ManualApprovalGate { + ownerRef := *metav1.NewControllerRef(config, config.GroupVersionKind()) + return &v1alpha1.ManualApprovalGate{ + ObjectMeta: metav1.ObjectMeta{ + Name: v1alpha1.ManualApprovalGates, + OwnerReferences: []metav1.OwnerReference{ownerRef}, + Labels: map[string]string{ + v1alpha1.ReleaseVersionKey: operatorVersion, + }, + }, + Spec: v1alpha1.ManualApprovalGateSpec{ + CommonSpec: v1alpha1.CommonSpec{ + TargetNamespace: config.Spec.TargetNamespace, + }, + ManualApproval: config.Spec.ManualApproval, + }, + } +} diff --git a/pkg/reconciler/shared/tektonconfig/manualapprovalgate/manualapprovalgate_test.go b/pkg/reconciler/shared/tektonconfig/manualapprovalgate/manualapprovalgate_test.go new file mode 100644 index 0000000000..6b88a27888 --- /dev/null +++ b/pkg/reconciler/shared/tektonconfig/manualapprovalgate/manualapprovalgate_test.go @@ -0,0 +1,239 @@ +/* +Copyright 2026 The Tekton Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package manualapprovalgate + +import ( + "context" + "testing" + + "github.com/tektoncd/operator/pkg/apis/operator/v1alpha1" + op "github.com/tektoncd/operator/pkg/client/clientset/versioned/typed/operator/v1alpha1" + "github.com/tektoncd/operator/pkg/client/injection/client/fake" + util "github.com/tektoncd/operator/pkg/reconciler/common/testing" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ts "knative.dev/pkg/reconciler/testing" +) + +func TestEnsureManualApprovalGateExists(t *testing.T) { + ctx, _, _ := ts.SetupFakeContextWithCancel(t) + c := fake.Get(ctx) + mag := GetManualApprovalGateCR(getTektonConfig(), "v0.80.0") + + // first invocation should create instance as it is non-existent and return RECONCILE_AGAIN_ERR + _, err := EnsureManualApprovalGateExists(ctx, c.OperatorV1alpha1().ManualApprovalGates(), mag) + util.AssertEqual(t, err, v1alpha1.RECONCILE_AGAIN_ERR) + + // during second invocation instance exists but waiting on dependencies + // hence returns RECONCILE_AGAIN_ERR + _, err = EnsureManualApprovalGateExists(ctx, c.OperatorV1alpha1().ManualApprovalGates(), mag) + util.AssertEqual(t, err, v1alpha1.RECONCILE_AGAIN_ERR) + + // make upgrade checks pass + makeUpgradeCheckPass(t, ctx, c.OperatorV1alpha1().ManualApprovalGates()) + + // next invocation should return RECONCILE_AGAIN_ERR as MAG is waiting for installation + _, err = EnsureManualApprovalGateExists(ctx, c.OperatorV1alpha1().ManualApprovalGates(), mag) + util.AssertEqual(t, err, v1alpha1.RECONCILE_AGAIN_ERR) + + // mark the instance ready + markMAGReady(t, ctx, c.OperatorV1alpha1().ManualApprovalGates()) + + // next invocation should return nil error as the instance is ready + _, err = EnsureManualApprovalGateExists(ctx, c.OperatorV1alpha1().ManualApprovalGates(), mag) + util.AssertEqual(t, err, nil) + + // test update propagation from tektonConfig + mag.Spec.TargetNamespace = "foobar" + _, err = EnsureManualApprovalGateExists(ctx, c.OperatorV1alpha1().ManualApprovalGates(), mag) + util.AssertEqual(t, err, v1alpha1.RECONCILE_AGAIN_ERR) + + _, err = EnsureManualApprovalGateExists(ctx, c.OperatorV1alpha1().ManualApprovalGates(), mag) + util.AssertEqual(t, err, nil) +} + +func TestEnsureManualApprovalGateCRNotExists(t *testing.T) { + ctx, _, _ := ts.SetupFakeContextWithCancel(t) + c := fake.Get(ctx) + + // when no instance exists, nil error is returned immediately + err := EnsureManualApprovalGateCRNotExists(ctx, c.OperatorV1alpha1().ManualApprovalGates()) + util.AssertEqual(t, err, nil) + + // create an instance for testing other cases + mag := GetManualApprovalGateCR(getTektonConfig(), "v0.80.0") + _, err = EnsureManualApprovalGateExists(ctx, c.OperatorV1alpha1().ManualApprovalGates(), mag) + util.AssertEqual(t, err, v1alpha1.RECONCILE_AGAIN_ERR) + + // when an instance exists the first invocation should make the delete API call and + // return RECONCILE_AGAIN_ERR. So that the deletion can be confirmed in a subsequent invocation + err = EnsureManualApprovalGateCRNotExists(ctx, c.OperatorV1alpha1().ManualApprovalGates()) + util.AssertEqual(t, err, v1alpha1.RECONCILE_AGAIN_ERR) + + // when the instance is completely removed from a cluster, the function should return nil error + err = EnsureManualApprovalGateCRNotExists(ctx, c.OperatorV1alpha1().ManualApprovalGates()) + util.AssertEqual(t, err, nil) +} + +func TestEnsureManualApprovalGateExists_MigratesOwnerRef(t *testing.T) { + ctx, _, _ := ts.SetupFakeContextWithCancel(t) + c := fake.Get(ctx) + clients := c.OperatorV1alpha1().ManualApprovalGates() + + // simulate a standalone MAG CR created directly by the user (no ownerRef) + standalone := &v1alpha1.ManualApprovalGate{ + ObjectMeta: metav1.ObjectMeta{ + Name: v1alpha1.ManualApprovalGates, + }, + Spec: v1alpha1.ManualApprovalGateSpec{ + CommonSpec: v1alpha1.CommonSpec{ + TargetNamespace: "tekton-pipelines", + }, + }, + } + _, err := clients.Create(ctx, standalone, metav1.CreateOptions{}) + util.AssertEqual(t, err, nil) + + // verify the standalone CR has no ownerReferences + existing, err := clients.Get(ctx, v1alpha1.ManualApprovalGates, metav1.GetOptions{}) + util.AssertEqual(t, err, nil) + util.AssertEqual(t, len(existing.OwnerReferences), 0) + + // build the desired CR (with ownerRef from TektonConfig) + desired := GetManualApprovalGateCR(getTektonConfig(), "v0.80.0") + + // EnsureExists should adopt the standalone CR by adding the ownerRef + _, err = EnsureManualApprovalGateExists(ctx, clients, desired) + util.AssertEqual(t, err, v1alpha1.RECONCILE_AGAIN_ERR) + + // verify ownerRef was added + migrated, err := clients.Get(ctx, v1alpha1.ManualApprovalGates, metav1.GetOptions{}) + util.AssertEqual(t, err, nil) + util.AssertEqual(t, len(migrated.OwnerReferences), 1) + util.AssertEqual(t, migrated.OwnerReferences[0].Name, v1alpha1.ConfigResourceName) +} + +func TestEnsureManualApprovalGateExists_PropagatesPlatformDataHash(t *testing.T) { + ctx, _, _ := ts.SetupFakeContextWithCancel(t) + c := fake.Get(ctx) + clients := c.OperatorV1alpha1().ManualApprovalGates() + + // create an initial MAG CR without platform-data-hash + mag := GetManualApprovalGateCR(getTektonConfig(), "v0.80.0") + _, err := EnsureManualApprovalGateExists(ctx, clients, mag) + util.AssertEqual(t, err, v1alpha1.RECONCILE_AGAIN_ERR) + + makeUpgradeCheckPass(t, ctx, clients) + + // reconcile again after upgrade check updated labels + _, err = EnsureManualApprovalGateExists(ctx, clients, mag) + util.AssertEqual(t, err, v1alpha1.RECONCILE_AGAIN_ERR) + + markMAGReady(t, ctx, clients) + + _, err = EnsureManualApprovalGateExists(ctx, clients, mag) + util.AssertEqual(t, err, nil) + + // verify no platform-data-hash annotation exists yet + existing, err := clients.Get(ctx, v1alpha1.ManualApprovalGates, metav1.GetOptions{}) + util.AssertEqual(t, err, nil) + util.AssertEqual(t, existing.Annotations[v1alpha1.PlatformDataHashKey], "") + + // simulate TektonConfig setting platform-data-hash (TLS profile change) + mag.Annotations = map[string]string{ + v1alpha1.PlatformDataHashKey: "abc123", + } + _, err = EnsureManualApprovalGateExists(ctx, clients, mag) + util.AssertEqual(t, err, v1alpha1.RECONCILE_AGAIN_ERR) + + // verify annotation was propagated + updated, err := clients.Get(ctx, v1alpha1.ManualApprovalGates, metav1.GetOptions{}) + util.AssertEqual(t, err, nil) + util.AssertEqual(t, updated.Annotations[v1alpha1.PlatformDataHashKey], "abc123") + + markMAGReady(t, ctx, clients) + + // simulate a TLS profile change (hash changes) + mag.Annotations[v1alpha1.PlatformDataHashKey] = "def456" + _, err = EnsureManualApprovalGateExists(ctx, clients, mag) + util.AssertEqual(t, err, v1alpha1.RECONCILE_AGAIN_ERR) + + // verify the new hash was propagated + updated, err = clients.Get(ctx, v1alpha1.ManualApprovalGates, metav1.GetOptions{}) + util.AssertEqual(t, err, nil) + util.AssertEqual(t, updated.Annotations[v1alpha1.PlatformDataHashKey], "def456") +} + +func TestGetManualApprovalGateCR(t *testing.T) { + config := getTektonConfig() + mag := GetManualApprovalGateCR(config, "v0.80.0") + + util.AssertEqual(t, mag.Name, v1alpha1.ManualApprovalGates) + util.AssertEqual(t, mag.Spec.TargetNamespace, "tekton-pipelines") + util.AssertEqual(t, len(mag.OwnerReferences), 1) + util.AssertEqual(t, mag.OwnerReferences[0].Name, v1alpha1.ConfigResourceName) + util.AssertEqual(t, mag.Labels[v1alpha1.ReleaseVersionKey], "v0.80.0") +} + +func markMAGReady(t *testing.T, ctx context.Context, c op.ManualApprovalGateInterface) { + t.Helper() + mag, err := c.Get(ctx, v1alpha1.ManualApprovalGates, metav1.GetOptions{}) + util.AssertEqual(t, err, nil) + mag.Status.MarkDependenciesInstalled() + mag.Status.MarkPreReconcilerComplete() + mag.Status.MarkInstallerSetAvailable() + mag.Status.MarkInstallerSetReady() + mag.Status.MarkPostReconcilerComplete() + _, err = c.UpdateStatus(ctx, mag, metav1.UpdateOptions{}) + util.AssertEqual(t, err, nil) +} + +func makeUpgradeCheckPass(t *testing.T, ctx context.Context, c op.ManualApprovalGateInterface) { + t.Helper() + mag, err := c.Get(ctx, v1alpha1.ManualApprovalGates, metav1.GetOptions{}) + util.AssertEqual(t, err, nil) + setDummyVersionLabel(t, mag) + _, err = c.Update(ctx, mag, metav1.UpdateOptions{}) + util.AssertEqual(t, err, nil) +} + +func setDummyVersionLabel(t *testing.T, mag *v1alpha1.ManualApprovalGate) { + t.Helper() + + oprVersion := "v1.2.3" + t.Setenv(v1alpha1.VersionEnvKey, oprVersion) + + labels := mag.GetLabels() + if labels == nil { + labels = map[string]string{} + } + labels[v1alpha1.ReleaseVersionKey] = oprVersion + mag.SetLabels(labels) +} + +func getTektonConfig() *v1alpha1.TektonConfig { + return &v1alpha1.TektonConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: v1alpha1.ConfigResourceName, + }, + Spec: v1alpha1.TektonConfigSpec{ + Profile: v1alpha1.ProfileAll, + CommonSpec: v1alpha1.CommonSpec{ + TargetNamespace: "tekton-pipelines", + }, + }, + } +} diff --git a/pkg/reconciler/shared/tektonconfig/tektonconfig.go b/pkg/reconciler/shared/tektonconfig/tektonconfig.go index 05d22cd27f..c589c7641c 100644 --- a/pkg/reconciler/shared/tektonconfig/tektonconfig.go +++ b/pkg/reconciler/shared/tektonconfig/tektonconfig.go @@ -26,6 +26,7 @@ import ( tektonConfigreconciler "github.com/tektoncd/operator/pkg/client/injection/reconciler/operator/v1alpha1/tektonconfig" "github.com/tektoncd/operator/pkg/reconciler/common" "github.com/tektoncd/operator/pkg/reconciler/shared/tektonconfig/chain" + "github.com/tektoncd/operator/pkg/reconciler/shared/tektonconfig/manualapprovalgate" "github.com/tektoncd/operator/pkg/reconciler/shared/tektonconfig/multiclusterproxyaae" "github.com/tektoncd/operator/pkg/reconciler/shared/tektonconfig/pipeline" "github.com/tektoncd/operator/pkg/reconciler/shared/tektonconfig/pruner" @@ -80,6 +81,9 @@ func (r *Reconciler) FinalizeKind(ctx context.Context, original *v1alpha1.Tekton if err := chain.EnsureTektonChainCRNotExists(ctx, r.operatorClientSet.OperatorV1alpha1().TektonChains()); err != nil { return err } + if err := manualapprovalgate.EnsureManualApprovalGateCRNotExists(ctx, r.operatorClientSet.OperatorV1alpha1().ManualApprovalGates()); err != nil { + return err + } if err := result.EnsureTektonResultCRNotExists(ctx, r.operatorClientSet.OperatorV1alpha1().TektonResults()); err != nil { return err } @@ -320,6 +324,45 @@ func (r *Reconciler) ReconcileKind(ctx context.Context, tc *v1alpha1.TektonConfi logger.Debug("TektonChain CR removal reconciled successfully") } + // Ensure ManualApprovalGate CR + // On upgrade: if a standalone MAG CR exists (no ownerRef, from a previous version), + // adopt it under TektonConfig regardless of the disabled setting. + // On fresh install: MAG is disabled by default and not created. + magEnabled := !tc.Spec.ManualApproval.IsDisabled() + if !magEnabled { + existingMAG, err := manualapprovalgate.GetManualApprovalGate(ctx, r.operatorClientSet.OperatorV1alpha1().ManualApprovalGates(), v1alpha1.ManualApprovalGates) + if err == nil && (tc.Spec.ManualApproval.Disabled == nil || len(existingMAG.OwnerReferences) == 0) { + logger.Debug("Found standalone ManualApprovalGate CR from previous version, adopting under TektonConfig") + magEnabled = true + } + } + if magEnabled { + magCR := manualapprovalgate.GetManualApprovalGateCR(tc, r.operatorVersion) + if platformData := r.extension.GetPlatformData(); platformData != "" { + if magCR.Annotations == nil { + magCR.Annotations = map[string]string{} + } + magCR.Annotations[v1alpha1.PlatformDataHashKey] = platformData + } + logger.Debug("Ensuring ManualApprovalGate CR exists") + if _, err := manualapprovalgate.EnsureManualApprovalGateExists(ctx, r.operatorClientSet.OperatorV1alpha1().ManualApprovalGates(), magCR); err != nil { + errMsg := fmt.Sprintf("ManualApprovalGate: %s", err.Error()) + logger.Errorw("Failed to ensure ManualApprovalGate exists", "error", err) + tc.Status.MarkComponentNotReady(errMsg) + return v1alpha1.REQUEUE_EVENT_AFTER + } + logger.Debug("ManualApprovalGate CR reconciled successfully") + } else { + logger.Debugw("Ensuring ManualApprovalGate CR doesn't exist", "manualApprovalDisabled", tc.Spec.ManualApproval.IsDisabled()) + if err := manualapprovalgate.EnsureManualApprovalGateCRNotExists(ctx, r.operatorClientSet.OperatorV1alpha1().ManualApprovalGates()); err != nil { + errMsg := fmt.Sprintf("ManualApprovalGate: %s", err.Error()) + logger.Errorw("Failed to ensure ManualApprovalGate has been deleted", "error", err) + tc.Status.MarkComponentNotReady(errMsg) + return v1alpha1.REQUEUE_EVENT_AFTER + } + logger.Debug("ManualApprovalGate CR removal reconciled successfully") + } + // Ensure Result CR if !tc.Spec.Result.Disabled && (tc.Spec.Profile == v1alpha1.ProfileAll || tc.Spec.Profile == v1alpha1.ProfileBasic) { tektonresult := result.GetTektonResultCR(tc, r.operatorVersion) diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go index e5944a9a55..7308bf428d 100644 --- a/pkg/webhook/webhook.go +++ b/pkg/webhook/webhook.go @@ -31,12 +31,13 @@ import ( ) var types = map[schema.GroupVersionKind]resourcesemantics.GenericCRD{ - v1alpha1.SchemeGroupVersion.WithKind(v1alpha1.KindTektonConfig): &v1alpha1.TektonConfig{}, - v1alpha1.SchemeGroupVersion.WithKind(v1alpha1.KindTektonPipeline): &v1alpha1.TektonPipeline{}, - v1alpha1.SchemeGroupVersion.WithKind(v1alpha1.KindTektonTrigger): &v1alpha1.TektonTrigger{}, - v1alpha1.SchemeGroupVersion.WithKind(v1alpha1.KindTektonResult): &v1alpha1.TektonResult{}, - v1alpha1.SchemeGroupVersion.WithKind(v1alpha1.KindTektonChain): &v1alpha1.TektonChain{}, - v1alpha1.SchemeGroupVersion.WithKind(v1alpha1.KindTektonPruner): &v1alpha1.TektonPruner{}, + v1alpha1.SchemeGroupVersion.WithKind(v1alpha1.KindTektonConfig): &v1alpha1.TektonConfig{}, + v1alpha1.SchemeGroupVersion.WithKind(v1alpha1.KindTektonPipeline): &v1alpha1.TektonPipeline{}, + v1alpha1.SchemeGroupVersion.WithKind(v1alpha1.KindTektonTrigger): &v1alpha1.TektonTrigger{}, + v1alpha1.SchemeGroupVersion.WithKind(v1alpha1.KindTektonResult): &v1alpha1.TektonResult{}, + v1alpha1.SchemeGroupVersion.WithKind(v1alpha1.KindTektonChain): &v1alpha1.TektonChain{}, + v1alpha1.SchemeGroupVersion.WithKind(v1alpha1.KindTektonPruner): &v1alpha1.TektonPruner{}, + v1alpha1.SchemeGroupVersion.WithKind(v1alpha1.KindManualApprovalGate): &v1alpha1.ManualApprovalGate{}, } func SetTypes(platform string) {