From c574e5f0f936e7c19766465a876375aca0682d80 Mon Sep 17 00:00:00 2001 From: Waleed Hammam Date: Thu, 12 Oct 2023 14:32:06 +0300 Subject: [PATCH] Add bootstrap command for gitops cli to bootstrap WGE (#3371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * init gitops bootstrap command for wge * add checks for entitlement and flux * Add wge version choise list * add create admin username and password secret * add install wge * fix adding admin password * fix linting * run go mod tidy * enable ingress * add domain type selector * add option to bootstrap flux * adjust bootstraping flux and domain * add install extra controllers * fix lint * rename package checks to commands * refactor packages * early exit * refactor errors and add utils for git repos * refactor wge installation to use files * refactor extra controllers * remove extra unneeded values * Add OIDC * update portforward * add admin password revert * refactor error handeling * fix unhandeled errors * refactor creating helmrepos and helmreleases * refactor styling * go mod tidy * split utils * Add OIDC * refactor creating extra controllers * move install extra controller to commands * add oidc * Add OIDC * install capi controller * add terraform controller * refactor OIDC cli * fix linting * fix lintting * fix lint * fix lint * update OIDC CLI * handle error msgs * move check mark to method * reformat error * cleanup * update CLI OIDC * fix confirm input * type * Add localhost portforward info * add gitopssets controller * enable pipelines controller and cluster-controller and gitopssets by default * remove gitops sets * handle existing secrets * Update onboarding CLI messages * cleanup utils * cleanup utils * cleanup domain * cleanup utils * cleanup input * cleanup input * edit messages and variableNames * update cli messages * cleanup variables * cleanup constants * cleanup constants * add unit test for k8s utils and refactor accordingly * add unit tests for flux * add unit test for admin password * Add unit -test for adding capi & policy-agent * move controllers under gitops add * add unit test for wge version * test oidc get issuer * Move controllers under gitops add * Move controllers under gitops add * pause add controllers tests * restore install controller function after setup * fix controllers * pass opts to controllers * refactor git utilits * refactor git utilities * prepare bootstrap bommand for release1 * address messages and languague * clean extra variables * remove oidc flow to another branch * Update cmd/gitops/app/bootstrap/cmd.go Co-authored-by: Eneko Fernández <12957664+enekofb@users.noreply.github.com> * Update cmd/gitops/app/bootstrap/cmd.go Co-authored-by: Eneko Fernández <12957664+enekofb@users.noreply.github.com> * refactor git utility & add unit-test * remove out of scope componenets * apply code review comments to refactor file names and error messages * move bootstrap package under pkg * refactor using the k8s client and add silent mode refactor git utils * fix admin password * verify username and password * remove unused methods * fix spelling * remove aws related stuff * use git library add check for previous installation * fix lint * imporve error messages to stage failures * adjust gitopssets values and installation checks * clean silent mode * add checks for kubeconfig * adjust admin password * refactor commands to use config interface struct * improve error messages * remove unused variable * wip adding cli design doc * wip adding cli design doc * add error guidance * rename config to bootstrapper to be more clear * refactor commands to use steps pattern * continue on refactor commands to use steps pattern * fix ssh authenticate by explictly asking for private key file in case the key is not loaded in ssh agent. this usually happens on macos * added integration test, refactored configuration and design (#3458) * Changes after the review to enhance the following aspects Testing: - Added integration test so we could test the functionality e2e: it uses some local configuration that we need to test but already provides the acceptance layer that we were missing: Design: - Bootstrap workflow moved to the domain layer within `pkg` so it could be presented in different forms. - Integrated configuration chain of responsibility into a single a builder pattern, so we have configurability in this layer. As a result: - we dont need to pass the flags to the steps - we config the stepsbefore the workflow is executed which seems the right moment. Other refactors: - Moved steps to package `steps` from `command` for consistency * integrated ssh key management * add events and error messages and fix domain bug * add current context * fix lint * add entitlement expiration message * fix entitlement expiration * fix entitlement expiration * seperate entitlement secret validations * add messages for flux * edit messages to small letter and update success messages * add validation for password and wge version * add test cases for admin password create creds * add test cases for domain type * update entitlement test * add validation on password input * remove aws related message * panic in case of casting error to give more context about the error * handle portforward and error messages * fix external dns spacing * add validation on domain type * Cli eneko review (#3474) * reviewed documentation * updated docs with waleed input * reviewed TBD * latest set of changes * removing commented * remove debugging * removed stale documentation * removed withe space * review * removed unused --------- Co-authored-by: Ahmad Samir Co-authored-by: Eneko Fernández <12957664+enekofb@users.noreply.github.com> Co-authored-by: Eneko Fernandez --- cmd/gitops/app/bootstrap/cmd.go | 91 ++++++ .../app/bootstrap/cmd_integration_test.go | 291 ++++++++++++++++++ cmd/gitops/app/bootstrap/suite_test.go | 99 ++++++ cmd/gitops/app/root/cmd.go | 2 + docs/cli/bootstrap.md | 171 ++++++++++ go.mod | 8 +- go.sum | 12 +- pkg/bootstrap/bootstrap.go | 29 ++ pkg/bootstrap/steps/admin_password.go | 154 +++++++++ pkg/bootstrap/steps/admin_password_test.go | 174 +++++++++++ pkg/bootstrap/steps/config.go | 229 ++++++++++++++ pkg/bootstrap/steps/domain_type.go | 65 ++++ pkg/bootstrap/steps/domain_type_test.go | 53 ++++ pkg/bootstrap/steps/entitlement.go | 78 +++++ pkg/bootstrap/steps/entitlement_test.go | 85 +++++ pkg/bootstrap/steps/flux.go | 42 +++ pkg/bootstrap/steps/install_wge.go | 248 +++++++++++++++ pkg/bootstrap/steps/private_key.go | 62 ++++ pkg/bootstrap/steps/public.pem | 3 + pkg/bootstrap/steps/step.go | 195 ++++++++++++ pkg/bootstrap/steps/success_page.go | 35 +++ pkg/bootstrap/steps/wge_version.go | 132 ++++++++ pkg/bootstrap/steps/wge_version_test.go | 37 +++ pkg/bootstrap/utils/flux.go | 133 ++++++++ pkg/bootstrap/utils/flux_test.go | 110 +++++++ pkg/bootstrap/utils/git.go | 165 ++++++++++ pkg/bootstrap/utils/git_test.go | 44 +++ pkg/bootstrap/utils/input.go | 98 ++++++ pkg/bootstrap/utils/k8s.go | 136 ++++++++ pkg/bootstrap/utils/k8s_test.go | 148 +++++++++ 30 files changed, 3119 insertions(+), 10 deletions(-) create mode 100644 cmd/gitops/app/bootstrap/cmd.go create mode 100644 cmd/gitops/app/bootstrap/cmd_integration_test.go create mode 100644 cmd/gitops/app/bootstrap/suite_test.go create mode 100644 docs/cli/bootstrap.md create mode 100644 pkg/bootstrap/bootstrap.go create mode 100644 pkg/bootstrap/steps/admin_password.go create mode 100644 pkg/bootstrap/steps/admin_password_test.go create mode 100644 pkg/bootstrap/steps/config.go create mode 100644 pkg/bootstrap/steps/domain_type.go create mode 100644 pkg/bootstrap/steps/domain_type_test.go create mode 100644 pkg/bootstrap/steps/entitlement.go create mode 100644 pkg/bootstrap/steps/entitlement_test.go create mode 100644 pkg/bootstrap/steps/flux.go create mode 100644 pkg/bootstrap/steps/install_wge.go create mode 100644 pkg/bootstrap/steps/private_key.go create mode 100644 pkg/bootstrap/steps/public.pem create mode 100644 pkg/bootstrap/steps/step.go create mode 100644 pkg/bootstrap/steps/success_page.go create mode 100644 pkg/bootstrap/steps/wge_version.go create mode 100644 pkg/bootstrap/steps/wge_version_test.go create mode 100644 pkg/bootstrap/utils/flux.go create mode 100644 pkg/bootstrap/utils/flux_test.go create mode 100644 pkg/bootstrap/utils/git.go create mode 100644 pkg/bootstrap/utils/git_test.go create mode 100644 pkg/bootstrap/utils/input.go create mode 100644 pkg/bootstrap/utils/k8s.go create mode 100644 pkg/bootstrap/utils/k8s_test.go diff --git a/cmd/gitops/app/bootstrap/cmd.go b/cmd/gitops/app/bootstrap/cmd.go new file mode 100644 index 0000000000..8a75c85316 --- /dev/null +++ b/cmd/gitops/app/bootstrap/cmd.go @@ -0,0 +1,91 @@ +package bootstrap + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + . "github.com/weaveworks/weave-gitops-enterprise/pkg/bootstrap" + "github.com/weaveworks/weave-gitops-enterprise/pkg/bootstrap/steps" + "github.com/weaveworks/weave-gitops/cmd/gitops/config" + "github.com/weaveworks/weave-gitops/pkg/logger" +) + +const ( + cmdName = "bootstrap" + cmdShortDescription = `gitops bootstrap installs Weave GitOps Enterprise in simple steps: +- Entitlements: check that you have valid entitlements. +- Flux: check or bootstrap Flux. +- Weave Gitops: check or install a supported Weave GitOps version with default configuration. +- Authentication: check or setup cluster user authentication to access the dashboard. +` + cmdExamples = ` +# Start WGE installation from the current kubeconfig +gitops bootstrap + +# Start WGE installation from a specific kubeconfig +gitops bootstrap --kubeconfig + +# Start WGE installation with given 'username' and 'password' +gitops bootstrap --username wego-admin --password=hell0! +` +) + +type bootstrapFlags struct { + username string + password string + version string + domainType string + domain string + privateKeyPath string + privateKeyPassword string +} + +var flags bootstrapFlags + +func Command(opts *config.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: cmdName, + Short: cmdShortDescription, + Example: cmdExamples, + RunE: getBootstrapCmdRun(opts), + } + + cmd.Flags().StringVarP(&flags.username, "username", "u", "", "dashboard admin username") + cmd.Flags().StringVarP(&flags.password, "password", "p", "", "dashboard admin password") + cmd.Flags().StringVarP(&flags.version, "version", "v", "", "version of Weave GitOps Enterprise (should be from the latest 3 versions)") + cmd.Flags().StringVarP(&flags.domainType, "domain-type", "t", "", "dashboard domain type: could be 'localhost' or 'externaldns'") + cmd.Flags().StringVarP(&flags.domain, "domain", "d", "", "indicate the domain to use in case of using `externaldns`") + cmd.Flags().StringVarP(&flags.privateKeyPath, "private-key", "k", "", "private key path. This key will be used to push the Weave GitOps Enterprise's resources to the default cluster repository") + cmd.Flags().StringVarP(&flags.privateKeyPassword, "private-key-password", "c", "", "private key password. If the private key is encrypted using password") + return cmd +} + +func getBootstrapCmdRun(opts *config.Options) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + + cliLogger := logger.NewCLILogger(os.Stdout) + + // create config from flags + c, err := steps.NewConfigBuilder(). + WithLogWriter(cliLogger). + WithKubeconfig(opts.Kubeconfig). + WithUsername(flags.username). + WithPassword(flags.password). + WithVersion(flags.version). + WithDomainType(flags.domainType). + WithDomain(flags.domain). + WithPrivateKey(flags.privateKeyPath, flags.privateKeyPassword). + Build() + + if err != nil { + return fmt.Errorf("cannot config bootstrap: %v", err) + } + + err = Bootstrap(c) + if err != nil { + return fmt.Errorf("cannot execute bootstrap: %v", err) + } + return nil + } +} diff --git a/cmd/gitops/app/bootstrap/cmd_integration_test.go b/cmd/gitops/app/bootstrap/cmd_integration_test.go new file mode 100644 index 0000000000..3f2647ad35 --- /dev/null +++ b/cmd/gitops/app/bootstrap/cmd_integration_test.go @@ -0,0 +1,291 @@ +//go:build integration +// +build integration + +package bootstrap_test + +import ( + "context" + "fmt" + "os" + "sync" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/go-logr/logr/testr" + "github.com/stretchr/testify/require" + "github.com/weaveworks/weave-gitops-enterprise/cmd/gitops/app/root" + "github.com/weaveworks/weave-gitops-enterprise/cmd/gitops/pkg/adapters" + "github.com/weaveworks/weave-gitops/pkg/runner" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + . "github.com/onsi/gomega" +) + +const ( + defaultTimeout = time.Second * 5 + defaultInterval = time.Second +) + +var fluxSystemNamespace = corev1.Namespace{ + TypeMeta: metav1.TypeMeta{ + Kind: "Namespace", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "flux-system", + }, +} + +func createEntitlementSecretFromEnv(t *testing.T) corev1.Secret { + + username := os.Getenv("WGE_ENTITLEMENT_USERNAME") + require.NotEmpty(t, username) + password := os.Getenv("WGE_ENTITLEMENT_PASSWORD") + require.NotEmpty(t, password) + entitlement := os.Getenv("WGE_ENTITLEMENT_ENTITLEMENT") + require.NotEmpty(t, entitlement) + + return corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "weave-gitops-enterprise-credentials", + Namespace: "flux-system", + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "username": []byte(username), + "password": []byte(password), + "entitlement": []byte(entitlement), + }, + } +} + +// TestBootstrapCmd is an integration test for bootstrapping command. +// It uses envtest to simulate a cluster. +func TestBootstrapCmd(t *testing.T) { + g := NewGomegaWithT(t) + g.SetDefaultEventuallyTimeout(defaultTimeout) + g.SetDefaultEventuallyPollingInterval(defaultInterval) + testLog := testr.New(t) + + lock := sync.Mutex{} + + privateKeyFile := os.Getenv("GIT_PRIVATEKEY_PATH") + g.Expect(privateKeyFile).NotTo(BeEmpty()) + privateKeyArg := fmt.Sprintf("--private-key=%s", privateKeyFile) + + // ensure flux-system ns exists + _ = k8sClient.Create(context.Background(), &fluxSystemNamespace) + + tests := []struct { + name string + flags []string + expectedErrorStr string + setup func(t *testing.T) + reset func(t *testing.T) + }{ + { + name: "should fail without flux bootstrapped", + flags: []string{}, + expectedErrorStr: "please bootstrap Flux in `flux-system` namespace: more info https://fluxcd.io/flux/installation", + }, + { + name: "should fail without entitlements", + flags: []string{}, + setup: func(t *testing.T) { + bootstrapFluxSsh(g) + }, + reset: func(t *testing.T) { + uninstallFlux(g) + }, + + expectedErrorStr: "entitlement file is not found", + }, + { + name: "should fail without private key", + flags: []string{}, + setup: func(t *testing.T) { + bootstrapFluxSsh(g) + createEntitlements(t, testLog) + }, + reset: func(t *testing.T) { + deleteEntitlements(t, testLog) + uninstallFlux(g) + }, + expectedErrorStr: "cannot process input 'private key path and password", + }, + { + name: "should fail without selected wge version", + flags: []string{ + privateKeyArg, + "--private-key-password=\"\"", + }, + setup: func(t *testing.T) { + bootstrapFluxSsh(g) + createEntitlements(t, testLog) + }, + reset: func(t *testing.T) { + deleteEntitlements(t, testLog) + uninstallFlux(g) + }, + expectedErrorStr: "cannot process input 'select WGE version'", + }, + { + name: "should fail without user authentication", + flags: []string{"--version=0.33.0", + privateKeyArg, + "--private-key-password=\"\"", + }, + setup: func(t *testing.T) { + bootstrapFluxSsh(g) + createEntitlements(t, testLog) + }, + reset: func(t *testing.T) { + deleteEntitlements(t, testLog) + uninstallFlux(g) + }, + expectedErrorStr: "cannot process input 'user authentication'", + }, + { + name: "should fail without dashboard access", + flags: []string{"--version=0.33.0", + privateKeyArg, + "--private-key-password=\"\"", + "--username=admin", + "--password=admin123"}, + setup: func(t *testing.T) { + bootstrapFluxSsh(g) + createEntitlements(t, testLog) + }, + reset: func(t *testing.T) { + deleteClusterUser(t, testLog) + deleteEntitlements(t, testLog) + uninstallFlux(g) + }, + expectedErrorStr: "cannot process input 'dashboard access'", + }, + } + for _, tt := range tests { + lock.Lock() + t.Run(tt.name, func(t *testing.T) { + + defer lock.Unlock() + + if tt.setup != nil { + tt.setup(t) + } + + if tt.reset != nil { + defer tt.reset(t) + } + + client := adapters.NewHTTPClient() + cmd := root.Command(client) + bootstrapCmdArgs := []string{"bootstrap"} + bootstrapCmdArgs = append(bootstrapCmdArgs, tt.flags...) + cmd.SetArgs(bootstrapCmdArgs) + + err := cmd.Execute() + if tt.expectedErrorStr != "" { + g.Expect(err.Error()).To(ContainSubstring(tt.expectedErrorStr)) + return + } + g.Expect(err).To(BeNil()) + + }) + } +} + +func bootstrapFluxSsh(g *WithT) { + var runner runner.CLIRunner + + repoUrl := os.Getenv("GIT_URL_SSH") + g.Expect(repoUrl).NotTo(BeEmpty()) + fmt.Println(repoUrl) + + privateKeyFile := os.Getenv("GIT_PRIVATEKEY_PATH") + g.Expect(privateKeyFile).NotTo(BeEmpty()) + fmt.Println(privateKeyFile) + + args := []string{"bootstrap", "git", "-s", fmt.Sprintf("--url=%s", repoUrl), fmt.Sprintf("--private-key-file=%s", privateKeyFile), "--path=clusters/management"} + fmt.Println(args) + + s, err := runner.Run("flux", args...) + fmt.Println(string(s)) + g.Expect(err).To(BeNil()) + +} + +func uninstallFlux(g *WithT) { + var runner runner.CLIRunner + args := []string{"uninstall", "-s", "--keep-namespace"} + _, err := runner.Run("flux", args...) + g.Expect(err).To(BeNil()) +} + +func createEntitlements(t *testing.T, testLog logr.Logger) { + secret := createEntitlementSecretFromEnv(t) + objects := []client.Object{ + &secret, + } + createResources(testLog, t, k8sClient, objects...) +} + +func deleteEntitlements(t *testing.T, testLog logr.Logger) { + secret := createEntitlementSecretFromEnv(t) + objects := []client.Object{ + &secret, + } + deleteResources(testLog, t, k8sClient, objects...) +} + +func deleteClusterUser(t *testing.T, testLog logr.Logger) { + secret := corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + Type: corev1.SecretTypeOpaque, + ObjectMeta: metav1.ObjectMeta{ + Name: "cluster-user-auth", + Namespace: "flux-system", + }, + Data: map[string][]byte{}, + } + + objects := []client.Object{ + &secret, + } + deleteResources(testLog, t, k8sClient, objects...) +} + +func createResources(log logr.Logger, t *testing.T, k client.Client, objects ...client.Object) { + ctx := context.Background() + t.Helper() + for _, o := range objects { + err := k.Create(ctx, o) + if err != nil { + t.Errorf("failed to create object: %s", err) + } + log.Info("created object", "name", o.GetName(), "ns", o.GetNamespace(), "kind", o.GetObjectKind().GroupVersionKind().Kind) + } +} + +func deleteResources(log logr.Logger, t *testing.T, k client.Client, objects ...client.Object) { + ctx := context.Background() + t.Helper() + for _, o := range objects { + err := k.Delete(ctx, o) + if err != nil { + t.Logf("failed to cleanup object: %s", err) + } + log.Info("deleted object", "name", o.GetName(), "ns", o.GetNamespace(), "kind", o.GetObjectKind().GroupVersionKind().Kind) + + } +} diff --git a/cmd/gitops/app/bootstrap/suite_test.go b/cmd/gitops/app/bootstrap/suite_test.go new file mode 100644 index 0000000000..31f071da73 --- /dev/null +++ b/cmd/gitops/app/bootstrap/suite_test.go @@ -0,0 +1,99 @@ +//go:build integration +// +build integration + +package bootstrap_test + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "strings" + "testing" + + helmv2beta1 "github.com/fluxcd/helm-controller/api/v2beta1" + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" + sourcev1 "github.com/fluxcd/source-controller/api/v1" + sourcev1beta2 "github.com/fluxcd/source-controller/api/v1beta2" + "k8s.io/client-go/rest" + "k8s.io/kubectl/pkg/scheme" + + "github.com/onsi/gomega" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +var k8sClient client.Client +var cfg *rest.Config + +func TestMain(m *testing.M) { + // setup testEnvironment + cmdOut, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() + repoRoot := strings.TrimSpace(string(cmdOut)) + envTestPath := fmt.Sprintf("%s/tools/bin/envtest", repoRoot) + os.Setenv("KUBEBUILDER_ASSETS", envTestPath) + useExistingCluster := true + testEnv := &envtest.Environment{ + //CRDDirectoryPaths: []string{ + // filepath.Join("testdata", "crds"), + //}, + //ErrorIfCRDPathMissing: true, + UseExistingCluster: &useExistingCluster, + } + + cfg, err = testEnv.Start() + if err != nil { + log.Fatalf("starting test env failed: %s", err) + } + + log.Println("environment started") + + err = sourcev1beta2.AddToScheme(scheme.Scheme) + if err != nil { + log.Fatalf("add helm to schema failed: %s", err) + } + + err = sourcev1.AddToScheme(scheme.Scheme) + if err != nil { + log.Fatalf("add helm to schema failed: %s", err) + } + + err = kustomizev1.AddToScheme(scheme.Scheme) + if err != nil { + log.Fatalf("add helm to schema failed: %s", err) + } + + err = helmv2beta1.AddToScheme(scheme.Scheme) + if err != nil { + log.Fatalf("add helm to schema failed: %s", err) + } + + _, cancel := context.WithCancel(context.Background()) + + k8sClient, err = client.New(cfg, client.Options{ + Scheme: scheme.Scheme, + }) + if err != nil { + log.Fatalf("cannot create kubernetes client: %s", err) + } + + log.Println("kube client created") + + gomega.RegisterFailHandler(func(message string, skip ...int) { + log.Println(message) + }) + + retCode := m.Run() + log.Printf("suite ran with return code: %d", retCode) + + cancel() + + err = testEnv.Stop() + if err != nil { + log.Fatalf("stoping test env failed: %s", err) + } + + log.Println("test environment stopped") + os.Exit(retCode) +} diff --git a/cmd/gitops/app/root/cmd.go b/cmd/gitops/app/root/cmd.go index 7e237ddc1f..fabc677427 100644 --- a/cmd/gitops/app/root/cmd.go +++ b/cmd/gitops/app/root/cmd.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/weaveworks/weave-gitops-enterprise/cmd/gitops/app/add" + "github.com/weaveworks/weave-gitops-enterprise/cmd/gitops/app/bootstrap" "github.com/weaveworks/weave-gitops-enterprise/cmd/gitops/app/connect" "github.com/weaveworks/weave-gitops-enterprise/cmd/gitops/app/create" "github.com/weaveworks/weave-gitops-enterprise/cmd/gitops/app/delete" @@ -139,6 +140,7 @@ func Command(client *adapters.HTTPClient) *cobra.Command { rootCmd.AddCommand(beta.GetCommand(options)) rootCmd.AddCommand(set.SetCommand(options)) rootCmd.AddCommand(generate.Command()) + rootCmd.AddCommand(bootstrap.Command(options)) rootCmd.AddCommand(connect.Command(options)) return rootCmd diff --git a/docs/cli/bootstrap.md b/docs/cli/bootstrap.md new file mode 100644 index 0000000000..1ad933fe4a --- /dev/null +++ b/docs/cli/bootstrap.md @@ -0,0 +1,171 @@ +# Bootstrap cli command + +The same as flux bootstrap, gitopsee bootstrap could be considered as one of the most important and complex commands that we have as part of our cli. + +Given the expectations of evolution for this command, this document provides background +and guidance on the design considerations taken for you to be in a successful extension path. + +## Glossary + +- Bootstrap: the process of installing weave gitops enterprise app and configure a management cluster. +- Step: each of the bootstrapping stages or activities the workflow goes through. For example, checking entitlements. + +## What is the bootstrapping command architecture? + +It follows a regular cli structure where: + +- [cmd/gitops/app/bootstrap/cmd.go](../../cmd/gitops/app/bootstrap/cmd.go): represents the presentation layer +- [pkg/bootstrap/bootstrap.go](../../pkg/bootstrap/bootstrap.go): domain layer for bootstrapping +- [pkg/bootstrap/steps](../../pkg/bootstrap/steps): domain layer for bootstrapping steps +- [pkg/bootstrap/steps/config.go](../../pkg/bootstrap/steps/config.go): configuration for bootstrapping + +## How the bootstrapping workflow looks like? + +You could find it in [pkg/bootstrap/bootstrap.go](../../pkg/bootstrap/bootstrap.go) as a sequence of steps: + +```go + var steps = []steps.BootstrapStep{ + steps.CheckEntitlementSecret, + steps.VerifyFluxInstallation, + steps.NewSelectWgeVersionStep(config), + steps.NewAskAdminCredsSecretStep(config), + steps.NewSelectDomainType(config), + steps.NewInstallWGEStep(config), + steps.CheckUIDomainStep, + } + +``` + +## How configuration works ? + +The following chain of responsibility applies for config: + +1. Users introduce command flags values [cmd/gitops/app/bootstrap/cmd.go](../../cmd/gitops/app/bootstrap/cmd.go) +2. We use builder pattern for configuration [pkg/bootstrap/steps/config.go](../../pkg/bootstrap/steps/config.go): + - builder: so we propagate user flags + - build: we build the configuration object +3. Configuration is then used to create the workflow steps [pkg/bootstrap/bootstrap.go](../../pkg/bootstrap/bootstrap.go) +``` + steps.NewSelectWgeVersionStep(config), +``` +4. Steps use configuration for execution (for example [wge_version.go](../../pkg/bootstrap/steps/wge_version.go)) +``` +// selectWgeVersion step ask user to select wge version from the latest 3 versions. +func selectWgeVersion(input []StepInput, c *Config) ([]StepOutput, error) { + for _, param := range input { + if param.Name == WGEVersion { + version, ok := param.Value.(string) + if !ok { + return []StepOutput{}, errors.New("unexpected error occurred. Version not found") + } + c.WGEVersion = version + } + +``` +## How can I add a new step? + +Follow these indications: + +1. Add or extend an existing [test case](../../cmd/gitops/app/bootstrap/cmd_integration_test.go) +2. Add the user flags to [cmd/gitops/app/bootstrap/cmd.go](../../cmd/gitops/app/bootstrap/cmd.go) +3. Add the config to [pkg/bootstrap/steps/config.go](../../pkg/bootstrap/steps/config.go): + - Add config values to the builder + - Resolves the configuration business logic in the build function. Ensure that validation happens to fail fast. +4. Add the step as part of the workflow [pkg/bootstrap/bootstrap.go](../../pkg/bootstrap/bootstrap.go) +5. Add the new step [pkg/bootstrap/steps](../../pkg/bootstrap/steps) + + +An example could be seen here given `gitops bootstrap` + +1. if user passes the flag we use the flag +```go + cmd.Flags().StringVarP(&flags.username, "username", "u", "", "Dashboard admin username") +``` +- this is empty so we go to the next level +2. if not, then ask user in interactive session with a default value +```go +func (c *Config) AskAdminCredsSecret() error { + + if c.Username == "" { + c.Username, err = utils.GetStringInput(adminUsernameMsg, DefaultAdminUsername) + if err != nil { + return err + } + } + + return nil +} +``` +User has not introduce a custom value so we take the custom value + +```go +type Config struct { + Username string + Password string + KubernetesClient k8s_client.Client + WGEVersion string + UserDomain string + Logger logger.Logger +} + +``` + +## Error management + +A bootstrapping error received by the platform engineer shoudl allow: + +1. understand the step that has failed +2. the reason and context of the failure +3. the actions to take to recover + +To achieve this: + +1) At internal layers like `util`, return the err. For example `CreateSecret`: +``` + err := client.Create(context.Background(), secret, &k8s_client.CreateOptions{}) + if err != nil { + return err + } + +``` +2) At step implementation: wrapping error with convenient error message in the step implementation for user like invalidEntitlementMsg. +These messages will provide extra information that's not provided by errors like contacting sales / information about flux download: + +``` + ent, err := entitlement.VerifyEntitlement(strings.NewReader(string(publicKey)), string(secret.Data["entitlement"])) + if err != nil || time.Now().Compare(ent.IssuedAt) <= 0 { + return fmt.Errorf("%s: %v", invalidEntitlementSecretMsg, err) + } + +``` + +Use custom errors when required for better handling like [this](https://github.com/weaveworks/weave-gitops-enterprise/blob/6b1c1db9dc0512a9a5c8dd03ddb2811a897849e6/pkg/bootstrap/steps/entitlement.go#L65) + +## Logging Actions + +For sharing progress with the user, the following levels are used: + +- `c.Logger.Waitingf()`: to identify the step. or a subtask that's taking a long time. like reconciliation +- `c.Logger.Actionf()`: to identify subtask of a step. like Writing file to repo. +- `c.Logger.Warningf`: to show warnings. like admin creds already existed. +- `c.Logger.Successf`: to show that subtask/step is done successfully. + + +## How to + +### How can I add a global behaviour around input management? + +For example `silent` flag that affects how we resolve inputs. To be added out of the work in https://github.com/weaveworks/weave-gitops-enterprise/issues/3465 + +### How can I add a global behaviour around output management? +See the following examples: + +- https://github.com/weaveworks/weave-gitops-enterprise/tree/cli-dry-run +- https://github.com/weaveworks/weave-gitops-enterprise/tree/cli-export + + +## How generated manifests are kept up to date beyond cli lifecycle? + +This will be addressed in the following [ticket](https://github.com/weaveworks/weave-gitops-enterprise/issues/3405) + + diff --git a/go.mod b/go.mod index 3417ac0f1c..70ff764ea0 100644 --- a/go.mod +++ b/go.mod @@ -92,7 +92,6 @@ require ( github.com/fluxcd/pkg/tar v0.2.0 // indirect github.com/gitops-tools/pkg v0.1.0 // indirect github.com/google/go-containerregistry v0.12.0 // indirect - golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect ) require ( @@ -121,6 +120,7 @@ require ( github.com/mschoch/smat v0.2.0 // indirect go.etcd.io/bbolt v1.3.7 // indirect go.opentelemetry.io/otel/metric v0.37.0 // indirect + golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect google.golang.org/api v0.126.0 // indirect google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230530153820-e85fd2cbaebc // indirect @@ -222,7 +222,7 @@ require ( require ( github.com/bufbuild/connect-go v0.2.0 // indirect - github.com/chzyer/readline v1.5.0 // indirect + github.com/chzyer/readline v1.5.1 // indirect github.com/containerd/typeurl v1.0.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/dustin/go-humanize v1.0.0 // indirect @@ -235,7 +235,7 @@ require ( github.com/jhump/protoreflect v1.12.1-0.20220721211354-060cc04fc18b // indirect github.com/klauspost/cpuid v1.3.1 // indirect github.com/klauspost/pgzip v1.2.5 // indirect - github.com/manifoldco/promptui v0.9.0 // indirect + github.com/manifoldco/promptui v0.9.0 github.com/minio/md5-simd v1.1.0 // indirect github.com/minio/minio-go/v7 v7.0.31 // indirect github.com/minio/sha256-simd v1.0.0 // indirect @@ -400,7 +400,7 @@ require ( gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v2 v2.4.0 gorm.io/driver/sqlite v1.4.4 k8s.io/apiserver v0.26.3 k8s.io/component-base v0.27.3 // indirect diff --git a/go.sum b/go.sum index 86dee482a6..ca79848770 100644 --- a/go.sum +++ b/go.sum @@ -287,14 +287,14 @@ github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S github.com/cheshir/ttlcache v1.0.1-0.20220504185148-8ceeff21b789 h1:eWRC5oPQ3G4BtSv0hsHTB777h7iCZct8RCm6jrsozsg= github.com/cheshir/ttlcache v1.0.1-0.20220504185148-8ceeff21b789/go.mod h1:B9qWHhPE7FnRG2HNiPajGzOFX9NYcObDTkg3Ixh9Fzk= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/logex v1.2.0 h1:+eqR0HfOetur4tgnC8ftU5imRnhi4te+BadWS95c5AM= -github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/readline v1.5.0 h1:lSwwFrbNviGePhkewF1az4oLmcwqCZijQ2/Wi3BGHAI= -github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/chzyer/test v0.0.0-20210722231415-061457976a23 h1:dZ0/VyGgQdVGAss6Ju0dt5P0QltE0SFY5Woh6hbIfiQ= -github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/clbanning/mxj/v2 v2.3.3-0.20201214204241-e937bdee5a3e h1:h0bq6tdJcD7OQPQRJ0oInvx6//C04Vzeddt2kiJAD8Y= diff --git a/pkg/bootstrap/bootstrap.go b/pkg/bootstrap/bootstrap.go new file mode 100644 index 0000000000..ac0cba51d7 --- /dev/null +++ b/pkg/bootstrap/bootstrap.go @@ -0,0 +1,29 @@ +package bootstrap + +import ( + "github.com/weaveworks/weave-gitops-enterprise/pkg/bootstrap/steps" +) + +// Bootstrap initiated by the command runs the WGE bootstrap workflow +func Bootstrap(config steps.Config) error { + // TODO have a single workflow source of truth and documented in https://docs.gitops.weave.works/docs/0.33.0/enterprise/getting-started/install-enterprise/ + var steps = []steps.BootstrapStep{ + steps.VerifyFluxInstallation, + steps.CheckEntitlementSecret, + steps.NewAskPrivateKeyStep(config), + steps.NewSelectWgeVersionStep(config), + steps.NewAskAdminCredsSecretStep(config), + steps.NewSelectDomainType(config), + steps.NewInstallWGEStep(config), + steps.CheckUIDomainStep, + } + + for _, step := range steps { + config.Logger.Waitingf(step.Name) + err := step.Execute(&config) + if err != nil { + return err + } + } + return nil +} diff --git a/pkg/bootstrap/steps/admin_password.go b/pkg/bootstrap/steps/admin_password.go new file mode 100644 index 0000000000..b1a144fb5e --- /dev/null +++ b/pkg/bootstrap/steps/admin_password.go @@ -0,0 +1,154 @@ +package steps + +import ( + "fmt" + + "github.com/weaveworks/weave-gitops-enterprise/pkg/bootstrap/utils" + "golang.org/x/crypto/bcrypt" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + adminUsernameMsg = "dashboard admin username (default: wego-admin)" + adminPasswordMsg = "dashboard admin password (minimum characters: 6)" + secretConfirmationMsg = "admin login credentials has been created successfully!" + adminSecretExistsMsgFormat = "admin login credentials already exist on the cluster. To reset admin credentials please remove secret '%s' in namespace '%s', then try again" + existingCredsMsg = "do you want to continue using existing credentials" + existingCredsExitMsg = "if you want to reset admin credentials please remove secret '%s' in namespace '%s', then try again.\nExiting gitops bootstrap" +) + +const ( + adminSecretName = "cluster-user-auth" + confirmYes = "y" +) + +var getUsernameInput = StepInput{ + Name: UserName, + Type: stringInput, + Msg: adminUsernameMsg, + DefaultValue: defaultAdminUsername, + Valuesfn: canAskForCreds, +} + +var getPasswordInput = StepInput{ + Name: Password, + Type: passwordInput, + Msg: adminPasswordMsg, + DefaultValue: defaultAdminPassword, + Valuesfn: canAskForCreds, + Required: true, +} + +// NewAskAdminCredsSecretStep asks user about admin username and password. +// admin username and password are you used for accessing WGE Dashboard +// for emergency access. OIDC can be used instead. +// there an option to revert these creds in case OIDC setup is successful +// if the creds already exist. user will be asked to continue with the current creds +// Or existing and deleting the creds then re-run the bootstrap process +func NewAskAdminCredsSecretStep(config Config) BootstrapStep { + inputs := []StepInput{ + { + Name: existingCreds, + Type: confirmInput, + Msg: existingCredsMsg, + DefaultValue: "", + Valuesfn: isExistingAdminSecret, + StepInformation: fmt.Sprintf(adminSecretExistsMsgFormat, adminSecretName, WGEDefaultNamespace), + }, + } + + if config.Username == "" { + inputs = append(inputs, getUsernameInput) + } + + if config.Password == "" { + inputs = append(inputs, getPasswordInput) + } + + return BootstrapStep{ + Name: "user authentication", + Input: inputs, + Step: createCredentials, + } +} + +func createCredentials(input []StepInput, c *Config) ([]StepOutput, error) { + // search for existing admin credentials in secret cluster-user-auth + continueWithExistingCreds := confirmYes + for _, param := range input { + if param.Name == UserName { + username, ok := param.Value.(string) + if ok { + c.Username = username + } + } + if param.Name == Password { + password, ok := param.Value.(string) + if ok { + c.Password = password + } + } + if param.Name == existingCreds { + existing, ok := param.Value.(string) + if ok { + continueWithExistingCreds = existing + } + } + } + + if existing, _ := isExistingAdminSecret(input, c); existing.(bool) { + if continueWithExistingCreds != confirmYes { + return []StepOutput{}, fmt.Errorf(existingCredsExitMsg, adminSecretName, WGEDefaultNamespace) + } else { + return []StepOutput{}, nil + } + } + + encryptedPassword, err := bcrypt.GenerateFromPassword([]byte(c.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + data := map[string][]byte{ + "username": []byte(c.Username), + "password": encryptedPassword, + } + c.Logger.Actionf("dashboard admin username: %s", c.Username) + + secret := corev1.Secret{ + ObjectMeta: v1.ObjectMeta{ + Name: adminSecretName, + Namespace: WGEDefaultNamespace, + }, + Data: data, + } + c.Logger.Successf(secretConfirmationMsg) + + return []StepOutput{ + { + Name: adminSecretName, + Type: typeSecret, + Value: secret, + }, + }, nil + +} + +// isExistingAdminSecret checks for admin secret on management cluster +// returns true if admin secret is already on the cluster +// returns false if no admin secret on the cluster +func isExistingAdminSecret(input []StepInput, c *Config) (interface{}, error) { + _, err := utils.GetSecret(c.KubernetesClient, adminSecretName, WGEDefaultNamespace) + if err != nil { + return false, nil + } + return true, nil +} + +func canAskForCreds(input []StepInput, c *Config) (interface{}, error) { + if ask, _ := isExistingAdminSecret(input, c); ask.(bool) { + return false, nil + } + return true, nil +} diff --git a/pkg/bootstrap/steps/admin_password_test.go b/pkg/bootstrap/steps/admin_password_test.go new file mode 100644 index 0000000000..9bdbc9f392 --- /dev/null +++ b/pkg/bootstrap/steps/admin_password_test.go @@ -0,0 +1,174 @@ +package steps + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/weaveworks/weave-gitops/pkg/logger" + "golang.org/x/crypto/bcrypt" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestCreateCredentials(t *testing.T) { + tests := []struct { + name string + secret *v1.Secret + input []StepInput + password string + output []StepOutput + err bool + }{ + { + name: "secret doesn't exist", + secret: &v1.Secret{}, + password: "password", + input: []StepInput{ + { + Name: UserName, + Value: "wego-admin", + }, + { + Name: Password, + Value: "password", + }, + { + Name: existingCreds, + Value: false, + }, + }, + output: []StepOutput{ + { + Name: adminSecretName, + Type: typeSecret, + Value: v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: adminSecretName, + Namespace: WGEDefaultNamespace, + }, + Data: map[string][]byte{ + "username": []byte("wego-admin"), + }, + }, + }, + }, + err: false, + }, + { + name: "secret exist and user refuse to continue", + secret: &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: adminSecretName, Namespace: WGEDefaultNamespace}, + Type: "Opaque", + Data: map[string][]byte{ + "username": []byte("test-username"), + "password": []byte("test-password"), + }, + }, + password: "password", + input: []StepInput{ + { + Name: UserName, + Value: "wego-admin", + }, + { + Name: Password, + Value: "password", + }, + { + Name: existingCreds, + Value: "n", + }, + }, + err: true, + }, + { + name: "secret exist and user continue", + secret: &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: adminSecretName, Namespace: WGEDefaultNamespace}, + Type: "Opaque", + Data: map[string][]byte{ + "username": []byte("test-username"), + "password": []byte("test-password"), + }, + }, + password: "password", + input: []StepInput{ + { + Name: UserName, + Value: "wego-admin", + }, + { + Name: Password, + Value: "password", + }, + { + Name: existingCreds, + Value: "y", + }, + }, + output: []StepOutput{ + { + Name: adminSecretName, + Type: typeSecret, + Value: v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: adminSecretName, + Namespace: WGEDefaultNamespace, + }, + Data: map[string][]byte{ + "username": []byte("wego-admin"), + }, + }, + }, + }, + err: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + schemeBuilder := runtime.SchemeBuilder{ + v1.AddToScheme, + } + err := schemeBuilder.AddToScheme(scheme) + if err != nil { + t.Fatal(err) + } + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(tt.secret).Build() + cliLogger := logger.NewCLILogger(os.Stdout) + + config := Config{ + KubernetesClient: fakeClient, + Logger: cliLogger, + } + out, err := createCredentials(tt.input, &config) + if err != nil { + if tt.err { + return + } + t.Fatalf("error creating creds: %v", err) + } + + for i, item := range out { + assert.Equal(t, item.Name, tt.output[i].Name) + assert.Equal(t, item.Type, tt.output[i].Type) + outSecret, ok := item.Value.(v1.Secret) + if !ok { + t.Fatalf("failed getting result secret data") + } + inSecret, ok := tt.output[i].Value.(v1.Secret) + if !ok { + t.Fatalf("failed getting output secret data") + } + assert.Equal(t, outSecret.Name, inSecret.Name, "mismatch name") + assert.Equal(t, outSecret.Namespace, inSecret.Namespace, "mismatch namespace") + assert.Equal(t, outSecret.Data["username"], inSecret.Data["username"], "mismatch username") + assert.NoError(t, bcrypt.CompareHashAndPassword(outSecret.Data["password"], []byte(tt.password)), "mismatch password") + } + }) + } +} diff --git a/pkg/bootstrap/steps/config.go b/pkg/bootstrap/steps/config.go new file mode 100644 index 0000000000..4361ef346c --- /dev/null +++ b/pkg/bootstrap/steps/config.go @@ -0,0 +1,229 @@ +package steps + +import ( + "errors" + "fmt" + "os" + + "github.com/weaveworks/weave-gitops-enterprise/pkg/bootstrap/utils" + "github.com/weaveworks/weave-gitops/pkg/logger" + k8s_client "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + defaultAdminUsername = "wego-admin" + defaultAdminPassword = "password" +) + +// inputs names +const ( + UserName = "username" + Password = "password" + WGEVersion = "wgeVersion" + UserDomain = "userDomain" + PrivateKeyPath = "privateKeyPath" + PrivateKeyPassword = "privateKeyPassword" + existingCreds = "existingCreds" + domainType = "domainType" +) + +// input/output types +const ( + failureMsg = "failureMsg" + multiSelectionChoice = "multiSelect" + stringInput = "string" + passwordInput = "password" + confirmInput = "confirm" + typeSecret = "secret" + typeFile = "file" + typePortforward = "portforward" +) + +// ConfigBuilder contains all the different configuration options that a user can introduce +type ConfigBuilder struct { + logger logger.Logger + kubeconfig string + username string + password string + wGEVersion string + domainType string + domain string + privateKeyPath string + privateKeyPassword string +} + +func NewConfigBuilder() *ConfigBuilder { + return &ConfigBuilder{} +} + +func (c *ConfigBuilder) WithLogWriter(logger logger.Logger) *ConfigBuilder { + c.logger = logger + return c +} + +func (c *ConfigBuilder) WithUsername(username string) *ConfigBuilder { + c.username = username + return c +} + +func (c *ConfigBuilder) WithPassword(password string) *ConfigBuilder { + c.password = password + return c +} + +func (c *ConfigBuilder) WithKubeconfig(kubeconfig string) *ConfigBuilder { + c.kubeconfig = kubeconfig + return c +} + +func (c *ConfigBuilder) WithVersion(version string) *ConfigBuilder { + c.wGEVersion = version + return c +} + +func (c *ConfigBuilder) WithDomainType(domainType string) *ConfigBuilder { + c.domainType = domainType + return c + +} + +func (c *ConfigBuilder) WithDomain(domain string) *ConfigBuilder { + c.domain = domain + return c + +} + +func (c *ConfigBuilder) WithPrivateKey(privateKeyPath string, privateKeyPassword string) *ConfigBuilder { + c.privateKeyPath = privateKeyPath + c.privateKeyPassword = privateKeyPassword + return c +} + +// Config is the configuration struct to user for WGE installation. It includes +// configuration values as well as other required structs like clients +type Config struct { + KubernetesClient k8s_client.Client + Logger logger.Logger + + ExistsWgeVersion string // existing wge version in the cluster + WGEVersion string // user want this version in the cluster + + Username string // cluster user username + Password string // cluster user password + + DomainType string + UserDomain string + + PrivateKeyPath string + PrivateKeyPassword string +} + +// Builds creates a valid config so boostrap could be executed. It uses values introduced +// and checks the requirements for the environments. +func (cb *ConfigBuilder) Build() (Config, error) { + l := cb.logger + l.Actionf("creating client to cluster") + kubernetesClient, err := utils.GetKubernetesClient(cb.kubeconfig) + if err != nil { + return Config{}, fmt.Errorf("failed to get kubernetes client. error: %s", err) + } + context, err := utils.GetCurrentContext(cb.kubeconfig) + if err != nil { + return Config{}, fmt.Errorf("failed to get kubernetes current context. error: %s", err) + } + l.Successf("created client to cluster %s", context) + + // validate ssh keys + if cb.privateKeyPath != "" { + _, err = os.ReadFile(cb.privateKeyPath) + if err != nil { + return Config{}, fmt.Errorf("cannot read ssh key: %v", err) + } + } + + if cb.password != "" && len(cb.password) < 6 { + return Config{}, errors.New("password minimum characters should be >= 6") + } + + //TODO we should do validations in case invalid values and throw an error early + return Config{ + KubernetesClient: kubernetesClient, + WGEVersion: cb.wGEVersion, + Username: cb.username, + Password: cb.password, + Logger: cb.logger, + DomainType: cb.domainType, + UserDomain: cb.domain, + PrivateKeyPath: cb.privateKeyPath, + PrivateKeyPassword: cb.privateKeyPassword, + }, nil + +} + +type fileContent struct { + Name string + Content string + CommitMsg string +} + +// ValuesFile store the wge values +type valuesFile struct { + Config ValuesWGEConfig `json:"config,omitempty"` + Ingress map[string]interface{} `json:"ingress,omitempty"` + TLS map[string]interface{} `json:"tls,omitempty"` + PolicyAgent map[string]interface{} `json:"policy-agent,omitempty"` + PipelineController map[string]interface{} `json:"pipeline-controller,omitempty"` + GitOpsSets map[string]interface{} `json:"gitopssets-controller,omitempty"` + EnablePipelines bool `json:"enablePipelines,omitempty"` + EnableTerraformUI bool `json:"enableTerraformUI,omitempty"` + Global global `json:"global,omitempty"` + ClusterController clusterController `json:"cluster-controller,omitempty"` +} + +// ValuesWGEConfig store the wge values config field +type ValuesWGEConfig struct { + CAPI map[string]interface{} `json:"capi,omitempty"` + OIDC map[string]interface{} `json:"oidc,omitempty"` +} + +// ClusterController store the wge values cluster controller field +type clusterController struct { + Enabled bool `json:"enabled,omitempty"` + FullNameOverride string `json:"fullnameOverride,omitempty"` + ControllerManager clusterControllerManager `json:"controllerManager,omitempty"` +} + +// ClusterController store the wge values clustercontrollermanager field +type clusterControllerManager struct { + Manager clusterControllerManagerManager `json:"manager,omitempty"` +} + +// ClusterControllerManagerManager store the wge values clustercontrollermanager manager field +type clusterControllerManagerManager struct { + Image clusterControllerImage `json:"image,omitempty"` +} + +// ClusterControllerManagerManager store the wge values clustercontrollermanager image field +type clusterControllerImage struct { + Repository string `json:"repository,omitempty"` + Tag string `json:"tag,omitempty"` +} + +// Global store the global variables +type global struct { + CapiEnabled bool `json:"capiEnabled,omitempty"` +} + +// HelmChartResponse store the chart versions response +type helmChartResponse struct { + ApiVersion string + Entries map[string][]chartEntry + Generated string +} + +// ChartEntry store the HelmChartResponse entries +type chartEntry struct { + ApiVersion string + Name string + Version string +} diff --git a/pkg/bootstrap/steps/domain_type.go b/pkg/bootstrap/steps/domain_type.go new file mode 100644 index 0000000000..d72778fc30 --- /dev/null +++ b/pkg/bootstrap/steps/domain_type.go @@ -0,0 +1,65 @@ +package steps + +import ( + "errors" +) + +const ( + domainStepName = "dashboard access" + domainMsg = "select dashboard access domain type" +) +const ( + domainTypeLocalhost = "localhost" + domainTypeExternalDNS = "externalDNS" +) + +var ( + domainTypes = []string{ + domainTypeLocalhost, + domainTypeExternalDNS, + } +) + +var getDomainType = StepInput{ + Name: domainType, + Type: multiSelectionChoice, + Msg: domainMsg, + Values: domainTypes, + DefaultValue: "", +} + +func NewSelectDomainType(config Config) BootstrapStep { + inputs := []StepInput{} + + switch config.DomainType { + case domainTypeLocalhost: + break + case domainTypeExternalDNS: + break + default: + inputs = append(inputs, getDomainType) + } + + return BootstrapStep{ + Name: domainStepName, + Input: inputs, + Step: selectDomainType, + } +} + +func selectDomainType(input []StepInput, c *Config) ([]StepOutput, error) { + for _, param := range input { + if param.Name == domainType { + domainType, ok := param.Value.(string) + if !ok { + return []StepOutput{}, errors.New("unexpected error occurred. domainType is not found") + } + c.DomainType = domainType + } + } + if c.DomainType == "" { + return []StepOutput{}, errors.New("unexpected error occurred. domainType is not found") + } + c.Logger.Successf("dashboard access domain: %s", c.DomainType) + return []StepOutput{}, nil +} diff --git a/pkg/bootstrap/steps/domain_type_test.go b/pkg/bootstrap/steps/domain_type_test.go new file mode 100644 index 0000000000..764af4a512 --- /dev/null +++ b/pkg/bootstrap/steps/domain_type_test.go @@ -0,0 +1,53 @@ +package steps + +import ( + "os" + "testing" + + "github.com/weaveworks/weave-gitops/pkg/logger" +) + +func TestSelectDomainType(t *testing.T) { + tests := []struct { + name string + input []StepInput + err bool + }{ + { + name: "domain type exist", + input: []StepInput{ + { + Name: domainType, + Value: "localhost", + }, + }, + err: false, + }, + { + name: "domain type doesn't exist", + input: []StepInput{ + { + Name: "anothervalue", + Value: "localhost", + }, + }, + err: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cliLogger := logger.NewCLILogger(os.Stdout) + config := Config{ + Logger: cliLogger, + } + _, err := selectDomainType(tt.input, &config) + if err != nil { + if tt.err { + return + } + t.Fatalf("error getting domain type: %v", err) + } + + }) + } +} diff --git a/pkg/bootstrap/steps/entitlement.go b/pkg/bootstrap/steps/entitlement.go new file mode 100644 index 0000000000..0151162fb5 --- /dev/null +++ b/pkg/bootstrap/steps/entitlement.go @@ -0,0 +1,78 @@ +package steps + +import ( + _ "embed" + "errors" + "fmt" + "strings" + "time" + + "github.com/weaveworks/weave-gitops-enterprise-credentials/pkg/entitlement" + "github.com/weaveworks/weave-gitops-enterprise/pkg/bootstrap/utils" + k8s_client "sigs.k8s.io/controller-runtime/pkg/client" +) + +// user messages +const ( + entitlementCheckConfirmMsg = "entitlement file exists and is valid" + nonExistingEntitlementSecretMsg = "entitlement file is not found, To get Weave GitOps Entitelment secret, please contact *sales@weave.works* and add it to your cluster" + invalidEntitlementSecretMsg = "entitlement file is invalid, please verify the secret content. If you still facing issues, please contact *sales@weave.works*" + expiredEntitlementSecretMsg = "entitlement file is expired at: %s, please contact *sales@weave.works*" + entitlementCheckMsg = "verifying Weave GitOps Entitlement File" +) + +// wge consts +const ( + entitlementSecretName = "weave-gitops-enterprise-credentials" +) + +var ( + //go:embed public.pem + publicKey string +) + +var CheckEntitlementSecret = BootstrapStep{ + Name: "checking entitlement", + Step: checkEntitlementSecret, +} + +func checkEntitlementSecret(input []StepInput, c *Config) ([]StepOutput, error) { + c.Logger.Actionf(entitlementCheckMsg) + err := verifyEntitlementSecret(c.KubernetesClient) + if err != nil { + return []StepOutput{}, err + } + c.Logger.Successf(entitlementCheckConfirmMsg) + + return []StepOutput{}, nil +} + +// verifyEntitlementSecret ensures the entitlement is valid and not expired also verifying username & password +// verifing entitlement by the public key (private key is used for encrypting and public is for verification) +// and making sure it's not expired +// verifying username and password by making http request for downloading charts and ensuring it's authenticated +func verifyEntitlementSecret(client k8s_client.Client) error { + secret, err := utils.GetSecret(client, entitlementSecretName, WGEDefaultNamespace) + if err != nil { + return fmt.Errorf("%s: %v", nonExistingEntitlementSecretMsg, err) + } + + if secret.Data["entitlement"] == nil || secret.Data["username"] == nil || secret.Data["password"] == nil { + return errors.New(invalidEntitlementSecretMsg) + } + + ent, err := entitlement.VerifyEntitlement(strings.NewReader(string(publicKey)), string(secret.Data["entitlement"])) + if err != nil { + return fmt.Errorf("%s: %v", invalidEntitlementSecretMsg, err) + } + if time.Now().Compare(ent.LicencedUntil) >= 0 { + return fmt.Errorf(expiredEntitlementSecretMsg, ent.LicencedUntil) + } + + body, err := doBasicAuthGetRequest(wgeChartUrl, string(secret.Data["username"]), string(secret.Data["password"])) + if err != nil || body == nil { + return fmt.Errorf("%s: %v", invalidEntitlementSecretMsg, err) + } + + return nil +} diff --git a/pkg/bootstrap/steps/entitlement_test.go b/pkg/bootstrap/steps/entitlement_test.go new file mode 100644 index 0000000000..95b321fcd3 --- /dev/null +++ b/pkg/bootstrap/steps/entitlement_test.go @@ -0,0 +1,85 @@ +package steps + +import ( + "os" + "testing" + + "github.com/weaveworks/weave-gitops/pkg/logger" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// CheckEntitlementFile test CheckEntitlementFile +func TestCheckEntitlementFile(t *testing.T) { + var ( + expiredEntitlement = `eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJsaWNlbmNlZFVudGlsIjoxNjMxMzYxMjg2LCJpYXQiOjE2MzEyNzQ4ODYsImlzcyI6InNhbGVzQHdlYXZlLndvcmtzIiwibmJmIjoxNjMxMjc0ODg2LCJzdWIiOiJ0ZXN0QHdlYXZlLndvcmtzIn0.EKGp89DFcRKZ_kGmC8FuLVPB0wiab2KddkQKAmVNC9UH459v63tCP13eFybx9dAmMuaC77SA8rp7ukN1qZM7DA` + invalidEntitlement = `eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJsaWNlbmNlZFVudGlsIjoxNjMxMzYxNDkwLCJpYXQiOjE2MzEyNzUwOTAsImlzcyI6InNhbGVzQHdlYXZlLndvcmtzIiwibmJmIjoxNjMxMjc1MDkwLCJzdWIiOiJ0ZXN0QHdlYXZlLndvcmtzIn0.E3Kfg4YzDOYJsTN9lD6B4uoW29tE0IB9X7lOpirSTwcZ7vVHk5PUXznYdiPIi9aSgLGAPIQL3YkAM4lyft3BDg` + ) + + tests := []struct { + name string + secret *v1.Secret + err bool + }{ + { + name: "secret does not exist", + secret: &v1.Secret{}, + err: true, + }, + { + name: "invalid entitlement", + secret: &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: entitlementSecretName, Namespace: WGEDefaultNamespace}, + Type: "Opaque", + Data: map[string][]byte{ + "entitlement": []byte(invalidEntitlement), + "username": []byte("test-username"), + "password": []byte("test-password"), + }, + }, + err: true, + }, + { + name: "expired entitlement", + secret: &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: entitlementSecretName, Namespace: WGEDefaultNamespace}, + Type: "Opaque", + Data: map[string][]byte{ + "entitlement": []byte(expiredEntitlement), + "username": []byte("test-username"), + "password": []byte("test-password"), + }, + }, + err: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + schemeBuilder := runtime.SchemeBuilder{ + v1.AddToScheme, + } + err := schemeBuilder.AddToScheme(scheme) + if err != nil { + t.Fatal(err) + } + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(tt.secret).Build() + cliLogger := logger.NewCLILogger(os.Stdout) + config := Config{ + Logger: cliLogger, + KubernetesClient: fakeClient, + } + _, err = checkEntitlementSecret([]StepInput{}, &config) + if err != nil { + if tt.err { + return + } + t.Fatalf("error validating entitlement: %v", err) + } + }) + } + +} diff --git a/pkg/bootstrap/steps/flux.go b/pkg/bootstrap/steps/flux.go new file mode 100644 index 0000000000..fb4c7defa6 --- /dev/null +++ b/pkg/bootstrap/steps/flux.go @@ -0,0 +1,42 @@ +package steps + +import ( + "fmt" + + "github.com/weaveworks/weave-gitops/pkg/runner" +) + +// user messages +const ( + fluxBoostrapCheckMsg = "checking flux" + fluxExistingInstallMsg = "flux is installed" + fluxExistingBootstrapMsg = "flux is bootstrapped" + fluxRecoverMsg = "please bootstrap Flux in 'flux-system' namespace: more info https://fluxcd.io/flux/installation" +) + +// VerifyFluxInstallation checks that Flux is present in the cluster. It fails in case not and returns next steps to install it. +var VerifyFluxInstallation = BootstrapStep{ + Name: fluxBoostrapCheckMsg, + Step: verifyFluxInstallation, +} + +// VerifyFluxInstallation checks for valid flux installation. +func verifyFluxInstallation(input []StepInput, c *Config) ([]StepOutput, error) { + var runner runner.CLIRunner + + c.Logger.Actionf("verifying flux installation") + out, err := runner.Run("flux", "check") + if err != nil { + return []StepOutput{}, fmt.Errorf("flux installed error: %v. %s", string(out), fluxRecoverMsg) + } + c.Logger.Successf(fluxExistingInstallMsg) + + c.Logger.Actionf("verifying flux reconcillation") + out, err = runner.Run("flux", "reconcile", "kustomization", "flux-system") + if err != nil { + return []StepOutput{}, fmt.Errorf("flux bootstrapped error: %v. %s", string(out), fluxRecoverMsg) + } + c.Logger.Successf(fluxExistingBootstrapMsg) + + return []StepOutput{}, nil +} diff --git a/pkg/bootstrap/steps/install_wge.go b/pkg/bootstrap/steps/install_wge.go new file mode 100644 index 0000000000..585394c285 --- /dev/null +++ b/pkg/bootstrap/steps/install_wge.go @@ -0,0 +1,248 @@ +package steps + +import ( + "encoding/json" + "fmt" + "time" + + helmv2 "github.com/fluxcd/helm-controller/api/v2beta1" + "github.com/fluxcd/pkg/apis/meta" + sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" + "github.com/weaveworks/weave-gitops-enterprise/pkg/bootstrap/utils" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + externalDNSWarningMsg = `please make sure to have the external DNS service installed in your cluster, or you have a domain that points to your cluster.` + clusterDomainMsg = "please enter your cluster domain" + + wgeInstallMsg = "installing v%s ... It may take a few minutes." +) + +const ( + wgeHelmRepoCommitMsg = "Add WGE HelmRepository YAML file" + wgeHelmReleaseCommitMsg = "Add WGE HelmRelease YAML file" + wgeChartName = "mccp" + wgeHelmRepositoryName = "weave-gitops-enterprise-charts" + WgeHelmReleaseName = "weave-gitops-enterprise" + WGEDefaultNamespace = "flux-system" + WGEDefaultRepoName = "flux-system" + wgeHelmrepoFileName = "wge-hrepo.yaml" + wgeHelmReleaseFileName = "wge-hrelease.yaml" + wgeChartUrl = "https://charts.dev.wkp.weave.works/releases/charts-v3" + clusterControllerFullOverrideName = "cluster" + clusterControllerImageName = "docker.io/weaveworks/cluster-controller" + clusterControllerImageTag = "v1.5.2" + gitopssetsEnabledGenerators = "GitRepository,Cluster,PullRequests,List,APIClient,Matrix,Config" + gitopssetsBindAddress = "127.0.0.1:8080" + gitopssetsHealthBindAddress = ":8081" +) + +var getUserDomain = StepInput{ + Name: UserDomain, + Type: stringInput, + Msg: clusterDomainMsg, + DefaultValue: "", + Valuesfn: isUserDomainEnabled, +} + +func NewInstallWGEStep(config Config) BootstrapStep { + inputs := []StepInput{} + + if config.UserDomain == "" { + inputs = append(inputs, getUserDomain) + } + + return BootstrapStep{ + Name: "install Weave Gitops Enterprise", + Input: inputs, + Step: installWge, + Output: []StepOutput{}, + } +} + +// InstallWge installs weave gitops enterprise chart. +func installWge(input []StepInput, c *Config) ([]StepOutput, error) { + switch c.DomainType { + case domainTypeLocalhost: + c.UserDomain = domainTypeLocalhost + case domainTypeExternalDNS: + if c.UserDomain == "" { + for _, param := range input { + if param.Name == UserDomain { + userDomain, ok := param.Value.(string) + if !ok { + return []StepOutput{}, fmt.Errorf("unexpected error occurred. UserDomain not found") + } + c.UserDomain = userDomain + } + } + } + default: + return []StepOutput{}, fmt.Errorf("unsupported domain type:%s", c.DomainType) + } + + c.Logger.Actionf(wgeInstallMsg, c.WGEVersion) + + wgehelmRepo, err := constructWgeHelmRepository() + if err != nil { + return []StepOutput{}, err + } + c.Logger.Actionf("rendered HelmRepository file") + + gitOpsSetsValues := map[string]interface{}{ + "enabled": true, + "controllerManager": map[string]interface{}{ + "manager": map[string]interface{}{ + "args": []string{ + fmt.Sprintf("--health-probe-bind-address=%s", gitopssetsHealthBindAddress), + fmt.Sprintf("--metrics-bind-address=%s", gitopssetsBindAddress), + "--leader-elect", + fmt.Sprintf("--enabled-generators=%s", gitopssetsEnabledGenerators), + }, + }, + }, + } + + clusterController := clusterController{ + Enabled: true, + FullNameOverride: clusterControllerFullOverrideName, + ControllerManager: clusterControllerManager{ + Manager: clusterControllerManagerManager{ + Image: clusterControllerImage{ + Repository: clusterControllerImageName, + Tag: clusterControllerImageTag, + }, + }, + }} + + values := valuesFile{ + Ingress: constructIngressValues(c.UserDomain), + TLS: map[string]interface{}{ + "enabled": false, + }, + GitOpsSets: gitOpsSetsValues, + EnablePipelines: true, + ClusterController: clusterController, + } + + wgeHelmRelease, err := constructWGEhelmRelease(values, c.WGEVersion) + if err != nil { + return []StepOutput{}, err + } + c.Logger.Actionf("rendered HelmRelease file") + + helmrepoFile := fileContent{ + Name: wgeHelmrepoFileName, + Content: wgehelmRepo, + CommitMsg: wgeHelmRepoCommitMsg, + } + helmreleaseFile := fileContent{ + Name: wgeHelmReleaseFileName, + Content: wgeHelmRelease, + CommitMsg: wgeHelmReleaseCommitMsg, + } + + return []StepOutput{ + { + Name: wgeHelmrepoFileName, + Type: typeFile, + Value: helmrepoFile, + }, + { + Name: wgeHelmReleaseFileName, + Type: typeFile, + Value: helmreleaseFile, + }, + }, nil +} + +func constructWgeHelmRepository() (string, error) { + wgeHelmRepo := sourcev1.HelmRepository{ + ObjectMeta: v1.ObjectMeta{ + Name: wgeHelmRepositoryName, + Namespace: WGEDefaultNamespace, + }, + Spec: sourcev1.HelmRepositorySpec{ + URL: wgeChartUrl, + Interval: v1.Duration{ + Duration: time.Minute, + }, + SecretRef: &meta.LocalObjectReference{ + Name: entitlementSecretName, + }, + }, + } + + return utils.CreateHelmRepositoryYamlString(wgeHelmRepo) +} + +func constructIngressValues(userDomain string) map[string]interface{} { + ingressValues := map[string]interface{}{ + "annotations": map[string]string{ + "external-dns.alpha.kubernetes.io/hostname": userDomain, + }, + "className": "public-nginx", + "enabled": true, + "hosts": []map[string]interface{}{ + { + "host": userDomain, + "paths": []map[string]string{ + { + "path": "/", + "pathType": "ImplementationSpecific", + }, + }, + }, + }, + } + + return ingressValues +} + +func constructWGEhelmRelease(valuesFile valuesFile, chartVersion string) (string, error) { + valuesBytes, err := json.Marshal(valuesFile) + if err != nil { + return "", err + } + + wgeHelmRelease := helmv2.HelmRelease{ + ObjectMeta: v1.ObjectMeta{ + Name: WgeHelmReleaseName, + Namespace: WGEDefaultNamespace, + }, Spec: helmv2.HelmReleaseSpec{ + Chart: helmv2.HelmChartTemplate{ + Spec: helmv2.HelmChartTemplateSpec{ + Chart: wgeChartName, + ReconcileStrategy: sourcev1.ReconcileStrategyChartVersion, + SourceRef: helmv2.CrossNamespaceObjectReference{ + Name: wgeHelmRepositoryName, + Namespace: WGEDefaultNamespace, + }, + Version: chartVersion, + }, + }, + Install: &helmv2.Install{ + CRDs: helmv2.CreateReplace, + }, + Upgrade: &helmv2.Upgrade{ + CRDs: helmv2.CreateReplace, + }, + Interval: v1.Duration{ + Duration: time.Hour, + }, + Values: &apiextensionsv1.JSON{Raw: valuesBytes}, + }, + } + + return utils.CreateHelmReleaseYamlString(wgeHelmRelease) +} + +func isUserDomainEnabled(input []StepInput, c *Config) (interface{}, error) { + if c.DomainType == domainTypeExternalDNS { + c.Logger.L().Info(externalDNSWarningMsg) + return true, nil + } + return false, nil +} diff --git a/pkg/bootstrap/steps/private_key.go b/pkg/bootstrap/steps/private_key.go new file mode 100644 index 0000000000..fe42bf3964 --- /dev/null +++ b/pkg/bootstrap/steps/private_key.go @@ -0,0 +1,62 @@ +package steps + +import ( + "fmt" + "os" +) + +const ( + privateKeyMsg = "private key path and password\nDisclaimer: private key will be used to push WGE resources into the default repository only. It won't be stored or used anywhere else for any reason." + privateKeyPathMsg = "private key path" + privateKeyPasswordMsg = "private key password" +) + +var ( + privateKeyDefaultPath = fmt.Sprintf("%s/.ssh/id_rsa", os.Getenv("HOME")) +) + +var getKeyPath = StepInput{ + Name: PrivateKeyPath, + Type: stringInput, + Msg: privateKeyPathMsg, + DefaultValue: privateKeyDefaultPath, +} + +var getKeyPassword = StepInput{ + Name: PrivateKeyPassword, + Type: passwordInput, + Msg: privateKeyPasswordMsg, + DefaultValue: "", +} + +func NewAskPrivateKeyStep(config Config) BootstrapStep { + inputs := []StepInput{} + + if config.PrivateKeyPath == "" { + inputs = append(inputs, getKeyPath) + inputs = append(inputs, getKeyPassword) + } + return BootstrapStep{ + Name: privateKeyMsg, + Input: inputs, + Step: configurePrivateKey, + } +} + +func configurePrivateKey(input []StepInput, c *Config) ([]StepOutput, error) { + for _, param := range input { + if param.Name == PrivateKeyPath { + privateKeyPath, ok := param.Value.(string) + if ok { + c.PrivateKeyPath = privateKeyPath + } + } + if param.Name == PrivateKeyPassword { + privateKeyPassword, ok := param.Value.(string) + if ok { + c.PrivateKeyPassword = privateKeyPassword + } + } + } + return []StepOutput{}, nil +} diff --git a/pkg/bootstrap/steps/public.pem b/pkg/bootstrap/steps/public.pem new file mode 100644 index 0000000000..fd210c6913 --- /dev/null +++ b/pkg/bootstrap/steps/public.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEA140z8yf4+R9MQwwS6yTrWIl/1IBOjLVvh9x87Wd84TU= +-----END PUBLIC KEY----- diff --git a/pkg/bootstrap/steps/step.go b/pkg/bootstrap/steps/step.go new file mode 100644 index 0000000000..1c11a06858 --- /dev/null +++ b/pkg/bootstrap/steps/step.go @@ -0,0 +1,195 @@ +package steps + +import ( + "fmt" + + "github.com/weaveworks/weave-gitops-enterprise/pkg/bootstrap/utils" + v1 "k8s.io/api/core/v1" +) + +// BootstrapStep struct that defines the contract of a bootstrapping step. +// It is abstracted to have a generic way to handle them, so we could achieve easier +// extensibility, consistency and maintainability. +type BootstrapStep struct { + Name string + Input []StepInput + Output []StepOutput + Step func(input []StepInput, c *Config) ([]StepOutput, error) +} + +// StepInput represents an input an step requires to execute it. for example the +// user needs to introduce an string or a password. +type StepInput struct { + Name string + Msg string + StepInformation string + Type string + DefaultValue any + Value any + Values []string + Valuesfn func(input []StepInput, c *Config) (interface{}, error) + Required bool +} + +// StepOutput represents an output generated out of the execution of a step. +// An example could be a helm release manifest for weave gitops. +type StepOutput struct { + Name string + Type string + Value any +} + +func (s BootstrapStep) Execute(c *Config) error { + inputValues, err := defaultInputStep(s.Input, c) + if err != nil { + return fmt.Errorf("cannot process input '%s': %v", s.Name, err) + } + + outputs, err := s.Step(inputValues, c) + if err != nil { + return fmt.Errorf("cannot execute '%s': %v", s.Name, err) + } + + err = defaultOutputStep(outputs, c) + if err != nil { + return fmt.Errorf("cannot process output '%s': %v", s.Name, err) + } + return nil +} + +func defaultInputStep(inputs []StepInput, c *Config) ([]StepInput, error) { + processedInputs := []StepInput{} + for _, input := range inputs { + switch input.Type { + case stringInput: + // verify the input is enabled by executing the function + enable := true + if input.Valuesfn != nil { + res, _ := input.Valuesfn(inputs, c) + enable = res.(bool) + if enable && input.StepInformation != "" { + c.Logger.Warningf(input.StepInformation) + } + } + // get the value from user otherwise + if input.Value == nil && enable { + paramValue, err := utils.GetStringInput(input.Msg, input.DefaultValue.(string)) + if err != nil { + return []StepInput{}, err + } + input.Value = paramValue + } + // fill the new inputs + processedInputs = append(processedInputs, input) + case passwordInput: + // verify the input is enabled by executing the function + enable := true + if input.Valuesfn != nil { + res, _ := input.Valuesfn(inputs, c) + enable = res.(bool) + if enable && input.StepInformation != "" { + c.Logger.Warningf(input.StepInformation) + } + } + // get the value from user otherwise + if input.Value == nil && enable { + paramValue, err := utils.GetPasswordInput(input.Msg, input.Required) + if err != nil { + return []StepInput{}, err + } + input.Value = paramValue + } + processedInputs = append(processedInputs, input) + case confirmInput: + // verify the input is enabled by executing the function + enable := true + if input.Valuesfn != nil { + res, _ := input.Valuesfn(inputs, c) + enable = res.(bool) + if enable && input.StepInformation != "" { + c.Logger.Warningf(input.StepInformation) + } + } + // get the value from user otherwise + if input.Value == nil && enable { + input.Value = utils.GetConfirmInput(input.Msg) + } + processedInputs = append(processedInputs, input) + case multiSelectionChoice: + // process the values from the function + var values []string = input.Values + if input.Valuesfn != nil { + res, err := input.Valuesfn(inputs, c) + if err != nil { + return []StepInput{}, err + } + values = res.([]string) + } + // get the values from user + if input.Value == nil { + paramValue, err := utils.GetSelectInput(input.Msg, values) + if err != nil { + return []StepInput{}, err + } + input.Value = paramValue + } + processedInputs = append(processedInputs, input) + default: + return []StepInput{}, fmt.Errorf("input not supported: %s", input.Name) + } + } + return processedInputs, nil +} + +func defaultOutputStep(params []StepOutput, c *Config) error { + for _, param := range params { + switch param.Type { + case typeSecret: + secret, ok := param.Value.(v1.Secret) + if !ok { + panic("unexpected internal error casting secret") + } + name := secret.ObjectMeta.Name + namespace := secret.ObjectMeta.Namespace + data := secret.Data + c.Logger.Actionf("creating secret: %s/%s", namespace, name) + if err := utils.CreateSecret(c.KubernetesClient, name, namespace, data); err != nil { + return err + } + c.Logger.Successf("created secret %s/%s", secret.Namespace, secret.Name) + case typeFile: + c.Logger.Actionf("write file to repo: %s", param.Name) + file, ok := param.Value.(fileContent) + if !ok { + panic("unexpected internal error casting file") + } + c.Logger.Actionf("cloning flux git repo: %s/%s", WGEDefaultNamespace, WGEDefaultRepoName) + pathInRepo, err := utils.CloneRepo(c.KubernetesClient, WGEDefaultRepoName, WGEDefaultNamespace, c.PrivateKeyPath, c.PrivateKeyPassword) + if err != nil { + return fmt.Errorf("cannot clone repo: %v", err) + } + defer func() { + err = utils.CleanupRepo() + if err != nil { + c.Logger.Failuref("failed to cleanup repo!") + } + }() + c.Logger.Successf("cloned flux git repo: %s/%s", WGEDefaultRepoName, WGEDefaultRepoName) + + err = utils.CreateFileToRepo(file.Name, file.Content, pathInRepo, file.CommitMsg, c.PrivateKeyPath, c.PrivateKeyPassword) + if err != nil { + return err + } + c.Logger.Successf("file committed to repo: %s", file.Name) + + c.Logger.Waitingf("reconciling changes") + if err := utils.ReconcileFlux(); err != nil { + return err + } + c.Logger.Successf("changes are reconciled successfully!") + default: + return fmt.Errorf("unsupported param type: %s", param.Type) + } + } + return nil +} diff --git a/pkg/bootstrap/steps/success_page.go b/pkg/bootstrap/steps/success_page.go new file mode 100644 index 0000000000..4107549c41 --- /dev/null +++ b/pkg/bootstrap/steps/success_page.go @@ -0,0 +1,35 @@ +package steps + +import ( + "strings" + + "github.com/weaveworks/weave-gitops-enterprise/pkg/bootstrap/utils" +) + +const ( + installSuccessMsg = "WGE v%s is installed successfully\nYou can visit the UI at https://%s/" + portforwardMsg = "WGE v%s is installed successfully. To access the dashboard, run the following command to create portforward to the dasboard local domain http://localhost:8000" + portforwardCmdMsg = "kubectl -n %s port-forward svc/clusters-service 8000:8000" +) + +var ( + CheckUIDomainStep = BootstrapStep{ + Name: "preparing dashboard domain", + Step: checkUIDomain, + } +) + +// checkUIDomain display the message to be for external dns or localhost. +func checkUIDomain(input []StepInput, c *Config) ([]StepOutput, error) { + if err := utils.ReconcileHelmRelease(WgeHelmReleaseName); err != nil { + return []StepOutput{}, err + } + if !strings.Contains(c.UserDomain, domainTypeLocalhost) { + c.Logger.Successf(installSuccessMsg, c.WGEVersion, c.UserDomain) + return []StepOutput{}, nil + } + + c.Logger.Successf(portforwardMsg, c.WGEVersion) + c.Logger.Println(portforwardCmdMsg, WGEDefaultNamespace) + return []StepOutput{}, nil +} diff --git a/pkg/bootstrap/steps/wge_version.go b/pkg/bootstrap/steps/wge_version.go new file mode 100644 index 0000000000..6293bb7cd8 --- /dev/null +++ b/pkg/bootstrap/steps/wge_version.go @@ -0,0 +1,132 @@ +package steps + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + + "github.com/weaveworks/weave-gitops-enterprise/pkg/bootstrap/utils" + "gopkg.in/yaml.v2" + "k8s.io/utils/strings/slices" +) + +// user messages +const ( + versionStepName = "select WGE version" + versionMsg = "select one of the following" +) + +var getVersionInput = StepInput{ + Name: WGEVersion, + Type: multiSelectionChoice, + Msg: versionMsg, + Valuesfn: getWgeVersions, +} + +func NewSelectWgeVersionStep(config Config) BootstrapStep { + inputs := []StepInput{} + + // validate value by user + if config.WGEVersion != "" { + versions, err := getWgeVersions(inputs, &config) + if err != nil { + config.Logger.Failuref("couldn't get WGE helm chart: %v", err) + os.Exit(1) + } + if versions, ok := versions.([]string); !ok || !slices.Contains(versions, config.WGEVersion) { + config.Logger.Failuref("invalid version: %v. available versions: %s", config.WGEVersion, versions) + os.Exit(1) + } + } + + if config.WGEVersion == "" { + inputs = append(inputs, getVersionInput) + } + return BootstrapStep{ + Name: versionStepName, + Input: inputs, + Step: selectWgeVersion, + } +} + +// selectWgeVersion step ask user to select wge version from the latest 3 versions. +func selectWgeVersion(input []StepInput, c *Config) ([]StepOutput, error) { + for _, param := range input { + if param.Name == WGEVersion { + version, ok := param.Value.(string) + if !ok { + return []StepOutput{}, errors.New("unexpected error occurred. WGEVersion is not found") + } + c.WGEVersion = version + } + } + + c.Logger.Successf("selected version %s", c.WGEVersion) + return []StepOutput{}, nil +} + +func getWgeVersions(input []StepInput, c *Config) (interface{}, error) { + entitlementSecret, err := utils.GetSecret(c.KubernetesClient, entitlementSecretName, WGEDefaultNamespace) + if err != nil { + return []string{}, err + } + + username, password := string(entitlementSecret.Data["username"]), string(entitlementSecret.Data["password"]) + + chartUrl := fmt.Sprintf("%s/index.yaml", wgeChartUrl) + versions, err := fetchHelmChartVersions(chartUrl, username, password) + if err != nil { + return []string{}, err + } + return versions, nil +} + +// fetchHelmChartVersions helper method to fetch wge helm chart versions. +func fetchHelmChartVersions(chartUrl, username, password string) ([]string, error) { + bodyBytes, err := doBasicAuthGetRequest(chartUrl, username, password) + if err != nil { + return []string{}, err + } + + var chart helmChartResponse + err = yaml.Unmarshal(bodyBytes, &chart) + if err != nil { + return []string{}, err + } + entries := chart.Entries[wgeChartName] + var versions []string + for _, entry := range entries { + if entry.Name == wgeChartName { + versions = append(versions, entry.Version) + if len(versions) == 3 { + break + } + } + } + + return versions, nil +} + +func doBasicAuthGetRequest(url, username, password string) ([]byte, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return []byte{}, err + + } + req.SetBasicAuth(username, password) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return []byte{}, err + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return []byte{}, err + } + return bodyBytes, err +} diff --git a/pkg/bootstrap/steps/wge_version_test.go b/pkg/bootstrap/steps/wge_version_test.go new file mode 100644 index 0000000000..0971d15ce3 --- /dev/null +++ b/pkg/bootstrap/steps/wge_version_test.go @@ -0,0 +1,37 @@ +package steps + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/alecthomas/assert" +) + +func TestFetchHelmChart(t *testing.T) { + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + username, password, ok := r.BasicAuth() + if !ok || username != "testuser" || password != "testpassword" { + w.WriteHeader(http.StatusUnauthorized) + return + } + + _, _ = fmt.Fprintln(w, `entries: + mccp: + - version: 1.0.0 + name: mccp + - version: 1.1.0 + name: mccp + - version: 1.2.0 + name: mccp`) + })) + defer mockServer.Close() + + versions, err := fetchHelmChartVersions(mockServer.URL, "testuser", "testpassword") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + expectedVersions := []string{"1.0.0", "1.1.0", "1.2.0"} + assert.Equal(t, expectedVersions, versions, "versions are not equal") +} diff --git a/pkg/bootstrap/utils/flux.go b/pkg/bootstrap/utils/flux.go new file mode 100644 index 0000000000..521b7d0bc8 --- /dev/null +++ b/pkg/bootstrap/utils/flux.go @@ -0,0 +1,133 @@ +package utils + +import ( + "context" + "fmt" + + helmv2 "github.com/fluxcd/helm-controller/api/v2beta1" + "github.com/fluxcd/pkg/apis/meta" + sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" + "github.com/weaveworks/weave-gitops/pkg/runner" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8s_client "sigs.k8s.io/controller-runtime/pkg/client" + k8syaml "sigs.k8s.io/yaml" +) + +// CreateHelmReleaseYamlString create HelmRelease yaml string to add to file. +func CreateHelmReleaseYamlString(hr helmv2.HelmRelease) (string, error) { + helmRelease := helmv2.HelmRelease{ + TypeMeta: v1.TypeMeta{ + Kind: helmv2.HelmReleaseKind, + APIVersion: helmv2.GroupVersion.Identifier(), + }, + ObjectMeta: v1.ObjectMeta{ + Name: hr.Name, + Namespace: hr.Namespace, + }, Spec: helmv2.HelmReleaseSpec{ + Chart: helmv2.HelmChartTemplate{ + Spec: helmv2.HelmChartTemplateSpec{ + Chart: hr.Spec.Chart.Spec.Chart, + ReconcileStrategy: sourcev1.ReconcileStrategyChartVersion, + SourceRef: helmv2.CrossNamespaceObjectReference{ + Kind: sourcev1.HelmRepositoryKind, + Name: hr.Spec.Chart.Spec.SourceRef.Name, + Namespace: hr.Spec.Chart.Spec.SourceRef.Namespace, + }, + Version: hr.Spec.Chart.Spec.Version, + }, + }, + Install: &helmv2.Install{ + CRDs: hr.Spec.Install.CRDs, + CreateNamespace: hr.Spec.Install.CreateNamespace, + }, + Upgrade: &helmv2.Upgrade{ + CRDs: hr.Spec.Upgrade.CRDs, + }, + Interval: v1.Duration{ + Duration: hr.Spec.Interval.Duration, + }, + Values: hr.Spec.Values, + }, + } + + helmReleaseBytes, err := k8syaml.Marshal(helmRelease) + if err != nil { + return "", err + } + + return string(helmReleaseBytes), nil +} + +// CreateHelmRepositoryYamlString create HelmRepository yaml string to add to file. +func CreateHelmRepositoryYamlString(helmRepo sourcev1.HelmRepository) (string, error) { + repo := sourcev1.HelmRepository{ + TypeMeta: v1.TypeMeta{ + APIVersion: sourcev1.GroupVersion.Identifier(), + Kind: sourcev1.HelmRepositoryKind, + }, + ObjectMeta: v1.ObjectMeta{ + Name: helmRepo.Name, + Namespace: helmRepo.Namespace, + }, + Spec: sourcev1.HelmRepositorySpec{ + URL: helmRepo.Spec.URL, + Interval: v1.Duration{ + Duration: helmRepo.Spec.Interval.Duration, + }, + SecretRef: &meta.LocalObjectReference{ + Name: helmRepo.Spec.SecretRef.Name, + }, + }, + } + + repoBytes, err := k8syaml.Marshal(repo) + if err != nil { + return "", err + } + + return string(repoBytes), nil +} + +// ReconcileFlux reconcile flux default source and kustomization +// Reconciliation is important to apply the effect of adding resources to the git repository +func ReconcileFlux() error { + var runner runner.CLIRunner + out, err := runner.Run("flux", "reconcile", "source", "git", "flux-system") + if err != nil { + // adding an error message, err is meaningless + return fmt.Errorf("failed to reconcile flux source flux-system: %s", string(out)) + } + + out, err = runner.Run("flux", "reconcile", "kustomization", "flux-system") + if err != nil { + // adding an error message, err is meaningless + return fmt.Errorf("failed to reconcile flux kustomization flux-system: %s", string(out)) + } + + return nil +} + +// ReconcileHelmRelease reconcile a particular helmrelease +func ReconcileHelmRelease(hrName string) error { + var runner runner.CLIRunner + out, err := runner.Run("flux", "reconcile", "helmrelease", hrName) + if err != nil { + // adding an error message, err is meaningless + return fmt.Errorf("failed to reconcile flux helmrelease %s: %s", hrName, string(out)) + } + + return nil +} + +// GetHelmReleaseVersion return the chart version for the release `releaseName` in `namespace` +func GetHelmReleaseVersion(client k8s_client.Client, releaseName string, namespace string) (string, error) { + helmrelease := &helmv2.HelmRelease{} + if err := client.Get(context.Background(), k8s_client.ObjectKey{ + Namespace: namespace, + Name: releaseName, + }, helmrelease); err != nil { + return "", err + } + + return helmrelease.Spec.Chart.Spec.Version, nil +} diff --git a/pkg/bootstrap/utils/flux_test.go b/pkg/bootstrap/utils/flux_test.go new file mode 100644 index 0000000000..e3c8e2fa36 --- /dev/null +++ b/pkg/bootstrap/utils/flux_test.go @@ -0,0 +1,110 @@ +package utils + +import ( + "testing" + "time" + + "github.com/alecthomas/assert" + helmv2 "github.com/fluxcd/helm-controller/api/v2beta1" + "github.com/fluxcd/pkg/apis/meta" + sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestCreateHelmReleaseYamlString(t *testing.T) { + hr := helmv2.HelmRelease{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-hr", + Namespace: "test-ns", + }, + Spec: helmv2.HelmReleaseSpec{ + Chart: helmv2.HelmChartTemplate{ + Spec: helmv2.HelmChartTemplateSpec{ + Chart: "test-chart", + ReconcileStrategy: sourcev1.ReconcileStrategyChartVersion, + SourceRef: helmv2.CrossNamespaceObjectReference{ + Name: "test-repo", + Namespace: "test-ns", + }, + Version: "1.0.0", + }, + }, + Install: &helmv2.Install{ + CRDs: helmv2.Create, + CreateNamespace: true, + }, + Upgrade: &helmv2.Upgrade{ + CRDs: helmv2.Create, + }, + Interval: v1.Duration{ + Duration: time.Hour, + }, + Values: nil, + }, + } + + expectedYaml := `apiVersion: helm.toolkit.fluxcd.io/v2beta1 +kind: HelmRelease +metadata: + creationTimestamp: null + name: test-hr + namespace: test-ns +spec: + chart: + spec: + chart: test-chart + reconcileStrategy: ChartVersion + sourceRef: + kind: HelmRepository + name: test-repo + namespace: test-ns + version: 1.0.0 + install: + crds: Create + createNamespace: true + interval: 1h0m0s + upgrade: + crds: Create +status: {} +` + + yamlString, err := CreateHelmReleaseYamlString(hr) + assert.NoError(t, err, "error creating HelmRelease YAML string") + assert.Equal(t, expectedYaml, yamlString, "error creating HelmRelease YAML string") +} + +func TestCreateHelmRepositoryYamlString(t *testing.T) { + helmRepo := sourcev1.HelmRepository{ + ObjectMeta: v1.ObjectMeta{ + Name: "test-helm-repo", + Namespace: "test-ns", + }, + Spec: sourcev1.HelmRepositorySpec{ + URL: "https://charts.example.com", + SecretRef: &meta.LocalObjectReference{ + Name: "test-secret", + }, + Interval: v1.Duration{ + Duration: time.Minute * 10, + }, + }, + } + + expectedYaml := `apiVersion: source.toolkit.fluxcd.io/v1beta2 +kind: HelmRepository +metadata: + creationTimestamp: null + name: test-helm-repo + namespace: test-ns +spec: + interval: 10m0s + secretRef: + name: test-secret + url: https://charts.example.com +status: {} +` + + yamlString, err := CreateHelmRepositoryYamlString(helmRepo) + assert.NoError(t, err, "error creating HelmRepository YAML string") + assert.Equal(t, expectedYaml, yamlString, "doesn't match expected YAML") +} diff --git a/pkg/bootstrap/utils/git.go b/pkg/bootstrap/utils/git.go new file mode 100644 index 0000000000..ca3a924155 --- /dev/null +++ b/pkg/bootstrap/utils/git.go @@ -0,0 +1,165 @@ +package utils + +import ( + "context" + "os" + "path/filepath" + "time" + + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" + sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" + "github.com/go-git/go-git/v5" + "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/transport/ssh" + k8s_client "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + workingDir = "/tmp/bootstrap-flux" + fluxGitUserName = "Flux Bootstrap CLI" + fluxGitEmail = "bootstrap@weave.works" +) + +// getGitRepositoryObject get the default source git repository object to be used in cloning +func getGitRepositoryObject(client k8s_client.Client, repoName string, namespace string) (*sourcev1.GitRepository, error) { + gitRepo := &sourcev1.GitRepository{} + + if err := client.Get(context.Background(), k8s_client.ObjectKey{ + Namespace: namespace, + Name: repoName, + }, gitRepo); err != nil { + return nil, err + } + + return gitRepo, nil +} + +// getRepoUrl get the default repo url for flux installation (flux-system) GitRepository. +func getRepoUrl(gitRepo *sourcev1.GitRepository) string { + return gitRepo.Spec.URL +} + +// getRepoBranch get the branch for flux installation (flux-system) GitRepository. +func getRepoBranch(gitRepo *sourcev1.GitRepository) string { + return gitRepo.Spec.Reference.Branch +} + +// getRepoPath get the path for flux installation (flux-system) Kustomization. +func getRepoPath(client k8s_client.Client, repoName string, namespace string) (string, error) { + kustomization := &kustomizev1.Kustomization{} + + if err := client.Get(context.Background(), k8s_client.ObjectKey{ + Namespace: namespace, + Name: repoName, + }, kustomization); err != nil { + return "", err + } + + return kustomization.Spec.Path, nil +} + +func getGitAuth(privateKeyPath, privateKeyPassword string) (*ssh.PublicKeys, error) { + sshKey, err := os.ReadFile(privateKeyPath) + if err != nil { + return nil, err + } + pubKey, err := ssh.NewPublicKeys("git", sshKey, privateKeyPassword) + if err != nil { + return nil, err + } + return pubKey, nil +} + +// CloneRepo shallow clones the user repo's branch under temp and returns the current path. +func CloneRepo(client k8s_client.Client, repoName string, namespace string, privateKeyPath string, privateKeyPassword string) (string, error) { + if err := CleanupRepo(); err != nil { + return "", err + } + + gitRepo, err := getGitRepositoryObject(client, repoName, namespace) + if err != nil { + return "", err + } + + repoUrl := getRepoUrl(gitRepo) + repoBranch := getRepoBranch(gitRepo) + + repoPath, err := getRepoPath(client, repoName, namespace) + if err != nil { + return "", err + } + + authMethod, err := getGitAuth(privateKeyPath, privateKeyPassword) + if err != nil { + return "", err + } + _, err = git.PlainClone(workingDir, false, &git.CloneOptions{ + Auth: authMethod, + URL: repoUrl, + ReferenceName: plumbing.NewBranchReferenceName(repoBranch), + SingleBranch: true, + Depth: 1, + Progress: nil, + }) + if err != nil { + return "", err + } + return repoPath, nil +} + +// CreateFileToRepo create a file and add to the repo. +func CreateFileToRepo(filename, filecontent, path, commitmsg, privateKeyPath, privateKeyPassword string) error { + repo, err := git.PlainOpen(workingDir) + if err != nil { + return err + } + + worktree, err := repo.Worktree() + if err != nil { + return err + } + + filePath := filepath.Join(workingDir, path, filename) + + file, err := os.Create(filePath) + if err != nil { + return err + } + + defer file.Close() + if _, err := file.WriteString(filecontent); err != nil { + return err + } + + if _, err := worktree.Add(filepath.Join(path, filename)); err != nil { + return err + } + + if _, err := worktree.Commit(commitmsg, &git.CommitOptions{ + Author: &object.Signature{ + Name: fluxGitUserName, + Email: fluxGitEmail, + When: time.Now(), + }, + }); err != nil { + return err + } + + authMethod, err := getGitAuth(privateKeyPath, privateKeyPassword) + if err != nil { + return err + } + if err := repo.Push(&git.PushOptions{ + Auth: authMethod, + }); err != nil { + return err + } + + return nil +} + +// CleanupRepo delete the temp repo. +func CleanupRepo() error { + return os.RemoveAll(workingDir) +} diff --git a/pkg/bootstrap/utils/git_test.go b/pkg/bootstrap/utils/git_test.go new file mode 100644 index 0000000000..25471ed28e --- /dev/null +++ b/pkg/bootstrap/utils/git_test.go @@ -0,0 +1,44 @@ +package utils + +import ( + "testing" + + "github.com/alecthomas/assert" + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// TestGetRepoPath tests the GetRepoPath function +func TestGetRepoPath(t *testing.T) { + scheme := runtime.NewScheme() + schemeBuilder := runtime.SchemeBuilder{ + v1.AddToScheme, + kustomizev1.AddToScheme, + } + err := schemeBuilder.AddToScheme(scheme) + if err != nil { + t.Fatal(err) + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(&kustomizev1.Kustomization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "flux-system", + Namespace: "flux-system", + }, + Spec: kustomizev1.KustomizationSpec{ + Path: "clusters/production", + }, + }).Build() + + expectedRepoPath := "clusters/production" + + repoPath, err := getRepoPath(fakeClient, "flux-system", "flux-system") + + assert.NoError(t, err) + assert.Equal(t, expectedRepoPath, repoPath) +} diff --git a/pkg/bootstrap/utils/input.go b/pkg/bootstrap/utils/input.go new file mode 100644 index 0000000000..7f9eedff77 --- /dev/null +++ b/pkg/bootstrap/utils/input.go @@ -0,0 +1,98 @@ +package utils + +import ( + "errors" + + "github.com/manifoldco/promptui" +) + +const ( + inputStringErrMsg = "value can't be empty" + blueInfo = "\x1b[34;1m✔ %s\x1b[0m\n" +) + +// GetPasswordInput prompt to enter password. +func GetPasswordInput(msg string, required bool) (string, error) { + validate := func(input string) error { + if required && len(input) < 6 { + return errors.New("password must have more than 6 characters") + } + return nil + } + prompt := promptui.Prompt{ + Label: msg, + Validate: validate, + Mask: '*', + } + + result, err := prompt.Run() + if err != nil { + return "", err + } + + return result, nil +} + +// GetSelectInput get option from multiple choices. +func GetSelectInput(msg string, items []string) (string, error) { + index := -1 + var result string + var err error + + for index < 0 { + prompt := promptui.Select{ + Label: msg, + Items: items, + } + + index, result, err = prompt.Run() + + if index == -1 { + items = append(items, result) + } + } + + if err != nil { + return "", err + } + + return result, nil +} + +// GetStringInput prompt to get string input. +func GetStringInput(msg string, defaultValue string) (string, error) { + validate := func(input string) error { + if input == "" { + return errors.New(inputStringErrMsg) + } + return nil + } + + prompt := promptui.Prompt{ + Label: msg, + Validate: validate, + Default: defaultValue, + } + + result, err := prompt.Run() + if err != nil { + return "", err + } + + return result, nil +} + +// GetConfirmInput prompt to get yes or no input. +func GetConfirmInput(msg string) string { + prompt := promptui.Prompt{ + Label: msg, + IsConfirm: true, + } + + result, err := prompt.Run() + if err != nil { + return "n" + } + + return result +} diff --git a/pkg/bootstrap/utils/k8s.go b/pkg/bootstrap/utils/k8s.go new file mode 100644 index 0000000000..23467c2aed --- /dev/null +++ b/pkg/bootstrap/utils/k8s.go @@ -0,0 +1,136 @@ +package utils + +import ( + "context" + "os" + "path/filepath" + + helmv2 "github.com/fluxcd/helm-controller/api/v2beta1" + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" + sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/clientcmd" + k8s_client "sigs.k8s.io/controller-runtime/pkg/client" +) + +// GetKubernetesClient creates a kuberentes client from the default kubeconfig. +func GetKubernetesClient(kubeconfig string) (k8s_client.Client, error) { + kubeconfig, err := getCurrentKubeConfig(kubeconfig) + if err != nil { + return nil, err + } + + config, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + return nil, err + } + + scheme := runtime.NewScheme() + schemeBuilder := runtime.SchemeBuilder{ + corev1.AddToScheme, + sourcev1.AddToScheme, + kustomizev1.AddToScheme, + helmv2.AddToScheme, + } + + err = schemeBuilder.AddToScheme(scheme) + if err != nil { + return nil, err + } + + client, err := k8s_client.New(config, k8s_client.Options{Scheme: scheme}) + if err != nil { + return nil, err + } + + return client, nil +} + +// GetCurrentContext get the current context and cluster name from the kubeconfig. +func GetCurrentContext(kubeconfig string) (string, error) { + kubeconfig, err := getCurrentKubeConfig(kubeconfig) + if err != nil { + return "", err + } + + config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfig}, + &clientcmd.ConfigOverrides{}).RawConfig() + if err != nil { + return "", err + } + + return config.CurrentContext, nil +} + +// getCurrentKubeConfig checks for active kubeconfig by the following priority: +// passed as cli argument, KUBECONFIG env variable and finally $HOME/.kube/config +func getCurrentKubeConfig(kubeconfig string) (string, error) { + if kubeconfig != "" { + return kubeconfig, nil + } + + kubeconfigFromEnv := os.Getenv(clientcmd.RecommendedConfigPathEnvVar) + if kubeconfigFromEnv != "" { + return kubeconfigFromEnv, nil + } + + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + return filepath.Join(home, clientcmd.RecommendedHomeDir, clientcmd.RecommendedFileName), nil +} + +// GetSecret get secret values from kubernetes. +func GetSecret(client k8s_client.Client, name string, namespace string) (*corev1.Secret, error) { + secret := &corev1.Secret{} + err := client.Get(context.Background(), types.NamespacedName{ + Name: name, + Namespace: namespace, + }, secret, &k8s_client.GetOptions{}) + + if err != nil { + return nil, err + } + + return secret, nil +} + +// CreateSecret create a kubernetes secret. +func CreateSecret(client k8s_client.Client, name string, namespace string, data map[string][]byte) error { + secret := &corev1.Secret{ + ObjectMeta: v1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Data: data, + } + + err := client.Create(context.Background(), secret, &k8s_client.CreateOptions{}) + if err != nil { + return err + } + + return nil +} + +// DeleteSecret delete a kubernetes secret. +func DeleteSecret(client k8s_client.Client, name string, namespace string) error { + secret := &corev1.Secret{ + ObjectMeta: v1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + } + err := client.Delete(context.Background(), secret, &k8s_client.DeleteOptions{}) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/bootstrap/utils/k8s_test.go b/pkg/bootstrap/utils/k8s_test.go new file mode 100644 index 0000000000..9bf018746b --- /dev/null +++ b/pkg/bootstrap/utils/k8s_test.go @@ -0,0 +1,148 @@ +package utils + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/alecthomas/assert" + "github.com/loft-sh/vcluster/pkg/util/random" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +// TestGetSecret test TestGetSecret +func TestGetSecret(t *testing.T) { + secretName := "test-secret" + secretNamespace := "flux-system" + invalidSecretName := "invalid-secret" + scheme := runtime.NewScheme() + schemeBuilder := runtime.SchemeBuilder{ + v1.AddToScheme, + } + err := schemeBuilder.AddToScheme(scheme) + if err != nil { + t.Fatal(err) + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(&v1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: secretNamespace}, + Type: "Opaque", + Data: map[string][]byte{ + "username": []byte("test-username"), + "password": []byte("test-password"), + }, + }).Build() + + secret, err := GetSecret(fakeClient, invalidSecretName, secretNamespace) + assert.Error(t, err, "error fetching secret: %v", err) + assert.Nil(t, secret, "error fetching secret: %v", err) + + secret, err = GetSecret(fakeClient, secretName, secretNamespace) + + expectedUsername := "test-username" + expectedPassword := "test-password" + assert.NoError(t, err, "error fetching secret: %v", err) + assert.Equal(t, expectedUsername, string(secret.Data["username"]), "Expected username %s, but got %s", expectedUsername, string(secret.Data["username"])) + assert.Equal(t, expectedPassword, string(secret.Data["password"]), "Expected password %s, but got %s", expectedPassword, string(secret.Data["password"])) + +} + +// TestCreateSecret test TestCreateSecret +func TestCreateSecret(t *testing.T) { + secretName := "test-secret" + secretNamespace := "flux-system" + secretData := map[string][]byte{ + "username": []byte("test-username"), + "password": []byte("test-password"), + } + + scheme := runtime.NewScheme() + schemeBuilder := runtime.SchemeBuilder{ + v1.AddToScheme, + } + err := schemeBuilder.AddToScheme(scheme) + if err != nil { + t.Fatal(err) + } + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + + err = CreateSecret(fakeClient, secretName, secretNamespace, secretData) + assert.NoError(t, err, "error creating secret: %v", err) + + secret, err := GetSecret(fakeClient, secretName, secretNamespace) + expectedUsername := "test-username" + expectedPassword := "test-password" + + assert.NoError(t, err, "error fetching secret: %v", err) + assert.Equal(t, expectedUsername, string(secret.Data["username"]), "Expected username %s, but got %s", expectedUsername, string(secret.Data["username"])) + assert.Equal(t, expectedPassword, string(secret.Data["password"]), "Expected password %s, but got %s", expectedPassword, string(secret.Data["password"])) + +} + +// TestDeleteSecret test TestDeleteSecret +func TestDeleteSecret(t *testing.T) { + secretName := "test-secret" + secretNamespace := "flux-system" + secretData := map[string][]byte{ + "username": []byte("test-username"), + "password": []byte("test-password"), + } + + scheme := runtime.NewScheme() + schemeBuilder := runtime.SchemeBuilder{ + v1.AddToScheme, + } + err := schemeBuilder.AddToScheme(scheme) + if err != nil { + t.Fatal(err) + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + err = CreateSecret(fakeClient, secretName, secretNamespace, secretData) + assert.NoError(t, err, "error creating secret: %v", err) + + err = DeleteSecret(fakeClient, secretName, secretNamespace) + assert.NoError(t, err, "error deleting secret: %v", err) + + _, err = GetSecret(fakeClient, secretName, secretNamespace) + assert.Error(t, err, "an error was expected") + +} + +// TestGetKubernetesClient test TestGetKubernetesClient +func TestGetKubernetesClient(t *testing.T) { + kubeConfigFileContent := `apiVersion: v1 +kind: Config +clusters: +- cluster: + server: http://example.com + name: my-cluster +contexts: +- context: + cluster: my-cluster + user: my-user + name: my-context +current-context: my-context +users: +- name: my-user + user: + username: test + password: test +` + fakeKubeconfigfile := filepath.Join(os.TempDir(), fmt.Sprintf("test-kubeconfig-%s.yaml", random.RandomString(6))) + file, err := os.Create(fakeKubeconfigfile) + assert.NoError(t, err, "error creating file") + + defer file.Close() + defer os.Remove(fakeKubeconfigfile) + + _, err = file.WriteString(kubeConfigFileContent) + assert.NoError(t, err, "error creating to file") + + _, err = GetKubernetesClient(fakeKubeconfigfile) + assert.Error(t, err, "error getting Kubernetes client") +}