diff --git a/Makefile b/Makefile index 760eb1a4..e37f9cf8 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,9 @@ API_PLUGIN_PATH ?= $(KUSTOMIZE_PLUGIN_HOME)/policy.open-cluster-management.io/v1 # Kustomize arguments SOURCE_DIR ?= examples/ +# Image settings +IMAGE_TAG ?= policy-generator-plugin:latest + # go-get-tool will 'go install' any package $1 and install it to LOCAL_BIN. define go-get-tool @set -e ;\ @@ -54,6 +57,10 @@ build: layout build-binary: go build -o PolicyGenerator cmd/main.go +.PHONY: build-image +build-image: + docker build -f ./build/Dockerfile -t $(IMAGE_TAG) . + .PHONY: build-release build-release: @if [[ $(shell git status --porcelain | wc -l) -gt 0 ]]; \ diff --git a/README.md b/README.md index 4e343af7..116887a5 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ For more about Open Cluster Management and its Policy Framework: **NOTE:** This will default to placing the binary in `${HOME}/.config/kustomize/plugin/`. You can change this by exporting `KUSTOMIZE_PLUGIN_HOME` to a different path. -#### Configuration +#### Configuration and use as a Generator 1. Create a `kustomization.yaml` file that points to `PolicyGenerator` manifest(s), with any additional desired patches or customizations (see @@ -80,6 +80,14 @@ For more about Open Cluster Management and its Policy Framework: kustomize build --enable-alpha-plugins ``` +#### Configuration and use as a Transformer + +The plugin can also be used as a transformer, to wrap all incoming `resources` from a +`kustomization.yaml` file into one Policy. This feature is somewhat experimental. + +An example configuration as a transformer (and its output) can be found in the +[`examples/generator/`](./examples/generator/) folder. + ### As a standalone binary In order to bypass Kustomize and run the generator binary directly: diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 00000000..f595c956 --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,24 @@ +# Build the manager binary +FROM golang:1.18 as builder + +WORKDIR /workspace +# Copy the Go Modules manifests +COPY go.mod go.mod +COPY go.sum go.sum +# cache deps before building and copying source so that we don't need to re-download as much +# and so that source changes don't invalidate our downloaded layer +RUN go mod download + +# Copy the go source +COPY cmd cmd +COPY internal internal + +# Build +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o generator cmd/main.go + +FROM registry.access.redhat.com/ubi8/ubi-minimal:latest +WORKDIR / +COPY --from=builder /workspace/generator /generator + +WORKDIR /tmp +ENTRYPOINT ["/generator"] diff --git a/cmd/main.go b/cmd/main.go index 5af698c0..e8c2bbe8 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -2,12 +2,15 @@ package main import ( "bytes" + "errors" "fmt" + "io" "io/ioutil" "os" "github.com/spf13/pflag" "open-cluster-management.io/ocm-kustomize-generator-plugins/internal" + "sigs.k8s.io/kustomize/kyaml/kio" ) var debug = false @@ -23,6 +26,14 @@ func main() { generators := pflag.Args() var outputBuffer bytes.Buffer + if len(generators) == 0 { + if err := runKRMplugin(os.Stdin, os.Stdout); err != nil { + errorAndExit(err.Error()) + } + + return + } + for _, gen := range generators { outputBuffer.Write(processGeneratorConfig(gen)) } @@ -78,3 +89,87 @@ func processGeneratorConfig(filePath string) []byte { return generatedOutput } + +func runKRMplugin(input io.Reader, output io.Writer) error { + inputReader := kio.ByteReader{Reader: input} + + inputs, err := inputReader.Read() + if err != nil { + return fmt.Errorf("failed to read input: %w", err) + } + + config, err := inputReader.FunctionConfig.MarshalJSON() + if err != nil { + return fmt.Errorf("failed to marshal KRM configuration from input: %w", err) + } + + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to determine the current directory: %w", err) + } + + p := internal.Plugin{} + + err = p.Config(config, cwd) + if err != nil { + return fmt.Errorf("error processing the PolicyGenerator file '[stdin]': %w", err) + } + + // in KRM generator mode, this annotation will be set by kustomize + if inputs[0].GetAnnotations()["config.kubernetes.io/local-config"] != "true" { + // in KRM transformer mode, convert the KRM-style input yaml into the + // flat yaml format the generator uses, and write it to a temp file. + inpFile, err := os.CreateTemp(".", "transformer-intput-*.yaml") + if err != nil { + return fmt.Errorf("error creating an input file: %w", err) + } + + defer os.Remove(inpFile.Name()) // clean up + + inpwriter := kio.ByteWriter{ + Writer: inpFile, + ClearAnnotations: []string{ + "config.k8s.io/id", + "internal.config.kubernetes.io/annotations-migration-resource-id", + "internal.config.kubernetes.io/id", + "kustomize.config.k8s.io/id", + }, + } + + err = inpwriter.Write(inputs) + if err != nil { + return fmt.Errorf("error writing input KRM yaml to the temporary manifest: %w", err) + } + + if len(p.Policies) == 0 || len(p.Policies[0].Manifests) == 0 { + return errors.New("no manifests in config file") + } + + // overwrites the path in the generator yaml, from stdin to the temp file. + p.Policies[0].Manifests[0].Path = inpFile.Name() + } + + generatedOutput, err := p.Generate() + if err != nil { + return fmt.Errorf("error generating policies from the PolicyGenerator file: %w", err) + } + + nodes, err := (&kio.ByteReader{Reader: bytes.NewReader(generatedOutput)}).Read() + if err != nil { + return fmt.Errorf("error reading generator output: %w", err) + } + + // Write the result in a ResourceList + outputWriter := kio.ByteWriter{ + Writer: output, + WrappingAPIVersion: "config.kubernetes.io/v1", + WrappingKind: "ResourceList", + } + + err = outputWriter.Write(nodes) + if err != nil { + return fmt.Errorf("error writing generator output: %w", err) + } + + return nil +} diff --git a/examples/container-generator/kustomization.yaml b/examples/container-generator/kustomization.yaml new file mode 100644 index 00000000..ddd67a7b --- /dev/null +++ b/examples/container-generator/kustomization.yaml @@ -0,0 +1,23 @@ +# run this example with `kustomize build . --enable-alpha-plugins --mount type=bind,source=$(pwd),target=/tmp/,readonly > output.yaml` +# Note: the manifests can not use kustomization.yaml files referencing non-local resources (eg github) + +generators: +- |- + apiVersion: policy.open-cluster-management.io/v1 + kind: PolicyGenerator + metadata: + name: policy-generator + annotations: + config.kubernetes.io/function: | + container: + image: policy-generator-plugin:latest + policyDefaults: + namespace: default + consolidateManifests: false + evaluationInterval: + compliant: 30m + noncompliant: 45s + policies: + - name: made-in-container + manifests: + - path: resources/ diff --git a/examples/container-generator/output.yaml b/examples/container-generator/output.yaml new file mode 100644 index 00000000..8541b09d --- /dev/null +++ b/examples/container-generator/output.yaml @@ -0,0 +1,59 @@ +apiVersion: apps.open-cluster-management.io/v1 +kind: PlacementRule +metadata: + name: placement-made-in-container + namespace: default +spec: + clusterSelector: + matchExpressions: [] +--- +apiVersion: policy.open-cluster-management.io/v1 +kind: PlacementBinding +metadata: + name: binding-made-in-container + namespace: default +placementRef: + apiGroup: apps.open-cluster-management.io + kind: PlacementRule + name: placement-made-in-container +subjects: +- apiGroup: policy.open-cluster-management.io + kind: Policy + name: made-in-container +--- +apiVersion: policy.open-cluster-management.io/v1 +kind: Policy +metadata: + annotations: + policy.open-cluster-management.io/categories: CM Configuration Management + policy.open-cluster-management.io/controls: CM-2 Baseline Configuration + policy.open-cluster-management.io/standards: NIST SP 800-53 + name: made-in-container + namespace: default +spec: + disabled: false + policy-templates: + - objectDefinition: + apiVersion: policy.open-cluster-management.io/v1 + kind: ConfigurationPolicy + metadata: + name: made-in-container + spec: + evaluationInterval: + compliant: 30m + noncompliant: 45s + object-templates: + - complianceType: musthave + objectDefinition: + apiVersion: v1 + data: + game.properties: "enemies=goldfish \n" + ui.properties: | + color.good=neon-green + kind: ConfigMap + metadata: + annotations: {} + name: kustomized-game-config-fish + namespace: default + remediationAction: inform + severity: low diff --git a/examples/container-generator/resources/configmap-fish.yaml b/examples/container-generator/resources/configmap-fish.yaml new file mode 100644 index 00000000..e16eb429 --- /dev/null +++ b/examples/container-generator/resources/configmap-fish.yaml @@ -0,0 +1,12 @@ +# Taken from https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/ +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: game-config-fish + namespace: default +data: + game.properties: | + enemies=goldfish + ui.properties: | + color.good=neon-green diff --git a/examples/container-generator/resources/kustomization.yaml b/examples/container-generator/resources/kustomization.yaml new file mode 100644 index 00000000..35106774 --- /dev/null +++ b/examples/container-generator/resources/kustomization.yaml @@ -0,0 +1,3 @@ +namePrefix: kustomized- +resources: +- ./configmap-fish.yaml diff --git a/examples/container-transformer/kustomization.yaml b/examples/container-transformer/kustomization.yaml new file mode 100644 index 00000000..a091c417 --- /dev/null +++ b/examples/container-transformer/kustomization.yaml @@ -0,0 +1,25 @@ +# run this example with `kustomize build . --enable-alpha-plugins > output.yaml` +# Note: the container image must be available + +resources: +- github.com/redhat-cop/gitops-catalog/advanced-cluster-management/operator/overlays/release-2.5?ref=main +transformers: +- |- + apiVersion: policy.open-cluster-management.io/v1 + kind: PolicyGenerator + metadata: + name: policy-transformer + annotations: + config.kubernetes.io/function: | + container: + image: policy-generator-plugin:latest + policyDefaults: + namespace: default + consolidateManifests: false + evaluationInterval: + compliant: 30m + noncompliant: 45s + policies: + - name: transformed-policy + manifests: + - path: stdin diff --git a/examples/container-transformer/output.yaml b/examples/container-transformer/output.yaml new file mode 100644 index 00000000..f7c9b7f3 --- /dev/null +++ b/examples/container-transformer/output.yaml @@ -0,0 +1,105 @@ +apiVersion: apps.open-cluster-management.io/v1 +kind: PlacementRule +metadata: + name: placement-transformed-policy + namespace: default +spec: + clusterSelector: + matchExpressions: [] +--- +apiVersion: policy.open-cluster-management.io/v1 +kind: PlacementBinding +metadata: + name: binding-transformed-policy + namespace: default +placementRef: + apiGroup: apps.open-cluster-management.io + kind: PlacementRule + name: placement-transformed-policy +subjects: +- apiGroup: policy.open-cluster-management.io + kind: Policy + name: transformed-policy +--- +apiVersion: policy.open-cluster-management.io/v1 +kind: Policy +metadata: + annotations: + policy.open-cluster-management.io/categories: CM Configuration Management + policy.open-cluster-management.io/controls: CM-2 Baseline Configuration + policy.open-cluster-management.io/standards: NIST SP 800-53 + name: transformed-policy + namespace: default +spec: + disabled: false + policy-templates: + - objectDefinition: + apiVersion: policy.open-cluster-management.io/v1 + kind: ConfigurationPolicy + metadata: + name: transformed-policy + spec: + evaluationInterval: + compliant: 30m + noncompliant: 45s + object-templates: + - complianceType: musthave + objectDefinition: + apiVersion: v1 + kind: Namespace + metadata: + annotations: {} + labels: + openshift.io/cluster-monitoring: "true" + name: open-cluster-management + remediationAction: inform + severity: low + - objectDefinition: + apiVersion: policy.open-cluster-management.io/v1 + kind: ConfigurationPolicy + metadata: + name: transformed-policy2 + spec: + evaluationInterval: + compliant: 30m + noncompliant: 45s + object-templates: + - complianceType: musthave + objectDefinition: + apiVersion: operators.coreos.com/v1 + kind: OperatorGroup + metadata: + annotations: {} + name: open-cluster-management + namespace: open-cluster-management + spec: + targetNamespaces: + - open-cluster-management + remediationAction: inform + severity: low + - objectDefinition: + apiVersion: policy.open-cluster-management.io/v1 + kind: ConfigurationPolicy + metadata: + name: transformed-policy3 + spec: + evaluationInterval: + compliant: 30m + noncompliant: 45s + object-templates: + - complianceType: musthave + objectDefinition: + apiVersion: operators.coreos.com/v1alpha1 + kind: Subscription + metadata: + annotations: {} + name: advanced-cluster-management + namespace: open-cluster-management + spec: + channel: release-2.5 + installPlanApproval: Automatic + name: advanced-cluster-management + source: redhat-operators + sourceNamespace: openshift-marketplace + remediationAction: inform + severity: low diff --git a/examples/transformer/kustomization.yaml b/examples/transformer/kustomization.yaml new file mode 100644 index 00000000..da04c7d3 --- /dev/null +++ b/examples/transformer/kustomization.yaml @@ -0,0 +1,7 @@ +# run this example with `kustomize build . --enable-alpha-plugins > output.yaml` +# Note: you must have the plugin installed to your kustomize plugin directory. + +resources: +- github.com/redhat-cop/gitops-catalog/advanced-cluster-management/operator/overlays/release-2.5?ref=main +transformers: +- ./policyTransformer.yaml diff --git a/examples/transformer/output.yaml b/examples/transformer/output.yaml new file mode 100644 index 00000000..f7c9b7f3 --- /dev/null +++ b/examples/transformer/output.yaml @@ -0,0 +1,105 @@ +apiVersion: apps.open-cluster-management.io/v1 +kind: PlacementRule +metadata: + name: placement-transformed-policy + namespace: default +spec: + clusterSelector: + matchExpressions: [] +--- +apiVersion: policy.open-cluster-management.io/v1 +kind: PlacementBinding +metadata: + name: binding-transformed-policy + namespace: default +placementRef: + apiGroup: apps.open-cluster-management.io + kind: PlacementRule + name: placement-transformed-policy +subjects: +- apiGroup: policy.open-cluster-management.io + kind: Policy + name: transformed-policy +--- +apiVersion: policy.open-cluster-management.io/v1 +kind: Policy +metadata: + annotations: + policy.open-cluster-management.io/categories: CM Configuration Management + policy.open-cluster-management.io/controls: CM-2 Baseline Configuration + policy.open-cluster-management.io/standards: NIST SP 800-53 + name: transformed-policy + namespace: default +spec: + disabled: false + policy-templates: + - objectDefinition: + apiVersion: policy.open-cluster-management.io/v1 + kind: ConfigurationPolicy + metadata: + name: transformed-policy + spec: + evaluationInterval: + compliant: 30m + noncompliant: 45s + object-templates: + - complianceType: musthave + objectDefinition: + apiVersion: v1 + kind: Namespace + metadata: + annotations: {} + labels: + openshift.io/cluster-monitoring: "true" + name: open-cluster-management + remediationAction: inform + severity: low + - objectDefinition: + apiVersion: policy.open-cluster-management.io/v1 + kind: ConfigurationPolicy + metadata: + name: transformed-policy2 + spec: + evaluationInterval: + compliant: 30m + noncompliant: 45s + object-templates: + - complianceType: musthave + objectDefinition: + apiVersion: operators.coreos.com/v1 + kind: OperatorGroup + metadata: + annotations: {} + name: open-cluster-management + namespace: open-cluster-management + spec: + targetNamespaces: + - open-cluster-management + remediationAction: inform + severity: low + - objectDefinition: + apiVersion: policy.open-cluster-management.io/v1 + kind: ConfigurationPolicy + metadata: + name: transformed-policy3 + spec: + evaluationInterval: + compliant: 30m + noncompliant: 45s + object-templates: + - complianceType: musthave + objectDefinition: + apiVersion: operators.coreos.com/v1alpha1 + kind: Subscription + metadata: + annotations: {} + name: advanced-cluster-management + namespace: open-cluster-management + spec: + channel: release-2.5 + installPlanApproval: Automatic + name: advanced-cluster-management + source: redhat-operators + sourceNamespace: openshift-marketplace + remediationAction: inform + severity: low diff --git a/examples/transformer/policyTransformer.yaml b/examples/transformer/policyTransformer.yaml new file mode 100644 index 00000000..0ab7ae03 --- /dev/null +++ b/examples/transformer/policyTransformer.yaml @@ -0,0 +1,16 @@ +apiVersion: policy.open-cluster-management.io/v1 +kind: PolicyGenerator +metadata: + name: policy-transformer + +policyDefaults: + namespace: default + consolidateManifests: false + evaluationInterval: + compliant: 30m + noncompliant: 45s + +policies: +- name: transformed-policy + manifests: + - path: stdin diff --git a/internal/plugin.go b/internal/plugin.go index 1e41b74b..f92eabf7 100644 --- a/internal/plugin.go +++ b/internal/plugin.go @@ -41,7 +41,8 @@ type Plugin struct { APIVersion string `json:"apiVersion,omitempty" yaml:"apiVersion,omitempty"` Kind string `json:"kind,omitempty" yaml:"kind,omitempty"` Metadata struct { - Name string `json:"name,omitempty" yaml:"name,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"` } `json:"metadata,omitempty" yaml:"metadata,omitempty"` PlacementBindingDefaults struct { Name string `json:"name,omitempty" yaml:"name,omitempty"` @@ -807,16 +808,18 @@ func (p *Plugin) assertValidConfig() error { ) } - _, err := os.Stat(manifest.Path) - if err != nil { - return fmt.Errorf( - "could not read the manifest path %s in policy %s", manifest.Path, policy.Name, - ) - } + if manifest.Path != "stdin" { + _, err := os.Stat(manifest.Path) + if err != nil { + return fmt.Errorf( + "could not read the manifest path %s in policy %s", manifest.Path, policy.Name, + ) + } - err = verifyManifestPath(p.baseDirectory, manifest.Path) - if err != nil { - return err + err = verifyManifestPath(p.baseDirectory, manifest.Path) + if err != nil { + return err + } } evalInterval := manifest.EvaluationInterval diff --git a/internal/utils.go b/internal/utils.go index 84da7b6c..0b9b5e19 100644 --- a/internal/utils.go +++ b/internal/utils.go @@ -25,66 +25,89 @@ import ( // be read. func getManifests(policyConf *types.PolicyConfig) ([][]map[string]interface{}, error) { manifests := [][]map[string]interface{}{} - hasKustomize := map[string]bool{} for _, manifest := range policyConf.Manifests { - manifestPaths := []string{} manifestFiles := []map[string]interface{}{} readErr := fmt.Errorf("failed to read the manifest path %s", manifest.Path) - manifestPathInfo, err := os.Stat(manifest.Path) + var manifestFD *os.File + var err error + + if manifest.Path == "stdin" { + manifestFD = os.Stdin + } else { + manifestFD, err = os.Open(manifest.Path) + if err != nil { + return nil, readErr + } + } + + manifestPathInfo, err := manifestFD.Stat() if err != nil { return nil, readErr } - resolvedFiles := []string{} - if manifestPathInfo.IsDir() { files, err := ioutil.ReadDir(manifest.Path) if err != nil { return nil, readErr } - for _, f := range files { - if f.IsDir() { - continue - } - - filepath := f.Name() - ext := path.Ext(filepath) + // Handle when a Kustomization directory is specified + hasKustomize := false - if ext != ".yaml" && ext != ".yml" { - continue - } - // Handle when a Kustomization directory is specified - _, filename := path.Split(filepath) + for _, f := range files { + _, filename := path.Split(f.Name()) if filename == "kustomization.yml" || filename == "kustomization.yaml" { - hasKustomize[manifest.Path] = true - resolvedFiles = []string{manifest.Path} + hasKustomize = true + manifestFiles, err = processKustomizeDir(manifest.Path) + + if err != nil { + return nil, err + } break } - - yamlPath := path.Join(manifest.Path, f.Name()) - resolvedFiles = append(resolvedFiles, yamlPath) } - manifestPaths = append(manifestPaths, resolvedFiles...) + if !hasKustomize { + for _, f := range files { + if f.IsDir() { + continue + } + + filepath := f.Name() + ext := path.Ext(filepath) + + if ext != ".yaml" && ext != ".yml" { + continue + } + + manifestDocs, err := unmarshalManifestFile(path.Join(manifest.Path, f.Name())) + if err != nil { + return nil, err + } + + manifestFiles = append(manifestFiles, manifestDocs...) + } + } } else { // Unmarshal the manifest in order to check for metadata patch replacement - manifestFile, err := unmarshalManifestFile(manifest.Path) + manifestBytes, err := io.ReadAll(manifestFD) if err != nil { return nil, err } - if len(manifestFile) == 0 { - continue + manifestFiles, err = unmarshalManifestBytes(manifestBytes) + if err != nil { + return nil, err } + // Allowing replace the original manifest metadata.name and/or metadata.namespace if it is a single // yaml structure in the manifest path - if len(manifestFile) == 1 && len(manifest.Patches) == 1 { + if len(manifestFiles) == 1 && len(manifest.Patches) == 1 { if patchMetadata, ok := manifest.Patches[0]["metadata"].(map[string]interface{}); ok { - if metadata, ok := manifestFile[0]["metadata"].(map[string]interface{}); ok { + if metadata, ok := manifestFiles[0]["metadata"].(map[string]interface{}); ok { name, ok := patchMetadata["name"].(string) if ok && name != "" { metadata["name"] = name @@ -93,33 +116,10 @@ func getManifests(policyConf *types.PolicyConfig) ([][]map[string]interface{}, e if ok && namespace != "" { metadata["namespace"] = namespace } - manifestFile[0]["metadata"] = metadata + manifestFiles[0]["metadata"] = metadata } } } - - manifestFiles = append(manifestFiles, manifestFile...) - } - - for _, manifestPath := range manifestPaths { - var manifestFile []map[string]interface{} - var err error - - if hasKustomize[manifestPath] { - manifestFile, err = processKustomizeDir(manifestPath) - } else { - manifestFile, err = unmarshalManifestFile(manifestPath) - } - - if err != nil { - return nil, err - } - - if len(manifestFile) == 0 { - continue - } - - manifestFiles = append(manifestFiles, manifestFile...) } if len(manifest.Patches) > 0 { @@ -189,6 +189,27 @@ func getPolicyTemplates(policyConf *types.PolicyConfig) ([]map[string]map[string continue } + // Annotations with these prefixes might be added to resources by kustomize, + // and should be removed when the resource is wrapped in a policy. + prefixesToDelete := []string{ + "config.kubernetes.io/path", + "config.kubernetes.io/index", + "config.k8s.io/id", + "kustomize.config.k8s.io/id", + "internal.config.kubernetes.io", + } + annotations, _, _ := unstructured.NestedStringMap(manifest, "metadata", "annotations") + + for key := range annotations { + for _, prefix := range prefixesToDelete { + if strings.HasPrefix(key, prefix) { + delete(annotations, key) + } + } + } + + _ = unstructured.SetNestedStringMap(manifest, annotations, "metadata", "annotations") + objTemplate := map[string]interface{}{ "complianceType": complianceType, "objectDefinition": manifest,