diff --git a/README.md b/README.md index 73545964e..01b49d13b 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,34 @@ An operator to install and configure [Shipwright](https://shipwright.io) on Kube ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for more information on how to build, test, and submit contributions to the operator. +See [CONTRIBUTING.md](CONTRIBUTING.md) for more information on how to build, test, and submit +contributions to the operator. + +## Usage + +To deploy and manage instances of [Shipwright Build-Controller][build-controller], make sure this +operator is up-and-running, and then create the following: + +```yml +--- +apiVersion: v1 +kind: Namespace +metadata: + name: shipwright-build +spec: {} + +--- +apiVersion: operator.shipwright.io/v1alpha1 +kind: ShipwrightBuild +metadata: + name: shipwright-operator +spec: + targetNamespace: shipwright-build + namespace: default +``` + +It will deploy the Build-Controller in `shipwright-build` namespace. When `.spec.namespace` is not +set, it will use the `shipwright-build` namespace, this namespace needs to be created before the +actual deployment takes place. + +[build-controller]: https://github.com/shipwright-io/build diff --git a/api/v1alpha1/doc.go b/api/v1alpha1/doc.go new file mode 100644 index 000000000..bd03cab6a --- /dev/null +++ b/api/v1alpha1/doc.go @@ -0,0 +1,4 @@ +// This package contains the CRD code, describing how the operator API will work in Kubernetes. When +// the contents of this package are modified, you must run `make` command to make sure files with +// `zz_generated.` prefix are updated, the additional code is generated as expected. +package v1alpha1 diff --git a/api/v1alpha1/shipwrightbuild_types.go b/api/v1alpha1/shipwrightbuild_types.go index 5a7432e74..8c7eb62e0 100644 --- a/api/v1alpha1/shipwrightbuild_types.go +++ b/api/v1alpha1/shipwrightbuild_types.go @@ -8,29 +8,20 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! -// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. - -// ShipwrightBuildSpec defines the desired state of ShipwrightBuild +// ShipwrightBuildSpec defines the configuration of a Shipwright Build deployment. type ShipwrightBuildSpec struct { - // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster - // Important: Run "make" to regenerate code after modifying this file - - // Foo is an example field of ShipwrightBuild. Edit ShipwrightBuild_types.go to remove/update - Foo string `json:"foo,omitempty"` + // TargetNamespace is the target namespace where Shipwright's build controller will be deployed. + TargetNamespace string `json:"targetNamespace,omitempty"` } -// ShipwrightBuildStatus defines the observed state of ShipwrightBuild -type ShipwrightBuildStatus struct { - // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster - // Important: Run "make" to regenerate code after modifying this file -} +// ShipwrightBuildStatus defines the observed state of Shipwright-Build +type ShipwrightBuildStatus struct{} // +kubebuilder:object:root=true // +kubebuilder:resource:scope=Cluster // +kubebuilder:subresource:status -// ShipwrightBuild is the Schema for the shipwrightbuilds API +// ShipwrightBuild represents the deployment of Shipwright's build controller on a Kubernetes cluster. type ShipwrightBuild struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` @@ -45,9 +36,11 @@ type ShipwrightBuild struct { type ShipwrightBuildList struct { metav1.TypeMeta `json:",inline"` metav1.ListMeta `json:"metadata,omitempty"` - Items []ShipwrightBuild `json:"items"` + + Items []ShipwrightBuild `json:"items"` } +// init registers the current Schema on the Scheme Builder during initialization. func init() { SchemeBuilder.Register(&ShipwrightBuild{}, &ShipwrightBuildList{}) } diff --git a/bundle/manifests/operator.clusterserviceversion.yaml b/bundle/manifests/operator.clusterserviceversion.yaml index 122ec5e44..6d79fc9b0 100644 --- a/bundle/manifests/operator.clusterserviceversion.yaml +++ b/bundle/manifests/operator.clusterserviceversion.yaml @@ -24,7 +24,7 @@ spec: apiservicedefinitions: {} customresourcedefinitions: owned: - - description: ShipwrightBuild is the Schema for the shipwrightbuilds API + - description: ShipwrightBuild represents the deployment of Shipwright's build controller on a Kubernetes cluster. displayName: Shipwright Build kind: ShipwrightBuild name: shipwrightbuilds.operator.shipwright.io diff --git a/bundle/manifests/operator.shipwright.io_shipwrightbuilds.yaml b/bundle/manifests/operator.shipwright.io_shipwrightbuilds.yaml index 389398957..35a1ea339 100644 --- a/bundle/manifests/operator.shipwright.io_shipwrightbuilds.yaml +++ b/bundle/manifests/operator.shipwright.io_shipwrightbuilds.yaml @@ -17,7 +17,7 @@ spec: - name: v1alpha1 schema: openAPIV3Schema: - description: ShipwrightBuild is the Schema for the shipwrightbuilds API + description: ShipwrightBuild represents the deployment of Shipwright's build controller on a Kubernetes cluster. 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' @@ -28,14 +28,14 @@ spec: metadata: type: object spec: - description: ShipwrightBuildSpec defines the desired state of ShipwrightBuild + description: ShipwrightBuildSpec defines the configuration of a Shipwright Build deployment. properties: - foo: - description: Foo is an example field of ShipwrightBuild. Edit ShipwrightBuild_types.go to remove/update + targetNamespace: + description: TargetNamespace is the target namespace where Shipwright's build controller will be deployed. type: string type: object status: - description: ShipwrightBuildStatus defines the observed state of ShipwrightBuild + description: ShipwrightBuildStatus defines the observed state of Shipwright-Build type: object type: object served: true diff --git a/cmd/operator/main.go b/cmd/operator/main.go index af4c59538..a222cf9a4 100644 --- a/cmd/operator/main.go +++ b/cmd/operator/main.go @@ -81,8 +81,8 @@ func main() { if err = (&controllers.ShipwrightBuildReconciler{ Client: mgr.GetClient(), - Log: ctrl.Log.WithName("controllers").WithName("ShipwrightBuild"), Scheme: mgr.GetScheme(), + Logger: ctrl.Log.WithName("controllers").WithName("ShipwrightBuild"), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "ShipwrightBuild") os.Exit(1) diff --git a/config/crd/bases/operator.shipwright.io_shipwrightbuilds.yaml b/config/crd/bases/operator.shipwright.io_shipwrightbuilds.yaml index 7a7eb2435..781c5f616 100644 --- a/config/crd/bases/operator.shipwright.io_shipwrightbuilds.yaml +++ b/config/crd/bases/operator.shipwright.io_shipwrightbuilds.yaml @@ -19,7 +19,8 @@ spec: - name: v1alpha1 schema: openAPIV3Schema: - description: ShipwrightBuild is the Schema for the shipwrightbuilds API + description: ShipwrightBuild represents the deployment of Shipwright's build + controller on a Kubernetes cluster. properties: apiVersion: description: 'APIVersion defines the versioned schema of this representation @@ -34,15 +35,16 @@ spec: metadata: type: object spec: - description: ShipwrightBuildSpec defines the desired state of ShipwrightBuild + description: ShipwrightBuildSpec defines the configuration of a Shipwright + Build deployment. properties: - foo: - description: Foo is an example field of ShipwrightBuild. Edit ShipwrightBuild_types.go - to remove/update + targetNamespace: + description: TargetNamespace is the target namespace where Shipwright's + build controller will be deployed. type: string type: object status: - description: ShipwrightBuildStatus defines the observed state of ShipwrightBuild + description: ShipwrightBuildStatus defines the observed state of Shipwright-Build type: object type: object served: true diff --git a/config/manifests/bases/operator.clusterserviceversion.yaml b/config/manifests/bases/operator.clusterserviceversion.yaml index 4e462e9a6..af9a33522 100644 --- a/config/manifests/bases/operator.clusterserviceversion.yaml +++ b/config/manifests/bases/operator.clusterserviceversion.yaml @@ -10,7 +10,7 @@ spec: apiservicedefinitions: {} customresourcedefinitions: owned: - - description: ShipwrightBuild is the Schema for the shipwrightbuilds API + - description: ShipwrightBuild represents the deployment of Shipwright's build controller on a Kubernetes cluster. displayName: Shipwright Build kind: ShipwrightBuild name: shipwrightbuilds.operator.shipwright.io diff --git a/controllers/default_test.go b/controllers/default_test.go index 4d1e5294f..dfe7ecdba 100644 --- a/controllers/default_test.go +++ b/controllers/default_test.go @@ -7,56 +7,77 @@ import ( corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/types" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/shipwright-io/operator/api/v1alpha1" "github.com/shipwright-io/operator/test" ) +// createNamespace creates the namespace informed. +func createNamespace(name string) { + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name}} + err := k8sClient.Get(ctx, types.NamespacedName{Name: ns.Name}, ns) + if errors.IsNotFound(err) { + err = k8sClient.Create(ctx, ns, &client.CreateOptions{}) + } + o.Expect(err).NotTo(o.HaveOccurred()) +} + var _ = g.Describe("Reconcile default ShipwrightBuild installation", func() { + // namespace where ShipwrightBuild instance will be located + const namespace = "namespace" + // targetNamespace namespace where shipwright Controller and dependencies will be located + const targetNamespace = "target-namespace" + // build Build instance employed during testing var build *v1alpha1.ShipwrightBuild g.BeforeEach(func() { - namespace := &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "shipwright-build", - }, - } - err := k8sClient.Get(ctx, types.NamespacedName{Name: namespace.Name}, namespace) - if errors.IsNotFound(err) { - err = k8sClient.Create(ctx, namespace, &client.CreateOptions{}) - } - o.Expect(err).NotTo(o.HaveOccurred()) + // setting up the namespaces, where Shipwright Controller will be deployed + createNamespace(namespace) + createNamespace(targetNamespace) g.By("creating a ShipwrightBuild instance") build = &v1alpha1.ShipwrightBuild{ ObjectMeta: metav1.ObjectMeta{ - Name: "cluster", + Namespace: namespace, + Name: "cluster", + }, + Spec: v1alpha1.ShipwrightBuildSpec{ + TargetNamespace: targetNamespace, }, - Spec: v1alpha1.ShipwrightBuildSpec{}, } - err = k8sClient.Create(ctx, build, &client.CreateOptions{}) + err := k8sClient.Create(ctx, build, &client.CreateOptions{}) o.Expect(err).NotTo(o.HaveOccurred()) + + // when the finalizer is in place, the deployment of manifest elements is done, and therefore + // functional testing can proceed + g.By("waiting for the finalizer to be set") + test.EventuallyContainFinalizer(ctx, k8sClient, build, FinalizerAnnotation) }) g.AfterEach(func() { g.By("deleting the ShipwrightBuild instance") - err := k8sClient.Get(ctx, types.NamespacedName{Name: build.Name}, build) + namespacedName := types.NamespacedName{Namespace: namespace, Name: build.Name} + err := k8sClient.Get(ctx, namespacedName, build) if errors.IsNotFound(err) { return } o.Expect(err).NotTo(o.HaveOccurred()) + err = k8sClient.Delete(ctx, build, &client.DeleteOptions{}) o.Expect(err).NotTo(o.HaveOccurred()) - g.By("checking that the shipwright-build deployment has been removed") + + g.By("waiting for ShipwrightBuild instance to be completely removed") + test.EventuallyRemoved(ctx, k8sClient, build) + + g.By("checking that the shipwright-build-controller deployment has been removed") deployment := &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ + Namespace: targetNamespace, Name: "shipwright-build-controller", - Namespace: "shipwright-build", }, } test.EventuallyRemoved(ctx, k8sClient, deployment) @@ -81,7 +102,7 @@ var _ = g.Describe("Reconcile default ShipwrightBuild installation", func() { expectedServiceAccount := &corev1.ServiceAccount{ ObjectMeta: metav1.ObjectMeta{ - Namespace: "shipwright-build", + Namespace: targetNamespace, Name: "shipwright-build-controller", }, } @@ -91,7 +112,7 @@ var _ = g.Describe("Reconcile default ShipwrightBuild installation", func() { g.It("creates a deployment for the Shipwright build controller", func() { expectedDeployment := &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ - Namespace: "shipwright-build", + Namespace: targetNamespace, Name: "shipwright-build-controller", }, } @@ -111,17 +132,19 @@ var _ = g.Describe("Reconcile default ShipwrightBuild installation", func() { g.It("deletes the RBAC for the Shipwright build controller", func() { expectedClusterRole := &rbacv1.ClusterRole{ ObjectMeta: metav1.ObjectMeta{ - Name: "shipwright-build-controller", + Namespace: targetNamespace, + Name: "shipwright-build-controller", }, } expectedClusterRoleBinding := &rbacv1.ClusterRoleBinding{ ObjectMeta: metav1.ObjectMeta{ - Name: "shipwright-build-controller", + Namespace: targetNamespace, + Name: "shipwright-build-controller", }, } expectedServiceAccount := &corev1.ServiceAccount{ ObjectMeta: metav1.ObjectMeta{ - Namespace: "shipwright-build", + Namespace: targetNamespace, Name: "shipwright-build-controller", }, } @@ -144,7 +167,7 @@ var _ = g.Describe("Reconcile default ShipwrightBuild installation", func() { g.It("deletes the deployment for the Shipwright build controller", func() { expectedDeployment := &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ - Namespace: "shipwright-build", + Namespace: targetNamespace, Name: "shipwright-build-controller", }, } diff --git a/controllers/result.go b/controllers/result.go new file mode 100644 index 000000000..867fd9cdc --- /dev/null +++ b/controllers/result.go @@ -0,0 +1,25 @@ +package controllers + +import ( + ctrl "sigs.k8s.io/controller-runtime" +) + +// Requeue triggers a object requeue. +func Requeue() (ctrl.Result, error) { + return ctrl.Result{Requeue: true}, nil +} + +// RequeueOnError triggers requeue when error is not nil. +func RequeueOnError(err error) (ctrl.Result, error) { + return ctrl.Result{}, err +} + +// RequeueWithError triggers a object requeue because the informed error happend. +func RequeueWithError(err error) (ctrl.Result, error) { + return ctrl.Result{Requeue: true}, err +} + +// NoRequeue all done, the object does not need reconciliation anymore. +func NoRequeue() (ctrl.Result, error) { + return ctrl.Result{Requeue: false}, nil +} diff --git a/controllers/shipwrightbuild_controller.go b/controllers/shipwrightbuild_controller.go index 1298a61ad..a787700b3 100644 --- a/controllers/shipwrightbuild_controller.go +++ b/controllers/shipwrightbuild_controller.go @@ -7,103 +7,180 @@ package controllers import ( "context" "fmt" - "os" "path/filepath" + "github.com/go-logr/logr" mfc "github.com/manifestival/controller-runtime-client" "github.com/manifestival/manifestival" - - "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" "github.com/shipwright-io/operator/api/v1alpha1" ) +const ( + // FinalizerAnnotation annotation string appended on finalizer slice. + FinalizerAnnotation = "finalizer.operator.shipwright.io" + // defaultTargetNamespace fallback namespace when `.spec.namepace` is not informed. + defaultTargetNamespace = "shipwright-build" +) + // ShipwrightBuildReconciler reconciles a ShipwrightBuild object type ShipwrightBuildReconciler struct { - client.Client - Log logr.Logger - Scheme *runtime.Scheme - Manifest manifestival.Manifest + client.Client // controller kubernetes client + + Logger logr.Logger // decorated logger + Scheme *runtime.Scheme // runtime scheme + Manifest manifestival.Manifest // release manifests render } -// Declare RBAC needed to reconcile the release manifest YAML -// To minimize the risk of privilege escalation or destructive behavior, the controller is only -// allowed to modify named resources that deploy Shipwright Build. -// This is especially true for the cluster roles and custom resource definitions included in the -// release manifest. - -// +kubebuilder:rbac:groups=operator.shipwright.io,resources=shipwrightbuilds,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=operator.shipwright.io,resources=shipwrightbuilds/status,verbs=get;update;patch -// +kubebuilder:rbac:groups=operator.shipwright.io,resources=shipwrightbuilds/finalizers,verbs=update -// +kubebuilder:rbac:groups=shipwright.io,resources=*,verbs=create;delete;get;list;patch;update;watch -// +kubebuilder:rbac:groups=core,resources=pods;services;services/finalizers;endpoints;persistentvolumeclaims;events;configmaps;secrets;serviceaccounts,verbs=create;delete;get;list;patch;update;watch -// +kubebuilder:rbac:groups=apps,resources=deployments;daemonsets;replicasets;statefulsets,verbs=create;delete;get;list;patch;update;watch -// +kubebuilder:rbac:groups=apps,resourceNames=shipwright-build,resources=deployments/finalizers,verbs=update -// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterroles,verbs=get;list;watch;create -// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterroles,resourceNames=shipwright-build-controller,verbs=update;patch;delete -// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterrolebindings,verbs=get;list;watch;create -// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterrolebindings,resourceNames=shipwright-build-controller,verbs=update;patch;delete -// +kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=get;list;watch;create -// +kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,resourceNames=builds.shipwright.io;buildruns.shipwright.io;buildstrategies.shipwright.io;clusterbuildstrategies.shipwright.io,verbs=update;patch;delete -// +kubebuilder:rbac:groups=monitoring.coreos.com,resources=servicemonitors,verbs=get;create -// +kubebuilder:rbac:groups=tekton.dev,resources=tasks;taskruns,verbs=create;delete;get;list;patch;update;watch - -// Reconcile is part of the main kubernetes reconciliation loop which aims to -// move the current state of the cluster closer to the desired state. -// TODO(user): Modify the Reconcile function to compare the state specified by -// the ShipwrightBuild object against the actual cluster state, and then -// perform operations to make the cluster state reflect the state specified by -// the user. -// -// For more details, check Reconcile and its Result here: -// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.7.0/pkg/reconcile +// setFinalizer append finalizer on the resource, and uses local client to update it immediately. +func (r *ShipwrightBuildReconciler) setFinalizer(ctx context.Context, b *v1alpha1.ShipwrightBuild) error { + if contains(b.GetFinalizers(), FinalizerAnnotation) { + return nil + } + b.SetFinalizers(append(b.GetFinalizers(), FinalizerAnnotation)) + return r.Update(ctx, b, &client.UpdateOptions{}) +} + +// unsetFinalizer remove all instances of local finalizer string, updating the resource immediately. +func (r *ShipwrightBuildReconciler) unsetFinalizer(ctx context.Context, b *v1alpha1.ShipwrightBuild) error { + finalizers := []string{} + for _, f := range b.GetFinalizers() { + if f == FinalizerAnnotation { + continue + } + finalizers = append(finalizers, f) + } + + b.SetFinalizers(finalizers) + return r.Update(ctx, b, &client.UpdateOptions{}) +} + +// Reconcile performs the resource reconciliation steps to deploy or remove Shipwright Build +// instances. When deletion-timestamp is found, the removal of the previously deploy resources is +// executed, otherwise the regular deploy workflow takes place. func (r *ShipwrightBuildReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - log := r.Log.WithValues("shipwrightbuild", req.NamespacedName) - - build := &v1alpha1.ShipwrightBuild{} - // Remove Namespaces from the manifest - cluster admins must provision the shipwright-build namespace - manifest := r.Manifest.Filter(manifestival.Not(manifestival.ByKind("Namespace"))) - err := r.Client.Get(ctx, req.NamespacedName, build) - if errors.IsNotFound(err) { - log.Info("object not found, deleting Shipwright Build from the cluster") - err = manifest.Delete() - if err != nil { - return ctrl.Result{}, err + logger := r.Logger.WithValues("namespace", req.Namespace, "name", req.Name) + logger.Info("Starting resource reconciliation...") + + // retrieving the ShipwrightBuild instance requested for reconciliation + b := &v1alpha1.ShipwrightBuild{} + if err := r.Get(ctx, req.NamespacedName, b); err != nil { + if errors.IsNotFound(err) { + logger.Info("Resource is not found!") + return NoRequeue() } - return ctrl.Result{}, nil + logger.Error(err, "Retrieving ShipwrightBuild object from cache") + return RequeueOnError(err) } - if err != nil { - return ctrl.Result{}, err + + // selecting the target namespace based on the CRD information, when not informed using the + // default namespace instead + targetNamespace := b.Spec.TargetNamespace + if targetNamespace == "" { + logger.Info( + "Namespace is not informed! Target namespace is selected from default settings instead", + "defaultTargetNamespace", defaultTargetNamespace, + ) + targetNamespace = defaultTargetNamespace } - log.Info("reconciling ShipwrightBuild with manifest") - err = manifest.Apply() + logger = logger.WithValues("targetNamespace", targetNamespace) + + // filtering out namespace resource, so it does not create new namespaces accidentally, and + // transforming object to target the namespace informed on the CRD (.spec.namespace) + manifest, err := r.Manifest. + Filter(manifestival.Not(manifestival.ByKind("Namespace"))). + Transform(manifestival.InjectNamespace(targetNamespace)) if err != nil { - return ctrl.Result{}, err + logger.Error(err, "Transforming manifests, injecting namespace") + return RequeueWithError(err) } - return ctrl.Result{}, nil + // when deletion-timestamp is set, the reconciliation process is in fact deleting the resources + // previously deployed. To mark the deletion process as done, it needs to clean up the + // finalizers, and thus the ShipwrightBuild is removed from cache + if !b.GetDeletionTimestamp().IsZero() { + logger.Info("DeletionTimestamp is set...") + if !contains(b.GetFinalizers(), FinalizerAnnotation) { + logger.Info("Finalizers removed, deletion of manifests completed!") + return NoRequeue() + } + + logger.Info("Deleting manifests...") + if err := manifest.Delete(); err != nil { + logger.Error(err, "Deleting manifest's resources") + return RequeueWithError(err) + } + logger.Info("Removing finalizers...") + if err := r.unsetFinalizer(ctx, b); err != nil { + logger.Error(err, "Removing the finalizer") + return RequeueWithError(err) + } + logger.Info("All removed!") + return NoRequeue() + } + + // rolling out the resources described on the manifests, it should create a new Shipwright Build + // instance with required dependencies + logger.Info("Applying manifest's resources...") + if err := manifest.Apply(); err != nil { + logger.Error(err, "Rolling out manifest's resources") + return RequeueWithError(err) + } + if err := r.setFinalizer(ctx, b); err != nil { + logger.Info(fmt.Sprintf("%#v", b)) + logger.Error(err, "Setting the finalizer") + return RequeueWithError(err) + } + logger.Info("All done!") + return NoRequeue() } -// SetupWithManager sets up the controller with the Manager. -func (r *ShipwrightBuildReconciler) SetupWithManager(mgr ctrl.Manager) error { - mfclient := mfc.NewClient(mgr.GetClient()) - mflogger := mgr.GetLogger().WithName("manifestival") - dataPath, exists := os.LookupEnv("KO_DATA_PATH") - if !exists { - return fmt.Errorf("KO_DATA_PATH is not set - cannot set up reconciler") +// setupManifestival instantiate manifestival with local controller attributes. +func (r *ShipwrightBuildReconciler) setupManifestival(managerLogger logr.Logger) error { + client := mfc.NewClient(r.Client) + logger := managerLogger.WithName("manifestival") + + dataPath, err := koDataPath() + if err != nil { + return err } buildManifest := filepath.Join(dataPath, "release.yaml") - mf, err := manifestival.NewManifest(buildManifest, manifestival.UseClient(mfclient), manifestival.UseLogger(mflogger)) - if err != nil { + r.Manifest, err = manifestival.NewManifest( + buildManifest, + manifestival.UseClient(client), + manifestival.UseLogger(logger), + ) + return err +} + +// SetupWithManager sets up the controller with the Manager, by instantiating Manifestival and +// setting up watch and predicate rules for ShipwrightBuild objects. +func (r *ShipwrightBuildReconciler) SetupWithManager(mgr ctrl.Manager) error { + if err := r.setupManifestival(mgr.GetLogger()); err != nil { return err } - r.Manifest = mf return ctrl.NewControllerManagedBy(mgr). - For(&v1alpha1.ShipwrightBuild{}). + For(&v1alpha1.ShipwrightBuild{}, builder.WithPredicates(predicate.Funcs{ + CreateFunc: func(ce event.CreateEvent) bool { + // all new objects must be subject to reconciliation + return true + }, + DeleteFunc: func(e event.DeleteEvent) bool { + // objects that haven't been confirmed deleted must be subject to reconciliation + return !e.DeleteStateUnknown + }, + UpdateFunc: func(e event.UpdateEvent) bool { + // objects that have updated generation must be subject to reconciliation + return e.ObjectOld.GetGeneration() != e.ObjectNew.GetGeneration() + }, + })). Complete(r) } diff --git a/controllers/shipwrightbuild_controller_test.go b/controllers/shipwrightbuild_controller_test.go new file mode 100644 index 000000000..c433d74a9 --- /dev/null +++ b/controllers/shipwrightbuild_controller_test.go @@ -0,0 +1,174 @@ +package controllers + +import ( + "context" + "os" + "testing" + "time" + + o "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/shipwright-io/operator/api/v1alpha1" +) + +func init() { + // exporting the environment variable which points Manifestival to the release.yaml file, + // containing all resources managed by it + _ = os.Setenv("KO_DATA_PATH", "../cmd/operator/kodata") +} + +// bootstrapShipwrightBuildReconciler start up a new instance of ShipwrightBuildReconciler which is +// ready to interact with Manifestival, returning the Manifestival instance and the client. +func bootstrapShipwrightBuildReconciler( + t *testing.T, + b *v1alpha1.ShipwrightBuild, +) (client.Client, *ShipwrightBuildReconciler) { + g := o.NewGomegaWithT(t) + + s := runtime.NewScheme() + s.AddKnownTypes(corev1.SchemeGroupVersion, &corev1.Namespace{}) + s.AddKnownTypes(appsv1.SchemeGroupVersion, &appsv1.Deployment{}) + s.AddKnownTypes(v1alpha1.GroupVersion, &v1alpha1.ShipwrightBuild{}) + + logger := zap.New() + + c := fake.NewFakeClientWithScheme(s, b) + r := &ShipwrightBuildReconciler{Client: c, Scheme: s, Logger: logger} + + // creating targetNamespace on which Shipwright-Build will be deployed against, before the other + // tests takes place + if b.Spec.TargetNamespace != "" { + t.Logf("Creating test namespace '%s'", b.Spec.TargetNamespace) + t.Run("create-test-namespace", func(t *testing.T) { + err := c.Create( + context.TODO(), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: b.Spec.TargetNamespace}}, + &client.CreateOptions{}, + ) + g.Expect(err).To(o.BeNil()) + }) + } + + // manifestival instance is setup as part of controller-=runtime's SetupWithManager, thus calling + // the setup before all other methods + t.Run("setupManifestival", func(t *testing.T) { + err := r.setupManifestival(logger) + g.Expect(err).To(o.BeNil()) + }) + + return c, r +} + +// TestShipwrightBuildReconciler_Finalizers testing adding and removing finalizers on the resource. +func TestShipwrightBuildReconciler_Finalizers(t *testing.T) { + g := o.NewGomegaWithT(t) + + b := &v1alpha1.ShipwrightBuild{ObjectMeta: metav1.ObjectMeta{Name: "name", Namespace: "default"}} + _, r := bootstrapShipwrightBuildReconciler(t, b) + + // adding one entry on finalizers slice, making sure it's registered + t.Run("setFinalizer", func(t *testing.T) { + err := r.setFinalizer(context.TODO(), b) + + g.Expect(err).To(o.BeNil()) + g.Expect(b.GetFinalizers()).To(o.Equal([]string{FinalizerAnnotation})) + }) + + // removing previously added finalizer entry, making sure slice it's empty afterwards + t.Run("unsetFinalizer", func(t *testing.T) { + err := r.unsetFinalizer(context.TODO(), b) + + g.Expect(err).To(o.BeNil()) + g.Expect(b.GetFinalizers()).To(o.Equal([]string{})) + }) +} + +// testShipwrightBuildReconcilerReconcile simulates the reconciliation process for rolling out and +// rolling back manifests in the informed target namespace name. +func testShipwrightBuildReconcilerReconcile(t *testing.T, targetNamespace string) { + g := o.NewGomegaWithT(t) + + namespacedName := types.NamespacedName{Namespace: "default", Name: "name"} + deploymentName := types.NamespacedName{ + Namespace: targetNamespace, + Name: "shipwright-build-controller", + } + req := reconcile.Request{NamespacedName: namespacedName} + + b := &v1alpha1.ShipwrightBuild{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespacedName.Name, + Namespace: namespacedName.Namespace, + }, + Spec: v1alpha1.ShipwrightBuildSpec{ + TargetNamespace: targetNamespace, + }, + } + c, r := bootstrapShipwrightBuildReconciler(t, b) + + t.Logf("Deploying Shipwright Controller against '%s' namespace", targetNamespace) + + // rolling out all manifests on the desired namespace, making sure the deployment for Shipwright + // Build Controller is created accordingly + t.Run("rollout-manifests", func(t *testing.T) { + ctx := context.TODO() + + res, err := r.Reconcile(ctx, req) + g.Expect(err).To(o.BeNil()) + g.Expect(res.Requeue).To(o.BeFalse()) + + err = c.Get(ctx, deploymentName, &appsv1.Deployment{}) + g.Expect(err).To(o.BeNil()) + }) + + // rolling back all changes, making sure the main deployment is also not found afterwards + t.Run("rollback-manifests", func(t *testing.T) { + ctx := context.TODO() + + err := r.Get(ctx, namespacedName, b) + g.Expect(err).To(o.BeNil()) + + // setting a deletion timestemp on the build object, it triggers the rollback logic so the + // reconciliation should remove the objects previously deployed + b.SetDeletionTimestamp(&metav1.Time{Time: time.Now()}) + err = r.Update(ctx, b, &client.UpdateOptions{}) + g.Expect(err).To(o.BeNil()) + + res, err := r.Reconcile(ctx, req) + g.Expect(err).To(o.BeNil()) + g.Expect(res.Requeue).To(o.BeFalse()) + + err = c.Get(ctx, deploymentName, &appsv1.Deployment{}) + g.Expect(errors.IsNotFound(err)).To(o.BeTrue()) + }) +} + +// TestShipwrightBuildReconciler_Reconcile runs rollout/rollback tests against different namespaces. +func TestShipwrightBuildReconciler_Reconcile(t *testing.T) { + tests := []struct { + testName string + targetNamespace string + }{{ + testName: "target namespace is informed", + targetNamespace: "namespace", + }, { + testName: "target namespace is not informed", + targetNamespace: defaultTargetNamespace, + }} + + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + testShipwrightBuildReconcilerReconcile(t, tt.targetNamespace) + }) + } +} diff --git a/controllers/shipwrightbuild_rbac.go b/controllers/shipwrightbuild_rbac.go new file mode 100644 index 000000000..9f1bbbeaf --- /dev/null +++ b/controllers/shipwrightbuild_rbac.go @@ -0,0 +1,21 @@ +package controllers + +// To minimize the risk of privilege escalation or destructive behavior, the controller is only +// allowed to modify named resources that deploy Shipwright Build. This is especially true for the +// cluster roles and custom resource definitions included in the release manifest. + +// +kubebuilder:rbac:groups=operator.shipwright.io,resources=shipwrightbuilds,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=operator.shipwright.io,resources=shipwrightbuilds/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=operator.shipwright.io,resources=shipwrightbuilds/finalizers,verbs=update +// +kubebuilder:rbac:groups=shipwright.io,resources=*,verbs=create;delete;get;list;patch;update;watch +// +kubebuilder:rbac:groups=core,resources=pods;services;services/finalizers;endpoints;persistentvolumeclaims;events;configmaps;secrets;serviceaccounts,verbs=create;delete;get;list;patch;update;watch +// +kubebuilder:rbac:groups=apps,resources=deployments;daemonsets;replicasets;statefulsets,verbs=create;delete;get;list;patch;update;watch +// +kubebuilder:rbac:groups=apps,resourceNames=shipwright-build,resources=deployments/finalizers,verbs=update +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterroles,verbs=get;list;watch;create +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterroles,resourceNames=shipwright-build-controller,verbs=update;patch;delete +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterrolebindings,verbs=get;list;watch;create +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=clusterrolebindings,resourceNames=shipwright-build-controller,verbs=update;patch;delete +// +kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=get;list;watch;create +// +kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,resourceNames=builds.shipwright.io;buildruns.shipwright.io;buildstrategies.shipwright.io;clusterbuildstrategies.shipwright.io,verbs=update;patch;delete +// +kubebuilder:rbac:groups=monitoring.coreos.com,resources=servicemonitors,verbs=get;create +// +kubebuilder:rbac:groups=tekton.dev,resources=tasks;taskruns,verbs=create;delete;get;list;patch;update;watch diff --git a/controllers/suite_test.go b/controllers/suite_test.go index 81cb381ca..2f9dd0f97 100644 --- a/controllers/suite_test.go +++ b/controllers/suite_test.go @@ -73,7 +73,7 @@ var _ = BeforeSuite(func() { err = (&ShipwrightBuildReconciler{ Client: mgr.GetClient(), Scheme: scheme.Scheme, - Log: ctrl.Log.WithName("controllers").WithName("shipwrightbuild"), + Logger: ctrl.Log.WithName("controllers").WithName("shipwrightbuild"), }).SetupWithManager(mgr) Expect(err).NotTo(HaveOccurred()) diff --git a/controllers/util.go b/controllers/util.go new file mode 100644 index 000000000..b04578e31 --- /dev/null +++ b/controllers/util.go @@ -0,0 +1,28 @@ +package controllers + +import ( + "fmt" + "os" +) + +// koDataPathEnv ko data-path environment variable. +const koDataPathEnv = "KO_DATA_PATH" + +// koDataPath retrieve the data path environment variable, returning error when not found. +func koDataPath() (string, error) { + dataPath, exists := os.LookupEnv(koDataPathEnv) + if !exists { + return "", fmt.Errorf("'%s' is not set", koDataPathEnv) + } + return dataPath, nil +} + +// contains returns true if the string if found in the slice. +func contains(slice []string, str string) bool { + for _, s := range slice { + if s == str { + return true + } + } + return false +} diff --git a/hack/check-git-status.sh b/hack/check-git-status.sh index 6481cdcf4..172f36c50 100755 --- a/hack/check-git-status.sh +++ b/hack/check-git-status.sh @@ -1,5 +1,6 @@ #! /bin/bash +set -x set -e fixCommand="$*" diff --git a/hack/test-with-envtest.sh b/hack/test-with-envtest.sh index 0d85bed0d..edaf8dedd 100755 --- a/hack/test-with-envtest.sh +++ b/hack/test-with-envtest.sh @@ -11,4 +11,4 @@ source "${ENVTEST_ASSETS_DIR}/setup-envtest.sh" fetch_envtest_tools "${ENVTEST_ASSETS_DIR}" setup_envtest_env "${ENVTEST_ASSETS_DIR}" # Run tests sequentially - the controller integration tests cannot be run concurrently -go test ./... -coverprofile cover.out -p 1 +go test ./... -coverprofile cover.out -p 1 -failfast -ginkgo.v -ginkgo.failFast diff --git a/test/common.go b/test/common.go index 3e2fcf85c..c6ed69d22 100644 --- a/test/common.go +++ b/test/common.go @@ -2,7 +2,7 @@ package test import ( "context" - "fmt" + "time" apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/api/errors" @@ -13,6 +13,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) +// timeout amount of time to wait for Eventually methods +var timeout = 30 * time.Second + // EventuallyExists checks if an object with the given namespace+name and type eventually exists. func EventuallyExists(ctx context.Context, k8sClient client.Client, obj client.Object) { o.Eventually(func() bool { @@ -26,7 +29,32 @@ func EventuallyExists(ctx context.Context, k8sClient client.Client, obj client.O } o.Expect(err).NotTo(o.HaveOccurred()) return true - }).Should(o.BeTrue()) + }, timeout).Should(o.BeTrue()) +} + +// EventuallyContainFinalizer retrieves and inspect the object to assert if the informed finalizer +// string is in the object. +func EventuallyContainFinalizer( + ctx context.Context, + k8sClient client.Client, + obj client.Object, + finalizer string, +) { + o.Eventually(func() bool { + key := types.NamespacedName{ + Namespace: obj.GetNamespace(), + Name: obj.GetName(), + } + if err := k8sClient.Get(ctx, key, obj); err != nil { + return false + } + for _, s := range obj.GetFinalizers() { + if s == finalizer { + return true + } + } + return false + }, timeout).Should(o.BeTrue()) } // CRDEventuallyExists checks if a custom resource definition with the given name eventually exists. @@ -44,13 +72,8 @@ func EventuallyRemoved(ctx context.Context, k8sClient client.Client, obj client. o.Eventually(func() bool { key := types.NamespacedName{Namespace: obj.GetNamespace(), Name: obj.GetName()} err := k8sClient.Get(ctx, key, obj) - if errors.IsNotFound(err) { - return true - } - o.Expect(err).NotTo(o.HaveOccurred()) - fmt.Printf("found object %s: %s\n", obj.GetObjectKind(), key) - return false - }).Should(o.BeTrue()) + return errors.IsNotFound(err) + }, timeout).Should(o.BeTrue()) } // CRDEventuallyRemoved checks if a custom resource definition has been eventually removed