forked from toolshed/abra
		
	
		
			
				
	
	
		
			351 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			351 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| 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 <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 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"),
 | |
| 	)
 | |
| }
 |