diff --git a/cli/server/add.go b/cli/server/add.go index 894afd0ae..13f3cc05a 100644 --- a/cli/server/add.go +++ b/cli/server/add.go @@ -3,13 +3,21 @@ package server import ( "context" "errors" + "fmt" + "net" + "os" "os/exec" "os/user" "strings" + "time" "coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/server" + "github.com/AlecAivazis/survey/v2" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/swarm" + dockerClient "github.com/docker/docker/client" "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" ) @@ -17,20 +25,237 @@ import ( var local bool var localFlag = &cli.BoolFlag{ Name: "local", - Aliases: []string{"L"}, + Aliases: []string{"l"}, Value: false, - Usage: "Set up the local server", + Usage: "Use local server", Destination: &local, } +var provision bool +var provisionFlag = &cli.BoolFlag{ + Name: "provision", + Aliases: []string{"p"}, + Value: false, + Usage: "Provision server so it can deploy apps", + Destination: &provision, +} + +var traefik bool +var traefikFlag = &cli.BoolFlag{ + Name: "traefi", + Aliases: []string{"t"}, + Value: false, + Usage: "Deploy traefik", + Destination: &traefik, +} + +func newLocalServer(c *cli.Context, domainName string) error { + if err := createServerDir(domainName); err != nil { + return err + } + + cl, err := newClient(c, domainName) + if err != nil { + return err + } + + if _, err := exec.LookPath("docker"); err != nil { + if provision { + if err := installDocker(c, cl, domainName); err != nil { + return err + } + if err := initSwarm(c, cl, domainName); err != nil { + logrus.Fatal(err) + } + } else { + logrus.Warn("no docker installation found, use '-p' to provision") + } + } + + if traefik { + if err := deployTraefik(c, cl, domainName); err != nil { + return err + } + } else { + logrus.Warn("no traefik app found, use '-t' to deploy") + } + + logrus.Info("local server has been added") + + return nil +} + +func newContext(c *cli.Context, domainName string) error { + username := c.Args().Get(1) + if username == "" { + systemUser, err := user.Current() + if err != nil { + return err + } + username = systemUser.Username + } + + port := c.Args().Get(2) + if port == "" { + port = "22" + } + + store := client.NewDefaultDockerContextStore() + contexts, err := store.Store.List() + if err != nil { + return err + } + + for _, context := range contexts { + if context.Name == domainName { + logrus.Debugf("context for %s already exists", domainName) + return nil + } + } + + logrus.Debugf("creating context with domain %s, username %s and port %s", domainName, username, port) + + if err := client.CreateContext(domainName, username, port); err != nil { + return err + } + + return nil +} + +func newClient(c *cli.Context, domainName string) (*dockerClient.Client, error) { + cl, err := client.New(domainName) + if err != nil { + logrus.Warn("cleaning up context due to connection failure") + if err := client.DeleteContext(domainName); err != nil { + return &dockerClient.Client{}, err + } + return &dockerClient.Client{}, err + } + return cl, nil +} + +func installDocker(c *cli.Context, cl *dockerClient.Client, domainName string) error { + if _, err := cl.Info(c.Context); err != nil { + if strings.Contains(err.Error(), "command not found") { + fmt.Println(fmt.Sprintf(` +A docker installation cannot be found on %s. This is a required system dependency +for running Co-op Cloud on your server. If you would like, Abra can attempt to install +Docker for you using the upstream non-interactive installation script. + +See the following documentation for more: + + https://docs.docker.com/engine/install/debian/#install-using-the-convenience-script + +N.B Docker doesn't recommend it for production environments but many use it for +such purposes. Docker stable is now installed by default by this script. The +source for this script can be seen here: + + https://github.com/docker/docker-install + +`, domainName)) + + response := false + prompt := &survey.Confirm{ + Message: fmt.Sprintf("attempt install docker on %s?", domainName), + } + if err := survey.AskOne(prompt, &response); err != nil { + return err + } + if !response { + logrus.Fatal("exiting as requested") + } + + // TODO: implement this remote installer run + // https://stackoverflow.com/questions/37679939/how-do-i-execute-a-command-on-a-remote-machine-in-a-golang-cli + logrus.Warn("NOT IMPLEMENTED - COMING SOON") + return nil + } + } + return nil +} + +func initSwarm(c *cli.Context, cl *dockerClient.Client, domainName string) error { + // comrade librehosters DNS resolver -> https://www.privacy-handbuch.de/handbuch_93d.htm + freifunkDNS := "5.1.66.255:53" + + resolver := &net.Resolver{ + PreferGo: false, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{ + Timeout: time.Millisecond * time.Duration(10000), + } + return d.DialContext(ctx, "udp", freifunkDNS) + }, + } + + logrus.Debugf("created DNS resolver via '%s'", freifunkDNS) + + ips, err := resolver.LookupIPAddr(c.Context, domainName) + if err != nil { + return err + } + + if len(ips) == 0 { + return fmt.Errorf("unable to retrieve ipv4 address for %s", domainName) + } + + ipv4 := ips[0].IP.To4().String() + logrus.Debugf("discovered the following ipv4 addr: %s", ipv4) + + initReq := swarm.InitRequest{ + ListenAddr: "0.0.0.0:2377", + AdvertiseAddr: ipv4, + } + if _, err := cl.SwarmInit(c.Context, initReq); err != nil { + if !strings.Contains(err.Error(), "is already part of a swarm") { + return err + } + } + + logrus.Infof("initialised swarm mode on %s", domainName) + + netOpts := types.NetworkCreate{Driver: "overlay", Scope: "swarm"} + if _, err := cl.NetworkCreate(c.Context, "proxy", netOpts); err != nil { + return err + } + + logrus.Infof("swarm overlay network created on %s", domainName) + + return nil +} + +func createServerDir(domainName string) error { + if err := server.CreateServerDir(domainName); err != nil { + if !os.IsExist(err) { + return err + } + logrus.Debugf("server dir for %s already created", domainName) + } + return nil +} + +func deployTraefik(c *cli.Context, cl *dockerClient.Client, domainName string) error { + // TODO: implement + logrus.Warn("NOT IMPLEMENTED - COMING SOON") + return nil +} + var serverAddCommand = &cli.Command{ Name: "add", Usage: "Add a new server", Description: ` -This command adds a new server that abra will communicate with, to deploy apps. +This command adds a new server to your configuration so that it can be managed +by Abra. + +This can be useful when you already have a server provisioned and want to start +running Abra commands against it. This command can also provision your server +("--provision/-p") so that it is capable of hosting Co-op Cloud apps. See below +for more on that. If "--local" is passed, then Abra assumes that the current local server is -intended as the target server. +intended as the target server. This is useful when you want to have your entire +Co-op Cloud config located on the server itself, and not on your local +developer machine. Otherwise, you may specify a remote server. The argument must be a publicy accessible domain name which points to your server. You should have SSH @@ -38,23 +263,32 @@ access to this server, Abra will assume port 22 and will use your current system username to make an initial connection. You can use the and arguments to adjust this. -For example: +Example: - abra server add varia.zone glodemodem 12345 + abra server add --provision --traefik varia.zone glodemodem 12345 Abra will construct the following SSH connection string then: ssh://globemodem@varia.zone:12345 All communication between Abra and the server will use this SSH connection. + +In this example, Abra will run the following operations: + + 1. Install Docker + 2. Initialise Swarm mode + 3. Deploy Traefik (core web proxy) + +You may omit flags to avoid performing this provisioning logic. `, Aliases: []string{"a"}, Flags: []cli.Flag{ localFlag, + provisionFlag, + traefikFlag, }, ArgsUsage: " [] []", Action: func(c *cli.Context) error { - if c.Args().Len() == 2 && !local { err := errors.New("missing arguments or '--local'") internal.ShowSubcommandHelpAndError(c, err) @@ -65,87 +299,43 @@ All communication between Abra and the server will use this SSH connection. internal.ShowSubcommandHelpAndError(c, err) } - domainName := "default" - if local { - if err := server.CreateServerDir(domainName); err != nil { + if err := newLocalServer(c, "default"); err != nil { logrus.Fatal(err) } - if _, err := exec.LookPath("docker"); err != nil { - return errors.New("docker command not found on $PATH, is it installed?") - } - logrus.Info("local server has been added") return nil } - domainName = internal.ValidateDomain(c) + domainName := internal.ValidateDomain(c) - var username string - var port string - - username = c.Args().Get(1) - if username == "" { - systemUser, err := user.Current() - if err != nil { - logrus.Fatal(err) - } - username = systemUser.Username + if err := createServerDir(domainName); err != nil { + logrus.Fatal(err) } - port = c.Args().Get(2) - if port == "" { - port = "22" + if err := newContext(c, domainName); err != nil { + logrus.Fatal(err) } - store := client.NewDefaultDockerContextStore() - contexts, err := store.Store.List() + cl, err := newClient(c, domainName) if err != nil { logrus.Fatal(err) } - for _, context := range contexts { - if context.Name == domainName { - logrus.Fatalf("server at '%s' already exists?", domainName) - } - } - - logrus.Debugf("creating context with domain '%s', username '%s' and port '%s'", domainName, username, port) - - if err := client.CreateContext(domainName, username, port); err != nil { - logrus.Fatal(err) - } - - ctx := context.Background() - cl, err := client.New(domainName) - if err != nil { - logrus.Warn("cleaning up context due to connection failure") - if err := client.DeleteContext(domainName); err != nil { + if provision { + if err := installDocker(c, cl, domainName); err != nil { logrus.Fatal(err) } - logrus.Fatal(err) - } - - if _, err := cl.Info(ctx); err != nil { - if strings.Contains(err.Error(), "command not found") { - logrus.Fatalf("docker is not installed on '%s'?", domainName) - } else { - logrus.Warn("cleaning up context due to connection failure") - if err := client.DeleteContext(domainName); err != nil { - logrus.Fatal(err) - } - logrus.Fatalf("unable to make a connection to '%s'?", domainName) + if err := initSwarm(c, cl, domainName); err != nil { + logrus.Fatal(err) } - logrus.Debug(err) } - logrus.Debugf("remote connection to '%s' is definitely up", domainName) - - if err := server.CreateServerDir(domainName); err != nil { - logrus.Fatal(err) + if traefik { + if err := deployTraefik(c, cl, domainName); err != nil { + logrus.Fatal(err) + } } - logrus.Infof("server at '%s' has been added", domainName) - return nil }, } diff --git a/cli/server/init.go b/cli/server/init.go deleted file mode 100644 index 528ced8ee..000000000 --- a/cli/server/init.go +++ /dev/null @@ -1,81 +0,0 @@ -package server - -import ( - "context" - "fmt" - "net" - "time" - - "coopcloud.tech/abra/cli/internal" - "coopcloud.tech/abra/pkg/client" - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/swarm" - "github.com/sirupsen/logrus" - "github.com/urfave/cli/v2" -) - -var serverInitCommand = &cli.Command{ - Name: "init", - Usage: "Initialise server for deploying apps", - Aliases: []string{"i"}, - HideHelp: true, - ArgsUsage: "", - Description: ` -Initialise swarm mode on the target . - -This initialisation explicitly chooses the "single host swarm" mode which uses -the default IPv4 address as the advertising address. This can be re-configured -later for more advanced use cases. -`, - Action: func(c *cli.Context) error { - domainName := internal.ValidateDomain(c) - - cl, err := client.New(domainName) - if err != nil { - return err - } - - // https://www.privacy-handbuch.de/handbuch_93d.htm - freifunkDNS := "5.1.66.255:53" - - resolver := &net.Resolver{ - PreferGo: false, - Dial: func(ctx context.Context, network, address string) (net.Conn, error) { - d := net.Dialer{ - Timeout: time.Millisecond * time.Duration(10000), - } - // comrade librehosters DNS resolver https://snopyta.org/service/dns/ - return d.DialContext(ctx, "udp", freifunkDNS) - }, - } - logrus.Debugf("created DNS resolver via '%s'", freifunkDNS) - - ctx := context.Background() - ips, err := resolver.LookupIPAddr(ctx, domainName) - if err != nil { - logrus.Fatal(err) - } - - if len(ips) == 0 { - return fmt.Errorf("unable to retrieve ipv4 address for %s", domainName) - } - ipv4 := ips[0].IP.To4().String() - - initReq := swarm.InitRequest{ - ListenAddr: "0.0.0.0:2377", - AdvertiseAddr: ipv4, - } - if _, err := cl.SwarmInit(ctx, initReq); err != nil { - return err - } - logrus.Debugf("initialised swarm on '%s'", domainName) - - netOpts := types.NetworkCreate{Driver: "overlay", Scope: "swarm"} - if _, err := cl.NetworkCreate(ctx, "proxy", netOpts); err != nil { - return err - } - logrus.Debug("swarm overlay network 'proxy' created") - - return nil - }, -} diff --git a/cli/server/server.go b/cli/server/server.go index b9f4538c9..c73af4a3e 100644 --- a/cli/server/server.go +++ b/cli/server/server.go @@ -10,15 +10,12 @@ var ServerCommand = &cli.Command{ Aliases: []string{"s"}, Usage: "Manage servers via 3rd party providers", Description: ` -Manage the lifecycle of a server. - -These commands support creating new servers using 3rd party integrations, -initialising existing servers to support Co-op Cloud deployments and managing -the connections to those servers. +These commands support creating and managing servers using 3rd party +integrations. Servers can be provisioned from scratch so that they are capable +of hosing Co-op Cloud apps. `, Subcommands: []*cli.Command{ serverNewCommand, - serverInitCommand, serverAddCommand, serverListCommand, serverRemoveCommand,