Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Vultr provider #88

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
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
5 changes: 5 additions & 0 deletions cmd/woodpecker-autoscaler/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,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/vultr"
"go.woodpecker-ci.org/autoscaler/server"
)

Expand All @@ -25,6 +26,8 @@ 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 "vultr":
return vultr.New(ctx, config)
case "":
return nil, fmt.Errorf("please select a provider")
}
Expand Down Expand Up @@ -119,6 +122,8 @@ 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...)
// Register vultr flags
app.Flags = append(app.Flags, vultr.DriverFlags...)

if err := app.Run(os.Args); err != nil {
log.Fatal().Err(err).Msg("")
Expand Down
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/rs/zerolog v1.32.0
github.com/stretchr/testify v1.8.4
github.com/urfave/cli/v2 v2.27.1
github.com/vultr/govultr/v3 v3.6.1
go.woodpecker-ci.org/woodpecker/v2 v2.3.0
golang.org/x/exp v0.0.0-20240314144324-c7f7c6466f7f
golang.org/x/net v0.22.0
Expand All @@ -23,6 +24,9 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-resty/resty/v2 v2.11.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
Expand Down
15 changes: 15 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,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/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
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.11.0 h1:i7jMfNOJYMp69lq7qozJP+bjgzfAzeOhuGlyDrqxT/8=
Expand All @@ -16,9 +18,19 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-hclog v1.2.0 h1:La19f8d7WIlm4ogzNHB0JGqs5AUDAZ2UfCY4sJXcJdM=
github.com/hashicorp/go-hclog v1.2.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hetznercloud/hcloud-go/v2 v2.6.0 h1:RJOA2hHZ7rD1pScA4O1NF6qhkHyUdbbxjHgFNot8928=
github.com/hetznercloud/hcloud-go/v2 v2.6.0/go.mod h1:4J1cSE57+g0WS93IiHLV7ubTHItcp+awzeBp5bM9mfA=
github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww=
Expand Down Expand Up @@ -57,10 +69,13 @@ 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/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/urfave/cli/v2 v2.27.1 h1:8xSQ6szndafKVRmfyeUMxkNUJQMjL1F2zmsZ+qHpfho=
github.com/urfave/cli/v2 v2.27.1/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
github.com/vultr/govultr/v3 v3.6.1 h1:l1hAXGtqWVnobBpLRzW/BxoocYFI7SSBwQHw65ntLk4=
github.com/vultr/govultr/v3 v3.6.1/go.mod h1:rt9v2x114jZmmLAE/h5N5jnxTmsK9ewwS2oQZ0UBQzM=
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e h1:+SOyEddqYF09QP7vr7CgJ1eti3pY9Fn3LHO1M1r/0sI=
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
Expand Down
67 changes: 67 additions & 0 deletions providers/vultr/flags.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package vultr

import (
"os"

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

const category = "Vultr"

var DriverFlags = []cli.Flag{
// vultr
&cli.StringFlag{
Name: "vultr-api-token",
Usage: "vultr api token",
EnvVars: []string{"WOODPECKER_VULTR_API_TOKEN"},
FilePath: os.Getenv("WOODPECKER_VULTR_API_TOKEN_FILE"),
Category: category,
},
&cli.StringFlag{
Name: "vultr-region",
Value: "nbg1",
Usage: "vultr region",
EnvVars: []string{"WOODPECKER_VULTR_REGION"},
Category: category,
},
&cli.StringFlag{
Name: "vultr-plan",
Value: "",
Usage: "vultr plan",
EnvVars: []string{"WOODPECKER_VULTR_PLAN"},
Category: category,
},
&cli.StringSliceFlag{
Name: "vultr-ssh-keys",
Usage: "names of vultr ssh keys",
EnvVars: []string{"WOODPECKER_VULTR_SSH_KEYS"},
Category: category,
},
&cli.StringFlag{
Name: "vultr-user-data",
Usage: "vultr userdata template",
EnvVars: []string{"WOODPECKER_VULTR_USERDATA"},
FilePath: os.Getenv("WOODPECKER_VULTR_USERDATA_FILE"),
Category: category,
},
&cli.StringFlag{
Name: "vultr-image",
Value: "ubuntu-22.04",
Usage: "vultr image",
EnvVars: []string{"WOODPECKER_VULTR_IMAGE"},
Category: category,
},
&cli.StringSliceFlag{
Name: "vultr-labels",
Usage: "vultr server labels",
EnvVars: []string{"WOODPECKER_VULTR_LABELS"},
Category: category,
},
&cli.BoolFlag{
Name: "vultr-public-ipv6-enable",
Value: true,
Usage: "enables public ipv6 network for agents",
EnvVars: []string{"WOODPECKER_VULTR_PUBLIC_IPV6_ENABLE"},
Category: category,
},
}
231 changes: 231 additions & 0 deletions providers/vultr/provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
package vultr

import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"
"text/template"
"time"

"github.com/urfave/cli/v2"
"github.com/vultr/govultr/v3"
"golang.org/x/exp/maps"
"golang.org/x/oauth2"

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

var (
ErrIllegalLablePrefix = errors.New("illegal label prefix")
ErrImageNotFound = errors.New("image not found")
ErrSSHKeyNotFound = errors.New("SSH key not found")
)

type Provider struct {
plan string
userData *template.Template
image string
sshKeys []string
labels map[string]string
config *config.Config
region string
enableIPv6 bool
name string
client *govultr.Client
}

func New(c *cli.Context, config *config.Config) (engine.Provider, error) {
p := &Provider{
name: "vultr",
region: c.String("vultr-region"),
plan: c.String("vultr-plan"),
image: c.String("vultr-image"),
enableIPv6: c.Bool("vultr-public-ipv6-enable"),
config: config,
}
oauthConfig := &oauth2.Config{}
ctx := context.Background()
ts := oauthConfig.TokenSource(ctx, &oauth2.Token{AccessToken: c.String("vultr-api-token")})
p.client = govultr.NewClient(oauth2.NewClient(ctx, ts))

err := p.setupKeypair(ctx)
if err != nil {
return nil, fmt.Errorf("%s: setupKeypair: %w", p.name, err)
}

userDataStr := engine.CloudInitUserDataUbuntuDefault
if _userDataStr := c.String("vultr-user-data"); _userDataStr != "" {
userDataStr = _userDataStr
}
userDataTmpl, err := template.New("user-data").Parse(userDataStr)
if err != nil {
return nil, fmt.Errorf("%s: template.New.Parse %w", p.name, err)
}
p.userData = userDataTmpl

defaultLabels := make(map[string]string, 0)
defaultLabels[engine.LabelPool] = p.config.PoolID
defaultLabels[engine.LabelImage] = p.image

labels, err := engine.SliceToMap(c.StringSlice("vultr-labels"), "=")
if err != nil {
return nil, fmt.Errorf("%s: %w", p.name, err)
}
for _, key := range maps.Keys(labels) {
if strings.HasPrefix(key, engine.LabelPrefix) {
return nil, fmt.Errorf("%s: %w: %s", p.name, ErrIllegalLablePrefix, engine.LabelPrefix)
}
}
p.labels = engine.MergeMaps(defaultLabels, p.labels)

return p, nil
}

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

image := -1
osList, _, _, err := p.client.OS.List(ctx, &govultr.ListOptions{})
if err != nil {
return fmt.Errorf("%s: OS.List: %w", p.name, err)
}
for _, osS := range osList {
if osS.Name == p.image {
image = osS.ID
break
}
}
if image == -1 {
return fmt.Errorf("%s: DeployAgent: no image found for %s", p.name, p.image)
}
tags := make([]string, 0)
for key, item := range p.labels {
tags = append(tags, fmt.Sprintf("%s=%s", key, item))
}

_, _, err = p.client.Instance.Create(ctx, &govultr.InstanceCreateReq{
Hostname: agent.Name,
UserData: base64.StdEncoding.EncodeToString([]byte(userdataString)),
Plan: p.plan,
Region: p.region,
Label: agent.Name,
Tags: tags,
OsID: image,
EnableVPC: govultr.BoolToBoolPtr(false), // TODO: allow to use private networks
ActivationEmail: govultr.BoolToBoolPtr(false),
SSHKeys: p.sshKeys,
EnableIPv6: &p.enableIPv6,
})
if err != nil {
return fmt.Errorf("%s: ServerCreate: %w", p.name, err)
}

return nil
}

func (p *Provider) getAgent(ctx context.Context, agent *woodpecker.Agent) (*govultr.Instance, error) {
servers, _, _, err := p.client.Instance.List(ctx, &govultr.ListOptions{
Label: agent.Name,
})
if err != nil {
return nil, fmt.Errorf("%s: %w", p.name, err)
}

if len(servers) == 0 {
return nil, nil
}

if len(servers) > 1 {
return nil, fmt.Errorf("%s: getAgent: found multiple instances with label %s", p.name, agent.Name)
}

return &servers[0], nil
}

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

if server == nil {
return nil
}

err = p.client.Instance.Delete(ctx, server.ID)
if err != nil {
return fmt.Errorf("%s: %w", p.name, err)
}

return nil
}

func (p *Provider) ListDeployedAgentNames(ctx context.Context) ([]string, error) {
var names []string
pageOpts := 200
listOptions := &govultr.ListOptions{
Tag: engine.LabelPool + "=" + p.config.PoolID,
PerPage: pageOpts,
}

// Allow time for records to show up in the API
// Otherwise cleanup loop tries to destroy instances before they
// are provisioned :(.

// TODO: Maybe investigate whether the cleanup loop can exclude
// newly provisioned instances for a bit?
time.Sleep(20 * time.Second)

servers, _, _, err := p.client.Instance.List(ctx,
listOptions)
if err != nil {
return nil, fmt.Errorf("%s: %w", p.name, err)
}

for _, server := range servers {
names = append(names, server.Hostname)
}

return names, nil
}

func (p *Provider) setupKeypair(ctx context.Context) error {
res, _, _, err := p.client.SSHKey.List(ctx, nil)
if err != nil {
return err
}

index := map[string]string{}
for key := range res {
index[res[key].Name] = res[key].ID
}

// if the account has multiple keys configured try to
// use an existing key based on naming convention.
for _, name := range []string{"woodpecker", "id_rsa_woodpecker"} {
fingerprint, ok := index[name]
if !ok {
continue
}
p.sshKeys = append(p.sshKeys, fingerprint)

return nil
}

// if there were no matches but the account has at least
// one keypair already created we will select the first
// in the list.
if len(res) > 0 {
p.sshKeys = append(p.sshKeys, res[0].ID)
return nil
}

return errors.New("no matching keys")
}