diff --git a/cli/app/env.go b/cli/app/env.go index 1b9e53bbd..1fd06d0e0 100644 --- a/cli/app/env.go +++ b/cli/app/env.go @@ -1,28 +1,50 @@ package app import ( + "context" "fmt" + "os" + "path" + "path/filepath" + "regexp" "sort" "strings" "coopcloud.tech/abra/cli/internal" + "coopcloud.tech/abra/pkg/app" + appPkg "coopcloud.tech/abra/pkg/app" "coopcloud.tech/abra/pkg/autocomplete" + "coopcloud.tech/abra/pkg/client" + "coopcloud.tech/abra/pkg/config" + containerPkg "coopcloud.tech/abra/pkg/container" + contextPkg "coopcloud.tech/abra/pkg/context" "coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/i18n" + "coopcloud.tech/abra/pkg/log" + "coopcloud.tech/abra/pkg/upstream/stack" + "github.com/docker/docker/api/types/filters" "github.com/spf13/cobra" ) -// translators: `abra app env` aliases. use a comma separated list of aliases with -// no spaces in between +// translators: `abra app env` aliases. use a comma separated list of aliases +// with no spaces in between var appEnvAliases = i18n.G("e") -var AppEnvCommand = &cobra.Command{ - // translators: `app env` command - Use: i18n.G("env [flags]"), - Aliases: strings.Split(appEnvAliases, ","), - // translators: Short description for `app env` command - Short: i18n.G("Show app .env values"), - Example: i18n.G(" abra app env 1312.net"), +// translators: `abra app env list` aliases. use a comma separated list of +// aliases with no spaces in between +var appEnvListAliases = i18n.G("l,ls") + +// translators: `abra app env pull` aliases. use a comma separated list of +// aliases with no spaces in between +var appEnvPullAliases = i18n.G("pl,p") + +var AppEnvListCommand = &cobra.Command{ + // translators: `app env list` command + Use: i18n.G("list [flags]"), + Aliases: strings.Split(appEnvListAliases, ","), + // translators: Short description for `app env list` command + Short: i18n.G("List all app environment values"), + Example: i18n.G(" abra app env list 1312.net"), Args: cobra.ExactArgs(1), ValidArgsFunction: func( cmd *cobra.Command, @@ -49,3 +71,270 @@ var AppEnvCommand = &cobra.Command{ fmt.Println(overview) }, } + +var AppEnvPullCommand = &cobra.Command{ + // translators: `app pull` command + Use: i18n.G("pull [flags]"), + Aliases: strings.Split(appEnvPullAliases, ","), + // translators: Short description for `app env pull` command + Short: i18n.G("Pull app environment values from a deployed app"), + Long: i18n.G(`Pull app environment values from a deploymed app. + +A convenient command for when you've lost your app environment file or want to +synchronize your local app environment values with what is deployed live.`), + Example: i18n.G(` # pull existing .env file and overwrite local values + abra app env pull 1312.net --force + + # pull lost app .env file + abra app env pull my.gitea.net --server 1312.net`), + Args: cobra.MaximumNArgs(2), + ValidArgsFunction: func( + cmd *cobra.Command, + args []string, + toComplete string) ([]string, cobra.ShellCompDirective) { + return autocomplete.AppNameComplete() + }, + Run: func(cmd *cobra.Command, args []string) { + appName := args[0] + + appEnvPath := path.Join(config.ABRA_DIR, "servers", server, fmt.Sprintf("%s.env", appName)) + if _, err := os.Stat(appEnvPath); !os.IsNotExist(err) { + log.Fatal(i18n.G("%s already exists?", appEnvPath)) + } + + if server == "" { + log.Fatal(i18n.G("unable to determine server of app %s, please pass --server/-s", appName)) + } + + serverDir := filepath.Join(config.SERVERS_DIR, server) + if _, err := os.Stat(serverDir); os.IsNotExist(err) { + log.Fatal(i18n.G("unknown server %s, run \"abra server add %s\"?", server, server)) + } + + store := contextPkg.NewDefaultDockerContextStore() + contexts, err := store.Store.List() + if err != nil { + log.Fatal(i18n.G("unable to look up server context for %s: %s", server, err)) + } + + var contextCreated bool + for _, context := range contexts { + if context.Name == server || server == "default" { + contextCreated = true + } + } + + if !contextCreated { + log.Fatal(i18n.G("%s missing context, run \"abra server add %s\"?", server, server)) + } + + cl, err := client.New(server) + if err != nil { + log.Fatal(err) + } + + deployMeta, err := stack.IsDeployed(context.Background(), cl, appPkg.StackName(appName)) + if err != nil { + log.Fatal(err) + } + + if !deployMeta.IsDeployed { + log.Fatal(i18n.G("%s is not deployed?", appName)) + } + + filters := filters.NewArgs() + filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(appName), "app")) + targetContainer, err := containerPkg.GetContainer(context.Background(), cl, filters, internal.NoInput) + if err != nil { + log.Fatal(i18n.G("unable to retrieve container for %s: %s", appName, err)) + } + + inspectResult, err := cl.ContainerInspect(context.Background(), targetContainer.ID) + if err != nil { + log.Fatal(i18n.G("unable to inspect container for %s: %s", appName, err)) + } + + deploymentEnv := make(map[string]string) + for _, envVar := range inspectResult.Config.Env { + split := strings.SplitN(envVar, "=", 2) + if len(split) != 2 { + log.Debug(i18n.G("no value attached to %s", envVar)) + continue + } + + key, val := split[0], split[1] + deploymentEnv[key] = val + } + + log.Debug(i18n.G("pulled env values from %s deployment: %s", appName, deploymentEnv)) + + var ( + recipeEnvVar string + recipeKey string + ) + + if r, ok := deploymentEnv["TYPE"]; ok { + recipeKey = "TYPE" + recipeEnvVar = r + } + + if r, ok := deploymentEnv["RECIPE"]; ok { + recipeKey = "RECIPE" + recipeEnvVar = r + } + + if recipeEnvVar == "" { + log.Fatal(i18n.G("unable to determine recipe type from %s, env: %v", appName, inspectResult.Config.Env)) + } + + var recipeName = recipeEnvVar + if strings.Contains(recipeEnvVar, ":") { + split := strings.Split(recipeEnvVar, ":") + recipeName = split[0] + } + + recipe := internal.ValidateRecipe( + []string{recipeName}, + cmd.Name(), + ) + + version := deployMeta.Version + if deployMeta.IsChaos { + version = deployMeta.ChaosVersion + } + + if _, err := recipe.EnsureVersion(version); err != nil { + log.Fatal(err) + } + + mergedEnv, err := recipe.SampleEnv() + if err != nil { + log.Fatal(err) + } + + log.Debug(i18n.G("retrieved env values from .env.sample of %s: %s", recipe.Name, mergedEnv)) + + for k, v := range deploymentEnv { + mergedEnv[k] = v + } + + if !strings.Contains(recipeEnvVar, ":") { + mergedEnv[recipeKey] = fmt.Sprintf("%s:%s", mergedEnv[recipeKey], version) + } + + log.Debug(i18n.G("final merged env values for %s are: %s", appName, mergedEnv)) + + envSample, err := os.ReadFile(recipe.SampleEnvPath) + if err != nil { + log.Fatal(err) + } + + err = os.WriteFile(appEnvPath, envSample, 0o664) + if err != nil { + log.Fatal(i18n.G("unable to write new env %s: %s", appEnvPath, err)) + } + + read, err := os.ReadFile(appEnvPath) + if err != nil { + log.Fatal(i18n.G("unable to read new env %s: %s", appEnvPath, err)) + } + + sampleEnv, err := recipe.SampleEnv() + if err != nil { + log.Fatal(err) + } + + var composeFileUpdated bool + newContents := string(read) + for key, val := range mergedEnv { + if sampleEnv[key] == val { + continue + } + + if key == "COMPOSE_FILE" { + composeFileUpdated = true + continue + } + + if m, _ := regexp.MatchString(fmt.Sprintf(`#%s=`, key), newContents); m { + log.Debug(i18n.G("uncommenting %s", key)) + re := regexp.MustCompile(fmt.Sprintf(`#%s=`, key)) + newContents = re.ReplaceAllString(newContents, fmt.Sprintf("%s=", key)) + } + + if m, _ := regexp.MatchString(fmt.Sprintf(`# %s=`, key), newContents); m { + log.Debug(i18n.G("uncommenting %s", key)) + re := regexp.MustCompile(fmt.Sprintf(`# %s=`, key)) + newContents = re.ReplaceAllString(newContents, fmt.Sprintf("%s=", key)) + } + + if m, _ := regexp.MatchString(fmt.Sprintf(`%s=".*"`, key), newContents); m { + log.Debug(i18n.G(`inserting %s="%s" (double quotes)`, key, val)) + re := regexp.MustCompile(fmt.Sprintf(`%s=".*"`, key)) + newContents = re.ReplaceAllString(newContents, fmt.Sprintf(`%s="%s"`, key, val)) + continue + } + + if m, _ := regexp.MatchString(fmt.Sprintf(`%s='.*'`, key), newContents); m { + log.Debug(i18n.G(`inserting %s='%s' (single quotes)`, key, val)) + re := regexp.MustCompile(fmt.Sprintf(`%s='.*'`, key)) + newContents = re.ReplaceAllString(newContents, fmt.Sprintf(`%s='%s'`, key, val)) + continue + } + + if m, _ := regexp.MatchString(fmt.Sprintf("%s=.*", key), newContents); m { + log.Debug(i18n.G("inserting %s=%s (no quotes)", key, val)) + re := regexp.MustCompile(fmt.Sprintf("%s=.*", key)) + newContents = re.ReplaceAllString(newContents, fmt.Sprintf("%s=%s", key, val)) + } + } + + err = os.WriteFile(appEnvPath, []byte(newContents), 0) + if err != nil { + log.Fatal(i18n.G("unable to write new env %s: %s", appEnvPath, err)) + } + + log.Info(i18n.G("%s successfully created", appEnvPath)) + + if composeFileUpdated { + log.Warn(i18n.G("manual update required: COMPOSE_FILE=\"%s\"", mergedEnv["COMPOSE_FILE"])) + } + }, +} + +var AppEnvCommand = &cobra.Command{ + // translators: `app env` command group + Use: i18n.G("env [cmd] [args] [flags]"), + Aliases: strings.Split(appEnvAliases, ","), + // translators: Short description for `app env` command group + Short: i18n.G("Manage app environment values"), +} + +var ( + server string +) + +func init() { + AppEnvPullCommand.Flags().BoolVarP( + &internal.Force, + i18n.G("force"), + i18n.G("f"), + false, + i18n.G("perform action without further prompt"), + ) + + AppEnvPullCommand.Flags().StringVarP( + &server, + i18n.G("server"), + i18n.G("s"), + "", + i18n.G("server associated with deployed app"), + ) + + AppEnvPullCommand.RegisterFlagCompletionFunc( + i18n.G("server"), + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return autocomplete.ServerNameComplete() + }, + ) +} diff --git a/cli/app/undeploy.go b/cli/app/undeploy.go index 9f9b1138f..4c0ca4466 100644 --- a/cli/app/undeploy.go +++ b/cli/app/undeploy.go @@ -28,6 +28,7 @@ var AppUndeployCommand = &cobra.Command{ Use: i18n.G("undeploy [flags]"), // translators: Short description for `app undeploy` command Aliases: strings.Split(appUndeployAliases, ","), + Short: i18n.G("Undeploy a deployed app"), Long: i18n.G(`This does not destroy any application data. However, you should remain vigilant, as your swarm installation will consider diff --git a/cli/run.go b/cli/run.go index 621f596f6..524afe019 100644 --- a/cli/run.go +++ b/cli/run.go @@ -283,6 +283,11 @@ Config: app.AppBackupSnapshotsCommand, ) + app.AppEnvCommand.AddCommand( + app.AppEnvListCommand, + app.AppEnvPullCommand, + ) + app.AppCommand.AddCommand( app.AppBackupCommand, app.AppCheckCommand, diff --git a/tests/integration/app_env.bats b/tests/integration/app_env.bats index 8bb55c102..830af703a 100644 --- a/tests/integration/app_env.bats +++ b/tests/integration/app_env.bats @@ -28,17 +28,17 @@ teardown(){ } @test "validate app argument" { - run $ABRA app env + run $ABRA app env list assert_failure - run $ABRA app env DOESNTEXIST + run $ABRA app env list DOESNTEXIST assert_failure } @test "show env version" { latestRelease=$(_latest_release) - run $ABRA app env "$TEST_APP_DOMAIN" + run $ABRA app env list "$TEST_APP_DOMAIN" assert_success assert_output --partial "$latestRelease" } @@ -48,7 +48,7 @@ teardown(){ assert_success assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo" - run $ABRA app env "$TEST_APP_DOMAIN" + run $ABRA app env list "$TEST_APP_DOMAIN" assert_success assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo" @@ -57,3 +57,40 @@ teardown(){ run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo" assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo" } + +@test "app env pull explodes when no deployed app" { + run $ABRA app env pull "$TEST_APP_DOMAIN" -s "$TEST_SERVER" + assert_failure +} + +# bats test_tags=slow +@test "app env pull recreates app env when missing" { + run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input + assert_success + + run rm -rf "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env" + assert_success + assert_not_exists "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env" + + run $ABRA app env pull "$TEST_APP_DOMAIN" -s "$TEST_SERVER" + assert_success + assert_exists "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env" +} + +# bats test_tags=slow +@test "app env pull recreates correct version" { + run $ABRA app deploy "$TEST_APP_DOMAIN" "0.1.0+1.20.0" --no-input + assert_success + + run rm -rf "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env" + assert_success + assert_not_exists "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env" + + run $ABRA app env pull "$TEST_APP_DOMAIN" -s "$TEST_SERVER" + assert_success + assert_exists "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env" + + run grep -q "TYPE=$TEST_RECIPE:0.1.0+1.20.0" \ + "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env" + assert_success +}