diff --git a/cmd/manageDeploymentRepo.go b/cmd/manageDeploymentRepo.go new file mode 100644 index 0000000..ba3fc8a --- /dev/null +++ b/cmd/manageDeploymentRepo.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "fmt" + + "github.com/go-logr/logr" + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/runtime" + controllerruntime "sigs.k8s.io/controller-runtime" + + "github.com/openmcp-project/bootstrapper/internal/config" + + "github.com/openmcp-project/bootstrapper/internal/util" + + deploymentrepo "github.com/openmcp-project/bootstrapper/internal/deployment-repo" + "github.com/openmcp-project/bootstrapper/internal/log" +) + +const ( + FlagGitConfig = "git-config" + FlagOcmConfig = "ocm-config" + FlagKubeConfig = "kubeconfig" +) + +type LogWriter struct{} + +func (w LogWriter) Write(p []byte) (n int, err error) { + logger := log.GetLogger() + logger.Debugf("Git progress: %s", string(p)) + return len(p), nil +} + +// manageDeploymentRepoCmd represents the manageDeploymentRepo command +var manageDeploymentRepoCmd = &cobra.Command{ + Use: "manageDeploymentRepo", + Short: "Updates the openMCP deployment specification in the specified Git repository", + Long: `Updates the openMCP deployment specification in the specified Git repository. +The update is based on the specified component version. +openmcp-bootstrapper manageDeploymentRepo `, + Args: cobra.ExactArgs(1), + ArgAliases: []string{ + "configFile", + }, + Example: ` openmcp-bootstrapper manageDeploymentRepo "./config.yaml"`, + RunE: func(cmd *cobra.Command, args []string) error { + configFilePath := args[0] + + // disable controller-runtime logging + controllerruntime.SetLogger(logr.Discard()) + + targetCluster, err := util.GetCluster(cmd.Flag(FlagKubeConfig).Value.String(), "target-cluster", runtime.NewScheme()) + if err != nil { + return fmt.Errorf("failed to get platform cluster: %w", err) + } + + config := &config.BootstrapperConfig{} + err = config.ReadFromFile(configFilePath) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + config.SetDefaults() + err = config.Validate() + if err != nil { + return fmt.Errorf("invalid config file: %w", err) + } + + deploymentRepoManager, err := deploymentrepo.NewDeploymentRepoManager( + config, + targetCluster, + cmd.Flag(FlagGitConfig).Value.String(), + cmd.Flag(FlagOcmConfig).Value.String(), + ).Initialize(cmd.Context()) + + defer func() { + deploymentRepoManager.Cleanup() + }() + + if err != nil { + return fmt.Errorf("failed to initialize deployment repo manager: %w", err) + } + + err = deploymentRepoManager.ApplyTemplates(cmd.Context()) + if err != nil { + return fmt.Errorf("failed to apply templates: %w", err) + } + + err = deploymentRepoManager.ApplyProviders(cmd.Context()) + if err != nil { + return fmt.Errorf("failed to apply providers: %w", err) + } + + err = deploymentRepoManager.ApplyCustomResourceDefinitions(cmd.Context()) + if err != nil { + return fmt.Errorf("failed to apply custom resource definitions: %w", err) + } + + err = deploymentRepoManager.UpdateResourcesKustomization() + if err != nil { + return fmt.Errorf("failed to update resources kustomization: %w", err) + } + + err = deploymentRepoManager.CommitAndPushChanges(cmd.Context()) + if err != nil { + return fmt.Errorf("failed to commit and push changes: %w", err) + } + + err = deploymentRepoManager.RunKustomizeAndApply(cmd.Context()) + if err != nil { + return fmt.Errorf("failed to run kustomize and apply: %w", err) + } + + return nil + }, +} + +func init() { + RootCmd.AddCommand(manageDeploymentRepoCmd) + manageDeploymentRepoCmd.Flags().SortFlags = false + manageDeploymentRepoCmd.Flags().String(FlagOcmConfig, "", "ocm configuration file") + manageDeploymentRepoCmd.Flags().String(FlagGitConfig, "", "Git configuration file") + manageDeploymentRepoCmd.Flags().String(FlagKubeConfig, "", "Kubernetes configuration file") + + if err := manageDeploymentRepoCmd.MarkFlagRequired(FlagGitConfig); err != nil { + panic(err) + } +} diff --git a/go.mod b/go.mod index c8f667f..89862ae 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,12 @@ module github.com/openmcp-project/bootstrapper go 1.25.1 require ( + github.com/Masterminds/sprig/v3 v3.3.0 + github.com/fluxcd/kustomize-controller/api v1.6.1 + github.com/fluxcd/pkg/apis/meta v1.12.0 + github.com/go-git/go-billy/v5 v5.6.2 github.com/go-git/go-git/v5 v5.16.2 + github.com/go-logr/logr v1.4.3 github.com/openmcp-project/controller-utils v0.19.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.10.1 @@ -12,14 +17,19 @@ require ( k8s.io/apimachinery v0.34.0 k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d sigs.k8s.io/controller-runtime v0.22.1 + sigs.k8s.io/kustomize/api v0.20.1 + sigs.k8s.io/kustomize/kyaml v0.20.1 sigs.k8s.io/yaml v1.6.0 ) require ( - dario.cat/mergo v1.0.0 // indirect + dario.cat/mergo v1.0.1 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect @@ -27,11 +37,11 @@ require ( github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fluxcd/pkg/apis/kustomize v1.12.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-errors/errors v1.4.2 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.6.2 // indirect - github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect @@ -41,14 +51,18 @@ require ( github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -58,10 +72,13 @@ require ( github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/skeema/knownhosts v1.3.1 // indirect + github.com/spf13/cast v1.7.0 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/xlab/treeprint v1.2.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.41.0 // indirect diff --git a/go.sum b/go.sum index 876a69f..d0aee20 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ -dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= -dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= @@ -13,6 +17,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= @@ -34,12 +40,22 @@ github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fluxcd/kustomize-controller/api v1.6.1 h1:8AOD+BWQwCLT+u5jgtGryZWkCeslgr+cnQAfUzgWmGk= +github.com/fluxcd/kustomize-controller/api v1.6.1/go.mod h1:b0i/KVz28tV8iuqlNHx7MW6ZtTcIbBELGLoKdaK+X8M= +github.com/fluxcd/pkg/apis/kustomize v1.12.0 h1:KvZN6xwgP/dNSeckL4a/Uv715XqiN1C3xS+jGcPejtE= +github.com/fluxcd/pkg/apis/kustomize v1.12.0/go.mod h1:OojLxIdKm1JAAdh3sL4j4F+vfrLKb7kq1vr8bpyEKgg= +github.com/fluxcd/pkg/apis/meta v1.12.0 h1:XW15TKZieC2b7MN8VS85stqZJOx+/b8jATQ/xTUhVYg= +github.com/fluxcd/pkg/apis/meta v1.12.0/go.mod h1:+son1Va60x2eiDcTwd7lcctbI6C+K3gM7R+ULmEq1SI= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= @@ -77,6 +93,8 @@ github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pI github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -102,12 +120,18 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/onsi/ginkgo/v2 v2.25.2 h1:hepmgwx1D+llZleKQDMEvy8vIlCxMGt7W5ZxDjIEhsw= @@ -136,11 +160,15 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= +github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= @@ -158,6 +186,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= @@ -262,6 +292,10 @@ sigs.k8s.io/controller-runtime v0.22.1 h1:Ah1T7I+0A7ize291nJZdS1CabF/lB4E++WizgV sigs.k8s.io/controller-runtime v0.22.1/go.mod h1:FwiwRjkRPbiN+zp2QRp7wlTCzbUXxZ/D4OzuQUDwBHY= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I= +sigs.k8s.io/kustomize/api v0.20.1/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM= +sigs.k8s.io/kustomize/kyaml v0.20.1 h1:PCMnA2mrVbRP3NIB6v9kYCAc38uvFLVs8j/CD567A78= +sigs.k8s.io/kustomize/kyaml v0.20.1/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..90f878d --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,100 @@ +package config + +import ( + "encoding/json" + "os" + + "k8s.io/apimachinery/pkg/util/validation/field" + "sigs.k8s.io/yaml" +) + +type BootstrapperConfig struct { + Component Component `json:"component"` + DeploymentRepository DeploymentRepository `json:"repository"` + Providers Providers `json:"providers"` + ImagePullSecrets []string `json:"imagePullSecrets"` + OpenMCPOperator OpenMCPOperator `json:"openmcpOperator"` + Environment string `json:"environment"` +} + +type Component struct { + OpenMCPComponentLocation string `json:"location"` + OpenMCPOperatorTemplateResourcePath string `json:"openmcpOperatorTemplateResourcePath"` + FluxcdTemplateResourcePath string `json:"fluxcdTemplateResourcePath"` +} + +type DeploymentRepository struct { + RepoURL string `json:"url"` + RepoBranch string `json:"branch"` +} + +type TargetCluster struct { + KubeconfigPath string `json:"kubeconfigPath"` +} + +type Providers struct { + ClusterProviders []string `json:"clusterProviders"` + ServiceProviders []string `json:"serviceProviders"` + PlatformServices []string `json:"platformServices"` +} + +type OpenMCPOperator struct { + Config json.RawMessage `json:"config"` + ConfigParsed map[string]interface{} +} + +type Manifest struct { + Name string `json:"name"` + Manifest json.RawMessage `json:"manifest"` + ManifestParsed map[string]interface{} +} + +func (c *BootstrapperConfig) ReadFromFile(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + + return yaml.Unmarshal(data, c) +} + +func (c *BootstrapperConfig) SetDefaults() { + if len(c.Component.FluxcdTemplateResourcePath) == 0 { + c.Component.FluxcdTemplateResourcePath = "gitops-templates/fluxcd" + } + + if len(c.Component.OpenMCPOperatorTemplateResourcePath) == 0 { + c.Component.OpenMCPOperatorTemplateResourcePath = "gitops-templates/openmcp" + } +} + +func (c *BootstrapperConfig) Validate() error { + errs := field.ErrorList{} + + if len(c.Environment) == 0 { + errs = append(errs, field.Required(field.NewPath("environment"), "environment is required")) + } + + if len(c.Component.OpenMCPComponentLocation) == 0 { + errs = append(errs, field.Required(field.NewPath("component.location"), "component location is required")) + } + + if len(c.DeploymentRepository.RepoURL) == 0 { + errs = append(errs, field.Required(field.NewPath("repository.url"), "repository url is required")) + } + + if len(c.DeploymentRepository.RepoBranch) == 0 { + errs = append(errs, field.Required(field.NewPath("repository.branch"), "repository branch is required")) + } + + if len(c.OpenMCPOperator.Config) == 0 { + errs = append(errs, field.Required(field.NewPath("openmcpOperator.config"), "openmcp operator config is required")) + } + + err := yaml.Unmarshal(c.OpenMCPOperator.Config, &c.OpenMCPOperator.ConfigParsed) + if err != nil { + errs = append(errs, field.Invalid(field.NewPath("openmcpOperator.config"), string(c.OpenMCPOperator.Config), "openmcp operator config is not valid yaml")) + } + + return errs.ToAggregate() +} diff --git a/internal/deployment-repo/deploymentRepoManager.go b/internal/deployment-repo/deploymentRepoManager.go new file mode 100644 index 0000000..756186a --- /dev/null +++ b/internal/deployment-repo/deploymentRepoManager.go @@ -0,0 +1,505 @@ +package deploymentrepo + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-git/v5" + "github.com/openmcp-project/controller-utils/pkg/clusters" + "sigs.k8s.io/kustomize/api/krusty" + "sigs.k8s.io/kustomize/kyaml/filesys" + + "github.com/openmcp-project/bootstrapper/internal/config" + + gitconfig "github.com/openmcp-project/bootstrapper/internal/git-config" + "github.com/openmcp-project/bootstrapper/internal/log" + ocmcli "github.com/openmcp-project/bootstrapper/internal/ocm-cli" + "github.com/openmcp-project/bootstrapper/internal/util" +) + +const ( + OpenMCPOperatorComponentName = "openmcp-operator" + FluxCDSourceControllerResourceName = "fluxcd-source-controller" + FluxCDKustomizationControllerResourceName = "fluxcd-kustomize-controller" + FluxCDHelmControllerResourceName = "fluxcd-helm-controller" + + EnvsDirectoryName = "envs" + ResourcesDirectoryName = "resources" + OpenMCPDirectoryName = "openmcp" + FluxCDDirectoryName = "fluxcd" + CRDsDirectoryName = "crds" +) + +// DeploymentRepoManager manages the deployment repository by applying templates and committing changes. +type DeploymentRepoManager struct { + // Vars set via constructor or "With" methods + + // GitConfigPath is the path to the Git configuration file + GitConfigPath string + // OcmConfigPath is the path to the OCM configuration file + // +optional + OcmConfigPath string + + Config *config.BootstrapperConfig + + // TargetCluster is the Kubernetes cluster to which the deployment will be applied + TargetCluster *clusters.Cluster + + // Internals + // workDir is a temporary directory used for processing + workDir string + // templatesDir is the directory into which the templates resource are downloaded + // (a subdirectory of workDir) + templatesDir string + // gitRepoDir is the directory into which the deployment repository is cloned + // (a subdirectory of workDir) + gitRepoDir string + + // compGetter is the OCM component getter used to fetch components and resources + compGetter *ocmcli.ComponentGetter + // gitConfig is the parsed Git configuration + gitConfig *gitconfig.Config + // gitRepo is the cloned Git repository + gitRepo *git.Repository + // openMCPOperatorCV is the component version of the openmcp-operator component + openMCPOperatorCV *ocmcli.ComponentVersion + // fluxcdCV is the component version of the fluxcd source controller component + fluxcdCV *ocmcli.ComponentVersion + // crdFiles is a list of CRD files downloaded from the openmcp-operator component + crdFiles []string +} + +// NewDeploymentRepoManager creates a new DeploymentRepoManager with the specified parameters. +func NewDeploymentRepoManager(config *config.BootstrapperConfig, targetCluster *clusters.Cluster, gitConfigPath, ocmConfigPath string) *DeploymentRepoManager { + return &DeploymentRepoManager{ + Config: config, + TargetCluster: targetCluster, + GitConfigPath: gitConfigPath, + OcmConfigPath: ocmConfigPath, + } +} + +// Initialize initializes the DeploymentRepoManager by setting up working directories, downloading components and templates, and cloning the deployment repository. +func (m *DeploymentRepoManager) Initialize(ctx context.Context) (*DeploymentRepoManager, error) { + var err error + + logger := log.GetLogger() + + m.workDir, err = util.CreateTempDir() + if err != nil { + return m, fmt.Errorf("failed to create working directory for deployment repository: %w", err) + } + + logger.Tracef("Created working dir: %s", m.workDir) + + m.templatesDir = filepath.Join(m.workDir, "templates") + m.gitRepoDir = filepath.Join(m.workDir, "repo") + + err = os.Mkdir(m.templatesDir, 0o755) + if err != nil { + return m, fmt.Errorf("failed to create working directory: %w", err) + } + + logger.Tracef("Created template dir: %s", m.templatesDir) + + err = os.Mkdir(m.gitRepoDir, 0o755) + if err != nil { + return m, fmt.Errorf("failed to create template directory: %w", err) + } + + logger.Tracef("Created Git repo dir: %s", m.gitRepoDir) + + logger.Infof("Downloading component %s", m.Config.Component.OpenMCPComponentLocation) + + m.compGetter = ocmcli.NewComponentGetter(m.Config.Component.OpenMCPComponentLocation, m.Config.Component.FluxcdTemplateResourcePath, m.OcmConfigPath) + err = m.compGetter.InitializeComponents(ctx) + if err != nil { + return m, fmt.Errorf("failed to initialize components: %w", err) + } + + logger.Info("Creating template transformer") + + templateTransformer := NewTemplateTransformer(m.compGetter, m.Config.Component.FluxcdTemplateResourcePath, m.Config.Component.OpenMCPOperatorTemplateResourcePath, m.workDir) + err = templateTransformer.Transform(ctx, m.Config.Environment, m.templatesDir) + if err != nil { + return m, fmt.Errorf("failed to transform templates: %w", err) + } + + logger.Infof("Fetching openmcp-operator component version") + + m.openMCPOperatorCV, err = m.compGetter.GetReferencedComponentVersionRecursive(ctx, m.compGetter.RootComponentVersion(), OpenMCPOperatorComponentName) + if err != nil { + return m, fmt.Errorf("failed to get openmcp-operator component version: %w", err) + } + + m.fluxcdCV, err = m.compGetter.GetComponentVersionForResourceRecursive(ctx, m.compGetter.RootComponentVersion(), FluxCDSourceControllerResourceName) + if err != nil { + return m, fmt.Errorf("failed to get fluxcd source controller component version: %w", err) + } + + m.gitConfig, err = gitconfig.ParseConfig(m.GitConfigPath) + if err != nil { + return m, fmt.Errorf("failed to parse git config: %w", err) + } + err = m.gitConfig.Validate() + if err != nil { + return m, fmt.Errorf("invalid git config: %w", err) + } + + logger.Infof("Cloning deployment repository %s", m.Config.DeploymentRepository.RepoURL) + + m.gitRepo, err = CloneRepo(m.Config.DeploymentRepository.RepoURL, m.gitRepoDir, m.gitConfig) + if err != nil { + return m, fmt.Errorf("failed to clone deployment repository: %w", err) + } + + logger.Infof("Checking out or creating branch %s", m.Config.DeploymentRepository.RepoBranch) + + err = CheckoutAndCreateBranchIfNotExists(m.gitRepo, m.Config.DeploymentRepository.RepoBranch, m.gitConfig) + if err != nil { + return m, fmt.Errorf("failed to checkout or create branch %s: %w", m.Config.DeploymentRepository.RepoBranch, err) + } + + return m, nil +} + +// Cleanup removes temporary directories created during processing. +func (m *DeploymentRepoManager) Cleanup() { + if m.workDir != "" { + log.GetLogger().Tracef("Removing working dir: %s", m.workDir) + if err := os.RemoveAll(m.workDir); err != nil { + log.GetLogger().Warnf("Failed to working dir %s: %v", m.workDir, err) + } + } +} + +// ApplyTemplates applies the templates from the templates directory to the deployment repository. +func (m *DeploymentRepoManager) ApplyTemplates(ctx context.Context) error { + logger := log.GetLogger() + + logger.Infof("Applying templates from %s/%s to deployment repository", m.Config.Component.FluxcdTemplateResourcePath, m.Config.Component.OpenMCPOperatorTemplateResourcePath) + templateInput := make(map[string]interface{}) + + openMCPOperatorImageResources := m.openMCPOperatorCV.GetResourcesByType(ocmcli.OCIImageResourceType) + if len(openMCPOperatorImageResources) == 0 || openMCPOperatorImageResources[0].Access.ImageReference == nil { + return fmt.Errorf("no image resource found for openmcp-operator component version %s:%s", m.openMCPOperatorCV.Component.Name, m.openMCPOperatorCV.Component.Version) + } + + imageName, imageTag, imageDigest, err := util.ParseImageVersionAndTag(*openMCPOperatorImageResources[0].Access.ImageReference) + if err != nil { + return fmt.Errorf("failed to parse image reference %s: %w", *openMCPOperatorImageResources[0].Access.ImageReference, err) + } + + if len(m.Config.ImagePullSecrets) > 0 { + templateInput["imagePullSecrets"] = make([]map[string]string, 0, len(m.Config.ImagePullSecrets)) + for _, secret := range m.Config.ImagePullSecrets { + templateInput["imagePullSecrets"] = append(templateInput["imagePullSecrets"].([]map[string]string), map[string]string{ + "name": secret, + }) + } + } + + templateInput["openmcpOperator"] = map[string]interface{}{ + "version": m.openMCPOperatorCV.Component.Version, + "image": imageName, + "tag": imageTag, + "digest": imageDigest, + "imagePullSecrets": m.Config.ImagePullSecrets, + "environment": m.Config.Environment, + "config": m.Config.OpenMCPOperator.ConfigParsed, + } + + templateInput["fluxCDEnvPath"] = "./" + EnvsDirectoryName + "/" + m.Config.Environment + "/" + FluxCDDirectoryName + templateInput["gitRepoEnvBranch"] = m.Config.DeploymentRepository.RepoBranch + templateInput["fluxCDResourcesPath"] = "../../../" + ResourcesDirectoryName + "/" + FluxCDDirectoryName + templateInput["openMCPResourcesPath"] = "../../../" + ResourcesDirectoryName + "/" + OpenMCPDirectoryName + templateInput["git"] = map[string]interface{}{ + "repoUrl": m.Config.DeploymentRepository.RepoURL, + "mainBranch": m.Config.DeploymentRepository.RepoBranch, + } + + templateInput["images"] = make(map[string]interface{}) + + err = applyFluxCDTemplateInput(templateInput, m.fluxcdCV, FluxCDSourceControllerResourceName, "sourceController") + if err != nil { + return fmt.Errorf("failed to apply fluxcd source controller template input: %w", err) + } + + err = applyFluxCDTemplateInput(templateInput, m.fluxcdCV, FluxCDKustomizationControllerResourceName, "kustomizeController") + if err != nil { + return fmt.Errorf("failed to apply fluxcd kustomize controller template input: %w", err) + } + + err = applyFluxCDTemplateInput(templateInput, m.fluxcdCV, FluxCDHelmControllerResourceName, "helmController") + if err != nil { + return fmt.Errorf("failed to apply fluxcd helm controller template input: %w", err) + } + + err = TemplateDir(m.templatesDir, templateInput, m.gitRepo) + if err != nil { + return fmt.Errorf("failed to apply templates from directory %s: %w", m.templatesDir, err) + } + + /* + workTree, err := m.gitRepo.Worktree() + if err != nil { + return fmt.Errorf("failed to get worktree: %w", err) + } + + workTreePath := filepath.Join(ResourcesDirectoryName, OpenMCPDirectoryName, "extra") + + for _, manifest := range m.Config.OpenMCPOperator.Manifests { + workTreeFile := filepath.Join(workTreePath, manifest.Name+".yaml") + logger.Infof("Applying openmcp-operator manifest %s to deployment repository", manifest.Name) + + manifestRaw, err := yaml.Marshal(manifest.ManifestParsed) + if err != nil { + return fmt.Errorf("failed to marshal openmcp-operator manifest %s: %w", manifest.Name, err) + } + + err = os.MkdirAll(filepath.Join(m.gitRepoDir, workTreePath), 0755) + if err != nil { + return fmt.Errorf("failed to create directory %s in deployment repository: %w", workTreePath, err) + } + + err = os.WriteFile(filepath.Join(m.gitRepoDir, workTreeFile), manifestRaw, 0o644) + if err != nil { + return fmt.Errorf("failed to write openmcp-operator manifest %s to deployment repository: %w", manifest.Name, err) + } + _, err = workTree.Add(workTreePath) + if err != nil { + return fmt.Errorf("failed to add openmcp-operator manifest %s to git index: %w", manifest.Name, err) + } + } + */ + + return nil +} + +func applyFluxCDTemplateInput(templateInput map[string]interface{}, fluxcdCV *ocmcli.ComponentVersion, fluxResource, key string) error { + fluxSourceControllerImageResource, err := fluxcdCV.GetResource(fluxResource) + if err != nil { + return fmt.Errorf("failed to get fluxcd resource %s: %w", fluxResource, err) + } + imageName, imageTag, imageDigest, err := util.ParseImageVersionAndTag(*fluxSourceControllerImageResource.Access.ImageReference) + if err != nil { + return fmt.Errorf("failed to parse image reference %s: %w", *fluxSourceControllerImageResource.Access.ImageReference, err) + } + templateInput["images"].(map[string]interface{})[key] = map[string]interface{}{ + "version": imageTag, + "image": imageName, + "tag": imageTag, + "digest": imageDigest, + } + return nil +} + +// ApplyProviders applies the specified providers to the deployment repository. +func (m *DeploymentRepoManager) ApplyProviders(ctx context.Context) error { + logger := log.GetLogger() + + logger.Infof("Templating providers: clusterProviders=%v, serviceProviders=%v, platformServices=%v, imagePullSecrets=%v", + m.Config.Providers.ClusterProviders, m.Config.Providers.ServiceProviders, m.Config.Providers.PlatformServices, m.Config.ImagePullSecrets) + + err := TemplateProviders(ctx, m.Config.Providers.ClusterProviders, m.Config.Providers.ServiceProviders, m.Config.Providers.PlatformServices, m.Config.ImagePullSecrets, m.compGetter, m.gitRepo) + if err != nil { + return fmt.Errorf("failed to template providers: %w", err) + } + + return nil +} + +// ApplyCustomResourceDefinitions downloads and applies Custom Resource Definitions (CRDs) from the openmcp-operator component to the deployment repository. +// If the openmcp-operator component is not found, it skips this step. +func (m *DeploymentRepoManager) ApplyCustomResourceDefinitions(ctx context.Context) error { + logger := log.GetLogger() + + if m.openMCPOperatorCV == nil { + logger.Infof("No openmcp-operator component version found, skipping CRD application") + return nil + } + + logger.Infof("Applying Custom Resource Definitions to deployment repository") + + crdsDownloadDir := filepath.Join(m.gitRepoDir, ResourcesDirectoryName, OpenMCPDirectoryName, CRDsDirectoryName) + + // if the CRDs directory already exists, remove it to ensure a clean state + if _, err := os.Stat(crdsDownloadDir); err == nil { + err = os.RemoveAll(crdsDownloadDir) + if err != nil { + return fmt.Errorf("failed to remove existing CRD directory: %w", err) + } + } + + err := os.Mkdir(crdsDownloadDir, 0o755) + if err != nil { + return fmt.Errorf("failed to create CRD download directory: %w", err) + } + + err = m.compGetter.DownloadDirectoryResource(ctx, m.openMCPOperatorCV, "openmcp-operator-crds", crdsDownloadDir) + if err != nil { + return fmt.Errorf("failed to download CRD resource: %w", err) + } + + // List all YAML files in the CRDs download directory + entries, err := os.ReadDir(crdsDownloadDir) + if err != nil { + return fmt.Errorf("failed to read CRD download directory: %w", err) + } + + m.crdFiles = make([]string, 0) + for _, entry := range entries { + if !entry.IsDir() { + fileName := entry.Name() + // Check if file has .yaml or .yml extension + if filepath.Ext(fileName) == ".yaml" || filepath.Ext(fileName) == ".yml" { + filePath := filepath.Join(crdsDownloadDir, fileName) + m.crdFiles = append(m.crdFiles, filePath) + logger.Tracef("Added CRD file: %s", filePath) + } + } + } + + logger.Infof("Found %d CRD files", len(m.crdFiles)) + + workTree, err := m.gitRepo.Worktree() + if err != nil { + return fmt.Errorf("failed to get worktree: %w", err) + } + + _, err = workTree.Add(filepath.Join(ResourcesDirectoryName, OpenMCPDirectoryName, CRDsDirectoryName)) + if err != nil { + return fmt.Errorf("failed to add CRD files: %w", err) + } + + return nil +} + +func (m *DeploymentRepoManager) UpdateResourcesKustomization() error { + logger := log.GetLogger() + files := make([]string, 0, + len(m.Config.Providers.ClusterProviders)+ + len(m.Config.Providers.ServiceProviders)+ + len(m.Config.Providers.PlatformServices)+ + len(m.crdFiles)) + + // len(m.Config.OpenMCPOperator.Manifests)) + + for _, crdFile := range m.crdFiles { + files = append(files, filepath.Join(CRDsDirectoryName, filepath.Base(crdFile))) + } + + for _, clusterProvider := range m.Config.Providers.ClusterProviders { + files = append(files, filepath.Join("cluster-providers", clusterProvider+".yaml")) + } + + for _, serviceProvider := range m.Config.Providers.ServiceProviders { + files = append(files, filepath.Join("service-providers", serviceProvider+".yaml")) + } + + for _, platformService := range m.Config.Providers.PlatformServices { + files = append(files, filepath.Join("platform-services", platformService+".yaml")) + } + + /* + for _, manifest := range m.Config.OpenMCPOperator.Manifests { + files = append(files, filepath.Join("extra", manifest.Name+".yaml")) + } + */ + + // open resources root customization + resourcesRootKustomizationPath := filepath.Join(ResourcesDirectoryName, OpenMCPDirectoryName, "kustomization.yaml") + + workTree, err := m.gitRepo.Worktree() + if err != nil { + return fmt.Errorf("failed to get worktree: %w", err) + } + + fileInWorkTree, err := workTree.Filesystem.OpenFile(resourcesRootKustomizationPath, os.O_RDWR, 0644) + if err != nil { + return fmt.Errorf("failed to open file %s in worktree: %w", resourcesRootKustomizationPath, err) + } + + defer func(pathInRepo billy.File) { + err := pathInRepo.Close() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "failed to close file in worktree: %v\n", err) + } + }(fileInWorkTree) + + kustomization := &KubernetesKustomization{} + err = kustomization.ParseFromFile(fileInWorkTree) + if err != nil { + return fmt.Errorf("failed to parse resources root kustomization: %w", err) + } + + _, err = fileInWorkTree.Seek(0, 0) + if err != nil { + return fmt.Errorf("failed to seek resources root kustomization: %w", err) + } + + logger.Debugf("Adding files to resources root kustomization: %v", files) + kustomization.AddResources(files) + + err = kustomization.WriteToFile(fileInWorkTree) + if err != nil { + return fmt.Errorf("failed to write resources root kustomization: %w", err) + } + + if _, err = workTree.Add(resourcesRootKustomizationPath); err != nil { + return fmt.Errorf("failed to add resources root kustomization to git index: %w", err) + } + + return nil +} + +func (m *DeploymentRepoManager) RunKustomizeAndApply(ctx context.Context) error { + logger := log.GetLogger() + fs := filesys.MakeFsOnDisk() + opts := krusty.MakeDefaultOptions() + kustomizer := krusty.MakeKustomizer(opts) + + logger.Infof("Running kustomize on %s", filepath.Join(m.gitRepoDir, EnvsDirectoryName, m.Config.Environment)) + resourceMap, err := kustomizer.Run(fs, filepath.Join(m.gitRepoDir, EnvsDirectoryName, m.Config.Environment)) + if err != nil { + return fmt.Errorf("failed to run kustomize: %w", err) + } + resourcesYAML, err := resourceMap.AsYaml() + if err != nil { + return fmt.Errorf("failed to convert kustomized resources to YAML: %w", err) + } + + logger.Infof("Applying kustomized resources to target cluster") + err = util.ApplyManifests(ctx, m.TargetCluster, resourcesYAML) + if err != nil { + return fmt.Errorf("failed to apply kustomized resources to cluster: %w", err) + } + return nil +} + +// CommitAndPushChanges commits all changes in the deployment repository and pushes them to the remote repository. +// If there are no changes to commit, it does nothing. +func (m *DeploymentRepoManager) CommitAndPushChanges(_ context.Context) error { + logger := log.GetLogger() + + logger.Info("Committing and pushing changes to deployment repository") + + err := CommitChanges(m.gitRepo, "apply templates", "openmcp", "noreply@openmcp.cloud") + if err != nil { + return fmt.Errorf("failed to commit changes: %w", err) + } + + err = PushRepo(m.gitRepo, m.Config.DeploymentRepository.RepoBranch, m.gitConfig) + if err != nil { + return fmt.Errorf("failed to push changes to deployment repository: %w", err) + } + + return nil +} + +func (m *DeploymentRepoManager) GitRepoDir() string { + return m.gitRepoDir +} diff --git a/internal/deployment-repo/deploymentRepoManager_test.go b/internal/deployment-repo/deploymentRepoManager_test.go new file mode 100644 index 0000000..c6d2da8 --- /dev/null +++ b/internal/deployment-repo/deploymentRepoManager_test.go @@ -0,0 +1,124 @@ +package deploymentrepo_test + +/* +import ( + "path/filepath" + "testing" + + "github.com/go-git/go-git/v5" + "github.com/stretchr/testify/assert" + + deploymentrepo "github.com/openmcp-project/bootstrapper/internal/deployment-repo" + testutils "github.com/openmcp-project/bootstrapper/test/utils" +) + +func TestDeploymentRepoManager(t *testing.T) { + testutils.DownloadOCMAndAddToPath(t) + + // Component + ctfIn := testutils.BuildComponent("./testdata/01/component-constructor.yaml", t) + + // Git repository + originDir := t.TempDir() + + origin, err := git.PlainInit(originDir, false) + assert.NoError(t, err) + + originWorkTree, err := origin.Worktree() + assert.NoError(t, err) + assert.NotNil(t, originWorkTree) + + dummyFilePath := filepath.Join(originDir, "dummy.txt") + testutils.WriteToFile(t, dummyFilePath, "This is a dummy file.") + testutils.AddFileToWorkTree(t, originWorkTree, "dummy.txt") + testutils.WorkTreeCommit(t, originWorkTree, "Initial commit") + + // imagePullSecrets := []string{"imgpull-a", "imgpull-b"} + + // Configuration + config := &deploymentrepo.DeploymentRepoConfig{ + Component: deploymentrepo.Component{ + OpenMCPComponentLocation: ctfIn + "//github.com/openmcp-project/openmcp", + }, + Environment: "incoming", + DeploymentRepository: deploymentrepo.DeploymentRepository{ + RepoURL: originDir, + RepoBranch: "incoming", + }, + TargetCluster: deploymentrepo.TargetCluster{KubeconfigPath: ""}, + OpenMCPOperator: deploymentrepo.OpenMCPOperator{}, + Providers: deploymentrepo.Providers{ + ClusterProviders: []string{"test"}, + ServiceProviders: []string{"test"}, + PlatformServices: []string{"test"}, + }, + } + + deploymentRepoManager := deploymentrepo.NewDeploymentRepoManager(config, + "./testdata/01/git-config.yaml", "") + assert.NotNil(t, deploymentRepoManager) + + //nolint:all + //repo := deploymentRepoManager.GitRepoDir() + // + //err = deploymentRepoManager.ApplyTemplates(t.Context()) + //assert.NoError(t, err) + // + //kustomization, err := deploymentrepo.ParseKustomization(filepath.Join(repo, "envs", config.Environment, "kustomization.yaml")) + //assert.NoError(t, err) + //assert.NotNil(t, kustomization) + //assert.Contains(t, kustomization.Resources, "../resources") + //assert.Contains(t, kustomization.Images, kimage.Image{ + // Name: "", + // NewName: "ghcr.io/openmcp-project/images/openmcp-operator", + // NewTag: "v0.2.1", + //}) + // + //err = deploymentRepoManager.ApplyProviders(t.Context()) + //assert.NoError(t, err) + // + //clusterProviderTestRaw := testutils.ReadFromFile(t, filepath.Join(repo, "resources", "cluster-providers", "test.yaml")) + //var clusterProviderTest map[string]interface{} + //err = yaml.Unmarshal([]byte(clusterProviderTestRaw), &clusterProviderTest) + //assert.NoError(t, err) + //ValidateProvider(t, clusterProviderTest, "test", "ghcr.io/openmcp-project/images/cluster-provider-test:v0.1.0", []string{"imgpull-a", "imgpull-b"}) + // + //serviceProviderTestRaw := testutils.ReadFromFile(t, filepath.Join(repo, "resources", "service-providers", "test.yaml")) + //var serviceProviderTest map[string]interface{} + //err = yaml.Unmarshal([]byte(serviceProviderTestRaw), &serviceProviderTest) + //assert.NoError(t, err) + //ValidateProvider(t, serviceProviderTest, "test", "ghcr.io/openmcp-project/images/service-provider-test:v0.2.0", []string{"imgpull-a", "imgpull-b"}) + // + //platformServiceTestRaw := testutils.ReadFromFile(t, filepath.Join(repo, "resources", "platform-services", "test.yaml")) + //var platformServiceTest map[string]interface{} + //err = yaml.Unmarshal([]byte(platformServiceTestRaw), &platformServiceTest) + //assert.NoError(t, err) + //ValidateProvider(t, platformServiceTest, "test", "ghcr.io/openmcp-project/images/platform-service-test:v0.3.0", []string{"imgpull-a", "imgpull-b"}) + // + //err = deploymentRepoManager.ApplyCustomResourceDefinitions(t.Context()) + //assert.NoError(t, err) + // + //err = deploymentRepoManager.UpdateResourcesKustomization() + //assert.NoError(t, err) + // + //crdRaw := testutils.ReadFromFile(t, filepath.Join(repo, deploymentrepo.RepoResourcesDir, deploymentrepo.RepoCRDsDir, "crd.yaml")) + //assert.NotEmpty(t, crdRaw) + // + //err = deploymentRepoManager.CommitAndPushChanges(t.Context()) + //assert.NoError(t, err) + // + //err = originWorkTree.Checkout(&git.CheckoutOptions{Branch: plumbing.NewBranchReferenceName(config.DeploymentRepository.RepoBranch)}) + //assert.NoError(t, err) + // + //envsDir := filepath.Join(originDir, deploymentrepo.RepoEnvsDir, config.DeploymentRepository.RepoBranch) + //resourcesDir := filepath.Join(originDir, deploymentrepo.RepoResourcesDir) + // + //_ = testutils.ReadFromFile(t, filepath.Join(originDir, "dummy.txt")) + //_ = testutils.ReadFromFile(t, filepath.Join(envsDir, "kustomization.yaml")) + //_ = testutils.ReadFromFile(t, filepath.Join(resourcesDir, "cluster-providers", "test.yaml")) + //_ = testutils.ReadFromFile(t, filepath.Join(resourcesDir, "service-providers", "test.yaml")) + //_ = testutils.ReadFromFile(t, filepath.Join(resourcesDir, "platform-services", "test.yaml")) + //_ = testutils.ReadFromFile(t, filepath.Join(resourcesDir, "crds", "crd.yaml")) +} + +*/ diff --git a/internal/deployment-repo/kustomization.go b/internal/deployment-repo/kustomization.go new file mode 100644 index 0000000..2fd87b5 --- /dev/null +++ b/internal/deployment-repo/kustomization.go @@ -0,0 +1,101 @@ +package deploymentrepo + +import ( + "context" + "fmt" + "io" + + fluxk "github.com/fluxcd/kustomize-controller/api/v1" + "github.com/openmcp-project/controller-utils/pkg/clusters" + ktypes "sigs.k8s.io/kustomize/api/types" + "sigs.k8s.io/yaml" + + "github.com/openmcp-project/bootstrapper/internal/util" +) + +type KubernetesKustomization struct { + ktypes.Kustomization `yaml:",inline"` +} + +func (k *KubernetesKustomization) ParseFromFile(file io.Reader) error { + kustomizationRaw, err := io.ReadAll(file) + if err != nil { + return fmt.Errorf("failed to read kustomization file: %w", err) + } + + err = yaml.Unmarshal(kustomizationRaw, k) + if err != nil { + return fmt.Errorf("failed to unmarshal kustomization file: %w", err) + } + + return nil +} + +func (k *KubernetesKustomization) WriteToFile(file io.Writer) error { + kustomizationRaw, err := yaml.Marshal(k) + if err != nil { + return fmt.Errorf("failed to marshal kustomization file: %w", err) + } + + _, err = file.Write(kustomizationRaw) + if err != nil { + return fmt.Errorf("failed to write kustomization file: %w", err) + } + + return nil +} + +func (k *KubernetesKustomization) AddResource(resource string) { + if len(k.Resources) == 0 { + k.Resources = make([]string, 1) + } + k.Resources = append(k.Resources, resource) +} + +func (k *KubernetesKustomization) AddResources(resources []string) { + if len(k.Resources) == 0 { + k.Resources = make([]string, 0, len(resources)) + } + k.Resources = append(k.Resources, resources...) +} + +type FluxKustomization struct { + fluxk.Kustomization `yaml:",inline"` +} + +func (k *FluxKustomization) ParseFromFile(file io.Reader) error { + kustomizationRaw, err := io.ReadAll(file) + if err != nil { + return fmt.Errorf("failed to read kustomization file: %w", err) + } + + err = yaml.Unmarshal(kustomizationRaw, k) + if err != nil { + return fmt.Errorf("failed to unmarshal kustomization file: %w", err) + } + + return nil +} + +func (k *FluxKustomization) WriteToFile(file io.Writer) error { + kustomizationRaw, err := yaml.Marshal(k) + if err != nil { + return fmt.Errorf("failed to marshal kustomization file: %w", err) + } + + _, err = file.Write(kustomizationRaw) + if err != nil { + return fmt.Errorf("failed to write kustomization file: %w", err) + } + + return nil +} + +func (k *FluxKustomization) ApplyToCluster(ctx context.Context, cluster *clusters.Cluster) error { + kMarshaled, err := yaml.Marshal(k) + if err != nil { + return fmt.Errorf("failed to marshal kustomization file: %w", err) + } + + return util.ApplyManifests(ctx, cluster, kMarshaled) +} diff --git a/internal/deployment-repo/repo.go b/internal/deployment-repo/repo.go new file mode 100644 index 0000000..d4c390b --- /dev/null +++ b/internal/deployment-repo/repo.go @@ -0,0 +1,185 @@ +package deploymentrepo + +import ( + "errors" + "fmt" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/storer" + + gitconfig "github.com/openmcp-project/bootstrapper/internal/git-config" + "github.com/openmcp-project/bootstrapper/internal/log" +) + +// gitProgressWriter is a writer that logs Git progress messages. +type gitProgressWriter struct{} + +func (w gitProgressWriter) Write(p []byte) (n int, err error) { + logger := log.GetLogger() + logger.Tracef("[Git] %s", string(p)) + return len(p), nil +} + +// CloneRepo clones a Git repository from the specified URL to the given path. +// It uses the provided gitConfig to configure the clone options with authentication. +func CloneRepo(repoURL, path string, gitConfig *gitconfig.Config) (*git.Repository, error) { + logger := log.GetLogger() + + logger.Debugf("Cloning repository from %s to %s", repoURL, path) + + cloneOptions := &git.CloneOptions{ + URL: repoURL, + SingleBranch: false, + Progress: gitProgressWriter{}, + } + + if err := gitConfig.ConfigureCloneOptions(cloneOptions); err != nil { + return nil, err + } + + repo, err := git.PlainClone(path, false, cloneOptions) + if err != nil { + return nil, fmt.Errorf("failed to clone repository: %w", err) + } + + return repo, nil +} + +// PushRepo pushes the changes in the given repository to the remote. +// It uses the provided gitConfig to configure the push options with authentication. +func PushRepo(repo *git.Repository, branch string, gitConfig *gitconfig.Config) error { + logger := log.GetLogger() + + logger.Debug("Pushing changes to remote repository") + + pushOptions := &git.PushOptions{ + RefSpecs: []config.RefSpec{ + config.RefSpec(plumbing.HEAD + ":" + plumbing.NewBranchReferenceName(branch)), + }, + Progress: gitProgressWriter{}, + } + + if err := gitConfig.ConfigurePushOptions(pushOptions); err != nil { + return fmt.Errorf("failed to configure push options: %w", err) + } + + if err := repo.Push(pushOptions); err != nil { + if errors.Is(err, git.NoErrAlreadyUpToDate) { + logger.Info("No changes to push") + } else { + return fmt.Errorf("failed to push changes: %w", err) + } + } + + return nil +} + +// CommitChanges commits all changes in the repository with the specified message, author name, and email. +func CommitChanges(repo *git.Repository, message, name, email string) error { + logger := log.GetLogger() + + logger.Debugf("Committing changes with message: %s", message) + + workTree, err := repo.Worktree() + if err != nil { + return fmt.Errorf("failed to get worktree: %w", err) + } + + hash, err := workTree.Commit(message, &git.CommitOptions{ + Author: &object.Signature{ + Name: name, + Email: email, + When: time.Now(), + }, + }) + if err != nil { + if errors.Is(err, git.ErrEmptyCommit) { + logger.Info("No changes to commit") + } else { + return fmt.Errorf("failed to commit changes: %w", err) + } + } + + if !hash.IsZero() { + logger.Infof("Created commit: %s", hash.String()) + } + + return nil +} + +// CheckoutAndCreateBranchIfNotExists checks out a branch with the given name. +// If the branch does not exist, it creates a new branch with that name and pushes it +// to the remote repository. If the branch already exists, it checks out the existing branch. +func CheckoutAndCreateBranchIfNotExists(repo *git.Repository, branchName string, gitConfig *gitconfig.Config) error { + logger := log.GetLogger() + + branchExists := false + references, err := repo.References() + if err != nil { + return fmt.Errorf("failed to list references: %w", err) + } + + localRef := plumbing.NewBranchReferenceName(branchName) + remoteRef := plumbing.NewRemoteReferenceName("origin", branchName) + + err = references.ForEach(func(ref *plumbing.Reference) error { + logger.Trace("Found reference: ", ref.Name()) + if ref.Name() == localRef || ref.Name() == remoteRef { + branchExists = true + logger.Tracef("Branch %s exists as %s", branchName, ref.Name()) + return storer.ErrStop + } + return nil + }) + if err != nil { + return err + } + + workTree, err := repo.Worktree() + if err != nil { + return fmt.Errorf("failed to get worktree: %w", err) + } + + if !branchExists { + // Create and checkout new branch + logger.Debugf("Branch %s does not exist. Creating...\n", branchName) + err = workTree.Checkout(&git.CheckoutOptions{ + Branch: localRef, + Create: true, + }) + if err != nil { + return fmt.Errorf("failed to create branch: %w", err) + } + + pushOptions := &git.PushOptions{ + RefSpecs: []config.RefSpec{ + config.RefSpec(localRef + ":" + localRef), + }, + Progress: gitProgressWriter{}, + } + + if err := gitConfig.ConfigurePushOptions(pushOptions); err != nil { + return fmt.Errorf("failed to configure push options: %w", err) + } + + // Push new branch to remote + err = repo.Push(pushOptions) + if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { + return fmt.Errorf("failed to push new branch: %w", err) + } + } + + // Checkout existing branch + err = workTree.Checkout(&git.CheckoutOptions{ + Branch: remoteRef, + }) + if err != nil { + return fmt.Errorf("failed to checkout branch: %w", err) + } + + return nil +} diff --git a/internal/deployment-repo/repo_test.go b/internal/deployment-repo/repo_test.go new file mode 100644 index 0000000..f58285d --- /dev/null +++ b/internal/deployment-repo/repo_test.go @@ -0,0 +1,70 @@ +package deploymentrepo_test + +import ( + "path/filepath" + "testing" + + "github.com/go-git/go-git/v5" + "github.com/stretchr/testify/assert" + + deploymentrepo "github.com/openmcp-project/bootstrapper/internal/deployment-repo" + gitconfig "github.com/openmcp-project/bootstrapper/internal/git-config" + testutils "github.com/openmcp-project/bootstrapper/test/utils" +) + +func Test_Repo(t *testing.T) { + originDir := t.TempDir() + targetDir := t.TempDir() + + origin, err := git.PlainInit(originDir, false) + assert.NoError(t, err) + + originWorkTree, err := origin.Worktree() + assert.NoError(t, err) + assert.NotNil(t, originWorkTree) + + dummyFilePath := filepath.Join(originDir, "dummy.txt") + testutils.WriteToFile(t, dummyFilePath, "This is a dummy file.") + testutils.AddFileToWorkTree(t, originWorkTree, "dummy.txt") + testutils.WorkTreeCommit(t, originWorkTree, "Initial commit") + + gitConfig := &gitconfig.Config{} + + repo, err := deploymentrepo.CloneRepo(originDir, targetDir, gitConfig) + assert.NoError(t, err) + assert.NotNil(t, repo) + + repoWorkTree, err := repo.Worktree() + assert.NoError(t, err) + assert.NotNil(t, repoWorkTree) + + err = deploymentrepo.CheckoutAndCreateBranchIfNotExists(repo, "test", gitConfig) + assert.NoError(t, err) + + testFilePath := filepath.Join(targetDir, "test.txt") + testutils.WriteToFile(t, testFilePath, "This is a test file.") + testutils.AddFileToWorkTree(t, repoWorkTree, "test.txt") + + err = deploymentrepo.CommitChanges(repo, "Add test.txt", "Test User", "noreply@test") + assert.NoError(t, err) + + err = deploymentrepo.PushRepo(repo, "test", gitConfig) + assert.NoError(t, err) + + hasTestBranch := false + branchIter, err := origin.Branches() + assert.NoError(t, err) + for { + branch, err := branchIter.Next() + if err != nil { + break + } + t.Logf("Branch: %s", branch.Name().String()) + if branch.Name().Short() == "test" { + hasTestBranch = true + break + } + } + + assert.True(t, hasTestBranch, "Origin repository should have 'test' branch") +} diff --git a/internal/deployment-repo/template_transformer.go b/internal/deployment-repo/template_transformer.go new file mode 100644 index 0000000..a8f7ad3 --- /dev/null +++ b/internal/deployment-repo/template_transformer.go @@ -0,0 +1,265 @@ +package deploymentrepo + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + fluxk "github.com/fluxcd/kustomize-controller/api/v1" + fluxm "github.com/fluxcd/pkg/apis/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ktypes "sigs.k8s.io/kustomize/api/types" + "sigs.k8s.io/yaml" + + "github.com/openmcp-project/bootstrapper/internal/log" + ocmcli "github.com/openmcp-project/bootstrapper/internal/ocm-cli" + "github.com/openmcp-project/bootstrapper/internal/util" +) + +const ( + TemplatesDirectoryName = "templates" +) + +type TemplateTransformer struct { + ComponentGetter *ocmcli.ComponentGetter + FluxTemplateResourceLocation string + OpenMMCPTemplateResourceLocation string + InitializeRESTConfig string + WorkDir string +} + +func NewTemplateTransformer(componentGetter *ocmcli.ComponentGetter, fluxTemplateResourceLocation, openMMCPTemplateResourceLocation, workDir string) *TemplateTransformer { + return &TemplateTransformer{ + ComponentGetter: componentGetter, + FluxTemplateResourceLocation: fluxTemplateResourceLocation, + OpenMMCPTemplateResourceLocation: openMMCPTemplateResourceLocation, + WorkDir: workDir, + } +} + +func (t *TemplateTransformer) Transform(ctx context.Context, envName, targetDir string) error { + logger := log.GetLogger() + + downloadDir := filepath.Join(t.WorkDir, "transformer", "download") + logger.Tracef("Using download directory: %s", downloadDir) + err := os.MkdirAll(downloadDir, 0755) + if err != nil { + return fmt.Errorf("failed to create download directory: %w", err) + } + + // clean the targetDir if it already exists + err = os.RemoveAll(targetDir) + if err != nil { + return fmt.Errorf("failed to clean target directory: %w", err) + } + + err = os.MkdirAll(targetDir, 0755) + if err != nil { + return fmt.Errorf("failed to create target directory: %w", err) + } + + // Download template resources + logger.Infof("Downloading template resources") + + // download the fluxcd template resource to /fluxcd + fluxcdDownloadDir := filepath.Join(downloadDir, FluxCDDirectoryName) + logger.Debugf("Downloading fluxcd template resource to: %s", fluxcdDownloadDir) + err = t.ComponentGetter.DownloadDirectoryResourceByLocation(ctx, t.ComponentGetter.RootComponentVersion(), t.FluxTemplateResourceLocation, fluxcdDownloadDir) + if err != nil { + return fmt.Errorf("failed to download fluxcd template resource: %w", err) + } + + // download the openmcp template resource to /openmcp + openMMCPDownloadDir := filepath.Join(downloadDir, OpenMCPDirectoryName) + logger.Debugf("Downloading openmmcp template resource to: %s", openMMCPDownloadDir) + err = t.ComponentGetter.DownloadDirectoryResourceByLocation(ctx, t.ComponentGetter.RootComponentVersion(), t.OpenMMCPTemplateResourceLocation, openMMCPDownloadDir) + if err != nil { + return fmt.Errorf("failed to download openmmcp template resource: %w", err) + } + + // Create directory structure + logger.Info("Transforming templates into deployment repository structure") + + // create directory /envs//fluxcd and /envs//openmmcp + fluxCDEnvDir := filepath.Join(targetDir, EnvsDirectoryName, envName, FluxCDDirectoryName) + err = os.MkdirAll(fluxCDEnvDir, 0755) + if err != nil { + return fmt.Errorf("failed to create fluxcd environment directory: %w", err) + } + + openMMCPEnvDir := filepath.Join(targetDir, EnvsDirectoryName, envName, OpenMCPDirectoryName) + err = os.MkdirAll(openMMCPEnvDir, 0755) + if err != nil { + return fmt.Errorf("failed to create openmmcp environment directory: %w", err) + } + + // create directory /resources/fluxcd and /resources/openmcp + fluxCDResourcesDir := filepath.Join(targetDir, ResourcesDirectoryName, FluxCDDirectoryName) + err = os.MkdirAll(fluxCDResourcesDir, 0755) + if err != nil { + return fmt.Errorf("failed to create fluxcd resources directory: %w", err) + } + + openMMCPResourcesDir := filepath.Join(targetDir, ResourcesDirectoryName, OpenMCPDirectoryName) + err = os.MkdirAll(openMMCPResourcesDir, 0755) + if err != nil { + return fmt.Errorf("failed to create openmmcp resources directory: %w", err) + } + + // Copy files from downloaded templates to target directories + logger.Debug("Copying template files to target directories") + + // copy all files from /templates/overlays to /envs//fluxcd + err = util.CopyDir(filepath.Join(fluxcdDownloadDir, TemplatesDirectoryName, "overlays"), fluxCDEnvDir) + if err != nil { + return fmt.Errorf("failed to copy fluxcd overlays: %w", err) + } + + // copy all files from /templates/resources to /resources/fluxcd + err = util.CopyDir(filepath.Join(fluxcdDownloadDir, TemplatesDirectoryName, ResourcesDirectoryName), fluxCDResourcesDir) + if err != nil { + return fmt.Errorf("failed to copy fluxcd resources: %w", err) + } + + // copy all files from /templates/overlays to /envs//openmmcp + err = util.CopyDir(filepath.Join(openMMCPDownloadDir, TemplatesDirectoryName, "overlays"), openMMCPEnvDir) + if err != nil { + return fmt.Errorf("failed to copy openmmcp overlays: %w", err) + } + + // copy all files from /templates/resources to /resources/openmmcp + err = util.CopyDir(filepath.Join(openMMCPDownloadDir, TemplatesDirectoryName, ResourcesDirectoryName), openMMCPResourcesDir) + if err != nil { + return fmt.Errorf("failed to copy openmmcp resources: %w", err) + } + + // create the /envs//kustomization.yaml file + kustomizationDir := filepath.Join(targetDir, EnvsDirectoryName, envName) + kustomizationFile := filepath.Join(kustomizationDir, "kustomization.yaml") + logger.Debugf("Creating Kubernetes kustomization file %s", kustomizationFile) + err = writeKubernetesKustomization([]string{"../../" + ResourcesDirectoryName, OpenMCPDirectoryName}, []string{"root-kustomization.yaml"}, "kustomization", kustomizationDir) + if err != nil { + return fmt.Errorf("failed to write environment kustomization file: %w", err) + } + + // create the /envs//root-kustomization.yaml file + kustomizationDir = filepath.Join(targetDir, EnvsDirectoryName, envName) + kustomizationFile = filepath.Join(kustomizationDir, "root-kustomization.yaml") + logger.Debugf("Creating Flux kustomization patch file %s", kustomizationFile) + rootKustomization := map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "bootstrap", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "path": "./" + EnvsDirectoryName + "/" + envName, + }, + } + err = writeFluxKustomizationPatch(rootKustomization, "root-kustomization", kustomizationDir) + if err != nil { + return fmt.Errorf("failed to write root kustomization file: %w", err) + } + + // create the /resources/kustomization.yaml file + logger.Debugf("Creating Kubernetes kustomization file %s", filepath.Join(targetDir, ResourcesDirectoryName, "kustomization.yaml")) + err = writeKubernetesKustomization([]string{"root-kustomization.yaml"}, nil, "kustomization", filepath.Join(targetDir, ResourcesDirectoryName)) + if err != nil { + return fmt.Errorf("failed to write resources kustomization file: %w", err) + } + + // create the /resources/root-kustomization.yaml file + kustomizationDir = filepath.Join(targetDir, ResourcesDirectoryName) + kustomizationFile = filepath.Join(kustomizationDir, "root-kustomization.yaml") + logger.Debugf("Creating Flux kustomization file %s", kustomizationFile) + rootResourcesKustomization := &fluxk.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bootstrap", + Namespace: "default", + }, + Spec: fluxk.KustomizationSpec{ + Interval: metav1.Duration{Duration: 10 * time.Minute}, + Path: "", + Prune: true, + SourceRef: fluxk.CrossNamespaceSourceReference{ + Kind: "GitRepository", + Name: "environments", + Namespace: "flux-system", + }, + DependsOn: []fluxm.NamespacedObjectReference{ + { + Name: "flux-system", + Namespace: "flux-system", + }, + }, + }, + } + err = writeFluxKustomization(rootResourcesKustomization, "root-kustomization", kustomizationDir) + if err != nil { + return fmt.Errorf("failed to write root resources kustomization file: %w", err) + } + + return nil +} + +func writeKubernetesKustomization(resources, patches []string, name, targetDir string) error { + k := &KubernetesKustomization{ + Kustomization: ktypes.Kustomization{ + TypeMeta: ktypes.TypeMeta{ + APIVersion: ktypes.KustomizationVersion, + Kind: ktypes.KustomizationKind, + }, + Resources: resources, + Patches: make([]ktypes.Patch, 0, len(patches)), + }, + } + + for _, p := range patches { + k.Patches = append(k.Patches, ktypes.Patch{ + Path: p, + }) + } + + file, err := os.Create(filepath.Join(targetDir, name+".yaml")) + if err != nil { + return fmt.Errorf("failed to create kustomization file: %w", err) + } + + return k.WriteToFile(file) +} + +func writeFluxKustomization(kustomization *fluxk.Kustomization, name, targetDir string) error { + k := &FluxKustomization{ + Kustomization: *kustomization, + } + + k.TypeMeta = metav1.TypeMeta{ + APIVersion: fluxk.GroupVersion.String(), + Kind: "Kustomization", + } + + file, err := os.Create(filepath.Join(targetDir, name+".yaml")) + if err != nil { + return fmt.Errorf("failed to create kustomization file: %w", err) + } + + return k.WriteToFile(file) +} + +func writeFluxKustomizationPatch(kustomizationPatch map[string]interface{}, name, targetDir string) error { + kustomizationPatch["apiVersion"] = fluxk.GroupVersion.String() + kustomizationPatch["kind"] = "Kustomization" + + patchRaw, err := yaml.Marshal(kustomizationPatch) + if err != nil { + return fmt.Errorf("failed to marshal kustomization patch: %w", err) + } + + patchPath := filepath.Join(targetDir, name+".yaml") + err = os.WriteFile(patchPath, patchRaw, 0644) + if err != nil { + return fmt.Errorf("failed to write kustomization patch file: %w", err) + } + return nil +} diff --git a/internal/deployment-repo/templater.go b/internal/deployment-repo/templater.go new file mode 100644 index 0000000..4159e22 --- /dev/null +++ b/internal/deployment-repo/templater.go @@ -0,0 +1,285 @@ +package deploymentrepo + +import ( + "context" + _ "embed" + "fmt" + "os" + "path/filepath" + + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-git/v5" + + "github.com/openmcp-project/bootstrapper/internal/log" + ocmcli "github.com/openmcp-project/bootstrapper/internal/ocm-cli" + "github.com/openmcp-project/bootstrapper/internal/template" +) + +// TemplateDir processes the template files in the specified directory and writes +// the rendered content to the corresponding files in the Git repository's worktree. +// It uses the provided template directory and Git repository to perform the operations. +func TemplateDir(templateDirectory string, templateInput map[string]interface{}, repo *git.Repository) error { + logger := log.GetLogger() + + workTree, err := repo.Worktree() + if err != nil { + return fmt.Errorf("failed to get worktree: %w", err) + } + + templateDir, err := os.Open(templateDirectory) + if err != nil { + return fmt.Errorf("failed to open template directory: %w", err) + } + defer func() { + if err = templateDir.Close(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "failed to close template directory: %v\n", err) + } + }() + + te := template.NewTemplateExecution().WithMissingKeyOption("zero") + + // Recursively walk through all files in the template directory + err = filepath.WalkDir(templateDirectory, func(path string, d os.DirEntry, walkError error) error { + var ( + errInWalk error + + templateFromFile []byte + templateResult []byte + + relativePath string + fileInWorkTree billy.File + ) + + if walkError != nil { + return walkError + } + if !d.IsDir() { + relativePath, errInWalk = filepath.Rel(templateDirectory, path) + if errInWalk != nil { + return fmt.Errorf("failed to get relative path for %s: %w", path, errInWalk) + } + + logger.Debugf("Found template file: %s", relativePath) + + templateFromFile, errInWalk = os.ReadFile(path) + if errInWalk != nil { + return fmt.Errorf("failed to read template file %s: %w", relativePath, err) + } + + wrappedTemplateInput := map[string]interface{}{ + "Values": templateInput, + } + + templateResult, errInWalk = te.Execute(path, string(templateFromFile), wrappedTemplateInput) + if errInWalk != nil { + return fmt.Errorf("failed to execute template %s: %w", relativePath, errInWalk) + } + + fileInWorkTree, errInWalk = workTree.Filesystem.OpenFile(relativePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if errInWalk != nil { + return fmt.Errorf("failed to open file in worktree %s: %w", relativePath, errInWalk) + } + defer func(pathInRepo billy.File) { + err := pathInRepo.Close() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "failed to close file in worktree %s: %v\n", relativePath, err) + } + }(fileInWorkTree) + + _, errInWalk = fileInWorkTree.Write(templateResult) + if errInWalk != nil { + return fmt.Errorf("failed to write to file in worktree %s: %w", relativePath, errInWalk) + } + + // Add the file to the git index + if _, errInWalk = workTree.Add(relativePath); errInWalk != nil { + return fmt.Errorf("failed to add file to git index: %w", errInWalk) + } + } + return nil + }) + + if err != nil { + return fmt.Errorf("failed to walk template directory: %w", err) + } + + return nil +} + +var ( + //go:embed templates/clusterProvider.yaml + clusterProviderTemplate string + //go:embed templates/serviceProvider.yaml + serviceProviderTemplate string + //go:embed templates/platformService.yaml + platformServiceTemplate string +) + +type ProviderOptions struct { + Name string + Image string + ImagePullSecrets []string +} + +// TemplateProviders templates the specified cluster providers, service providers, and platform services +func TemplateProviders(ctx context.Context, clusterProviders, serviceProviders, platformServices, imagePullSecrets []string, ocmGetter *ocmcli.ComponentGetter, repo *git.Repository) error { + basePath := filepath.Join("resources", "openmcp") + clusterProvidersDir := filepath.Join(basePath, "cluster-providers") + serviceProvidersDir := filepath.Join(basePath, "service-providers") + platformServicesDir := filepath.Join(basePath, "platform-services") + + if _, err := os.Stat(clusterProvidersDir); err == nil { + err = os.RemoveAll(clusterProvidersDir) + if err != nil { + return fmt.Errorf("failed to remove existing cluster providers directory: %w", err) + } + } + + if _, err := os.Stat(serviceProvidersDir); err == nil { + err = os.RemoveAll(serviceProvidersDir) + if err != nil { + return fmt.Errorf("failed to remove existing service providers directory: %w", err) + } + } + + if _, err := os.Stat(platformServicesDir); err == nil { + err = os.RemoveAll(platformServicesDir) + if err != nil { + return fmt.Errorf("failed to remove existing platform services directory: %w", err) + } + } + + for _, cp := range clusterProviders { + componentVersion, err := ocmGetter.GetReferencedComponentVersionRecursive(ctx, ocmGetter.RootComponentVersion(), "cluster-provider-"+cp) + if err != nil { + return fmt.Errorf("failed to get component version for cluster provider %s: %w", cp, err) + } + + imageResource, err := getImageResource(componentVersion) + if err != nil { + return fmt.Errorf("failed to get image resource for cluster provider %s: %w", cp, err) + } + + opts := &ProviderOptions{ + Name: cp, + Image: *imageResource.Access.ImageReference, + ImagePullSecrets: imagePullSecrets, + } + + err = templateProvider(opts, clusterProviderTemplate, clusterProvidersDir, repo) + if err != nil { + return fmt.Errorf("failed to apply cluster provider %s: %w", cp, err) + } + } + + for _, sp := range serviceProviders { + componentVersion, err := ocmGetter.GetReferencedComponentVersionRecursive(ctx, ocmGetter.RootComponentVersion(), "service-provider-"+sp) + if err != nil { + return fmt.Errorf("failed to get component version for service provider %s: %w", sp, err) + } + + imageResource, err := getImageResource(componentVersion) + if err != nil { + return fmt.Errorf("failed to get image resource for service provider %s: %w", sp, err) + } + + opts := &ProviderOptions{ + Name: sp, + Image: *imageResource.Access.ImageReference, + ImagePullSecrets: imagePullSecrets, + } + + err = templateProvider(opts, serviceProviderTemplate, serviceProvidersDir, repo) + if err != nil { + return fmt.Errorf("failed to apply service provider %s: %w", sp, err) + } + } + + for _, ps := range platformServices { + componentVersion, err := ocmGetter.GetReferencedComponentVersionRecursive(ctx, ocmGetter.RootComponentVersion(), "platform-service-"+ps) + if err != nil { + return fmt.Errorf("failed to get component version for platform service %s: %w", ps, err) + } + + imageResource, err := getImageResource(componentVersion) + if err != nil { + return fmt.Errorf("failed to get image resource for platform service %s: %w", ps, err) + } + + opts := &ProviderOptions{ + Name: ps, + Image: *imageResource.Access.ImageReference, + ImagePullSecrets: imagePullSecrets, + } + + err = templateProvider(opts, platformServiceTemplate, platformServicesDir, repo) + if err != nil { + return fmt.Errorf("failed to apply platform service %s: %w", ps, err) + } + } + + return nil +} + +func getImageResource(cv *ocmcli.ComponentVersion) (*ocmcli.Resource, error) { + resources := cv.GetResourcesByType(ocmcli.OCIImageResourceType) + + if len(resources) > 0 { + return &resources[0], nil + } + + return nil, fmt.Errorf("image resource not found for component %s", cv.Component.Name) +} + +func templateProvider(options *ProviderOptions, templateSource, dir string, repo *git.Repository) error { + logger := log.GetLogger() + providerPath := filepath.Join(dir, options.Name+".yaml") + + logger.Debugf("Creating provider %s with image %s in path %s", options.Name, options.Image, providerPath) + + te := template.NewTemplateExecution() + templateInput := map[string]interface{}{ + "values": map[string]interface{}{ + "name": options.Name, + "image": map[string]interface{}{ + "location": options.Image, + "imagePullSecrets": options.ImagePullSecrets, + }, + }, + } + + workTree, err := repo.Worktree() + if err != nil { + return fmt.Errorf("failed to get worktree: %w", err) + } + + logger.Tracef("Template input: %v", templateInput) + + fileInWorkTree, err := workTree.Filesystem.OpenFile(providerPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("failed to open file %s in worktree: %w", providerPath, err) + } + + defer func(pathInRepo billy.File) { + err := pathInRepo.Close() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "failed to close file in worktree: %v\n", err) + } + }(fileInWorkTree) + + templateResult, err := te.Execute(providerPath, templateSource, templateInput) + if err != nil { + return fmt.Errorf("failed to execute templateSource for cluster provider %s: %w", options.Name, err) + } + + _, err = fileInWorkTree.Write(templateResult) + if err != nil { + return fmt.Errorf("failed to write to file %s in worktree: %w", providerPath, err) + } + + if _, err = workTree.Add(providerPath); err != nil { + return fmt.Errorf("failed to add provider %s file to git index: %w", providerPath, err) + } + + return nil +} diff --git a/internal/deployment-repo/templater_test.go b/internal/deployment-repo/templater_test.go new file mode 100644 index 0000000..a71c511 --- /dev/null +++ b/internal/deployment-repo/templater_test.go @@ -0,0 +1,105 @@ +package deploymentrepo_test + +/* +import ( + "path/filepath" + "testing" + + "github.com/go-git/go-git/v5" + "github.com/stretchr/testify/assert" + "sigs.k8s.io/yaml" + + deploymentrepo "github.com/openmcp-project/bootstrapper/internal/deployment-repo" + ocmcli "github.com/openmcp-project/bootstrapper/internal/ocm-cli" + testutils "github.com/openmcp-project/bootstrapper/test/utils" +) + +func TestTemplateDir(t *testing.T) { + templateDir := t.TempDir() + repoDir := t.TempDir() + + templateFilePath := filepath.Join(templateDir, "template.txt") + templateContent := "Hello, {{.values.name}}!" + testutils.WriteToFile(t, templateFilePath, templateContent) + + repo, err := git.PlainInit(repoDir, false) + assert.NoError(t, err) + assert.NotNil(t, repo) + + templateInput := map[string]interface{}{ + "name": "World", + } + + err = deploymentrepo.TemplateDir(templateDir, templateInput, repo) + assert.NoError(t, err) + + templateResult := testutils.ReadFromFile(t, filepath.Join(repoDir, "template.txt")) + assert.Equal(t, "Hello, World!", templateResult) + + workTree, err := repo.Worktree() + assert.NoError(t, err) + workTreeStatus, err := workTree.Status() + assert.NoError(t, err) + assert.False(t, workTreeStatus.IsClean()) + workTreeStatus.File("template.txt").Staging = git.Added +} + +func TestTemplateProviders(t *testing.T) { + testutils.DownloadOCMAndAddToPath(t) + + ctfIn := testutils.BuildComponent("./testdata/01/component-constructor.yaml", t) + + compGetter := ocmcli.NewComponentGetter(ctfIn+"//github.com/openmcp-project/openmcp", "deployment-templates/templates", ocmcli.NoOcmConfig) + assert.NotNil(t, compGetter) + + err := compGetter.InitializeComponents(t.Context()) + assert.NoError(t, err) + + repoDir := t.TempDir() + repo, err := git.PlainInit(repoDir, false) + assert.NoError(t, err) + + clusterProviders := []string{"test"} + serviceProviders := []string{"test"} + platformServices := []string{"test"} + imagePullSecrets := []string{"imgpull-a", "imgpull-b"} + + err = deploymentrepo.TemplateProviders(t.Context(), clusterProviders, serviceProviders, platformServices, imagePullSecrets, compGetter, repo) + assert.NoError(t, err) + + clusterProviderTestRaw := testutils.ReadFromFile(t, filepath.Join(repoDir, "cluster-providers", "test.yaml")) + var clusterProviderTest map[string]interface{} + err = yaml.Unmarshal([]byte(clusterProviderTestRaw), &clusterProviderTest) + assert.NoError(t, err) + ValidateProvider(t, clusterProviderTest, "test", "ghcr.io/openmcp-project/images/cluster-provider-test:v0.1.0", []string{"imgpull-a", "imgpull-b"}) + + serviceProviderTestRaw := testutils.ReadFromFile(t, filepath.Join(repoDir, "service-providers", "test.yaml")) + var serviceProviderTest map[string]interface{} + err = yaml.Unmarshal([]byte(serviceProviderTestRaw), &serviceProviderTest) + assert.NoError(t, err) + ValidateProvider(t, serviceProviderTest, "test", "ghcr.io/openmcp-project/images/service-provider-test:v0.2.0", []string{"imgpull-a", "imgpull-b"}) + + platformServiceTestRaw := testutils.ReadFromFile(t, filepath.Join(repoDir, "platform-services", "test.yaml")) + var platformServiceTest map[string]interface{} + err = yaml.Unmarshal([]byte(platformServiceTestRaw), &platformServiceTest) + assert.NoError(t, err) + ValidateProvider(t, platformServiceTest, "test", "ghcr.io/openmcp-project/images/platform-service-test:v0.3.0", []string{"imgpull-a", "imgpull-b"}) +} + +func ValidateProvider(t *testing.T, provider map[string]interface{}, name, image string, imagePullSecrets []string) { + assert.Contains(t, provider, "metadata") + assert.Contains(t, provider["metadata"], "name") + assert.Equal(t, name, provider["metadata"].(map[string]interface{})["name"]) + + assert.Contains(t, provider, "spec") + assert.Contains(t, provider["spec"], "image") + assert.Equal(t, image, provider["spec"].(map[string]interface{})["image"]) + assert.Contains(t, provider["spec"], "imagePullSecrets") + + imagePullSecretsList := provider["spec"].(map[string]interface{})["imagePullSecrets"].([]interface{}) + assert.Len(t, imagePullSecretsList, len(imagePullSecrets)) + for _, ips := range imagePullSecrets { + assert.Contains(t, imagePullSecretsList, map[string]interface{}{"name": ips}) + } +} +*/ diff --git a/internal/deployment-repo/templates/clusterProvider.yaml b/internal/deployment-repo/templates/clusterProvider.yaml new file mode 100644 index 0000000..184b399 --- /dev/null +++ b/internal/deployment-repo/templates/clusterProvider.yaml @@ -0,0 +1,21 @@ +apiVersion: openmcp.cloud/v1alpha1 +kind: ClusterProvider +metadata: + name: "{{ .values.name }}" +spec: + image: "{{ .values.image.location }}" +{{- if dig "image" "imagePullSecrets" "" .values }} + imagePullSecrets: +{{- range $index, $value := .values.image.imagePullSecrets }} + - name: "{{ $value }}" +{{- end }} +{{- end }} +{{- if dig "env" "" .values }} + env: +{{- range $key, $value := .values.env }} + {{ $key }}: {{ $value }} +{{- end }} +{{- end }} +{{- if dig "verbosity" "" .values }} + verbosity: {{ .values.verbosity }} +{{- end }} diff --git a/internal/deployment-repo/templates/platformService.yaml b/internal/deployment-repo/templates/platformService.yaml new file mode 100644 index 0000000..04e2e5e --- /dev/null +++ b/internal/deployment-repo/templates/platformService.yaml @@ -0,0 +1,21 @@ +apiVersion: openmcp.cloud/v1alpha1 +kind: PlatformService +metadata: + name: "{{ .values.name }}" +spec: + image: "{{ .values.image.location }}" +{{- if dig "image" "imagePullSecrets" "" .values }} + imagePullSecrets: +{{- range $index, $value := .values.image.imagePullSecrets }} + - name: "{{ $value }}" +{{- end }} +{{- end }} +{{- if dig "env" "" .values }} + env: +{{- range $key, $value := .values.env }} + {{ $key }}: {{ $value }} +{{- end }} +{{- end }} +{{- if dig "verbosity" "" .values }} + verbosity: {{ .values.verbosity }} +{{- end }} diff --git a/internal/deployment-repo/templates/serviceProvider.yaml b/internal/deployment-repo/templates/serviceProvider.yaml new file mode 100644 index 0000000..8b4857e --- /dev/null +++ b/internal/deployment-repo/templates/serviceProvider.yaml @@ -0,0 +1,21 @@ +apiVersion: openmcp.cloud/v1alpha1 +kind: ServiceProvider +metadata: + name: "{{ .values.name }}" +spec: + image: "{{ .values.image.location }}" +{{- if dig "image" "imagePullSecrets" "" .values }} + imagePullSecrets: +{{- range $index, $value := .values.image.imagePullSecrets }} + - name: "{{ $value }}" +{{- end }} +{{- end }} +{{- if dig "env" "" .values }} + env: +{{- range $key, $value := .values.env }} + {{ $key }}: {{ $value }} +{{- end }} +{{- end }} +{{- if dig "verbosity" "" .values }} + verbosity: {{ .values.verbosity }} +{{- end }} diff --git a/internal/deployment-repo/testdata/01/component-constructor.yaml b/internal/deployment-repo/testdata/01/component-constructor.yaml new file mode 100644 index 0000000..63bd47e --- /dev/null +++ b/internal/deployment-repo/testdata/01/component-constructor.yaml @@ -0,0 +1,98 @@ +components: + - name: github.com/openmcp-project/openmcp + version: v0.0.1 + provider: + name: openmcp-project + + componentReferences: + - componentName: github.com/openmcp-project/openmcp-operator + name: openmcp-operator + version: v0.2.1 + + - componentName: github.com/openmcp-project/cluster-provider-test + name: cluster-provider-test + version: v0.1.0 + + - componentName: github.com/openmcp-project/service-provider-test + name: service-provider-test + version: v0.2.0 + + - componentName: github.com/openmcp-project/platform-service-test + name: platform-service-test + version: v0.3.0 + + - componentName: github.com/openmcp-project/deployment-templates + name: deployment-templates + version: v0.1.1 + + - name: github.com/openmcp-project/openmcp-operator + version: v0.2.1 + provider: + name: openmcp-project + + resources: + - name: openmcp-operator-image + type: ociImage + version: v0.2.0 + access: + type: ociArtifact + imageReference: ghcr.io/openmcp-project/images/openmcp-operator:v0.2.1 + + - name: openmcp-operator-crds + type: fileSystem + version: v0.1.1 + input: + type: dir + path: ./crds + + - name: github.com/openmcp-project/cluster-provider-test + version: v0.1.0 + provider: + name: openmcp-project + + resources: + - name: cluster-provider-test-image + type: ociImage + version: v0.1.0 + access: + type: ociArtifact + imageReference: ghcr.io/openmcp-project/images/cluster-provider-test:v0.1.0 + + - name: github.com/openmcp-project/service-provider-test + version: v0.2.0 + provider: + name: openmcp-project + + resources: + - name: service-provider-test-image + type: ociImage + version: v0.2.0 + access: + type: ociArtifact + imageReference: ghcr.io/openmcp-project/images/service-provider-test:v0.2.0 + + - name: github.com/openmcp-project/platform-service-test + version: v0.3.0 + provider: + name: openmcp-project + + resources: + - name: service-provider-test-image + type: ociImage + version: v0.3.0 + access: + type: ociArtifact + imageReference: ghcr.io/openmcp-project/images/platform-service-test:v0.3.0 + + - name: github.com/openmcp-project/deployment-templates + version: v0.1.1 + provider: + name: openmcp-project + + resources: + - name: templates + type: fileSystem + version: v0.1.1 + input: + type: dir + path: ./templates \ No newline at end of file diff --git a/internal/deployment-repo/testdata/01/crds/crd.yaml b/internal/deployment-repo/testdata/01/crds/crd.yaml new file mode 100644 index 0000000..5eb8b80 --- /dev/null +++ b/internal/deployment-repo/testdata/01/crds/crd.yaml @@ -0,0 +1,5 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: test +spec: {} diff --git a/internal/deployment-repo/testdata/01/git-config.yaml b/internal/deployment-repo/testdata/01/git-config.yaml new file mode 100644 index 0000000..9bdd3dd --- /dev/null +++ b/internal/deployment-repo/testdata/01/git-config.yaml @@ -0,0 +1,4 @@ +auth: + basic: + username: user + password: pass \ No newline at end of file diff --git a/internal/deployment-repo/testdata/01/templates/overlays/kustomization.yaml b/internal/deployment-repo/testdata/01/templates/overlays/kustomization.yaml new file mode 100644 index 0000000..879b7cb --- /dev/null +++ b/internal/deployment-repo/testdata/01/templates/overlays/kustomization.yaml @@ -0,0 +1,13 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ../resources +images: + - name: + newName: {{.Values.openmcpOperator.image }} + newTag: {{.Values.openmcpOperator.tag | default "latest"}} +secretGenerator: + - name: openmcp-operator-config + namespace: openmcp-system + files: + - config=config/openmcp-operator-config.yaml \ No newline at end of file diff --git a/internal/deployment-repo/testdata/01/templates/resources/deployment.yaml b/internal/deployment-repo/testdata/01/templates/resources/deployment.yaml new file mode 100644 index 0000000..c75d128 --- /dev/null +++ b/internal/deployment-repo/testdata/01/templates/resources/deployment.yaml @@ -0,0 +1,95 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: openmcp-operator + name: openmcp-operator + namespace: openmcp-system +spec: + replicas: 1 + selector: + matchLabels: + app: openmcp-operator + template: + metadata: + labels: + app: openmcp-operator + spec: + initContainers: + - name: openmcp-init + image: :latest + args: + - init + - --environment=default + {{- if .Values.onboardingClusterKubeconfigSecretName }} + - --onboarding-cluster=/etc/secret/onboarding-cluster-kubeconfig/kubeconfig + {{- end }} + {{- if .Values.platformClusterKubeconfigSecretName }} + - --platform-cluster=/etc/secret/platform-cluster-kubeconfig/kubeconfig + {{- end }} + - --config=/etc/secret/openmcp-operator-config/config + volumeMounts: + - mountPath: /etc/secret/openmcp-operator-config + name: openmcp-operator-config + readOnly: true + {{- if .Values.onboardingClusterKubeconfigSecretName }} + - mountPath: /etc/secret/onboarding-cluster-kubeconfig + name: onboarding-cluster-kubeconfig + readOnly: true + {{- end }} + {{- if .Values.platformClusterKubeconfigSecretName }} + - mountPath: /etc/secret/platform-cluster-kubeconfig + name: platform-cluster-kubeconfig + readOnly: true + {{- end }} + containers: + - name: openmcp-operator + image: :latest + args: + - run + - --environment=default + {{- if .Values.onboardingClusterKubeconfigSecretName }} + - --onboarding-cluster=/etc/secret/onboarding-cluster-kubeconfig/kubeconfig + {{- end }} + {{- if .Values.platformClusterKubeconfigSecretName }} + - --platform-cluster=/etc/secret/platform-cluster-kubeconfig/kubeconfig + {{- end }} + - --config=/etc/secret/openmcp-operator-config/config + volumeMounts: + - mountPath: /etc/secret/openmcp-operator-config + name: openmcp-operator-config + readOnly: true + {{- if .Values.onboardingClusterKubeconfigSecretName }} + - mountPath: /etc/secret/onboarding-cluster-kubeconfig + name: onboarding-cluster-kubeconfig + readOnly: true + {{- end }} + {{- if .Values.platformClusterKubeconfigSecretName }} + - mountPath: /etc/secret/platform-cluster-kubeconfig + name: platform-cluster-kubeconfig + readOnly: true + {{- end }} + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 1000m + memory: 1024Mi + volumes: + - name: openmcp-operator-config + secret: + defaultMode: 420 + secretName: openmcp-operator-config + {{- if .Values.onboardingClusterKubeconfigSecretName }} + - name: onboarding-cluster-kubeconfig + secret: + defaultMode: 420 + secretName: {{.Values.onboardingClusterKubeconfigSecretName}} + {{- end }} + {{- if .Values.platformClusterKubeconfigSecretName }} + - name: platform-cluster-kubeconfig + secret: + defaultMode: 420 + secretName: {{.Values.platformClusterKubeconfigSecretName}} + {{- end }} \ No newline at end of file diff --git a/internal/deployment-repo/testdata/01/templates/resources/kustomization.yaml b/internal/deployment-repo/testdata/01/templates/resources/kustomization.yaml new file mode 100644 index 0000000..351be08 --- /dev/null +++ b/internal/deployment-repo/testdata/01/templates/resources/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - deployment.yaml \ No newline at end of file diff --git a/internal/deployment-repo/testdata/01/templates/resources/namespace.yaml b/internal/deployment-repo/testdata/01/templates/resources/namespace.yaml new file mode 100644 index 0000000..f83baa4 --- /dev/null +++ b/internal/deployment-repo/testdata/01/templates/resources/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: openmcp-system \ No newline at end of file diff --git a/internal/flux_deployer/deployer.go b/internal/flux_deployer/deployer.go index 253cf0d..d526276 100644 --- a/internal/flux_deployer/deployer.go +++ b/internal/flux_deployer/deployer.go @@ -1,22 +1,17 @@ package flux_deployer import ( - "bytes" "context" "fmt" - "io" "os" "path" "github.com/openmcp-project/controller-utils/pkg/clusters" "github.com/sirupsen/logrus" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/util/yaml" - "sigs.k8s.io/controller-runtime/pkg/client" ocmcli "github.com/openmcp-project/bootstrapper/internal/ocm-cli" "github.com/openmcp-project/bootstrapper/internal/template" + "github.com/openmcp-project/bootstrapper/internal/util" ) type FluxDeployer struct { @@ -126,7 +121,7 @@ func (d *FluxDeployer) DeployFluxControllers(ctx context.Context, rootComponentV // Apply d.log.Debug("Applying flux deployment objects") - if err := d.applyManifests(ctx, manifest); err != nil { + if err := util.ApplyManifests(ctx, d.platformCluster, manifest); err != nil { return err } @@ -170,7 +165,7 @@ func (d *FluxDeployer) establishFluxSync(ctx context.Context, downloadDir string // Apply d.log.Debug("Applying flux synchronization objects") - if err := d.applyManifests(ctx, manifest); err != nil { + if err := util.ApplyManifests(ctx, d.platformCluster, manifest); err != nil { return err } @@ -191,71 +186,3 @@ func (d *FluxDeployer) readFileContent(filepath string) ([]byte, error) { return content, nil } - -func (d *FluxDeployer) applyManifests(ctx context.Context, manifests []byte) error { - - // Parse manifests into unstructured objects - reader := bytes.NewReader(manifests) - unstructuredObjects, err := d.parseManifests(reader) - if err != nil { - return fmt.Errorf("error parsing manifests: %w", err) - } - - // Apply objects to the platform cluster - for _, u := range unstructuredObjects { - if err := d.applyUnstructuredObject(ctx, u); err != nil { - return err - } - } - - return nil -} - -func (d *FluxDeployer) parseManifests(reader io.Reader) ([]*unstructured.Unstructured, error) { - decoder := yaml.NewYAMLOrJSONDecoder(reader, 4096) - var result []*unstructured.Unstructured - for { - u := &unstructured.Unstructured{} - err := decoder.Decode(u) - if err != nil { - if err.Error() == "EOF" { - break - } - return nil, err - } - if len(u.Object) == 0 { - continue - } - result = append(result, u) - } - return result, nil -} - -func (d *FluxDeployer) applyUnstructuredObject(ctx context.Context, u *unstructured.Unstructured) error { - objectKey := client.ObjectKeyFromObject(u) - objectLogString := fmt.Sprintf("%s %s", u.GetObjectKind(), objectKey.String()) - fmt.Printf("Applying object %s\n", objectLogString) - - existingObj := &unstructured.Unstructured{} - existingObj.SetGroupVersionKind(u.GroupVersionKind()) - getErr := d.platformCluster.Client().Get(ctx, objectKey, existingObj) - if getErr != nil { - if apierrors.IsNotFound(getErr) { - // create object - createErr := d.platformCluster.Client().Create(ctx, u) - if createErr != nil { - return fmt.Errorf("error creating object %s: %w", objectLogString, createErr) - } - } else { - return fmt.Errorf("error reading object %s: %w", objectLogString, getErr) - } - } else { - // update object - u.SetResourceVersion(existingObj.GetResourceVersion()) - updateErr := d.platformCluster.Client().Update(ctx, u) - if updateErr != nil { - return fmt.Errorf("error updating object %s: %w", objectLogString, updateErr) - } - } - return nil -} diff --git a/internal/ocm-cli/component_getter.go b/internal/ocm-cli/component_getter.go index 7c1ed7d..df6ec74 100644 --- a/internal/ocm-cli/component_getter.go +++ b/internal/ocm-cli/component_getter.go @@ -14,6 +14,8 @@ type ComponentGetter struct { ocmConfig string // Fields derived during InitializeComponents + repo string + rootComponentVersion *ComponentVersion templatesComponentVersion *ComponentVersion templatesComponentLocation string @@ -29,7 +31,9 @@ func NewComponentGetter(rootComponentLocation, deploymentTemplates, ocmConfig st } func (g *ComponentGetter) InitializeComponents(ctx context.Context) error { - repo, err := extractRepoFromLocation(g.rootComponentLocation) + var err error + + g.repo, err = extractRepoFromLocation(g.rootComponentLocation) if err != nil { return err } @@ -50,14 +54,14 @@ func (g *ComponentGetter) InitializeComponents(ctx context.Context) error { cv := g.rootComponentVersion for _, refName := range referenceNames { - cv, err = getReferencedComponentVersion(ctx, repo, cv, refName, g.ocmConfig) + cv, err = g.GetReferencedComponentVersion(ctx, cv, refName) if err != nil { return fmt.Errorf("error getting referenced component version %s: %w", refName, err) } } g.templatesComponentVersion = cv - g.templatesComponentLocation = buildLocation(repo, cv.Component.Name, cv.Component.Version) + g.templatesComponentLocation = buildLocation(g.repo, cv.Component.Name, cv.Component.Version) return nil } @@ -73,14 +77,14 @@ func (g *ComponentGetter) TemplatesResourceName() string { return g.templatesResourceName } -func getReferencedComponentVersion(ctx context.Context, repo string, parentCV *ComponentVersion, refName string, ocmConfig string) (*ComponentVersion, error) { +func (g *ComponentGetter) GetReferencedComponentVersion(ctx context.Context, parentCV *ComponentVersion, refName string) (*ComponentVersion, error) { ref, err := parentCV.GetComponentReference(refName) if err != nil { return nil, fmt.Errorf("error getting component reference %s: %w", refName, err) } - location := buildLocation(repo, ref.ComponentName, ref.Version) - cv, err := GetComponentVersion(ctx, location, ocmConfig) + location := buildLocation(g.repo, ref.ComponentName, ref.Version) + cv, err := GetComponentVersion(ctx, location, g.ocmConfig) if err != nil { return nil, fmt.Errorf("error getting component version %s: %w", location, err) } @@ -88,10 +92,83 @@ func getReferencedComponentVersion(ctx context.Context, repo string, parentCV *C return cv, nil } +func (g *ComponentGetter) GetReferencedComponentVersionRecursive(ctx context.Context, parentCV *ComponentVersion, refName string) (*ComponentVersion, error) { + // First, try to get the reference directly from the parent component version + ref, err := g.GetReferencedComponentVersion(ctx, parentCV, refName) + if err == nil { + return ref, nil + } + + // If not found, search recursively in all component references + for _, componentRef := range parentCV.Component.ComponentReferences { + subCV, err := g.GetReferencedComponentVersion(ctx, parentCV, componentRef.Name) + if err != nil { + continue + } + ref, err := g.GetReferencedComponentVersionRecursive(ctx, subCV, refName) + if err == nil { + return ref, nil + } + } + + return nil, fmt.Errorf("component reference %s not found in component version %s or its references", refName, parentCV.Component.Name) +} + +func (g *ComponentGetter) GetComponentVersionForResourceRecursive(ctx context.Context, parentCV *ComponentVersion, resourceName string) (*ComponentVersion, error) { + // Check if the resource exists in the current component version + _, err := parentCV.GetResource(resourceName) + if err == nil { + return parentCV, nil + } + + // If not found, search recursively in all component references + for _, componentRef := range parentCV.Component.ComponentReferences { + subCV, err := g.GetReferencedComponentVersion(ctx, parentCV, componentRef.Name) + if err != nil { + continue + } + cv, err := g.GetComponentVersionForResourceRecursive(ctx, subCV, resourceName) + if err == nil { + return cv, nil + } + } + + return nil, fmt.Errorf("resource %s not found in component version %s or its references", resourceName, parentCV.Component.Name) +} + func (g *ComponentGetter) DownloadTemplatesResource(ctx context.Context, downloadDir string) error { return downloadDirectoryResource(ctx, g.templatesComponentLocation, g.templatesResourceName, downloadDir, g.ocmConfig) } +func (g *ComponentGetter) DownloadDirectoryResourceByLocation(ctx context.Context, rootCV *ComponentVersion, location string, downloadDir string) error { + var err error + + location = strings.TrimSpace(location) + segments := strings.Split(location, "/") + if len(segments) == 0 { + return fmt.Errorf("location must contain a resource name or component references and a resource name separated by slashes (ref1/.../refN/resource): %s", location) + } + + referenceNames := segments[:len(segments)-1] + resourceName := segments[len(segments)-1] + + cv := rootCV + for _, refName := range referenceNames { + cv, err = g.GetReferencedComponentVersion(ctx, cv, refName) + if err != nil { + return fmt.Errorf("error getting referenced component version %s: %w", refName, err) + } + } + + componentLocation := buildLocation(g.repo, cv.Component.Name, cv.Component.Version) + return downloadDirectoryResource(ctx, componentLocation, resourceName, downloadDir, g.ocmConfig) +} + +func (g *ComponentGetter) DownloadDirectoryResource(ctx context.Context, cv *ComponentVersion, resourceName string, downloadDir string) error { + componentLocation := buildLocation(g.repo, cv.Component.Name, cv.Component.Version) + return downloadDirectoryResource(ctx, componentLocation, resourceName, downloadDir, g.ocmConfig) +} + func downloadDirectoryResource(ctx context.Context, componentLocation string, resourceName string, downloadDir string, ocmConfig string) error { return Execute(ctx, []string{"download", "resources", componentLocation, resourceName}, diff --git a/internal/ocm-cli/ocm.go b/internal/ocm-cli/ocm.go index 761bf91..e00e7eb 100644 --- a/internal/ocm-cli/ocm.go +++ b/internal/ocm-cli/ocm.go @@ -52,7 +52,8 @@ func Execute(ctx context.Context, commands []string, args []string, ocmConfig st // ComponentVersion represents a version of an OCM component. type ComponentVersion struct { // Component is the OCM component associated with this version. - Component Component `json:"component"` + Component Component `json:"component"` + Repository string `json:"repository,omitempty"` } // Component represents an OCM component with its name, version, references to other components, and resources. @@ -101,6 +102,10 @@ type Access struct { MediaType *string `json:"mediaType"` } +var ( + OCIImageResourceType = "ociImage" +) + // GetResource retrieves a resource by its name from the component version. func (cv *ComponentVersion) GetResource(name string) (*Resource, error) { for _, resource := range cv.Component.Resources { @@ -111,6 +116,16 @@ func (cv *ComponentVersion) GetResource(name string) (*Resource, error) { return nil, fmt.Errorf("resource %s not found in component version %s", name, cv.Component.Name) } +func (cv *ComponentVersion) GetResourcesByType(resourceType string) []Resource { + var resources []Resource + for _, resource := range cv.Component.Resources { + if resource.Type == resourceType { + resources = append(resources, resource) + } + } + return resources +} + // GetComponentReference retrieves a component reference by its name from the component version. func (cv *ComponentVersion) GetComponentReference(name string) (*ComponentReference, error) { for _, ref := range cv.Component.ComponentReferences { @@ -147,6 +162,8 @@ func GetComponentVersion(ctx context.Context, componentReference string, ocmConf return nil, fmt.Errorf("error unmarshalling component version: %w", err) } + cv.Repository = strings.SplitN(componentReference, "//", 2)[0] + return &cv, nil } diff --git a/internal/template/novalue_error.go b/internal/template/novalue_error.go new file mode 100644 index 0000000..05d694a --- /dev/null +++ b/internal/template/novalue_error.go @@ -0,0 +1,67 @@ +package template + +import ( + "fmt" + "strings" +) + +const ( + noValue = "" +) + +// NoValueError is used for when an executed template result contains fields with "no value". +type NoValueError struct { + templateResult string + templateName string + input map[string]interface{} + inputFormatter *TemplateInputFormatter + message string +} + +// CreateErrorIfContainsNoValue creates a NoValueError when the template result contains the "no value" string, otherwise nil is returned. +func CreateErrorIfContainsNoValue(templateResult, templateName string, input map[string]interface{}, inputFormatter *TemplateInputFormatter) *NoValueError { + if strings.Contains(templateResult, noValue) { + err := &NoValueError{ + templateResult: templateResult, + templateName: templateName, + input: input, + inputFormatter: inputFormatter, + } + err.buildErrorMessage() + return err + } + return nil +} + +// Error returns the error message. +func (e *NoValueError) Error() string { + return e.message +} + +// buildErrorMessage creates the error message for this error. +func (e *NoValueError) buildErrorMessage() { + var ( + builder = strings.Builder{} + ) + + builder.WriteString(fmt.Sprintf("template \"%s\" contains fields with \"no value\":\n", e.templateName)) + + lines := strings.Split(e.templateResult, "\n") + + for line, content := range lines { + line += 1 + column := strings.Index(content, noValue) + + if column > -1 { + builder.WriteString(fmt.Sprintf("\nline %d:%d\n", line, column)) + builder.WriteString(CreateSourceSnippet(line, column, lines)) + } + } + + if e.input != nil && e.inputFormatter != nil { + builder.WriteString("\ntemplate input:\n") + builder.WriteString(e.inputFormatter.Format(e.input, "\t")) + } + + e.message = builder.String() +} diff --git a/internal/template/novalue_error_test.go b/internal/template/novalue_error_test.go new file mode 100644 index 0000000..487ba97 --- /dev/null +++ b/internal/template/novalue_error_test.go @@ -0,0 +1,314 @@ +package template + +import ( + "fmt" + "strings" + "testing" +) + +func TestCreateErrorIfContainsNoValue(t *testing.T) { + tests := []struct { + name string + templateResult string + templateName string + input map[string]interface{} + inputFormatter *TemplateInputFormatter + expectError bool + }{ + { + name: "template result contains no value", + templateResult: "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: \ndata:\n key: value", + templateName: "configmap.yaml", + input: map[string]interface{}{"key": "value"}, + inputFormatter: NewTemplateInputFormatter(true), + expectError: true, + }, + { + name: "template result contains multiple no values", + templateResult: "name: \nnamespace: \nversion: 1.0.0", + templateName: "manifest.yaml", + input: map[string]interface{}{"version": "1.0.0"}, + inputFormatter: NewTemplateInputFormatter(true), + expectError: true, + }, + { + name: "template result does not contain no value", + templateResult: "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: my-config\ndata:\n key: value", + templateName: "configmap.yaml", + input: map[string]interface{}{"name": "my-config"}, + inputFormatter: NewTemplateInputFormatter(true), + expectError: false, + }, + { + name: "empty template result", + templateResult: "", + templateName: "empty.yaml", + input: map[string]interface{}{}, + inputFormatter: NewTemplateInputFormatter(true), + expectError: false, + }, + { + name: "template result with no value at end of line", + templateResult: "key: ", + templateName: "simple.yaml", + input: map[string]interface{}{}, + inputFormatter: NewTemplateInputFormatter(true), + expectError: true, + }, + { + name: "nil input and formatter", + templateResult: "name: ", + templateName: "test.yaml", + input: nil, + inputFormatter: nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := CreateErrorIfContainsNoValue(tt.templateResult, tt.templateName, tt.input, tt.inputFormatter) + + if tt.expectError { + if err == nil { + t.Errorf("expected error but got none") + return + } + + // Verify the error has the expected fields set + if err.templateResult != tt.templateResult { + t.Errorf("expected templateResult %q, got %q", tt.templateResult, err.templateResult) + } + if err.templateName != tt.templateName { + t.Errorf("expected templateName %q, got %q", tt.templateName, err.templateName) + } + if err.input == nil && tt.input != nil { + t.Errorf("expected input to be set") + } + if err.inputFormatter != tt.inputFormatter { + t.Errorf("expected inputFormatter to be set correctly") + } + if err.message == "" { + t.Errorf("expected message to be built") + } + } else { + if err != nil { + t.Errorf("expected no error but got: %v", err) + } + } + }) + } +} + +func TestNoValueError_Error(t *testing.T) { + tests := []struct { + name string + templateResult string + templateName string + input map[string]interface{} + inputFormatter *TemplateInputFormatter + expectedInMsg []string + }{ + { + name: "single no value error", + templateResult: "name: ", + templateName: "test.yaml", + input: map[string]interface{}{"key": "value"}, + inputFormatter: NewTemplateInputFormatter(true), + expectedInMsg: []string{"test.yaml", "contains fields with", "no value", "line 1:6"}, + }, + { + name: "multiple no value errors", + templateResult: "name: \nnamespace: ", + templateName: "multi.yaml", + input: map[string]interface{}{"other": "data"}, + inputFormatter: NewTemplateInputFormatter(true), + expectedInMsg: []string{"multi.yaml", "line 1:6", "line 2:11"}, + }, + { + name: "no value in middle of line", + templateResult: "prefix suffix", + templateName: "middle.yaml", + input: map[string]interface{}{}, + inputFormatter: NewTemplateInputFormatter(true), + expectedInMsg: []string{"middle.yaml", "line 1:7"}, + }, + { + name: "error with nil input and formatter", + templateResult: "test: ", + templateName: "nil.yaml", + input: nil, + inputFormatter: nil, + expectedInMsg: []string{"nil.yaml", "line 1:6"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := CreateErrorIfContainsNoValue(tt.templateResult, tt.templateName, tt.input, tt.inputFormatter) + if err == nil { + t.Fatalf("expected error but got none") + } + + errorMsg := err.Error() + + // Check that all expected strings are present in the error message + for _, expected := range tt.expectedInMsg { + if !strings.Contains(errorMsg, expected) { + t.Errorf("expected error message to contain %q, got: %s", expected, errorMsg) + } + } + + // Verify error message starts with template name + if !strings.HasPrefix(errorMsg, `template "`+tt.templateName+`"`) { + t.Errorf("expected error message to start with template name, got: %s", errorMsg) + } + }) + } +} + +func TestNoValueError_buildErrorMessage(t *testing.T) { + tests := []struct { + name string + templateResult string + templateName string + input map[string]interface{} + inputFormatter *TemplateInputFormatter + expectInputSection bool + }{ + { + name: "error message with input section", + templateResult: "name: ", + templateName: "with-input.yaml", + input: map[string]interface{}{"key": "value", "number": 42}, + inputFormatter: NewTemplateInputFormatter(true), + expectInputSection: true, + }, + { + name: "error message without input section (nil input)", + templateResult: "name: ", + templateName: "no-input.yaml", + input: nil, + inputFormatter: NewTemplateInputFormatter(true), + expectInputSection: false, + }, + { + name: "error message without input section (nil formatter)", + templateResult: "name: ", + templateName: "no-formatter.yaml", + input: map[string]interface{}{"key": "value"}, + inputFormatter: nil, + expectInputSection: false, + }, + { + name: "multiline template with multiple no values", + templateResult: "line1: value\nline2: \nline3: another\nline4: ", + templateName: "multiline.yaml", + input: map[string]interface{}{"data": "test"}, + inputFormatter: NewTemplateInputFormatter(true), + expectInputSection: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := &NoValueError{ + templateResult: tt.templateResult, + templateName: tt.templateName, + input: tt.input, + inputFormatter: tt.inputFormatter, + } + + err.buildErrorMessage() + + if err.message == "" { + t.Errorf("expected message to be built") + } + + // Check if input section is present when expected + hasInputSection := strings.Contains(err.message, "template input:") + if tt.expectInputSection && !hasInputSection { + t.Errorf("expected input section in error message, but not found") + } + if !tt.expectInputSection && hasInputSection { + t.Errorf("did not expect input section in error message, but found one") + } + + // Verify template name is in the message + if !strings.Contains(err.message, tt.templateName) { + t.Errorf("expected template name %q in error message", tt.templateName) + } + + // Verify "no value" phrase is mentioned + if !strings.Contains(err.message, "no value") { + t.Errorf("expected 'no value' phrase in error message") + } + + // Count the number of "line X:Y" patterns to match number of no value occurrences + noValueCount := strings.Count(tt.templateResult, "") + linePatternCount := strings.Count(err.message, "line ") + if linePatternCount != noValueCount { + t.Errorf("expected %d line patterns in error message, got %d", noValueCount, linePatternCount) + } + }) + } +} + +func TestNoValueError_ErrorImplementsErrorInterface(t *testing.T) { + // Test that NoValueError implements the error interface + var err error = &NoValueError{ + templateResult: "test: ", + templateName: "test.yaml", + input: map[string]interface{}{}, + inputFormatter: NewTemplateInputFormatter(true), + message: "test error message", + } + + if err.Error() != "test error message" { + t.Errorf("expected Error() to return the message field") + } +} + +func TestNoValueError_ColumnCalculation(t *testing.T) { + tests := []struct { + name string + templateResult string + expectedColumn int + }{ + { + name: "no value at beginning of line", + templateResult: " test", + expectedColumn: 0, + }, + { + name: "no value after spaces", + templateResult: " ", + expectedColumn: 3, + }, + { + name: "no value with prefix", + templateResult: "key: ", + expectedColumn: 5, + }, + { + name: "no value in yaml structure", + templateResult: "metadata:\n name: \n namespace: default", + expectedColumn: 8, // Should find the column in the second line + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := CreateErrorIfContainsNoValue(tt.templateResult, "test.yaml", nil, nil) + if err == nil { + t.Fatalf("expected error but got none") + } + + errorMsg := err.Error() + expectedColumnStr := strings.Contains(errorMsg, ":"+fmt.Sprint(tt.expectedColumn)) + if !expectedColumnStr { + t.Errorf("expected column %d to be mentioned in error message: %s", tt.expectedColumn, errorMsg) + } + }) + } +} diff --git a/internal/template/template.go b/internal/template/template.go index e79c3bf..b2de7f4 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -2,9 +2,31 @@ package template import ( "bytes" + "strings" gotmpl "text/template" + + "github.com/Masterminds/sprig/v3" + "sigs.k8s.io/yaml" ) +// toYAML takes an interface, marshals it to yaml, and returns a string. It will +// always return a string, even on marshal error (empty string). +func toYAML(v interface{}) string { + data, err := yaml.Marshal(v) + if err != nil { + // Swallow errors inside of a template. + return "" + } + return strings.TrimSuffix(string(data), "\n") +} + +// fromYAML takes a string, unmarshals it from yaml, and returns an interface. +func fromYAML(input string) (any, error) { + var output any + err := yaml.Unmarshal([]byte(input), &output) + return output, err +} + // TemplateExecution is a struct that provides methods to execute templates with input data. type TemplateExecution struct { funcMaps []gotmpl.FuncMap @@ -15,10 +37,16 @@ type TemplateExecution struct { // NewTemplateExecution creates a new TemplateExecution instance with default settings. func NewTemplateExecution() *TemplateExecution { t := &TemplateExecution{ - funcMaps: make([]gotmpl.FuncMap, 0), + funcMaps: make([]gotmpl.FuncMap, 1), templateInputFormatter: NewTemplateInputFormatter(true), missingKeyOption: "error", } + + t.funcMaps = append(t.funcMaps, sprig.FuncMap()) + t.funcMaps = append(t.funcMaps, gotmpl.FuncMap{ + "toYaml": toYAML, + "fromYaml": fromYAML, + }) return t } @@ -30,8 +58,8 @@ func (t *TemplateExecution) WithInputFormatter(formatter *TemplateInputFormatter } // WithFuncMap adds a function map to the template execution. -func (t *TemplateExecution) WithFuncMap(funcMaps gotmpl.FuncMap) *TemplateExecution { - t.funcMaps = append(t.funcMaps, funcMaps) +func (t *TemplateExecution) WithFuncMap(funcMap gotmpl.FuncMap) *TemplateExecution { + t.funcMaps = append(t.funcMaps, funcMap) return t } @@ -68,5 +96,11 @@ func (t *TemplateExecution) Execute(name, template string, input map[string]inte return nil, TemplateErrorBuilder(err).WithSource(&template).WithInput(input, t.templateInputFormatter).Build() } + if t.missingKeyOption == "zero" { + if noValueErr := CreateErrorIfContainsNoValue(data.String(), name, input, t.templateInputFormatter); noValueErr != nil { + return nil, noValueErr + } + } + return data.Bytes(), nil } diff --git a/internal/util/file.go b/internal/util/file.go new file mode 100644 index 0000000..0191eb9 --- /dev/null +++ b/internal/util/file.go @@ -0,0 +1,90 @@ +package util + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +// CopyDir copies all files and subdirectories from src to dst recursively +func CopyDir(src, dst string) error { + // Get source directory info + srcInfo, err := os.Stat(src) + if err != nil { + return fmt.Errorf("failed to stat source directory %s: %w", src, err) + } + + // Create destination directory with same permissions as source + err = os.MkdirAll(dst, srcInfo.Mode()) + if err != nil { + return fmt.Errorf("failed to create destination directory %s: %w", dst, err) + } + + // Walk through the source directory + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Calculate relative path from source + relPath, err := filepath.Rel(src, path) + if err != nil { + return fmt.Errorf("failed to get relative path: %w", err) + } + + // Skip the root directory itself + if relPath == "." { + return nil + } + + // Calculate destination path + dstPath := filepath.Join(dst, relPath) + + if info.IsDir() { + // Create directory + return os.MkdirAll(dstPath, info.Mode()) + } + + // Copy file + return copyFile(path, dstPath, info.Mode()) + }) +} + +// copyFile copies a single file from src to dst with the specified mode +func copyFile(src, dst string, mode os.FileMode) (err error) { + srcFile, err := os.Open(src) + if err != nil { + return fmt.Errorf("failed to open source file %s: %w", src, err) + } + defer func() { + closeErr := srcFile.Close() + err = errors.Join(err, closeErr) + }() + + // Create destination directory if it doesn't exist + dstDir := filepath.Dir(dst) + err = os.MkdirAll(dstDir, 0755) + if err != nil { + return fmt.Errorf("failed to create destination directory %s: %w", dstDir, err) + } + + dstFile, err := os.Create(dst) + if err != nil { + return fmt.Errorf("failed to create destination file %s: %w", dst, err) + } + defer func() { + closeErr := dstFile.Close() + err = errors.Join(err, closeErr) + }() + + // Copy file contents + _, err = io.Copy(dstFile, srcFile) + if err != nil { + return fmt.Errorf("failed to copy file contents: %w", err) + } + + // Set file permissions + return os.Chmod(dst, mode) +} diff --git a/internal/util/image.go b/internal/util/image.go new file mode 100644 index 0000000..2313079 --- /dev/null +++ b/internal/util/image.go @@ -0,0 +1,55 @@ +package util + +import ( + "fmt" + "strings" +) + +// ParseImageVersionAndTag parses a container image string and returns the image name, tag, and digest. +// If no tag is specified, it defaults to "latest". If a digest is present, it is returned as well. +// Examples of valid image strings: +// - "nginx:1.19.0" -> imageName: "nginx", tag: "1.19.0", digest: "" +// - "nginx" -> imageName: "nginx", tag: "latest", digest: "" +// - "nginx@sha256:abcdef..." -> imageName: "nginx", tag: "", digest: "sha256:abcdef..." +// - "nginx:1.19.0@sha256:abcdef..." -> imageName: "nginx", tag: "1.19.0", digest: "sha256:abcdef..." +func ParseImageVersionAndTag(image string) (imageName string, tag string, digest string, err error) { + if image == "" { + return "", "", "", fmt.Errorf("image string cannot be empty") + } + + // Check if the image contains a digest (indicated by @) + digestIndex := strings.LastIndex(image, "@") + + var tagPart string + if digestIndex != -1 { + // Extract digest + digest = image[digestIndex+1:] + tagPart = image[:digestIndex] + } else { + tagPart = image + } + + // Find the last colon to separate the tag from the image name + // We use LastIndex to handle registry URLs with ports (e.g., registry.io:5000/image:tag) + colonIndex := strings.LastIndex(tagPart, ":") + + // If there's a digest but no colon in the tag part, it's a digest-only image + if digestIndex != -1 && colonIndex == -1 { + imageName = tagPart + return imageName, "", digest, nil + } + + // If there's no colon, it means no explicit tag was provided + // In this case, default to "latest" tag + if colonIndex == -1 { + imageName = tagPart + return imageName, "latest", digest, nil + } + + // Extract image name (everything before the last colon) + imageName = tagPart[:colonIndex] + // Extract tag (everything after the last colon) + tag = tagPart[colonIndex+1:] + + return imageName, tag, digest, nil +} diff --git a/internal/util/image_test.go b/internal/util/image_test.go new file mode 100644 index 0000000..07024e1 --- /dev/null +++ b/internal/util/image_test.go @@ -0,0 +1,143 @@ +package util + +import ( + "testing" +) + +func TestParseImageVersionAndTag(t *testing.T) { + tests := []struct { + name string + image string + expectedImageName string + expectedTag string + expectedDigest string + expectError bool + }{ + { + name: "image with tag only", + image: "nginx:1.21.0", + expectedImageName: "nginx", + expectedTag: "1.21.0", + expectedDigest: "", + expectError: false, + }, + { + name: "image with tag and digest", + image: "nginx:1.21.0@sha256:abc123def456", + expectedImageName: "nginx", + expectedTag: "1.21.0", + expectedDigest: "sha256:abc123def456", + expectError: false, + }, + { + name: "image with latest tag", + image: "ubuntu:latest", + expectedImageName: "ubuntu", + expectedTag: "latest", + expectedDigest: "", + expectError: false, + }, + { + name: "image with version tag and digest", + image: "registry.io/myapp:v2.1.3@sha256:fedcba987654", + expectedImageName: "registry.io/myapp", + expectedTag: "v2.1.3", + expectedDigest: "sha256:fedcba987654", + expectError: false, + }, + { + name: "image without explicit tag (defaults to latest)", + image: "nginx", + expectedImageName: "nginx", + expectedTag: "latest", + expectedDigest: "", + expectError: false, + }, + { + name: "empty image string", + image: "", + expectedImageName: "", + expectedTag: "", + expectedDigest: "", + expectError: true, + }, + { + name: "image with multiple colons in name", + image: "registry.io:5000/namespace/image:v1.0.0", + expectedImageName: "registry.io:5000/namespace/image", + expectedTag: "v1.0.0", + expectedDigest: "", + expectError: false, + }, + { + name: "image with multiple colons and digest", + image: "registry.io:5000/namespace/image:v1.0.0@sha256:123456789abc", + expectedImageName: "registry.io:5000/namespace/image", + expectedTag: "v1.0.0", + expectedDigest: "sha256:123456789abc", + expectError: false, + }, + { + name: "image with digest only (no tag)", + image: "nginx@sha256:abc123def456", + expectedImageName: "nginx", + expectedTag: "", + expectedDigest: "sha256:abc123def456", + expectError: false, + }, + { + name: "complex registry with namespace and tag", + image: "ghcr.io/openmcp-project/components/github.com/openmcp-project/openmcp:v0.0.11", + expectedImageName: "ghcr.io/openmcp-project/components/github.com/openmcp-project/openmcp", + expectedTag: "v0.0.11", + expectedDigest: "", + expectError: false, + }, + { + name: "image with port and path", + image: "localhost:5000/my-namespace/my-image:1.2.3", + expectedImageName: "localhost:5000/my-namespace/my-image", + expectedTag: "1.2.3", + expectedDigest: "", + expectError: false, + }, + { + name: "image with port, path and digest", + image: "localhost:5000/my-namespace/my-image:1.2.3@sha256:abcdef123456", + expectedImageName: "localhost:5000/my-namespace/my-image", + expectedTag: "1.2.3", + expectedDigest: "sha256:abcdef123456", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + imageName, tag, digest, err := ParseImageVersionAndTag(tt.image) + + if tt.expectError { + if err == nil { + t.Errorf("expected error but got none") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if imageName != tt.expectedImageName { + t.Errorf("expected image name %q, got %q", tt.expectedImageName, imageName) + } + + if tag != tt.expectedTag { + t.Errorf("expected tag %q, got %q", tt.expectedTag, tag) + } + + if digest != tt.expectedDigest { + t.Errorf("expected digest %q, got %q", tt.expectedDigest, digest) + } + }) + } +} diff --git a/internal/util/kubernetes.go b/internal/util/kubernetes.go new file mode 100644 index 0000000..5b8cd1a --- /dev/null +++ b/internal/util/kubernetes.go @@ -0,0 +1,128 @@ +package util + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/openmcp-project/controller-utils/pkg/clusters" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/yaml" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/openmcp-project/bootstrapper/internal/log" +) + +// GetCluster creates and initializes a clusters.Cluster object based on the provided kubeconfigPath. +// If kubeconfigPath is empty, it tries to read the "KUBECONFIG" environment variable. +// If that is also empty, it defaults to "$HOME/.kube/config". +func GetCluster(kubeconfigPath, id string, scheme *runtime.Scheme) (*clusters.Cluster, error) { + if len(kubeconfigPath) > 0 { + return createCluster(kubeconfigPath, id, scheme) + } + + kubeconfigEnvVar := os.Getenv("KUBECONFIG") + if len(kubeconfigEnvVar) > 0 { + return createCluster(kubeconfigEnvVar, id, scheme) + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("error getting user home directory: %w", err) + } + + homeConfigPath := filepath.Join(homeDir, ".kube", "config") + return createCluster(homeConfigPath, id, scheme) +} + +func createCluster(kubeconfigPath, id string, scheme *runtime.Scheme) (*clusters.Cluster, error) { + c := clusters.New(id) + c.WithConfigPath(kubeconfigPath) + + err := c.InitializeRESTConfig() + if err != nil { + return nil, fmt.Errorf("error initializing REST config: %w", err) + } + + err = c.InitializeClient(scheme) + if err != nil { + return nil, fmt.Errorf("error initializing cluster client: %w", err) + } + + return c, nil +} + +func ApplyManifests(ctx context.Context, cluster *clusters.Cluster, manifests []byte) error { + + // Parse manifests into unstructured objects + reader := bytes.NewReader(manifests) + unstructuredObjects, err := parseManifests(reader) + if err != nil { + return fmt.Errorf("error parsing manifests: %w", err) + } + + // Apply objects to the platform cluster + for _, u := range unstructuredObjects { + if err = applyUnstructuredObject(ctx, cluster, u); err != nil { + return err + } + } + + return nil +} + +func parseManifests(reader io.Reader) ([]*unstructured.Unstructured, error) { + decoder := yaml.NewYAMLOrJSONDecoder(reader, 4096) + var result []*unstructured.Unstructured + for { + u := &unstructured.Unstructured{} + err := decoder.Decode(u) + if err != nil { + if err.Error() == "EOF" { + break + } + return nil, err + } + if len(u.Object) == 0 { + continue + } + result = append(result, u) + } + return result, nil +} + +func applyUnstructuredObject(ctx context.Context, cluster *clusters.Cluster, u *unstructured.Unstructured) error { + logger := log.GetLogger() + objectKey := client.ObjectKeyFromObject(u) + objectLogString := fmt.Sprintf("%s %s", u.GetObjectKind().GroupVersionKind().String(), objectKey.String()) + + existingObj := &unstructured.Unstructured{} + existingObj.SetGroupVersionKind(u.GroupVersionKind()) + getErr := cluster.Client().Get(ctx, objectKey, existingObj) + if getErr != nil { + if apierrors.IsNotFound(getErr) { + // create object + logger.Tracef("Creating object %s", objectLogString) + createErr := cluster.Client().Create(ctx, u) + if createErr != nil { + return fmt.Errorf("error creating object %s: %w", objectLogString, createErr) + } + } else { + return fmt.Errorf("error reading object %s: %w", objectLogString, getErr) + } + } else { + // update object + logger.Tracef("Updating object %s", objectLogString) + u.SetResourceVersion(existingObj.GetResourceVersion()) + updateErr := cluster.Client().Update(ctx, u) + if updateErr != nil { + return fmt.Errorf("error updating object %s: %w", objectLogString, updateErr) + } + } + return nil +} diff --git a/internal/util/tempdir.go b/internal/util/tempdir.go new file mode 100644 index 0000000..d86dffa --- /dev/null +++ b/internal/util/tempdir.go @@ -0,0 +1,25 @@ +package util + +import ( + "fmt" + "os" +) + +const ( + TempDirPrefix = "openmcp.cloud.bootstrapper-" +) + +func CreateTempDir() (string, error) { + tempDir, err := os.MkdirTemp("", TempDirPrefix) + if err != nil { + return "", fmt.Errorf("failed to create temporary directory: %w", err) + } + return tempDir, nil +} + +func DeleteTempDir(path string) error { + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("failed to delete temporary directory %s: %w", path, err) + } + return nil +} diff --git a/test/utils/git.go b/test/utils/git.go new file mode 100644 index 0000000..ae6f962 --- /dev/null +++ b/test/utils/git.go @@ -0,0 +1,45 @@ +package utils + +import ( + "os" + "testing" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" +) + +func WriteToFile(t *testing.T, filePath, content string) { + err := os.WriteFile(filePath, []byte(content), 0644) + if err != nil { + t.Fatalf("failed to write to file %s: %v", filePath, err) + } +} + +func ReadFromFile(t *testing.T, filePath string) string { + data, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("failed to read from file %s: %v", filePath, err) + } + return string(data) +} + +func AddFileToWorkTree(t *testing.T, workTree *git.Worktree, filePath string) { + _, err := workTree.Add(filePath) + if err != nil { + t.Fatalf("failed to add file %s to worktree: %v", filePath, err) + } +} + +func WorkTreeCommit(t *testing.T, workTree *git.Worktree, message string) { + _, err := workTree.Commit(message, &git.CommitOptions{ + Author: &object.Signature{ + Name: "Test User", + Email: "noreply@test", + When: time.Now(), + }, + }) + if err != nil { + t.Fatalf("failed to commit changes: %v", err) + } +} diff --git a/test/utils/ocm.go b/test/utils/ocm.go index d80b4b1..b807844 100644 --- a/test/utils/ocm.go +++ b/test/utils/ocm.go @@ -118,6 +118,7 @@ func BuildComponent(componentConstructorLocation string, t *testing.T) string { "add", "componentversions", "--create", + "--skip-digest-generation", "--file", ctfDir, componentConstructorLocation}...)