feat!: abra app env pull
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing

`abra app env` -> `abra app env list`.

See #497
This commit is contained in:
2025-10-31 17:47:32 +01:00
parent 96e59cf196
commit 85b2b35f32
4 changed files with 276 additions and 13 deletions

View File

@ -1,28 +1,50 @@
package app
import (
"context"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"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 <domain> [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 <domain> [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,231 @@ var AppEnvCommand = &cobra.Command{
fmt.Println(overview)
},
}
var AppEnvPullCommand = &cobra.Command{
// translators: `app pull` command
Use: i18n.G("pull <domain> [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) {
if !internal.Force {
log.Fatal(i18n.G("%s.env exists, pass --force/-f to force update"))
}
if err := pullAppEnvDoesExist(appName); err != nil {
log.Fatal(err)
}
return
}
if err := pullAppEnvDoesntExist(appName, cmd.Name()); err != nil {
log.Fatal(err)
}
},
}
func pullAppEnvDoesExist(appName string) error {
return nil
}
func pullAppEnvDoesntExist(appName string, cmdName string) error {
if server == "" {
return errors.New(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) {
return errors.New(i18n.G("unknown server %s, run \"abra server add %s\"?"))
}
store := contextPkg.NewDefaultDockerContextStore()
contexts, err := store.Store.List()
if err != nil {
return errors.New(i18n.G("unable to look up server context: %s", err))
}
var contextCreated bool
for _, context := range contexts {
if context.Name == server {
contextCreated = true
}
}
if !contextCreated {
return errors.New(i18n.G("server missing context, run \"abra server add %s\"?", server))
}
cl, err := client.New(server)
if err != nil {
return err
}
deployMeta, err := stack.IsDeployed(context.Background(), cl, appPkg.StackName(appName))
if err != nil {
return err
}
if !deployMeta.IsDeployed {
return errors.New(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 {
return errors.New(i18n.G("unable to retrieve container for %s: %s", appName, err))
}
inspectResult, err := cl.ContainerInspect(context.Background(), targetContainer.ID)
if err != nil {
return errors.New(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 {
return errors.New(i18n.G("unable to parse key/val for %s from %s", appName, envVar))
}
key, val := split[0], split[1]
deploymentEnv[key] = val
}
log.Debug(i18n.G("pulled env values from %s deployment: %s", appName, deploymentEnv))
var (
recipeName string
recipeKey string
)
if r, ok := deploymentEnv["TYPE"]; ok {
recipeKey = "TYPE"
recipeName = r
}
if r, ok := deploymentEnv["RECIPE"]; ok {
recipeKey = "RECIPE"
recipeName = r
}
if recipeName == "" {
return errors.New(i18n.G("unable to determine recipe type from %s, env: %v", appName, inspectResult.Config.Env))
}
recipe := internal.ValidateRecipe([]string{recipeName}, cmdName)
version := deployMeta.Version
if deployMeta.IsChaos {
version = deployMeta.ChaosVersion
}
if _, err := recipe.EnsureVersion(version); err != nil {
return err
}
mergedEnv, err := recipe.SampleEnv()
if err != nil {
return 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
}
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 {
return err
}
appEnvPath := path.Join(config.ABRA_DIR, "servers", server, fmt.Sprintf("%s.env", appName))
err = os.WriteFile(appEnvPath, envSample, 0o664)
if err != nil {
return errors.New(i18n.G("unable to write new env %s: %s", appEnvPath, err))
}
read, err := os.ReadFile(appEnvPath)
if err != nil {
return errors.New(i18n.G("unable to read new env %s: %s", appEnvPath, err))
}
sampleEnv, err := recipe.SampleEnv()
if err != nil {
return err
}
newContents := string(read)
for key, val := range mergedEnv {
if sampleEnv[key] == val {
continue
}
// TODO: figure out how to write correctly
// here be dragons!
}
err = os.WriteFile(appEnvPath, []byte(newContents), 0)
if err != nil {
return errors.New(i18n.G("unable to write new env %s: %s", appEnvPath, err))
}
return nil
}
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"),
)
}

View File

@ -28,6 +28,7 @@ var AppUndeployCommand = &cobra.Command{
Use: i18n.G("undeploy <domain> [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

View File

@ -283,6 +283,11 @@ Config:
app.AppBackupSnapshotsCommand,
)
app.AppEnvCommand.AddCommand(
app.AppEnvListCommand,
app.AppEnvPullCommand,
)
app.AppCommand.AddCommand(
app.AppBackupCommand,
app.AppCheckCommand,

View File

@ -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,10 @@ 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" { }
@test "app env pull existing app env (no --force)" { }
@test "app env pull existing app env (--force)" { }
@test "app env pull recreates app env when missing" { }
@test "app env pull recreates correct version" { }
@test "app env pull recreates correct changed values" { }