diff --git a/cli/app/move.go b/cli/app/move.go new file mode 100644 index 00000000..4c7ccd9e --- /dev/null +++ b/cli/app/move.go @@ -0,0 +1,313 @@ +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]", + Short: "Moves an app to a different server", + Long: `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. + +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`, + 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 err := internal.PromptProcced(); err != nil { + 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", + ) +} diff --git a/cli/app/secret.go b/cli/app/secret.go index 45dbcbaa..b3f5c0f7 100644 --- a/cli/app/secret.go +++ b/cli/app/secret.go @@ -247,7 +247,7 @@ environment. Typically, you can let Abra generate them for you on app creation } secretName := fmt.Sprintf("%s_%s_%s", app.StackName(), name, version) - if err := client.StoreSecret(cl, secretName, data, app.Server); err != nil { + if err := client.StoreSecret(cl, secretName, data); err != nil { log.Fatal(err) } diff --git a/cli/internal/deploy.go b/cli/internal/deploy.go index 37406128..44349288 100644 --- a/cli/internal/deploy.go +++ b/cli/internal/deploy.go @@ -142,6 +142,59 @@ func getDeployType(currentVersion, newVersion string) string { return i18n.G("DOWNGRADE") } +// MoveOverview shows a overview before moving an app to a different server +func MoveOverview( + app appPkg.App, + newServer string, + secrets []string, + volumes []string, +) { + server := app.Server + if app.Server == "default" { + server = "local" + } + + domain := app.Domain + if domain == "" { + domain = config.NO_DOMAIN_DEFAULT + } + + rows := [][]string{ + {"DOMAIN", domain}, + {"RECIPE", app.Recipe.Name}, + {"OLD SERVER", server}, + {"New SERVER", newServer}, + {"SECRETS", strings.Join(secrets, "\n")}, + {"VOLUMES", strings.Join(volumes, "\n")}, + } + + overview := formatter.CreateOverview("MOVE OVERVIEW", rows) + + fmt.Println(overview) +} + +func PromptProcced() error { + if NoInput { + return nil + } + + if Dry { + return fmt.Errorf("dry run") + } + + response := false + prompt := &survey.Confirm{Message: "proceed?"} + if err := survey.AskOne(prompt, &response); err != nil { + return err + } + + if !response { + return errors.New("cancelled") + } + + return nil +} + // PostCmds parses a string of commands and executes them inside of the respective services // the commands string must have the following format: // " | |... " diff --git a/cli/run.go b/cli/run.go index aafc86e4..a2540f9e 100644 --- a/cli/run.go +++ b/cli/run.go @@ -258,6 +258,7 @@ Config: app.AppRestartCommand, app.AppRestoreCommand, app.AppRollbackCommand, + app.AppMoveCommand, app.AppRunCommand, app.AppSecretCommand, app.AppServicesCommand, diff --git a/pkg/client/secret.go b/pkg/client/secret.go index f8419425..1d095467 100644 --- a/pkg/client/secret.go +++ b/pkg/client/secret.go @@ -7,7 +7,7 @@ import ( "github.com/docker/docker/client" ) -func StoreSecret(cl *client.Client, secretName, secretValue, server string) error { +func StoreSecret(cl *client.Client, secretName, secretValue string) error { ann := swarm.Annotations{Name: secretName} spec := swarm.SecretSpec{Annotations: ann, Data: []byte(secretValue)} diff --git a/pkg/secret/secret.go b/pkg/secret/secret.go index 2f9c9529..ffec2915 100644 --- a/pkg/secret/secret.go +++ b/pkg/secret/secret.go @@ -218,7 +218,7 @@ func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server return } - if err := client.StoreSecret(cl, secret.RemoteName, password, server); err != nil { + if err := client.StoreSecret(cl, secret.RemoteName, password); err != nil { if strings.Contains(err.Error(), "AlreadyExists") { log.Warnf(i18n.G("%s already exists", secret.RemoteName)) ch <- nil @@ -238,7 +238,7 @@ func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server return } - if err := client.StoreSecret(cl, secret.RemoteName, passphrase, server); err != nil { + if err := client.StoreSecret(cl, secret.RemoteName, passphrase); err != nil { if strings.Contains(err.Error(), "AlreadyExists") { log.Warnf(i18n.G("%s already exists", secret.RemoteName)) ch <- nil diff --git a/pkg/upstream/stack/remove.go b/pkg/upstream/stack/remove.go index 35f0052d..e4011580 100644 --- a/pkg/upstream/stack/remove.go +++ b/pkg/upstream/stack/remove.go @@ -84,7 +84,7 @@ func RunRemove(ctx context.Context, client *apiclient.Client, opts Remove) error continue } - log.Info(i18n.G("polling undeploy status")) + log.Debug("polling undeploy status") timeout, err := waitOnTasks(ctx, client, namespace) if timeout { errs = append(errs, err.Error())