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" "coopcloud.tech/abra/pkg/config" 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. The app MUST be deployed on the old server before doing the move. The app will be undeployed from the current server but not deployed on the new server. The "tar" command is required on both the old and new server as well as "sudo" permissions. The "rsync" command is required on your local machine for transferring volumes. Do not forget to update your DNS records. Don't panic, it might take a while for the dust to settle after you move an app. 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.1312.net 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 := internal.ValidateServer([]string{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) } deployMeta, err := stack.IsDeployed(context.Background(), currentServerClient, app.StackName()) if err != nil { log.Fatal(err) } if !deployMeta.IsDeployed { log.Fatal(i18n.G("%s must first be deployed on %s before moving", app.Name, app.Server)) } 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)) } 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)) } 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 %s from %s to %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 %s on %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 %s on %s: %s", v.Name, newServer, err)) } filename := fmt.Sprintf("%s_outgoing.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("rsyncing %s from %s to local machine", filename, app.Server)) cmd = exec.Command("rsync", "-a", "-v", 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("rsyncing %s to %s from local machine", filename, filename, newServer)) cmd = exec.Command("rsync", "-a", "-v", filename, fmt.Sprintf("%s:%s", newServer, filename)) if out, err := cmd.CombinedOutput(); err != nil { log.Fatal(i18n.G("failed to copy %s from local machine to %s: output:%s err:%s", filename, 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 log.Debug(i18n.G("removing %s from %s", filename, newServer)) cmd = exec.Command("ssh", newServer, "-tt", fmt.Sprintf("sudo rm -rf %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)) } log.Debug(i18n.G("removing %s from %s", filename, app.Server)) cmd = exec.Command("ssh", app.Server, "-tt", fmt.Sprintf("sudo rm -rf %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)) } log.Debug(i18n.G("removing %s from local machine", filename)) cmd = exec.Command("rm", "-r", "-f", 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)) } } newServerPath := fmt.Sprintf("%s/servers/%s/%s.env", config.ABRA_DIR, newServer, app.Name) log.Info(i18n.G("migrating app config from %s to %s", app.Server, newServerPath)) if err := copyFile(app.Path, newServerPath); 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 successfully 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"), ) }