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 [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", ) }