diff --git a/pkg/template/template.go b/pkg/template/template.go new file mode 100644 index 00000000..5f609353 --- /dev/null +++ b/pkg/template/template.go @@ -0,0 +1,84 @@ +package template + +import ( + "bytes" + "embed" + "io" + "io/fs" + "text/template" + + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/kubernetes/scheme" +) + +// Variables contains all the available variables that are supported by the templates +type Variables struct { + Namespace string +} + +// LoadObjectsFromEmbedFS loads all the kubernetes objects from an embedded filesystem and returns a list of Unstructured objects that can be applied in the cluster. +// The function will return all the objects it finds starting from the root of the embedded filesystem. +func LoadObjectsFromEmbedFS(efs *embed.FS, variables *Variables) ([]*unstructured.Unstructured, error) { + var objects []*unstructured.Unstructured + entries, err := getAllTemplateNames(efs) + if err != nil { + return objects, err + } + for _, templatePath := range entries { + templateContent, err := efs.ReadFile(templatePath) + if err != nil { + return objects, err + } + buf, err := replaceTemplateVariables(templatePath, templateContent, variables) + if err != nil { + return objects, err + } + decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(buf.Bytes()), 100) + for { + var rawExt runtime.RawExtension + if err := decoder.Decode(&rawExt); err != nil { + if errors.Is(err, io.EOF) { + break + } + return objects, err + } + rawExt.Raw = bytes.TrimSpace(rawExt.Raw) + if len(rawExt.Raw) == 0 || bytes.Equal(rawExt.Raw, []byte("null")) { + continue + } + unstructuredObj := &unstructured.Unstructured{} + _, _, err = scheme.Codecs.UniversalDeserializer().Decode(rawExt.Raw, nil, unstructuredObj) + if err != nil { + return objects, err + } + objects = append(objects, unstructuredObj) + } + } + return objects, nil +} + +// replaceTemplateVariables replaces all the variables in the given template and returns a buffer with the evaluated content +func replaceTemplateVariables(templateName string, templateContent []byte, variables *Variables) (bytes.Buffer, error) { + var buf bytes.Buffer + tmpl, err := template.New(templateName).Parse(string(templateContent)) + if err != nil { + return buf, err + } + err = tmpl.Execute(&buf, variables) + return buf, err +} + +// getAllTemplateNames reads the embedded filesystem and returns a list with all the filenames +func getAllTemplateNames(efs *embed.FS) (files []string, err error) { + err = fs.WalkDir(efs, ".", func(path string, d fs.DirEntry, err error) error { + if d.IsDir() { + return nil + } + files = append(files, path) + return nil + }) + return files, err +} diff --git a/pkg/template/template_test.go b/pkg/template/template_test.go new file mode 100644 index 00000000..90aeeb97 --- /dev/null +++ b/pkg/template/template_test.go @@ -0,0 +1,100 @@ +package template_test + +import ( + "embed" + "testing" + + "github.com/codeready-toolchain/toolchain-common/pkg/template" + "github.com/codeready-toolchain/toolchain-common/pkg/test" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + rbac "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +//go:embed testdata/* +var EFS embed.FS + +//go:embed testdata/host/* +var hostFS embed.FS + +//go:embed testdata/member/* +var memberFS embed.FS + +func TestLoadObjectsFromEmbedFS(t *testing.T) { + t.Run("loads objects recursively from all subdirectories", func(t *testing.T) { + // when + allObjects, err := template.LoadObjectsFromEmbedFS(&EFS, &template.Variables{Namespace: test.HostOperatorNs}) + require.NoError(t, err) + hostFolderObjects, err := template.LoadObjectsFromEmbedFS(&hostFS, &template.Variables{Namespace: test.HostOperatorNs}) + require.NoError(t, err) + memberFolderObjects, err := template.LoadObjectsFromEmbedFS(&memberFS, nil) + require.NoError(t, err) + // then + require.NotNil(t, allObjects) + require.NotNil(t, hostFolderObjects) + require.NotNil(t, memberFolderObjects) + require.Equal(t, 4, len(allObjects), "invalid number of expected total objects") + require.Equal(t, 3, len(hostFolderObjects), "invalid number of expected objects from host folder") + require.Equal(t, 1, len(memberFolderObjects), "invalid number of expected objects from member folder") + // check match for the expected objects + checkExpectedObjects(t, allObjects) + }) + + t.Run("error - when variables are not provided", func(t *testing.T) { + // when + // we do not pass required variables for the templates that requires variables + objects, err := template.LoadObjectsFromEmbedFS(&hostFS, nil) + // then + // we should get back an error + require.Error(t, err) + require.Nil(t, objects) + }) +} + +func checkExpectedObjects(t *testing.T, objects []*unstructured.Unstructured) { + sa := &v1.ServiceAccount{} + err := runtime.DefaultUnstructuredConverter.FromUnstructured(objects[0].Object, sa) + require.NoError(t, err) + require.Equal(t, "toolchaincluster-host", sa.GetName()) + require.Equal(t, "toolchain-host-operator", sa.GetNamespace()) + role := &rbac.Role{} + err = runtime.DefaultUnstructuredConverter.FromUnstructured(objects[1].Object, role) + require.NoError(t, err) + require.Equal(t, "toolchaincluster-host", role.GetName()) + require.Equal(t, "toolchain-host-operator", role.GetNamespace()) + require.Equal(t, []rbac.PolicyRule{ + { + APIGroups: []string{"toolchain.dev.openshift.com"}, + Resources: []string{"*"}, + Verbs: []string{"*"}, + }, + }, role.Rules) + roleBinding := &rbac.RoleBinding{} + err = runtime.DefaultUnstructuredConverter.FromUnstructured(objects[2].Object, roleBinding) + require.NoError(t, err) + require.Equal(t, "toolchaincluster-host", roleBinding.GetName()) + require.Equal(t, "toolchain-host-operator", roleBinding.GetNamespace()) + require.Equal(t, rbac.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: "toolchaincluster-host", + }, roleBinding.RoleRef) + require.Equal(t, 1, len(roleBinding.Subjects)) + require.Equal(t, rbac.Subject{ + Kind: "ServiceAccount", + Name: "toolchaincluster-host", + }, roleBinding.Subjects[0]) + clusterRole := &rbac.ClusterRole{} + err = runtime.DefaultUnstructuredConverter.FromUnstructured(objects[3].Object, clusterRole) + require.NoError(t, err) + require.Equal(t, "member-toolchaincluster-cr", clusterRole.GetName()) + require.Equal(t, []rbac.PolicyRule{ + { + APIGroups: []string{"authentication.k8s.io"}, + Resources: []string{"tokenreviews"}, + Verbs: []string{"create"}, + }, + }, clusterRole.Rules) +} diff --git a/pkg/template/testdata/host/service-account.yaml b/pkg/template/testdata/host/service-account.yaml new file mode 100644 index 00000000..70dc0c13 --- /dev/null +++ b/pkg/template/testdata/host/service-account.yaml @@ -0,0 +1,32 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: toolchaincluster-host + namespace: {{.Namespace}} +--- +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: toolchaincluster-host + namespace: {{.Namespace}} +rules: +- apiGroups: + - toolchain.dev.openshift.com + resources: + - "*" + verbs: + - "*" +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: toolchaincluster-host + namespace: {{.Namespace}} +subjects: +- kind: ServiceAccount + name: toolchaincluster-host +roleRef: + kind: Role + name: toolchaincluster-host + apiGroup: rbac.authorization.k8s.io diff --git a/pkg/template/testdata/member/cluster-role.yaml b/pkg/template/testdata/member/cluster-role.yaml new file mode 100644 index 00000000..3da01b2f --- /dev/null +++ b/pkg/template/testdata/member/cluster-role.yaml @@ -0,0 +1,11 @@ +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: member-toolchaincluster-cr +rules: +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create \ No newline at end of file