feat(app): Adds abra app move command
This commit is contained in:
324
cli/app/move.go
Normal file
324
cli/app/move.go
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
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 <domain> <server> [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",
|
||||||
|
)
|
||||||
|
}
|
@ -224,7 +224,7 @@ environment. Typically, you can let Abra generate them for you on app creation
|
|||||||
}
|
}
|
||||||
|
|
||||||
secretName := fmt.Sprintf("%s_%s_%s", app.StackName(), name, version)
|
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)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -140,6 +140,53 @@ func getDeployType(currentVersion, newVersion string) string {
|
|||||||
return "DOWNGRADE"
|
return "DOWNGRADE"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MoveOverview shows a overview before moving an app to a different server
|
||||||
|
func MoveOverview(
|
||||||
|
app appPkg.App,
|
||||||
|
newServer string,
|
||||||
|
secrets []string,
|
||||||
|
volumes []string,
|
||||||
|
) error {
|
||||||
|
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)
|
||||||
|
|
||||||
|
if NoInput {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
response := false
|
||||||
|
prompt := &survey.Confirm{Message: "proceed?"}
|
||||||
|
if err := survey.AskOne(prompt, &response); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !response {
|
||||||
|
log.Fatal("move cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// PostCmds parses a string of commands and executes them inside of the respective services
|
// PostCmds parses a string of commands and executes them inside of the respective services
|
||||||
// the commands string must have the following format:
|
// the commands string must have the following format:
|
||||||
// "<service> <command> <arguments>|<service> <command> <arguments>|... "
|
// "<service> <command> <arguments>|<service> <command> <arguments>|... "
|
||||||
|
@ -204,6 +204,7 @@ func Run(version, commit string) {
|
|||||||
app.AppRestartCommand,
|
app.AppRestartCommand,
|
||||||
app.AppRestoreCommand,
|
app.AppRestoreCommand,
|
||||||
app.AppRollbackCommand,
|
app.AppRollbackCommand,
|
||||||
|
app.AppMoveCommand,
|
||||||
app.AppRunCommand,
|
app.AppRunCommand,
|
||||||
app.AppSecretCommand,
|
app.AppSecretCommand,
|
||||||
app.AppServicesCommand,
|
app.AppServicesCommand,
|
||||||
|
@ -7,7 +7,7 @@ import (
|
|||||||
"github.com/docker/docker/client"
|
"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}
|
ann := swarm.Annotations{Name: secretName}
|
||||||
spec := swarm.SecretSpec{Annotations: ann, Data: []byte(secretValue)}
|
spec := swarm.SecretSpec{Annotations: ann, Data: []byte(secretValue)}
|
||||||
|
|
||||||
|
@ -216,7 +216,7 @@ func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server
|
|||||||
return
|
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") {
|
if strings.Contains(err.Error(), "AlreadyExists") {
|
||||||
log.Warnf("%s already exists", secret.RemoteName)
|
log.Warnf("%s already exists", secret.RemoteName)
|
||||||
ch <- nil
|
ch <- nil
|
||||||
@ -236,7 +236,7 @@ func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server
|
|||||||
return
|
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") {
|
if strings.Contains(err.Error(), "AlreadyExists") {
|
||||||
log.Warnf("%s already exists", secret.RemoteName)
|
log.Warnf("%s already exists", secret.RemoteName)
|
||||||
ch <- nil
|
ch <- nil
|
||||||
|
@ -76,7 +76,7 @@ func RunRemove(ctx context.Context, client *apiclient.Client, opts Remove) error
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("polling undeploy status")
|
log.Debug("polling undeploy status")
|
||||||
timeout, err := waitOnTasks(ctx, client, namespace)
|
timeout, err := waitOnTasks(ctx, client, namespace)
|
||||||
if timeout {
|
if timeout {
|
||||||
errs = append(errs, err.Error())
|
errs = append(errs, err.Error())
|
||||||
|
Reference in New Issue
Block a user