From 94e81cbb75f85b0174c48f892f0c79b1c75dbe15 Mon Sep 17 00:00:00 2001 From: "Radek Schekalla (SAP)" Date: Fri, 24 Oct 2025 14:06:39 +0200 Subject: [PATCH 1/7] feat: add deploy-eso command, read eso resources from ocm root component, deploy OCIRepositories for image and chart, deploy HelmRelease, add tests. On-behalf-of: Radek Schekalla (SAP) Signed-off-by: Radek Schekalla (SAP) --- cmd/deploy_eso.go | 49 ++++++ go.mod | 7 +- go.sum | 6 + .../component_manager.go | 8 +- .../component_manager_mock.go} | 9 +- .../deployment-repo/deploymentRepoManager.go | 2 +- internal/eso-deployer/constants.go | 8 + internal/eso-deployer/deployer.go | 146 ++++++++++++++++++ internal/eso-deployer/deployer_test.go | 94 +++++++++++ .../eso-deployer/testdata/component_1.yaml | 19 +++ .../eso-deployer/testdata/component_2.yaml | 19 +++ internal/flux_deployer/deployer.go | 8 +- internal/flux_deployer/deployer_test.go | 6 +- internal/scheme/scheme.go | 18 +++ internal/util/kubernetes.go | 43 ++---- 15 files changed, 398 insertions(+), 44 deletions(-) create mode 100644 cmd/deploy_eso.go rename internal/{flux_deployer => component}/component_manager.go (83%) rename internal/{flux_deployer/component_manager_test.go => component/component_manager_mock.go} (75%) create mode 100644 internal/eso-deployer/constants.go create mode 100644 internal/eso-deployer/deployer.go create mode 100644 internal/eso-deployer/deployer_test.go create mode 100644 internal/eso-deployer/testdata/component_1.yaml create mode 100644 internal/eso-deployer/testdata/component_2.yaml create mode 100644 internal/scheme/scheme.go diff --git a/cmd/deploy_eso.go b/cmd/deploy_eso.go new file mode 100644 index 0000000..9766ce7 --- /dev/null +++ b/cmd/deploy_eso.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + cfg "github.com/openmcp-project/bootstrapper/internal/config" + + esodeployer "github.com/openmcp-project/bootstrapper/internal/eso-deployer" + logging "github.com/openmcp-project/bootstrapper/internal/log" + "github.com/openmcp-project/bootstrapper/internal/scheme" + "github.com/openmcp-project/bootstrapper/internal/util" +) + +// deployEsoCmd represents the deploy-eso command +var deployEsoCmd = &cobra.Command{ + Use: "deploy-eso", + Short: "Deploys External Secrets Operator controllers on the target cluster", + Long: "Deploys External Secrets Operator controllers on the target cluster", + RunE: func(cmd *cobra.Command, args []string) error { + configFilePath := args[0] + config := &cfg.BootstrapperConfig{} + err := config.ReadFromFile(configFilePath) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + log := logging.GetLogger() + log.Info("Starting deployment of external secrets operator controllers.") + + targetCluster, err := util.GetCluster(cmd.Flag(FlagKubeConfig).Value.String(), "target-cluster", scheme.NewFluxScheme()) + if err != nil { + return fmt.Errorf("failed to get platform cluster: %w", err) + } + + if err = esodeployer.NewEsoDeployer(config, cmd.Flag(FlagOcmConfig).Value.String(), targetCluster, log).Deploy(cmd.Context()); err != nil { + return fmt.Errorf("failed deploying eso: %w", err) + } + + return nil + }, +} + +func init() { + RootCmd.AddCommand(deployEsoCmd) + deployEsoCmd.Flags().SortFlags = false + deployEsoCmd.Flags().String(FlagOcmConfig, "", "OCM configuration file") + deployEsoCmd.Flags().String(FlagKubeConfig, "", "Kubernetes configuration file") +} diff --git a/go.mod b/go.mod index f458c29..778ce00 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,12 @@ module github.com/openmcp-project/bootstrapper -go 1.25.3 +go 1.25.1 require ( github.com/Masterminds/sprig/v3 v3.3.0 + github.com/fluxcd/helm-controller/api v1.4.2 github.com/fluxcd/kustomize-controller/api v1.7.1 + github.com/fluxcd/source-controller/api v1.7.2 github.com/go-git/go-billy/v5 v5.6.2 github.com/go-git/go-git/v5 v5.16.3 github.com/go-logr/logr v1.4.3 @@ -13,6 +15,7 @@ require ( github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 k8s.io/api v0.34.1 + k8s.io/apiextensions-apiserver v0.34.1 k8s.io/apimachinery v0.34.1 k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 sigs.k8s.io/controller-runtime v0.22.3 @@ -36,6 +39,7 @@ require ( github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fluxcd/pkg/apis/acl v0.9.0 // indirect github.com/fluxcd/pkg/apis/kustomize v1.13.0 // indirect github.com/fluxcd/pkg/apis/meta v1.22.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -94,7 +98,6 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.34.1 // indirect k8s.io/client-go v0.34.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250814151709-d7b6acb124c3 // indirect diff --git a/go.sum b/go.sum index 7ab824d..c8360d5 100644 --- a/go.sum +++ b/go.sum @@ -40,12 +40,18 @@ 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/helm-controller/api v1.4.2 h1:2+D3kX3UJhYlr+1rzOkQ/YbIQ96R/olmdfjaYS+okNg= +github.com/fluxcd/helm-controller/api v1.4.2/go.mod h1:0XrBhKEaqvxyDj/FziG1Q8Fmx2UATdaqLgYqmZh6wW4= github.com/fluxcd/kustomize-controller/api v1.7.1 h1:wFevRoziJcQEcJtNL2/NrQfAA1lrrOnFSmFIZrBBfBc= github.com/fluxcd/kustomize-controller/api v1.7.1/go.mod h1:77OSly9kxQli7Nmcln0OqZDjVpRMc6eLKED0CiJHYz8= +github.com/fluxcd/pkg/apis/acl v0.9.0 h1:wBpgsKT+jcyZEcM//OmZr9RiF8klL3ebrDp2u2ThsnA= +github.com/fluxcd/pkg/apis/acl v0.9.0/go.mod h1:TttNS+gocsGLwnvmgVi3/Yscwqrjc17+vhgYfqkfrV4= github.com/fluxcd/pkg/apis/kustomize v1.13.0 h1:GGf0UBVRIku+gebY944icVeEIhyg1P/KE3IrhOyJJnE= github.com/fluxcd/pkg/apis/kustomize v1.13.0/go.mod h1:TLKVqbtnzkhDuhWnAsN35977HvRfIjs+lgMuNro/LEc= github.com/fluxcd/pkg/apis/meta v1.22.0 h1:EHWQH5ZWml7i8eZ/AMjm1jxid3j/PQ31p+hIwCt6crM= github.com/fluxcd/pkg/apis/meta v1.22.0/go.mod h1:Kc1+bWe5p0doROzuV9XiTfV/oL3ddsemYXt8ZYWdVVg= +github.com/fluxcd/source-controller/api v1.7.2 h1:/lg/xoyRjxwdhHKqjTxQS2o1cp+DMKJ8W4rpm+ZLemQ= +github.com/fluxcd/source-controller/api v1.7.2/go.mod h1:2JtCeUVpl0aqKImS19jUz9EEnMdzgqNWHkllrIhV004= 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= diff --git a/internal/flux_deployer/component_manager.go b/internal/component/component_manager.go similarity index 83% rename from internal/flux_deployer/component_manager.go rename to internal/component/component_manager.go index 932b707..cb8d0dc 100644 --- a/internal/flux_deployer/component_manager.go +++ b/internal/component/component_manager.go @@ -1,4 +1,4 @@ -package flux_deployer +package component import ( "context" @@ -9,7 +9,7 @@ import ( // ComponentManager bundles the OCM logic required by the FluxDeployer. type ComponentManager interface { - GetComponentWithImageResources(ctx context.Context) (*ocm_cli.ComponentVersion, error) + GetComponentWithImageResources(ctx context.Context, resourceName string) (*ocm_cli.ComponentVersion, error) DownloadTemplatesResource(ctx context.Context, downloadDir string) error } @@ -35,8 +35,8 @@ func NewComponentManager(ctx context.Context, config *cfg.BootstrapperConfig, oc return m, nil } -func (m *ComponentManagerImpl) GetComponentWithImageResources(ctx context.Context) (*ocm_cli.ComponentVersion, error) { - return m.ComponentGetter.GetComponentVersionForResourceRecursive(ctx, m.ComponentGetter.RootComponentVersion(), FluxCDSourceControllerResourceName) +func (m *ComponentManagerImpl) GetComponentWithImageResources(ctx context.Context, resourceName string) (*ocm_cli.ComponentVersion, error) { + return m.ComponentGetter.GetComponentVersionForResourceRecursive(ctx, m.ComponentGetter.RootComponentVersion(), resourceName) } func (m *ComponentManagerImpl) DownloadTemplatesResource(ctx context.Context, downloadDir string) error { diff --git a/internal/flux_deployer/component_manager_test.go b/internal/component/component_manager_mock.go similarity index 75% rename from internal/flux_deployer/component_manager_test.go rename to internal/component/component_manager_mock.go index 222a4fc..6c226f1 100644 --- a/internal/flux_deployer/component_manager_test.go +++ b/internal/component/component_manager_mock.go @@ -1,4 +1,4 @@ -package flux_deployer_test +package component import ( "context" @@ -7,7 +7,6 @@ import ( "sigs.k8s.io/yaml" - "github.com/openmcp-project/bootstrapper/internal/flux_deployer" ocmcli "github.com/openmcp-project/bootstrapper/internal/ocm-cli" "github.com/openmcp-project/bootstrapper/internal/util" ) @@ -18,13 +17,13 @@ type MockComponentManager struct { TemplatesPath string } -var _ flux_deployer.ComponentManager = (*MockComponentManager)(nil) +var _ ComponentManager = (*MockComponentManager)(nil) -func (m MockComponentManager) GetComponentWithImageResources(_ context.Context) (*ocmcli.ComponentVersion, error) { +func (m MockComponentManager) GetComponentWithImageResources(_ context.Context, _ string) (*ocmcli.ComponentVersion, error) { return loadComponentVersion(m.ComponentPath) } -func (m MockComponentManager) DownloadTemplatesResource(ctx context.Context, downloadDir string) error { +func (m MockComponentManager) DownloadTemplatesResource(_ context.Context, downloadDir string) error { return util.CopyDir(m.TemplatesPath, downloadDir) } diff --git a/internal/deployment-repo/deploymentRepoManager.go b/internal/deployment-repo/deploymentRepoManager.go index ad63bc7..83bc973 100644 --- a/internal/deployment-repo/deploymentRepoManager.go +++ b/internal/deployment-repo/deploymentRepoManager.go @@ -599,7 +599,7 @@ func (m *DeploymentRepoManager) RunKustomizeAndApply(ctx context.Context) error for _, manifest := range manifests { if manifest.GetKind() == "Kustomization" && strings.Contains(manifest.GetAPIVersion(), "kustomize.toolkit.fluxcd.io") { logger.Infof("Applying Kustomization manifest: %s/%s", manifest.GetNamespace(), manifest.GetName()) - err = util.ApplyUnstructuredObject(ctx, m.TargetCluster, manifest) + err = util.CreateOrUpdate(ctx, m.TargetCluster, manifest) if err != nil { return fmt.Errorf("failed to apply Kustomization manifest %s/%s: %w", manifest.GetNamespace(), manifest.GetName(), err) } diff --git a/internal/eso-deployer/constants.go b/internal/eso-deployer/constants.go new file mode 100644 index 0000000..95e0898 --- /dev/null +++ b/internal/eso-deployer/constants.go @@ -0,0 +1,8 @@ +package eso_deployer + +const ( + esoNamespace = "external-secrets-system" + esoImageRepoName = "external-secrets-image" + esoChartRepoName = "external-secrets-chart" + esoHelmReleaseName = "external-secrets-operator" +) diff --git a/internal/eso-deployer/deployer.go b/internal/eso-deployer/deployer.go new file mode 100644 index 0000000..5fe9b34 --- /dev/null +++ b/internal/eso-deployer/deployer.go @@ -0,0 +1,146 @@ +package eso_deployer + +import ( + "context" + "encoding/json" + "fmt" + "time" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "github.com/openmcp-project/controller-utils/pkg/clusters" + "github.com/sirupsen/logrus" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + ocmcli "github.com/openmcp-project/bootstrapper/internal/ocm-cli" + + "github.com/openmcp-project/bootstrapper/internal/component" + cfg "github.com/openmcp-project/bootstrapper/internal/config" + + "github.com/openmcp-project/bootstrapper/internal/flux_deployer" + "github.com/openmcp-project/bootstrapper/internal/util" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +type EsoDeployer struct { + Config *cfg.BootstrapperConfig + + // OcmConfigPath is the path to the OCM configuration file + OcmConfigPath string + + platformCluster *clusters.Cluster + log *logrus.Logger +} + +func NewEsoDeployer(config *cfg.BootstrapperConfig, ocmConfigPath string, platformCluster *clusters.Cluster, log *logrus.Logger) *EsoDeployer { + return &EsoDeployer{ + Config: config, + OcmConfigPath: ocmConfigPath, + platformCluster: platformCluster, + log: log, + } +} + +func (d *EsoDeployer) Deploy(ctx context.Context) error { + componentManager, err := component.NewComponentManager(ctx, d.Config, d.OcmConfigPath) + if err != nil { + return fmt.Errorf("error creating component manager: %w", err) + } + + return d.DeployWithComponentManager(ctx, componentManager) +} + +func (d *EsoDeployer) DeployWithComponentManager(ctx context.Context, componentManager component.ComponentManager) error { + d.log.Info("Getting OCM component containing ESO resources.") + esoComponent, err := componentManager.GetComponentWithImageResources(ctx, "external-secrets-operator-image") + if err != nil { + return fmt.Errorf("failed to get external-secrets-operator-image component: %w", err) + } + + esoChartRes, err := esoComponent.GetResource("external-secrets-operator-chart") + if err != nil { + return fmt.Errorf("failed to get external-secrets-operator-chart resource: %w", err) + } + d.log.Info("Deploying OCIRepo for ESO chart.") + if err = deployRepo(ctx, d, esoChartRes, esoChartRepoName); err != nil { + return fmt.Errorf("failed to create helm chart repo: %w", err) + } + + esoImageRes, err := esoComponent.GetResource("external-secrets-operator-image") + if err != nil { + return fmt.Errorf("failed to get external-secrets-operator-image resource: %w", err) + } + d.log.Info("Deploying OCIRepo for ESO image.") + if err = deployRepo(ctx, d, esoImageRes, esoImageRepoName); err != nil { + return fmt.Errorf("failed to create helm image repo: %w", err) + } + + d.log.Info("Deploying HelmRelease for ESO.") + if err = deployHelmRelease(ctx, d, esoImageRes); err != nil { + return fmt.Errorf("failed to deploy helm release: %w", err) + } + + d.log.Info("Done.") + return nil +} + +func deployHelmRelease(ctx context.Context, d *EsoDeployer, res *ocmcli.Resource) error { + name, _, _, err := util.ParseImageVersionAndTag(*res.Access.ImageReference) + if err != nil { + return fmt.Errorf("failed to parse image resource: %w", err) + } + + values := map[string]any{ + "image": map[string]any{"repository": name}, + } + encoded, err := json.Marshal(values) + if err != nil { + return fmt.Errorf("failed to marshal ESO Helm values: %w", err) + } + jsonVals := &apiextensionsv1.JSON{Raw: encoded} + + helmRelease := &helmv2.HelmRelease{ + ObjectMeta: metav1.ObjectMeta{ + Name: esoHelmReleaseName, + Namespace: flux_deployer.FluxSystemNamespace, + }, + Spec: helmv2.HelmReleaseSpec{ + ChartRef: &helmv2.CrossNamespaceSourceReference{ + Kind: "OCIRepository", + Name: esoChartRepoName, + Namespace: flux_deployer.FluxSystemNamespace, + }, + ReleaseName: "eso", + TargetNamespace: esoNamespace, + Install: &helmv2.Install{ + CreateNamespace: true, + }, + Values: jsonVals, + }, + } + return util.CreateOrUpdate(ctx, d.platformCluster, helmRelease) +} + +func deployRepo(ctx context.Context, d *EsoDeployer, res *ocmcli.Resource, repoName string) error { + imageName, tag, digest, err := util.ParseImageVersionAndTag(*res.Access.ImageReference) + if err != nil { + return err + } + + ociRepo := &sourcev1.OCIRepository{ + ObjectMeta: metav1.ObjectMeta{ + Name: repoName, + Namespace: flux_deployer.FluxSystemNamespace, + }, + Spec: sourcev1.OCIRepositorySpec{ + URL: fmt.Sprintf("oci://%s", imageName), + Reference: &sourcev1.OCIRepositoryRef{ + Tag: tag, + Digest: digest, + }, + Timeout: &metav1.Duration{Duration: 1 * time.Minute}, + }, + } + return util.CreateOrUpdate(ctx, d.platformCluster, ociRepo) +} diff --git a/internal/eso-deployer/deployer_test.go b/internal/eso-deployer/deployer_test.go new file mode 100644 index 0000000..da287eb --- /dev/null +++ b/internal/eso-deployer/deployer_test.go @@ -0,0 +1,94 @@ +package eso_deployer + +import ( + "context" + "testing" + + helmv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "github.com/openmcp-project/controller-utils/pkg/clusters" + "github.com/stretchr/testify/assert" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/openmcp-project/bootstrapper/internal/component" + cfg "github.com/openmcp-project/bootstrapper/internal/config" + "github.com/openmcp-project/bootstrapper/internal/flux_deployer" + logging "github.com/openmcp-project/bootstrapper/internal/log" + "github.com/openmcp-project/bootstrapper/internal/scheme" +) + +func TestEsoDeployer_DeployWithComponentManager(t *testing.T) { + platformClient := fake.NewClientBuilder(). + WithScheme(scheme.NewFluxScheme()). + Build() + platformCluster := clusters.NewTestClusterFromClient("platform", platformClient) + namespace := flux_deployer.FluxSystemNamespace + + config := &cfg.BootstrapperConfig{ + Component: cfg.Component{}, + DeploymentRepository: cfg.DeploymentRepository{}, + Providers: cfg.Providers{}, + ImagePullSecrets: nil, + Environment: "test", + } + + d := NewEsoDeployer(config, "", platformCluster, logging.GetLogger()) + + // Initial + initial := &component.MockComponentManager{ + ComponentPath: "./testdata/component_1.yaml", + TemplatesPath: "", + } + + err := d.DeployWithComponentManager(context.Background(), initial) + assert.NoError(t, err, "Error deploying eso controllers") + + chartRepo := &sourcev1.OCIRepository{} + err = platformClient.Get(t.Context(), client.ObjectKey{Name: esoChartRepoName, Namespace: namespace}, chartRepo) + assert.NoError(t, err, "Error getting chart repo") + assert.Equal(t, namespace, chartRepo.Namespace, "Repo namespace does not match expected namespace") + assert.Equal(t, "external-secrets-v1.0.0", chartRepo.Spec.Reference.Tag, "Tag does not match expected tag") + + imgRepo := &sourcev1.OCIRepository{} + err = platformClient.Get(t.Context(), client.ObjectKey{Name: esoImageRepoName, Namespace: namespace}, imgRepo) + assert.NoError(t, err, "Error getting image repo") + assert.Equal(t, namespace, imgRepo.Namespace, "Repo namespace does not match expected namespace") + assert.Equal(t, "external-secrets-v1.0.0", imgRepo.Spec.Reference.Tag, "Tag does not match expected tag") + + helmRelease := &helmv2.HelmRelease{} + err = platformClient.Get(t.Context(), client.ObjectKey{Name: esoHelmReleaseName, Namespace: namespace}, helmRelease) + assert.NoError(t, err, "Error getting eso helm release") + assert.Equal(t, namespace, helmRelease.Namespace, "HelmRelease namespace does not match expected namespace") + assert.Equal(t, esoChartRepoName, helmRelease.Spec.ChartRef.Name, "ChartRef name does not match expected name") + assert.Equal(t, "eso", helmRelease.Spec.ReleaseName, "ReleaseName does not match expected name") + + // Updated + updated := &component.MockComponentManager{ + ComponentPath: "./testdata/component_2.yaml", + TemplatesPath: "", + } + + err = d.DeployWithComponentManager(context.Background(), updated) + assert.NoError(t, err, "Error deploying eso controllers") + + chartRepo = &sourcev1.OCIRepository{} + err = platformClient.Get(t.Context(), client.ObjectKey{Name: esoChartRepoName, Namespace: namespace}, chartRepo) + assert.NoError(t, err, "Error getting chart repo") + assert.Equal(t, namespace, chartRepo.Namespace, "Repo namespace does not match expected namespace") + assert.Equal(t, "external-secrets-v2.0.0", chartRepo.Spec.Reference.Tag, "Tag does not match expected tag") + + imgRepo = &sourcev1.OCIRepository{} + err = platformClient.Get(t.Context(), client.ObjectKey{Name: esoImageRepoName, Namespace: namespace}, imgRepo) + assert.NoError(t, err, "Error getting image repo") + assert.Equal(t, namespace, imgRepo.Namespace, "Repo namespace does not match expected namespace") + assert.Equal(t, "external-secrets-v2.0.0", imgRepo.Spec.Reference.Tag, "Tag does not match expected tag") + + helmRelease = &helmv2.HelmRelease{} + err = platformClient.Get(t.Context(), client.ObjectKey{Name: esoHelmReleaseName, Namespace: namespace}, helmRelease) + assert.NoError(t, err, "Error getting eso helm release") + assert.Equal(t, namespace, helmRelease.Namespace, "HelmRelease namespace does not match expected namespace") + assert.Equal(t, esoChartRepoName, helmRelease.Spec.ChartRef.Name, "ChartRef name does not match expected name") + assert.Equal(t, "eso", helmRelease.Spec.ReleaseName, "ReleaseName does not match expected name") + +} diff --git a/internal/eso-deployer/testdata/component_1.yaml b/internal/eso-deployer/testdata/component_1.yaml new file mode 100644 index 0000000..32a878e --- /dev/null +++ b/internal/eso-deployer/testdata/component_1.yaml @@ -0,0 +1,19 @@ +component: + name: github.com/openmcp-project/openmcp + version: v1.0.0 + provider: + name: openmcp-project + + resources: + - name: external-secrets-operator-chart + type: helmChart + access: + type: ociArtifact + imageReference: ghcr.io/charts/external-secrets-operator:external-secrets-v1.0.0@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + + - name: external-secrets-operator-image + version: v1.0.0 + type: ociImage + access: + type: ociArtifact + imageReference: ghcr.io/images/external-secrets-operator:external-secrets-v1.0.0@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 diff --git a/internal/eso-deployer/testdata/component_2.yaml b/internal/eso-deployer/testdata/component_2.yaml new file mode 100644 index 0000000..5ab7a7e --- /dev/null +++ b/internal/eso-deployer/testdata/component_2.yaml @@ -0,0 +1,19 @@ +component: + name: github.com/openmcp-project/openmcp + version: v2.0.0 + provider: + name: openmcp-project + + resources: + - name: external-secrets-operator-chart + type: helmChart + access: + type: ociArtifact + imageReference: ghcr.io/charts/external-secrets-operator:external-secrets-v2.0.0@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 + + - name: external-secrets-operator-image + version: v2.0.0 + type: ociImage + access: + type: ociArtifact + imageReference: ghcr.io/images/external-secrets-operator:external-secrets-v2.0.0@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 diff --git a/internal/flux_deployer/deployer.go b/internal/flux_deployer/deployer.go index 0a9456a..cfaebff 100644 --- a/internal/flux_deployer/deployer.go +++ b/internal/flux_deployer/deployer.go @@ -12,6 +12,8 @@ import ( "sigs.k8s.io/kustomize/api/krusty" "sigs.k8s.io/kustomize/kyaml/filesys" + "github.com/openmcp-project/bootstrapper/internal/component" + cfg "github.com/openmcp-project/bootstrapper/internal/config" ocmcli "github.com/openmcp-project/bootstrapper/internal/ocm-cli" "github.com/openmcp-project/bootstrapper/internal/util" @@ -49,7 +51,7 @@ func NewFluxDeployer(config *cfg.BootstrapperConfig, gitConfigPath, ocmConfigPat } func (d *FluxDeployer) Deploy(ctx context.Context) (err error) { - componentManager, err := NewComponentManager(ctx, d.Config, d.OcmConfigPath) + componentManager, err := component.NewComponentManager(ctx, d.Config, d.OcmConfigPath) if err != nil { return fmt.Errorf("error creating component manager: %w", err) } @@ -57,7 +59,7 @@ func (d *FluxDeployer) Deploy(ctx context.Context) (err error) { return d.DeployWithComponentManager(ctx, componentManager) } -func (d *FluxDeployer) DeployWithComponentManager(ctx context.Context, componentManager ComponentManager) (err error) { +func (d *FluxDeployer) DeployWithComponentManager(ctx context.Context, componentManager component.ComponentManager) (err error) { d.log.Infof("Ensure namespace %s exists", d.fluxNamespace) namespaceMutator := resources.NewNamespaceMutator(d.fluxNamespace) if err := resources.CreateOrUpdateResource(ctx, d.platformCluster.Client(), namespaceMutator); err != nil { @@ -107,7 +109,7 @@ func (d *FluxDeployer) DeployWithComponentManager(ctx context.Context, component d.log.Tracef("Created repo directory: %s", d.repoDir) // Get component which contains the fluxcd images as resources - d.fluxcdCV, err = componentManager.GetComponentWithImageResources(ctx) + d.fluxcdCV, err = componentManager.GetComponentWithImageResources(ctx, FluxCDSourceControllerResourceName) if err != nil { return fmt.Errorf("failed to get fluxcd source controller component version: %w", err) } diff --git a/internal/flux_deployer/deployer_test.go b/internal/flux_deployer/deployer_test.go index 0b34384..4806a2a 100644 --- a/internal/flux_deployer/deployer_test.go +++ b/internal/flux_deployer/deployer_test.go @@ -9,6 +9,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "github.com/openmcp-project/bootstrapper/internal/component" + cfg "github.com/openmcp-project/bootstrapper/internal/config" "github.com/openmcp-project/bootstrapper/internal/flux_deployer" logging "github.com/openmcp-project/bootstrapper/internal/log" @@ -34,7 +36,7 @@ func TestDeployFluxController(t *testing.T) { d := flux_deployer.NewFluxDeployer(config, "", ocmcli.NoOcmConfig, platformCluster, logging.GetLogger()) // Initial deployment - componentManager1 := &MockComponentManager{ + componentManager1 := &component.MockComponentManager{ ComponentPath: "./testdata/01/component_1.yaml", TemplatesPath: "./testdata/01/fluxcd_resource", } @@ -48,7 +50,7 @@ func TestDeployFluxController(t *testing.T) { assert.Equal(t, "ghcr.io/fluxcd/source-controller:v1.0.0", deployment.Spec.Template.Spec.Containers[0].Image, "Deployment image does not match expected image") // Update deployment - componentManager2 := &MockComponentManager{ + componentManager2 := &component.MockComponentManager{ ComponentPath: "./testdata/01/component_2.yaml", TemplatesPath: "./testdata/01/fluxcd_resource", } diff --git a/internal/scheme/scheme.go b/internal/scheme/scheme.go new file mode 100644 index 0000000..f144738 --- /dev/null +++ b/internal/scheme/scheme.go @@ -0,0 +1,18 @@ +package scheme + +import ( + helmv2 "github.com/fluxcd/helm-controller/api/v2" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" +) + +func NewFluxScheme() *runtime.Scheme { + scheme := runtime.NewScheme() + + // Register Flux types + utilruntime.Must(helmv2.AddToScheme(scheme)) + utilruntime.Must(sourcev1.AddToScheme(scheme)) + + return scheme +} diff --git a/internal/util/kubernetes.go b/internal/util/kubernetes.go index ba4af7c..cdacbbe 100644 --- a/internal/util/kubernetes.go +++ b/internal/util/kubernetes.go @@ -9,7 +9,6 @@ import ( "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" @@ -67,7 +66,7 @@ func ApplyManifests(ctx context.Context, cluster *clusters.Cluster, manifests [] // Apply objects to the platform cluster for _, u := range unstructuredObjects { - if err = ApplyUnstructuredObject(ctx, cluster, u); err != nil { + if err = CreateOrUpdate(ctx, cluster, u); err != nil { return err } } @@ -95,33 +94,23 @@ func ParseManifests(reader io.Reader) ([]*unstructured.Unstructured, error) { return result, nil } -func ApplyUnstructuredObject(ctx context.Context, cluster *clusters.Cluster, u *unstructured.Unstructured) error { +func CreateOrUpdate(ctx context.Context, cluster *clusters.Cluster, obj client.Object) 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 + objectKey := client.ObjectKeyFromObject(obj) + objectLogString := fmt.Sprintf("%s %s", obj.GetObjectKind().GroupVersionKind().String(), objectKey.String()) + + existing := obj.DeepCopyObject().(client.Object) + err := cluster.Client().Get(ctx, client.ObjectKeyFromObject(obj), existing) + + if err != nil { + if client.IgnoreNotFound(err) == nil { 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 cluster.Client().Create(ctx, obj) } + return err } - return nil + + logger.Tracef("Updating object %s", objectLogString) + obj.SetResourceVersion(existing.GetResourceVersion()) + return cluster.Client().Update(ctx, obj) } From 6f82a072ad2b7bc16e88f9a02b643f3cfeba0c34 Mon Sep 17 00:00:00 2001 From: "Radek Schekalla (SAP)" Date: Mon, 27 Oct 2025 15:04:32 +0100 Subject: [PATCH 2/7] feat: add ExternalSecrets structure to bootstrapper config to allow specification of repository and image pull secrets On-behalf-of: Radek Schekalla (SAP) Signed-off-by: Radek Schekalla (SAP) --- go.mod | 2 +- internal/config/config.go | 7 +++++++ internal/eso-deployer/constants.go | 2 +- internal/eso-deployer/deployer.go | 33 ++++++++++++++++-------------- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index 778ce00..2a239e9 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/Masterminds/sprig/v3 v3.3.0 github.com/fluxcd/helm-controller/api v1.4.2 github.com/fluxcd/kustomize-controller/api v1.7.1 + github.com/fluxcd/pkg/apis/meta v1.22.0 github.com/fluxcd/source-controller/api v1.7.2 github.com/go-git/go-billy/v5 v5.6.2 github.com/go-git/go-git/v5 v5.16.3 @@ -41,7 +42,6 @@ require ( github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/fluxcd/pkg/apis/acl v0.9.0 // indirect github.com/fluxcd/pkg/apis/kustomize v1.13.0 // indirect - github.com/fluxcd/pkg/apis/meta v1.22.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 diff --git a/internal/config/config.go b/internal/config/config.go index abf7560..da473c5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" + "github.com/fluxcd/pkg/apis/meta" "k8s.io/apimachinery/pkg/util/validation/field" "sigs.k8s.io/yaml" ) @@ -16,6 +17,7 @@ type BootstrapperConfig struct { OpenMCPOperator OpenMCPOperator `json:"openmcpOperator"` Environment string `json:"environment"` TemplateInput map[string]interface{} `json:"templateInput"` + ExternalSecrets ExternalSecrets `json:"externalSecrets"` } type Component struct { @@ -56,6 +58,11 @@ type Manifest struct { ManifestParsed map[string]interface{} } +type ExternalSecrets struct { + RepositorySecretRef meta.LocalObjectReference `json:"repositorySecretRef"` + ImagePullSecrets []meta.LocalObjectReference `json:"imagePullSecrets"` +} + func (c *BootstrapperConfig) ReadFromFile(path string) error { data, err := os.ReadFile(path) if err != nil { diff --git a/internal/eso-deployer/constants.go b/internal/eso-deployer/constants.go index 95e0898..0886805 100644 --- a/internal/eso-deployer/constants.go +++ b/internal/eso-deployer/constants.go @@ -1,7 +1,7 @@ package eso_deployer const ( - esoNamespace = "external-secrets-system" + esoNamespace = "external-secrets" esoImageRepoName = "external-secrets-image" esoChartRepoName = "external-secrets-chart" esoHelmReleaseName = "external-secrets-operator" diff --git a/internal/eso-deployer/deployer.go b/internal/eso-deployer/deployer.go index 5fe9b34..ac82686 100644 --- a/internal/eso-deployer/deployer.go +++ b/internal/eso-deployer/deployer.go @@ -10,17 +10,14 @@ import ( sourcev1 "github.com/fluxcd/source-controller/api/v1" "github.com/openmcp-project/controller-utils/pkg/clusters" "github.com/sirupsen/logrus" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - ocmcli "github.com/openmcp-project/bootstrapper/internal/ocm-cli" - "github.com/openmcp-project/bootstrapper/internal/component" cfg "github.com/openmcp-project/bootstrapper/internal/config" - "github.com/openmcp-project/bootstrapper/internal/flux_deployer" + ocmcli "github.com/openmcp-project/bootstrapper/internal/ocm-cli" "github.com/openmcp-project/bootstrapper/internal/util" - - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" ) type EsoDeployer struct { @@ -63,7 +60,7 @@ func (d *EsoDeployer) DeployWithComponentManager(ctx context.Context, componentM return fmt.Errorf("failed to get external-secrets-operator-chart resource: %w", err) } d.log.Info("Deploying OCIRepo for ESO chart.") - if err = deployRepo(ctx, d, esoChartRes, esoChartRepoName); err != nil { + if err = d.deployRepo(ctx, esoChartRes, esoChartRepoName); err != nil { return fmt.Errorf("failed to create helm chart repo: %w", err) } @@ -72,12 +69,12 @@ func (d *EsoDeployer) DeployWithComponentManager(ctx context.Context, componentM return fmt.Errorf("failed to get external-secrets-operator-image resource: %w", err) } d.log.Info("Deploying OCIRepo for ESO image.") - if err = deployRepo(ctx, d, esoImageRes, esoImageRepoName); err != nil { + if err = d.deployRepo(ctx, esoImageRes, esoImageRepoName); err != nil { return fmt.Errorf("failed to create helm image repo: %w", err) } d.log.Info("Deploying HelmRelease for ESO.") - if err = deployHelmRelease(ctx, d, esoImageRes); err != nil { + if err = d.deployHelmRelease(ctx, esoImageRes); err != nil { return fmt.Errorf("failed to deploy helm release: %w", err) } @@ -85,15 +82,20 @@ func (d *EsoDeployer) DeployWithComponentManager(ctx context.Context, componentM return nil } -func deployHelmRelease(ctx context.Context, d *EsoDeployer, res *ocmcli.Resource) error { - name, _, _, err := util.ParseImageVersionAndTag(*res.Access.ImageReference) +func (d *EsoDeployer) deployHelmRelease(ctx context.Context, res *ocmcli.Resource) error { + name, tag, _, err := util.ParseImageVersionAndTag(*res.Access.ImageReference) if err != nil { return fmt.Errorf("failed to parse image resource: %w", err) } values := map[string]any{ - "image": map[string]any{"repository": name}, + "image": map[string]any{ + "repository": name, + "tag": tag, + }, } + values["imagePullSecrets"] = d.Config.ExternalSecrets.ImagePullSecrets + encoded, err := json.Marshal(values) if err != nil { return fmt.Errorf("failed to marshal ESO Helm values: %w", err) @@ -122,8 +124,8 @@ func deployHelmRelease(ctx context.Context, d *EsoDeployer, res *ocmcli.Resource return util.CreateOrUpdate(ctx, d.platformCluster, helmRelease) } -func deployRepo(ctx context.Context, d *EsoDeployer, res *ocmcli.Resource, repoName string) error { - imageName, tag, digest, err := util.ParseImageVersionAndTag(*res.Access.ImageReference) +func (d *EsoDeployer) deployRepo(ctx context.Context, res *ocmcli.Resource, repoName string) error { + name, tag, digest, err := util.ParseImageVersionAndTag(*res.Access.ImageReference) if err != nil { return err } @@ -134,12 +136,13 @@ func deployRepo(ctx context.Context, d *EsoDeployer, res *ocmcli.Resource, repoN Namespace: flux_deployer.FluxSystemNamespace, }, Spec: sourcev1.OCIRepositorySpec{ - URL: fmt.Sprintf("oci://%s", imageName), + URL: fmt.Sprintf("oci://%s", name), Reference: &sourcev1.OCIRepositoryRef{ Tag: tag, Digest: digest, }, - Timeout: &metav1.Duration{Duration: 1 * time.Minute}, + Timeout: &metav1.Duration{Duration: 1 * time.Minute}, + SecretRef: &d.Config.ExternalSecrets.RepositorySecretRef, }, } return util.CreateOrUpdate(ctx, d.platformCluster, ociRepo) From 05aacdd1b32ba9649b268c57a9287f8dc3051e5c Mon Sep 17 00:00:00 2001 From: "Radek Schekalla (SAP)" Date: Mon, 27 Oct 2025 15:25:44 +0100 Subject: [PATCH 3/7] feat: use pointer ref On-behalf-of: Radek Schekalla (SAP) Signed-off-by: Radek Schekalla (SAP) --- internal/config/config.go | 2 +- internal/eso-deployer/deployer.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index da473c5..8b9a65a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -59,7 +59,7 @@ type Manifest struct { } type ExternalSecrets struct { - RepositorySecretRef meta.LocalObjectReference `json:"repositorySecretRef"` + RepositorySecretRef *meta.LocalObjectReference `json:"repositorySecretRef"` ImagePullSecrets []meta.LocalObjectReference `json:"imagePullSecrets"` } diff --git a/internal/eso-deployer/deployer.go b/internal/eso-deployer/deployer.go index ac82686..8e8eb21 100644 --- a/internal/eso-deployer/deployer.go +++ b/internal/eso-deployer/deployer.go @@ -142,7 +142,7 @@ func (d *EsoDeployer) deployRepo(ctx context.Context, res *ocmcli.Resource, repo Digest: digest, }, Timeout: &metav1.Duration{Duration: 1 * time.Minute}, - SecretRef: &d.Config.ExternalSecrets.RepositorySecretRef, + SecretRef: d.Config.ExternalSecrets.RepositorySecretRef, }, } return util.CreateOrUpdate(ctx, d.platformCluster, ociRepo) From dd5fbed4109200702501442624e5a8e97f9166e7 Mon Sep 17 00:00:00 2001 From: "Radek Schekalla (SAP)" Date: Mon, 27 Oct 2025 15:32:57 +0100 Subject: [PATCH 4/7] feat: add deploy-eso part to readme On-behalf-of: Radek Schekalla (SAP) Signed-off-by: Radek Schekalla (SAP) --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 070ac00..f8e099b 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,30 @@ Example: openmcp-bootstrapper deploy-flux ./examples/bootstrapper-config.yaml --kubeconfig ~/.kube/config --ocm-config ./examples/ocm-config.yaml --git-config ./examples/git-config.yaml ./examples/bootstrapper-config.yaml ``` +## `deploy-eso` +The `deploy-eso` command is used to deploy the `external-secrets-operator` to a Kubernetes cluster using the previously deployed `FluxCD` components. + +The `deploy-eso` command requires the following parameters: +* `bootstrapper-config`: Path to the bootstrapper configuration file, optionally containing the `ExternalSecrets` section. + * `ExternalSecrets` (optional): Configuration for the external-secrets-operator deployment containing `RepositorySecretRef` and `ImagePullSecrets` + +```yaml +externalSecrets: + repositorySecretRef: + name: repo-secret + imagePullSecrets: + - name: image-pull-secret +``` + +Optional parameters: +* `--kubeconfig`: Path to the kubeconfig file of the target Kubernetes cluster. If not set, the value of the `KUBECONFIG` environment variable will be used. If the `KUBECONFIG` environment variable is not set, the default kubeconfig file located at `$HOME/.kube/config` will be used. +* `--ocm-config`: Path to the OCM configuration file. + +Example: +```shell +openmcp-bootstrapper deploy-eso ./examples/bootstrapper-config.yaml --kubeconfig ~/.kube/config --ocm-config ./examples/ocm-config.yaml ./examples/bootstrapper-config.yaml +``` + ## `manage-deployment-repo` The `manageDeploymentRepo` command is used to template the openMCP git ops templates and apply them to the specified git repository and all kustomized resources to the specified Kubernetes cluster. From e9fdc44a2af4bac700e844b7d12a345131ce4853 Mon Sep 17 00:00:00 2001 From: "Radek Schekalla (SAP)" Date: Mon, 27 Oct 2025 15:41:32 +0100 Subject: [PATCH 5/7] fix: revert go version change On-behalf-of: Radek Schekalla (SAP) Signed-off-by: Radek Schekalla (SAP) --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 2a239e9..33c4056 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/openmcp-project/bootstrapper -go 1.25.1 +go 1.25.3 require ( github.com/Masterminds/sprig/v3 v3.3.0 From 36358fc443ba9df338261649c6306fa71e80b053 Mon Sep 17 00:00:00 2001 From: rdksap Date: Tue, 28 Oct 2025 07:50:59 +0100 Subject: [PATCH 6/7] Update cmd/deploy_eso.go MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: René Schünemann --- cmd/deploy_eso.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/deploy_eso.go b/cmd/deploy_eso.go index 9766ce7..eafce97 100644 --- a/cmd/deploy_eso.go +++ b/cmd/deploy_eso.go @@ -18,6 +18,11 @@ var deployEsoCmd = &cobra.Command{ Use: "deploy-eso", Short: "Deploys External Secrets Operator controllers on the target cluster", Long: "Deploys External Secrets Operator controllers on the target cluster", + Args: cobra.ExactArgs(1), + ArgAliases: []string{ + "configFile", + }, + Example: `openmcp-bootstrapper deploy-eso "./config.yaml"`, RunE: func(cmd *cobra.Command, args []string) error { configFilePath := args[0] config := &cfg.BootstrapperConfig{} From ef267a8404a2c747c3a928c8a170360e7e753afd Mon Sep 17 00:00:00 2001 From: "Radek Schekalla (SAP)" Date: Tue, 28 Oct 2025 08:01:59 +0100 Subject: [PATCH 7/7] fix: re-add indentation On-behalf-of: Radek Schekalla (SAP) Signed-off-by: Radek Schekalla (SAP) --- cmd/deploy_eso.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/deploy_eso.go b/cmd/deploy_eso.go index eafce97..131621c 100644 --- a/cmd/deploy_eso.go +++ b/cmd/deploy_eso.go @@ -18,11 +18,11 @@ var deployEsoCmd = &cobra.Command{ Use: "deploy-eso", Short: "Deploys External Secrets Operator controllers on the target cluster", Long: "Deploys External Secrets Operator controllers on the target cluster", - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), ArgAliases: []string{ "configFile", }, - Example: `openmcp-bootstrapper deploy-eso "./config.yaml"`, + Example: ` openmcp-bootstrapper deploy-eso "./config.yaml"`, RunE: func(cmd *cobra.Command, args []string) error { configFilePath := args[0] config := &cfg.BootstrapperConfig{}