All checks were successful
continuous-integration/drone/push Build is passing
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"),
|
|
)
|
|
}
|