refactor: app move review pass

This commit is contained in:
2025-09-01 07:36:13 +02:00
parent 61849a358c
commit 3b674844c8
3 changed files with 118 additions and 90 deletions

View File

@ -2,6 +2,7 @@ package app
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
@ -13,6 +14,7 @@ import (
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/client"
containerPkg "coopcloud.tech/abra/pkg/container"
"coopcloud.tech/abra/pkg/i18n"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/secret"
"coopcloud.tech/abra/pkg/upstream/stack"
@ -26,16 +28,32 @@ import (
"github.com/spf13/cobra"
)
// translators: `abra app move` aliases. use a comma separated list of aliases
// with no spaces in between
var appMoveAliases = i18n.G("m")
var AppMoveCommand = &cobra.Command{
Use: "move <domain> <server> [flags]",
Short: "Moves an app to a different server",
Long: `Move an app to a differnt server.
// translators: `app move` command
Use: i18n.G("move <domain> <server> [flags]"),
Aliases: strings.Split(appMoveAliases, ","),
// translators: Short description for `app move` command
Short: i18n.G("Moves an app to a different server"),
Long: i18n.G(`Move an app to a differnt server.
This will copy secrets and volumes from the old server to the new one. It will also undeploy the app from old server but not deploy it on the new. You will have to do that your self, after the move finished.
This command will migrate an app config and copy secrets and volumes from the
old server to the new one. It will undeploy the app from the current server but
not deploy it on the new server.
Use "--dry-run/-r" to see which secrets and volumes will be moved.`,
Example: ` # moving an app
abra app move nextcloud.example.com myserver.com`,
Deploying on the new server is usually done with "abra app deploy
--no-domain-checks <domain>". Do not forget to update your DNS records. And
remember: don't panic, as it might take a while for the dust to settle.
If anything goes wrong, you can always move the app config file to the original
server and deploy it there again. No data is removed from the old server.
Use "--dry-run/-r" to see which secrets and volumes will be moved.`),
Example: i18n.G(` # move an app
abra app move nextcloud.example.com myserver.com`),
Args: cobra.RangeArgs(1, 2),
ValidArgsFunction: func(
cmd *cobra.Command,
@ -54,7 +72,7 @@ Use "--dry-run/-r" to see which secrets and volumes will be moved.`,
Run: func(cmd *cobra.Command, args []string) {
app := internal.ValidateApp(args)
if len(args) <= 1 {
log.Fatal("no server provided")
log.Fatal(i18n.G("no server provided?"))
}
newServer := args[1]
@ -62,32 +80,40 @@ Use "--dry-run/-r" to see which secrets and volumes will be moved.`,
log.Fatal(err)
}
cl, err := client.New(app.Server)
currentServerClient, err := client.New(app.Server)
if err != nil {
log.Fatal(err)
}
resources, err := getAppResources(cl, app)
resources, err := getAppResources(currentServerClient, app)
if err != nil {
log.Fatal(err)
log.Fatal(i18n.G("unable to retrieve %s resources on %s: %s", app.Name, app.Server, err))
}
internal.MoveOverview(app, newServer, resources.SecretNames(), resources.VolumeNames())
if err := internal.PromptProcced(); err != nil {
return
log.Fatal(i18n.G("bailing out: %s", err))
}
// 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 {
deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
if err != nil {
log.Fatal(err)
}
cl2, err := client.New(newServer)
if deployMeta.IsDeployed {
log.Info(i18n.G("undeploying %s on %s", app.Name, app.Server))
rmOpts := stack.Remove{
Namespaces: []string{app.StackName()},
Detach: false,
}
if err := stack.RunRemove(context.Background(), currentServerClient, rmOpts); err != nil {
log.Fatal(i18n.G("failed to remove app from %s: %s", err, app.Server))
}
} else {
log.Info(i18n.G("%s is not deployed on %s, moving on", app.Name, app.Server))
}
newServerClient, err := client.New(newServer)
if err != nil {
log.Fatal(err)
}
@ -96,88 +122,82 @@ Use "--dry-run/-r" to see which secrets and volumes will be moved.`,
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)
if err := client.StoreSecret(newServerClient, s.Spec.Name, data); err != nil {
log.Fatal(i18.G("failed to store secret on %s: %s", err, newServer))
}
log.Info(i18n.G("created secret on %s: %s", s.Spec.Name, newServer))
}
for _, v := range resources.Volumes {
log.Infof("moving volume: %s", v.Name)
log.Info(i18n.G("moving volume from %s to %s: %s", v.Name, app.Server, newServer))
// 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{
// NOTE(p4u1): 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(i18n.G("creating volume on %s: %s", v.Name, newServer))
_, err := newServerClient.VolumeCreate(context.Background(), volume.CreateOptions{
Name: v.Name,
Driver: v.Driver,
})
if err != nil {
log.Errorf("failed to create volume: %s", err)
log.Fatal(i18n.G("failed to create volume on %s: %s", err, newServer))
}
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))
log.Debug(i18n.G("creating %s on %s", fileName, app.Server))
tarCmd := fmt.Sprintf("sudo tar --same-owner -czhpf %s -C /var/lib/docker/volumes %s", fileName, v.Name)
cmd := exec.Command("ssh", app.Server, "-tt", tarCmd)
if out, err := cmd.CombinedOutput(); err != nil {
log.Errorf("failed to tar volume: %s", err)
fmt.Println(string(out))
log.Fatal(i18n.G("%s failed on %s: output:%s err:%s", tarCmd, app.Server, string(out), err))
}
log.Debug("copying %s to local machine", fileName)
log.Debug(i18n.G("copying %s from %s to local machine", fileName, app.Server))
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.Fata(i18n.G("failed to copy %s from %s to local machine: output:%s err:%s", fileName, app.Server, string(out), err))
}
log.Debug("copying %s to %s", fileName, newServer)
log.Debug(i18n.G("copying %s to %s from local machine", 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.Fatal(i18n.G("failed to copy tar from local machine to %s: output:%s err:%s", newServer, string(out), err))
}
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))
log.Debug(i18n.G("extracting %s on %s", fileName, newServer))
tarExtractCmd := fmt.Sprintf("sudo tar --same-owner -xzpf %s -C /var/lib/docker/volumes", fileName)
cmd = exec.Command("ssh", newServer, "-tt", tarExtractCmd)
if out, err := cmd.CombinedOutput(); err != nil {
log.Errorf("failed to extract tar: %s", err)
fmt.Println(string(out))
log.Fatal(i18n.G("%s failed to extract %s on %s: output:%s err:%s", tarExtractCmd, fileName, newServer, string(out), err))
}
// 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))
log.Fatal(i18n.G("failed to remove %s from %s: output:%s err:%s", fileName, newServer, string(out), err))
}
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))
log.Fatal(i18n.G("failed to remove %s from %s: output:%s err:%s", fileName, app.Server, string(out), err))
}
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.Fatal(i18n.G("failed to remove %s on local machine: output:%s err:%s", fileName, string(out), err))
}
}
log.Debug("moving app config to new server")
log.Debug(i18n.G("migrating app config from %s to %s", app.Server, newServer))
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)
log.Fatal(i18n.G("failed to migrate app config: %s", 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
if err := os.Remove(app.Path); err != nil {
log.Fatal(i18n.G("unable to remove %s: %s", app.Path, err))
}
log.Info(i18n.G("%s was succefully moved from %s to %s 🎉", app.Name, app.Server, newServer))
},
}
@ -221,18 +241,18 @@ func getAppResources(cl *dockerclient.Client, app app.App) (*AppResources, error
secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: filter})
if err != nil {
log.Fatal(err)
return nil, err
}
secretConfigs, err := secret.ReadSecretsConfig(app.Path, composeFiles, app.StackName())
if err != nil {
log.Fatal(err)
return nil, err
}
opts := stack.Deploy{Composefiles: composeFiles, Namespace: app.StackName()}
compose, err := appPkg.GetAppComposeConfig(app.Name, opts, app.Env)
if err != nil {
log.Fatal(err)
return nil, err
}
resources := &AppResources{
@ -240,16 +260,20 @@ func getAppResources(cl *dockerclient.Client, app app.App) (*AppResources, error
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 {
stackService := fmt.Sprintf("%s_%s", app.StackName(), serviceCompose.Name)
if stackService != s.Spec.Name {
log.Debug(i18n.G("skipping %s as it does not match %s", stackService, 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 {
stackSecret := fmt.Sprintf("%s_%s_%s", app.StackName(), secret.Source, secretConfigs[secret.Source].Version)
if s.Spec.Name == stackSecret {
secretNames[secret.Source] = s.ID
break
}
@ -261,9 +285,9 @@ func getAppResources(cl *dockerclient.Client, app app.App) (*AppResources, error
f.Add("name", s.Spec.Name)
targetContainer, err := containerPkg.GetContainer(context.Background(), cl, f, true)
if err != nil {
log.Error(err)
continue
return nil, errors.New(i18n.G("unable to get container matching %s: %s", s.Spec.Name, err))
}
for _, m := range targetContainer.Mounts {
if m.Type == mount.TypeVolume {
resources.Volumes[m.Name] = m
@ -274,17 +298,19 @@ func getAppResources(cl *dockerclient.Client, app app.App) (*AppResources, error
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()
log.Debug(i18n.G("extracting secret %s on %s", secretName, app.Server))
cmd := fmt.Sprintf("sudo cat /var/lib/docker/containers/%s/mounts/secrets/%s", targetContainer.ID, secretID)
out, err := exec.Command("ssh", app.Server, "-tt", cmd).Output()
if err != nil {
fmt.Println(string(out))
fmt.Println(err)
continue
return nil, errors.New(i18n.G("%s failed on %s: output:%s err:%s", cmd, app.Server, string(out), err))
}
resources.Secrets[secretName] = string(out)
}
}
return resources, nil
}
@ -294,20 +320,22 @@ func copyFile(src string, dst string) error {
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",
i18n.G("dry-run"),
i18n.G("r"),
false,
"report changes that would be made",
i18n.G("report changes that would be made"),
)
}

View File

@ -160,15 +160,15 @@ func MoveOverview(
}
rows := [][]string{
{"DOMAIN", domain},
{"RECIPE", app.Recipe.Name},
{"OLD SERVER", server},
{"New SERVER", newServer},
{"SECRETS", strings.Join(secrets, "\n")},
{"VOLUMES", strings.Join(volumes, "\n")},
{i18n.G("DOMAIN", domain)},
{i18n.G("RECIPE", app.Recipe.Name)},
{i18n.G("OLD SERVER", server)},
{i18n.G("NEW SERVER", newServer)},
{i18n.G("SECRETS", strings.Join(secrets, "\n"))},
{i18n.G("VOLUMES", strings.Join(volumes, "\n"))},
}
overview := formatter.CreateOverview("MOVE OVERVIEW", rows)
overview := formatter.CreateOverview(i18n.G("MOVE OVERVIEW"), rows)
fmt.Println(overview)
}
@ -179,17 +179,17 @@ func PromptProcced() error {
}
if Dry {
return fmt.Errorf("dry run")
return fmt.Error(i18n.G("dry run"))
}
response := false
prompt := &survey.Confirm{Message: "proceed?"}
prompt := &survey.Confirm{Message: i18n.G("proceed?")}
if err := survey.AskOne(prompt, &response); err != nil {
return err
}
if !response {
return errors.New("cancelled")
return errors.New(i18n.G("cancelled"))
}
return nil

View File

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