garm/cmd/garm-cli/cmd/pool.go
Gabriel Adrian Samfira ce3c917ae5 Add pool balancing strategy
This change adds the ability to specify the pool balancing strategy to
use when processing queued jobs. Before this change, GARM would round-robin
through all pools that matched the set of tags requested by queued jobs.

When round-robin (default) is used for an entity (repo, org or enterprise)
and you have 2 pools defined for that entity with a common set of tags that
match 10 jobs (for example), then those jobs would trigger the creation of
a new runner in each of the two pools in turn. Job 1 would go to pool 1,
job 2 would go to pool 2, job 3 to pool 1, job 4 to pool 2 and so on.

When "stack" is used, those same 10 jobs would trigger the creation of a
new runner in the pool with the highest priority, every time.

In both cases, if a pool is full, the next one would be tried automatically.

For the stack case, this would mean that if pool 2 had a priority of 10 and
pool 1 would have a priority of 5, pool 2 would be saturated first, then
pool 1.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
2024-03-14 20:04:34 +00:00

556 lines
19 KiB
Go

// Copyright 2022 Cloudbase Solutions SRL
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
package cmd
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/pkg/errors"
"github.com/spf13/cobra"
commonParams "github.com/cloudbase/garm-provider-common/params"
apiClientEnterprises "github.com/cloudbase/garm/client/enterprises"
apiClientOrgs "github.com/cloudbase/garm/client/organizations"
apiClientPools "github.com/cloudbase/garm/client/pools"
apiClientRepos "github.com/cloudbase/garm/client/repositories"
"github.com/cloudbase/garm/params"
)
var (
poolProvider string
poolMaxRunners uint
poolMinIdleRunners uint
poolRunnerPrefix string
poolImage string
poolFlavor string
poolOSType string
poolOSArch string
poolTags string
poolEnabled bool
poolRunnerBootstrapTimeout uint
poolRepository string
poolOrganization string
poolEnterprise string
poolExtraSpecsFile string
poolExtraSpecs string
poolAll bool
poolGitHubRunnerGroup string
priority uint
)
type poolPayloadGetter interface {
GetPayload() params.Pools
}
// runnerCmd represents the runner command
var poolCmd = &cobra.Command{
Use: "pool",
SilenceUsage: true,
Short: "List pools",
Long: `Query information or perform operations on pools.`,
Run: nil,
}
var poolListCmd = &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List pools",
Long: `List pools of repositories, orgs or all of the above.
This command will list pools from one repo, one org or all pools
on the system. The list flags are mutually exclusive. You must however
specify one of them.
Example:
List pools from one repo:
garm-cli pool list --repo=05e7eac6-4705-486d-89c9-0170bbb576af
List pools from one org:
garm-cli pool list --org=5493e51f-3170-4ce3-9f05-3fe690fc6ec6
List pools from one enterprise:
garm-cli pool list --enterprise=a8ee4c66-e762-4cbe-a35d-175dba2c9e62
List all pools from all repos, orgs and enterprises:
garm-cli pool list --all
`,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
if needsInit {
return errNeedsInitError
}
var response poolPayloadGetter
var err error
switch len(args) {
case 0:
if cmd.Flags().Changed("repo") {
listRepoPoolsReq := apiClientRepos.NewListRepoPoolsParams()
listRepoPoolsReq.RepoID = poolRepository
response, err = apiCli.Repositories.ListRepoPools(listRepoPoolsReq, authToken)
} else if cmd.Flags().Changed("org") {
listOrgPoolsReq := apiClientOrgs.NewListOrgPoolsParams()
listOrgPoolsReq.OrgID = poolOrganization
response, err = apiCli.Organizations.ListOrgPools(listOrgPoolsReq, authToken)
} else if cmd.Flags().Changed("enterprise") {
listEnterprisePoolsReq := apiClientEnterprises.NewListEnterprisePoolsParams()
listEnterprisePoolsReq.EnterpriseID = poolEnterprise
response, err = apiCli.Enterprises.ListEnterprisePools(listEnterprisePoolsReq, authToken)
} else if cmd.Flags().Changed("all") {
listPoolsReq := apiClientPools.NewListPoolsParams()
response, err = apiCli.Pools.ListPools(listPoolsReq, authToken)
} else {
cmd.Help() //nolint
os.Exit(0)
}
default:
cmd.Help() //nolint
os.Exit(0)
}
if err != nil {
return err
}
formatPools(response.GetPayload())
return nil
},
}
var poolShowCmd = &cobra.Command{
Use: "show",
Short: "Show details for a runner",
Long: `Displays a detailed view of a single runner.`,
SilenceUsage: true,
RunE: func(_ *cobra.Command, args []string) error {
if needsInit {
return errNeedsInitError
}
if len(args) == 0 {
return fmt.Errorf("requires a pool ID")
}
if len(args) > 1 {
return fmt.Errorf("too many arguments")
}
getPoolReq := apiClientPools.NewGetPoolParams()
getPoolReq.PoolID = args[0]
response, err := apiCli.Pools.GetPool(getPoolReq, authToken)
if err != nil {
return err
}
formatOnePool(response.Payload)
return nil
},
}
var poolDeleteCmd = &cobra.Command{
Use: "delete",
Aliases: []string{"remove", "rm", "del"},
Short: "Delete pool by ID",
Long: `Delete one pool by referencing it's ID, regardless of repo or org.`,
SilenceUsage: true,
RunE: func(_ *cobra.Command, args []string) error {
if needsInit {
return errNeedsInitError
}
if len(args) == 0 {
return fmt.Errorf("requires a pool ID")
}
if len(args) > 1 {
return fmt.Errorf("too many arguments")
}
deletePoolReq := apiClientPools.NewDeletePoolParams()
deletePoolReq.PoolID = args[0]
if err := apiCli.Pools.DeletePool(deletePoolReq, authToken); err != nil {
return err
}
return nil
},
}
var poolAddCmd = &cobra.Command{
Use: "add",
Aliases: []string{"create"},
Short: "Add pool",
Long: `Add a new pool to a repository or organization.`,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
if needsInit {
return errNeedsInitError
}
tags := strings.Split(poolTags, ",")
newPoolParams := params.CreatePoolParams{
RunnerPrefix: params.RunnerPrefix{
Prefix: poolRunnerPrefix,
},
ProviderName: poolProvider,
MaxRunners: poolMaxRunners,
MinIdleRunners: poolMinIdleRunners,
Image: poolImage,
Flavor: poolFlavor,
OSType: commonParams.OSType(poolOSType),
OSArch: commonParams.OSArch(poolOSArch),
Tags: tags,
Enabled: poolEnabled,
RunnerBootstrapTimeout: poolRunnerBootstrapTimeout,
GitHubRunnerGroup: poolGitHubRunnerGroup,
Priority: priority,
}
if cmd.Flags().Changed("extra-specs") {
data, err := asRawMessage([]byte(poolExtraSpecs))
if err != nil {
return err
}
newPoolParams.ExtraSpecs = data
}
if poolExtraSpecsFile != "" {
data, err := extraSpecsFromFile(poolExtraSpecsFile)
if err != nil {
return err
}
newPoolParams.ExtraSpecs = data
}
if err := newPoolParams.Validate(); err != nil {
return err
}
var pool params.Pool
var err error
if cmd.Flags().Changed("repo") {
var response *apiClientRepos.CreateRepoPoolOK
newRepoPoolReq := apiClientRepos.NewCreateRepoPoolParams()
newRepoPoolReq.RepoID = poolRepository
newRepoPoolReq.Body = newPoolParams
response, err = apiCli.Repositories.CreateRepoPool(newRepoPoolReq, authToken)
pool = response.Payload
} else if cmd.Flags().Changed("org") {
var response *apiClientOrgs.CreateOrgPoolOK
newOrgPoolReq := apiClientOrgs.NewCreateOrgPoolParams()
newOrgPoolReq.OrgID = poolOrganization
newOrgPoolReq.Body = newPoolParams
response, err = apiCli.Organizations.CreateOrgPool(newOrgPoolReq, authToken)
pool = response.Payload
} else if cmd.Flags().Changed("enterprise") {
var response *apiClientEnterprises.CreateEnterprisePoolOK
newEnterprisePoolReq := apiClientEnterprises.NewCreateEnterprisePoolParams()
newEnterprisePoolReq.EnterpriseID = poolEnterprise
newEnterprisePoolReq.Body = newPoolParams
response, err = apiCli.Enterprises.CreateEnterprisePool(newEnterprisePoolReq, authToken)
pool = response.Payload
} else {
cmd.Help() //nolint
os.Exit(0)
}
if err != nil {
return err
}
formatOnePool(pool)
return nil
},
}
var poolUpdateCmd = &cobra.Command{
Use: "update",
Short: "Update one pool",
Long: `Updates pool characteristics.
This command updates the pool characteristics. Runners already created prior to updating
the pool, will not be recreated. If they no longer suit your needs, you will need to
explicitly remove them using the runner delete command.
`,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
if needsInit {
return errNeedsInitError
}
if len(args) == 0 {
return fmt.Errorf("command requires a poolID")
}
if len(args) > 1 {
return fmt.Errorf("too many arguments")
}
updatePoolReq := apiClientPools.NewUpdatePoolParams()
poolUpdateParams := params.UpdatePoolParams{}
if cmd.Flags().Changed("image") {
poolUpdateParams.Image = poolImage
}
if cmd.Flags().Changed("flavor") {
poolUpdateParams.Flavor = poolFlavor
}
if cmd.Flags().Changed("tags") {
poolUpdateParams.Tags = strings.Split(poolTags, ",")
}
if cmd.Flags().Changed("os-type") {
poolUpdateParams.OSType = commonParams.OSType(poolOSType)
}
if cmd.Flags().Changed("os-arch") {
poolUpdateParams.OSArch = commonParams.OSArch(poolOSArch)
}
if cmd.Flags().Changed("max-runners") {
poolUpdateParams.MaxRunners = &poolMaxRunners
}
if cmd.Flags().Changed("priority") {
poolUpdateParams.Priority = &priority
}
if cmd.Flags().Changed("min-idle-runners") {
poolUpdateParams.MinIdleRunners = &poolMinIdleRunners
}
if cmd.Flags().Changed("runner-prefix") {
poolUpdateParams.RunnerPrefix = params.RunnerPrefix{
Prefix: poolRunnerPrefix,
}
}
if cmd.Flags().Changed("runner-group") {
poolUpdateParams.GitHubRunnerGroup = &poolGitHubRunnerGroup
}
if cmd.Flags().Changed("enabled") {
poolUpdateParams.Enabled = &poolEnabled
}
if cmd.Flags().Changed("runner-bootstrap-timeout") {
poolUpdateParams.RunnerBootstrapTimeout = &poolRunnerBootstrapTimeout
}
if cmd.Flags().Changed("extra-specs") {
data, err := asRawMessage([]byte(poolExtraSpecs))
if err != nil {
return err
}
poolUpdateParams.ExtraSpecs = data
}
if poolExtraSpecsFile != "" {
data, err := extraSpecsFromFile(poolExtraSpecsFile)
if err != nil {
return err
}
poolUpdateParams.ExtraSpecs = data
}
updatePoolReq.PoolID = args[0]
updatePoolReq.Body = poolUpdateParams
response, err := apiCli.Pools.UpdatePool(updatePoolReq, authToken)
if err != nil {
return err
}
formatOnePool(response.Payload)
return nil
},
}
func init() {
poolListCmd.Flags().StringVarP(&poolRepository, "repo", "r", "", "List all pools within this repository.")
poolListCmd.Flags().StringVarP(&poolOrganization, "org", "o", "", "List all pools within this organization.")
poolListCmd.Flags().StringVarP(&poolEnterprise, "enterprise", "e", "", "List all pools within this enterprise.")
poolListCmd.Flags().BoolVarP(&poolAll, "all", "a", false, "List all pools, regardless of org or repo.")
poolListCmd.MarkFlagsMutuallyExclusive("repo", "org", "all", "enterprise")
poolUpdateCmd.Flags().StringVar(&poolImage, "image", "", "The provider-specific image name to use for runners in this pool.")
poolUpdateCmd.Flags().UintVar(&priority, "priority", 0, "When multiple pools match the same labels, priority dictates the order by which they are returned, in descending order.")
poolUpdateCmd.Flags().StringVar(&poolFlavor, "flavor", "", "The flavor to use for this runner.")
poolUpdateCmd.Flags().StringVar(&poolTags, "tags", "", "A comma separated list of tags to assign to this runner.")
poolUpdateCmd.Flags().StringVar(&poolOSType, "os-type", "linux", "Operating system type (windows, linux, etc).")
poolUpdateCmd.Flags().StringVar(&poolOSArch, "os-arch", "amd64", "Operating system architecture (amd64, arm, etc).")
poolUpdateCmd.Flags().StringVar(&poolRunnerPrefix, "runner-prefix", "", "The name prefix to use for runners in this pool.")
poolUpdateCmd.Flags().UintVar(&poolMaxRunners, "max-runners", 5, "The maximum number of runner this pool will create.")
poolUpdateCmd.Flags().UintVar(&poolMinIdleRunners, "min-idle-runners", 1, "Attempt to maintain a minimum of idle self-hosted runners of this type.")
poolUpdateCmd.Flags().StringVar(&poolGitHubRunnerGroup, "runner-group", "", "The GitHub runner group in which all runners of this pool will be added.")
poolUpdateCmd.Flags().BoolVar(&poolEnabled, "enabled", false, "Enable this pool.")
poolUpdateCmd.Flags().UintVar(&poolRunnerBootstrapTimeout, "runner-bootstrap-timeout", 20, "Duration in minutes after which a runner is considered failed if it does not join Github.")
poolUpdateCmd.Flags().StringVar(&poolExtraSpecsFile, "extra-specs-file", "", "A file containing a valid json which will be passed to the IaaS provider managing the pool.")
poolUpdateCmd.Flags().StringVar(&poolExtraSpecs, "extra-specs", "", "A valid json which will be passed to the IaaS provider managing the pool.")
poolUpdateCmd.MarkFlagsMutuallyExclusive("extra-specs-file", "extra-specs")
poolAddCmd.Flags().StringVar(&poolProvider, "provider-name", "", "The name of the provider where runners will be created.")
poolAddCmd.Flags().UintVar(&priority, "priority", 0, "When multiple pools match the same labels, priority dictates the order by which they are returned, in descending order.")
poolAddCmd.Flags().StringVar(&poolImage, "image", "", "The provider-specific image name to use for runners in this pool.")
poolAddCmd.Flags().StringVar(&poolFlavor, "flavor", "", "The flavor to use for this runner.")
poolAddCmd.Flags().StringVar(&poolRunnerPrefix, "runner-prefix", "", "The name prefix to use for runners in this pool.")
poolAddCmd.Flags().StringVar(&poolTags, "tags", "", "A comma separated list of tags to assign to this runner.")
poolAddCmd.Flags().StringVar(&poolOSType, "os-type", "linux", "Operating system type (windows, linux, etc).")
poolAddCmd.Flags().StringVar(&poolOSArch, "os-arch", "amd64", "Operating system architecture (amd64, arm, etc).")
poolAddCmd.Flags().StringVar(&poolExtraSpecsFile, "extra-specs-file", "", "A file containing a valid json which will be passed to the IaaS provider managing the pool.")
poolAddCmd.Flags().StringVar(&poolExtraSpecs, "extra-specs", "", "A valid json which will be passed to the IaaS provider managing the pool.")
poolAddCmd.Flags().StringVar(&poolGitHubRunnerGroup, "runner-group", "", "The GitHub runner group in which all runners of this pool will be added.")
poolAddCmd.Flags().UintVar(&poolMaxRunners, "max-runners", 5, "The maximum number of runner this pool will create.")
poolAddCmd.Flags().UintVar(&poolRunnerBootstrapTimeout, "runner-bootstrap-timeout", 20, "Duration in minutes after which a runner is considered failed if it does not join Github.")
poolAddCmd.Flags().UintVar(&poolMinIdleRunners, "min-idle-runners", 1, "Attempt to maintain a minimum of idle self-hosted runners of this type.")
poolAddCmd.Flags().BoolVar(&poolEnabled, "enabled", false, "Enable this pool.")
poolAddCmd.MarkFlagRequired("provider-name") //nolint
poolAddCmd.MarkFlagRequired("image") //nolint
poolAddCmd.MarkFlagRequired("flavor") //nolint
poolAddCmd.MarkFlagRequired("tags") //nolint
poolAddCmd.Flags().StringVarP(&poolRepository, "repo", "r", "", "Add the new pool within this repository.")
poolAddCmd.Flags().StringVarP(&poolOrganization, "org", "o", "", "Add the new pool within this organization.")
poolAddCmd.Flags().StringVarP(&poolEnterprise, "enterprise", "e", "", "Add the new pool within this enterprise.")
poolAddCmd.MarkFlagsMutuallyExclusive("repo", "org", "enterprise")
poolAddCmd.MarkFlagsMutuallyExclusive("extra-specs-file", "extra-specs")
poolCmd.AddCommand(
poolListCmd,
poolShowCmd,
poolDeleteCmd,
poolUpdateCmd,
poolAddCmd,
)
rootCmd.AddCommand(poolCmd)
}
func extraSpecsFromFile(specsFile string) (json.RawMessage, error) {
data, err := os.ReadFile(specsFile)
if err != nil {
return nil, errors.Wrap(err, "opening specs file")
}
return asRawMessage(data)
}
func asRawMessage(data []byte) (json.RawMessage, error) {
// unmarshaling and marshaling again will remove new lines and verify we
// have a valid json.
var unmarshaled interface{}
if err := json.Unmarshal(data, &unmarshaled); err != nil {
return nil, errors.Wrap(err, "decoding extra specs")
}
var asRawJSON json.RawMessage
var err error
asRawJSON, err = json.Marshal(unmarshaled)
if err != nil {
return nil, errors.Wrap(err, "marshaling json")
}
return asRawJSON, nil
}
func formatPools(pools []params.Pool) {
t := table.NewWriter()
header := table.Row{"ID", "Image", "Flavor", "Tags", "Belongs to", "Level", "Enabled", "Runner Prefix", "Priority"}
t.AppendHeader(header)
for _, pool := range pools {
tags := []string{}
for _, tag := range pool.Tags {
tags = append(tags, tag.Name)
}
var belongsTo string
var level string
switch {
case pool.RepoID != "" && pool.RepoName != "":
belongsTo = pool.RepoName
level = "repo"
case pool.OrgID != "" && pool.OrgName != "":
belongsTo = pool.OrgName
level = "org"
case pool.EnterpriseID != "" && pool.EnterpriseName != "":
belongsTo = pool.EnterpriseName
level = "enterprise"
}
t.AppendRow(table.Row{pool.ID, pool.Image, pool.Flavor, strings.Join(tags, " "), belongsTo, level, pool.Enabled, pool.GetRunnerPrefix(), pool.Priority})
t.AppendSeparator()
}
fmt.Println(t.Render())
}
func formatOnePool(pool params.Pool) {
t := table.NewWriter()
rowConfigAutoMerge := table.RowConfig{AutoMerge: true}
header := table.Row{"Field", "Value"}
tags := []string{}
for _, tag := range pool.Tags {
tags = append(tags, tag.Name)
}
var belongsTo string
var level string
switch {
case pool.RepoID != "" && pool.RepoName != "":
belongsTo = pool.RepoName
level = "repo"
case pool.OrgID != "" && pool.OrgName != "":
belongsTo = pool.OrgName
level = "org"
case pool.EnterpriseID != "" && pool.EnterpriseName != "":
belongsTo = pool.EnterpriseName
level = "enterprise"
}
t.AppendHeader(header)
t.AppendRow(table.Row{"ID", pool.ID})
t.AppendRow(table.Row{"Provider Name", pool.ProviderName})
t.AppendRow(table.Row{"Priority", pool.Priority})
t.AppendRow(table.Row{"Image", pool.Image})
t.AppendRow(table.Row{"Flavor", pool.Flavor})
t.AppendRow(table.Row{"OS Type", pool.OSType})
t.AppendRow(table.Row{"OS Architecture", pool.OSArch})
t.AppendRow(table.Row{"Max Runners", pool.MaxRunners})
t.AppendRow(table.Row{"Min Idle Runners", pool.MinIdleRunners})
t.AppendRow(table.Row{"Runner Bootstrap Timeout", pool.RunnerBootstrapTimeout})
t.AppendRow(table.Row{"Tags", strings.Join(tags, ", ")})
t.AppendRow(table.Row{"Belongs to", belongsTo})
t.AppendRow(table.Row{"Level", level})
t.AppendRow(table.Row{"Enabled", pool.Enabled})
t.AppendRow(table.Row{"Runner Prefix", pool.GetRunnerPrefix()})
t.AppendRow(table.Row{"Extra specs", string(pool.ExtraSpecs)})
t.AppendRow(table.Row{"GitHub Runner Group", pool.GitHubRunnerGroup})
if len(pool.Instances) > 0 {
for _, instance := range pool.Instances {
t.AppendRow(table.Row{"Instances", fmt.Sprintf("%s (%s)", instance.Name, instance.ID)}, rowConfigAutoMerge)
}
}
t.SetColumnConfigs([]table.ColumnConfig{
{Number: 1, AutoMerge: true},
{Number: 2, AutoMerge: false, WidthMax: 100},
})
fmt.Println(t.Render())
}