From 2f6c6f45dc21e5f37315bce16410f33ee5e3df4f Mon Sep 17 00:00:00 2001 From: Enzo NOCERA Date: Sat, 11 Nov 2023 09:55:26 +0100 Subject: [PATCH 01/10] feat: WIP: Scaleway provider --- providers/scaleway/doc.go | 10 +++ providers/scaleway/v1/config.go | 64 ++++++++++++++++ providers/scaleway/v1/flags.go | 121 ++++++++++++++++++++++++++++++ providers/scaleway/v1/provider.go | 5 ++ 4 files changed, 200 insertions(+) create mode 100644 providers/scaleway/doc.go create mode 100644 providers/scaleway/v1/config.go create mode 100644 providers/scaleway/v1/flags.go create mode 100644 providers/scaleway/v1/provider.go diff --git a/providers/scaleway/doc.go b/providers/scaleway/doc.go new file mode 100644 index 0000000..ebec269 --- /dev/null +++ b/providers/scaleway/doc.go @@ -0,0 +1,10 @@ +// Package scaleway implements a way to use the Scaleway Cloud Provider for your +// Woodpecker CIs. +// +// ## Limitations +// +// For now, we only support deploying on single AZ per agent pool. +// +// Authors: +// - Enzo "raskyld" Nocera +package scaleway diff --git a/providers/scaleway/v1/config.go b/providers/scaleway/v1/config.go new file mode 100644 index 0000000..6ab4c12 --- /dev/null +++ b/providers/scaleway/v1/config.go @@ -0,0 +1,64 @@ +package v1 + +import ( + "github.com/scaleway/scaleway-sdk-go/api/instance/v1" + "github.com/scaleway/scaleway-sdk-go/scw" + "io" +) + +// Config is the Scaleway Provider specific configuration +// NB(raskyld): In the future, I think each provider should be able to +// unmarshal from a config file JSON stream passed by the engine. +// The engine should also provide utilities, for example, a pre-defined +// type that allows the providers to either read hard-coded values +// or retrieve them from the filesystem, e.g. for secrets. +type Config struct { + // ApiToken of Scaleway IAM + // + // Creating a standalone IAM Applications is recommended to segregate + // permissions. + ApiToken io.Reader + InstancePool map[string]InstancePool +} + +// Locality defines a geographical area +// +// Scaleway Cloud has multiple Region that are made of several Zones. +// Exactly one of Zones or Region SHOULD be set, +// if both are set, use Zones and ignore Region +type Locality struct { + Zones []scw.Zone + Region *scw.Region +} + +// InstancePool is a small helper to handle a pool of instances +type InstancePool struct { + // Locality where your instances should live + // The InstancePool scheduler will try to spread your + // instances evenly among Locality.Zones if possible + Locality Locality + // ProjectID where resources should be applied + ProjectID *string + // Prefix is added before each instance name + Prefix string + // Tags added to the placement group and its instances + Tags []string + // DynamicIPRequired: define if a dynamic IPv4 is required for the Instance. + DynamicIPRequired *bool `json:"dynamic_ip_required,omitempty"` + // RoutedIPEnabled: if true, configure the Instance, so it uses the new routed IP mode. + RoutedIPEnabled *bool `json:"routed_ip_enabled,omitempty"` + // CommercialType: define the Instance commercial type (i.e. GP1-S). + CommercialType string `json:"commercial_type,omitempty"` + // Image: instance image ID or label. + Image string `json:"image,omitempty"` + // EnableIPv6: true if IPv6 is enabled on the server. + EnableIPv6 bool `json:"enable_ipv6,omitempty"` + // PublicIPs to attach to your instance indexed per instance.IPType + PublicIPs map[instance.IPType]int + // SecurityGroups to use per zone + SecurityGroups map[scw.Zone]string + // Storage of the block storage associated with your Instances + // It should be a multiple of 512 bytes, in future version we could give + // more customisation over the volumes used by the agents + Storage scw.Size +} diff --git a/providers/scaleway/v1/flags.go b/providers/scaleway/v1/flags.go new file mode 100644 index 0000000..20941aa --- /dev/null +++ b/providers/scaleway/v1/flags.go @@ -0,0 +1,121 @@ +package v1 + +import ( + "bytes" + "errors" + "github.com/scaleway/scaleway-sdk-go/scw" + "github.com/urfave/cli/v2" + "go.woodpecker-ci.org/autoscaler/config" + "os" +) + +const category = "Scaleway" +const flagPrefix = "scw" +const envPrefix = "WOODPECKER_SCW" + +var ProviderFlags = []cli.Flag{ + &cli.StringFlag{ + Name: flagPrefix + "-api-token", + Usage: "Scaleway IAM API Token", + EnvVars: []string{envPrefix + "_API_TOKEN"}, + // NB(raskyld): We should recommend the usage of file-system to users + // Most container runtimes support mounting secrets into the fs + // natively. + FilePath: os.Getenv(envPrefix + "_API_TOKEN_FILE"), + Category: category, + }, + &cli.StringFlag{ + Name: flagPrefix + "-zone", + Usage: "Scaleway Zone where to spawn instances", + EnvVars: []string{envPrefix + "_ZONE"}, + Category: category, + DefaultText: scw.ZoneFrPar2.String(), + }, + &cli.StringFlag{ + Name: flagPrefix + "-instance-type", + Usage: "Scaleway Instance type to spawn", + EnvVars: []string{envPrefix + "_INSTANCE_TYPE"}, + Category: category, + }, + &cli.StringSliceFlag{ + Name: flagPrefix + "-tags", + Usage: "Comma separated list of tags to uniquely identify the instances spawned", + EnvVars: []string{envPrefix + "_TAGS"}, + Category: category, + }, + &cli.StringFlag{ + Name: flagPrefix + "-project", + Usage: "Scaleway Project ID in which to spawn the instances", + EnvVars: []string{envPrefix + "_PROJECT"}, + Category: category, + }, + &cli.StringFlag{ + Name: flagPrefix + "-prefix", + Usage: "Prefix prepended before any Scaleway resource name", + EnvVars: []string{envPrefix + "_PREFIX"}, + Category: category, + DefaultText: "wip-woodpecker-ci-autoscaler", + }, + &cli.BoolFlag{ + Name: flagPrefix + "-enable-ipv6", + Usage: "Enable IPv6 for the instances", + EnvVars: []string{envPrefix + "_ENABLE_IPV6"}, + Category: category, + }, + &cli.BoolFlag{ + Name: flagPrefix + "-image", + Usage: "The base image for your instance", + EnvVars: []string{envPrefix + "_IMAGE"}, + Category: category, + DefaultText: "ubuntu_local", + }, + &cli.Uint64Flag{ + Name: flagPrefix + "-storage-size", + Usage: "How much storage to provision for your agents in bytes", + EnvVars: []string{envPrefix + "_STORAGE_SIZE"}, + Category: category, + DefaultText: "20000000000", + }, +} + +func FromCLI(c *cli.Context, engineConfig *config.Config) (*Config, error) { + if !c.IsSet(flagPrefix + "-instance-type") { + return nil, errors.New("you must specify an instance type") + } + + if !c.IsSet(flagPrefix + "-tags") { + return nil, errors.New("you must specify tags to apply to your resources") + } + + if !c.IsSet(flagPrefix + "-project") { + return nil, errors.New("you must specify in which project resources should be spawned") + } + + zone := scw.Zone(c.String(flagPrefix + "-zone")) + if !zone.Exists() { + return nil, errors.New(zone.String() + " is not a valid zone") + } + + cfg := &Config{ + ApiToken: bytes.NewBufferString(c.String(flagPrefix + "-api-token")), + } + + cfg.InstancePool = map[string]InstancePool{ + "default": { + Locality: Locality{ + Zones: []scw.Zone{zone}, + }, + ProjectID: scw.StringPtr(c.String(flagPrefix + "-project")), + Prefix: c.String(flagPrefix + "-prefix"), + Tags: c.StringSlice(flagPrefix + "-tags"), + // We do not need stables IP for our JIT runners + DynamicIPRequired: scw.BoolPtr(true), + CommercialType: c.String(flagPrefix + "-instance-type"), + Image: c.String(flagPrefix + "-image"), + EnableIPv6: c.Bool(flagPrefix + "-enable-ipv6"), + Storage: scw.Size(c.Uint64(flagPrefix + "-storage-size")), + }, + } + + return cfg, nil +} diff --git a/providers/scaleway/v1/provider.go b/providers/scaleway/v1/provider.go new file mode 100644 index 0000000..e9c2bd7 --- /dev/null +++ b/providers/scaleway/v1/provider.go @@ -0,0 +1,5 @@ +package v1 + +type Provider struct { + Config *Config +} From 80b3f1e6e8d0aca54b2357b7bcd8309941b9f1c9 Mon Sep 17 00:00:00 2001 From: Enzo NOCERA Date: Sat, 11 Nov 2023 18:20:19 +0100 Subject: [PATCH 02/10] clean: config scheme --- providers/scaleway/v1/config.go | 51 ++++++++++++++++++++++++--------- providers/scaleway/v1/flags.go | 31 ++++++++++++++++---- 2 files changed, 63 insertions(+), 19 deletions(-) diff --git a/providers/scaleway/v1/config.go b/providers/scaleway/v1/config.go index 6ab4c12..1955df1 100644 --- a/providers/scaleway/v1/config.go +++ b/providers/scaleway/v1/config.go @@ -1,9 +1,9 @@ package v1 import ( + "errors" "github.com/scaleway/scaleway-sdk-go/api/instance/v1" "github.com/scaleway/scaleway-sdk-go/scw" - "io" ) // Config is the Scaleway Provider specific configuration @@ -17,18 +17,20 @@ type Config struct { // // Creating a standalone IAM Applications is recommended to segregate // permissions. - ApiToken io.Reader - InstancePool map[string]InstancePool + SecretKey string `json:"secret_key"` + AccessKey string `json:"access_key"` + DefaultProjectID string `json:"default_project_id"` + InstancePool map[string]InstancePool `json:"instance_pool"` } // Locality defines a geographical area // // Scaleway Cloud has multiple Region that are made of several Zones. // Exactly one of Zones or Region SHOULD be set, -// if both are set, use Zones and ignore Region +// if both are set, use Region and ignore Zones type Locality struct { - Zones []scw.Zone - Region *scw.Region + Zones []scw.Zone `json:"zones,omitempty"` + Region *scw.Region `json:"region,omitempty"` } // InstancePool is a small helper to handle a pool of instances @@ -36,13 +38,13 @@ type InstancePool struct { // Locality where your instances should live // The InstancePool scheduler will try to spread your // instances evenly among Locality.Zones if possible - Locality Locality + Locality Locality `json:"locality"` // ProjectID where resources should be applied - ProjectID *string + ProjectID *string `json:"project_id,omitempty"` // Prefix is added before each instance name - Prefix string + Prefix string `json:"prefix"` // Tags added to the placement group and its instances - Tags []string + Tags []string `json:"tags"` // DynamicIPRequired: define if a dynamic IPv4 is required for the Instance. DynamicIPRequired *bool `json:"dynamic_ip_required,omitempty"` // RoutedIPEnabled: if true, configure the Instance, so it uses the new routed IP mode. @@ -54,11 +56,34 @@ type InstancePool struct { // EnableIPv6: true if IPv6 is enabled on the server. EnableIPv6 bool `json:"enable_ipv6,omitempty"` // PublicIPs to attach to your instance indexed per instance.IPType - PublicIPs map[instance.IPType]int + PublicIPs map[instance.IPType]int `json:"public_ips,omitempty"` // SecurityGroups to use per zone - SecurityGroups map[scw.Zone]string + SecurityGroups map[scw.Zone]string `json:"security_groups,omitempty"` // Storage of the block storage associated with your Instances // It should be a multiple of 512 bytes, in future version we could give // more customisation over the volumes used by the agents - Storage scw.Size + Storage scw.Size `json:"storage"` +} + +func (l Locality) ResolveZones() ([]scw.Zone, error) { + if l.Region != nil { + if !l.Region.Exists() { + return nil, errors.New("you specified an invalid region: " + l.Region.String()) + } + + return l.Region.GetZones(), nil + } + + zones := l.Zones + if zones == nil || len(zones) <= 0 { + return nil, errors.New("you need to specify a valid locality") + } + + for _, zone := range zones { + if !zone.Exists() { + return nil, errors.New("you specified a non-existing zone: " + zone.String()) + } + } + + return zones, nil } diff --git a/providers/scaleway/v1/flags.go b/providers/scaleway/v1/flags.go index 20941aa..c314ef6 100644 --- a/providers/scaleway/v1/flags.go +++ b/providers/scaleway/v1/flags.go @@ -1,7 +1,6 @@ package v1 import ( - "bytes" "errors" "github.com/scaleway/scaleway-sdk-go/scw" "github.com/urfave/cli/v2" @@ -15,13 +14,23 @@ const envPrefix = "WOODPECKER_SCW" var ProviderFlags = []cli.Flag{ &cli.StringFlag{ - Name: flagPrefix + "-api-token", - Usage: "Scaleway IAM API Token", - EnvVars: []string{envPrefix + "_API_TOKEN"}, + Name: flagPrefix + "-access-key", + Usage: "Scaleway IAM API Token Access Key", + EnvVars: []string{envPrefix + "_ACCESS_KEY"}, // NB(raskyld): We should recommend the usage of file-system to users // Most container runtimes support mounting secrets into the fs // natively. - FilePath: os.Getenv(envPrefix + "_API_TOKEN_FILE"), + FilePath: os.Getenv(envPrefix + "_ACCESS_KEY_FILE"), + Category: category, + }, + &cli.StringFlag{ + Name: flagPrefix + "-secret-ket", + Usage: "Scaleway IAM API Token Secret Key", + EnvVars: []string{envPrefix + "_SECRET_KEY"}, + // NB(raskyld): We should recommend the usage of file-system to users + // Most container runtimes support mounting secrets into the fs + // natively. + FilePath: os.Getenv(envPrefix + "_SECRET_KEY_FILE"), Category: category, }, &cli.StringFlag{ @@ -91,13 +100,23 @@ func FromCLI(c *cli.Context, engineConfig *config.Config) (*Config, error) { return nil, errors.New("you must specify in which project resources should be spawned") } + if !c.IsSet(flagPrefix + "-secret-key") { + return nil, errors.New("you must specify a secret key") + } + + if !c.IsSet(flagPrefix + "-access-key") { + return nil, errors.New("you must specify an access key") + } + zone := scw.Zone(c.String(flagPrefix + "-zone")) if !zone.Exists() { return nil, errors.New(zone.String() + " is not a valid zone") } cfg := &Config{ - ApiToken: bytes.NewBufferString(c.String(flagPrefix + "-api-token")), + SecretKey: c.String(flagPrefix + "-secret-key"), + AccessKey: c.String(flagPrefix + "-access-key"), + DefaultProjectID: c.String(flagPrefix + "-project"), } cfg.InstancePool = map[string]InstancePool{ From f02631772be677760d50bdd500a7bbde1d244e8b Mon Sep 17 00:00:00 2001 From: Enzo NOCERA Date: Sun, 12 Nov 2023 11:17:13 +0100 Subject: [PATCH 03/10] feat: add provider logic --- go.mod | 1 + go.sum | 2 + providers/scaleway/v1/config.go | 8 +- providers/scaleway/v1/errors.go | 40 +++++ providers/scaleway/v1/flags.go | 47 +++++- providers/scaleway/v1/provider.go | 262 +++++++++++++++++++++++++++++- 6 files changed, 352 insertions(+), 8 deletions(-) create mode 100644 providers/scaleway/v1/errors.go diff --git a/go.mod b/go.mod index 01d00a4..9d1698b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module go.woodpecker-ci.org/autoscaler go 1.21 require ( + github.com/cenkalti/backoff/v4 v4.2.1 github.com/franela/goblin v0.0.0-20211003143422-0a4f594942bf github.com/hetznercloud/hcloud-go/v2 v2.7.0 github.com/joho/godotenv v1.5.1 diff --git a/go.sum b/go.sum index 636c396..f804273 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= diff --git a/providers/scaleway/v1/config.go b/providers/scaleway/v1/config.go index 1955df1..d1ce2f3 100644 --- a/providers/scaleway/v1/config.go +++ b/providers/scaleway/v1/config.go @@ -2,6 +2,7 @@ package v1 import ( "errors" + "github.com/cenkalti/backoff/v4" "github.com/scaleway/scaleway-sdk-go/api/instance/v1" "github.com/scaleway/scaleway-sdk-go/scw" ) @@ -17,9 +18,10 @@ type Config struct { // // Creating a standalone IAM Applications is recommended to segregate // permissions. - SecretKey string `json:"secret_key"` - AccessKey string `json:"access_key"` - DefaultProjectID string `json:"default_project_id"` + SecretKey string `json:"secret_key"` + AccessKey string `json:"access_key"` + DefaultProjectID string `json:"default_project_id"` + ClientRetry backoff.BackOff InstancePool map[string]InstancePool `json:"instance_pool"` } diff --git a/providers/scaleway/v1/errors.go b/providers/scaleway/v1/errors.go new file mode 100644 index 0000000..2d5f103 --- /dev/null +++ b/providers/scaleway/v1/errors.go @@ -0,0 +1,40 @@ +package v1 + +import ( + "github.com/scaleway/scaleway-sdk-go/api/instance/v1" + "github.com/scaleway/scaleway-sdk-go/scw" + "log/slog" +) + +type InstanceAlreadyExistsError struct { + inst *instance.Server +} + +type InstanceDoesNotExists struct { + InstanceName string + Project string + Zones []scw.Zone +} + +func (i InstanceAlreadyExistsError) Error() string { + return "instance already exists" +} + +func (i InstanceAlreadyExistsError) LogValue() slog.Value { + return slog.GroupValue(slog.String("err", i.Error()), + slog.Group("instance", slog.String("id", i.inst.ID), slog.String("name", i.inst.Name), + slog.String("zone", i.inst.Zone.String()), slog.String("project", i.inst.Project))) +} + +func (i InstanceDoesNotExists) Error() string { + return "instance does not exists" +} + +func (i InstanceDoesNotExists) LogValue() slog.Value { + zones := make([]string, len(i.Zones)) + for _, zone := range i.Zones { + zones = append(zones, zone.String()) + } + + return slog.GroupValue(slog.String("name", i.InstanceName), slog.String("project", i.Project), slog.Any("zones", zones)) +} diff --git a/providers/scaleway/v1/flags.go b/providers/scaleway/v1/flags.go index c314ef6..a8e508a 100644 --- a/providers/scaleway/v1/flags.go +++ b/providers/scaleway/v1/flags.go @@ -2,15 +2,21 @@ package v1 import ( "errors" + "github.com/cenkalti/backoff/v4" "github.com/scaleway/scaleway-sdk-go/scw" "github.com/urfave/cli/v2" "go.woodpecker-ci.org/autoscaler/config" "os" + "time" ) -const category = "Scaleway" -const flagPrefix = "scw" -const envPrefix = "WOODPECKER_SCW" +const ( + DefaultPool = "default" + + category = "Scaleway" + flagPrefix = "scw" + envPrefix = "WOODPECKER_SCW" +) var ProviderFlags = []cli.Flag{ &cli.StringFlag{ @@ -85,6 +91,20 @@ var ProviderFlags = []cli.Flag{ Category: category, DefaultText: "20000000000", }, + &cli.IntFlag{ + Name: flagPrefix + "-client-max-retries", + Usage: "How much times should we retry requests (< 0: infinite, 0: no retry)", + EnvVars: []string{envPrefix + "_CLIENT_MAX_RETRIES"}, + Category: category, + DefaultText: "5", + }, + &cli.StringFlag{ + Name: flagPrefix + "-client-retry-exponential-base", + Usage: "Exponential base duration for the retry mechanisms", + EnvVars: []string{envPrefix + "_CLIENT_RETRY_EXPONENTIAL_BASE"}, + Category: category, + DefaultText: "2s", + }, } func FromCLI(c *cli.Context, engineConfig *config.Config) (*Config, error) { @@ -119,8 +139,27 @@ func FromCLI(c *cli.Context, engineConfig *config.Config) (*Config, error) { DefaultProjectID: c.String(flagPrefix + "-project"), } + maxRetries := c.Int(flagPrefix + "-client-max-retries") + expoBase, err := time.ParseDuration(c.String(flagPrefix + "-client-retry-exponential-base")) + + if err != nil { + return nil, err + } + + if maxRetries == 0 { + cfg.ClientRetry = &backoff.StopBackOff{} + } else { + bo := backoff.NewExponentialBackOff() + bo.InitialInterval = expoBase + cfg.ClientRetry = bo + } + + if maxRetries > 0 { + cfg.ClientRetry = backoff.WithMaxRetries(cfg.ClientRetry, uint64(maxRetries)) + } + cfg.InstancePool = map[string]InstancePool{ - "default": { + DefaultPool: { Locality: Locality{ Zones: []scw.Zone{zone}, }, diff --git a/providers/scaleway/v1/provider.go b/providers/scaleway/v1/provider.go index e9c2bd7..d301968 100644 --- a/providers/scaleway/v1/provider.go +++ b/providers/scaleway/v1/provider.go @@ -1,5 +1,265 @@ package v1 +import ( + "bytes" + "context" + "errors" + "github.com/cenkalti/backoff/v4" + "github.com/scaleway/scaleway-sdk-go/api/instance/v1" + "github.com/scaleway/scaleway-sdk-go/scw" + "go.woodpecker-ci.org/autoscaler/config" + "go.woodpecker-ci.org/autoscaler/engine" + "go.woodpecker-ci.org/woodpecker/woodpecker-go/woodpecker" + "math/rand" + "text/template" + "time" +) + type Provider struct { - Config *Config + scwCfg Config + engineCfg *config.Config + client *scw.Client +} + +func New(scwCfg Config, engineCfg *config.Config) (engine.Provider, error) { + client, err := scw.NewClient(scw.WithDefaultProjectID(scwCfg.DefaultProjectID), scw.WithAuth(scwCfg.AccessKey, scwCfg.SecretKey)) + if err != nil { + return nil, err + } + + return &Provider{ + scwCfg: scwCfg, + client: client, + engineCfg: engineCfg, + }, nil +} + +func (p *Provider) DeployAgent(ctx context.Context, agent *woodpecker.Agent) error { + inst, err := p.getInstance(ctx, agent.Name) + if err != nil { + var doesNotExists InstanceDoesNotExists + if !errors.As(err, &doesNotExists) { + return err + } + } + + inst, err = p.createInstance(ctx, agent) + if err != nil { + return err + } + + err = p.setCloudInit(ctx, agent, inst) + if err != nil { + return err + } + + // NB(raskyld): use the value for logging purpose once we implement slog + _, err = p.bootInstance(ctx, inst) + return err +} + +func (p *Provider) RemoveAgent(ctx context.Context, agent *woodpecker.Agent) error { + inst, err := p.getInstance(ctx, agent.Name) + if err != nil { + return err + } + + return p.deleteInstance(ctx, inst) +} + +func (p *Provider) ListDeployedAgentNames(ctx context.Context) ([]string, error) { + instances, err := p.getAllInstances(ctx) + if err != nil { + return nil, err + } + + names := make([]string, 0, len(instances)) + for _, inst := range instances { + names = append(names, inst.Name) + } + + return names, nil +} + +func (p *Provider) getInstance(ctx context.Context, name string) (*instance.Server, error) { + pool := p.scwCfg.InstancePool[DefaultPool] + zones, err := pool.Locality.ResolveZones() + if err != nil { + return nil, err + } + + api := instance.NewAPI(p.client) + project := pool.ProjectID + + if project == nil { + project = &p.scwCfg.DefaultProjectID + } + + for _, zone := range zones { + req := instance.ListServersRequest{ + Zone: zone, + Project: project, + Name: scw.StringPtr(name), + Tags: pool.Tags, + } + + ops := backoff.OperationWithData[*instance.ListServersResponse](func() (*instance.ListServersResponse, error) { + return api.ListServers(&req, scw.WithContext(ctx)) + }) + + resp, err := backoff.RetryWithData(ops, backoff.WithContext(p.scwCfg.ClientRetry, ctx)) + if err != nil { + return nil, err + } + + if resp.TotalCount > 0 { + // TODO(raskyld): add a warning if there are more than 1 found, it means there are orphan resources + return resp.Servers[0], nil + } + } + + return nil, &InstanceDoesNotExists{ + InstanceName: name, + Project: *project, + Zones: zones, + } +} + +func (p *Provider) getAllInstances(ctx context.Context) ([]*instance.Server, error) { + pool := p.scwCfg.InstancePool[DefaultPool] + zones, err := pool.Locality.ResolveZones() + if err != nil { + return nil, err + } + + api := instance.NewAPI(p.client) + instances := make([]*instance.Server, 0, 150) + + for _, zone := range zones { + // TODO(raskyld): handle pagination for cases with more than 50 agents running per region + req := instance.ListServersRequest{ + Zone: zone, + Project: pool.ProjectID, + Tags: pool.Tags, + } + + ops := backoff.OperationWithData[*instance.ListServersResponse](func() (*instance.ListServersResponse, error) { + return api.ListServers(&req, scw.WithContext(ctx)) + }) + + resp, err := backoff.RetryWithData(ops, backoff.WithContext(p.scwCfg.ClientRetry, ctx)) + if err != nil { + return nil, err + } + + if resp.TotalCount > 0 { + instances = append(instances, resp.Servers...) + } + } + + return instances, nil +} + +func (p *Provider) createInstance(ctx context.Context, agent *woodpecker.Agent) (*instance.Server, error) { + pool := p.scwCfg.InstancePool[DefaultPool] + zones, err := pool.Locality.ResolveZones() + if err != nil { + return nil, err + } + + // TODO(raskyld): Implement a well-balanced zone anti-affinity to spread instance + // evenly among zones for greater resilience. + random := rand.New(rand.NewSource(time.Now().Unix())) + zone := zones[random.Intn(len(zones))] + + api := instance.NewAPI(p.client) + + req := instance.CreateServerRequest{ + Zone: zone, + Name: agent.Name, + DynamicIPRequired: scw.BoolPtr(true), + CommercialType: pool.CommercialType, + Image: pool.Image, + Volumes: map[string]*instance.VolumeServerTemplate{ + "0": { + Boot: scw.BoolPtr(true), + Size: scw.SizePtr(pool.Storage), + VolumeType: instance.VolumeVolumeTypeBSSD, + Project: pool.ProjectID, + }, + }, + EnableIPv6: pool.EnableIPv6, + Project: pool.ProjectID, + Tags: pool.Tags, + } + + ops := backoff.OperationWithData[*instance.CreateServerResponse](func() (*instance.CreateServerResponse, error) { + return api.CreateServer(&req, scw.WithContext(ctx)) + }) + + res, err := backoff.RetryWithData(ops, backoff.WithContext(p.scwCfg.ClientRetry, ctx)) + if err != nil { + return nil, err + } + + return res.Server, nil +} + +func (p *Provider) setCloudInit(ctx context.Context, agent *woodpecker.Agent, inst *instance.Server) error { + tpl, err := template.New("user-data").Parse(engine.CloudInitUserDataUbuntuDefault) + if err != nil { + return err + } + + ud, err := engine.RenderUserDataTemplate(p.engineCfg, agent, tpl) + if err != nil { + return err + } + + api := instance.NewAPI(p.client) + + req := instance.SetServerUserDataRequest{ + Zone: inst.Zone, + ServerID: inst.ID, + Key: "cloud-init", + Content: bytes.NewBufferString(ud), + } + + ops := backoff.Operation(func() error { + return api.SetServerUserData(&req, scw.WithContext(ctx)) + }) + + err = backoff.Retry(ops, backoff.WithContext(p.scwCfg.ClientRetry, ctx)) + if err != nil { + return err + } + + return nil +} + +func (p *Provider) deleteInstance(ctx context.Context, inst *instance.Server) error { + api := instance.NewAPI(p.client) + + ops := backoff.Operation(func() error { + return api.DeleteServer(&instance.DeleteServerRequest{ + Zone: inst.Zone, + ServerID: inst.ID, + }) + }) + + return backoff.Retry(ops, backoff.WithContext(p.scwCfg.ClientRetry, ctx)) +} + +func (p *Provider) bootInstance(ctx context.Context, inst *instance.Server) (*instance.ServerActionResponse, error) { + api := instance.NewAPI(p.client) + + ops := backoff.OperationWithData[*instance.ServerActionResponse](func() (*instance.ServerActionResponse, error) { + return api.ServerAction(&instance.ServerActionRequest{ + Zone: inst.Zone, + ServerID: inst.ID, + Action: instance.ServerActionPoweron, + }) + }) + + return backoff.RetryWithData(ops, backoff.WithContext(p.scwCfg.ClientRetry, ctx)) } From ae2c686df9f3da47ec1eafec26e0f188476cfc09 Mon Sep 17 00:00:00 2001 From: Enzo NOCERA Date: Sun, 12 Nov 2023 12:25:52 +0100 Subject: [PATCH 04/10] feat: add scw to setupProvider() - chore(doc): cleaning - chore(ci): run gofumpt Signed-off-by: Enzo NOCERA --- cmd/woodpecker-autoscaler/main.go | 12 +++++++++++- providers/scaleway/doc.go | 10 +++++++--- providers/scaleway/v1/config.go | 14 +++++++------- providers/scaleway/v1/errors.go | 3 ++- providers/scaleway/v1/flags.go | 26 +++++++++++++------------- providers/scaleway/v1/provider.go | 11 ++++++----- 6 files changed, 46 insertions(+), 30 deletions(-) diff --git a/cmd/woodpecker-autoscaler/main.go b/cmd/woodpecker-autoscaler/main.go index feaa7ca..15a26ac 100644 --- a/cmd/woodpecker-autoscaler/main.go +++ b/cmd/woodpecker-autoscaler/main.go @@ -6,6 +6,8 @@ import ( "strings" "time" + scwv1 "go.woodpecker-ci.org/autoscaler/providers/scaleway/v1" + _ "github.com/joho/godotenv/autoload" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -25,8 +27,15 @@ func setupProvider(ctx *cli.Context, config *config.Config) (engine.Provider, er // Enable it again when the issue is fixed. // case "linode": // return linode.New(ctx, config) + case "scaleway": + scwCfg, err := scwv1.FromCLI(ctx) + if err != nil { + return nil, err + } + + return scwv1.New(scwCfg, config) case "": - return nil, fmt.Errorf("please select a provider") + return nil, fmt.Errorf("Please select a provider") } return nil, fmt.Errorf("unknown provider: %s", ctx.String("provider")) @@ -119,6 +128,7 @@ func main() { // TODO: Temp disabled due to the security issue https://github.com/woodpecker-ci/autoscaler/issues/91 // Enable it again when the issue is fixed. // app.Flags = append(app.Flags, linode.DriverFlags...) + app.Flags = append(app.Flags, scwv1.ProviderFlags...) if err := app.Run(os.Args); err != nil { log.Fatal().Err(err).Msg("") diff --git a/providers/scaleway/doc.go b/providers/scaleway/doc.go index ebec269..aa702ab 100644 --- a/providers/scaleway/doc.go +++ b/providers/scaleway/doc.go @@ -1,10 +1,14 @@ // Package scaleway implements a way to use the Scaleway Cloud Provider for your // Woodpecker CIs. // -// ## Limitations +// This package contains subpackage per Scaleway Instance API version. // -// For now, we only support deploying on single AZ per agent pool. +// # Limitations +// +// - As of now, we can only deploy instances in single-zones. // // Authors: -// - Enzo "raskyld" Nocera +// - Enzo "raskyld" Nocera [@raskyld@social.vivaldi.net] +// +// [@raskyld@social.vivaldi.net]: https://social.vivaldi.net/@raskyld package scaleway diff --git a/providers/scaleway/v1/config.go b/providers/scaleway/v1/config.go index d1ce2f3..e18b064 100644 --- a/providers/scaleway/v1/config.go +++ b/providers/scaleway/v1/config.go @@ -2,17 +2,17 @@ package v1 import ( "errors" + "github.com/cenkalti/backoff/v4" "github.com/scaleway/scaleway-sdk-go/api/instance/v1" "github.com/scaleway/scaleway-sdk-go/scw" ) // Config is the Scaleway Provider specific configuration -// NB(raskyld): In the future, I think each provider should be able to -// unmarshal from a config file JSON stream passed by the engine. -// The engine should also provide utilities, for example, a pre-defined -// type that allows the providers to either read hard-coded values -// or retrieve them from the filesystem, e.g. for secrets. +// +// This is decoupled from the CLI interface for future-proofing reasons. +// Please, see ProviderFlags for information on how to configure the provider from the +// CLI or environment variables. type Config struct { // ApiToken of Scaleway IAM // @@ -35,10 +35,10 @@ type Locality struct { Region *scw.Region `json:"region,omitempty"` } -// InstancePool is a small helper to handle a pool of instances +// InstancePool is used as a template to spawn your instances type InstancePool struct { // Locality where your instances should live - // The InstancePool scheduler will try to spread your + // The Provider will try to spread your // instances evenly among Locality.Zones if possible Locality Locality `json:"locality"` // ProjectID where resources should be applied diff --git a/providers/scaleway/v1/errors.go b/providers/scaleway/v1/errors.go index 2d5f103..bbb4eb2 100644 --- a/providers/scaleway/v1/errors.go +++ b/providers/scaleway/v1/errors.go @@ -1,9 +1,10 @@ package v1 import ( + "log/slog" + "github.com/scaleway/scaleway-sdk-go/api/instance/v1" "github.com/scaleway/scaleway-sdk-go/scw" - "log/slog" ) type InstanceAlreadyExistsError struct { diff --git a/providers/scaleway/v1/flags.go b/providers/scaleway/v1/flags.go index a8e508a..bf90b8c 100644 --- a/providers/scaleway/v1/flags.go +++ b/providers/scaleway/v1/flags.go @@ -2,12 +2,12 @@ package v1 import ( "errors" + "os" + "time" + "github.com/cenkalti/backoff/v4" "github.com/scaleway/scaleway-sdk-go/scw" "github.com/urfave/cli/v2" - "go.woodpecker-ci.org/autoscaler/config" - "os" - "time" ) const ( @@ -39,6 +39,7 @@ var ProviderFlags = []cli.Flag{ FilePath: os.Getenv(envPrefix + "_SECRET_KEY_FILE"), Category: category, }, + // TODO(raskyld): implement multi-AZ &cli.StringFlag{ Name: flagPrefix + "-zone", Usage: "Scaleway Zone where to spawn instances", @@ -107,33 +108,33 @@ var ProviderFlags = []cli.Flag{ }, } -func FromCLI(c *cli.Context, engineConfig *config.Config) (*Config, error) { +func FromCLI(c *cli.Context) (Config, error) { if !c.IsSet(flagPrefix + "-instance-type") { - return nil, errors.New("you must specify an instance type") + return Config{}, errors.New("you must specify an instance type") } if !c.IsSet(flagPrefix + "-tags") { - return nil, errors.New("you must specify tags to apply to your resources") + return Config{}, errors.New("you must specify tags to apply to your resources") } if !c.IsSet(flagPrefix + "-project") { - return nil, errors.New("you must specify in which project resources should be spawned") + return Config{}, errors.New("you must specify in which project resources should be spawned") } if !c.IsSet(flagPrefix + "-secret-key") { - return nil, errors.New("you must specify a secret key") + return Config{}, errors.New("you must specify a secret key") } if !c.IsSet(flagPrefix + "-access-key") { - return nil, errors.New("you must specify an access key") + return Config{}, errors.New("you must specify an access key") } zone := scw.Zone(c.String(flagPrefix + "-zone")) if !zone.Exists() { - return nil, errors.New(zone.String() + " is not a valid zone") + return Config{}, errors.New(zone.String() + " is not a valid zone") } - cfg := &Config{ + cfg := Config{ SecretKey: c.String(flagPrefix + "-secret-key"), AccessKey: c.String(flagPrefix + "-access-key"), DefaultProjectID: c.String(flagPrefix + "-project"), @@ -141,9 +142,8 @@ func FromCLI(c *cli.Context, engineConfig *config.Config) (*Config, error) { maxRetries := c.Int(flagPrefix + "-client-max-retries") expoBase, err := time.ParseDuration(c.String(flagPrefix + "-client-retry-exponential-base")) - if err != nil { - return nil, err + return Config{}, err } if maxRetries == 0 { diff --git a/providers/scaleway/v1/provider.go b/providers/scaleway/v1/provider.go index d301968..8cf2e62 100644 --- a/providers/scaleway/v1/provider.go +++ b/providers/scaleway/v1/provider.go @@ -4,15 +4,16 @@ import ( "bytes" "context" "errors" + "math/rand" + "text/template" + "time" + "github.com/cenkalti/backoff/v4" "github.com/scaleway/scaleway-sdk-go/api/instance/v1" "github.com/scaleway/scaleway-sdk-go/scw" "go.woodpecker-ci.org/autoscaler/config" "go.woodpecker-ci.org/autoscaler/engine" "go.woodpecker-ci.org/woodpecker/woodpecker-go/woodpecker" - "math/rand" - "text/template" - "time" ) type Provider struct { @@ -35,7 +36,7 @@ func New(scwCfg Config, engineCfg *config.Config) (engine.Provider, error) { } func (p *Provider) DeployAgent(ctx context.Context, agent *woodpecker.Agent) error { - inst, err := p.getInstance(ctx, agent.Name) + _, err := p.getInstance(ctx, agent.Name) if err != nil { var doesNotExists InstanceDoesNotExists if !errors.As(err, &doesNotExists) { @@ -43,7 +44,7 @@ func (p *Provider) DeployAgent(ctx context.Context, agent *woodpecker.Agent) err } } - inst, err = p.createInstance(ctx, agent) + inst, err := p.createInstance(ctx, agent) if err != nil { return err } From 4cae2e4394e811971da6726166ec4418ba49090b Mon Sep 17 00:00:00 2001 From: Enzo NOCERA Date: Sun, 12 Nov 2023 17:21:50 +0100 Subject: [PATCH 05/10] fix(chore): fix typo Signed-off-by: Enzo NOCERA --- providers/scaleway/v1/flags.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/scaleway/v1/flags.go b/providers/scaleway/v1/flags.go index bf90b8c..d4beeba 100644 --- a/providers/scaleway/v1/flags.go +++ b/providers/scaleway/v1/flags.go @@ -30,7 +30,7 @@ var ProviderFlags = []cli.Flag{ Category: category, }, &cli.StringFlag{ - Name: flagPrefix + "-secret-ket", + Name: flagPrefix + "-secret-key", Usage: "Scaleway IAM API Token Secret Key", EnvVars: []string{envPrefix + "_SECRET_KEY"}, // NB(raskyld): We should recommend the usage of file-system to users From 4ec4a7a662e93dd285d0c9551ccf18c936555597 Mon Sep 17 00:00:00 2001 From: Enzo NOCERA Date: Sun, 12 Nov 2023 20:30:04 +0100 Subject: [PATCH 06/10] fix: typo & halt before delete Signed-off-by: Enzo NOCERA --- providers/scaleway/v1/errors.go | 2 +- providers/scaleway/v1/flags.go | 64 +++++++++++++++---------------- providers/scaleway/v1/provider.go | 22 ++++++++++- 3 files changed, 54 insertions(+), 34 deletions(-) diff --git a/providers/scaleway/v1/errors.go b/providers/scaleway/v1/errors.go index bbb4eb2..aec629c 100644 --- a/providers/scaleway/v1/errors.go +++ b/providers/scaleway/v1/errors.go @@ -28,7 +28,7 @@ func (i InstanceAlreadyExistsError) LogValue() slog.Value { } func (i InstanceDoesNotExists) Error() string { - return "instance does not exists" + return "instance does not exist" } func (i InstanceDoesNotExists) LogValue() slog.Value { diff --git a/providers/scaleway/v1/flags.go b/providers/scaleway/v1/flags.go index d4beeba..f712a4d 100644 --- a/providers/scaleway/v1/flags.go +++ b/providers/scaleway/v1/flags.go @@ -41,11 +41,11 @@ var ProviderFlags = []cli.Flag{ }, // TODO(raskyld): implement multi-AZ &cli.StringFlag{ - Name: flagPrefix + "-zone", - Usage: "Scaleway Zone where to spawn instances", - EnvVars: []string{envPrefix + "_ZONE"}, - Category: category, - DefaultText: scw.ZoneFrPar2.String(), + Name: flagPrefix + "-zone", + Usage: "Scaleway Zone where to spawn instances", + EnvVars: []string{envPrefix + "_ZONE"}, + Category: category, + Value: scw.ZoneFrPar2.String(), }, &cli.StringFlag{ Name: flagPrefix + "-instance-type", @@ -66,11 +66,11 @@ var ProviderFlags = []cli.Flag{ Category: category, }, &cli.StringFlag{ - Name: flagPrefix + "-prefix", - Usage: "Prefix prepended before any Scaleway resource name", - EnvVars: []string{envPrefix + "_PREFIX"}, - Category: category, - DefaultText: "wip-woodpecker-ci-autoscaler", + Name: flagPrefix + "-prefix", + Usage: "Prefix prepended before any Scaleway resource name", + EnvVars: []string{envPrefix + "_PREFIX"}, + Category: category, + Value: "wip-woodpecker-ci-autoscaler", }, &cli.BoolFlag{ Name: flagPrefix + "-enable-ipv6", @@ -78,33 +78,33 @@ var ProviderFlags = []cli.Flag{ EnvVars: []string{envPrefix + "_ENABLE_IPV6"}, Category: category, }, - &cli.BoolFlag{ - Name: flagPrefix + "-image", - Usage: "The base image for your instance", - EnvVars: []string{envPrefix + "_IMAGE"}, - Category: category, - DefaultText: "ubuntu_local", + &cli.StringFlag{ + Name: flagPrefix + "-image", + Usage: "The base image for your instance", + EnvVars: []string{envPrefix + "_IMAGE"}, + Category: category, + Value: "ubuntu_jammy", }, &cli.Uint64Flag{ - Name: flagPrefix + "-storage-size", - Usage: "How much storage to provision for your agents in bytes", - EnvVars: []string{envPrefix + "_STORAGE_SIZE"}, - Category: category, - DefaultText: "20000000000", + Name: flagPrefix + "-storage-size", + Usage: "How much storage to provision for your agents in bytes", + EnvVars: []string{envPrefix + "_STORAGE_SIZE"}, + Category: category, + Value: 25000000000, }, &cli.IntFlag{ - Name: flagPrefix + "-client-max-retries", - Usage: "How much times should we retry requests (< 0: infinite, 0: no retry)", - EnvVars: []string{envPrefix + "_CLIENT_MAX_RETRIES"}, - Category: category, - DefaultText: "5", + Name: flagPrefix + "-client-max-retries", + Usage: "How much times should we retry requests (< 0: infinite, 0: no retry)", + EnvVars: []string{envPrefix + "_CLIENT_MAX_RETRIES"}, + Category: category, + Value: 5, }, - &cli.StringFlag{ - Name: flagPrefix + "-client-retry-exponential-base", - Usage: "Exponential base duration for the retry mechanisms", - EnvVars: []string{envPrefix + "_CLIENT_RETRY_EXPONENTIAL_BASE"}, - Category: category, - DefaultText: "2s", + &cli.DurationFlag{ + Name: flagPrefix + "-client-retry-exponential-base", + Usage: "Exponential base duration for the retry mechanisms", + EnvVars: []string{envPrefix + "_CLIENT_RETRY_EXPONENTIAL_BASE"}, + Category: category, + Value: 2 * time.Second, }, } diff --git a/providers/scaleway/v1/provider.go b/providers/scaleway/v1/provider.go index 8cf2e62..9216f59 100644 --- a/providers/scaleway/v1/provider.go +++ b/providers/scaleway/v1/provider.go @@ -38,7 +38,7 @@ func New(scwCfg Config, engineCfg *config.Config) (engine.Provider, error) { func (p *Provider) DeployAgent(ctx context.Context, agent *woodpecker.Agent) error { _, err := p.getInstance(ctx, agent.Name) if err != nil { - var doesNotExists InstanceDoesNotExists + var doesNotExists *InstanceDoesNotExists if !errors.As(err, &doesNotExists) { return err } @@ -183,6 +183,7 @@ func (p *Provider) createInstance(ctx context.Context, agent *woodpecker.Agent) Image: pool.Image, Volumes: map[string]*instance.VolumeServerTemplate{ "0": { + Name: scw.StringPtr(agent.Name), Boot: scw.BoolPtr(true), Size: scw.SizePtr(pool.Storage), VolumeType: instance.VolumeVolumeTypeBSSD, @@ -239,6 +240,11 @@ func (p *Provider) setCloudInit(ctx context.Context, agent *woodpecker.Agent, in } func (p *Provider) deleteInstance(ctx context.Context, inst *instance.Server) error { + err := p.haltInstance(ctx, inst) + if err != nil { + return err + } + api := instance.NewAPI(p.client) ops := backoff.Operation(func() error { @@ -264,3 +270,17 @@ func (p *Provider) bootInstance(ctx context.Context, inst *instance.Server) (*in return backoff.RetryWithData(ops, backoff.WithContext(p.scwCfg.ClientRetry, ctx)) } + +func (p *Provider) haltInstance(ctx context.Context, inst *instance.Server) error { + api := instance.NewAPI(p.client) + + ops := backoff.Operation(func() error { + return api.ServerActionAndWait(&instance.ServerActionAndWaitRequest{ + Zone: inst.Zone, + ServerID: inst.ID, + Action: instance.ServerActionPoweroff, + }) + }) + + return backoff.Retry(ops, backoff.WithContext(p.scwCfg.ClientRetry, ctx)) +} From 37ff856e4d93eb1263b04ad60078aed71d05dd84 Mon Sep 17 00:00:00 2001 From: Enzo NOCERA Date: Mon, 13 Nov 2023 09:38:36 +0100 Subject: [PATCH 07/10] fix: bad volume params Signed-off-by: Enzo NOCERA --- cmd/woodpecker-autoscaler/main.go | 3 +++ providers/scaleway/v1/provider.go | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/woodpecker-autoscaler/main.go b/cmd/woodpecker-autoscaler/main.go index 15a26ac..f39d921 100644 --- a/cmd/woodpecker-autoscaler/main.go +++ b/cmd/woodpecker-autoscaler/main.go @@ -86,6 +86,9 @@ func run(ctx *cli.Context) error { return fmt.Errorf("can't parse reconciliation-interval: %w", err) } + // Run a reconcile loop at start-up to avoid waiting 1m or more + autoscaler.Reconcile(ctx.Context) + for { select { case <-ctx.Done(): diff --git a/providers/scaleway/v1/provider.go b/providers/scaleway/v1/provider.go index 9216f59..1b8bb26 100644 --- a/providers/scaleway/v1/provider.go +++ b/providers/scaleway/v1/provider.go @@ -183,11 +183,9 @@ func (p *Provider) createInstance(ctx context.Context, agent *woodpecker.Agent) Image: pool.Image, Volumes: map[string]*instance.VolumeServerTemplate{ "0": { - Name: scw.StringPtr(agent.Name), Boot: scw.BoolPtr(true), Size: scw.SizePtr(pool.Storage), VolumeType: instance.VolumeVolumeTypeBSSD, - Project: pool.ProjectID, }, }, EnableIPv6: pool.EnableIPv6, From 1bad108e2e42983882a7818b25c33a8dac290b66 Mon Sep 17 00:00:00 2001 From: Enzo NOCERA Date: Mon, 13 Nov 2023 20:05:13 +0100 Subject: [PATCH 08/10] address review Signed-off-by: Enzo NOCERA --- cmd/woodpecker-autoscaler/main.go | 8 +-- providers/scaleway/{v1 => }/config.go | 2 +- providers/scaleway/{v1 => }/errors.go | 2 +- providers/scaleway/{v1 => }/flags.go | 42 ++-------------- providers/scaleway/{v1 => }/provider.go | 67 +++++++------------------ 5 files changed, 29 insertions(+), 92 deletions(-) rename providers/scaleway/{v1 => }/config.go (99%) rename providers/scaleway/{v1 => }/errors.go (98%) rename providers/scaleway/{v1 => }/flags.go (77%) rename providers/scaleway/{v1 => }/provider.go (74%) diff --git a/cmd/woodpecker-autoscaler/main.go b/cmd/woodpecker-autoscaler/main.go index f39d921..03a9a6b 100644 --- a/cmd/woodpecker-autoscaler/main.go +++ b/cmd/woodpecker-autoscaler/main.go @@ -6,7 +6,7 @@ import ( "strings" "time" - scwv1 "go.woodpecker-ci.org/autoscaler/providers/scaleway/v1" + "go.woodpecker-ci.org/autoscaler/providers/scaleway" _ "github.com/joho/godotenv/autoload" "github.com/rs/zerolog" @@ -28,12 +28,12 @@ func setupProvider(ctx *cli.Context, config *config.Config) (engine.Provider, er // case "linode": // return linode.New(ctx, config) case "scaleway": - scwCfg, err := scwv1.FromCLI(ctx) + scwCfg, err := scaleway.FromCLI(ctx) if err != nil { return nil, err } - return scwv1.New(scwCfg, config) + return scaleway.New(scwCfg, config) case "": return nil, fmt.Errorf("Please select a provider") } @@ -127,11 +127,11 @@ func main() { // Register hetznercloud flags app.Flags = append(app.Flags, hetznercloud.DriverFlags...) + app.Flags = append(app.Flags, scaleway.ProviderFlags...) // Register linode flags // TODO: Temp disabled due to the security issue https://github.com/woodpecker-ci/autoscaler/issues/91 // Enable it again when the issue is fixed. // app.Flags = append(app.Flags, linode.DriverFlags...) - app.Flags = append(app.Flags, scwv1.ProviderFlags...) if err := app.Run(os.Args); err != nil { log.Fatal().Err(err).Msg("") diff --git a/providers/scaleway/v1/config.go b/providers/scaleway/config.go similarity index 99% rename from providers/scaleway/v1/config.go rename to providers/scaleway/config.go index e18b064..062fe15 100644 --- a/providers/scaleway/v1/config.go +++ b/providers/scaleway/config.go @@ -1,4 +1,4 @@ -package v1 +package scaleway import ( "errors" diff --git a/providers/scaleway/v1/errors.go b/providers/scaleway/errors.go similarity index 98% rename from providers/scaleway/v1/errors.go rename to providers/scaleway/errors.go index aec629c..259324d 100644 --- a/providers/scaleway/v1/errors.go +++ b/providers/scaleway/errors.go @@ -1,4 +1,4 @@ -package v1 +package scaleway import ( "log/slog" diff --git a/providers/scaleway/v1/flags.go b/providers/scaleway/flags.go similarity index 77% rename from providers/scaleway/v1/flags.go rename to providers/scaleway/flags.go index f712a4d..9336388 100644 --- a/providers/scaleway/v1/flags.go +++ b/providers/scaleway/flags.go @@ -1,11 +1,9 @@ -package v1 +package scaleway import ( "errors" "os" - "time" - "github.com/cenkalti/backoff/v4" "github.com/scaleway/scaleway-sdk-go/scw" "github.com/urfave/cli/v2" ) @@ -87,24 +85,10 @@ var ProviderFlags = []cli.Flag{ }, &cli.Uint64Flag{ Name: flagPrefix + "-storage-size", - Usage: "How much storage to provision for your agents in bytes", + Usage: "How much storage to provision for your agents in GB", EnvVars: []string{envPrefix + "_STORAGE_SIZE"}, Category: category, - Value: 25000000000, - }, - &cli.IntFlag{ - Name: flagPrefix + "-client-max-retries", - Usage: "How much times should we retry requests (< 0: infinite, 0: no retry)", - EnvVars: []string{envPrefix + "_CLIENT_MAX_RETRIES"}, - Category: category, - Value: 5, - }, - &cli.DurationFlag{ - Name: flagPrefix + "-client-retry-exponential-base", - Usage: "Exponential base duration for the retry mechanisms", - EnvVars: []string{envPrefix + "_CLIENT_RETRY_EXPONENTIAL_BASE"}, - Category: category, - Value: 2 * time.Second, + Value: 25, }, } @@ -140,24 +124,6 @@ func FromCLI(c *cli.Context) (Config, error) { DefaultProjectID: c.String(flagPrefix + "-project"), } - maxRetries := c.Int(flagPrefix + "-client-max-retries") - expoBase, err := time.ParseDuration(c.String(flagPrefix + "-client-retry-exponential-base")) - if err != nil { - return Config{}, err - } - - if maxRetries == 0 { - cfg.ClientRetry = &backoff.StopBackOff{} - } else { - bo := backoff.NewExponentialBackOff() - bo.InitialInterval = expoBase - cfg.ClientRetry = bo - } - - if maxRetries > 0 { - cfg.ClientRetry = backoff.WithMaxRetries(cfg.ClientRetry, uint64(maxRetries)) - } - cfg.InstancePool = map[string]InstancePool{ DefaultPool: { Locality: Locality{ @@ -171,7 +137,7 @@ func FromCLI(c *cli.Context) (Config, error) { CommercialType: c.String(flagPrefix + "-instance-type"), Image: c.String(flagPrefix + "-image"), EnableIPv6: c.Bool(flagPrefix + "-enable-ipv6"), - Storage: scw.Size(c.Uint64(flagPrefix + "-storage-size")), + Storage: scw.Size(c.Uint64(flagPrefix+"-storage-size") * 1e9), }, } diff --git a/providers/scaleway/v1/provider.go b/providers/scaleway/provider.go similarity index 74% rename from providers/scaleway/v1/provider.go rename to providers/scaleway/provider.go index 1b8bb26..682ab4c 100644 --- a/providers/scaleway/v1/provider.go +++ b/providers/scaleway/provider.go @@ -1,4 +1,4 @@ -package v1 +package scaleway import ( "bytes" @@ -8,7 +8,6 @@ import ( "text/template" "time" - "github.com/cenkalti/backoff/v4" "github.com/scaleway/scaleway-sdk-go/api/instance/v1" "github.com/scaleway/scaleway-sdk-go/scw" "go.woodpecker-ci.org/autoscaler/config" @@ -104,11 +103,7 @@ func (p *Provider) getInstance(ctx context.Context, name string) (*instance.Serv Tags: pool.Tags, } - ops := backoff.OperationWithData[*instance.ListServersResponse](func() (*instance.ListServersResponse, error) { - return api.ListServers(&req, scw.WithContext(ctx)) - }) - - resp, err := backoff.RetryWithData(ops, backoff.WithContext(p.scwCfg.ClientRetry, ctx)) + resp, err := api.ListServers(&req, scw.WithContext(ctx)) if err != nil { return nil, err } @@ -144,11 +139,7 @@ func (p *Provider) getAllInstances(ctx context.Context) ([]*instance.Server, err Tags: pool.Tags, } - ops := backoff.OperationWithData[*instance.ListServersResponse](func() (*instance.ListServersResponse, error) { - return api.ListServers(&req, scw.WithContext(ctx)) - }) - - resp, err := backoff.RetryWithData(ops, backoff.WithContext(p.scwCfg.ClientRetry, ctx)) + resp, err := api.ListServers(&req, scw.WithContext(ctx)) if err != nil { return nil, err } @@ -193,11 +184,7 @@ func (p *Provider) createInstance(ctx context.Context, agent *woodpecker.Agent) Tags: pool.Tags, } - ops := backoff.OperationWithData[*instance.CreateServerResponse](func() (*instance.CreateServerResponse, error) { - return api.CreateServer(&req, scw.WithContext(ctx)) - }) - - res, err := backoff.RetryWithData(ops, backoff.WithContext(p.scwCfg.ClientRetry, ctx)) + res, err := api.CreateServer(&req, scw.WithContext(ctx)) if err != nil { return nil, err } @@ -225,11 +212,7 @@ func (p *Provider) setCloudInit(ctx context.Context, agent *woodpecker.Agent, in Content: bytes.NewBufferString(ud), } - ops := backoff.Operation(func() error { - return api.SetServerUserData(&req, scw.WithContext(ctx)) - }) - - err = backoff.Retry(ops, backoff.WithContext(p.scwCfg.ClientRetry, ctx)) + err = api.SetServerUserData(&req, scw.WithContext(ctx)) if err != nil { return err } @@ -245,40 +228,28 @@ func (p *Provider) deleteInstance(ctx context.Context, inst *instance.Server) er api := instance.NewAPI(p.client) - ops := backoff.Operation(func() error { - return api.DeleteServer(&instance.DeleteServerRequest{ - Zone: inst.Zone, - ServerID: inst.ID, - }) - }) - - return backoff.Retry(ops, backoff.WithContext(p.scwCfg.ClientRetry, ctx)) + return api.DeleteServer(&instance.DeleteServerRequest{ + Zone: inst.Zone, + ServerID: inst.ID, + }, scw.WithContext(ctx)) } func (p *Provider) bootInstance(ctx context.Context, inst *instance.Server) (*instance.ServerActionResponse, error) { api := instance.NewAPI(p.client) - ops := backoff.OperationWithData[*instance.ServerActionResponse](func() (*instance.ServerActionResponse, error) { - return api.ServerAction(&instance.ServerActionRequest{ - Zone: inst.Zone, - ServerID: inst.ID, - Action: instance.ServerActionPoweron, - }) - }) - - return backoff.RetryWithData(ops, backoff.WithContext(p.scwCfg.ClientRetry, ctx)) + return api.ServerAction(&instance.ServerActionRequest{ + Zone: inst.Zone, + ServerID: inst.ID, + Action: instance.ServerActionPoweron, + }, scw.WithContext(ctx)) } func (p *Provider) haltInstance(ctx context.Context, inst *instance.Server) error { api := instance.NewAPI(p.client) - ops := backoff.Operation(func() error { - return api.ServerActionAndWait(&instance.ServerActionAndWaitRequest{ - Zone: inst.Zone, - ServerID: inst.ID, - Action: instance.ServerActionPoweroff, - }) - }) - - return backoff.Retry(ops, backoff.WithContext(p.scwCfg.ClientRetry, ctx)) + return api.ServerActionAndWait(&instance.ServerActionAndWaitRequest{ + Zone: inst.Zone, + ServerID: inst.ID, + Action: instance.ServerActionPoweroff, + }, scw.WithContext(ctx)) } From a9b3feb8a2ffea09b12b4695a67ca4dfc62de8a8 Mon Sep 17 00:00:00 2001 From: Enzo NOCERA Date: Sat, 6 Apr 2024 10:38:40 +0200 Subject: [PATCH 09/10] bump deps after rebase Signed-off-by: Enzo NOCERA --- go.mod | 2 ++ go.sum | 6 ++++++ providers/scaleway/provider.go | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 9d1698b..916a6f4 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/linode/linodego v1.32.0 github.com/rs/zerolog v1.32.0 + github.com/scaleway/scaleway-sdk-go v1.0.0-beta.25 github.com/stretchr/testify v1.9.0 github.com/urfave/cli/v2 v2.27.1 go.woodpecker-ci.org/woodpecker/v2 v2.4.1 @@ -36,5 +37,6 @@ require ( golang.org/x/text v0.14.0 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/ini.v1 v1.66.6 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index f804273..fb10f80 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.3 h1:qMCsGGgs+MAzDFyp9LpAe1Lqy/fY/qCovCm0q github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/franela/goblin v0.0.0-20211003143422-0a4f594942bf h1:NrF81UtW8gG2LBGkXFQFqlfNnvMt9WdB46sfdJY4oqc= github.com/franela/goblin v0.0.0-20211003143422-0a4f594942bf/go.mod h1:VzmDKDJVZI3aJmnRI9VjAn9nJ8qPPsN1fqzr9dqInIo= github.com/go-resty/resty/v2 v2.12.0 h1:rsVL8P90LFvkUYq/V5BTVe203WfRIU4gvcf+yfzJzGA= @@ -52,6 +54,8 @@ github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.25 h1:/8rfZAdFfafRXOgz+ZpMZZWZ5pYggCY9t7e/BvjaBHM= +github.com/scaleway/scaleway-sdk-go v1.0.0-beta.25/go.mod h1:fCa7OJZ/9DRTnOKmxvT6pn+LPWUptQAmHF/SBJUGEcg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho= @@ -124,5 +128,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/ini.v1 v1.66.6 h1:LATuAqN/shcYAOkv3wl2L4rkaKqkcgTBQjOyYDvcPKI= gopkg.in/ini.v1 v1.66.6/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/providers/scaleway/provider.go b/providers/scaleway/provider.go index 682ab4c..fc5d508 100644 --- a/providers/scaleway/provider.go +++ b/providers/scaleway/provider.go @@ -12,7 +12,7 @@ import ( "github.com/scaleway/scaleway-sdk-go/scw" "go.woodpecker-ci.org/autoscaler/config" "go.woodpecker-ci.org/autoscaler/engine" - "go.woodpecker-ci.org/woodpecker/woodpecker-go/woodpecker" + "go.woodpecker-ci.org/woodpecker/v2/woodpecker-go/woodpecker" ) type Provider struct { From edb6c1a41e15fe458b5f8db419200ae04c553e8d Mon Sep 17 00:00:00 2001 From: Enzo NOCERA Date: Sat, 6 Apr 2024 11:01:27 +0200 Subject: [PATCH 10/10] some lint and cleaning Signed-off-by: Enzo NOCERA --- cmd/woodpecker-autoscaler/main.go | 5 ++--- providers/scaleway/config.go | 2 +- providers/scaleway/errors.go | 2 +- providers/scaleway/flags.go | 8 +++++--- providers/scaleway/provider.go | 1 + 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/cmd/woodpecker-autoscaler/main.go b/cmd/woodpecker-autoscaler/main.go index 03a9a6b..2675f70 100644 --- a/cmd/woodpecker-autoscaler/main.go +++ b/cmd/woodpecker-autoscaler/main.go @@ -6,8 +6,6 @@ import ( "strings" "time" - "go.woodpecker-ci.org/autoscaler/providers/scaleway" - _ "github.com/joho/godotenv/autoload" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -16,6 +14,7 @@ import ( "go.woodpecker-ci.org/autoscaler/config" "go.woodpecker-ci.org/autoscaler/engine" "go.woodpecker-ci.org/autoscaler/providers/hetznercloud" + "go.woodpecker-ci.org/autoscaler/providers/scaleway" "go.woodpecker-ci.org/autoscaler/server" ) @@ -35,7 +34,7 @@ func setupProvider(ctx *cli.Context, config *config.Config) (engine.Provider, er return scaleway.New(scwCfg, config) case "": - return nil, fmt.Errorf("Please select a provider") + return nil, fmt.Errorf("please select a provider") } return nil, fmt.Errorf("unknown provider: %s", ctx.String("provider")) diff --git a/providers/scaleway/config.go b/providers/scaleway/config.go index 062fe15..1bc4a52 100644 --- a/providers/scaleway/config.go +++ b/providers/scaleway/config.go @@ -77,7 +77,7 @@ func (l Locality) ResolveZones() ([]scw.Zone, error) { } zones := l.Zones - if zones == nil || len(zones) <= 0 { + if len(zones) == 0 { return nil, errors.New("you need to specify a valid locality") } diff --git a/providers/scaleway/errors.go b/providers/scaleway/errors.go index 259324d..5615d8a 100644 --- a/providers/scaleway/errors.go +++ b/providers/scaleway/errors.go @@ -32,7 +32,7 @@ func (i InstanceDoesNotExists) Error() string { } func (i InstanceDoesNotExists) LogValue() slog.Value { - zones := make([]string, len(i.Zones)) + zones := make([]string, 0, len(i.Zones)) for _, zone := range i.Zones { zones = append(zones, zone.String()) } diff --git a/providers/scaleway/flags.go b/providers/scaleway/flags.go index 9336388..01e2c50 100644 --- a/providers/scaleway/flags.go +++ b/providers/scaleway/flags.go @@ -9,7 +9,8 @@ import ( ) const ( - DefaultPool = "default" + DefaultPool = "default" + DefaultAgentStorageGB = 25 category = "Scaleway" flagPrefix = "scw" @@ -88,7 +89,7 @@ var ProviderFlags = []cli.Flag{ Usage: "How much storage to provision for your agents in GB", EnvVars: []string{envPrefix + "_STORAGE_SIZE"}, Category: category, - Value: 25, + Value: DefaultAgentStorageGB, }, } @@ -137,7 +138,8 @@ func FromCLI(c *cli.Context) (Config, error) { CommercialType: c.String(flagPrefix + "-instance-type"), Image: c.String(flagPrefix + "-image"), EnableIPv6: c.Bool(flagPrefix + "-enable-ipv6"), - Storage: scw.Size(c.Uint64(flagPrefix+"-storage-size") * 1e9), + //nolint:gomnd + Storage: scw.Size(c.Uint64(flagPrefix+"-storage-size") * 1e9), }, } diff --git a/providers/scaleway/provider.go b/providers/scaleway/provider.go index fc5d508..3263baf 100644 --- a/providers/scaleway/provider.go +++ b/providers/scaleway/provider.go @@ -10,6 +10,7 @@ import ( "github.com/scaleway/scaleway-sdk-go/api/instance/v1" "github.com/scaleway/scaleway-sdk-go/scw" + "go.woodpecker-ci.org/autoscaler/config" "go.woodpecker-ci.org/autoscaler/engine" "go.woodpecker-ci.org/woodpecker/v2/woodpecker-go/woodpecker"