Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ The agents will use `WOODPECKER_GRPC_ADDR` and a token automatically generated b
- [x] Amazon AWS
- [ ] Google Cloud
- [ ] Azure
- [ ] Digital Ocean
- [x] Digital Ocean
- [x] Linode (temp disabled until the [security issue](https://github.com/woodpecker-ci/autoscaler/issues/91) was addressed)
- [ ] Oracle Cloud
- [ ] Equinix Metal
Expand Down
4 changes: 4 additions & 0 deletions cmd/woodpecker-autoscaler/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"go.woodpecker-ci.org/autoscaler/config"
"go.woodpecker-ci.org/autoscaler/engine"
"go.woodpecker-ci.org/autoscaler/providers/aws"
"go.woodpecker-ci.org/autoscaler/providers/digitalocean"
"go.woodpecker-ci.org/autoscaler/providers/hetznercloud"
"go.woodpecker-ci.org/autoscaler/providers/scaleway"
"go.woodpecker-ci.org/autoscaler/providers/vultr"
Expand All @@ -34,6 +35,8 @@ func setupProvider(ctx context.Context, cmd *cli.Command, config *config.Config)
// return linode.New(ctx, config)
case "vultr":
return vultr.New(ctx, cmd, config)
case "digitalocean":
return digitalocean.New(ctx, cmd, config)
case "scaleway":
return scaleway.New(ctx, cmd, config)
case "":
Expand Down Expand Up @@ -141,6 +144,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, digitalocean.ProviderFlags...)
app.Flags = append(app.Flags, vultr.ProviderFlags...)

if err := app.Run(context.Background(), os.Args); err != nil {
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.32.14
github.com/aws/aws-sdk-go-v2/service/ec2 v1.297.0
github.com/digitalocean/godo v1.184.0
github.com/docker/go-units v0.5.0
github.com/hetznercloud/hcloud-go/v2 v2.37.0
github.com/joho/godotenv v1.5.1
Expand Down Expand Up @@ -55,6 +56,7 @@ require (
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/digitalocean/godo v1.184.0 h1:2B2CQhxftlf3xa24Nrzn5CBQlaQjyaWqi3XbbnJlG3w=
github.com/digitalocean/godo v1.184.0/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
Expand Down
77 changes: 77 additions & 0 deletions providers/digitalocean/flags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package digitalocean

import (
"os"

"github.com/urfave/cli/v3"
)

const category = "DigitalOcean"

var ProviderFlags = []cli.Flag{
&cli.StringFlag{
Name: "digitalocean-api-token",
Usage: "DigitalOcean API token",
Sources: cli.NewValueSourceChain(
cli.EnvVar("WOODPECKER_DIGITALOCEAN_API_TOKEN"),
cli.File(os.Getenv("WOODPECKER_DIGITALOCEAN_API_TOKEN_FILE")),
),
Category: category,
},
&cli.StringFlag{
Name: "digitalocean-region",
Value: "nyc1",
Usage: "DigitalOcean region slug",
Sources: cli.EnvVars("WOODPECKER_DIGITALOCEAN_REGION"),
Category: category,
},
&cli.StringFlag{
Name: "digitalocean-size",
Value: "s-1vcpu-1gb",
Usage: "DigitalOcean droplet size slug",
Sources: cli.EnvVars("WOODPECKER_DIGITALOCEAN_SIZE"),
Category: category,
},
&cli.StringFlag{
Name: "digitalocean-image",
Value: "ubuntu-22-04-x64",
Usage: "DigitalOcean image slug or ID",
Sources: cli.EnvVars("WOODPECKER_DIGITALOCEAN_IMAGE"),
Category: category,
},
&cli.StringSliceFlag{
Name: "digitalocean-ssh-keys",
Usage: "DigitalOcean SSH key fingerprints or IDs",
Sources: cli.EnvVars("WOODPECKER_DIGITALOCEAN_SSH_KEYS"),
Category: category,
},
&cli.StringSliceFlag{
Name: "digitalocean-tags",
Usage: "DigitalOcean droplet tags",
Sources: cli.EnvVars("WOODPECKER_DIGITALOCEAN_TAGS"),
Category: category,
},
&cli.BoolFlag{
Name: "digitalocean-ipv6",
Value: false,
Usage: "enable IPv6 for droplets",
Sources: cli.EnvVars("WOODPECKER_DIGITALOCEAN_IPV6"),
Category: category,
},
&cli.StringFlag{
Name: "digitalocean-vpc-uuid",
Usage: "DigitalOcean VPC UUID",
Sources: cli.EnvVars("WOODPECKER_DIGITALOCEAN_VPC_UUID"),
Category: category,
},
// TODO: Deprecated remove in v2.0
&cli.StringFlag{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we dont add deprecations we remove them...

Name: "digitalocean-user-data",
Usage: "DigitalOcean userdata template (deprecated)",
Sources: cli.NewValueSourceChain(
cli.EnvVar("WOODPECKER_DIGITALOCEAN_USERDATA"),
cli.File(os.Getenv("WOODPECKER_DIGITALOCEAN_USERDATA_FILE")),
),
Category: category,
},
}
203 changes: 203 additions & 0 deletions providers/digitalocean/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package digitalocean

import (
"context"
"errors"
"fmt"
"strings"
"text/template"

"github.com/digitalocean/godo"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v3"

"go.woodpecker-ci.org/autoscaler/config"
"go.woodpecker-ci.org/autoscaler/engine"
"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker"
)

var (
ErrIllegalLabelPrefix = errors.New("illegal label prefix")
)

// DropletsService abstracts the godo.DropletsService for testability.
type DropletsService interface {
Create(ctx context.Context, createRequest *godo.DropletCreateRequest) (*godo.Droplet, *godo.Response, error)
Delete(ctx context.Context, dropletID int) (*godo.Response, error)
List(ctx context.Context, opt *godo.ListOptions) ([]godo.Droplet, *godo.Response, error)
}

type Provider struct {
name string
region string
size string
image string
sshKeys []string
tags []string
enableIPv6 bool
vpcUUID string
userDataTemplate *template.Template
config *config.Config
droplets DropletsService
}

func New(_ context.Context, c *cli.Command, cfg *config.Config) (engine.Provider, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolfe config on new and throuw error

not on first agent development!

see #605

token := c.String("digitalocean-api-token")
client := godo.NewFromToken(token)

p := &Provider{
name: "digitalocean",
region: c.String("digitalocean-region"),
size: c.String("digitalocean-size"),
image: c.String("digitalocean-image"),
sshKeys: c.StringSlice("digitalocean-ssh-keys"),
enableIPv6: c.Bool("digitalocean-ipv6"),
vpcUUID: c.String("digitalocean-vpc-uuid"),
config: cfg,
droplets: client.Droplets,
}

// TODO: Deprecated remove in v2.0
if u := c.String("digitalocean-user-data"); u != "" {
log.Warn().Msg("digitalocean-user-data is deprecated, please use provider-user-data instead")
userDataTmpl, err := template.New("user-data").Parse(u)
if err != nil {
return nil, fmt.Errorf("%s: template.New.Parse %w", p.name, err)
}
p.userDataTemplate = userDataTmpl
}

// Build default tags (used for listing deployed agents)
defaultTags := []string{
fmt.Sprintf("%s=%s", engine.LabelPool, cfg.PoolID),
fmt.Sprintf("%s=%s", engine.LabelImage, p.image),
}

userTags := c.StringSlice("digitalocean-tags")
for _, tag := range userTags {
key, _, _ := strings.Cut(tag, "=")
if strings.HasPrefix(key, engine.LabelPrefix) {
return nil, fmt.Errorf("%s: %w: %s", p.name, ErrIllegalLabelPrefix, engine.LabelPrefix)
}
}
p.tags = append(defaultTags, userTags...)

return p, nil
}

func (p *Provider) DeployAgent(ctx context.Context, agent *woodpecker.Agent) error {
userData, err := engine.RenderUserDataTemplate(p.config, agent, p.userDataTemplate)
if err != nil {
return fmt.Errorf("%s: engine.RenderUserDataTemplate: %w", p.name, err)
}

sshKeys := make([]godo.DropletCreateSSHKey, 0, len(p.sshKeys))
for _, key := range p.sshKeys {
sshKeys = append(sshKeys, godo.DropletCreateSSHKey{Fingerprint: key})
}

createReq := &godo.DropletCreateRequest{
Name: agent.Name,
Region: p.region,
Size: p.size,
UserData: userData,
SSHKeys: sshKeys,
Tags: p.tags,
IPv6: p.enableIPv6,
Image: godo.DropletCreateImage{
Slug: p.image,
},
}

if p.vpcUUID != "" {
createReq.VPCUUID = p.vpcUUID
}

log.Info().Msgf("create agent: region = %s size = %s", p.region, p.size)

_, _, err = p.droplets.Create(ctx, createReq)
if err != nil {
return fmt.Errorf("%s: Droplets.Create: %w", p.name, err)
}

return nil
}

func (p *Provider) RemoveAgent(ctx context.Context, agent *woodpecker.Agent) error {
droplet, err := p.findDropletByName(ctx, agent.Name)
if err != nil {
return fmt.Errorf("%s: findDropletByName: %w", p.name, err)
}

if droplet == nil {
return nil
}

_, err = p.droplets.Delete(ctx, droplet.ID)
if err != nil {
return fmt.Errorf("%s: Droplets.Delete: %w", p.name, err)
}

return nil
}

func (p *Provider) findDropletByName(ctx context.Context, name string) (*godo.Droplet, error) {
page := 1
for {
droplets, resp, err := p.droplets.List(ctx, &godo.ListOptions{
Page: page,
PerPage: 200, //nolint:mnd
})
if err != nil {
return nil, err
}

for i := range droplets {
if droplets[i].Name == name {
return &droplets[i], nil
}
}

if resp == nil || resp.Links == nil || resp.Links.Pages == nil || resp.Links.Pages.Next == "" {
break
}
page++
}

return nil, nil
}

func (p *Provider) ListDeployedAgentNames(ctx context.Context) ([]string, error) {
var names []string

page := 1
for {
droplets, resp, err := p.droplets.List(ctx, &godo.ListOptions{
Page: page,
PerPage: 200, //nolint:mnd
})
if err != nil {
return nil, fmt.Errorf("%s: Droplets.List: %w", p.name, err)
}

poolTag := fmt.Sprintf("%s=%s", engine.LabelPool, p.config.PoolID)
for _, d := range droplets {
for _, tag := range d.Tags {
if tag == poolTag {
names = append(names, d.Name)
break
}
}
}

if resp == nil || resp.Links == nil || resp.Links.Pages == nil || resp.Links.Pages.Next == "" {
break
}
page++
}

return names, nil
}

// Ensure Provider implements engine.Provider at compile time.
var _ engine.Provider = (*Provider)(nil)
Loading