diff --git a/command/external_node-add.go b/command/external_node-add.go index 0ac52fb..e9a9d18 100644 --- a/command/external_node-add.go +++ b/command/external_node-add.go @@ -71,7 +71,8 @@ func (c *ExternalNodeAddCommand) Run(args []string) int { if !*nowait { c.runWithSpinner("wait for external node add", endpoint.String(), func(client *squarescale.Client) (string, error) { - externalNode, err := client.WaitExternalNode(UUID, externalNodeName, 5) + // can also be externalNode, err := client.WaitExternalNode(UUID, externalNodeName, 5, []string{"provisionned", "inconsistent"}) + externalNode, err := client.WaitExternalNode(UUID, externalNodeName, 5, []string{}) if err != nil { return "", err } else { diff --git a/command/external_node-list.go b/command/external_node-list.go index 4cd95be..77ddf2b 100644 --- a/command/external_node-list.go +++ b/command/external_node-list.go @@ -4,9 +4,12 @@ import ( "errors" "flag" "fmt" + "regexp" "strings" + "github.com/olekukonko/tablewriter" "github.com/squarescale/squarescale-cli/squarescale" + "github.com/squarescale/squarescale-cli/ui" ) // ExternalNodeListCommand is a cli.Command implementation for listing external nodes. @@ -51,16 +54,30 @@ func (e *ExternalNodeListCommand) Run(args []string) int { return "", err } - var msg string = "Name\t\t\t\tPublicIP\tStatus\n" + tableString := &strings.Builder{} + table := tablewriter.NewWriter(tableString) + // reset by ui/table.go FormatTable function: table.SetAutoFormatHeaders(false) + // seems like this should be taken into account earlier than in the ui/table.go FormatTable function to have effect on fields + table.SetAutoWrapText(false) + table.SetHeader([]string{"Name", "PublicIP", "Status"}) + for _, en := range externalNodes { - msg += fmt.Sprintf("%s\t%s\t%s\n", en.Name, en.PublicIP, en.Status) + table.Append([]string{ + en.Name, + en.PublicIP, + en.Status, + }) } if len(externalNodes) == 0 { - msg = "No external nodes" + return "No external nodes", nil } - return msg, nil + ui.FormatTable(table) + + table.Render() + // Remove trailing \n and HT + return string(regexp.MustCompile(`[\n\x09][\n\x09]*$`).ReplaceAll([]byte(tableString.String()), []byte(""))), nil }) } diff --git a/command/flags.go b/command/flags.go index 159b508..0d3eaad 100644 --- a/command/flags.go +++ b/command/flags.go @@ -202,6 +202,15 @@ func schedulingGroupNameArg(f *flag.FlagSet, arg int) (string, error) { } } +func schedulingGroupHostsArg(f *flag.FlagSet, arg int) (string, error) { + value := f.Arg(arg) + if value == "" { + return "", errors.New("Scheduling group hosts must be specified") + } else { + return value, nil + } +} + func externalNodeNameArg(f *flag.FlagSet, arg int) (string, error) { value := f.Arg(arg) if value == "" { @@ -487,3 +496,19 @@ func networkPolicyDumpFlag(f *flag.FlagSet) *bool { func networkPolicyVersionArg(f *flag.FlagSet) string { return f.Arg(0) } + +func projectDetailsNoSummaryFlag(f *flag.FlagSet) *bool { + return f.Bool("no-summary", false, "Enable/Disable project detailed informations summary section") +} + +func projectDetailsNoComputeResourcesFlag(f *flag.FlagSet) *bool { + return f.Bool("no-compute-resources", false, "Enable/Disable project detailed informations compute resources section") +} + +func projectDetailsNoSchedulingGroupsFlag(f *flag.FlagSet) *bool { + return f.Bool("no-scheduling-groups", false, "Enable/Disable project detailed informations scheduling groups section") +} + +func projectDetailsNoExternalNodesFlag(f *flag.FlagSet) *bool { + return f.Bool("no-external-nodes", false, "Enable/Disable project detailed informations external nodes section") +} diff --git a/command/project-details.go b/command/project-details.go new file mode 100644 index 0000000..7e736be --- /dev/null +++ b/command/project-details.go @@ -0,0 +1,288 @@ +package command + +import ( + "errors" + "flag" + "fmt" + "strings" + "strconv" + "time" + + "github.com/BenJetson/humantime" + "github.com/olekukonko/tablewriter" + "github.com/squarescale/squarescale-cli/squarescale" + "github.com/squarescale/squarescale-cli/ui" +) + +// ProjectDetailsCommand is a cli.Command implementation for listing project details. +type ProjectDetailsCommand struct { + Meta + flagSet *flag.FlagSet +} + +// Run is part of cli.Command implementation. +func (c *ProjectDetailsCommand) Run(args []string) int { + c.flagSet = newFlagSet(c, c.Ui) + endpoint := endpointFlag(c.flagSet) + projectUUID := projectUUIDFlag(c.flagSet) + projectName := projectNameFlag(c.flagSet) + noSummary := projectDetailsNoSummaryFlag(c.flagSet) + noComputeResources := projectDetailsNoComputeResourcesFlag(c.flagSet) + noSchedulingGroups := projectDetailsNoSchedulingGroupsFlag(c.flagSet) + noExternalNodes := projectDetailsNoExternalNodesFlag(c.flagSet) + + if err := c.flagSet.Parse(args); err != nil { + return 1 + } + + if *projectUUID == "" && *projectName == "" { + return c.errorWithUsage(errors.New("Project name or uuid is mandatory")) + } + + if c.flagSet.NArg() > 0 { + return c.errorWithUsage(fmt.Errorf("Unparsed arguments on the command line: %v", c.flagSet.Args())) + } + + return c.runWithSpinner("detail project", endpoint.String(), func(client *squarescale.Client) (string, error) { + var UUID string + var err error + if *projectUUID == "" { + UUID, err = client.ProjectByName(*projectName) + if err != nil { + return "", err + } + } else { + UUID = *projectUUID + } + projectDetails, err := client.GetProjectDetails(UUID) + if err != nil { + return "", err + } + if projectDetails == nil { + return "No details to show", nil + } + // TODO: add Main cluster + Extra nodes + Volumes sections + res := "" + if !*noSummary { + res += ProjectSummary(projectDetails) + "\n" + } + if !*noComputeResources { + res += ProjectComputeNodes(projectDetails) + "\n" + } + if !*noSchedulingGroups { + res += ProjectSchedulingGroups(projectDetails) + "\n" + } + if !*noExternalNodes { + res += ProjectExternalNodes(projectDetails) + "\n" + } + return res, nil + }) +} + +// Return project summary info like in front Overview page +func ProjectSummary(project *squarescale.ProjectWithAllDetails) string { + tableString := &strings.Builder{} + tableString.WriteString(fmt.Sprintf("========== Summary: %s [%s]\n", project.Project.Name, project.Project.Organization)) + table := tablewriter.NewWriter(tableString) + // reset by ui/table.go FormatTable function: table.SetAutoFormatHeaders(false) + // seems like this should be taken into account earlier than in the ui/table.go FormatTable function to have effect on fields + table.SetAutoWrapText(false) + + location, _ := time.LoadLocation(time.Now().Location().String()) + + table.Append([]string{ + fmt.Sprintf("Provider: %s/%s", project.Project.Infrastructure.CloudProvider, project.Project.Infrastructure.CloudProviderLabel), + fmt.Sprintf("Region: %s/%s", project.Project.Infrastructure.Region, project.Project.Infrastructure.RegionLabel), + fmt.Sprintf("Credentials: %s", project.Project.Infrastructure.CredentialName), + }) + table.Append([]string{ + fmt.Sprintf("Infrastructure type: %s", project.Project.Infrastructure.Type), + fmt.Sprintf("Size: %s", project.Project.Infrastructure.NodeSize), + fmt.Sprintf("Hybrid: %v", project.Project.HybridClusterEnabled), + }) + table.Append([]string{ + fmt.Sprintf("Created: %s (%s)", project.Project.CreatedAt.In(location).Format("2006-01-02 15:04"), humantime.Since(project.Project.CreatedAt)), + fmt.Sprintf("External ElasticSearch: %s", project.Project.ExternalElasticSearch), + "", + }) + table.Append([]string{ + fmt.Sprintf("Updated: %s (%s)", project.Project.UpdatedAt.In(location).Format("2006-01-02 15:04"), humantime.Since(project.Project.UpdatedAt)), + fmt.Sprintf("Slack WebHook: %s", project.Project.SlackWebHook), + "", + }) + + ui.FormatTable(table) + + table.Render() + return tableString.String() +} + +func findExternalNodeRef(name string, project *squarescale.ProjectWithAllDetails) *squarescale.ExternalNode { + for _, n := range project.Project.Infrastructure.Cluster.ExternalNodes { + if n.Name == name { + return &n + } + } + return nil +} + +// Return project compute nodes like in front Compute Resources page +// See font/src/components/infrastructure/ComputeResourcesTab.jsx +func ProjectComputeNodes(project *squarescale.ProjectWithAllDetails) string { + tableString := &strings.Builder{} + tableString.WriteString(fmt.Sprintf("========== Compute resources: %s [%s]\n", project.Project.Name, project.Project.Organization)) + table := tablewriter.NewWriter(tableString) + // reset by ui/table.go FormatTable function: table.SetAutoFormatHeaders(false) + // seems like this should be taken into account earlier than in the ui/table.go FormatTable function to have effect on fields + table.SetAutoWrapText(false) + // TODO: add monitoring URLs + table.SetHeader([]string{"Hostname", "Arch", "(v)CPUs", "Freq (Ghz)", "Mem (Gb)", "Type", "Disk (Gb)", "Free Disk (Gb)", "IP Address", "OS", "Status", "Mode", "Availability Zone", "Scheduling group", "Nomad", "Consul"}) + + for _, c := range project.Project.Infrastructure.Cluster.ClusterMembersDetails { + freq, _ := strconv.ParseFloat(c.CPUFrequency, 32) + mem, _ := strconv.ParseFloat(c.Memory, 32) + stt, _ := strconv.ParseFloat(c.StorageBytesTotal, 32) + stf, _ := strconv.ParseFloat(c.StorageBytesFree, 32) + extRef := findExternalNodeRef(c.Name, project) + mode := "Cluster" + zone := "N/A" + fullStorage := "" + freeStorage := fmt.Sprintf("%.0f (%.2f%%%%)", stf / 1024.0 / 1024.0 / 1024.0, (100.0 * stf) / stt) + schedulingGroup := "None" + if c.SchedulingGroup.Name != "" { + schedulingGroup = c.SchedulingGroup.Name + } + if extRef != nil { + mode = fmt.Sprintf("External: %s", c.Name) + fullStorage = fmt.Sprintf("%.0f", stt / 1024.0 / 1024.0 / 1024.0) + } else { + fullStorage = fmt.Sprintf("%d", project.Project.Infrastructure.Cluster.RootDiskSize) + zone = c.Zone + } + table.Append([]string{ + c.Hostname, + c.CPUArch, + c.CPUCores, + fmt.Sprintf("%.1f", freq / 1000.0 ), + fmt.Sprintf("%.2f", mem / 1024.0 / 1024.0 / 1024.0), + c.InstanceType, + fullStorage, + freeStorage, + c.PrivateIP, + fmt.Sprintf("%s %s", c.OSName, c.OSVersion), + c.NomadStatus, + mode, + zone, + schedulingGroup, + c.NomadVersion, + c.ConsulVersion, + }) + } + + ui.FormatTable(table) + + table.Render() + return tableString.String() +} + +// Return project scheduling groups like in front Compute Resources page +// See font/src/components/infrastructure/ComputeResourcesTab.jsx +func ProjectSchedulingGroups(project *squarescale.ProjectWithAllDetails) string { + tableString := &strings.Builder{} + tableString.WriteString(fmt.Sprintf("========== Scheduling groups: %s [%s]\n", project.Project.Name, project.Project.Organization)) + table := tablewriter.NewWriter(tableString) + // reset by ui/table.go FormatTable function: table.SetAutoFormatHeaders(false) + // seems like this should be taken into account earlier than in the ui/table.go FormatTable function to have effect on fields + table.SetAutoWrapText(false) + // TODO: add monitoring URLs + table.SetHeader([]string{"Name", "# Nodes", "Nodes", "# Services", "Services"}) + + for _, s := range project.Project.Infrastructure.Cluster.SchedulingGroups { + nodes := "" + for _, n := range s.ClusterMembers { + nodes += "," + n.Name + } + if len(nodes) > 0 { + nodes = nodes[1:] + } + services := "" + for _, n := range s.Services { + services += "," + n.Name + } + if len(services) > 0 { + services = services[1:] + } + table.Append([]string{ + s.Name, + fmt.Sprintf("%d", len(s.ClusterMembers)), + nodes, + fmt.Sprintf("%d", len(s.Services)), + services, + }) + } + + ui.FormatTable(table) + + table.Render() + return tableString.String() +} + +func findComputeNodeRef(name string, project *squarescale.ProjectWithAllDetails) *squarescale.ClusterMemberDetails { + for _, n := range project.Project.Infrastructure.Cluster.ClusterMembersDetails { + if n.Name == name { + return &n + } + } + return nil +} + +// Return project external nodes like in front Compute Resources page +// See font/src/components/infrastructure/externalNodes/ExternalNodes.jsx +func ProjectExternalNodes(project *squarescale.ProjectWithAllDetails) string { + tableString := &strings.Builder{} + tableString.WriteString(fmt.Sprintf("========== External nodes: %s [%s]\n", project.Project.Name, project.Project.Organization)) + table := tablewriter.NewWriter(tableString) + // reset by ui/table.go FormatTable function: table.SetAutoFormatHeaders(false) + // seems like this should be taken into account earlier than in the ui/table.go FormatTable function to have effect on fields + table.SetAutoWrapText(false) + // TODO: add monitoring URLs + table.SetHeader([]string{"Hostname", "Public IP", "Status", "Private Network"}) + + for _, s := range project.Project.Infrastructure.Cluster.ExternalNodes { + status := s.Status + computeRef := findComputeNodeRef(s.Name, project) + if computeRef != nil { + if computeRef.NomadStatus == "ready" { + status = "Connected" + } else { + status = "Not connected" + } + } + table.Append([]string{ + s.Name, + s.PublicIP, + status, + s.PrivateNetwork, + }) + } + + ui.FormatTable(table) + + table.Render() + return tableString.String() +} + +// Synopsis is part of cli.Command implementation. +func (c *ProjectDetailsCommand) Synopsis() string { + return "Get project detailed informations" +} + +// Help is part of cli.Command implementation. +func (c *ProjectDetailsCommand) Help() string { + helpText := ` +usage: sqsc project details [options] + + Get project detailed informations. +` + return strings.TrimSpace(helpText + optionsFromFlags(c.flagSet)) +} diff --git a/command/project-get.go b/command/project-get.go index 1a37547..41a461a 100644 --- a/command/project-get.go +++ b/command/project-get.go @@ -11,7 +11,7 @@ import ( "github.com/squarescale/squarescale-cli/squarescale" ) -// ProjectGetCommand is a cli.Command implementation for listing all projects. +// ProjectGetCommand is a cli.Command implementation for listing project basic infos. type ProjectGetCommand struct { Meta flagSet *flag.FlagSet @@ -36,7 +36,7 @@ func (c *ProjectGetCommand) Run(args []string) int { return c.errorWithUsage(fmt.Errorf("Unparsed arguments on the command line: %v", c.flagSet.Args())) } - return c.runWithSpinner("get projects", endpoint.String(), func(client *squarescale.Client) (string, error) { + return c.runWithSpinner("get project", endpoint.String(), func(client *squarescale.Client) (string, error) { var UUID string var err error if *projectUUID == "" { @@ -105,7 +105,7 @@ func (c *ProjectGetCommand) Run(args []string) int { // Synopsis is part of cli.Command implementation. func (c *ProjectGetCommand) Synopsis() string { - return "Get project informations" + return "Get project basic informations" } // Help is part of cli.Command implementation. @@ -113,7 +113,7 @@ func (c *ProjectGetCommand) Help() string { helpText := ` usage: sqsc project get [options] - Get projects attached to the authenticated account. + Get project basic informations. ` return strings.TrimSpace(helpText + optionsFromFlags(c.flagSet)) } diff --git a/command/project-list.go b/command/project-list.go index da0cda9..9de717f 100644 --- a/command/project-list.go +++ b/command/project-list.go @@ -87,11 +87,10 @@ func fmtProjectListOutput(projects []squarescale.Project, organizations []square // seems like this should be taken into account earlier than in the ui/table.go FormatTable function to have effect on fields table.SetAutoWrapText(false) table.SetHeader([]string{"Name", "UUID", "Monitoring", "Provider", "Credentials", "Region", "Organization", "Status", "Cluster", "Extra", "Hybrid", "Size", "Created", "Updated", "External ElasticSearch", "Slack Webhook"}) - data := make([][]string, len(projects), len(projects)) location, _ := time.LoadLocation(time.Now().Location().String()) - for i, project := range projects { + for _, project := range projects { monitoring := "" if project.MonitoringEnabled && len(project.MonitoringEngine) > 0 { monitoring = project.MonitoringEngine @@ -100,7 +99,7 @@ func fmtProjectListOutput(projects []squarescale.Project, organizations []square if project.HybridClusterEnabled { isHybrid = "true" } - data[i] = []string{ + table.Append([]string{ project.Name, project.UUID, monitoring, @@ -117,11 +116,9 @@ func fmtProjectListOutput(projects []squarescale.Project, organizations []square fmt.Sprintf("%s (%s)", project.UpdatedAt.In(location).Format("2006-01-02 15:04"), humantime.Since(project.UpdatedAt)), project.ExternalES, project.SlackWebHook, - } + }) } - table.AppendBulk(data) - for _, o := range organizations { for _, project := range o.Projects { monitoring := "" diff --git a/command/scheduling_group-assign.go b/command/scheduling_group-assign.go new file mode 100644 index 0000000..5c72514 --- /dev/null +++ b/command/scheduling_group-assign.go @@ -0,0 +1,114 @@ +package command + +import ( + "errors" + "flag" + "fmt" + "strings" + + "github.com/squarescale/squarescale-cli/squarescale" +) + +// SchedulingGroupAssignCommand is a cli.Command implementation for assigning hosts to scheduling group. +type SchedulingGroupAssignCommand struct { + Meta + flagSet *flag.FlagSet +} + +func (b *SchedulingGroupAssignCommand) Run(args []string) int { + b.flagSet = newFlagSet(b, b.Ui) + endpoint := endpointFlag(b.flagSet) + projectUUID := projectUUIDFlag(b.flagSet) + projectName := projectNameFlag(b.flagSet) + + if err := b.flagSet.Parse(args); err != nil { + return 1 + } + + if *projectUUID == "" && *projectName == "" { + return b.errorWithUsage(errors.New("Project name or uuid is mandatory")) + } + + schedulingGroupName, err := schedulingGroupNameArg(b.flagSet, 0) + if err != nil { + return b.errorWithUsage(err) + } + + schedulingGroupHosts, err := schedulingGroupHostsArg(b.flagSet, 1) + if err != nil { + return b.errorWithUsage(err) + } + + if b.flagSet.NArg() > 2 { + return b.errorWithUsage(fmt.Errorf("Unparsed arguments on the command line: %v", b.flagSet.Args())) + } + + return b.runWithSpinner("assign host(s) to scheduling group", endpoint.String(), func(client *squarescale.Client) (string, error) { + var UUID string + var err error + if *projectUUID == "" { + UUID, err = client.ProjectByName(*projectName) + if err != nil { + return "", err + } + } else { + UUID = *projectUUID + } + _, err = client.GetSchedulingGroupInfo(UUID, schedulingGroupName) + if err != nil { + return "", err + } + + projectDetails, err := client.GetProjectDetails(UUID) + if err != nil { + return "", err + } + if projectDetails == nil { + return "", fmt.Errorf("No project details found") + } + var ids []int + hosts := strings.Split(schedulingGroupHosts, ",") + for _, h := range hosts { + found := false + for _, c := range projectDetails.Project.Infrastructure.Cluster.ClusterMembersDetails { + if c.Name == h { + ids = append(ids, c.ID) + found = true + break + } + } + if !found { + for _, c := range projectDetails.Project.Infrastructure.Cluster.ExternalNodes { + if c.Name == h { + ids = append(ids, c.ID) + found = true + break + } + } + if !found { + return "", fmt.Errorf("Can not find host %s", h) + } + } + } + err = client.PutSchedulingGroupsNodes(UUID, schedulingGroupName, "cluster_members_to_add", ids) + if err != nil { + return "", err + } + return "Success", nil + }) +} + +// Synopsis is part of cli.Command implementation. +func (b *SchedulingGroupAssignCommand) Synopsis() string { + return "Assign host(s) to scheduling group of project" +} + +// Help is part of cli.Command implementation. +func (b *SchedulingGroupAssignCommand) Help() string { + helpText := ` +usage: sqsc scheduling-group assign [options] + + Assign host(s) to scheduling group of project. +` + return strings.TrimSpace(helpText + optionsFromFlags(b.flagSet)) +} diff --git a/command/scheduling_group-unassign.go b/command/scheduling_group-unassign.go new file mode 100644 index 0000000..baf45b4 --- /dev/null +++ b/command/scheduling_group-unassign.go @@ -0,0 +1,114 @@ +package command + +import ( + "errors" + "flag" + "fmt" + "strings" + + "github.com/squarescale/squarescale-cli/squarescale" +) + +// SchedulingGroupUnAssignCommand is a cli.Command implementation for assigning hosts to scheduling group. +type SchedulingGroupUnAssignCommand struct { + Meta + flagSet *flag.FlagSet +} + +func (b *SchedulingGroupUnAssignCommand) Run(args []string) int { + b.flagSet = newFlagSet(b, b.Ui) + endpoint := endpointFlag(b.flagSet) + projectUUID := projectUUIDFlag(b.flagSet) + projectName := projectNameFlag(b.flagSet) + + if err := b.flagSet.Parse(args); err != nil { + return 1 + } + + if *projectUUID == "" && *projectName == "" { + return b.errorWithUsage(errors.New("Project name or uuid is mandatory")) + } + + schedulingGroupName, err := schedulingGroupNameArg(b.flagSet, 0) + if err != nil { + return b.errorWithUsage(err) + } + + schedulingGroupHosts, err := schedulingGroupHostsArg(b.flagSet, 1) + if err != nil { + return b.errorWithUsage(err) + } + + if b.flagSet.NArg() > 2 { + return b.errorWithUsage(fmt.Errorf("Unparsed arguments on the command line: %v", b.flagSet.Args())) + } + + return b.runWithSpinner("unassign host(s) from scheduling group", endpoint.String(), func(client *squarescale.Client) (string, error) { + var UUID string + var err error + if *projectUUID == "" { + UUID, err = client.ProjectByName(*projectName) + if err != nil { + return "", err + } + } else { + UUID = *projectUUID + } + _, err = client.GetSchedulingGroupInfo(UUID, schedulingGroupName) + if err != nil { + return "", err + } + + projectDetails, err := client.GetProjectDetails(UUID) + if err != nil { + return "", err + } + if projectDetails == nil { + return "", fmt.Errorf("No project details found") + } + var ids []int + hosts := strings.Split(schedulingGroupHosts, ",") + for _, h := range hosts { + found := false + for _, c := range projectDetails.Project.Infrastructure.Cluster.ClusterMembersDetails { + if c.Name == h { + ids = append(ids, c.ID) + found = true + break + } + } + if !found { + for _, c := range projectDetails.Project.Infrastructure.Cluster.ExternalNodes { + if c.Name == h { + ids = append(ids, c.ID) + found = true + break + } + } + if !found { + return "", fmt.Errorf("Can not find host %s", h) + } + } + } + err = client.PutSchedulingGroupsNodes(UUID, schedulingGroupName, "cluster_member_to_remove", ids) + if err != nil { + return "", err + } + return "Success", nil + }) +} + +// Synopsis is part of cli.Command implementation. +func (b *SchedulingGroupUnAssignCommand) Synopsis() string { + return "Unassign host(s) from scheduling group of project" +} + +// Help is part of cli.Command implementation. +func (b *SchedulingGroupUnAssignCommand) Help() string { + helpText := ` +usage: sqsc scheduling-group unassign [options] + + Unassign host(s) from scheduling group of project. +` + return strings.TrimSpace(helpText + optionsFromFlags(b.flagSet)) +} diff --git a/commands.go b/commands.go index 0c80b9d..50e303e 100644 --- a/commands.go +++ b/commands.go @@ -123,6 +123,16 @@ func Commands(meta *command.Meta) map[string]cli.CommandFactory { Meta: *meta, }, nil }, + "scheduling-group assign": func() (cli.Command, error) { + return &command.SchedulingGroupAssignCommand{ + Meta: *meta, + }, nil + }, + "scheduling-group unassign": func() (cli.Command, error) { + return &command.SchedulingGroupUnAssignCommand{ + Meta: *meta, + }, nil + }, "external-node": func() (cli.Command, error) { return &command.ExternalNodeCommand{}, nil }, @@ -216,6 +226,11 @@ func Commands(meta *command.Meta) map[string]cli.CommandFactory { Meta: *meta, }, nil }, + "project details": func() (cli.Command, error) { + return &command.ProjectDetailsCommand{ + Meta: *meta, + }, nil + }, "project remove": func() (cli.Command, error) { return &command.ProjectRemoveCommand{ Meta: *meta, diff --git a/go.mod b/go.mod index 5467858..ec7e7c3 100644 --- a/go.mod +++ b/go.mod @@ -52,10 +52,10 @@ require ( github.com/shopspring/decimal v1.3.1 // indirect github.com/spf13/cast v1.5.1 // indirect github.com/stretchr/testify v1.8.4 // indirect - golang.org/x/crypto v0.13.0 // indirect - golang.org/x/net v0.15.0 //indirect - golang.org/x/sys v0.12.0 // indirect - golang.org/x/term v0.12.0 // indirect + golang.org/x/crypto v0.14.0 // indirect + golang.org/x/net v0.16.0 //indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/term v0.13.0 // indirect golang.org/x/text v0.13.0 // indirect golang.org/x/tools v0.13.0 // indirect google.golang.org/protobuf v1.31.0 // indirect diff --git a/go.sum b/go.sum index 35624cb..e2ec7f6 100644 --- a/go.sum +++ b/go.sum @@ -165,6 +165,8 @@ golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -180,6 +182,8 @@ golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.16.0 h1:7eBu7KsSvFDtSXUIDbh3aqlK4DPsZ1rByC8PFfBThos= +golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -199,6 +203,8 @@ golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= @@ -208,6 +214,8 @@ golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= diff --git a/squarescale/cluster-members.go b/squarescale/cluster-members.go index 9b1c089..79837e8 100644 --- a/squarescale/cluster-members.go +++ b/squarescale/cluster-members.go @@ -9,9 +9,10 @@ import ( type ClusterMember struct { ID int `json:"id"` Name string `json:"name"` - PublicIP string `json:"public_ip"` PrivateIP string `json:"private_ip"` + PublicIP string `json:"public_ip"` Status string `json:"nomad_status"` + Zone string `json:"zone"` SchedulingGroup SchedulingGroup `json:"scheduling_group"` } diff --git a/squarescale/db.go b/squarescale/db.go index 79c450a..b27c467 100644 --- a/squarescale/db.go +++ b/squarescale/db.go @@ -27,6 +27,16 @@ type DbConfig struct { BackupRetention int `json:"backup_retention_days"` } +type Database struct { + BackupEnabled bool `json:"backup_enabled"` + BackupRetention int `json:"backup_retention_days"` + Enabled bool `json:"enabled"` + Engine string `json:"engine"` + Size string `json:"size"` + Status string `json:"status"` + Version string `json:"version"` +} + func (db *DbConfig) String() string { if db.Enabled { return db.Size + " " + db.Engine diff --git a/squarescale/external_nodes.go b/squarescale/external_nodes.go index e9c7d5c..c1ca81e 100644 --- a/squarescale/external_nodes.go +++ b/squarescale/external_nodes.go @@ -10,10 +10,11 @@ import ( ) type ExternalNode struct { - ID int `json:"id"` - Name string `json:"name"` - PublicIP string `json:"public_ip"` - Status string `json:"status"` + ID int `json:"id"` + Name string `json:"name"` + PublicIP string `json:"public_ip"` + Status string `json:"status"` + PrivateNetwork string `json:"private_network"` } // GetExternalNodes gets all the external nodes attached to a Project @@ -84,17 +85,30 @@ func (c *Client) AddExternalNode(projectUUID string, name string, public_ip stri return newExternalNode, nil } -// WaitExternalNode wait a new external-node -func (c *Client) WaitExternalNode(projectUUID string, name string, timeToWait int64) (ExternalNode, error) { +// TODO: potentially add a real timeout on this wait condition +// WaitExternalNode wait a new external-node to reach one status +func (c *Client) WaitExternalNode(projectUUID string, name string, timeToWait int64, targetStatuses []string) (ExternalNode, error) { externalNode, err := c.GetExternalNodeInfo(projectUUID, name) if err != nil { return externalNode, err } - for externalNode.Status != "provisionned" && err == nil { - time.Sleep(time.Duration(timeToWait) * time.Second) - externalNode, err = c.GetExternalNodeInfo(projectUUID, name) - logger.Debug.Println("externalNode status update: ", externalNode.Name) + if len(targetStatuses) > 0 { + found := false + logger.Debug.Println("externalNode status: ", externalNode.Name, " Status: ", externalNode.Status) + for err == nil && !found { + for _, v := range targetStatuses { + if externalNode.Status == v { + found = true + break + } + } + if !found { + time.Sleep(time.Duration(timeToWait) * time.Second) + externalNode, err = c.GetExternalNodeInfo(projectUUID, name) + logger.Debug.Println("externalNode status update: ", externalNode.Name, " Status: ", externalNode.Status) + } + } } return externalNode, err diff --git a/squarescale/external_nodes_test.go b/squarescale/external_nodes_test.go index 90533bb..6bff929 100644 --- a/squarescale/external_nodes_test.go +++ b/squarescale/external_nodes_test.go @@ -393,7 +393,7 @@ func nominalCaseOnWaitExternalNode(t *testing.T) { cli := squarescale.NewClient(server.URL, token) // when - _, err := cli.WaitExternalNode(projectName, nodeName, 0) + _, err := cli.WaitExternalNode(projectName, nodeName, 0, []string{}) // then if err != nil { diff --git a/squarescale/lb.go b/squarescale/lb.go index 2e8d609..9098939 100644 --- a/squarescale/lb.go +++ b/squarescale/lb.go @@ -13,6 +13,7 @@ type LoadBalancer struct { CertificateBody string `json:"certificate_body"` CertificateChain string `json:"certificate_chain"` HTTPS bool `json:"https"` + PublicDomain string `json:"public_domain"` PublicURL string `json:"public_url"` } diff --git a/squarescale/project.go b/squarescale/project.go index 010092d..1249c10 100644 --- a/squarescale/project.go +++ b/squarescale/project.go @@ -42,9 +42,175 @@ type Project struct { TfCommand string `json:"tf_command"` } -/* - json.organization p.organization&.name -*/ +// Need special decoding +// cf https://stackoverflow.com/questions/37782278/fully-parsing-timestamps-in-golang +// or more complete example +// https://dev.to/arshamalh/how-to-unmarshal-json-in-a-custom-way-in-golang-42m5 +type Timestamp struct { + time.Time +} + +// UnmarshalJSON decodes an int64 timestamp into a time.Time object +func (p *Timestamp) UnmarshalJSON(bytes []byte) error { + // 1. Decode the bytes into an int64 + var raw int64 + err := json.Unmarshal(bytes, &raw) + + if err != nil { + fmt.Printf("error decoding timestamp: %s\n", err) + return err + } + + // 2. Parse the unix timestamp + p.Time = time.Unix(raw, 0) + return nil +} + +type Notification struct { +// Component string `json:"component_name"` + Level string `json:"level"` + NotificationType string `json:"type"` + Message string `json:"message"` + NotifiedAt Timestamp `json:"notified_at"` + ProjectUUID string `json:"project_uuid"` +} + +type ClusterMemberDetails struct { + ConsulName string `json:"consul_name"` + ConsulVersion string `json:"consul_version"` + CPUArch string `json:"cpu_arch"` + CPUCores string `json:"cpu_cores"` + CPUFrequency string `json:"cpu_frequency"` + CPUModel string `json:"cpu_model_name"` + // Drivers + Hostname string `json:"hostname"` + ID int `json:"id"` + InstanceID string `json:"instance_id"` + InstanceType string `json:"instance_type"` + KernelArch string `json:"kernel_arch"` + KernelName string `json:"kernel_name"` + KernelVersion string `json:"kernel_version"` + Memory string `json:"memory"` + Name string `json:"name"` + NomadEligibility string `json:"nomad_eligibility"` + NomadID string `json:"nomad_id"` + NomadStatus string `json:"nomad_status"` + NomadVersion string `json:"nomad_version"` + OSName string `json:"os_name"` + OSVersion string `json:"os_version"` + PrivateIP string `json:"private_ip"` + // PublicIP + SchedulingGroup SchedulingGroup `json:"scheduling_group"` + // StatefulNode + StorageBytesFree string `json:"storage_bytesfree"` + StorageBytesTotal string `json:"storage_bytestotal"` + Zone string `json:"zone"` +} + +type Cluster struct { + ActualExternal int `json:"actual_external"` + ActualStateful int `json:"actual_stateful"` + ActualStateless int `json:"actual_stateless"` + CurrentSize int `json:"current_size"` + DesiredExternal int `json:"desired_external"` + DesiredSize int `json:"desired_size"` + DesiredStateful int `json:"desired_stateful"` + DesiredStateless int `json:"desired_stateless"` + ExternalNodes []ExternalNode `json:"external_nodes"` + ClusterMembersDetails []ClusterMemberDetails `json:"members"` + RootDiskSize int `json:"root_disk_size_gb"` + SchedulingGroups []SchedulingGroup `json:"scheduling_groups"` + Status string `json:"status"` +} + +type IntegratedServices struct { + IntegratedServices []IntegratedServiceInfo +} + +type IntegratedServiceInfo struct { + BasicAuth string `json:"basic_auth"` + Enabled bool `json:"enabled"` + IPWhiteList string `json:"ip_whitelist"` + Name string `json:"name"` + Prefix string `json:"prefix"` + URLs [][]string `json:"urls"` +} + +// UnmarshalJSON decodes an Integrated Service JSON map into a proper object +func (p *IntegratedServices) UnmarshalJSON(bytes []byte) error { + // 1. Decode the bytes into a raw interface object + var raw map[string]IntegratedServiceInfo + err := json.Unmarshal(bytes, &raw) + + if err != nil { + fmt.Printf("error decoding integrated service: %s\n", err) + return err + } + + res := make([]IntegratedServiceInfo, len(raw)) + i := 0 + for _, v := range raw { + res[i] = v + i++ + } + *p = IntegratedServices{ + IntegratedServices: res, + } + // 2. Parse the unix timestamp + //p.Time = time.Unix(raw, 0) + return nil +} + +type Infrastructure struct { + Action string `json:"action"` + Cluster Cluster `json:"cluster"` + Database Database `json:"db"` + IntegratedServices IntegratedServices `json:"integrated_services"` + LoadBalancer LoadBalancer `json:"lb"` + MonitoringEngine string `json:"monitoring_engine"` + NodeSize string `json:"node_size"` + CloudProvider string `json:"provider"` + CredentialName string `json:"provider_credential_name"` + CloudProviderLabel string `json:"provider_label"` + //`json:"redis_databases"` + Region string `json:"region"` + RegionLabel string `json:"region_label"` + RootDiskSize int `json:"root_disk_size_gb"` + Status string `json:"status"` + TFRunAt time.Time `json:"terraform_run_at"` + Type string `json:"type"` +} + +type GenericVariable struct { + Key string + Value interface{} + Predefined bool +} + +type ProjectDetails struct { + CreatedAt time.Time `json:"created_at"` + ExternalElasticSearch string `json:"external_elasticsearch"` + Environment []GenericVariable `json:"global_environment"` + HighAvailability bool `json:"high_availability"` + HybridClusterEnabled bool `json:"hybrid_cluster_enabled"` + Infrastructure Infrastructure `json:"infra"` + //`json:"integrated_services"` + //`json:"intentions"` + //`json:"managed_services"` + Name string `json:"name"` + Organization string `json:"organization_name"` + // TODO: see if type is Service or ServiceBody + Services []Service `json:"services"` + SlackWebHook string `json:"slack_webhook"` + UpdatedAt time.Time `json:"updated_at"` + User User `json:"user"` + UUID string `json:"uuid"` +} + +type ProjectWithAllDetails struct { + Notifications []Notification `json:"notifications"` + Project ProjectDetails `json:"project"` +} // UnprovisionError defined how export provision errors type UnprovisionError struct { @@ -234,7 +400,32 @@ func (c *Client) ProjectByName(projectName string) (string, error) { return "", fmt.Errorf("Project '%s' not found", projectName) } -// GetProject return the status of the project +// GetProjectDetails return the detailed informations of the project +func (c *Client) GetProjectDetails(project string) (*ProjectWithAllDetails, error) { + code, body, err := c.get("/project_info/" + project) + if err != nil { + return nil, err + } + + switch code { + case http.StatusOK: + case http.StatusNotFound: + return nil, fmt.Errorf("Project '%s' not found", project) + default: + return nil, unexpectedHTTPError(code, body) + } + + var details ProjectWithAllDetails + err = json.Unmarshal(body, &details) + if err != nil { + fmt.Printf("Error decoding project details %+v\n", err) + return nil, err + } + + return &details, nil +} + +// GetProject return the basic infos of the project func (c *Client) GetProject(project string) (*Project, error) { code, body, err := c.get("/projects/" + project) if err != nil { @@ -249,13 +440,13 @@ func (c *Client) GetProject(project string) (*Project, error) { return nil, unexpectedHTTPError(code, body) } - var status Project - err = json.Unmarshal(body, &status) + var basicInfos Project + err = json.Unmarshal(body, &basicInfos) if err != nil { return nil, err } - return &status, nil + return &basicInfos, nil } // WaitProject wait project provisioning diff --git a/squarescale/scheduling_groups.go b/squarescale/scheduling_groups.go index 64dd966..5534238 100644 --- a/squarescale/scheduling_groups.go +++ b/squarescale/scheduling_groups.go @@ -127,3 +127,39 @@ func (c *Client) GetSchedulingGroupNodes(schedulingGroup SchedulingGroup, concat } return strings.Join(nodes[:], concatSep) } + +func (c *Client) PutSchedulingGroupsNodes(projectUUID, schedulingGroupName, payloadEntry string, ids []int) error { + if len(ids) == 0 { + return fmt.Errorf("Can not add empty list of nodes to project %s scheduling group %s", projectUUID, schedulingGroupName) + } + + if payloadEntry == "cluster_members_to_add" { + payload := &JSONObject{payloadEntry: ids} + return c.sendSchedulingGroupsPut(projectUUID, schedulingGroupName, payload) + } else { + for _, id := range ids { + payload := &JSONObject{payloadEntry: id} + err := c.sendSchedulingGroupsPut(projectUUID, schedulingGroupName, payload) + if err != nil { + return err + } + } + } + return nil +} + +func (c *Client) sendSchedulingGroupsPut(projectUUID, schedulingGroupName string, payload *JSONObject) error { + code, body, err := c.put("/projects/" + projectUUID + "/scheduling_groups/" + schedulingGroupName, payload) + if err != nil { + return err + } + + switch code { + case http.StatusOK: + return nil + case http.StatusNotFound: + return fmt.Errorf("Project '%s' does not exist", projectUUID) + default: + return unexpectedHTTPError(code, body) + } +} diff --git a/squarescale/service.go b/squarescale/service.go index e2aadec..55806d4 100644 --- a/squarescale/service.go +++ b/squarescale/service.go @@ -18,11 +18,15 @@ type ServiceEnv struct { Predefined bool `json:"predefined"` } +// TODO: add missing allocations, docker_image, custom_environment -> environment, instances_count +// refresh_callbacks, status +// TODO: see why 2 different structs are used ??? + // Service describes a project container as returned by the SquareScale API type Service struct { ID int `json:"container_id"` Name string `json:"name"` - RunCommand string `json:"run_command"` + RunCommand string `json:"run_command"` // is array in the next structure Entrypoint string `json:"entrypoint"` Running int `json:"running"` Size int `json:"size"` @@ -34,14 +38,14 @@ type Service struct { DockerCapabilities []string `json:"docker_capabilities"` DockerDevices []DockerDevice `json:"docker_devices"` AutoStart bool `json:"auto_start"` - MaxClientDisconnect string `json:"max_client_disconnect"` + MaxClientDisconnect string `json:"max_client_disconnect"` // is int in the next structure Volumes []VolumeToBind `json:"volumes"` } type ServiceBody struct { ID int `json:"container_id"` Name string `json:"name"` - RunCommand []string `json:"run_command"` + RunCommand []string `json:"run_command"` // is not an array in the previous structure Entrypoint string `json:"entrypoint"` Running int `json:"running"` Size int `json:"size"` @@ -53,7 +57,7 @@ type ServiceBody struct { DockerCapabilities []string `json:"docker_capabilities"` DockerDevices []DockerDevice `json:"docker_devices"` AutoStart bool `json:"auto_start"` - MaxClientDisconnect int `json:"max_client_disconnect"` + MaxClientDisconnect int `json:"max_client_disconnect"` // is string in previous structure Volumes []VolumeToBind `json:"volumes"` } @@ -112,6 +116,7 @@ type ServiceLimits struct { IOPS int `json:"iops"` } +// TODO: get /projects/" + projectUUID + "/project_info" // GetContainers gets all the services attached to a Project func (c *Client) GetServices(projectUUID string) ([]Service, error) { code, body, err := c.get("/projects/" + projectUUID + "/services")