package app import ( "context" "errors" "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/i18n" "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" ) // 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{ // translators: `app move` command Use: i18n.G("move [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 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. Deploying on the new server is usually done with "abra app deploy --no-domain-checks ". 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, 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(i18n.G("no server provided?")) } newServer := args[1] if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil { log.Fatal(err) } currentServerClient, err := client.New(app.Server) if err != nil { log.Fatal(err) } resources, err := getAppResources(currentServerClient, app) if err != nil { 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 { log.Fatal(i18n.G("bailing out: %s", err)) } deployMeta, err := stack.IsDeployed(context.Background(), currentServerClient, app.StackName()) if err != nil { log.Fatal(err) } 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) } 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(newServerClient, s.Spec.Name, data); err != nil { log.Fatal(i18n.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.Info(i18n.G("moving volume from %s to %s: %s", v.Name, app.Server, newServer)) // 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.Fatal(i18n.G("failed to create volume on %s: %s", err, newServer)) } fileName := fmt.Sprintf("%s.tar.gz", 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.Fatal(i18n.G("%s failed on %s: output:%s err:%s", tarCmd, app.Server, string(out), err)) } 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.Fatal(i18n.G("failed to copy %s from %s to local machine: output:%s err:%s", fileName, app.Server, string(out), err)) } 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.Fatal(i18n.G("failed to copy tar from local machine to %s: output:%s err:%s", newServer, string(out), err)) } 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.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.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.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.Fatal(i18n.G("failed to remove %s on local machine: output:%s err:%s", fileName, string(out), err)) } } 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(i18n.G("failed to migrate app config: %s", err)) } 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)) }, } 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 { return nil, err } secretConfigs, err := secret.ReadSecretsConfig(app.Path, composeFiles, app.StackName()) if err != nil { return nil, err } opts := stack.Deploy{Composefiles: composeFiles, Namespace: app.StackName()} compose, err := appPkg.GetAppComposeConfig(app.Name, opts, app.Env) if err != nil { return nil, 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 { 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 { 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 } } } } f := filters.NewArgs() f.Add("name", s.Spec.Name) targetContainer, err := containerPkg.GetContainer(context.Background(), cl, f, true) if err != nil { 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 } } for secretName, secretID := range secretNames { if _, ok := resources.Secrets[secretName]; ok { continue } 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 { 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 } 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, i18n.G("dry-run"), i18n.G("r"), false, i18n.G("report changes that would be made"), ) }