diff --git a/cmd/flux/build.go b/cmd/flux/build.go new file mode 100644 index 00000000..0c901036 --- /dev/null +++ b/cmd/flux/build.go @@ -0,0 +1,31 @@ +/* +Copyright 2021 The Flux authors + +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 main + +import ( + "github.com/spf13/cobra" +) + +var buildCmd = &cobra.Command{ + Use: "build", + Short: "Build a flux resource", + Long: "The build command is used to build flux resources.", +} + +func init() { + rootCmd.AddCommand(buildCmd) +} diff --git a/cmd/flux/build_kustomization.go b/cmd/flux/build_kustomization.go new file mode 100644 index 00000000..d4d8a8a0 --- /dev/null +++ b/cmd/flux/build_kustomization.go @@ -0,0 +1,80 @@ +/* +Copyright 2021 The Flux authors + +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 main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/fluxcd/flux2/internal/kustomization" + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta2" +) + +var buildKsCmd = &cobra.Command{ + Use: "kustomization", + Aliases: []string{"ks"}, + Short: "Build Kustomization", + Long: `The build command queries the Kubernetes API and fetches the specified Flux Kustomization, +then it uses the specified files or path to build the overlay to write the resulting multi-doc YAML to stdout.`, + Example: `# Create a new overlay. +flux build kustomization my-app --resources ./path/to/local/manifests`, + ValidArgsFunction: resourceNamesCompletionFunc(kustomizev1.GroupVersion.WithKind(kustomizev1.KustomizationKind)), + RunE: buildKsCmdRun, +} + +type buildKsFlags struct { + resources string +} + +var buildKsArgs buildKsFlags + +func init() { + buildKsCmd.Flags().StringVar(&buildKsArgs.resources, "resources", "", "Name of a file containing a file to add to the kustomization file.)") + buildCmd.AddCommand(buildKsCmd) +} + +func buildKsCmdRun(cmd *cobra.Command, args []string) error { + if len(args) < 1 { + return fmt.Errorf("%s name is required", kustomizationType.humanKind) + } + name := args[0] + + if buildKsArgs.resources == "" { + return fmt.Errorf("invalid resource path %q", buildKsArgs.resources) + } + + if fs, err := os.Stat(buildKsArgs.resources); err != nil || !fs.IsDir() { + return fmt.Errorf("invalid resource path %q", buildKsArgs.resources) + } + + builder, err := kustomization.NewBuilder(rootArgs.kubeconfig, rootArgs.kubecontext, rootArgs.namespace, name, buildKsArgs.resources, kustomization.WithTimeout(rootArgs.timeout)) + if err != nil { + return err + } + + manifests, err := builder.Build() + if err != nil { + return err + } + + cmd.Print(string(manifests)) + + return nil + +} diff --git a/go.mod b/go.mod index 4bfb0a14..86cb5883 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/go-git/go-git/v5 v5.4.2 github.com/google/go-cmp v0.5.6 github.com/google/go-containerregistry v0.2.0 + github.com/hashicorp/go-retryablehttp v0.7.0 // indirect github.com/manifoldco/promptui v0.9.0 github.com/mattn/go-shellwords v1.0.12 github.com/olekukonko/tablewriter v0.0.4 diff --git a/go.sum b/go.sum index 17b4f820..dd909e60 100644 --- a/go.sum +++ b/go.sum @@ -192,6 +192,8 @@ github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5Xh github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/drone/envsubst v1.0.3-0.20200804185402-58bc65f69603 h1:PMzPM0wCHDrXlO7TlEq5lqlhGKHEgSnUR4YMSEVKrQ0= +github.com/drone/envsubst v1.0.3-0.20200804185402-58bc65f69603/go.mod h1:N2jZmlMufstn1KEqvbHjw40h1KyTmnVzHcSc9bFiJ2g= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153 h1:yUdfgN0XgIJw7foRItutHYUIhlcKzcSf5vDpdhQAKTc= @@ -449,8 +451,9 @@ github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iP github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-retryablehttp v0.6.8 h1:92lWxgpa+fF3FozM4B3UZtHZMJX8T5XT+TFdCxsPyWs= github.com/hashicorp/go-retryablehttp v0.6.8/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= +github.com/hashicorp/go-retryablehttp v0.7.0 h1:eu1EI/mbirUgP5C8hVsTNaGZreBDlYiwC1FZWkvQPQ4= +github.com/hashicorp/go-retryablehttp v0.7.0/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= diff --git a/internal/kustomization/build.go b/internal/kustomization/build.go new file mode 100644 index 00000000..4b5279a4 --- /dev/null +++ b/internal/kustomization/build.go @@ -0,0 +1,228 @@ +/* +Copyright 2021 The Flux authors + +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 kustomization + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/fluxcd/flux2/internal/utils" + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta2" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/kustomize/api/konfig" + "sigs.k8s.io/kustomize/api/resmap" + "sigs.k8s.io/kustomize/api/resource" + "sigs.k8s.io/kustomize/kyaml/filesys" +) + +const mask string = "**SOPS**" + +var defaultTimeout = 80 * time.Second + +// Builder builds yaml manifests +// It retrieves the kustomization object from the k8s cluster +// and overlays the manifests with the resources specified in the resourcesPath +type Builder struct { + client client.WithWatch + name string + namespace string + resourcesPath string + kustomization *kustomizev1.Kustomization + timeout time.Duration +} + +type BuilderOptionFunc func(b *Builder) error + +func WithTimeout(timeout time.Duration) BuilderOptionFunc { + return func(b *Builder) error { + b.timeout = timeout + return nil + } +} + +// NewBuilder returns a new Builder +// to dp : create functional options +func NewBuilder(kubeconfig string, kubecontext string, namespace, name, resources string, opts ...BuilderOptionFunc) (*Builder, error) { + kubeClient, err := utils.KubeClient(kubeconfig, kubecontext) + if err != nil { + return nil, err + } + + b := &Builder{ + client: kubeClient, + name: name, + namespace: namespace, + resourcesPath: resources, + } + + for _, opt := range opts { + if err := opt(b); err != nil { + return nil, err + } + } + + if b.timeout == 0 { + b.timeout = defaultTimeout + } + + return b, nil +} + +func (b *Builder) getKustomization(ctx context.Context) (*kustomizev1.Kustomization, error) { + namespacedName := types.NamespacedName{ + Namespace: b.namespace, + Name: b.name, + } + + k := &kustomizev1.Kustomization{} + err := b.client.Get(ctx, namespacedName, k) + if err != nil { + return nil, err + } + + return k, nil +} + +// Build builds the yaml manifests from the kustomization object +// and overlays the manifests with the resources specified in the resourcesPath +// It expects a kustomization.yaml file in the resourcesPath, and it will +// generate a kustomization.yaml file if it doesn't exist +func (b *Builder) Build() ([]byte, error) { + m, err := b.build() + if err != nil { + return nil, err + } + + resources, err := m.AsYaml() + if err != nil { + return nil, fmt.Errorf("kustomize build failed: %w", err) + } + + return resources, nil +} + +func (b *Builder) build() (resmap.ResMap, error) { + ctx, cancel := context.WithTimeout(context.Background(), b.timeout) + defer cancel() + + // Get the kustomization object + k, err := b.getKustomization(ctx) + if err != nil { + return nil, err + } + + // generate kustomization.yaml if needed + saved, err := b.generate(*k, b.resourcesPath) + if err != nil { + return nil, fmt.Errorf("failed to generate kustomization.yaml: %w", err) + } + + // build the kustomization + m, err := b.do(ctx, *k, b.resourcesPath) + if err != nil { + return nil, err + } + + // make sure secrets are masked + for _, res := range m.Resources() { + err := trimSopsData(res) + if err != nil { + return nil, err + } + } + + // store the kustomization object + b.kustomization = k + + // restore the kustomization.yaml + err = restore(saved, b.resourcesPath) + if err != nil { + return nil, fmt.Errorf("failed to restore kustomization.yaml: %w", err) + } + + return m, nil + +} + +func (b *Builder) generate(kustomization kustomizev1.Kustomization, dirPath string) ([]byte, error) { + gen := NewGenerator(&kustomizeImpl{kustomization}) + return gen.WriteFile(dirPath) +} + +func (b *Builder) do(ctx context.Context, kustomization kustomizev1.Kustomization, dirPath string) (resmap.ResMap, error) { + fs := filesys.MakeFsOnDisk() + m, err := buildKustomization(fs, dirPath) + if err != nil { + return nil, fmt.Errorf("kustomize build failed: %w", err) + } + + for _, res := range m.Resources() { + // run variable substitutions + if kustomization.Spec.PostBuild != nil { + outRes, err := substituteVariables(ctx, b.client, &kustomizeImpl{kustomization}, res) + if err != nil { + return nil, fmt.Errorf("var substitution failed for '%s': %w", res.GetName(), err) + } + + if outRes != nil { + _, err = m.Replace(res) + if err != nil { + return nil, err + } + } + } + } + + return m, nil +} + +func trimSopsData(res *resource.Resource) error { + // sopsMess is the base64 encoded mask + sopsMess := base64.StdEncoding.EncodeToString([]byte(mask)) + + if res.GetKind() == "Secret" { + dataMap := res.GetDataMap() + for k, v := range dataMap { + data, err := base64.StdEncoding.DecodeString(v) + if err != nil { + fmt.Println(fmt.Errorf("failed to decode secret data: %w", err)) + } + + if bytes.Contains(data, []byte("sops")) { + dataMap[k] = sopsMess + } + } + res.SetDataMap(dataMap) + } + + return nil +} + +func restore(saved []byte, dirPath string) error { + kfile := filepath.Join(dirPath, konfig.DefaultKustomizationFileName()) + err := os.WriteFile(kfile, saved, 0644) + if err != nil { + return fmt.Errorf("failed to restore kustomization.yaml: %w", err) + } + return nil +} diff --git a/internal/kustomization/build_test.go b/internal/kustomization/build_test.go new file mode 100644 index 00000000..7834e108 --- /dev/null +++ b/internal/kustomization/build_test.go @@ -0,0 +1,120 @@ +/* +Copyright 2021 The Flux authors + +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 kustomization + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "sigs.k8s.io/kustomize/api/resource" + "sigs.k8s.io/kustomize/kyaml/yaml" +) + +func TestSanitizeResources(t *testing.T) { + testCases := []struct { + name string + yamlStr string + expected string + }{ + { + name: "secret with sops token", + yamlStr: `apiVersion: v1 +kind: Secret +metadata: + name: my-secret +type: Opaque +data: + token: | + ewoJImRhdGEiOiAiRU5DW0FFUzI1Nl9HQ00sZGF0YTpvQmU1UGxQbWZRQ1VVYzRzcUtJbW + p3PT0saXY6TUxMRVcxNVFDOWtSZFZWYWdKbnpMQ1NrMHhaR1dJcEFlVGZIenl4VDEwZz0s + dGFnOkszR2tCQ0dTK3V0NFRwazZuZGIwQ0E9PSx0eXBlOnN0cl0iLAoJInNvcHMiOiB7Cg + kJImttcyI6IG51bGwsCgkJImdjcF9rbXMiOiBudWxsLAoJCSJhenVyZV9rdiI6IG51bGws + CgkJImhjX3ZhdWx0IjogbnVsbCwKCQkiYWdlIjogWwoJCQl7CgkJCQkicmVjaXBpZW50Ij + ogImFnZTEwbGEyZ2Uwd3R2eDNxcjdkYXRxZjdyczR5bmd4c3pkYWw5MjdmczlydWthbXI4 + dTJwc2hzdnR6N2NlIiwKCQkJCSJlbmMiOiAiLS0tLS1CRUdJTiBBR0UgRU5DUllQVEVEIE + ZJTEUtLS0tLVxuWVdkbExXVnVZM0o1Y0hScGIyNHViM0puTDNZeENpMCtJRmd5TlRVeE9T + QTFMMlJwWkhScksxRlNWbVlyZDFWYVxuWTBoeFdGUXpTREJzVDFrM1dqTnRZbVUxUW1saW + FESnljWGxOQ25GMVlqZE5PVGhWYlZOdk1HOXJOUzlaVVhad1xuTW5WMGJuUlVNR050ZWpG + UGJ6TTRVMlV6V2tzemVWa0tMUzB0SUdKNlVHaHhNVVYzWW1WSlRIbEpTVUpwUlZSWlxuVm + pkMFJWUmFkVTh3ZWt4WFRISXJZVXBsWWtOMmFFRUswSS9NQ0V0WFJrK2IvTjJHMUpGM3ZI + UVQyNGRTaFdZRFxudytKSVVTQTNhTGYyc3YwenIyTWRVRWRWV0JKb004blQ0RDR4VmJCT1 + JEKzY2OVcrOW5EZVN3PT1cbi0tLS0tRU5EIEFHRSBFTkNSWVBURUQgRklMRS0tLS0tXG4i + CgkJCX0KCQldLAoJCSJsYXN0bW9kaWZpZWQiOiAiMjAyMS0xMS0yNlQxNjozNDo1MVoiLA + oJCSJtYWMiOiAiRU5DW0FFUzI1Nl9HQ00sZGF0YTpDT0d6ZjVZQ0hOTlA2ejRKYUVLcmpO + M004ZjUrUTF1S1VLVE1Id2ozODgvSUNtTHlpMnNTclRtajdQUCtYN005alRWd2E4d1ZnWV + RwTkxpVkp4K0xjeHF2SVhNMFR5bysvQ3UxenJmYW85OGFpQUNQOCtUU0VEaUZRTnRFdXMy + M0grZC9YMWhxTXdSSERJM2tRKzZzY2dFR25xWTU3cjNSRFNBM0U4RWhIcjQ9LGl2Okx4aX + RWSVltOHNyWlZxRnVlSmg5bG9DbEE0NFkyWjNYQVZZbXhlc01tT2c9LHRhZzpZOHFGRDhV + R2xEZndOU3Y3eGxjbjZBPT0sdHlwZTpzdHJdIiwKCQkicGdwIjogbnVsbCwKCQkidW5lbm + NyeXB0ZWRfc3VmZml4IjogIl91bmVuY3J5cHRlZCIsCgkJInZlcnNpb24iOiAiMy43LjEi + Cgl9Cn0= +`, + expected: `apiVersion: v1 +data: + token: KipTT1BTKio= +kind: Secret +metadata: + name: my-secret +type: Opaque +`, + }, + { + name: "secret with basic auth", + yamlStr: `apiVersion: v1 +kind: Secret +metadata: + name: secret-basic-auth +type: kubernetes.io/basic-auth +data: + username: admin + password: password +`, + expected: `apiVersion: v1 +data: + password: password + username: admin +kind: Secret +metadata: + name: secret-basic-auth +type: kubernetes.io/basic-auth +`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + r, err := yaml.Parse(tc.yamlStr) + if err != nil { + t.Fatalf("unable to parse yaml: %v", err) + } + + resource := &resource.Resource{RNode: *r} + err = trimSopsData(resource) + if err != nil { + t.Fatalf("unable to trim sops data: %v", err) + } + + sYaml, err := resource.AsYAML() + if err != nil { + t.Fatalf("unable to convert sanitized resources to yaml: %v", err) + } + if diff := cmp.Diff(string(sYaml), tc.expected); diff != "" { + t.Errorf("unexpected sanitized resources: (-got +want)%v", diff) + } + }) + } +} diff --git a/internal/kustomization/kustomization.go b/internal/kustomization/kustomization.go new file mode 100644 index 00000000..537d0011 --- /dev/null +++ b/internal/kustomization/kustomization.go @@ -0,0 +1,84 @@ +/* +Copyright 2021 The Flux authors + +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 kustomization + +import ( + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta2" + "github.com/fluxcd/pkg/apis/kustomize" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Kustomize defines the methods to retrieve the kustomization information +// TO DO @souleb: move this to fluxcd/pkg along with generator and varsub +type Kustomize interface { + client.Object + GetTargetNamespace() string + GetPatches() []kustomize.Patch + GetPatchesStrategicMerge() []apiextensionsv1.JSON + GetPatchesJSON6902() []kustomize.JSON6902Patch + GetImages() []kustomize.Image + GetSubstituteFrom() []SubstituteReference + GetSubstitute() map[string]string +} + +// SubstituteReference contains a reference to a resource containing +// the variables name and value. +type SubstituteReference struct { + Kind string `json:"kind"` + Name string `json:"name"` +} + +// TO DO @souleb: this is a temporary hack to get the kustomize object +// from the kustomize controller. +// At some point we should remove this and have the kustomize controller implement +// the Kustomize interface. +type kustomizeImpl struct { + kustomizev1.Kustomization +} + +func (k *kustomizeImpl) GetTargetNamespace() string { + return k.Spec.TargetNamespace +} + +func (k *kustomizeImpl) GetPatches() []kustomize.Patch { + return k.Spec.Patches +} + +func (k *kustomizeImpl) GetPatchesStrategicMerge() []apiextensionsv1.JSON { + return k.Spec.PatchesStrategicMerge +} + +func (k *kustomizeImpl) GetPatchesJSON6902() []kustomize.JSON6902Patch { + return k.Spec.PatchesJSON6902 +} + +func (k *kustomizeImpl) GetImages() []kustomize.Image { + return k.Spec.Images +} + +func (k *kustomizeImpl) GetSubstituteFrom() []SubstituteReference { + refs := make([]SubstituteReference, 0, len(k.Spec.PostBuild.SubstituteFrom)) + for _, s := range k.Spec.PostBuild.SubstituteFrom { + refs = append(refs, SubstituteReference(s)) + } + return refs +} + +func (k *kustomizeImpl) GetSubstitute() map[string]string { + return k.Spec.PostBuild.Substitute +} diff --git a/internal/kustomization/kustomization_generator.go b/internal/kustomization/kustomization_generator.go new file mode 100644 index 00000000..829c3bdf --- /dev/null +++ b/internal/kustomization/kustomization_generator.go @@ -0,0 +1,258 @@ +/* +Copyright 2021 The Flux authors + +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 kustomization + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "sigs.k8s.io/kustomize/api/konfig" + "sigs.k8s.io/kustomize/api/krusty" + "sigs.k8s.io/kustomize/api/provider" + "sigs.k8s.io/kustomize/api/resmap" + kustypes "sigs.k8s.io/kustomize/api/types" + "sigs.k8s.io/kustomize/kyaml/filesys" + "sigs.k8s.io/yaml" + + "github.com/fluxcd/pkg/apis/kustomize" +) + +type KustomizeGenerator struct { + kustomization Kustomize +} + +func NewGenerator(kustomization Kustomize) *KustomizeGenerator { + return &KustomizeGenerator{ + kustomization: kustomization, + } +} + +// WriteFile generates a kustomization.yaml in the given directory if it does not exist. +// It apply the flux kustomize resources to the kustomization.yaml and then write the +// updated kustomization.yaml to the directory. +// It returns the original kustomization.yaml. +func (kg *KustomizeGenerator) WriteFile(dirPath string) ([]byte, error) { + if err := kg.generateKustomization(dirPath); err != nil { + return nil, err + } + + kfile := filepath.Join(dirPath, konfig.DefaultKustomizationFileName()) + + data, err := os.ReadFile(kfile) + if err != nil { + return nil, err + } + + kus := kustypes.Kustomization{ + TypeMeta: kustypes.TypeMeta{ + APIVersion: kustypes.KustomizationVersion, + Kind: kustypes.KustomizationKind, + }, + } + + if err := yaml.Unmarshal(data, &kus); err != nil { + return nil, err + } + + if kg.kustomization.GetTargetNamespace() != "" { + kus.Namespace = kg.kustomization.GetTargetNamespace() + } + + for _, m := range kg.kustomization.GetPatches() { + kus.Patches = append(kus.Patches, kustypes.Patch{ + Patch: m.Patch, + Target: adaptSelector(&m.Target), + }) + } + + for _, m := range kg.kustomization.GetPatchesStrategicMerge() { + kus.PatchesStrategicMerge = append(kus.PatchesStrategicMerge, kustypes.PatchStrategicMerge(m.Raw)) + } + + for _, m := range kg.kustomization.GetPatchesJSON6902() { + patch, err := json.Marshal(m.Patch) + if err != nil { + return nil, err + } + kus.PatchesJson6902 = append(kus.PatchesJson6902, kustypes.Patch{ + Patch: string(patch), + Target: adaptSelector(&m.Target), + }) + } + + for _, image := range kg.kustomization.GetImages() { + newImage := kustypes.Image{ + Name: image.Name, + NewName: image.NewName, + NewTag: image.NewTag, + } + if exists, index := checkKustomizeImageExists(kus.Images, image.Name); exists { + kus.Images[index] = newImage + } else { + kus.Images = append(kus.Images, newImage) + } + } + + manifest, err := yaml.Marshal(kus) + if err != nil { + return nil, err + } + + os.WriteFile(kfile, manifest, 0644) + + return data, nil +} + +func checkKustomizeImageExists(images []kustypes.Image, imageName string) (bool, int) { + for i, image := range images { + if imageName == image.Name { + return true, i + } + } + + return false, -1 +} + +func (kg *KustomizeGenerator) generateKustomization(dirPath string) error { + fs := filesys.MakeFsOnDisk() + + // Determine if there already is a Kustomization file at the root, + // as this means we do not have to generate one. + for _, kfilename := range konfig.RecognizedKustomizationFileNames() { + if kpath := filepath.Join(dirPath, kfilename); fs.Exists(kpath) && !fs.IsDir(kpath) { + return nil + } + } + + scan := func(base string) ([]string, error) { + var paths []string + pvd := provider.NewDefaultDepProvider() + rf := pvd.GetResourceFactory() + err := fs.Walk(base, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if path == base { + return nil + } + if info.IsDir() { + // If a sub-directory contains an existing kustomization file add the + // directory as a resource and do not decend into it. + for _, kfilename := range konfig.RecognizedKustomizationFileNames() { + if kpath := filepath.Join(path, kfilename); fs.Exists(kpath) && !fs.IsDir(kpath) { + paths = append(paths, path) + return filepath.SkipDir + } + } + return nil + } + + extension := filepath.Ext(path) + if extension != ".yaml" && extension != ".yml" { + return nil + } + + fContents, err := fs.ReadFile(path) + if err != nil { + return err + } + + if _, err := rf.SliceFromBytes(fContents); err != nil { + return fmt.Errorf("failed to decode Kubernetes YAML from %s: %w", path, err) + } + paths = append(paths, path) + return nil + }) + return paths, err + } + + abs, err := filepath.Abs(dirPath) + if err != nil { + return err + } + + files, err := scan(abs) + if err != nil { + return err + } + + kfile := filepath.Join(dirPath, konfig.DefaultKustomizationFileName()) + f, err := fs.Create(kfile) + if err != nil { + return err + } + f.Close() + + kus := kustypes.Kustomization{ + TypeMeta: kustypes.TypeMeta{ + APIVersion: kustypes.KustomizationVersion, + Kind: kustypes.KustomizationKind, + }, + } + + var resources []string + for _, file := range files { + resources = append(resources, strings.Replace(file, abs, ".", 1)) + } + + kus.Resources = resources + kd, err := yaml.Marshal(kus) + if err != nil { + return err + } + + return os.WriteFile(kfile, kd, os.ModePerm) +} + +func adaptSelector(selector *kustomize.Selector) (output *kustypes.Selector) { + if selector != nil { + output = &kustypes.Selector{} + output.Gvk.Group = selector.Group + output.Gvk.Kind = selector.Kind + output.Gvk.Version = selector.Version + output.Name = selector.Name + output.Namespace = selector.Namespace + output.LabelSelector = selector.LabelSelector + output.AnnotationSelector = selector.AnnotationSelector + } + return +} + +// TODO: remove mutex when kustomize fixes the concurrent map read/write panic +var kustomizeBuildMutex sync.Mutex + +// buildKustomization wraps krusty.MakeKustomizer with the following settings: +// - load files from outside the kustomization.yaml root +// - disable plugins except for the builtin ones +func buildKustomization(fs filesys.FileSystem, dirPath string) (resmap.ResMap, error) { + // temporary workaround for concurrent map read and map write bug + // https://github.com/kubernetes-sigs/kustomize/issues/3659 + kustomizeBuildMutex.Lock() + defer kustomizeBuildMutex.Unlock() + + buildOptions := &krusty.Options{ + LoadRestrictions: kustypes.LoadRestrictionsNone, + PluginConfig: kustypes.DisabledPluginConfig(), + } + + k := krusty.MakeKustomizer(buildOptions) + return k.Run(fs, dirPath) +} diff --git a/internal/kustomization/kustomization_varsub.go b/internal/kustomization/kustomization_varsub.go new file mode 100644 index 00000000..5a893eb9 --- /dev/null +++ b/internal/kustomization/kustomization_varsub.go @@ -0,0 +1,119 @@ +/* +Copyright 2021 The Flux authors + +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 kustomization + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/drone/envsubst" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/kustomize/api/resource" + "sigs.k8s.io/yaml" +) + +const ( + // varsubRegex is the regular expression used to validate + // the var names before substitution + varsubRegex = "^[_[:alpha:]][_[:alpha:][:digit:]]*$" + DisabledValue = "disabled" +) + +// substituteVariables replaces the vars with their values in the specified resource. +// If a resource is labeled or annotated with +// 'kustomize.toolkit.fluxcd.io/substitute: disabled' the substitution is skipped. +func substituteVariables( + ctx context.Context, + kubeClient client.Client, + kustomization Kustomize, + res *resource.Resource) (*resource.Resource, error) { + resData, err := res.AsYAML() + if err != nil { + return nil, err + } + + key := fmt.Sprintf("%s/substitute", kustomization.GetObjectKind().GroupVersionKind().Group) + + if res.GetLabels()[key] == DisabledValue || res.GetAnnotations()[key] == DisabledValue { + return nil, nil + } + + vars := make(map[string]string) + + // load vars from ConfigMaps and Secrets data keys + for _, reference := range kustomization.GetSubstituteFrom() { + namespacedName := types.NamespacedName{Namespace: kustomization.GetNamespace(), Name: reference.Name} + switch reference.Kind { + case "ConfigMap": + resource := &corev1.ConfigMap{} + if err := kubeClient.Get(ctx, namespacedName, resource); err != nil { + return nil, fmt.Errorf("substitute from 'ConfigMap/%s' error: %w", reference.Name, err) + } + for k, v := range resource.Data { + vars[k] = strings.Replace(v, "\n", "", -1) + } + case "Secret": + resource := &corev1.Secret{} + if err := kubeClient.Get(ctx, namespacedName, resource); err != nil { + return nil, fmt.Errorf("substitute from 'Secret/%s' error: %w", reference.Name, err) + } + for k, v := range resource.Data { + vars[k] = strings.Replace(string(v), "\n", "", -1) + } + } + } + + // load in-line vars (overrides the ones from resources) + if kustomization.GetSubstitute() != nil { + for k, v := range kustomization.GetSubstitute() { + vars[k] = strings.Replace(v, "\n", "", -1) + } + } + + // run bash variable substitutions + if len(vars) > 0 { + r, _ := regexp.Compile(varsubRegex) + for v := range vars { + if !r.MatchString(v) { + return nil, fmt.Errorf("'%s' var name is invalid, must match '%s'", v, varsubRegex) + } + } + + output, err := envsubst.Eval(string(resData), func(s string) string { + return vars[s] + }) + if err != nil { + return nil, fmt.Errorf("variable substitution failed: %w", err) + } + + jsonData, err := yaml.YAMLToJSON([]byte(output)) + if err != nil { + return nil, fmt.Errorf("YAMLToJSON: %w", err) + } + + err = res.UnmarshalJSON(jsonData) + if err != nil { + return nil, fmt.Errorf("UnmarshalJSON: %w", err) + } + } + + return res, nil +}