diff --git a/pkg/utils/kube/resource_ops.go b/pkg/utils/kube/resource_ops.go index 47d144bc4..f08a14141 100644 --- a/pkg/utils/kube/resource_ops.go +++ b/pkg/utils/kube/resource_ops.go @@ -238,10 +238,6 @@ func (k *kubectlResourceOperations) ReplaceResource(ctx context.Context, obj *un return err } - if err := replaceOptions.Validate(); err != nil { - return fmt.Errorf("error validating replace options: %w", err) - } - return replaceOptions.Run(k.fact) }) } @@ -433,6 +429,10 @@ func (k *kubectlServerSideDiffDryRunApplier) newApplyOptions(ioStreams genericcl } o.ForceConflicts = true + + if err := o.Validate(); err != nil { + return nil, fmt.Errorf("error validating options: %w", err) + } return o, nil } @@ -462,6 +462,10 @@ func (k *kubectlResourceOperations) newApplyOptions(ioStreams genericclioptions. if serverSideApply { o.ForceConflicts = true } + + if err := o.Validate(); err != nil { + return nil, fmt.Errorf("error validating options: %w", err) + } return o, nil } @@ -496,6 +500,10 @@ func (k *kubectlResourceOperations) newCreateOptions(ioStreams genericclioptions return printer.PrintObj(obj, o.Out) } o.FilenameOptions.Filenames = []string{fileName} + + if err := o.Validate(); err != nil { + return nil, fmt.Errorf("error validating options: %w", err) + } return o, nil } @@ -551,6 +559,9 @@ func (k *kubectlResourceOperations) newReplaceOptions(config *rest.Config, f cmd o.DeleteOptions.ForceDeletion = force } + if err := o.Validate(); err != nil { + return nil, fmt.Errorf("error validating options: %w", err) + } return o, nil } @@ -580,6 +591,10 @@ func newReconcileOptions(f cmdutil.Factory, kubeClient *kubernetes.Clientset, fi return nil, fmt.Errorf("error configuring printer: %w", err) } o.PrintObject = printer.PrintObj + + if err := o.Validate(); err != nil { + return nil, fmt.Errorf("error validating options: %w", err) + } return o, nil } diff --git a/pkg/utils/kube/resource_ops_test.go b/pkg/utils/kube/resource_ops_test.go new file mode 100644 index 000000000..03f6b55d3 --- /dev/null +++ b/pkg/utils/kube/resource_ops_test.go @@ -0,0 +1,167 @@ +package kube + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/rest" + k8stesting "k8s.io/client-go/testing" + "k8s.io/klog/v2/textlogger" + cmdtesting "k8s.io/kubectl/pkg/cmd/testing" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/scheme" + + testingutils "github.com/argoproj/gitops-engine/pkg/utils/testing" + "github.com/argoproj/gitops-engine/pkg/utils/tracing" +) + +func newTestResourceOperations(client *fake.FakeDynamicClient) (*kubectlResourceOperations, func()) { + tf := cmdtesting.NewTestFactory() + tf.FakeDynamicClient = client + tf.UnstructuredClientForMappingFunc = func(version schema.GroupVersion) (resource.RESTClient, error) { + return testingutils.NewFakeRESTClientBackedByDynamic(version, client), nil + } + + ops := &kubectlResourceOperations{ + config: &rest.Config{}, + log: textlogger.NewLogger(textlogger.NewConfig()).WithValues("application", "fake-app"), + tracer: tracing.NopTracer{}, + fact: tf, + } + return ops, tf.Cleanup +} + +func TestApplyResource_Success(t *testing.T) { + obj := testingutils.NewService() + obj.SetNamespace("test") + + client := fake.NewSimpleDynamicClient(scheme.Scheme) + ops, cleanup := newTestResourceOperations(client) + defer cleanup() + + called := false + client.PrependReactor("create", "*", func(_ k8stesting.Action) (_ bool, _ runtime.Object, _ error) { + called = true + return false, nil, nil + }) + + out, err := ops.ApplyResource(context.Background(), obj, cmdutil.DryRunNone, false, false, false, "test-manager") + require.NoError(t, err) + assert.True(t, called) + assert.Equal(t, "service/my-service created", out) +} + +func Test_kubectlResourceOperations_ApplyResource(t *testing.T) { + newService := func() *unstructured.Unstructured { + obj := testingutils.NewService() + obj.SetNamespace("test") + return obj + } + + type args struct { + obj *unstructured.Unstructured + dryRunStrategy cmdutil.DryRunStrategy + force bool + validate bool + serverSideApply bool + } + tests := []struct { + name string + args args + client *fake.FakeDynamicClient + want string + wantErr bool + }{ + { + name: "success", + args: args{ + obj: newService(), + dryRunStrategy: cmdutil.DryRunNone, + force: false, + validate: false, + serverSideApply: false, + }, + client: fake.NewSimpleDynamicClient(scheme.Scheme), + want: "service/my-service created", + }, + { + name: "success existing", + args: args{ + obj: newService(), + dryRunStrategy: cmdutil.DryRunNone, + force: false, + validate: false, + serverSideApply: false, + }, + client: fake.NewSimpleDynamicClient(scheme.Scheme, newService()), + want: "service/my-service created", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ops, cleanup := newTestResourceOperations(tt.client) + defer cleanup() + + got, err := ops.ApplyResource(context.Background(), tt.args.obj, tt.args.dryRunStrategy, tt.args.force, tt.args.validate, tt.args.serverSideApply, "test-manager") + if tt.wantErr { + require.Error(t, err) + assert.ErrorContains(t, err, tt.want) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +// func TestApplyResource_RunResourceCommandError(t *testing.T) { +// obj := &unstructured.Unstructured{} +// obj.SetKind("ConfigMap") +// obj.SetName("fail-cm") +// obj.SetNamespace("default") + +// mockOps := &mockKubectlResourceOperations{ +// kubectlResourceOperations: kubectlResourceOperations{ +// config: &fakeRestConfig{Host: "https://k8s.example.com"}, +// log: testr.New(t), +// tracer: &mockTracer{}, +// }, +// runResourceCommandFunc: func(ctx context.Context, o *unstructured.Unstructured, dryRunStrategy cmdutil.DryRunStrategy, executor commandExecutor) (string, error) { +// return "", errors.New("runResourceCommand failed") +// }, +// } + +// out, err := mockOps.ApplyResource(context.Background(), obj, cmdutil.DryRunNone, false, false, false, "test-manager") +// assert.Error(t, err) +// assert.Contains(t, err.Error(), "runResourceCommand failed") +// assert.Empty(t, out) +// } + +// func TestApplyResource_LogsWithDryRun(t *testing.T) { +// obj := &unstructured.Unstructured{} +// obj.SetKind("ConfigMap") +// obj.SetName("dryrun-cm") +// obj.SetNamespace("default") + +// mockOps := &mockKubectlResourceOperations{ +// kubectlResourceOperations: kubectlResourceOperations{ +// config: &fakeRestConfig{Host: "https://k8s.example.com"}, +// log: textlogger.NewLogger(textlogger.NewConfig()).WithValues("application", "fake-app"), +// tracer: &mockTracer{}, +// }, +// runResourceCommandFunc: func(ctx context.Context, o *unstructured.Unstructured, dryRunStrategy cmdutil.DryRunStrategy, executor commandExecutor) (string, error) { +// return "dryrun applied", nil +// }, +// } + +// out, err := mockOps.ApplyResource(context.Background(), obj, cmdutil.DryRunClient, false, false, false, "test-manager") +// assert.NoError(t, err) +// assert.Equal(t, "dryrun applied", out) +// } diff --git a/pkg/utils/testing/api_resources.go b/pkg/utils/testing/api_resources.go new file mode 100644 index 000000000..ebfc53543 --- /dev/null +++ b/pkg/utils/testing/api_resources.go @@ -0,0 +1,81 @@ +package testing + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +var ( + commonVerbs = []string{"create", "get", "list", "watch", "update", "patch", "delete", "deletecollection"} + subresourceVerbs = []string{"get", "update", "patch"} +) + +// StaticAPIResources defines the common Kubernetes API resources that are usually returned by a DiscoveryClient +var StaticAPIResources = []*metav1.APIResourceList{ + { + GroupVersion: "v1", + APIResources: []metav1.APIResource{ + {Name: "pods", SingularName: "pod", Namespaced: true, Kind: "Pod", Verbs: commonVerbs}, + {Name: "pods/status", SingularName: "", Namespaced: true, Kind: "Pod", Verbs: subresourceVerbs}, + {Name: "pods/log", SingularName: "", Namespaced: true, Kind: "Pod", Verbs: []string{"get"}}, + {Name: "pods/exec", SingularName: "", Namespaced: true, Kind: "Pod", Verbs: []string{"create"}}, + {Name: "services", SingularName: "service", Namespaced: true, Kind: "Service", Verbs: commonVerbs}, + {Name: "services/status", SingularName: "", Namespaced: true, Kind: "Service", Verbs: subresourceVerbs}, + {Name: "configmaps", SingularName: "configmap", Namespaced: true, Kind: "ConfigMap", Verbs: commonVerbs}, + {Name: "secrets", SingularName: "secret", Namespaced: true, Kind: "Secret", Verbs: commonVerbs}, + {Name: "namespaces", SingularName: "namespace", Namespaced: false, Kind: "Namespace", Verbs: commonVerbs}, + {Name: "namespaces/status", SingularName: "", Namespaced: false, Kind: "Namespace", Verbs: subresourceVerbs}, + {Name: "nodes", SingularName: "node", Namespaced: false, Kind: "Node", Verbs: []string{"get", "list", "watch"}}, + {Name: "persistentvolumes", SingularName: "persistentvolume", Namespaced: false, Kind: "PersistentVolume", Verbs: commonVerbs}, + {Name: "persistentvolumeclaims", SingularName: "persistentvolumeclaim", Namespaced: true, Kind: "PersistentVolumeClaim", Verbs: commonVerbs}, + {Name: "persistentvolumeclaims/status", SingularName: "", Namespaced: true, Kind: "PersistentVolumeClaim", Verbs: subresourceVerbs}, + {Name: "events", SingularName: "event", Namespaced: true, Kind: "Event", Verbs: []string{"create", "get", "list", "watch"}}, + {Name: "serviceaccounts", SingularName: "serviceaccount", Namespaced: true, Kind: "ServiceAccount", Verbs: commonVerbs}, + }, + }, + { + GroupVersion: "apps/v1", + APIResources: []metav1.APIResource{ + {Name: "deployments", SingularName: "deployment", Namespaced: true, Kind: "Deployment", Verbs: commonVerbs}, + {Name: "deployments/status", SingularName: "", Namespaced: true, Kind: "Deployment", Verbs: subresourceVerbs}, + {Name: "deployments/scale", SingularName: "", Namespaced: true, Kind: "Scale", Verbs: subresourceVerbs}, + {Name: "statefulsets", SingularName: "statefulset", Namespaced: true, Kind: "StatefulSet", Verbs: commonVerbs}, + {Name: "statefulsets/status", SingularName: "", Namespaced: true, Kind: "StatefulSet", Verbs: subresourceVerbs}, + {Name: "statefulsets/scale", SingularName: "", Namespaced: true, Kind: "Scale", Verbs: subresourceVerbs}, + {Name: "daemonsets", SingularName: "daemonset", Namespaced: true, Kind: "DaemonSet", Verbs: commonVerbs}, + {Name: "daemonsets/status", SingularName: "", Namespaced: true, Kind: "DaemonSet", Verbs: subresourceVerbs}, + {Name: "replicasets", SingularName: "replicaset", Namespaced: true, Kind: "ReplicaSet", Verbs: commonVerbs}, + {Name: "replicasets/status", SingularName: "", Namespaced: true, Kind: "ReplicaSet", Verbs: subresourceVerbs}, + }, + }, + { + GroupVersion: "batch/v1", + APIResources: []metav1.APIResource{ + {Name: "jobs", SingularName: "job", Namespaced: true, Kind: "Job", Verbs: commonVerbs}, + {Name: "jobs/status", SingularName: "", Namespaced: true, Kind: "Job", Verbs: subresourceVerbs}, + {Name: "cronjobs", SingularName: "cronjob", Namespaced: true, Kind: "CronJob", Verbs: commonVerbs}, + {Name: "cronjobs/status", SingularName: "", Namespaced: true, Kind: "CronJob", Verbs: subresourceVerbs}, + }, + }, + { + GroupVersion: "rbac.authorization.k8s.io/v1", + APIResources: []metav1.APIResource{ + {Name: "roles", SingularName: "role", Namespaced: true, Kind: "Role", Verbs: commonVerbs}, + {Name: "rolebindings", SingularName: "rolebinding", Namespaced: true, Kind: "RoleBinding", Verbs: commonVerbs}, + {Name: "clusterroles", SingularName: "clusterrole", Namespaced: false, Kind: "ClusterRole", Verbs: commonVerbs}, + {Name: "clusterrolebindings", SingularName: "clusterrolebinding", Namespaced: false, Kind: "ClusterRoleBinding", Verbs: commonVerbs}, + }, + }, + { + GroupVersion: "networking.k8s.io/v1", + APIResources: []metav1.APIResource{ + {Name: "ingresses", SingularName: "ingress", Namespaced: true, Kind: "Ingress", Verbs: commonVerbs}, + {Name: "ingresses/status", SingularName: "", Namespaced: true, Kind: "Ingress", Verbs: subresourceVerbs}, + {Name: "networkpolicies", SingularName: "networkpolicy", Namespaced: true, Kind: "NetworkPolicy", Verbs: commonVerbs}, + }, + }, + { + GroupVersion: "policy/v1", + APIResources: []metav1.APIResource{ + {Name: "poddisruptionbudgets", SingularName: "poddisruptionbudget", Namespaced: true, Kind: "PodDisruptionBudget", Verbs: commonVerbs}, + {Name: "poddisruptionbudgets/status", SingularName: "", Namespaced: true, Kind: "PodDisruptionBudget", Verbs: subresourceVerbs}, + }, + }, +} diff --git a/pkg/utils/testing/restclient.go b/pkg/utils/testing/restclient.go new file mode 100644 index 000000000..c98fca30f --- /dev/null +++ b/pkg/utils/testing/restclient.go @@ -0,0 +1,256 @@ +package testing + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/cli-runtime/pkg/resource" + "k8s.io/client-go/dynamic" + restfake "k8s.io/client-go/rest/fake" + cmdtesting "k8s.io/kubectl/pkg/cmd/testing" +) + +func NewFakeRESTClientBackedByDynamic(gv schema.GroupVersion, dc dynamic.Interface) *restfake.RESTClient { + rt := func(req *http.Request) (*http.Response, error) { + gvr, ns, name, subres, err := parse(req.URL.Path, gv) + if err != nil { + return errResp(req, http.StatusBadRequest, err), nil + } + // We’ll ignore subresources here; easy to extend if you need "status", etc. + var ri dynamic.ResourceInterface + ri = dc.Resource(gvr) + if ns != "" { + ri = dc.Resource(gvr).Namespace(ns) + } + + ctx := context.Background() + switch req.Method { + case http.MethodGet: + if name == "" { + list, err := ri.List(ctx, metav1.ListOptions{}) + return objResp(req, list, statusFromErr(err)), nil + } + obj, err := ri.Get(ctx, name, metav1.GetOptions{}) + return objResp(req, obj, statusFromErr(err)), nil + + case http.MethodPost: + u, rerr := readUnstructured(req.Body) + if rerr != nil { + return errResp(req, http.StatusBadRequest, rerr), nil + } + created, err := ri.Create(ctx, u, metav1.CreateOptions{}) + if err != nil { + return errResp(req, statusFromErr(err), err), nil + } + return objResp(req, created, http.StatusCreated), nil + + case http.MethodPut: + u, rerr := readUnstructured(req.Body) + if rerr != nil { + return errResp(req, http.StatusBadRequest, rerr), nil + } + updated, err := ri.Update(ctx, u, metav1.UpdateOptions{}) + return objResp(req, updated, statusFromErr(err)), nil + + case http.MethodPatch: + patch, _ := io.ReadAll(req.Body) + pt := parsePatchType(req.Header.Get("Content-Type")) + patched, err := ri.Patch(ctx, name, pt, patch, metav1.PatchOptions{}, subresOpt(subres)...) + return objResp(req, patched, statusFromErr(err)), nil + + case http.MethodDelete: + err := ri.Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil { + return errResp(req, statusFromErr(err), err), nil + } + // Respond like apiserver: 200 + Status or empty body is fine for tests + return objResp(req, map[string]string{"status": "Success"}, http.StatusOK), nil + + default: + return errResp(req, http.StatusNotImplemented, fmt.Errorf("verb %q not implemented", req.Method)), nil + } + } + + return &restfake.RESTClient{ + GroupVersion: gv, + Client: restfake.CreateHTTPClient(rt), + NegotiatedSerializer: resource.UnstructuredPlusDefaultContentConfig().NegotiatedSerializer, + } +} + +func parse(path string, defaultGroupVersion schema.GroupVersion) (schema.GroupVersionResource, string, string, string, error) { + seg := strings.Split(strings.Trim(path, "/"), "/") + if len(seg) < 2 { + return schema.GroupVersionResource{}, "", "", "", fmt.Errorf("bad path: %s", path) + } + gv := defaultGroupVersion + i := 0 + switch seg[0] { + case "api": + // Handles /api/v1/... + if len(seg) < 2 { + return schema.GroupVersionResource{}, "", "", "", errors.New("missing version") + } + gv = schema.GroupVersion{Version: seg[1]} + i = 2 + case "apis": + // Handles /apis///..., + if len(seg) < 3 { + return schema.GroupVersionResource{}, "", "", "", errors.New("missing group/version") + } + gv = schema.GroupVersion{Group: seg[1], Version: seg[2]} + i = 3 + } + + ns := "" + if i+1 < len(seg) && seg[i] == "namespaces" { + ns = seg[i+1] + i += 2 + } + if i >= len(seg) { + return schema.GroupVersionResource{}, "", "", "", errors.New("missing resource") + } + res := seg[i] + i++ + name := "" + if i < len(seg) { + name = seg[i] + i++ + } + subres := "" + if i < len(seg) { + subres = seg[i] + } + return gv.WithResource(res), ns, name, subres, nil +} + +func readUnstructured(r io.Reader) (*unstructured.Unstructured, error) { + body, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("failed to read request body: %w", err) + } + u := &unstructured.Unstructured{} + if len(body) > 0 { + if err := json.Unmarshal(body, &u.Object); err != nil { + return nil, fmt.Errorf("failed to unmarshal request body: %w", err) + } + } + return u, nil +} + +func objResp(req *http.Request, obj any, code int) *http.Response { + var b []byte + if obj != nil { + b, _ = json.Marshal(obj) + } + + return &http.Response{ + StatusCode: code, + Header: cmdtesting.DefaultHeader(), + Body: cmdtesting.BytesBody(b), + Request: req, + } +} + +func errResp(req *http.Request, code int, err error) *http.Response { + msg := map[string]any{ + "kind": "Status", + "code": code, + "status": "Failure", + "reason": err.Error(), + } + return objResp(req, msg, code) +} + +// statusFromErr returns the HTTP status code you'd expect the apiserver to emit +// for the given Kubernetes-style error. nil => 200 OK. +func statusFromErr(err error) int { + if err == nil { + return http.StatusOK + } + + switch { + case apierrors.IsNotFound(err): + return http.StatusNotFound // 404 + + case apierrors.IsAlreadyExists(err), apierrors.IsConflict(err): + return http.StatusConflict // 409 + + case apierrors.IsInvalid(err): + return http.StatusUnprocessableEntity // 422 + + case apierrors.IsBadRequest(err): + return http.StatusBadRequest // 400 + + case apierrors.IsUnauthorized(err): + return http.StatusUnauthorized // 401 + + case apierrors.IsForbidden(err): + return http.StatusForbidden // 403 + + case apierrors.IsMethodNotSupported(err): + return http.StatusMethodNotAllowed // 405 + + case apierrors.IsNotAcceptable(err): + return http.StatusNotAcceptable // 406 + + case apierrors.IsUnsupportedMediaType(err): + return http.StatusUnsupportedMediaType // 415 + + case apierrors.IsRequestEntityTooLargeError(err): + return http.StatusRequestEntityTooLarge // 413 + + case apierrors.IsTooManyRequests(err): + return http.StatusTooManyRequests // 429 + + case apierrors.IsTimeout(err), apierrors.IsServerTimeout(err), errors.Is(err, context.DeadlineExceeded): + return http.StatusGatewayTimeout // 504 + + case apierrors.IsResourceExpired(err), apierrors.IsGone(err): + return http.StatusGone // 410 + + case apierrors.IsServiceUnavailable(err): + return http.StatusServiceUnavailable // 503 + + case apierrors.IsUnexpectedServerError(err): + return http.StatusInternalServerError // 500 + } + + // If the error implements APIStatus and carries a non-zero code, honor it. + if s, ok := err.(apierrors.APIStatus); ok { + if code := s.Status().Code; code != 0 { + return int(code) + } + } + + // Final fallback + return http.StatusInternalServerError // 500 +} + +func parsePatchType(ct string) types.PatchType { + // Very small helper; adjust if you need strategic/json/merge detection + if strings.Contains(ct, "application/merge-patch+json") { + return types.PatchType("application/merge-patch+json") + } + if strings.Contains(ct, "application/apply-patch+yaml") { + return types.PatchType("application/apply-patch+yaml") + } + return types.PatchType("application/json-patch+json") +} + +func subresOpt(sub string) []string { + if sub == "" { + return nil + } + return []string{sub} +}