diff --git a/.gitignore b/.gitignore index b9d8154..d21d574 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,5 @@ # Dependency directories (remove the comment below to include it) # vendor/ -bin/ \ No newline at end of file +bin/ +testbin/ diff --git a/go.sum b/go.sum index 8059d68..87b482c 100644 --- a/go.sum +++ b/go.sum @@ -258,6 +258,7 @@ github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4er github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= @@ -310,6 +311,7 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3 github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= @@ -1043,6 +1045,7 @@ k8s.io/client-go v0.22.1/go.mod h1:BquC5A4UOo4qVDUtoc04/+Nxp1MeHcVc1HJm1KmG8kk= k8s.io/code-generator v0.21.1/go.mod h1:hUlps5+9QaTrKx+jiM4rmq7YmH8wPOIko64uZCHDh6Q= k8s.io/code-generator v0.22.1/go.mod h1:eV77Y09IopzeXOJzndrDyCI88UBok2h6WxAlBwpxa+o= k8s.io/component-base v0.21.1/go.mod h1:NgzFZ2qu4m1juby4TnrmpR8adRk6ka62YdH5DkIIyKA= +k8s.io/component-base v0.22.1 h1:SFqIXsEN3v3Kkr1bS6rstrs1wd45StJqbtgbQ4nRQdo= k8s.io/component-base v0.22.1/go.mod h1:0D+Bl8rrnsPN9v0dyYvkqFfBeAd4u7n77ze+p8CMiPo= k8s.io/component-helpers v0.21.1/go.mod h1:FtC1flbiQlosHQrLrRUulnKxE4ajgWCGy/67fT2GRlQ= k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= diff --git a/pkg/resmgr/main_test.go b/pkg/resmgr/main_test.go new file mode 100644 index 0000000..26125c8 --- /dev/null +++ b/pkg/resmgr/main_test.go @@ -0,0 +1,125 @@ +package resmgr + +import ( + "fmt" + "io" + "os" + "strings" + "sync/atomic" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + apiruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + yamlutil "k8s.io/apimachinery/pkg/util/yaml" + "sigs.k8s.io/cli-utils/pkg/kstatus/polling" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/apiutil" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +var manager *ResourceManager + +func TestMain(m *testing.M) { + testEnv := &envtest.Environment{} + + cfg, err := testEnv.Start() + if err != nil { + panic(err) + } + + kubeClient, err := client.NewWithWatch(cfg, client.Options{Scheme: newScheme()}) + if err != nil { + panic(err) + } + + restMapper, err := apiutil.NewDynamicRESTMapper(cfg) + if err != nil { + panic(err) + } + + c, err := client.New(cfg, client.Options{Mapper: restMapper}) + if err != nil { + panic(err) + } + + poller := polling.NewStatusPoller(c, restMapper) + + manager = &ResourceManager{ + kubeClient: kubeClient, + kstatusPoller: poller, + fmt: &ResourceFormatter{}, + fieldOwner: "resource-manager", + } + + code := m.Run() + + testEnv.Stop() + + os.Exit(code) +} + +func readManifest(manifest, namespace string) ([]*unstructured.Unstructured, error) { + data, err := os.ReadFile(manifest) + if err != nil { + return nil, err + } + yml := fmt.Sprintf(string(data), namespace) + + reader := yamlutil.NewYAMLOrJSONDecoder(strings.NewReader(yml), 2048) + objects := make([]*unstructured.Unstructured, 0) + for { + obj := &unstructured.Unstructured{} + err := reader.Decode(obj) + if err != nil { + if err == io.EOF { + err = nil + break + } + return objects, err + } + + if obj.IsList() { + err = obj.EachListItem(func(item apiruntime.Object) error { + obj := item.(*unstructured.Unstructured) + objects = append(objects, obj) + return nil + }) + if err != nil { + return objects, err + } + continue + } + + objects = append(objects, obj) + } + + return objects, nil + +} + +func setNamespace(objects []*unstructured.Unstructured, namespace string) { + for _, object := range objects { + object.SetNamespace(namespace) + } + + u := &unstructured.Unstructured{} + u.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "", + Kind: "Namespace", + Version: "v1", + }) + u.SetName(namespace) + objects = append(objects, u) +} + +var nextNameId int64 + +func generateName(prefix string) string { + id := atomic.AddInt64(&nextNameId, 1) + return fmt.Sprintf("%s-%d", prefix, id) +} + +func removeObject(s []*unstructured.Unstructured, index int) []*unstructured.Unstructured { + return append(s[:index], s[index+1:]...) +} diff --git a/pkg/resmgr/manager_apply_test.go b/pkg/resmgr/manager_apply_test.go new file mode 100644 index 0000000..d43445a --- /dev/null +++ b/pkg/resmgr/manager_apply_test.go @@ -0,0 +1,211 @@ +package resmgr + +import ( + "context" + "encoding/base64" + "fmt" + "sort" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestApply(t *testing.T) { + timeout := 10 * time.Second + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + objects, err := readManifest("testdata/test1.yaml", generateName("ns")) + if err != nil { + t.Fatal(err) + } + + // create objects + createdChangeSet, err := manager.ApplyAllStaged(ctx, objects, false, timeout) + if err != nil { + t.Fatal(err) + } + + // expected created order + sort.Sort(ApplyOrder(objects)) + var expected []string + for _, object := range objects { + expected = append(expected, manager.fmt.Unstructured(object)) + } + + // verify the change set contains only created actions + var output []string + for _, entry := range createdChangeSet.Entries { + if diff := cmp.Diff(entry.Action, string(CreatedAction)); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } + output = append(output, entry.Subject) + } + + // verify the change set contains all objects in the right order + if diff := cmp.Diff(expected, output); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } + + // no-op apply + unchangedChangeSet, err := manager.ApplyAllStaged(ctx, objects, false, timeout) + if err != nil { + t.Fatal(err) + } + + // verify the change set contains only unchanged actions + for _, entry := range unchangedChangeSet.Entries { + if diff := cmp.Diff(string(UnchangedAction), entry.Action); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } + output = append(output, entry.Subject) + } + + // extract configmap + var configMap *unstructured.Unstructured + for _, object := range objects { + if object.GetKind() == "ConfigMap" { + configMap = object + break + } + } + configMapName := manager.fmt.Unstructured(configMap) + + // update a value in the configmap + err = unstructured.SetNestedField(configMap.Object, "val", "data", "key") + if err != nil { + t.Fatal(err) + } + + // apply changes + configuredChangeSet, err := manager.ApplyAllStaged(ctx, objects, false, timeout) + if err != nil { + t.Fatal(err) + } + + // verify the change set contains the configured action only for the configmap + for _, entry := range configuredChangeSet.Entries { + if entry.Subject == configMapName { + if diff := cmp.Diff(string(ConfiguredAction), entry.Action); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } + } else { + if diff := cmp.Diff(string(UnchangedAction), entry.Action); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } + } + } + + // get the configmap from cluster + configMapClone := configMap.DeepCopy() + err = manager.kubeClient.Get(ctx, client.ObjectKeyFromObject(configMapClone), configMapClone) + if err != nil { + t.Fatal(err) + } + + // get data value from the in-cluster configmap + val, _, err := unstructured.NestedFieldCopy(configMapClone.Object, "data", "key") + if err != nil { + t.Fatal(err) + } + + // verify the configmap was updated in cluster with the right data value + if diff := cmp.Diff(val, "val"); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } + + // delete the configmap + deletedChangeSet, err := manager.DeleteAll(ctx, []*unstructured.Unstructured{configMap}) + for _, entry := range deletedChangeSet.Entries { + if diff := cmp.Diff(string(DeletedAction), entry.Action); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } + } + + // reapply objects + changeSet, err := manager.ApplyAllStaged(ctx, objects, false, timeout) + if err != nil { + t.Fatal(err) + } + + // verify the configmap was recreated + for _, entry := range changeSet.Entries { + if entry.Subject == configMapName { + if diff := cmp.Diff(string(CreatedAction), entry.Action); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } + } else { + if diff := cmp.Diff(string(UnchangedAction), entry.Action); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } + } + } + + // extract secret + var secret *unstructured.Unstructured + for _, object := range objects { + if object.GetKind() == "Secret" { + secret = object + break + } + } + secretName := manager.fmt.Unstructured(secret) + + // update a value in the secret + err = unstructured.SetNestedField(secret.Object, "val-secret", "stringData", "key") + if err != nil { + t.Fatal(err) + } + + // apply and expect to fail + changeSet, err = manager.ApplyAllStaged(ctx, objects, false, timeout) + if err == nil { + t.Fatal("Expected error got none") + } + + // verify that the error message does not contain sensitive information + expectedErr := fmt.Sprintf("%s is invalid, error: secret is is immutable", manager.fmt.Unstructured(secret)) + if diff := cmp.Diff(expectedErr, err.Error()); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } + + // force apply + changeSet, err = manager.ApplyAllStaged(ctx, objects, true, timeout) + if err != nil { + t.Fatal(err) + } + + // verify the secret was recreated + for _, entry := range changeSet.Entries { + if entry.Subject == secretName { + if diff := cmp.Diff(string(CreatedAction), entry.Action); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } + } else { + if diff := cmp.Diff(string(UnchangedAction), entry.Action); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } + } + } + + // get the secret from cluster + secretClone := secret.DeepCopy() + err = manager.kubeClient.Get(ctx, client.ObjectKeyFromObject(secretClone), secretClone) + if err != nil { + t.Fatal(err) + } + + // get data value from the in-cluster secret + val, _, err = unstructured.NestedFieldCopy(secretClone.Object, "data", "key") + if err != nil { + t.Fatal(err) + } + + // verify the secret was updated in cluster with the right data value + if diff := cmp.Diff(val, base64.StdEncoding.EncodeToString([]byte("val-secret"))); diff != "" { + t.Errorf("Mismatch from expected value (-want +got):\n%s", diff) + } +} diff --git a/pkg/resmgr/testdata/test1.yaml b/pkg/resmgr/testdata/test1.yaml new file mode 100644 index 0000000..f9c60b2 --- /dev/null +++ b/pkg/resmgr/testdata/test1.yaml @@ -0,0 +1,53 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: "%[1]s" + namespace: "%[1]s" +--- +apiVersion: v1 +kind: Secret +metadata: + name: "%[1]s" + namespace: "%[1]s" +immutable: true +stringData: + key: "private-key" +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: "%[1]s" + namespace: "%[1]s" +data: + key: "public-key" +--- +apiVersion: v1 +kind: Namespace +metadata: + name: "%[1]s" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: "%[1]s" +rules: + - apiGroups: + - apps + resources: ["*"] + verbs: + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: "%[1]s" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: "%[1]s" +subjects: + - kind: ServiceAccount + name: "%[1]s" + namespace: "%[1]s"