feat(app): Adds abra app move command

This commit is contained in:
2025-08-18 14:28:27 +02:00
parent 5cf6048ecb
commit b8dbc5d65d
7 changed files with 377 additions and 5 deletions

324
cli/app/move.go Normal file
View File

@ -0,0 +1,324 @@
package app
import (
"context"
"fmt"
"os"
"os/exec"
"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"
containerPkg "coopcloud.tech/abra/pkg/container"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/secret"
"coopcloud.tech/abra/pkg/upstream/stack"
"github.com/docker/docker/api/types"
containertypes "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/api/types/volume"
dockerclient "github.com/docker/docker/client"
"github.com/spf13/cobra"
)
var AppMoveCommand = &cobra.Command{
Use: "move <domain> <server> [flags]",
Aliases: []string{"d"},
Short: "Moves an app to a different server",
Long: `Deploy an app.
This command supports chaos operations. Use "--chaos/-C" to deploy your recipe
checkout as-is. Recipe commit hashes are also supported as values for
"[version]". Please note, "upgrade"/"rollback" do not support chaos operations.`,
Example: ` # standard deployment
abra app deploy 1312.net
# chaos deployment
abra app deploy 1312.net --chaos
# deploy specific version
abra app deploy 1312.net 2.0.0+1.2.3
# deploy a specific git hash
abra app deploy 1312.net 886db76d`,
Args: cobra.RangeArgs(1, 2),
ValidArgsFunction: func(
cmd *cobra.Command,
args []string,
toComplete string,
) ([]string, cobra.ShellCompDirective) {
switch l := len(args); l {
case 0:
return autocomplete.AppNameComplete()
case 1:
return autocomplete.ServerNameComplete()
default:
return nil, cobra.ShellCompDirectiveDefault
}
},
Run: func(cmd *cobra.Command, args []string) {
app := internal.ValidateApp(args)
if len(args) <= 1 {
log.Fatal("no server provided")
}
newServer := args[1]
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
log.Fatal(err)
}
cl, err := client.New(app.Server)
if err != nil {
log.Fatal(err)
}
resources, err := getAppResources(cl, app)
if err != nil {
log.Fatal(err)
}
internal.MoveOverview(app, newServer, resources.SecretNames(), resources.VolumeNames())
if internal.Dry {
return
}
// NOTE: wait timeout will be removed, until it actually is just set it to a high value.
stack.WaitTimeout = 500
rmOpts := stack.Remove{
Namespaces: []string{app.StackName()},
Detach: false,
}
if err := stack.RunRemove(context.Background(), cl, rmOpts); err != nil {
log.Fatal(err)
}
cl2, err := client.New(newServer)
if err != nil {
log.Fatal(err)
}
for _, s := range resources.SecretList {
sname := strings.Split(strings.TrimPrefix(s.Spec.Name, app.StackName()+"_"), "_")
secretName := strings.Join(sname[:len(sname)-1], "_")
data := resources.Secrets[secretName]
if err := client.StoreSecret(cl2, s.Spec.Name, data); err != nil {
log.Infof("creating secret: %s", s.Spec.Name)
log.Errorf("failed to store secret on new server: %s", err)
}
}
for _, v := range resources.Volumes {
log.Infof("moving volume: %s", v.Name)
// Need to create the volume before copying the data, because when
// docker creates a new volume it set the folder permissions to
// root, which might be wrong. This ensures we always have the
// correct folder permissions inside the volume.
log.Debug("creating volume: %s", v.Name)
_, err := cl2.VolumeCreate(context.Background(), volume.CreateOptions{
Name: v.Name,
Driver: v.Driver,
})
if err != nil {
log.Errorf("failed to create volume: %s", err)
}
fileName := fmt.Sprintf("%s.tar.gz", v.Name)
log.Debug("creating %s", fileName)
cmd := exec.Command("ssh", app.Server, "-tt", fmt.Sprintf("sudo tar --same-owner -czhpf %s -C /var/lib/docker/volumes %s", fileName, v.Name))
if out, err := cmd.CombinedOutput(); err != nil {
log.Errorf("failed to tar volume: %s", err)
fmt.Println(string(out))
}
log.Debug("copying %s to local machine", fileName)
cmd = exec.Command("scp", fmt.Sprintf("%s:%s", app.Server, fileName), fileName)
if out, err := cmd.CombinedOutput(); err != nil {
log.Errorf("failed to copy tar to local machine: %s", err)
fmt.Println(string(out))
}
log.Debug("copying %s to %s", fileName, newServer)
cmd = exec.Command("scp", fileName, fmt.Sprintf("%s:%s", newServer, fileName))
if out, err := cmd.CombinedOutput(); err != nil {
log.Errorf("failed to copy tar to new server: %s", err)
fmt.Println(string(out))
}
log.Debug("extracting %s on %s", fileName, newServer)
cmd = exec.Command("ssh", newServer, "-tt", fmt.Sprintf("sudo tar --same-owner -xzpf %s -C /var/lib/docker/volumes", fileName))
if out, err := cmd.CombinedOutput(); err != nil {
log.Errorf("failed to extract tar: %s", err)
fmt.Println(string(out))
}
// Remove tar files
cmd = exec.Command("ssh", newServer, "-tt", fmt.Sprintf("sudo rm %s", fileName))
if out, err := cmd.CombinedOutput(); err != nil {
log.Errorf("failed to remove tar from new server: %s", err)
fmt.Println(string(out))
}
cmd = exec.Command("ssh", app.Server, "-tt", fmt.Sprintf("sudo rm %s", fileName))
if out, err := cmd.CombinedOutput(); err != nil {
log.Errorf("failed to remove tar from old server: %s", err)
fmt.Println(string(out))
}
cmd = exec.Command("rm", fileName)
if out, err := cmd.CombinedOutput(); err != nil {
log.Errorf("failed to remove tar on local machine: %s", err)
fmt.Println(string(out))
}
}
log.Debug("moving app config to new server")
if err := copyFile(app.Path, strings.ReplaceAll(app.Path, app.Server, newServer)); err != nil {
log.Fatal(err)
}
if err := os.Remove(app.Path); err != nil {
log.Fatal(err)
}
fmt.Println("% was succefully moved to %s", app.Name, newServer)
fmt.Println("Run the following command to deploy the app", app.Name, newServer)
fmt.Println(" abra app deploy --no-domain-checks", app.Domain)
fmt.Println()
fmt.Println("And don't forget to update you DNS record. And don't panic, as it might take a bit for the dust to settle. Traefik for example might fail to obtain the lets encrypt certificate for a while.", app.Domain)
fmt.Println()
fmt.Println("If anything goes wrong, you can always move the app config file to the original server and deploy it there again. There was no data removed on the old server")
return
},
}
type AppResources struct {
Secrets map[string]string
SecretList []swarm.Secret
Volumes map[string]containertypes.MountPoint
}
func (a *AppResources) SecretNames() []string {
secrets := []string{}
for name := range a.Secrets {
secrets = append(secrets, name)
}
return secrets
}
func (a *AppResources) VolumeNames() []string {
volumes := []string{}
for name := range a.Volumes {
volumes = append(volumes, name)
}
return volumes
}
func getAppResources(cl *dockerclient.Client, app app.App) (*AppResources, error) {
filter, err := app.Filters(false, false)
if err != nil {
return nil, err
}
services, err := cl.ServiceList(context.Background(), types.ServiceListOptions{Filters: filter})
if err != nil {
return nil, err
}
composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
if err != nil {
return nil, err
}
secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: filter})
if err != nil {
log.Fatal(err)
}
secretConfigs, err := secret.ReadSecretsConfig(app.Path, composeFiles, app.StackName())
if err != nil {
log.Fatal(err)
}
opts := stack.Deploy{Composefiles: composeFiles, Namespace: app.StackName()}
compose, err := appPkg.GetAppComposeConfig(app.Name, opts, app.Env)
if err != nil {
log.Fatal(err)
}
resources := &AppResources{
Secrets: make(map[string]string),
SecretList: secretList,
Volumes: make(map[string]containertypes.MountPoint),
}
for _, s := range services {
secretNames := map[string]string{}
for _, serviceCompose := range compose.Services {
if app.StackName()+"_"+serviceCompose.Name != s.Spec.Name {
continue
}
for _, secret := range serviceCompose.Secrets {
for _, s := range secretList {
if s.Spec.Name == app.StackName()+"_"+secret.Source+"_"+secretConfigs[secret.Source].Version {
secretNames[secret.Source] = s.ID
break
}
}
}
}
f := filters.NewArgs()
f.Add("name", s.Spec.Name)
targetContainer, err := containerPkg.GetContainer(context.Background(), cl, f, true)
if err != nil {
log.Error(err)
continue
}
for _, m := range targetContainer.Mounts {
if m.Type == mount.TypeVolume {
resources.Volumes[m.Name] = m
}
}
for secretName, secretID := range secretNames {
if _, ok := resources.Secrets[secretName]; ok {
continue
}
log.Debugf("extracting secret %s", secretName)
out, err := exec.Command("ssh", app.Server, "-tt", fmt.Sprintf("sudo cat /var/lib/docker/containers/%s/mounts/secrets/%s", targetContainer.ID, secretID)).Output()
if err != nil {
fmt.Println(string(out))
fmt.Println(err)
continue
}
resources.Secrets[secretName] = string(out)
}
}
return resources, nil
}
func copyFile(src string, dst string) error {
// Read all content of src to data, may cause OOM for a large file.
data, err := os.ReadFile(src)
if err != nil {
return err
}
// Write data to dst
err = os.WriteFile(dst, data, 0o644)
if err != nil {
return err
}
return nil
}
func init() {
AppMoveCommand.Flags().BoolVarP(
&internal.Dry,
"dry-run",
"r",
false,
"report changes that would be made",
)
}

View File

@ -224,7 +224,7 @@ environment. Typically, you can let Abra generate them for you on app creation
} }
secretName := fmt.Sprintf("%s_%s_%s", app.StackName(), name, version) secretName := fmt.Sprintf("%s_%s_%s", app.StackName(), name, version)
if err := client.StoreSecret(cl, secretName, data, app.Server); err != nil { if err := client.StoreSecret(cl, secretName, data); err != nil {
log.Fatal(err) log.Fatal(err)
} }

View File

@ -140,6 +140,53 @@ func getDeployType(currentVersion, newVersion string) string {
return "DOWNGRADE" return "DOWNGRADE"
} }
// MoveOverview shows a overview before moving an app to a different server
func MoveOverview(
app appPkg.App,
newServer string,
secrets []string,
volumes []string,
) error {
server := app.Server
if app.Server == "default" {
server = "local"
}
domain := app.Domain
if domain == "" {
domain = config.NO_DOMAIN_DEFAULT
}
rows := [][]string{
{"DOMAIN", domain},
{"RECIPE", app.Recipe.Name},
{"OLD SERVER", server},
{"New SERVER", newServer},
{"SECRETS", strings.Join(secrets, "\n")},
{"VOLUMES", strings.Join(volumes, "\n")},
}
overview := formatter.CreateOverview("MOVE OVERVIEW", rows)
fmt.Println(overview)
if NoInput {
return nil
}
response := false
prompt := &survey.Confirm{Message: "proceed?"}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
log.Fatal("move cancelled")
}
return nil
}
// PostCmds parses a string of commands and executes them inside of the respective services // PostCmds parses a string of commands and executes them inside of the respective services
// the commands string must have the following format: // the commands string must have the following format:
// "<service> <command> <arguments>|<service> <command> <arguments>|... " // "<service> <command> <arguments>|<service> <command> <arguments>|... "

View File

@ -204,6 +204,7 @@ func Run(version, commit string) {
app.AppRestartCommand, app.AppRestartCommand,
app.AppRestoreCommand, app.AppRestoreCommand,
app.AppRollbackCommand, app.AppRollbackCommand,
app.AppMoveCommand,
app.AppRunCommand, app.AppRunCommand,
app.AppSecretCommand, app.AppSecretCommand,
app.AppServicesCommand, app.AppServicesCommand,

View File

@ -7,7 +7,7 @@ import (
"github.com/docker/docker/client" "github.com/docker/docker/client"
) )
func StoreSecret(cl *client.Client, secretName, secretValue, server string) error { func StoreSecret(cl *client.Client, secretName, secretValue string) error {
ann := swarm.Annotations{Name: secretName} ann := swarm.Annotations{Name: secretName}
spec := swarm.SecretSpec{Annotations: ann, Data: []byte(secretValue)} spec := swarm.SecretSpec{Annotations: ann, Data: []byte(secretValue)}

View File

@ -216,7 +216,7 @@ func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server
return return
} }
if err := client.StoreSecret(cl, secret.RemoteName, password, server); err != nil { if err := client.StoreSecret(cl, secret.RemoteName, password); err != nil {
if strings.Contains(err.Error(), "AlreadyExists") { if strings.Contains(err.Error(), "AlreadyExists") {
log.Warnf("%s already exists", secret.RemoteName) log.Warnf("%s already exists", secret.RemoteName)
ch <- nil ch <- nil
@ -236,7 +236,7 @@ func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server
return return
} }
if err := client.StoreSecret(cl, secret.RemoteName, passphrase, server); err != nil { if err := client.StoreSecret(cl, secret.RemoteName, passphrase); err != nil {
if strings.Contains(err.Error(), "AlreadyExists") { if strings.Contains(err.Error(), "AlreadyExists") {
log.Warnf("%s already exists", secret.RemoteName) log.Warnf("%s already exists", secret.RemoteName)
ch <- nil ch <- nil

View File

@ -76,7 +76,7 @@ func RunRemove(ctx context.Context, client *apiclient.Client, opts Remove) error
continue continue
} }
log.Info("polling undeploy status") log.Debug("polling undeploy status")
timeout, err := waitOnTasks(ctx, client, namespace) timeout, err := waitOnTasks(ctx, client, namespace)
if timeout { if timeout {
errs = append(errs, err.Error()) errs = append(errs, err.Error())