From 85eac363d5b81208b34fa56d556d5dd587eafa96 Mon Sep 17 00:00:00 2001 From: Gabriel Adrian Samfira Date: Tue, 8 Apr 2025 09:15:54 +0000 Subject: [PATCH] Add ScaleSet models, functions and types Signed-off-by: Gabriel Adrian Samfira --- database/common/watcher.go | 1 + database/sql/instances.go | 19 ++ database/sql/models.go | 54 +++++ database/sql/scalesets.go | 381 ++++++++++++++++++++++++++++++++++++ database/sql/sql.go | 1 + database/sql/util.go | 78 ++++++++ database/watcher/filters.go | 74 +++++-- params/params.go | 103 ++++++++++ params/requests.go | 73 +++++++ 9 files changed, 772 insertions(+), 12 deletions(-) create mode 100644 database/sql/scalesets.go diff --git a/database/common/watcher.go b/database/common/watcher.go index d8700189..85df1151 100644 --- a/database/common/watcher.go +++ b/database/common/watcher.go @@ -19,6 +19,7 @@ const ( ControllerEntityType DatabaseEntityType = "controller" GithubCredentialsEntityType DatabaseEntityType = "github_credentials" // #nosec G101 GithubEndpointEntityType DatabaseEntityType = "github_endpoint" + ScaleSetEntityType DatabaseEntityType = "scaleset" ) const ( diff --git a/database/sql/instances.go b/database/sql/instances.go index 864e7ba2..65cf0dba 100644 --- a/database/sql/instances.go +++ b/database/sql/instances.go @@ -288,6 +288,25 @@ func (s *sqlDatabase) ListPoolInstances(_ context.Context, poolID string) ([]par return ret, nil } +func (s *sqlDatabase) ListScaleSetInstances(_ context.Context, scalesetID uint) ([]params.Instance, error) { + var instances []Instance + query := s.conn.Model(&Instance{}).Preload("Job").Where("scale_set_id = ?", scalesetID) + + if err := query.Find(&instances); err.Error != nil { + return nil, errors.Wrap(err.Error, "fetching instances") + } + + var err error + ret := make([]params.Instance, len(instances)) + for idx, inst := range instances { + ret[idx], err = s.sqlToParamsInstance(inst) + if err != nil { + return nil, errors.Wrap(err, "converting instance") + } + } + return ret, nil +} + func (s *sqlDatabase) ListAllInstances(_ context.Context) ([]params.Instance, error) { var instances []Instance diff --git a/database/sql/models.go b/database/sql/models.go index ac7a056a..d040760c 100644 --- a/database/sql/models.go +++ b/database/sql/models.go @@ -86,6 +86,54 @@ type Pool struct { Priority uint `gorm:"index:idx_pool_priority"` } +// ScaleSet represents a github scale set. Scale sets are almost identical to pools with a few +// notable exceptions: +// - Labels are no longer relevant +// - Workflows will use the scaleset name to target runners. +// - A scale set is a stand alone unit. If a workflow targets a scale set, no other runner will pick up that job. +type ScaleSet struct { + gorm.Model + + // ScaleSetID is the github ID of the scale set. This field may not be set if + // the scale set was ceated in GARM but has not yet been created in GitHub. + ScaleSetID int `gorm:"index:idx_scale_set"` + Name string `gorm:"index:idx_name"` + DisableUpdate bool + + // State stores the provisioning state of the scale set in GitHub + State params.ScaleSetState + // ExtendedState stores a more detailed message regarding the State. + // If an error occurs, the reason for the error will be stored here. + ExtendedState string + + ProviderName string + RunnerPrefix string + MaxRunners uint + MinIdleRunners uint + RunnerBootstrapTimeout uint + Image string + Flavor string + OSType commonParams.OSType + OSArch commonParams.OSArch + Enabled bool + // ExtraSpecs is an opaque json that gets sent to the provider + // as part of the bootstrap params for instances. It can contain + // any kind of data needed by providers. + ExtraSpecs datatypes.JSON + GitHubRunnerGroup string + + RepoID *uuid.UUID `gorm:"index"` + Repository Repository `gorm:"foreignKey:RepoID;"` + + OrgID *uuid.UUID `gorm:"index"` + Organization Organization `gorm:"foreignKey:OrgID"` + + EnterpriseID *uuid.UUID `gorm:"index"` + Enterprise Enterprise `gorm:"foreignKey:EnterpriseID"` + + Instances []Instance `gorm:"foreignKey:ScaleSetFkID"` +} + type Repository struct { Base @@ -98,6 +146,7 @@ type Repository struct { Name string `gorm:"index:idx_owner_nocase,unique,collate:nocase"` WebhookSecret []byte Pools []Pool `gorm:"foreignKey:RepoID"` + ScaleSets []ScaleSet `gorm:"foreignKey:RepoID"` Jobs []WorkflowJob `gorm:"foreignKey:RepoID;constraint:OnDelete:SET NULL"` PoolBalancerType params.PoolBalancerType `gorm:"type:varchar(64)"` @@ -116,6 +165,7 @@ type Organization struct { Name string `gorm:"index:idx_org_name_nocase,collate:nocase"` WebhookSecret []byte Pools []Pool `gorm:"foreignKey:OrgID"` + ScaleSet []ScaleSet `gorm:"foreignKey:OrgID"` Jobs []WorkflowJob `gorm:"foreignKey:OrgID;constraint:OnDelete:SET NULL"` PoolBalancerType params.PoolBalancerType `gorm:"type:varchar(64)"` @@ -134,6 +184,7 @@ type Enterprise struct { Name string `gorm:"index:idx_ent_name_nocase,collate:nocase"` WebhookSecret []byte Pools []Pool `gorm:"foreignKey:EnterpriseID"` + ScaleSet []ScaleSet `gorm:"foreignKey:EnterpriseID"` Jobs []WorkflowJob `gorm:"foreignKey:EnterpriseID;constraint:OnDelete:SET NULL"` PoolBalancerType params.PoolBalancerType `gorm:"type:varchar(64)"` @@ -187,6 +238,9 @@ type Instance struct { PoolID uuid.UUID Pool Pool `gorm:"foreignKey:PoolID"` + ScaleSetFkID *uint + ScaleSet ScaleSet `gorm:"foreignKey:ScaleSetFkID"` + StatusMessages []InstanceStatusUpdate `gorm:"foreignKey:InstanceID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;"` Job *WorkflowJob `gorm:"foreignKey:InstanceID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;"` diff --git a/database/sql/scalesets.go b/database/sql/scalesets.go new file mode 100644 index 00000000..3a5d8431 --- /dev/null +++ b/database/sql/scalesets.go @@ -0,0 +1,381 @@ +// Copyright 2024 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 sql + +import ( + "context" + "fmt" + + runnerErrors "github.com/cloudbase/garm-provider-common/errors" + "github.com/cloudbase/garm/database/common" + "github.com/cloudbase/garm/params" + "github.com/google/uuid" + "github.com/pkg/errors" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +func (s *sqlDatabase) ListAllScaleSets(_ context.Context) ([]params.ScaleSet, error) { + var scaleSets []ScaleSet + + q := s.conn.Model(&ScaleSet{}). + Preload("Organization"). + Preload("Repository"). + Preload("Enterprise"). + Omit("extra_specs"). + Omit("status_messages"). + Find(&scaleSets) + if q.Error != nil { + return nil, errors.Wrap(q.Error, "fetching all scale sets") + } + + ret := make([]params.ScaleSet, len(scaleSets)) + var err error + for idx, val := range scaleSets { + ret[idx], err = s.sqlToCommonScaleSet(val) + if err != nil { + return nil, errors.Wrap(err, "converting scale sets") + } + } + return ret, nil +} + +func (s *sqlDatabase) CreateEntityScaleSet(_ context.Context, entity params.GithubEntity, param params.CreateScaleSetParams) (scaleSet params.ScaleSet, err error) { + if err := param.Validate(); err != nil { + return params.ScaleSet{}, fmt.Errorf("failed to validate create params: %w", err) + } + + defer func() { + if err == nil { + s.sendNotify(common.ScaleSetEntityType, common.CreateOperation, scaleSet) + } + }() + + newScaleSet := ScaleSet{ + Name: param.Name, + ScaleSetID: param.ScaleSetID, + DisableUpdate: param.DisableUpdate, + ProviderName: param.ProviderName, + RunnerPrefix: param.GetRunnerPrefix(), + MaxRunners: param.MaxRunners, + MinIdleRunners: param.MinIdleRunners, + RunnerBootstrapTimeout: param.RunnerBootstrapTimeout, + Image: param.Image, + Flavor: param.Flavor, + OSType: param.OSType, + OSArch: param.OSArch, + Enabled: param.Enabled, + GitHubRunnerGroup: param.GitHubRunnerGroup, + State: params.ScaleSetPendingCreate, + } + + if len(param.ExtraSpecs) > 0 { + newScaleSet.ExtraSpecs = datatypes.JSON(param.ExtraSpecs) + } + + entityID, err := uuid.Parse(entity.ID) + if err != nil { + return params.ScaleSet{}, errors.Wrap(runnerErrors.ErrBadRequest, "parsing id") + } + + switch entity.EntityType { + case params.GithubEntityTypeRepository: + newScaleSet.RepoID = &entityID + case params.GithubEntityTypeOrganization: + newScaleSet.OrgID = &entityID + case params.GithubEntityTypeEnterprise: + newScaleSet.EnterpriseID = &entityID + } + err = s.conn.Transaction(func(tx *gorm.DB) error { + if err := s.hasGithubEntity(tx, entity.EntityType, entity.ID); err != nil { + return errors.Wrap(err, "checking entity existence") + } + + q := tx.Create(&newScaleSet) + if q.Error != nil { + return errors.Wrap(q.Error, "creating scale set") + } + + return nil + }) + if err != nil { + return params.ScaleSet{}, err + } + + dbScaleSet, err := s.getScaleSetByID(s.conn, newScaleSet.ID, "Instances", "Enterprise", "Organization", "Repository") + if err != nil { + return params.ScaleSet{}, errors.Wrap(err, "fetching scale set") + } + + return s.sqlToCommonScaleSet(dbScaleSet) +} + +func (s *sqlDatabase) listEntityScaleSets(tx *gorm.DB, entityType params.GithubEntityType, entityID string, preload ...string) ([]ScaleSet, error) { + if _, err := uuid.Parse(entityID); err != nil { + return nil, errors.Wrap(runnerErrors.ErrBadRequest, "parsing id") + } + + if err := s.hasGithubEntity(tx, entityType, entityID); err != nil { + return nil, errors.Wrap(err, "checking entity existence") + } + + var preloadEntity string + var fieldName string + switch entityType { + case params.GithubEntityTypeRepository: + fieldName = entityTypeRepoName + preloadEntity = "Repository" + case params.GithubEntityTypeOrganization: + fieldName = entityTypeOrgName + preloadEntity = "Organization" + case params.GithubEntityTypeEnterprise: + fieldName = entityTypeEnterpriseName + preloadEntity = "Enterprise" + default: + return nil, fmt.Errorf("invalid entityType: %v", entityType) + } + + q := tx + q = q.Preload(preloadEntity) + if len(preload) > 0 { + for _, item := range preload { + q = q.Preload(item) + } + } + + var scaleSets []ScaleSet + condition := fmt.Sprintf("%s = ?", fieldName) + err := q.Model(&ScaleSet{}). + Where(condition, entityID). + Omit("extra_specs"). + Omit("status_messages"). + Find(&scaleSets).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return []ScaleSet{}, nil + } + return nil, errors.Wrap(err, "fetching scale sets") + } + + return scaleSets, nil +} + +func (s *sqlDatabase) ListEntityScaleSets(_ context.Context, entity params.GithubEntity) ([]params.ScaleSet, error) { + scaleSets, err := s.listEntityScaleSets(s.conn, entity.EntityType, entity.ID) + if err != nil { + return nil, errors.Wrap(err, "fetching scale sets") + } + + ret := make([]params.ScaleSet, len(scaleSets)) + for idx, set := range scaleSets { + ret[idx], err = s.sqlToCommonScaleSet(set) + if err != nil { + return nil, errors.Wrap(err, "conbverting scale set") + } + } + + return ret, nil +} + +func (s *sqlDatabase) UpdateEntityScaleSet(_ context.Context, entity params.GithubEntity, scaleSetID uint, param params.UpdateScaleSetParams, callback func(old, new params.ScaleSet) error) (updatedScaleSet params.ScaleSet, err error) { + defer func() { + if err == nil { + s.sendNotify(common.ScaleSetEntityType, common.UpdateOperation, updatedScaleSet) + } + }() + err = s.conn.Transaction(func(tx *gorm.DB) error { + scaleSet, err := s.getEntityScaleSet(tx, entity.EntityType, entity.ID, scaleSetID, "Instances") + if err != nil { + return errors.Wrap(err, "fetching scale set") + } + + old, err := s.sqlToCommonScaleSet(scaleSet) + if err != nil { + return errors.Wrap(err, "converting scale set") + } + + updatedScaleSet, err = s.updateScaleSet(tx, scaleSet, param) + if err != nil { + return errors.Wrap(err, "updating scale set") + } + + if callback != nil { + if err := callback(old, updatedScaleSet); err != nil { + return errors.Wrap(err, "executing update callback") + } + } + return nil + }) + if err != nil { + return params.ScaleSet{}, err + } + return updatedScaleSet, nil +} + +func (s *sqlDatabase) getEntityScaleSet(tx *gorm.DB, entityType params.GithubEntityType, entityID string, scaleSetID uint, preload ...string) (ScaleSet, error) { + if entityID == "" { + return ScaleSet{}, errors.Wrap(runnerErrors.ErrBadRequest, "missing entity id") + } + + if scaleSetID == 0 { + return ScaleSet{}, errors.Wrap(runnerErrors.ErrBadRequest, "missing scaleset id") + } + + var fieldName string + var entityField string + switch entityType { + case params.GithubEntityTypeRepository: + fieldName = entityTypeRepoName + entityField = "Repository" + case params.GithubEntityTypeOrganization: + fieldName = entityTypeOrgName + entityField = "Organization" + case params.GithubEntityTypeEnterprise: + fieldName = entityTypeEnterpriseName + entityField = "Enterprise" + default: + return ScaleSet{}, fmt.Errorf("invalid entityType: %v", entityType) + } + + q := tx + q = q.Preload(entityField) + if len(preload) > 0 { + for _, item := range preload { + q = q.Preload(item) + } + } + + var scaleSet ScaleSet + condition := fmt.Sprintf("id = ? and %s = ?", fieldName) + err := q.Model(&ScaleSet{}). + Where(condition, scaleSetID, entityID). + First(&scaleSet).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ScaleSet{}, errors.Wrap(runnerErrors.ErrNotFound, "finding scale set") + } + return ScaleSet{}, errors.Wrap(err, "fetching scale set") + } + + return scaleSet, nil +} + +func (s *sqlDatabase) updateScaleSet(tx *gorm.DB, scaleSet ScaleSet, param params.UpdateScaleSetParams) (params.ScaleSet, error) { + if param.Enabled != nil && scaleSet.Enabled != *param.Enabled { + scaleSet.Enabled = *param.Enabled + } + + if param.State != nil && *param.State != scaleSet.State { + scaleSet.State = *param.State + } + + if param.ExtendedState != nil && *param.ExtendedState != scaleSet.ExtendedState { + scaleSet.ExtendedState = *param.ExtendedState + } + + if param.Name != "" { + scaleSet.Name = param.Name + } + + if param.GitHubRunnerGroup != nil && *param.GitHubRunnerGroup != "" { + scaleSet.GitHubRunnerGroup = *param.GitHubRunnerGroup + } + + if param.Flavor != "" { + scaleSet.Flavor = param.Flavor + } + + if param.Image != "" { + scaleSet.Image = param.Image + } + + if param.Prefix != "" { + scaleSet.RunnerPrefix = param.Prefix + } + + if param.MaxRunners != nil { + scaleSet.MaxRunners = *param.MaxRunners + } + + if param.MinIdleRunners != nil { + scaleSet.MinIdleRunners = *param.MinIdleRunners + } + + if param.OSArch != "" { + scaleSet.OSArch = param.OSArch + } + + if param.OSType != "" { + scaleSet.OSType = param.OSType + } + + if param.ExtraSpecs != nil { + scaleSet.ExtraSpecs = datatypes.JSON(param.ExtraSpecs) + } + + if param.RunnerBootstrapTimeout != nil && *param.RunnerBootstrapTimeout > 0 { + scaleSet.RunnerBootstrapTimeout = *param.RunnerBootstrapTimeout + } + + if param.GitHubRunnerGroup != nil { + scaleSet.GitHubRunnerGroup = *param.GitHubRunnerGroup + } + + if q := tx.Save(&scaleSet); q.Error != nil { + return params.ScaleSet{}, errors.Wrap(q.Error, "saving database entry") + } + + return s.sqlToCommonScaleSet(scaleSet) +} + +func (s *sqlDatabase) GetScaleSetByID(_ context.Context, scaleSet uint) (params.ScaleSet, error) { + set, err := s.getScaleSetByID(s.conn, scaleSet, "Instances", "Enterprise", "Organization", "Repository") + if err != nil { + return params.ScaleSet{}, errors.Wrap(err, "fetching scale set by ID") + } + return s.sqlToCommonScaleSet(set) +} + +func (s *sqlDatabase) DeleteScaleSetByID(ctx context.Context, scaleSetID uint) (err error) { + var scaleSet params.ScaleSet + defer func() { + if err == nil && scaleSet.ID != 0 { + s.sendNotify(common.ScaleSetEntityType, common.DeleteOperation, scaleSet) + } + }() + err = s.conn.Transaction(func(tx *gorm.DB) error { + dbSet, err := s.getScaleSetByID(tx, scaleSetID, "Instances") + if err != nil { + return errors.Wrap(err, "fetching scale set") + } + + if len(dbSet.Instances) > 0 { + return runnerErrors.NewBadRequestError("cannot delete scaleset with runners") + } + scaleSet, err = s.sqlToCommonScaleSet(dbSet) + if err != nil { + return errors.Wrap(err, "converting scale set") + } + + if q := tx.Unscoped().Delete(&dbSet); q.Error != nil { + return errors.Wrap(q.Error, "deleting scale set") + } + return nil + }) + if err != nil { + return errors.Wrap(err, "removing scale set") + } + return nil +} diff --git a/database/sql/sql.go b/database/sql/sql.go index 1a024516..4d23d253 100644 --- a/database/sql/sql.go +++ b/database/sql/sql.go @@ -428,6 +428,7 @@ func (s *sqlDatabase) migrateDB() error { &Instance{}, &ControllerInfo{}, &WorkflowJob{}, + &ScaleSet{}, ); err != nil { return errors.Wrap(err, "running auto migrate") } diff --git a/database/sql/util.go b/database/sql/util.go index cc2bbcb9..c5e412a9 100644 --- a/database/sql/util.go +++ b/database/sql/util.go @@ -73,6 +73,10 @@ func (s *sqlDatabase) sqlToParamsInstance(instance Instance) (params.Instance, e AditionalLabels: labels, } + if instance.ScaleSetFkID != nil { + ret.ScaleSetID = *instance.ScaleSetFkID + } + if instance.Job != nil { paramJob, err := sqlWorkflowJobToParamsJob(*instance.Job) if err != nil { @@ -265,6 +269,60 @@ func (s *sqlDatabase) sqlToCommonPool(pool Pool) (params.Pool, error) { return ret, nil } +func (s *sqlDatabase) sqlToCommonScaleSet(scaleSet ScaleSet) (params.ScaleSet, error) { + ret := params.ScaleSet{ + ID: scaleSet.ID, + ScaleSetID: scaleSet.ScaleSetID, + Name: scaleSet.Name, + DisableUpdate: scaleSet.DisableUpdate, + + ProviderName: scaleSet.ProviderName, + MaxRunners: scaleSet.MaxRunners, + MinIdleRunners: scaleSet.MinIdleRunners, + RunnerPrefix: params.RunnerPrefix{ + Prefix: scaleSet.RunnerPrefix, + }, + Image: scaleSet.Image, + Flavor: scaleSet.Flavor, + OSArch: scaleSet.OSArch, + OSType: scaleSet.OSType, + Enabled: scaleSet.Enabled, + Instances: make([]params.Instance, len(scaleSet.Instances)), + RunnerBootstrapTimeout: scaleSet.RunnerBootstrapTimeout, + ExtraSpecs: json.RawMessage(scaleSet.ExtraSpecs), + GitHubRunnerGroup: scaleSet.GitHubRunnerGroup, + State: scaleSet.State, + ExtendedState: scaleSet.ExtendedState, + } + + if scaleSet.RepoID != nil { + ret.RepoID = scaleSet.RepoID.String() + if scaleSet.Repository.Owner != "" && scaleSet.Repository.Name != "" { + ret.RepoName = fmt.Sprintf("%s/%s", scaleSet.Repository.Owner, scaleSet.Repository.Name) + } + } + + if scaleSet.OrgID != nil && scaleSet.Organization.Name != "" { + ret.OrgID = scaleSet.OrgID.String() + ret.OrgName = scaleSet.Organization.Name + } + + if scaleSet.EnterpriseID != nil && scaleSet.Enterprise.Name != "" { + ret.EnterpriseID = scaleSet.EnterpriseID.String() + ret.EnterpriseName = scaleSet.Enterprise.Name + } + + var err error + for idx, inst := range scaleSet.Instances { + ret.Instances[idx], err = s.sqlToParamsInstance(inst) + if err != nil { + return params.ScaleSet{}, errors.Wrap(err, "converting instance") + } + } + + return ret, nil +} + func (s *sqlDatabase) sqlToCommonTags(tag Tag) params.Tag { return params.Tag{ ID: tag.ID.String(), @@ -452,6 +510,26 @@ func (s *sqlDatabase) getPoolByID(tx *gorm.DB, poolID string, preload ...string) return pool, nil } +func (s *sqlDatabase) getScaleSetByID(tx *gorm.DB, scaleSetID uint, preload ...string) (ScaleSet, error) { + var scaleSet ScaleSet + q := tx.Model(&ScaleSet{}) + if len(preload) > 0 { + for _, item := range preload { + q = q.Preload(item) + } + } + + q = q.Where("id = ?", scaleSetID).First(&scaleSet) + + if q.Error != nil { + if errors.Is(q.Error, gorm.ErrRecordNotFound) { + return ScaleSet{}, runnerErrors.ErrNotFound + } + return ScaleSet{}, errors.Wrap(q.Error, "fetching scale set from database") + } + return scaleSet, nil +} + func (s *sqlDatabase) hasGithubEntity(tx *gorm.DB, entityType params.GithubEntityType, entityID string) error { u, err := uuid.Parse(entityID) if err != nil { diff --git a/database/watcher/filters.go b/database/watcher/filters.go index af1852dc..aa5131b1 100644 --- a/database/watcher/filters.go +++ b/database/watcher/filters.go @@ -5,7 +5,7 @@ import ( "github.com/cloudbase/garm/params" ) -type idGetter interface { +type IDGetter interface { GetID() string } @@ -72,21 +72,41 @@ func WithEntityPoolFilter(ghEntity params.GithubEntity) dbCommon.PayloadFilterFu } switch ghEntity.EntityType { case params.GithubEntityTypeRepository: - if pool.RepoID != ghEntity.ID { - return false - } + return pool.RepoID == ghEntity.ID case params.GithubEntityTypeOrganization: - if pool.OrgID != ghEntity.ID { - return false - } + return pool.OrgID == ghEntity.ID case params.GithubEntityTypeEnterprise: - if pool.EnterpriseID != ghEntity.ID { - return false - } + return pool.EnterpriseID == ghEntity.ID + default: + return false + } + default: + return false + } + } +} + +// WithEntityPoolFilter returns true if the change payload is a pool that belongs to the +// supplied Github entity. This is useful when an entity worker wants to watch for changes +// in pools that belong to it. +func WithEntityScaleSetFilter(ghEntity params.GithubEntity) dbCommon.PayloadFilterFunc { + return func(payload dbCommon.ChangePayload) bool { + switch payload.EntityType { + case dbCommon.ScaleSetEntityType: + scaleSet, ok := payload.Payload.(params.ScaleSet) + if !ok { + return false + } + switch ghEntity.EntityType { + case params.GithubEntityTypeRepository: + return scaleSet.RepoID == ghEntity.ID + case params.GithubEntityTypeOrganization: + return scaleSet.OrgID == ghEntity.ID + case params.GithubEntityTypeEnterprise: + return scaleSet.EnterpriseID == ghEntity.ID default: return false } - return true default: return false } @@ -100,7 +120,7 @@ func WithEntityFilter(entity params.GithubEntity) dbCommon.PayloadFilterFunc { if params.GithubEntityType(payload.EntityType) != entity.EntityType { return false } - var ent idGetter + var ent IDGetter var ok bool switch payload.EntityType { case dbCommon.RepositoryEntityType: @@ -210,3 +230,33 @@ func WithExcludeEntityTypeFilter(entityType dbCommon.DatabaseEntityType) dbCommo return payload.EntityType != entityType } } + +// WithScaleSetFilter returns a filter function that matches a particular scale set. +func WithScaleSetFilter(scaleset params.ScaleSet) dbCommon.PayloadFilterFunc { + return func(payload dbCommon.ChangePayload) bool { + if payload.EntityType != dbCommon.ScaleSetEntityType { + return false + } + + ss, ok := payload.Payload.(params.ScaleSet) + if !ok { + return false + } + + return ss.ID == scaleset.ID + } +} + +func WithScaleSetInstanceFilter(scaleset params.ScaleSet) dbCommon.PayloadFilterFunc { + return func(payload dbCommon.ChangePayload) bool { + if payload.EntityType != dbCommon.InstanceEntityType { + return false + } + + instance, ok := payload.Payload.(params.Instance) + if !ok { + return false + } + return instance.ScaleSetID == scaleset.ID + } +} diff --git a/params/params.go b/params/params.go index 375edc10..68227dd2 100644 --- a/params/params.go +++ b/params/params.go @@ -45,6 +45,7 @@ type ( WebhookEndpointType string GithubAuthType string PoolBalancerType string + ScaleSetState string ) const ( @@ -128,6 +129,14 @@ func (e GithubEntityType) String() string { return string(e) } +const ( + ScaleSetPendingCreate ScaleSetState = "pending_create" + ScaleSetCreated ScaleSetState = "created" + ScaleSetError ScaleSetState = "error" + ScaleSetPendingDelete ScaleSetState = "pending_delete" + ScaleSetPendingForceDelete ScaleSetState = "pending_force_delete" +) + type StatusMessage struct { CreatedAt time.Time `json:"created_at,omitempty"` Message string `json:"message,omitempty"` @@ -179,6 +188,9 @@ type Instance struct { // PoolID is the ID of the garm pool to which a runner belongs. PoolID string `json:"pool_id,omitempty"` + // ScaleSetID is the ID of the scale set to which a runner belongs. + ScaleSetID uint `json:"scale_set_id,omitempty"` + // ProviderFault holds any error messages captured from the IaaS provider that is // responsible for managing the lifecycle of the runner. ProviderFault []byte `json:"provider_fault,omitempty"` @@ -403,6 +415,97 @@ func (p *Pool) HasRequiredLabels(set []string) bool { // used by swagger client generated code type Pools []Pool +type ScaleSet struct { + RunnerPrefix + + ID uint `json:"id,omitempty"` + ScaleSetID int `json:"scale_set_id,omitempty"` + Name string `json:"name,omitempty"` + DisableUpdate bool `json:"disable_update"` + + State ScaleSetState `json:"state"` + ExtendedState string `json:"extended_state,omitempty"` + + ProviderName string `json:"provider_name,omitempty"` + MaxRunners uint `json:"max_runners,omitempty"` + MinIdleRunners uint `json:"min_idle_runners,omitempty"` + Image string `json:"image,omitempty"` + Flavor string `json:"flavor,omitempty"` + OSType commonParams.OSType `json:"os_type,omitempty"` + OSArch commonParams.OSArch `json:"os_arch,omitempty"` + Enabled bool `json:"enabled,omitempty"` + Instances []Instance `json:"instances,omitempty"` + + RunnerBootstrapTimeout uint `json:"runner_bootstrap_timeout,omitempty"` + // ExtraSpecs is an opaque raw json that gets sent to the provider + // as part of the bootstrap params for instances. It can contain + // any kind of data needed by providers. The contents of this field means + // nothing to garm itself. We don't act on the information in this field at + // all. We only validate that it's a proper json. + ExtraSpecs json.RawMessage `json:"extra_specs,omitempty"` + // 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. + GitHubRunnerGroup string `json:"github-runner-group,omitempty"` + + StatusMessages []StatusMessage `json:"status_messages"` + + RepoID string `json:"repo_id,omitempty"` + RepoName string `json:"repo_name,omitempty"` + + OrgID string `json:"org_id,omitempty"` + OrgName string `json:"org_name,omitempty"` + + EnterpriseID string `json:"enterprise_id,omitempty"` + EnterpriseName string `json:"enterprise_name,omitempty"` +} + +func (p ScaleSet) GithubEntity() (GithubEntity, error) { + switch p.ScaleSetType() { + case GithubEntityTypeRepository: + return GithubEntity{ + ID: p.RepoID, + EntityType: GithubEntityTypeRepository, + }, nil + case GithubEntityTypeOrganization: + return GithubEntity{ + ID: p.OrgID, + EntityType: GithubEntityTypeOrganization, + }, nil + case GithubEntityTypeEnterprise: + return GithubEntity{ + ID: p.EnterpriseID, + EntityType: GithubEntityTypeEnterprise, + }, nil + } + return GithubEntity{}, fmt.Errorf("pool has no associated entity") +} + +func (p *ScaleSet) ScaleSetType() GithubEntityType { + switch { + case p.RepoID != "": + return GithubEntityTypeRepository + case p.OrgID != "": + return GithubEntityTypeOrganization + case p.EnterpriseID != "": + return GithubEntityTypeEnterprise + } + return "" +} + +func (p ScaleSet) GetID() uint { + return p.ID +} + +func (p *ScaleSet) RunnerTimeout() uint { + if p.RunnerBootstrapTimeout == 0 { + return appdefaults.DefaultRunnerBootstrapTimeout + } + return p.RunnerBootstrapTimeout +} + +// used by swagger client generated code +type ScaleSets []ScaleSet + type Repository struct { ID string `json:"id,omitempty"` Owner string `json:"owner,omitempty"` diff --git a/params/requests.go b/params/requests.go index c7c46821..1166418f 100644 --- a/params/requests.go +++ b/params/requests.go @@ -533,3 +533,76 @@ func (u UpdateControllerParams) Validate() error { return nil } + +type CreateScaleSetParams struct { + RunnerPrefix + + Name string `json:"name"` + DisableUpdate bool `json:"disable_update"` + ScaleSetID int `json:"scale_set_id"` + + ProviderName string `json:"provider_name,omitempty"` + MaxRunners uint `json:"max_runners,omitempty"` + MinIdleRunners uint `json:"min_idle_runners,omitempty"` + Image string `json:"image,omitempty"` + Flavor string `json:"flavor,omitempty"` + OSType commonParams.OSType `json:"os_type,omitempty"` + OSArch commonParams.OSArch `json:"os_arch,omitempty"` + Tags []string `json:"tags,omitempty"` + Enabled bool `json:"enabled,omitempty"` + RunnerBootstrapTimeout uint `json:"runner_bootstrap_timeout,omitempty"` + ExtraSpecs json.RawMessage `json:"extra_specs,omitempty"` + // GithubRunnerGroup is the github runner group in which the runners of this + // pool will be added to. + // The runner group must be created by someone with access to the enterprise. + GitHubRunnerGroup string `json:"github-runner-group,omitempty"` +} + +func (s *CreateScaleSetParams) Validate() error { + if s.ProviderName == "" { + return fmt.Errorf("missing provider") + } + + if s.MinIdleRunners > s.MaxRunners { + return fmt.Errorf("min_idle_runners cannot be larger than max_runners") + } + + if s.MaxRunners == 0 { + return fmt.Errorf("max_runners cannot be 0") + } + + if s.Flavor == "" { + return fmt.Errorf("missing flavor") + } + + if s.Image == "" { + return fmt.Errorf("missing image") + } + + if s.Name == "" { + return fmt.Errorf("missing scale set name") + } + + return nil +} + +type UpdateScaleSetParams struct { + RunnerPrefix + + Name string `json:"name,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + MaxRunners *uint `json:"max_runners,omitempty"` + MinIdleRunners *uint `json:"min_idle_runners,omitempty"` + RunnerBootstrapTimeout *uint `json:"runner_bootstrap_timeout,omitempty"` + Image string `json:"image,omitempty"` + Flavor string `json:"flavor,omitempty"` + OSType commonParams.OSType `json:"os_type,omitempty"` + OSArch commonParams.OSArch `json:"os_arch,omitempty"` + ExtraSpecs json.RawMessage `json:"extra_specs,omitempty"` + // GithubRunnerGroup is the github runner group in which the runners of this + // pool will be added to. + // The runner group must be created by someone with access to the enterprise. + GitHubRunnerGroup *string `json:"runner_group,omitempty"` + State *ScaleSetState `json:"state"` + ExtendedState *string `json:"extended_state"` +}