diff --git a/go.mod b/go.mod index 58770ef..62a042a 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/moby/buildkit v0.8.0 // indirect github.com/moby/term v0.0.0-20201110203204-bea5bbe245bf // indirect + github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5 github.com/openshift/api v3.9.0+incompatible github.com/openshift/client-go v3.9.0+incompatible // indirect github.com/openshift/library-go v0.0.0-20201211095848-8399bf6288d6 diff --git a/go.sum b/go.sum index abb6774..95e48c3 100644 --- a/go.sum +++ b/go.sum @@ -835,6 +835,7 @@ github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-runewidth v0.0.2 h1:UnlwIPBGaTZfPQ6T1IGzPI0EkYAQmT9fAEJ/poFC63o= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-shellwords v1.0.5/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= github.com/mattn/go-shellwords v1.0.10/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= @@ -915,6 +916,7 @@ github.com/ncw/swift v1.0.47/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5 h1:58+kh9C6jJVXYjt8IE48G2eWl6BjwU5Gj0gqY84fy78= github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -1451,7 +1453,6 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201013081832-0aaa2718063a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201110211018-35f3e6cf4a65/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201112073958-5cba982894dd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3 h1:kzM6+9dur93BcC2kVlYl34cHU+TYZLanmpSJHVMmL64= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220808155132-1c4a2a72c664 h1:v1W7bwXHsnLLloWYTVEdvGvA7BHMeBYsPcF0GLDxIRs= golang.org/x/sys v0.0.0-20220808155132-1c4a2a72c664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/pkg/api/api.go b/pkg/api/api.go index 1f188df..38b7d40 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "errors" + "fmt" "io/ioutil" "net/http" "net/url" @@ -66,7 +67,7 @@ func GetZones() (config.Region, error) { arvanConfig := config.GetConfigInfo() arvanURL, err := url.Parse(arvanConfig.GetServer()) if err != nil { - return regions, nil + return regions, fmt.Errorf("invalid config") } httpReq, err := http.NewRequest("GET", arvanURL.Scheme + "://" + arvanURL.Host+regionsEndpoint, nil) diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index 7df5c3b..e9eb655 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -2,20 +2,19 @@ package cli import ( "fmt" - "github.com/arvancloud/cli/pkg/api" - "github.com/arvancloud/cli/pkg/utl" - "github.com/inconshreveable/go-update" "net/http" "os" "path/filepath" "runtime" - "github.com/spf13/cobra" - - "github.com/openshift/oc/pkg/helpers/term" - + "github.com/arvancloud/cli/pkg/api" "github.com/arvancloud/cli/pkg/config" "github.com/arvancloud/cli/pkg/paas" + "github.com/arvancloud/cli/pkg/utl" + "github.com/inconshreveable/go-update" + + "github.com/openshift/oc/pkg/helpers/term" + "github.com/spf13/cobra" ) var ( @@ -60,7 +59,7 @@ func NewCommandCLI() *cobra.Command { cmd.AddCommand(loginCommand) paasCommand := paas.NewCmdPaas() - cmd.AddCommand(paasCommand) + cmd.AddCommand(paasCommand) cmd.AddCommand(updateCmd()) return cmd diff --git a/pkg/paas/login.go b/pkg/paas/login.go index ae90d0a..ece6b90 100644 --- a/pkg/paas/login.go +++ b/pkg/paas/login.go @@ -83,7 +83,7 @@ func NewCmdSwitchRegion(in io.Reader, out, errout io.Writer) *cobra.Command { region, err := getSelectedRegion(in, explainOut) utl.CheckErr(err) - + _, err = config.LoadConfigFile() if err != nil { log.Println(err) @@ -189,18 +189,17 @@ func (r regionValidator) validate(input string) (bool, error) { func sprintRegions(activeZones, inactiveRegions []config.Zone) string { result := "" - var activeZoneIndex int + var activeZoneIndex int for i := 0; i < len(activeZones); i++ { if activeZones[i].Default { activeZoneIndex++ - log.Println(activeZones[i].Release) if activeZones[i].Release == "STABLE" { result += fmt.Sprintf(" [%d] %s-%s\n", activeZoneIndex, activeZones[i].RegionName, activeZones[i].Name) } else { result += fmt.Sprintf(" [%d] %s-%s(%s)\n", activeZoneIndex, activeZones[i].RegionName, activeZones[i].Name, activeZones[i].Release) } - + } } for i := 0; i < len(activeZones); i++ { @@ -211,7 +210,8 @@ func sprintRegions(activeZones, inactiveRegions []config.Zone) string { } else { result += fmt.Sprintf(" [%d] %s-%s(%s)\n", activeZoneIndex, activeZones[i].RegionName, activeZones[i].Name, activeZones[i].Release) } - } } + } + } for i := 0; i < len(inactiveRegions); i++ { result += fmt.Sprintf(" [-] %s-%s (inactive)\n", inactiveRegions[i].RegionName, inactiveRegions[i].Name) } diff --git a/pkg/paas/migration.go b/pkg/paas/migration.go new file mode 100644 index 0000000..d8114c0 --- /dev/null +++ b/pkg/paas/migration.go @@ -0,0 +1,367 @@ +package paas + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + "net/url" + + "github.com/arvancloud/cli/pkg/api" + "github.com/arvancloud/cli/pkg/config" + "github.com/arvancloud/cli/pkg/utl" + "github.com/olekukonko/tablewriter" + "k8s.io/client-go/rest" + + "github.com/openshift/oc/pkg/helpers/term" + "github.com/spf13/cobra" +) + +const ( + migrationEndpoint = "/paas/v1/migrate" + redColor = "\033[31m" + greenColor = "\033[32m" + yellowColor = "\033[33m" + resetColor = "\033[0m" + bamdad = "ba1" +) + +type Request struct { + Namespace string `json:"namespace"` + Source string `json:"source"` + Destination string `json:"destination"` +} + +type Service struct { + Name string `json:"name"` + IP string `json:"ip"` +} + +type Route struct { + Name string `json:"name"` + Host string `json:"host"` + IsFree bool `json:"is_free"` +} + +type ZoneInfo struct { + Services []Service `json:"services"` + Routes []Route `json:"routes"` + Gateway string `json:"gateway"` +} + +type Response struct { + Source ZoneInfo `json:"source"` + Destination ZoneInfo `json:"destination"` +} + +// NewCmdMigrate returns new cobra commad enables user to migrate namespaces to another region on arvan servers. +func NewCmdMigrate(in io.Reader, out, errout io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "migrate", + Short: "Migrate namespaces to destination region", + Long: loginLong, + Run: func(c *cobra.Command, args []string) { + explainOut := term.NewResponsiveWriter(out) + c.SetOutput(explainOut) + + project, _ := getSelectedProject(in, explainOut) + + currentRegionName := getCurrentRegion() + + if currentRegionName == bamdad { + log.Printf("migration from region %s is not possible now\nplease first switch your region using command:\n\n \tarvan paas region\n\n", currentRegionName) + return + } + + destinationRegion, err := getZoneByName(bamdad) + utl.CheckErr(err) + + if currentRegionName == getRegionFromEndpoint(destinationRegion.Endpoint) { + log.Printf("can not migrate to this region") + return + } + + confirmed := migrationConfirm(project, getRegionFromEndpoint(destinationRegion.Endpoint), in, explainOut) + if !confirmed { + return + } + + requset := Request{ + Namespace: project, + Source: currentRegionName, + Destination: fmt.Sprintf("%s-%s", destinationRegion.RegionName, destinationRegion.Name), + } + + err = migrate(requset) + if err != nil { + log.Println(err) + } + }, + } + + return cmd +} + +// getSelectedProject gets intending namespace to migrate. +func getSelectedProject(in io.Reader, writer io.Writer) (string, error) { + projects, err := projectList() + + if err != nil { + return "", err + } + + if len(projects) < 1 { + return "", errors.New("no project to migrate") + } + + explain := "Select project:\n" + explain += sprintProjects(projects) + + _, err = fmt.Fprint(writer, explain) + if err != nil { + return "", err + } + inputExplain := "Project Number[1]: " + + defaultVal := "1" + + validator := projectValidator{len(projects)} + + projectIndex, err := strconv.Atoi(utl.ReadInput(inputExplain, defaultVal, writer, in, validator.validate)) + if err != nil { + return "", err + } + + return projects[projectIndex-1], nil +} + +type projectValidator struct { + upperBound int +} + +// validate makes sure the inserted namespace is correct. +func (p projectValidator) validate(input string) (bool, error) { + intInput, err := strconv.Atoi(input) + if err != nil || intInput < 1 || intInput > p.upperBound { + return false, fmt.Errorf("enter a number between '1' and '%d'", p.upperBound) + } + return true, nil +} + +// getCurrentRegion returns users current region, fetched from config, in string. +func getCurrentRegion() string { + _, err := config.LoadConfigFile() + utl.CheckErr(err) + + arvanConfig := config.GetConfigInfo() + + return getRegionFromEndpoint(arvanConfig.GetServer()) +} + +// getRegionFromEndpoint parses endpoint to return region name. +func getRegionFromEndpoint(endpoint string) string { + currentRegionNameIndex := strings.LastIndex(endpoint, "/") + + return endpoint[currentRegionNameIndex+1:] +} + +// sprintProjects displays projects to select in lines. +func sprintProjects(projects []string) string { + result := "" + var projectIndex int + + for i := 0; i < len(projects); i++ { + projectIndex++ + result += fmt.Sprintf(" [%d] %s\n", projectIndex, projects[i]) + } + + return result +} + +// migrationConfirm gets confirmation of proceeding namespace migration by asking user to enter namespace's name. +func migrationConfirm(project, region string, in io.Reader, writer io.Writer) bool { + explain := fmt.Sprintf("\nYou're about to migrate \"%s\" from region \"%s\" to \"%s\".\n", project, getCurrentRegion(), region) + + _, err := fmt.Fprint(writer, explain) + if err != nil { + return false + } + inputExplain := fmt.Sprintf(yellowColor+"\nWARNING: This will STOP your applications during migration process."+resetColor+"\n\nPlease enter project's name [%s] to proceed: ", project) + + defaultVal := "" + + v := confirmationValidator{project: project} + + value := utl.ReadInput(inputExplain, defaultVal, writer, in, v.confirmationValidate) + return value == project +} + +type confirmationValidator struct { + project string +} + +// confirmationValidate makes sure that user enters namespace correctly. +func (v confirmationValidator) confirmationValidate(input string) (bool, error) { + if input != v.project { + return false, fmt.Errorf("please enter project name correctly: \"%s\"", v.project) + } + return true, nil +} + +// migrate sends migration request and displays response. +func migrate(request Request) error { + response, err := httpPost(migrationEndpoint, request) + if err != nil { + failureOutput() + return err + } + successOutput(response) + + return nil +} + +// httpPost sends POST request to inserted url. +func httpPost(endpoint string, payload interface{}) (*Response, error) { + requestBody, err := json.Marshal(payload) + if err != nil { + return nil, err + } + arvanConfig := config.GetConfigInfo() + arvanURL, err := url.Parse(arvanConfig.GetServer()) + if err != nil { + return nil, fmt.Errorf("invalid config") + } + + httpReq, err := http.NewRequest(http.MethodPost, arvanURL.Scheme + "://" + arvanURL.Host+endpoint, bytes.NewBuffer(requestBody)) + if err != nil { + return nil, err + } + apikey := arvanConfig.GetApiKey() + if apikey != "" { + httpReq.Header.Add("Authorization", apikey) + } + + httpReq.Header.Add("accept", "application/json") + httpReq.Header.Add("User-Agent", rest.DefaultKubernetesUserAgent()) + httpResp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, err + } + + if httpResp.StatusCode != http.StatusOK { + return nil, errors.New("server error. try again later") + } + + // read body + defer httpResp.Body.Close() + responseBody, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, err + } + + // parse response + var response Response + err = json.Unmarshal(responseBody, &response) + if err != nil { + return nil, err + } + return &response, nil +} + +// failureOutput displays failure output. +func failureOutput() { + fmt.Println("failed to migrate") +} + +// successOutput displays success output. +func successOutput(response *Response) { + fmt.Println("\nYour IPs changed successfully") + + ipTable := tablewriter.NewWriter(os.Stdout) + ipTable.SetHeader([]string{"Old IPs", "New IPs"}) + + for i := 0; i < len(response.Source.Services); i++ { + ipTable.Append([]string{redColor + response.Source.Services[i].IP + resetColor, greenColor + response.Destination.Services[i].IP + resetColor}) + } + + ipTable.Render() + + freeSourceRoutes := make([]Route, 0) + freeDestinationRoutes := make([]Route, 0) + nonfreeSourceRoutes := make([]Route, 0) + nonfreeDestinationRoutes := make([]Route, 0) + + for i := 0; i < len(response.Source.Routes); i++ { + if response.Source.Routes[i].IsFree { + freeSourceRoutes = append(freeSourceRoutes, response.Source.Routes[i]) + freeDestinationRoutes = append(freeDestinationRoutes, response.Destination.Routes[i]) + } else { + nonfreeSourceRoutes = append(nonfreeSourceRoutes, response.Source.Routes[i]) + nonfreeDestinationRoutes = append(nonfreeDestinationRoutes, response.Destination.Routes[i]) + } + } + + if len(freeSourceRoutes) > 0 { + fmt.Println("Your free routes changed successfully:") + + freeRouteTable := tablewriter.NewWriter(os.Stdout) + freeRouteTable.SetHeader([]string{"old free routes", "new free routes"}) + + for i := 0; i < len(freeSourceRoutes); i++ { + freeRouteTable.Append([]string{redColor + freeSourceRoutes[i].Host + resetColor, greenColor + freeDestinationRoutes[i].Host + resetColor}) + } + + freeRouteTable.Render() + } + + if len(nonfreeSourceRoutes) > 0 { + nonFreeRouteTable := tablewriter.NewWriter(os.Stdout) + nonFreeRouteTable.SetHeader([]string{"non-free routes"}) + + for i := 0; i < len(nonfreeSourceRoutes); i++ { + nonFreeRouteTable.Append([]string{yellowColor + nonfreeDestinationRoutes[i].Host + resetColor}) + } + + nonFreeRouteTable.Render() + } + + gatewayTable := tablewriter.NewWriter(os.Stdout) + gatewayTable.SetHeader([]string{"old gateway", "new gateway"}) + + fmt.Println("For non-free domains above, please change your gateway in your DNS provider as bellow:") + gatewayTable.Append([]string{redColor + response.Source.Gateway + resetColor, greenColor + response.Destination.Gateway + resetColor}) + gatewayTable.Render() +} + +// getZoneByName gets zone from list of active zones giving it's name. +func getZoneByName(name string) (*config.Zone, error) { + regions, err := api.GetZones() + if err != nil { + return nil, err + } + if len(regions.Zones) < 1 { + return nil, errors.New("invalid region info") + } + + activeZones, _ := getActiveAndInactiveZones(regions.Zones) + + if len(activeZones) < 1 { + return nil, errors.New("no active region available") + } + + for i, zone := range activeZones { + if zone.Name == name { + return &activeZones[i], nil + } + } + + log.Printf("destination region not found") + + return nil, nil +} diff --git a/pkg/paas/paas.go b/pkg/paas/paas.go index 7100493..f217702 100644 --- a/pkg/paas/paas.go +++ b/pkg/paas/paas.go @@ -30,7 +30,6 @@ const ( projectListPath = "apis/project.openshift.io/v1/projects" ) - // NewCmdPaas return new cobra cli for paas func NewCmdPaas() *cobra.Command { @@ -40,6 +39,9 @@ func NewCmdPaas() *cobra.Command { paasCommand.AddCommand(NewCmdSwitchRegion(in, out, errout)) + migrateCommand := NewCmdMigrate(in, out, errout) + paasCommand.AddCommand(migrateCommand) + paasCommand.PersistentPreRun = func(cmd *cobra.Command, args []string) { err := prepareCommand(cmd) utl.CheckErr(err) @@ -263,7 +265,7 @@ func getArvanAuthorization() string { func getArvanPaasServerBase() string { arvanConfig := config.GetConfigInfo() arvanServer := arvanConfig.GetServer() - return arvanServer + paasUrlPostfix + return arvanServer + paasUrlPostfix } func syncKubeConfig(path, username string, projects []string) error {