diff --git a/PROJECT b/PROJECT index bcfbb29..fc73fb5 100644 --- a/PROJECT +++ b/PROJECT @@ -40,4 +40,12 @@ resources: kind: Context path: github.com/spacelift-io/spacelift-operator/api/v1beta1 version: v1beta1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: app.spacelift.io + kind: Policy + path: github.com/spacelift-io/spacelift-operator/api/v1beta1 + version: v1beta1 version: "3" diff --git a/api/v1beta1/policy_types.go b/api/v1beta1/policy_types.go new file mode 100644 index 0000000..5d03b61 --- /dev/null +++ b/api/v1beta1/policy_types.go @@ -0,0 +1,84 @@ +/* +Copyright 2024. + +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 v1beta1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/spacelift-io/spacelift-operator/internal/spacelift/models" +) + +// PolicySpec defines the desired state of Policy +// +kubebuilder:validation:XValidation:rule="(has(self.spaceName) != has(self.spaceId)) || (!has(self.spaceName) && !has(self.spaceId))",message="only one of spaceName or spaceId can be set" +type PolicySpec struct { + // Name of the policy - should be unique in one account + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + // Body of the policy + // +kubebuilder:validation:MinLength=1 + Body string `json:"body"` + // Type of the policy. Possible values are ACCESS, APPROVAL, GIT_PUSH, INITIALIZATION, LOGIN, PLAN, TASK, TRIGGER and NOTIFICATION. + // Deprecated values are STACK_ACCESS (use ACCESS instead), TASK_RUN (use TASK instead), and TERRAFORM_PLAN (use PLAN instead). + // +kubebuilder:validation:Enum:=ACCESS;APPROVAL;GIT_PUSH;INITIALIZATION;LOGIN;PLAN;TASK;TRIGGER;NOTIFICATION + Type string `json:"type"` + + // Description of the policy + Description *string `json:"description,omitempty"` + Labels []string `json:"labels,omitempty"` + + // SpaceName is Name of a Space kubernetes resource of the space the policy is in + SpaceName *string `json:"spaceName,omitempty"` + // SpaceId is ID (slug) of the space the policy is in + SpaceId *string `json:"spaceId,omitempty"` + + AttachedStacksNames []string `json:"attachedStacks,omitempty"` + AttachedStacksIds []string `json:"attachedStacksIds,omitempty"` +} + +// PolicyStatus defines the observed state of Policy +type PolicyStatus struct { + Id string `json:"id"` +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status + +// Policy is the Schema for the policies API +type Policy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec PolicySpec `json:"spec,omitempty"` + Status PolicyStatus `json:"status,omitempty"` +} + +func (p *Policy) SetPolicy(policy models.Policy) { + p.Status.Id = policy.Id +} + +//+kubebuilder:object:root=true + +// PolicyList contains a list of Policy +type PolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Policy `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Policy{}, &PolicyList{}) +} diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 4b23cdf..995cd2b 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -421,6 +421,125 @@ func (in *MountedFile) DeepCopy() *MountedFile { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Policy) DeepCopyInto(out *Policy) { + *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 Policy. +func (in *Policy) DeepCopy() *Policy { + if in == nil { + return nil + } + out := new(Policy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Policy) 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 *PolicyList) DeepCopyInto(out *PolicyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Policy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PolicyList. +func (in *PolicyList) DeepCopy() *PolicyList { + if in == nil { + return nil + } + out := new(PolicyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PolicyList) 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 *PolicySpec) DeepCopyInto(out *PolicySpec) { + *out = *in + if in.Description != nil { + in, out := &in.Description, &out.Description + *out = new(string) + **out = **in + } + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.SpaceName != nil { + in, out := &in.SpaceName, &out.SpaceName + *out = new(string) + **out = **in + } + if in.SpaceId != nil { + in, out := &in.SpaceId, &out.SpaceId + *out = new(string) + **out = **in + } + if in.AttachedStacksNames != nil { + in, out := &in.AttachedStacksNames, &out.AttachedStacksNames + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.AttachedStacksIds != nil { + in, out := &in.AttachedStacksIds, &out.AttachedStacksIds + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PolicySpec. +func (in *PolicySpec) DeepCopy() *PolicySpec { + if in == nil { + return nil + } + out := new(PolicySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PolicyStatus) DeepCopyInto(out *PolicyStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PolicyStatus. +func (in *PolicyStatus) DeepCopy() *PolicyStatus { + if in == nil { + return nil + } + out := new(PolicyStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PulumiConfig) DeepCopyInto(out *PulumiConfig) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index a31d233..6f1d39c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -113,9 +113,11 @@ func main() { spaceRepo := repository.NewSpaceRepository(mgr.GetClient()) contextRepo := repository.NewContextRepository(mgr.GetClient(), mgr.GetScheme()) secretRepo := repository.NewSecretRepository(mgr.GetClient()) + policyRepo := repository.NewPolicyRepository(mgr.GetClient(), mgr.GetScheme()) spaceliftRunRepo := spaceliftRepository.NewRunRepository(mgr.GetClient()) spaceliftStackRepo := spaceliftRepository.NewStackRepository(mgr.GetClient()) spaceliftContextRepo := spaceliftRepository.NewContextRepository(mgr.GetClient()) + spaceliftPolicyRepo := spaceliftRepository.NewPolicyRepository(mgr.GetClient()) runWatcher := watcher.NewRunWatcher(runRepo, spaceliftRunRepo) if err = (&controller.RunReconciler{ @@ -154,6 +156,15 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Context") os.Exit(1) } + if err = (&controller.PolicyReconciler{ + StackRepository: stackRepo, + PolicyRepository: policyRepo, + SpaceRepository: spaceRepo, + SpaceliftPolicyRepository: spaceliftPolicyRepo, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Policy") + os.Exit(1) + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/crd/bases/app.spacelift.io_policies.yaml b/config/crd/bases/app.spacelift.io_policies.yaml new file mode 100644 index 0000000..8961c24 --- /dev/null +++ b/config/crd/bases/app.spacelift.io_policies.yaml @@ -0,0 +1,104 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.13.0 + name: policies.app.spacelift.io +spec: + group: app.spacelift.io + names: + kind: Policy + listKind: PolicyList + plural: policies + singular: policy + scope: Namespaced + versions: + - name: v1beta1 + schema: + openAPIV3Schema: + description: Policy is the Schema for the policies 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: PolicySpec defines the desired state of Policy + properties: + attachedStacks: + items: + type: string + type: array + attachedStacksIds: + items: + type: string + type: array + body: + description: Body of the policy + minLength: 1 + type: string + description: + description: Description of the policy + type: string + labels: + items: + type: string + type: array + name: + description: Name of the policy - should be unique in one account + minLength: 1 + type: string + spaceId: + description: SpaceId is ID (slug) of the space the policy is in + type: string + spaceName: + description: SpaceName is Name of a Space kubernetes resource of the + space the policy is in + type: string + type: + description: Type of the policy. Possible values are ACCESS, APPROVAL, + GIT_PUSH, INITIALIZATION, LOGIN, PLAN, TASK, TRIGGER and NOTIFICATION. + Deprecated values are STACK_ACCESS (use ACCESS instead), TASK_RUN + (use TASK instead), and TERRAFORM_PLAN (use PLAN instead). + enum: + - ACCESS + - APPROVAL + - GIT_PUSH + - INITIALIZATION + - LOGIN + - PLAN + - TASK + - TRIGGER + - NOTIFICATION + type: string + required: + - body + - name + - type + type: object + x-kubernetes-validations: + - message: only one of spaceName or spaceId can be set + rule: (has(self.spaceName) != has(self.spaceId)) || (!has(self.spaceName) + && !has(self.spaceId)) + status: + description: PolicyStatus defines the observed state of Policy + properties: + id: + type: string + required: + - id + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index fde79df..3465283 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -6,6 +6,7 @@ resources: - bases/app.spacelift.io_stacks.yaml - bases/app.spacelift.io_spaces.yaml - bases/app.spacelift.io_contexts.yaml +- bases/app.spacelift.io_policies.yaml #+kubebuilder:scaffold:crdkustomizeresource patches: @@ -14,6 +15,7 @@ patches: #- path: patches/webhook_in_runs.yaml #- path: patches/webhook_in_stacks.yaml #- path: patches/webhook_in_contexts.yaml +#- path: patches/webhook_in_policies.yaml #+kubebuilder:scaffold:crdkustomizewebhookpatch # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. @@ -22,6 +24,7 @@ patches: #- path: patches/cainjection_in_stacks.yaml #- path: patches/cainjection_in_spaces.yaml #- path: patches/cainjection_in_contexts.yaml +#- path: patches/cainjection_in_policies.yaml #+kubebuilder:scaffold:crdkustomizecainjectionpatch # the following config is for teaching kustomize how to do kustomization for CRDs. diff --git a/config/crd/patches/cainjection_in_policies.yaml b/config/crd/patches/cainjection_in_policies.yaml new file mode 100644 index 0000000..61deb24 --- /dev/null +++ b/config/crd/patches/cainjection_in_policies.yaml @@ -0,0 +1,7 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME + name: policies.app.spacelift.io diff --git a/config/crd/patches/webhook_in_policies.yaml b/config/crd/patches/webhook_in_policies.yaml new file mode 100644 index 0000000..36092b3 --- /dev/null +++ b/config/crd/patches/webhook_in_policies.yaml @@ -0,0 +1,16 @@ +# The following patch enables a conversion webhook for the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: policies.app.spacelift.io +spec: + conversion: + strategy: Webhook + webhook: + clientConfig: + service: + namespace: system + name: webhook-service + path: /convert + conversionReviewVersions: + - v1 diff --git a/config/rbac/policy_editor_role.yaml b/config/rbac/policy_editor_role.yaml new file mode 100644 index 0000000..6795ad4 --- /dev/null +++ b/config/rbac/policy_editor_role.yaml @@ -0,0 +1,31 @@ +# permissions for end users to edit policies. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: policy-editor-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: spacelift-operator + app.kubernetes.io/part-of: spacelift-operator + app.kubernetes.io/managed-by: kustomize + name: policy-editor-role +rules: +- apiGroups: + - app.spacelift.io + resources: + - policies + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - app.spacelift.io + resources: + - policies/status + verbs: + - get diff --git a/config/rbac/policy_viewer_role.yaml b/config/rbac/policy_viewer_role.yaml new file mode 100644 index 0000000..615adba --- /dev/null +++ b/config/rbac/policy_viewer_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to view policies. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: clusterrole + app.kubernetes.io/instance: policy-viewer-role + app.kubernetes.io/component: rbac + app.kubernetes.io/created-by: spacelift-operator + app.kubernetes.io/part-of: spacelift-operator + app.kubernetes.io/managed-by: kustomize + name: policy-viewer-role +rules: +- apiGroups: + - app.spacelift.io + resources: + - policies + verbs: + - get + - list + - watch +- apiGroups: + - app.spacelift.io + resources: + - policies/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 246eb91..6cf9fef 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -42,6 +42,32 @@ rules: - get - patch - update +- apiGroups: + - app.spacelift.io + resources: + - policies + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - app.spacelift.io + resources: + - policies/finalizers + verbs: + - update +- apiGroups: + - app.spacelift.io + resources: + - policies/status + verbs: + - get + - patch + - update - apiGroups: - app.spacelift.io resources: diff --git a/config/samples/_v1beta1_policy.yaml b/config/samples/_v1beta1_policy.yaml new file mode 100644 index 0000000..d0f1f97 --- /dev/null +++ b/config/samples/_v1beta1_policy.yaml @@ -0,0 +1,19 @@ +apiVersion: app.spacelift.io/v1beta1 +kind: Policy +metadata: + name: policy-sample +spec: + name: test policy + type: PLAN + spaceName: space-sample + attachedStacks: + - stack-sample + description: Prevent creation of IAM users test + body: | + package spacelift + + deny[sprintf("must not create AWS IAM users", [resource.address])] { + some resource + created_resources[resource] + resource.type == "aws_iam_user" + } diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index a0fe471..a95a985 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -4,4 +4,5 @@ resources: - _v1beta1_stack.yaml - _v1beta1_space.yaml - _v1beta1_context.yaml +- _v1beta1_policy.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/internal/controller/policy_controller.go b/internal/controller/policy_controller.go new file mode 100644 index 0000000..61d5998 --- /dev/null +++ b/internal/controller/policy_controller.go @@ -0,0 +1,199 @@ +/* +Copyright 2024. + +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 controller + +import ( + "context" + "slices" + "time" + + "github.com/pkg/errors" + k8sErrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + "github.com/spacelift-io/spacelift-operator/api/v1beta1" + "github.com/spacelift-io/spacelift-operator/internal/k8s/repository" + "github.com/spacelift-io/spacelift-operator/internal/logging" + "github.com/spacelift-io/spacelift-operator/internal/spacelift/models" + spaceliftRepository "github.com/spacelift-io/spacelift-operator/internal/spacelift/repository" +) + +// PolicyReconciler reconciles a Policy object +type PolicyReconciler struct { + PolicyRepository *repository.PolicyRepository + SpaceRepository *repository.SpaceRepository + StackRepository *repository.StackRepository + SpaceliftPolicyRepository spaceliftRepository.PolicyRepository +} + +//+kubebuilder:rbac:groups=app.spacelift.io,resources=policies,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=app.spacelift.io,resources=policies/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=app.spacelift.io,resources=policies/finalizers,verbs=update + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.16.0/pkg/reconcile +func (r *PolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + logger.Info("Reconciling Policy") + policy, err := r.PolicyRepository.Get(ctx, req.NamespacedName) + + // The Policy is removed, this should not happen because we filter out deletion events. + // This can't really hurt and makes the reconciliation logic a bit more straightforward to read + if k8sErrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + if err != nil { + logger.Error(err, "Unable to retrieve Policy from kube API.") + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + if policy.Spec.SpaceName != nil { + logger := logger.WithValues( + logging.SpaceName, *policy.Spec.SpaceName, + logging.PolicyType, policy.Spec.Type, + logging.PolicyName, policy.Spec.Name, + ) + space, err := r.SpaceRepository.Get(ctx, types.NamespacedName{Namespace: policy.Namespace, Name: *policy.Spec.SpaceName}) + if err != nil { + if k8sErrors.IsNotFound(err) { + logger.V(logging.Level4).Info("Unable to find space for policy, will retry in 10 seconds") + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + logger.Error(err, "Error fetching space for policy.") + return ctrl.Result{}, err + } + // If the policy does not have owner reference let's set it + if len(policy.OwnerReferences) == 0 { + if err := r.PolicyRepository.SetOwner(ctx, policy, space); err != nil { + logger.Error(err, "Error setting space owner for policy.") + return ctrl.Result{}, err + } + } + + if !space.Ready() { + logger.Info("Space is not ready, will retry in 3 seconds") + return ctrl.Result{RequeueAfter: 3 * time.Second}, nil + } + // This set the space ID in the spec object to be reused in the graphql mutation. + // We kind of use the policy spec as a DTO here, but since we never update the spec in the controller + // that should be fine. + policy.Spec.SpaceId = &space.Status.Id + } + + if len(policy.Spec.AttachedStacksNames) > 0 { + for _, stackName := range policy.Spec.AttachedStacksNames { + logger := logger.WithValues(logging.StackName, stackName) + stack, err := r.StackRepository.Get(ctx, types.NamespacedName{Namespace: policy.Namespace, Name: stackName}) + if err != nil { + if k8sErrors.IsNotFound(err) { + logger.V(logging.Level4).Info("Unable to find attached stack for policy, will retry in 10 seconds") + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + logger.Error(err, "Error fetching stack for policy.") + return ctrl.Result{}, err + } + if !stack.Ready() { + logger.Info("Stack is not ready, will retry in 3 seconds") + return ctrl.Result{RequeueAfter: 3 * time.Second}, nil + } + if !slices.Contains(policy.Spec.AttachedStacksIds, stack.Status.Id) { + policy.Spec.AttachedStacksIds = append(policy.Spec.AttachedStacksIds, stack.Status.Id) + } + } + } + + _, err = r.SpaceliftPolicyRepository.Get(ctx, policy) + if err != nil && !errors.Is(err, spaceliftRepository.ErrPolicyNotFound) { + return ctrl.Result{}, errors.Wrap(err, "unable to retrieve policy from spacelift") + } + + if errors.Is(err, spaceliftRepository.ErrPolicyNotFound) { + return r.handleCreatePolicy(ctx, policy) + } + + return r.handleUpdatePolicy(ctx, policy) +} + +func (r *PolicyReconciler) handleCreatePolicy(ctx context.Context, policy *v1beta1.Policy) (ctrl.Result, error) { + logger := log.FromContext(ctx) + spaceliftPolicy, err := r.SpaceliftPolicyRepository.Create(ctx, policy) + if err != nil { + logger.Error(err, "Unable to create policy in spacelift") + return ctrl.Result{}, nil + } + + res, err := r.updatePolicyStatus(ctx, policy, *spaceliftPolicy) + + logger.WithValues(logging.PolicyId, spaceliftPolicy.Id).Info("Policy created") + + return res, err +} + +func (r *PolicyReconciler) handleUpdatePolicy(ctx context.Context, policy *v1beta1.Policy) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + spaceliftUpdatedPolicy, err := r.SpaceliftPolicyRepository.Update(ctx, policy) + if err != nil { + logger.Error(err, "Unable to update the policy in spacelift") + return ctrl.Result{}, nil + } + + res, err := r.updatePolicyStatus(ctx, policy, *spaceliftUpdatedPolicy) + + logger.WithValues(logging.PolicyId, spaceliftUpdatedPolicy.Id).Info("Policy updated") + + return res, err +} + +func (r *PolicyReconciler) updatePolicyStatus(ctx context.Context, policy *v1beta1.Policy, spaceliftPolicy models.Policy) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + policy.SetPolicy(spaceliftPolicy) + if err := r.PolicyRepository.UpdateStatus(ctx, policy); err != nil { + if k8sErrors.IsConflict(err) { + logger.Info("Conflict on Policy status update, let's try again.") + return ctrl.Result{RequeueAfter: time.Second * 3}, nil + } + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *PolicyReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&v1beta1.Policy{}). + WithEventFilter(predicate.Funcs{ + // Always handle new resource creation + CreateFunc: func(event.CreateEvent) bool { return true }, + // Always handle resource update + UpdateFunc: func(e event.UpdateEvent) bool { return e.ObjectOld.GetGeneration() != e.ObjectNew.GetGeneration() }, + // We don't care about policy removal + DeleteFunc: func(event.DeleteEvent) bool { return false }, + }). + Complete(r) +} diff --git a/internal/controller/policy_controller_test.go b/internal/controller/policy_controller_test.go new file mode 100644 index 0000000..76a487d --- /dev/null +++ b/internal/controller/policy_controller_test.go @@ -0,0 +1,314 @@ +package controller_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "go.uber.org/zap/zaptest/observer" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/manager" + + "github.com/spacelift-io/spacelift-operator/api/v1beta1" + "github.com/spacelift-io/spacelift-operator/internal/controller" + "github.com/spacelift-io/spacelift-operator/internal/k8s/repository" + "github.com/spacelift-io/spacelift-operator/internal/logging" + "github.com/spacelift-io/spacelift-operator/internal/spacelift/models" + spaceliftRepository "github.com/spacelift-io/spacelift-operator/internal/spacelift/repository" + "github.com/spacelift-io/spacelift-operator/internal/spacelift/repository/mocks" + "github.com/spacelift-io/spacelift-operator/internal/utils" + "github.com/spacelift-io/spacelift-operator/tests/integration" +) + +type PolicyControllerSuite struct { + integration.IntegrationTestSuite + integration.WithPolicySuiteHelper + integration.WithSpaceSuiteHelper + integration.WithStackSuiteHelper +} + +func (s *PolicyControllerSuite) SetupSuite() { + s.SetupManager = func(mgr manager.Manager) { + s.FakeSpaceliftPolicyRepo = new(mocks.PolicyRepository) + s.SpaceRepo = repository.NewSpaceRepository(mgr.GetClient()) + s.StackRepo = repository.NewStackRepository(mgr.GetClient(), mgr.GetScheme()) + s.PolicyRepo = repository.NewPolicyRepository(mgr.GetClient(), mgr.GetScheme()) + err := (&controller.PolicyReconciler{ + PolicyRepository: s.PolicyRepo, + SpaceRepository: s.SpaceRepo, + StackRepository: s.StackRepo, + SpaceliftPolicyRepository: s.FakeSpaceliftPolicyRepo, + }).SetupWithManager(mgr) + s.Require().NoError(err) + } + s.IntegrationTestSuite.SetupSuite() + s.WithPolicySuiteHelper = integration.WithPolicySuiteHelper{ + IntegrationTestSuite: &s.IntegrationTestSuite, + } + s.WithSpaceSuiteHelper = integration.WithSpaceSuiteHelper{ + IntegrationTestSuite: &s.IntegrationTestSuite, + } + s.WithStackSuiteHelper = integration.WithStackSuiteHelper{ + IntegrationTestSuite: &s.IntegrationTestSuite, + } +} + +func (s *PolicyControllerSuite) SetupTest() { + s.FakeSpaceliftPolicyRepo.Test(s.T()) + s.IntegrationTestSuite.SetupTest() +} + +func (s *PolicyControllerSuite) TestPolicyCreation_InvalidSpec() { + cases := []struct { + Name string + Spec v1beta1.PolicySpec + ExpectedErr string + }{ + { + Name: "empty name", + Spec: v1beta1.PolicySpec{ + Body: "test", + Type: "ACCESS", + }, + ExpectedErr: `Policy.app.spacelift.io "invalid-policy" is invalid: spec.name: Invalid value: "": spec.name in body should be at least 1 chars long`, + }, + { + Name: "empty body", + Spec: v1beta1.PolicySpec{ + Name: "test", + Type: "ACCESS", + }, + ExpectedErr: `Policy.app.spacelift.io "invalid-policy" is invalid: spec.body: Invalid value: "": spec.body in body should be at least 1 chars long`, + }, + { + Name: "empty type", + Spec: v1beta1.PolicySpec{ + Name: "test", + Body: "test", + }, + ExpectedErr: `Policy.app.spacelift.io "invalid-policy" is invalid: spec.type: Unsupported value: "": supported values: "ACCESS", "APPROVAL", "GIT_PUSH", "INITIALIZATION", "LOGIN", "PLAN", "TASK", "TRIGGER", "NOTIFICATION"`, + }, + { + Name: "invalid type", + Spec: v1beta1.PolicySpec{ + Name: "test", + Body: "test", + Type: "FOOBAR", + }, + ExpectedErr: `Policy.app.spacelift.io "invalid-policy" is invalid: spec.type: Unsupported value: "FOOBAR": supported values: "ACCESS", "APPROVAL", "GIT_PUSH", "INITIALIZATION", "LOGIN", "PLAN", "TASK", "TRIGGER", "NOTIFICATION"`, + }, + { + Name: "both stackName and stackId are set", + Spec: v1beta1.PolicySpec{ + Name: "test", + Body: "test", + Type: "ACCESS", + SpaceId: utils.AddressOf("space-id"), + SpaceName: utils.AddressOf("space-name"), + }, + ExpectedErr: `Policy.app.spacelift.io "invalid-policy" is invalid: spec: Invalid value: "object": only one of spaceName or spaceId can be set`, + }, + } + + for _, c := range cases { + s.Run(c.Name, func() { + policy := &v1beta1.Policy{ + TypeMeta: metav1.TypeMeta{ + Kind: "Policy", + APIVersion: v1beta1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-policy", + Namespace: "default", + }, + Spec: c.Spec, + } + err := s.Client().Create(s.Context(), policy) + s.Assert().EqualError(err, c.ExpectedErr) + }) + } +} + +func (s *PolicyControllerSuite) TestPolicyCreation_UnableToCreateOnSpacelift() { + s.FakeSpaceliftPolicyRepo.EXPECT().Create(mock.Anything, mock.Anything).Once(). + Return(nil, fmt.Errorf("unable to create resource on spacelift")) + s.FakeSpaceliftPolicyRepo.EXPECT().Get(mock.Anything, mock.Anything).Once(). + Return(nil, spaceliftRepository.ErrPolicyNotFound) + + policy, err := s.CreateTestPolicy() + s.Require().NoError(err) + defer s.DeletePolicy(policy) + + // Make sure we don't update the policy ID + s.Require().Never(func() bool { + policy, err := s.PolicyRepo.Get(s.Context(), types.NamespacedName{ + Namespace: policy.Namespace, + Name: policy.Name, + }) + s.Require().NoError(err) + return policy.Status.Id != "" + }, 3*time.Second, integration.DefaultInterval) + + // Check that the error has been logged + logs := s.Logs.FilterMessage("Unable to create policy in spacelift") + s.Require().Equal(1, logs.Len()) + logs = s.Logs.FilterMessage("Policy created") + s.Require().Equal(0, logs.Len()) +} + +func (s *PolicyControllerSuite) TestPolicyCreation_OK_AttachedStackNotReady() { + p := integration.DefaultValidPolicy + p.Spec.AttachedStacksNames = []string{"test-stack"} + + err := s.CreatePolicy(&p) + s.Require().NoError(err) + defer s.DeletePolicy(&p) + + var logs *observer.ObservedLogs + s.Require().Eventually(func() bool { + logs = s.Logs.FilterMessage("Unable to find attached stack for policy, will retry in 10 seconds") + return logs.Len() == 1 + }, integration.DefaultTimeout, integration.DefaultInterval) + s.Assert().Equal("test-stack", logs.All()[0].ContextMap()[logging.StackName]) + s.Assert().EqualValues(logging.Level4, -logs.All()[0].Level) + + stack, err := s.CreateTestStack() + s.Require().NoError(err) + defer s.DeleteStack(stack) + + s.Require().Eventually(func() bool { + logs = s.Logs.FilterMessage("Stack is not ready, will retry in 3 seconds") + return logs.Len() == 1 + }, 12*time.Second, integration.DefaultInterval) + s.Assert().Equal("test-stack", logs.All()[0].ContextMap()[logging.StackName]) + + s.FakeSpaceliftPolicyRepo.EXPECT().Get(mock.Anything, mock.Anything).Once(). + Return(nil, spaceliftRepository.ErrPolicyNotFound) + var policySpecToCreate v1beta1.PolicySpec + s.FakeSpaceliftPolicyRepo.EXPECT().Create(mock.Anything, mock.Anything). + Run(func(_ context.Context, c *v1beta1.Policy) { + policySpecToCreate = c.Spec + }).Once(). + Return(&models.Policy{Id: "test-policy-id"}, nil) + + stack.Status.Id = "test-stack-id" + err = s.StackRepo.UpdateStatus(s.Context(), stack) + s.Require().NoError(err) + + s.Require().Eventually(func() bool { + logs = s.Logs.FilterMessage("Policy created") + return logs.Len() == 1 + }, integration.DefaultTimeout, integration.DefaultInterval) + s.Assert().Equal("test-policy-id", logs.All()[0].ContextMap()[logging.PolicyId]) + + s.Assert().Contains(policySpecToCreate.AttachedStacksIds, "test-stack-id") +} + +func (s *PolicyControllerSuite) TestPolicyCreation_OK_SpaceNotReady() { + p := integration.DefaultValidPolicy + p.Spec.SpaceName = utils.AddressOf("test-space") + + err := s.CreatePolicy(&p) + s.Require().NoError(err) + defer s.DeletePolicy(&p) + + var logs *observer.ObservedLogs + s.Require().Eventually(func() bool { + logs = s.Logs.FilterMessage("Unable to find space for policy, will retry in 10 seconds") + return logs.Len() == 1 + }, integration.DefaultTimeout, integration.DefaultInterval) + s.Assert().Equal("test-space", logs.All()[0].ContextMap()[logging.SpaceName]) + s.Assert().EqualValues(logging.Level4, -logs.All()[0].Level) + + space, err := s.CreateTestSpace() + s.Require().NoError(err) + defer s.DeleteSpace(space) + + s.Require().Eventually(func() bool { + logs = s.Logs.FilterMessage("Space is not ready, will retry in 3 seconds") + return logs.Len() == 1 + }, 12*time.Second, integration.DefaultInterval) + s.Assert().Equal("test-space", logs.All()[0].ContextMap()[logging.SpaceName]) + + s.FakeSpaceliftPolicyRepo.EXPECT().Get(mock.Anything, mock.Anything).Once(). + Return(nil, spaceliftRepository.ErrPolicyNotFound) + var policySpecToCreate v1beta1.PolicySpec + s.FakeSpaceliftPolicyRepo.EXPECT().Create(mock.Anything, mock.Anything). + Run(func(_ context.Context, c *v1beta1.Policy) { + policySpecToCreate = c.Spec + }).Once(). + Return(&models.Policy{Id: "test-policy-id"}, nil) + + space.Status.Id = "test-space" + err = s.SpaceRepo.UpdateStatus(s.Context(), space) + s.Require().NoError(err) + + s.Require().Eventually(func() bool { + logs = s.Logs.FilterMessage("Policy created") + return logs.Len() == 1 + }, integration.DefaultTimeout, integration.DefaultInterval) + s.Assert().Equal("test-policy-id", logs.All()[0].ContextMap()[logging.PolicyId]) + + s.Require().NotNil(policySpecToCreate.SpaceId) + s.Assert().Equal("test-space", *policySpecToCreate.SpaceId) +} + +func (s *PolicyControllerSuite) TestPolicyCreation_OK() { + + s.FakeSpaceliftPolicyRepo.EXPECT().Get(mock.Anything, mock.Anything).Once(). + Return(nil, spaceliftRepository.ErrPolicyNotFound) + s.FakeSpaceliftPolicyRepo.EXPECT().Create(mock.Anything, mock.Anything).Once(). + Return(&models.Policy{Id: "test-policy-id"}, nil) + + policy, err := s.CreateTestPolicy() + s.Require().NoError(err) + defer s.DeletePolicy(policy) + + var logs *observer.ObservedLogs + s.Require().Eventually(func() bool { + logs = s.Logs.FilterMessage("Policy created") + return logs.Len() == 1 + }, integration.DefaultTimeout, integration.DefaultInterval) + s.Assert().Equal("test-policy-id", logs.All()[0].ContextMap()[logging.PolicyId]) + + policy, err = s.PolicyRepo.Get(s.Context(), types.NamespacedName{ + Namespace: policy.Namespace, + Name: policy.Name, + }) + s.Require().NoError(err) + s.Assert().Equal("test-policy-id", policy.Status.Id) +} + +func (s *PolicyControllerSuite) TestPolicyUpdate_OK() { + + s.FakeSpaceliftPolicyRepo.EXPECT().Get(mock.Anything, mock.Anything).Once(). + Return(nil, nil) + s.FakeSpaceliftPolicyRepo.EXPECT().Update(mock.Anything, mock.Anything).Once(). + Return(&models.Policy{Id: "test-policy-id"}, nil) + + policy, err := s.CreateTestPolicy() + s.Require().NoError(err) + defer s.DeletePolicy(policy) + + var logs *observer.ObservedLogs + s.Require().Eventually(func() bool { + logs = s.Logs.FilterMessage("Policy updated") + return logs.Len() == 1 + }, integration.DefaultTimeout, integration.DefaultInterval) + s.Assert().Equal("test-policy-id", logs.All()[0].ContextMap()[logging.PolicyId]) + + policy, err = s.PolicyRepo.Get(s.Context(), types.NamespacedName{ + Namespace: policy.Namespace, + Name: policy.Name, + }) + s.Require().NoError(err) + s.Assert().Equal("test-policy-id", policy.Status.Id) +} + +func TestPolicyController(t *testing.T) { + suite.Run(t, new(PolicyControllerSuite)) +} diff --git a/internal/controller/run_controller_test.go b/internal/controller/run_controller_test.go index a400c76..b156ded 100644 --- a/internal/controller/run_controller_test.go +++ b/internal/controller/run_controller_test.go @@ -59,6 +59,7 @@ func (s *RunControllerSuite) SetupSuite() { func (s *RunControllerSuite) SetupTest() { s.FakeSpaceliftRunRepo.Test(s.T()) s.FakeSpaceliftStackRepo.Test(s.T()) + s.IntegrationTestSuite.SetupTest() } func (s *RunControllerSuite) TearDownTest() { diff --git a/internal/controller/space_controller_test.go b/internal/controller/space_controller_test.go index 9edb77c..19de515 100644 --- a/internal/controller/space_controller_test.go +++ b/internal/controller/space_controller_test.go @@ -5,6 +5,13 @@ import ( "testing" "time" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "go.uber.org/zap/zaptest/observer" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/manager" + "github.com/spacelift-io/spacelift-operator/api/v1beta1" "github.com/spacelift-io/spacelift-operator/internal/controller" "github.com/spacelift-io/spacelift-operator/internal/k8s/repository" @@ -13,12 +20,6 @@ import ( spaceliftRepository "github.com/spacelift-io/spacelift-operator/internal/spacelift/repository" "github.com/spacelift-io/spacelift-operator/internal/spacelift/repository/mocks" "github.com/spacelift-io/spacelift-operator/tests/integration" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/suite" - "go.uber.org/zap/zaptest/observer" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/manager" ) type SpaceControllerSuite struct { @@ -44,6 +45,7 @@ func (s *SpaceControllerSuite) SetupSuite() { func (s *SpaceControllerSuite) SetupTest() { s.FakeSpaceliftSpaceRepo.Test(s.T()) + s.IntegrationTestSuite.SetupTest() } func (s *SpaceControllerSuite) TearDownTest() { diff --git a/internal/controller/stack_controller_test.go b/internal/controller/stack_controller_test.go index e548302..2970a85 100644 --- a/internal/controller/stack_controller_test.go +++ b/internal/controller/stack_controller_test.go @@ -52,6 +52,7 @@ func (s *StackControllerSuite) SetupSuite() { func (s *StackControllerSuite) SetupTest() { s.FakeSpaceliftStackRepo.Test(s.T()) + s.IntegrationTestSuite.SetupTest() } func (s *StackControllerSuite) TearDownTest() { diff --git a/internal/k8s/repository/policy_repository.go b/internal/k8s/repository/policy_repository.go new file mode 100644 index 0000000..6b35ad5 --- /dev/null +++ b/internal/k8s/repository/policy_repository.go @@ -0,0 +1,44 @@ +package repository + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/spacelift-io/spacelift-operator/api/v1beta1" +) + +type PolicyRepository struct { + client client.Client + scheme *runtime.Scheme +} + +func NewPolicyRepository(client client.Client, scheme *runtime.Scheme) *PolicyRepository { + return &PolicyRepository{client: client, scheme: scheme} +} + +func (r *PolicyRepository) Get(ctx context.Context, name types.NamespacedName) (*v1beta1.Policy, error) { + var policy v1beta1.Policy + if err := r.client.Get(ctx, name, &policy); err != nil { + return nil, err + } + return &policy, nil +} + +func (r *PolicyRepository) Update(ctx context.Context, policy *v1beta1.Policy) error { + return r.client.Update(ctx, policy) +} + +func (r *PolicyRepository) UpdateStatus(ctx context.Context, policy *v1beta1.Policy) error { + return r.client.Status().Update(ctx, policy) +} + +func (r *PolicyRepository) SetOwner(ctx context.Context, policy *v1beta1.Policy, space *v1beta1.Space) error { + if err := ctrl.SetControllerReference(space, policy, r.scheme); err != nil { + return err + } + return r.client.Update(ctx, policy) +} diff --git a/internal/logging/keys.go b/internal/logging/keys.go index 6a64830..0d77178 100644 --- a/internal/logging/keys.go +++ b/internal/logging/keys.go @@ -19,4 +19,9 @@ const ( ContextName = "context.name" ContextId = "context.id" + + PolicyId = "policy.id" + PolicyName = "policy.name" + PolicyType = "policy.type" + PolicyAttachmentId = "policy.attachment_id" ) diff --git a/internal/spacelift/client/client.go b/internal/spacelift/client/client.go index 8501717..69ea0fc 100644 --- a/internal/spacelift/client/client.go +++ b/internal/spacelift/client/client.go @@ -28,6 +28,8 @@ const ( SpaceliftApiKeySecretKey = "SPACELIFT_API_KEY_SECRET" //nolint:gosec ) +var DefaultClient = GetSpaceliftClient + func GetSpaceliftClient(ctx context.Context, client k8sclient.Client, namespace string) (Client, error) { if spaceliftClient != nil { return spaceliftClient, nil diff --git a/internal/spacelift/client/interface.go b/internal/spacelift/client/interface.go index 7065d1f..8ddf659 100644 --- a/internal/spacelift/client/interface.go +++ b/internal/spacelift/client/interface.go @@ -7,6 +7,8 @@ import ( ) // Client abstracts away Spacelift's client API. +// +//go:generate mockery --with-expecter --name Client type Client interface { // Query executes a single GraphQL query request. Query(context.Context, interface{}, map[string]interface{}, ...graphql.RequestOption) error diff --git a/internal/spacelift/client/mocks/Client.go b/internal/spacelift/client/mocks/Client.go new file mode 100644 index 0000000..d4aeb6d --- /dev/null +++ b/internal/spacelift/client/mocks/Client.go @@ -0,0 +1,220 @@ +// Code generated by mockery v2.40.2. DO NOT EDIT. + +package mocks + +import ( + context "context" + + graphql "github.com/shurcooL/graphql" + mock "github.com/stretchr/testify/mock" +) + +// Client is an autogenerated mock type for the Client type +type Client struct { + mock.Mock +} + +type Client_Expecter struct { + mock *mock.Mock +} + +func (_m *Client) EXPECT() *Client_Expecter { + return &Client_Expecter{mock: &_m.Mock} +} + +// Mutate provides a mock function with given fields: _a0, _a1, _a2, _a3 +func (_m *Client) Mutate(_a0 context.Context, _a1 interface{}, _a2 map[string]interface{}, _a3 ...graphql.RequestOption) error { + _va := make([]interface{}, len(_a3)) + for _i := range _a3 { + _va[_i] = _a3[_i] + } + var _ca []interface{} + _ca = append(_ca, _a0, _a1, _a2) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for Mutate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, interface{}, map[string]interface{}, ...graphql.RequestOption) error); ok { + r0 = rf(_a0, _a1, _a2, _a3...) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Client_Mutate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Mutate' +type Client_Mutate_Call struct { + *mock.Call +} + +// Mutate is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 interface{} +// - _a2 map[string]interface{} +// - _a3 ...graphql.RequestOption +func (_e *Client_Expecter) Mutate(_a0 interface{}, _a1 interface{}, _a2 interface{}, _a3 ...interface{}) *Client_Mutate_Call { + return &Client_Mutate_Call{Call: _e.mock.On("Mutate", + append([]interface{}{_a0, _a1, _a2}, _a3...)...)} +} + +func (_c *Client_Mutate_Call) Run(run func(_a0 context.Context, _a1 interface{}, _a2 map[string]interface{}, _a3 ...graphql.RequestOption)) *Client_Mutate_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]graphql.RequestOption, len(args)-3) + for i, a := range args[3:] { + if a != nil { + variadicArgs[i] = a.(graphql.RequestOption) + } + } + run(args[0].(context.Context), args[1].(interface{}), args[2].(map[string]interface{}), variadicArgs...) + }) + return _c +} + +func (_c *Client_Mutate_Call) Return(_a0 error) *Client_Mutate_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Client_Mutate_Call) RunAndReturn(run func(context.Context, interface{}, map[string]interface{}, ...graphql.RequestOption) error) *Client_Mutate_Call { + _c.Call.Return(run) + return _c +} + +// Query provides a mock function with given fields: _a0, _a1, _a2, _a3 +func (_m *Client) Query(_a0 context.Context, _a1 interface{}, _a2 map[string]interface{}, _a3 ...graphql.RequestOption) error { + _va := make([]interface{}, len(_a3)) + for _i := range _a3 { + _va[_i] = _a3[_i] + } + var _ca []interface{} + _ca = append(_ca, _a0, _a1, _a2) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for Query") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, interface{}, map[string]interface{}, ...graphql.RequestOption) error); ok { + r0 = rf(_a0, _a1, _a2, _a3...) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Client_Query_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Query' +type Client_Query_Call struct { + *mock.Call +} + +// Query is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 interface{} +// - _a2 map[string]interface{} +// - _a3 ...graphql.RequestOption +func (_e *Client_Expecter) Query(_a0 interface{}, _a1 interface{}, _a2 interface{}, _a3 ...interface{}) *Client_Query_Call { + return &Client_Query_Call{Call: _e.mock.On("Query", + append([]interface{}{_a0, _a1, _a2}, _a3...)...)} +} + +func (_c *Client_Query_Call) Run(run func(_a0 context.Context, _a1 interface{}, _a2 map[string]interface{}, _a3 ...graphql.RequestOption)) *Client_Query_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]graphql.RequestOption, len(args)-3) + for i, a := range args[3:] { + if a != nil { + variadicArgs[i] = a.(graphql.RequestOption) + } + } + run(args[0].(context.Context), args[1].(interface{}), args[2].(map[string]interface{}), variadicArgs...) + }) + return _c +} + +func (_c *Client_Query_Call) Return(_a0 error) *Client_Query_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Client_Query_Call) RunAndReturn(run func(context.Context, interface{}, map[string]interface{}, ...graphql.RequestOption) error) *Client_Query_Call { + _c.Call.Return(run) + return _c +} + +// URL provides a mock function with given fields: _a0, _a1 +func (_m *Client) URL(_a0 string, _a1 ...interface{}) string { + var _ca []interface{} + _ca = append(_ca, _a0) + _ca = append(_ca, _a1...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for URL") + } + + var r0 string + if rf, ok := ret.Get(0).(func(string, ...interface{}) string); ok { + r0 = rf(_a0, _a1...) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// Client_URL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'URL' +type Client_URL_Call struct { + *mock.Call +} + +// URL is a helper method to define mock.On call +// - _a0 string +// - _a1 ...interface{} +func (_e *Client_Expecter) URL(_a0 interface{}, _a1 ...interface{}) *Client_URL_Call { + return &Client_URL_Call{Call: _e.mock.On("URL", + append([]interface{}{_a0}, _a1...)...)} +} + +func (_c *Client_URL_Call) Run(run func(_a0 string, _a1 ...interface{})) *Client_URL_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]interface{}, len(args)-1) + for i, a := range args[1:] { + if a != nil { + variadicArgs[i] = a.(interface{}) + } + } + run(args[0].(string), variadicArgs...) + }) + return _c +} + +func (_c *Client_URL_Call) Return(_a0 string) *Client_URL_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Client_URL_Call) RunAndReturn(run func(string, ...interface{}) string) *Client_URL_Call { + _c.Call.Return(run) + return _c +} + +// NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewClient(t interface { + mock.TestingT + Cleanup(func()) +}) *Client { + mock := &Client{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/spacelift/models/policy.go b/internal/spacelift/models/policy.go new file mode 100644 index 0000000..baea2ee --- /dev/null +++ b/internal/spacelift/models/policy.go @@ -0,0 +1,5 @@ +package models + +type Policy struct { + Id string `json:"id"` +} diff --git a/internal/spacelift/repository/mocks/PolicyRepository.go b/internal/spacelift/repository/mocks/PolicyRepository.go new file mode 100644 index 0000000..9dd9bed --- /dev/null +++ b/internal/spacelift/repository/mocks/PolicyRepository.go @@ -0,0 +1,216 @@ +// Code generated by mockery v2.40.2. DO NOT EDIT. + +package mocks + +import ( + context "context" + + models "github.com/spacelift-io/spacelift-operator/internal/spacelift/models" + mock "github.com/stretchr/testify/mock" + + v1beta1 "github.com/spacelift-io/spacelift-operator/api/v1beta1" +) + +// PolicyRepository is an autogenerated mock type for the PolicyRepository type +type PolicyRepository struct { + mock.Mock +} + +type PolicyRepository_Expecter struct { + mock *mock.Mock +} + +func (_m *PolicyRepository) EXPECT() *PolicyRepository_Expecter { + return &PolicyRepository_Expecter{mock: &_m.Mock} +} + +// Create provides a mock function with given fields: _a0, _a1 +func (_m *PolicyRepository) Create(_a0 context.Context, _a1 *v1beta1.Policy) (*models.Policy, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 *models.Policy + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *v1beta1.Policy) (*models.Policy, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, *v1beta1.Policy) *models.Policy); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Policy) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *v1beta1.Policy) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// PolicyRepository_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type PolicyRepository_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 *v1beta1.Policy +func (_e *PolicyRepository_Expecter) Create(_a0 interface{}, _a1 interface{}) *PolicyRepository_Create_Call { + return &PolicyRepository_Create_Call{Call: _e.mock.On("Create", _a0, _a1)} +} + +func (_c *PolicyRepository_Create_Call) Run(run func(_a0 context.Context, _a1 *v1beta1.Policy)) *PolicyRepository_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*v1beta1.Policy)) + }) + return _c +} + +func (_c *PolicyRepository_Create_Call) Return(_a0 *models.Policy, _a1 error) *PolicyRepository_Create_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *PolicyRepository_Create_Call) RunAndReturn(run func(context.Context, *v1beta1.Policy) (*models.Policy, error)) *PolicyRepository_Create_Call { + _c.Call.Return(run) + return _c +} + +// Get provides a mock function with given fields: _a0, _a1 +func (_m *PolicyRepository) Get(_a0 context.Context, _a1 *v1beta1.Policy) (*models.Policy, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 *models.Policy + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *v1beta1.Policy) (*models.Policy, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, *v1beta1.Policy) *models.Policy); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Policy) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *v1beta1.Policy) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// PolicyRepository_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' +type PolicyRepository_Get_Call struct { + *mock.Call +} + +// Get is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 *v1beta1.Policy +func (_e *PolicyRepository_Expecter) Get(_a0 interface{}, _a1 interface{}) *PolicyRepository_Get_Call { + return &PolicyRepository_Get_Call{Call: _e.mock.On("Get", _a0, _a1)} +} + +func (_c *PolicyRepository_Get_Call) Run(run func(_a0 context.Context, _a1 *v1beta1.Policy)) *PolicyRepository_Get_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*v1beta1.Policy)) + }) + return _c +} + +func (_c *PolicyRepository_Get_Call) Return(_a0 *models.Policy, _a1 error) *PolicyRepository_Get_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *PolicyRepository_Get_Call) RunAndReturn(run func(context.Context, *v1beta1.Policy) (*models.Policy, error)) *PolicyRepository_Get_Call { + _c.Call.Return(run) + return _c +} + +// Update provides a mock function with given fields: _a0, _a1 +func (_m *PolicyRepository) Update(_a0 context.Context, _a1 *v1beta1.Policy) (*models.Policy, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for Update") + } + + var r0 *models.Policy + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *v1beta1.Policy) (*models.Policy, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, *v1beta1.Policy) *models.Policy); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Policy) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *v1beta1.Policy) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// PolicyRepository_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' +type PolicyRepository_Update_Call struct { + *mock.Call +} + +// Update is a helper method to define mock.On call +// - _a0 context.Context +// - _a1 *v1beta1.Policy +func (_e *PolicyRepository_Expecter) Update(_a0 interface{}, _a1 interface{}) *PolicyRepository_Update_Call { + return &PolicyRepository_Update_Call{Call: _e.mock.On("Update", _a0, _a1)} +} + +func (_c *PolicyRepository_Update_Call) Run(run func(_a0 context.Context, _a1 *v1beta1.Policy)) *PolicyRepository_Update_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*v1beta1.Policy)) + }) + return _c +} + +func (_c *PolicyRepository_Update_Call) Return(_a0 *models.Policy, _a1 error) *PolicyRepository_Update_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *PolicyRepository_Update_Call) RunAndReturn(run func(context.Context, *v1beta1.Policy) (*models.Policy, error)) *PolicyRepository_Update_Call { + _c.Call.Return(run) + return _c +} + +// NewPolicyRepository creates a new instance of PolicyRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewPolicyRepository(t interface { + mock.TestingT + Cleanup(func()) +}) *PolicyRepository { + mock := &PolicyRepository{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/spacelift/repository/policy.go b/internal/spacelift/repository/policy.go new file mode 100644 index 0000000..d29dc51 --- /dev/null +++ b/internal/spacelift/repository/policy.go @@ -0,0 +1,224 @@ +package repository + +import ( + "context" + "slices" + + "github.com/pkg/errors" + "github.com/shurcooL/graphql" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/spacelift-io/spacelift-operator/api/v1beta1" + "github.com/spacelift-io/spacelift-operator/internal/logging" + spaceliftclient "github.com/spacelift-io/spacelift-operator/internal/spacelift/client" + "github.com/spacelift-io/spacelift-operator/internal/spacelift/models" + "github.com/spacelift-io/spacelift-operator/internal/spacelift/repository/structs" +) + +var ErrPolicyNotFound = errors.New("policy not found") + +//go:generate mockery --with-expecter --name PolicyRepository +type PolicyRepository interface { + Create(context.Context, *v1beta1.Policy) (*models.Policy, error) + Update(context.Context, *v1beta1.Policy) (*models.Policy, error) + Get(context.Context, *v1beta1.Policy) (*models.Policy, error) +} + +type policyRepository struct { + client client.Client +} + +type PolicyType string +type attachedStack struct { + Id string `graphql:"id"` + StackId string `graphql:"stackId"` + IsAutoAttached bool `graphql:"isAutoattached"` +} +type policyCreate struct { + Id string `graphql:"id"` + AttachedStacks []attachedStack `graphql:"attachedStacks"` +} +type policyCreateMutation struct { + PolicyCreate policyCreate `graphql:"policyCreate(name: $name, body: $body, type: $type, labels: $labels, space: $space)"` +} +type policyAttach struct { + Id string `graphql:"id"` +} +type policyAttachMutation struct { + PolicyAttach struct { + Id string `graphql:"id"` + } `graphql:"policyAttach(id: $id, stack: $stack)"` +} +type policyDetachMutation struct { + PolicyDetach struct { + Id string `graphql:"id"` + } `graphql:"policyDetach(id: $id)"` +} +type policyUpdate struct { + Id string `graphql:"id"` + AttachedStacks []attachedStack `graphql:"attachedStacks"` +} +type policyUpdateMutation struct { + PolicyUpdate policyUpdate `graphql:"policyUpdate(id: $id, name: $name, body: $body, labels: $labels, space: $space)"` +} + +func NewPolicyRepository(client client.Client) *policyRepository { + return &policyRepository{client: client} +} + +func (r *policyRepository) Create(ctx context.Context, policy *v1beta1.Policy) (*models.Policy, error) { + c, err := spaceliftclient.DefaultClient(ctx, r.client, policy.Namespace) + if err != nil { + return nil, errors.Wrap(err, "unable to fetch spacelift client while creating policy") + } + + var mutation policyCreateMutation + creationVars := map[string]any{ + "name": graphql.String(policy.Spec.Name), + "body": graphql.String(policy.Spec.Body), + "type": PolicyType(policy.Spec.Type), + "labels": structs.GetGraphQLStrings(&policy.Spec.Labels), + "space": (*graphql.ID)(nil), + } + + if policy.Spec.SpaceId != nil && *policy.Spec.SpaceId != "" { + creationVars["space"] = graphql.ID(*policy.Spec.SpaceId) + } + + if err := c.Mutate(ctx, &mutation, creationVars); err != nil { + return nil, errors.Wrap(err, "unable to create policy") + } + + logger := log.FromContext(ctx).WithValues(logging.PolicyId, mutation.PolicyCreate.Id) + + policyId := mutation.PolicyCreate.Id + + stacksToAttach := r.findStackToAttach(policy, mutation.PolicyCreate.AttachedStacks) + for _, stackId := range stacksToAttach { + attachMutation := policyAttachMutation{} + if err := c.Mutate(ctx, &attachMutation, map[string]any{ + "id": policyId, + "stack": stackId, + }); err != nil { + return nil, errors.Wrapf(err, "unable to attach stack %s to policy %s", stackId, policyId) + } + logger.WithValues(logging.StackId, stackId).Info("Attached stack to policy") + } + + return &models.Policy{ + Id: policyId, + }, nil +} + +func (r *policyRepository) Update(ctx context.Context, policy *v1beta1.Policy) (*models.Policy, error) { + c, err := spaceliftclient.DefaultClient(ctx, r.client, policy.Namespace) + if err != nil { + return nil, errors.Wrap(err, "unable to fetch spacelift client while updating policy") + } + + var updateMutation policyUpdateMutation + updateVars := map[string]any{ + "id": graphql.ID(policy.Status.Id), + "name": graphql.String(policy.Spec.Name), + "body": graphql.String(policy.Spec.Body), + "labels": structs.GetGraphQLStrings(&policy.Spec.Labels), + "space": (*graphql.ID)(nil), + } + + if policy.Spec.SpaceId != nil && *policy.Spec.SpaceId != "" { + updateVars["space"] = graphql.ID(*policy.Spec.SpaceId) + } + + if err := c.Mutate(ctx, &updateMutation, updateVars); err != nil { + return nil, errors.Wrap(err, "unable to update policy") + } + + logger := log.FromContext(ctx).WithValues(logging.PolicyId, updateMutation.PolicyUpdate.Id) + + policyId := updateMutation.PolicyUpdate.Id + stacksToAttach := r.findStackToAttach(policy, updateMutation.PolicyUpdate.AttachedStacks) + attachmentsToDetach := r.findStackToDetach(policy, updateMutation.PolicyUpdate.AttachedStacks) + + for _, attachmentId := range attachmentsToDetach { + detachMutation := policyDetachMutation{} + if err := c.Mutate(ctx, &detachMutation, map[string]any{ + "id": attachmentId, + }); err != nil { + return nil, errors.Wrapf(err, "unable to remove attachment %s to policy %s", attachmentId, policyId) + } + logger.WithValues(logging.PolicyAttachmentId, attachmentId).Info("Removed policy attachment") + } + + for _, stackId := range stacksToAttach { + attachMutation := policyAttachMutation{} + if err := c.Mutate(ctx, &attachMutation, map[string]any{ + "id": policyId, + "stack": stackId, + }); err != nil { + return nil, errors.Wrapf(err, "unable to attach stack %s to policy %s", stackId, policyId) + } + logger.WithValues(logging.StackId, stackId).Info("Attached stack to policy") + } + + return &models.Policy{ + Id: policyId, + }, nil +} + +func (r *policyRepository) Get(ctx context.Context, policy *v1beta1.Policy) (*models.Policy, error) { + c, err := spaceliftclient.GetSpaceliftClient(ctx, r.client, policy.Namespace) + if err != nil { + return nil, errors.Wrap(err, "unable to fetch spacelift client while getting a policy") + } + + var spaceQuery struct { + Policy *struct { + Id string `graphql:"id"` + } `graphql:"policy(id: $id)"` + } + + vars := map[string]any{"id": graphql.ID(policy.Status.Id)} + + if err := c.Query(ctx, &spaceQuery, vars); err != nil { + return nil, errors.Wrap(err, "unable to get policy") + } + + if spaceQuery.Policy == nil { + return nil, ErrPolicyNotFound + } + + return &models.Policy{ + Id: spaceQuery.Policy.Id, + }, nil +} + +func (*policyRepository) findStackToAttach(policy *v1beta1.Policy, attachedStacks []attachedStack) []string { + var stacksToAttach []string +stacksToAttach: + for _, stacksId := range policy.Spec.AttachedStacksIds { + // Let's see if the stack is already attached + if slices.ContainsFunc( + attachedStacks, + func(attachedStack attachedStack) bool { return attachedStack.StackId == stacksId }, + ) { + continue stacksToAttach + } + stacksToAttach = append(stacksToAttach, stacksId) + } + return stacksToAttach +} + +func (*policyRepository) findStackToDetach(policy *v1beta1.Policy, attachedStacks []attachedStack) []string { + var attachmentsToDetach []string + for _, attachedStack := range attachedStacks { + if attachedStack.IsAutoAttached { + continue + } + if slices.Contains(policy.Spec.AttachedStacksIds, attachedStack.StackId) { + continue + } + attachmentsToDetach = append(attachmentsToDetach, attachedStack.Id) + } + return attachmentsToDetach +} diff --git a/internal/spacelift/repository/policy_test.go b/internal/spacelift/repository/policy_test.go new file mode 100644 index 0000000..b75407b --- /dev/null +++ b/internal/spacelift/repository/policy_test.go @@ -0,0 +1,384 @@ +package repository + +import ( + "context" + "testing" + + "github.com/shurcooL/graphql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/spacelift-io/spacelift-operator/api/v1beta1" + spaceliftclient "github.com/spacelift-io/spacelift-operator/internal/spacelift/client" + "github.com/spacelift-io/spacelift-operator/internal/spacelift/client/mocks" + "github.com/spacelift-io/spacelift-operator/internal/spacelift/repository/structs" + "github.com/spacelift-io/spacelift-operator/internal/utils" +) + +func Test_policyRepository_Create(t *testing.T) { + testCases := []struct { + name string + policy v1beta1.Policy + expectedVars map[string]any + }{ + { + name: "basic policy", + policy: v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + Name: "name", + Body: "body", + Type: "PLAN", + Labels: []string{ + "label1", + "label2", + }, + }, + }, + expectedVars: map[string]any{ + "name": graphql.String("name"), + "body": graphql.String("body"), + "type": PolicyType("PLAN"), + "labels": structs.GetGraphQLStrings(&[]string{"label1", "label2"}), + "space": (*graphql.ID)(nil), + }, + }, + { + name: "basic policy with space", + policy: v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + Name: "name", + Body: "body", + Type: "PLAN", + SpaceId: utils.AddressOf("space-1"), + Labels: []string{}, + }, + }, + expectedVars: map[string]any{ + "name": graphql.String("name"), + "body": graphql.String("body"), + "type": PolicyType("PLAN"), + "labels": structs.GetGraphQLStrings(&[]string{}), + "space": graphql.ID("space-1"), + }, + }, + { + name: "policy with space to attach", + policy: v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + Name: "name", + Body: "body", + Type: "PLAN", + SpaceId: utils.AddressOf("space-1"), + Labels: []string{}, + }, + }, + expectedVars: map[string]any{ + "name": graphql.String("name"), + "body": graphql.String("body"), + "type": PolicyType("PLAN"), + "labels": structs.GetGraphQLStrings(&[]string{}), + "space": graphql.ID("space-1"), + }, + }, + } + + originalClient := spaceliftclient.DefaultClient + defer func() { spaceliftclient.DefaultClient = originalClient }() + var fakeClient *mocks.Client + spaceliftclient.DefaultClient = func(_ context.Context, _ client.Client, _ string) (spaceliftclient.Client, error) { + return fakeClient, nil + } + repo := NewPolicyRepository(nil) + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + fakeClient = mocks.NewClient(t) + var actualVars = map[string]any{} + fakeClient.EXPECT(). + Mutate(mock.Anything, mock.AnythingOfType("*repository.policyCreateMutation"), mock.Anything). + Run(func(_ context.Context, _ interface{}, vars map[string]interface{}, _a3 ...graphql.RequestOption) { + actualVars = vars + }).Return(nil) + _, err := repo.Create(context.Background(), &testCase.policy) + require.NoError(t, err) + assert.Equal(t, testCase.expectedVars, actualVars) + }) + } + +} + +func Test_policyRepository_Create_WithAttachedStacks(t *testing.T) { + + originalClient := spaceliftclient.DefaultClient + defer func() { spaceliftclient.DefaultClient = originalClient }() + var fakeClient *mocks.Client + spaceliftclient.DefaultClient = func(_ context.Context, _ client.Client, _ string) (spaceliftclient.Client, error) { + return fakeClient, nil + } + repo := NewPolicyRepository(nil) + + fakeClient = mocks.NewClient(t) + fakeClient.EXPECT().Mutate(mock.Anything, mock.AnythingOfType("*repository.policyCreateMutation"), mock.Anything). + Run(func(_ context.Context, mutation any, _ map[string]any, _ ...graphql.RequestOption) { + if mut, ok := mutation.(*policyCreateMutation); ok { + *mut = policyCreateMutation{ + PolicyCreate: policyCreate{ + Id: "policy-id", + AttachedStacks: []attachedStack{ + {StackId: "stack-id-1"}, + }, + }, + } + } + }).Return(nil) + + // stack-id-1 is already attached so we should only attach stack-id-2 + fakeClient.EXPECT().Mutate( + mock.Anything, + mock.AnythingOfType("*repository.policyAttachMutation"), + map[string]any{ + "id": "policy-id", + "stack": "stack-id-2", + }, + ).Once().Return(nil) + + policy := v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + Name: "name", + Body: "body", + Type: "PLAN", + AttachedStacksIds: []string{"stack-id-1", "stack-id-2"}, + }, + } + + _, err := repo.Create(context.Background(), &policy) + require.NoError(t, err) +} + +func Test_policyRepository_Update(t *testing.T) { + testCases := []struct { + name string + policy v1beta1.Policy + expectedVars map[string]any + }{ + { + name: "basic policy", + policy: v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + Name: "name", + Body: "body", + Type: "PLAN", + Labels: []string{ + "label1", + "label2", + }, + }, + Status: v1beta1.PolicyStatus{ + Id: "policy-id", + }, + }, + expectedVars: map[string]any{ + "id": graphql.ID("policy-id"), + "name": graphql.String("name"), + "body": graphql.String("body"), + "labels": structs.GetGraphQLStrings(&[]string{"label1", "label2"}), + "space": (*graphql.ID)(nil), + }, + }, + { + name: "basic policy with space", + policy: v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + Name: "name", + Body: "body", + Type: "PLAN", + SpaceId: utils.AddressOf("space-1"), + Labels: []string{}, + }, + Status: v1beta1.PolicyStatus{ + Id: "policy-id", + }, + }, + expectedVars: map[string]any{ + "id": graphql.ID("policy-id"), + "name": graphql.String("name"), + "body": graphql.String("body"), + "labels": structs.GetGraphQLStrings(&[]string{}), + "space": graphql.ID("space-1"), + }, + }, + { + name: "policy with space to attach", + policy: v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + Name: "name", + Body: "body", + Type: "PLAN", + SpaceId: utils.AddressOf("space-1"), + Labels: []string{}, + }, + Status: v1beta1.PolicyStatus{ + Id: "policy-id", + }, + }, + expectedVars: map[string]any{ + "id": graphql.ID("policy-id"), + "name": graphql.String("name"), + "body": graphql.String("body"), + "labels": structs.GetGraphQLStrings(&[]string{}), + "space": graphql.ID("space-1"), + }, + }, + } + + originalClient := spaceliftclient.DefaultClient + defer func() { spaceliftclient.DefaultClient = originalClient }() + var fakeClient *mocks.Client + spaceliftclient.DefaultClient = func(_ context.Context, _ client.Client, _ string) (spaceliftclient.Client, error) { + return fakeClient, nil + } + repo := NewPolicyRepository(nil) + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + fakeClient = mocks.NewClient(t) + var actualVars = map[string]any{} + fakeClient.EXPECT(). + Mutate(mock.Anything, mock.AnythingOfType("*repository.policyUpdateMutation"), mock.Anything). + Run(func(_ context.Context, _ interface{}, vars map[string]interface{}, _ ...graphql.RequestOption) { + actualVars = vars + }).Return(nil) + _, err := repo.Update(context.Background(), &testCase.policy) + require.NoError(t, err) + assert.Equal(t, testCase.expectedVars, actualVars) + }) + } +} + +func Test_policyRepository_Update_WithAttachedStacks(t *testing.T) { + + originalClient := spaceliftclient.DefaultClient + defer func() { spaceliftclient.DefaultClient = originalClient }() + var fakeClient *mocks.Client + spaceliftclient.DefaultClient = func(_ context.Context, _ client.Client, _ string) (spaceliftclient.Client, error) { + return fakeClient, nil + } + repo := NewPolicyRepository(nil) + + fakeClient = mocks.NewClient(t) + fakeClient.EXPECT().Mutate(mock.Anything, mock.AnythingOfType("*repository.policyUpdateMutation"), mock.Anything). + Run(func(_ context.Context, mutation any, _ map[string]any, _ ...graphql.RequestOption) { + if mut, ok := mutation.(*policyUpdateMutation); ok { + *mut = policyUpdateMutation{ + PolicyUpdate: policyUpdate{ + Id: "policy-id", + AttachedStacks: []attachedStack{ + { + Id: "attachment-id-1", + StackId: "stack-id-1", + IsAutoAttached: false, + }, + { + StackId: "stack-id-2", + IsAutoAttached: true, + }, + }, + }, + } + } + }).Return(nil) + + // attachment-id-1 should be detached because stack-id-1 not specified in the spec + // and it is not auto attached + fakeClient.EXPECT().Mutate( + mock.Anything, + mock.AnythingOfType("*repository.policyDetachMutation"), + map[string]any{ + "id": "attachment-id-1", + }, + ).Once().Return(nil) + + // stack-id-2 should not be detached because it is autoattached + + // stack-id-3 should be attached + fakeClient.EXPECT().Mutate( + mock.Anything, + mock.AnythingOfType("*repository.policyAttachMutation"), + map[string]any{ + "id": "policy-id", + "stack": "stack-id-3", + }, + ).Once().Return(nil) + + policy := v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + Name: "name", + Body: "body", + Type: "PLAN", + AttachedStacksIds: []string{"stack-id-3"}, + }, + } + + _, err := repo.Update(context.Background(), &policy) + require.NoError(t, err) +} + +func Test_policyRepository_Update_DetachAllStacks(t *testing.T) { + + originalClient := spaceliftclient.DefaultClient + defer func() { spaceliftclient.DefaultClient = originalClient }() + var fakeClient *mocks.Client + spaceliftclient.DefaultClient = func(_ context.Context, _ client.Client, _ string) (spaceliftclient.Client, error) { + return fakeClient, nil + } + repo := NewPolicyRepository(nil) + + fakeClient = mocks.NewClient(t) + fakeClient.EXPECT().Mutate(mock.Anything, mock.AnythingOfType("*repository.policyUpdateMutation"), mock.Anything). + Run(func(_ context.Context, mutation any, _ map[string]any, _ ...graphql.RequestOption) { + if mut, ok := mutation.(*policyUpdateMutation); ok { + *mut = policyUpdateMutation{ + PolicyUpdate: policyUpdate{ + Id: "policy-id", + AttachedStacks: []attachedStack{ + { + Id: "attachment-id-1", + StackId: "stack-id-1", + IsAutoAttached: false, + }, + { + StackId: "stack-id-2", + IsAutoAttached: true, + }, + }, + }, + } + } + }).Return(nil) + + // attachment-id-1 should be detached because stack-id-1 not specified in the spec + // and it is not auto attached + fakeClient.EXPECT().Mutate( + mock.Anything, + mock.AnythingOfType("*repository.policyDetachMutation"), + map[string]any{ + "id": "attachment-id-1", + }, + ).Once().Return(nil) + + // stack-id-2 should not be detached because it is autoattached + + policy := v1beta1.Policy{ + Spec: v1beta1.PolicySpec{ + Name: "name", + Body: "body", + Type: "PLAN", + AttachedStacksIds: []string{}, + }, + } + + _, err := repo.Update(context.Background(), &policy) + require.NoError(t, err) +} diff --git a/internal/spacelift/repository/structs/space_input.go b/internal/spacelift/repository/structs/space_input.go index bf2ada2..ccc0942 100644 --- a/internal/spacelift/repository/structs/space_input.go +++ b/internal/spacelift/repository/structs/space_input.go @@ -20,6 +20,6 @@ func FromSpaceSpec(spec v1beta1.SpaceSpec) SpaceInput { Description: graphql.String(spec.Description), InheritEntities: graphql.Boolean(spec.InheritEntities), ParentSpace: graphql.String(spec.ParentSpace), - Labels: getGraphQLStrings(spec.Labels), + Labels: GetGraphQLStrings(spec.Labels), } } diff --git a/internal/spacelift/repository/structs/stack_input.go b/internal/spacelift/repository/structs/stack_input.go index 19cff36..e99a91e 100644 --- a/internal/spacelift/repository/structs/stack_input.go +++ b/internal/spacelift/repository/structs/stack_input.go @@ -126,21 +126,21 @@ func FromStackSpec(stackSpec v1beta1.StackSpec) StackInput { Repository: graphql.String(repo), } - ret.AddditionalProjectGlobs = getGraphQLStrings(stackSpec.Settings.AdditionalProjectGlobs) - ret.AfterApply = getGraphQLStrings(stackSpec.Settings.AfterApply) - ret.AfterDestroy = getGraphQLStrings(stackSpec.Settings.AfterDestroy) - ret.AfterInit = getGraphQLStrings(stackSpec.Settings.AfterInit) - ret.AfterPerform = getGraphQLStrings(stackSpec.Settings.AfterPerform) - ret.AfterPlan = getGraphQLStrings(stackSpec.Settings.AfterPlan) - ret.AfterRun = getGraphQLStrings(stackSpec.Settings.AfterRun) - ret.BeforeApply = getGraphQLStrings(stackSpec.Settings.BeforeApply) - ret.BeforeDestroy = getGraphQLStrings(stackSpec.Settings.BeforeDestroy) - ret.BeforeInit = getGraphQLStrings(stackSpec.Settings.BeforeInit) - ret.BeforePerform = getGraphQLStrings(stackSpec.Settings.BeforePerform) - ret.BeforePlan = getGraphQLStrings(stackSpec.Settings.BeforePlan) + ret.AddditionalProjectGlobs = GetGraphQLStrings(stackSpec.Settings.AdditionalProjectGlobs) + ret.AfterApply = GetGraphQLStrings(stackSpec.Settings.AfterApply) + ret.AfterDestroy = GetGraphQLStrings(stackSpec.Settings.AfterDestroy) + ret.AfterInit = GetGraphQLStrings(stackSpec.Settings.AfterInit) + ret.AfterPerform = GetGraphQLStrings(stackSpec.Settings.AfterPerform) + ret.AfterPlan = GetGraphQLStrings(stackSpec.Settings.AfterPlan) + ret.AfterRun = GetGraphQLStrings(stackSpec.Settings.AfterRun) + ret.BeforeApply = GetGraphQLStrings(stackSpec.Settings.BeforeApply) + ret.BeforeDestroy = GetGraphQLStrings(stackSpec.Settings.BeforeDestroy) + ret.BeforeInit = GetGraphQLStrings(stackSpec.Settings.BeforeInit) + ret.BeforePerform = GetGraphQLStrings(stackSpec.Settings.BeforePerform) + ret.BeforePlan = GetGraphQLStrings(stackSpec.Settings.BeforePlan) ret.Description = getGraphQLString(stackSpec.Settings.Description) ret.Provider = getGraphQLString(stackSpec.Settings.Provider) - ret.Labels = getGraphQLStrings(stackSpec.Settings.Labels) + ret.Labels = GetGraphQLStrings(stackSpec.Settings.Labels) ret.Space = getGraphQLString(stackSpec.Settings.SpaceId) ret.ProjectRoot = getGraphQLString(stackSpec.Settings.ProjectRoot) ret.RunnerImage = getGraphQLString(stackSpec.Settings.RunnerImage) @@ -158,19 +158,6 @@ func getGraphQLBoolean(input *bool) *graphql.Boolean { return graphql.NewBoolean(graphql.Boolean(*input)) } -func getGraphQLStrings(input *[]string) *[]graphql.String { - if input == nil { - return nil - } - - var ret []graphql.String - for _, s := range *input { - ret = append(ret, graphql.String(s)) - } - - return &ret -} - func getGraphQLString(input *string) *graphql.String { if input == nil { return nil diff --git a/internal/spacelift/repository/structs/utils.go b/internal/spacelift/repository/structs/utils.go new file mode 100644 index 0000000..dd70494 --- /dev/null +++ b/internal/spacelift/repository/structs/utils.go @@ -0,0 +1,16 @@ +package structs + +import "github.com/shurcooL/graphql" + +func GetGraphQLStrings(input *[]string) *[]graphql.String { + if input == nil { + return nil + } + + var ret []graphql.String + for _, s := range *input { + ret = append(ret, graphql.String(s)) + } + + return &ret +} diff --git a/tests/integration/policy_suite.go b/tests/integration/policy_suite.go new file mode 100644 index 0000000..98129ee --- /dev/null +++ b/tests/integration/policy_suite.go @@ -0,0 +1,40 @@ +package integration + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/spacelift-io/spacelift-operator/api/v1beta1" +) + +var DefaultValidPolicy = v1beta1.Policy{ + TypeMeta: metav1.TypeMeta{ + Kind: "Policy", + APIVersion: v1beta1.GroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-policy", + Namespace: "default", + }, + Spec: v1beta1.PolicySpec{ + Name: "test policy", + Body: "package spacelift", + Type: "PLAN", + }, +} + +type WithPolicySuiteHelper struct { + *IntegrationTestSuite +} + +func (s *WithPolicySuiteHelper) CreateTestPolicy() (*v1beta1.Policy, error) { + policy := DefaultValidPolicy + return &policy, s.CreatePolicy(&policy) +} + +func (s *WithPolicySuiteHelper) CreatePolicy(policy *v1beta1.Policy) error { + return s.Client().Create(s.Context(), policy) +} + +func (s *WithPolicySuiteHelper) DeletePolicy(policy *v1beta1.Policy) error { + return s.Client().Delete(s.Context(), policy) +} diff --git a/tests/integration/suite.go b/tests/integration/suite.go index 223f43b..582b210 100644 --- a/tests/integration/suite.go +++ b/tests/integration/suite.go @@ -48,12 +48,14 @@ type IntegrationTestSuite struct { FakeSpaceliftStackRepo *mocks.StackRepository FakeSpaceliftSpaceRepo *mocks.SpaceRepository FakeSpaceliftContextRepo *mocks.ContextRepository + FakeSpaceliftPolicyRepo *mocks.PolicyRepository RunRepo *repository.RunRepository StackRepo *repository.StackRepository SpaceRepo *repository.SpaceRepository ContextRepo *repository.ContextRepository SecretRepo *repository.SecretRepository + PolicyRepo *repository.PolicyRepository } func (s *IntegrationTestSuite) SetupSuite() { @@ -119,6 +121,10 @@ func (s *IntegrationTestSuite) SetupSuite() { }() } +func (s *IntegrationTestSuite) SetupTest() { + s.Logs.TakeAll() +} + func (s *IntegrationTestSuite) TearDownSuite() { s.cancel() err := s.testEnv.Stop()