refactor: app move
review pass
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
*.tar.gz
|
||||
*fmtcoverage.html
|
||||
.e2e.env
|
||||
.envrc
|
||||
|
201
cli/app/move.go
201
cli/app/move.go
@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@ -12,7 +13,9 @@ import (
|
||||
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"
|
||||
@ -26,16 +29,36 @@ import (
|
||||
"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{
|
||||
Use: "move <domain> <server> [flags]",
|
||||
Short: "Moves an app to a different server",
|
||||
Long: `Move an app to a differnt server.
|
||||
// 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 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.
|
||||
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.
|
||||
|
||||
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`,
|
||||
This command requires the "cat" command to be present on the app containers
|
||||
when retrieving secrets. Some "distroless" images will not support this. Not
|
||||
all apps are therefore moveable. Rsync 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,
|
||||
@ -53,41 +76,50 @@ Use "--dry-run/-r" to see which secrets and volumes will be moved.`,
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
app := internal.ValidateApp(args)
|
||||
|
||||
if len(args) <= 1 {
|
||||
log.Fatal("no server provided")
|
||||
log.Fatal(i18n.G("no server provided?"))
|
||||
}
|
||||
newServer := args[1]
|
||||
newServer := internal.ValidateServer([]string{args[1]})
|
||||
|
||||
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
cl, err := client.New(app.Server)
|
||||
currentServerClient, err := client.New(app.Server)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
resources, err := getAppResources(cl, app)
|
||||
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 {
|
||||
return
|
||||
log.Fatal(i18n.G("bailing out: %s", err))
|
||||
}
|
||||
|
||||
// NOTE: wait timeout will be removed, until it actually is just set it to a high value.
|
||||
stack.WaitTimeout = 500
|
||||
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(), cl, rmOpts); err != nil {
|
||||
log.Fatal(err)
|
||||
if err := stack.RunRemove(context.Background(), currentServerClient, rmOpts); err != nil {
|
||||
log.Fatal(i18n.G("failed to remove app from %s: %s", err, app.Server))
|
||||
}
|
||||
|
||||
cl2, err := client.New(newServer)
|
||||
newServerClient, err := client.New(newServer)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@ -96,88 +128,87 @@ Use "--dry-run/-r" to see which secrets and volumes will be moved.`,
|
||||
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)
|
||||
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.Infof("moving volume: %s", v.Name)
|
||||
log.Info(i18n.G("moving volume %s from %s to %s", v.Name, app.Server, newServer))
|
||||
|
||||
// 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{
|
||||
// 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.Errorf("failed to create volume: %s", err)
|
||||
log.Fatal(i18n.G("failed to create volume %s on %s: %s", v.Name, newServer, 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))
|
||||
outgoingFilename := fmt.Sprintf("%s_outgoing.tar.gz", v.Name)
|
||||
log.Debug(i18n.G("creating %s on %s", outgoingFilename, app.Server))
|
||||
tarCmd := fmt.Sprintf("sudo tar --same-owner -czhpf %s -C /var/lib/docker/volumes %s", outgoingFilename, v.Name)
|
||||
cmd := exec.Command("ssh", app.Server, "-tt", tarCmd)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Errorf("failed to tar volume: %s", err)
|
||||
fmt.Println(string(out))
|
||||
log.Fatal(i18n.G("%s failed on %s: output:%s err:%s", tarCmd, app.Server, string(out), err))
|
||||
}
|
||||
log.Debug("copying %s to local machine", fileName)
|
||||
cmd = exec.Command("scp", fmt.Sprintf("%s:%s", app.Server, fileName), fileName)
|
||||
|
||||
log.Debug(i18n.G("rsyncing %s from %s to local machine", outgoingFilename, app.Server))
|
||||
cmd = exec.Command("rsync", "-a", "-v", fmt.Sprintf("%s:%s", app.Server, outgoingFilename), outgoingFilename)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Errorf("failed to copy tar to local machine: %s", err)
|
||||
fmt.Println(string(out))
|
||||
log.Fatal(i18n.G("failed to copy %s from %s to local machine: output:%s err:%s", outgoingFilename, app.Server, string(out), err))
|
||||
}
|
||||
log.Debug("copying %s to %s", fileName, newServer)
|
||||
cmd = exec.Command("scp", fileName, fmt.Sprintf("%s:%s", newServer, fileName))
|
||||
|
||||
incomingFilename := fmt.Sprintf("%s_incoming.tar.gz", v.Name)
|
||||
log.Debug(i18n.G("rsyncing %s (renaming to %s) to %s from local machine", outgoingFilename, incomingFilename, newServer))
|
||||
cmd = exec.Command("rsync", "-a", "-v", outgoingFilename, fmt.Sprintf("%s:%s", newServer, incomingFilename))
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Errorf("failed to copy tar to new server: %s", err)
|
||||
fmt.Println(string(out))
|
||||
log.Fatal(i18n.G("failed to copy %s from local machine to %s: output:%s err:%s", outgoingFilename, newServer, string(out), err))
|
||||
}
|
||||
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))
|
||||
|
||||
log.Debug(i18n.G("extracting %s on %s", incomingFilename, newServer))
|
||||
tarExtractCmd := fmt.Sprintf("sudo tar --same-owner -xzpf %s -C /var/lib/docker/volumes", outgoingFilename)
|
||||
cmd = exec.Command("ssh", newServer, "-tt", tarExtractCmd)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Errorf("failed to extract tar: %s", err)
|
||||
fmt.Println(string(out))
|
||||
log.Fatal(i18n.G("%s failed to extract %s on %s: output:%s err:%s", tarExtractCmd, outgoingFilename, newServer, string(out), err))
|
||||
}
|
||||
|
||||
// Remove tar files
|
||||
cmd = exec.Command("ssh", newServer, "-tt", fmt.Sprintf("sudo rm %s", fileName))
|
||||
log.Debug(i18n.G("removing %s from %s", incomingFilename, newServer))
|
||||
cmd = exec.Command("ssh", newServer, "-tt", fmt.Sprintf("sudo rm %s", incomingFilename))
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Errorf("failed to remove tar from new server: %s", err)
|
||||
fmt.Println(string(out))
|
||||
log.Fatal(i18n.G("failed to remove %s from %s: output:%s err:%s", incomingFilename, newServer, string(out), err))
|
||||
}
|
||||
cmd = exec.Command("ssh", app.Server, "-tt", fmt.Sprintf("sudo rm %s", fileName))
|
||||
|
||||
log.Debug(i18n.G("removing %s from %s", outgoingFilename, app.Server))
|
||||
cmd = exec.Command("ssh", app.Server, "-tt", fmt.Sprintf("sudo rm %s", outgoingFilename))
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Errorf("failed to remove tar from old server: %s", err)
|
||||
fmt.Println(string(out))
|
||||
log.Fatal(i18n.G("failed to remove %s from %s: output:%s err:%s", outgoingFilename, app.Server, string(out), err))
|
||||
}
|
||||
cmd = exec.Command("rm", fileName)
|
||||
|
||||
log.Debug(i18n.G("removing %s from local machine", outgoingFilename))
|
||||
cmd = exec.Command("rm", outgoingFilename)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
log.Errorf("failed to remove tar on local machine: %s", err)
|
||||
fmt.Println(string(out))
|
||||
log.Fatal(i18n.G("failed to remove %s on local machine: output:%s err:%s", outgoingFilename, string(out), err))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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(err)
|
||||
log.Fatal(i18n.G("unable to remove %s: %s", app.Path, 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
|
||||
log.Info(i18n.G("%s was successfully moved from %s to %s 🎉", app.Name, app.Server, newServer))
|
||||
},
|
||||
}
|
||||
|
||||
@ -221,18 +252,18 @@ func getAppResources(cl *dockerclient.Client, app app.App) (*AppResources, error
|
||||
|
||||
secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: filter})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secretConfigs, err := secret.ReadSecretsConfig(app.Path, composeFiles, app.StackName())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts := stack.Deploy{Composefiles: composeFiles, Namespace: app.StackName()}
|
||||
compose, err := appPkg.GetAppComposeConfig(app.Name, opts, app.Env)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resources := &AppResources{
|
||||
@ -240,16 +271,20 @@ func getAppResources(cl *dockerclient.Client, app app.App) (*AppResources, error
|
||||
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 {
|
||||
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 {
|
||||
if s.Spec.Name == app.StackName()+"_"+secret.Source+"_"+secretConfigs[secret.Source].Version {
|
||||
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
|
||||
}
|
||||
@ -261,9 +296,9 @@ func getAppResources(cl *dockerclient.Client, app app.App) (*AppResources, error
|
||||
f.Add("name", s.Spec.Name)
|
||||
targetContainer, err := containerPkg.GetContainer(context.Background(), cl, f, true)
|
||||
if err != nil {
|
||||
log.Error(err)
|
||||
continue
|
||||
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
|
||||
@ -274,17 +309,19 @@ func getAppResources(cl *dockerclient.Client, app app.App) (*AppResources, error
|
||||
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()
|
||||
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 {
|
||||
fmt.Println(string(out))
|
||||
fmt.Println(err)
|
||||
continue
|
||||
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
|
||||
}
|
||||
|
||||
@ -294,20 +331,22 @@ func copyFile(src string, dst string) error {
|
||||
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",
|
||||
i18n.G("dry-run"),
|
||||
i18n.G("r"),
|
||||
false,
|
||||
"report changes that would be made",
|
||||
i18n.G("report changes that would be made"),
|
||||
)
|
||||
}
|
||||
|
@ -159,16 +159,26 @@ func MoveOverview(
|
||||
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")},
|
||||
secretsOverview := strings.Join(secrets, "\n")
|
||||
if len(secrets) == 0 {
|
||||
secretsOverview = config.NO_SECRETS_DEFAULT
|
||||
}
|
||||
|
||||
overview := formatter.CreateOverview("MOVE OVERVIEW", rows)
|
||||
volumesOverview := strings.Join(volumes, "\n")
|
||||
if len(volumes) == 0 {
|
||||
volumesOverview = config.NO_VOLUMES_DEFAULT
|
||||
}
|
||||
|
||||
rows := [][]string{
|
||||
{i18n.G("DOMAIN"), domain},
|
||||
{i18n.G("RECIPE"), app.Recipe.Name},
|
||||
{i18n.G("OLD SERVER"), server},
|
||||
{i18n.G("NEW SERVER"), newServer},
|
||||
{i18n.G("SECRETS"), secretsOverview},
|
||||
{i18n.G("VOLUMES"), volumesOverview},
|
||||
}
|
||||
|
||||
overview := formatter.CreateOverview(i18n.G("MOVE OVERVIEW"), rows)
|
||||
|
||||
fmt.Println(overview)
|
||||
}
|
||||
@ -179,17 +189,17 @@ func PromptProcced() error {
|
||||
}
|
||||
|
||||
if Dry {
|
||||
return fmt.Errorf("dry run")
|
||||
return errors.New(i18n.G("dry run"))
|
||||
}
|
||||
|
||||
response := false
|
||||
prompt := &survey.Confirm{Message: "proceed?"}
|
||||
prompt := &survey.Confirm{Message: i18n.G("proceed?")}
|
||||
if err := survey.AskOne(prompt, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !response {
|
||||
return errors.New("cancelled")
|
||||
return errors.New(i18n.G("cancelled"))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -118,6 +118,8 @@ var (
|
||||
|
||||
NO_DOMAIN_DEFAULT = "N/A"
|
||||
NO_VERSION_DEFAULT = "N/A"
|
||||
NO_SECRETS_DEFAULT = "N/A"
|
||||
NO_VOLUMES_DEFAULT = "N/A"
|
||||
|
||||
UNKNOWN_DEFAULT = "unknown"
|
||||
)
|
||||
|
@ -84,7 +84,7 @@ func RunRemove(ctx context.Context, client *apiclient.Client, opts Remove) error
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug("polling undeploy status")
|
||||
log.Info(i18n.G("polling undeploy status"))
|
||||
timeout, err := waitOnTasks(ctx, client, namespace)
|
||||
if timeout {
|
||||
errs = append(errs, err.Error())
|
||||
|
Reference in New Issue
Block a user