diff --git a/.github/workflows/dev-be-ci.yaml b/.github/workflows/dev-be-ci.yaml
index 37adad7b3..d292d0dea 100644
--- a/.github/workflows/dev-be-ci.yaml
+++ b/.github/workflows/dev-be-ci.yaml
@@ -245,6 +245,14 @@ jobs:
${{ matrix.os }}-go-${{ matrix.go-version }}-build-${{ github.ref }}-
${{ matrix.os }}-go-${{ matrix.go-version }}-build-
+ # Switch docker data directory to /mnt to have more space for the local Kubernetes cluster
+ - name: Switch docker-daemon data directory to /mnt
+ run: |
+ sudo systemctl stop docker
+ echo '{ "exec-opts": ["native.cgroupdriver=cgroupfs"], "cgroup-parent": "/actions_job", "data-root": "/mnt/docker-data" }' | sudo tee /etc/docker/daemon.json
+ sudo mkdir /mnt/docker-data
+ sudo systemctl start docker
+
- name: Start local Kubernetes cluster with the local registry
uses: medyagh/setup-minikube@latest
id: minikube
@@ -262,6 +270,11 @@ jobs:
run: |
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 make build-debug
+ - name: Build Everest CLI
+ shell: bash
+ run: |
+ make build-cli
+
- name: Build Everest docker container
uses: docker/metadata-action@v5
id: meta
@@ -319,8 +332,8 @@ jobs:
- name: Provision Everest using CLI
shell: bash
run: |
- make build-cli
./bin/everestctl install -v \
+ --disable-telemetry \
--version 0.0.0 \
--version-metadata-url https://check-dev.percona.com \
--operator.mongodb \
@@ -346,10 +359,14 @@ jobs:
kubectl patch configmap everest-rbac -n everest-system --patch "$(kubectl get configmap everest-rbac -n everest-system -o json | jq '.data["policy.csv"] += "\ng, everest_ci, role:admin"' | jq '{data: { "policy.csv": .data["policy.csv"] } }')"
kubectl get configmap everest-rbac -n everest-system -ojsonpath='{.data.policy\.csv}'
- - name: Run integration tests
+ - name: Init integration tests
run: |
cd api-tests
make init
+
+ - name: Run integration tests
+ run: |
+ cd api-tests
make test
- name: Run debug commands on failure
@@ -360,9 +377,6 @@ jobs:
kubectl -n everest describe pods
kubectl -n everest-system logs deploy/everest-server
-
-
-
integration_tests_cli:
name: CLI Integration Tests
strategy:
@@ -418,9 +432,9 @@ jobs:
uses: helm/kind-action@v1.12.0
- name: Run integration tests
- working-directory: cli-tests
id: cli-tests
run: |
+ cd cli-tests
make init
make install-operators
make test-cli
diff --git a/.github/workflows/dev-fe-e2e.yaml b/.github/workflows/dev-fe-e2e.yaml
index f9bdff853..2a6c3b248 100644
--- a/.github/workflows/dev-fe-e2e.yaml
+++ b/.github/workflows/dev-fe-e2e.yaml
@@ -17,8 +17,8 @@ jobs:
strategy:
fail-fast: false
matrix:
- go-version: [1.23.x]
- may-fail: [false]
+ go-version: [ 1.23.x ]
+ may-fail: [ false ]
runs-on: ubuntu-latest
steps:
# Setup Go
@@ -139,6 +139,7 @@ jobs:
shell: bash
run: |
./bin/everestctl install -v \
+ --disable-telemetry \
--version 0.0.0 \
--version-metadata-url https://check-dev.percona.com \
--skip-wizard \
diff --git a/Makefile b/Makefile
index a07a12d0f..02a8ce3dc 100644
--- a/Makefile
+++ b/Makefile
@@ -75,7 +75,7 @@ run-debug: build-debug ## Run binary
bin/everest
run-cli-install: build-cli
- bin/everestctl install --skip-wizard --namespaces=everest
+ bin/everestctl install --disable-telemetry --skip-wizard --namespaces=everest
cert: ## Install dev TLS certificates
mkcert -install
diff --git a/cli-tests/Makefile b/cli-tests/Makefile
index e70e680ed..8a17762c3 100644
--- a/cli-tests/Makefile
+++ b/cli-tests/Makefile
@@ -4,6 +4,7 @@ init: ## Install dependencies
install-operators: ## Install operators to k8s
../bin/everestctl install -v \
+ --disable-telemetry \
--version 0.0.0 \
--version-metadata-url https://check-dev.percona.com \
--namespaces percona-everest-operators \
diff --git a/cli-tests/helpers/cliHelper.ts b/cli-tests/helpers/cliHelper.ts
index 8d24376e8..fa4f0c186 100644
--- a/cli-tests/helpers/cliHelper.ts
+++ b/cli-tests/helpers/cliHelper.ts
@@ -73,14 +73,14 @@ export class CliHelper {
async everestExecSkipWizard(command: string) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return test.step(`Run "${command}" command with --skip-wizard`, async () => {
- return this.execute(`${this.pathToBinary} ${command} --skip-wizard --version 0.0.0 --version-metadata-url https://check-dev.percona.com`);
+ return this.execute(`${this.pathToBinary} ${command} --disable-telemetry --skip-wizard --version 0.0.0 --version-metadata-url https://check-dev.percona.com`);
});
}
async everestExecSkipWizardWithEnv(command, env: string) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return test.step(`Run "${command}" command with env variables`, async () => {
- return this.execute(`${env} ${this.pathToBinary} ${command} --skip-wizard --version 0.0.0 --version-metadata-url https://check-dev.percona.com`);
+ return this.execute(`${env} ${this.pathToBinary} ${command} --disable-telemetry --skip-wizard --version 0.0.0 --version-metadata-url https://check-dev.percona.com`);
});
}
diff --git a/cli-tests/tests/flow/all-operators.spec.ts b/cli-tests/tests/flow/all-operators.spec.ts
index b79ba138c..5f8c18d11 100644
--- a/cli-tests/tests/flow/all-operators.spec.ts
+++ b/cli-tests/tests/flow/all-operators.spec.ts
@@ -50,13 +50,13 @@ test.describe('Everest CLI install', async () => {
await out.assertSuccess();
await out.outContainsNormalizedMany([
- '✓ Installing Everest Helm chart',
- '✓ Ensuring Everest API deployment is ready',
- '✓ Ensuring Everest operator deployment is ready',
- '✓ Ensuring OLM components are ready',
- '✓ Ensuring Everest CatalogSource is ready',
- '✓ Ensuring monitoring stack is ready',
- '✓ Provisioning database namespaces (everest-all)',
+ '✅ Installing Everest Helm chart',
+ '✅ Ensuring Everest API deployment is ready',
+ '✅ Ensuring Everest operator deployment is ready',
+ '✅ Ensuring OLM components are ready',
+ '✅ Ensuring Everest CatalogSource is ready',
+ '✅ Ensuring monitoring stack is ready',
+ '✅ Provisioning database namespace \'everest-all\'',
'Thank you for installing Everest',
]);
});
diff --git a/cli-tests/tests/flow/mongodb-operator.spec.ts b/cli-tests/tests/flow/mongodb-operator.spec.ts
index 46f0be37b..d578c1e54 100644
--- a/cli-tests/tests/flow/mongodb-operator.spec.ts
+++ b/cli-tests/tests/flow/mongodb-operator.spec.ts
@@ -54,13 +54,13 @@ test.describe('Everest CLI install', async () => {
await out.assertSuccess();
await out.outContainsNormalizedMany([
- '✓ Installing Everest Helm chart',
- '✓ Ensuring Everest API deployment is ready',
- '✓ Ensuring Everest operator deployment is ready',
- '✓ Ensuring OLM components are ready',
- '✓ Ensuring Everest CatalogSource is ready',
- '✓ Ensuring monitoring stack is ready',
- '✓ Provisioning database namespaces (everest-operators)',
+ '✅ Installing Everest Helm chart',
+ '✅ Ensuring Everest API deployment is ready',
+ '✅ Ensuring Everest operator deployment is ready',
+ '✅ Ensuring OLM components are ready',
+ '✅ Ensuring Everest CatalogSource is ready',
+ '✅ Ensuring monitoring stack is ready',
+ '✅ Provisioning database namespace \'everest-operators\'',
'Thank you for installing Everest',
]);
});
diff --git a/cli-tests/tests/flow/namespaces.spec.ts b/cli-tests/tests/flow/namespaces.spec.ts
index 97028971b..385bbb087 100644
--- a/cli-tests/tests/flow/namespaces.spec.ts
+++ b/cli-tests/tests/flow/namespaces.spec.ts
@@ -48,12 +48,12 @@ test.describe('Everest CLI install', async () => {
await out.assertSuccess();
await out.outContainsNormalizedMany([
- '✓ Installing Everest Helm chart',
- '✓ Ensuring Everest API deployment is ready',
- '✓ Ensuring Everest operator deployment is ready',
- '✓ Ensuring OLM components are ready',
- '✓ Ensuring Everest CatalogSource is ready',
- '✓ Ensuring monitoring stack is ready',
+ '✅ Installing Everest Helm chart',
+ '✅ Ensuring Everest API deployment is ready',
+ '✅ Ensuring Everest operator deployment is ready',
+ '✅ Ensuring OLM components are ready',
+ '✅ Ensuring Everest CatalogSource is ready',
+ '✅ Ensuring monitoring stack is ready',
'Thank you for installing Everest',
]);
});
@@ -65,7 +65,7 @@ test.describe('Everest CLI install', async () => {
`install`,
);
await out.outErrContainsNormalizedMany([
- '× everest is already installed',
+ '❌ everest is already installed',
]);
});
await page.waitForTimeout(10_000);
@@ -77,7 +77,7 @@ test.describe('Everest CLI install', async () => {
);
await out.assertSuccess();
await out.outContainsNormalizedMany([
- '✓ Installing namespace \'everest\'',
+ '✅ Provisioning database namespace \'everest\'',
]);
});
await page.waitForTimeout(10_000);
@@ -89,7 +89,7 @@ test.describe('Everest CLI install', async () => {
`add everest --operator.mongodb=false --operator.postgresql=false --operator.xtradb-cluster=true`,
);
await out.outErrContainsNormalizedMany([
- '× invalid namespace (everest): namespace already exists. HINT: set \'--take-ownership\' flag to use existing namespaces',
+ '❌ \'everest\': namespace already exists and is managed by Everest',
]);
});
await page.waitForTimeout(10_000);
@@ -102,7 +102,7 @@ test.describe('Everest CLI install', async () => {
);
await out.assertSuccess();
await out.outContainsNormalizedMany([
- '✓ Updating namespace \'everest\'',
+ '✅ Updating database namespace \'everest\'',
]);
});
await page.waitForTimeout(10_000);
@@ -119,10 +119,10 @@ test.describe('Everest CLI install', async () => {
);
await out.assertSuccess();
await out.outContainsNormalizedMany([
- '✓ Deleting database clusters in namespace \'everest\'',
- '✓ Deleting backup storages in namespace \'everest\'',
- '✓ Deleting monitoring instances in namespace \'everest\'',
- '✓ Deleting namespace \'everest\'',
+ '✅ Deleting database clusters in namespace \'everest\'',
+ '✅ Deleting backup storages in namespace \'everest\'',
+ '✅ Deleting monitoring instances in namespace \'everest\'',
+ '✅ Deleting database namespace \'everest\'',
]);
out = await cli.exec(`kubectl get namespace everest`);
@@ -142,7 +142,7 @@ test.describe('Everest CLI install', async () => {
);
await out.assertSuccess();
await out.outContainsNormalizedMany([
- '✓ Installing namespace \'existing-ns\'',
+ '✅ Provisioning database namespace \'existing-ns\'',
]);
});
await page.waitForTimeout(10_000);
@@ -154,10 +154,10 @@ test.describe('Everest CLI install', async () => {
);
await out.assertSuccess();
await out.outContainsNormalizedMany([
- '✓ Deleting database clusters in namespace \'existing-ns\'',
- '✓ Deleting backup storages in namespace \'existing-ns\'',
- '✓ Deleting monitoring instances in namespace \'existing-ns\'',
- '✓ Deleting resources from namespace \'existing-ns\'',
+ '✅ Deleting database clusters in namespace \'existing-ns\'',
+ '✅ Deleting backup storages in namespace \'existing-ns\'',
+ '✅ Deleting monitoring instances in namespace \'existing-ns\'',
+ '✅ Deleting resources from namespace \'existing-ns\'',
]);
out = await cli.exec(`kubectl get namespace existing-ns`);
diff --git a/cli-tests/tests/flow/pg-operator.spec.ts b/cli-tests/tests/flow/pg-operator.spec.ts
index 968ac6a10..0034610b4 100644
--- a/cli-tests/tests/flow/pg-operator.spec.ts
+++ b/cli-tests/tests/flow/pg-operator.spec.ts
@@ -51,13 +51,13 @@ test.describe('Everest CLI install', async () => {
await out.assertSuccess();
await out.outContainsNormalizedMany([
- '✓ Installing Everest Helm chart',
- '✓ Ensuring Everest API deployment is ready',
- '✓ Ensuring Everest operator deployment is ready',
- '✓ Ensuring OLM components are ready',
- '✓ Ensuring Everest CatalogSource is ready',
- '✓ Ensuring monitoring stack is ready',
- '✓ Provisioning database namespaces (everest-operators)',
+ '✅ Installing Everest Helm chart',
+ '✅ Ensuring Everest API deployment is ready',
+ '✅ Ensuring Everest operator deployment is ready',
+ '✅ Ensuring OLM components are ready',
+ '✅ Ensuring Everest CatalogSource is ready',
+ '✅ Ensuring monitoring stack is ready',
+ '✅ Provisioning database namespace \'everest-operators\'',
'Thank you for installing Everest',
]);
});
diff --git a/cli-tests/tests/flow/pxc-operator.spec.ts b/cli-tests/tests/flow/pxc-operator.spec.ts
index 8aeff63b0..26615ae5e 100644
--- a/cli-tests/tests/flow/pxc-operator.spec.ts
+++ b/cli-tests/tests/flow/pxc-operator.spec.ts
@@ -54,13 +54,13 @@ test.describe('Everest CLI install', async () => {
await out.assertSuccess();
await out.outContainsNormalizedMany([
- '✓ Installing Everest Helm chart',
- '✓ Ensuring Everest API deployment is ready',
- '✓ Ensuring Everest operator deployment is ready',
- '✓ Ensuring OLM components are ready',
- '✓ Ensuring Everest CatalogSource is ready',
- '✓ Ensuring monitoring stack is ready',
- '✓ Provisioning database namespaces (everest-operators)',
+ '✅ Installing Everest Helm chart',
+ '✅ Ensuring Everest API deployment is ready',
+ '✅ Ensuring Everest operator deployment is ready',
+ '✅ Ensuring OLM components are ready',
+ '✅ Ensuring Everest CatalogSource is ready',
+ '✅ Ensuring monitoring stack is ready',
+ '✅ Provisioning database namespace \'everest-operators\'',
'Thank you for installing Everest',
]);
});
diff --git a/commands/accounts/create.go b/commands/accounts/create.go
index d7c276ffb..85f3fb11c 100644
--- a/commands/accounts/create.go
+++ b/commands/accounts/create.go
@@ -26,6 +26,7 @@ import (
accountscli "github.com/percona/everest/pkg/accounts/cli"
"github.com/percona/everest/pkg/cli"
"github.com/percona/everest/pkg/logger"
+ "github.com/percona/everest/pkg/output"
)
var (
@@ -53,17 +54,51 @@ func accountsCreatePreRun(cmd *cobra.Command, _ []string) { //nolint:revive
// Copy global flags to config
accountsCreateCfg.Pretty = !(cmd.Flag(cli.FlagVerbose).Changed || cmd.Flag(cli.FlagJSON).Changed)
accountsCreateCfg.KubeconfigPath = cmd.Flag(cli.FlagKubeconfig).Value.String()
+
+ // Check username
+ if accountsCreateOpts.Username != "" {
+ // Validate provided username for new account.
+ if err := accountscli.ValidateUsername(accountsCreateOpts.Username); err != nil {
+ output.PrintError(err, logger.GetLogger(), accountsCreateCfg.Pretty)
+ os.Exit(1)
+ }
+ } else {
+ // Ask user in interactive mode to provide username for new account.
+ if username, err := accountscli.PopulateUsername(cmd.Context()); err != nil {
+ output.PrintError(err, logger.GetLogger(), accountsCreateCfg.Pretty)
+ os.Exit(1)
+ } else {
+ accountsCreateOpts.Username = username
+ }
+ }
+
+ // Check password
+ if accountsCreateOpts.Password != "" {
+ // Validate provided password for new account.
+ if err := accountscli.ValidatePassword(accountsCreateOpts.Password); err != nil {
+ output.PrintError(err, logger.GetLogger(), accountsCreateCfg.Pretty)
+ os.Exit(1)
+ }
+ } else {
+ // Ask user in interactive mode to provide password for new account.
+ if password, err := accountscli.PopulatePassword(cmd.Context()); err != nil {
+ output.PrintError(err, logger.GetLogger(), accountsCreateCfg.Pretty)
+ os.Exit(1)
+ } else {
+ accountsCreateOpts.Password = password
+ }
+ }
}
func accountsCreateRun(cmd *cobra.Command, _ []string) { //nolint:revive
cliA, err := accountscli.NewAccounts(*accountsCreateCfg, logger.GetLogger())
if err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), accountsCreateCfg.Pretty)
os.Exit(1)
}
if err := cliA.Create(cmd.Context(), *accountsCreateOpts); err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), accountsCreateCfg.Pretty)
os.Exit(1)
}
}
diff --git a/commands/accounts/delete.go b/commands/accounts/delete.go
index 6c2929630..357c4d9f9 100644
--- a/commands/accounts/delete.go
+++ b/commands/accounts/delete.go
@@ -24,6 +24,7 @@ import (
accountscli "github.com/percona/everest/pkg/accounts/cli"
"github.com/percona/everest/pkg/cli"
"github.com/percona/everest/pkg/logger"
+ "github.com/percona/everest/pkg/output"
)
var (
@@ -49,17 +50,34 @@ func accountsDeletePreRun(cmd *cobra.Command, _ []string) { //nolint:revive
// Copy global flags to config
accountsDeleteCfg.Pretty = !(cmd.Flag(cli.FlagVerbose).Changed || cmd.Flag(cli.FlagJSON).Changed)
accountsDeleteCfg.KubeconfigPath = cmd.Flag(cli.FlagKubeconfig).Value.String()
+
+ // Check username
+ if accountsDeleteUsername != "" {
+ // Validate provided username to be deleted.
+ if err := accountscli.ValidateUsername(accountsDeleteUsername); err != nil {
+ output.PrintError(err, logger.GetLogger(), accountsDeleteCfg.Pretty)
+ os.Exit(1)
+ }
+ } else {
+ // Ask user in interactive mode to provide username to delete.
+ if username, err := accountscli.PopulateUsername(cmd.Context()); err != nil {
+ output.PrintError(err, logger.GetLogger(), accountsDeleteCfg.Pretty)
+ os.Exit(1)
+ } else {
+ accountsDeleteUsername = username
+ }
+ }
}
func accountsDeleteRun(cmd *cobra.Command, _ []string) { //nolint:revive
cliA, err := accountscli.NewAccounts(*accountsDeleteCfg, logger.GetLogger())
if err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), accountsDeleteCfg.Pretty)
os.Exit(1)
}
if err := cliA.Delete(cmd.Context(), accountsDeleteUsername); err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), accountsDeleteCfg.Pretty)
os.Exit(1)
}
}
diff --git a/commands/accounts/initial_admin_password.go b/commands/accounts/initial_admin_password.go
index c57039be0..5313b4386 100644
--- a/commands/accounts/initial_admin_password.go
+++ b/commands/accounts/initial_admin_password.go
@@ -25,6 +25,7 @@ import (
accountscli "github.com/percona/everest/pkg/accounts/cli"
"github.com/percona/everest/pkg/cli"
"github.com/percona/everest/pkg/logger"
+ "github.com/percona/everest/pkg/output"
)
var (
@@ -49,13 +50,13 @@ func accountsInitAdminPasswdPreRun(cmd *cobra.Command, _ []string) { //nolint:re
func accountsInitAdminPasswdRun(cmd *cobra.Command, _ []string) { //nolint:revive
cliA, err := accountscli.NewAccounts(*accountsInitAdminPasswdCfg, logger.GetLogger())
if err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), accountsInitAdminPasswdCfg.Pretty)
os.Exit(1)
}
passwordHash, err := cliA.GetInitAdminPassword(cmd.Context())
if err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), accountsInitAdminPasswdCfg.Pretty)
os.Exit(1)
}
diff --git a/commands/accounts/list.go b/commands/accounts/list.go
index 475b53a6a..098bd591d 100644
--- a/commands/accounts/list.go
+++ b/commands/accounts/list.go
@@ -25,6 +25,7 @@ import (
accountscli "github.com/percona/everest/pkg/accounts/cli"
"github.com/percona/everest/pkg/cli"
"github.com/percona/everest/pkg/logger"
+ "github.com/percona/everest/pkg/output"
)
var (
@@ -60,12 +61,12 @@ func accountsListPreRun(cmd *cobra.Command, _ []string) { //nolint:revive
func accountsListRun(cmd *cobra.Command, _ []string) { //nolint:revive
cliA, err := accountscli.NewAccounts(*accountsListCfg, logger.GetLogger())
if err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), accountsListCfg.Pretty)
os.Exit(1)
}
if err := cliA.List(cmd.Context(), *accountsListOpts); err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), accountsListCfg.Pretty)
os.Exit(1)
}
}
diff --git a/commands/accounts/reset_jwt_keys.go b/commands/accounts/reset_jwt_keys.go
index 1cf94be3c..4f1acc9a7 100644
--- a/commands/accounts/reset_jwt_keys.go
+++ b/commands/accounts/reset_jwt_keys.go
@@ -24,6 +24,7 @@ import (
accountscli "github.com/percona/everest/pkg/accounts/cli"
"github.com/percona/everest/pkg/cli"
"github.com/percona/everest/pkg/logger"
+ "github.com/percona/everest/pkg/output"
)
var (
@@ -48,12 +49,12 @@ func accountsResetJWTKeysPreRun(cmd *cobra.Command, _ []string) { //nolint:reviv
func accountsResetJWTKeysRun(cmd *cobra.Command, _ []string) { //nolint:revive
cliA, err := accountscli.NewAccounts(*accountsResetJWTKeysCfg, logger.GetLogger())
if err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), accountsResetJWTKeysCfg.Pretty)
os.Exit(1)
}
if err := cliA.CreateRSAKeyPair(cmd.Context()); err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), accountsResetJWTKeysCfg.Pretty)
os.Exit(1)
}
}
diff --git a/commands/accounts/set_password.go b/commands/accounts/set_password.go
index 05ac7a500..d62d778f0 100644
--- a/commands/accounts/set_password.go
+++ b/commands/accounts/set_password.go
@@ -26,6 +26,7 @@ import (
accountscli "github.com/percona/everest/pkg/accounts/cli"
"github.com/percona/everest/pkg/cli"
"github.com/percona/everest/pkg/logger"
+ "github.com/percona/everest/pkg/output"
)
var (
@@ -52,17 +53,51 @@ func accountsSetPasswordPreRun(cmd *cobra.Command, _ []string) { //nolint:revive
// Copy global flags to config
accountsSetPasswordCfg.Pretty = !(cmd.Flag(cli.FlagVerbose).Changed || cmd.Flag(cli.FlagJSON).Changed)
accountsSetPasswordCfg.KubeconfigPath = cmd.Flag(cli.FlagKubeconfig).Value.String()
+
+ // Check username
+ if accountsSetPasswordOpts.Username != "" {
+ // Validate provided username for whom password shall be changed.
+ if err := accountscli.ValidateUsername(accountsSetPasswordOpts.Username); err != nil {
+ output.PrintError(err, logger.GetLogger(), accountsSetPasswordCfg.Pretty)
+ os.Exit(1)
+ }
+ } else {
+ // Ask user in interactive mode to provide username for whom password shall be changed.
+ if username, err := accountscli.PopulateUsername(cmd.Context()); err != nil {
+ output.PrintError(err, logger.GetLogger(), accountsSetPasswordCfg.Pretty)
+ os.Exit(1)
+ } else {
+ accountsSetPasswordOpts.Username = username
+ }
+ }
+
+ // Check password
+ if accountsSetPasswordOpts.NewPassword != "" {
+ // Validate provided a new password.
+ if err := accountscli.ValidatePassword(accountsSetPasswordOpts.NewPassword); err != nil {
+ output.PrintError(err, logger.GetLogger(), accountsSetPasswordCfg.Pretty)
+ os.Exit(1)
+ }
+ } else {
+ // Ask user in interactive mode to provide a new password.
+ if password, err := accountscli.PopulateNewPassword(cmd.Context()); err != nil {
+ output.PrintError(err, logger.GetLogger(), accountsSetPasswordCfg.Pretty)
+ os.Exit(1)
+ } else {
+ accountsSetPasswordOpts.NewPassword = password
+ }
+ }
}
func accountsSetPasswordRun(cmd *cobra.Command, _ []string) { //nolint:revive
cliA, err := accountscli.NewAccounts(*accountsSetPasswordCfg, logger.GetLogger())
if err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), accountsSetPasswordCfg.Pretty)
os.Exit(1)
}
if err := cliA.SetPassword(cmd.Context(), *accountsSetPasswordOpts); err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), accountsSetPasswordCfg.Pretty)
os.Exit(1)
}
}
diff --git a/commands/install.go b/commands/install.go
index 90a10bbc4..20ec482f1 100644
--- a/commands/install.go
+++ b/commands/install.go
@@ -24,6 +24,8 @@ import (
"github.com/percona/everest/pkg/cli"
"github.com/percona/everest/pkg/cli/helm"
"github.com/percona/everest/pkg/cli/install"
+ "github.com/percona/everest/pkg/cli/namespaces"
+ "github.com/percona/everest/pkg/common"
"github.com/percona/everest/pkg/logger"
"github.com/percona/everest/pkg/output"
)
@@ -40,18 +42,19 @@ var (
Example: "everestctl install --namespaces dev,staging,prod --operator.mongodb=true --operator.postgresql=false --operator.xtradb-cluster=false --skip-wizard",
Long: "Install Percona Everest using Helm",
Short: "Install Percona Everest using Helm",
- PreRunE: installPreRunE,
+ PreRun: installPreRun,
Run: installRun,
}
- installCfg = &install.Config{}
+ installCfg = install.NewInstallConfig()
+ namespacesToAdd string
)
func init() {
rootCmd.AddCommand(installCmd)
// local command flags
- installCmd.Flags().StringVar(&installCfg.Namespaces, cli.FlagNamespaces, install.DefaultDBNamespaceName, "Comma-separated namespaces list Percona Everest can manage")
- installCmd.Flags().BoolVar(&installCfg.SkipWizard, cli.FlagSkipWizard, false, "Skip installation wizard")
+ installCmd.Flags().StringVar(&namespacesToAdd, cli.FlagNamespaces, common.DefaultDBNamespaceName, "Comma-separated namespaces list Percona Everest can manage")
+ installCmd.Flags().BoolVar(&installCfg.NamespaceAddConfig.SkipWizard, cli.FlagSkipWizard, false, "Skip installation wizard")
installCmd.Flags().StringVar(&installCfg.VersionMetadataURL, cli.FlagVersionMetadataURL, "https://check.percona.com", "URL to retrieve version metadata information from")
installCmd.Flags().StringVar(&installCfg.Version, cli.FlagVersion, "", "Everest version to install. By default the latest version is installed")
installCmd.Flags().BoolVar(&installCfg.DisableTelemetry, cli.FlagDisableTelemetry, false, "Disable telemetry")
@@ -63,43 +66,85 @@ func init() {
installCmd.MarkFlagsMutuallyExclusive(cli.FlagNamespaces, cli.FlagInstallSkipDBNamespace)
// --helm.* flags
- installCmd.Flags().StringVar(&installCfg.ChartDir, helm.FlagChartDir, "", "Path to the chart directory. If not set, the chart will be downloaded from the repository")
+ installCmd.Flags().StringVar(&installCfg.HelmConfig.ChartDir, helm.FlagChartDir, "", "Path to the chart directory. If not set, the chart will be downloaded from the repository")
_ = installCmd.Flags().MarkHidden(helm.FlagChartDir)
- installCmd.Flags().StringVar(&installCfg.RepoURL, helm.FlagRepository, helm.DefaultHelmRepoURL, "Helm chart repository to download the Everest charts from")
- installCmd.Flags().StringSliceVar(&installCfg.Values.Values, helm.FlagHelmSet, []string{}, "Set helm values on the command line (can specify multiple values with commas: key1=val1,key2=val2)")
- installCmd.Flags().StringSliceVarP(&installCfg.Values.ValueFiles, helm.FlagHelmValues, "f", []string{}, "Specify values in a YAML file or a URL (can specify multiple)")
+ installCmd.Flags().StringVar(&installCfg.HelmConfig.RepoURL, helm.FlagRepository, helm.DefaultHelmRepoURL, "Helm chart repository to download the Everest charts from")
+ installCmd.Flags().StringSliceVar(&installCfg.HelmConfig.Values.Values, helm.FlagHelmSet, []string{}, "Set helm values on the command line (can specify multiple values with commas: key1=val1,key2=val2)")
+ installCmd.Flags().StringSliceVarP(&installCfg.HelmConfig.Values.ValueFiles, helm.FlagHelmValues, "f", []string{}, "Specify values in a YAML file or a URL (can specify multiple)")
// --operator.* flags
- installCmd.Flags().BoolVar(&installCfg.Operator.PSMDB, cli.FlagOperatorMongoDB, true, "Install MongoDB operator")
- installCmd.Flags().BoolVar(&installCfg.Operator.PG, cli.FlagOperatorPostgresql, true, "Install PostgreSQL operator")
- installCmd.Flags().BoolVar(&installCfg.Operator.PXC, cli.FlagOperatorXtraDBCluster, true, "Install XtraDB Cluster operator")
+ installCmd.Flags().BoolVar(&installCfg.NamespaceAddConfig.Operators.PSMDB, cli.FlagOperatorMongoDB, true, "Install MongoDB operator")
+ installCmd.Flags().BoolVar(&installCfg.NamespaceAddConfig.Operators.PG, cli.FlagOperatorPostgresql, true, "Install PostgreSQL operator")
+ installCmd.Flags().BoolVar(&installCfg.NamespaceAddConfig.Operators.PXC, cli.FlagOperatorXtraDBCluster, true, "Install XtraDB Cluster operator")
}
-func installPreRunE(cmd *cobra.Command, _ []string) error { //nolint:revive
- if installCfg.SkipDBNamespace {
- installCfg.Namespaces = ""
- }
-
+func installPreRun(cmd *cobra.Command, _ []string) { //nolint:revive
// Copy global flags to config
installCfg.Pretty = rootCmdFlags.Pretty
installCfg.KubeconfigPath = rootCmdFlags.KubeconfigPath
+ installCfg.NamespaceAddConfig.KubeconfigPath = rootCmdFlags.KubeconfigPath
+ // Check if Everest is already installed.
+ if err := install.CheckEverestAlreadyinstalled(cmd.Context(), logger.GetLogger(), installCfg.KubeconfigPath); err != nil {
+ output.PrintError(err, logger.GetLogger(), installCfg.Pretty)
+ os.Exit(1)
+ }
+
+ if !installCfg.SkipDBNamespace {
+ if err := checkDBNamespaceParameters(cmd); err != nil {
+ output.PrintError(err, logger.GetLogger(), installCfg.Pretty)
+ os.Exit(1)
+ }
+ }
+}
+
+// checkDBNamespaceParameters checks, validates and sets the database namespace parameters into installCfg.
+// If the user doesn't pass '--namespaces' or '--operators.*' flags,
+// it will ask the user to provide them in interactive mode (if it is enabled).
+func checkDBNamespaceParameters(cmd *cobra.Command) error {
+ // Check DB namespaces parameters
// If user doesn't pass --namespaces flag - need to ask explicitly.
- installCfg.AskNamespaces = !(cmd.Flags().Lookup(cli.FlagNamespaces).Changed || installCfg.SkipDBNamespace)
+ askNamespaces := !(cmd.Flags().Lookup(cli.FlagNamespaces).Changed ||
+ installCfg.NamespaceAddConfig.SkipWizard)
+
+ // Note: there are the following cases possible:
+ // - user doesn't provide '--namespaces' flag -> namespacesToAdd="everest" (default).
+ // - user provides '--namespaces' flag -> namespacesToAdd contains the user provided value.
+ if askNamespaces {
+ // need to ask user in interactive mode to provide database namespaces to be created.
+ if err := installCfg.NamespaceAddConfig.PopulateNamespaces(cmd.Context()); err != nil {
+ return err
+ }
+ } else {
+ // Parse and validate user provided namespaces.
+ nsList := namespaces.ParseNamespaceNames(namespacesToAdd)
+ if err := installCfg.NamespaceAddConfig.ValidateNamespaces(cmd.Context(), nsList); err != nil {
+ return err
+ }
+
+ installCfg.NamespaceAddConfig.NamespaceList = nsList
+ }
// If user doesn't pass any --operator.* flags - need to ask explicitly.
- installCfg.AskOperators = !(cmd.Flags().Lookup(cli.FlagOperatorMongoDB).Changed ||
+ askOperators := !(cmd.Flags().Lookup(cli.FlagOperatorMongoDB).Changed ||
cmd.Flags().Lookup(cli.FlagOperatorPostgresql).Changed ||
cmd.Flags().Lookup(cli.FlagOperatorXtraDBCluster).Changed ||
- installCfg.SkipDBNamespace)
+ installCfg.NamespaceAddConfig.SkipWizard)
+
+ if askOperators {
+ // need to ask user to provide operators to be installed in interactive mode.
+ if err := installCfg.NamespaceAddConfig.PopulateOperators(cmd.Context()); err != nil {
+ return err
+ }
+ }
return nil
}
func installRun(cmd *cobra.Command, _ []string) { //nolint:revive
- op, err := install.NewInstall(*installCfg, logger.GetLogger())
+ op, err := install.NewInstall(installCfg, logger.GetLogger())
if err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), installCfg.Pretty)
os.Exit(1)
}
diff --git a/commands/namespaces/add.go b/commands/namespaces/add.go
index 5f57e8bfe..8679d70e9 100644
--- a/commands/namespaces/add.go
+++ b/commands/namespaces/add.go
@@ -43,7 +43,7 @@ var (
PreRun: namespacesAddPreRun,
Run: namespacesAddRun,
}
- namespacesAddCfg = &namespaces.NamespaceAddConfig{}
+ namespacesAddCfg = namespaces.NewNamespaceAddConfig()
)
func init() {
@@ -55,45 +55,63 @@ func init() {
namespacesAddCmd.Flags().BoolVar(&namespacesAddCfg.SkipEnvDetection, cli.FlagSkipEnvDetection, false, "Skip detecting Kubernetes environment where Everest is installed")
// --helm.* flags
- namespacesAddCmd.Flags().StringVar(&namespacesAddCfg.CLIOptions.ChartDir, helm.FlagChartDir, "", "Path to the chart directory. If not set, the chart will be downloaded from the repository")
+ namespacesAddCmd.Flags().StringVar(&namespacesAddCfg.HelmConfig.ChartDir, helm.FlagChartDir, "", "Path to the chart directory. If not set, the chart will be downloaded from the repository")
_ = namespacesAddCmd.Flags().MarkHidden(helm.FlagChartDir) //nolint:errcheck,gosec
- namespacesAddCmd.Flags().StringVar(&namespacesAddCfg.CLIOptions.RepoURL, helm.FlagRepository, helm.DefaultHelmRepoURL, "Helm chart repository to download the Everest charts from")
- namespacesAddCmd.Flags().StringSliceVar(&namespacesAddCfg.CLIOptions.Values.Values, helm.FlagHelmSet, []string{}, "Set helm values on the command line (can specify multiple values with commas: key1=val1,key2=val2)")
- namespacesAddCmd.Flags().StringSliceVarP(&namespacesAddCfg.CLIOptions.Values.ValueFiles, helm.FlagHelmValues, "f", []string{}, "Specify values in a YAML file or a URL (can specify multiple)")
+ namespacesAddCmd.Flags().StringVar(&namespacesAddCfg.HelmConfig.RepoURL, helm.FlagRepository, helm.DefaultHelmRepoURL, "Helm chart repository to download the Everest charts from")
+ namespacesAddCmd.Flags().StringSliceVar(&namespacesAddCfg.HelmConfig.Values.Values, helm.FlagHelmSet, []string{}, "Set helm values on the command line (can specify multiple values with commas: key1=val1,key2=val2)")
+ namespacesAddCmd.Flags().StringSliceVarP(&namespacesAddCfg.HelmConfig.Values.ValueFiles, helm.FlagHelmValues, "f", []string{}, "Specify values in a YAML file or a URL (can specify multiple)")
// --operator.* flags
- namespacesAddCmd.Flags().BoolVar(&namespacesAddCfg.Operator.PSMDB, cli.FlagOperatorMongoDB, true, "Install MongoDB operator")
- namespacesAddCmd.Flags().BoolVar(&namespacesAddCfg.Operator.PG, cli.FlagOperatorPostgresql, true, "Install PostgreSQL operator")
- namespacesAddCmd.Flags().BoolVar(&namespacesAddCfg.Operator.PXC, cli.FlagOperatorXtraDBCluster, true, "Install XtraDB Cluster operator")
+ namespacesAddCmd.Flags().BoolVar(&namespacesAddCfg.Operators.PSMDB, cli.FlagOperatorMongoDB, true, "Install MongoDB operator")
+ namespacesAddCmd.Flags().BoolVar(&namespacesAddCfg.Operators.PG, cli.FlagOperatorPostgresql, true, "Install PostgreSQL operator")
+ namespacesAddCmd.Flags().BoolVar(&namespacesAddCfg.Operators.PXC, cli.FlagOperatorXtraDBCluster, true, "Install XtraDB Cluster operator")
}
func namespacesAddPreRun(cmd *cobra.Command, args []string) { //nolint:revive
- namespacesAddCfg.Namespaces = args[0]
-
// Copy global flags to config
namespacesAddCfg.Pretty = !(cmd.Flag(cli.FlagVerbose).Changed || cmd.Flag(cli.FlagJSON).Changed)
namespacesAddCfg.KubeconfigPath = cmd.Flag(cli.FlagKubeconfig).Value.String()
+ {
+ // Parse and validate provided namespaces
+ nsList := namespaces.ParseNamespaceNames(args[0])
+ if err := namespacesAddCfg.ValidateNamespaces(cmd.Context(), nsList); err != nil {
+ if errors.Is(err, namespaces.ErrNamespaceAlreadyExists) {
+ err = fmt.Errorf("%w. %s", err, takeOwnershipHintMessage)
+ }
+ if errors.Is(err, namespaces.ErrNamespaceAlreadyManagedByEverest) {
+ err = fmt.Errorf("%w. %s", err, updateHintMessage)
+ }
+ output.PrintError(err, logger.GetLogger(), namespacesAddCfg.Pretty)
+ os.Exit(1)
+ }
+
+ namespacesAddCfg.NamespaceList = nsList
+ }
+
// If user doesn't pass any --operator.* flags - need to ask explicitly.
- namespacesAddCfg.AskOperators = !(cmd.Flags().Lookup(cli.FlagOperatorMongoDB).Changed ||
+ askOperators := !(cmd.Flags().Lookup(cli.FlagOperatorMongoDB).Changed ||
cmd.Flags().Lookup(cli.FlagOperatorPostgresql).Changed ||
- cmd.Flags().Lookup(cli.FlagOperatorXtraDBCluster).Changed)
+ cmd.Flags().Lookup(cli.FlagOperatorXtraDBCluster).Changed ||
+ namespacesAddCfg.SkipWizard)
+
+ if askOperators {
+ // need to ask user to provide operators to be installed in interactive mode.
+ if err := namespacesAddCfg.PopulateOperators(cmd.Context()); err != nil {
+ output.PrintError(err, logger.GetLogger(), namespacesAddCfg.Pretty)
+ os.Exit(1)
+ }
+ }
}
func namespacesAddRun(cmd *cobra.Command, _ []string) {
- op, err := namespaces.NewNamespaceAdd(*namespacesAddCfg, logger.GetLogger())
+ op, err := namespaces.NewNamespaceAdd(namespacesAddCfg, logger.GetLogger())
if err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), namespacesAddCfg.Pretty)
os.Exit(1)
}
if err := op.Run(cmd.Context()); err != nil {
- if errors.Is(err, namespaces.ErrNamespaceAlreadyExists) {
- err = fmt.Errorf("%w. %s", err, takeOwnershipHintMessage)
- }
- if errors.Is(err, namespaces.ErrNamespaceAlreadyOwned) {
- err = fmt.Errorf("%w. %s", err, updateHintMessage)
- }
output.PrintError(err, logger.GetLogger(), namespacesAddCfg.Pretty)
os.Exit(1)
}
diff --git a/commands/namespaces/remove.go b/commands/namespaces/remove.go
index 4a00ff0ed..a60fdae06 100644
--- a/commands/namespaces/remove.go
+++ b/commands/namespaces/remove.go
@@ -51,24 +51,30 @@ func init() {
}
func namespacesRemovePreRun(cmd *cobra.Command, args []string) { //nolint:revive
- namespacesRemoveCfg.Namespaces = args[0]
-
// Copy global flags to config
namespacesRemoveCfg.Pretty = !(cmd.Flag(cli.FlagVerbose).Changed || cmd.Flag(cli.FlagJSON).Changed)
namespacesRemoveCfg.KubeconfigPath = cmd.Flag(cli.FlagKubeconfig).Value.String()
+
+ // Parse and validate provided namespaces
+ nsList := namespaces.ParseNamespaceNames(args[0])
+ if err := namespacesRemoveCfg.ValidateNamespaces(cmd.Context(), nsList); err != nil {
+ if errors.Is(err, namespaces.ErrNamespaceNotEmpty) {
+ err = fmt.Errorf("%w. %s", err, forceUninstallHint)
+ }
+ output.PrintError(err, logger.GetLogger(), namespacesRemoveCfg.Pretty)
+ os.Exit(1)
+ }
+ namespacesRemoveCfg.NamespaceList = nsList
}
func namespacesRemoveRun(cmd *cobra.Command, _ []string) {
op, err := namespaces.NewNamespaceRemove(*namespacesRemoveCfg, logger.GetLogger())
if err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), namespacesRemoveCfg.Pretty)
os.Exit(1)
}
if err := op.Run(cmd.Context()); err != nil {
- if errors.Is(err, namespaces.ErrNamespaceNotEmpty) {
- err = fmt.Errorf("%w. %s", err, forceUninstallHint)
- }
output.PrintError(err, logger.GetLogger(), namespacesRemoveCfg.Pretty)
os.Exit(1)
}
diff --git a/commands/namespaces/update.go b/commands/namespaces/update.go
index b30cd879e..13191515a 100644
--- a/commands/namespaces/update.go
+++ b/commands/namespaces/update.go
@@ -20,6 +20,7 @@ import (
"errors"
"fmt"
"os"
+ "strings"
"github.com/spf13/cobra"
@@ -40,10 +41,12 @@ var (
PreRun: namespacesUpdatePreRun,
Run: namespacesUpdateRun,
}
- namespacesUpdateCfg = &namespaces.NamespaceAddConfig{}
+ namespacesUpdateCfg = namespaces.NewNamespaceAddConfig()
)
func init() {
+ namespacesUpdateCfg.Update = true
+
// local command flags
namespacesUpdateCmd.Flags().BoolVar(&namespacesUpdateCfg.DisableTelemetry, cli.FlagDisableTelemetry, false, "Disable telemetry")
_ = namespacesUpdateCmd.Flags().MarkHidden(cli.FlagDisableTelemetry) //nolint:errcheck,gosec
@@ -51,36 +54,52 @@ func init() {
namespacesUpdateCmd.Flags().BoolVar(&namespacesUpdateCfg.SkipEnvDetection, cli.FlagSkipEnvDetection, false, "Skip detecting Kubernetes environment where Everest is installed")
// --helm.* flags
- namespacesUpdateCmd.Flags().StringVar(&namespacesUpdateCfg.CLIOptions.ChartDir, helm.FlagChartDir, "", "Path to the chart directory. If not set, the chart will be downloaded from the repository")
+ namespacesUpdateCmd.Flags().StringVar(&namespacesUpdateCfg.HelmConfig.ChartDir, helm.FlagChartDir, "", "Path to the chart directory. If not set, the chart will be downloaded from the repository")
_ = namespacesUpdateCmd.Flags().MarkHidden(helm.FlagChartDir) //nolint:errcheck,gosec
- namespacesUpdateCmd.Flags().StringVar(&namespacesUpdateCfg.CLIOptions.RepoURL, helm.FlagRepository, helm.DefaultHelmRepoURL, "Helm chart repository to download the Everest charts from")
- namespacesUpdateCmd.Flags().StringSliceVar(&namespacesUpdateCfg.CLIOptions.Values.Values, helm.FlagHelmSet, []string{}, "Set helm values on the command line (can specify multiple values with commas: key1=val1,key2=val2)")
- namespacesUpdateCmd.Flags().StringSliceVarP(&namespacesUpdateCfg.CLIOptions.Values.ValueFiles, helm.FlagHelmValues, "f", []string{}, "Specify values in a YAML file or a URL (can specify multiple)")
+ namespacesUpdateCmd.Flags().StringVar(&namespacesUpdateCfg.HelmConfig.RepoURL, helm.FlagRepository, helm.DefaultHelmRepoURL, "Helm chart repository to download the Everest charts from")
+ namespacesUpdateCmd.Flags().StringSliceVar(&namespacesUpdateCfg.HelmConfig.Values.Values, helm.FlagHelmSet, []string{}, "Set helm values on the command line (can specify multiple values with commas: key1=val1,key2=val2)")
+ namespacesUpdateCmd.Flags().StringSliceVarP(&namespacesUpdateCfg.HelmConfig.Values.ValueFiles, helm.FlagHelmValues, "f", []string{}, "Specify values in a YAML file or a URL (can specify multiple)")
// --operator.* flags
- namespacesUpdateCmd.Flags().BoolVar(&namespacesUpdateCfg.Operator.PSMDB, cli.FlagOperatorMongoDB, true, "Install MongoDB operator")
- namespacesUpdateCmd.Flags().BoolVar(&namespacesUpdateCfg.Operator.PG, cli.FlagOperatorPostgresql, true, "Install PostgreSQL operator")
- namespacesUpdateCmd.Flags().BoolVar(&namespacesUpdateCfg.Operator.PXC, cli.FlagOperatorXtraDBCluster, true, "Install XtraDB Cluster operator")
+ namespacesUpdateCmd.Flags().BoolVar(&namespacesUpdateCfg.Operators.PSMDB, cli.FlagOperatorMongoDB, true, "Install MongoDB operator")
+ namespacesUpdateCmd.Flags().BoolVar(&namespacesUpdateCfg.Operators.PG, cli.FlagOperatorPostgresql, true, "Install PostgreSQL operator")
+ namespacesUpdateCmd.Flags().BoolVar(&namespacesUpdateCfg.Operators.PXC, cli.FlagOperatorXtraDBCluster, true, "Install XtraDB Cluster operator")
}
func namespacesUpdatePreRun(cmd *cobra.Command, args []string) { //nolint:revive
- namespacesUpdateCfg.Namespaces = args[0]
- namespacesUpdateCfg.Update = true
-
// Copy global flags to config
namespacesUpdateCfg.Pretty = !(cmd.Flag(cli.FlagVerbose).Changed || cmd.Flag(cli.FlagJSON).Changed)
namespacesUpdateCfg.KubeconfigPath = cmd.Flag(cli.FlagKubeconfig).Value.String()
+ {
+ // Parse and validate provided namespaces
+ nsList := namespaces.ParseNamespaceNames(args[0])
+ if err := namespacesUpdateCfg.ValidateNamespaces(cmd.Context(), nsList); err != nil {
+ output.PrintError(err, logger.GetLogger(), namespacesUpdateCfg.Pretty)
+ os.Exit(1)
+ }
+
+ namespacesUpdateCfg.NamespaceList = nsList
+ }
+
// If user doesn't pass any --operator.* flags - need to ask explicitly.
- namespacesUpdateCfg.AskOperators = !(cmd.Flags().Lookup(cli.FlagOperatorMongoDB).Changed ||
+ askOperators := !(cmd.Flags().Lookup(cli.FlagOperatorMongoDB).Changed ||
cmd.Flags().Lookup(cli.FlagOperatorPostgresql).Changed ||
cmd.Flags().Lookup(cli.FlagOperatorXtraDBCluster).Changed)
+
+ if askOperators {
+ // need to ask user to provide operators to be installed in interactive mode.
+ if err := namespacesUpdateCfg.PopulateOperators(cmd.Context()); err != nil {
+ output.PrintError(err, logger.GetLogger(), namespacesUpdateCfg.Pretty)
+ os.Exit(1)
+ }
+ }
}
func namespacesUpdateRun(cmd *cobra.Command, _ []string) {
- op, err := namespaces.NewNamespaceAdd(*namespacesUpdateCfg, logger.GetLogger())
+ op, err := namespaces.NewNamespaceAdd(namespacesUpdateCfg, logger.GetLogger())
if err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), namespacesUpdateCfg.Pretty)
os.Exit(1)
}
@@ -89,11 +108,11 @@ func namespacesUpdateRun(cmd *cobra.Command, _ []string) {
err = fmt.Errorf("%w. HINT: use 'everestctl namespaces add --%s %s' first to make namespace managed by Everest",
err,
cli.FlagTakeNamespaceOwnership,
- namespacesUpdateCfg.Namespaces,
+ strings.Join(namespacesUpdateCfg.NamespaceList, ", "),
)
}
- output.PrintError(err, logger.GetLogger(), namespacesAddCfg.Pretty)
+ output.PrintError(err, logger.GetLogger(), namespacesUpdateCfg.Pretty)
os.Exit(1)
}
}
diff --git a/commands/settings/oidc/configure.go b/commands/settings/oidc/configure.go
index 6684484e7..348f743ac 100644
--- a/commands/settings/oidc/configure.go
+++ b/commands/settings/oidc/configure.go
@@ -40,25 +40,64 @@ var (
Run: settingsOIDCConfigureRun,
}
settingsOIDCConfigureCfg = &oidc.Config{}
+ scopes string
)
func init() {
// local command flags
settingsOIDCConfigureCmd.Flags().StringVar(&settingsOIDCConfigureCfg.IssuerURL, cli.FlagOIDCIssuerURL, "", "OIDC issuer url")
settingsOIDCConfigureCmd.Flags().StringVar(&settingsOIDCConfigureCfg.ClientID, cli.FlagOIDCClientID, "", "OIDC application client ID")
- settingsOIDCConfigureCmd.Flags().StringVar(&settingsOIDCConfigureCfg.Scopes, cli.FlagOIDCScopes, strings.Join(common.DefaultOIDCScopes, ","), "Comma-separated list of scopes")
+ settingsOIDCConfigureCmd.Flags().StringVar(&scopes, cli.FlagOIDCScopes, strings.Join(common.DefaultOIDCScopes, ","), "Comma-separated list of scopes")
}
func settingsOIDCConfigurePreRun(cmd *cobra.Command, _ []string) { //nolint:revive
// Copy global flags to config
settingsOIDCConfigureCfg.Pretty = !(cmd.Flag(cli.FlagVerbose).Changed || cmd.Flag(cli.FlagJSON).Changed)
settingsOIDCConfigureCfg.KubeconfigPath = cmd.Flag(cli.FlagKubeconfig).Value.String()
+
+ // Check if issuer URL is provided
+ if settingsOIDCConfigureCfg.IssuerURL == "" {
+ // Ask user to provide issuer URL in interactive mode
+ if err := settingsOIDCConfigureCfg.PopulateIssuerURL(cmd.Context()); err != nil {
+ output.PrintError(err, logger.GetLogger(), settingsOIDCConfigureCfg.Pretty)
+ os.Exit(1)
+ }
+ } else {
+ // Validate issuer URL provided by user in flags
+ if err := oidc.ValidateURL(settingsOIDCConfigureCfg.IssuerURL); err != nil {
+ output.PrintError(err, logger.GetLogger(), settingsOIDCConfigureCfg.Pretty)
+ os.Exit(1)
+ }
+ }
+
+ // Check if Client ID is provided
+ if settingsOIDCConfigureCfg.ClientID == "" {
+ // Ask user to provide client ID in interactive mode
+ if err := settingsOIDCConfigureCfg.PopulateClientID(cmd.Context()); err != nil {
+ output.PrintError(err, logger.GetLogger(), settingsOIDCConfigureCfg.Pretty)
+ os.Exit(1)
+ }
+ } else {
+ // Validate client ID provided by user in flags
+ if err := oidc.ValidateClientID(settingsOIDCConfigureCfg.ClientID); err != nil {
+ output.PrintError(err, logger.GetLogger(), settingsOIDCConfigureCfg.Pretty)
+ os.Exit(1)
+ }
+ }
+
+ // Validate scopes (default or provided by user in flags)
+ scopesList := strings.Split(scopes, ",")
+ if err := oidc.ValidateScopes(scopesList); err != nil {
+ output.PrintError(err, logger.GetLogger(), settingsOIDCConfigureCfg.Pretty)
+ os.Exit(1)
+ }
+ settingsOIDCConfigureCfg.Scopes = scopesList
}
func settingsOIDCConfigureRun(cmd *cobra.Command, _ []string) {
op, err := oidc.NewOIDC(*settingsOIDCConfigureCfg, logger.GetLogger())
if err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), settingsOIDCConfigureCfg.Pretty)
os.Exit(1)
}
diff --git a/commands/settings/rbac/can.go b/commands/settings/rbac/can.go
index b13b037a5..8fbc18abd 100644
--- a/commands/settings/rbac/can.go
+++ b/commands/settings/rbac/can.go
@@ -28,6 +28,7 @@ import (
"github.com/percona/everest/pkg/cli"
"github.com/percona/everest/pkg/kubernetes"
"github.com/percona/everest/pkg/logger"
+ "github.com/percona/everest/pkg/output"
"github.com/percona/everest/pkg/rbac"
)
@@ -99,7 +100,7 @@ func settingsRBACCanRun(cmd *cobra.Command, args []string) {
client, err := kubernetes.New(rbacCanKubeconfigPath, l)
if err != nil {
- l.Error(err)
+ output.PrintError(err, logger.GetLogger(), rbacCanPretty)
os.Exit(1)
}
k = client
@@ -107,7 +108,7 @@ func settingsRBACCanRun(cmd *cobra.Command, args []string) {
can, err := rbac.Can(cmd.Context(), rbacCanPolicyFilePath, k, args...)
if err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), rbacCanPretty)
os.Exit(1)
}
diff --git a/commands/uninstall.go b/commands/uninstall.go
index d6ef4a459..1190fa48e 100644
--- a/commands/uninstall.go
+++ b/commands/uninstall.go
@@ -57,7 +57,7 @@ func uninstallPreRun(_ *cobra.Command, _ []string) { //nolint:revive
func uninstallRun(cmd *cobra.Command, _ []string) { //nolint:revive
op, err := uninstall.NewUninstall(*uninstallCfg, logger.GetLogger())
if err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), uninstallCfg.Pretty)
os.Exit(1)
}
diff --git a/commands/upgrade.go b/commands/upgrade.go
index 531e17536..0a6f225ed 100644
--- a/commands/upgrade.go
+++ b/commands/upgrade.go
@@ -71,7 +71,7 @@ func upgradePreRun(_ *cobra.Command, _ []string) { //nolint:revive
func upgradeRun(cmd *cobra.Command, _ []string) { //nolint:revive
op, err := upgrade.NewUpgrade(upgradeCfg, logger.GetLogger())
if err != nil {
- logger.GetLogger().Error(err)
+ output.PrintError(err, logger.GetLogger(), upgradeCfg.Pretty)
os.Exit(1)
}
diff --git a/go.mod b/go.mod
index 2eb4b90ad..e805cbc5a 100644
--- a/go.mod
+++ b/go.mod
@@ -12,17 +12,17 @@ replace (
)
require (
- github.com/AlecAivazis/survey/v2 v2.3.7
github.com/AlekSi/pointer v1.2.0
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.5.0
github.com/Percona-Lab/percona-version-service v0.0.0-20240311164804-ffbc02387a1b
github.com/aws/aws-sdk-go v1.55.5
- github.com/briandowns/spinner v1.23.2
github.com/casbin/casbin/v2 v2.103.0
github.com/casbin/govaluate v1.3.0
github.com/cenkalti/backoff v2.2.1+incompatible
github.com/cenkalti/backoff/v4 v4.3.0
- github.com/fatih/color v1.18.0
+ github.com/charmbracelet/bubbles v0.20.0
+ github.com/charmbracelet/bubbletea v1.2.4
+ github.com/charmbracelet/lipgloss v1.0.0
github.com/getkin/kin-openapi v0.128.0
github.com/go-logr/zapr v1.3.0
github.com/golang-jwt/jwt/v5 v5.2.1
@@ -38,13 +38,14 @@ require (
github.com/operator-framework/api v0.27.0
github.com/operator-framework/operator-lifecycle-manager v0.27.0
github.com/percona/everest-operator v0.6.0-dev1.0.20250131090446-40b6d1d65b10
- github.com/percona/percona-helm-charts/charts/everest v0.0.0-20250205100220-bfc757bae052
+ github.com/percona/percona-helm-charts/charts/everest v0.0.0-20250206203544-2d61a898d367
github.com/rodaine/table v1.3.0
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.10.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.31.0
golang.org/x/mod v0.22.0
+ golang.org/x/term v0.27.0
golang.org/x/time v0.9.0
google.golang.org/protobuf v1.36.2
gopkg.in/yaml.v2 v2.4.0
@@ -56,15 +57,11 @@ require (
k8s.io/cli-runtime v0.32.0
k8s.io/client-go v12.0.0+incompatible
k8s.io/kubectl v0.32.0
+ k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738
sigs.k8s.io/controller-runtime v0.20.1
sigs.k8s.io/yaml v1.4.0
)
-require (
- k8s.io/apiserver v0.32.0 // indirect
- k8s.io/component-base v0.32.0 // indirect
-)
-
require (
cel.dev/expr v0.18.0 // indirect
dario.cat/mergo v1.0.1 // indirect
@@ -82,12 +79,16 @@ require (
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
+ github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
github.com/cert-manager/cert-manager v1.16.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chai2010/gettext-go v1.0.2 // indirect
+ github.com/charmbracelet/x/ansi v0.4.5 // indirect
+ github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/containerd/containerd v1.7.23 // indirect
github.com/containerd/errdefs v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
@@ -103,9 +104,11 @@ require (
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-metrics v0.0.1 // indirect
github.com/emicklei/go-restful/v3 v3.12.1 // indirect
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/evanphx/json-patch v5.9.0+incompatible // indirect
github.com/evanphx/json-patch/v5 v5.9.0 // indirect
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
+ github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/flosch/pongo2/v6 v6.0.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
@@ -146,7 +149,6 @@ require (
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
- github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/labstack/gommon v0.4.2 // indirect
@@ -159,11 +161,12 @@ require (
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
- github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
@@ -175,6 +178,9 @@ require (
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/muesli/termenv v0.15.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/onsi/gomega v1.36.2 // indirect
@@ -194,7 +200,7 @@ require (
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.59.1 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
- github.com/rivo/uniseg v0.4.4 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
github.com/rubenv/sql-migrate v1.7.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
@@ -227,16 +233,16 @@ require (
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
- golang.org/x/term v0.27.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241118233622-e639e219e697 // indirect
google.golang.org/grpc v1.69.2 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
+ k8s.io/apiserver v0.32.0 // indirect
+ k8s.io/component-base v0.32.0 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
- k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
oras.land/oras-go v1.2.5 // indirect
sigs.k8s.io/gateway-api v1.1.0 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
diff --git a/go.sum b/go.sum
index 5cde41c80..ef3f68e53 100644
--- a/go.sum
+++ b/go.sum
@@ -1326,8 +1326,6 @@ gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zum
git.sr.ht/~sbinet/gg v0.3.1/go.mod h1:KGYtlADtqsqANL9ueOFkWymvzUvLMQllU5Ixo+8v3pc=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
-github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
-github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 h1:JZg6HRh6W6U4OLl6lk7BZ7BLisIzM9dG1R50zUk9C/M=
@@ -1367,8 +1365,6 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA
github.com/Microsoft/hcsshim v0.12.0-rc.0 h1:wX/F5huJxH9APBkhKSEAqaiZsuBvbbDnyBROZAqsSaY=
github.com/Microsoft/hcsshim v0.12.0-rc.0/go.mod h1:rvOnw3YlfoNnEp45wReUngvsXbwRW+AFQ10GVjG1kMU=
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
-github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
-github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/Percona-Lab/percona-version-service v0.0.0-20240311164804-ffbc02387a1b h1:6+5kSyTLQ3yiBtLzokN2GhgkkWwBZE0auO19PpOTndo=
github.com/Percona-Lab/percona-version-service v0.0.0-20240311164804-ffbc02387a1b/go.mod h1:/R/tVunZsnlasTvqRbJvH/1doO818dpGJqhW3aXPH9g=
@@ -1417,8 +1413,12 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkY
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -1432,8 +1432,6 @@ github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwN
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
-github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
-github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70=
github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk=
github.com/bugsnag/bugsnag-go v1.5.3 h1:yeRUT3mUE13jL1tGwvoQsKdVbAsQx9AJ+fqahKveP04=
@@ -1462,6 +1460,16 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk=
github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA=
+github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
+github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
+github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE=
+github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM=
+github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
+github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
+github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM=
+github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
@@ -1542,7 +1550,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsr
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
-github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cyphar/filepath-securejoin v0.3.4 h1:VBWugsJh2ZxJmLFSM06/0qzQyiQX2Qs0ViKrUAcqdZ8=
@@ -1606,6 +1613,8 @@ github.com/envoyproxy/protoc-gen-validate v0.10.1/go.mod h1:DRjgyB0I43LtJapqN6Ni
github.com/envoyproxy/protoc-gen-validate v1.0.1/go.mod h1:0vj8bNkYbSTNS2PIyH87KZaeN4x9zpL9Qt8fQC7d+vs=
github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE=
github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls=
github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
@@ -1943,8 +1952,6 @@ github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0m
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
-github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
-github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
@@ -1989,7 +1996,6 @@ github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+
github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
-github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
@@ -2049,6 +2055,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0=
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o=
@@ -2072,6 +2080,8 @@ github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
@@ -2082,9 +2092,6 @@ github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxU
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
-github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
-github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
-github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
@@ -2131,6 +2138,12 @@ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
+github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
@@ -2223,8 +2236,8 @@ github.com/percona/everest-operator v0.6.0-dev1.0.20250131090446-40b6d1d65b10 h1
github.com/percona/everest-operator v0.6.0-dev1.0.20250131090446-40b6d1d65b10/go.mod h1:jpmlzDw0avyNWwmlBABbaHNZO4/G3q9AonI1GoXfQfE=
github.com/percona/percona-backup-mongodb v1.8.1-0.20241212160532-0157f87a7eee h1:LtitxWyhBqCNjIZqdvsSEPBd2HPg11lDBlIExTQAbGQ=
github.com/percona/percona-backup-mongodb v1.8.1-0.20241212160532-0157f87a7eee/go.mod h1:zikIUTNTflfcth3ZJRqhvW8+7Jj38aVlg+wSV1jwnxo=
-github.com/percona/percona-helm-charts/charts/everest v0.0.0-20250205100220-bfc757bae052 h1:iTiSwfEzVWbFhTF9vu5/keuPZhqGZKUiBSGB52oAWos=
-github.com/percona/percona-helm-charts/charts/everest v0.0.0-20250205100220-bfc757bae052/go.mod h1:j5Ci48Azwb4Xs4XvZQNfleWCn2uyiZywazklxNH1ut4=
+github.com/percona/percona-helm-charts/charts/everest v0.0.0-20250206203544-2d61a898d367 h1:OawISaw1ZWLaLxddWTO9KGBuXofGY4KovB2hjPhW1+8=
+github.com/percona/percona-helm-charts/charts/everest v0.0.0-20250206203544-2d61a898d367/go.mod h1:j5Ci48Azwb4Xs4XvZQNfleWCn2uyiZywazklxNH1ut4=
github.com/percona/percona-postgresql-operator v0.0.0-20241007204305-35d61aa5aebd h1:9RCUfPUxbdXuL/247r77DJmRSowDzA2xzZC9FpuLuUw=
github.com/percona/percona-postgresql-operator v0.0.0-20241007204305-35d61aa5aebd/go.mod h1:ICbLstSO4zhYo+SFSciIWO9rLHQg29GJ1335L0tfhR0=
github.com/percona/percona-server-mongodb-operator v1.19.0 h1:X67Vx2jDYhSzyVfQZBKiVIjV3MICpyMLmon/m7y8tUo=
@@ -2318,8 +2331,8 @@ github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40T
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
-github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rodaine/table v1.3.0 h1:4/3S3SVkHnVZX91EHFvAMV7K42AnJ0XuymRR2C5HlGE=
github.com/rodaine/table v1.3.0/go.mod h1:47zRsHar4zw0jgxGxL9YtFfs7EGN6B/TaS+/Dmk4WxU=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
@@ -2912,6 +2925,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/internal/server/handlers/rbac/database_cluster_backup_test.go b/internal/server/handlers/rbac/database_cluster_backup_test.go
index 2470c4d8d..d2e5e9382 100644
--- a/internal/server/handlers/rbac/database_cluster_backup_test.go
+++ b/internal/server/handlers/rbac/database_cluster_backup_test.go
@@ -45,7 +45,8 @@ func TestRBAC_DatabaseClusterBackup(t *testing.T) {
},
},
},
- }, nil)
+ }, nil,
+ )
return h
}
@@ -175,7 +176,8 @@ func TestRBAC_DatabaseClusterBackup(t *testing.T) {
DBClusterName: "cluster1",
BackupStorageName: "bs1",
},
- }, nil)
+ }, nil,
+ )
return h
}
@@ -316,17 +318,20 @@ func TestRBAC_DatabaseClusterBackup(t *testing.T) {
t.Run("DeleteDatabaseClusterBackup", func(t *testing.T) {
next := func() *handlers.MockHandler {
h := &handlers.MockHandler{}
- h.On("GetDatabaseClusterBackup", mock.Anything, mock.Anything, mock.Anything).Return(&everestv1alpha1.DatabaseClusterBackup{
- ObjectMeta: metav1.ObjectMeta{
- Name: "backup1",
- Namespace: "default",
- },
- Spec: everestv1alpha1.DatabaseClusterBackupSpec{
- DBClusterName: "cluster1",
- BackupStorageName: "bs1",
- },
- }, nil)
- h.On("DeleteDatabaseClusterBackup", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
+ h.On("GetDatabaseClusterBackup", mock.Anything, mock.Anything, mock.Anything).
+ Return(&everestv1alpha1.DatabaseClusterBackup{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "backup1",
+ Namespace: "default",
+ },
+ Spec: everestv1alpha1.DatabaseClusterBackupSpec{
+ DBClusterName: "cluster1",
+ BackupStorageName: "bs1",
+ },
+ }, nil,
+ )
+ h.On("DeleteDatabaseClusterBackup", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
+ Return(nil)
return h
}
diff --git a/internal/server/handlers/rbac/database_cluster_restore_test.go b/internal/server/handlers/rbac/database_cluster_restore_test.go
index 55c4d87ba..edd754c34 100644
--- a/internal/server/handlers/rbac/database_cluster_restore_test.go
+++ b/internal/server/handlers/rbac/database_cluster_restore_test.go
@@ -42,7 +42,8 @@ func TestRBAC_DatabaseClusterRestore(t *testing.T) {
},
},
},
- }, nil)
+ }, nil,
+ )
return h
}
@@ -127,7 +128,8 @@ func TestRBAC_DatabaseClusterRestore(t *testing.T) {
Spec: everestv1alpha1.DatabaseClusterRestoreSpec{
DBClusterName: "cluster1",
},
- }, nil)
+ }, nil,
+ )
return h
}
@@ -400,7 +402,8 @@ func TestRBAC_DatabaseClusterRestore(t *testing.T) {
Spec: everestv1alpha1.DatabaseClusterRestoreSpec{
DBClusterName: "cluster1",
},
- }, nil)
+ }, nil,
+ )
h.On("DeleteDatabaseClusterRestore", mock.Anything, mock.Anything, mock.Anything).Return(nil)
return h
}
diff --git a/internal/server/handlers/rbac/database_cluster_test.go b/internal/server/handlers/rbac/database_cluster_test.go
index 92c3cb1cc..652331943 100644
--- a/internal/server/handlers/rbac/database_cluster_test.go
+++ b/internal/server/handlers/rbac/database_cluster_test.go
@@ -211,7 +211,8 @@ func TestRBAC_DatabaseCluster(t *testing.T) {
Spec: everestv1alpha1.DatabaseClusterBackupSpec{
DBClusterName: "source-cluster",
},
- }, nil)
+ }, nil,
+ )
next.On("CreateDatabaseCluster", mock.Anything, mock.Anything).
Return(&everestv1alpha1.DatabaseCluster{}, nil)
@@ -321,7 +322,8 @@ func TestRBAC_DatabaseCluster(t *testing.T) {
Name: "test-cluster",
Namespace: "default",
},
- }, nil)
+ }, nil,
+ )
next.On("UpdateDatabaseCluster", mock.Anything, mock.Anything).
Return(&everestv1alpha1.DatabaseCluster{}, nil)
@@ -467,7 +469,8 @@ func TestRBAC_DatabaseCluster(t *testing.T) {
MonitoringConfigName: "test-monitoring-instance",
},
},
- }, nil)
+ }, nil,
+ )
h := &rbacHandler{
next: next,
@@ -604,7 +607,7 @@ func TestRBAC_DatabaseCluster(t *testing.T) {
return false
}
}
- return true && len(res.Items) == 3
+ return len(res.Items) == 3
},
},
{
@@ -621,7 +624,7 @@ func TestRBAC_DatabaseCluster(t *testing.T) {
return false
}
}
- return true && len(res.Items) == 3
+ return len(res.Items) == 3
},
},
{
diff --git a/internal/server/handlers/rbac/database_engine_test.go b/internal/server/handlers/rbac/database_engine_test.go
index bc4f5eba9..cca969a63 100644
--- a/internal/server/handlers/rbac/database_engine_test.go
+++ b/internal/server/handlers/rbac/database_engine_test.go
@@ -44,7 +44,8 @@ func TestRBAC_DatabaseEngines(t *testing.T) {
},
},
},
- }, nil)
+ }, nil,
+ )
return &h
}
@@ -349,7 +350,8 @@ func TestRBAC_DatabaseEngines(t *testing.T) {
{Name: pointer.ToString(common.PGOperatorName)},
{Name: pointer.ToString(common.PSMDBOperatorName)},
},
- }, nil)
+ }, nil,
+ )
return &h
}
@@ -484,7 +486,8 @@ func TestRBAC_DatabaseEngines(t *testing.T) {
{Name: pointer.ToString(common.PGOperatorName)},
{Name: pointer.ToString(common.PSMDBOperatorName)},
},
- }, nil)
+ }, nil,
+ )
h.On("ApproveUpgradePlan", mock.Anything, "default").Return(nil)
return &h
}
diff --git a/pkg/accounts/cli/accounts.go b/pkg/accounts/cli/accounts.go
index 24423b7dd..816161bba 100644
--- a/pkg/accounts/cli/accounts.go
+++ b/pkg/accounts/cli/accounts.go
@@ -21,10 +21,8 @@ import (
"errors"
"fmt"
"os"
- "regexp"
"strings"
- "github.com/AlecAivazis/survey/v2"
"github.com/rodaine/table"
"go.uber.org/zap"
@@ -35,25 +33,23 @@ import (
"github.com/percona/everest/pkg/output"
)
-const (
- minPasswordLength = 6
-)
-
-// Accounts provides functionality for managing user accounts via the Accounts.
-type Accounts struct {
- accountManager accounts.Interface
- l *zap.SugaredLogger
- config Config
- kubeClient *kubernetes.Kubernetes
-}
+type (
+ // Config holds the configuration for the accounts subcommands.
+ Config struct {
+ // KubeconfigPath is a path to a kubeconfig
+ KubeconfigPath string
+ // If set, we will print the pretty output.
+ Pretty bool
+ }
-// Config holds the configuration for the accounts subcommands.
-type Config struct {
- // KubeconfigPath is a path to a kubeconfig
- KubeconfigPath string
- // If set, we will print the pretty output.
- Pretty bool
-}
+ // Accounts provides functionality for managing user accounts via the Accounts.
+ Accounts struct {
+ accountManager accounts.Interface
+ l *zap.SugaredLogger
+ config Config
+ kubeClient *kubernetes.Kubernetes
+ }
+)
// NewAccounts creates a new Accounts for running accounts commands.
func NewAccounts(c Config, l *zap.SugaredLogger) (*Accounts, error) {
@@ -80,93 +76,6 @@ func (c *Accounts) WithAccountManager(m accounts.Interface) {
c.accountManager = m
}
-func (c *Accounts) runCredentialsWizard(username, password *string) error {
- if *username == "" {
- pUsername := survey.Input{
- Message: "Enter username",
- }
- if err := survey.AskOne(&pUsername, username); err != nil {
- return err
- }
- }
- if *password == "" {
- pPassword := survey.Password{
- Message: "Enter password",
- }
- if err := survey.AskOne(&pPassword, password); err != nil {
- return err
- }
- }
- return nil
-}
-
-// SetPasswordOptions holds options for setting a new password for user accounts.
-type SetPasswordOptions struct {
- // Username is the username for the account.
- Username string
- // NewPassword is a new password for the account.
- NewPassword string
-}
-
-// SetPassword sets the password for an existing account.
-func (c *Accounts) SetPassword(ctx context.Context, opts SetPasswordOptions) error {
- if opts.Username == "" {
- pUsername := survey.Input{
- Message: "Enter username",
- }
- if err := survey.AskOne(&pUsername, &opts.Username); err != nil {
- return err
- }
- }
-
- if opts.Username == "" {
- return errors.New("username is required")
- }
-
- if opts.NewPassword == "" {
- resp := struct {
- Password string
- ConfPassword string
- }{}
- if err := survey.Ask([]*survey.Question{
- {
- Name: "Password",
- Prompt: &survey.Password{Message: "Enter new password"},
- Validate: survey.Required,
- },
- {
- Name: "ConfPassword",
- Prompt: &survey.Password{Message: "Re-enter new password"},
- Validate: survey.Required,
- },
- }, &resp,
- ); err != nil {
- return err
- }
- if resp.Password != resp.ConfPassword {
- return errors.New("passwords do not match")
- }
- opts.NewPassword = resp.Password
- }
-
- c.l.Infof("Setting a new password for user '%s'", opts.Username)
- if ok, msg := validateCredentials(opts.Username, opts.NewPassword); !ok {
- c.l.Error(msg)
- return errors.New("invalid credentials")
- }
-
- if err := c.accountManager.SetPassword(ctx, opts.Username, opts.NewPassword, true); err != nil {
- return err
- }
-
- c.l.Infof("Password for user '%s' has been set succesfully", opts.Username)
- if c.config.Pretty {
- _, _ = fmt.Fprintln(os.Stdout, output.Success("Password for user '%s' has been set successfully", opts.Username))
- }
-
- return nil
-}
-
// CreateOptions holds options for creating a new user accounts.
type CreateOptions struct {
// Username is the username for the account.
@@ -177,13 +86,12 @@ type CreateOptions struct {
// Create a new user account.
func (c *Accounts) Create(ctx context.Context, opts CreateOptions) error {
- if err := c.runCredentialsWizard(&opts.Username, &opts.Password); err != nil {
+ if err := ValidateUsername(opts.Username); err != nil {
return err
}
- if ok, msg := validateCredentials(opts.Username, opts.Password); !ok {
- c.l.Error(msg)
- return errors.New("invalid credentials")
+ if err := ValidatePassword(opts.Password); err != nil {
+ return err
}
c.l.Infof("Creating user '%s'", opts.Username)
@@ -201,16 +109,8 @@ func (c *Accounts) Create(ctx context.Context, opts CreateOptions) error {
// Delete an existing user account.
func (c *Accounts) Delete(ctx context.Context, username string) error {
- if username == "" {
- if err := survey.AskOne(&survey.Input{
- Message: "Enter username",
- }, &username,
- ); err != nil {
- return err
- }
- }
- if username == "" {
- return errors.New("username is required")
+ if err := ValidateUsername(username); err != nil {
+ return err
}
c.l.Infof("Deleting user '%s'", username)
@@ -218,7 +118,7 @@ func (c *Accounts) Delete(ctx context.Context, username string) error {
return err
}
- c.l.Infof("User '%s' has been deleted succesfully", username)
+ c.l.Infof("User '%s' has been deleted successfully", username)
if c.config.Pretty {
_, _ = fmt.Fprintln(os.Stdout, output.Success("User '%s' has been deleted successfully", username))
}
@@ -226,6 +126,37 @@ func (c *Accounts) Delete(ctx context.Context, username string) error {
return nil
}
+// SetPasswordOptions holds options for setting a new password for user accounts.
+type SetPasswordOptions struct {
+ // Username is the username for the account.
+ Username string
+ // NewPassword is a new password for the account.
+ NewPassword string
+}
+
+// SetPassword sets the password for an existing account.
+func (c *Accounts) SetPassword(ctx context.Context, opts SetPasswordOptions) error {
+ if err := ValidateUsername(opts.Username); err != nil {
+ return err
+ }
+
+ if err := ValidatePassword(opts.NewPassword); err != nil {
+ return err
+ }
+
+ c.l.Infof("Setting a new password for user '%s'", opts.Username)
+ if err := c.accountManager.SetPassword(ctx, opts.Username, opts.NewPassword, true); err != nil {
+ return err
+ }
+
+ c.l.Infof("Password for user '%s' has been set succesfully", opts.Username)
+ if c.config.Pretty {
+ _, _ = fmt.Fprintln(os.Stdout, output.Success("Password for user '%s' has been set successfully", opts.Username))
+ }
+
+ return nil
+}
+
// ListOptions holds options for listing user accounts.
type ListOptions struct {
NoHeaders bool
@@ -267,7 +198,7 @@ func (c *Accounts) List(ctx context.Context, opts ListOptions) error {
// Return a table row for the given account.
row := func(user string, account *accounts.Account) []any {
- row := []any{}
+ var row []any
for _, heading := range headings {
switch heading {
case ColumnUser:
@@ -304,18 +235,6 @@ func (c *Accounts) GetInitAdminPassword(ctx context.Context) (string, error) {
return admin.PasswordHash, nil
}
-func validateCredentials(username, password string) (bool, string) {
- if !validateUsername(username) {
- return false,
- "Username must contain only letters, numbers, and underscores, and must be at least 3 characters long"
- }
- if !validatePassword(password) {
- return false,
- "Password must contain only letters, numbers and specific special characters (@#$%^&+=!_), and must be at least 6 characters long"
- }
- return true, ""
-}
-
// CreateRSAKeyPair creates a new RSA key pair for user authentication. New RSA key pair is stored in the Kubernetes secret.
func (c *Accounts) CreateRSAKeyPair(ctx context.Context) error {
c.l.Info("Creating/Updating JWT keys and restarting Everest.")
@@ -330,22 +249,3 @@ func (c *Accounts) CreateRSAKeyPair(ctx context.Context) error {
}
return nil
}
-
-func validateUsername(username string) bool {
- // Regular expression to validate username.
- // [a-zA-Z0-9_] - Allowed characters (letters, digits, underscore)
- // {3,} - Length of the username (minimum 3 characters)
- pattern := "^[a-zA-Z0-9_]{3,}$"
- regex := regexp.MustCompile(pattern)
- return regex.MatchString(username)
-}
-
-func validatePassword(password string) bool {
- if strings.Contains(password, " ") {
- return false
- }
- if len(password) < minPasswordLength {
- return false
- }
- return true
-}
diff --git a/pkg/accounts/cli/accounts_test.go b/pkg/accounts/cli/accounts_test.go
index 877956764..9335362db 100644
--- a/pkg/accounts/cli/accounts_test.go
+++ b/pkg/accounts/cli/accounts_test.go
@@ -3,7 +3,7 @@ package cli
import (
"testing"
- "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestUsernamePasswordSanitation(t *testing.T) {
@@ -11,33 +11,56 @@ func TestUsernamePasswordSanitation(t *testing.T) {
t.Run("Username", func(t *testing.T) {
t.Parallel()
testCases := []struct {
- username string
- allowed bool
+ name string
+ username string
+ expectedErr error
}{
- {"alice", true},
- {"bob!!", false},
- {"f", false},
- {"hello@@", false},
- {"bruce_wayne11", true},
+ {"invalid_with_spaces", "b ob", ErrInvalidUsername},
+ {"invalid_non_latin_chars", "аккаунт", ErrInvalidUsername},
+ {"invalid_special_chars", "bob!!", ErrInvalidUsername},
+ {"invalid_short", "f", ErrInvalidUsername},
+ {"invalid_empty", "", ErrInvalidUsername},
+ {"valid", "bob1", nil},
+ {"valid_with_underscore", "bruce_wayne11", nil},
}
for _, tc := range testCases {
- result := validateUsername(tc.username)
- assert.Equal(t, tc.allowed, result)
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ err := ValidateUsername(tc.username)
+ if tc.expectedErr == nil {
+ require.ErrorIs(t, err, tc.expectedErr)
+ }
+ })
}
})
- t.Run("Password", func(t *testing.T) {
+
+ t.Run("Password validation", func(t *testing.T) {
t.Parallel()
+
testCases := []struct {
- password string
- allowed bool
+ name string
+ password string
+ expectedErr error
}{
- {"pass", false},
- {"password with spaces", false},
- {"verysecurepassword!", true},
+ {"invalid_short", "pass", ErrInvalidNewPassword},
+ {"invalid_with_spaces", "password with spaces", ErrInvalidNewPassword},
+ {"invalid_non_latin_chars", "пароль", ErrInvalidNewPassword},
+ {"invalid_empty", "", ErrInvalidNewPassword},
+ {"valid_lower_case", "verysecurepassword", nil},
+ {"valid_upper_case", "VERYSECUREPASSWORD", nil},
+ {"valid_lower_case_with_special_chars", "^v#r4$ec*u%ep@s+sw_o&!d=-", nil},
+ {"valid_upper_case_with_special_chars", "^V#R4$EC*U%EP@S+SW_O&!D=-", nil},
+ {"valid_mixed_case_with_special_chars", "^V#R4$Ec*U%Ep@S+sW_o&!d=-", nil},
}
+
for _, tc := range testCases {
- result := validatePassword(tc.password)
- assert.Equal(t, tc.allowed, result)
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ err := ValidatePassword(tc.password)
+ if tc.expectedErr == nil {
+ require.ErrorIs(t, err, tc.expectedErr)
+ }
+ })
}
})
}
diff --git a/pkg/accounts/cli/utils.go b/pkg/accounts/cli/utils.go
new file mode 100644
index 000000000..7ea5c6d66
--- /dev/null
+++ b/pkg/accounts/cli/utils.go
@@ -0,0 +1,122 @@
+// everest
+// Copyright (C) 2025 Percona LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package cli holds commands for accounts command.
+package cli
+
+import (
+ "context"
+ "errors"
+ "regexp"
+ "strings"
+
+ "github.com/percona/everest/pkg/cli/tui"
+)
+
+const (
+ usernameCriteria = "Username may contain only letters, numbers, underscores, and must be at least 3 characters long"
+ passwordCriteria = "Password may contain only letters, numbers and specific special characters (@*#$%^&+=!_-), and must be at least 6 characters long"
+)
+
+var (
+ // Regular expression to validate username.
+ // [a-zA-Z0-9_] - Allowed characters (letters, digits, underscore)
+ // {3,} - Length of the username (minimum 3 characters)
+ userNameValidateRegex = regexp.MustCompile("^[a-zA-Z0-9_]{3,}$")
+
+ // ErrInvalidUsername is returned when the username doesn't match criteria.
+ ErrInvalidUsername = errors.New(strings.ToLower(usernameCriteria))
+
+ // Regular expression to validate password.
+ // [a-zA-Z0-9@*#$%^&+=!_-] - Allowed characters (letters, digits, special characters)
+ // {6,} - Length of the password (minimum 6 characters)
+ passwordValidateRegex = regexp.MustCompile("^[a-zA-Z0-9@*#$%^&+=!_-]{6,}$")
+
+ // ErrInvalidNewPassword is returned when the new password doesn't match criteria.
+ ErrInvalidNewPassword = errors.New(strings.ToLower(passwordCriteria))
+)
+
+// ValidateUsername validates the provided username.
+func ValidateUsername(username string) error {
+ if !userNameValidateRegex.MatchString(username) {
+ return ErrInvalidUsername
+ }
+ return nil
+}
+
+// ValidatePassword validates the provided password.
+func ValidatePassword(password string) error {
+ if !passwordValidateRegex.MatchString(password) {
+ return ErrInvalidNewPassword
+ }
+ return nil
+}
+
+// PopulateUsername function to fill the username.
+// This function shall be called only in cases when there is no other way to obtain username value.
+// User will be asked to provide the username in interactive mode.
+func PopulateUsername(ctx context.Context) (string, error) {
+ if username, err := tui.NewInput(ctx, "Provide username",
+ tui.WithInputHint(usernameCriteria),
+ tui.WithInputValidation(ValidateUsername),
+ ).Run(); err != nil {
+ return "", err
+ } else {
+ return username, nil
+ }
+}
+
+// PopulatePassword function to fill the password.
+// This function shall be called only in cases when there is no other way to obtain password value.
+// User will be asked to provide the password in interactive mode.
+func PopulatePassword(ctx context.Context) (string, error) {
+ // ask user to provide password
+ if password, err := tui.NewInputPassword(ctx, "Provide password",
+ tui.WithPasswordHint(passwordCriteria),
+ tui.WithPasswordValidation(ValidatePassword),
+ ).Run(); err != nil {
+ return "", err
+ } else {
+ return password, nil
+ }
+}
+
+// PopulateNewPassword function to fill the new password.
+// This function shall be called only in cases when there is no other way to obtain new password value.
+// User will be asked to provide the new password and password confirmation in interactive mode.
+func PopulateNewPassword(ctx context.Context) (string, error) {
+ // ask user to provide new password
+ var newPassword, newConfPassword string
+ var err error
+ if newPassword, err = tui.NewInputPassword(ctx, "Provide a new password",
+ tui.WithPasswordHint(passwordCriteria),
+ tui.WithPasswordValidation(ValidatePassword),
+ ).Run(); err != nil {
+ return "", err
+ }
+
+ if newConfPassword, err = tui.NewInputPassword(ctx, "Confirm a new password",
+ tui.WithPasswordHint(passwordCriteria),
+ tui.WithPasswordValidation(ValidatePassword),
+ ).Run(); err != nil {
+ return "", err
+ }
+
+ if newPassword != newConfPassword {
+ return "", errors.New("passwords do not match")
+ }
+
+ return newPassword, nil
+}
diff --git a/pkg/cli/helm/installer.go b/pkg/cli/helm/installer.go
index 5fade2def..8de6c788a 100644
--- a/pkg/cli/helm/installer.go
+++ b/pkg/cli/helm/installer.go
@@ -39,57 +39,70 @@ import (
"k8s.io/cli-runtime/pkg/genericclioptions"
)
-var settings = helmcli.New() //nolint:gochecknoglobals
-
-// CLIOptions contains common options for the CLI.
-type CLIOptions struct {
- ChartDir string
- RepoURL string
- Values values.Options
- Devel bool
- ReuseValues bool
- ResetValues bool
- ResetThenReuseValues bool
-}
-
// Everest Helm chart names.
const (
- EverestChartName = "everest"
+ // DefaultHelmRepoURL is the default Helm repository URL to download the Everest charts.
+ DefaultHelmRepoURL = "https://percona.github.io/percona-helm-charts/"
+ // EverestChartName is the name of the Everest Helm chart that installs the Everest operator.
+ EverestChartName = "everest"
+ // EverestDBNamespaceChartName is the name of the Everest Helm chart that is installed
+ // into DB namespaces managed by Everest.
EverestDBNamespaceChartName = "everest-db-namespace"
)
-// DefaultHelmRepoURL is the default Helm repository URL to download the Everest charts.
-const DefaultHelmRepoURL = "https://percona.github.io/percona-helm-charts/"
-
-// Installer installs a Helm chart.
-type Installer struct {
- ReleaseName string
- ReleaseNamespace string
- Values map[string]interface{}
- CreateReleaseNamespace bool
+var settings = helmcli.New() //nolint:gochecknoglobals
- // internal fields, set only after Init() is called.
- chart *chart.Chart
- cfg *action.Configuration
+// CLIOptions contains common options for the CLI.
+type (
+ CLIOptions struct {
+ // ChartDir path to the local directory with the Helm chart to be installed.
+ ChartDir string
+ // RepoURL URL of the Helm repository to download the chart from.
+ RepoURL string
+ // Values Helm values to be used during installation.
+ Values values.Options
+ // Devel indicates whether to use development versions of Helm charts, if available.
+ Devel bool
+ // ReuseValues indicates whether to reuse the last release's values during release upgrade.
+ ReuseValues bool
+ // ResetValues indicates whether to reset the last release's values during release upgrade.
+ ResetValues bool
+ // ResetThenReuseValues indicates whether to reset the last release's values then reuse them during release upgrade.
+ ResetThenReuseValues bool
+ }
- // This is set only after Install/Upgrade is called.
- release *release.Release
-}
+ // Installer installs a Helm chart.
+ Installer struct {
+ // ReleaseName is the name of the Helm release.
+ ReleaseName string
+ // ReleaseNamespace is the namespace where the Helm release will be installed.
+ ReleaseNamespace string
+ // Values are the Helm values to be used during installation.
+ Values map[string]interface{}
+ // CreateReleaseNamespace indicates whether to create the release namespace.
+ CreateReleaseNamespace bool
+ // internal fields, set only after Init() is called.
+ chart *chart.Chart
+ cfg *action.Configuration
+ // This is set only after Install/Upgrade is called.
+ release *release.Release
+ }
-// ChartOptions provide the options for loading a Helm chart.
-type ChartOptions struct {
- // Directory to load the Helm chart from.
- // If set, ignores URL.
- Directory string
- // URL of the repository to pull the chart from.
- URL string
- // Version of the helm chart to install.
- // If loading from a directory, needs to match the chart version.
- Version string
- // Name of the Helm chart to install.
- // Required only if pulling from the specified URL.
- Name string
-}
+ // ChartOptions provide the options for loading a Helm chart.
+ ChartOptions struct {
+ // Directory to load the Helm chart from.
+ // If set, ignores URL.
+ Directory string
+ // URL of the repository to pull the chart from.
+ URL string
+ // Version of the helm chart to install.
+ // If loading from a directory, needs to match the chart version.
+ Version string
+ // Name of the Helm chart to install.
+ // Required only if pulling from the specified URL.
+ Name string
+ }
+)
// Init initializes the Installer with the specified options.
func (i *Installer) Init(kubeconfigPath string, o ChartOptions) error {
diff --git a/pkg/cli/install/install.go b/pkg/cli/install/install.go
index fe737aabe..46e38e5fe 100644
--- a/pkg/cli/install/install.go
+++ b/pkg/cli/install/install.go
@@ -22,11 +22,10 @@ import (
"fmt"
"io"
"os"
- "strings"
"time"
versionpb "github.com/Percona-Lab/percona-version-service/versionpb"
- "github.com/fatih/color"
+ "github.com/charmbracelet/lipgloss"
goversion "github.com/hashicorp/go-version"
"go.uber.org/zap"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
@@ -46,105 +45,132 @@ import (
)
const (
- pollInterval = 5 * time.Second
- pollTimeout = 10 * time.Minute
- backoffInterval = 5 * time.Second
-
- // DefaultDBNamespaceName is the name of the default DB namespace during installation.
- DefaultDBNamespaceName = "everest"
+ pollInterval = 5 * time.Second
+ pollTimeout = 10 * time.Minute
)
-// Install implements the main logic for commands.
-type Install struct {
- l *zap.SugaredLogger
+// Installer implements the main logic for commands.
+type (
+ // InstallConfig holds the configuration for the `install` command.
+ InstallConfig struct {
+ // KubeconfigPath is the path to the kubeconfig file.
+ KubeconfigPath string
+ // VersionMetadataURL Version service URL to retrieve version metadata information from.
+ VersionMetadataURL string
+ // Version defines Everest version to be installed. If empty, the latest version is installed.
+ Version string
+ // DisableTelemetry disables telemetry.
+ DisableTelemetry bool
+ // ClusterType is the type of the Kubernetes environment.
+ // If it is not set, the environment will be detected.
+ ClusterType kubernetes.ClusterType
+ // SkipEnvDetection skips detecting the Kubernetes environment.
+ // If it is set, the environment will not be detected.
+ // Set ClusterType if the environment is known and set this flag to avoid detection duplication.
+ SkipEnvDetection bool
+ // Pretty if set print the output in pretty mode.
+ Pretty bool
+ // SkipDBNamespace is set if the installation should skip provisioning database.
+ SkipDBNamespace bool
+ // Options related to Helm.
+ HelmConfig helm.CLIOptions
+ // NamespaceAddConfig is the configuration for the namespace add operation.
+ NamespaceAddConfig namespaces.NamespaceAddConfig
+ }
- config Config
- kubeClient *kubernetes.Kubernetes
- versionService versionservice.Interface
+ // Installer provides the functionality to install Everest.
+ Installer struct {
+ l *zap.SugaredLogger
+ cfg InstallConfig
+ kubeClient *kubernetes.Kubernetes
+ versionService versionservice.Interface
+ // these are set only when Run is called.
+ installVersion string
+ helmInstaller *helm.Installer
+ }
+)
- // these are set only when Run is called.
- clusterType kubernetes.ClusterType
- installVersion string
- helmInstaller *helm.Installer
+// ------ Install Config ------
+
+// NewInstallConfig returns a new InstallConfig.
+func NewInstallConfig() InstallConfig {
+ return InstallConfig{
+ ClusterType: kubernetes.ClusterTypeUnknown,
+ Pretty: true,
+ NamespaceAddConfig: namespaces.NewNamespaceAddConfig(),
+ }
}
-// Config holds the configuration for the install command.
-type Config struct {
- // KubeconfigPath is a path to a kubeconfig
- KubeconfigPath string
- // VersionMetadataURL stores hostname to retrieve version metadata information from.
- VersionMetadataURL string
- // Version defines the version to be installed. If empty, the latest version is installed.
- Version string
- // DisableTelemetry disables telemetry.
- DisableTelemetry bool
- // SkipEnvDetection skips detecting the Kubernetes environment.
- SkipEnvDetection bool
- // If set, we will print the pretty output.
- Pretty bool
- // SkipDBNamespace is set if the installation should skip provisioning database.
- SkipDBNamespace bool
- // Ask user to provide namespaces to be managed by Everest.
- AskNamespaces bool
- // Ask user to provide DB operators to be installed into namespaces managed by Everest.
- AskOperators bool
-
- helm.CLIOptions
- namespaces.NamespaceAddConfig
+// detectKubernetesEnv detects the Kubernetes environment where Everest is installed.
+func (cfg *InstallConfig) detectKubernetesEnv(ctx context.Context, l *zap.SugaredLogger) error {
+ if cfg.SkipEnvDetection {
+ return nil
+ }
+
+ kubeClient, err := cliutils.NewKubeclient(l, cfg.KubeconfigPath)
+ if err != nil {
+ return fmt.Errorf("failed to create kubernetes client: %w", err)
+ }
+
+ t, err := kubeClient.GetClusterType(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to detect cluster type: %w", err)
+ }
+ cfg.ClusterType = t
+ cfg.NamespaceAddConfig.ClusterType = t
+
+ // Skip detecting Kubernetes environment in the future.
+ cfg.SkipEnvDetection = true
+ cfg.NamespaceAddConfig.SkipEnvDetection = true
+ l.Infof("Detected Kubernetes environment: %s", t)
+ return nil
}
-// NewInstall returns a new Install struct.
-func NewInstall(c Config, l *zap.SugaredLogger) (*Install, error) {
- cli := &Install{
+// ------ Installer ------
+
+// NewInstall returns a new Installer struct.
+func NewInstall(c InstallConfig, l *zap.SugaredLogger) (*Installer, error) {
+ cli := &Installer{
l: l.With("component", "install"),
}
if c.Pretty {
cli.l = zap.NewNop().Sugar()
}
- c.NamespaceAddConfig.Pretty = false
- c.NamespaceAddConfig.CLIOptions = c.CLIOptions
+ c.NamespaceAddConfig.Pretty = c.Pretty
+ c.NamespaceAddConfig.HelmConfig = c.HelmConfig
c.NamespaceAddConfig.KubeconfigPath = c.KubeconfigPath
c.NamespaceAddConfig.DisableTelemetry = c.DisableTelemetry
- cli.config = c
+ c.NamespaceAddConfig.SkipEnvDetection = c.SkipEnvDetection
+ cli.cfg = c
- k, err := cliutils.NewKubeclient(cli.l, c.KubeconfigPath)
+ var err error
+ cli.kubeClient, err = cliutils.NewKubeclient(cli.l, c.KubeconfigPath)
if err != nil {
return nil, err
}
- cli.kubeClient = k
+
cli.versionService = versionservice.New(c.VersionMetadataURL)
return cli, nil
}
// Run the Everest installation process.
-func (o *Install) Run(ctx context.Context) error {
- // Do not continue if Everest is already installed.
- installedVersion, err := version.EverestVersionFromDeployment(ctx, o.kubeClient)
- if client.IgnoreNotFound(err) != nil {
- return errors.Join(err, errors.New("cannot check if Everest is already installed"))
- } else if err == nil {
- return fmt.Errorf("everest is already installed. Version: %s", installedVersion)
- }
-
- if err := o.setKubernetesEnv(ctx); err != nil {
+func (o *Installer) Run(ctx context.Context) error {
+ if err := o.cfg.detectKubernetesEnv(ctx, o.l); err != nil {
return fmt.Errorf("failed to detect Kubernetes environment: %w", err)
}
- dbInstallStep, err := o.installDBNamespacesStep(ctx)
- if err != nil {
- return fmt.Errorf("could not create db install step: %w", err)
- }
-
if err := o.setVersionInfo(ctx); err != nil {
return fmt.Errorf("failed to get Everest version info: %w", err)
}
- if version.IsDev(o.installVersion) && o.config.ChartDir == "" {
- cleanup, err := helmutils.SetupEverestDevChart(o.l, &o.config.ChartDir)
+ if version.IsDev(o.installVersion) && o.cfg.HelmConfig.ChartDir == "" {
+ // Note: n.cfg.HelmConfig.ChartDir will be rewritten inside SetupEverestDevChart
+ cleanup, err := helmutils.SetupEverestDevChart(o.l, &o.cfg.HelmConfig.ChartDir)
if err != nil {
return err
}
+ o.cfg.NamespaceAddConfig.HelmConfig = o.cfg.HelmConfig
defer cleanup()
}
@@ -153,18 +179,23 @@ func (o *Install) Run(ctx context.Context) error {
}
installSteps := o.newInstallSteps()
- if dbInstallStep != nil {
- installSteps = append(installSteps, *dbInstallStep)
+ if !o.cfg.SkipDBNamespace {
+ // DB namespaces creation is required.
+ if dbInstallSteps, err := o.getDBNamespacesInstallSteps(ctx); err != nil {
+ return fmt.Errorf("could not create db install step: %w", err)
+ } else if dbInstallSteps != nil {
+ installSteps = append(installSteps, dbInstallSteps...)
+ }
}
var out io.Writer = os.Stdout
- if !o.config.Pretty {
+ if !o.cfg.Pretty {
out = io.Discard
}
// Run steps.
- fmt.Fprintln(out, output.Info("Installing Everest version %s", o.installVersion))
- if err := steps.RunStepsWithSpinner(ctx, installSteps, out); err != nil {
+ _, _ = fmt.Fprintln(out, output.Info("Installing Everest version %s", o.installVersion))
+ if err := steps.RunStepsWithSpinner(ctx, o.l, installSteps, o.cfg.Pretty); err != nil {
return err
}
o.l.Infof("Everest '%s' has been successfully installed", o.installVersion)
@@ -172,56 +203,54 @@ func (o *Install) Run(ctx context.Context) error {
return nil
}
-func (o *Install) installDBNamespacesStep(ctx context.Context) (*steps.Step, error) {
- if err := o.config.Populate(ctx, o.config.AskNamespaces, o.config.AskOperators); err != nil {
- // not specifying a namespace in this context is allowed.
- if errors.Is(err, namespaces.ErrNSEmpty) {
- return nil, nil //nolint:nilnil
- }
- return nil, errors.Join(err, errors.New("namespaces configuration error"))
- }
- o.config.NamespaceAddConfig.ClusterType = o.clusterType
- if o.clusterType != "" || o.config.SkipEnvDetection {
- o.config.NamespaceAddConfig.SkipEnvDetection = true
- }
- i, err := namespaces.NewNamespaceAdd(o.config.NamespaceAddConfig, zap.NewNop().Sugar())
+// getDBNamespacesInstallSteps returns the steps to install the database namespaces.
+// It returns nil if the namespaces are already installed.
+// Note: o.cfg.NamespaceAddConfig.NamespaceList and o.cfg.NamespaceAddConfig.Operators
+// must be set before calling this function.
+func (o *Installer) getDBNamespacesInstallSteps(ctx context.Context) ([]steps.Step, error) {
+ i, err := namespaces.NewNamespaceAdd(o.cfg.NamespaceAddConfig, o.l)
if err != nil {
return nil, err
}
- return &steps.Step{
- Desc: fmt.Sprintf("Provisioning database namespaces (%s)", strings.Join(o.config.NamespaceList, ", ")),
- F: func(ctx context.Context) error {
- return i.Run(ctx)
- },
- }, nil
+
+ return i.GetNamespaceInstallSteps(ctx, o.installVersion)
}
//nolint:gochecknoglobals
-var bold = color.New(color.Bold).SprintFunc()
+var (
+ titleStyle = lipgloss.NewStyle().Bold(true)
+ commandStyle = lipgloss.NewStyle().Italic(true)
+)
-func (o *Install) printPostInstallMessage(out io.Writer) {
+func (o *Installer) printPostInstallMessage(out io.Writer) {
message := "\n" + output.Rocket("Thank you for installing Everest (v%s)!\n", o.installVersion)
- message += "Follow the steps below to get started:\n\n"
+ message += "Follow the steps below to get started:"
- if len(o.config.NamespaceList) == 0 {
- message += bold("PROVISION A NAMESPACE FOR YOUR DATABASE:\n\n")
+ count := 1
+ if len(o.cfg.NamespaceAddConfig.NamespaceList) == 0 {
+ message += fmt.Sprintf("\n\n%s", output.Numeric(count, titleStyle.Render("PROVISION A NAMESPACE FOR YOUR DATABASE:")))
+ count++
message += "Install a namespace for your databases using the following command:\n\n"
- message += "\teverestctl namespaces add [NAMESPACE]"
- message += "\n\n"
+ message += fmt.Sprintf("\t%s", commandStyle.Render("everestctl namespaces add [NAMESPACE]"))
}
- message += bold("RETRIEVE THE INITIAL ADMIN PASSWORD:\n\n")
- message += common.InitialPasswordWarningMessage + "\n\n"
+ message += fmt.Sprintf("\n\n%s", output.Numeric(count, titleStyle.Render("RETRIEVE THE INITIAL ADMIN PASSWORD:")))
+ count++
+ message += "Run the following command to get the initial admin password:\n\n"
+ message += fmt.Sprintf("\t%s\n\n", commandStyle.Render("everestctl accounts initial-admin-password"))
+ message += output.Warn("NOTE: The initial password is stored in plain text. For security, change it immediately using the following command:\n")
+ message += fmt.Sprintf("\t%s", commandStyle.Render("everestctl accounts set-password --username admin"))
- message += bold("ACCESS THE EVEREST UI:\n\n")
+ message += fmt.Sprintf("\n\n%s", output.Numeric(count, titleStyle.Render("ACCESS THE EVEREST UI:")))
+ count++
message += "To access the web UI, set up port-forwarding and visit http://localhost:8080 in your browser:\n\n"
- message += "\tkubectl port-forward -n everest-system svc/everest 8080:8080"
- message += "\n"
+ message += fmt.Sprintf("\t%s\n", commandStyle.Render("kubectl port-forward -n everest-system svc/everest 8080:8080"))
- fmt.Fprint(out, message)
+ _, _ = fmt.Fprint(out, message)
}
-func (o *Install) setVersionInfo(ctx context.Context) error {
+// setVersionInfo fetches the latest Everest version information from Version service.
+func (o *Installer) setVersionInfo(ctx context.Context) error {
meta, err := o.versionService.GetEverestMetadata(ctx)
if err != nil {
return errors.Join(err, errors.New("could not fetch version metadata"))
@@ -245,32 +274,33 @@ func (o *Install) setVersionInfo(ctx context.Context) error {
return nil
}
-func (o *Install) checkRequirements(supVer *common.SupportedVersion) error {
+func (o *Installer) checkRequirements(supVer *common.SupportedVersion) error {
if err := cliutils.VerifyCLIVersion(supVer); err != nil {
return err
}
return nil
}
-func (o *Install) setupHelmInstaller(ctx context.Context) error {
+// setupHelmInstaller initializes the Helm installer.
+func (o *Installer) setupHelmInstaller(ctx context.Context) error {
nsExists, err := o.namespaceExists(ctx, common.SystemNamespace)
if err != nil {
return err
}
overrides := helm.NewValues(helm.Values{
- ClusterType: o.clusterType,
- VersionMetadataURL: o.config.VersionMetadataURL,
+ ClusterType: o.cfg.ClusterType,
+ VersionMetadataURL: o.cfg.VersionMetadataURL,
})
- values := Must(helmutils.MergeVals(o.config.Values, overrides))
+ values := Must(helmutils.MergeVals(o.cfg.HelmConfig.Values, overrides))
installer := &helm.Installer{
ReleaseName: common.SystemNamespace,
ReleaseNamespace: common.SystemNamespace,
Values: values,
CreateReleaseNamespace: !nsExists,
}
- if err := installer.Init(o.config.KubeconfigPath, helm.ChartOptions{
- Directory: o.config.ChartDir,
- URL: o.config.RepoURL,
+ if err := installer.Init(o.cfg.KubeconfigPath, helm.ChartOptions{
+ Directory: o.cfg.HelmConfig.ChartDir,
+ URL: o.cfg.HelmConfig.RepoURL,
Name: helm.EverestChartName,
Version: o.installVersion,
}); err != nil {
@@ -280,21 +310,8 @@ func (o *Install) setupHelmInstaller(ctx context.Context) error {
return nil
}
-func (o *Install) setKubernetesEnv(ctx context.Context) error {
- if o.config.SkipEnvDetection {
- return nil
- }
- t, err := o.kubeClient.GetClusterType(ctx)
- if err != nil {
- return fmt.Errorf("failed to detect cluster type: %w", err)
- }
- o.clusterType = t
- o.l.Infof("Detected Kubernetes environment: %s", t)
- return nil
-}
-
-func (o *Install) newInstallSteps() []steps.Step {
- steps := []steps.Step{
+func (o *Installer) newInstallSteps() []steps.Step {
+ return []steps.Step{
o.newStepInstallEverestHelmChart(),
o.newStepEnsureEverestAPI(),
o.newStepEnsureEverestOperator(),
@@ -302,10 +319,9 @@ func (o *Install) newInstallSteps() []steps.Step {
o.newStepEnsureCatalogSource(),
o.newStepEnsureEverestMonitoring(),
}
- return steps
}
-func (o *Install) latestVersion(meta *versionpb.MetadataResponse) (*goversion.Version, *versionpb.MetadataVersion, error) {
+func (o *Installer) latestVersion(meta *versionpb.MetadataResponse) (*goversion.Version, *versionpb.MetadataVersion, error) {
var (
latest *goversion.Version
latestMeta *versionpb.MetadataVersion
@@ -314,10 +330,10 @@ func (o *Install) latestVersion(meta *versionpb.MetadataResponse) (*goversion.Ve
err error
)
- if o.config.Version != "" {
- targetVersion, err = goversion.NewSemver(o.config.Version)
+ if o.cfg.Version != "" {
+ targetVersion, err = goversion.NewSemver(o.cfg.Version)
if err != nil {
- return nil, nil, errors.Join(err, fmt.Errorf("could not parse target version %q", o.config.Version))
+ return nil, nil, errors.Join(err, fmt.Errorf("could not parse target version %q", o.cfg.Version))
}
}
@@ -348,7 +364,7 @@ func (o *Install) latestVersion(meta *versionpb.MetadataResponse) (*goversion.Ve
return latest, latestMeta, nil
}
-func (o *Install) namespaceExists(ctx context.Context, namespace string) (bool, error) {
+func (o *Installer) namespaceExists(ctx context.Context, namespace string) (bool, error) {
_, err := o.kubeClient.GetNamespace(ctx, namespace)
if err != nil {
if k8serrors.IsNotFound(err) {
@@ -358,3 +374,20 @@ func (o *Install) namespaceExists(ctx context.Context, namespace string) (bool,
}
return true, nil
}
+
+// CheckEverestAlreadyinstalled checks if Everest is already installed.
+func CheckEverestAlreadyinstalled(ctx context.Context, l *zap.SugaredLogger, kubeConfig string) error {
+ kubeClient, err := cliutils.NewKubeclient(l, kubeConfig)
+ if err != nil {
+ return fmt.Errorf("failed to create kubernetes client: %w", err)
+ }
+
+ installedVersion, err := version.EverestVersionFromDeployment(ctx, kubeClient)
+ if client.IgnoreNotFound(err) != nil {
+ return errors.Join(err, errors.New("cannot check if Everest is already installed"))
+ } else if err == nil {
+ return fmt.Errorf("everest is already installed. Version: %s", installedVersion)
+ }
+
+ return nil
+}
diff --git a/pkg/cli/install/install_test.go b/pkg/cli/install/install_test.go
index e3d992f0d..d8c33c35f 100644
--- a/pkg/cli/install/install_test.go
+++ b/pkg/cli/install/install_test.go
@@ -98,8 +98,8 @@ func TestInstall_latestVersion(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
- i := &Install{
- config: Config{
+ i := &Installer{
+ cfg: InstallConfig{
Version: tc.providedVersion,
},
}
diff --git a/pkg/cli/install/steps.go b/pkg/cli/install/steps.go
index 8ffab2597..2719b4ded 100644
--- a/pkg/cli/install/steps.go
+++ b/pkg/cli/install/steps.go
@@ -27,7 +27,7 @@ import (
"github.com/percona/everest/pkg/kubernetes"
)
-func (o *Install) newStepInstallEverestHelmChart() steps.Step {
+func (o *Installer) newStepInstallEverestHelmChart() steps.Step {
return steps.Step{
Desc: "Installing Everest Helm chart",
F: func(ctx context.Context) error {
@@ -36,7 +36,7 @@ func (o *Install) newStepInstallEverestHelmChart() steps.Step {
}
}
-func (o *Install) newStepEnsureEverestOperator() steps.Step {
+func (o *Installer) newStepEnsureEverestOperator() steps.Step {
return steps.Step{
Desc: "Ensuring Everest operator deployment is ready",
F: func(ctx context.Context) error {
@@ -45,7 +45,7 @@ func (o *Install) newStepEnsureEverestOperator() steps.Step {
}
}
-func (o *Install) newStepEnsureEverestAPI() steps.Step {
+func (o *Installer) newStepEnsureEverestAPI() steps.Step {
return steps.Step{
Desc: "Ensuring Everest API deployment is ready",
F: func(ctx context.Context) error {
@@ -54,7 +54,7 @@ func (o *Install) newStepEnsureEverestAPI() steps.Step {
}
}
-func (o *Install) newStepEnsureEverestOLM() steps.Step {
+func (o *Installer) newStepEnsureEverestOLM() steps.Step {
return steps.Step{
Desc: "Ensuring OLM components are ready",
F: func(ctx context.Context) error {
@@ -72,14 +72,14 @@ func (o *Install) newStepEnsureEverestOLM() steps.Step {
}
}
-func (o *Install) newStepEnsureEverestMonitoring() steps.Step {
+func (o *Installer) newStepEnsureEverestMonitoring() steps.Step {
return steps.Step{
Desc: "Ensuring monitoring stack is ready",
F: func(ctx context.Context) error {
if err := o.waitForDeployment(ctx, common.VictoriaMetricsOperatorDeploymentName, common.MonitoringNamespace); err != nil {
return err
}
- if o.clusterType != kubernetes.ClusterTypeOpenShift {
+ if o.cfg.ClusterType != kubernetes.ClusterTypeOpenShift {
if err := o.waitForDeployment(ctx, common.KubeStateMetricsDeploymentName, common.MonitoringNamespace); err != nil {
return err
}
@@ -89,7 +89,7 @@ func (o *Install) newStepEnsureEverestMonitoring() steps.Step {
}
}
-func (o *Install) newStepEnsureCatalogSource() steps.Step {
+func (o *Installer) newStepEnsureCatalogSource() steps.Step {
return steps.Step{
Desc: "Ensuring Everest CatalogSource is ready",
F: func(ctx context.Context) error {
@@ -112,7 +112,7 @@ func (o *Install) newStepEnsureCatalogSource() steps.Step {
}
}
-func (o *Install) waitForDeployment(ctx context.Context, name, namespace string) error {
+func (o *Installer) waitForDeployment(ctx context.Context, name, namespace string) error {
o.l.Infof("Waiting for Deployment '%s' in namespace '%s'", name, namespace)
if err := o.kubeClient.WaitForRollout(ctx, name, namespace); err != nil {
return err
@@ -121,7 +121,7 @@ func (o *Install) waitForDeployment(ctx context.Context, name, namespace string)
return nil
}
-func (o *Install) installEverestHelmChart(ctx context.Context) error {
+func (o *Installer) installEverestHelmChart(ctx context.Context) error {
o.l.Info("Installing Everest Helm chart")
if err := o.helmInstaller.Install(ctx); err != nil {
return fmt.Errorf("could not install Helm chart: %w", err)
diff --git a/pkg/cli/namespaces/add.go b/pkg/cli/namespaces/add.go
index e861d5c48..f226120b5 100644
--- a/pkg/cli/namespaces/add.go
+++ b/pkg/cli/namespaces/add.go
@@ -20,21 +20,15 @@ import (
"context"
"errors"
"fmt"
- "io"
"os"
- "regexp"
- "strings"
- "github.com/AlecAivazis/survey/v2"
- olmv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1"
"go.uber.org/zap"
"helm.sh/helm/v3/pkg/cli/values"
- v1 "k8s.io/api/core/v1"
- k8serrors "k8s.io/apimachinery/pkg/api/errors"
"github.com/percona/everest/pkg/cli/helm"
helmutils "github.com/percona/everest/pkg/cli/helm/utils"
"github.com/percona/everest/pkg/cli/steps"
+ "github.com/percona/everest/pkg/cli/tui"
cliutils "github.com/percona/everest/pkg/cli/utils"
"github.com/percona/everest/pkg/common"
"github.com/percona/everest/pkg/kubernetes"
@@ -43,432 +37,369 @@ import (
"github.com/percona/everest/pkg/version"
)
-//nolint:gochecknoglobals
-var (
- // ErrNSEmpty appears when the provided list of the namespaces is considered empty.
- ErrNSEmpty = errors.New("namespace list is empty. Specify at least one namespace")
- // ErrNSReserved appears when some of the provided names are forbidden to use.
- ErrNSReserved = func(ns string) error {
- return fmt.Errorf("'%s' namespace is reserved for Everest internals. Please specify another namespace", ns)
- }
- // ErrNameNotRFC1035Compatible appears when some of the provided names are not RFC1035 compatible.
- ErrNameNotRFC1035Compatible = func(fieldName string) error {
- return fmt.Errorf(`'%s' is not RFC 1035 compatible. The name should contain only lowercase alphanumeric characters or '-', start with an alphabetic character, end with an alphanumeric character`,
- fieldName,
- )
+type (
+ // OperatorConfig identifies which operators shall be installed.
+ OperatorConfig struct {
+ PG bool // is set if PostgresSQL shall be installed.
+ PSMDB bool // is set if MongoDB shall be installed.
+ PXC bool // is set if XtraDB Cluster shall be installed.
+ }
+
+ // NamespaceAddConfig is the configuration for adding namespaces.
+ NamespaceAddConfig struct {
+ // NamespaceList is a list of namespaces to be managed by Everest and install operators.
+ // The property shall be set in case the namespaces are parsed and validated using ValidateNamespaces func.
+ // Otherwise, use Populate function to asked user to provide the namespaces in interactive mode.
+ NamespaceList []string
+ // SkipWizard is set if the wizard should be skipped.
+ SkipWizard bool
+ // KubeconfigPath is the path to the kubeconfig file.
+ KubeconfigPath string
+ // DisableTelemetry is set if telemetry should be disabled.
+ DisableTelemetry bool
+ // TakeOwnership make an existing namespace managed by Everest.
+ TakeOwnership bool
+ // ClusterType is the type of the Kubernetes environment.
+ // If it is not set, the environment will be detected.
+ ClusterType kubernetes.ClusterType
+ // SkipEnvDetection skips detecting the Kubernetes environment.
+ // If it is set, the environment will not be detected.
+ // Set ClusterType if the environment is known and set this flag to avoid detection duplication.
+ SkipEnvDetection bool
+ // AskOperators is set in case it is needed to use interactive mode and
+ // ask user to provide DB operators to be installed into namespaces managed by Everest.
+ // AskOperators bool
+ // Operators configurations for the operators to be installed into namespaces managed by Everest.
+ Operators OperatorConfig
+ // Pretty if set print the output in pretty mode.
+ Pretty bool
+ // Update is set if the existing namespace needs to be updated.
+ // This flag is set internally only, so that the add functionality may
+ // be re-used for updating the namespace as well.
+ Update bool
+ // Helm related options
+ HelmConfig helm.CLIOptions
+ }
+
+ // NamespaceAdder provides the functionality to add namespaces.
+ NamespaceAdder struct {
+ l *zap.SugaredLogger
+ cfg NamespaceAddConfig
+ kubeClient *kubernetes.Kubernetes
}
- // ErrNoOperatorsSelected appears when no operators are selected for installation.
- ErrNoOperatorsSelected = errors.New("no operators selected for installation. Minimum one operator must be selected")
-
- errCannotRemoveOperators = errors.New("cannot remove operators")
)
-// NewNamespaceAdd returns a new CLI operation to add namespaces.
-func NewNamespaceAdd(c NamespaceAddConfig, l *zap.SugaredLogger) (*NamespaceAdder, error) {
- n := &NamespaceAdder{
- cfg: c,
- l: l.With("component", "namespace-adder"),
- }
- if c.Pretty {
- n.l = zap.NewNop().Sugar()
- }
+// --- NamespaceAddConfig functions
- k, err := cliutils.NewKubeclient(n.l, c.KubeconfigPath)
- if err != nil {
- return nil, err
+// NewNamespaceAddConfig returns a new NamespaceAddConfig.
+func NewNamespaceAddConfig() NamespaceAddConfig {
+ return NamespaceAddConfig{
+ ClusterType: kubernetes.ClusterTypeUnknown,
+ Pretty: true,
}
- n.kubeClient = k
- n.clusterType = c.ClusterType
- n.skipEnvDetection = c.SkipEnvDetection
- return n, nil
-}
-
-// NamespaceAddConfig is the configuration for adding namespaces.
-type NamespaceAddConfig struct {
- // Namespaces to install.
- Namespaces string
- // SkipWizard is set if the wizard should be skipped.
- SkipWizard bool
- // KubeconfigPath is the path to the kubeconfig file.
- KubeconfigPath string
- // DisableTelemetry is set if telemetry should be disabled.
- DisableTelemetry bool
- // TakeOwnership of an existing namespace.
- TakeOwnership bool
- // SkipEnvDetection skips detecting the Kubernetes environment.
- SkipEnvDetection bool
- // Ask user to provide DB operators to be installed into namespaces managed by Everest.
- AskOperators bool
-
- Operator OperatorConfig
-
- // Pretty print the output.
- Pretty bool
-
- // Update is set if the existing namespace needs to be updated.
- // This flag is set internally only, so that the add functionality may
- // be re-used for updating the namespace as well.
- Update bool
- // NamespaceList is a list of namespaces to install.
- // This is populated internally after validating the Namespaces field.:
- NamespaceList []string
-
- ClusterType kubernetes.ClusterType
- helm.CLIOptions
-}
-
-// OperatorConfig identifies which operators shall be installed.
-type OperatorConfig struct {
- // PG stores if PostgresSQL shall be installed.
- PG bool
- // PSMDB stores if MongoDB shall be installed.
- PSMDB bool
- // PXC stores if XtraDB Cluster shall be installed.
- PXC bool
-}
-
-// NamespaceAdder provides the functionality to add namespaces.
-type NamespaceAdder struct {
- l *zap.SugaredLogger
- cfg NamespaceAddConfig
- kubeClient *kubernetes.Kubernetes
- clusterType kubernetes.ClusterType
- skipEnvDetection bool
}
-// Run namespace add operation.
-func (n *NamespaceAdder) Run(ctx context.Context) error {
- // This command expects a Helm based installation (< 1.4.0)
- ver, err := cliutils.CheckHelmInstallation(ctx, n.kubeClient)
- if err != nil {
+// PopulateNamespaces function to fill the configuration with the required NamespaceList.
+// This function shall be called only in cases when there is no other way to obtain values for NamespaceList.
+// User will be asked to provide the namespaces in interactive mode (if it is enabled).
+// Provided by user namespaces will be parsed, validated and stored in the NamespaceList property.
+// Note: in case NamespaceList is not empty - it will be overwritten by user's input.
+func (cfg *NamespaceAddConfig) PopulateNamespaces(ctx context.Context) error {
+ if cfg.SkipWizard {
+ return errors.Join(fmt.Errorf("can't ask user for namespaces to install"), ErrInteractiveModeDisabled)
+ }
+
+ var err error
+ var ns string
+ // Ask user to provide namespaces in interactive mode.
+ if ns, err = tui.NewInput(ctx,
+ "Provide database namespaces to be managed by Everest",
+ tui.WithInputDefaultValue(common.DefaultDBNamespaceName),
+ tui.WithInputHint("Namespaces can be provided in comma-separated form: ns-1,ns-2"),
+ ).Run(); err != nil {
return err
}
- if err := n.cfg.Populate(ctx, false, n.cfg.AskOperators); err != nil {
+ nsList := ParseNamespaceNames(ns)
+ if err = cfg.ValidateNamespaces(ctx, nsList); err != nil {
return err
}
- if !n.skipEnvDetection {
- if err := n.setKubernetesEnv(ctx); err != nil {
- return err
- }
- }
+ cfg.NamespaceList = nsList
+ return nil
+}
- var installSteps []steps.Step
- if version.IsDev(ver) && n.cfg.ChartDir == "" {
- cleanup, err := helmutils.SetupEverestDevChart(n.l, &n.cfg.ChartDir)
- if err != nil {
- return err
- }
- defer cleanup()
+// PopulateOperators function to fill the configuration with the required Operators.
+// This function shall be called only in cases when there is no other way to obtain values for Operators.
+// User will be asked to provide the operators in interactive mode (if it is enabled).
+// Provided by user operators will be stored in the Operators property.
+// Note: Operators property will be overwritten by user's input.
+func (cfg *NamespaceAddConfig) PopulateOperators(ctx context.Context) error {
+ if cfg.SkipWizard {
+ return fmt.Errorf("can't ask user for operators to install: %w", ErrInteractiveModeDisabled)
+ }
+
+ // By default, all operators are selected.
+ defaultOpts := []tui.MultiSelectOption{
+ {common.PXCProductName, true},
+ {common.PSMDBProductName, true},
+ {common.PGProductName, true},
+ }
+
+ var selectedOpts []tui.MultiSelectOption
+ var err error
+ if selectedOpts, err = tui.NewMultiSelect(
+ ctx,
+ "Which operators do you want to install?",
+ defaultOpts,
+ ).Run(); err != nil {
+ return err
}
- // validate operators for each namespace.
- for _, namespace := range n.cfg.NamespaceList {
- err := n.validateNamespace(ctx, namespace)
- if errors.Is(err, errCannotRemoveOperators) {
- msg := "Removal of an installed operator is not supported. Proceeding without removal."
- fmt.Fprint(os.Stdout, output.Warn(msg)) //nolint:govet
- n.l.Warn(msg)
- break
- } else if err != nil {
- return fmt.Errorf("namespace validation error: %w", err)
+ // Copy user's choice to config.
+ for _, op := range selectedOpts {
+ switch op.Text {
+ case common.PXCProductName:
+ cfg.Operators.PXC = op.Selected
+ case common.PSMDBProductName:
+ cfg.Operators.PSMDB = op.Selected
+ case common.PGProductName:
+ cfg.Operators.PG = op.Selected
}
}
- for _, namespace := range n.cfg.NamespaceList {
- installSteps = append(installSteps,
- n.newStepInstallNamespace(ver, namespace),
- )
- }
-
- var out io.Writer = os.Stdout
- if !n.cfg.Pretty {
- out = io.Discard
+ if !(cfg.Operators.PXC || cfg.Operators.PG || cfg.Operators.PSMDB) {
+ // need to select at least one operator to install
+ return ErrOperatorsNotSelected
}
- if err := steps.RunStepsWithSpinner(ctx, installSteps, out); err != nil {
- return err
- }
return nil
}
-func (n *NamespaceAdder) setKubernetesEnv(ctx context.Context) error {
- if n.skipEnvDetection {
- return nil
- }
- t, err := n.kubeClient.GetClusterType(ctx)
- if err != nil {
- return fmt.Errorf("failed to detect cluster type: %w", err)
+// ValidateNamespaces validates the provided list of namespaces.
+// It validates:
+// - namespace names
+// - namespace ownership
+func (cfg *NamespaceAddConfig) ValidateNamespaces(ctx context.Context, nsList []string) error {
+ if err := validateNamespaceNames(nsList); err != nil {
+ return err
}
- n.clusterType = t
- n.l.Infof("Detected Kubernetes environment: %s", t)
- return nil
-}
-
-func (n *NamespaceAdder) getValues() values.Options {
- v := []string{}
- v = append(v, "cleanupOnUninstall=false") // uninstall command will do the clean-up on its own.
- v = append(v, fmt.Sprintf("pxc=%t", n.cfg.Operator.PXC))
- v = append(v, fmt.Sprintf("postgresql=%t", n.cfg.Operator.PG))
- v = append(v, fmt.Sprintf("psmdb=%t", n.cfg.Operator.PSMDB))
- v = append(v, fmt.Sprintf("telemetry=%t", !n.cfg.DisableTelemetry))
- if n.clusterType == kubernetes.ClusterTypeOpenShift {
- v = append(v, "compatibility.openshift=true")
+ k, err := cliutils.NewKubeclient(zap.NewNop().Sugar(), cfg.KubeconfigPath)
+ if err != nil {
+ return err
}
- return values.Options{Values: v}
-}
-func (n *NamespaceAdder) newStepInstallNamespace(version, namespace string) steps.Step {
- action := "Installing"
- if n.cfg.Update {
- action = "Updating"
- }
- return steps.Step{
- Desc: fmt.Sprintf("%s namespace '%s'", action, namespace),
- F: func(ctx context.Context) error {
- return n.provisionDBNamespace(ctx, version, namespace)
- },
+ for _, ns := range nsList {
+ if err := cfg.validateNamespaceOwnership(ctx, k, ns); err != nil {
+ return err
+ }
}
+ return nil
}
-var (
- // ErrNsDoesNotExist appears when the namespace does not exist.
- ErrNsDoesNotExist = errors.New("namespace does not exist")
- // ErrNamespaceNotManagedByEverest appears when the namespace is not managed by Everest.
- ErrNamespaceNotManagedByEverest = errors.New("namespace is not managed by Everest")
- // ErrNamespaceAlreadyExists appears when the namespace already exists.
- ErrNamespaceAlreadyExists = errors.New("namespace already exists")
- // ErrNamespaceAlreadyOwned appears when the namespace is already owned by Everest.
- ErrNamespaceAlreadyOwned = errors.New("namespace already exists and is managed by Everest")
-)
-
+// validateNamespaceOwnership validates the namespace existence and ownership.
func (cfg *NamespaceAddConfig) validateNamespaceOwnership(
ctx context.Context,
+ k kubernetes.KubernetesConnector,
namespace string,
) error {
- k, err := cliutils.NewKubeclient(zap.NewNop().Sugar(), cfg.KubeconfigPath)
- if err != nil {
- return err
- }
-
- nsExists, ownedByEverest, err := namespaceExists(ctx, namespace, k)
+ nsExists, ownedByEverest, err := namespaceExists(ctx, k, namespace)
if err != nil {
return err
}
if cfg.Update {
- if !nsExists {
- return ErrNsDoesNotExist
- }
if !ownedByEverest {
- return ErrNamespaceNotManagedByEverest
+ return NewErrNamespaceNotManagedByEverest(namespace)
}
+
+ if !nsExists {
+ return NewErrNamespaceNotExist(namespace)
+ }
+
return nil
}
- if nsExists && !cfg.TakeOwnership {
- return ErrNamespaceAlreadyExists
- }
+
if nsExists && ownedByEverest {
- return ErrNamespaceAlreadyOwned
+ return NewErrNamespaceAlreadyManagedByEverest(namespace)
+ }
+
+ if nsExists && !cfg.TakeOwnership {
+ return NewErrNamespaceAlreadyExists(namespace)
}
return nil
}
-func (n *NamespaceAdder) provisionDBNamespace(
- ctx context.Context,
- version string,
- namespace string,
-) error {
- nsExists, _, err := namespaceExists(ctx, namespace, n.kubeClient)
- if err != nil {
- return err
- }
- values := Must(helmutils.MergeVals(n.getValues(), nil))
- installer := helm.Installer{
- ReleaseName: namespace,
- ReleaseNamespace: namespace,
- Values: values,
- CreateReleaseNamespace: !nsExists,
+// detectKubernetesEnv detects the Kubernetes environment where Everest is installed.
+func (cfg *NamespaceAddConfig) detectKubernetesEnv(ctx context.Context, l *zap.SugaredLogger) error {
+ if cfg.SkipEnvDetection {
+ return nil
}
- if err := installer.Init(n.cfg.KubeconfigPath, helm.ChartOptions{
- Directory: cliutils.DBNamespaceSubChartPath(n.cfg.ChartDir),
- URL: n.cfg.RepoURL,
- Name: helm.EverestDBNamespaceChartName,
- Version: version,
- }); err != nil {
- return fmt.Errorf("could not initialize Helm installer: %w", err)
+
+ client, err := cliutils.NewKubeclient(l, cfg.KubeconfigPath)
+ if err != nil {
+ return fmt.Errorf("failed to create kubernetes client: %w", err)
}
- n.l.Infof("Installing DB namespace Helm chart in namespace ", namespace)
- return installer.Install(ctx)
-}
-// Returns: [exists, managedByEverest, error].
-func namespaceExists(
- ctx context.Context,
- namespace string,
- k kubernetes.KubernetesConnector,
-) (bool, bool, error) {
- ns, err := k.GetNamespace(ctx, namespace)
+ t, err := client.GetClusterType(ctx)
if err != nil {
- if k8serrors.IsNotFound(err) {
- return false, false, nil
- }
- return false, false, fmt.Errorf("cannot check if namesapce exists: %w", err)
+ return fmt.Errorf("failed to detect cluster type: %w", err)
}
- return true, isManagedByEverest(ns), nil
-}
-func isManagedByEverest(ns *v1.Namespace) bool {
- val, ok := ns.GetLabels()[common.KubernetesManagedByLabel]
- return ok && val == common.Everest
+ cfg.ClusterType = t
+ // Skip detecting Kubernetes environment in the future.
+ cfg.SkipEnvDetection = true
+ l.Infof("Detected Kubernetes environment: %s", t)
+ return nil
}
-// Populate the configuration with the required values.
-func (cfg *NamespaceAddConfig) Populate(ctx context.Context, askNamespaces, askOperators bool) error {
- if err := cfg.populateNamespaces(askNamespaces); err != nil {
- return err
- }
+// --- NewNamespaceAdd functions
- for _, ns := range cfg.NamespaceList {
- if err := cfg.validateNamespaceOwnership(ctx, ns); err != nil {
- return fmt.Errorf("invalid namespace (%s): %w", ns, err)
+// NewNamespaceAdd returns a new CLI operation to add namespaces.
+func NewNamespaceAdd(c NamespaceAddConfig, l *zap.SugaredLogger) (*NamespaceAdder, error) {
+ {
+ // validate the provided configuration
+ if len(c.NamespaceList) == 0 {
+ // need to provide at least one namespace to install
+ return nil, ErrNamespaceListEmpty
}
- }
- if askOperators && len(cfg.NamespaceList) > 0 && !cfg.SkipWizard {
- if err := cfg.populateOperators(); err != nil {
- return err
+ if !(c.Operators.PXC || c.Operators.PG || c.Operators.PSMDB) {
+ // need to select at least one operator to install
+ return nil, ErrOperatorsNotSelected
}
}
- return nil
-}
-
-func (cfg *NamespaceAddConfig) populateNamespaces(wizard bool) error {
- namespaces := cfg.Namespaces
- // no namespaces provided, ask the user
- if wizard && !cfg.SkipWizard {
- pNamespace := &survey.Input{
- Message: "Namespaces managed by Everest [comma separated]",
- Default: cfg.Namespaces,
- }
- if err := survey.AskOne(pNamespace, &namespaces); err != nil {
- return err
- }
+ n := &NamespaceAdder{
+ cfg: c,
+ l: l.With("component", "namespace-adder"),
+ }
+ if c.Pretty {
+ n.l = zap.NewNop().Sugar()
}
- list, err := ValidateNamespaces(namespaces)
+ k, err := cliutils.NewKubeclient(n.l, c.KubeconfigPath)
if err != nil {
- return err
+ return nil, err
}
- cfg.NamespaceList = list
- return nil
+ n.kubeClient = k
+ return n, nil
}
-func (cfg *NamespaceAddConfig) populateOperators() error {
- operatorOpts := []struct {
- label string
- boolFlag *bool
- }{
- {"MySQL", &cfg.Operator.PXC},
- {"MongoDB", &cfg.Operator.PSMDB},
- {"PostgreSQL", &cfg.Operator.PG},
- }
- operatorLabels := make([]string, 0, len(operatorOpts))
- for _, v := range operatorOpts {
- operatorLabels = append(operatorLabels, v.label)
- }
- operatorDefaults := make([]string, 0, len(operatorOpts))
- for _, v := range operatorOpts {
- if *v.boolFlag {
- operatorDefaults = append(operatorDefaults, v.label)
- }
- }
-
- pOps := &survey.MultiSelect{
- Message: "Which operators do you want to install?",
- Default: operatorDefaults,
- Options: operatorLabels,
- }
- opIndexes := []int{}
- if err := survey.AskOne(
- pOps,
- &opIndexes,
- ); err != nil {
+// Run namespace add operation.
+func (n *NamespaceAdder) Run(ctx context.Context) error {
+ // This command expects a Helm based installation (>= 1.4.0)
+ dbNSChartVersion, err := cliutils.CheckHelmInstallation(ctx, n.kubeClient)
+ if err != nil {
return err
}
- if len(opIndexes) == 0 && len(cfg.NamespaceList) > 0 {
- return ErrNoOperatorsSelected
+ if version.IsDev(dbNSChartVersion) && n.cfg.HelmConfig.ChartDir == "" {
+ // Note: new value will be set to n.cfg.ChartDir inside SetupEverestDevChart
+ cleanup, err := helmutils.SetupEverestDevChart(n.l, &n.cfg.HelmConfig.ChartDir)
+ if err != nil {
+ return err
+ }
+ defer cleanup()
}
- // We reset all flags to false so we select only
- // the ones which the user selected in the multiselect.
- for _, op := range operatorOpts {
- *op.boolFlag = false
+ if err := n.cfg.detectKubernetesEnv(ctx, n.l); err != nil {
+ return fmt.Errorf("failed to detect Kubernetes environment: %w", err)
}
- for _, i := range opIndexes {
- *operatorOpts[i].boolFlag = true
+ installSteps, err := n.GetNamespaceInstallSteps(ctx, dbNSChartVersion)
+ if err != nil {
+ return err
}
+ if err := steps.RunStepsWithSpinner(ctx, n.l, installSteps, n.cfg.Pretty); err != nil {
+ return err
+ }
return nil
}
-// ValidateNamespaces validates a comma-separated namespaces string.
-func ValidateNamespaces(str string) ([]string, error) {
- nsList := strings.Split(str, ",")
- m := make(map[string]struct{})
- for _, ns := range nsList {
- ns = strings.TrimSpace(ns)
- if ns == "" {
- continue
- }
-
- if ns == common.SystemNamespace || ns == common.MonitoringNamespace || ns == kubernetes.OLMNamespace {
- return nil, ErrNSReserved(ns)
- }
-
- if err := validateRFC1035(ns); err != nil {
- return nil, err
+// GetNamespaceInstallSteps returns the steps to install namespaces.
+func (n *NamespaceAdder) GetNamespaceInstallSteps(ctx context.Context, dbNSChartVersion string) ([]steps.Step, error) {
+ if n.cfg.Update {
+ // validate operators updated list for each namespace.
+ for _, namespace := range n.cfg.NamespaceList {
+ err := n.validateNamespaceUpdate(ctx, namespace)
+ if errors.Is(err, ErrCannotRemoveOperators) {
+ msg := "Removal of an installed operator is not supported. Proceeding without removal."
+ _, _ = fmt.Fprint(os.Stdout, output.Warn(msg)) //nolint:govet
+ n.l.Warn(msg)
+ break
+ } else if err != nil {
+ return nil, fmt.Errorf("namespace validation error: %w", err)
+ }
}
-
- m[ns] = struct{}{}
}
- list := make([]string, 0, len(m))
- for k := range m {
- list = append(list, k)
- }
- if len(list) == 0 {
- return nil, ErrNSEmpty
+ var installSteps []steps.Step
+ for _, namespace := range n.cfg.NamespaceList {
+ installSteps = append(installSteps,
+ n.newStepInstallNamespace(dbNSChartVersion, namespace),
+ )
}
- return list, nil
+ return installSteps, nil
}
-// validates names to be RFC-1035 compatible https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#rfc-1035-label-names
-func validateRFC1035(s string) error {
- rfc1035Regex := "^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$"
- re := regexp.MustCompile(rfc1035Regex)
- if !re.MatchString(s) {
- return ErrNameNotRFC1035Compatible(s)
+func (n *NamespaceAdder) getValues() values.Options {
+ var v []string
+ v = append(v, "cleanupOnUninstall=false") // uninstall command will do the clean-up on its own.
+ v = append(v, fmt.Sprintf("pxc=%t", n.cfg.Operators.PXC))
+ v = append(v, fmt.Sprintf("postgresql=%t", n.cfg.Operators.PG))
+ v = append(v, fmt.Sprintf("psmdb=%t", n.cfg.Operators.PSMDB))
+ v = append(v, fmt.Sprintf("telemetry=%t", !n.cfg.DisableTelemetry))
+
+ if n.cfg.ClusterType == kubernetes.ClusterTypeOpenShift {
+ v = append(v, "compatibility.openshift=true")
}
+ return values.Options{Values: v}
+}
- return nil
+func (n *NamespaceAdder) newStepInstallNamespace(version, namespace string) steps.Step {
+ action := "Provisioning"
+ if n.cfg.Update {
+ action = "Updating"
+ }
+ return steps.Step{
+ Desc: fmt.Sprintf("%s database namespace '%s'", action, namespace),
+ F: func(ctx context.Context) error {
+ return n.provisionDBNamespace(ctx, version, namespace)
+ },
+ }
}
-func (n *NamespaceAdder) validateNamespace(
+func (n *NamespaceAdder) provisionDBNamespace(
ctx context.Context,
+ version string,
namespace string,
) error {
- if n.cfg.Update {
- return n.validateNamespaceUpdate(ctx, namespace)
+ nsExists, _, err := namespaceExists(ctx, n.kubeClient, namespace)
+ if err != nil {
+ return err
}
- return nil
+ values := Must(helmutils.MergeVals(n.getValues(), nil))
+ installer := helm.Installer{
+ ReleaseName: namespace,
+ ReleaseNamespace: namespace,
+ Values: values,
+ CreateReleaseNamespace: !nsExists,
+ }
+ if err := installer.Init(n.cfg.KubeconfigPath, helm.ChartOptions{
+ Directory: cliutils.DBNamespaceSubChartPath(n.cfg.HelmConfig.ChartDir),
+ URL: n.cfg.HelmConfig.RepoURL,
+ Name: helm.EverestDBNamespaceChartName,
+ Version: version,
+ }); err != nil {
+ return fmt.Errorf("could not initialize Helm installer: %w", err)
+ }
+ n.l.Info("Installing DB namespace Helm chart in namespace ", namespace)
+ return installer.Install(ctx)
}
func (n *NamespaceAdder) validateNamespaceUpdate(ctx context.Context, namespace string) error {
@@ -477,34 +408,9 @@ func (n *NamespaceAdder) validateNamespaceUpdate(ctx context.Context, namespace
return fmt.Errorf("cannot list subscriptions: %w", err)
}
if !ensureNoOperatorsRemoved(subscriptions.Items,
- n.cfg.Operator.PG, n.cfg.Operator.PXC, n.cfg.Operator.PSMDB,
+ n.cfg.Operators.PG, n.cfg.Operators.PXC, n.cfg.Operators.PSMDB,
) {
- return errCannotRemoveOperators
+ return ErrCannotRemoveOperators
}
return nil
}
-
-func ensureNoOperatorsRemoved(
- subscriptions []olmv1alpha1.Subscription,
- installPG, installPXC, installPSMDB bool,
-) bool {
- for _, subscription := range subscriptions {
- switch subscription.GetName() {
- case common.PGOperatorName:
- if !installPG {
- return false
- }
- case common.PSMDBOperatorName:
- if !installPSMDB {
- return false
- }
- case common.PXCOperatorName:
- if !installPXC {
- return false
- }
- default:
- continue
- }
- }
- return true
-}
diff --git a/pkg/cli/namespaces/add_test.go b/pkg/cli/namespaces/add_test.go
index 4a5b7bfaf..d1cf18e98 100644
--- a/pkg/cli/namespaces/add_test.go
+++ b/pkg/cli/namespaces/add_test.go
@@ -6,98 +6,166 @@ import (
"github.com/stretchr/testify/assert"
)
-func TestValidateNamespaces(t *testing.T) {
+func TestParseNamespaceNames(t *testing.T) {
t.Parallel()
type tcase struct {
name string
input string
output []string
- error error
}
tcases := []tcase{
{
name: "empty string",
input: "",
- output: nil,
- error: ErrNSEmpty,
+ output: []string{},
},
{
name: "several empty strings",
input: " , ,",
- output: nil,
- error: ErrNSEmpty,
+ output: []string{},
},
{
name: "correct",
input: "aaa,bbb,ccc",
output: []string{"aaa", "bbb", "ccc"},
- error: nil,
},
{
name: "correct with spaces",
input: ` aaa, bbb
,ccc `,
output: []string{"aaa", "bbb", "ccc"},
- error: nil,
},
{
name: "reserved system ns",
input: "everest-system",
- output: nil,
- error: ErrNSReserved("everest-system"),
+ output: []string{"everest-system"},
},
{
name: "reserved system ns and empty ns",
input: "everest-system, ",
- output: nil,
- error: ErrNSReserved("everest-system"),
+ output: []string{"everest-system"},
},
{
name: "reserved monitoring ns",
input: "everest-monitoring",
- output: nil,
- error: ErrNSReserved("everest-monitoring"),
+ output: []string{"everest-monitoring"},
},
{
name: "reserved olm ns",
input: "everest-olm",
- output: nil,
- error: ErrNSReserved("everest-olm"),
+ output: []string{"everest-olm"},
},
{
name: "duplicated ns",
input: "aaa,bbb,aaa",
output: []string{"aaa", "bbb"},
- error: nil,
},
{
name: "name is too long",
input: "e1234567890123456789012345678901234567890123456789012345678901234567890,bbb",
- output: nil,
- error: ErrNameNotRFC1035Compatible("e1234567890123456789012345678901234567890123456789012345678901234567890"),
+ output: []string{"e1234567890123456789012345678901234567890123456789012345678901234567890", "bbb"},
},
{
name: "name starts with number",
input: "1aaa,bbb",
- output: nil,
- error: ErrNameNotRFC1035Compatible("1aaa"),
+ output: []string{"1aaa", "bbb"},
},
{
name: "name contains special characters",
input: "aa12a,b$s",
- output: nil,
- error: ErrNameNotRFC1035Compatible("b$s"),
+ output: []string{"aa12a", "b$s"},
+ },
+ }
+
+ for _, tc := range tcases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ output := ParseNamespaceNames(tc.input)
+ assert.Equal(t, tc.output, output)
+ })
+ }
+}
+
+func TestValidateNamespaces(t *testing.T) {
+ t.Parallel()
+
+ type tcase struct {
+ name string
+ input []string
+ error error
+ }
+
+ tcases := []tcase{
+ {
+ name: "empty list",
+ input: []string{},
+ error: ErrNamespaceListEmpty,
+ },
+ {
+ name: "empty string",
+ input: []string{""},
+ error: ErrNameNotRFC1035Compatible(""),
+ },
+ {
+ name: "several empty strings",
+ input: []string{" ", " "},
+ error: ErrNameNotRFC1035Compatible(" "),
+ },
+ {
+ name: "correct",
+ input: []string{"aaa", "bbb", "ccc"},
+ error: nil,
+ },
+ {
+ name: "reserved system ns",
+ input: []string{"everest-system"},
+ error: ErrNamespaceReserved("everest-system"),
+ },
+ {
+ name: "reserved system ns and empty ns",
+ input: []string{"everest-system", " "},
+ error: ErrNamespaceReserved("everest-system"),
+ },
+ {
+ name: "reserved monitoring ns",
+ input: []string{"everest-monitoring"},
+ error: ErrNamespaceReserved("everest-monitoring"),
+ },
+ {
+ name: "reserved olm ns",
+ input: []string{"everest-olm"},
+ error: ErrNamespaceReserved("everest-olm"),
+ },
+ {
+ name: "duplicated ns",
+ input: []string{"aaa", "bbb", "aaa"},
+ error: nil,
+ },
+ {
+ name: "name is too long",
+ input: []string{"e1234567890123456789012345678901234567890123456789012345678901234567890", "bbb"},
+ error: ErrNameNotRFC1035Compatible("e1234567890123456789012345678901234567890123456789012345678901234567890"),
+ },
+ {
+ name: "name starts with number",
+ input: []string{"1aaa", "bbb"},
+ error: ErrNameNotRFC1035Compatible("1aaa"),
+ },
+ {
+ name: "name contains special characters",
+ input: []string{"aa12a", "b$s"},
+ error: ErrNameNotRFC1035Compatible("b$s"),
},
}
for _, tc := range tcases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
- output, err := ValidateNamespaces(tc.input)
+ err := validateNamespaceNames(tc.input)
assert.Equal(t, tc.error, err)
- assert.ElementsMatch(t, tc.output, output)
+ // assert.ElementsMatch(t, tc.output, output)
})
}
}
diff --git a/pkg/cli/namespaces/errors.go b/pkg/cli/namespaces/errors.go
new file mode 100644
index 000000000..fead3afb9
--- /dev/null
+++ b/pkg/cli/namespaces/errors.go
@@ -0,0 +1,83 @@
+// everest
+// Copyright (C) 2025 Percona LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package namespaces
+
+import (
+ "errors"
+ "fmt"
+)
+
+// nolint:gochecknoglobals
+var (
+ // ErrNamespaceNotExist appears when the namespace does not exist.
+ ErrNamespaceNotExist = errors.New("namespace does not exist")
+
+ // ErrNamespaceNotExist appears when the namespace does not exist.
+ NewErrNamespaceNotExist = func(namespace string) error {
+ return fmt.Errorf("'%s': %w", namespace, ErrNamespaceNotExist)
+ }
+
+ // ErrNamespaceAlreadyExists appears when the namespace already exists.
+ ErrNamespaceAlreadyExists = errors.New("namespace already exists")
+
+ // ErrNamespaceAlreadyExists appears when the namespace already exists.
+ NewErrNamespaceAlreadyExists = func(namespace string) error {
+ return fmt.Errorf("'%s': %w", namespace, ErrNamespaceAlreadyExists)
+ }
+
+ // ErrNamespaceNotManagedByEverest appears when the namespace is not managed by Everest.
+ ErrNamespaceNotManagedByEverest = errors.New("namespace is not managed by Everest")
+
+ // ErrNamespaceNotManagedByEverest appears when the namespace is not managed by Everest.
+ NewErrNamespaceNotManagedByEverest = func(namespace string) error {
+ return fmt.Errorf("'%s': %w", namespace, ErrNamespaceNotManagedByEverest)
+ }
+
+ // // ErrNamespaceAlreadyManagedByEverest appears when the namespace is already owned by Everest.
+ ErrNamespaceAlreadyManagedByEverest = errors.New("namespace already exists and is managed by Everest")
+
+ // ErrNamespaceAlreadyManagedByEverest appears when the namespace is already owned by Everest.
+ NewErrNamespaceAlreadyManagedByEverest = func(namespace string) error {
+ return fmt.Errorf("'%s': %s", namespace, ErrNamespaceAlreadyManagedByEverest)
+ }
+
+ // ErrNamespaceListEmpty appears when the provided list of the namespaces is considered empty.
+ ErrNamespaceListEmpty = errors.New("namespace list is empty. Specify at least one namespace")
+
+ // ErrNamespaceReserved appears when some of the provided names are forbidden to use.
+ ErrNamespaceReserved = func(ns string) error {
+ return fmt.Errorf("'%s' namespace is reserved for Everest internals. Please specify another namespace", ns)
+ }
+
+ // ErrOperatorsNotSelected appears when no operators are selected for installation.
+ ErrOperatorsNotSelected = errors.New("no operators selected for installation. Minimum one operator must be selected")
+
+ // ErrCannotRemoveOperators appears when user tries to delete operator from namespace.
+ ErrCannotRemoveOperators = errors.New("cannot remove operators")
+
+ // ErrNameNotRFC1035Compatible appears when some of the provided names are not RFC1035 compatible.
+ ErrNameNotRFC1035Compatible = func(fieldName string) error {
+ return fmt.Errorf(`'%s' is not RFC 1035 compatible. The name should contain only lowercase alphanumeric characters or '-', start with an alphabetic character, end with an alphanumeric character`,
+ fieldName,
+ )
+ }
+
+ // ErrNamespaceNotEmpty is returned when the namespace is not empty.
+ ErrNamespaceNotEmpty = errors.New("cannot remove namespace with running database clusters")
+
+ // ErrInteractiveModeDisabled is returned when interactive mode is disabled.
+ ErrInteractiveModeDisabled = errors.New("interactive mode is disabled")
+)
diff --git a/pkg/cli/namespaces/remove.go b/pkg/cli/namespaces/remove.go
index 41ac894a5..22b03c16f 100644
--- a/pkg/cli/namespaces/remove.go
+++ b/pkg/cli/namespaces/remove.go
@@ -20,8 +20,6 @@ import (
"context"
"errors"
"fmt"
- "io"
- "os"
"time"
"go.uber.org/zap"
@@ -41,67 +39,96 @@ const (
pollTimeout = 5 * time.Minute
)
-// ErrNamespaceNotEmpty is returned when the namespace is not empty.
-var ErrNamespaceNotEmpty = errors.New("cannot remove namespace with running database clusters")
-
// NamespaceRemoveConfig is the configuration for the namespace removal operation.
-type NamespaceRemoveConfig struct {
- // KubeconfigPath is a path to a kubeconfig
- KubeconfigPath string
- // Force delete a namespace by deleting databases in it.
- Force bool
- // If set, we will keep the namespace
- KeepNamespace bool
- // If set, we will print the pretty output.
- Pretty bool
-
- // Namespaces (DB Namespaces managed by Everest) to remove
- Namespaces string
- // NamespaceList is a list of namespaces to remove.
- // This is populated internally after validating the Namespaces field.:
- NamespaceList []string
-}
+type (
+ NamespaceRemoveConfig struct {
+ // NamespaceList is a list of namespaces to be removed.
+ // The property shall be set explicitly after the provided namespaces are parsed and validated using ValidateNamespaces func.
+ NamespaceList []string
+ // KubeconfigPath is a path to a kubeconfig
+ KubeconfigPath string
+ // Force delete a namespace by deleting databases in it.
+ Force bool
+ // If set, keep the namespace but remove all resources from it.
+ KeepNamespace bool
+ // Pretty if set print the output in pretty mode.
+ Pretty bool
+ }
+
+ // NamespaceRemover is the CLI operation to remove namespaces.
+ NamespaceRemover struct {
+ cfg NamespaceRemoveConfig
+ kubeClient *kubernetes.Kubernetes
+ l *zap.SugaredLogger
+ }
+)
-// populate the configuration with the required values.
-func (cfg *NamespaceRemoveConfig) populate(ctx context.Context, kubeClient *kubernetes.Kubernetes) error {
- nsList, err := ValidateNamespaces(cfg.Namespaces)
+// ValidateNamespaces validates the provided list of namespaces.
+// It validates:
+// - namespace names
+// - namespace ownership
+func (cfg *NamespaceRemoveConfig) ValidateNamespaces(ctx context.Context, nsList []string) error {
+ if err := validateNamespaceNames(nsList); err != nil {
+ return err
+ }
+
+ k, err := cliutils.NewKubeclient(zap.NewNop().Sugar(), cfg.KubeconfigPath)
if err != nil {
return err
}
for _, ns := range nsList {
- // Check that the namespace exists.
- exists, managedByEverest, err := namespaceExists(ctx, ns, kubeClient)
- if err != nil {
- return errors.Join(err, errors.New("failed to check if namespace exists"))
- }
- if !exists || !managedByEverest {
- return errors.New(fmt.Sprintf("namespace '%s' does not exist or not managed by Everest", ns))
+ if err := cfg.validateNamespaceOwnership(ctx, k, ns); err != nil {
+ return err
}
}
- cfg.NamespaceList = nsList
+ // Check that there are no DB clusters left in namespaces.
+ dbsExist, err := k.DatabasesExist(ctx, nsList...)
+ if err != nil {
+ return errors.Join(err, errors.New("failed to check if databases exist"))
+ }
+
+ if dbsExist && !cfg.Force {
+ return ErrNamespaceNotEmpty
+ }
+
return nil
}
-// NamespaceRemover is the CLI operation to remove namespaces.
-type NamespaceRemover struct {
- config NamespaceRemoveConfig
- kubeClient *kubernetes.Kubernetes
- l *zap.SugaredLogger
+// validateNamespaceOwnership validates the namespace existence and ownership.
+func (cfg *NamespaceRemoveConfig) validateNamespaceOwnership(
+ ctx context.Context,
+ k kubernetes.KubernetesConnector,
+ namespace string,
+) error {
+ // Check that the namespace exists.
+ exists, managedByEverest, err := namespaceExists(ctx, k, namespace)
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return NewErrNamespaceNotExist(namespace)
+ }
+
+ if !managedByEverest {
+ return NewErrNamespaceNotManagedByEverest(namespace)
+ }
+
+ return nil
}
// NewNamespaceRemove returns a new CLI operation to remove namespaces.
func NewNamespaceRemove(c NamespaceRemoveConfig, l *zap.SugaredLogger) (*NamespaceRemover, error) {
n := &NamespaceRemover{
- config: c,
- l: l.With("component", "namespace-remover"),
+ cfg: c,
+ l: l.With("component", "namespace-remover"),
}
if c.Pretty {
n.l = zap.NewNop().Sugar()
}
- k, err := cliutils.NewKubeclient(n.l, n.config.KubeconfigPath)
+ k, err := cliutils.NewKubeclient(n.l, n.cfg.KubeconfigPath)
if err != nil {
return nil, err
}
@@ -117,30 +144,12 @@ func (r *NamespaceRemover) Run(ctx context.Context) error {
return err
}
- if err := r.config.populate(ctx, r.kubeClient); err != nil {
- return err
- }
-
- dbsExist, err := r.kubeClient.DatabasesExist(ctx, r.config.NamespaceList...)
- if err != nil {
- return errors.Join(err, errors.New("failed to check if databases exist"))
- }
-
- if dbsExist && !r.config.Force {
- return ErrNamespaceNotEmpty
- }
-
var removalSteps []steps.Step
- for _, ns := range r.config.NamespaceList {
- removalSteps = append(removalSteps, NewRemoveNamespaceSteps(ns, r.config.KeepNamespace, r.kubeClient)...)
- }
-
- var out io.Writer = os.Stdout
- if !r.config.Pretty {
- out = io.Discard
+ for _, ns := range r.cfg.NamespaceList {
+ removalSteps = append(removalSteps, NewRemoveNamespaceSteps(ns, r.cfg.KeepNamespace, r.kubeClient)...)
}
- return steps.RunStepsWithSpinner(ctx, removalSteps, out)
+ return steps.RunStepsWithSpinner(ctx, r.l, removalSteps, r.cfg.Pretty)
}
// NewRemoveNamespaceSteps returns the steps to remove a namespace.
@@ -165,7 +174,7 @@ func NewRemoveNamespaceSteps(namespace string, keepNs bool, k *kubernetes.Kubern
},
},
}
- nsStepDesc := fmt.Sprintf("Deleting namespace '%s'", namespace)
+ nsStepDesc := fmt.Sprintf("Deleting database namespace '%s'", namespace)
if keepNs {
nsStepDesc = fmt.Sprintf("Deleting resources from namespace '%s'", namespace)
}
diff --git a/pkg/cli/namespaces/utils.go b/pkg/cli/namespaces/utils.go
new file mode 100644
index 000000000..08d5515af
--- /dev/null
+++ b/pkg/cli/namespaces/utils.go
@@ -0,0 +1,133 @@
+// everest
+// Copyright (C) 2025 Percona LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package namespaces
+
+import (
+ "context"
+ "fmt"
+ "regexp"
+ "strings"
+
+ olmv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1"
+ v1 "k8s.io/api/core/v1"
+ k8serrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/utils/strings/slices"
+
+ "github.com/percona/everest/pkg/common"
+ "github.com/percona/everest/pkg/kubernetes"
+)
+
+// Regexp used to validate RFC1035 compatible names.
+var rfc1035Regexp = regexp.MustCompile("^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$")
+
+// ParseNamespaceNames parses a comma-separated namespaces string.
+// It returns a list of namespaces.
+// Note: namespace names are not validated.
+// Use validateNamespaceNames to validate them.
+func ParseNamespaceNames(namespaces string) []string {
+ result := []string{}
+ for _, ns := range strings.Split(namespaces, ",") {
+ ns = strings.TrimSpace(ns)
+ if ns == "" {
+ continue
+ }
+
+ if !slices.Contains(result, ns) {
+ result = append(result, ns)
+ }
+ }
+
+ return result
+}
+
+// validateNamespaceNames validates a list of namespaces parsed by ParseNamespaceNames.
+// It validates the names to be:
+// - RFC-1035 compatible
+// - not reserved by Everest core
+func validateNamespaceNames(nsList []string) error {
+ if len(nsList) == 0 {
+ return ErrNamespaceListEmpty
+ }
+
+ for _, ns := range nsList {
+ if ns == common.SystemNamespace ||
+ ns == common.MonitoringNamespace ||
+ ns == kubernetes.OLMNamespace {
+ return ErrNamespaceReserved(ns)
+ }
+
+ if err := validateRFC1035(ns); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// validates names to be RFC-1035 compatible https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#rfc-1035-label-names
+func validateRFC1035(s string) error {
+ if !rfc1035Regexp.MatchString(s) {
+ return ErrNameNotRFC1035Compatible(s)
+ }
+
+ return nil
+}
+
+// isManagedByEverest checks if the namespace is managed by Everest.
+func isManagedByEverest(ns *v1.Namespace) bool {
+ val, ok := ns.GetLabels()[common.KubernetesManagedByLabel]
+ return ok && val == common.Everest
+}
+
+// Returns: [exists, managedByEverest, error].
+func namespaceExists(
+ ctx context.Context,
+ k kubernetes.KubernetesConnector,
+ namespace string,
+) (bool, bool, error) {
+ ns, err := k.GetNamespace(ctx, namespace)
+ if err != nil {
+ if k8serrors.IsNotFound(err) {
+ return false, false, nil
+ }
+ return false, false, fmt.Errorf("cannot check if namesapce exists: %w", err)
+ }
+ return true, isManagedByEverest(ns), nil
+}
+
+func ensureNoOperatorsRemoved(
+ subscriptions []olmv1alpha1.Subscription,
+ installPG, installPXC, installPSMDB bool,
+) bool {
+ for _, subscription := range subscriptions {
+ switch subscription.GetName() {
+ case common.PGOperatorName:
+ if !installPG {
+ return false
+ }
+ case common.PSMDBOperatorName:
+ if !installPSMDB {
+ return false
+ }
+ case common.PXCOperatorName:
+ if !installPXC {
+ return false
+ }
+ default:
+ continue
+ }
+ }
+ return true
+}
diff --git a/pkg/cli/steps/steps.go b/pkg/cli/steps/steps.go
index 6b3d2d791..ea70291d6 100644
--- a/pkg/cli/steps/steps.go
+++ b/pkg/cli/steps/steps.go
@@ -18,23 +18,16 @@ package steps
import (
"context"
- "fmt"
- "io"
- "time"
- "github.com/briandowns/spinner"
+ "go.uber.org/zap"
- "github.com/percona/everest/pkg/output"
-)
-
-const (
- spinnerInterval = 150 * time.Millisecond
+ "github.com/percona/everest/pkg/cli/tui"
)
// Step provides a way to run a function with a
// pretty loading spinner animation.
type Step struct {
- // Desc is a human readable description of the step.
+ // Desc is a human-readable description of the step.
Desc string
// F is the function that will be called to execute the step.
F func(ctx context.Context) error
@@ -43,25 +36,22 @@ type Step struct {
// RunStepsWithSpinner runs a list of steps with a loading spinner animation.
func RunStepsWithSpinner(
ctx context.Context,
+ l *zap.SugaredLogger,
steps []Step,
- out io.Writer,
+ prettyPrint bool,
) error {
- s := spinner.New(
- spinner.CharSets[9],
- spinnerInterval,
- spinner.WithWriter(out),
- )
+ spinnerSteps := make([]tui.Step, 0, len(steps))
for _, step := range steps {
- s.Suffix = " " + step.Desc
- s.Start()
- if err := step.F(ctx); err != nil {
- s.Stop()
- fmt.Fprint(out, output.Failure(step.Desc)) //nolint:govet
- fmt.Fprint(out, "\t", err, "\n")
- return err
- }
- s.Stop()
- fmt.Fprint(out, output.Success(step.Desc)) //nolint:govet
+ spinnerSteps = append(spinnerSteps, tui.Step{
+ Desc: step.Desc,
+ F: step.F,
+ })
}
+
+ if err := tui.NewSpinner(ctx, l, spinnerSteps, tui.WithSpinnerPrettyPrint(prettyPrint)).
+ Run(); err != nil {
+ return err
+ }
+
return nil
}
diff --git a/pkg/cli/tui/common.go b/pkg/cli/tui/common.go
new file mode 100644
index 000000000..0e096b015
--- /dev/null
+++ b/pkg/cli/tui/common.go
@@ -0,0 +1,87 @@
+// everest
+// Copyright (C) 2025 Percona LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package tui provides UI elements for the CLI.
+package tui
+
+import (
+ "errors"
+
+ "github.com/charmbracelet/bubbles/help"
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+type (
+ // ValidateInputFunc is a function that returns an error if the input is invalid.
+ ValidateInputFunc func(string) error
+)
+
+var (
+ // Common errors.
+
+ ErrUserInterrupted = errors.New("user interrupted")
+
+ // Common styles.
+
+ // Style is applied to the prompt text.
+ textStyle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(
+ lipgloss.AdaptiveColor{Light: "#000000", Dark: "ffffff"},
+ )
+
+ // Style is applied to the successful result.
+ successStyle = lipgloss.NewStyle().
+ Foreground(
+ lipgloss.AdaptiveColor{Light: "#000000", Dark: "#5fd700"},
+ )
+
+ // Style is applied to the failure result.
+ failureStyle = lipgloss.NewStyle().
+ Foreground(
+ lipgloss.AdaptiveColor{Light: "#B10810", Dark: "#F37C6F"},
+ )
+
+ // Style is applied to the helper text: supported key combinations, etc.
+ helperTextStyle = lipgloss.NewStyle().
+ Foreground(
+ lipgloss.AdaptiveColor{Light: "#1A7362", Dark: "#30D1B2"},
+ )
+
+ // Common key bindings.
+
+ // key binding and help description for Confirm action.
+ confirmKeyBinding = key.NewBinding(
+ key.WithKeys(tea.KeyEnter.String()),
+ key.WithHelp("Enter", "confirm"),
+ )
+
+ // key binding and help description for Quit action.
+ quitKeyBinding = key.NewBinding(
+ key.WithKeys(tea.KeyEsc.String(), tea.KeyCtrlC.String()),
+ key.WithHelp("Esc/Ctrl+c", "quit"),
+ )
+)
+
+func newHelpModel() help.Model {
+ model := help.New()
+ model.ShortSeparator = " | "
+ model.Styles.ShortKey = textStyle
+ model.Styles.ShortDesc = helperTextStyle
+ model.Styles.ShortSeparator = helperTextStyle
+ return model
+}
diff --git a/pkg/cli/tui/confirm.go b/pkg/cli/tui/confirm.go
new file mode 100644
index 000000000..0d57c312b
--- /dev/null
+++ b/pkg/cli/tui/confirm.go
@@ -0,0 +1,165 @@
+// everest
+// Copyright (C) 2025 Percona LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package tui provides UI elements for the CLI.
+package tui
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/help"
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// Key bindings.
+var confirmKeys = confirmKeyMap{
+ Confirm: key.NewBinding(
+ key.WithKeys("y"),
+ key.WithHelp("y", "confirm"),
+ ),
+ Abort: key.NewBinding(
+ key.WithKeys("n"),
+ key.WithHelp("n", "abort"),
+ ),
+ Quit: quitKeyBinding,
+}
+
+type (
+ // confirmKeyMap defines a set of keybindings. To work for help it must satisfy
+ // key.Map. It could also very easily be a map[string]key.Binding.
+ confirmKeyMap struct {
+ Confirm key.Binding
+ Abort key.Binding
+ Help key.Binding
+ Quit key.Binding
+ }
+
+ // Confirm represents a confirm (y/N) input element.
+ Confirm struct {
+ keys confirmKeyMap
+ help help.Model
+ textInput textinput.Model
+ p *tea.Program
+ confirm bool // set to true in case user confirms the action
+ done bool // set when user made a choice (doesn't matter if it's y or n)
+ interrupt bool // set in case user wants to quit (Esc or Ctrl+c)
+ }
+)
+
+// ShortHelp returns keybindings to be shown in the mini help view. It's part
+// of the key.Map interface.
+func (k confirmKeyMap) ShortHelp() []key.Binding {
+ return []key.Binding{k.Confirm, k.Abort, k.Quit}
+}
+
+// FullHelp returns keybindings for the expanded help view. It's part of the
+// key.Map interface.
+func (k confirmKeyMap) FullHelp() [][]key.Binding {
+ return [][]key.Binding{
+ {k.Confirm, k.Abort, k.Quit}, // first column
+ {}, // second column
+ }
+}
+
+// NewConfirm creates a new confirm input element.
+// It asks user the question/message and wait for the confirmation (y/N).
+func NewConfirm(ctx context.Context, message string) Confirm {
+ ti := textinput.New()
+ ti.Prompt = fmt.Sprintf("❓ %s ", message)
+ ti.PromptStyle = textStyle
+ ti.Placeholder = "(y/N): "
+ ti.PlaceholderStyle = helperTextStyle
+ ti.Cursor.Style = textStyle
+ ti.Cursor.TextStyle = textStyle
+ ti.CharLimit = 1
+ ti.Focus()
+
+ m := Confirm{
+ keys: confirmKeys,
+ help: newHelpModel(),
+ textInput: ti,
+ }
+
+ p := tea.NewProgram(m, tea.WithContext(ctx))
+ m.p = p
+ return m
+}
+
+// Run runs the confirm element.
+func (m Confirm) Run() (bool, error) {
+ model, err := m.p.Run()
+ if model.(Confirm).interrupt {
+ os.Exit(1)
+ }
+
+ return model.(Confirm).confirm, err
+}
+
+// Init initializes the text confirm element.
+// Implements bubbletea.Model interface.
+func (m Confirm) Init() tea.Cmd {
+ return textinput.Blink
+}
+
+// Update updates the text confirm element.
+// Implements bubbletea.Model interface.
+func (m Confirm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmd tea.Cmd
+
+ // need to update the text input model first in order to
+ // get the correct cursor position and show user's input
+ var textInputCmd tea.Cmd
+ m.textInput, textInputCmd = m.textInput.Update(msg)
+
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, m.keys.Quit):
+ m.interrupt = true
+ m.textInput.Blur()
+ cmd = tea.Quit
+ case key.Matches(msg, m.keys.Confirm):
+ m.confirm = true
+ m.done = true
+ m.textInput.Blur()
+ cmd = tea.Quit
+ case key.Matches(msg, m.keys.Abort):
+ m.confirm = false
+ m.done = true
+ m.textInput.Blur()
+ cmd = tea.Quit
+ }
+ }
+
+ return m, tea.Sequence(textInputCmd, cmd)
+}
+
+// View renders the confirm element view.
+// Implements bubbletea.Model interface.
+func (m Confirm) View() string {
+ s := strings.Builder{}
+ s.WriteString(fmt.Sprintf("%s\n", m.textInput.View()))
+
+ if !m.done {
+ s.WriteString(fmt.Sprintf("\n%s\n", m.help.View(m.keys)))
+ }
+
+ return s.String()
+}
diff --git a/pkg/cli/tui/input.go b/pkg/cli/tui/input.go
new file mode 100644
index 000000000..dbe363f67
--- /dev/null
+++ b/pkg/cli/tui/input.go
@@ -0,0 +1,206 @@
+// everest
+// Copyright (C) 2025 Percona LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package tui provides UI elements for the CLI.
+package tui
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/help"
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type (
+ // inputKeyMap defines a set of keybindings. To work for help it must satisfy
+ // key.Map. It could also very easily be a map[string]key.Binding.
+ inputKeyMap struct {
+ Confirm key.Binding
+ Quit key.Binding
+ }
+
+ // Input represents a text input element.
+ Input struct {
+ keys inputKeyMap
+ help help.Model
+ textInput textinput.Model
+ p *tea.Program
+ done bool // user has confirmed the input
+ interrupt bool // set in case user wants to quit (Esc or Ctrl+c)
+ validateFunc ValidateInputFunc // function to validate the input
+ hint string // hint to show in the input field
+ defaultValue string // default value to show in the input field
+ }
+
+ // InputOption is used to set options when initializing Input.
+ // Input can accept a variable number of options.
+ //
+ // Example usage:
+ //
+ // p := NewInput(ctx, WithValidation(validationFunc), WithHint(hintMessage))
+ InputOption func(m *Input)
+)
+
+// Key bindings.
+var inputKeys = inputKeyMap{
+ Confirm: confirmKeyBinding,
+ Quit: quitKeyBinding,
+}
+
+// ShortHelp returns keybindings to be shown in the mini help view. It's part
+// of the key.Map interface.
+func (k inputKeyMap) ShortHelp() []key.Binding {
+ return []key.Binding{k.Confirm, k.Quit}
+}
+
+// FullHelp returns keybindings for the expanded help view. It's part of the
+// key.Map interface.
+func (k inputKeyMap) FullHelp() [][]key.Binding {
+ return [][]key.Binding{
+ {k.Confirm, k.Quit}, // first column
+ {}, // second column
+ }
+}
+
+// NewInput creates a new text input element.
+func NewInput(ctx context.Context, message string, opts ...InputOption) Input {
+ ti := textinput.New()
+ ti.Prompt = fmt.Sprintf("❓ %s: ", message)
+ ti.PromptStyle = textStyle
+ ti.Cursor.Style = textStyle
+ ti.Cursor.TextStyle = textStyle
+ ti.Focus()
+
+ m := Input{
+ keys: inputKeys,
+ help: newHelpModel(),
+ textInput: ti,
+ }
+
+ // Apply all options to the program.
+ for _, opt := range opts {
+ opt(&m)
+ }
+
+ p := tea.NewProgram(m, tea.WithContext(ctx))
+ m.p = p
+ return m
+}
+
+// WithInputDefaultValue lets you specify a default value that will be shown in the dialog
+// when the user is prompted for input.
+func WithInputDefaultValue(defaultValue string) InputOption {
+ return func(m *Input) {
+ if defaultValue != "" {
+ m.defaultValue = defaultValue
+ m.textInput.Placeholder = defaultValue
+ m.textInput.PlaceholderStyle = helperTextStyle
+ }
+ }
+}
+
+// WithInputHint lets you specify a hint message that will be shown in the dialog
+// when the user is prompted for input.
+func WithInputHint(hint string) InputOption {
+ return func(m *Input) {
+ if hint != "" {
+ m.hint = fmt.Sprintf("HINT: %s", hint)
+ }
+ }
+}
+
+// WithInputValidation lets you specify a validate function that will be
+// used to validate user's input.
+func WithInputValidation(validateFunc ValidateInputFunc) InputOption {
+ return func(m *Input) {
+ if validateFunc != nil {
+ m.validateFunc = validateFunc
+ }
+ }
+}
+
+// Run runs the text input element.
+func (m Input) Run() (string, error) {
+ model, err := m.p.Run()
+ if err != nil {
+ return "", err
+ }
+
+ if model.(Input).interrupt {
+ return "", ErrUserInterrupted
+ }
+
+ if model.(Input).validateFunc != nil {
+ if err := model.(Input).validateFunc(model.(Input).textInput.Value()); err != nil {
+ return "", err
+ }
+ }
+ return model.(Input).textInput.Value(), nil
+}
+
+// Init initializes the text input element.
+// Implements bubbletea.Model interface.
+func (m Input) Init() tea.Cmd {
+ return textinput.Blink
+}
+
+// Update updates the text input element.
+// Implements bubbletea.Model interface.
+func (m Input) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmd tea.Cmd
+
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, m.keys.Confirm):
+ m.done = true
+ m.textInput.Blur()
+ if m.textInput.Value() == "" {
+ m.textInput.SetValue(m.defaultValue)
+ }
+ cmd = tea.Quit
+ case key.Matches(msg, m.keys.Quit):
+ m.done = true
+ m.interrupt = true
+ m.textInput.Blur()
+ cmd = tea.Quit
+ }
+ }
+
+ var textInputCmd tea.Cmd
+ m.textInput, textInputCmd = m.textInput.Update(msg)
+
+ return m, tea.Sequence(textInputCmd, cmd)
+}
+
+// View renders the input element view.
+// Implements bubbletea.Model interface.
+func (m Input) View() string {
+ s := strings.Builder{}
+ s.WriteString(fmt.Sprintf("%s\n", m.textInput.View()))
+
+ if !m.done {
+ if m.hint != "" {
+ s.WriteString(fmt.Sprintf("\n%s\n", helperTextStyle.Render(m.hint)))
+ }
+ s.WriteString(fmt.Sprintf("\n%s\n", m.help.View(m.keys)))
+ }
+
+ return s.String()
+}
diff --git a/pkg/cli/tui/input_password.go b/pkg/cli/tui/input_password.go
new file mode 100644
index 000000000..0f8ace62a
--- /dev/null
+++ b/pkg/cli/tui/input_password.go
@@ -0,0 +1,191 @@
+// everest
+// Copyright (C) 2025 Percona LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package tui provides UI elements for the CLI.
+package tui
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/help"
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type (
+ // inputPasswordKeyMap defines a set of keybindings. To work for help it must satisfy
+ // key.Map. It could also very easily be a map[string]key.Binding.
+ inputPasswordKeyMap struct {
+ Confirm key.Binding
+ Quit key.Binding
+ }
+
+ // InputPassword represents a password input element.
+ InputPassword struct {
+ keys inputPasswordKeyMap
+ help help.Model
+ textInput textinput.Model
+ p *tea.Program
+ done bool // user has confirmed the input
+ interrupt bool // set in case user wants to quit (Esc or Ctrl+c)
+ validateFunc ValidateInputFunc // function to validate the input
+ hint string // hint to show in the input field
+ }
+
+ // InputPasswordOption is used to set options when initializing InputPassword.
+ // InputPassword can accept a variable number of options.
+ //
+ // Example usage:
+ //
+ // p := NewInputPassword(ctx, WithValidation(validationFunc), WithHint(hintMessage))
+ InputPasswordOption func(m *InputPassword)
+)
+
+// Key bindings.
+var inputPasswordKeys = inputPasswordKeyMap{
+ Confirm: confirmKeyBinding,
+ Quit: quitKeyBinding,
+}
+
+// ShortHelp returns keybindings to be shown in the mini help view. It's part
+// of the key.Map interface.
+func (k inputPasswordKeyMap) ShortHelp() []key.Binding {
+ return []key.Binding{k.Confirm, k.Quit}
+}
+
+// FullHelp returns keybindings for the expanded help view. It's part of the
+// key.Map interface.
+func (k inputPasswordKeyMap) FullHelp() [][]key.Binding {
+ return [][]key.Binding{
+ {k.Confirm, k.Quit}, // first column
+ {}, // second column
+ }
+}
+
+// NewInputPassword creates a new password input element.
+func NewInputPassword(ctx context.Context, message string, opts ...InputPasswordOption) InputPassword {
+ ti := textinput.New()
+ ti.Prompt = fmt.Sprintf("❓ %s: ", message)
+ ti.PromptStyle = textStyle
+ ti.EchoMode = textinput.EchoPassword
+ ti.EchoCharacter = '*'
+ ti.Cursor.Style = textStyle
+ ti.Cursor.TextStyle = textStyle
+ ti.Focus()
+
+ m := InputPassword{
+ keys: inputPasswordKeys,
+ help: newHelpModel(),
+ textInput: ti,
+ }
+
+ // Apply all options to the program.
+ for _, opt := range opts {
+ opt(&m)
+ }
+
+ p := tea.NewProgram(m, tea.WithContext(ctx))
+ m.p = p
+ return m
+}
+
+// WithPasswordHint lets you specify a hint message that will be shown in the dialog
+// when the user is prompted for input.
+func WithPasswordHint(hint string) InputPasswordOption {
+ return func(m *InputPassword) {
+ if hint != "" {
+ m.hint = fmt.Sprintf("HINT: %s", hint)
+ }
+ }
+}
+
+// WithPasswordValidation lets you specify a validate function that will be
+// used to validate user's input.
+func WithPasswordValidation(validateFunc ValidateInputFunc) InputPasswordOption {
+ return func(m *InputPassword) {
+ if validateFunc != nil {
+ m.validateFunc = validateFunc
+ }
+ }
+}
+
+// Run runs the password input element.
+func (m InputPassword) Run() (string, error) {
+ model, err := m.p.Run()
+ if err != nil {
+ return "", err
+ }
+
+ if model.(InputPassword).interrupt {
+ return "", ErrUserInterrupted
+ }
+
+ if model.(InputPassword).validateFunc != nil {
+ if err := model.(InputPassword).validateFunc(model.(InputPassword).textInput.Value()); err != nil {
+ return "", err
+ }
+ }
+ return model.(InputPassword).textInput.Value(), nil
+}
+
+// Init initializes the password input element.
+// Implements bubbletea.Model interface.
+func (m InputPassword) Init() tea.Cmd {
+ return textinput.Blink
+}
+
+// Update updates the password input element.
+// Implements bubbletea.Model interface.
+func (m InputPassword) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmd tea.Cmd
+
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, m.keys.Confirm):
+ m.done = true
+ m.textInput.Blur()
+ cmd = tea.Quit
+ case key.Matches(msg, m.keys.Quit):
+ m.done = true
+ m.interrupt = true
+ m.textInput.Blur()
+ cmd = tea.Quit
+ }
+ }
+
+ var textInputCmd tea.Cmd
+ m.textInput, textInputCmd = m.textInput.Update(msg)
+ return m, tea.Sequence(textInputCmd, cmd)
+}
+
+// View renders the password element view.
+// Implements bubbletea.Model interface.
+func (m InputPassword) View() string {
+ s := strings.Builder{}
+ s.WriteString(fmt.Sprintf("%s\n", m.textInput.View()))
+
+ if !m.done {
+ if m.hint != "" {
+ s.WriteString(fmt.Sprintf("\n%s\n", helperTextStyle.Render(m.hint)))
+ }
+ s.WriteString(fmt.Sprintf("\n%s\n", m.help.View(m.keys)))
+ }
+
+ return s.String()
+}
diff --git a/pkg/cli/tui/multi_select.go b/pkg/cli/tui/multi_select.go
new file mode 100644
index 000000000..88000cece
--- /dev/null
+++ b/pkg/cli/tui/multi_select.go
@@ -0,0 +1,224 @@
+// everest
+// Copyright (C) 2025 Percona LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package tui provides UI elements for the CLI.
+package tui
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/help"
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+var (
+ // ------
+ // Style applied to option label in case its checkbox is selected.
+ optionStyle = lipgloss.NewStyle().
+ Foreground(
+ lipgloss.AdaptiveColor{Light: "#0E5FB5", Dark: "#93C7FF"},
+ )
+ // Style applied to option label in case its checkbox is unselected.
+ hoverOptionStyle = lipgloss.NewStyle().
+ Foreground(lipgloss.AdaptiveColor{Light: "#0B4A8C", Dark: "#62AEFF"}).
+ Background(lipgloss.AdaptiveColor{Light: "#E2EFFC", Dark: "#2F435B"})
+
+ // Selected checkbox symbol.
+ selectedCheckBox = "[X]"
+
+ // Unselected checkbox symbol.
+ unselectedCheckbox = "[ ]"
+
+ // cursor current position symbol.
+ cursorSymbol = ">"
+
+ // ----------
+ // Key bindings.
+ multiSelectKeys = multiSelectKeyMap{
+ Confirm: confirmKeyBinding,
+ Quit: quitKeyBinding,
+ Up: key.NewBinding(
+ key.WithKeys(tea.KeyUp.String()),
+ key.WithHelp("↑", "up"),
+ ),
+ Down: key.NewBinding(
+ key.WithKeys(tea.KeyDown.String()),
+ key.WithHelp("↓", "down"),
+ ),
+ Space: key.NewBinding(
+ key.WithKeys(tea.KeySpace.String()),
+ key.WithHelp("space", "select/unselect"),
+ ),
+ }
+)
+
+type (
+ // multiSelectKeyMap defines a set of keybindings. To work for help it must satisfy
+ // key.Map. It could also very easily be a map[string]key.Binding.
+ multiSelectKeyMap struct {
+ Confirm key.Binding
+ Quit key.Binding
+ Up key.Binding
+ Down key.Binding
+ Space key.Binding
+ }
+
+ // MultiSelectOption represents an option in the multi-select list.
+ MultiSelectOption struct {
+ Text string
+ Selected bool
+ }
+
+ // MultiSelect represents a multi-select list.
+ MultiSelect struct {
+ keys multiSelectKeyMap
+ help help.Model
+ Message string // message to display
+ Choices []MultiSelectOption // possible options user may choose from
+ cursor int // which item in choice list our cursor is pointing at
+ p *tea.Program
+ done bool // user has confirmed the selection
+ interrupt bool // set in case user wants to quit (Esc or Ctrl+c)
+ }
+)
+
+// ShortHelp returns keybindings to be shown in the mini help view. It's part
+// of the key.Map interface.
+func (k multiSelectKeyMap) ShortHelp() []key.Binding {
+ return []key.Binding{k.Confirm, k.Up, k.Down, k.Space, k.Quit}
+}
+
+// FullHelp returns keybindings for the expanded help view. It's part of the
+// key.Map interface.
+func (k multiSelectKeyMap) FullHelp() [][]key.Binding {
+ return [][]key.Binding{
+ {k.Confirm, k.Up, k.Down, k.Space, k.Quit}, // first column
+ {}, // second column
+ }
+}
+
+// NewMultiSelect creates a new multi-select list.
+func NewMultiSelect(ctx context.Context, message string, choices []MultiSelectOption) MultiSelect {
+ m := MultiSelect{
+ keys: multiSelectKeys,
+ help: newHelpModel(),
+ Message: message,
+ Choices: choices,
+ }
+
+ p := tea.NewProgram(m, tea.WithContext(ctx))
+ m.p = p
+ return m
+}
+
+// Run starts the multi-select list.
+// It returns the selected options and error.
+func (m MultiSelect) Run() ([]MultiSelectOption, error) {
+ model, err := m.p.Run()
+ if model.(MultiSelect).interrupt {
+ os.Exit(1)
+ }
+ return model.(MultiSelect).Choices, err
+}
+
+// Init initializes the multi-select list.
+// Implements bubbletea.Model interface.
+func (m MultiSelect) Init() tea.Cmd {
+ // we do not need command here.
+ return nil
+}
+
+// Update updates the multi-select list.
+// Implements bubbletea.Model interface.
+func (m MultiSelect) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, m.keys.Confirm):
+ m.done = true
+ return m, tea.Quit
+ case key.Matches(msg, m.keys.Quit):
+ m.interrupt = true
+ m.done = true
+ return m, tea.Quit
+
+ // The "up" key move the cursor up
+ case key.Matches(msg, m.keys.Up):
+ if m.cursor > 0 {
+ m.cursor--
+ }
+
+ // The "down" key move the cursor down
+ case key.Matches(msg, m.keys.Down):
+ if m.cursor < len(m.Choices)-1 {
+ m.cursor++
+ }
+
+ // The spacebar (a literal space) toggle
+ // the selected state for the item that the cursor is pointing at.
+ case key.Matches(msg, m.keys.Space):
+ m.Choices[m.cursor].Selected = !m.Choices[m.cursor].Selected
+ }
+ }
+
+ // Return the updated model to the Bubble Tea runtime for processing.
+ // Note that we're not returning a command.
+ return m, nil
+}
+
+// View renders the multi-select list.
+// It returns a string representation of the UI.
+// Implements bubbletea.Model interface.
+func (m MultiSelect) View() string {
+ // The header
+ s := strings.Builder{}
+ s.WriteString(fmt.Sprintf("%s %s\n", failureStyle.Render("❓"), textStyle.Render(m.Message)))
+
+ // Iterate over our choices
+ for i, choice := range m.Choices {
+ // Render the row
+ s.WriteString(drawLine(m.cursor == i, choice.Selected, choice.Text))
+ }
+
+ if !m.done {
+ s.WriteString(fmt.Sprintf("\n%s\n", m.help.View(m.keys)))
+ }
+
+ // Send the UI for rendering
+ return s.String()
+}
+
+func drawLine(hover, checked bool, label string) string {
+ // Template contains: