diff --git a/README.md b/README.md index 8b158c29..580f5a8a 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,21 @@ services: The agents will use `WOODPECKER_GRPC_ADDR` and a token automatically generated by the autoscaler to connect to the server.Therefore the `WOODPECKER_GRPC_ADDR` has to be publicly accessible from the newly created agents. Check for example how you could use [caddy](https://woodpecker-ci.org/docs/administration/configuration/server#caddy) to expose the grpc connection. +### Oracle Cloud + +Set `WOODPECKER_PROVIDER=oracle` and provide an OCI config file through the default SDK lookup (`~/.oci/config`) or `WOODPECKER_ORACLE_CONFIG_FILE`. The autoscaler launches instances from an image OCID and tags them with the autoscaler pool so only agents from the configured pool are listed or terminated. + +Required Oracle-specific settings: + +- `WOODPECKER_ORACLE_COMPARTMENT_ID` +- `WOODPECKER_ORACLE_AVAILABILITY_DOMAIN` +- `WOODPECKER_ORACLE_SUBNET_ID` +- `WOODPECKER_ORACLE_IMAGE_ID` +- `WOODPECKER_ORACLE_SHAPE` +- `WOODPECKER_ORACLE_SSH_AUTHORIZED_KEY` + +Optional settings include `WOODPECKER_ORACLE_REGION`, `WOODPECKER_ORACLE_PROFILE`, `WOODPECKER_ORACLE_OCPUS`, `WOODPECKER_ORACLE_MEMORY_GBS`, `WOODPECKER_ORACLE_ASSIGN_PUBLIC_IP`, and `WOODPECKER_ORACLE_FREEFORM_TAGS`. + ## Roadmap - [ ] Add support for multiple providers @@ -44,7 +59,7 @@ The agents will use `WOODPECKER_GRPC_ADDR` and a token automatically generated b - [ ] Azure - [ ] Digital Ocean - [x] Linode (temp disabled until the [security issue](https://github.com/woodpecker-ci/autoscaler/issues/91) was addressed) - - [ ] Oracle Cloud + - [x] Oracle Cloud - [ ] Equinix Metal - [x] Vultr - [x] Scaleway diff --git a/cmd/woodpecker-autoscaler/main.go b/cmd/woodpecker-autoscaler/main.go index d77ec1aa..138b6e23 100644 --- a/cmd/woodpecker-autoscaler/main.go +++ b/cmd/woodpecker-autoscaler/main.go @@ -17,6 +17,7 @@ import ( "go.woodpecker-ci.org/autoscaler/engine/types" "go.woodpecker-ci.org/autoscaler/providers/aws" "go.woodpecker-ci.org/autoscaler/providers/hetznercloud" + "go.woodpecker-ci.org/autoscaler/providers/oracle" "go.woodpecker-ci.org/autoscaler/providers/scaleway" "go.woodpecker-ci.org/autoscaler/providers/vultr" "go.woodpecker-ci.org/autoscaler/server" @@ -33,6 +34,8 @@ func setupProvider(ctx context.Context, cmd *cli.Command, config *config.Config) // Enable it again when the issue is fixed. // case "linode": // return linode.New(ctx, config) + case "oracle": + return oracle.New(ctx, cmd, config) case "vultr": return vultr.New(ctx, cmd, config) case "scaleway": @@ -168,6 +171,7 @@ func main() { // Enable it again when the issue is fixed. // app.Flags = append(app.Flags, linode.ProviderFlags...) app.Flags = append(app.Flags, aws.ProviderFlags...) + app.Flags = append(app.Flags, oracle.ProviderFlags...) app.Flags = append(app.Flags, vultr.ProviderFlags...) if err := app.Run(context.Background(), os.Args); err != nil { diff --git a/go.mod b/go.mod index f5d27403..52ac4105 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/hetznercloud/hcloud-go/v2 v2.40.0 github.com/joho/godotenv v1.5.1 github.com/linode/linodego v1.69.1 + github.com/oracle/oci-go-sdk/v65 v65.114.2 github.com/rs/zerolog v1.35.1 github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 github.com/stretchr/testify v1.11.1 @@ -40,6 +41,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/go-resty/resty/v2 v2.17.2 // indirect + github.com/gofrs/flock v0.10.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect @@ -51,8 +53,11 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.17.0 // indirect + github.com/sony/gobreaker/v2 v2.4.0 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect diff --git a/go.sum b/go.sum index be7bed45..b35b8244 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/gofrs/flock v0.10.0 h1:SHMXenfaB03KbroETaCMtbBg3Yn29v4w1r+tgy4ff4k= +github.com/gofrs/flock v0.10.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -75,6 +77,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oracle/oci-go-sdk/v65 v65.114.2 h1:7ROvpCkL5v63HTHKGajlL+++zqz3+jAflfJt4wLE+J8= +github.com/oracle/oci-go-sdk/v65 v65.114.2/go.mod h1:oo33NDf2XPqx3/N6oLG4jFlrqJ0xu4Rlt9SfuAbtDFs= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -86,12 +90,14 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 h1:ObX9hZmK+VmijreZO/8x9pQ8/P/ToHD/bdSb4Eg4tUo= github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36/go.mod h1:LEsDu4BubxK7/cWhtlQWfuxwL4rf/2UEpxXz1o1EMtM= +github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg= +github.com/sony/gobreaker/v2 v2.4.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G83wK1ZQl+s= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -106,6 +112,8 @@ github.com/urfave/cli/v3 v3.9.0 h1:AV9lIiPv3ukYnxunaCUsHnEozptYmDN2F0+yWqLMn/c= github.com/urfave/cli/v3 v3.9.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/vultr/govultr/v3 v3.31.2 h1:2l3/KDvfemG+4azw4LLquJoh9mFOAVEdBXtPPzix3ac= github.com/vultr/govultr/v3 v3.31.2/go.mod h1:2zyUw9yADQaGwKnwDesmIOlBNLrm7edsCfWHFJpWKf8= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.woodpecker-ci.org/woodpecker/v3 v3.14.1 h1:/HrTfGQOJxVHx5+ReCAf/4BBZe+HoE7s/rYgOzZlRi0= @@ -114,6 +122,8 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= diff --git a/providers/oracle/flags.go b/providers/oracle/flags.go new file mode 100644 index 00000000..8da5e57d --- /dev/null +++ b/providers/oracle/flags.go @@ -0,0 +1,99 @@ +package oracle + +import ( + "os" + + "github.com/urfave/cli/v3" +) + +const category = "Oracle Cloud" + +//nolint:mnd +var ProviderFlags = []cli.Flag{ + &cli.StringFlag{ + Name: "oracle-config-file", + Usage: "OCI config file path; defaults to the SDK default config provider", + Sources: cli.EnvVars("WOODPECKER_ORACLE_CONFIG_FILE"), + Category: category, + }, + &cli.StringFlag{ + Name: "oracle-profile", + Value: "DEFAULT", + Usage: "OCI config profile name", + Sources: cli.EnvVars("WOODPECKER_ORACLE_PROFILE"), + Category: category, + }, + &cli.StringFlag{ + Name: "oracle-region", + Usage: "OCI region override, e.g. eu-frankfurt-1", + Sources: cli.EnvVars("WOODPECKER_ORACLE_REGION"), + Category: category, + }, + &cli.StringFlag{ + Name: "oracle-compartment-id", + Usage: "OCI compartment OCID for autoscaler instances", + Sources: cli.EnvVars("WOODPECKER_ORACLE_COMPARTMENT_ID"), + Category: category, + }, + &cli.StringFlag{ + Name: "oracle-availability-domain", + Usage: "OCI availability domain where agents are launched", + Sources: cli.EnvVars("WOODPECKER_ORACLE_AVAILABILITY_DOMAIN"), + Category: category, + }, + &cli.StringFlag{ + Name: "oracle-subnet-id", + Usage: "OCI subnet OCID used for agent VNICs", + Sources: cli.EnvVars("WOODPECKER_ORACLE_SUBNET_ID"), + Category: category, + }, + &cli.StringFlag{ + Name: "oracle-image-id", + Usage: "OCI image OCID used to boot agents", + Sources: cli.EnvVars("WOODPECKER_ORACLE_IMAGE_ID"), + Category: category, + }, + &cli.StringFlag{ + Name: "oracle-shape", + Value: "VM.Standard.E4.Flex", + Usage: "OCI compute shape", + Sources: cli.EnvVars("WOODPECKER_ORACLE_SHAPE"), + Category: category, + }, + &cli.FloatFlag{ + Name: "oracle-ocpus", + Value: 1, + Usage: "OCPU count for flex shapes", + Sources: cli.EnvVars("WOODPECKER_ORACLE_OCPUS"), + Category: category, + }, + &cli.FloatFlag{ + Name: "oracle-memory-gbs", + Value: 6, + Usage: "memory in GB for flex shapes", + Sources: cli.EnvVars("WOODPECKER_ORACLE_MEMORY_GBS"), + Category: category, + }, + &cli.StringFlag{ + Name: "oracle-ssh-authorized-key", + Usage: "SSH public key added to launched agents", + Sources: cli.NewValueSourceChain( + cli.EnvVar("WOODPECKER_ORACLE_SSH_AUTHORIZED_KEY"), + cli.File(os.Getenv("WOODPECKER_ORACLE_SSH_AUTHORIZED_KEY_FILE")), + ), + Category: category, + }, + &cli.StringSliceFlag{ + Name: "oracle-freeform-tags", + Usage: "additional OCI freeform tags as key=value pairs", + Sources: cli.EnvVars("WOODPECKER_ORACLE_FREEFORM_TAGS"), + Category: category, + }, + &cli.BoolFlag{ + Name: "oracle-assign-public-ip", + Value: true, + Usage: "assign public IPv4 addresses to agent VNICs", + Sources: cli.EnvVars("WOODPECKER_ORACLE_ASSIGN_PUBLIC_IP"), + Category: category, + }, +} diff --git a/providers/oracle/provider.go b/providers/oracle/provider.go new file mode 100644 index 00000000..522f4b5b --- /dev/null +++ b/providers/oracle/provider.go @@ -0,0 +1,292 @@ +package oracle + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "strings" + + "github.com/oracle/oci-go-sdk/v65/common" + "github.com/oracle/oci-go-sdk/v65/core" + "github.com/urfave/cli/v3" + "golang.org/x/exp/maps" + + "go.woodpecker-ci.org/autoscaler/config" + "go.woodpecker-ci.org/autoscaler/engine" + "go.woodpecker-ci.org/autoscaler/engine/inits/cloudinit" + "go.woodpecker-ci.org/autoscaler/engine/types" + "go.woodpecker-ci.org/autoscaler/utils" + "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" +) + +var ( + ErrIllegalLabelPrefix = errors.New("illegal label prefix") + ErrParameterNotSet = errors.New("required parameter not set") +) + +type computeClient interface { + LaunchInstance(context.Context, core.LaunchInstanceRequest) (core.LaunchInstanceResponse, error) + TerminateInstance(context.Context, core.TerminateInstanceRequest) (core.TerminateInstanceResponse, error) + ListInstances(context.Context, core.ListInstancesRequest) (core.ListInstancesResponse, error) +} + +type Provider struct { + name string + compartmentID string + availabilityDomain string + subnetID string + imageID string + shape string + ocpus float32 + memoryInGBs float32 + sshAuthorizedKey string + assignPublicIP bool + tags map[string]string + config *config.Config + client computeClient +} + +func New(_ context.Context, c *cli.Command, config *config.Config) (types.Provider, error) { + p := &Provider{ + name: "oracle", + compartmentID: c.String("oracle-compartment-id"), + availabilityDomain: c.String("oracle-availability-domain"), + subnetID: c.String("oracle-subnet-id"), + imageID: c.String("oracle-image-id"), + shape: c.String("oracle-shape"), + ocpus: float32(c.Float("oracle-ocpus")), + memoryInGBs: float32(c.Float("oracle-memory-gbs")), + sshAuthorizedKey: c.String("oracle-ssh-authorized-key"), + assignPublicIP: c.Bool("oracle-assign-public-ip"), + config: config, + } + + if err := p.validateConfig(); err != nil { + return nil, err + } + + defaultTags := map[string]string{ + engine.LabelPool: config.PoolID, + engine.LabelImage: p.imageID, + } + tags, err := utils.SliceToMap(c.StringSlice("oracle-freeform-tags"), "=") + if err != nil { + return nil, fmt.Errorf("%s: %w", p.name, err) + } + for _, key := range maps.Keys(tags) { + if strings.HasPrefix(key, engine.LabelPrefix) { + return nil, fmt.Errorf("%s: %w: %s", p.name, ErrIllegalLabelPrefix, engine.LabelPrefix) + } + } + p.tags = utils.MergeMaps(defaultTags, tags) + + client, err := newComputeClient(c) + if err != nil { + return nil, fmt.Errorf("%s: new compute client: %w", p.name, err) + } + p.client = client + + return p, nil +} + +func (p *Provider) validateConfig() error { + required := map[string]string{ + "oracle-compartment-id": p.compartmentID, + "oracle-availability-domain": p.availabilityDomain, + "oracle-subnet-id": p.subnetID, + "oracle-image-id": p.imageID, + "oracle-shape": p.shape, + "oracle-ssh-authorized-key": p.sshAuthorizedKey, + } + for name, value := range required { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%w: %s", ErrParameterNotSet, name) + } + } + return nil +} + +func newComputeClient(c *cli.Command) (computeClient, error) { + provider := common.DefaultConfigProvider() + if configFile := c.String("oracle-config-file"); configFile != "" { + provider = common.CustomProfileConfigProvider(configFile, c.String("oracle-profile")) + } + if region := c.String("oracle-region"); region != "" { + provider = regionOverrideProvider{ + ConfigurationProvider: provider, + region: region, + } + } + + return core.NewComputeClientWithConfigurationProvider(provider) +} + +type regionOverrideProvider struct { + common.ConfigurationProvider + region string +} + +func (p regionOverrideProvider) Region() (string, error) { + return p.region, nil +} + +func (p *Provider) DeployAgent(ctx context.Context, agent *woodpecker.Agent) error { + userData, err := cloudinit.RenderUserDataTemplate(p.config, agent, nil) + if err != nil { + return fmt.Errorf("%s: cloudinit.RenderUserDataTemplate: %w", p.name, err) + } + + metadata := map[string]string{ + "ssh_authorized_keys": p.sshAuthorizedKey, + "user_data": base64.StdEncoding.EncodeToString([]byte(userData)), + } + + shapeConfig := &core.LaunchInstanceShapeConfigDetails{} + if p.ocpus > 0 { + shapeConfig.Ocpus = float32Ptr(p.ocpus) + } + if p.memoryInGBs > 0 { + shapeConfig.MemoryInGBs = float32Ptr(p.memoryInGBs) + } + if shapeConfig.Ocpus == nil && shapeConfig.MemoryInGBs == nil { + shapeConfig = nil + } + + _, err = p.client.LaunchInstance(ctx, core.LaunchInstanceRequest{ + LaunchInstanceDetails: core.LaunchInstanceDetails{ + AvailabilityDomain: strPtr(p.availabilityDomain), + CompartmentId: strPtr(p.compartmentID), + DisplayName: strPtr(agent.Name), + FreeformTags: p.tags, + Metadata: metadata, + Shape: strPtr(p.shape), + ShapeConfig: shapeConfig, + CreateVnicDetails: &core.CreateVnicDetails{ + AssignPublicIp: boolPtr(p.assignPublicIP), + SubnetId: strPtr(p.subnetID), + }, + SourceDetails: core.InstanceSourceViaImageDetails{ + ImageId: strPtr(p.imageID), + }, + }, + }) + if err != nil { + return fmt.Errorf("%s: LaunchInstance: %w", p.name, err) + } + + return nil +} + +func (p *Provider) RemoveAgent(ctx context.Context, agent *woodpecker.Agent) error { + instance, err := p.getAgent(ctx, agent) + if err != nil { + return fmt.Errorf("%s: getAgent: %w", p.name, err) + } + if instance == nil { + return nil + } + + _, err = p.client.TerminateInstance(ctx, core.TerminateInstanceRequest{ + InstanceId: instance.Id, + }) + if err != nil { + return fmt.Errorf("%s: TerminateInstance: %w", p.name, err) + } + + return nil +} + +func (p *Provider) ListDeployedAgentNames(ctx context.Context) ([]string, error) { + instances, err := p.listPoolInstances(ctx, "") + if err != nil { + return nil, err + } + + names := make([]string, 0, len(instances)) + for _, instance := range instances { + if instance.DisplayName != nil { + names = append(names, *instance.DisplayName) + } + } + + return names, nil +} + +func (p *Provider) getAgent(ctx context.Context, agent *woodpecker.Agent) (*core.Instance, error) { + instances, err := p.listPoolInstances(ctx, agent.Name) + if err != nil { + return nil, err + } + if len(instances) == 0 { + return nil, nil + } + if len(instances) > 1 { + return nil, fmt.Errorf("found multiple instances with display name %s", agent.Name) + } + + return &instances[0], nil +} + +func (p *Provider) listPoolInstances(ctx context.Context, displayName string) ([]core.Instance, error) { + var page *string + instances := make([]core.Instance, 0) + + for { + req := core.ListInstancesRequest{ + AvailabilityDomain: strPtr(p.availabilityDomain), + CompartmentId: strPtr(p.compartmentID), + Limit: intPtr(100), + Page: page, + } + if displayName != "" { + req.DisplayName = strPtr(displayName) + } + + resp, err := p.client.ListInstances(ctx, req) + if err != nil { + return nil, fmt.Errorf("%s: ListInstances: %w", p.name, err) + } + + for _, instance := range resp.Items { + if p.isPoolInstance(instance) && isActive(instance.LifecycleState) { + instances = append(instances, instance) + } + } + + if resp.OpcNextPage == nil || *resp.OpcNextPage == "" { + break + } + page = resp.OpcNextPage + } + + return instances, nil +} + +func (p *Provider) isPoolInstance(instance core.Instance) bool { + if instance.FreeformTags == nil { + return false + } + return instance.FreeformTags[engine.LabelPool] == p.config.PoolID +} + +func isActive(state core.InstanceLifecycleStateEnum) bool { + return state != core.InstanceLifecycleStateTerminating && + state != core.InstanceLifecycleStateTerminated +} + +func strPtr(value string) *string { + return &value +} + +func boolPtr(value bool) *bool { + return &value +} + +func float32Ptr(value float32) *float32 { + return &value +} + +func intPtr(value int) *int { + return &value +} diff --git a/providers/oracle/provider_test.go b/providers/oracle/provider_test.go new file mode 100644 index 00000000..bc2ee59f --- /dev/null +++ b/providers/oracle/provider_test.go @@ -0,0 +1,200 @@ +package oracle + +import ( + "context" + "encoding/base64" + "errors" + "testing" + + "github.com/oracle/oci-go-sdk/v65/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.woodpecker-ci.org/autoscaler/config" + "go.woodpecker-ci.org/autoscaler/engine" + "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker" +) + +func TestDeployAgentLaunchesConfiguredInstance(t *testing.T) { + client := &mockComputeClient{} + provider := testProvider(client) + + err := provider.DeployAgent(t.Context(), &woodpecker.Agent{ + Name: "agent-1", + Token: "secret", + }) + require.NoError(t, err) + require.Len(t, client.launchRequests, 1) + + details := client.launchRequests[0].LaunchInstanceDetails + assert.Equal(t, "Uocm:PHX-AD-1", *details.AvailabilityDomain) + assert.Equal(t, "ocid1.compartment.oc1..test", *details.CompartmentId) + assert.Equal(t, "agent-1", *details.DisplayName) + assert.Equal(t, "VM.Standard.E4.Flex", *details.Shape) + assert.Equal(t, "ocid1.subnet.oc1..test", *details.CreateVnicDetails.SubnetId) + assert.True(t, *details.CreateVnicDetails.AssignPublicIp) + assert.Equal(t, "pool-1", details.FreeformTags[engine.LabelPool]) + assert.Equal(t, "ocid1.image.oc1..test", details.FreeformTags[engine.LabelImage]) + assert.Equal(t, float32(1), *details.ShapeConfig.Ocpus) + assert.Equal(t, float32(6), *details.ShapeConfig.MemoryInGBs) + assert.Equal(t, "ssh-ed25519 AAAA test", details.Metadata["ssh_authorized_keys"]) + + decodedUserData, err := base64.StdEncoding.DecodeString(details.Metadata["user_data"]) + require.NoError(t, err) + assert.Contains(t, string(decodedUserData), "WOODPECKER_AGENT_SECRET=secret") + + source, ok := details.SourceDetails.(core.InstanceSourceViaImageDetails) + require.True(t, ok) + assert.Equal(t, "ocid1.image.oc1..test", *source.ImageId) +} + +func TestListDeployedAgentNamesFiltersPoolStateAndPages(t *testing.T) { + nextPage := "next" + client := &mockComputeClient{ + listResponses: []core.ListInstancesResponse{ + { + Items: []core.Instance{ + testInstance("agent-1", "instance-1", "pool-1", core.InstanceLifecycleStateRunning), + testInstance("other-pool", "instance-2", "pool-2", core.InstanceLifecycleStateRunning), + }, + OpcNextPage: &nextPage, + }, + { + Items: []core.Instance{ + testInstance("agent-2", "instance-3", "pool-1", core.InstanceLifecycleStateProvisioning), + testInstance("terminating", "instance-4", "pool-1", core.InstanceLifecycleStateTerminating), + }, + }, + }, + } + provider := testProvider(client) + + names, err := provider.ListDeployedAgentNames(t.Context()) + require.NoError(t, err) + + assert.Equal(t, []string{"agent-1", "agent-2"}, names) + require.Len(t, client.listRequests, 2) + assert.Nil(t, client.listRequests[0].Page) + assert.Equal(t, nextPage, *client.listRequests[1].Page) +} + +func TestRemoveAgentTerminatesMatchingInstance(t *testing.T) { + client := &mockComputeClient{ + listResponses: []core.ListInstancesResponse{ + {Items: []core.Instance{ + testInstance("agent-1", "instance-1", "pool-1", core.InstanceLifecycleStateRunning), + }}, + }, + } + provider := testProvider(client) + + err := provider.RemoveAgent(t.Context(), &woodpecker.Agent{Name: "agent-1"}) + require.NoError(t, err) + + require.Len(t, client.listRequests, 1) + assert.Equal(t, "agent-1", *client.listRequests[0].DisplayName) + require.Len(t, client.terminateRequests, 1) + assert.Equal(t, "instance-1", *client.terminateRequests[0].InstanceId) +} + +func TestRemoveAgentErrorsOnDuplicateInstances(t *testing.T) { + client := &mockComputeClient{ + listResponses: []core.ListInstancesResponse{ + {Items: []core.Instance{ + testInstance("agent-1", "instance-1", "pool-1", core.InstanceLifecycleStateRunning), + testInstance("agent-1", "instance-2", "pool-1", core.InstanceLifecycleStateRunning), + }}, + }, + } + provider := testProvider(client) + + err := provider.RemoveAgent(t.Context(), &woodpecker.Agent{Name: "agent-1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "multiple instances") + assert.Empty(t, client.terminateRequests) +} + +func TestDeployAgentWrapsRenderErrors(t *testing.T) { + provider := testProvider(&mockComputeClient{}) + provider.config.UserData = "{{ .Missing }}" + + err := provider.DeployAgent(t.Context(), &woodpecker.Agent{Name: "agent-1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "RenderUserDataTemplate") +} + +func TestListDeployedAgentNamesWrapsClientError(t *testing.T) { + provider := testProvider(&mockComputeClient{listErr: errors.New("boom")}) + + _, err := provider.ListDeployedAgentNames(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "ListInstances") +} + +type mockComputeClient struct { + launchRequests []core.LaunchInstanceRequest + terminateRequests []core.TerminateInstanceRequest + listRequests []core.ListInstancesRequest + listResponses []core.ListInstancesResponse + listErr error +} + +func (m *mockComputeClient) LaunchInstance(_ context.Context, req core.LaunchInstanceRequest) (core.LaunchInstanceResponse, error) { + m.launchRequests = append(m.launchRequests, req) + return core.LaunchInstanceResponse{}, nil +} + +func (m *mockComputeClient) TerminateInstance(_ context.Context, req core.TerminateInstanceRequest) (core.TerminateInstanceResponse, error) { + m.terminateRequests = append(m.terminateRequests, req) + return core.TerminateInstanceResponse{}, nil +} + +func (m *mockComputeClient) ListInstances(_ context.Context, req core.ListInstancesRequest) (core.ListInstancesResponse, error) { + m.listRequests = append(m.listRequests, req) + if m.listErr != nil { + return core.ListInstancesResponse{}, m.listErr + } + if len(m.listResponses) == 0 { + return core.ListInstancesResponse{}, nil + } + resp := m.listResponses[0] + m.listResponses = m.listResponses[1:] + return resp, nil +} + +func testProvider(client computeClient) *Provider { + return &Provider{ + name: "oracle", + compartmentID: "ocid1.compartment.oc1..test", + availabilityDomain: "Uocm:PHX-AD-1", + subnetID: "ocid1.subnet.oc1..test", + imageID: "ocid1.image.oc1..test", + shape: "VM.Standard.E4.Flex", + ocpus: 1, + memoryInGBs: 6, + sshAuthorizedKey: "ssh-ed25519 AAAA test", + assignPublicIP: true, + tags: map[string]string{ + engine.LabelPool: "pool-1", + engine.LabelImage: "ocid1.image.oc1..test", + }, + config: &config.Config{ + PoolID: "pool-1", + GRPCAddress: "grpc.example.com:9000", + Image: "woodpeckerci/woodpecker-agent:next", + WorkflowsPerAgent: 2, + }, + client: client, + } +} + +func testInstance(name, id, pool string, state core.InstanceLifecycleStateEnum) core.Instance { + return core.Instance{ + DisplayName: strPtr(name), + Id: strPtr(id), + LifecycleState: state, + FreeformTags: map[string]string{ + engine.LabelPool: pool, + }, + } +}