diff --git a/.vscode/settings.json b/.vscode/settings.json index aa9fca5..b1485e5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,6 +12,8 @@ "gopls": { "formatting.gofumpt": true }, + // https://github.com/golang/vscode-go/wiki/features#analyze-vulnerabilities-in-dependencies + "go.diagnostic.vulncheck": "Imports", // https://github.com/segmentio/golines#visual-studio-code "emeraldwalk.runonsave": { diff --git a/internal/api/v1alpha1/pod_access_request_types.go b/internal/api/v1alpha1/pod_access_request_types.go index 4f55d4c..b8a654f 100644 --- a/internal/api/v1alpha1/pod_access_request_types.go +++ b/internal/api/v1alpha1/pod_access_request_types.go @@ -118,7 +118,7 @@ func (r *PodAccessRequest) GetUptime() time.Duration { // SetPodName conforms to the interfaces.OzRequestResource interface func (r *PodAccessRequest) SetPodName(name string) error { - if r.Status.PodName != "" { + if (r.Status.PodName != "") && (r.Status.PodName != name) { return fmt.Errorf( "immutable field Status.PodName already set (%s), cannot update to %s", r.Status.PodName, diff --git a/internal/builders/execaccessbuilder/create_access_resources_test.go b/internal/builders/execaccessbuilder/create_access_resources_test.go index d064ac8..1c048e8 100644 --- a/internal/builders/execaccessbuilder/create_access_resources_test.go +++ b/internal/builders/execaccessbuilder/create_access_resources_test.go @@ -10,10 +10,13 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "github.com/diranged/oz/internal/api/v1alpha1" "github.com/diranged/oz/internal/builders/execaccessbuilder/internal" + bldutil "github.com/diranged/oz/internal/builders/utils" "github.com/diranged/oz/internal/testing/utils" ) @@ -23,7 +26,7 @@ var _ = Describe("RequestReconciler", Ordered, func() { ctx = context.Background() ns *corev1.Namespace deployment *appsv1.Deployment - pod *v1.Pod + pod *corev1.Pod request *v1alpha1.ExecAccessRequest template *v1alpha1.ExecAccessTemplate builder = ExecAccessBuilder{} @@ -34,7 +37,7 @@ var _ = Describe("RequestReconciler", Ordered, func() { BeforeAll(func() { By("Should have a namespace to execute tests in") - ns = &v1.Namespace{ + ns = &corev1.Namespace{ ObjectMeta: metav1.ObjectMeta{ Name: utils.RandomString(8), }, @@ -75,7 +78,7 @@ var _ = Describe("RequestReconciler", Ordered, func() { Expect(err).To(Not(HaveOccurred())) By("Create a single Pod that should match the Deployment spec above for testing") - pod = &v1.Pod{ + pod = &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: utils.RandomString(8), Namespace: ns.GetName(), @@ -178,13 +181,40 @@ var _ = Describe("RequestReconciler", Ordered, func() { It("CreateAccessResources() should succeed with random pod selection", func() { request.Status.PodName = "" request.Spec.TargetPod = "" + ret, err := builder.CreateAccessResources(ctx, k8sClient, request, template) + + // VERIFY: No error returned Expect(err).ToNot(HaveOccurred()) + + // VERIFY: Proper status string returned Expect(ret).To(MatchRegexp(fmt.Sprintf( "Success. Role %s-.*, RoleBinding %s.* created", request.GetName(), request.GetName(), ))) + + // VERIFY: Role Created as expected + foundRole := &rbacv1.Role{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: bldutil.GenerateResourceName(request), + Namespace: ns.GetName(), + }, foundRole) + Expect(err).ToNot(HaveOccurred()) + Expect(foundRole.GetOwnerReferences()).ToNot(BeNil()) + Expect(foundRole.Rules[0].ResourceNames[0]).To(Equal(pod.GetName())) + Expect(foundRole.Rules[1].ResourceNames[0]).To(Equal(pod.GetName())) + + // VERIFY: RoleBinding Created as expected + foundRoleBinding := &rbacv1.RoleBinding{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: bldutil.GenerateResourceName(request), + Namespace: ns.GetName(), + }, foundRoleBinding) + Expect(err).ToNot(HaveOccurred()) + Expect(foundRoleBinding.GetOwnerReferences()).ToNot(BeNil()) + Expect(foundRoleBinding.RoleRef.Name).To(Equal(foundRole.GetName())) + Expect(foundRoleBinding.Subjects[0].Name).To(Equal("foo")) }) }) }) diff --git a/internal/builders/execaccessbuilder/internal/get_random_pod.go b/internal/builders/execaccessbuilder/internal/get_random_pod.go index 28c5a89..125f6e5 100644 --- a/internal/builders/execaccessbuilder/internal/get_random_pod.go +++ b/internal/builders/execaccessbuilder/internal/get_random_pod.go @@ -37,7 +37,7 @@ func getRandomPod( Selector: selector, }, client.MatchingFields{ - v1alpha1.FieldSelectorStatusPhase: PodPhaseRunning, + v1alpha1.FieldSelectorStatusPhase: string(PodPhaseRunning), }, } if err := cl.List(ctx, podList, opts...); err != nil { diff --git a/internal/builders/execaccessbuilder/internal/get_specific_pod.go b/internal/builders/execaccessbuilder/internal/get_specific_pod.go index 1b65cb8..d9daf59 100644 --- a/internal/builders/execaccessbuilder/internal/get_specific_pod.go +++ b/internal/builders/execaccessbuilder/internal/get_specific_pod.go @@ -38,7 +38,7 @@ func getSpecificPod( }, client.MatchingFields{ v1alpha1.FieldSelectorMetadataName: podName, - v1alpha1.FieldSelectorStatusPhase: PodPhaseRunning, + v1alpha1.FieldSelectorStatusPhase: string(PodPhaseRunning), }, } if err := cl.List(ctx, podList, opts...); err != nil { diff --git a/internal/builders/execaccessbuilder/internal/vars.go b/internal/builders/execaccessbuilder/internal/vars.go index caf2f0d..3c4a209 100644 --- a/internal/builders/execaccessbuilder/internal/vars.go +++ b/internal/builders/execaccessbuilder/internal/vars.go @@ -1,5 +1,9 @@ package internal +import ( + corev1 "k8s.io/api/core/v1" +) + // PodPhaseRunning is exposed here so that we can reconfigure the search during // tests to look for Pending pods. -var PodPhaseRunning = "Running" +var PodPhaseRunning = corev1.PodRunning diff --git a/internal/builders/execaccessbuilder/types.go b/internal/builders/execaccessbuilder/types.go index 5b4acb9..dc42aeb 100644 --- a/internal/builders/execaccessbuilder/types.go +++ b/internal/builders/execaccessbuilder/types.go @@ -9,6 +9,9 @@ import ( //+kubebuilder:rbac:groups=crds.wizardofoz.co,resources=execaccessrequests/status,verbs=get;update;patch //+kubebuilder:rbac:groups=crds.wizardofoz.co,resources=execaccessrequests/finalizers,verbs=update +//+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=get;list;watch;create;update;patch;delete;bind;escalate +//+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=get;list;watch;create;update;patch;delete + // ExecAccessBuilder implements the IBuilder interface for ExecAccessRequest resources type ExecAccessBuilder struct{} diff --git a/internal/builders/podaccessbuilder/access_resources_are_ready.go b/internal/builders/podaccessbuilder/access_resources_are_ready.go new file mode 100644 index 0000000..af4776f --- /dev/null +++ b/internal/builders/podaccessbuilder/access_resources_are_ready.go @@ -0,0 +1,132 @@ +package podaccessbuilder + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/diranged/oz/internal/api/v1alpha1" +) + +// AccessResourcesAreReady implements the IBuilder interface by checking for +// the current state of the Pod for the user and returning True when it is +// ready, or False if it is not ready after a specified timeout. +// +// TODO: Implement a per-pod-access-template setting to tune this timeout. +func (b *PodAccessBuilder) AccessResourcesAreReady( + ctx context.Context, + client client.Client, + req v1alpha1.IRequestResource, + _ v1alpha1.ITemplateResource, +) (bool, error) { + log := logf.FromContext(ctx).WithName("AccessResourcesAreReady") + + // Cast the Request into an PodAccessRequest. + podReq := req.(*v1alpha1.PodAccessRequest) + + // First, verify whether or not the PodName field has been set. If not, + // then some part of the reconciliation has previously failed. + if podReq.GetPodName() == "" { + return false, errors.New("status.podName not yet set") + } + + // This empty Pod struct will be filled in by the isPodReady() function. + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: podReq.GetPodName(), + Namespace: podReq.GetNamespace(), + }, + } + + log.Info( + fmt.Sprintf( + "Checking if pod %s is ready yet (timeout: %s)", + pod.GetName(), + defaultReadyWaitTime, + ), + ) + + // Store the ready/err states outside of the loop so that we can return + // them at the end of the method. + var ready bool + var err error + + // In a loop, keep checking the Pod state. When it's ready, return. In an + // error, just keep looping. After the timeout has occurrred, we simply + // return the last known state. + for stay, timeout := true, time.After(defaultReadyWaitTime); stay; { + select { + case <-timeout: + log.Info(fmt.Sprintf("Timeout waiting for %s to become ready.", pod.GetName())) + stay = false + default: + if ready, err = isPodReady(ctx, client, log, pod); err != nil { + if apierrors.IsNotFound(err) { + // Immediately bail out and let a requeue event happen + return false, err + } + // For any other error, consider it transient and try again + log.Error(err, "Error getting Pod status (will retry)") + } else if ready { + // return ready = true, and no error + log.Info("Pod ready state", "phase", pod.Status.Phase) + return ready, nil + } + } + + // Wait 1 second before trying again + log.V(1).Info("Sleeping and trying again") + time.Sleep(defaultReadyWaitInterval) + } + + return ready, nil +} + +func isPodReady( + ctx context.Context, + client client.Client, + log logr.Logger, + pod *corev1.Pod, +) (bool, error) { + // Next, get the Pod. If the pod-get fails, then we need to return that failure. + log.V(2).Info("Getting pod") + err := client.Get(ctx, types.NamespacedName{ + Name: pod.GetName(), + Namespace: pod.GetNamespace(), + }, pod) + if err != nil { + return false, err + } + + // Now, check the Pod Phase first... the pod could be Pending and not yet + // ready to even have a condition state. + if pod.Status.Phase != PodPhaseRunning { + log.V(2).Info(fmt.Sprintf("Pod Phase is %s, not %s", pod.Status.Phase, PodPhaseRunning)) + return false, nil + } + + // Iterate through the PodConditions looking for the PodReady condition. + // When we find it, return whether it's "True" or "False". + conditions := pod.Status.Conditions + for _, condition := range conditions { + if condition.Type == corev1.PodReady { + // val = condition.Status == corev1.ConditionTrue + log.V(2). + Info(fmt.Sprintf("Got to the inner condition... returning %s", condition.Status)) + return condition.Status == corev1.ConditionTrue, nil + } + } + + // Return ready=false at this point + log.V(2).Info("Pod Ready Condition not yet True") + return false, nil +} diff --git a/internal/builders/podaccessbuilder/access_resources_are_ready_test.go b/internal/builders/podaccessbuilder/access_resources_are_ready_test.go new file mode 100644 index 0000000..a295bb1 --- /dev/null +++ b/internal/builders/podaccessbuilder/access_resources_are_ready_test.go @@ -0,0 +1,241 @@ +package podaccessbuilder + +import ( + "context" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/diranged/oz/internal/api/v1alpha1" + "github.com/diranged/oz/internal/testing/utils" +) + +var _ = Describe("RequestReconciler", Ordered, func() { + Context("AreAccessResourcesReady()", func() { + var ( + ctx = context.Background() + ns *corev1.Namespace + request *v1alpha1.PodAccessRequest + pod *corev1.Pod + builder = PodAccessBuilder{} + ) + + // Override the retry timeout so it won't take 30s for a failed test + defaultReadyWaitTime = 100 * time.Millisecond + defaultReadyWaitInterval = 10 * time.Millisecond + + BeforeAll(func() { + By("Should have a namespace to execute tests in") + ns = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.RandomString(8), + }, + } + err := k8sClient.Create(ctx, ns) + Expect(err).ToNot(HaveOccurred()) + + By("Creating a Pod to reference for the test") + pod = &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.RandomString(8), + Namespace: ns.Name, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test", + Image: "nginx:latest", + }, + }, + }, + } + err = k8sClient.Create(ctx, pod) + Expect(err).To(Not(HaveOccurred())) + + By("Should have an PodAccessRequest built to test against") + request = &v1alpha1.PodAccessRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "createaccessresource-test", + Namespace: ns.GetName(), + }, + Spec: v1alpha1.PodAccessRequestSpec{ + TemplateName: "bogus", + }, + } + err = k8sClient.Create(ctx, request) + Expect(err).ToNot(HaveOccurred()) + + request.Status.PodName = pod.GetName() + err = k8sClient.Status().Update(ctx, request) + Expect(err).ToNot(HaveOccurred()) + }) + + AfterAll(func() { + By("Should delete the namespace") + err := k8sClient.Delete(ctx, ns) + Expect(err).ToNot(HaveOccurred()) + }) + + It("AccessResoucesAreReady() should fail the first time", func() { + // Execute our waiter... + ret, err := builder.AccessResourcesAreReady( + ctx, + k8sClient, + request, + &v1alpha1.PodAccessTemplate{}, + ) + + // VERIFY: No error returned + Expect(err).ToNot(HaveOccurred()) + + // VERIFY: The returned ready state is True + Expect(ret).To(BeFalse()) + }) + + It("AccessResoucesAreReady() should succeed", func() { + // Mock out the Pod status to be ready + pod.Status.Phase = corev1.PodRunning + err := setPodReadyCondition( + ctx, + pod, + corev1.ConditionTrue, + metav1.StatusSuccess, + "Pod is running", + ) + Expect(err).ToNot(HaveOccurred()) + + // Execute our waiter... + ret, err := builder.AccessResourcesAreReady( + ctx, + k8sClient, + request, + &v1alpha1.PodAccessTemplate{}, + ) + + // VERIFY: No error returned + Expect(err).ToNot(HaveOccurred()) + + // VERIFY: The returned ready state is True + Expect(ret).To(BeTrue()) + }) + + It("AccessResoucesAreReady() should fail if the pod has no conditions", func() { + // Mock out the Pod status to be ready + pod.Status.Phase = corev1.PodRunning + pod.Status.Conditions = []corev1.PodCondition{} + err := k8sClient.Status().Update(ctx, pod) + Expect(err).ToNot(HaveOccurred()) + + // Execute our waiter... + ret, err := builder.AccessResourcesAreReady( + ctx, + k8sClient, + request, + &v1alpha1.PodAccessTemplate{}, + ) + + // VERIFY: No error returned + Expect(err).ToNot(HaveOccurred()) + + // VERIFY: The returned ready state is False + Expect(ret).To(BeFalse()) + }) + + It("AccessResoucesAreReady() should fail if the pod is never ready", func() { + // Mock out the Pod status to be ready + pod.Status.Phase = corev1.PodRunning + err := setPodReadyCondition( + ctx, + pod, + corev1.ConditionFalse, + metav1.StatusFailure, + "Pod is not yet running", + ) + Expect(err).ToNot(HaveOccurred()) + + // Execute our waiter... + ret, err := builder.AccessResourcesAreReady( + ctx, + k8sClient, + request, + &v1alpha1.PodAccessTemplate{}, + ) + + // VERIFY: No error returned + Expect(err).ToNot(HaveOccurred()) + + // VERIFY: The returned ready state is False + Expect(ret).To(BeFalse()) + }) + + It("AccessResoucesAreReady() should fail immediately if the pod is missing", func() { + // Delete the pod + err := k8sClient.Delete(ctx, pod) + Expect(err).ToNot(HaveOccurred()) + + // Execute our waiter... + ret, err := builder.AccessResourcesAreReady( + ctx, + k8sClient, + request, + &v1alpha1.PodAccessTemplate{}, + ) + + // VERIFY: Not Found Error + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(MatchRegexp("not found")) + + // VERIFY: The returned ready state is False + Expect(ret).To(BeFalse()) + }) + It( + "AccessResoucesAreReady() should fail immediately if the status.PodName is not set", + func() { + request.Status.PodName = "" + + // Execute our waiter... + ret, err := builder.AccessResourcesAreReady( + ctx, + k8sClient, + request, + &v1alpha1.PodAccessTemplate{}, + ) + + // VERIFY: No error returned + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Equal("status.podName not yet set")) + + // VERIFY: The returned ready state is False + Expect(ret).To(BeFalse()) + }, + ) + }) +}) + +func setPodReadyCondition( + ctx context.Context, + pod *corev1.Pod, + status corev1.ConditionStatus, + reason string, + message string, +) error { + pod.Status.Conditions = []corev1.PodCondition{ + { + Type: corev1.PodReady, + Status: status, + LastProbeTime: metav1.Time{ + Time: time.Now(), + }, + LastTransitionTime: metav1.Time{ + Time: time.Now(), + }, + Reason: reason, + Message: message, + }, + } + return k8sClient.Status().Update(ctx, pod) +} diff --git a/internal/builders/podaccessbuilder/create_access_resources.go b/internal/builders/podaccessbuilder/create_access_resources.go new file mode 100644 index 0000000..24fbc81 --- /dev/null +++ b/internal/builders/podaccessbuilder/create_access_resources.go @@ -0,0 +1,118 @@ +package podaccessbuilder + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/diranged/oz/internal/api/v1alpha1" + "github.com/diranged/oz/internal/builders/utils" +) + +// CreateAccessResources implements the IBuilder interface +func (b *PodAccessBuilder) CreateAccessResources( + ctx context.Context, + client client.Client, + req v1alpha1.IRequestResource, + tmpl v1alpha1.ITemplateResource, +) (statusString string, err error) { + log := logf.FromContext(ctx).WithName("CreateAccessResources") + + // Cast the Request into an PodAccessRequest. + podReq := req.(*v1alpha1.PodAccessRequest) + // Cast the Template into an PodAccessTemplate. + podTmpl := tmpl.(*v1alpha1.PodAccessTemplate) + + // First, get the desired PodSpec. If there's a failure at this point, return it. + podTemplateSpec, err := utils.GetPodTemplateFromController(ctx, client, tmpl) + if err != nil { + log.Error(err, "Failed to generate PodSpec for PodAccessRequest") + return "", err + } + + // Run the PodSpec through the optional mutation config + mutator := podTmpl.Spec.ControllerTargetMutationConfig + if mutator != nil { + podTemplateSpec, err = mutator.PatchPodTemplateSpec(ctx, podTemplateSpec) + if err != nil { + log.Error(err, "Failed to mutate PodSpec for PodAccessRequest") + return statusString, err + } + } + + // Generate a Pod for the user to access + pod, err := utils.CreatePod(ctx, client, podReq, podTemplateSpec) + if err != nil { + log.Error(err, "Failed to create Pod for AccessRequest") + return statusString, err + } + + // Define the permissions the access request will grant. + // + // TODO: Implement the ability to tune this in the PodAccessTemplate settings. + rules := []rbacv1.PolicyRule{ + { + APIGroups: []string{corev1.GroupName}, + Resources: []string{"pods"}, + ResourceNames: []string{pod.GetName()}, + Verbs: []string{"get", "list", "watch"}, + }, + { + APIGroups: []string{corev1.GroupName}, + Resources: []string{"pods/exec"}, + ResourceNames: []string{pod.GetName()}, + Verbs: []string{"create", "update", "delete", "get", "list"}, + }, + } + + // Get the Role, or error out + role, err := utils.CreateRole(ctx, client, podReq, rules) + if err != nil { + return statusString, err + } + + // Get the Binding, or error out + rb, err := utils.CreateRoleBinding(ctx, client, podReq, tmpl, role) + if err != nil { + return statusString, err + } + + // Generate the user-friendly information for how to access the pod + // + // TODO: Templatize this into the PodAccessTemplate in some way + // + accessString := fmt.Sprintf( + "kubectl exec -ti -n %s %s -- /bin/sh", + req.GetNamespace(), + pod.GetName(), + ) + podReq.Status.SetAccessMessage(accessString) + + // Set the podName (note, just in the local object). If this fails (for + // example, its already set on the object), then we also bail out. This + // only fails if the Status.PodName field has already been set, which would + // indicate some kind of a reconcile loop conflict. + // + // Writing back into the cluster is not handled here - must be handled by + // the caller of this method. + if err := podReq.SetPodName(pod.GetName()); err != nil { + return "", err + } + + // We've been mutating the podReq Status throughout this build. Need to + // push the update back to the cluster here. + if err := client.Status().Update(ctx, podReq); err != nil { + return "", err + } + + statusString = fmt.Sprintf("Success. Pod %s, Role %s, RoleBinding %s created", + pod.Name, + role.Name, + rb.Name, + ) + return statusString, nil +} diff --git a/internal/builders/podaccessbuilder/create_access_resources_test.go b/internal/builders/podaccessbuilder/create_access_resources_test.go new file mode 100644 index 0000000..6f53bcd --- /dev/null +++ b/internal/builders/podaccessbuilder/create_access_resources_test.go @@ -0,0 +1,182 @@ +package podaccessbuilder + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/diranged/oz/internal/api/v1alpha1" + bldutil "github.com/diranged/oz/internal/builders/utils" + "github.com/diranged/oz/internal/testing/utils" +) + +var _ = Describe("RequestReconciler", Ordered, func() { + Context("CreateAccessResources()", func() { + var ( + ctx = context.Background() + ns *corev1.Namespace + deployment *appsv1.Deployment + request *v1alpha1.PodAccessRequest + template *v1alpha1.PodAccessTemplate + builder = PodAccessBuilder{} + ) + + BeforeAll(func() { + By("Should have a namespace to execute tests in") + ns = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.RandomString(8), + }, + } + err := k8sClient.Create(ctx, ns) + Expect(err).ToNot(HaveOccurred()) + + By("Creating a Deployment to reference for the test") + deployment = &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "deployment-test", + Namespace: ns.Name, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "testLabel": "testValue", + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "testLabel": "testValue", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "test", + Image: "nginx:latest", + }, + }, + }, + }, + }, + } + err = k8sClient.Create(ctx, deployment) + Expect(err).To(Not(HaveOccurred())) + + By("Should have an PodAccessTemplate to test against") + cpuReq, _ := resource.ParseQuantity("1") + template = &v1alpha1.PodAccessTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.RandomString(8), + Namespace: ns.GetName(), + }, + Spec: v1alpha1.PodAccessTemplateSpec{ + AccessConfig: v1alpha1.AccessConfig{ + AllowedGroups: []string{"testGroupA"}, + DefaultDuration: "1h", + MaxDuration: "2h", + }, + ControllerTargetRef: &v1alpha1.CrossVersionObjectReference{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: deployment.Name, + }, + ControllerTargetMutationConfig: &v1alpha1.PodTemplateSpecMutationConfig{ + DefaultContainerName: "test", + Command: &[]string{"/bin/sleep"}, + Args: &[]string{"100"}, + Env: []corev1.EnvVar{ + {Name: "FOO", Value: "BAR"}, + }, + Resources: corev1.ResourceRequirements{ + Requests: map[corev1.ResourceName]resource.Quantity{ + "cpu": cpuReq, + }, + }, + }, + }, + } + err = k8sClient.Create(ctx, template) + Expect(err).ToNot(HaveOccurred()) + + By("Should have an PodAccessRequest built to test against") + request = &v1alpha1.PodAccessRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "createaccessresource-test", + Namespace: ns.GetName(), + }, + Spec: v1alpha1.PodAccessRequestSpec{ + TemplateName: template.GetName(), + }, + } + err = k8sClient.Create(ctx, request) + Expect(err).ToNot(HaveOccurred()) + }) + + AfterAll(func() { + By("Should delete the namespace") + err := k8sClient.Delete(ctx, ns) + Expect(err).ToNot(HaveOccurred()) + }) + + It("CreateAccessResources() should succeed", func() { + request.Status.PodName = "" + + // Execute + ret, err := builder.CreateAccessResources(ctx, k8sClient, request, template) + + // VERIFY: No error returned + Expect(err).ToNot(HaveOccurred()) + + // VERIFY: Proper status string returned + Expect(ret).To(MatchRegexp(fmt.Sprintf( + "Success. Pod %s-.*, Role %s-.*, RoleBinding %s.* created", + request.GetName(), + request.GetName(), + request.GetName(), + ))) + + // VERIFY: Pod Created as expected + foundPod := &corev1.Pod{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: bldutil.GenerateResourceName(request), + Namespace: ns.GetName(), + }, foundPod) + Expect(err).ToNot(HaveOccurred()) + Expect(foundPod.GetOwnerReferences()).ToNot(BeNil()) + Expect(foundPod.Spec.Containers[0].Command[0]).To(Equal("/bin/sleep")) + Expect(foundPod.Spec.Containers[0].Args[0]).To(Equal("100")) + + // VERIFY: Role Created as expected + foundRole := &rbacv1.Role{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: bldutil.GenerateResourceName(request), + Namespace: ns.GetName(), + }, foundRole) + Expect(err).ToNot(HaveOccurred()) + Expect(foundRole.GetOwnerReferences()).ToNot(BeNil()) + Expect(foundRole.Rules[0].ResourceNames[0]).To(Equal(foundPod.GetName())) + Expect(foundRole.Rules[1].ResourceNames[0]).To(Equal(foundPod.GetName())) + + // VERIFY: RoleBinding Created as expected + foundRoleBinding := &rbacv1.RoleBinding{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: bldutil.GenerateResourceName(request), + Namespace: ns.GetName(), + }, foundRoleBinding) + Expect(err).ToNot(HaveOccurred()) + Expect(foundRoleBinding.GetOwnerReferences()).ToNot(BeNil()) + Expect(foundRoleBinding.RoleRef.Name).To(Equal(foundRole.GetName())) + Expect(foundRoleBinding.Subjects[0].Name).To(Equal("testGroupA")) + }) + }) +}) diff --git a/internal/builders/podaccessbuilder/get_access_duration.go b/internal/builders/podaccessbuilder/get_access_duration.go new file mode 100644 index 0000000..181f9ad --- /dev/null +++ b/internal/builders/podaccessbuilder/get_access_duration.go @@ -0,0 +1,16 @@ +package podaccessbuilder + +import ( + "time" + + "github.com/diranged/oz/internal/api/v1alpha1" + "github.com/diranged/oz/internal/builders/utils" +) + +// GetAccessDuration implements the IBuilder interface +func (b *PodAccessBuilder) GetAccessDuration( + req v1alpha1.IRequestResource, + tmpl v1alpha1.ITemplateResource, +) (time.Duration, string, error) { + return utils.GetAccessDuration(req, tmpl) +} diff --git a/internal/builders/podaccessbuilder/get_template.go b/internal/builders/podaccessbuilder/get_template.go new file mode 100644 index 0000000..f79f76e --- /dev/null +++ b/internal/builders/podaccessbuilder/get_template.go @@ -0,0 +1,27 @@ +package podaccessbuilder + +import ( + "context" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/diranged/oz/internal/api/v1alpha1" + "github.com/diranged/oz/internal/builders" +) + +// GetTemplate implements the IBuilder interface +func (b *PodAccessBuilder) GetTemplate( + ctx context.Context, + client client.Client, + req v1alpha1.IRequestResource, +) (v1alpha1.ITemplateResource, error) { + tmpl, err := req.GetTemplate(ctx, client) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, builders.ErrTemplateDoesNotExist + } + return nil, err + } + return tmpl, nil +} diff --git a/internal/builders/podaccessbuilder/get_template_test.go b/internal/builders/podaccessbuilder/get_template_test.go new file mode 100644 index 0000000..0ad5c62 --- /dev/null +++ b/internal/builders/podaccessbuilder/get_template_test.go @@ -0,0 +1,108 @@ +package podaccessbuilder + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/diranged/oz/internal/api/v1alpha1" + "github.com/diranged/oz/internal/builders" + "github.com/diranged/oz/internal/testing/utils" +) + +var _ = Describe("PodAccessBuilder", Ordered, func() { + Context("GetTemplate()", func() { + var ( + ctx = context.Background() + ns *v1.Namespace + template *v1alpha1.PodAccessTemplate + ) + + BeforeAll(func() { + By("Should have a namespace to execute tests in") + ns = &v1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.RandomString(8), + }, + } + err := k8sClient.Create(ctx, ns) + Expect(err).ToNot(HaveOccurred()) + + By("Should have an PodAccessTemplate built to test against") + template = &v1alpha1.PodAccessTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "verifytemplate-test", + Namespace: ns.GetName(), + }, + Spec: v1alpha1.PodAccessTemplateSpec{ + AccessConfig: v1alpha1.AccessConfig{ + AllowedGroups: []string{}, + DefaultDuration: "1h", + MaxDuration: "2h", + }, + ControllerTargetRef: &v1alpha1.CrossVersionObjectReference{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "foo", + }, + }, + } + err = k8sClient.Create(ctx, template) + Expect(err).ToNot(HaveOccurred()) + }) + + AfterAll(func() { + By("Should delete the namespace") + err := k8sClient.Delete(ctx, ns) + Expect(err).ToNot(HaveOccurred()) + }) + + It("GetTemplate() should work", func() { + request := &v1alpha1.PodAccessRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "verifytemplate-test", + Namespace: ns.GetName(), + }, + Spec: v1alpha1.PodAccessRequestSpec{ + TemplateName: template.GetName(), + }, + } + builder := PodAccessBuilder{} + tmpl, err := builder.GetTemplate(ctx, k8sClient, request) + Expect(err).ToNot(HaveOccurred()) + Expect(tmpl.GetName()).To(Equal(template.GetName())) + }) + + It("GetTemplate() should throw TemplateDoesNotExist", func() { + request := &v1alpha1.PodAccessRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "verifytemplate-test", + Namespace: ns.GetName(), + }, + Spec: v1alpha1.PodAccessRequestSpec{ + TemplateName: "missing", + }, + } + builder := PodAccessBuilder{} + _, err := builder.GetTemplate(ctx, k8sClient, request) + Expect(err).To(Equal(builders.ErrTemplateDoesNotExist)) + }) + + It("GetTemplate() should throw unexpected errors", func() { + request := &v1alpha1.PodAccessRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "verifytemplate-missing", + Namespace: ns.GetName(), + }, + Spec: v1alpha1.PodAccessRequestSpec{}, + } + builder := PodAccessBuilder{} + _, err := builder.GetTemplate(ctx, k8sClient, request) + Expect(err.Error()).To(Equal("resource name may not be empty")) + }) + }) +}) diff --git a/internal/builders/podaccessbuilder/internal/doc.go b/internal/builders/podaccessbuilder/internal/doc.go new file mode 100644 index 0000000..9d92317 --- /dev/null +++ b/internal/builders/podaccessbuilder/internal/doc.go @@ -0,0 +1,4 @@ +// Package internal separates out some of the internal builder logic from the +// top level podaccessbuilder package to make it easier to see the +// interface-implementing methods as separate from the backend business logic. +package internal diff --git a/internal/builders/podaccessbuilder/set_request_owner_reference.go b/internal/builders/podaccessbuilder/set_request_owner_reference.go new file mode 100644 index 0000000..d9b3658 --- /dev/null +++ b/internal/builders/podaccessbuilder/set_request_owner_reference.go @@ -0,0 +1,20 @@ +package podaccessbuilder + +import ( + "context" + + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/diranged/oz/internal/api/v1alpha1" + "github.com/diranged/oz/internal/builders/utils" +) + +// SetRequestOwnerReference implements the IBuilder interface +func (b *PodAccessBuilder) SetRequestOwnerReference( + ctx context.Context, + client client.Client, + req v1alpha1.IRequestResource, + tmpl v1alpha1.ITemplateResource, +) error { + return utils.SetOwnerReference(ctx, client, tmpl, req) +} diff --git a/internal/builders/podaccessbuilder/set_request_owner_reference_test.go b/internal/builders/podaccessbuilder/set_request_owner_reference_test.go new file mode 100644 index 0000000..262950d --- /dev/null +++ b/internal/builders/podaccessbuilder/set_request_owner_reference_test.go @@ -0,0 +1,108 @@ +package podaccessbuilder + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/diranged/oz/internal/api/v1alpha1" + "github.com/diranged/oz/internal/testing/utils" +) + +var _ = Describe("PodAccessBuilder", Ordered, func() { + Context("SetOwnerReference()", func() { + var ( + ctx = context.Background() + ns *v1.Namespace + template *v1alpha1.PodAccessTemplate + ) + + BeforeAll(func() { + By("Should have a namespace to execute tests in") + ns = &v1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.RandomString(8), + }, + } + err := k8sClient.Create(ctx, ns) + Expect(err).ToNot(HaveOccurred()) + + By("Should have an PodAccessTemplate built to test against") + template = &v1alpha1.PodAccessTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Name: "verifytemplate-test", + Namespace: ns.GetName(), + }, + Spec: v1alpha1.PodAccessTemplateSpec{ + AccessConfig: v1alpha1.AccessConfig{ + AllowedGroups: []string{}, + DefaultDuration: "1h", + MaxDuration: "2h", + }, + ControllerTargetRef: &v1alpha1.CrossVersionObjectReference{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "foo", + }, + }, + } + err = k8sClient.Create(ctx, template) + Expect(err).ToNot(HaveOccurred()) + }) + + AfterAll(func() { + By("Should delete the namespace") + err := k8sClient.Delete(ctx, ns) + Expect(err).ToNot(HaveOccurred()) + }) + + It("SetOwnerReference() should work", func() { + By("Creating an PodAccessRequest object") + request := &v1alpha1.PodAccessRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.RandomString(8), + Namespace: ns.GetName(), + }, + Spec: v1alpha1.PodAccessRequestSpec{ + TemplateName: template.GetName(), + }, + } + err := k8sClient.Create(ctx, request) + Expect(err).To(Not(HaveOccurred())) + + By("Calling the SetOwnerReference() function") + builder := PodAccessBuilder{} + err = builder.SetRequestOwnerReference(ctx, k8sClient, request, template) + Expect(err).ToNot(HaveOccurred()) + + // VERIFY: The owner reference got set? + Expect(len(request.ObjectMeta.OwnerReferences)).To(Equal(1)) + }) + + It("SetOwnerReference() should fail if the template is invalid", func() { + By("Creating an PodAccessRequest object") + invalidtemplate := &v1alpha1.PodAccessTemplate{} + request := &v1alpha1.PodAccessRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.RandomString(8), + Namespace: ns.GetName(), + }, + Spec: v1alpha1.PodAccessRequestSpec{ + TemplateName: "missing", + }, + } + err := k8sClient.Create(ctx, request) + Expect(err).To(Not(HaveOccurred())) + + By("Calling the SetOwnerReference() function") + builder := PodAccessBuilder{} + err = builder.SetRequestOwnerReference(ctx, k8sClient, request, invalidtemplate) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(MatchRegexp("uid must not be empty")) + }) + }) +}) diff --git a/internal/builders/podaccessbuilder/suite_test.go b/internal/builders/podaccessbuilder/suite_test.go new file mode 100644 index 0000000..be23a77 --- /dev/null +++ b/internal/builders/podaccessbuilder/suite_test.go @@ -0,0 +1,87 @@ +/* +Copyright 2022 Matt Wise. + +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 podaccessbuilder + +import ( + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/zap/zapcore" + + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + crdsv1alpha1 "github.com/diranged/oz/internal/api/v1alpha1" + //+kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var ( + cfg *rest.Config + k8sClient client.Client + testEnv *envtest.Environment +) + +func TestAPIs(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Builder Suite / PodAccessBuilder") +} + +var _ = BeforeSuite(func() { + logger := zap.New( + zap.WriteTo(GinkgoWriter), + zap.UseDevMode(true), + zap.Level(zapcore.Level(-5)), + ) + logf.SetLogger(logger) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + var err error + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + err = crdsv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + //+kubebuilder:scaffold:scheme + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) diff --git a/internal/builders/podaccessbuilder/types.go b/internal/builders/podaccessbuilder/types.go new file mode 100644 index 0000000..bd8e393 --- /dev/null +++ b/internal/builders/podaccessbuilder/types.go @@ -0,0 +1,32 @@ +// Package podaccessbuilder implements the IBuilder interface for PodAccessRequest resources +package podaccessbuilder + +import ( + "time" + + "github.com/diranged/oz/internal/builders" +) + +//+kubebuilder:rbac:groups=crds.wizardofoz.co,resources=podaccessrequests,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=crds.wizardofoz.co,resources=podaccessrequests/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=crds.wizardofoz.co,resources=podaccessrequests/finalizers,verbs=update + +//+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=get;list;watch;create;update;patch;delete;bind;escalate +//+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;create;update;patch;delete + +// defaultReadyWaitTime is the default time in which we wait for resources to +// become Ready in the AccessResourcesAreReady() method. +var defaultReadyWaitTime = 30 * time.Second + +// defaultReadyWaitInterval is the time inbetween checks on the Pod status. +var defaultReadyWaitInterval = time.Second + +// PodAccessBuilder implements the IBuilder interface for PodAccessRequest resources +type PodAccessBuilder struct{} + +// https://stackoverflow.com/questions/33089523/how-to-mark-golang-struct-as-implementing-interface +var ( + _ builders.IBuilder = &PodAccessBuilder{} + _ builders.IBuilder = (*PodAccessBuilder)(nil) +) diff --git a/internal/builders/podaccessbuilder/vars.go b/internal/builders/podaccessbuilder/vars.go new file mode 100644 index 0000000..677fc79 --- /dev/null +++ b/internal/builders/podaccessbuilder/vars.go @@ -0,0 +1,9 @@ +package podaccessbuilder + +import ( + corev1 "k8s.io/api/core/v1" +) + +// PodPhaseRunning is exposed here so that we can reconfigure the search during +// tests to look for Pending pods. +var PodPhaseRunning = corev1.PodRunning diff --git a/internal/builders/utils/create_pod.go b/internal/builders/utils/create_pod.go new file mode 100644 index 0000000..72735b8 --- /dev/null +++ b/internal/builders/utils/create_pod.go @@ -0,0 +1,74 @@ +package utils + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + ctrlutil "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/diranged/oz/internal/api/v1alpha1" +) + +// CreatePod creates a new Pod based on the supplied PodTemplateSpec, ensuring +// that the OwnerReference is set appropriately before the creation to +// guarantee proper cleanup. +func CreatePod( + ctx context.Context, + client client.Client, + req v1alpha1.IRequestResource, + podTemplateSpec corev1.PodTemplateSpec, +) (*corev1.Pod, error) { + logger := logf.FromContext(ctx) + + // We'll populate this pod object + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: GenerateResourceName(req), + Namespace: req.GetNamespace(), + }, + } + + // Verify first whether or not a pod already exists with this name. If it + // does, we just return it back. The issue here is that updating a Pod is + // an unusual thing to do once it's alive, and can cause race condition + // issues if you do not do the updates properly. + // + // https://github.com/diranged/oz/issues/27 + err := client.Get(ctx, types.NamespacedName{ + Name: pod.Name, + Namespace: pod.Namespace, + }, pod) + + // If there was no error on this get, then the object already exists in K8S + // and we need to just return that. + if err == nil { + return pod, err + } + + // Finish filling out the desired PodSpec at this point. + pod.Spec = *podTemplateSpec.Spec.DeepCopy() + pod.ObjectMeta.Annotations = podTemplateSpec.ObjectMeta.Annotations + pod.ObjectMeta.Labels = podTemplateSpec.ObjectMeta.Labels + + // Set the ownerRef for the Deployment + // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/owners-dependents/ + if err := ctrlutil.SetControllerReference(req, pod, client.Scheme()); err != nil { + return nil, err + } + + // https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/controller/controllerutil#CreateOrUpdate + // + // In an update event, wec an only update the Annotations and the OwnerReference. Nothing else. + logger.V(1).Info(fmt.Sprintf("Creating Pod %s (ns: %s)", pod.Name, pod.Namespace)) + logger.V(5).Info("Pod Json", "json", ObjectToJSON(pod)) + if err := client.Create(ctx, pod); err != nil { + return nil, err + } + + return pod, nil +} diff --git a/internal/builders/utils/create_role.go b/internal/builders/utils/create_role.go index 3149378..372e7d6 100644 --- a/internal/builders/utils/create_role.go +++ b/internal/builders/utils/create_role.go @@ -22,7 +22,7 @@ func CreateRole( ) (*rbacv1.Role, error) { role := &rbacv1.Role{ ObjectMeta: metav1.ObjectMeta{ - Name: generateResourceName(req), + Name: GenerateResourceName(req), Namespace: req.GetNamespace(), }, Rules: rules, diff --git a/internal/builders/utils/create_role_binding.go b/internal/builders/utils/create_role_binding.go index 5b92b04..9997ef7 100644 --- a/internal/builders/utils/create_role_binding.go +++ b/internal/builders/utils/create_role_binding.go @@ -22,7 +22,7 @@ func CreateRoleBinding( ) (*rbacv1.RoleBinding, error) { rb := &rbacv1.RoleBinding{ ObjectMeta: metav1.ObjectMeta{ - Name: generateResourceName(req), + Name: GenerateResourceName(req), Namespace: req.GetNamespace(), }, RoleRef: rbacv1.RoleRef{ diff --git a/internal/builders/utils/generate_resource_name.go b/internal/builders/utils/generate_resource_name.go index 4b45c22..4c26a10 100644 --- a/internal/builders/utils/generate_resource_name.go +++ b/internal/builders/utils/generate_resource_name.go @@ -6,12 +6,12 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) -// generateResourceName takes in an API.IRequestResource conforming object and returns a unique +// GenerateResourceName takes in an API.IRequestResource conforming object and returns a unique // resource name string that can be used to safely create other resources (roles, bindings, etc). // // Returns: // // string: A resource name string -func generateResourceName(req client.Object) string { +func GenerateResourceName(req client.Object) string { return fmt.Sprintf("%s-%s", req.GetName(), getShortUID(req)) } diff --git a/internal/builders/utils/object_to_json.go b/internal/builders/utils/object_to_json.go new file mode 100644 index 0000000..19d4803 --- /dev/null +++ b/internal/builders/utils/object_to_json.go @@ -0,0 +1,19 @@ +package utils + +import ( + "encoding/json" + "fmt" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ObjectToJSON is a quick helper function for pretty-printing an entire K8S object in JSON form. +// Used in certain debug log statements primarily. +func ObjectToJSON(obj client.Object) string { + jsonData, err := json.Marshal(obj) + if err != nil { + fmt.Printf("could not marshal json: %s\n", err) + return "" + } + return string(jsonData) +} diff --git a/internal/builders/utils/suite_test.go b/internal/builders/utils/suite_test.go new file mode 100644 index 0000000..c5ac8e6 --- /dev/null +++ b/internal/builders/utils/suite_test.go @@ -0,0 +1,87 @@ +/* +Copyright 2022 Matt Wise. + +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 utils + +import ( + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/zap/zapcore" + + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + crdsv1alpha1 "github.com/diranged/oz/internal/api/v1alpha1" + //+kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var ( + cfg *rest.Config + k8sClient client.Client + testEnv *envtest.Environment +) + +func TestAPIs(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Builder Suite / Utils") +} + +var _ = BeforeSuite(func() { + logger := zap.New( + zap.WriteTo(GinkgoWriter), + zap.UseDevMode(true), + zap.Level(zapcore.Level(-5)), + ) + logf.SetLogger(logger) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + var err error + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + err = crdsv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + //+kubebuilder:scaffold:scheme + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) diff --git a/internal/legacybuilder/pod_access_builder_test.go b/internal/builders/utils/utils_test.go similarity index 58% rename from internal/legacybuilder/pod_access_builder_test.go rename to internal/builders/utils/utils_test.go index c4d86c5..336908a 100644 --- a/internal/legacybuilder/pod_access_builder_test.go +++ b/internal/builders/utils/utils_test.go @@ -1,4 +1,4 @@ -package legacybuilder +package utils import ( "context" @@ -9,32 +9,41 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" api "github.com/diranged/oz/internal/api/v1alpha1" + "github.com/diranged/oz/internal/testing/utils" ) -var _ = Describe("PodAccessBuilder", Ordered, func() { +var _ = Describe("IBuilder / Utils", Ordered, func() { Context("Functions()", func() { var ( - fakeClient client.Client + namespace *corev1.Namespace deployment *appsv1.Deployment ctx = context.Background() request *api.PodAccessRequest template *api.PodAccessTemplate - builder *PodAccessBuilder ) - BeforeEach(func() { - // NOTE: Fake Client used here to make it easier to keep state separate between each It() test. - fakeClient = fake.NewClientBuilder().WithRuntimeObjects().Build() + // NOTE: We use a real k8sClient for these tests beacuse we need to + // verify things like UID generation happening in the backend, as well + // as generation spec updates. + BeforeAll(func() { + By("Creating the Namespace to perform the tests") + namespace = &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: utils.RandomString(8), + }, + } + err := k8sClient.Create(ctx, namespace) + Expect(err).To(Not(HaveOccurred())) + }) + BeforeEach(func() { // Create a fake deployment target deployment = &appsv1.Deployment{ ObjectMeta: metav1.ObjectMeta{ Name: "test-dep", - Namespace: "test-ns", + Namespace: namespace.GetName(), }, Spec: appsv1.DeploymentSpec{ Selector: &metav1.LabelSelector{ @@ -45,7 +54,7 @@ var _ = Describe("PodAccessBuilder", Ordered, func() { Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{ - api.DefaultContainerAnnotationKey: "contB", + api.DefaultContainerAnnotationKey: "contb", "Foo": "bar", }, Labels: map[string]string{ @@ -55,11 +64,11 @@ var _ = Describe("PodAccessBuilder", Ordered, func() { Spec: corev1.PodSpec{ Containers: []corev1.Container{ { - Name: "contA", + Name: "conta", Image: "nginx:latest", }, { - Name: "contB", + Name: "contb", Image: "nginx:latest", }, }, @@ -67,7 +76,7 @@ var _ = Describe("PodAccessBuilder", Ordered, func() { }, }, } - err := fakeClient.Create(ctx, deployment) + err := k8sClient.Create(ctx, deployment) Expect(err).To(Not(HaveOccurred())) // Create a default PodAccessTemplate. We'll mutate it for specific tests. @@ -90,7 +99,7 @@ var _ = Describe("PodAccessBuilder", Ordered, func() { ControllerTargetMutationConfig: &api.PodTemplateSpecMutationConfig{}, }, } - err = fakeClient.Create(ctx, template) + err = k8sClient.Create(ctx, template) Expect(err).To(Not(HaveOccurred())) // Create a simple PodAccessRequest resource to test the template with @@ -104,41 +113,27 @@ var _ = Describe("PodAccessBuilder", Ordered, func() { Duration: "5m", }, } - err = fakeClient.Create(ctx, request) + err = k8sClient.Create(ctx, request) Expect(err).To(Not(HaveOccurred())) - - // Create the PodAccessBuilder finally - fully populated with the - // Request, Template and fake clients. - builder = &PodAccessBuilder{ - BaseBuilder: BaseBuilder{ - Client: fakeClient, - Ctx: ctx, - APIReader: fakeClient, - Request: request, - Template: template, - }, - Template: template, - Request: request, - } }) - // TODO: Write tests that check the builder logic, more than that check the nested api logic - It("generatePodTemplateSpec should return unmutated without error", func() { - // Get the original pod template spec... - podTemplateSpec, err := builder.generatePodTemplateSpec() + AfterEach(func() { + err := k8sClient.Delete(ctx, deployment) Expect(err).To(Not(HaveOccurred())) - - // Run the PodSpec through the optional mutation config - mutator := template.Spec.ControllerTargetMutationConfig - ret, err := mutator.PatchPodTemplateSpec(ctx, podTemplateSpec) + err = k8sClient.Delete(ctx, request) Expect(err).To(Not(HaveOccurred())) + err = k8sClient.Delete(ctx, template) + Expect(err).To(Not(HaveOccurred())) + }) - // Wipe: metadata.labels (not optional) - expectedPodTemplateSpec := podTemplateSpec.DeepCopy() - expectedPodTemplateSpec.ObjectMeta.Labels = map[string]string{} + It("getShortUID should work", func() { + ret := getShortUID(request) + Expect(len(ret)).To(Equal(8)) + }) - // VERIFY: The original spec and new spec are identical - Expect(ret.DeepCopy()).To(Equal(expectedPodTemplateSpec)) + It("generateResourceName should work", func() { + ret := GenerateResourceName(request) + Expect(len(ret)).To(Equal(17)) }) }) }) diff --git a/internal/cmd/manager/main.go b/internal/cmd/manager/main.go index ea1687a..29f8fa6 100644 --- a/internal/cmd/manager/main.go +++ b/internal/cmd/manager/main.go @@ -18,14 +18,17 @@ limitations under the License. package manager import ( + "context" "flag" "os" "time" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/webhook" @@ -33,6 +36,7 @@ import ( "github.com/diranged/oz/internal/api/v1alpha1" crdsv1alpha1 "github.com/diranged/oz/internal/api/v1alpha1" "github.com/diranged/oz/internal/builders/execaccessbuilder" + "github.com/diranged/oz/internal/builders/podaccessbuilder" "github.com/diranged/oz/internal/controllers" "github.com/diranged/oz/internal/controllers/podwatcher" "github.com/diranged/oz/internal/controllers/requestcontroller" @@ -66,6 +70,7 @@ func Main() { var probeAddr string var enableLeaderElection bool var requestReconciliationInterval int + // Boilerplate flag.StringVar( &metricsAddr, @@ -151,6 +156,27 @@ func Main() { &webhook.Admission{Handler: &podwatcher.PodExecWatcher{Client: mgr.GetClient()}}, ) + // Provide a searchable index in the cached kubernetes client for "metadata.name" - the pod name. + if err := mgr.GetFieldIndexer().IndexField(context.Background(), &corev1.Pod{}, v1alpha1.FieldSelectorMetadataName, func(rawObj client.Object) []string { + // grab the job object, extract the name... + pod := rawObj.(*corev1.Pod) + name := pod.GetName() + return []string{name} + }); err != nil { + panic(err) + } + + // Provide a searchable index in the cached kubernetes client for "status.phase", allowing us to + // search for Running Pods. + if err := mgr.GetFieldIndexer().IndexField(context.Background(), &corev1.Pod{}, v1alpha1.FieldSelectorStatusPhase, func(rawObj client.Object) []string { + // grab the job object, extract the phase... + pod := rawObj.(*corev1.Pod) + phase := string(pod.Status.Phase) + return []string{phase} + }); err != nil { + panic(err) + } + // Set Up the Reconcilers // // These are the core components that are "watching" the custom resource @@ -198,17 +224,15 @@ func Main() { os.Exit(1) } - if err = (&controllers.PodAccessRequestReconciler{ - BaseRequestReconciler: controllers.BaseRequestReconciler{ - BaseReconciler: controllers.BaseReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - APIReader: mgr.GetAPIReader(), - ReconcililationInterval: requestReconciliationInterval, - }, - }, + if err = (&requestcontroller.RequestReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + APIReader: mgr.GetAPIReader(), + RequestType: &v1alpha1.PodAccessRequest{}, + Builder: &podaccessbuilder.PodAccessBuilder{}, + ReconcilliationInterval: time.Duration(requestReconciliationInterval) * time.Minute, }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, unableToCreateMsg, controllerKey, "AccessRequest") + setupLog.Error(err, unableToCreateMsg, controllerKey, "PodAccessRequest") os.Exit(1) } diff --git a/internal/controllers/base_controller.go b/internal/controllers/base_controller.go index 1ff70d9..b5f8647 100644 --- a/internal/controllers/base_controller.go +++ b/internal/controllers/base_controller.go @@ -1,12 +1,9 @@ package controllers import ( - "context" - "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/log" ) // BaseReconciler extends the default reconciler behaviors (client.Client+Scheme) and provide some @@ -47,11 +44,3 @@ func (r *BaseReconciler) SetReconciliationInterval() { r.ReconcililationInterval = DefaultReconciliationInterval } } - -func (r *BaseReconciler) getLogger(ctx context.Context) logr.Logger { - if (r.logger == logr.Logger{}) { - // https://sdk.operatorframework.io/docs/building-operators/golang/references/logging/ - r.logger = log.FromContext(ctx) - } - return r.logger -} diff --git a/internal/controllers/base_request_controller.go b/internal/controllers/base_request_controller.go deleted file mode 100644 index a24e8f7..0000000 --- a/internal/controllers/base_request_controller.go +++ /dev/null @@ -1,200 +0,0 @@ -package controllers - -import ( - "fmt" - "time" - - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/log" - - "github.com/diranged/oz/internal/api/v1alpha1" - "github.com/diranged/oz/internal/controllers/internal/status" - "github.com/diranged/oz/internal/legacybuilder" -) - -// BaseRequestReconciler provides a base reconciler with common functions for handling our Template CRDs -// (ExecAccessTemplate, AccessTemplate, etc) -type BaseRequestReconciler struct { - BaseReconciler -} - -// verifyDuration checks a few components of whether or not the AccessRequest is still valid: -// -// - Was the (optional) supplied "spec.duration" valid? -// - Is the target tempate "spec.defaultDuration" valid? -// - Is the target template "spec.maxDuration" valid? -// - Did the user supply their own "spec.duration"? -// yes? Is it lower than the target template "spec.maxDuration"? -// no? Use the target template "spec.defaultDuration" -// - Is the access request duration less than its current age? -// yes? approve -// no? mark the resource for deletion -func (r *BaseRequestReconciler) verifyDuration(builder legacybuilder.IBuilder) error { - var err error - logger := r.getLogger(builder.GetCtx()) - - logger.Info("Beginning access request duration verification") - - // Step one - verify the inputs themselves. If the user supplied invalid inputs, or the template has any - // invalid inputs, we bail out and update the conditions as such. This is to prevent escalated privilegess - // from lasting indefinitely. - var requestedDuration time.Duration - if requestedDuration, err = builder.GetRequest().GetDuration(); err != nil { - // NOTE: Blindly ignoring the error return here because we are already - // returning an error which will fail the reconciliation. - _ = status.SetRequestDurationsNotValid(builder.GetCtx(), r, builder.GetRequest(), - fmt.Sprintf("spec.duration error: %s", err), - ) - return err - } - templateDefaultDuration, err := builder.GetTemplate().GetAccessConfig().GetDefaultDuration() - if err != nil { - // NOTE: Blindly ignoring the error return here because we are already - // returning an error which will fail the reconciliation. - _ = status.SetRequestDurationsNotValid(builder.GetCtx(), r, builder.GetRequest(), - fmt.Sprintf("Template Error, spec.defaultDuration error: %s", err), - ) - return err - } - - templateMaxDuration, err := builder.GetTemplate().GetAccessConfig().GetMaxDuration() - if err != nil { - // NOTE: Blindly ignoring the error return here because we are already - // returning an error which will fail the reconciliation. - _ = status.SetRequestDurationsNotValid(builder.GetCtx(), r, builder.GetRequest(), - fmt.Sprintf("Template Error, spec.maxDuration error: %s", err), - ) - return err - } - - // Now determine which duration is the one we'll use - var accessDuration time.Duration - { - var reasonStr string - if requestedDuration == 0 { - // If no requested duration supplied, then default to the template's default duration - reasonStr = fmt.Sprintf( - "Access request duration defaulting to template duration time (%s)", - templateDefaultDuration.String(), - ) - accessDuration = templateDefaultDuration - } else if requestedDuration <= templateMaxDuration { - // If the requested duration is too long, use the template max - reasonStr = fmt.Sprintf("Access requested custom duration (%s)", requestedDuration.String()) - accessDuration = requestedDuration - } else { - // Finally, if it's valid, use the supplied duration - reasonStr = fmt.Sprintf("Access requested duration (%s) larger than template maximum duration (%s)", requestedDuration.String(), templateMaxDuration.String()) - accessDuration = templateMaxDuration - } - - // Log out the decision, and update the condition - logger.Info(reasonStr) - if err := status.SetRequestDurationsValid(builder.GetCtx(), r, builder.GetRequest(), reasonStr); err != nil { - return err - } - } - - // If the accessUptime is greater than the accessDuration, kill it. - if builder.GetRequest().GetUptime() > accessDuration { - return status.SetAccessNotValid(builder.GetCtx(), r, builder.GetRequest()) - } - - // Update the resource, and let the user know how much time is remaining - return status.SetAccessStillValid(builder.GetCtx(), r, builder.GetRequest()) -} - -// isAccessExpired checks the AccessRequest status for the ConditionAccessStillValid condition. If it is no longer -// a valid request, then the resource is immediately deleted. -// -// Returns: -// -// true: if the resource is expired, AND has now been deleted -// false: if the resource is still valid -// error: any error during the checks -func (r *BaseRequestReconciler) isAccessExpired(builder legacybuilder.IBuilder) (bool, error) { - logger := r.getLogger(builder.GetCtx()) - logger.Info("Checking if access has expired or not...") - cond := meta.FindStatusCondition( - *builder.GetRequest().GetStatus().GetConditions(), - v1alpha1.ConditionAccessStillValid.String(), - ) - if cond == nil { - logger.Info( - fmt.Sprintf( - "Missing Condition %s, skipping deletion", - v1alpha1.ConditionAccessStillValid, - ), - ) - return false, nil - } - - if cond.Status == metav1.ConditionFalse { - logger.Info( - fmt.Sprintf( - "Found Condition %s in state %s, terminating rqeuest", - v1alpha1.ConditionAccessStillValid, - cond.Status, - ), - ) - return true, r.DeleteResource(builder) - } - - logger.Info( - fmt.Sprintf( - "Found Condition %s in state %s, leaving alone", - v1alpha1.ConditionAccessStillValid, - cond.Status, - ), - ) - return false, nil -} - -// verifyAccessResourcesBuilt calls out to the Builder interface's GenerateAccessResources() method to build out -// all of the resources that are required for thie particular access request. The Status.Conditions field is -// then updated with the ConditionAccessResourcesCreated condition appropriately. -func (r *BaseRequestReconciler) verifyAccessResourcesBuilt( - builder legacybuilder.IBuilder, -) error { - logger := log.FromContext(builder.GetCtx()) - logger.Info("Verifying that access resources are built") - - statusString, err := builder.GenerateAccessResources() - if err != nil { - // NOTE: Blindly ignoring the error return here because we are already - // returning an error which will fail the reconciliation. - _ = status.SetAccessResourcesNotCreated(builder.GetCtx(), r, builder.GetRequest(), err) - return err - } - return status.SetAccessResourcesCreated(builder.GetCtx(), r, builder.GetRequest(), statusString) -} - -// verifyAccessResourcesReady is a followup to the verifyAccessResources() -// function - where we make sure that the .Status.PodName resource has come all -// the way up and reached the "Running" phase. -func (r *BaseRequestReconciler) verifyAccessResourcesReady( - builder legacybuilder.IPodAccessBuilder, -) error { - logger := log.FromContext(builder.GetCtx()) - logger.Info("Verifying that access resources are ready") - - statusString, err := builder.VerifyAccessResources() - if err != nil { - // NOTE: Blindly ignoring the error return here because we are already - // returning an error which will fail the reconciliation. - _ = status.SetAccessResourcesNotReady(builder.GetCtx(), r, builder.GetRequest(), err) - return err - } - - return status.SetAccessResourcesReady(builder.GetCtx(), r, builder.GetRequest(), statusString) -} - -// DeleteResource just deletes the resource immediately -// -// Returns: -// -// error: Any error during the deletion -func (r *BaseRequestReconciler) DeleteResource(builder legacybuilder.IBuilder) error { - return r.Delete(builder.GetCtx(), builder.GetRequest()) -} diff --git a/internal/controllers/base_request_controller_test.go b/internal/controllers/base_request_controller_test.go deleted file mode 100644 index e0fd740..0000000 --- a/internal/controllers/base_request_controller_test.go +++ /dev/null @@ -1,749 +0,0 @@ -package controllers - -import ( - "context" - "errors" - "time" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "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" - - "github.com/diranged/oz/internal/api/v1alpha1" - "github.com/diranged/oz/internal/legacybuilder" -) - -var _ = Describe("BaseRequestReconciler", Ordered, func() { - Context("verifyAccessResources", func() { - var ( - request *v1alpha1.ExecAccessRequest - builder *FakeBuilder - r *BaseRequestReconciler - fakeClient client.Client - ctx = context.Background() - ) - - BeforeEach(func() { - // NOTE: Fake Client used here to make it easier to keep state separate between each It() test. - fakeClient = fake.NewClientBuilder().WithRuntimeObjects().Build() - - // Create the template that can be referenced by the request - r = &BaseRequestReconciler{ - BaseReconciler: BaseReconciler{ - Client: fakeClient, - Scheme: fakeClient.Scheme(), - APIReader: fakeClient, - ReconcililationInterval: 0, - }, - } - - // Create an empty request to test against - request = &v1alpha1.ExecAccessRequest{ - ObjectMeta: metav1.ObjectMeta{ - Name: "expiredRequest", - Namespace: "namespace", - }, - Spec: v1alpha1.ExecAccessRequestSpec{ - TemplateName: "bogus", - }, - Status: v1alpha1.ExecAccessRequestStatus{ - CoreStatus: v1alpha1.CoreStatus{ - Conditions: []metav1.Condition{}, - Ready: false, - AccessMessage: "", - }, - }, - } - - // Create the Builder that we'll be testing - builder = &FakeBuilder{ - BaseBuilder: legacybuilder.BaseBuilder{ - Client: fakeClient, - Ctx: ctx, - APIReader: fakeClient, - Request: request, - }, - } - - // Create the template resource for real in the fake kubernetes client - err := fakeClient.Create(ctx, request) - Expect(err).To(Not(HaveOccurred())) - }) - - It("Should return clean access message set condition to true", func() { - // Configure FakeBuilder to return success - builder.retErr = nil - builder.retStatusString = "success" - - // Build the resources - err := r.verifyAccessResourcesBuilt(builder) - - // VERIFY: no error - Expect(err).To(Not(HaveOccurred())) - - // VERIFY: the conditions in the request object were updated - Expect(meta.IsStatusConditionPresentAndEqual( - request.Status.Conditions, - v1alpha1.ConditionAccessResourcesCreated.String(), - metav1.ConditionTrue)).To(BeTrue()) - cond := meta.FindStatusCondition( - request.Status.Conditions, - v1alpha1.ConditionAccessResourcesCreated.String(), - ) - Expect(cond.Message).To(Equal("success")) - }) - - It( - "Should return access message set condition to false if error creating resources", - func() { - // Configure FakeBuilder to return success - builder.retErr = errors.New("i failed") - builder.retStatusString = "failure" - - // Build the resources - err := r.verifyAccessResourcesBuilt(builder) - - // VERIFY: error occurred - Expect(err).To(HaveOccurred()) - - // VERIFY: the conditions in the request object were updated - Expect(meta.IsStatusConditionPresentAndEqual( - request.Status.Conditions, - v1alpha1.ConditionAccessResourcesCreated.String(), - metav1.ConditionFalse)).To(BeTrue()) - cond := meta.FindStatusCondition( - request.Status.Conditions, - v1alpha1.ConditionAccessResourcesCreated.String(), - ) - Expect(cond.Message).To(Equal("ERROR: i failed")) - }, - ) - - It("Should return an error if the UpdateCondition fails on success", func() { - // Configure FakeBuilder to return success - builder.retErr = nil - builder.retStatusString = "success" - - // Break the "request" object by changing its name to something that doesn't exist, - // to cause the UpdateCondition() to fail. - request.Name = "bogus" - - // Build the resources - err := r.verifyAccessResourcesBuilt(builder) - - // VERIFY: error occurred - Expect(err).To(HaveOccurred()) - Expect( - err.Error(), - ).To(Equal("execaccessrequests.crds.wizardofoz.co \"bogus\" not found")) - }) - }) - - Context("isAccessExpired", func() { - var ( - request *v1alpha1.ExecAccessRequest - builder *legacybuilder.BaseBuilder - r *BaseRequestReconciler - fakeClient client.Client - ctx = context.Background() - ) - - BeforeEach(func() { - // NOTE: Fake Client used here to make it easier to keep state separate between each It() test. - fakeClient = fake.NewClientBuilder().WithRuntimeObjects().Build() - - // Create the template that can be referenced by the request - r = &BaseRequestReconciler{ - BaseReconciler: BaseReconciler{ - Client: fakeClient, - Scheme: fakeClient.Scheme(), - APIReader: fakeClient, - ReconcililationInterval: 0, - }, - } - }) - - It("Should return False if the access valid condition is True", func() { - // Create a fake request with a condition already populated that indicates we've been expired - request = &v1alpha1.ExecAccessRequest{ - ObjectMeta: metav1.ObjectMeta{ - Name: "expiredRequest", - Namespace: "namespace", - }, - Spec: v1alpha1.ExecAccessRequestSpec{ - TemplateName: "bogus", - }, - Status: v1alpha1.ExecAccessRequestStatus{ - CoreStatus: v1alpha1.CoreStatus{ - Conditions: []metav1.Condition{ - { - Type: v1alpha1.ConditionAccessStillValid.String(), - Status: metav1.ConditionTrue, - Reason: "Valid", - Message: "Valid", - }, - }, - Ready: false, - AccessMessage: "", - }, - }, - } - - // Create the template resource for real in the fake kubernetes client - err := fakeClient.Create(ctx, request) - Expect(err).To(Not(HaveOccurred())) - - // Create the Builder that we'll be testing - builder = &legacybuilder.BaseBuilder{ - Client: fakeClient, - Ctx: ctx, - APIReader: fakeClient, - Request: request, - } - - // VERIFY: isExpired returned False - isExpired, err := r.isAccessExpired(builder) - Expect(err).To(Not(HaveOccurred())) - Expect(isExpired).To(BeFalse()) - }) - - It("Should return True if the access valid condition is false", func() { - // Create a fake request with a condition already populated that indicates we've been expired - request = &v1alpha1.ExecAccessRequest{ - ObjectMeta: metav1.ObjectMeta{ - Name: "expiredRequest", - Namespace: "namespace", - }, - Spec: v1alpha1.ExecAccessRequestSpec{ - TemplateName: "bogus", - }, - Status: v1alpha1.ExecAccessRequestStatus{ - CoreStatus: v1alpha1.CoreStatus{ - Conditions: []metav1.Condition{ - { - Type: v1alpha1.ConditionAccessStillValid.String(), - Status: metav1.ConditionFalse, - Reason: "Expired", - Message: "Expired", - }, - }, - Ready: false, - AccessMessage: "", - }, - }, - } - - // Create the template resource for real in the fake kubernetes client - err := fakeClient.Create(ctx, request) - Expect(err).To(Not(HaveOccurred())) - - // Create the Builder that we'll be testing - builder = &legacybuilder.BaseBuilder{ - Client: fakeClient, - Ctx: ctx, - APIReader: fakeClient, - Request: request, - } - - // VERIFY: isExpired returned True - isExpired, err := r.isAccessExpired(builder) - Expect(err).To(Not(HaveOccurred())) - Expect(isExpired).To(BeTrue()) - - // VERIFY: the AccessRequest is deleted - found := &v1alpha1.ExecAccessRequest{} - err = fakeClient.Get(ctx, types.NamespacedName{ - Name: request.Name, - Namespace: request.Namespace, - }, found) - Expect( - err.Error(), - ).To(Equal("execaccessrequests.crds.wizardofoz.co \"expiredRequest\" not found")) - }) - - It("Should return False if the AccessValid condition is missing", func() { - // Create a fake request with a condition already populated that indicates we've been expired - request = &v1alpha1.ExecAccessRequest{ - ObjectMeta: metav1.ObjectMeta{ - Name: "expiredRequest", - Namespace: "namespace", - }, - Spec: v1alpha1.ExecAccessRequestSpec{ - TemplateName: "bogus", - }, - Status: v1alpha1.ExecAccessRequestStatus{ - CoreStatus: v1alpha1.CoreStatus{ - Conditions: []metav1.Condition{}, - Ready: false, - AccessMessage: "", - }, - }, - } - - // Create the template resource for real in the fake kubernetes client - err := fakeClient.Create(ctx, request) - Expect(err).To(Not(HaveOccurred())) - - // Create the Builder that we'll be testing - builder = &legacybuilder.BaseBuilder{ - Client: fakeClient, - Ctx: ctx, - APIReader: fakeClient, - Request: request, - } - - // VERIFY: isExpired returns false - isExpired, err := r.isAccessExpired(builder) - Expect(err).To(Not(HaveOccurred())) - Expect(isExpired).To(BeFalse()) - }) - }) - - Context("verifyDuration", func() { - var ( - template *v1alpha1.ExecAccessTemplate - request *v1alpha1.ExecAccessRequest - builder *legacybuilder.BaseBuilder - r *BaseRequestReconciler - fakeClient client.Client - ctx = context.Background() - logger = log.FromContext(ctx) - ) - - BeforeEach(func() { - logger.Info("BeforeEach...") - - // NOTE: Fake Client used here to make it easier to keep state separate between each It() test. - fakeClient = fake.NewClientBuilder().WithRuntimeObjects().Build() - - // Create a common ExecAccessTemplate used to test the request against - template = &v1alpha1.ExecAccessTemplate{ - ObjectMeta: metav1.ObjectMeta{ - Name: "testingTemplate", - Namespace: "fake", - }, - Spec: v1alpha1.ExecAccessTemplateSpec{ - AccessConfig: v1alpha1.AccessConfig{ - AllowedGroups: []string{"foo", "bar"}, - DefaultDuration: "1h", - MaxDuration: "2h", - }, - ControllerTargetRef: &v1alpha1.CrossVersionObjectReference{ - APIVersion: "apps/v1", - Kind: "Deployment", - Name: "targetDeployment", - }, - }, - } - - // Create the template that can be referenced by the request - err := fakeClient.Create(ctx, template) - Expect(err).To(Not(HaveOccurred())) - - r = &BaseRequestReconciler{ - BaseReconciler: BaseReconciler{ - Client: fakeClient, - Scheme: fakeClient.Scheme(), - APIReader: fakeClient, - logger: logger, - ReconcililationInterval: 0, - }, - } - }) - - It("Should update conditions to True in success", func() { - // Create the ExecAccessTemplate object that points to the valid Deployment - request = &v1alpha1.ExecAccessRequest{ - ObjectMeta: metav1.ObjectMeta{ - Name: "testingRequest", - Namespace: "fake", - // Set the creation timestamp so that the verification works, the fake kubeclient doesn't do that. - CreationTimestamp: metav1.NewTime(time.Now()), - }, - Spec: v1alpha1.ExecAccessRequestSpec{ - TemplateName: "testingTemplate", - Duration: "30m", - }, - } - - // Create the template resource for real in the fake kubernetes client - err := fakeClient.Create(ctx, request) - Expect(err).To(Not(HaveOccurred())) - - // Create the Builder that we'll be testing - builder = &legacybuilder.BaseBuilder{ - Client: fakeClient, - Ctx: ctx, - APIReader: fakeClient, - Template: template, - Request: request, - } - - // Call the method.. it should succeed. - err = r.verifyDuration(builder) - Expect(err).To(Not(HaveOccurred())) - - // VERIFY: The ConditionRequestDurationsValid is True - Expect(meta.IsStatusConditionPresentAndEqual( - request.Status.Conditions, - v1alpha1.ConditionRequestDurationsValid.String(), - metav1.ConditionTrue)).To(BeTrue()) - cond := meta.FindStatusCondition( - request.Status.Conditions, - v1alpha1.ConditionRequestDurationsValid.String(), - ) - Expect(cond.Message).To(Equal("Access requested custom duration (30m0s)")) - - // VERIFY: The conditionAccessStillValid is True - cond = meta.FindStatusCondition( - request.Status.Conditions, - v1alpha1.ConditionAccessStillValid.String(), - ) - Expect(cond.Message).To(Equal("Access still valid")) - }) - - It("Should use template default duration if none is supplied", func() { - // Create the ExecAccessTemplate object that points to the valid Deployment - request = &v1alpha1.ExecAccessRequest{ - ObjectMeta: metav1.ObjectMeta{ - Name: "testingRequest", - Namespace: "fake", - // Set the creation timestamp so that the verification works, the fake kubeclient doesn't do that. - CreationTimestamp: metav1.NewTime(time.Now()), - }, - Spec: v1alpha1.ExecAccessRequestSpec{ - TemplateName: "testingTemplate", - // Duration: "30m", - }, - } - - // Create the template resource for real in the fake kubernetes client - err := fakeClient.Create(ctx, request) - Expect(err).To(Not(HaveOccurred())) - - // Create the Builder that we'll be testing - builder = &legacybuilder.BaseBuilder{ - Client: fakeClient, - Ctx: ctx, - APIReader: fakeClient, - Template: template, - Request: request, - } - - // Call the method.. it should succeed. - err = r.verifyDuration(builder) - Expect(err).To(Not(HaveOccurred())) - - // VERIFY: The ConditionRequestDurationsValid is True - Expect(meta.IsStatusConditionPresentAndEqual( - request.Status.Conditions, - v1alpha1.ConditionRequestDurationsValid.String(), - metav1.ConditionTrue)).To(BeTrue()) - cond := meta.FindStatusCondition( - request.Status.Conditions, - v1alpha1.ConditionRequestDurationsValid.String(), - ) - Expect( - cond.Message, - ).To(Equal("Access request duration defaulting to template duration time (1h0m0s)")) - - // VERIFY: The conditionAccessStillValid is True - cond = meta.FindStatusCondition( - request.Status.Conditions, - v1alpha1.ConditionAccessStillValid.String(), - ) - Expect(cond.Message).To(Equal("Access still valid")) - }) - - It("Should use template max duration if requested duration is too long", func() { - // Create the ExecAccessTemplate object that points to the valid Deployment - request = &v1alpha1.ExecAccessRequest{ - ObjectMeta: metav1.ObjectMeta{ - Name: "testingRequest", - Namespace: "fake", - // Set the creation timestamp so that the verification works, the fake kubeclient doesn't do that. - CreationTimestamp: metav1.NewTime(time.Now()), - }, - Spec: v1alpha1.ExecAccessRequestSpec{ - TemplateName: "testingTemplate", - Duration: "24h", - }, - } - - // Create the template resource for real in the fake kubernetes client - err := fakeClient.Create(ctx, request) - Expect(err).To(Not(HaveOccurred())) - - // Create the Builder that we'll be testing - builder = &legacybuilder.BaseBuilder{ - Client: fakeClient, - Ctx: ctx, - APIReader: fakeClient, - Template: template, - Request: request, - } - - // Call the method.. it should succeed. - err = r.verifyDuration(builder) - Expect(err).To(Not(HaveOccurred())) - - // VERIFY: The ConditionRequestDurationsValid is True - Expect(meta.IsStatusConditionPresentAndEqual( - request.Status.Conditions, - v1alpha1.ConditionRequestDurationsValid.String(), - metav1.ConditionTrue)).To(BeTrue()) - cond := meta.FindStatusCondition( - request.Status.Conditions, - v1alpha1.ConditionRequestDurationsValid.String(), - ) - Expect( - cond.Message, - ).To(Equal("Access requested duration (24h0m0s) larger than template maximum duration (2h0m0s)")) - - // VERIFY: The conditionAccessStillValid is True - cond = meta.FindStatusCondition( - request.Status.Conditions, - v1alpha1.ConditionAccessStillValid.String(), - ) - Expect(cond.Message).To(Equal("Access still valid")) - }) - - It("Should set condition if the spec.Duration is invalid", func() { - // Create the ExecAccessTemplate object that points to the valid Deployment - request = &v1alpha1.ExecAccessRequest{ - ObjectMeta: metav1.ObjectMeta{ - Name: "testingRequest", - Namespace: "fake", - // Set the creation timestamp so that the verification works, the fake kubeclient doesn't do that. - CreationTimestamp: metav1.NewTime(time.Now()), - }, - Spec: v1alpha1.ExecAccessRequestSpec{ - TemplateName: "testingTemplate", - Duration: "30minutes", - }, - } - - // Create the template resource for real in the fake kubernetes client - err := fakeClient.Create(ctx, request) - Expect(err).To(Not(HaveOccurred())) - - // Create the Builder that we'll be testing - builder = &legacybuilder.BaseBuilder{ - Client: fakeClient, - Ctx: ctx, - APIReader: fakeClient, - Template: template, - Request: request, - } - - // Call the method.. it should succeed. - err = r.verifyDuration(builder) - - // VERIFY: The proper Error was returned - Expect(err).To(HaveOccurred()) - Expect( - err.Error(), - ).To(Equal("time: unknown unit \"minutes\" in duration \"30minutes\"")) - - // VERIFY: The ConditionRequestDurationsValid is False - Expect(meta.IsStatusConditionPresentAndEqual( - request.Status.Conditions, - v1alpha1.ConditionRequestDurationsValid.String(), - metav1.ConditionFalse)).To(BeTrue()) - - // VERIFY: The Condition was updated properly in the object even though an error was returned - cond := meta.FindStatusCondition( - request.Status.Conditions, - v1alpha1.ConditionRequestDurationsValid.String(), - ) - Expect( - cond.Message, - ).To(Equal("spec.duration error: time: unknown unit \"minutes\" in duration \"30minutes\"")) - }) - - It( - "Should set condition if the referenced template spec.accessConfig.defaultDuration is invalid", - func() { - // Get the template, and update its defaultDuration to something invalid - err := fakeClient.Get(ctx, types.NamespacedName{ - Name: template.Name, - Namespace: template.Namespace, - }, template) - Expect(err).To(Not(HaveOccurred())) - template.Spec.AccessConfig.DefaultDuration = "1hour" - err = fakeClient.Update(ctx, template) - Expect(err).To(Not(HaveOccurred())) - - // Create the ExecAccessTemplate object that points to the valid Deployment - request = &v1alpha1.ExecAccessRequest{ - ObjectMeta: metav1.ObjectMeta{ - Name: "testingRequest", - Namespace: "fake", - // Set the creation timestamp so that the verification works, the fake kubeclient doesn't do that. - CreationTimestamp: metav1.NewTime(time.Now()), - }, - Spec: v1alpha1.ExecAccessRequestSpec{ - TemplateName: "testingTemplate", - Duration: "30m", - }, - } - - // Create the template resource for real in the fake kubernetes client - err = fakeClient.Create(ctx, request) - Expect(err).To(Not(HaveOccurred())) - - // Create the Builder that we'll be testing - builder = &legacybuilder.BaseBuilder{ - Client: fakeClient, - Ctx: ctx, - APIReader: fakeClient, - Template: template, - Request: request, - } - - // Call the method.. it should succeed. - err = r.verifyDuration(builder) - - // VERIFY: The proper Error was returned - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(Equal("time: unknown unit \"hour\" in duration \"1hour\"")) - - // VERIFY: The ConditionRequestDurationsValid is False - Expect(meta.IsStatusConditionPresentAndEqual( - request.Status.Conditions, - v1alpha1.ConditionRequestDurationsValid.String(), - metav1.ConditionFalse)).To(BeTrue()) - - // VERIFY: The Condition was updated properly in the object even though an error was returned - cond := meta.FindStatusCondition( - request.Status.Conditions, - v1alpha1.ConditionRequestDurationsValid.String(), - ) - Expect( - cond.Message, - ).To(Equal("Template Error, spec.defaultDuration error: time: unknown unit \"hour\" in duration \"1hour\"")) - }, - ) - - It( - "Should set condition if the referenced template spec.accessConfig.maxDuration is invalid", - func() { - // Get the template, and update its defaultDuration to something invalid - err := fakeClient.Get(ctx, types.NamespacedName{ - Name: template.Name, - Namespace: template.Namespace, - }, template) - Expect(err).To(Not(HaveOccurred())) - template.Spec.AccessConfig.MaxDuration = "1hour" - err = fakeClient.Update(ctx, template) - Expect(err).To(Not(HaveOccurred())) - - // Create the ExecAccessTemplate object that points to the valid Deployment - request = &v1alpha1.ExecAccessRequest{ - ObjectMeta: metav1.ObjectMeta{ - Name: "testingRequest", - Namespace: "fake", - // Set the creation timestamp so that the verification works, the fake kubeclient doesn't do that. - CreationTimestamp: metav1.NewTime(time.Now()), - }, - Spec: v1alpha1.ExecAccessRequestSpec{ - TemplateName: "testingTemplate", - Duration: "30m", - }, - } - - // Create the template resource for real in the fake kubernetes client - err = fakeClient.Create(ctx, request) - Expect(err).To(Not(HaveOccurred())) - - // Create the Builder that we'll be testing - builder = &legacybuilder.BaseBuilder{ - Client: fakeClient, - Ctx: ctx, - APIReader: fakeClient, - Template: template, - Request: request, - } - - // Call the method.. it should succeed. - err = r.verifyDuration(builder) - - // VERIFY: The proper Error was returned - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(Equal("time: unknown unit \"hour\" in duration \"1hour\"")) - - // VERIFY: The ConditionRequestDurationsValid is False - Expect(meta.IsStatusConditionPresentAndEqual( - request.Status.Conditions, - v1alpha1.ConditionRequestDurationsValid.String(), - metav1.ConditionFalse)).To(BeTrue()) - - // VERIFY: The Condition was updated properly in the object even though an error was returned - cond := meta.FindStatusCondition( - request.Status.Conditions, - v1alpha1.ConditionRequestDurationsValid.String(), - ) - Expect( - cond.Message, - ).To(Equal("Template Error, spec.maxDuration error: time: unknown unit \"hour\" in duration \"1hour\"")) - }, - ) - - It("Should set access expired if uptime > duration", func() { - // Create the ExecAccessTemplate object that points to the valid Deployment - request = &v1alpha1.ExecAccessRequest{ - ObjectMeta: metav1.ObjectMeta{ - Name: "testingRequest", - Namespace: "fake", - // Set the creation timestamp so that the verification works, the fake kubeclient doesn't do that. - CreationTimestamp: metav1.NewTime(time.Now().Add(time.Minute * -5)), - }, - Spec: v1alpha1.ExecAccessRequestSpec{ - TemplateName: "testingTemplate", - Duration: "1m", - }, - } - - // Create the template resource for real in the fake kubernetes client - err := fakeClient.Create(ctx, request) - Expect(err).To(Not(HaveOccurred())) - - // Create the Builder that we'll be testing - builder = &legacybuilder.BaseBuilder{ - Client: fakeClient, - Ctx: ctx, - APIReader: fakeClient, - Template: template, - Request: request, - } - - // Call the method.. it should succeed. - err = r.verifyDuration(builder) - Expect(err).To(Not(HaveOccurred())) - - // VERIFY: The ConditionRequestDurationsValid is True - Expect(meta.IsStatusConditionPresentAndEqual( - request.Status.Conditions, - v1alpha1.ConditionRequestDurationsValid.String(), - metav1.ConditionTrue)).To(BeTrue()) - cond := meta.FindStatusCondition( - request.Status.Conditions, - v1alpha1.ConditionRequestDurationsValid.String(), - ) - Expect(cond.Message).To(Equal("Access requested custom duration (1m0s)")) - - // VERIFY: The conditionAccessStillValid is True - cond = meta.FindStatusCondition( - request.Status.Conditions, - v1alpha1.ConditionAccessStillValid.String(), - ) - Expect(cond.Message).To(Equal("Access expired")) - }) - }) -}) diff --git a/internal/controllers/internal/status/update_status.go b/internal/controllers/internal/status/update_status.go index 1cd4538..3621ac3 100644 --- a/internal/controllers/internal/status/update_status.go +++ b/internal/controllers/internal/status/update_status.go @@ -3,11 +3,10 @@ package status import ( "context" - "sigs.k8s.io/controller-runtime/pkg/log" + logf "sigs.k8s.io/controller-runtime/pkg/log" api "github.com/diranged/oz/internal/api/v1alpha1" "github.com/diranged/oz/internal/controllers/internal/utils" - "github.com/diranged/oz/internal/legacybuilder" ) // UpdateStatus pushes the client.Object.Status field into Kubernetes if it has been updated, and @@ -17,24 +16,19 @@ import ( // This wrapper makes it much easier to update the Status field of an object iteratively throughout // a reconciliation loop. func UpdateStatus(ctx context.Context, rec hasStatusReconciler, res api.ICoreResource) error { - logger := log.FromContext(ctx) + log := logf.FromContext(ctx) // Update the status, handle failure. - logger.V(2). - Info("Pre Obj Json", "resourceVersion", res.GetResourceVersion(), "json", legacybuilder.ObjectToJSON(res)) if err := rec.Status().Update(ctx, res); err != nil { - logger.Error(err, "Failed to update status") + log.Error(err, "Failed to update status") return err } // Re-fetch the object when we're done to make sure we are working with the latest version if _, err := utils.Refetch(ctx, rec.GetAPIReader(), res); err != nil { - logger.Error(err, "Failed to refetch object") + log.Error(err, "Failed to refetch object") return err } - logger.V(2). - Info("Post Obj Json", "resourceVersion", res.GetResourceVersion(), "json", legacybuilder.ObjectToJSON(res)) - return nil } diff --git a/internal/controllers/pod_access_request_controller.go b/internal/controllers/pod_access_request_controller.go deleted file mode 100644 index 476c6b2..0000000 --- a/internal/controllers/pod_access_request_controller.go +++ /dev/null @@ -1,201 +0,0 @@ -/* -Copyright 2022 Matt Wise. - -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 controllers contains all of the operator runtime reconciliation logic. -package controllers - -import ( - "context" - "fmt" - "time" - - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/log" - - "github.com/diranged/oz/internal/api/v1alpha1" - "github.com/diranged/oz/internal/controllers/internal/status" - "github.com/diranged/oz/internal/controllers/internal/utils" - "github.com/diranged/oz/internal/legacybuilder" -) - -// PodAccessRequestReconciler reconciles a AccessRequest object -type PodAccessRequestReconciler struct { - // Pass in the common functions from our BaseController - BaseRequestReconciler -} - -//+kubebuilder:rbac:groups=crds.wizardofoz.co,resources=podaccessrequests,verbs=get;list;watch;create;update;patch;delete -//+kubebuilder:rbac:groups=crds.wizardofoz.co,resources=podaccessrequests/status,verbs=get;update;patch -//+kubebuilder:rbac:groups=crds.wizardofoz.co,resources=podaccessrequests/finalizers,verbs=update - -// https://kubernetes.io/docs/concepts/security/rbac-good-practices/#escalate-verb -// -// We leverage the escalate verb here because we don't specifically want or need the Oz controller -// pods to have Exec/Debug privileges on pods, but we want them to be able to grant those privileges -// to users. -// -//+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=get;list;watch;create;update;patch;delete;bind;escalate -//+kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=get;list;watch;create;update;patch;delete -//+kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;create;update;patch;delete - -// 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 AccessRequest 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.13.0/pkg/reconcile -func (r *PodAccessRequestReconciler) Reconcile( - ctx context.Context, - req ctrl.Request, -) (ctrl.Result, error) { - logger := log.FromContext(ctx).WithName("PodAccessRequestReconciler") - logger.Info("Starting reconcile loop") - - // SETUP - r.SetReconciliationInterval() - - // First make sure we use the ApiReader (non-cached) client to go and figure out if the resource exists or not. If - // it doesn't come back, we exit out beacuse it is likely the object has been deleted and we no longer need to - // worry about it. - // - // TODO: Validate IsReady(). - logger.Info("Verifying PodAccessRequest exists") - resource, err := v1alpha1.GetPodAccessRequest(ctx, r.Client, req.Name, req.Namespace) - if err != nil { - logger.Info(fmt.Sprintf("Failed to find PodAccessRequest %s, perhaps deleted.", req.Name)) - return ctrl.Result{}, nil - } - - // VERIFICATION: Make sure the Target TemplateName field points to a valid Template - tmpl, err := r.getTargetTemplate(ctx, resource) - if err != nil { - return ctrl.Result{}, err - } - - // OWNER UPDATE: Update the OwnerRef to the TargetTemplate. - // - // Ensure that if the TargetTemplate is ever deleted, that all of the AccessRequests are - // also deleted, which will cascade down and delete any roles/bindings/etc. - // - // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/owners-dependents/ - // - // TODO: BUGFIX< THIS IS NOT PUSHING THE UPDATE TO K8S - if err := ctrl.SetControllerReference(tmpl, resource, r.Scheme); err != nil { - return ctrl.Result{}, err - } - - // Create an AccessBuilder resource for this particular template, which we'll use to then verify the resource. - builder := &legacybuilder.PodAccessBuilder{ - BaseBuilder: legacybuilder.BaseBuilder{ - Client: r.Client, - Ctx: ctx, - APIReader: r.APIReader, - Request: resource, - Template: tmpl, - }, - Request: resource, - Template: tmpl, - } - - // VERIFICATION: Verifies the requested duration - err = r.verifyDuration(builder) - if err != nil { - return ctrl.Result{}, err - } - - // VERIFICATION: Handle whether or not the access is expired at this point! If so, delete it. - if expired, err := r.isAccessExpired(builder); err != nil { - return ctrl.Result{}, err - } else if expired { - return ctrl.Result{}, nil - } - - // VERIFICATION: Make sure all of the access resources are built properly. On any failure, - // set up a 30 second delay before the next reconciliation attempt. - err = r.verifyAccessResourcesBuilt(builder) - if err != nil { - return ctrl.Result{}, err - } - - // VERIFICATION: Make sure the access resources (pod) are actually up and ready - err = r.verifyAccessResourcesReady(builder) - if err != nil { - // SPECIAL TREATMENT: An error here means, requeue this and run it - // again in few seconds while we wait for the Pod to come up. It does - // not necessarily mean a real failure happened. - // - // The requeue logic of the requeueAfter setting only takes effect if - // err==nil: - // - // https://github.com/kubernetes-sigs/controller-runtime/blob/053233652960f536727e1980eb0ad8ceb9bef096/pkg/internal/controller/controller.go#L214-L235 - // - // TODO: Check the error type. If it's a non-failure, then be more - // intelligent - return ctrl.Result{ - RequeueAfter: time.Duration(PodWaitReconciliationInterval) * time.Second, - }, nil - } - - // FINAL: Set Status.Ready state - err = status.SetReadyStatus(ctx, r, resource) - if err != nil { - return ctrl.Result{}, err - } - - // Exit Reconciliation Loop - logger.Info("Ending reconcile loop") - - // Finally, requeue to re-reconcile again in the future - return ctrl.Result{ - RequeueAfter: time.Duration(r.ReconcililationInterval * int(time.Minute)), - }, nil -} - -// getTargetTemplate is used to both verify that the desired Spec.TemplateName field actually exists in the cluster, -// and to return that populated object back to the reconciler loop. The ConditionTargetTemplateExists condition is -// updated with the status. -// -// Returns: -// - Pointer to the v1alpha1.ExecAccessTemplate (or nil) -// - An "error" only if the UpdateCondition function fails -func (r *PodAccessRequestReconciler) getTargetTemplate( - ctx context.Context, - req *v1alpha1.PodAccessRequest, -) (*v1alpha1.PodAccessTemplate, error) { - logger := r.getLogger(ctx) - logger.Info( - fmt.Sprintf("Verifying that Target Template %s still exists...", req.Spec.TemplateName), - ) - - var tmpl *v1alpha1.PodAccessTemplate - var err error - if tmpl, err = v1alpha1.GetPodAccessTemplate(ctx, r.Client, req.Spec.TemplateName, req.Namespace); err != nil { - // On failure: Update the condition, and return. - return nil, status.SetTargetTemplateNotExists(ctx, r, req, err) - } - return tmpl, status.SetTargetTemplateExists(ctx, r, req) -} - -// SetupWithManager sets up the controller with the Manager. -func (r *PodAccessRequestReconciler) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - For(&v1alpha1.PodAccessRequest{}). - WithEventFilter(utils.IgnoreStatusUpdatesAndDeletion()). - Complete(r) -} diff --git a/internal/controllers/pod_access_request_controller_test.go b/internal/controllers/pod_access_request_controller_test.go deleted file mode 100644 index 83c3532..0000000 --- a/internal/controllers/pod_access_request_controller_test.go +++ /dev/null @@ -1,248 +0,0 @@ -package controllers - -import ( - "context" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/reconcile" - - api "github.com/diranged/oz/internal/api/v1alpha1" - "github.com/diranged/oz/internal/testing/utils" -) - -var _ = Describe("PodAccessRequestController", Ordered, func() { - Context("Controller Test", func() { - const TestName = "podaccesstemplatecontroller" - - var ( - namespace *corev1.Namespace - deployment *appsv1.Deployment - ctx = context.Background() - request *api.PodAccessRequest - template *api.PodAccessTemplate - ) - - // NOTE: We use a real k8sClient for these tests beacuse we need to - // verify things like UID generation happening in the backend, as well - // as generation spec updates. - BeforeAll(func() { - By("Creating the Namespace to perform the tests") - namespace = &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: utils.RandomString(8), - }, - } - err := k8sClient.Create(ctx, namespace) - Expect(err).To(Not(HaveOccurred())) - }) - - // Before each test case, we create a new Deployment, PodAccessRequest and PodAccessTemplate. - BeforeEach(func() { - // Create a fake deployment target - deployment = &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-dep", - Namespace: namespace.GetName(), - }, - Spec: appsv1.DeploymentSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "testLabel": "testValue", - }, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Annotations: map[string]string{ - api.DefaultContainerAnnotationKey: "contb", - "Foo": "bar", - }, - Labels: map[string]string{ - "testLabel": "testValue", - }, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "conta", - Image: "nginx:latest", - }, - { - Name: "contb", - Image: "nginx:latest", - }, - }, - }, - }, - }, - } - err := k8sClient.Create(ctx, deployment) - Expect(err).To(Not(HaveOccurred())) - - // Create a default PodAccessTemplate. We'll mutate it for specific tests. - template = &api.PodAccessTemplate{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-template", - Namespace: deployment.Namespace, - }, - Spec: api.PodAccessTemplateSpec{ - AccessConfig: api.AccessConfig{ - AllowedGroups: []string{"testGroupA"}, - DefaultDuration: "1h", - MaxDuration: "2h", - }, - ControllerTargetRef: &api.CrossVersionObjectReference{ - APIVersion: "apps/v1", - Kind: "Deployment", - Name: deployment.Name, - }, - ControllerTargetMutationConfig: &api.PodTemplateSpecMutationConfig{}, - }, - } - err = k8sClient.Create(ctx, template) - Expect(err).To(Not(HaveOccurred())) - - // Create a simple PodAccessRequest resource to test the template with - request = &api.PodAccessRequest{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-req", - Namespace: template.Namespace, - }, - Spec: api.PodAccessRequestSpec{ - TemplateName: template.Name, - Duration: "5m", - }, - } - err = k8sClient.Create(ctx, request) - Expect(err).To(Not(HaveOccurred())) - }) - - // After each test, we wipe out the Deployment, Template and Request - AfterEach(func() { - err := k8sClient.Delete(ctx, deployment) - Expect(err).To(Not(HaveOccurred())) - err = k8sClient.Delete(ctx, request) - Expect(err).To(Not(HaveOccurred())) - err = k8sClient.Delete(ctx, template) - Expect(err).To(Not(HaveOccurred())) - }) - - // One conceptual test here... our goal is primarily to test - // reconciliation, not the deep internals about the Builders and the - // particular settings that they are applying. - It("Should successfully reconcile a simple Deployment and Template", func() { - // Verify that reconciliation of the PodAccessTemplate succeeds on - // the first attempt, without any failures reported. - By("Reconciling the custom PodAccessTemplate first") - tmplReconciler := &PodAccessTemplateReconciler{ - BaseTemplateReconciler: BaseTemplateReconciler{ - BaseReconciler: BaseReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), - APIReader: k8sClient, - }, - }, - } - _, err := tmplReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: types.NamespacedName{ - Name: TestName, - Namespace: namespace.Name, - }, - }) - Expect(err).To(Not(HaveOccurred())) - - // Verify that the PodAccessRequest reconciliation actually does - // NOT pass the first time. It should get about half way through, - // and report an error (and requeue) while waiting for the desired - // Pod to come up. - By("Reconciling the custom PodAccessRequest first, which should error out") - reqReconciler := &PodAccessRequestReconciler{ - BaseRequestReconciler: BaseRequestReconciler{ - BaseReconciler: BaseReconciler{ - Client: k8sClient, - Scheme: k8sClient.Scheme(), - APIReader: k8sClient, - }, - }, - } - _, err = reqReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: types.NamespacedName{ - Name: request.Name, - Namespace: request.Namespace, - }, - }) - Expect(err).To(Not(HaveOccurred())) - - // Verify that the Request is still in not-ready state, even though - // the reconcile didn't error out. - Expect(request.Status.IsReady()).To(BeFalse()) - - // Refetch our Request object... reconiliation has mutated its - // .Status fields. - By("Refetching our Request...") - err = k8sClient.Get(ctx, types.NamespacedName{ - Name: request.Name, - Namespace: request.Namespace, - }, request) - Expect(err).To(Not(HaveOccurred())) - - // Verify that the request access message was set - By("Verifying the Status.AccessMessage is set, but ready state is false") - Expect( - request.Status.AccessMessage, - ).To(MatchRegexp("kubectl exec -ti -n .* .* -- /bin/sh")) - Expect(request.Status.IsReady()).To(BeFalse()) - - // Patch the pod status state to "Running" so we can simulate that - // the pod is actually up now. - By("Patching the Pod State...") - pod := &corev1.Pod{} - err = k8sClient.Get(ctx, types.NamespacedName{ - Name: request.Status.PodName, - Namespace: request.Namespace, - }, pod) - Expect(err).To(Not(HaveOccurred())) - - // Update it - pod.Status.Phase = corev1.PodRunning - - // Update the status, handle failure. - err = k8sClient.Status().Update(ctx, pod) - Expect(err).To(Not(HaveOccurred())) - - // Refetch it - err = k8sClient.Get(ctx, types.NamespacedName{ - Name: pod.Name, - Namespace: pod.Namespace, - }, pod) - Expect(pod.Status.Phase).To(Equal(corev1.PodRunning)) - Expect(err).To(Not(HaveOccurred())) - - // Now we verify that on the second reconciliation there are no - // errors and we get all the way through the process. - By("Reconciling again, expecting success") - _, err = reqReconciler.Reconcile(ctx, reconcile.Request{ - NamespacedName: types.NamespacedName{ - Name: request.Name, - Namespace: request.Namespace, - }, - }) - Expect(err).To(Not(HaveOccurred())) - - // Lastly, make sure we updated the request status with - // Status.Ready=True. - By("Verifying that the PodAccessRequest IS ready") - err = k8sClient.Get(ctx, types.NamespacedName{ - Name: request.Name, - Namespace: request.Namespace, - }, request) - Expect(err).To(Not(HaveOccurred())) - Expect(request.Status.IsReady()).To(BeTrue()) - }) - }) -}) diff --git a/internal/controllers/pod_access_template_controller.go b/internal/controllers/pod_access_template_controller.go index b73564c..d62eb49 100644 --- a/internal/controllers/pod_access_template_controller.go +++ b/internal/controllers/pod_access_template_controller.go @@ -71,12 +71,10 @@ func (r *PodAccessTemplateReconciler) Reconcile( } // Create an AccessBuilder resource for this particular template, which we'll use to then verify the resource. - builder := &legacybuilder.PodAccessBuilder{ - BaseBuilder: legacybuilder.BaseBuilder{ - Client: r.Client, - Ctx: ctx, - Template: resource, - }, + builder := &legacybuilder.BaseBuilder{ + Client: r.Client, + Ctx: ctx, + Template: resource, } // VERIFICATION: Make sure that the TargetRef is valid and points to an active controller diff --git a/internal/controllers/requestcontroller/setup_with_manager.go b/internal/controllers/requestcontroller/setup_with_manager.go index ee53f7d..ec1a675 100644 --- a/internal/controllers/requestcontroller/setup_with_manager.go +++ b/internal/controllers/requestcontroller/setup_with_manager.go @@ -1,39 +1,12 @@ package requestcontroller import ( - "context" - - "github.com/diranged/oz/internal/api/v1alpha1" "github.com/diranged/oz/internal/controllers/internal/utils" - corev1 "k8s.io/api/core/v1" - v1 "k8s.io/api/core/v1" ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" ) // SetupWithManager sets up the controller with the Manager. func (r *RequestReconciler) SetupWithManager(mgr ctrl.Manager) error { - // Provide a searchable index in the cached kubernetes client for "metadata.name" - the pod name. - if err := mgr.GetFieldIndexer().IndexField(context.Background(), &corev1.Pod{}, v1alpha1.FieldSelectorMetadataName, func(rawObj client.Object) []string { - // grab the job object, extract the name... - pod := rawObj.(*v1.Pod) - name := pod.GetName() - return []string{name} - }); err != nil { - return err - } - - // Provide a searchable index in the cached kubernetes client for "status.phase", allowing us to - // search for Running Pods. - if err := mgr.GetFieldIndexer().IndexField(context.Background(), &corev1.Pod{}, v1alpha1.FieldSelectorStatusPhase, func(rawObj client.Object) []string { - // grab the job object, extract the phase... - pod := rawObj.(*v1.Pod) - phase := string(pod.Status.Phase) - return []string{phase} - }); err != nil { - return err - } - return ctrl.NewControllerManagedBy(mgr). For(r.RequestType). WithEventFilter(utils.IgnoreStatusUpdatesAndDeletion()). diff --git a/internal/controllers/requestcontroller/verify_access_resources.go b/internal/controllers/requestcontroller/verify_access_resources.go index ea70d06..a24fd8a 100644 --- a/internal/controllers/requestcontroller/verify_access_resources.go +++ b/internal/controllers/requestcontroller/verify_access_resources.go @@ -43,12 +43,15 @@ func (r *RequestReconciler) verifyAccessResources( // returning an error which will fail the reconciliation. _ = status.SetAccessResourcesNotReady(rctx.Context, r, rctx.obj, err) return true, result, err + } else if !areReady { interval := r.getVerifyResourcesRequeueInterval() + // NOTE: Blindly ignoring the error return here because we are already // returning an error which will fail the reconciliation. _ = status.SetAccessResourcesNotReady(rctx.Context, r, rctx.obj, fmt.Errorf("Resources not yet available... will check in %s", interval)) + return true, ctrl.Result{RequeueAfter: interval}, nil } diff --git a/internal/legacybuilder/base_builder.go b/internal/legacybuilder/base_builder.go index dc099bb..21b3a12 100644 --- a/internal/legacybuilder/base_builder.go +++ b/internal/legacybuilder/base_builder.go @@ -4,25 +4,14 @@ package legacybuilder import ( "context" - "encoding/json" - "errors" - "fmt" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - rbacv1 "k8s.io/api/rbac/v1" - 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" - ctrlutil "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "sigs.k8s.io/controller-runtime/pkg/log" api "github.com/diranged/oz/internal/api/v1alpha1" ) -const shortUIDLength = 8 - // BaseBuilder provides a starting point struct with a set of common methods. These methods are used // by template specific builders to reduce the amount of code we re-write. type BaseBuilder struct { @@ -113,262 +102,3 @@ func (b *BaseBuilder) GetTargetRefResource() (client.Object, error) { }, obj) return obj, err } - -func (b *BaseBuilder) getPodTemplateFromController() (corev1.PodTemplateSpec, error) { - // https://sdk.operatorframework.io/docs/building-operators/golang/references/logging/ - logger := log.FromContext(b.Ctx) - - targetController, err := b.GetTargetRefResource() - if err != nil { - return corev1.PodTemplateSpec{}, err - } - - // TODO: Figure out a more generic way to do this that doesn't involve a bunch of checks like this - switch kind := targetController.GetObjectKind().GroupVersionKind().Kind; kind { - case "Deployment": - controller, err := b.getDeployment(targetController) - if err != nil { - logger.Error(err, "Failed to find target Deployment") - return corev1.PodTemplateSpec{}, err - } - return *controller.Spec.Template.DeepCopy(), nil - - case "DaemonSet": - controller, err := b.getDaemonSet(targetController) - if err != nil { - logger.Error(err, "Failed to find target DaemonSet") - return corev1.PodTemplateSpec{}, err - } - return *controller.Spec.Template.DeepCopy(), nil - - case "StatefulSet": - controller, err := b.getStatefulSet(targetController) - if err != nil { - logger.Error(err, "Failed to find target StatefulSet") - return corev1.PodTemplateSpec{}, err - } - return *controller.Spec.Template.DeepCopy(), nil - - default: - return corev1.PodTemplateSpec{}, errors.New("invalid input") - } -} - -// getDeployment returns a Deployment given the supplied generic client.Object resource -// -// Returns: -// -// appsv1.Deployment: A populated deployment object -// error: Any error that may have occurred -func (b *BaseBuilder) getDeployment(obj client.Object) (*appsv1.Deployment, error) { - found := &appsv1.Deployment{} - err := b.Client.Get(b.Ctx, types.NamespacedName{ - Name: obj.GetName(), - Namespace: obj.GetNamespace(), - }, found) - return found, err -} - -// getDaemonSet returns a DaemonSet given the supplied generic client.Object resource -// -// Returns: -// -// appsv1.DaemonSet: A populated deployment object -// error: Any error that may have occurred -func (b *BaseBuilder) getDaemonSet(obj client.Object) (*appsv1.DaemonSet, error) { - found := &appsv1.DaemonSet{} - err := b.Client.Get(b.Ctx, types.NamespacedName{ - Name: obj.GetName(), - Namespace: obj.GetNamespace(), - }, found) - return found, err -} - -// getStatefulSet returns a StatefulSet given the supplied generic client.Object resource -// -// Returns: -// -// appsv1.StatefulSet: A populated deployment object -// error: Any error that may have occurred -func (b *BaseBuilder) getStatefulSet(obj client.Object) (*appsv1.StatefulSet, error) { - found := &appsv1.StatefulSet{} - err := b.Client.Get(b.Ctx, types.NamespacedName{ - Name: obj.GetName(), - Namespace: obj.GetNamespace(), - }, found) - return found, err -} - -func (b *BaseBuilder) createAccessRole(podName string) (*rbacv1.Role, error) { - role := &rbacv1.Role{} - - role.Name = generateResourceName(b.Request) - role.Namespace = b.Template.GetNamespace() - role.Rules = []rbacv1.PolicyRule{ - { - APIGroups: []string{corev1.GroupName}, - Resources: []string{"pods"}, - ResourceNames: []string{podName}, - Verbs: []string{"get", "list", "watch"}, - }, - { - APIGroups: []string{corev1.GroupName}, - Resources: []string{"pods/exec"}, - ResourceNames: []string{podName}, - Verbs: []string{"create", "update", "delete", "get", "list"}, - }, - } - - // Set the ownerRef for the Deployment - // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/owners-dependents/ - if err := ctrlutil.SetControllerReference(b.Request, role, b.GetScheme()); err != nil { - return nil, err - } - - // Generate an empty role resource. This role resource will be filled-in by the CreateOrUpdate() call when - // it checks the Kubernetes API for the existing role. Our update function will then update the appropriate - // values from the desired role object above. - emptyRole := &rbacv1.Role{ - ObjectMeta: metav1.ObjectMeta{Name: role.Name, Namespace: role.Namespace}, - } - - // https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/controller/controllerutil#CreateOrUpdate - if _, err := ctrlutil.CreateOrUpdate(b.Ctx, b.Client, emptyRole, func() error { - emptyRole.ObjectMeta = role.ObjectMeta - emptyRole.Rules = role.Rules - emptyRole.OwnerReferences = role.OwnerReferences - return nil - }); err != nil { - return nil, err - } - - return role, nil -} - -func (b *BaseBuilder) createAccessRoleBinding() (*rbacv1.RoleBinding, error) { - rb := &rbacv1.RoleBinding{} - - rb.Name = generateResourceName(b.Request) - rb.Namespace = b.Template.GetNamespace() - rb.RoleRef = rbacv1.RoleRef{ - APIGroup: rbacv1.GroupName, - Kind: "Role", - Name: rb.Name, - } - rb.Subjects = []rbacv1.Subject{} - - for _, group := range b.Template.GetAccessConfig().GetAllowedGroups() { - rb.Subjects = append(rb.Subjects, rbacv1.Subject{ - APIGroup: rbacv1.SchemeGroupVersion.Group, - Kind: rbacv1.GroupKind, - Name: group, - }) - } - - // Set the ownerRef for the Deployment - // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/owners-dependents/ - if err := ctrlutil.SetControllerReference(b.Request, rb, b.GetScheme()); err != nil { - return nil, err - } - - // Generate an empty role resource. This role resource will be filled-in by the CreateOrUpdate() call when - // it checks the Kubernetes API for the existing role. Our update function will then update the appropriate - // values from the desired role object above. - emptyRb := &rbacv1.RoleBinding{ - ObjectMeta: metav1.ObjectMeta{Name: rb.Name, Namespace: rb.Namespace}, - } - - // https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/controller/controllerutil#CreateOrUpdate - if _, err := ctrlutil.CreateOrUpdate(b.Ctx, b.Client, emptyRb, func() error { - emptyRb.ObjectMeta = rb.ObjectMeta - emptyRb.RoleRef = rb.RoleRef - emptyRb.Subjects = rb.Subjects - emptyRb.OwnerReferences = rb.OwnerReferences - return nil - }); err != nil { - return nil, err - } - - return rb, nil -} - -func (b *BaseBuilder) createPod(podTemplateSpec corev1.PodTemplateSpec) (*corev1.Pod, error) { - logger := log.FromContext(b.Ctx) - - // We'll populate this pod object - pod := &corev1.Pod{} - pod.Name = generateResourceName(b.Request) - pod.Namespace = b.Template.GetNamespace() - - // Verify first whether or not a pod already exists with this name. If it - // does, we just return it back. The issue here is that updating a Pod is - // an unusual thing to do once it's alive, and can cause race condition - // issues if you do not do the updates properly. - // - // https://github.com/diranged/oz/issues/27 - err := b.Client.Get(b.Ctx, types.NamespacedName{ - Name: pod.Name, - Namespace: pod.Namespace, - }, pod) - - // If there was no error on this get, then the object already exists in K8S - // and we need to just return that. - if err == nil { - return pod, err - } - - // Finish filling out the desired PodSpec at this point. - pod.Spec = *podTemplateSpec.Spec.DeepCopy() - pod.ObjectMeta.Annotations = podTemplateSpec.ObjectMeta.Annotations - pod.ObjectMeta.Labels = podTemplateSpec.ObjectMeta.Labels - - // Set the ownerRef for the Deployment - // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/owners-dependents/ - if err := ctrlutil.SetControllerReference(b.Request, pod, b.GetScheme()); err != nil { - return nil, err - } - - // https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/controller/controllerutil#CreateOrUpdate - // - // In an update event, wec an only update the Annotations and the OwnerReference. Nothing else. - logger.V(1).Info(fmt.Sprintf("Creating or Updating Pod %s (ns: %s)", pod.Name, pod.Namespace)) - logger.V(1).Info("Pod Json", "json", ObjectToJSON(pod)) - if err := b.Client.Create(b.Ctx, pod); err != nil { - return nil, err - } - - return pod, nil -} - -// getShortUID returns back a shortened version of the UID that the Kubernetes cluster used to store -// the AccessRequest internally. This is used by the Builders to create unique names for the -// resources they manage (Roles, RoleBindings, etc). -// -// Returns: -// -// shortUID: A 10-digit long shortened UID -func getShortUID(obj client.Object) string { - // TODO: If the UID isn't there, we should generate something random OR throw an error. - return string(obj.GetUID())[0:shortUIDLength] -} - -// generateResourceName takes in an API.IRequestResource conforming object and returns a unique -// resource name string that can be used to safely create other resources (roles, bindings, etc). -// -// Returns: -// -// string: A resource name string -func generateResourceName(req api.IRequestResource) string { - return fmt.Sprintf("%s-%s", req.GetName(), getShortUID(req)) -} - -// ObjectToJSON is a quick helper function for pretty-printing an entire K8S object in JSON form. -// Used in certain debug log statements primarily. -func ObjectToJSON(obj client.Object) string { - jsonData, err := json.Marshal(obj) - if err != nil { - fmt.Printf("could not marshal json: %s\n", err) - return "" - } - return string(jsonData) -} diff --git a/internal/legacybuilder/base_builder_test.go b/internal/legacybuilder/base_builder_test.go index 8dc7484..798f122 100644 --- a/internal/legacybuilder/base_builder_test.go +++ b/internal/legacybuilder/base_builder_test.go @@ -9,7 +9,6 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" api "github.com/diranged/oz/internal/api/v1alpha1" "github.com/diranged/oz/internal/testing/utils" @@ -146,62 +145,10 @@ var _ = Describe("BaseBuilder", Ordered, func() { Expect(builder.GetRequest()).To(Equal(request)) }) - It("getShortUID should work", func() { - ret := getShortUID(request) - Expect(len(ret)).To(Equal(8)) - }) - - It("generateResourceName should work", func() { - ret := generateResourceName(request) - Expect(len(ret)).To(Equal(17)) - }) - It("GetTargetRefResource() should return a valid Client.Object", func() { ret, err := builder.GetTargetRefResource() Expect(err).To(Not(HaveOccurred())) Expect(ret.GetName()).To(Equal("test-dep")) }) - - It("createPod() should work sanely", func() { - // Get the PodTemplateSpec - pts, err := builder.getPodTemplateFromController() - Expect(err).To(Not(HaveOccurred())) - - // First, we should create the pod and return it. - pod, err := builder.createPod(pts) - Expect(err).To(Not(HaveOccurred())) - - // Store the original resourceVersino - origResourceVersion := pod.ResourceVersion - - // Mutate the pod ourslves. This simulates a third party resource, - // eg, "istio", mutating the pod. - pod.ObjectMeta.SetAnnotations(map[string]string{ - "MyAnnotation": "bar", - "MyOtherAnnotation": "baz", - }) - err = k8sClient.Update(ctx, pod) - Expect(err).To(Not(HaveOccurred())) - - // VERIFY: The resourceVersion should have changed - postAnnotationUpdateVersion := pod.ResourceVersion - Expect(origResourceVersion).To(Not(Equal(postAnnotationUpdateVersion))) - - // Next, re-run the createPod function. We want this function to - // never re-create the Pod object once it's been created, or update - // it. - _, err = builder.createPod(pts) - Expect(err).To(Not(HaveOccurred())) - - // Re-get the pod from the API - err = k8sClient.Get(ctx, types.NamespacedName{ - Name: pod.Name, - Namespace: pod.Namespace, - }, pod) - Expect(err).To(Not(HaveOccurred())) - - // VERIFY: The Pod resourceVersion has not changed - Expect(pod.ObjectMeta.ResourceVersion).To(Equal(postAnnotationUpdateVersion)) - }) }) }) diff --git a/internal/legacybuilder/pod_access_builder.go b/internal/legacybuilder/pod_access_builder.go deleted file mode 100644 index 0b9802f..0000000 --- a/internal/legacybuilder/pod_access_builder.go +++ /dev/null @@ -1,136 +0,0 @@ -package legacybuilder - -import ( - "errors" - "fmt" - - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/log" - - api "github.com/diranged/oz/internal/api/v1alpha1" -) - -// PodAccessBuilder implements the required resources for the api.AccessTemplate CRD. -// -// An "AccessRequest" is used to generate access that has been defined through an "AccessTemplate". -// -// An "AccessTemplate" defines a mode of access into a Pod by which a PodSpec is copied out of an -// existing Deployment (or StatefulSet, DaemonSet), mutated so that the Pod is not in the path of -// live traffic, and then Role and RoleBindings are created to grant the developer access into the -// Pod. -type PodAccessBuilder struct { - BaseBuilder - - Request *api.PodAccessRequest - Template *api.PodAccessTemplate -} - -// https://stackoverflow.com/questions/33089523/how-to-mark-golang-struct-as-implementing-interface -var ( - _ IBuilder = &PodAccessBuilder{} - _ IBuilder = (*PodAccessBuilder)(nil) -) - -// GenerateAccessResources is the primary function called by the reconciler to this Builder object. This function -// is responsible for building all of the temporary access resources, and returning back information about them -// to the user. Any error causes this function to stop and fail. -// -// Returns: -// -// statusString: A string representing the status of all of the resources created. This is applied to the -// conditions of the AccessRequest by the reconciler loop. -// -// accessString: A string representing how the end-user can use the resources. Eg: "kubectl exec ...". This -// string may go away. -// -// err: Any errors during the building and application of these resources. -func (b *PodAccessBuilder) GenerateAccessResources() (statusString string, err error) { - logger := log.FromContext(b.Ctx) - var accessString string - - // First, get the desired PodSpec. If there's a failure at this point, return it. - podTemplateSpec, err := b.generatePodTemplateSpec() - if err != nil { - logger.Error(err, "Failed to generate PodSpec for PodAccessRequest") - return statusString, err - } - - // Run the PodSpec through the optional mutation config - mutator := b.Template.Spec.ControllerTargetMutationConfig - podTemplateSpec, err = mutator.PatchPodTemplateSpec(b.Ctx, podTemplateSpec) - if err != nil { - logger.Error(err, "Failed to mutate PodSpec for PodAccessRequest") - return statusString, err - } - - // Generate a Pod for the user to access - pod, err := b.createPod(podTemplateSpec) - if err != nil { - logger.Error(err, "Failed to create Pod for AccessRequest") - return statusString, err - } - - // Get the Role, or error out - role, err := b.createAccessRole(pod.GetName()) - if err != nil { - return statusString, err - } - - // Get the Binding, or error out - rb, err := b.createAccessRoleBinding() - if err != nil { - return statusString, err - } - - statusString = fmt.Sprintf( - "Success. Pod %s, Role %s, RoleBinding %s created", - pod.Name, - role.Name, - rb.Name, - ) - accessString = fmt.Sprintf( - "kubectl exec -ti -n %s %s -- /bin/sh", - pod.GetNamespace(), - pod.GetName(), - ) - - // TODO: check err return from SetPodName - _ = b.Request.SetPodName(pod.GetName()) - b.Request.Status.SetAccessMessage(accessString) - - return statusString, err -} - -// VerifyAccessResources verifies that the Pod created in the -// GenerateAccessResources() function is up and in the "Running" phase. -func (b *PodAccessBuilder) VerifyAccessResources() (statusString string, err error) { - // First, verify whether or not the PodName field has been set. If not, - // then some part of the reconciliation has previously failed. - if b.Request.GetPodName() == "" { - return "No Pod Assigned Yet", errors.New("status.podName not yet set") - } - - // Next, get the Pod. If the pod-get fails, then we need to return that failure. - pod := &corev1.Pod{} - err = b.APIReader.Get(b.Ctx, types.NamespacedName{ - Name: b.Request.GetPodName(), - Namespace: b.Request.Namespace, - }, pod) - if err != nil { - return "Error Fetching Pod", err - } - - // Now, check the Pod ready status - if pod.Status.Phase != corev1.PodRunning { - statusMsg := fmt.Sprintf("Pod in %s Phase", pod.Status.Phase) - return statusMsg, errors.New(statusMsg) - } - - // Finally, return the pod phase - return fmt.Sprintf("Pod is %s", pod.Status.Phase), nil -} - -func (b *PodAccessBuilder) generatePodTemplateSpec() (corev1.PodTemplateSpec, error) { - return b.getPodTemplateFromController() -}