diff --git a/api/v1/clustercatalog_types.go b/api/v1/clustercatalog_types.go index ee1391b79..d03df4a0d 100644 --- a/api/v1/clustercatalog_types.go +++ b/api/v1/clustercatalog_types.go @@ -36,7 +36,9 @@ const ( AvailabilityModeUnavailable AvailabilityMode = "Unavailable" // Condition types - TypeServing = "Serving" + TypeServing = "Serving" + TypeArbackulationApprovalRequired = "ArbackulationApprovalRequired" + TypeArbackulationApprovalGranted = "ArbackulationApprovalGranted" // Serving Reasons ReasonAvailable = "Available" diff --git a/api/v1/clusterextension_types.go b/api/v1/clusterextension_types.go index 0141f1a7a..b7ab8df73 100644 --- a/api/v1/clusterextension_types.go +++ b/api/v1/clusterextension_types.go @@ -25,6 +25,8 @@ var ClusterExtensionKind = "ClusterExtension" type ( UpgradeConstraintPolicy string CRDUpgradeSafetyEnforcement string + + DeploymentApprovalPolicy string ) const ( @@ -39,6 +41,9 @@ const ( // Use with caution as this can lead to unknown and potentially // disastrous results such as data loss. UpgradeConstraintPolicySelfCertified UpgradeConstraintPolicy = "SelfCertified" + + DeploymentApprovalPolicyAutomatic DeploymentApprovalPolicy = "Automatic" + DeploymentApprovalPolicyManual DeploymentApprovalPolicy = "Manual" ) // ClusterExtensionSpec defines the desired state of ClusterExtension @@ -92,6 +97,13 @@ type ClusterExtensionSpec struct { // // +optional Install *ClusterExtensionInstallConfig `json:"install,omitempty"` + + // how to handle operator lifecycle rbac requirements + // Automatic: assign necessary rbac to service account + // Manual: + // +kubebuilder:default:=Manual + // +optional + DeploymentApprovalPolicy DeploymentApprovalPolicy `json:"deploymentApprovalPolicy,omitempty"` } const SourceTypeCatalog = "Catalog" diff --git a/api/v1/common_types.go b/api/v1/common_types.go index 5478039c9..e790403c1 100644 --- a/api/v1/common_types.go +++ b/api/v1/common_types.go @@ -21,9 +21,10 @@ const ( TypeProgressing = "Progressing" // Progressing reasons - ReasonSucceeded = "Succeeded" - ReasonRetrying = "Retrying" - ReasonBlocked = "Blocked" + ReasonSucceeded = "Succeeded" + ReasonRetrying = "Retrying" + ReasonBlocked = "Blocked" + ReasonArbackulationApprovalRequired = "ArbackulationApprovalRequired" // Terminal reasons ReasonDeprecated = "Deprecated" diff --git a/cmd/operator-controller/main.go b/cmd/operator-controller/main.go index a9cd69863..a9c01a9a6 100644 --- a/cmd/operator-controller/main.go +++ b/cmd/operator-controller/main.go @@ -424,6 +424,9 @@ func run() error { Preflights: preflights, BundleToHelmChartFn: convert.RegistryV1ToHelmChart, PreAuthorizer: preAuth, + Arbackulator: &applier.Arbackulator{ + Client: cl, + }, } cm := contentmanager.NewManager(clientRestConfigMapper, mgr.GetConfig(), mgr.GetRESTMapper()) diff --git a/config/base/operator-controller/crd/bases/olm.operatorframework.io_clusterextensions.yaml b/config/base/operator-controller/crd/bases/olm.operatorframework.io_clusterextensions.yaml index a582917aa..7e01d5a5e 100644 --- a/config/base/operator-controller/crd/bases/olm.operatorframework.io_clusterextensions.yaml +++ b/config/base/operator-controller/crd/bases/olm.operatorframework.io_clusterextensions.yaml @@ -56,6 +56,13 @@ spec: description: spec is an optional field that defines the desired state of the ClusterExtension. properties: + deploymentApprovalPolicy: + default: Manual + description: |- + how to handle operator lifecycle rbac requirements + Automatic: assign necessary rbac to service account + Manual: + type: string install: description: |- install is an optional field used to configure the installation options diff --git a/config/base/operator-controller/rbac/role.yaml b/config/base/operator-controller/rbac/role.yaml index be89deec1..e2caba13b 100644 --- a/config/base/operator-controller/rbac/role.yaml +++ b/config/base/operator-controller/rbac/role.yaml @@ -10,6 +10,12 @@ rules: - serviceaccounts/token verbs: - create +- apiGroups: + - '*' + resources: + - '*' + verbs: + - '*' - apiGroups: - apiextensions.k8s.io resources: diff --git a/internal/operator-controller/applier/helm.go b/internal/operator-controller/applier/helm.go index 7691989e6..d81a1317a 100644 --- a/internal/operator-controller/applier/helm.go +++ b/internal/operator-controller/applier/helm.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "io/fs" + "k8s.io/apimachinery/pkg/api/meta" "slices" "strings" @@ -31,11 +32,12 @@ import ( ) const ( - StateNeedsInstall string = "NeedsInstall" - StateNeedsUpgrade string = "NeedsUpgrade" - StateUnchanged string = "Unchanged" - StateError string = "Error" - maxHelmReleaseHistory = 10 + StateNeedsInstall string = "NeedsInstall" + StateNeedsUpgrade string = "NeedsUpgrade" + StateUnchanged string = "Unchanged" + StateNeedsArbackulationApproval string = "ArbackulationRequired" + StateError string = "Error" + maxHelmReleaseHistory = 10 ) // Preflight is a check that should be run before making any changes to the cluster @@ -60,6 +62,7 @@ type Helm struct { Preflights []Preflight PreAuthorizer authorization.PreAuthorizer BundleToHelmChartFn BundleToHelmChartFn + Arbackulator *Arbackulator } // shouldSkipPreflight is a helper to determine if the preflight check is CRDUpgradeSafety AND @@ -85,10 +88,10 @@ func shouldSkipPreflight(ctx context.Context, preflight Preflight, ext *ocv1.Clu // runPreAuthorizationChecks performs pre-authorization checks for a Helm release // it renders a client-only release, checks permissions using the PreAuthorizer // and returns an error if authorization fails or required permissions are missing -func (h *Helm) runPreAuthorizationChecks(ctx context.Context, ext *ocv1.ClusterExtension, chart *chart.Chart, values chartutil.Values, post postrender.PostRenderer) error { +func (h *Helm) runPreAuthorizationChecks(ctx context.Context, ext *ocv1.ClusterExtension, chart *chart.Chart, values chartutil.Values, post postrender.PostRenderer) ([]authorization.ScopedPolicyRules, error) { tmplRel, err := h.renderClientOnlyRelease(ctx, ext, chart, values, post) if err != nil { - return fmt.Errorf("failed to get release state using client-only dry-run: %w", err) + return nil, fmt.Errorf("failed to get release state using client-only dry-run: %w", err) } missingRules, authErr := h.PreAuthorizer.PreAuthorize(ctx, ext, strings.NewReader(tmplRel.Manifest)) @@ -111,9 +114,9 @@ func (h *Helm) runPreAuthorizationChecks(ctx context.Context, ext *ocv1.ClusterE } if len(preAuthErrors) > 0 { // This phrase is explicitly checked by external testing - return fmt.Errorf("pre-authorization failed: %v", errors.Join(preAuthErrors...)) + return missingRules, fmt.Errorf("pre-authorization failed: %v", errors.Join(preAuthErrors...)) } - return nil + return nil, nil } func (h *Helm) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExtension, objectLabels map[string]string, storageLabels map[string]string) ([]client.Object, string, error) { @@ -128,10 +131,18 @@ func (h *Helm) Apply(ctx context.Context, contentFS fs.FS, ext *ocv1.ClusterExte } if h.PreAuthorizer != nil { - err := h.runPreAuthorizationChecks(ctx, ext, chrt, values, post) - if err != nil { - // Return the pre-authorization error directly - return nil, "", err + missingRules, err := h.runPreAuthorizationChecks(ctx, ext, chrt, values, post) + if len(missingRules) > 0 { + if shouldArbackulate(ext) { + if err := h.Arbackulator.GrantPassage(ctx, ext, missingRules); err != nil { + return nil, "", fmt.Errorf("error arbackulating: %w", err) + } + return nil, StateNeedsInstall, nil + } else if ext.Spec.DeploymentApprovalPolicy == ocv1.DeploymentApprovalPolicyManual { + return nil, StateNeedsArbackulationApproval, err + } else { + return nil, "", err + } } } @@ -323,3 +334,10 @@ func ruleDescription(ns string, rule rbacv1.PolicyRule) string { } return sb.String() } + +func shouldArbackulate(cExt *ocv1.ClusterExtension) bool { + if cExt.Spec.DeploymentApprovalPolicy == ocv1.DeploymentApprovalPolicyAutomatic { + return true + } + return meta.FindStatusCondition(cExt.Status.Conditions, ocv1.TypeArbackulationApprovalGranted) != nil +} diff --git a/internal/operator-controller/applier/rbacker.go b/internal/operator-controller/applier/rbacker.go new file mode 100644 index 000000000..37fc095ad --- /dev/null +++ b/internal/operator-controller/applier/rbacker.go @@ -0,0 +1,126 @@ +package applier + +import ( + "context" + "fmt" + ocv1 "github.com/operator-framework/operator-controller/api/v1" + "github.com/operator-framework/operator-controller/internal/operator-controller/authorization" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" +) + +type Arbackulator struct { + Client client.Client +} + +func (t *Arbackulator) GrantPassage(ctx context.Context, cExt *ocv1.ClusterExtension, perms []authorization.ScopedPolicyRules) error { + permMap := map[string][]rbacv1.PolicyRule{} + for _, perm := range perms { + permMap[perm.Namespace] = append(permMap[perm.Namespace], perm.MissingRules...) + } + + for ns, rules := range permMap { + switch ns { + case "": + if err := t.updateClusterPerms(ctx, cExt, rules); err != nil { + return err + } + default: + if err := t.updateNamespacedPerms(ctx, cExt, ns, rules); err != nil { + return err + } + } + } + return nil +} + +func (t *Arbackulator) updateClusterPerms(ctx context.Context, cExt *ocv1.ClusterExtension, rules []rbacv1.PolicyRule) error { + clusterRoleName := fmt.Sprintf("%s-mananaged-cluster-role", cExt.Name) + role := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleName, + }, + } + + if _, err := controllerutil.CreateOrUpdate(ctx, t.Client, role, func() error { + role.Rules = append(role.Rules, rules...) + return nil + }); err != nil { + return err + } + + clusterRoleBindingName := fmt.Sprintf("%s-mananaged-cluster-rolebinding", cExt.Name) + roleBinding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleBindingName, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: cExt.Spec.ServiceAccount.Name, + Namespace: cExt.Spec.Namespace, + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "ClusterRole", + Name: clusterRoleName, + }, + } + + if _, err := controllerutil.CreateOrUpdate(ctx, t.Client, roleBinding, func() error { + return nil + }); err != nil { + return err + } + + return nil +} + +func (t *Arbackulator) updateNamespacedPerms(ctx context.Context, cExt *ocv1.ClusterExtension, namespace string, rules []rbacv1.PolicyRule) error { + roleName := fmt.Sprintf("%s-mananaged-role", cExt.Name) + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: roleName, + Namespace: namespace, + }, + } + + if _, err := controllerutil.CreateOrUpdate(ctx, t.Client, role, func() error { + role.Rules = append(role.Rules, rules...) + return nil + }); err != nil { + return err + } + + roleBindingName := fmt.Sprintf("%s-mananaged-rolebinding", cExt.Name) + roleBinding := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: roleBindingName, + Namespace: namespace, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: cExt.Spec.ServiceAccount.Name, + Namespace: cExt.Spec.Namespace, + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "Role", + Name: roleName, + }, + } + + if _, err := controllerutil.CreateOrUpdate(ctx, t.Client, roleBinding, func() error { + return nil + }); err != nil { + return err + } + + return nil +} diff --git a/internal/operator-controller/controllers/clusterextension_controller.go b/internal/operator-controller/controllers/clusterextension_controller.go index e571174b0..d0cc5a840 100644 --- a/internal/operator-controller/controllers/clusterextension_controller.go +++ b/internal/operator-controller/controllers/clusterextension_controller.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "github.com/operator-framework/operator-controller/internal/operator-controller/applier" "io/fs" "strings" "time" @@ -97,9 +98,10 @@ type InstalledBundleGetter interface { //+kubebuilder:rbac:groups=core,resources=serviceaccounts/token,verbs=create //+kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=get //+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterroles;clusterrolebindings;roles;rolebindings,verbs=list;watch - //+kubebuilder:rbac:groups=olm.operatorframework.io,resources=clustercatalogs,verbs=list;watch +//+kubebuilder:rbac:groups=*,resources=*,verbs=* + // The operator controller needs to watch all the bundle objects and reconcile accordingly. Though not ideal, but these permissions are required. // This has been taken from rukpak, and an issue was created before to discuss it: https://github.com/operator-framework/rukpak/issues/800. func (r *ClusterExtensionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { @@ -287,14 +289,32 @@ func (r *ClusterExtensionReconciler) reconcile(ctx context.Context, ext *ocv1.Cl // to ensure exponential backoff can occur: // - Permission errors (it is not possible to watch changes to permissions. // The only way to eventually recover from permission errors is to keep retrying). - managedObjs, _, err := r.Applier.Apply(ctx, imageFS, ext, objLbls, storeLbls) + managedObjs, state, err := r.Applier.Apply(ctx, imageFS, ext, objLbls, storeLbls) if err != nil { + if state == applier.StateNeedsArbackulationApproval { + progressingCond := metav1.Condition{ + Type: ocv1.TypeProgressing, + Status: metav1.ConditionFalse, + Reason: ocv1.ReasonArbackulationApprovalRequired, + Message: err.Error(), + ObservedGeneration: ext.GetGeneration(), + } + apimeta.SetStatusCondition(&ext.Status.Conditions, progressingCond) + return ctrl.Result{}, nil + } + setStatusProgressing(ext, wrapErrorWithResolutionInfo(resolvedBundleMetadata, err)) // Now that we're actually trying to install, use the error setInstalledStatusFromBundle(ext, installedBundle) return ctrl.Result{}, err } + // if state is needs install and not objects were generated + // applier needs another round of reconciliation + if state == applier.StateNeedsInstall && managedObjs == nil { + return ctrl.Result{Requeue: true}, nil + } + newInstalledBundle := &InstalledBundle{ BundleMetadata: resolvedBundleMetadata, Image: resolvedBundle.Image,