├── .drone.sh ├── .drone.yml ├── .github ├── issue_template.md └── pull_request_template.md ├── .gitignore ├── BUILDING ├── CHANGELOG.md ├── COPYRIGHT ├── Dockerfile ├── LICENSE.md ├── README.md ├── cmd └── drone-autoscaler │ └── main.go ├── config ├── config.go ├── load.go └── load_test.go ├── drivers ├── amazon │ ├── create.go │ ├── create_test.go │ ├── destroy.go │ ├── destroy_test.go │ ├── option.go │ ├── option_test.go │ ├── provider.go │ ├── provider_test.go │ ├── setup.go │ ├── setup_test.go │ ├── util.go │ └── util_test.go ├── azure │ ├── create.go │ ├── create_test.go │ ├── destroy.go │ ├── destroy_test.go │ ├── option.go │ ├── option_test.go │ ├── provider.go │ └── provider_test.go ├── digitalocean │ ├── create.go │ ├── create_test.go │ ├── destroy.go │ ├── destroy_test.go │ ├── option.go │ ├── option_test.go │ ├── provider.go │ ├── provider_test.go │ ├── setup.go │ ├── setup_test.go │ └── userdata.go ├── google │ ├── create.go │ ├── create_test.go │ ├── destroy.go │ ├── destroy_test.go │ ├── option.go │ ├── option_test.go │ ├── provider.go │ ├── provider_test.go │ ├── setup.go │ └── setup_test.go ├── hetznercloud │ ├── create.go │ ├── create_test.go │ ├── destroy.go │ ├── destroy_test.go │ ├── option.go │ ├── option_test.go │ ├── provider.go │ ├── provider_test.go │ ├── setup.go │ └── setup_test.go ├── internal │ └── userdata │ │ ├── userdata.go │ │ └── userdata_test.go ├── openstack │ ├── create.go │ ├── create_test.go │ ├── destroy.go │ ├── destroy_test.go │ ├── doc.go │ ├── option.go │ ├── option_test.go │ ├── provider.go │ ├── provider_test.go │ ├── setup.go │ ├── setup_test.go │ └── testdata │ │ ├── associateresp1.json │ │ ├── authresp1.json │ │ ├── fipresp1.json │ │ ├── flavorlistresp1.json │ │ ├── imagelistresp1.json │ │ ├── servercreateresp1.json │ │ ├── serverstatusresp1.json │ │ └── tokenresp1.json ├── packet │ ├── create.go │ ├── create_test.go │ ├── destroy.go │ ├── destroy_test.go │ ├── option.go │ ├── option_test.go │ ├── provider.go │ ├── provider_test.go │ ├── setup.go │ └── setup_test.go └── scaleway │ ├── create.go │ ├── create_test.go │ ├── destroy.go │ ├── destroy_test.go │ ├── option.go │ ├── option_test.go │ ├── provider.go │ ├── provider_test.go │ └── setup.go ├── engine.go ├── engine ├── alloc.go ├── alloc_test.go ├── calc.go ├── calc_test.go ├── certs │ ├── cert.go │ └── cert_test.go ├── collect.go ├── collect_test.go ├── docker.go ├── engine.go ├── install.go ├── install_test.go ├── pinger.go ├── pinger_test.go ├── planner.go ├── planner_test.go ├── reaper.go ├── reaper_test.go ├── sort.go └── sort_test.go ├── go.mod ├── go.sum ├── licenses ├── Polyform-Free-Trial.md └── Polyform-Small-Business.md ├── logger ├── context.go ├── context_test.go ├── history │ ├── history.go │ └── history_test.go ├── logger.go ├── logger_test.go ├── logrus.go ├── logrus_test.go └── request │ └── request.go ├── metrics ├── metrics.go ├── server_capacity.go ├── server_capacity_test.go ├── server_count.go ├── server_count_test.go ├── server_create.go ├── server_create_test.go ├── server_delete.go └── server_delete_test.go ├── mocks ├── mock_docker.go ├── mock_drone.go ├── mock_engine.go ├── mock_metrics.go ├── mock_provider.go ├── mock_server.go └── mocks.go ├── provider.go ├── server.go ├── server ├── auth.go ├── auth_test.go ├── engine.go ├── engine_test.go ├── healthz.go ├── healthz_test.go ├── metrics.go ├── metrics_test.go ├── servers.go ├── servers_test.go ├── varz.go ├── varz_test.go ├── version.go ├── version_test.go ├── web │ ├── handler.go │ ├── nocache.go │ ├── nocache_test.go │ ├── render.go │ ├── render_test.go │ ├── static │ │ ├── files │ │ │ ├── favicon.png │ │ │ ├── icons │ │ │ │ ├── server-list-empty-mono.svg │ │ │ │ └── server-list-empty.svg │ │ │ ├── reset.css │ │ │ ├── style.css │ │ │ └── timeago.js │ │ ├── static.go │ │ └── static_gen.go │ └── template │ │ ├── files │ │ ├── index.tmpl │ │ └── logs.tmpl │ │ ├── server.go │ │ ├── template.go │ │ ├── template_gen.go │ │ └── testdata │ │ ├── logs.json │ │ ├── logs_empty.json │ │ ├── servers.json │ │ └── servers_empty.json ├── writer.go └── writer_test.go ├── slack ├── slack.go └── slack_test.go └── store ├── db.go ├── db_test.go ├── lock.go ├── migrate ├── migrate.go ├── mysql │ ├── ddl.go │ ├── ddl_gen.go │ └── files │ │ └── 001_create_table_servers.sql ├── postgres │ ├── ddl.go │ ├── ddl_gen.go │ └── files │ │ └── 001_create_table_servers.sql └── sqlite │ ├── ddl.go │ ├── ddl_gen.go │ └── files │ └── 001_create_table_servers.sql ├── servers.go ├── servers_test.go ├── util.go └── util_test.go /.drone.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | set -x 5 | 6 | COMMIT="-X main.commit=${DRONE_COMMIT_SHA}" 7 | VERSION="-X main.version=${DRONE_TAG=latest}" 8 | 9 | go build \ 10 | -ldflags "-extldflags \"-static\" $COMMIT $VERSION" \ 11 | -o release/linux/amd64/drone-autoscaler \ 12 | github.com/drone/autoscaler/cmd/drone-autoscaler 13 | -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: pipeline 3 | name: default 4 | type: vm 5 | 6 | pool: 7 | use: ubuntu 8 | 9 | platform: 10 | os: linux 11 | arch: amd64 12 | 13 | steps: 14 | - name: test 15 | pull: default 16 | image: golang 17 | volumes: 18 | - name: deps 19 | path: /go 20 | commands: 21 | - go get 22 | - go test -v -cover ./... 23 | 24 | - name: test_postgres 25 | pull: default 26 | image: golang 27 | volumes: 28 | - name: deps 29 | path: /go 30 | commands: 31 | - cd store 32 | - go test -v 33 | environment: 34 | DATABASE_CONFIG: host=postgres user=postgres password=password dbname=test sslmode=disable 35 | DATABASE_DRIVER: postgres 36 | 37 | - name: test_mysql 38 | pull: default 39 | image: golang 40 | volumes: 41 | - name: deps 42 | path: /go 43 | commands: 44 | - cd store 45 | - go test -v 46 | environment: 47 | DATABASE_CONFIG: "root:password@tcp(mysql:3306)/test?parseTime=true" 48 | DATABASE_DRIVER: mysql 49 | 50 | - name: build 51 | pull: default 52 | image: golang 53 | volumes: 54 | - name: deps 55 | path: /go 56 | commands: 57 | - sh .drone.sh 58 | 59 | - name: publish 60 | pull: default 61 | image: plugins/docker 62 | settings: 63 | auto_tag: true 64 | repo: drone/autoscaler 65 | password: 66 | from_secret: docker_password 67 | username: 68 | from_secret: docker_username 69 | when: 70 | event: 71 | - push 72 | - tag 73 | 74 | volumes: 75 | - name: deps 76 | temp: {} 77 | 78 | services: 79 | - name: postgres 80 | pull: default 81 | image: postgres:9 82 | environment: 83 | POSTGRES_DB: test 84 | POSTGRES_PASSWORD: password 85 | 86 | - name: mysql 87 | pull: default 88 | image: mysql:5 89 | environment: 90 | MYSQL_DATABASE: test 91 | MYSQL_ROOT_PASSWORD: password 92 | 93 | ... 94 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drone/autoscaler/9a40e5c65dc31a7ba706b1afe6796d70f1c5acc0/.github/issue_template.md -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ./drone-autoscaler 2 | NOTES.md 3 | release 4 | vendor 5 | *.sqlite 6 | *.sqlite3 7 | *.bak 8 | *.out 9 | *.db 10 | *.env 11 | -------------------------------------------------------------------------------- /BUILDING: -------------------------------------------------------------------------------- 1 | 1. Install go 1.11 or later 2 | 2. Install dependencies: 3 | 4 | go get 5 | 6 | 3. Compile and test: 7 | 8 | go install ./... 9 | go test ./... 10 | -------------------------------------------------------------------------------- /COPYRIGHT: -------------------------------------------------------------------------------- 1 | Copyright 2018 Drone.IO Inc 2 | Use of this software is governed by the Polyform License 3 | that can be found in the LICENSE file. -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.20 as alpine 2 | RUN apk add -U --no-cache ca-certificates 3 | 4 | FROM alpine:3.20 5 | EXPOSE 8080 80 443 6 | VOLUME /data 7 | 8 | ENV GODEBUG netdns=go 9 | ENV XDG_CACHE_HOME /data 10 | ENV DRONE_DATABASE_DRIVER sqlite3 11 | ENV DRONE_DATABASE_DATASOURCE /data/database.sqlite?cache=shared&mode=rwc&_busy_timeout=9999999 12 | 13 | COPY --from=alpine /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 14 | 15 | ADD release/linux/amd64/drone-autoscaler /bin/ 16 | ENTRYPOINT ["/bin/drone-autoscaler"] 17 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | [Polyform-Small-Business-1.0.0](https://polyformproject.org/licenses/small-business/1.0.0) OR 2 | [Polyform-Free-Trial-1.0.0](https://polyformproject.org/licenses/free-trial/1.0.0) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [](https://cloud.drone.io/drone/autoscaler) 2 | 3 | Drone Autoscale is a lightweight daemon that elastically increases and decreases your compute resources based on your build volume. Integrates with [DigitalOcean](https://m.do.co/c/00500d28741b), [Amazon Web services](http://autoscale.drone.io/intro/amazon/), Hetzner and more. 4 | 5 | Documentation: 6 | https://autoscale.drone.io 7 | 8 | Technical Support: 9 | https://discourse.drone.io 10 | 11 | Issue Tracker and Roadmap: 12 | https://trello.com/b/ttae5E5o/drone 13 | -------------------------------------------------------------------------------- /config/load.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package config 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "strings" 11 | 12 | "github.com/drone/envconfig" 13 | "github.com/joho/godotenv" 14 | ) 15 | 16 | // legacy environment variables. the key is the legacy 17 | // variable name, and the value is the new variable name. 18 | var legacy = map[string]string{ 19 | "DRONE_ENABLE_PINGER": "DRONE_PINGER_ENABLED", 20 | "DRONE_ENABLE_REAPER": "DRONE_REAPER_ENABLED", 21 | } 22 | 23 | func init() { 24 | // loop through legacy environment variable and, if set 25 | // rewrite to the new variable name. 26 | for k, v := range legacy { 27 | if s, ok := os.LookupEnv(k); ok { 28 | os.Setenv(v, s) 29 | } 30 | } 31 | } 32 | 33 | // Load loads the configuration from the environment. 34 | func Load() (Config, error) { 35 | config := Config{} 36 | if err := envconfig.Process("DRONE", &config); err != nil { 37 | return config, err 38 | } 39 | if path := config.Agent.EnvironFile; path != "" { 40 | envs, _ := godotenv.Read(path) 41 | for k, v := range envs { 42 | config.Agent.Environ = append( 43 | config.Agent.Environ, 44 | fmt.Sprintf("%s=%s", k, v), 45 | ) 46 | } 47 | } 48 | // If environment variables don't contain `=`, we consider that it's an environment name, we fetch and expose the value 49 | for i, env := range config.Agent.Environ { 50 | if !strings.Contains(env, "=") { 51 | config.Agent.Environ[i] = fmt.Sprintf("%s=%s", env, os.Getenv(env)) 52 | } 53 | } 54 | godotenv.Load() 55 | return config, nil 56 | } 57 | 58 | // MustLoad loads the configuration from the environmnet 59 | // and panics if an error is encountered. 60 | func MustLoad() Config { 61 | config, err := Load() 62 | if err != nil { 63 | panic(err) 64 | } 65 | return config 66 | } 67 | -------------------------------------------------------------------------------- /drivers/amazon/create_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package amazon 6 | -------------------------------------------------------------------------------- /drivers/amazon/destroy_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package amazon 6 | 7 | import ( 8 | "context" 9 | "os" 10 | "testing" 11 | 12 | "github.com/drone/autoscaler" 13 | 14 | "github.com/h2non/gock" 15 | ) 16 | 17 | func TestDestroy(t *testing.T) { 18 | defer gock.Off() 19 | 20 | os.Setenv("AWS_ACCESS_KEY_ID", "your_access_key_id") 21 | os.Setenv("AWS_SECRET_ACCESS_KEY", "your_secret_access_key") 22 | defer func() { 23 | os.Unsetenv("AWS_ACCESS_KEY_ID") 24 | os.Unsetenv("AWS_SECRET_ACCESS_KEY") 25 | }() 26 | 27 | gock.New("https://ec2.us-east-1.amazonaws.com"). 28 | Post("/"). 29 | Reply(200) 30 | 31 | mockContext := context.TODO() 32 | mockInstance := &autoscaler.Instance{ 33 | ID: "i-1234567890abcdef0", 34 | } 35 | 36 | p := New( 37 | WithRegion("us-east-1"), 38 | ).(*provider) 39 | p.retries = 1 40 | 41 | err := p.Destroy(mockContext, mockInstance) 42 | if err != nil { 43 | t.Error(err) 44 | } 45 | } 46 | 47 | func TestDestroyDeleteError(t *testing.T) { 48 | mockContext := context.TODO() 49 | mockInstance := &autoscaler.Instance{ 50 | ID: "i-1234567890abcdef0", 51 | } 52 | 53 | p := New( 54 | WithRegion("us-east-1"), 55 | ).(*provider) 56 | p.retries = 1 57 | 58 | err := p.Destroy(mockContext, mockInstance) 59 | if err == nil { 60 | t.Errorf("Expect error returned from aws") 61 | } 62 | } 63 | 64 | func TestDestroyNotFound(t *testing.T) { 65 | defer gock.Off() 66 | 67 | os.Setenv("AWS_ACCESS_KEY_ID", "your_access_key_id") 68 | os.Setenv("AWS_SECRET_ACCESS_KEY", "your_secret_access_key") 69 | defer func() { 70 | os.Unsetenv("AWS_ACCESS_KEY_ID") 71 | os.Unsetenv("AWS_SECRET_ACCESS_KEY") 72 | }() 73 | 74 | gock.New("https://ec2.us-east-1.amazonaws.com"). 75 | Post("/"). 76 | Reply(400). 77 | BodyString(`InvalidInstanceID.NotFoundThe instance ID 'i-1a2b3c4d' does not existea966190-f9aa-478e-9ede-example`) 78 | 79 | mockContext := context.TODO() 80 | mockInstance := &autoscaler.Instance{ 81 | ID: "i-1234567890abcdef0", 82 | } 83 | 84 | p := New( 85 | WithRegion("us-east-1"), 86 | ).(*provider) 87 | p.retries = 1 88 | 89 | err := p.Destroy(mockContext, mockInstance) 90 | if err == nil { 91 | t.Errorf("Expect error returned from aws") 92 | } 93 | if err != autoscaler.ErrInstanceNotFound { 94 | t.Errorf("Expect instance not found returned from aws") 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /drivers/amazon/option_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package amazon 6 | 7 | import "testing" 8 | 9 | func TestOptions(t *testing.T) { 10 | p := New( 11 | WithDeviceName("/dev/sda2"), 12 | WithImage("ami-0aab355e1bfa1e72e"), 13 | WithPrivateIP(true), 14 | WithRegion("us-west-2"), 15 | WithRetries(10), 16 | WithSecurityGroup("sg-770eabe1"), 17 | WithSize("t3.2xlarge"), 18 | WithSSHKey("id_rsa"), 19 | WithSubnets([]string{"subnet-0b32177f"}), 20 | WithTags(map[string]string{"foo": "bar", "baz": "qux"}), 21 | WithVolumeSize(64), 22 | WithVolumeType("io1"), 23 | ).(*provider) 24 | 25 | if got, want := p.deviceName, "/dev/sda2"; got != want { 26 | t.Errorf("Want device name %q, got %q", want, got) 27 | } 28 | if got, want := p.image, "ami-0aab355e1bfa1e72e"; got != want { 29 | t.Errorf("Want image %q, got %q", want, got) 30 | } 31 | if got, want := p.region, "us-west-2"; got != want { 32 | t.Errorf("Want region %q, got %q", want, got) 33 | } 34 | if got, want := p.size, "t3.2xlarge"; got != want { 35 | t.Errorf("Want size %q, got %q", want, got) 36 | } 37 | if got, want := p.key, "id_rsa"; got != want { 38 | t.Errorf("Want key %q, got %q", want, got) 39 | } 40 | if got, want := p.groups[0], "sg-770eabe1"; got != want { 41 | t.Errorf("Want security groups %q, got %q", want, got) 42 | } 43 | if got, want := p.subnets, []string{"subnet-0b32177f"}; len(got) != 1 || got[0] != want[0] { 44 | t.Errorf("Want subnet %q, got %q", want, got) 45 | } 46 | if got, want := p.retries, 10; got != want { 47 | t.Errorf("Want %d retries, got %d", want, got) 48 | } 49 | if got, want := p.privateIP, true; got != want { 50 | t.Errorf("Want %v privateIP, got %v", want, got) 51 | } 52 | if got, want := len(p.tags), 2; got != want { 53 | t.Errorf("Want %d tags, got %d", want, got) 54 | } 55 | if got, want := p.volumeSize, int64(64); got != want { 56 | t.Errorf("Want volume size %d, got %d", want, got) 57 | } 58 | if got, want := p.volumeType, "io1"; got != want { 59 | t.Errorf("Want volume type %q, got %q", want, got) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /drivers/amazon/provider.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package amazon 6 | 7 | import ( 8 | "sync" 9 | "text/template" 10 | 11 | "github.com/drone/autoscaler" 12 | "github.com/drone/autoscaler/drivers/internal/userdata" 13 | 14 | "github.com/aws/aws-sdk-go/aws" 15 | "github.com/aws/aws-sdk-go/aws/session" 16 | "github.com/aws/aws-sdk-go/service/ec2" 17 | ) 18 | 19 | type provider struct { 20 | init sync.Once 21 | 22 | deviceName string 23 | volumeSize int64 24 | volumeType string 25 | volumeIops int64 26 | volumeThroughput int64 27 | retries int 28 | key string 29 | region string 30 | image string 31 | privateIP bool 32 | userdata *template.Template 33 | size string 34 | sizeAlt string 35 | subnets []string 36 | groups []string 37 | tags map[string]string 38 | iamProfileArn string 39 | spotInstance bool 40 | imdsTokens string 41 | } 42 | 43 | func (p *provider) getClient() *ec2.EC2 { 44 | config := aws.NewConfig() 45 | config = config.WithRegion(p.region) 46 | config = config.WithMaxRetries(p.retries) 47 | return ec2.New(session.New(config)) 48 | } 49 | 50 | // New returns a new Digital Ocean provider. 51 | func New(opts ...Option) autoscaler.Provider { 52 | p := new(provider) 53 | for _, opt := range opts { 54 | opt(p) 55 | } 56 | if p.retries == 0 { 57 | p.retries = 10 58 | } 59 | if p.region == "" { 60 | p.region = "us-east-1" 61 | } 62 | if p.size == "" { 63 | p.size = "t3.medium" 64 | } 65 | if p.image == "" { 66 | p.image = defaultImage(p.region) 67 | } 68 | if p.deviceName == "" { 69 | p.deviceName = "/dev/sda1" 70 | } 71 | if p.volumeSize == 0 { 72 | p.volumeSize = 32 73 | } 74 | if p.volumeType == "" { 75 | p.volumeType = "gp2" 76 | } 77 | if (p.volumeType == "io1" || p.volumeType == "io2") && p.volumeIops == 0 { 78 | p.volumeIops = 100 79 | } 80 | if p.volumeType == "gp3" && p.volumeIops == 0 { 81 | p.volumeIops = 3000 // 3000 is the minimum for gp3 82 | } 83 | if p.volumeType == "gp3" && p.volumeThroughput == 0 { 84 | p.volumeThroughput = 125 // 125 is the minimum for gp3 85 | } 86 | if p.userdata == nil { 87 | p.userdata = userdata.T 88 | } 89 | return p 90 | } 91 | -------------------------------------------------------------------------------- /drivers/amazon/provider_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package amazon 6 | -------------------------------------------------------------------------------- /drivers/amazon/setup.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package amazon 6 | 7 | import ( 8 | "context" 9 | "errors" 10 | 11 | "github.com/drone/autoscaler/logger" 12 | 13 | "github.com/aws/aws-sdk-go/service/ec2" 14 | "golang.org/x/sync/errgroup" 15 | ) 16 | 17 | func (p *provider) setup(ctx context.Context) error { 18 | var g errgroup.Group 19 | if p.key == "" { 20 | g.Go(func() error { 21 | return p.setupKeypair(ctx) 22 | }) 23 | } 24 | if len(p.subnets) == 0 { 25 | // TODO: find or create subnet 26 | } 27 | if len(p.groups) == 0 { 28 | // TODO: find or create security groups 29 | } 30 | return g.Wait() 31 | } 32 | 33 | func (p *provider) setupKeypair(ctx context.Context) error { 34 | logger := logger.FromContext(ctx) 35 | 36 | logger.Debugln("finding default ssh key") 37 | 38 | opts := new(ec2.DescribeKeyPairsInput) 39 | keys, err := p.getClient().DescribeKeyPairs(opts) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | index := map[string]string{} 45 | for _, key := range keys.KeyPairs { 46 | index[*key.KeyName] = *key.KeyFingerprint 47 | } 48 | 49 | // if the account has multiple keys configured we will 50 | // attempt to use an existing key based on naming convention. 51 | for _, name := range []string{"drone", "id_rsa_drone"} { 52 | fingerprint, ok := index[name] 53 | if !ok { 54 | continue 55 | } 56 | p.key = name 57 | 58 | logger. 59 | WithField("name", name). 60 | WithField("fingerprint", fingerprint). 61 | Debugln("using default ssh key") 62 | return nil 63 | } 64 | 65 | // if there were no matches but the account has at least 66 | // one keypair already created we will select the first 67 | // in the list. 68 | if len(keys.KeyPairs) > 0 { 69 | key := keys.KeyPairs[0] 70 | p.key = *key.KeyName 71 | 72 | logger. 73 | WithField("name", *key.KeyName). 74 | WithField("fingerprint", *key.KeyFingerprint). 75 | Debugln("using default ssh key") 76 | return nil 77 | } 78 | 79 | return errors.New("No matching keys") 80 | } 81 | -------------------------------------------------------------------------------- /drivers/amazon/setup_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package amazon 6 | -------------------------------------------------------------------------------- /drivers/amazon/util.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package amazon 6 | 7 | import ( 8 | "github.com/aws/aws-sdk-go/aws" 9 | "github.com/aws/aws-sdk-go/service/ec2" 10 | ) 11 | 12 | // helper function converts an array of tags in string 13 | // format to an array of ec2 tags. 14 | func convertTags(in map[string]string) []*ec2.Tag { 15 | var out []*ec2.Tag 16 | for k, v := range in { 17 | out = append(out, &ec2.Tag{ 18 | Key: aws.String(k), 19 | Value: aws.String(v), 20 | }) 21 | } 22 | return out 23 | } 24 | 25 | // helper function creates a copy of map[string]string 26 | func createCopy(in map[string]string) map[string]string { 27 | out := map[string]string{} 28 | for k, v := range in { 29 | out[k] = v 30 | } 31 | return out 32 | } 33 | 34 | // helper function returns the default image based on the 35 | // selected region. 36 | func defaultImage(region string) string { 37 | return images[region] 38 | } 39 | 40 | // static ami id list for Ubuntu Server 20.04 LTS 41 | // source: https://cloud-images.ubuntu.com/locator/ 42 | // filters: 43 | // - Cloud: Amazon AWS, Amazon GovCloud, Amazon AWS China 44 | // - Version: 20.04 45 | // - Instance Type: hvm-ssd 46 | var images = map[string]string{ 47 | // AWS Regions: Ubuntu Server 20.04 LTS 48 | // Upstream release version: 20220706 49 | "af-south-1": "ami-0f5298ccab965edeb", 50 | "ap-east-1": "ami-0dfad1f1f65cd083b", 51 | "ap-northeast-1": "ami-0986c991cc80c6ad9", 52 | "ap-northeast-2": "ami-0565d651769eb3de5", 53 | "ap-northeast-3": "ami-0e6078093a109801c", 54 | "ap-south-1": "ami-0325e3016099f9112", 55 | "ap-southeast-1": "ami-0eaf04122a1ae7b3b", 56 | "ap-southeast-2": "ami-048a2d001938101dd", 57 | "ap-southeast-3": "ami-09915141a4f1dafdd", 58 | "ca-central-1": "ami-04a579d2f00bb4001", 59 | "eu-central-1": "ami-06cac34c3836ff90b", 60 | "eu-north-1": "ami-0ede84a5f28ec932a", 61 | "eu-south-1": "ami-0a39f417b8836bc59", 62 | "eu-west-1": "ami-0141514361b6a3c1b", 63 | "eu-west-2": "ami-014b642f603e350c3", 64 | "eu-west-3": "ami-0d0b8d91779dec1e5", 65 | "me-south-1": "ami-0c769d841005394ee", 66 | "sa-east-1": "ami-088afbba294231fe0", 67 | "us-east-1": "ami-0070c5311b7677678", 68 | "us-east-2": "ami-07f84a50d2dec2fa4", 69 | "us-west-1": "ami-040a251ee9d7d1a9b", 70 | "us-west-2": "ami-0aab355e1bfa1e72e", 71 | 72 | // AWS GovCloud (US): Ubuntu Server 20.04 LTS 73 | // Upstream release version: 20220627.1 74 | "us-gov-east-1": "ami-0d8ee446ec886f5cf", 75 | "us-gov-west-1": "ami-0cbaf57cea1d72aec", 76 | 77 | // AWS China: Ubuntu Server 20.04 LTS 78 | // Upstream release version: 20210720 79 | "cn-north-1": "ami-0741e7b8b4fb0001c", 80 | "cn-northwest-1": "ami-0883e8062ff31f727", 81 | } 82 | -------------------------------------------------------------------------------- /drivers/amazon/util_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package amazon 6 | 7 | import ( 8 | "reflect" 9 | "testing" 10 | 11 | "github.com/kr/pretty" 12 | ) 13 | 14 | func TestConvertTags(t *testing.T) { 15 | a := map[string]string{"foo": "bar", "baz": "qux"} 16 | b := map[string]string{} 17 | 18 | tags := convertTags(a) 19 | 20 | if got, want := len(tags), 2; got != want { 21 | t.Errorf("Want %d tags, got %d", want, got) 22 | } 23 | 24 | for _, tag := range tags { 25 | b[*tag.Key] = *tag.Value 26 | } 27 | 28 | if !reflect.DeepEqual(a, b) { 29 | t.Errorf("unexpected tag conversion") 30 | pretty.Ldiff(t, a, b) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /drivers/azure/create.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package azure 6 | -------------------------------------------------------------------------------- /drivers/azure/create_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package azure 6 | -------------------------------------------------------------------------------- /drivers/azure/destroy.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package azure 6 | -------------------------------------------------------------------------------- /drivers/azure/destroy_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package azure 6 | -------------------------------------------------------------------------------- /drivers/azure/option.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package azure 6 | -------------------------------------------------------------------------------- /drivers/azure/option_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package azure 6 | -------------------------------------------------------------------------------- /drivers/azure/provider.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package azure 6 | -------------------------------------------------------------------------------- /drivers/azure/provider_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package azure 6 | -------------------------------------------------------------------------------- /drivers/digitalocean/destroy.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package digitalocean 6 | 7 | import ( 8 | "context" 9 | "strconv" 10 | 11 | "github.com/drone/autoscaler" 12 | "github.com/drone/autoscaler/logger" 13 | ) 14 | 15 | func (p *provider) Destroy(ctx context.Context, instance *autoscaler.Instance) error { 16 | logger := logger.FromContext(ctx). 17 | WithField("region", instance.Region). 18 | WithField("image", instance.Image). 19 | WithField("size", instance.Size). 20 | WithField("name", instance.Name) 21 | 22 | client := newClient(ctx, p.token) 23 | id, err := strconv.Atoi(instance.ID) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | _, res, err := client.Droplets.Get(ctx, id) 29 | if err != nil && res.StatusCode == 404 { 30 | logger.WithError(err). 31 | Warnln("droplet does not exist") 32 | return autoscaler.ErrInstanceNotFound 33 | } else if err != nil { 34 | logger.WithError(err). 35 | Errorln("cannot find droplet") 36 | return err 37 | } 38 | 39 | logger.Debugln("deleting droplet") 40 | 41 | _, err = client.Droplets.Delete(ctx, id) 42 | if err != nil { 43 | logger.WithError(err). 44 | Errorln("deleting droplet failed") 45 | return err 46 | } 47 | 48 | logger.Debugln("droplet deleted") 49 | 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /drivers/digitalocean/option.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package digitalocean 6 | 7 | import ( 8 | "io/ioutil" 9 | 10 | "github.com/drone/autoscaler/drivers/internal/userdata" 11 | ) 12 | 13 | // Option configures a Digital Ocean provider option. 14 | type Option func(*provider) 15 | 16 | // WithImage returns an option to set the image. 17 | func WithImage(image string) Option { 18 | return func(p *provider) { 19 | p.image = image 20 | } 21 | } 22 | 23 | // WithRegion returns an option to set the target region. 24 | func WithRegion(region string) Option { 25 | return func(p *provider) { 26 | p.region = region 27 | } 28 | } 29 | 30 | // WithSize returns an option to set the instance size. 31 | func WithSize(size string) Option { 32 | return func(p *provider) { 33 | p.size = size 34 | } 35 | } 36 | 37 | // WithSSHKey returns an option to set the ssh key. 38 | func WithSSHKey(key string) Option { 39 | return func(p *provider) { 40 | p.key = key 41 | } 42 | } 43 | 44 | // WithTags returns an option to set the image. 45 | func WithTags(tags ...string) Option { 46 | return func(p *provider) { 47 | p.tags = tags 48 | } 49 | } 50 | 51 | // WithToken returns an option to set the auth token. 52 | func WithToken(token string) Option { 53 | return func(p *provider) { 54 | p.token = token 55 | } 56 | } 57 | 58 | // WithFirewall returns an option to set the droplet firewall. 59 | func WithFirewall(firewall string) Option { 60 | return func(p *provider) { 61 | p.firewall = firewall 62 | } 63 | } 64 | 65 | // WithPrivateIP returns an option to set the private IP address. 66 | func WithPrivateIP(private bool) Option { 67 | return func(p *provider) { 68 | p.privateIP = private 69 | } 70 | } 71 | 72 | // WithUserData returns an option to set the cloud-init 73 | // template from text. 74 | func WithUserData(text string) Option { 75 | return func(p *provider) { 76 | if text != "" { 77 | p.userdata = userdata.Parse(text) 78 | } 79 | } 80 | } 81 | 82 | // WithUserDataFile returns an option to set the cloud-init 83 | // template from file. 84 | func WithUserDataFile(filepath string) Option { 85 | return func(p *provider) { 86 | if filepath != "" { 87 | b, err := ioutil.ReadFile(filepath) 88 | if err != nil { 89 | panic(err) 90 | } 91 | p.userdata = userdata.Parse(string(b)) 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /drivers/digitalocean/option_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package digitalocean 6 | 7 | import "testing" 8 | 9 | func TestOptions(t *testing.T) { 10 | p := New( 11 | WithImage("ubuntu-18-04-x64"), 12 | WithRegion("nyc3"), 13 | WithSize("s-8vcpu-32gb"), 14 | WithSSHKey("58:8e:30:66:fc:e2:ff:ad:4f:6f:02:4b:af:28:0d:c7"), 15 | WithTags("drone", "agent"), 16 | WithFirewall("f33e7128-f3e7-4229-b6cc-a4751381a104"), 17 | WithToken("77e027c7447f468068a7d4fea41e7149a75a94088082c66fcf555de3977f69d3"), 18 | WithPrivateIP(false), 19 | ).(*provider) 20 | 21 | if got, want := p.image, "ubuntu-18-04-x64"; got != want { 22 | t.Errorf("Want image %q, got %q", want, got) 23 | } 24 | if got, want := p.region, "nyc3"; got != want { 25 | t.Errorf("Want region %q, got %q", want, got) 26 | } 27 | if got, want := p.size, "s-8vcpu-32gb"; got != want { 28 | t.Errorf("Want size %q, got %q", want, got) 29 | } 30 | if got, want := p.key, "58:8e:30:66:fc:e2:ff:ad:4f:6f:02:4b:af:28:0d:c7"; got != want { 31 | t.Errorf("Want key %q, got %q", want, got) 32 | } 33 | if got, want := p.token, "77e027c7447f468068a7d4fea41e7149a75a94088082c66fcf555de3977f69d3"; got != want { 34 | t.Errorf("Want token %q, got %q", want, got) 35 | } 36 | if got, want := p.firewall, "f33e7128-f3e7-4229-b6cc-a4751381a104"; got != want { 37 | t.Errorf("Want token %q, got %q", want, got) 38 | } 39 | if got, want := p.privateIP, false; got != want { 40 | t.Errorf("Want %v privateIP, got %v", want, got) 41 | } 42 | if got, want := len(p.tags), 2; got != want { 43 | t.Errorf("Want %d tags, got %d", want, got) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /drivers/digitalocean/provider.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package digitalocean 6 | 7 | import ( 8 | "context" 9 | "sync" 10 | "text/template" 11 | 12 | "github.com/drone/autoscaler" 13 | 14 | "github.com/digitalocean/godo" 15 | "golang.org/x/oauth2" 16 | ) 17 | 18 | // provider implements a DigitalOcean provider. 19 | type provider struct { 20 | init sync.Once 21 | 22 | key string 23 | region string 24 | token string 25 | size string 26 | image string 27 | firewall string 28 | privateIP bool 29 | userdata *template.Template 30 | tags []string 31 | } 32 | 33 | // New returns a new Digital Ocean provider. 34 | func New(opts ...Option) autoscaler.Provider { 35 | p := new(provider) 36 | for _, opt := range opts { 37 | opt(p) 38 | } 39 | if p.region == "" { 40 | p.region = "nyc1" 41 | } 42 | if p.size == "" { 43 | p.size = "s-2vcpu-4gb" 44 | } 45 | if p.image == "" { 46 | p.image = "docker-18-04" 47 | } 48 | if p.userdata == nil { 49 | p.userdata = userdataT 50 | } 51 | return p 52 | } 53 | 54 | // helper function returns a new digitalocean client. 55 | func newClient(ctx context.Context, token string) *godo.Client { 56 | return godo.NewClient( 57 | oauth2.NewClient(ctx, oauth2.StaticTokenSource( 58 | &oauth2.Token{ 59 | AccessToken: token, 60 | }, 61 | )), 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /drivers/digitalocean/provider_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package digitalocean 6 | 7 | import "testing" 8 | 9 | func TestDefaults(t *testing.T) { 10 | p := New().(*provider) 11 | if got, want := p.image, "docker-18-04"; got != want { 12 | t.Errorf("Want image %q, got %q", want, got) 13 | } 14 | if got, want := p.region, "nyc1"; got != want { 15 | t.Errorf("Want region %q, got %q", want, got) 16 | } 17 | if got, want := p.size, "s-2vcpu-4gb"; got != want { 18 | t.Errorf("Want size %q, got %q", want, got) 19 | } 20 | if got, want := p.key, ""; got != want { 21 | t.Errorf("Want key %q, got %q", want, got) 22 | } 23 | if got, want := p.token, ""; got != want { 24 | t.Errorf("Want token %q, got %q", want, got) 25 | } 26 | if got, want := len(p.tags), 0; got != want { 27 | t.Errorf("Want %d tags, got %d", want, got) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /drivers/digitalocean/setup.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package digitalocean 6 | 7 | import ( 8 | "context" 9 | "errors" 10 | 11 | "github.com/digitalocean/godo" 12 | "github.com/drone/autoscaler/logger" 13 | 14 | "golang.org/x/sync/errgroup" 15 | ) 16 | 17 | func (p *provider) setup(ctx context.Context) error { 18 | var g errgroup.Group 19 | if p.key == "" { 20 | g.Go(func() error { 21 | return p.setupKeypair(ctx) 22 | }) 23 | } 24 | return g.Wait() 25 | } 26 | 27 | func (p *provider) setupKeypair(ctx context.Context) error { 28 | logger := logger.FromContext(ctx) 29 | 30 | logger.Debugln("finding default ssh key") 31 | 32 | client := newClient(ctx, p.token) 33 | keys, _, err := client.Keys.List(ctx, &godo.ListOptions{}) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | index := map[string]string{} 39 | for _, key := range keys { 40 | index[key.Name] = key.Fingerprint 41 | } 42 | 43 | // if the account has multiple keys configured we will 44 | // attempt to use an existing key based on naming convention. 45 | for _, name := range []string{"drone", "id_rsa_drone"} { 46 | fingerprint, ok := index[name] 47 | if !ok { 48 | continue 49 | } 50 | p.key = fingerprint 51 | 52 | logger. 53 | WithField("name", name). 54 | WithField("fingerprint", fingerprint). 55 | Debugln("using default ssh key") 56 | return nil 57 | } 58 | 59 | // if there were no matches but the account has at least 60 | // one keypair already created we will select the first 61 | // in the list. 62 | if len(keys) > 0 { 63 | key := keys[0] 64 | p.key = key.Fingerprint 65 | 66 | logger. 67 | WithField("name", key.Name). 68 | WithField("fingerprint", key.Fingerprint). 69 | Debugln("using default ssh key") 70 | return nil 71 | } 72 | 73 | return errors.New("No matching keys") 74 | } 75 | -------------------------------------------------------------------------------- /drivers/digitalocean/userdata.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package digitalocean 6 | 7 | import "github.com/drone/autoscaler/drivers/internal/userdata" 8 | 9 | var userdataT = userdata.Parse(`#cloud-config 10 | write_files: 11 | - path: /etc/systemd/system/docker.service.d/override.conf 12 | content: | 13 | [Service] 14 | ExecStart= 15 | ExecStart=/usr/bin/dockerd 16 | - path: /etc/default/docker 17 | content: | 18 | DOCKER_OPTS="" 19 | - path: /etc/docker/daemon.json 20 | content: | 21 | { 22 | "dns": [ "8.8.8.8", "8.8.4.4" ], 23 | "hosts": [ "0.0.0.0:2376", "unix:///var/run/docker.sock" ], 24 | "tls": true, 25 | "tlsverify": true, 26 | "tlscacert": "/etc/docker/ca.pem", 27 | "tlscert": "/etc/docker/server-cert.pem", 28 | "tlskey": "/etc/docker/server-key.pem" 29 | } 30 | - path: /etc/docker/ca.pem 31 | encoding: b64 32 | content: {{ .CACert | base64 }} 33 | - path: /etc/docker/server-cert.pem 34 | encoding: b64 35 | content: {{ .TLSCert | base64 }} 36 | - path: /etc/docker/server-key.pem 37 | encoding: b64 38 | content: {{ .TLSKey | base64 }} 39 | 40 | runcmd: 41 | - [ systemctl, daemon-reload ] 42 | - [ systemctl, restart, docker ] 43 | `) 44 | -------------------------------------------------------------------------------- /drivers/google/destroy.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package google 6 | 7 | import ( 8 | "context" 9 | "net/http" 10 | 11 | "github.com/drone/autoscaler" 12 | "google.golang.org/api/googleapi" 13 | ) 14 | 15 | func (p *provider) Destroy(ctx context.Context, instance *autoscaler.Instance) error { 16 | // An instance's Region is actually a Zone in the google provider 17 | op, err := p.service.Instances.Delete(p.project, instance.Region, instance.ID).Do() 18 | if err != nil { 19 | // https://github.com/googleapis/google-api-go-client/blob/master/googleapi/googleapi.go#L135 20 | if gerr, ok := err.(*googleapi.Error); ok && 21 | gerr.Code == http.StatusNotFound { 22 | return autoscaler.ErrInstanceNotFound 23 | } 24 | return err 25 | } 26 | return p.waitZoneOperation(ctx, op.Name, instance.Region) 27 | } 28 | -------------------------------------------------------------------------------- /drivers/google/destroy_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package google 6 | 7 | import ( 8 | "context" 9 | "net/http" 10 | "testing" 11 | 12 | "github.com/drone/autoscaler" 13 | "github.com/h2non/gock" 14 | ) 15 | 16 | func TestDestroy(t *testing.T) { 17 | defer gock.Off() 18 | 19 | gock.New("https://compute.googleapis.com"). 20 | Delete("/compute/v1/projects/my-project/zones/us-central1-a/instances/my-instance"). 21 | Reply(200). 22 | BodyString(`{ "name": "operation-name" }`) 23 | 24 | gock.New("https://compute.googleapis.com"). 25 | Get("/compute/v1/projects/my-project/zones/us-central1-a/operations/operation-name"). 26 | Reply(200). 27 | BodyString(`{ "status": "DONE" }`) 28 | 29 | mockContext := context.TODO() 30 | mockInstance := &autoscaler.Instance{ 31 | ID: "my-instance", 32 | Region: "us-central1-a", 33 | } 34 | 35 | p, err := New( 36 | WithClient(http.DefaultClient), 37 | WithZones("us-central1-a"), 38 | WithProject("my-project"), 39 | ) 40 | if err != nil { 41 | t.Error(err) 42 | return 43 | } 44 | 45 | err = p.Destroy(mockContext, mockInstance) 46 | if err != nil { 47 | t.Error(err) 48 | } 49 | } 50 | 51 | func TestDestroy_Error(t *testing.T) { 52 | defer gock.Off() 53 | 54 | gock.New("https://compute.googleapis.com"). 55 | Delete("/compute/v1/projects/my-project/zones/us-central1-a/instances/my-instance"). 56 | Reply(404) 57 | 58 | mockContext := context.TODO() 59 | mockInstance := &autoscaler.Instance{ 60 | ID: "my-instance", 61 | Region: "us-central1-a", 62 | } 63 | 64 | p, err := New( 65 | WithClient(http.DefaultClient), 66 | WithZones("us-central1-a"), 67 | WithProject("my-project"), 68 | ) 69 | if err != nil { 70 | t.Error(err) 71 | return 72 | } 73 | 74 | err = p.Destroy(mockContext, mockInstance) 75 | if err == nil { 76 | t.Errorf("Expect error deleting server") 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /drivers/google/option_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package google 6 | 7 | import ( 8 | "net/http" 9 | "reflect" 10 | "testing" 11 | ) 12 | 13 | func TestOptions(t *testing.T) { 14 | v, err := New( 15 | WithClient(http.DefaultClient), 16 | WithDiskSize(100), 17 | WithDiskType("local-ssd"), 18 | WithMachineImage("ubuntu-1604-lts"), 19 | WithMachineType("c3.large"), 20 | WithNetwork("global/defaults/foo"), 21 | WithPrivateIP(false), 22 | WithServiceAccountEmail("default"), 23 | WithProject("my-project"), 24 | WithTags("drone", "agent"), 25 | WithZones("us-central1-f"), 26 | WithScopes("scope1", "scope2"), 27 | WithUserDataKey("test-key"), 28 | ) 29 | if err != nil { 30 | t.Error(err) 31 | return 32 | } 33 | p := v.(*provider) 34 | 35 | if got, want := p.diskSize, int64(100); got != want { 36 | t.Errorf("Want diskSize %d, got %d", want, got) 37 | } 38 | if got, want := p.diskType, "local-ssd"; got != want { 39 | t.Errorf("Want diskType %s, got %s", want, got) 40 | } 41 | if got, want := p.image, "ubuntu-1604-lts"; got != want { 42 | t.Errorf("Want image %q, got %q", want, got) 43 | } 44 | if got, want := p.network, "global/defaults/foo"; got != want { 45 | t.Errorf("Want network %q, got %q", want, got) 46 | } 47 | if got, want := p.privateIP, false; got != want { 48 | t.Errorf("Want %v privateIP, got %v", want, got) 49 | } 50 | if got, want := p.project, "my-project"; got != want { 51 | t.Errorf("Want project %q, got %q", want, got) 52 | } 53 | if got, want := p.size, "c3.large"; got != want { 54 | t.Errorf("Want size %q, got %q", want, got) 55 | } 56 | if got, want := len(p.tags), 2; got != want { 57 | t.Errorf("Want %d tags, got %d", want, got) 58 | } 59 | if got, want := p.zones, []string{"us-central1-f"}; !reflect.DeepEqual(want, got) { 60 | t.Errorf("Want zone %q, got %q", want, got) 61 | } 62 | if got, want := len(p.scopes), 2; got != want { 63 | t.Errorf("Want %d scopes, got %d", want, got) 64 | } 65 | if got, want := p.serviceAccountEmail, "default"; got != want { 66 | t.Errorf("Want service account name %q, got %q", want, got) 67 | } 68 | if got, want := p.userdataKey, "test-key"; got != want { 69 | t.Errorf("Want userdata key %q, got %q", want, got) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /drivers/google/provider_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package google 6 | 7 | import ( 8 | "net/http" 9 | "reflect" 10 | "testing" 11 | 12 | "github.com/drone/autoscaler/drivers/internal/userdata" 13 | ) 14 | 15 | func TestDefaults(t *testing.T) { 16 | v, err := New( 17 | WithClient(http.DefaultClient), 18 | ) 19 | if err != nil { 20 | t.Error(err) 21 | return 22 | } 23 | p := v.(*provider) 24 | 25 | if got, want := p.diskSize, int64(50); got != want { 26 | t.Errorf("Want diskSize %d, got %d", want, got) 27 | } 28 | if got, want := p.diskType, "pd-standard"; got != want { 29 | t.Errorf("Want diskType %s, got %s", want, got) 30 | } 31 | if got, want := p.image, "ubuntu-os-cloud/global/images/ubuntu-2004-focal-v20220712"; got != want { 32 | t.Errorf("Want image %q, got %q", want, got) 33 | } 34 | if got, want := p.network, "global/networks/default"; got != want { 35 | t.Errorf("Want network %q, got %q", want, got) 36 | } 37 | if !reflect.DeepEqual(p.scopes, defaultScopes) { 38 | t.Errorf("Want default scopes") 39 | } 40 | if got, want := p.size, "n1-standard-1"; got != want { 41 | t.Errorf("Want size %q, got %q", want, got) 42 | } 43 | if !reflect.DeepEqual(p.tags, defaultTags) { 44 | t.Errorf("Want default tags") 45 | } 46 | if p.userdata != userdata.T { 47 | t.Errorf("Want default userdata template") 48 | } 49 | if p.userdataKey != "user-data" { 50 | t.Errorf("Want default userdata key") 51 | } 52 | if got, want := p.zones, []string{"us-central1-a"}; !reflect.DeepEqual(got, want) { 53 | t.Errorf("Want region %q, got %q", want, got) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /drivers/google/setup.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package google 6 | 7 | import ( 8 | "context" 9 | "reflect" 10 | 11 | "github.com/drone/autoscaler/logger" 12 | 13 | compute "google.golang.org/api/compute/v1" 14 | ) 15 | 16 | func (p *provider) setup(ctx context.Context) error { 17 | if reflect.DeepEqual(p.tags, defaultTags) { 18 | return p.setupFirewall(ctx) 19 | } 20 | return nil 21 | } 22 | 23 | func (p *provider) setupFirewall(ctx context.Context) error { 24 | logger := logger.FromContext(ctx) 25 | 26 | logger.Debugln("finding default firewall rules") 27 | 28 | _, err := p.service.Firewalls.Get(p.project, "default-allow-docker").Context(ctx).Do() 29 | if err == nil { 30 | logger.Debugln("found default firewall rule") 31 | return nil 32 | } 33 | 34 | rule := &compute.Firewall{ 35 | Allowed: []*compute.FirewallAllowed{ 36 | { 37 | IPProtocol: "tcp", 38 | Ports: []string{"2376"}, 39 | }, 40 | }, 41 | Direction: "INGRESS", 42 | Name: "default-allow-docker", 43 | Network: p.network, 44 | Priority: 1000, 45 | SourceRanges: []string{"0.0.0.0/0"}, 46 | TargetTags: []string{"allow-docker"}, 47 | } 48 | 49 | op, err := p.service.Firewalls.Insert(p.project, rule).Context(ctx).Do() 50 | if err != nil { 51 | logger.WithError(err). 52 | Errorln("cannot create firewall operation") 53 | return err 54 | } 55 | 56 | err = p.waitGlobalOperation(ctx, op.Name) 57 | if err != nil { 58 | logger.WithError(err). 59 | Errorln("cannot create firewall rule") 60 | } 61 | 62 | return err 63 | } 64 | -------------------------------------------------------------------------------- /drivers/google/setup_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package google 6 | 7 | import ( 8 | "context" 9 | "net/http" 10 | "testing" 11 | 12 | "github.com/h2non/gock" 13 | compute "google.golang.org/api/compute/v1" 14 | ) 15 | 16 | func TestSetupFirewall(t *testing.T) { 17 | defer gock.Off() 18 | 19 | gock.New("https://compute.googleapis.com"). 20 | Get("/compute/v1/projects/my-project/global/firewalls/default-allow-docker"). 21 | Reply(404) 22 | 23 | gock.New("https://compute.googleapis.com"). 24 | Post("/compute/v1/projects/my-project/global/firewalls"). 25 | JSON(createFirewallMock). 26 | Reply(200). 27 | BodyString(`{ "name": "operation-name" }`) 28 | 29 | gock.New("https://compute.googleapis.com"). 30 | Get("/compute/v1/projects/my-project/global/operations/operation-name"). 31 | Reply(200). 32 | BodyString(`{ "status": "DONE" }`) 33 | 34 | p, err := New( 35 | WithClient(http.DefaultClient), 36 | WithZones("us-central1-a"), 37 | WithProject("my-project"), 38 | ) 39 | if err != nil { 40 | t.Error(err) 41 | return 42 | } 43 | 44 | err = p.(*provider).setupFirewall(context.TODO()) 45 | if err != nil { 46 | t.Error(err) 47 | } 48 | } 49 | 50 | func TestSetupFirewall_Exists(t *testing.T) { 51 | defer gock.Off() 52 | 53 | gock.New("https://compute.googleapis.com"). 54 | Get("/compute/v1/projects/my-project/global/firewalls/default-allow-docker"). 55 | Reply(200). 56 | BodyString(findFirewallRes) 57 | 58 | p, err := New( 59 | WithClient(http.DefaultClient), 60 | WithZones("us-central1-a"), 61 | WithProject("my-project"), 62 | ) 63 | if err != nil { 64 | t.Error(err) 65 | return 66 | } 67 | 68 | err = p.(*provider).setupFirewall(context.TODO()) 69 | if err != nil { 70 | t.Error(err) 71 | } 72 | } 73 | 74 | var createFirewallMock = &compute.Firewall{ 75 | Allowed: []*compute.FirewallAllowed{ 76 | { 77 | IPProtocol: "tcp", 78 | Ports: []string{"2376"}, 79 | }, 80 | }, 81 | Direction: "INGRESS", 82 | Name: "default-allow-docker", 83 | Network: "global/networks/default", 84 | Priority: 1000, 85 | SourceRanges: []string{"0.0.0.0/0"}, 86 | TargetTags: []string{"allow-docker"}, 87 | } 88 | 89 | var findFirewallRes = ` 90 | { 91 | "allowed": [ 92 | { 93 | "IPProtocol": "tcp", 94 | "ports": [ 95 | "2376" 96 | ] 97 | } 98 | ], 99 | "creationTimestamp": "2018-03-10T11:31:09.445-08:00", 100 | "description": "", 101 | "direction": "INGRESS", 102 | "id": "3206167972979853122", 103 | "kind": "compute#firewall", 104 | "name": "default-allow-docker", 105 | "network": "projects/my-project/global/networks/default", 106 | "priority": 1000, 107 | "selfLink": "projects/my-project/global/firewalls/default-allow-docker", 108 | "sourceRanges": [ 109 | "0.0.0.0/0" 110 | ], 111 | "targetTags": [ 112 | "allow-docker" 113 | ] 114 | } 115 | ` 116 | -------------------------------------------------------------------------------- /drivers/hetznercloud/create.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package hetznercloud 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "strconv" 11 | 12 | "github.com/drone/autoscaler" 13 | "github.com/drone/autoscaler/logger" 14 | 15 | "github.com/hetznercloud/hcloud-go/hcloud" 16 | ) 17 | 18 | func (p *provider) Create(ctx context.Context, opts autoscaler.InstanceCreateOpts) (*autoscaler.Instance, error) { 19 | p.init.Do(func() { 20 | p.setup(ctx) 21 | }) 22 | 23 | buf := new(bytes.Buffer) 24 | err := p.userdata.Execute(buf, &opts) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | req := hcloud.ServerCreateOpts{ 30 | Name: opts.Name, 31 | UserData: buf.String(), 32 | ServerType: &hcloud.ServerType{ 33 | Name: p.serverType, 34 | }, 35 | Image: &hcloud.Image{ 36 | Name: p.image, 37 | }, 38 | SSHKeys: []*hcloud.SSHKey{ 39 | { 40 | ID: p.key, 41 | }, 42 | }, 43 | } 44 | 45 | datacenter := "unknown" 46 | 47 | if p.datacenter != "" { 48 | req.Datacenter = &hcloud.Datacenter{ 49 | Name: p.datacenter, 50 | } 51 | 52 | datacenter = p.datacenter 53 | } 54 | 55 | logger := logger.FromContext(ctx). 56 | WithField("datacenter", datacenter). 57 | WithField("image", req.Image.Name). 58 | WithField("serverType", req.ServerType.Name). 59 | WithField("name", req.Name) 60 | 61 | logger.Debugln("instance create") 62 | 63 | resp, _, err := p.client.Server.Create(ctx, req) 64 | if err != nil { 65 | logger.WithError(err). 66 | Errorln("cannot create instance") 67 | return nil, err 68 | } 69 | 70 | logger. 71 | WithField("name", req.Name). 72 | Infoln("instance created") 73 | 74 | return &autoscaler.Instance{ 75 | Provider: autoscaler.ProviderHetznerCloud, 76 | ID: strconv.Itoa(resp.Server.ID), 77 | Name: resp.Server.Name, 78 | Address: resp.Server.PublicNet.IPv4.IP.String(), 79 | Size: req.ServerType.Name, 80 | Region: datacenter, 81 | Image: req.Image.Name, 82 | }, nil 83 | } 84 | -------------------------------------------------------------------------------- /drivers/hetznercloud/destroy.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package hetznercloud 6 | 7 | import ( 8 | "context" 9 | "strconv" 10 | 11 | "github.com/drone/autoscaler" 12 | "github.com/drone/autoscaler/logger" 13 | 14 | "github.com/hetznercloud/hcloud-go/hcloud" 15 | ) 16 | 17 | func (p *provider) Destroy(ctx context.Context, instance *autoscaler.Instance) error { 18 | logger := logger.FromContext(ctx). 19 | WithField("region", instance.Region). 20 | WithField("image", instance.Image). 21 | WithField("size", instance.Size). 22 | WithField("name", instance.Name) 23 | 24 | id, err := strconv.Atoi(instance.ID) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | logger.Debugln("deleting instance") 30 | 31 | _, err = p.client.Server.Delete(ctx, &hcloud.Server{ID: id}) 32 | 33 | if err != nil { 34 | if err.Error() == "hcloud: server responded with status code 404" { 35 | logger.WithError(err). 36 | Debugln("instance does not exist") 37 | return autoscaler.ErrInstanceNotFound 38 | } 39 | 40 | logger.WithError(err). 41 | Errorln("deleting instance failed") 42 | return err 43 | } 44 | 45 | logger.Debugln("instance deleted") 46 | 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /drivers/hetznercloud/destroy_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package hetznercloud 6 | 7 | import ( 8 | "context" 9 | "strconv" 10 | "testing" 11 | 12 | "github.com/drone/autoscaler" 13 | 14 | "github.com/h2non/gock" 15 | ) 16 | 17 | func TestDestroy(t *testing.T) { 18 | defer gock.Off() 19 | 20 | gock.New("https://api.hetzner.cloud"). 21 | Delete("/v1/servers/3164494"). 22 | Reply(200) 23 | 24 | mockContext := context.TODO() 25 | mockInstance := &autoscaler.Instance{ 26 | ID: "3164494", 27 | } 28 | 29 | p := New( 30 | WithToken("LRK9DAWQ1ZAEFSrCNEEzLCUwhYX1U3g7wMg4dTlkkDC96fyDuyJ39nVbVjCKSDfj"), 31 | ) 32 | err := p.Destroy(mockContext, mockInstance) 33 | if err != nil { 34 | t.Error(err) 35 | } 36 | } 37 | 38 | func TestDestroyDeleteError(t *testing.T) { 39 | defer gock.Off() 40 | 41 | gock.New("https://api.hetzner.cloud"). 42 | Delete("/v1/servers/3164494"). 43 | Reply(500) 44 | 45 | mockContext := context.TODO() 46 | mockInstance := &autoscaler.Instance{ 47 | ID: "3164494", 48 | } 49 | 50 | p := New( 51 | WithToken("LRK9DAWQ1ZAEFSrCNEEzLCUwhYX1U3g7wMg4dTlkkDC96fyDuyJ39nVbVjCKSDfj"), 52 | ) 53 | err := p.Destroy(mockContext, mockInstance) 54 | if err == nil { 55 | t.Errorf("Expect error returned from hetzner cloud") 56 | } 57 | } 58 | 59 | func TestDestroyNotFound(t *testing.T) { 60 | defer gock.Off() 61 | 62 | gock.New("https://api.hetzner.cloud"). 63 | Delete("/v1/servers/3164494"). 64 | Reply(404). 65 | BodyString(destroyNotFoundResponse) 66 | 67 | mockContext := context.TODO() 68 | mockInstance := &autoscaler.Instance{ 69 | ID: "3164494", 70 | } 71 | 72 | p := New( 73 | WithToken("LRK9DAWQ1ZAEFSrCNEEzLCUwhYX1U3g7wMg4dTlkkDC96fyDuyJ39nVbVjCKSDfj"), 74 | ) 75 | 76 | err := p.Destroy(mockContext, mockInstance) 77 | if err == nil { 78 | t.Errorf("Expect error returned from hetzner cloud") 79 | } 80 | if err != autoscaler.ErrInstanceNotFound { 81 | t.Errorf("Expect instance not found returned from hetzner cloud") 82 | } 83 | } 84 | 85 | func TestDestroyInvalidInput(t *testing.T) { 86 | i := &autoscaler.Instance{} 87 | p := provider{} 88 | err := p.Destroy(context.TODO(), i) 89 | if _, ok := err.(*strconv.NumError); !ok { 90 | t.Errorf("Expected invalid or missing ID error") 91 | } 92 | } 93 | 94 | var destroyNotFoundResponse = `{ 95 | "error": { 96 | "message": "server with ID '3164494' not found", 97 | "code": "not_found", 98 | "details": null 99 | } 100 | }` 101 | -------------------------------------------------------------------------------- /drivers/hetznercloud/option.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package hetznercloud 6 | 7 | import ( 8 | "io/ioutil" 9 | 10 | "github.com/drone/autoscaler/drivers/internal/userdata" 11 | "github.com/hetznercloud/hcloud-go/hcloud" 12 | ) 13 | 14 | // Option configures a Digital Ocean provider option. 15 | type Option func(*provider) 16 | 17 | // WithClient returns an option to set the Hetzner client. 18 | func WithClient(client *hcloud.Client) Option { 19 | return func(p *provider) { 20 | p.client = client 21 | } 22 | } 23 | 24 | // WithDatacenter returns an option to set the datacenter. 25 | func WithDatacenter(datacenter string) Option { 26 | return func(p *provider) { 27 | p.datacenter = datacenter 28 | } 29 | } 30 | 31 | // WithImage returns an option to set the image. 32 | func WithImage(image string) Option { 33 | return func(p *provider) { 34 | p.image = image 35 | } 36 | } 37 | 38 | // WithServerType returns an option to set the server type. 39 | func WithServerType(serverType string) Option { 40 | return func(p *provider) { 41 | p.serverType = serverType 42 | } 43 | } 44 | 45 | // WithSSHKey returns an option to set the ssh key. 46 | func WithSSHKey(key int) Option { 47 | return func(p *provider) { 48 | p.key = key 49 | } 50 | } 51 | 52 | // WithToken returns an option to set the auth token. 53 | func WithToken(token string) Option { 54 | return WithClient( 55 | hcloud.NewClient( 56 | hcloud.WithToken( 57 | token, 58 | ), 59 | ), 60 | ) 61 | } 62 | 63 | // WithUserData returns an option to set the cloud-init 64 | // template from text. 65 | func WithUserData(text string) Option { 66 | return func(p *provider) { 67 | if text != "" { 68 | p.userdata = userdata.Parse(text) 69 | } 70 | } 71 | } 72 | 73 | // WithUserDataFile returns an option to set the cloud-init 74 | // template from file. 75 | func WithUserDataFile(filepath string) Option { 76 | return func(p *provider) { 77 | if filepath != "" { 78 | b, err := ioutil.ReadFile(filepath) 79 | if err != nil { 80 | panic(err) 81 | } 82 | p.userdata = userdata.Parse(string(b)) 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /drivers/hetznercloud/option_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package hetznercloud 6 | 7 | import "testing" 8 | 9 | func TestOptions(t *testing.T) { 10 | p := New( 11 | WithImage("ubuntu-17.04"), 12 | WithDatacenter("fsn1-dc8"), 13 | WithServerType("cx20"), 14 | WithSSHKey(23234), 15 | ).(*provider) 16 | 17 | if got, want := p.image, "ubuntu-17.04"; got != want { 18 | t.Errorf("Want image %q, got %q", want, got) 19 | } 20 | if got, want := p.datacenter, "fsn1-dc8"; got != want { 21 | t.Errorf("Want region %q, got %q", want, got) 22 | } 23 | if got, want := p.serverType, "cx20"; got != want { 24 | t.Errorf("Want serverType %q, got %q", want, got) 25 | } 26 | if got, want := p.key, 23234; got != want { 27 | t.Errorf("Want key %d, got %d", want, got) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /drivers/hetznercloud/provider.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package hetznercloud 6 | 7 | import ( 8 | "sync" 9 | "text/template" 10 | 11 | "github.com/drone/autoscaler" 12 | "github.com/drone/autoscaler/drivers/internal/userdata" 13 | 14 | "github.com/hetznercloud/hcloud-go/hcloud" 15 | ) 16 | 17 | // provider implement a Hetzner Cloud provider. 18 | type provider struct { 19 | init sync.Once 20 | 21 | token string 22 | datacenter string 23 | serverType string 24 | image string 25 | userdata *template.Template 26 | key int 27 | 28 | client *hcloud.Client 29 | } 30 | 31 | // New returns a new Digital Ocean provider. 32 | func New(opts ...Option) autoscaler.Provider { 33 | p := new(provider) 34 | for _, opt := range opts { 35 | opt(p) 36 | } 37 | if p.serverType == "" { 38 | p.serverType = "cx11" 39 | } 40 | if p.image == "" { 41 | p.image = "ubuntu-20.04" 42 | } 43 | if p.userdata == nil { 44 | p.userdata = userdata.T 45 | } 46 | return p 47 | } 48 | -------------------------------------------------------------------------------- /drivers/hetznercloud/provider_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package hetznercloud 6 | 7 | import "testing" 8 | 9 | func TestDefaults(t *testing.T) { 10 | p := New().(*provider) 11 | if got, want := p.image, "ubuntu-20.04"; got != want { 12 | t.Errorf("Want image %q, got %q", want, got) 13 | } 14 | if got, want := p.datacenter, ""; got != want { 15 | t.Errorf("Want datacenter %q, got %q", want, got) 16 | } 17 | if got, want := p.serverType, "cx11"; got != want { 18 | t.Errorf("Want server type %q, got %q", want, got) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /drivers/hetznercloud/setup.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package hetznercloud 6 | 7 | import ( 8 | "context" 9 | "errors" 10 | 11 | "github.com/drone/autoscaler/logger" 12 | "github.com/hetznercloud/hcloud-go/hcloud" 13 | "golang.org/x/sync/errgroup" 14 | ) 15 | 16 | func (p *provider) setup(ctx context.Context) error { 17 | var g errgroup.Group 18 | if p.key == 0 { 19 | g.Go(func() error { 20 | return p.setupKeypair(ctx) 21 | }) 22 | } 23 | return g.Wait() 24 | } 25 | 26 | func (p *provider) setupKeypair(ctx context.Context) error { 27 | logger := logger.FromContext(ctx) 28 | 29 | logger.Debugln("finding default ssh key") 30 | 31 | keys, _, err := p.client.SSHKey.List(ctx, hcloud.SSHKeyListOpts{}) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | index := map[string]*hcloud.SSHKey{} 37 | for _, key := range keys { 38 | index[key.Name] = key 39 | } 40 | 41 | // if the account has multiple keys configured we will 42 | // attempt to use an existing key based on naming convention. 43 | for _, name := range []string{"drone", "id_rsa_drone"} { 44 | key, ok := index[name] 45 | if !ok { 46 | continue 47 | } 48 | p.key = key.ID 49 | 50 | logger. 51 | WithField("name", name). 52 | WithField("fingerprint", key.Fingerprint). 53 | Debugln("using default ssh key") 54 | return nil 55 | } 56 | 57 | // if there were no matches but the account has at least 58 | // one keypair already created we will select the first 59 | // in the list. 60 | if len(keys) > 0 { 61 | key := keys[0] 62 | p.key = key.ID 63 | 64 | logger. 65 | WithField("name", key.Name). 66 | WithField("fingerprint", key.Fingerprint). 67 | Debugln("using default ssh key") 68 | return nil 69 | } 70 | 71 | return errors.New("No matching keys") 72 | } 73 | -------------------------------------------------------------------------------- /drivers/hetznercloud/setup_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package hetznercloud 6 | 7 | import ( 8 | "context" 9 | "testing" 10 | 11 | "github.com/h2non/gock" 12 | ) 13 | 14 | func TestSetupKey_ChooseFirst(t *testing.T) { 15 | defer gock.Off() 16 | 17 | gock.New("https://api.hetzner.cloud"). 18 | Get("/v1/ssh_keys"). 19 | Reply(200). 20 | BodyString(respSingleKey) 21 | 22 | p := New( 23 | WithToken("LRK9DAWQ1ZAEFSrCNEEzLCUwhYX1U3g7wMg4dTlkkDC96fyDuyJ39nVbVjCKSDfj"), 24 | ).(*provider) 25 | 26 | err := p.setup(context.TODO()) 27 | if err != nil { 28 | t.Error(err) 29 | } 30 | 31 | if got, want := p.key, 2323; got != want { 32 | t.Errorf("Want key id %d, got %d", want, got) 33 | } 34 | } 35 | 36 | func TestSetupKey_ChooseMatch(t *testing.T) { 37 | defer gock.Off() 38 | 39 | gock.New("https://api.hetzner.cloud"). 40 | Get("/v1/ssh_keys"). 41 | Reply(200). 42 | BodyString(respMultiKey) 43 | 44 | p := New( 45 | WithToken("LRK9DAWQ1ZAEFSrCNEEzLCUwhYX1U3g7wMg4dTlkkDC96fyDuyJ39nVbVjCKSDfj"), 46 | ).(*provider) 47 | 48 | err := p.setup(context.TODO()) 49 | if err != nil { 50 | t.Error(err) 51 | } 52 | 53 | if got, want := p.key, 2324; got != want { 54 | t.Errorf("Want key id %d, got %d", want, got) 55 | } 56 | } 57 | 58 | const respSingleKey = ` 59 | { 60 | "ssh_keys": [ 61 | { 62 | "id": 2323, 63 | "name": "My ssh key", 64 | "fingerprint": "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f", 65 | "public_key": "ssh-rsa AAAjjk76kgf...Xt" 66 | } 67 | ] 68 | } 69 | ` 70 | 71 | const respMultiKey = ` 72 | { 73 | "ssh_keys": [ 74 | { 75 | "id": 2323, 76 | "name": "My ssh key", 77 | "fingerprint": "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f", 78 | "public_key": "ssh-rsa AAAjjk76kgf...Xt" 79 | }, 80 | { 81 | "id": 2324, 82 | "name": "drone", 83 | "fingerprint": "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f", 84 | "public_key": "ssh-rsa AAAjjk76kgf...Xt" 85 | } 86 | ] 87 | } 88 | ` 89 | -------------------------------------------------------------------------------- /drivers/internal/userdata/userdata.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package userdata 6 | 7 | import ( 8 | "encoding/base64" 9 | "text/template" 10 | 11 | "github.com/drone/funcmap" 12 | ) 13 | 14 | var funcs = map[string]interface{}{ 15 | "base64": func(src []byte) string { 16 | return base64.StdEncoding.EncodeToString(src) 17 | }, 18 | } 19 | 20 | // Parse parses the userdata template. 21 | func Parse(text string) *template.Template { 22 | if decoded, err := base64.StdEncoding.DecodeString(text); err == nil { 23 | return template.Must( 24 | template.New("_").Funcs(funcs).Funcs(funcmap.Funcs).Parse(string(decoded)), 25 | ) 26 | } 27 | 28 | return template.Must( 29 | template.New("_").Funcs(funcs).Funcs(funcmap.Funcs).Parse(text), 30 | ) 31 | } 32 | 33 | // T is the default userdata template. 34 | var T = Parse(`#cloud-config 35 | 36 | apt_reboot_if_required: false 37 | package_update: false 38 | package_upgrade: false 39 | 40 | apt: 41 | sources: 42 | docker.list: 43 | source: deb [arch=amd64] https://download.docker.com/linux/ubuntu $RELEASE stable 44 | keyid: 0EBFCD88 45 | 46 | packages: 47 | - docker-ce 48 | 49 | write_files: 50 | - path: /etc/systemd/system/docker.service.d/override.conf 51 | content: | 52 | [Service] 53 | ExecStart= 54 | ExecStart=/usr/bin/dockerd 55 | - path: /etc/default/docker 56 | content: | 57 | DOCKER_OPTS="" 58 | - path: /etc/docker/daemon.json 59 | content: | 60 | { 61 | "hosts": [ "0.0.0.0:2376", "unix:///var/run/docker.sock" ], 62 | "tls": true, 63 | "tlsverify": true, 64 | "tlscacert": "/etc/docker/ca.pem", 65 | "tlscert": "/etc/docker/server-cert.pem", 66 | "tlskey": "/etc/docker/server-key.pem" 67 | } 68 | - path: /etc/docker/ca.pem 69 | encoding: b64 70 | content: {{ .CACert | base64 }} 71 | - path: /etc/docker/server-cert.pem 72 | encoding: b64 73 | content: {{ .TLSCert | base64 }} 74 | - path: /etc/docker/server-key.pem 75 | encoding: b64 76 | content: {{ .TLSKey | base64 }} 77 | 78 | runcmd: 79 | - [ systemctl, daemon-reload ] 80 | - [ systemctl, restart, docker ] 81 | `) 82 | -------------------------------------------------------------------------------- /drivers/internal/userdata/userdata_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package userdata 6 | 7 | import ( 8 | "bytes" 9 | "fmt" 10 | "testing" 11 | 12 | "github.com/drone/autoscaler" 13 | ) 14 | 15 | func TestUserdata(t *testing.T) { 16 | buf := new(bytes.Buffer) 17 | err := T.Execute(buf, &autoscaler.InstanceCreateOpts{ 18 | Name: "agent-123456", 19 | CACert: []byte(dummyCA), 20 | TLSKey: []byte(dummykey), 21 | TLSCert: []byte(dummyCert), 22 | }) 23 | if err != nil { 24 | t.Error(err) 25 | return 26 | } 27 | } 28 | 29 | func TestUserdataFuncmap(t *testing.T) { 30 | buf := new(bytes.Buffer) 31 | err := UD.Execute(buf, &map[string]interface{}{ 32 | "Content": "foo", 33 | }) 34 | fmt.Println(buf.String()) 35 | if err != nil { 36 | t.Error(err) 37 | return 38 | } 39 | if buf.String() != UDExpected { 40 | t.Errorf("expected '%s', got '%s'", UDExpected, buf.String()) 41 | } 42 | } 43 | 44 | var dummyCA = `-----BEGIN CERTIFICATE----- 45 | MIIGOTCCBCGgAwIBAgIJAOE/vJd8EB24MA0GCSqGSIb3DQEBBQUAMIGyMQswCQYD 46 | VQQGEwJGUjEPMA0GA1UECAwGQWxzYWNlMRMwEQYDVQQHDApTdHJhc2JvdXJnMRgw 47 | FgYDVQQKDA93d3cuZnJlZWxhbi5vcmcxEDAOBgNVBAsMB2ZyZWVsYW4xLTArBgNV 48 | BAMMJEZyZWVsYW4gU2FtcGxlIENlcnRpZmljYXRlIEF1dGhvcml0eTEiMCAGCSqG 49 | KvbxUcDaVvXB0EU0bg== 50 | -----END CERTIFICATE-----` 51 | 52 | var dummykey = `-----BEGIN RSA PRIVATE KEY----- 53 | MIIJKQIBAAKCAgEA3W29+ID6194bH6ejLrIC4hb2Ugo8v6ZC+Mrck2dNYMNPjcOK 54 | ABvxxEtBamnSaeU/IY7FC/giN622LEtV/3oDcrua0+yWuVafyxmZyTKUb4/GUgaf 55 | RQPf/eiX9urWurtIK7XgNGFNUjYPq4dSJQPPhwCHE/LKAykWnZBXRrX0Dq4XyApN 56 | ku0IpjIjEXH+8ixE12wH8wt7DEvdO7T3N3CfUbaITl1qBX+Nm2Z6q4Ag/u5rl8NJ 57 | v3TGd3xXD9yQIjmugNgxNiwAZzhJs/ZJy++fPSJ1XQxbd9qPghgGoe/ff6G7 58 | -----END RSA PRIVATE KEY-----` 59 | 60 | var dummyCert = `-----BEGIN CERTIFICATE----- 61 | MIIGJzCCBA+gAwIBAgIBATANBgkqhkiG9w0BAQUFADCBsjELMAkGA1UEBhMCRlIx 62 | d3d3LmZyZWVsYW4ub3JnMRAwDgYDVQQLDAdmcmVlbGFuMS0wKwYDVQQDDCRGcmVl 63 | bGFuIFNhbXBsZSBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkxIjAgBgkqhkiG9w0BCQEW 64 | E2NvbnRhY3RAZnJlZWxhbi5vcmcwHhcNMTIwNDI3MTAzMTE4WhcNMjIwNDI1MTAz 65 | DiH5uEqBXExjrj0FslxcVKdVj5glVcSmkLwZKbEU1OKwleT/iXFhvooWhQ== 66 | -----END CERTIFICATE-----` 67 | 68 | var UD = Parse(`#cloud-config 69 | 70 | apt_reboot_if_required: 71 | package_update: false 72 | package_upgrade: false 73 | 74 | write_files: 75 | - path: /etc/systemd/system/docker.service.d/override.conf 76 | content: | {{nindent .Content 6 }} 77 | `) 78 | 79 | var UDExpected = `#cloud-config 80 | 81 | apt_reboot_if_required: 82 | package_update: false 83 | package_upgrade: false 84 | 85 | write_files: 86 | - path: /etc/systemd/system/docker.service.d/override.conf 87 | content: | 88 | foo 89 | ` 90 | -------------------------------------------------------------------------------- /drivers/openstack/destroy.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package openstack 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | 11 | "github.com/drone/autoscaler" 12 | "github.com/drone/autoscaler/logger" 13 | 14 | "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/floatingips" 15 | "github.com/gophercloud/gophercloud/openstack/compute/v2/servers" 16 | "github.com/gophercloud/gophercloud/pagination" 17 | ) 18 | 19 | func (p *provider) Destroy(ctx context.Context, instance *autoscaler.Instance) error { 20 | logger := logger.FromContext(ctx). 21 | WithField("region", instance.Region). 22 | WithField("image", instance.Image). 23 | WithField("flavor", instance.Size). 24 | WithField("name", instance.Name) 25 | 26 | logger.Debugln("deleting instance") 27 | 28 | err := p.deleteFloatingIps(instance) 29 | if err != nil { 30 | logger.WithError(err). 31 | Debugln("failed to delete floating ips") 32 | 33 | return err 34 | } 35 | 36 | err = servers.Delete(p.computeClient, instance.ID).ExtractErr() 37 | if err == nil { 38 | logger.Debugln("instance deleted") 39 | return nil 40 | } 41 | 42 | if err.Error() == "Resource not found" { 43 | logger.WithError(err). 44 | Debugln("instance does not exist") 45 | return autoscaler.ErrInstanceNotFound 46 | } 47 | 48 | logger.WithError(err). 49 | Errorln("attempting to force delete") 50 | 51 | err = servers.ForceDelete(p.computeClient, instance.ID).ExtractErr() 52 | if err == nil { 53 | logger.Debugln("instance deleted") 54 | return nil 55 | } 56 | 57 | if err.Error() == "Resource not found" { 58 | logger.WithError(err). 59 | Debugln("instance does not exist") 60 | return autoscaler.ErrInstanceNotFound 61 | } 62 | 63 | logger.WithError(err). 64 | Errorln("force-deleting instance failed") 65 | 66 | return err 67 | } 68 | 69 | func (p *provider) deleteFloatingIps(instance *autoscaler.Instance) error { 70 | return floatingips.List(p.computeClient).EachPage(func(page pagination.Page) (bool, error) { 71 | ips, err := floatingips.ExtractFloatingIPs(page) 72 | if err != nil { 73 | return false, err 74 | } 75 | 76 | for _, ip := range ips { 77 | if ip.InstanceID == instance.ID { 78 | if err := floatingips.DisassociateInstance(p.computeClient, instance.ID, floatingips.DisassociateOpts{ 79 | FloatingIP: ip.IP, 80 | }).ExtractErr(); err != nil { 81 | return false, fmt.Errorf("failed to disassociate floating ip: %s", err) 82 | } 83 | 84 | if err := floatingips.Delete(p.computeClient, ip.ID).ExtractErr(); err != nil { 85 | return false, fmt.Errorf("failed to delete floating ip: %s", err) 86 | } 87 | } 88 | } 89 | 90 | return true, nil 91 | }) 92 | } 93 | -------------------------------------------------------------------------------- /drivers/openstack/destroy_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package openstack 6 | 7 | import ( 8 | "context" 9 | "testing" 10 | 11 | "github.com/drone/autoscaler" 12 | "github.com/h2non/gock" 13 | ) 14 | 15 | func TestDestroy(t *testing.T) { 16 | defer gock.Off() 17 | setupEnv(t) 18 | 19 | authResp1 := helperLoad(t, "authresp1.json") 20 | gock.New("http://ops.my.cloud"). 21 | Get("/identity"). 22 | Reply(300). 23 | SetHeader("Content-Type", "application/json"). 24 | BodyString(string(authResp1)) 25 | 26 | tokenResp1 := helperLoad(t, "tokenresp1.json") 27 | gock.New("http://ops.my.cloud"). 28 | Post("/identity/v3/auth/tokens"). 29 | Reply(201). 30 | SetHeader("Content-Type", "application/json"). 31 | SetHeader("X-Subject-Token", authToken). 32 | BodyString(string(tokenResp1)) 33 | 34 | authResp2 := helperLoad(t, "authresp1.json") 35 | gock.New("http://ops.my.cloud"). 36 | Get("/identity"). 37 | Reply(300). 38 | SetHeader("Content-Type", "application/json"). 39 | BodyString(string(authResp2)) 40 | 41 | tokenResp2 := helperLoad(t, "tokenresp1.json") 42 | gock.New("http://ops.my.cloud"). 43 | Post("/identity/v3/auth/tokens"). 44 | Reply(201). 45 | SetHeader("Content-Type", "application/json"). 46 | SetHeader("X-Subject-Token", authToken). 47 | BodyString(string(tokenResp2)) 48 | 49 | fipResp1 := helperLoad(t, "fipresp1.json") 50 | gock.New("http://ops.my.cloud"). 51 | MatchHeader("X-Auth-Token", authToken). 52 | Get("/compute/v2.1/os-floating-ips"). 53 | Reply(200). 54 | SetHeader("Content-Type", "application/json"). 55 | BodyString(string(fipResp1)) 56 | 57 | gock.New("http://ops.my.cloud"). 58 | MatchHeader("X-Auth-Token", authToken). 59 | Delete("/compute/v2.1/servers/56046f6d-3184-495b-938b-baa450db970d"). 60 | Reply(204) 61 | 62 | imageListResp := helperLoad(t, "imagelistresp1.json") 63 | gock.New("http://ops.my.cloud"). 64 | Get("/compute/v2.1/images/detail"). 65 | MatchHeader("X-Auth-Token", authToken). 66 | Reply(200). 67 | SetHeader("Content-Type", "application/json"). 68 | BodyString(string(imageListResp)) 69 | 70 | flavorListResp1 := helperLoad(t, "flavorlistresp1.json") 71 | gock.New("http://ops.my.cloud"). 72 | Get("/compute/v2.1/flavors/detail"). 73 | MatchHeader("X-Auth-Token", authToken). 74 | Reply(200). 75 | SetHeader("Content-Type", "application/json"). 76 | BodyString(string(flavorListResp1)) 77 | 78 | mockContext := context.TODO() 79 | mockInstance := &autoscaler.Instance{ 80 | ID: "56046f6d-3184-495b-938b-baa450db970d", 81 | Address: "172.24.4.5", 82 | } 83 | 84 | v, err := New( 85 | WithRegion("RegionOne"), 86 | WithFlavor("m1.small"), 87 | WithImage("ubuntu-16.04-server-latest"), 88 | WithFloatingIpPool("public"), 89 | WithSSHKey("drone-ci-key"), 90 | ) 91 | if err != nil { 92 | t.Error(err) 93 | return 94 | } 95 | p := v.(*provider) 96 | p.init.Do(func() {}) // 97 | 98 | err = p.Destroy(mockContext, mockInstance) 99 | if err != nil { 100 | t.Error(err) 101 | } 102 | 103 | if !gock.IsDone() { 104 | t.Error("Not all expected http requests completed") 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /drivers/openstack/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package openstack contains a autoscaler driver for OpenStack 3 | Configuration: 4 | 5 | Authenticate with the usual OpenStack environment variables. 6 | (Not all of these may be necessary: 7 | see https://github.com/gophercloud/gophercloud/blob/master/openstack/auth_env.go) 8 | 9 | OS_AUTH_URL=https://my.openstack.cloud:5000 10 | OS_ENDPOINT_TYPE=publicURL 11 | OS_IDENTITY_API_VERSION=2 12 | OS_PASSWORD= 13 | OS_DOMAIN_ID=default 14 | OS_REGION_NAME=my-region 15 | OS_TENANT_ID=my-tenant-id 16 | OS_TENANT_NAME=my-tenant-name 17 | OS_USERNAME=my-username 18 | 19 | Configure driver with: 20 | DRONE_OPENSTACK_SSHKEY=drone-key-name 21 | DRONE_OPENSTACK_SECURITY_GROUP=my-security-group 22 | # Pool for floating ips 23 | DRONE_OPENSTACK_IP_POOL=my-ip-pool 24 | DRONE_OPENSTACK_FLAVOR=v1-standard-2 25 | DRONE_OPENSTACK_IMAGE=ubuntu-16.04-server-latest 26 | DRONE_OPENSTACK_METADATA=name:agent,owner:drone-ci 27 | 28 | */ 29 | package openstack 30 | -------------------------------------------------------------------------------- /drivers/openstack/option.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package openstack 6 | 7 | import ( 8 | "io/ioutil" 9 | 10 | "github.com/drone/autoscaler/drivers/internal/userdata" 11 | "github.com/gophercloud/gophercloud" 12 | ) 13 | 14 | type Option func(*provider) 15 | 16 | // WithImage returns an option to set the instance image. 17 | func WithImage(image string) Option { 18 | return func(p *provider) { 19 | p.image = image 20 | } 21 | } 22 | 23 | // WithRegion returns an option to set the OpenStack target region. 24 | func WithRegion(region string) Option { 25 | return func(p *provider) { 26 | p.region = region 27 | } 28 | } 29 | 30 | // WithFlavor returns an option to set the instance flavor. 31 | func WithFlavor(flavor string) Option { 32 | return func(p *provider) { 33 | p.flavor = flavor 34 | } 35 | } 36 | 37 | // WithSecurityGroup returns an option to set the instance security groups. 38 | func WithSecurityGroup(group ...string) Option { 39 | return func(p *provider) { 40 | p.groups = group 41 | } 42 | } 43 | 44 | // WithComputeClient returns an option to set the 45 | // GopherCloud ServiceClient. 46 | func WithComputeClient(computeClient *gophercloud.ServiceClient) Option { 47 | return func(p *provider) { 48 | p.computeClient = computeClient 49 | } 50 | } 51 | 52 | // WithNetworkClient returns an option to set the 53 | // GopherCloud ServiceClient. 54 | func WithNetworkClient(networkClient *gophercloud.ServiceClient) Option { 55 | return func(p *provider) { 56 | p.networkClient = networkClient 57 | } 58 | } 59 | 60 | // WithSSHKey returns an option to set the ssh key. 61 | func WithSSHKey(key string) Option { 62 | return func(p *provider) { 63 | p.key = key 64 | } 65 | } 66 | 67 | // WithNetwork returns an option to set the network id. 68 | func WithNetwork(id string) Option { 69 | return func(p *provider) { 70 | p.network = id 71 | } 72 | } 73 | 74 | func WithFloatingIpPool(pool string) Option { 75 | return func(p *provider) { 76 | p.pool = pool 77 | } 78 | } 79 | 80 | // WithMetadata returns an option to set the instance metadata. 81 | func WithMetadata(metadata map[string]string) Option { 82 | return func(p *provider) { 83 | p.metadata = metadata 84 | } 85 | } 86 | 87 | // WithUserData returns an option to set the cloud-init 88 | // template from text. 89 | func WithUserData(text string) Option { 90 | return func(p *provider) { 91 | if text != "" { 92 | p.userdata = userdata.Parse(text) 93 | } 94 | } 95 | } 96 | 97 | // WithUserDataFile returns an option to set the cloud-init 98 | // template from file. 99 | func WithUserDataFile(filepath string) Option { 100 | return func(p *provider) { 101 | if filepath != "" { 102 | b, err := ioutil.ReadFile(filepath) 103 | if err != nil { 104 | panic(err) 105 | } 106 | p.userdata = userdata.Parse(string(b)) 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /drivers/openstack/option_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package openstack 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/gophercloud/gophercloud" 11 | ) 12 | 13 | func TestOptions(t *testing.T) { 14 | v, err := New( 15 | WithComputeClient(&gophercloud.ServiceClient{}), 16 | WithNetworkClient(&gophercloud.ServiceClient{}), 17 | WithFloatingIpPool("ext-ips-1"), 18 | WithFlavor("053dc448-045b-4c15-a4a0-1908b6b9310d"), 19 | WithSecurityGroup("drone-ci"), 20 | WithSSHKey("drone-ci"), 21 | WithRegion("sto-01"), 22 | WithImage("0e9fe318-568f-417e-b2c1-f1218aa2712f"), 23 | WithMetadata(map[string]string{"foo": "bar", "baz": "qux"}), 24 | WithNetwork("c7d172c8-96e6-40ab-aaaa-4a555e247c73"), 25 | ) 26 | if err != nil { 27 | t.Error(err) 28 | return 29 | } 30 | p := v.(*provider) 31 | 32 | if got, want := p.pool, "ext-ips-1"; got != want { 33 | t.Errorf("Want pool %q, got %q", want, got) 34 | } 35 | if got, want := p.region, "sto-01"; got != want { 36 | t.Errorf("Want region %q, got %q", want, got) 37 | } 38 | if got, want := p.flavor, "053dc448-045b-4c15-a4a0-1908b6b9310d"; got != want { 39 | t.Errorf("Want flavor %q, got %q", want, got) 40 | } 41 | if got, want := p.image, "0e9fe318-568f-417e-b2c1-f1218aa2712f"; got != want { 42 | t.Errorf("Want image %q, got %q", want, got) 43 | } 44 | if got, want := p.network, "c7d172c8-96e6-40ab-aaaa-4a555e247c73"; got != want { 45 | t.Errorf("Want network %q, got %q", want, got) 46 | } 47 | if got, want := p.key, "drone-ci"; got != want { 48 | t.Errorf("Want key %q, got %q", want, got) 49 | } 50 | if got, want := len(p.metadata), 2; got != want { 51 | t.Errorf("Want %d tags, got %d", want, got) 52 | } 53 | if got, want := p.metadata["foo"], "bar"; got != want { 54 | t.Errorf("Want foo=%q metadata, got foo=%q", want, got) 55 | } 56 | if got, want := p.metadata["baz"], "qux"; got != want { 57 | t.Errorf("Want baz=%q metadata, got baz=%q", want, got) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /drivers/openstack/provider.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package openstack 6 | 7 | import ( 8 | "regexp" 9 | "sync" 10 | "text/template" 11 | 12 | "github.com/drone/autoscaler" 13 | "github.com/drone/autoscaler/drivers/internal/userdata" 14 | "github.com/gophercloud/gophercloud" 15 | "github.com/gophercloud/gophercloud/openstack" 16 | "github.com/gophercloud/gophercloud/openstack/compute/v2/flavors" 17 | "github.com/gophercloud/gophercloud/openstack/compute/v2/images" 18 | "github.com/gophercloud/gophercloud/openstack/networking/v2/networks" 19 | ) 20 | 21 | // provider implements an OpenStack provider 22 | type provider struct { 23 | init sync.Once 24 | 25 | key string 26 | region string 27 | image string 28 | flavor string 29 | network string 30 | pool string 31 | userdata *template.Template 32 | groups []string 33 | metadata map[string]string 34 | 35 | computeClient *gophercloud.ServiceClient 36 | networkClient *gophercloud.ServiceClient 37 | } 38 | 39 | // New returns a new OpenStack provider. 40 | func New(opts ...Option) (autoscaler.Provider, error) { 41 | p := new(provider) 42 | for _, opt := range opts { 43 | opt(p) 44 | } 45 | 46 | if p.userdata == nil { 47 | p.userdata = userdata.T 48 | } 49 | 50 | if p.computeClient == nil { 51 | authOpts, err := openstack.AuthOptionsFromEnv() 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | authClient, err := openstack.AuthenticatedClient(authOpts) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | p.computeClient, err = openstack.NewComputeV2(authClient, gophercloud.EndpointOpts{ 62 | Region: p.region, 63 | }) 64 | if err != nil { 65 | return nil, err 66 | } 67 | } 68 | 69 | if p.networkClient == nil { 70 | authOpts, err := openstack.AuthOptionsFromEnv() 71 | if err != nil { 72 | return nil, err 73 | } 74 | 75 | authClient, err := openstack.AuthenticatedClient(authOpts) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | p.networkClient, err = openstack.NewNetworkV2(authClient, gophercloud.EndpointOpts{ 81 | Region: p.region, 82 | }) 83 | if err != nil { 84 | return nil, err 85 | } 86 | } 87 | 88 | if p.image != "" && !isUUID(p.image) { 89 | uuid, err := images.IDFromName(p.computeClient, p.image) 90 | if err != nil { 91 | return nil, err 92 | } 93 | p.image = uuid 94 | } 95 | 96 | if p.flavor != "" && !isUUID(p.flavor) { 97 | uuid, err := flavors.IDFromName(p.computeClient, p.flavor) 98 | if err != nil { 99 | return nil, err 100 | } 101 | p.flavor = uuid 102 | } 103 | 104 | if p.network != "" && !isUUID(p.network) { 105 | uuid, err := networks.IDFromName(p.networkClient, p.network) 106 | if err != nil { 107 | return nil, err 108 | } 109 | p.network = uuid 110 | } 111 | 112 | return p, nil 113 | } 114 | 115 | func isUUID(uuid string) bool { 116 | r := regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$") 117 | return r.MatchString(uuid) 118 | } 119 | -------------------------------------------------------------------------------- /drivers/openstack/provider_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package openstack 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/gophercloud/gophercloud" 11 | ) 12 | 13 | func TestDefaults(t *testing.T) { 14 | v, err := New( 15 | WithComputeClient(&gophercloud.ServiceClient{}), 16 | WithNetworkClient(&gophercloud.ServiceClient{}), 17 | ) 18 | if err != nil { 19 | t.Error(err) 20 | return 21 | } 22 | p := v.(*provider) 23 | // Add tests if we set some actual defaults in the future. 24 | _ = p 25 | } 26 | -------------------------------------------------------------------------------- /drivers/openstack/setup.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package openstack 6 | 7 | import ( 8 | "context" 9 | "errors" 10 | 11 | "github.com/drone/autoscaler/logger" 12 | 13 | "github.com/gophercloud/gophercloud/openstack/compute/v2/extensions/keypairs" 14 | "golang.org/x/sync/errgroup" 15 | ) 16 | 17 | func (p *provider) setup(ctx context.Context) error { 18 | var g errgroup.Group 19 | if p.key == "" { 20 | g.Go(func() error { 21 | return p.findKeyPair(ctx) 22 | }) 23 | } 24 | 25 | return g.Wait() 26 | } 27 | 28 | func (p *provider) findKeyPair(ctx context.Context) error { 29 | logger := logger.FromContext(ctx) 30 | 31 | logger.Debugln("finding default ssh key") 32 | 33 | allPages, err := keypairs.List(p.computeClient).AllPages() 34 | if err != nil { 35 | return err 36 | } 37 | keys, err := keypairs.ExtractKeyPairs(allPages) 38 | 39 | if err != nil { 40 | return err 41 | } 42 | 43 | index := map[string]keypairs.KeyPair{} 44 | for _, key := range keys { 45 | index[key.Name] = key 46 | } 47 | 48 | // if the account has multiple keys configured we will 49 | // attempt to use an existing key based on naming convention. 50 | for _, name := range []string{"drone", "id_rsa_drone"} { 51 | key, ok := index[name] 52 | if !ok { 53 | continue 54 | } 55 | p.key = key.Name 56 | 57 | logger. 58 | WithField("name", name). 59 | WithField("fingerprint", key.Fingerprint). 60 | Debugln("using default ssh key") 61 | return nil 62 | } 63 | 64 | // if there were no matches but the account has at least 65 | // one keypair already created we will select the first 66 | // in the list. 67 | if len(keys) > 0 { 68 | key := keys[0] 69 | p.key = key.Name 70 | 71 | logger. 72 | WithField("name", key.Name). 73 | WithField("fingerprint", key.Fingerprint). 74 | Debugln("using default ssh key") 75 | return nil 76 | } 77 | return errors.New("no matching keys") 78 | } 79 | -------------------------------------------------------------------------------- /drivers/openstack/setup_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package openstack 6 | 7 | import ( 8 | "io/ioutil" 9 | "path/filepath" 10 | "testing" 11 | ) 12 | 13 | func helperLoad(t *testing.T, name string) []byte { 14 | path := filepath.Join("testdata", name) // relative path 15 | bytes, err := ioutil.ReadFile(path) 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | return bytes 20 | } 21 | 22 | const authToken = "gAAAAABb1tQPtYVBv68airR0dgKC2vXpkLNfEHx0w1EL89dOOjKrtdYHR7IZrDd4VjwZapC5Sri4CndpPscw-nHoh0VQsrvFjtuvT6M64RdrrOljmJbvP0o7PbV713-Pi8OpRIfunvsQFnEQ2DxDH56QC6fsLEcF14VtogOQwTRBod0SkeOCpi4" -------------------------------------------------------------------------------- /drivers/openstack/testdata/associateresp1.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drone/autoscaler/9a40e5c65dc31a7ba706b1afe6796d70f1c5acc0/drivers/openstack/testdata/associateresp1.json -------------------------------------------------------------------------------- /drivers/openstack/testdata/authresp1.json: -------------------------------------------------------------------------------- 1 | { 2 | "versions": { 3 | "values": [ 4 | { 5 | "status": "stable", 6 | "updated": "2018-10-15T00:00:00Z", 7 | "media-types": [ 8 | { 9 | "base": "application/json", 10 | "type": "application/vnd.openstack.identity-v3+json" 11 | } 12 | ], 13 | "id": "v3.11", 14 | "links": [ 15 | { 16 | "href": "http://ops.my.cloud/identity/v3/", 17 | "rel": "self" 18 | } 19 | ] 20 | } 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /drivers/openstack/testdata/fipresp1.json: -------------------------------------------------------------------------------- 1 | { 2 | "floating_ip": { 3 | "instance_id": null, 4 | "ip": "172.24.4.5", 5 | "fixed_ip": null, 6 | "id": "0f013e62-42b1-461c-af7c-8aa3c705ff29", 7 | "pool": "public" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /drivers/openstack/testdata/imagelistresp1.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": [ 3 | { 4 | "status": "ACTIVE", 5 | "updated": "2018-10-26T14:29:41Z", 6 | "links": [ 7 | { 8 | "href": "http://ops.my.cloud/compute/v2.1/images/ee7d6850-0592-4036-bd6e-198b41df7381", 9 | "rel": "self" 10 | }, 11 | { 12 | "href": "http://ops.my.cloud/compute/images/ee7d6850-0592-4036-bd6e-198b41df7381", 13 | "rel": "bookmark" 14 | }, 15 | { 16 | "href": "http://ops.my.cloud/image/images/ee7d6850-0592-4036-bd6e-198b41df7381", 17 | "type": "application/vnd.openstack.image", 18 | "rel": "alternate" 19 | } 20 | ], 21 | "id": "ee7d6850-0592-4036-bd6e-198b41df7381", 22 | "OS-EXT-IMG-SIZE:size": 74448896, 23 | "name": "rancheros-v1.4.1", 24 | "created": "2018-10-26T14:29:39Z", 25 | "minDisk": 0, 26 | "progress": 100, 27 | "minRam": 0, 28 | "metadata": { 29 | "description": "RancherOS v1.4.1" 30 | } 31 | }, 32 | { 33 | "status": "ACTIVE", 34 | "updated": "2018-10-23T13:20:03Z", 35 | "links": [ 36 | { 37 | "href": "http://ops.my.cloud/compute/v2.1/images/4ef19958-ee2d-44a7-a100-de0b8afdbc8e", 38 | "rel": "self" 39 | }, 40 | { 41 | "href": "http://ops.my.cloud/compute/images/4ef19958-ee2d-44a7-a100-de0b8afdbc8e", 42 | "rel": "bookmark" 43 | }, 44 | { 45 | "href": "http://ops.my.cloud/image/images/4ef19958-ee2d-44a7-a100-de0b8afdbc8e", 46 | "type": "application/vnd.openstack.image", 47 | "rel": "alternate" 48 | } 49 | ], 50 | "id": "4ef19958-ee2d-44a7-a100-de0b8afdbc8e", 51 | "OS-EXT-IMG-SIZE:size": 296943616, 52 | "name": "ubuntu-16.04-server-latest", 53 | "created": "2018-10-23T13:19:58Z", 54 | "minDisk": 0, 55 | "progress": 100, 56 | "minRam": 0, 57 | "metadata": { 58 | "description": "Ubuntu 16.04 LTS" 59 | } 60 | }, 61 | { 62 | "status": "ACTIVE", 63 | "updated": "2018-10-22T12:03:52Z", 64 | "links": [ 65 | { 66 | "href": "http://ops.my.cloud/compute/v2.1/images/7fd93141-c387-4859-bc79-b92fac420473", 67 | "rel": "self" 68 | }, 69 | { 70 | "href": "http://ops.my.cloud/compute/images/7fd93141-c387-4859-bc79-b92fac420473", 71 | "rel": "bookmark" 72 | }, 73 | { 74 | "href": "http://ops.my.cloud/image/images/7fd93141-c387-4859-bc79-b92fac420473", 75 | "type": "application/vnd.openstack.image", 76 | "rel": "alternate" 77 | } 78 | ], 79 | "id": "7fd93141-c387-4859-bc79-b92fac420473", 80 | "OS-EXT-IMG-SIZE:size": 13267968, 81 | "name": "cirros-0.3.5-x86_64-disk", 82 | "created": "2018-10-22T12:03:51Z", 83 | "minDisk": 0, 84 | "progress": 100, 85 | "minRam": 0, 86 | "metadata": {} 87 | } 88 | ] 89 | } -------------------------------------------------------------------------------- /drivers/openstack/testdata/servercreateresp1.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "OS-EXT-STS:task_state": null, 4 | "addresses": { 5 | "private": [ 6 | { 7 | "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:7a:f3:1f", 8 | "version": 4, 9 | "addr": "10.0.0.14", 10 | "OS-EXT-IPS:type": "fixed" 11 | } 12 | ] 13 | }, 14 | "links": [ 15 | { 16 | "href": "http://ops.my.cloud/compute/v2.1/servers/56046f6d-3184-495b-938b-baa450db970d", 17 | "rel": "self" 18 | }, 19 | { 20 | "href": "http://ops.my.cloud/compute/servers/56046f6d-3184-495b-938b-baa450db970d", 21 | "rel": "bookmark" 22 | } 23 | ], 24 | "image": { 25 | "id": "4ef19958-ee2d-44a7-a100-de0b8afdbc8e", 26 | "links": [ 27 | { 28 | "href": "http://ops.my.cloud/compute/images/4ef19958-ee2d-44a7-a100-de0b8afdbc8e", 29 | "rel": "bookmark" 30 | } 31 | ] 32 | }, 33 | "OS-EXT-STS:vm_state": "active", 34 | "OS-EXT-SRV-ATTR:instance_name": "instance-0000000d", 35 | "OS-SRV-USG:launched_at": "2018-10-29T09:37:05.000000", 36 | "flavor": { 37 | "id": "2", 38 | "links": [ 39 | { 40 | "href": "http://ops.my.cloud/compute/flavors/2", 41 | "rel": "bookmark" 42 | } 43 | ] 44 | }, 45 | "id": "56046f6d-3184-495b-938b-baa450db970d", 46 | "security_groups": [ 47 | { 48 | "name": "drone-agent" 49 | } 50 | ], 51 | "user_id": "898384bb1b5e4d5a9ff816f7ea911943", 52 | "OS-DCF:diskConfig": "MANUAL", 53 | "accessIPv4": "", 54 | "accessIPv6": "", 55 | "progress": 0, 56 | "OS-EXT-STS:power_state": 1, 57 | "OS-EXT-AZ:availability_zone": "nova", 58 | "config_drive": "", 59 | "status": "ACTIVE", 60 | "updated": "2018-10-29T09:37:06Z", 61 | "hostId": "1e678c454d7593d464d1a0c1c15111119ae841d11d3f7ba66f9aaee9", 62 | "OS-EXT-SRV-ATTR:host": "devstack", 63 | "OS-SRV-USG:terminated_at": null, 64 | "key_name": "drone-ci-key", 65 | "OS-EXT-SRV-ATTR:hypervisor_hostname": "devstack", 66 | "name": "agent-RjISb5v1", 67 | "created": "2018-10-29T09:37:01Z", 68 | "tenant_id": "661f707340b0486caf878f9cc2bc1fab", 69 | "os-extended-volumes:volumes_attached": [], 70 | "metadata": { 71 | "owner": "drone-ci", 72 | "name": "agent" 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /drivers/openstack/testdata/serverstatusresp1.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "OS-EXT-STS:task_state": null, 4 | "addresses": { 5 | "private": [ 6 | { 7 | "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:7a:f3:1f", 8 | "version": 4, 9 | "addr": "10.0.0.14", 10 | "OS-EXT-IPS:type": "fixed" 11 | } 12 | ] 13 | }, 14 | "links": [ 15 | { 16 | "href": "http://ops.my.cloud/compute/v2.1/servers/56046f6d-3184-495b-938b-baa450db970d", 17 | "rel": "self" 18 | }, 19 | { 20 | "href": "http://ops.my.cloud/compute/servers/56046f6d-3184-495b-938b-baa450db970d", 21 | "rel": "bookmark" 22 | } 23 | ], 24 | "image": { 25 | "id": "4ef19958-ee2d-44a7-a100-de0b8afdbc8e", 26 | "links": [ 27 | { 28 | "href": "http://ops.my.cloud/compute/images/4ef19958-ee2d-44a7-a100-de0b8afdbc8e", 29 | "rel": "bookmark" 30 | } 31 | ] 32 | }, 33 | "OS-EXT-STS:vm_state": "active", 34 | "OS-EXT-SRV-ATTR:instance_name": "instance-0000000d", 35 | "OS-SRV-USG:launched_at": "2018-10-29T09:37:05.000000", 36 | "flavor": { 37 | "id": "2", 38 | "links": [ 39 | { 40 | "href": "http://ops.my.cloud/compute/flavors/2", 41 | "rel": "bookmark" 42 | } 43 | ] 44 | }, 45 | "id": "56046f6d-3184-495b-938b-baa450db970d", 46 | "security_groups": [ 47 | { 48 | "name": "drone-agent" 49 | } 50 | ], 51 | "user_id": "898384bb1b5e4d5a9ff816f7ea911943", 52 | "OS-DCF:diskConfig": "MANUAL", 53 | "accessIPv4": "", 54 | "accessIPv6": "", 55 | "progress": 0, 56 | "OS-EXT-STS:power_state": 1, 57 | "OS-EXT-AZ:availability_zone": "nova", 58 | "config_drive": "", 59 | "status": "ACTIVE", 60 | "updated": "2018-10-29T09:37:06Z", 61 | "hostId": "1e678c454d7593d464d1a0c1c15111119ae841d11d3f7ba66f9aaee9", 62 | "OS-EXT-SRV-ATTR:host": "devstack", 63 | "OS-SRV-USG:terminated_at": null, 64 | "key_name": "drone-ci-key", 65 | "OS-EXT-SRV-ATTR:hypervisor_hostname": "devstack", 66 | "name": "agent-RjISb5v1", 67 | "created": "2018-10-29T09:37:01Z", 68 | "tenant_id": "661f707340b0486caf878f9cc2bc1fab", 69 | "os-extended-volumes:volumes_attached": [], 70 | "metadata": { 71 | "owner": "drone-ci", 72 | "name": "agent" 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /drivers/packet/create.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package packet 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "time" 11 | 12 | "github.com/drone/autoscaler" 13 | "github.com/drone/autoscaler/logger" 14 | 15 | "github.com/packethost/packngo" 16 | ) 17 | 18 | func (p *provider) Create(ctx context.Context, opts autoscaler.InstanceCreateOpts) (*autoscaler.Instance, error) { 19 | p.init.Do(func() { 20 | p.setup(ctx) 21 | }) 22 | 23 | buf := new(bytes.Buffer) 24 | err := p.userdata.Execute(buf, &opts) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | logger := logger.FromContext(ctx). 30 | WithField("project", p.project). 31 | WithField("facility", p.facility). 32 | WithField("billing", p.billing). 33 | WithField("plan", p.plan). 34 | WithField("os", p.os). 35 | WithField("hostname", p.hostname) 36 | 37 | cr := &packngo.DeviceCreateRequest{ 38 | HostName: p.hostname, 39 | Facility: p.facility, 40 | Plan: p.plan, 41 | OS: p.os, 42 | ProjectID: p.project, 43 | BillingCycle: p.billing, 44 | UserData: buf.String(), 45 | } 46 | 47 | logger.Debugln("instance create") 48 | 49 | d, _, err := p.client.Devices.Create(cr) 50 | if err != nil { 51 | logger.WithError(err). 52 | Errorln("cannot create instance") 53 | return nil, err 54 | } 55 | 56 | instance := &autoscaler.Instance{ 57 | Provider: autoscaler.ProviderPacket, 58 | ID: d.ID, 59 | Name: opts.Name, 60 | Image: d.OS.Slug, 61 | Region: d.Facility.Code, 62 | Size: d.Plan.Slug, 63 | } 64 | 65 | // poll the packet endpoint for server updates 66 | // and exit when a network address is allocated. 67 | interval := time.Duration(0) 68 | poller: 69 | for { 70 | select { 71 | case <-ctx.Done(): 72 | logger.WithField("name", instance.Name). 73 | Debugln("cannot ascertain network") 74 | 75 | return instance, ctx.Err() 76 | case <-time.After(interval): 77 | interval = time.Minute 78 | 79 | logger.WithField("name", instance.Name). 80 | Debugln("find instance network") 81 | 82 | d, _, err := p.client.Devices.Get(d.ID) 83 | if err != nil { 84 | logger.WithError(err). 85 | Errorln("cannot find instance") 86 | return instance, err 87 | } 88 | 89 | if d.State == "active" { 90 | for _, ip := range d.Network { 91 | if ip.Public && ip.AddressFamily == 4 { 92 | instance.Address = ip.Address 93 | } 94 | } 95 | 96 | if instance.Address != "" { 97 | break poller 98 | } 99 | } 100 | } 101 | } 102 | 103 | logger. 104 | WithField("name", instance.Name). 105 | WithField("ip", instance.Address). 106 | Debugln("instance network ready") 107 | 108 | return instance, nil 109 | } 110 | -------------------------------------------------------------------------------- /drivers/packet/destroy.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package packet 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/drone/autoscaler" 11 | ) 12 | 13 | func (p *provider) Destroy(ctx context.Context, instance *autoscaler.Instance) error { 14 | _, err := p.client.Devices.Delete(instance.ID) 15 | return err 16 | } 17 | -------------------------------------------------------------------------------- /drivers/packet/destroy_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package packet 6 | 7 | import ( 8 | "context" 9 | "reflect" 10 | "testing" 11 | 12 | "github.com/drone/autoscaler" 13 | "github.com/h2non/gock" 14 | "github.com/packethost/packngo" 15 | ) 16 | 17 | func TestDestroyError(t *testing.T) { 18 | defer gock.Off() 19 | 20 | gock.New(baseURL). 21 | Delete(getDevice + "/" + instanceID). 22 | Reply(400) 23 | 24 | err := prov.Destroy(context.Background(), &autoscaler.Instance{ID: instanceID}) 25 | 26 | if err == nil { 27 | t.Errorf("Expect error when deleting a device") 28 | } else if _, ok := err.(*packngo.ErrorResponse); !ok { 29 | t.Errorf("expected: %s , got: %s ", reflect.TypeOf(&packngo.ErrorResponse{}), reflect.TypeOf(err)) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /drivers/packet/option.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package packet 6 | 7 | import ( 8 | "io/ioutil" 9 | 10 | "github.com/drone/autoscaler/drivers/internal/userdata" 11 | ) 12 | 13 | // Option configures a Digital Ocean provider option. 14 | type Option func(*provider) 15 | 16 | // WithAPIKey returns an option to set the api key. 17 | func WithAPIKey(apikey string) Option { 18 | return func(p *provider) { 19 | p.apikey = apikey 20 | } 21 | } 22 | 23 | // WithFacility returns an option to set the target facility. 24 | func WithFacility(facility string) Option { 25 | return func(p *provider) { 26 | p.facility = facility 27 | } 28 | } 29 | 30 | // WithPlan returns an option to set the plan. 31 | func WithPlan(plan string) Option { 32 | return func(p *provider) { 33 | p.plan = plan 34 | } 35 | } 36 | 37 | // WithOS returns an option to set the operating system. 38 | func WithOS(os string) Option { 39 | return func(p *provider) { 40 | p.os = os 41 | } 42 | } 43 | 44 | // WithProject returns an option to set the project id. 45 | func WithProject(project string) Option { 46 | return func(p *provider) { 47 | p.project = project 48 | } 49 | } 50 | 51 | // WithSSHKey returns an option to set the ssh key. 52 | func WithSSHKey(sshkey string) Option { 53 | return func(p *provider) { 54 | p.sshkey = sshkey 55 | } 56 | } 57 | 58 | // WithHostname returns an option to set the hostname 59 | func WithHostname(hostname string) Option { 60 | return func(p *provider) { 61 | if hostname != "" { 62 | p.hostname = hostname 63 | } 64 | } 65 | } 66 | 67 | // WithTags returns an option to set the image. 68 | func WithTags(tags ...string) Option { 69 | return func(p *provider) { 70 | p.tags = tags 71 | } 72 | } 73 | 74 | // WithUserData returns an option to set the cloud-init 75 | // template from text. 76 | func WithUserData(text string) Option { 77 | return func(p *provider) { 78 | if text != "" { 79 | p.userdata = userdata.Parse(text) 80 | } 81 | } 82 | } 83 | 84 | // WithUserDataFile returns an option to set the cloud-init 85 | // template from file. 86 | func WithUserDataFile(filepath string) Option { 87 | return func(p *provider) { 88 | if filepath != "" { 89 | b, err := ioutil.ReadFile(filepath) 90 | if err != nil { 91 | panic(err) 92 | } 93 | p.userdata = userdata.Parse(string(b)) 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /drivers/packet/option_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package packet 6 | 7 | import "testing" 8 | 9 | func TestOptions(t *testing.T) { 10 | p := New( 11 | WithAPIKey("my_authentication_token"), 12 | WithFacility("sjc1"), 13 | WithOS("ubuntu_16_10"), 14 | WithPlan("baremetal_1"), 15 | WithProject("my_project"), 16 | WithSSHKey("id_rsa"), 17 | WithHostname("agent-abcdef"), 18 | WithTags("drone", "agent"), 19 | ).(*provider) 20 | 21 | if got, want := p.apikey, "my_authentication_token"; got != want { 22 | t.Errorf("Want api key %q, got %q", want, got) 23 | } 24 | if got, want := p.facility, "sjc1"; got != want { 25 | t.Errorf("Want facility %q, got %q", want, got) 26 | } 27 | if got, want := p.os, "ubuntu_16_10"; got != want { 28 | t.Errorf("Want os %q, got %q", want, got) 29 | } 30 | if got, want := p.plan, "baremetal_1"; got != want { 31 | t.Errorf("Want plan %q, got %q", want, got) 32 | } 33 | if got, want := p.project, "my_project"; got != want { 34 | t.Errorf("Want project %q, got %q", want, got) 35 | } 36 | if got, want := p.sshkey, "id_rsa"; got != want { 37 | t.Errorf("Want sshkey %q, got %q", want, got) 38 | } 39 | if got, want := p.hostname, "agent-abcdef"; got != want { 40 | t.Errorf("Want hostname %q, got %q", want, got) 41 | } 42 | if got, want := len(p.tags), 2; got != want { 43 | t.Errorf("Want %d tags, got %d", want, got) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /drivers/packet/provider.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package packet 6 | 7 | import ( 8 | "sync" 9 | "text/template" 10 | 11 | "github.com/drone/autoscaler" 12 | "github.com/drone/autoscaler/drivers/internal/userdata" 13 | "github.com/packethost/packngo" 14 | ) 15 | 16 | const consumerToken = "24e70949af5ecd17fe8e867b335fc88e7de8bd4ad617c0403d8769a376ddea72" 17 | 18 | // provider implements a Packet.net provider. 19 | type provider struct { 20 | init sync.Once 21 | 22 | apikey string 23 | billing string 24 | facility string 25 | os string 26 | plan string 27 | project string 28 | sshkey string 29 | hostname string 30 | tags []string 31 | userdata *template.Template 32 | 33 | client *packngo.Client 34 | } 35 | 36 | // New returns a new Packet.net provider. 37 | func New(opts ...Option) autoscaler.Provider { 38 | p := new(provider) 39 | for _, opt := range opts { 40 | opt(p) 41 | } 42 | if p.facility == "" { 43 | p.facility = "ewr1" 44 | } 45 | if p.os == "" { 46 | p.os = "ubuntu_18_04" 47 | } 48 | if p.plan == "" { 49 | p.plan = "baremetal_0" 50 | } 51 | if p.billing == "" { 52 | p.billing = "hourly" 53 | } 54 | if p.userdata == nil { 55 | p.userdata = userdata.T 56 | } 57 | if p.client == nil { 58 | p.client = packngo.NewClient( 59 | consumerToken, p.apikey, nil) 60 | } 61 | return p 62 | } 63 | -------------------------------------------------------------------------------- /drivers/packet/provider_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package packet 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/drone/autoscaler/drivers/internal/userdata" 11 | ) 12 | 13 | func TestDefaults(t *testing.T) { 14 | p := New().(*provider) 15 | 16 | if got, want := p.plan, "baremetal_0"; got != want { 17 | t.Errorf("Want plan %q, got %q", want, got) 18 | } 19 | if got, want := p.facility, "ewr1"; got != want { 20 | t.Errorf("Want region %q, got %q", want, got) 21 | } 22 | if got, want := p.billing, "hourly"; got != want { 23 | t.Errorf("Want billing %q, got %q", want, got) 24 | } 25 | if got, want := p.os, "ubuntu_18_04"; got != want { 26 | t.Errorf("Want os %q, got %q", want, got) 27 | } 28 | if p.userdata != userdata.T { 29 | t.Errorf("Want default userdata template") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /drivers/packet/setup.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package packet 6 | 7 | import ( 8 | "context" 9 | "errors" 10 | 11 | "github.com/drone/autoscaler/logger" 12 | 13 | "github.com/packethost/packngo" 14 | "golang.org/x/sync/errgroup" 15 | ) 16 | 17 | func (p *provider) setup(ctx context.Context) error { 18 | var g errgroup.Group 19 | if p.sshkey == "" { 20 | g.Go(func() error { 21 | return p.setupKeypair(ctx) 22 | }) 23 | } 24 | return g.Wait() 25 | } 26 | 27 | // helper funciton to ascertain the ID of an existing SSH 28 | // key to use when provisioning instances. This is only 29 | // necessary when the user has not provided the ID. 30 | func (p *provider) setupKeypair(ctx context.Context) error { 31 | logger := logger.FromContext(ctx) 32 | logger.Debugln("finding default ssh key") 33 | 34 | keys, _, err := p.client.SSHKeys.List() 35 | if err != nil { 36 | return err 37 | } 38 | 39 | index := map[string]packngo.SSHKey{} 40 | for _, key := range keys { 41 | index[key.Label] = key 42 | } 43 | 44 | // if the account has multiple keys configured we will 45 | // attempt to use an existing key based on naming convention. 46 | for _, name := range []string{"drone", "id_rsa_drone"} { 47 | key, ok := index[name] 48 | if !ok { 49 | continue 50 | } 51 | p.sshkey = key.Key 52 | 53 | logger. 54 | WithField("id", key.ID). 55 | WithField("label", key.Key). 56 | WithField("fingerprint", key.FingerPrint). 57 | Debugln("using default ssh key") 58 | return nil 59 | } 60 | 61 | // if there were no matches but the account has at least 62 | // one keypair already created we will select the first 63 | // in the list. 64 | if len(keys) > 0 { 65 | key := keys[0] 66 | p.sshkey = key.ID 67 | 68 | logger. 69 | WithField("id", key.ID). 70 | WithField("label", key.Label). 71 | WithField("fingerprint", key.FingerPrint). 72 | Debugln("using default ssh key") 73 | return nil 74 | } 75 | 76 | return errors.New("No matching keys") 77 | } 78 | -------------------------------------------------------------------------------- /drivers/packet/setup_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package packet 6 | 7 | import ( 8 | "context" 9 | "os" 10 | "testing" 11 | 12 | "github.com/h2non/gock" 13 | ) 14 | 15 | const ( 16 | baseURL = "https://api.packet.net/" 17 | getDevice = "/devices" 18 | getSSH = "/ssh-keys" 19 | projectID = "x" 20 | createDevice = "/projects/" + projectID + getDevice 21 | instanceID = "92b0facf-189e-4bbf-81a8-bc56c0c4dc88" 22 | apiKey = "apiKey" 23 | sshKey = "sshKey" 24 | hostname = "hostname" 25 | tag = "tag" 26 | ) 27 | 28 | var ( 29 | prov *provider 30 | respCreate string 31 | respCreateInactive string 32 | respSSHKeys string 33 | ) 34 | 35 | func TestMain(m *testing.M) { 36 | prov = New( 37 | WithProject(projectID), 38 | WithTags(tag), 39 | WithHostname(hostname), 40 | WithAPIKey(apiKey), 41 | ).(*provider) 42 | 43 | respCreate = ` 44 | { 45 | "id": "` + instanceID + `", 46 | "state": "active", 47 | "tags": ["` + tag + `"], 48 | "hostname": "` + hostname + `", 49 | "operating_system": { 50 | "slug": "` + prov.os + `" 51 | }, 52 | "facility": { 53 | "code": "ewr1" 54 | }, 55 | "ip_addresses": [ 56 | { 57 | "address_family": 4, 58 | "public": true, 59 | "address": "147.75.77.155" 60 | } 61 | ], 62 | "plan": { 63 | "slug": "baremetal_0" 64 | } 65 | } 66 | ` 67 | respCreateInactive = ` 68 | { 69 | "id": "` + instanceID + `", 70 | "state": "inactive", 71 | "tags": ["` + tag + `"], 72 | "hostname": "` + hostname + `", 73 | "operating_system": { 74 | "slug": "` + prov.os + `" 75 | }, 76 | "facility": { 77 | "code": "ewr1" 78 | }, 79 | "ip_addresses": [ 80 | { 81 | "address_family": 4, 82 | "public": true, 83 | "address": "147.75.77.155" 84 | } 85 | ], 86 | "plan": { 87 | "slug": "baremetal_0" 88 | } 89 | } 90 | ` 91 | 92 | respSSHKeys = ` 93 | { 94 | "ssh_keys": [ 95 | { 96 | "id": "` + sshKey + `", 97 | "label": "label", 98 | "key": "key", 99 | "fingerprint": "fingerprint" 100 | } 101 | ] 102 | } 103 | ` 104 | os.Exit(m.Run()) 105 | 106 | } 107 | 108 | func TestSetup_Keypair(t *testing.T) { 109 | defer gock.Off() 110 | 111 | gock.New(baseURL). 112 | MatchHeader("X-Auth-Token", apiKey). 113 | Get(getSSH). 114 | Reply(200). 115 | BodyString(respSSHKeys) 116 | 117 | if err := prov.setupKeypair(context.Background()); err != nil { 118 | t.Error(err) 119 | t.FailNow() 120 | } 121 | 122 | if prov.sshkey != sshKey { 123 | t.Errorf("expected: %s, got: %s", sshKey, prov.sshkey) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /drivers/scaleway/create_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package scaleway 6 | -------------------------------------------------------------------------------- /drivers/scaleway/destroy.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package scaleway 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/drone/autoscaler" 11 | "github.com/drone/autoscaler/logger" 12 | 13 | "github.com/scaleway/scaleway-sdk-go/api/instance/v1" 14 | "github.com/scaleway/scaleway-sdk-go/scw" 15 | ) 16 | 17 | func (p *provider) Destroy(ctx context.Context, inst *autoscaler.Instance) error { 18 | p.init.Do(func() { 19 | p.setup(ctx) 20 | }) 21 | 22 | logger := logger.FromContext(ctx). 23 | WithField("datacenter", inst.Region). 24 | WithField("image", inst.Image). 25 | WithField("size", inst.Size). 26 | WithField("name", inst.Name) 27 | 28 | api := instance.NewAPI(p.client) 29 | 30 | srvReq := &instance.GetServerRequest{ 31 | ServerID: inst.ID, 32 | } 33 | _, err := api.GetServer(srvReq, scw.WithContext(ctx)) 34 | if err != nil { 35 | scwErr, ok := err.(*scw.ResponseError) 36 | if ok && scwErr.StatusCode == 404 { 37 | return autoscaler.ErrInstanceNotFound 38 | } else { 39 | logger.WithError(err). 40 | Errorln("cannot get server") 41 | return err 42 | } 43 | } 44 | 45 | // Issue "terminate" action, instead of DeleteServer, as terminate 46 | // cleans up volumes and IP addresses attached, too 47 | req := &instance.ServerActionRequest{ 48 | ServerID: inst.ID, 49 | Action: instance.ServerActionTerminate, 50 | } 51 | 52 | logger.Debugln("terminating server") 53 | 54 | _, err = api.ServerAction(req, scw.WithContext(ctx)) 55 | 56 | if err != nil { 57 | logger.WithError(err). 58 | Errorln("terminating server failed") 59 | return err 60 | } 61 | 62 | logger.Infoln("server terminated") 63 | 64 | return err 65 | } 66 | -------------------------------------------------------------------------------- /drivers/scaleway/destroy_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package scaleway 6 | -------------------------------------------------------------------------------- /drivers/scaleway/option.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package scaleway 6 | 7 | import ( 8 | "io/ioutil" 9 | 10 | "github.com/drone/autoscaler/drivers/internal/userdata" 11 | "github.com/scaleway/scaleway-sdk-go/scw" 12 | ) 13 | 14 | // Option configures a Scaleway provider option. 15 | type Option func(*provider) error 16 | 17 | // WithAccessKey returns an option to set the user access key 18 | func WithAccessKey(accessKey string) Option { 19 | return func(p *provider) error { 20 | p.accessKey = accessKey 21 | return nil 22 | } 23 | } 24 | 25 | // WithSecretKey returns an option to set the user secret key 26 | func WithSecretKey(secretKey string) Option { 27 | return func(p *provider) error { 28 | p.secretKey = secretKey 29 | return nil 30 | } 31 | } 32 | 33 | // WithOrganisationID returns an option to set the user organisation id 34 | func WithOrganisationID(orgId string) Option { 35 | return func(p *provider) error { 36 | p.orgID = orgId 37 | return nil 38 | } 39 | } 40 | 41 | // WithImage returns an option to set the image. 42 | func WithImage(image string) Option { 43 | return func(p *provider) error { 44 | p.image = image 45 | return nil 46 | } 47 | } 48 | 49 | // WithDynamicIP returns an option to enable a dynamic IP. 50 | func WithDynamicIP(dynamicIP bool) Option { 51 | return func(p *provider) error { 52 | p.dynamicIP = dynamicIP 53 | return nil 54 | } 55 | } 56 | 57 | // WithTags returns an option to set the server tags. 58 | func WithTags(tags ...string) Option { 59 | return func(p *provider) error { 60 | p.tags = tags 61 | return nil 62 | } 63 | } 64 | 65 | // WithZone returns an option to set the target zone. 66 | func WithZone(name string) Option { 67 | return func(p *provider) error { 68 | if name == "" { 69 | return nil 70 | } 71 | zone, err := scw.ParseZone(name) 72 | if err != nil { 73 | return err 74 | } 75 | p.zone = zone 76 | return nil 77 | } 78 | } 79 | 80 | // WithSize returns an option to set the instance size. 81 | func WithSize(size string) Option { 82 | return func(p *provider) error { 83 | p.size = size 84 | return nil 85 | } 86 | } 87 | 88 | // WithUserData returns an option to set the cloud-init 89 | // template from text. 90 | func WithUserData(text string) Option { 91 | return func(p *provider) error { 92 | if text != "" { 93 | p.userdata = userdata.Parse(text) 94 | } 95 | return nil 96 | } 97 | } 98 | 99 | // WithUserDataFile returns an option to set the cloud-init 100 | // template from file. 101 | func WithUserDataFile(filepath string) Option { 102 | return func(p *provider) error { 103 | if filepath != "" { 104 | b, err := ioutil.ReadFile(filepath) 105 | if err != nil { 106 | return err 107 | } 108 | p.userdata = userdata.Parse(string(b)) 109 | } 110 | return nil 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /drivers/scaleway/option_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package scaleway 6 | -------------------------------------------------------------------------------- /drivers/scaleway/provider.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package scaleway 6 | 7 | import ( 8 | "sync" 9 | "text/template" 10 | 11 | "github.com/drone/autoscaler/drivers/internal/userdata" 12 | "github.com/scaleway/scaleway-sdk-go/scw" 13 | 14 | "github.com/drone/autoscaler" 15 | ) 16 | 17 | // provider implements a Scaleway provider. 18 | type provider struct { 19 | init sync.Once 20 | 21 | accessKey string 22 | secretKey string 23 | orgID string 24 | securityGroup string 25 | dynamicIP bool 26 | zone scw.Zone // fr-par-1 or nl-ams-1 27 | size string 28 | image string 29 | tags []string 30 | userdata *template.Template 31 | 32 | client *scw.Client 33 | } 34 | 35 | // New returns a new Scaleway provider. 36 | func New(opts ...Option) (autoscaler.Provider, error) { 37 | p := new(provider) 38 | for _, opt := range opts { 39 | err := opt(p) 40 | if err != nil { 41 | return nil, err 42 | } 43 | } 44 | 45 | if p.zone == "" { 46 | p.zone = scw.ZoneFrPar1 47 | } 48 | if p.size == "" { 49 | p.size = "dev1-l" 50 | } 51 | if p.image == "" { 52 | p.image = "ubuntu-bionic" 53 | } 54 | if p.userdata == nil { 55 | p.userdata = userdata.T 56 | } 57 | 58 | return p, nil 59 | } 60 | -------------------------------------------------------------------------------- /drivers/scaleway/provider_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package scaleway 6 | -------------------------------------------------------------------------------- /drivers/scaleway/setup.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package scaleway 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/scaleway/scaleway-sdk-go/scw" 11 | "golang.org/x/sync/errgroup" 12 | ) 13 | 14 | func (p *provider) setup(ctx context.Context) error { 15 | var g errgroup.Group 16 | if p.client == nil { 17 | g.Go(func() error { 18 | return p.newClient(ctx) 19 | }) 20 | } 21 | return g.Wait() 22 | } 23 | 24 | func (p *provider) newClient(ctx context.Context) error { 25 | client, err := scw.NewClient( 26 | scw.WithDefaultOrganizationID(p.orgID), 27 | scw.WithAuth(p.accessKey, p.secretKey), 28 | scw.WithDefaultZone(p.zone), 29 | ) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | p.client = client 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /engine.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package autoscaler 6 | 7 | import "context" 8 | 9 | // An Engine is responsible for running the scaling 10 | // alogirthm to provision and shutdown instances according 11 | // to build volume. 12 | type Engine interface { 13 | // Start starts the Engine. The context can be used 14 | // to cancel a running engine. 15 | Start(context.Context) 16 | // Pause pauses the Engine. 17 | Pause() 18 | // Paused returns true if th Engine is paused. 19 | Paused() bool 20 | // Resume resumes the Engine if paused. 21 | Resume() 22 | } 23 | -------------------------------------------------------------------------------- /engine/calc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package engine 6 | 7 | import "math" 8 | 9 | // helper function returns the absolute value of x. 10 | func abs(x int) int { 11 | if x < 0 { 12 | x = x * -1 13 | } 14 | return x 15 | } 16 | 17 | // helper function returns the larger of x or y. 18 | func max(x, y int) int { 19 | if x > y { 20 | return x 21 | } 22 | return y 23 | } 24 | 25 | // helper function calculates the different between the existing 26 | // server count and required server count to handle queue volume. 27 | func serverDiff(pending, available, concurrency int) int { 28 | return int( 29 | math.Ceil( 30 | float64(pending-available) / 31 | float64(concurrency), 32 | ), 33 | ) 34 | } 35 | 36 | // helper function adjusts the number of servers to provision 37 | // to ensure it does not exceed the max server count. 38 | func serverCeil(count, additions, ceiling int) int { 39 | if count+additions >= ceiling { 40 | additions = ceiling - count 41 | } 42 | return additions 43 | } 44 | 45 | // helper function adjusts the number of servers to provision 46 | // to ensure the minimum server count is maintained. 47 | func serverFloor(count, deletions, floor int) int { 48 | if deletions == 0 { 49 | return 0 50 | } 51 | if floor > count-deletions { 52 | deletions = count - floor 53 | } 54 | return max(deletions, 0) 55 | } 56 | -------------------------------------------------------------------------------- /engine/certs/cert_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package certs 6 | 7 | import ( 8 | "testing" 9 | ) 10 | 11 | func TestGenerate(t *testing.T) { 12 | ca, err := GenerateCA() 13 | if err != nil { 14 | t.Error(err) 15 | } 16 | 17 | _, err = GenerateCert("company.com", ca) 18 | if err != nil { 19 | t.Error(err) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /engine/docker.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package engine 6 | 7 | import ( 8 | "crypto/tls" 9 | "crypto/x509" 10 | "fmt" 11 | "io" 12 | "net/http" 13 | 14 | docker "github.com/docker/docker/client" 15 | "github.com/drone/autoscaler" 16 | ) 17 | 18 | // clientFunc defines a builder funciton used to build and return 19 | // the docker client from a Server. This is primarily used for 20 | // mock unit testing. 21 | type clientFunc func(*autoscaler.Server) (docker.APIClient, io.Closer, error) 22 | 23 | // newDockerClient returns a new Docker client configured for the 24 | // Server host and certificate chain. 25 | func newDockerClient(server *autoscaler.Server) (docker.APIClient, io.Closer, error) { 26 | tlsCert, err := tls.X509KeyPair(server.TLSCert, server.TLSKey) 27 | if err != nil { 28 | return nil, nil, err 29 | } 30 | tlsConfig := &tls.Config{ 31 | ServerName: server.Name, 32 | Certificates: []tls.Certificate{tlsCert}, 33 | } 34 | tlsConfig.RootCAs = x509.NewCertPool() 35 | tlsConfig.RootCAs.AppendCertsFromPEM(server.CACert) 36 | client := &http.Client{ 37 | Transport: &http.Transport{ 38 | TLSClientConfig: tlsConfig, 39 | }, 40 | } 41 | dockerClient, err := docker.NewClientWithOpts( 42 | docker.WithAPIVersionNegotiation(), 43 | docker.WithHTTPClient(client), 44 | docker.WithHost(fmt.Sprintf("https://%s:2376", server.Address)), 45 | ) 46 | return dockerClient, dockerClient, err 47 | } 48 | -------------------------------------------------------------------------------- /engine/install_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package engine 6 | 7 | import ( 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestSplitVolumeParts(t *testing.T) { 13 | testdata := []struct { 14 | from string 15 | to []string 16 | success bool 17 | }{ 18 | { 19 | from: `Z::Z::rw`, 20 | to: []string{`Z:`, `Z:`, `rw`}, 21 | success: true, 22 | }, 23 | { 24 | from: `Z:\:Z:\:rw`, 25 | to: []string{`Z:\`, `Z:\`, `rw`}, 26 | success: true, 27 | }, 28 | { 29 | from: `Z:\git\refs:Z:\git\refs:rw`, 30 | to: []string{`Z:\git\refs`, `Z:\git\refs`, `rw`}, 31 | success: true, 32 | }, 33 | { 34 | from: `Z:\git\refs:Z:\git\refs`, 35 | to: []string{`Z:\git\refs`, `Z:\git\refs`}, 36 | success: true, 37 | }, 38 | { 39 | from: `Z:/:Z:/:rw`, 40 | to: []string{`Z:/`, `Z:/`, `rw`}, 41 | success: true, 42 | }, 43 | { 44 | from: `Z:/git/refs:Z:/git/refs:rw`, 45 | to: []string{`Z:/git/refs`, `Z:/git/refs`, `rw`}, 46 | success: true, 47 | }, 48 | { 49 | from: `Z:/git/refs:Z:/git/refs`, 50 | to: []string{`Z:/git/refs`, `Z:/git/refs`}, 51 | success: true, 52 | }, 53 | { 54 | from: `/test:/test`, 55 | to: []string{`/test`, `/test`}, 56 | success: true, 57 | }, 58 | { 59 | from: `test:/test`, 60 | to: []string{`test`, `/test`}, 61 | success: true, 62 | }, 63 | { 64 | from: `test:test`, 65 | to: []string{`test`, `test`}, 66 | success: true, 67 | }, 68 | } 69 | for _, test := range testdata { 70 | results, err := splitVolumeParts(test.from) 71 | if test.success == (err != nil) { 72 | } else { 73 | if reflect.DeepEqual(results, test.to) != test.success { 74 | t.Errorf("Expect %q matches %q is %v", test.from, results, test.to) 75 | } 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /engine/pinger.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package engine 6 | 7 | import ( 8 | "context" 9 | "sync" 10 | "time" 11 | 12 | "github.com/drone/autoscaler" 13 | "github.com/drone/autoscaler/logger" 14 | ) 15 | 16 | type pinger struct { 17 | wg sync.WaitGroup 18 | 19 | servers autoscaler.ServerStore 20 | client clientFunc 21 | interval time.Duration 22 | enabled bool 23 | } 24 | 25 | func (p *pinger) Ping(ctx context.Context) error { 26 | // this is a feature flag that can be used to enable 27 | // experimental pinging and detection of zombie instances. 28 | if !p.enabled { 29 | return nil 30 | } 31 | 32 | servers, err := p.servers.ListState(ctx, autoscaler.StateRunning) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | for _, server := range servers { 38 | p.wg.Add(1) 39 | go func(server *autoscaler.Server) { 40 | p.ping(ctx, server) 41 | p.wg.Done() 42 | }(server) 43 | } 44 | return nil 45 | } 46 | 47 | func (p *pinger) ping(ctx context.Context, server *autoscaler.Server) error { 48 | logger := logger.FromContext(ctx). 49 | WithField("ip", server.Address). 50 | WithField("name", server.Name) 51 | 52 | client, closer, err := p.client(server) 53 | if closer != nil { 54 | defer closer.Close() 55 | } 56 | if err != nil { 57 | logger.WithError(err). 58 | Errorln("cannot create docker client") 59 | return nil 60 | } 61 | 62 | // the system will attempt to ping the server a maximum of 63 | // five times, with a 1 minute timeout for each ping. If the 64 | // server cannot be reached, it will be placed in an error 65 | // state. 66 | 67 | for i := 0; i < 5; i++ { 68 | logger.Debugln("pinging the server") 69 | 70 | timeout, cancel := context.WithTimeout(ctx, time.Minute) 71 | _, err := client.Ping(timeout) 72 | cancel() 73 | 74 | // If the global context is in an error state we 75 | // should assume this is because the program is 76 | // being gracefully terminated. This could cause 77 | // false positive ping errors, so we ignore and 78 | // exit the routine. 79 | if ctx.Err() != nil { 80 | return nil 81 | } 82 | 83 | if err == nil { 84 | logger.WithField("state", "healthy"). 85 | Debugln("server ping successful") 86 | return nil 87 | } else { 88 | logger.WithError(err). 89 | Warnln("server ping unsuccessful") 90 | } 91 | } 92 | 93 | server, err = p.servers.Find(ctx, server.Name) 94 | if err != nil { 95 | // if the server no longer exists in the database 96 | // it is possible it was mutated by another goroutine. 97 | return err 98 | } 99 | 100 | if server.State != autoscaler.StateRunning { 101 | // if the server was mutated by another goroutine 102 | // we should exit without making any changes. 103 | return nil 104 | } 105 | 106 | logger.WithField("state", "unhealthy"). 107 | Debugln("failed to reach server") 108 | 109 | server.Error = "Failed to ping the server" 110 | server.Stopped = time.Now().Unix() 111 | server.State = autoscaler.StateError 112 | err = p.servers.Update(ctx, server) 113 | if err != nil { 114 | logger.WithError(err). 115 | WithField("server", server.Name). 116 | WithField("state", "error"). 117 | Errorln("failed to update server state") 118 | return err 119 | } 120 | 121 | return nil 122 | } 123 | -------------------------------------------------------------------------------- /engine/pinger_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package engine 6 | -------------------------------------------------------------------------------- /engine/reaper_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package engine 6 | -------------------------------------------------------------------------------- /engine/sort.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package engine 6 | 7 | import "github.com/drone/autoscaler" 8 | 9 | // byCreated sorts the server list by created date. 10 | type byCreated []*autoscaler.Server 11 | 12 | func (a byCreated) Len() int { return len(a) } 13 | func (a byCreated) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 14 | func (a byCreated) Less(i, j int) bool { return a[i].Created < a[j].Created } 15 | -------------------------------------------------------------------------------- /engine/sort_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package engine 6 | 7 | import ( 8 | "sort" 9 | "testing" 10 | 11 | "github.com/drone/autoscaler" 12 | ) 13 | 14 | func TestSortByCreated(t *testing.T) { 15 | servers := []*autoscaler.Server{ 16 | {Created: 4, Name: "fourth"}, 17 | {Created: 2, Name: "second"}, 18 | {Created: 3, Name: "third"}, 19 | {Created: 5, Name: "fifth"}, 20 | {Created: 1, Name: "first"}, 21 | } 22 | 23 | sort.Sort(byCreated(servers)) 24 | 25 | for i, server := range servers { 26 | if server.Created != int64(i+1) { 27 | t.Errorf("Invalid sort order %d for %q", i, server.Name) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /logger/context.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Drone.IO Inc. All rights reserved. 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package logger 6 | 7 | import ( 8 | "context" 9 | "net/http" 10 | ) 11 | 12 | type loggerKey struct{} 13 | 14 | // WithContext returns a new context with the provided logger. 15 | // Use in combination with logger.WithField for great effect. 16 | func WithContext(ctx context.Context, logger Logger) context.Context { 17 | return context.WithValue(ctx, loggerKey{}, logger) 18 | } 19 | 20 | // FromContext retrieves the current logger from the context. 21 | func FromContext(ctx context.Context) Logger { 22 | logger := ctx.Value(loggerKey{}) 23 | if logger == nil { 24 | return Default 25 | } 26 | return logger.(Logger) 27 | } 28 | 29 | // FromRequest retrieves the current logger from the request. If no 30 | // logger is available, the default logger is returned. 31 | func FromRequest(r *http.Request) Logger { 32 | return FromContext(r.Context()) 33 | } 34 | -------------------------------------------------------------------------------- /logger/context_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Drone.IO Inc. All rights reserved. 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package logger 6 | 7 | import ( 8 | "context" 9 | "net/http" 10 | "testing" 11 | ) 12 | 13 | func TestContext(t *testing.T) { 14 | entry := Discard() 15 | 16 | ctx := WithContext(context.Background(), entry) 17 | got := FromContext(ctx) 18 | 19 | if got != entry { 20 | t.Errorf("Expected Logger from context") 21 | } 22 | } 23 | 24 | func TestEmptyContext(t *testing.T) { 25 | got := FromContext(context.Background()) 26 | if got == nil { 27 | t.Errorf("Expected Logger from context") 28 | } 29 | if _, ok := got.(*discard); !ok { 30 | t.Errorf("Expected discard Logger from context") 31 | } 32 | } 33 | 34 | func TestRequest(t *testing.T) { 35 | entry := Discard() 36 | 37 | ctx := WithContext(context.Background(), entry) 38 | req := new(http.Request) 39 | req = req.WithContext(ctx) 40 | 41 | got := FromRequest(req) 42 | 43 | if got != entry { 44 | t.Errorf("Expected Logger from http.Request") 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /logger/logger.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Drone.IO Inc. All rights reserved. 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | // Package logger defines interfaces that logger drivers 6 | // implement to log messages. 7 | package logger 8 | 9 | // A Logger represents an active logging object that generates 10 | // lines of output to an io.Writer. 11 | type Logger interface { 12 | Debug(args ...interface{}) 13 | Debugf(format string, args ...interface{}) 14 | Debugln(args ...interface{}) 15 | 16 | Error(args ...interface{}) 17 | Errorf(format string, args ...interface{}) 18 | Errorln(args ...interface{}) 19 | 20 | Info(args ...interface{}) 21 | Infof(format string, args ...interface{}) 22 | Infoln(args ...interface{}) 23 | 24 | Trace(args ...interface{}) 25 | Tracef(format string, args ...interface{}) 26 | Traceln(args ...interface{}) 27 | 28 | Warn(args ...interface{}) 29 | Warnf(format string, args ...interface{}) 30 | Warnln(args ...interface{}) 31 | 32 | WithError(error) Logger 33 | WithField(string, interface{}) Logger 34 | } 35 | 36 | // Default returns the default logger. 37 | var Default = Discard() 38 | 39 | // Discard returns a no-op logger 40 | func Discard() Logger { 41 | return &discard{} 42 | } 43 | 44 | type discard struct{} 45 | 46 | func (*discard) Debug(args ...interface{}) {} 47 | func (*discard) Debugf(format string, args ...interface{}) {} 48 | func (*discard) Debugln(args ...interface{}) {} 49 | func (*discard) Error(args ...interface{}) {} 50 | func (*discard) Errorf(format string, args ...interface{}) {} 51 | func (*discard) Errorln(args ...interface{}) {} 52 | func (*discard) Info(args ...interface{}) {} 53 | func (*discard) Infof(format string, args ...interface{}) {} 54 | func (*discard) Infoln(args ...interface{}) {} 55 | func (*discard) Trace(args ...interface{}) {} 56 | func (*discard) Tracef(format string, args ...interface{}) {} 57 | func (*discard) Traceln(args ...interface{}) {} 58 | func (*discard) Warn(args ...interface{}) {} 59 | func (*discard) Warnf(format string, args ...interface{}) {} 60 | func (*discard) Warnln(args ...interface{}) {} 61 | func (d *discard) WithError(error) Logger { return d } 62 | func (d *discard) WithField(string, interface{}) Logger { return d } 63 | -------------------------------------------------------------------------------- /logger/logger_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Drone.IO Inc. All rights reserved. 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package logger 6 | 7 | import "testing" 8 | 9 | func TestWithError(t *testing.T) { 10 | d := &discard{} 11 | if d.WithError(nil) != d { 12 | t.Errorf("Expect WithError to return base logger") 13 | } 14 | } 15 | 16 | func TestWithField(t *testing.T) { 17 | d := &discard{} 18 | if d.WithField("hello", "world") != d { 19 | t.Errorf("Expect WithField to return base logger") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /logger/logrus.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Drone.IO Inc. All rights reserved. 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package logger 6 | 7 | import "github.com/sirupsen/logrus" 8 | 9 | // Logrus returns a Logger that wraps a logrus.Entry. 10 | func Logrus(entry *logrus.Entry) Logger { 11 | return &wrapLogrus{entry} 12 | } 13 | 14 | type wrapLogrus struct { 15 | *logrus.Entry 16 | } 17 | 18 | func (w *wrapLogrus) WithError(err error) Logger { 19 | return &wrapLogrus{w.Entry.WithError(err)} 20 | } 21 | 22 | func (w *wrapLogrus) WithField(key string, value interface{}) Logger { 23 | return &wrapLogrus{w.Entry.WithField(key, value)} 24 | } 25 | -------------------------------------------------------------------------------- /logger/logrus_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Drone.IO Inc. All rights reserved. 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package logger 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | func TestLogrus(t *testing.T) { 14 | logger := Logrus( 15 | logrus.NewEntry( 16 | logrus.StandardLogger(), 17 | ), 18 | ) 19 | if _, ok := logger.(*wrapLogrus); !ok { 20 | t.Errorf("Expect wrapped logrus") 21 | } 22 | if _, ok := logger.WithError(nil).(*wrapLogrus); !ok { 23 | t.Errorf("Expect WithError wraps logrus") 24 | } 25 | if _, ok := logger.WithField("foo", "bar").(*wrapLogrus); !ok { 26 | t.Errorf("Expect WithField logrus") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /logger/request/request.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Drone.IO Inc. All rights reserved. 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package request 6 | 7 | import ( 8 | "net/http" 9 | "time" 10 | 11 | "github.com/drone/autoscaler/logger" 12 | "github.com/go-chi/chi/middleware" 13 | "github.com/sirupsen/logrus" 14 | ) 15 | 16 | // Logger provides logrus middleware. 17 | func Logger(next http.Handler) http.Handler { 18 | fn := func(w http.ResponseWriter, r *http.Request) { 19 | start := time.Now() 20 | rw := middleware.NewWrapResponseWriter(w, r.ProtoMajor) 21 | 22 | fields := logrus.Fields{ 23 | "method": r.Method, 24 | "request": r.RequestURI, 25 | "remote": r.RemoteAddr, 26 | "referer": r.Referer(), 27 | "user-agent": r.UserAgent(), 28 | } 29 | log := logrus.WithFields(fields) 30 | ctx := r.Context() 31 | ctx = logger.WithContext(ctx, logger.Logrus(log)) 32 | next.ServeHTTP(rw, r) 33 | 34 | fields["status"] = rw.Status() 35 | fields["duration"] = time.Since(start) 36 | if id := r.Context().Value(middleware.RequestIDKey); id != nil { 37 | fields["request-id"] = id 38 | } 39 | log.WithFields(fields).Debugln("request completed") 40 | } 41 | return http.HandlerFunc(fn) 42 | } 43 | -------------------------------------------------------------------------------- /metrics/server_capacity.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package metrics 6 | 7 | import ( 8 | "github.com/drone/autoscaler" 9 | "github.com/prometheus/client_golang/prometheus" 10 | ) 11 | 12 | // ServerCapacity provides metrics for server capacity count. 13 | func ServerCapacity(store autoscaler.ServerStore) autoscaler.ServerStore { 14 | prometheus.MustRegister( 15 | prometheus.NewGaugeFunc(prometheus.GaugeOpts{ 16 | Name: "drone_server_capacity", 17 | Help: "Total capacity of active servers.", 18 | }, func() float64 { 19 | var capacity int 20 | servers, _ := store.ListState(noContext, autoscaler.StateRunning) 21 | for _, server := range servers { 22 | capacity += server.Capacity 23 | } 24 | return float64(capacity) 25 | }), 26 | ) 27 | return store 28 | } 29 | -------------------------------------------------------------------------------- /metrics/server_capacity_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package metrics 6 | 7 | import ( 8 | "testing" 9 | "time" 10 | 11 | "github.com/drone/autoscaler" 12 | "github.com/drone/autoscaler/mocks" 13 | "github.com/golang/mock/gomock" 14 | "github.com/prometheus/client_golang/prometheus" 15 | ) 16 | 17 | func TestServerCapacity(t *testing.T) { 18 | controller := gomock.NewController(t) 19 | defer controller.Finish() 20 | 21 | // restore the default prometheus registerer 22 | // when the unit test is complete. 23 | snapshot := prometheus.DefaultRegisterer 24 | defer func() { 25 | prometheus.DefaultRegisterer = snapshot 26 | }() 27 | 28 | // creates a blank registry 29 | registry := prometheus.NewRegistry() 30 | prometheus.DefaultRegisterer = registry 31 | 32 | // x2 server count 33 | // x3 server capacity 34 | servers := []*autoscaler.Server{ 35 | {Name: "server1", Capacity: 1, Created: time.Now().Unix()}, 36 | {Name: "server2", Capacity: 2, Created: time.Now().Unix()}, 37 | } 38 | 39 | store := mocks.NewMockServerStore(controller) 40 | store.EXPECT().ListState(gomock.Any(), autoscaler.StateRunning).Return(servers, nil) 41 | ServerCapacity(store) 42 | 43 | metrics, err := registry.Gather() 44 | if err != nil { 45 | t.Error(err) 46 | return 47 | } 48 | if want, got := len(metrics), 1; want != got { 49 | t.Errorf("Expect registered metric") 50 | return 51 | } 52 | metric := metrics[0] 53 | if want, got := metric.GetName(), "drone_server_capacity"; want != got { 54 | t.Errorf("Expect metric name %s, got %s", want, got) 55 | } 56 | if want, got := metric.Metric[0].Gauge.GetValue(), float64(3); want != got { 57 | t.Errorf("Expect metric value %f, got %f", want, got) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /metrics/server_count.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package metrics 6 | 7 | import ( 8 | "github.com/drone/autoscaler" 9 | "github.com/prometheus/client_golang/prometheus" 10 | ) 11 | 12 | // ServerCount provides metrics for server counts. 13 | func ServerCount(store autoscaler.ServerStore) autoscaler.ServerStore { 14 | prometheus.MustRegister( 15 | prometheus.NewGaugeFunc(prometheus.GaugeOpts{ 16 | Name: "drone_server_count", 17 | Help: "Total number of active servers.", 18 | }, func() float64 { 19 | servers, _ := store.ListState(noContext, autoscaler.StateRunning) 20 | return float64(len(servers)) 21 | }), 22 | ) 23 | return store 24 | } 25 | -------------------------------------------------------------------------------- /metrics/server_count_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package metrics 6 | 7 | import ( 8 | "testing" 9 | "time" 10 | 11 | "github.com/drone/autoscaler" 12 | "github.com/drone/autoscaler/mocks" 13 | "github.com/golang/mock/gomock" 14 | "github.com/prometheus/client_golang/prometheus" 15 | ) 16 | 17 | func TestServerCount(t *testing.T) { 18 | controller := gomock.NewController(t) 19 | defer controller.Finish() 20 | 21 | // restore the default prometheus registerer 22 | // when the unit test is complete. 23 | snapshot := prometheus.DefaultRegisterer 24 | defer func() { 25 | prometheus.DefaultRegisterer = snapshot 26 | }() 27 | 28 | // creates a blank registry 29 | registry := prometheus.NewRegistry() 30 | prometheus.DefaultRegisterer = registry 31 | 32 | // x2 server count 33 | servers := []*autoscaler.Server{ 34 | {Name: "server1", Capacity: 1, Created: time.Now().Unix()}, 35 | {Name: "server2", Capacity: 1, Created: time.Now().Unix()}, 36 | } 37 | 38 | store := mocks.NewMockServerStore(controller) 39 | store.EXPECT().ListState(gomock.Any(), autoscaler.StateRunning).Return(servers, nil).AnyTimes() 40 | ServerCount(store) 41 | 42 | metrics, err := registry.Gather() 43 | if err != nil { 44 | t.Error(err) 45 | return 46 | } 47 | if want, got := len(metrics), 1; want != got { 48 | t.Errorf("Expect registered metric") 49 | return 50 | } 51 | metric := metrics[0] 52 | if want, got := metric.GetName(), "drone_server_count"; want != got { 53 | t.Errorf("Expect metric name %s, got %s", want, got) 54 | } 55 | if want, got := metric.Metric[0].Gauge.GetValue(), float64(len(servers)); want != got { 56 | t.Errorf("Expect metric value %f, got %f", want, got) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /metrics/server_create.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package metrics 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/drone/autoscaler" 11 | "github.com/prometheus/client_golang/prometheus" 12 | ) 13 | 14 | // ServerCreate provides metrics for servers created. 15 | func ServerCreate(provider autoscaler.Provider) autoscaler.Provider { 16 | counter := prometheus.NewCounter(prometheus.CounterOpts{ 17 | Name: "drone_servers_created", 18 | Help: "Total number of servers created.", 19 | }) 20 | errors := prometheus.NewCounter(prometheus.CounterOpts{ 21 | Name: "drone_servers_created_err", 22 | Help: "Total number of server creation errors.", 23 | }) 24 | prometheus.MustRegister(counter) 25 | prometheus.MustRegister(errors) 26 | return &providerWrapCreate{ 27 | Provider: provider, 28 | created: counter, 29 | errors: errors, 30 | } 31 | } 32 | 33 | // instruments the Provider to count server create events. 34 | type providerWrapCreate struct { 35 | autoscaler.Provider 36 | created prometheus.Counter 37 | errors prometheus.Counter 38 | } 39 | 40 | func (p *providerWrapCreate) Create(ctx context.Context, opts autoscaler.InstanceCreateOpts) (*autoscaler.Instance, error) { 41 | instance, err := p.Provider.Create(ctx, opts) 42 | if err == nil { 43 | p.created.Add(1) 44 | } else { 45 | p.errors.Add(1) 46 | } 47 | return instance, err 48 | } 49 | -------------------------------------------------------------------------------- /metrics/server_create_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package metrics 6 | 7 | import ( 8 | "errors" 9 | "testing" 10 | 11 | "github.com/drone/autoscaler" 12 | "github.com/drone/autoscaler/mocks" 13 | "github.com/golang/mock/gomock" 14 | "github.com/prometheus/client_golang/prometheus" 15 | ) 16 | 17 | func TestServerCreate(t *testing.T) { 18 | controller := gomock.NewController(t) 19 | defer controller.Finish() 20 | 21 | // restore the default prometheus registerer 22 | // when the unit test is complete. 23 | snapshot := prometheus.DefaultRegisterer 24 | defer func() { 25 | prometheus.DefaultRegisterer = snapshot 26 | }() 27 | 28 | // creates a blank registry 29 | registry := prometheus.NewRegistry() 30 | prometheus.DefaultRegisterer = registry 31 | 32 | opts := autoscaler.InstanceCreateOpts{Name: "server1"} 33 | instance := &autoscaler.Instance{} 34 | 35 | provider := mocks.NewMockProvider(controller) 36 | provider.EXPECT().Create(gomock.Any(), opts).Times(3).Return(instance, nil) 37 | provider.EXPECT().Create(gomock.Any(), opts).Return(nil, errors.New("error")) 38 | 39 | providerInst := ServerCreate(provider) 40 | for i := 0; i < 3; i++ { 41 | res, err := providerInst.Create(noContext, opts) 42 | if err != nil { 43 | t.Error(err) 44 | } 45 | if res != instance { 46 | t.Errorf("Expect instance returned") 47 | } 48 | } 49 | _, err := providerInst.Create(noContext, opts) 50 | if err == nil { 51 | t.Errorf("Expect error returned from provider") 52 | } 53 | 54 | metrics, err := registry.Gather() 55 | if err != nil { 56 | t.Error(err) 57 | return 58 | } 59 | if want, got := len(metrics), 2; want != got { 60 | t.Errorf("Expect registered metric") 61 | return 62 | } 63 | if got, want := metrics[0].GetName(), "drone_servers_created"; want != got { 64 | t.Errorf("Expect metric name %s, got %s", want, got) 65 | } 66 | if got, want := metrics[0].Metric[0].Counter.GetValue(), float64(3); want != got { 67 | t.Errorf("Expect metric value %f, got %f", want, got) 68 | } 69 | if got, want := metrics[1].GetName(), "drone_servers_created_err"; want != got { 70 | t.Errorf("Expect metric name %s, got %s", want, got) 71 | } 72 | if got, want := metrics[1].Metric[0].Counter.GetValue(), float64(1); want != got { 73 | t.Errorf("Expect metric value %f, got %f", want, got) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /metrics/server_delete.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package metrics 6 | 7 | import ( 8 | "context" 9 | 10 | "github.com/drone/autoscaler" 11 | "github.com/prometheus/client_golang/prometheus" 12 | ) 13 | 14 | // ServerDelete provides metrics for servers deleted. 15 | func ServerDelete(provider autoscaler.Provider) autoscaler.Provider { 16 | created := prometheus.NewCounter(prometheus.CounterOpts{ 17 | Name: "drone_servers_deleted", 18 | Help: "Total number of servers deleted.", 19 | }) 20 | errors := prometheus.NewCounter(prometheus.CounterOpts{ 21 | Name: "drone_servers_deleted_err", 22 | Help: "Total number of server deletion errors.", 23 | }) 24 | prometheus.MustRegister(created) 25 | prometheus.MustRegister(errors) 26 | return &providerWrapDestroy{ 27 | Provider: provider, 28 | created: created, 29 | errors: errors, 30 | } 31 | } 32 | 33 | // instruments the Provider to count server destroy events. 34 | type providerWrapDestroy struct { 35 | autoscaler.Provider 36 | created prometheus.Counter 37 | errors prometheus.Counter 38 | } 39 | 40 | func (p *providerWrapDestroy) Destroy(ctx context.Context, instance *autoscaler.Instance) error { 41 | err := p.Provider.Destroy(ctx, instance) 42 | if err == nil { 43 | p.created.Add(1) 44 | } else { 45 | p.errors.Add(1) 46 | } 47 | return err 48 | } 49 | -------------------------------------------------------------------------------- /metrics/server_delete_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package metrics 6 | 7 | import ( 8 | "errors" 9 | "testing" 10 | 11 | "github.com/drone/autoscaler" 12 | "github.com/drone/autoscaler/mocks" 13 | "github.com/golang/mock/gomock" 14 | "github.com/prometheus/client_golang/prometheus" 15 | ) 16 | 17 | func TestServerDelete(t *testing.T) { 18 | controller := gomock.NewController(t) 19 | defer controller.Finish() 20 | 21 | // restore the default prometheus registerer 22 | // when the unit test is complete. 23 | snapshot := prometheus.DefaultRegisterer 24 | defer func() { 25 | prometheus.DefaultRegisterer = snapshot 26 | }() 27 | 28 | // creates a blank registry 29 | registry := prometheus.NewRegistry() 30 | prometheus.DefaultRegisterer = registry 31 | 32 | instance := &autoscaler.Instance{Name: "server1"} 33 | 34 | provider := mocks.NewMockProvider(controller) 35 | provider.EXPECT().Destroy(noContext, instance).Times(3).Return(nil) 36 | provider.EXPECT().Destroy(noContext, instance).Return(errors.New("error")) 37 | 38 | providerInst := ServerDelete(provider) 39 | for i := 0; i < 3; i++ { 40 | err := providerInst.Destroy(noContext, instance) 41 | if err != nil { 42 | t.Error(err) 43 | } 44 | } 45 | err := providerInst.Destroy(noContext, instance) 46 | if err == nil { 47 | t.Errorf("Expect error returned from provider") 48 | } 49 | 50 | metrics, err := registry.Gather() 51 | if err != nil { 52 | t.Error(err) 53 | return 54 | } 55 | if want, got := len(metrics), 2; want != got { 56 | t.Errorf("Expect registered metric") 57 | return 58 | } 59 | if got, want := metrics[0].GetName(), "drone_servers_deleted"; want != got { 60 | t.Errorf("Expect metric name %s, got %s", want, got) 61 | } 62 | if got, want := metrics[0].Metric[0].Counter.GetValue(), float64(3); want != got { 63 | t.Errorf("Expect metric value %f, got %f", want, got) 64 | } 65 | if got, want := metrics[1].GetName(), "drone_servers_deleted_err"; want != got { 66 | t.Errorf("Expect metric name %s, got %s", want, got) 67 | } 68 | if got, want := metrics[1].Metric[0].Counter.GetValue(), float64(1); want != got { 69 | t.Errorf("Expect metric value %f, got %f", want, got) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /mocks/mock_engine.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/drone/autoscaler (interfaces: Engine) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | context "context" 9 | gomock "github.com/golang/mock/gomock" 10 | reflect "reflect" 11 | ) 12 | 13 | // MockEngine is a mock of Engine interface 14 | type MockEngine struct { 15 | ctrl *gomock.Controller 16 | recorder *MockEngineMockRecorder 17 | } 18 | 19 | // MockEngineMockRecorder is the mock recorder for MockEngine 20 | type MockEngineMockRecorder struct { 21 | mock *MockEngine 22 | } 23 | 24 | // NewMockEngine creates a new mock instance 25 | func NewMockEngine(ctrl *gomock.Controller) *MockEngine { 26 | mock := &MockEngine{ctrl: ctrl} 27 | mock.recorder = &MockEngineMockRecorder{mock} 28 | return mock 29 | } 30 | 31 | // EXPECT returns an object that allows the caller to indicate expected use 32 | func (m *MockEngine) EXPECT() *MockEngineMockRecorder { 33 | return m.recorder 34 | } 35 | 36 | // Pause mocks base method 37 | func (m *MockEngine) Pause() { 38 | m.ctrl.T.Helper() 39 | m.ctrl.Call(m, "Pause") 40 | } 41 | 42 | // Pause indicates an expected call of Pause 43 | func (mr *MockEngineMockRecorder) Pause() *gomock.Call { 44 | mr.mock.ctrl.T.Helper() 45 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Pause", reflect.TypeOf((*MockEngine)(nil).Pause)) 46 | } 47 | 48 | // Paused mocks base method 49 | func (m *MockEngine) Paused() bool { 50 | m.ctrl.T.Helper() 51 | ret := m.ctrl.Call(m, "Paused") 52 | ret0, _ := ret[0].(bool) 53 | return ret0 54 | } 55 | 56 | // Paused indicates an expected call of Paused 57 | func (mr *MockEngineMockRecorder) Paused() *gomock.Call { 58 | mr.mock.ctrl.T.Helper() 59 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Paused", reflect.TypeOf((*MockEngine)(nil).Paused)) 60 | } 61 | 62 | // Resume mocks base method 63 | func (m *MockEngine) Resume() { 64 | m.ctrl.T.Helper() 65 | m.ctrl.Call(m, "Resume") 66 | } 67 | 68 | // Resume indicates an expected call of Resume 69 | func (mr *MockEngineMockRecorder) Resume() *gomock.Call { 70 | mr.mock.ctrl.T.Helper() 71 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resume", reflect.TypeOf((*MockEngine)(nil).Resume)) 72 | } 73 | 74 | // Start mocks base method 75 | func (m *MockEngine) Start(arg0 context.Context) { 76 | m.ctrl.T.Helper() 77 | m.ctrl.Call(m, "Start", arg0) 78 | } 79 | 80 | // Start indicates an expected call of Start 81 | func (mr *MockEngineMockRecorder) Start(arg0 interface{}) *gomock.Call { 82 | mr.mock.ctrl.T.Helper() 83 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockEngine)(nil).Start), arg0) 84 | } 85 | -------------------------------------------------------------------------------- /mocks/mock_provider.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/drone/autoscaler (interfaces: Provider) 3 | 4 | // Package mocks is a generated GoMock package. 5 | package mocks 6 | 7 | import ( 8 | context "context" 9 | autoscaler "github.com/drone/autoscaler" 10 | gomock "github.com/golang/mock/gomock" 11 | reflect "reflect" 12 | ) 13 | 14 | // MockProvider is a mock of Provider interface 15 | type MockProvider struct { 16 | ctrl *gomock.Controller 17 | recorder *MockProviderMockRecorder 18 | } 19 | 20 | // MockProviderMockRecorder is the mock recorder for MockProvider 21 | type MockProviderMockRecorder struct { 22 | mock *MockProvider 23 | } 24 | 25 | // NewMockProvider creates a new mock instance 26 | func NewMockProvider(ctrl *gomock.Controller) *MockProvider { 27 | mock := &MockProvider{ctrl: ctrl} 28 | mock.recorder = &MockProviderMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use 33 | func (m *MockProvider) EXPECT() *MockProviderMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // Create mocks base method 38 | func (m *MockProvider) Create(arg0 context.Context, arg1 autoscaler.InstanceCreateOpts) (*autoscaler.Instance, error) { 39 | m.ctrl.T.Helper() 40 | ret := m.ctrl.Call(m, "Create", arg0, arg1) 41 | ret0, _ := ret[0].(*autoscaler.Instance) 42 | ret1, _ := ret[1].(error) 43 | return ret0, ret1 44 | } 45 | 46 | // Create indicates an expected call of Create 47 | func (mr *MockProviderMockRecorder) Create(arg0, arg1 interface{}) *gomock.Call { 48 | mr.mock.ctrl.T.Helper() 49 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockProvider)(nil).Create), arg0, arg1) 50 | } 51 | 52 | // Destroy mocks base method 53 | func (m *MockProvider) Destroy(arg0 context.Context, arg1 *autoscaler.Instance) error { 54 | m.ctrl.T.Helper() 55 | ret := m.ctrl.Call(m, "Destroy", arg0, arg1) 56 | ret0, _ := ret[0].(error) 57 | return ret0 58 | } 59 | 60 | // Destroy indicates an expected call of Destroy 61 | func (mr *MockProviderMockRecorder) Destroy(arg0, arg1 interface{}) *gomock.Call { 62 | mr.mock.ctrl.T.Helper() 63 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Destroy", reflect.TypeOf((*MockProvider)(nil).Destroy), arg0, arg1) 64 | } 65 | -------------------------------------------------------------------------------- /mocks/mocks.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package mocks 6 | 7 | //go:generate mockgen -package=mocks -destination=mock_engine.go github.com/drone/autoscaler Engine 8 | //go:generate mockgen -package=mocks -destination=mock_server.go github.com/drone/autoscaler ServerStore 9 | //go:generate mockgen -package=mocks -destination=mock_provider.go github.com/drone/autoscaler Provider 10 | //go:generate mockgen -package=mocks -destination=mock_metrics.go github.com/drone/autoscaler/metrics Collector 11 | //go:generate mockgen -package=mocks -destination=mock_drone.go github.com/drone/drone-go/drone Client 12 | //go:generate mockgen -package=mocks -destination=mock_docker.go github.com/docker/docker/client APIClient 13 | -------------------------------------------------------------------------------- /provider.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package autoscaler 6 | 7 | import ( 8 | "context" 9 | "database/sql/driver" 10 | "errors" 11 | ) 12 | 13 | // ProviderType specifies the hosting provider. 14 | type ProviderType string 15 | 16 | // Value converts the value to a sql string. 17 | func (s ProviderType) Value() (driver.Value, error) { 18 | return string(s), nil 19 | } 20 | 21 | // Provider type enumeration. 22 | const ( 23 | ProviderAmazon = ProviderType("amazon") 24 | ProviderAzure = ProviderType("azure") 25 | ProviderDigitalOcean = ProviderType("digitalocean") 26 | ProviderGoogle = ProviderType("google") 27 | ProviderHetznerCloud = ProviderType("hetznercloud") 28 | ProviderLinode = ProviderType("linode") 29 | ProviderOpenStack = ProviderType("openstack") 30 | ProviderPacket = ProviderType("packet") 31 | ProviderScaleway = ProviderType("scaleway") 32 | ProviderVultr = ProviderType("vultr") 33 | ) 34 | 35 | // ErrInstanceNotFound is returned when the requested 36 | // instance does not exist in the cloud provider. 37 | var ErrInstanceNotFound = errors.New("Not Found") 38 | 39 | // A Provider represents a hosting provider, such as 40 | // Digital Ocean and is responsible for server management. 41 | type Provider interface { 42 | // Create creates a new server. 43 | Create(context.Context, InstanceCreateOpts) (*Instance, error) 44 | // Destroy destroys an existing server. 45 | Destroy(context.Context, *Instance) error 46 | } 47 | 48 | // An Instance represents a server instance 49 | // (e.g Digital Ocean Droplet). 50 | type Instance struct { 51 | Provider ProviderType 52 | ID string 53 | Name string 54 | Address string 55 | Region string 56 | Image string 57 | Size string 58 | ServiceAccountEmail string 59 | Scopes []string 60 | } 61 | 62 | // InstanceCreateOpts define soptional instructions for 63 | // creating server instances. 64 | type InstanceCreateOpts struct { 65 | Name string 66 | CAKey []byte 67 | CACert []byte 68 | TLSKey []byte 69 | TLSCert []byte 70 | } 71 | 72 | // InstanceError snapshots an error creating an instance 73 | // with server logs. 74 | type InstanceError struct { 75 | Err error 76 | Logs []byte 77 | } 78 | 79 | // Error implements the error interface. 80 | func (e *InstanceError) Error() string { 81 | return e.Err.Error() 82 | } 83 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package autoscaler 6 | 7 | import ( 8 | "context" 9 | "database/sql/driver" 10 | "errors" 11 | ) 12 | 13 | // ServerState specifies the server state. 14 | type ServerState string 15 | 16 | // Value converts the value to a sql string. 17 | func (s ServerState) Value() (driver.Value, error) { 18 | return string(s), nil 19 | } 20 | 21 | // ServerState type enumeration. 22 | const ( 23 | StatePending = ServerState("pending") 24 | StateCreating = ServerState("creating") 25 | StateCreated = ServerState("created") 26 | StateStaging = ServerState("staging") // starting 27 | StateRunning = ServerState("running") 28 | StateShutdown = ServerState("shutdown") 29 | StateStopping = ServerState("stopping") 30 | StateStopped = ServerState("stopped") 31 | StateError = ServerState("error") 32 | ) 33 | 34 | // ErrServerNotFound is returned when the requested server 35 | // does not exist in the store. 36 | var ErrServerNotFound = errors.New("Not Found") 37 | 38 | // A ServerStore persists server information. 39 | type ServerStore interface { 40 | // Find a server by unique name. 41 | Find(context.Context, string) (*Server, error) 42 | 43 | // List returns all registered servers 44 | List(context.Context) ([]*Server, error) 45 | 46 | // ListState returns all servers with the given state. 47 | ListState(context.Context, ServerState) ([]*Server, error) 48 | 49 | // Create the server record in the store. 50 | Create(context.Context, *Server) error 51 | 52 | // Update the server record in the store. 53 | Update(context.Context, *Server) error 54 | 55 | // Delete the server record from the store. 56 | Delete(context.Context, *Server) error 57 | 58 | // Purge old server records from the store. 59 | Purge(context.Context, int64) error 60 | } 61 | 62 | // Server stores the server details. 63 | type Server struct { 64 | ID string `db:"server_id" json:"id"` 65 | Provider ProviderType `db:"server_provider" json:"provider"` 66 | State ServerState `db:"server_state" json:"state"` 67 | Name string `db:"server_name" json:"name"` 68 | Image string `db:"server_image" json:"image"` 69 | Region string `db:"server_region" json:"region"` 70 | Size string `db:"server_size" json:"size"` 71 | Platform string `db:"server_platform" json:"platform"` 72 | Address string `db:"server_address" json:"address"` 73 | Capacity int `db:"server_capacity" json:"capacity"` 74 | Secret string `db:"server_secret" json:"secret"` 75 | Error string `db:"server_error" json:"error"` 76 | CAKey []byte `db:"server_ca_key" json:"ca_key"` 77 | CACert []byte `db:"server_ca_cert" json:"ca_cert"` 78 | TLSKey []byte `db:"server_tls_key" json:"tls_key"` 79 | TLSCert []byte `db:"server_tls_cert" json:"tls_cert"` 80 | Created int64 `db:"server_created" json:"created"` 81 | Updated int64 `db:"server_updated" json:"updated"` 82 | Started int64 `db:"server_started" json:"started"` 83 | Stopped int64 `db:"server_stopped" json:"stopped"` 84 | } 85 | -------------------------------------------------------------------------------- /server/auth.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package server 6 | 7 | import ( 8 | "net/http" 9 | "strings" 10 | 11 | "github.com/drone/autoscaler/config" 12 | "github.com/drone/autoscaler/logger" 13 | "github.com/drone/drone-go/drone" 14 | 15 | "golang.org/x/oauth2" 16 | ) 17 | 18 | // CheckDrone returns a middleware function that authorizes 19 | // the incoming http.Request using the Drone API. 20 | func CheckDrone(conf config.Config) func(http.Handler) http.Handler { 21 | return func(next http.Handler) http.Handler { 22 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 | ctx := r.Context() 24 | log := logger.FromContext(ctx) 25 | 26 | // the user can authenticate with a global authorization 27 | // token provied in the Authorization header. 28 | token := r.Header.Get("Authorization") 29 | token = strings.TrimPrefix(token, "Bearer ") 30 | token = strings.TrimSpace(token) 31 | if token == "" { 32 | log.Debugln("missing authorization header") 33 | writeUnauthorized(w, errInvalidToken) 34 | return 35 | } 36 | 37 | // creates a new drone client using the bearer token 38 | // in the incoming request to authenticate with drone. 39 | config := new(oauth2.Config) 40 | auther := config.Client( 41 | oauth2.NoContext, 42 | &oauth2.Token{ 43 | AccessToken: token, 44 | }, 45 | ) 46 | server := conf.Server.Proto + "://" + conf.Server.Host 47 | client := drone.NewClient(server, auther) 48 | 49 | // fetch the user account associated with the currently 50 | // authenticated bearer token. This user must exist in 51 | // drone and must be an administrator. 52 | user, err := client.Self() 53 | if err != nil { 54 | log.WithError(err). 55 | Errorln("cannot authenticate user") 56 | writeUnauthorized(w, errUnauthorized) 57 | return 58 | } 59 | 60 | if !user.Admin { 61 | log.WithError(err). 62 | WithField("username", user.Login). 63 | Errorln("insufficient privileges") 64 | writeForbidden(w, errForbidden) 65 | return 66 | } 67 | 68 | log = log.WithField("username", user.Login) 69 | log.Debugln("user authorized") 70 | 71 | next.ServeHTTP(w, r.WithContext( 72 | logger.WithContext(ctx, log), 73 | )) 74 | }) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /server/engine.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package server 6 | 7 | import ( 8 | "net/http" 9 | 10 | "github.com/drone/autoscaler" 11 | ) 12 | 13 | // HandleEnginePause returns an http.HandlerFunc that pauses 14 | // scaling engine. 15 | func HandleEnginePause(engine autoscaler.Engine) http.HandlerFunc { 16 | return func(w http.ResponseWriter, r *http.Request) { 17 | engine.Pause() 18 | w.WriteHeader(204) 19 | } 20 | } 21 | 22 | // HandleEngineResume returns an http.HandlerFunc that resumes 23 | // scaling engine. 24 | func HandleEngineResume(engine autoscaler.Engine) http.HandlerFunc { 25 | return func(w http.ResponseWriter, r *http.Request) { 26 | engine.Resume() 27 | w.WriteHeader(204) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /server/engine_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package server 6 | 7 | import ( 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/drone/autoscaler/mocks" 12 | "github.com/golang/mock/gomock" 13 | ) 14 | 15 | func TestHandleEnginePause(t *testing.T) { 16 | controller := gomock.NewController(t) 17 | defer controller.Finish() 18 | 19 | w := httptest.NewRecorder() 20 | r := httptest.NewRequest("POST", "/api/pause", nil) 21 | 22 | e := mocks.NewMockEngine(controller) 23 | e.EXPECT().Pause() 24 | 25 | HandleEnginePause(e).ServeHTTP(w, r) 26 | 27 | if got, want := w.Code, 204; want != got { 28 | t.Errorf("Want response code %d, got %d", want, got) 29 | } 30 | } 31 | 32 | func TestHandleEngineResume(t *testing.T) { 33 | controller := gomock.NewController(t) 34 | defer controller.Finish() 35 | 36 | w := httptest.NewRecorder() 37 | r := httptest.NewRequest("POST", "/api/resume", nil) 38 | 39 | e := mocks.NewMockEngine(controller) 40 | e.EXPECT().Resume() 41 | 42 | HandleEngineResume(e).ServeHTTP(w, r) 43 | 44 | if got, want := w.Code, 204; want != got { 45 | t.Errorf("Want response code %d, got %d", want, got) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /server/healthz.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package server 6 | 7 | import ( 8 | "io" 9 | "net/http" 10 | ) 11 | 12 | // HandleHealthz creates an http.HandlerFunc that returns performs system 13 | // healthchecks and returns 500 if the system is in an unhealthy state. 14 | func HandleHealthz() http.HandlerFunc { 15 | return func(w http.ResponseWriter, r *http.Request) { 16 | w.WriteHeader(200) 17 | w.Header().Set("Content-Type", "text/plain") 18 | io.WriteString(w, "OK") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /server/healthz_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package server 6 | 7 | import ( 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/golang/mock/gomock" 12 | ) 13 | 14 | func TestHandleHealthz(t *testing.T) { 15 | controller := gomock.NewController(t) 16 | defer controller.Finish() 17 | 18 | w := httptest.NewRecorder() 19 | r := httptest.NewRequest("GET", "/healthz", nil) 20 | 21 | HandleHealthz().ServeHTTP(w, r) 22 | 23 | if got, want := w.Code, 200; want != got { 24 | t.Errorf("Want response code %d, got %d", want, got) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/metrics.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package server 6 | 7 | import ( 8 | "net/http" 9 | 10 | "github.com/prometheus/client_golang/prometheus/promhttp" 11 | ) 12 | 13 | // HandleMetrics returns an http.HandlerFunc that writes 14 | // metrics to the response body in plain text format. 15 | func HandleMetrics(token string) http.HandlerFunc { 16 | handler := promhttp.Handler() 17 | return func(w http.ResponseWriter, r *http.Request) { 18 | // if a bearer token is not configured we should 19 | // just server the http request. 20 | if token == "" { 21 | handler.ServeHTTP(w, r) 22 | return 23 | } 24 | header := r.Header.Get("Authorization") 25 | if header == "" { 26 | http.Error(w, errInvalidToken.Error(), 401) 27 | return 28 | } 29 | if header != "Bearer "+token { 30 | http.Error(w, errInvalidToken.Error(), 401) 31 | return 32 | } 33 | handler.ServeHTTP(w, r) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /server/metrics_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package server 6 | 7 | import ( 8 | "net/http/httptest" 9 | "testing" 10 | ) 11 | 12 | func TestHandleMetrics(t *testing.T) { 13 | w := httptest.NewRecorder() 14 | r := httptest.NewRequest("GET", "/", nil) 15 | r.Header.Set("Authorization", "Bearer correct-horse-batter-staple") 16 | 17 | HandleMetrics("correct-horse-batter-staple").ServeHTTP(w, r) 18 | 19 | if got, want := w.Code, 200; got != want { 20 | t.Errorf("Want status code %d, got %d", want, got) 21 | } 22 | 23 | if got, want := w.HeaderMap.Get("Content-Type"), "text/plain; version=0.0.4; charset=utf-8"; got != want { 24 | t.Errorf("Want prometheus header %q, got %q", want, got) 25 | } 26 | } 27 | 28 | func TestHandleMetricsUnprotected(t *testing.T) { 29 | w := httptest.NewRecorder() 30 | r := httptest.NewRequest("GET", "/", nil) 31 | 32 | HandleMetrics("").ServeHTTP(w, r) 33 | 34 | if got, want := w.Code, 200; got != want { 35 | t.Errorf("Want status code %d, got %d", want, got) 36 | } 37 | 38 | if got, want := w.HeaderMap.Get("Content-Type"), "text/plain; version=0.0.4; charset=utf-8"; got != want { 39 | t.Errorf("Want prometheus header %q, got %q", want, got) 40 | } 41 | } 42 | 43 | func TestHandleMetricsMissingToken(t *testing.T) { 44 | w := httptest.NewRecorder() 45 | r := httptest.NewRequest("GET", "/", nil) 46 | 47 | HandleMetrics("correct-horse-batter-staple").ServeHTTP(w, r) 48 | 49 | if got, want := w.Code, 401; got != want { 50 | t.Errorf("Want status code %d, got %d", want, got) 51 | } 52 | } 53 | 54 | func TestHandleMetricsInvalidToken(t *testing.T) { 55 | w := httptest.NewRecorder() 56 | r := httptest.NewRequest("GET", "/", nil) 57 | r.Header.Set("Authorization", "correct-horse-batter-staple") 58 | 59 | HandleMetrics("correct-horse-batter-staple").ServeHTTP(w, r) 60 | 61 | if got, want := w.Code, 401; got != want { 62 | t.Errorf("Want status code %d, got %d", want, got) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /server/varz.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package server 6 | 7 | import ( 8 | "net/http" 9 | 10 | "github.com/drone/autoscaler" 11 | ) 12 | 13 | type varz struct { 14 | Paused bool `json:"paused"` 15 | } 16 | 17 | // HandleVarz creates an http.HandlerFunc that returns system 18 | // configuration and runtime information. 19 | func HandleVarz(engine autoscaler.Engine) http.HandlerFunc { 20 | return func(w http.ResponseWriter, r *http.Request) { 21 | data := varz{ 22 | Paused: engine.Paused(), 23 | } 24 | writeJSON(w, &data, 200) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/varz_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package server 6 | 7 | import ( 8 | "encoding/json" 9 | "net/http/httptest" 10 | "reflect" 11 | "testing" 12 | 13 | "github.com/drone/autoscaler/mocks" 14 | "github.com/go-chi/chi" 15 | "github.com/golang/mock/gomock" 16 | "github.com/kr/pretty" 17 | ) 18 | 19 | func TestHandleVarz(t *testing.T) { 20 | controller := gomock.NewController(t) 21 | defer controller.Finish() 22 | 23 | mockVarz := &varz{ 24 | Paused: true, 25 | } 26 | 27 | w := httptest.NewRecorder() 28 | r := httptest.NewRequest("POST", "/varz", nil) 29 | 30 | engine := mocks.NewMockEngine(controller) 31 | engine.EXPECT().Paused().Return(true) 32 | 33 | router := chi.NewRouter() 34 | router.Post("/varz", HandleVarz(engine)) 35 | router.ServeHTTP(w, r) 36 | 37 | if got, want := w.Code, 200; want != got { 38 | t.Errorf("Want response code %d, got %d", want, got) 39 | } 40 | 41 | got, want := &varz{}, mockVarz 42 | json.NewDecoder(w.Body).Decode(got) 43 | if !reflect.DeepEqual(got, want) { 44 | t.Errorf("response body does match expected result") 45 | pretty.Ldiff(t, got, want) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /server/version.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package server 6 | 7 | import ( 8 | "net/http" 9 | ) 10 | 11 | // version information, loosely based on 12 | // https://github.com/mozilla-services/Dockerflow 13 | type versionInfo struct { 14 | Source string `json:"source,omitempty"` 15 | Version string `json:"version,omitempty"` 16 | Commit string `json:"commit,omitempty"` 17 | } 18 | 19 | // HandleVersion creates an http.HandlerFunc that returns the 20 | // version number and build details. 21 | func HandleVersion(source, version, commit string) http.HandlerFunc { 22 | return func(w http.ResponseWriter, r *http.Request) { 23 | data := versionInfo{ 24 | Source: source, 25 | Version: version, 26 | Commit: commit, 27 | } 28 | writeJSON(w, &data, 200) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /server/version_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package server 6 | 7 | import ( 8 | "encoding/json" 9 | "net/http/httptest" 10 | "reflect" 11 | "testing" 12 | 13 | "github.com/golang/mock/gomock" 14 | "github.com/kr/pretty" 15 | ) 16 | 17 | func TestHandleVersion(t *testing.T) { 18 | controller := gomock.NewController(t) 19 | defer controller.Finish() 20 | 21 | w := httptest.NewRecorder() 22 | r := httptest.NewRequest("GET", "/version", nil) 23 | 24 | mockVersion := &versionInfo{ 25 | Source: "github.com/octocat/hello-world", 26 | Version: "1.0.0", 27 | Commit: "ad2aec", 28 | } 29 | 30 | h := HandleVersion(mockVersion.Source, mockVersion.Version, mockVersion.Commit) 31 | h.ServeHTTP(w, r) 32 | 33 | if got, want := w.Code, 200; want != got { 34 | t.Errorf("Want response code %d, got %d", want, got) 35 | } 36 | 37 | got, want := &versionInfo{}, mockVersion 38 | json.NewDecoder(w.Body).Decode(got) 39 | if !reflect.DeepEqual(got, want) { 40 | t.Errorf("response body does match expected result") 41 | pretty.Ldiff(t, got, want) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /server/web/handler.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Drone.IO Inc. All rights reserved. 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | // Package web provides HTTP handlers that expose pipeline 6 | // state and status. 7 | package web 8 | 9 | import ( 10 | "net/http" 11 | 12 | "github.com/drone/autoscaler" 13 | "github.com/drone/autoscaler/logger/history" 14 | ) 15 | 16 | // HandleServers returns a http.HandlerFunc that displays a 17 | // list of activate servers. 18 | func HandleServers(servers autoscaler.ServerStore) http.HandlerFunc { 19 | return func(w http.ResponseWriter, r *http.Request) { 20 | nocache(w) 21 | items, _ := servers.List(r.Context()) 22 | filtered := []*autoscaler.Server{} 23 | for _, item := range items { 24 | if item.State != autoscaler.StateStopped { 25 | filtered = append(filtered, item) 26 | } 27 | } 28 | render(w, "index.tmpl", struct { 29 | Items []*autoscaler.Server 30 | }{filtered}) 31 | } 32 | } 33 | 34 | // HandleLogging returns a http.HandlerFunc that displays a 35 | // list recent log entries. 36 | func HandleLogging(t *history.Hook) http.HandlerFunc { 37 | return func(w http.ResponseWriter, r *http.Request) { 38 | nocache(w) 39 | render(w, "logs.tmpl", struct { 40 | Entries []*history.Entry 41 | }{t.Entries()}) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /server/web/nocache.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Drone.IO Inc. All rights reserved. 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package web 6 | 7 | import ( 8 | "net/http" 9 | "time" 10 | ) 11 | 12 | // unix epoch time 13 | var epoch = time.Unix(0, 0).Format(time.RFC1123) 14 | 15 | // http headers to disable caching. 16 | var noCacheHeaders = map[string]string{ 17 | "Expires": epoch, 18 | "Cache-Control": "no-cache, private, max-age=0", 19 | "Pragma": "no-cache", 20 | "X-Accel-Expires": "0", 21 | } 22 | 23 | // helper function to prevent http response caching. 24 | func nocache(w http.ResponseWriter) { 25 | for k, v := range noCacheHeaders { 26 | w.Header().Set(k, v) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /server/web/nocache_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Drone.IO Inc. All rights reserved. 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package web 6 | -------------------------------------------------------------------------------- /server/web/render.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Drone.IO Inc. All rights reserved. 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package web 6 | 7 | import ( 8 | "net/http" 9 | 10 | "github.com/drone/autoscaler/server/web/template" 11 | ) 12 | 13 | // render writes the template to the response body. 14 | func render(w http.ResponseWriter, t string, v interface{}) { 15 | w.Header().Set("Content-Type", "text/html") 16 | template.T.ExecuteTemplate(w, t, v) 17 | } 18 | -------------------------------------------------------------------------------- /server/web/render_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Drone.IO Inc. All rights reserved. 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package web 6 | -------------------------------------------------------------------------------- /server/web/static/files/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drone/autoscaler/9a40e5c65dc31a7ba706b1afe6796d70f1c5acc0/server/web/static/files/favicon.png -------------------------------------------------------------------------------- /server/web/static/files/icons/server-list-empty-mono.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 10 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 21 | 22 | 23 | 24 | 27 | 28 | 30 | 31 | 32 | 33 | 36 | 37 | -------------------------------------------------------------------------------- /server/web/static/files/icons/server-list-empty.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 10 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 21 | 22 | 23 | 24 | 27 | 28 | 30 | 31 | 32 | 33 | 36 | 37 | -------------------------------------------------------------------------------- /server/web/static/files/reset.css: -------------------------------------------------------------------------------- 1 | html, body, div, span, applet, object, iframe, 2 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 3 | a, abbr, acronym, address, big, cite, code, 4 | del, dfn, em, img, ins, kbd, q, s, samp, 5 | small, strike, strong, sub, sup, tt, var, 6 | b, u, i, center, 7 | dl, dt, dd, ol, ul, li, 8 | fieldset, form, label, legend, 9 | table, caption, tbody, tfoot, thead, tr, th, td, 10 | article, aside, canvas, details, embed, 11 | figure, figcaption, footer, header, hgroup, 12 | menu, nav, output, ruby, section, summary, 13 | time, mark, audio, video { 14 | margin: 0; 15 | padding: 0; 16 | border: 0; 17 | font-size: 100%; 18 | font: inherit; 19 | vertical-align: baseline; 20 | } 21 | /* HTML5 display-role reset for older browsers */ 22 | article, aside, details, figcaption, figure, 23 | footer, header, hgroup, menu, nav, section { 24 | display: block; 25 | } 26 | body { 27 | line-height: 1; 28 | } 29 | ol, ul { 30 | list-style: none; 31 | } 32 | blockquote, q { 33 | quotes: none; 34 | } 35 | blockquote:before, blockquote:after, 36 | q:before, q:after { 37 | content: ''; 38 | content: none; 39 | } 40 | table { 41 | border-collapse: collapse; 42 | border-spacing: 0; 43 | } -------------------------------------------------------------------------------- /server/web/static/static.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Drone.IO Inc. All rights reserved. 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package static 6 | 7 | //go:generate togo http -package static -output static_gen.go 8 | -------------------------------------------------------------------------------- /server/web/template/files/logs.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Dashboard 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | Servers 19 | Logging 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | Recent Logs 28 | 29 | 30 | 31 | {{ range .Entries }} 32 | 33 | 34 | {{ .Level }} 35 | 36 | {{ .Message }} 37 | 38 | {{ range $key, $val := .Data }} 39 | {{ $key }}{{ $val }} 40 | {{ end }} 41 | 42 | {{ timestamp .Unix }} 43 | 44 | {{ end }} 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /server/web/template/server.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Drone.IO Inc. All rights reserved. 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | // +build ignore 6 | 7 | package main 8 | 9 | import ( 10 | "encoding/json" 11 | "html/template" 12 | "io/ioutil" 13 | "log" 14 | "net/http" 15 | "path/filepath" 16 | "time" 17 | ) 18 | 19 | func main() { 20 | addr := ":3333" 21 | 22 | // serve templates with dummy data 23 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 24 | path := r.FormValue("data") 25 | if path == "" { 26 | http.Error(w, "missing data parameter", 500) 27 | return 28 | } 29 | 30 | tmpl := r.FormValue("template") 31 | if path == "" { 32 | http.Error(w, "missing template parameter", 500) 33 | return 34 | } 35 | 36 | // read the json data from file. 37 | rawjson, err := ioutil.ReadFile(filepath.Join("testdata", path)) 38 | if err != nil { 39 | http.Error(w, "cannot open json file", 500) 40 | return 41 | } 42 | 43 | // unmarshal the json data 44 | data := map[string]interface{}{} 45 | err = json.Unmarshal(rawjson, &data) 46 | if err != nil { 47 | http.Error(w, err.Error(), 500) 48 | return 49 | } 50 | 51 | // load the templates 52 | T := template.New("_").Funcs(funcMap) 53 | matches, _ := filepath.Glob("files/*.tmpl") 54 | for _, match := range matches { 55 | raw, _ := ioutil.ReadFile(match) 56 | base := filepath.Base(match) 57 | T = template.Must( 58 | T.New(base).Parse(string(raw)), 59 | ) 60 | } 61 | 62 | // render the template 63 | w.Header().Set("Content-Type", "text/html") 64 | err = T.ExecuteTemplate(w, tmpl, data) 65 | if err != nil { 66 | log.Println(err) 67 | } 68 | }) 69 | 70 | // serve static content. 71 | http.Handle("/static/", 72 | http.StripPrefix("/static/", 73 | http.FileServer( 74 | http.Dir("../static/files"), 75 | ), 76 | ), 77 | ) 78 | 79 | log.Printf("listening at %s", addr) 80 | log.Fatalln(http.ListenAndServe(addr, nil)) 81 | } 82 | 83 | // mirros the func map in template.go 84 | var funcMap = map[string]interface{}{ 85 | "substr": func(v string, i int) string { 86 | return v[0:i] 87 | }, 88 | "timestamp": func(v float64) string { 89 | return time.Unix(int64(v), 0).UTC().Format("2006-01-02T15:04:05Z") 90 | }, 91 | } 92 | -------------------------------------------------------------------------------- /server/web/template/template.go: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Drone.IO Inc. All rights reserved. 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package template 6 | 7 | import "time" 8 | 9 | //go:generate togo tmpl -func funcMap -format html 10 | 11 | // mirros the func map in template.go 12 | var funcMap = map[string]interface{}{ 13 | "timestamp": func(v int64) string { 14 | return time.Unix(v, 0).UTC().Format("2006-01-02T15:04:05Z") 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /server/web/template/testdata/logs.json: -------------------------------------------------------------------------------- 1 | { 2 | "Entries": [ 3 | { 4 | "Level": "trace", 5 | "Message": "this is a test trace message", 6 | "Data": { "foo": "bar", "baz": "boo" }, 7 | "Unix": 1563058875 8 | }, 9 | { 10 | "Level": "debug", 11 | "Message": "this is a test debug message", 12 | "Data": { "foo": "bar", "baz": "boo" }, 13 | "Unix": 1563058875 14 | }, 15 | { 16 | "Level": "info", 17 | "Message": "this is an info trace message", 18 | "Data": { "foo": "bar", "baz": "boo" }, 19 | "Unix": 1563058975 20 | }, 21 | { 22 | "Level": "warn", 23 | "Message": "this is a test warning message", 24 | "Data": { "foo": "bar", "baz": "boo" }, 25 | "Unix": 1563058977 26 | }, 27 | { 28 | "Level": "error", 29 | "Message": "this is a test error message", 30 | "Data": { "foo": "bar", "baz": "boo" }, 31 | "Unix": 1563059000 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /server/web/template/testdata/logs_empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "Entries": [] 3 | } -------------------------------------------------------------------------------- /server/web/template/testdata/servers.json: -------------------------------------------------------------------------------- 1 | { 2 | "Items": [ 3 | { 4 | "ID": "agent-123456789", 5 | "Name": "i-5203422c", 6 | "Provider": "amazon", 7 | "State": "starting", 8 | "Address": "54.194.252.215", 9 | "Capacity": 2, 10 | "Error": "", 11 | "Image": "ami-0070c5311b7677678", 12 | "Size": "t3.medium", 13 | "Region": "us-east-1", 14 | "Platform": "linux/amd64", 15 | "Created": 1573575703, 16 | "Updated": 1573575719, 17 | "Started": 1573575703, 18 | "Stopped": 1573575719 19 | }, 20 | { 21 | "ID": "agent-123456789", 22 | "Name": "i-5203422c", 23 | "Provider": "amazon", 24 | "State": "running", 25 | "Address": "54.194.252.215", 26 | "Capacity": 2, 27 | "Error": "", 28 | "Image": "ami-0070c5311b7677678", 29 | "Size": "t3.medium", 30 | "Region": "us-east-1", 31 | "Platform": "linux/amd64", 32 | "Created": 1573575703, 33 | "Updated": 1573575719, 34 | "Started": 1573575703, 35 | "Stopped": 1573575719 36 | }, 37 | { 38 | "ID": "agent-123456789", 39 | "Name": "i-5203422c", 40 | "Provider": "amazon", 41 | "State": "running", 42 | "Address": "54.194.252.215", 43 | "Capacity": 2, 44 | "Error": "", 45 | "Image": "ami-0070c5311b7677678", 46 | "Size": "t3.medium", 47 | "Region": "us-east-1", 48 | "Platform": "linux/amd64", 49 | "Created": 1573575703, 50 | "Updated": 1573575719, 51 | "Started": 1573575703, 52 | "Stopped": 1573575719 53 | }, 54 | { 55 | "ID": "agent-123456789", 56 | "Name": "i-5203422c", 57 | "Provider": "amazon", 58 | "State": "running", 59 | "Address": "54.194.252.215", 60 | "Capacity": 2, 61 | "Error": "", 62 | "Image": "ami-0070c5311b7677678", 63 | "Size": "t3.medium", 64 | "Region": "us-east-1", 65 | "Platform": "linux/amd64", 66 | "Created": 1573600782, 67 | "Updated": 1573575719, 68 | "Started": 1573575703, 69 | "Stopped": 1573575719 70 | }, 71 | { 72 | "ID": "agent-123456789", 73 | "Name": "i-5203422c", 74 | "Provider": "amazon", 75 | "State": "error", 76 | "Address": "54.194.252.215", 77 | "Capacity": 2, 78 | "Error": "", 79 | "Image": "ami-0070c5311b7677678", 80 | "Size": "t3.medium", 81 | "Region": "us-east-1", 82 | "Platform": "linux/amd64", 83 | "Created": 1573600782, 84 | "Updated": 1573575719, 85 | "Started": 1573575703, 86 | "Stopped": 1573575719 87 | } 88 | ] 89 | } 90 | -------------------------------------------------------------------------------- /server/web/template/testdata/servers_empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "Items": [] 3 | } -------------------------------------------------------------------------------- /server/writer.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package server 6 | 7 | import ( 8 | "encoding/json" 9 | "errors" 10 | "net/http" 11 | "os" 12 | "strconv" 13 | ) 14 | 15 | // indent the json-encoded API responses 16 | var indent bool 17 | 18 | func init() { 19 | indent, _ = strconv.ParseBool( 20 | os.Getenv("HTTP_JSON_INDENT"), 21 | ) 22 | } 23 | 24 | var ( 25 | // errInvalidToken is returned when the api request token is invalid. 26 | errInvalidToken = errors.New("Invalid or missing token") 27 | 28 | // errUnauthorized is returned when the user is not authorized. 29 | errUnauthorized = errors.New("Unauthorized") 30 | 31 | // errForbidden is returned when user access is forbidden. 32 | errForbidden = errors.New("Forbidden") 33 | 34 | // errNotFound is returned when a resource is not found. 35 | errNotFound = errors.New("Not Found") 36 | ) 37 | 38 | // Error represents a json-encoded API error. 39 | type Error struct { 40 | Message string `json:"message"` 41 | } 42 | 43 | // writeErrorCode writes the json-encoded error message to the response. 44 | func writeErrorCode(w http.ResponseWriter, err error, status int) { 45 | writeJSON(w, &Error{Message: err.Error()}, status) 46 | } 47 | 48 | // writeError writes the json-encoded error message to the response 49 | // with a 500 internal server error. 50 | func writeError(w http.ResponseWriter, err error) { 51 | writeErrorCode(w, err, 500) 52 | } 53 | 54 | // writeNotFound writes the json-encoded error message to the response 55 | // with a 404 not found status code. 56 | func writeNotFound(w http.ResponseWriter, err error) { 57 | writeErrorCode(w, err, 404) 58 | } 59 | 60 | // writeUnauthorized writes the json-encoded error message to the response 61 | // with a 401 unauthorized status code. 62 | func writeUnauthorized(w http.ResponseWriter, err error) { 63 | writeErrorCode(w, err, 401) 64 | } 65 | 66 | // writeForbidden writes the json-encoded error message to the response 67 | // with a 403 forbidden status code. 68 | func writeForbidden(w http.ResponseWriter, err error) { 69 | writeErrorCode(w, err, 403) 70 | } 71 | 72 | // writeBadRequest writes the json-encoded error message to the response 73 | // with a 400 bad request status code. 74 | func writeBadRequest(w http.ResponseWriter, err error) { 75 | writeErrorCode(w, err, 400) 76 | } 77 | 78 | // writeJSON writes the json-encoded error message to the response 79 | // with a 400 bad request status code. 80 | func writeJSON(w http.ResponseWriter, v interface{}, status int) { 81 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 82 | w.Header().Set("X-Content-Type-Options", "nosniff") 83 | w.WriteHeader(status) 84 | enc := json.NewEncoder(w) 85 | if indent { 86 | enc.SetIndent("", " ") 87 | } 88 | enc.Encode(v) 89 | } 90 | -------------------------------------------------------------------------------- /store/db.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package store 6 | 7 | import ( 8 | "context" 9 | "database/sql" 10 | "time" 11 | 12 | ddl "github.com/drone/autoscaler/store/migrate" 13 | 14 | "github.com/jmoiron/sqlx" 15 | ) 16 | 17 | var noContext = context.Background() 18 | 19 | // Connect to a database and verify with a ping. 20 | func Connect(driver, datasource string, maxconn int, maxlifetime time.Duration) (*sqlx.DB, error) { 21 | db, err := sql.Open(driver, datasource) 22 | if err != nil { 23 | return nil, err 24 | } 25 | switch driver { 26 | case "postgres": 27 | db.SetMaxIdleConns(maxconn) 28 | db.SetConnMaxLifetime(maxlifetime) 29 | case "mysql": 30 | db.SetMaxIdleConns(0) 31 | case "sqlite3": 32 | db.SetMaxOpenConns(1) 33 | } 34 | dbx := sqlx.NewDb(db, driver) 35 | if err := pingDatabase(dbx); err != nil { 36 | return nil, err 37 | } 38 | if err := setupDatabase(dbx); err != nil { 39 | return nil, err 40 | } 41 | return dbx, nil 42 | } 43 | 44 | // Must is a helper function that wraps a call to Connect 45 | // and panics if the error is non-nil. 46 | func Must(db *sqlx.DB, err error) *sqlx.DB { 47 | if err != nil { 48 | panic(err) 49 | } 50 | return db 51 | } 52 | 53 | // helper function to ping the database with backoff to ensure 54 | // a connection can be established before we proceed with the 55 | // database setup and migration. 56 | func pingDatabase(db *sqlx.DB) (err error) { 57 | for i := 0; i < 30; i++ { 58 | err = db.Ping() 59 | if err == nil { 60 | return 61 | } 62 | time.Sleep(time.Second) 63 | } 64 | return 65 | } 66 | 67 | // helper function to setup the databsae by performing automated 68 | // database migration steps. 69 | func setupDatabase(db *sqlx.DB) error { 70 | return ddl.Migrate(db) 71 | } 72 | -------------------------------------------------------------------------------- /store/db_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package store 6 | 7 | import ( 8 | "os" 9 | "sync" 10 | 11 | "github.com/jmoiron/sqlx" 12 | 13 | _ "github.com/go-sql-driver/mysql" 14 | _ "github.com/lib/pq" 15 | _ "github.com/mattn/go-sqlite3" 16 | ) 17 | 18 | // connect opens a new test database connection. 19 | func connect() (*sqlx.DB, error) { 20 | var ( 21 | driver = "sqlite3" 22 | config = ":memory:" 23 | ) 24 | if os.Getenv("DATABASE_DRIVER") != "" { 25 | driver = os.Getenv("DATABASE_DRIVER") 26 | config = os.Getenv("DATABASE_CONFIG") 27 | } 28 | return Connect(driver, config, 0, 0) 29 | } 30 | 31 | // locker returns a new text locker. 32 | func locker() sync.Locker { 33 | driver := "sqlite3" 34 | if os.Getenv("DATABASE_DRIVER") != "" { 35 | driver = os.Getenv("DATABASE_DRIVER") 36 | } 37 | return NewLocker(driver) 38 | } 39 | -------------------------------------------------------------------------------- /store/lock.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package store 6 | 7 | import "sync" 8 | 9 | // NewLocker returns a new database mutex. If the driver 10 | // is mysql or postgres a noop is returned. 11 | func NewLocker(driver string) sync.Locker { 12 | switch driver { 13 | case "sqlite3": 14 | return new(sync.Mutex) 15 | default: 16 | return new(noopLocker) 17 | } 18 | } 19 | 20 | type noopLocker struct{} 21 | 22 | func (*noopLocker) Lock() {} 23 | func (*noopLocker) Unlock() {} 24 | -------------------------------------------------------------------------------- /store/migrate/migrate.go: -------------------------------------------------------------------------------- 1 | package ddl 2 | 3 | import ( 4 | "github.com/drone/autoscaler/store/migrate/mysql" 5 | "github.com/drone/autoscaler/store/migrate/postgres" 6 | "github.com/drone/autoscaler/store/migrate/sqlite" 7 | 8 | "github.com/jmoiron/sqlx" 9 | ) 10 | 11 | // Migrate performs the database migration. 12 | func Migrate(db *sqlx.DB) error { 13 | switch db.DriverName() { 14 | case "postgres": 15 | return postgres.Migrate(db.DB) 16 | case "mysql": 17 | return mysql.Migrate(db.DB) 18 | default: 19 | return sqlite.Migrate(db.DB) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /store/migrate/mysql/ddl.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package mysql 6 | 7 | //go:generate togo ddl -package mysql -dialect mysql 8 | -------------------------------------------------------------------------------- /store/migrate/mysql/ddl_gen.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | var migrations = []struct { 8 | name string 9 | stmt string 10 | }{ 11 | { 12 | name: "create-table-servers", 13 | stmt: createTableServers, 14 | }, 15 | { 16 | name: "create-index-server-id", 17 | stmt: createIndexServerId, 18 | }, 19 | { 20 | name: "create-index-server-state", 21 | stmt: createIndexServerState, 22 | }, 23 | } 24 | 25 | // Migrate performs the database migration. If the migration fails 26 | // and error is returned. 27 | func Migrate(db *sql.DB) error { 28 | if err := createTable(db); err != nil { 29 | return err 30 | } 31 | completed, err := selectCompleted(db) 32 | if err != nil && err != sql.ErrNoRows { 33 | return err 34 | } 35 | for _, migration := range migrations { 36 | if _, ok := completed[migration.name]; ok { 37 | 38 | continue 39 | } 40 | 41 | if _, err := db.Exec(migration.stmt); err != nil { 42 | return err 43 | } 44 | if err := insertMigration(db, migration.name); err != nil { 45 | return err 46 | } 47 | 48 | } 49 | return nil 50 | } 51 | 52 | func createTable(db *sql.DB) error { 53 | _, err := db.Exec(migrationTableCreate) 54 | return err 55 | } 56 | 57 | func insertMigration(db *sql.DB, name string) error { 58 | _, err := db.Exec(migrationInsert, name) 59 | return err 60 | } 61 | 62 | func selectCompleted(db *sql.DB) (map[string]struct{}, error) { 63 | migrations := map[string]struct{}{} 64 | rows, err := db.Query(migrationSelect) 65 | if err != nil { 66 | return nil, err 67 | } 68 | defer rows.Close() 69 | for rows.Next() { 70 | var name string 71 | if err := rows.Scan(&name); err != nil { 72 | return nil, err 73 | } 74 | migrations[name] = struct{}{} 75 | } 76 | return migrations, nil 77 | } 78 | 79 | // 80 | // migration table ddl and sql 81 | // 82 | 83 | var migrationTableCreate = ` 84 | CREATE TABLE IF NOT EXISTS migrations ( 85 | name VARCHAR(255) 86 | ,UNIQUE(name) 87 | ) 88 | ` 89 | 90 | var migrationInsert = ` 91 | INSERT INTO migrations (name) VALUES (?) 92 | ` 93 | 94 | var migrationSelect = ` 95 | SELECT name FROM migrations 96 | ` 97 | 98 | // 99 | // 001_create_table_servers.sql 100 | // 101 | 102 | var createTableServers = ` 103 | CREATE TABLE servers ( 104 | server_name VARCHAR(50) PRIMARY KEY 105 | ,server_id VARCHAR(250) 106 | ,server_provider VARCHAR(50) 107 | ,server_state VARCHAR(50) 108 | ,server_image VARCHAR(250) 109 | ,server_region VARCHAR(50) 110 | ,server_size VARCHAR(50) 111 | ,server_platform VARCHAR(50) 112 | ,server_address VARCHAR(250) 113 | ,server_capacity INTEGER 114 | ,server_secret VARCHAR(50) 115 | ,server_error BLOB 116 | ,server_ca_key BLOB 117 | ,server_ca_cert BLOB 118 | ,server_tls_key BLOB 119 | ,server_tls_cert BLOB 120 | ,server_created INTEGER 121 | ,server_updated INTEGER 122 | ,server_started INTEGER 123 | ,server_stopped INTEGER 124 | ); 125 | ` 126 | 127 | var createIndexServerId = ` 128 | CREATE INDEX ix_servers_id ON servers (server_id); 129 | ` 130 | 131 | var createIndexServerState = ` 132 | CREATE INDEX ix_servers_state ON servers (server_state); 133 | ` 134 | -------------------------------------------------------------------------------- /store/migrate/mysql/files/001_create_table_servers.sql: -------------------------------------------------------------------------------- 1 | -- name: create-table-servers 2 | 3 | CREATE TABLE servers ( 4 | server_name VARCHAR(50) PRIMARY KEY 5 | ,server_id VARCHAR(250) 6 | ,server_provider VARCHAR(50) 7 | ,server_state VARCHAR(50) 8 | ,server_image VARCHAR(250) 9 | ,server_region VARCHAR(50) 10 | ,server_size VARCHAR(50) 11 | ,server_platform VARCHAR(50) 12 | ,server_address VARCHAR(250) 13 | ,server_capacity INTEGER 14 | ,server_secret VARCHAR(50) 15 | ,server_error BLOB 16 | ,server_ca_key BLOB 17 | ,server_ca_cert BLOB 18 | ,server_tls_key BLOB 19 | ,server_tls_cert BLOB 20 | ,server_created INTEGER 21 | ,server_updated INTEGER 22 | ,server_started INTEGER 23 | ,server_stopped INTEGER 24 | ); 25 | 26 | -- name: create-index-server-id 27 | 28 | CREATE INDEX ix_servers_id ON servers (server_id); 29 | 30 | -- name: create-index-server-state 31 | 32 | CREATE INDEX ix_servers_state ON servers (server_state); 33 | -------------------------------------------------------------------------------- /store/migrate/postgres/ddl.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package postgres 6 | 7 | //go:generate togo ddl -package postgres -dialect postgres 8 | -------------------------------------------------------------------------------- /store/migrate/postgres/ddl_gen.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | var migrations = []struct { 8 | name string 9 | stmt string 10 | }{ 11 | { 12 | name: "create-table-servers", 13 | stmt: createTableServers, 14 | }, 15 | { 16 | name: "create-index-server-id", 17 | stmt: createIndexServerId, 18 | }, 19 | { 20 | name: "create-index-server-state", 21 | stmt: createIndexServerState, 22 | }, 23 | } 24 | 25 | // Migrate performs the database migration. If the migration fails 26 | // and error is returned. 27 | func Migrate(db *sql.DB) error { 28 | if err := createTable(db); err != nil { 29 | return err 30 | } 31 | completed, err := selectCompleted(db) 32 | if err != nil && err != sql.ErrNoRows { 33 | return err 34 | } 35 | for _, migration := range migrations { 36 | if _, ok := completed[migration.name]; ok { 37 | 38 | continue 39 | } 40 | 41 | if _, err := db.Exec(migration.stmt); err != nil { 42 | return err 43 | } 44 | if err := insertMigration(db, migration.name); err != nil { 45 | return err 46 | } 47 | 48 | } 49 | return nil 50 | } 51 | 52 | func createTable(db *sql.DB) error { 53 | _, err := db.Exec(migrationTableCreate) 54 | return err 55 | } 56 | 57 | func insertMigration(db *sql.DB, name string) error { 58 | _, err := db.Exec(migrationInsert, name) 59 | return err 60 | } 61 | 62 | func selectCompleted(db *sql.DB) (map[string]struct{}, error) { 63 | migrations := map[string]struct{}{} 64 | rows, err := db.Query(migrationSelect) 65 | if err != nil { 66 | return nil, err 67 | } 68 | defer rows.Close() 69 | for rows.Next() { 70 | var name string 71 | if err := rows.Scan(&name); err != nil { 72 | return nil, err 73 | } 74 | migrations[name] = struct{}{} 75 | } 76 | return migrations, nil 77 | } 78 | 79 | // 80 | // migration table ddl and sql 81 | // 82 | 83 | var migrationTableCreate = ` 84 | CREATE TABLE IF NOT EXISTS migrations ( 85 | name VARCHAR(255) 86 | ,UNIQUE(name) 87 | ) 88 | ` 89 | 90 | var migrationInsert = ` 91 | INSERT INTO migrations (name) VALUES ($1) 92 | ` 93 | 94 | var migrationSelect = ` 95 | SELECT name FROM migrations 96 | ` 97 | 98 | // 99 | // 001_create_table_servers.sql 100 | // 101 | 102 | var createTableServers = ` 103 | CREATE TABLE servers ( 104 | server_name VARCHAR(50) PRIMARY KEY 105 | ,server_id VARCHAR(250) 106 | ,server_provider VARCHAR(50) 107 | ,server_state VARCHAR(50) 108 | ,server_image VARCHAR(250) 109 | ,server_region VARCHAR(50) 110 | ,server_size VARCHAR(50) 111 | ,server_platform VARCHAR(50) 112 | ,server_address VARCHAR(250) 113 | ,server_capacity INTEGER 114 | ,server_secret VARCHAR(50) 115 | ,server_error TEXT 116 | ,server_ca_key TEXT 117 | ,server_ca_cert TEXT 118 | ,server_tls_key TEXT 119 | ,server_tls_cert TEXT 120 | ,server_created INTEGER 121 | ,server_updated INTEGER 122 | ,server_started INTEGER 123 | ,server_stopped INTEGER 124 | ); 125 | ` 126 | 127 | var createIndexServerId = ` 128 | CREATE INDEX ix_servers_id ON servers (server_id); 129 | ` 130 | 131 | var createIndexServerState = ` 132 | CREATE INDEX ix_servers_state ON servers (server_state); 133 | ` 134 | -------------------------------------------------------------------------------- /store/migrate/postgres/files/001_create_table_servers.sql: -------------------------------------------------------------------------------- 1 | -- name: create-table-servers 2 | 3 | CREATE TABLE servers ( 4 | server_name VARCHAR(50) PRIMARY KEY 5 | ,server_id VARCHAR(250) 6 | ,server_provider VARCHAR(50) 7 | ,server_state VARCHAR(50) 8 | ,server_image VARCHAR(250) 9 | ,server_region VARCHAR(50) 10 | ,server_size VARCHAR(50) 11 | ,server_platform VARCHAR(50) 12 | ,server_address VARCHAR(250) 13 | ,server_capacity INTEGER 14 | ,server_secret VARCHAR(50) 15 | ,server_error TEXT 16 | ,server_ca_key TEXT 17 | ,server_ca_cert TEXT 18 | ,server_tls_key TEXT 19 | ,server_tls_cert TEXT 20 | ,server_created INTEGER 21 | ,server_updated INTEGER 22 | ,server_started INTEGER 23 | ,server_stopped INTEGER 24 | ); 25 | 26 | -- name: create-index-server-id 27 | 28 | CREATE INDEX ix_servers_id ON servers (server_id); 29 | 30 | -- name: create-index-server-state 31 | 32 | CREATE INDEX ix_servers_state ON servers (server_state); 33 | -------------------------------------------------------------------------------- /store/migrate/sqlite/ddl.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package sqlite 6 | 7 | //go:generate togo ddl -package sqlite -dialect sqlite3 8 | -------------------------------------------------------------------------------- /store/migrate/sqlite/ddl_gen.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | var migrations = []struct { 8 | name string 9 | stmt string 10 | }{ 11 | { 12 | name: "create-table-servers", 13 | stmt: createTableServers, 14 | }, 15 | { 16 | name: "create-index-server-id", 17 | stmt: createIndexServerId, 18 | }, 19 | { 20 | name: "create-index-server-state", 21 | stmt: createIndexServerState, 22 | }, 23 | } 24 | 25 | // Migrate performs the database migration. If the migration fails 26 | // and error is returned. 27 | func Migrate(db *sql.DB) error { 28 | if err := createTable(db); err != nil { 29 | return err 30 | } 31 | completed, err := selectCompleted(db) 32 | if err != nil && err != sql.ErrNoRows { 33 | return err 34 | } 35 | for _, migration := range migrations { 36 | if _, ok := completed[migration.name]; ok { 37 | 38 | continue 39 | } 40 | 41 | if _, err := db.Exec(migration.stmt); err != nil { 42 | return err 43 | } 44 | if err := insertMigration(db, migration.name); err != nil { 45 | return err 46 | } 47 | 48 | } 49 | return nil 50 | } 51 | 52 | func createTable(db *sql.DB) error { 53 | _, err := db.Exec(migrationTableCreate) 54 | return err 55 | } 56 | 57 | func insertMigration(db *sql.DB, name string) error { 58 | _, err := db.Exec(migrationInsert, name) 59 | return err 60 | } 61 | 62 | func selectCompleted(db *sql.DB) (map[string]struct{}, error) { 63 | migrations := map[string]struct{}{} 64 | rows, err := db.Query(migrationSelect) 65 | if err != nil { 66 | return nil, err 67 | } 68 | defer rows.Close() 69 | for rows.Next() { 70 | var name string 71 | if err := rows.Scan(&name); err != nil { 72 | return nil, err 73 | } 74 | migrations[name] = struct{}{} 75 | } 76 | return migrations, nil 77 | } 78 | 79 | // 80 | // migration table ddl and sql 81 | // 82 | 83 | var migrationTableCreate = ` 84 | CREATE TABLE IF NOT EXISTS migrations ( 85 | name VARCHAR(255) 86 | ,UNIQUE(name) 87 | ) 88 | ` 89 | 90 | var migrationInsert = ` 91 | INSERT INTO migrations (name) VALUES (?) 92 | ` 93 | 94 | var migrationSelect = ` 95 | SELECT name FROM migrations 96 | ` 97 | 98 | // 99 | // 001_create_table_servers.sql 100 | // 101 | 102 | var createTableServers = ` 103 | CREATE TABLE IF NOT EXISTS servers ( 104 | server_name TEXT PRIMARY KEY 105 | ,server_id TEXT 106 | ,server_provider TEXT 107 | ,server_state TEXT 108 | ,server_image TEXT 109 | ,server_region TEXT 110 | ,server_size TEXT 111 | ,server_platform TEXT 112 | ,server_address TEXT 113 | ,server_capacity INTEGER 114 | ,server_secret TEXT 115 | ,server_error TEXT 116 | ,server_ca_key TEXT 117 | ,server_ca_cert TEXT 118 | ,server_tls_key TEXT 119 | ,server_tls_cert TEXT 120 | ,server_created INTEGER 121 | ,server_updated INTEGER 122 | ,server_started INTEGER 123 | ,server_stopped INTEGER 124 | ); 125 | ` 126 | 127 | var createIndexServerId = ` 128 | CREATE INDEX IF NOT EXISTS ix_servers_id ON servers (server_id); 129 | ` 130 | 131 | var createIndexServerState = ` 132 | CREATE INDEX IF NOT EXISTS ix_servers_state ON servers (server_state); 133 | ` 134 | -------------------------------------------------------------------------------- /store/migrate/sqlite/files/001_create_table_servers.sql: -------------------------------------------------------------------------------- 1 | -- name: create-table-servers 2 | 3 | CREATE TABLE IF NOT EXISTS servers ( 4 | server_name TEXT PRIMARY KEY 5 | ,server_id TEXT 6 | ,server_provider TEXT 7 | ,server_state TEXT 8 | ,server_image TEXT 9 | ,server_region TEXT 10 | ,server_size TEXT 11 | ,server_platform TEXT 12 | ,server_address TEXT 13 | ,server_capacity INTEGER 14 | ,server_secret TEXT 15 | ,server_error TEXT 16 | ,server_ca_key TEXT 17 | ,server_ca_cert TEXT 18 | ,server_tls_key TEXT 19 | ,server_tls_cert TEXT 20 | ,server_created INTEGER 21 | ,server_updated INTEGER 22 | ,server_started INTEGER 23 | ,server_stopped INTEGER 24 | ); 25 | 26 | -- name: create-index-server-id 27 | 28 | CREATE INDEX IF NOT EXISTS ix_servers_id ON servers (server_id); 29 | 30 | -- name: create-index-server-state 31 | 32 | CREATE INDEX IF NOT EXISTS ix_servers_state ON servers (server_state); 33 | -------------------------------------------------------------------------------- /store/util.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package store 6 | 7 | import "strings" 8 | 9 | // helper function returns true if the error message 10 | // indicates the connection has been reset. 11 | func isConnReset(err error) bool { 12 | if err == nil { 13 | return false 14 | } 15 | return strings.Contains(err.Error(), 16 | "connection reset by peer") 17 | } 18 | -------------------------------------------------------------------------------- /store/util_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Drone.IO Inc 2 | // Use of this source code is governed by the Polyform License 3 | // that can be found in the LICENSE file. 4 | 5 | package store 6 | 7 | import ( 8 | "database/sql" 9 | "errors" 10 | "testing" 11 | ) 12 | 13 | func TestConnectionReset(t *testing.T) { 14 | if isConnReset(nil) { 15 | t.Errorf("Expect nil error returns false") 16 | } 17 | if isConnReset(sql.ErrNoRows) { 18 | t.Errorf("Expect ErrNoRows returns false") 19 | } 20 | if !isConnReset(errors.New("read: connection reset by peer")) { 21 | t.Errorf("Expect connection reset by peer return true") 22 | } 23 | } 24 | 25 | // connect: connection timed out 26 | --------------------------------------------------------------------------------
InvalidInstanceID.NotFound