Add runner rotate ability to CLI

This change adds a new "generation" field to pools, scalesets and
runners. The generation field is inherited by runners from scale sets
or pools at the time of creation.

The generation field on scalesets and pools is incremented when the
pool or scale set is updated in a way that might influence how runners
are created (flavor, image, specs, runner groups, etc).

Using this new field, we can determine if existing runners have diverged
from the settings of the pool/scale set that spawned them.

In the CLI we now have a new set of commands available for both
pools and scalesets that lists runners, with an optional --outdated
flag and a new "rotate" flag that removes all idle runners. Optionally
the --outdated flag can be passed to the rotate command to only remove
outdated runners.

Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
This commit is contained in:
Gabriel Adrian Samfira 2026-02-08 23:48:57 +02:00 committed by Gabriel
parent 61b4b4cadd
commit 80e042ee88
27 changed files with 648 additions and 93 deletions

View file

@ -38,6 +38,12 @@ import (
// in: path
// required: true
//
// + name: outdatedOnly
// description: List only instances that were created prior to a pool update that changed a setting which influences how instances are created (image, flavor, runner group, etc).
// type: boolean
// in: query
// required: false
//
// Responses:
// 200: Instances
// default: APIErrorResponse
@ -56,7 +62,8 @@ func (a *APIController) ListPoolInstancesHandler(w http.ResponseWriter, r *http.
return
}
instances, err := a.r.ListPoolInstances(ctx, poolID)
filterByOutdated, _ := strconv.ParseBool(r.URL.Query().Get("outdatedOnly"))
instances, err := a.r.ListPoolInstances(ctx, poolID, filterByOutdated)
if err != nil {
slog.With(slog.Any("error", err)).ErrorContext(ctx, "listing pool instances")
handleError(ctx, w, err)
@ -80,6 +87,12 @@ func (a *APIController) ListPoolInstancesHandler(w http.ResponseWriter, r *http.
// in: path
// required: true
//
// + name: outdatedOnly
// description: List only instances that were created prior to a scaleset update that changed a setting which influences how instances are created (image, flavor, runner group, etc).
// type: boolean
// in: query
// required: false
//
// Responses:
// 200: Instances
// default: APIErrorResponse
@ -104,7 +117,8 @@ func (a *APIController) ListScaleSetInstancesHandler(w http.ResponseWriter, r *h
return
}
instances, err := a.r.ListScaleSetInstances(ctx, uint(id))
filterByOutdated, _ := strconv.ParseBool(r.URL.Query().Get("outdatedOnly"))
instances, err := a.r.ListScaleSetInstances(ctx, uint(id), filterByOutdated)
if err != nil {
slog.With(slog.Any("error", err)).ErrorContext(ctx, "listing pool instances")
handleError(ctx, w, err)

View file

@ -1940,6 +1940,10 @@ paths:
name: poolID
required: true
type: string
- description: List only instances that were created prior to a pool update that changed a setting which influences how instances are created (image, flavor, runner group, etc).
in: query
name: outdatedOnly
type: boolean
responses:
"200":
description: Instances
@ -2453,6 +2457,10 @@ paths:
name: scalesetID
required: true
type: string
- description: List only instances that were created prior to a scaleset update that changed a setting which influences how instances are created (image, flavor, runner group, etc).
in: query
name: outdatedOnly
type: boolean
responses:
"200":
description: Instances

View file

@ -14,6 +14,7 @@ import (
"github.com/go-openapi/runtime"
cr "github.com/go-openapi/runtime/client"
"github.com/go-openapi/strfmt"
"github.com/go-openapi/swag"
)
// NewListPoolInstancesParams creates a new ListPoolInstancesParams object,
@ -61,6 +62,12 @@ ListPoolInstancesParams contains all the parameters to send to the API endpoint
*/
type ListPoolInstancesParams struct {
/* OutdatedOnly.
List only instances that were created prior to a pool update that changed a setting which influences how instances are created (image, flavor, runner group, etc).
*/
OutdatedOnly *bool
/* PoolID.
Runner pool ID.
@ -120,6 +127,17 @@ func (o *ListPoolInstancesParams) SetHTTPClient(client *http.Client) {
o.HTTPClient = client
}
// WithOutdatedOnly adds the outdatedOnly to the list pool instances params
func (o *ListPoolInstancesParams) WithOutdatedOnly(outdatedOnly *bool) *ListPoolInstancesParams {
o.SetOutdatedOnly(outdatedOnly)
return o
}
// SetOutdatedOnly adds the outdatedOnly to the list pool instances params
func (o *ListPoolInstancesParams) SetOutdatedOnly(outdatedOnly *bool) {
o.OutdatedOnly = outdatedOnly
}
// WithPoolID adds the poolID to the list pool instances params
func (o *ListPoolInstancesParams) WithPoolID(poolID string) *ListPoolInstancesParams {
o.SetPoolID(poolID)
@ -139,6 +157,23 @@ func (o *ListPoolInstancesParams) WriteToRequest(r runtime.ClientRequest, reg st
}
var res []error
if o.OutdatedOnly != nil {
// query param outdatedOnly
var qrOutdatedOnly bool
if o.OutdatedOnly != nil {
qrOutdatedOnly = *o.OutdatedOnly
}
qOutdatedOnly := swag.FormatBool(qrOutdatedOnly)
if qOutdatedOnly != "" {
if err := r.SetQueryParam("outdatedOnly", qOutdatedOnly); err != nil {
return err
}
}
}
// path param poolID
if err := r.SetPathParam("poolID", o.PoolID); err != nil {
return err

View file

@ -14,6 +14,7 @@ import (
"github.com/go-openapi/runtime"
cr "github.com/go-openapi/runtime/client"
"github.com/go-openapi/strfmt"
"github.com/go-openapi/swag"
)
// NewListScaleSetInstancesParams creates a new ListScaleSetInstancesParams object,
@ -61,6 +62,12 @@ ListScaleSetInstancesParams contains all the parameters to send to the API endpo
*/
type ListScaleSetInstancesParams struct {
/* OutdatedOnly.
List only instances that were created prior to a scaleset update that changed a setting which influences how instances are created (image, flavor, runner group, etc).
*/
OutdatedOnly *bool
/* ScalesetID.
Runner scale set ID.
@ -120,6 +127,17 @@ func (o *ListScaleSetInstancesParams) SetHTTPClient(client *http.Client) {
o.HTTPClient = client
}
// WithOutdatedOnly adds the outdatedOnly to the list scale set instances params
func (o *ListScaleSetInstancesParams) WithOutdatedOnly(outdatedOnly *bool) *ListScaleSetInstancesParams {
o.SetOutdatedOnly(outdatedOnly)
return o
}
// SetOutdatedOnly adds the outdatedOnly to the list scale set instances params
func (o *ListScaleSetInstancesParams) SetOutdatedOnly(outdatedOnly *bool) {
o.OutdatedOnly = outdatedOnly
}
// WithScalesetID adds the scalesetID to the list scale set instances params
func (o *ListScaleSetInstancesParams) WithScalesetID(scalesetID string) *ListScaleSetInstancesParams {
o.SetScalesetID(scalesetID)
@ -139,6 +157,23 @@ func (o *ListScaleSetInstancesParams) WriteToRequest(r runtime.ClientRequest, re
}
var res []error
if o.OutdatedOnly != nil {
// query param outdatedOnly
var qrOutdatedOnly bool
if o.OutdatedOnly != nil {
qrOutdatedOnly = *o.OutdatedOnly
}
qOutdatedOnly := swag.FormatBool(qrOutdatedOnly)
if qOutdatedOnly != "" {
if err := r.SetQueryParam("outdatedOnly", qOutdatedOnly); err != nil {
return err
}
}
}
// path param scalesetID
if err := r.SetPathParam("scalesetID", o.ScalesetID); err != nil {
return err

View file

@ -15,6 +15,7 @@
package cmd
import (
"bufio"
"encoding/json"
"fmt"
"os"
@ -25,6 +26,7 @@ import (
commonParams "github.com/cloudbase/garm-provider-common/params"
apiClientEnterprises "github.com/cloudbase/garm/client/enterprises"
apiClientInstances "github.com/cloudbase/garm/client/instances"
apiClientOrgs "github.com/cloudbase/garm/client/organizations"
apiClientPools "github.com/cloudbase/garm/client/pools"
apiClientRepos "github.com/cloudbase/garm/client/repositories"
@ -424,6 +426,128 @@ explicitly remove them using the runner delete command.
},
}
var (
poolRunnerOutdated bool
poolRunnerDryRun bool
poolRunnerForce bool
poolRunnerBypass bool
poolRunnerConfirmSkip bool
)
var poolRunnerCmd = &cobra.Command{
Use: "runner",
Short: "Manage runners in a pool",
Long: `List or rotate runners in a pool.`,
SilenceUsage: true,
Run: nil,
}
var poolRunnerListCmd = &cobra.Command{
Use: "list <pool-id>",
Aliases: []string{"ls"},
Short: "List runners in a pool",
Long: `List all runners belonging to a pool. Use --outdated to show only runners with a generation older than the pool.`,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
if needsInit {
return errNeedsInitError
}
listReq := apiClientInstances.NewListPoolInstancesParams()
listReq.PoolID = args[0]
if cmd.Flags().Changed("outdated") {
listReq.OutdatedOnly = &poolRunnerOutdated
}
response, err := apiCli.Instances.ListPoolInstances(listReq, authToken)
if err != nil {
return err
}
formatInstances(response.Payload, false, false)
return nil
},
}
var poolRunnerRotateCmd = &cobra.Command{
Use: "rotate <pool-id>",
Short: "Rotate idle runners in a pool",
Long: `Remove idle runners in a pool so they get replaced with fresh ones. Use --outdated to only rotate runners with a generation older than the pool.`,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
if needsInit {
return errNeedsInitError
}
listReq := apiClientInstances.NewListPoolInstancesParams()
listReq.PoolID = args[0]
if cmd.Flags().Changed("outdated") {
listReq.OutdatedOnly = &poolRunnerOutdated
}
response, err := apiCli.Instances.ListPoolInstances(listReq, authToken)
if err != nil {
return err
}
idle := filterIdleRunners(response.Payload)
if len(idle) == 0 {
fmt.Println("No idle runners to rotate.")
return nil
}
if poolRunnerDryRun {
fmt.Printf("Would remove %d idle runner(s):\n", len(idle))
formatInstances(idle, false, false)
return nil
}
if !poolRunnerConfirmSkip {
fmt.Printf("About to remove %d idle runner(s). Continue? [y/N] ", len(idle))
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Println("Aborted.")
return nil
}
}
removed, skipped := rotateRunners(idle, poolRunnerForce, poolRunnerBypass)
fmt.Printf("Removed %d runner(s), skipped %d.\n", removed, skipped)
return nil
},
}
func filterIdleRunners(instances []params.Instance) []params.Instance {
var idle []params.Instance
for _, inst := range instances {
if inst.Status == commonParams.InstanceRunning && inst.RunnerStatus == params.RunnerIdle {
idle = append(idle, inst)
}
}
return idle
}
func rotateRunners(instances []params.Instance, forceRemove, bypassGH bool) (removed, skipped int) {
for _, inst := range instances {
deleteReq := apiClientInstances.NewDeleteInstanceParams()
deleteReq.InstanceName = inst.Name
deleteReq.ForceRemove = &forceRemove
deleteReq.BypassGHUnauthorized = &bypassGH
if err := apiCli.Instances.DeleteInstance(deleteReq, authToken); err != nil {
fmt.Printf(" Warning: failed to remove %s: %v\n", inst.Name, err)
skipped++
continue
}
fmt.Printf(" Removed %s\n", inst.Name)
removed++
}
return
}
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.")
@ -483,12 +607,26 @@ func init() {
poolAddCmd.MarkFlagsMutuallyExclusive("repo", "org", "enterprise")
poolAddCmd.MarkFlagsMutuallyExclusive("extra-specs-file", "extra-specs")
poolRunnerListCmd.Flags().BoolVar(&poolRunnerOutdated, "outdated", false, "List only runners with a generation older than the pool.")
poolRunnerRotateCmd.Flags().BoolVar(&poolRunnerOutdated, "outdated", false, "Only rotate runners with a generation older than the pool.")
poolRunnerRotateCmd.Flags().BoolVar(&poolRunnerDryRun, "dry-run", false, "Show what would be removed without actually removing.")
poolRunnerRotateCmd.Flags().BoolVarP(&poolRunnerForce, "force-remove-runner", "f", false, "Ignore provider errors when removing runners.")
poolRunnerRotateCmd.Flags().BoolVarP(&poolRunnerBypass, "bypass-github-unauthorized", "b", false, "Ignore GitHub unauthorized errors when removing runners.")
poolRunnerRotateCmd.Flags().BoolVar(&poolRunnerConfirmSkip, "yes-i-really-mean-it", false, "Skip the confirmation prompt.")
poolRunnerCmd.AddCommand(
poolRunnerListCmd,
poolRunnerRotateCmd,
)
poolCmd.AddCommand(
poolListCmd,
poolShowCmd,
poolDeleteCmd,
poolUpdateCmd,
poolAddCmd,
poolRunnerCmd,
)
rootCmd.AddCommand(poolCmd)

View file

@ -226,16 +226,20 @@ var runnerListCmd = &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List runners",
Long: `List runners of pools, repositories, orgs or all of the above.
Long: `List runners of pools, scale sets, repositories, orgs or all of the above.
This command expects to get either a pool ID as a positional parameter, or it expects
that one of the supported switches be used to fetch runners of --repo, --org or --all
This command expects to get either a pool ID (UUID) or scale set ID (integer) as a
positional parameter, or it expects that one of the supported switches be used to
fetch runners of --repo, --org or --all
Example:
List runners from one pool:
garm-cli runner list e87e70bd-3d0d-4b25-be9a-86b85e114bcb
List runners from one scale set:
garm-cli runner list 42
List runners from one repo:
garm-cli runner list --repo=05e7eac6-4705-486d-89c9-0170bbb576af
@ -265,11 +269,17 @@ Example:
cmd.Flags().Changed("enterprise") ||
cmd.Flags().Changed("all") {
return fmt.Errorf("specifying a pool ID and any of [all org repo enterprise] are mutually exclusive")
return fmt.Errorf("specifying a pool/scaleset ID and any of [all org repo enterprise] are mutually exclusive")
}
if _, parseErr := uuid.Parse(args[0]); parseErr == nil {
listPoolInstancesReq := apiClientInstances.NewListPoolInstancesParams()
listPoolInstancesReq.PoolID = args[0]
response, err = apiCli.Instances.ListPoolInstances(listPoolInstancesReq, authToken)
} else {
listScaleSetReq := apiClientInstances.NewListScaleSetInstancesParams()
listScaleSetReq.ScalesetID = args[0]
response, err = apiCli.Instances.ListScaleSetInstances(listScaleSetReq, authToken)
}
listPoolInstancesReq := apiClientInstances.NewListPoolInstancesParams()
listPoolInstancesReq.PoolID = args[0]
response, err = apiCli.Instances.ListPoolInstances(listPoolInstancesReq, authToken)
case 0:
if cmd.Flags().Changed("repo") {
runnerRepo, resErr := resolveRepository(runnerRepository, endpointName)
@ -309,7 +319,7 @@ Example:
}
instances := response.GetPayload()
formatInstances(instances, long)
formatInstances(instances, long, true)
return nil
},
}
@ -403,20 +413,30 @@ func init() {
rootCmd.AddCommand(runnerCmd)
}
func formatInstances(param []params.Instance, detailed bool) {
func formatInstances(param []params.Instance, detailed bool, includeParent bool) {
if outputFormat == common.OutputFormatJSON {
printAsJSON(param)
return
}
t := table.NewWriter()
header := table.Row{"Nr", "Name", "Status", "Runner Status", "Pool ID", "Scalse Set ID"}
header := table.Row{"Nr", "Name", "Status", "Runner Status"}
if includeParent {
header = append(header, "Pool / Scale Set")
}
if detailed {
header = append(header, "Created At", "Updated At", "Job Name", "Started At", "Run ID", "Repository")
}
t.AppendHeader(header)
for idx, inst := range param {
row := table.Row{idx + 1, inst.Name, inst.Status, inst.RunnerStatus, inst.PoolID, inst.ScaleSetID}
row := table.Row{idx + 1, inst.Name, inst.Status, inst.RunnerStatus}
if includeParent {
poolOrScaleSet := fmt.Sprintf("Pool: %v", inst.PoolID)
if inst.ScaleSetID > 0 {
poolOrScaleSet = fmt.Sprintf("Scale Set: %d", inst.ScaleSetID)
}
row = append(row, poolOrScaleSet)
}
if detailed {
row = append(row, inst.CreatedAt, inst.UpdatedAt)
if inst.Job != nil {

View file

@ -15,14 +15,17 @@
package cmd
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
commonParams "github.com/cloudbase/garm-provider-common/params"
apiClientEnterprises "github.com/cloudbase/garm/client/enterprises"
apiClientInstances "github.com/cloudbase/garm/client/instances"
apiClientOrgs "github.com/cloudbase/garm/client/organizations"
apiClientRepos "github.com/cloudbase/garm/client/repositories"
apiClientScaleSets "github.com/cloudbase/garm/client/scalesets"
@ -415,6 +418,101 @@ explicitly remove them using the runner delete command.
},
}
var (
scalesetRunnerOutdated bool
scalesetRunnerDryRun bool
scalesetRunnerForce bool
scalesetRunnerBypass bool
scalesetRunnerConfirmSkip bool
)
var scalesetRunnerCmd = &cobra.Command{
Use: "runner",
Short: "Manage runners in a scale set",
Long: `List or rotate runners in a scale set.`,
SilenceUsage: true,
Run: nil,
}
var scalesetRunnerListCmd = &cobra.Command{
Use: "list <scaleset-id>",
Aliases: []string{"ls"},
Short: "List runners in a scale set",
Long: `List all runners belonging to a scale set. Use --outdated to show only runners with a generation older than the scale set.`,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
if needsInit {
return errNeedsInitError
}
listReq := apiClientInstances.NewListScaleSetInstancesParams()
listReq.ScalesetID = args[0]
if cmd.Flags().Changed("outdated") {
listReq.OutdatedOnly = &scalesetRunnerOutdated
}
response, err := apiCli.Instances.ListScaleSetInstances(listReq, authToken)
if err != nil {
return err
}
formatInstances(response.Payload, false, false)
return nil
},
}
var scalesetRunnerRotateCmd = &cobra.Command{
Use: "rotate <scaleset-id>",
Short: "Rotate idle runners in a scale set",
Long: `Remove idle runners in a scale set so they get replaced with fresh ones. Use --outdated to only rotate runners with a generation older than the scale set.`,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
if needsInit {
return errNeedsInitError
}
listReq := apiClientInstances.NewListScaleSetInstancesParams()
listReq.ScalesetID = args[0]
if cmd.Flags().Changed("outdated") {
listReq.OutdatedOnly = &scalesetRunnerOutdated
}
response, err := apiCli.Instances.ListScaleSetInstances(listReq, authToken)
if err != nil {
return err
}
idle := filterIdleRunners(response.Payload)
if len(idle) == 0 {
fmt.Println("No idle runners to rotate.")
return nil
}
if scalesetRunnerDryRun {
fmt.Printf("Would remove %d idle runner(s):\n", len(idle))
formatInstances(idle, false, false)
return nil
}
if !scalesetRunnerConfirmSkip {
fmt.Printf("About to remove %d idle runner(s). Continue? [y/N] ", len(idle))
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Println("Aborted.")
return nil
}
}
removed, skipped := rotateRunners(idle, scalesetRunnerForce, scalesetRunnerBypass)
fmt.Printf("Removed %d runner(s), skipped %d.\n", removed, skipped)
return nil
},
}
func init() {
scalesetListCmd.Flags().StringVarP(&scalesetRepository, "repo", "r", "", "List all scale sets within this repository.")
scalesetListCmd.Flags().StringVarP(&scalesetOrganization, "org", "o", "", "List all scale sets within this organization.")
@ -467,12 +565,26 @@ func init() {
scaleSetAddCmd.MarkFlagsMutuallyExclusive("repo", "org", "enterprise")
scaleSetAddCmd.MarkFlagsMutuallyExclusive("extra-specs-file", "extra-specs")
scalesetRunnerListCmd.Flags().BoolVar(&scalesetRunnerOutdated, "outdated", false, "List only runners with a generation older than the scale set.")
scalesetRunnerRotateCmd.Flags().BoolVar(&scalesetRunnerOutdated, "outdated", false, "Only rotate runners with a generation older than the scale set.")
scalesetRunnerRotateCmd.Flags().BoolVar(&scalesetRunnerDryRun, "dry-run", false, "Show what would be removed without actually removing.")
scalesetRunnerRotateCmd.Flags().BoolVarP(&scalesetRunnerForce, "force-remove-runner", "f", false, "Ignore provider errors when removing runners.")
scalesetRunnerRotateCmd.Flags().BoolVarP(&scalesetRunnerBypass, "bypass-github-unauthorized", "b", false, "Ignore GitHub unauthorized errors when removing runners.")
scalesetRunnerRotateCmd.Flags().BoolVar(&scalesetRunnerConfirmSkip, "yes-i-really-mean-it", false, "Skip the confirmation prompt.")
scalesetRunnerCmd.AddCommand(
scalesetRunnerListCmd,
scalesetRunnerRotateCmd,
)
scalesetCmd.AddCommand(
scalesetListCmd,
scaleSetShowCmd,
scaleSetDeleteCmd,
scaleSetUpdateCmd,
scaleSetAddCmd,
scalesetRunnerCmd,
)
rootCmd.AddCommand(scalesetCmd)

View file

@ -4445,9 +4445,9 @@ func (_c *Store_ListOrganizations_Call) RunAndReturn(run func(context.Context, p
return _c
}
// ListPoolInstances provides a mock function with given fields: ctx, poolID
func (_m *Store) ListPoolInstances(ctx context.Context, poolID string) ([]params.Instance, error) {
ret := _m.Called(ctx, poolID)
// ListPoolInstances provides a mock function with given fields: ctx, poolID, oudatedOnly
func (_m *Store) ListPoolInstances(ctx context.Context, poolID string, oudatedOnly bool) ([]params.Instance, error) {
ret := _m.Called(ctx, poolID, oudatedOnly)
if len(ret) == 0 {
panic("no return value specified for ListPoolInstances")
@ -4455,19 +4455,19 @@ func (_m *Store) ListPoolInstances(ctx context.Context, poolID string) ([]params
var r0 []params.Instance
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) ([]params.Instance, error)); ok {
return rf(ctx, poolID)
if rf, ok := ret.Get(0).(func(context.Context, string, bool) ([]params.Instance, error)); ok {
return rf(ctx, poolID, oudatedOnly)
}
if rf, ok := ret.Get(0).(func(context.Context, string) []params.Instance); ok {
r0 = rf(ctx, poolID)
if rf, ok := ret.Get(0).(func(context.Context, string, bool) []params.Instance); ok {
r0 = rf(ctx, poolID, oudatedOnly)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]params.Instance)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, poolID)
if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {
r1 = rf(ctx, poolID, oudatedOnly)
} else {
r1 = ret.Error(1)
}
@ -4483,13 +4483,14 @@ type Store_ListPoolInstances_Call struct {
// ListPoolInstances is a helper method to define mock.On call
// - ctx context.Context
// - poolID string
func (_e *Store_Expecter) ListPoolInstances(ctx interface{}, poolID interface{}) *Store_ListPoolInstances_Call {
return &Store_ListPoolInstances_Call{Call: _e.mock.On("ListPoolInstances", ctx, poolID)}
// - oudatedOnly bool
func (_e *Store_Expecter) ListPoolInstances(ctx interface{}, poolID interface{}, oudatedOnly interface{}) *Store_ListPoolInstances_Call {
return &Store_ListPoolInstances_Call{Call: _e.mock.On("ListPoolInstances", ctx, poolID, oudatedOnly)}
}
func (_c *Store_ListPoolInstances_Call) Run(run func(ctx context.Context, poolID string)) *Store_ListPoolInstances_Call {
func (_c *Store_ListPoolInstances_Call) Run(run func(ctx context.Context, poolID string, oudatedOnly bool)) *Store_ListPoolInstances_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
run(args[0].(context.Context), args[1].(string), args[2].(bool))
})
return _c
}
@ -4499,7 +4500,7 @@ func (_c *Store_ListPoolInstances_Call) Return(_a0 []params.Instance, _a1 error)
return _c
}
func (_c *Store_ListPoolInstances_Call) RunAndReturn(run func(context.Context, string) ([]params.Instance, error)) *Store_ListPoolInstances_Call {
func (_c *Store_ListPoolInstances_Call) RunAndReturn(run func(context.Context, string, bool) ([]params.Instance, error)) *Store_ListPoolInstances_Call {
_c.Call.Return(run)
return _c
}
@ -4563,9 +4564,9 @@ func (_c *Store_ListRepositories_Call) RunAndReturn(run func(context.Context, pa
return _c
}
// ListScaleSetInstances provides a mock function with given fields: _a0, scalesetID
func (_m *Store) ListScaleSetInstances(_a0 context.Context, scalesetID uint) ([]params.Instance, error) {
ret := _m.Called(_a0, scalesetID)
// ListScaleSetInstances provides a mock function with given fields: _a0, scalesetID, outdatedOnly
func (_m *Store) ListScaleSetInstances(_a0 context.Context, scalesetID uint, outdatedOnly bool) ([]params.Instance, error) {
ret := _m.Called(_a0, scalesetID, outdatedOnly)
if len(ret) == 0 {
panic("no return value specified for ListScaleSetInstances")
@ -4573,19 +4574,19 @@ func (_m *Store) ListScaleSetInstances(_a0 context.Context, scalesetID uint) ([]
var r0 []params.Instance
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, uint) ([]params.Instance, error)); ok {
return rf(_a0, scalesetID)
if rf, ok := ret.Get(0).(func(context.Context, uint, bool) ([]params.Instance, error)); ok {
return rf(_a0, scalesetID, outdatedOnly)
}
if rf, ok := ret.Get(0).(func(context.Context, uint) []params.Instance); ok {
r0 = rf(_a0, scalesetID)
if rf, ok := ret.Get(0).(func(context.Context, uint, bool) []params.Instance); ok {
r0 = rf(_a0, scalesetID, outdatedOnly)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]params.Instance)
}
}
if rf, ok := ret.Get(1).(func(context.Context, uint) error); ok {
r1 = rf(_a0, scalesetID)
if rf, ok := ret.Get(1).(func(context.Context, uint, bool) error); ok {
r1 = rf(_a0, scalesetID, outdatedOnly)
} else {
r1 = ret.Error(1)
}
@ -4601,13 +4602,14 @@ type Store_ListScaleSetInstances_Call struct {
// ListScaleSetInstances is a helper method to define mock.On call
// - _a0 context.Context
// - scalesetID uint
func (_e *Store_Expecter) ListScaleSetInstances(_a0 interface{}, scalesetID interface{}) *Store_ListScaleSetInstances_Call {
return &Store_ListScaleSetInstances_Call{Call: _e.mock.On("ListScaleSetInstances", _a0, scalesetID)}
// - outdatedOnly bool
func (_e *Store_Expecter) ListScaleSetInstances(_a0 interface{}, scalesetID interface{}, outdatedOnly interface{}) *Store_ListScaleSetInstances_Call {
return &Store_ListScaleSetInstances_Call{Call: _e.mock.On("ListScaleSetInstances", _a0, scalesetID, outdatedOnly)}
}
func (_c *Store_ListScaleSetInstances_Call) Run(run func(_a0 context.Context, scalesetID uint)) *Store_ListScaleSetInstances_Call {
func (_c *Store_ListScaleSetInstances_Call) Run(run func(_a0 context.Context, scalesetID uint, outdatedOnly bool)) *Store_ListScaleSetInstances_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(uint))
run(args[0].(context.Context), args[1].(uint), args[2].(bool))
})
return _c
}
@ -4617,7 +4619,7 @@ func (_c *Store_ListScaleSetInstances_Call) Return(_a0 []params.Instance, _a1 er
return _c
}
func (_c *Store_ListScaleSetInstances_Call) RunAndReturn(run func(context.Context, uint) ([]params.Instance, error)) *Store_ListScaleSetInstances_Call {
func (_c *Store_ListScaleSetInstances_Call) RunAndReturn(run func(context.Context, uint, bool) ([]params.Instance, error)) *Store_ListScaleSetInstances_Call {
_c.Call.Return(run)
return _c
}

View file

@ -75,7 +75,7 @@ type PoolStore interface {
GetPoolByID(ctx context.Context, poolID string) (params.Pool, error)
DeletePoolByID(ctx context.Context, poolID string) error
ListPoolInstances(ctx context.Context, poolID string) ([]params.Instance, error)
ListPoolInstances(ctx context.Context, poolID string, oudatedOnly bool) ([]params.Instance, error)
PoolInstanceCount(ctx context.Context, poolID string) (int64, error)
FindPoolsMatchingAllTags(ctx context.Context, entityType params.ForgeEntityType, entityID string, tags []string) ([]params.Pool, error)
@ -152,7 +152,7 @@ type ScaleSetsStore interface {
}
type ScaleSetInstanceStore interface {
ListScaleSetInstances(_ context.Context, scalesetID uint) ([]params.Instance, error)
ListScaleSetInstances(_ context.Context, scalesetID uint, outdatedOnly bool) ([]params.Instance, error)
CreateScaleSetInstance(_ context.Context, scaleSetID uint, param params.CreateInstanceParams) (instance params.Instance, err error)
}

View file

@ -90,6 +90,7 @@ func (s *sqlDatabase) CreateInstance(ctx context.Context, poolID string, param p
JitConfiguration: secret,
AditionalLabels: labels,
AgentID: param.AgentID,
Generation: param.Generation,
}
q = tx.Create(&newInstance)
if q.Error != nil {
@ -511,14 +512,18 @@ func (s *sqlDatabase) listInstancesBatched(queryModifier func(*gorm.DB) *gorm.DB
return ret, err
}
func (s *sqlDatabase) ListPoolInstances(_ context.Context, poolID string) ([]params.Instance, error) {
func (s *sqlDatabase) ListPoolInstances(_ context.Context, poolID string, outdatedOnly bool) ([]params.Instance, error) {
u, err := uuid.Parse(poolID)
if err != nil {
return nil, fmt.Errorf("error parsing id: %w", runnerErrors.ErrBadRequest)
}
ret, err := s.listInstancesBatched(func(query *gorm.DB) *gorm.DB {
return query.Where("pool_id = ?", u)
q := query.Where("pool_id = ?", u)
if outdatedOnly {
q = q.Where("instances.generation < (SELECT pools.generation FROM pools WHERE pools.id = instances.pool_id)")
}
return q
})
if err != nil {
return nil, fmt.Errorf("failed to list pool instances: %w", err)
@ -527,7 +532,7 @@ func (s *sqlDatabase) ListPoolInstances(_ context.Context, poolID string) ([]par
}
func (s *sqlDatabase) ListAllInstances(_ context.Context) ([]params.Instance, error) {
ret, err := s.listInstancesBatched(nil) // No query modifier for all instances
ret, err := s.listInstancesBatched(nil)
if err != nil {
return nil, fmt.Errorf("failed to list all instances: %w", err)
}

View file

@ -668,14 +668,14 @@ func (s *InstancesTestSuite) TestUpdateInstanceDBUpdateAddressErr() {
}
func (s *InstancesTestSuite) TestListPoolInstances() {
instances, err := s.Store.ListPoolInstances(s.adminCtx, s.Fixtures.Pool.ID)
instances, err := s.Store.ListPoolInstances(s.adminCtx, s.Fixtures.Pool.ID, false)
s.Require().Nil(err)
s.equalInstancesByName(s.Fixtures.Instances, instances)
}
func (s *InstancesTestSuite) TestListPoolInstancesInvalidPoolID() {
_, err := s.Store.ListPoolInstances(s.adminCtx, "dummy-pool-id")
_, err := s.Store.ListPoolInstances(s.adminCtx, "dummy-pool-id", false)
s.Require().Equal("error parsing id: invalid request", err.Error())
}

View file

@ -121,6 +121,14 @@ type Pool struct {
GitHubRunnerGroup string
EnableShell bool
// Generation holds the numeric generation of the pool. This number
// will be incremented, every time certain settings of the pool, which
// may influence how runners are created (flavor, specs, image) are changed.
// When a runner is created, this generation will be copied to the runners as
// well. That way if some settings diverge, we can target those runners
// to be recreated.
Generation uint64
RepoID *uuid.UUID `gorm:"index"`
Repository Repository `gorm:"foreignKey:RepoID;"`
@ -178,6 +186,13 @@ type ScaleSet struct {
ExtraSpecs datatypes.JSON
EnableShell bool
// Generation is the scaleset generation at the time of creating this instance.
// This field is to track a divergence between when the instance was created
// and the settings currently set on a scaleset. We can then use this field to know
// if the instance is out of date with the scaleset, allowing us to remove it if we
// need to.
Generation uint64
RepoID *uuid.UUID `gorm:"index"`
Repository Repository `gorm:"foreignKey:RepoID;"`
@ -336,6 +351,12 @@ type Instance struct {
GitHubRunnerGroup string
AditionalLabels datatypes.JSON
Capabilities datatypes.JSON
// Generation is the pool generation at the time of creating this instance.
// This field is to track a divergence between when the instance was created
// and the settings currently set on a pool. We can then use this field to know
// if the instance is out of date with the pool, allowing us to remove it if we
// need to.
Generation uint64
PoolID *uuid.UUID
Pool Pool `gorm:"foreignKey:PoolID"`

View file

@ -150,7 +150,7 @@ func (s *PoolsTestSuite) TestListAllPools() {
func (s *PoolsTestSuite) TestListAllPoolsDBFetchErr() {
s.Fixtures.SQLMock.
ExpectQuery(regexp.QuoteMeta("SELECT `pools`.`id`,`pools`.`created_at`,`pools`.`updated_at`,`pools`.`deleted_at`,`pools`.`provider_name`,`pools`.`runner_prefix`,`pools`.`max_runners`,`pools`.`min_idle_runners`,`pools`.`runner_bootstrap_timeout`,`pools`.`image`,`pools`.`flavor`,`pools`.`os_type`,`pools`.`os_arch`,`pools`.`enabled`,`pools`.`git_hub_runner_group`,`pools`.`enable_shell`,`pools`.`repo_id`,`pools`.`org_id`,`pools`.`enterprise_id`,`pools`.`template_id`,`pools`.`priority` FROM `pools` WHERE `pools`.`deleted_at` IS NULL")).
ExpectQuery(regexp.QuoteMeta("SELECT `pools`.`id`,`pools`.`created_at`,`pools`.`updated_at`,`pools`.`deleted_at`,`pools`.`provider_name`,`pools`.`runner_prefix`,`pools`.`max_runners`,`pools`.`min_idle_runners`,`pools`.`runner_bootstrap_timeout`,`pools`.`image`,`pools`.`flavor`,`pools`.`os_type`,`pools`.`os_arch`,`pools`.`enabled`,`pools`.`git_hub_runner_group`,`pools`.`enable_shell`,`pools`.`generation`,`pools`.`repo_id`,`pools`.`org_id`,`pools`.`enterprise_id`,`pools`.`template_id`,`pools`.`priority` FROM `pools` WHERE `pools`.`deleted_at` IS NULL")).
WillReturnError(fmt.Errorf("mocked fetching all pools error"))
_, err := s.StoreSQLMocked.ListAllPools(s.adminCtx)

View file

@ -65,9 +65,13 @@ func (s *sqlDatabase) CreateScaleSetInstance(_ context.Context, scaleSetID uint,
return s.sqlToParamsInstance(newInstance)
}
func (s *sqlDatabase) ListScaleSetInstances(_ context.Context, scalesetID uint) ([]params.Instance, error) {
func (s *sqlDatabase) ListScaleSetInstances(_ context.Context, scalesetID uint, outdatedOnly bool) ([]params.Instance, error) {
ret, err := s.listInstancesBatched(func(query *gorm.DB) *gorm.DB {
return query.Where("scale_set_fk_id = ?", scalesetID)
q := query.Where("scale_set_fk_id = ?", scalesetID)
if outdatedOnly {
q = q.Where("instances.generation < (SELECT scale_sets.generation FROM scale_sets WHERE scale_sets.id = instances.scale_set_fk_id)")
}
return q
})
if err != nil {
return nil, fmt.Errorf("failed to list scaleset instances: %w", err)

View file

@ -284,6 +284,7 @@ func (s *sqlDatabase) getEntityScaleSet(tx *gorm.DB, entityType params.ForgeEnti
}
func (s *sqlDatabase) updateScaleSet(tx *gorm.DB, scaleSet ScaleSet, param params.UpdateScaleSetParams) (params.ScaleSet, error) {
incrementGeneration := false
if param.Enabled != nil && scaleSet.Enabled != *param.Enabled {
scaleSet.Enabled = *param.Enabled
}
@ -306,6 +307,7 @@ func (s *sqlDatabase) updateScaleSet(tx *gorm.DB, scaleSet ScaleSet, param param
if param.EnableShell != nil {
scaleSet.EnableShell = *param.EnableShell
incrementGeneration = true
}
if param.Name != "" {
@ -314,14 +316,17 @@ func (s *sqlDatabase) updateScaleSet(tx *gorm.DB, scaleSet ScaleSet, param param
if param.GitHubRunnerGroup != nil && *param.GitHubRunnerGroup != "" {
scaleSet.GitHubRunnerGroup = *param.GitHubRunnerGroup
incrementGeneration = true
}
if param.Flavor != "" {
scaleSet.Flavor = param.Flavor
incrementGeneration = true
}
if param.Image != "" {
scaleSet.Image = param.Image
incrementGeneration = true
}
if param.Prefix != "" {
@ -338,22 +343,25 @@ func (s *sqlDatabase) updateScaleSet(tx *gorm.DB, scaleSet ScaleSet, param param
if param.OSArch != "" {
scaleSet.OSArch = param.OSArch
incrementGeneration = true
}
if param.OSType != "" {
scaleSet.OSType = param.OSType
incrementGeneration = true
}
if param.ExtraSpecs != nil {
scaleSet.ExtraSpecs = datatypes.JSON(param.ExtraSpecs)
incrementGeneration = true
}
if param.RunnerBootstrapTimeout != nil && *param.RunnerBootstrapTimeout > 0 {
scaleSet.RunnerBootstrapTimeout = *param.RunnerBootstrapTimeout
}
if param.GitHubRunnerGroup != nil {
scaleSet.GitHubRunnerGroup = *param.GitHubRunnerGroup
if incrementGeneration {
scaleSet.Generation++
}
if q := tx.Save(&scaleSet); q.Error != nil {

View file

@ -356,7 +356,7 @@ func (s *ScaleSetsTestSuite) TestScaleSetOperations() {
})
s.T().Run("List repo scale set instances", func(_ *testing.T) {
instances, err := s.Store.ListScaleSetInstances(s.adminCtx, repoScaleSet.ID)
instances, err := s.Store.ListScaleSetInstances(s.adminCtx, repoScaleSet.ID, false)
s.Require().NoError(err)
s.Require().NotEmpty(instances)
s.Require().Len(instances, 1)

View file

@ -75,6 +75,7 @@ func (s *sqlDatabase) sqlToParamsInstance(instance Instance) (params.Instance, e
GitHubRunnerGroup: instance.GitHubRunnerGroup,
AditionalLabels: labels,
Heartbeat: instance.Heartbeat,
Generation: instance.Generation,
}
if len(instance.Capabilities) > 0 {
@ -299,6 +300,7 @@ func (s *sqlDatabase) sqlToCommonPool(pool Pool) (params.Pool, error) {
CreatedAt: pool.CreatedAt,
UpdatedAt: pool.UpdatedAt,
EnableShell: pool.EnableShell,
Generation: pool.Generation,
}
if pool.TemplateID != nil && *pool.TemplateID != 0 {
@ -376,6 +378,7 @@ func (s *sqlDatabase) sqlToCommonScaleSet(scaleSet ScaleSet) (params.ScaleSet, e
LastMessageID: scaleSet.LastMessageID,
DesiredRunnerCount: scaleSet.DesiredRunnerCount,
EnableShell: scaleSet.EnableShell,
Generation: scaleSet.Generation,
}
if scaleSet.TemplateID != nil && *scaleSet.TemplateID != 0 {
@ -539,20 +542,24 @@ func (s *sqlDatabase) getOrCreateTag(tx *gorm.DB, tagName string) (Tag, error) {
}
func (s *sqlDatabase) updatePool(tx *gorm.DB, pool Pool, param params.UpdatePoolParams) (params.Pool, error) {
incrementGeneration := false
if param.Enabled != nil && pool.Enabled != *param.Enabled {
pool.Enabled = *param.Enabled
}
if param.Flavor != "" {
pool.Flavor = param.Flavor
incrementGeneration = true
}
if param.EnableShell != nil {
pool.EnableShell = *param.EnableShell
incrementGeneration = true
}
if param.Image != "" {
pool.Image = param.Image
incrementGeneration = true
}
if param.Prefix != "" {
@ -573,14 +580,17 @@ func (s *sqlDatabase) updatePool(tx *gorm.DB, pool Pool, param params.UpdatePool
if param.OSArch != "" {
pool.OSArch = param.OSArch
incrementGeneration = true
}
if param.OSType != "" {
pool.OSType = param.OSType
incrementGeneration = true
}
if param.ExtraSpecs != nil {
pool.ExtraSpecs = datatypes.JSON(param.ExtraSpecs)
incrementGeneration = true
}
if param.RunnerBootstrapTimeout != nil && *param.RunnerBootstrapTimeout > 0 {
@ -589,12 +599,17 @@ func (s *sqlDatabase) updatePool(tx *gorm.DB, pool Pool, param params.UpdatePool
if param.GitHubRunnerGroup != nil {
pool.GitHubRunnerGroup = *param.GitHubRunnerGroup
incrementGeneration = true
}
if param.Priority != nil {
pool.Priority = *param.Priority
}
if incrementGeneration {
pool.Generation++
}
if q := tx.Save(&pool); q.Error != nil {
return params.Pool{}, fmt.Errorf("error saving database entry: %w", q.Error)
}

View file

@ -364,6 +364,13 @@ type Instance struct {
Heartbeat time.Time `json:"heartbeat"`
Capabilities AgentCapabilities `json:"capabilities"`
// Generation is the pool generation at the time of creating this instance.
// This field is to track a divergence between when the instance was created
// and the settings currently set on a pool. We can then use this field to know
// if the instance is out of date with the pool, allowing us to remove it if we
// need to.
Generation uint64 `json:"generation"`
// Do not serialize sensitive info.
CallbackURL string `json:"-"`
MetadataURL string `json:"-"`
@ -474,6 +481,14 @@ type Pool struct {
Instances []Instance `json:"instances,omitempty"`
EnableShell bool `json:"enable_shell"`
// Generation holds the numeric generation of the pool. This number
// will be incremented, every time certain settings of the pool, which
// may influence how runners are created (flavor, specs, image) are changed.
// When a runner is created, this generation will be copied to the runners as
// well. That way if some settings diverge, we can target those runners
// to be recreated.
Generation uint64 `json:"generation"`
RepoID string `json:"repo_id,omitempty"`
RepoName string `json:"repo_name,omitempty"`
@ -630,6 +645,14 @@ type ScaleSet struct {
DesiredRunnerCount int `json:"desired_runner_count,omitempty"`
EnableShell bool `json:"enable_shell"`
// Generation holds the numeric generation of the scaleset. This number
// will be incremented, every time certain settings of the scaleset, which
// may influence how runners are created (flavor, specs, image) are changed.
// When a runner is created, this generation will be copied to the runners as
// well. That way if some settings diverge, we can target those runners
// to be recreated.
Generation uint64 `json:"generation"`
Endpoint ForgeEndpoint `json:"endpoint,omitempty"`
RunnerBootstrapTimeout uint `json:"runner_bootstrap_timeout,omitempty"`

View file

@ -196,6 +196,7 @@ type CreateInstanceParams struct {
AgentID int64 `json:"-"`
AditionalLabels []string `json:"aditional_labels,omitempty"`
JitConfiguration map[string]string `json:"jit_configuration,omitempty"`
Generation uint64 `json:"generation"`
}
// swagger:model CreatePoolParams

View file

@ -166,7 +166,6 @@ func (s *GARMToolsTestSuite) TestCreateGARMToolSuccess() {
s.Equal(param.Description, tool.Description)
s.Equal(int64(len(content)), tool.Size)
// Verify tags (should include origin=manual)
expectedTags := []string{
"category=garm-agent",
"os_type=linux",

View file

@ -798,6 +798,7 @@ func (r *basePoolManager) AddRunner(ctx context.Context, poolID string, aditiona
GitHubRunnerGroup: pool.GitHubRunnerGroup,
AditionalLabels: aditionalLabels,
JitConfiguration: jitConfig,
Generation: pool.Generation,
}
if runner != nil {
@ -1050,7 +1051,7 @@ func (r *basePoolManager) scaleDownOnePool(ctx context.Context, pool params.Pool
return nil
}
existingInstances, err := r.store.ListPoolInstances(r.ctx, pool.ID)
existingInstances, err := r.store.ListPoolInstances(r.ctx, pool.ID, false)
if err != nil {
return fmt.Errorf("failed to ensure minimum idle workers for pool %s: %w", pool.ID, err)
}
@ -1156,7 +1157,7 @@ func (r *basePoolManager) ensureIdleRunnersForOnePool(pool params.Pool) error {
return nil
}
existingInstances, err := r.store.ListPoolInstances(r.ctx, pool.ID)
existingInstances, err := r.store.ListPoolInstances(r.ctx, pool.ID, false)
if err != nil {
return fmt.Errorf("failed to ensure minimum idle workers for pool %s: %w", pool.ID, err)
}
@ -1213,7 +1214,7 @@ func (r *basePoolManager) retryFailedInstancesForOnePool(ctx context.Context, po
ctx, "running retry failed instances for pool",
"pool_id", pool.ID)
existingInstances, err := r.store.ListPoolInstances(r.ctx, pool.ID)
existingInstances, err := r.store.ListPoolInstances(r.ctx, pool.ID, false)
if err != nil {
return fmt.Errorf("failed to list instances for pool %s: %w", pool.ID, err)
}

View file

@ -364,12 +364,12 @@ func (r *Runner) ListRepoPools(ctx context.Context, repoID string) ([]params.Poo
return pools, nil
}
func (r *Runner) ListPoolInstances(ctx context.Context, poolID string) ([]params.Instance, error) {
func (r *Runner) ListPoolInstances(ctx context.Context, poolID string, outdatedOnly bool) ([]params.Instance, error) {
if !auth.IsAdmin(ctx) {
return nil, runnerErrors.ErrUnauthorized
}
instances, err := r.store.ListPoolInstances(ctx, poolID)
instances, err := r.store.ListPoolInstances(ctx, poolID, outdatedOnly)
if err != nil {
return []params.Instance{}, fmt.Errorf("error fetching instances: %w", err)
}

View file

@ -605,14 +605,14 @@ func (s *RepoTestSuite) TestListPoolInstances() {
poolInstances = append(poolInstances, instance)
}
instances, err := s.Runner.ListPoolInstances(s.Fixtures.AdminContext, pool.ID)
instances, err := s.Runner.ListPoolInstances(s.Fixtures.AdminContext, pool.ID, false)
s.Require().Nil(err)
garmTesting.EqualDBEntityID(s.T(), poolInstances, instances)
}
func (s *RepoTestSuite) TestListPoolInstancesErrUnauthorized() {
_, err := s.Runner.ListPoolInstances(context.Background(), "dummy-pool-id")
_, err := s.Runner.ListPoolInstances(context.Background(), "dummy-pool-id", false)
s.Require().Equal(runnerErrors.ErrUnauthorized, err)
}

View file

@ -288,12 +288,12 @@ func (r *Runner) CreateEntityScaleSet(ctx context.Context, entityType params.For
return scaleSet, nil
}
func (r *Runner) ListScaleSetInstances(ctx context.Context, scalesetID uint) ([]params.Instance, error) {
func (r *Runner) ListScaleSetInstances(ctx context.Context, scalesetID uint, outdatedOnly bool) ([]params.Instance, error) {
if !auth.IsAdmin(ctx) {
return nil, runnerErrors.ErrUnauthorized
}
instances, err := r.store.ListScaleSetInstances(ctx, scalesetID)
instances, err := r.store.ListScaleSetInstances(ctx, scalesetID, outdatedOnly)
if err != nil {
return []params.Instance{}, fmt.Errorf("error fetching instances: %w", err)
}

View file

@ -1391,6 +1391,12 @@ export interface GARMAgentTool {
* @memberof GARMAgentTool
*/
'size'?: number;
/**
* Source indicates where this tool is currently stored. \"local\" means the tool is stored in the internal object store. \"upstream\" means the tool is only available from the upstream cached release and has not been downloaded locally.
* @type {string}
* @memberof GARMAgentTool
*/
'source'?: string;
/**
*
* @type {string}
@ -1519,6 +1525,12 @@ export interface GARMAgentToolsPaginatedResponseResultsInner {
* @memberof GARMAgentToolsPaginatedResponseResultsInner
*/
'size'?: number;
/**
* Source indicates where this tool is currently stored. \"local\" means the tool is stored in the internal object store. \"upstream\" means the tool is only available from the upstream cached release and has not been downloaded locally.
* @type {string}
* @memberof GARMAgentToolsPaginatedResponseResultsInner
*/
'source'?: string;
/**
*
* @type {string}
@ -1687,6 +1699,12 @@ export interface Instance {
* @memberof Instance
*/
'created_at'?: string;
/**
* Generation is the pool generation at the time of creating this instance. This field is to track a divergence between when the instance was created and the settings currently set on a pool. We can then use this field to know if the instance is out of date with the pool, allowing us to remove it if we need to.
* @type {number}
* @memberof Instance
*/
'generation'?: number;
/**
* GithubRunnerGroup is the github runner group to which the runner belongs. The runner group must be created by someone with access to the enterprise.
* @type {string}
@ -2253,6 +2271,12 @@ export interface Pool {
* @memberof Pool
*/
'flavor'?: string;
/**
* Generation holds the numeric generation of the pool. This number will be incremented, every time certain settings of the pool, which may influence how runners are created (flavor, specs, image) are changed. When a runner is created, this generation will be copied to the runners as well. That way if some settings diverge, we can target those runners to be recreated.
* @type {number}
* @memberof Pool
*/
'generation'?: number;
/**
* GithubRunnerGroup is the github runner group in which the runners will be added. The runner group must be created by someone with access to the enterprise.
* @type {string}
@ -2662,6 +2686,12 @@ export interface ScaleSet {
* @memberof ScaleSet
*/
'flavor'?: string;
/**
* Generation holds the numeric generation of the scaleset. This number will be incremented, every time certain settings of the scaleset, which may influence how runners are created (flavor, specs, image) are changed. When a runner is created, this generation will be copied to the runners as well. That way if some settings diverge, we can target those runners to be recreated.
* @type {number}
* @memberof ScaleSet
*/
'generation'?: number;
/**
* GithubRunnerGroup is the github runner group in which the runners will be added. The runner group must be created by someone with access to the enterprise.
* @type {string}
@ -7082,10 +7112,11 @@ export const InstancesApiAxiosParamCreator = function (configuration?: Configura
*
* @summary List runner instances in a pool.
* @param {string} poolID Runner pool ID.
* @param {boolean} [outdatedOnly] List only instances that were created prior to a pool update that changed a setting which influences how instances are created (image, flavor, runner group, etc).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listPoolInstances: async (poolID: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
listPoolInstances: async (poolID: string, outdatedOnly?: boolean, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'poolID' is not null or undefined
assertParamExists('listPoolInstances', 'poolID', poolID)
const localVarPath = `/pools/{poolID}/instances`
@ -7104,6 +7135,10 @@ export const InstancesApiAxiosParamCreator = function (configuration?: Configura
// authentication Bearer required
await setApiKeyToObject(localVarHeaderParameter, "Authorization", configuration)
if (outdatedOnly !== undefined) {
localVarQueryParameter['outdatedOnly'] = outdatedOnly;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -7156,10 +7191,11 @@ export const InstancesApiAxiosParamCreator = function (configuration?: Configura
*
* @summary List runner instances in a scale set.
* @param {string} scalesetID Runner scale set ID.
* @param {boolean} [outdatedOnly] List only instances that were created prior to a scaleset update that changed a setting which influences how instances are created (image, flavor, runner group, etc).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listScaleSetInstances: async (scalesetID: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
listScaleSetInstances: async (scalesetID: string, outdatedOnly?: boolean, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'scalesetID' is not null or undefined
assertParamExists('listScaleSetInstances', 'scalesetID', scalesetID)
const localVarPath = `/scalesets/{scalesetID}/instances`
@ -7178,6 +7214,10 @@ export const InstancesApiAxiosParamCreator = function (configuration?: Configura
// authentication Bearer required
await setApiKeyToObject(localVarHeaderParameter, "Authorization", configuration)
if (outdatedOnly !== undefined) {
localVarQueryParameter['outdatedOnly'] = outdatedOnly;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -7269,11 +7309,12 @@ export const InstancesApiFp = function(configuration?: Configuration) {
*
* @summary List runner instances in a pool.
* @param {string} poolID Runner pool ID.
* @param {boolean} [outdatedOnly] List only instances that were created prior to a pool update that changed a setting which influences how instances are created (image, flavor, runner group, etc).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listPoolInstances(poolID: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<Instance>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listPoolInstances(poolID, options);
async listPoolInstances(poolID: string, outdatedOnly?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<Instance>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listPoolInstances(poolID, outdatedOnly, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['InstancesApi.listPoolInstances']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
@ -7295,11 +7336,12 @@ export const InstancesApiFp = function(configuration?: Configuration) {
*
* @summary List runner instances in a scale set.
* @param {string} scalesetID Runner scale set ID.
* @param {boolean} [outdatedOnly] List only instances that were created prior to a scaleset update that changed a setting which influences how instances are created (image, flavor, runner group, etc).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listScaleSetInstances(scalesetID: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<Instance>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listScaleSetInstances(scalesetID, options);
async listScaleSetInstances(scalesetID: string, outdatedOnly?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<Instance>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listScaleSetInstances(scalesetID, outdatedOnly, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['InstancesApi.listScaleSetInstances']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
@ -7369,11 +7411,12 @@ export const InstancesApiFactory = function (configuration?: Configuration, base
*
* @summary List runner instances in a pool.
* @param {string} poolID Runner pool ID.
* @param {boolean} [outdatedOnly] List only instances that were created prior to a pool update that changed a setting which influences how instances are created (image, flavor, runner group, etc).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listPoolInstances(poolID: string, options?: RawAxiosRequestConfig): AxiosPromise<Array<Instance>> {
return localVarFp.listPoolInstances(poolID, options).then((request) => request(axios, basePath));
listPoolInstances(poolID: string, outdatedOnly?: boolean, options?: RawAxiosRequestConfig): AxiosPromise<Array<Instance>> {
return localVarFp.listPoolInstances(poolID, outdatedOnly, options).then((request) => request(axios, basePath));
},
/**
*
@ -7389,11 +7432,12 @@ export const InstancesApiFactory = function (configuration?: Configuration, base
*
* @summary List runner instances in a scale set.
* @param {string} scalesetID Runner scale set ID.
* @param {boolean} [outdatedOnly] List only instances that were created prior to a scaleset update that changed a setting which influences how instances are created (image, flavor, runner group, etc).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listScaleSetInstances(scalesetID: string, options?: RawAxiosRequestConfig): AxiosPromise<Array<Instance>> {
return localVarFp.listScaleSetInstances(scalesetID, options).then((request) => request(axios, basePath));
listScaleSetInstances(scalesetID: string, outdatedOnly?: boolean, options?: RawAxiosRequestConfig): AxiosPromise<Array<Instance>> {
return localVarFp.listScaleSetInstances(scalesetID, outdatedOnly, options).then((request) => request(axios, basePath));
},
};
};
@ -7470,12 +7514,13 @@ export class InstancesApi extends BaseAPI {
*
* @summary List runner instances in a pool.
* @param {string} poolID Runner pool ID.
* @param {boolean} [outdatedOnly] List only instances that were created prior to a pool update that changed a setting which influences how instances are created (image, flavor, runner group, etc).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof InstancesApi
*/
public listPoolInstances(poolID: string, options?: RawAxiosRequestConfig) {
return InstancesApiFp(this.configuration).listPoolInstances(poolID, options).then((request) => request(this.axios, this.basePath));
public listPoolInstances(poolID: string, outdatedOnly?: boolean, options?: RawAxiosRequestConfig) {
return InstancesApiFp(this.configuration).listPoolInstances(poolID, outdatedOnly, options).then((request) => request(this.axios, this.basePath));
}
/**
@ -7494,12 +7539,13 @@ export class InstancesApi extends BaseAPI {
*
* @summary List runner instances in a scale set.
* @param {string} scalesetID Runner scale set ID.
* @param {boolean} [outdatedOnly] List only instances that were created prior to a scaleset update that changed a setting which influences how instances are created (image, flavor, runner group, etc).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof InstancesApi
*/
public listScaleSetInstances(scalesetID: string, options?: RawAxiosRequestConfig) {
return InstancesApiFp(this.configuration).listScaleSetInstances(scalesetID, options).then((request) => request(this.axios, this.basePath));
public listScaleSetInstances(scalesetID: string, outdatedOnly?: boolean, options?: RawAxiosRequestConfig) {
return InstancesApiFp(this.configuration).listScaleSetInstances(scalesetID, outdatedOnly, options).then((request) => request(this.axios, this.basePath));
}
}
@ -13658,13 +13704,14 @@ export const ToolsApiAxiosParamCreator = function (configuration?: Configuration
return {
/**
*
* @summary List GARM agent tools.
* @summary List GARM agent tools for admin users.
* @param {number} [page] The page at which to list.
* @param {number} [pageSize] Number of items per page.
* @param {boolean} [upstream] If true, list tools from the upstream cached release instead of the local object store.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
garmAgentList: async (page?: number, pageSize?: number, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
adminGarmAgentList: async (page?: number, pageSize?: number, upstream?: boolean, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/tools/garm-agent`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -13688,6 +13735,10 @@ export const ToolsApiAxiosParamCreator = function (configuration?: Configuration
localVarQueryParameter['pageSize'] = pageSize;
}
if (upstream !== undefined) {
localVarQueryParameter['upstream'] = upstream;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -13744,16 +13795,17 @@ export const ToolsApiFp = function(configuration?: Configuration) {
return {
/**
*
* @summary List GARM agent tools.
* @summary List GARM agent tools for admin users.
* @param {number} [page] The page at which to list.
* @param {number} [pageSize] Number of items per page.
* @param {boolean} [upstream] If true, list tools from the upstream cached release instead of the local object store.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async garmAgentList(page?: number, pageSize?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<GARMAgentToolsPaginatedResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.garmAgentList(page, pageSize, options);
async adminGarmAgentList(page?: number, pageSize?: number, upstream?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<GARMAgentToolsPaginatedResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.adminGarmAgentList(page, pageSize, upstream, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['ToolsApi.garmAgentList']?.[localVarOperationServerIndex]?.url;
const localVarOperationServerBasePath = operationServerMap['ToolsApi.adminGarmAgentList']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
@ -13780,14 +13832,15 @@ export const ToolsApiFactory = function (configuration?: Configuration, basePath
return {
/**
*
* @summary List GARM agent tools.
* @summary List GARM agent tools for admin users.
* @param {number} [page] The page at which to list.
* @param {number} [pageSize] Number of items per page.
* @param {boolean} [upstream] If true, list tools from the upstream cached release instead of the local object store.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
garmAgentList(page?: number, pageSize?: number, options?: RawAxiosRequestConfig): AxiosPromise<GARMAgentToolsPaginatedResponse> {
return localVarFp.garmAgentList(page, pageSize, options).then((request) => request(axios, basePath));
adminGarmAgentList(page?: number, pageSize?: number, upstream?: boolean, options?: RawAxiosRequestConfig): AxiosPromise<GARMAgentToolsPaginatedResponse> {
return localVarFp.adminGarmAgentList(page, pageSize, upstream, options).then((request) => request(axios, basePath));
},
/**
* Uploads a GARM agent tool for a specific OS and architecture. This will automatically replace any existing tool for the same OS/architecture combination. Uses custom headers for metadata: X-Tool-Name: Name of the tool X-Tool-Description: Description X-Tool-OS-Type: OS type (linux or windows) X-Tool-OS-Arch: Architecture (amd64 or arm64) X-Tool-Version: Version string
@ -13810,15 +13863,16 @@ export const ToolsApiFactory = function (configuration?: Configuration, basePath
export class ToolsApi extends BaseAPI {
/**
*
* @summary List GARM agent tools.
* @summary List GARM agent tools for admin users.
* @param {number} [page] The page at which to list.
* @param {number} [pageSize] Number of items per page.
* @param {boolean} [upstream] If true, list tools from the upstream cached release instead of the local object store.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ToolsApi
*/
public garmAgentList(page?: number, pageSize?: number, options?: RawAxiosRequestConfig) {
return ToolsApiFp(this.configuration).garmAgentList(page, pageSize, options).then((request) => request(this.axios, this.basePath));
public adminGarmAgentList(page?: number, pageSize?: number, upstream?: boolean, options?: RawAxiosRequestConfig) {
return ToolsApiFp(this.configuration).adminGarmAgentList(page, pageSize, upstream, options).then((request) => request(this.axios, this.basePath));
}
/**

View file

@ -840,6 +840,14 @@ definitions:
format: int64
type: integer
x-go-name: Size
source:
description: |-
Source indicates where this tool is currently stored.
"local" means the tool is stored in the internal object store.
"upstream" means the tool is only available from the upstream
cached release and has not been downloaded locally.
type: string
x-go-name: Source
updated_at:
format: date-time
type: string
@ -911,6 +919,14 @@ definitions:
format: int64
type: integer
x-go-name: Size
source:
description: |-
Source indicates where this tool is currently stored.
"local" means the tool is stored in the internal object store.
"upstream" means the tool is only available from the upstream
cached release and has not been downloaded locally.
type: string
x-go-name: Source
updated_at:
format: date-time
type: string
@ -1025,6 +1041,16 @@ definitions:
format: date-time
type: string
x-go-name: CreatedAt
generation:
description: |-
Generation is the pool generation at the time of creating this instance.
This field is to track a divergence between when the instance was created
and the settings currently set on a pool. We can then use this field to know
if the instance is out of date with the pool, allowing us to remove it if we
need to.
format: uint64
type: integer
x-go-name: Generation
github-runner-group:
description: |-
GithubRunnerGroup is the github runner group to which the runner belongs.
@ -1441,6 +1467,17 @@ definitions:
flavor:
type: string
x-go-name: Flavor
generation:
description: |-
Generation holds the numeric generation of the pool. This number
will be incremented, every time certain settings of the pool, which
may influence how runners are created (flavor, specs, image) are changed.
When a runner is created, this generation will be copied to the runners as
well. That way if some settings diverge, we can target those runners
to be recreated.
format: uint64
type: integer
x-go-name: Generation
github-runner-group:
description: |-
GithubRunnerGroup is the github runner group in which the runners will be added.
@ -1710,6 +1747,17 @@ definitions:
flavor:
type: string
x-go-name: Flavor
generation:
description: |-
Generation holds the numeric generation of the scaleset. This number
will be incremented, every time certain settings of the scaleset, which
may influence how runners are created (flavor, specs, image) are changed.
When a runner is created, this generation will be copied to the runners as
well. That way if some settings diverge, we can target those runners
to be recreated.
format: uint64
type: integer
x-go-name: Generation
github-runner-group:
description: |-
GithubRunnerGroup is the github runner group in which the runners will be added.
@ -3675,6 +3723,10 @@ paths:
name: poolID
required: true
type: string
- description: List only instances that were created prior to a pool update that changed a setting which influences how instances are created (image, flavor, runner group, etc).
in: query
name: outdatedOnly
type: boolean
responses:
"200":
description: Instances
@ -4188,6 +4240,10 @@ paths:
name: scalesetID
required: true
type: string
- description: List only instances that were created prior to a scaleset update that changed a setting which influences how instances are created (image, flavor, runner group, etc).
in: query
name: outdatedOnly
type: boolean
responses:
"200":
description: Instances
@ -4338,7 +4394,7 @@ paths:
- templates
/tools/garm-agent:
get:
operationId: GarmAgentList
operationId: AdminGarmAgentList
parameters:
- description: The page at which to list.
in: query
@ -4348,6 +4404,10 @@ paths:
in: query
name: pageSize
type: integer
- description: If true, list tools from the upstream cached release instead of the local object store.
in: query
name: upstream
type: boolean
responses:
"200":
description: GARMAgentToolsPaginatedResponse
@ -4357,7 +4417,7 @@ paths:
description: APIErrorResponse
schema:
$ref: '#/definitions/APIErrorResponse'
summary: List GARM agent tools.
summary: List GARM agent tools for admin users.
tags:
- tools
post:

View file

@ -176,7 +176,7 @@ func (w *Worker) Start() (err error) {
return nil
}
instances, err := w.store.ListScaleSetInstances(w.ctx, w.scaleSet.ID)
instances, err := w.store.ListScaleSetInstances(w.ctx, w.scaleSet.ID, false)
if err != nil {
return fmt.Errorf("listing scale set instances: %w", err)
}