feat: add Timeweb Cloud provider for Woodpecker CI autoscaler

- Implement timewebcloud provider with DeployAgent, RemoveAgent, ListDeployedAgentNames
- Add minimal HTTP API client for Timeweb Cloud (create/list/delete servers)
- Register provider in main.go with CLI flags
- Add timeweb-list and timeweb-tester utilities
- Include Dockerfile and docker-compose.yml for deployment
- Update DEPLOY.md with verified OS/preset IDs
This commit is contained in:
2026-05-16 13:09:07 +03:00
commit 191cdd108f
34 changed files with 8651 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
woodpecker-autoscaler
timeweb-list
timeweb-tester
timeweb-debug
*.exe
*.test
*.out
.DS_Store

743
CHANGELOG.md Normal file
View File

@@ -0,0 +1,743 @@
# Changelog
## [1.4.0](https://github.com/woodpecker-ci/autoscaler/releases/tag/1.4.0) - 2026-04-29
### ❤️ Thanks to all contributors! ❤️
@6543, @mendarb
### 📈 Enhancement
- Make agent extra labels an explicit option [[#584](https://github.com/woodpecker-ci/autoscaler/pull/584)]
- Move code in subpackages [[#585](https://github.com/woodpecker-ci/autoscaler/pull/585)]
### 🐛 Bug Fixes
- Propagate tags to EBS volumes on AWS instances [[#568](https://github.com/woodpecker-ci/autoscaler/pull/568)]
### 📦️ Dependency
- fix(deps): update module github.com/hetznercloud/hcloud-go/v2 to v2.39.0 [[#599](https://github.com/woodpecker-ci/autoscaler/pull/599)]
- fix(deps): update golang deps non-major [[#591](https://github.com/woodpecker-ci/autoscaler/pull/591)]
- fix(deps): update module github.com/vultr/govultr/v3 to v3.31.1 [[#589](https://github.com/woodpecker-ci/autoscaler/pull/589)]
## [1.3.0](https://github.com/woodpecker-ci/autoscaler/releases/tag/1.3.0) - 2026-04-21
### ❤️ Thanks to all contributors! ❤️
@6543, @BnMcG
### 📈 Enhancement
- Surface regex compile error [[#586](https://github.com/woodpecker-ci/autoscaler/pull/586)]
### 🐛 Bug Fixes
- fix(scaleway): tidy up scaleway volumes [[#559](https://github.com/woodpecker-ci/autoscaler/pull/559)]
### 📦️ Dependency
- fix(deps): update golang deps non-major [[#588](https://github.com/woodpecker-ci/autoscaler/pull/588)]
- chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v4.1.1 [[#587](https://github.com/woodpecker-ci/autoscaler/pull/587)]
- fix(deps): update golang deps non-major [[#578](https://github.com/woodpecker-ci/autoscaler/pull/578)]
- fix(deps): update golang.org/x/exp digest to 746e56f [[#579](https://github.com/woodpecker-ci/autoscaler/pull/579)]
- chore(deps): update dependency golangci/golangci-lint to v2.11.4 [[#554](https://github.com/woodpecker-ci/autoscaler/pull/554)]
- fix(deps): update module github.com/vultr/govultr/v3 to v3.30.0 [[#576](https://github.com/woodpecker-ci/autoscaler/pull/576)]
- chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v4 [[#556](https://github.com/woodpecker-ci/autoscaler/pull/556)]
- fix(deps): update golang.org/x/exp digest to 7ab1446 [[#560](https://github.com/woodpecker-ci/autoscaler/pull/560)]
- fix(deps): update golang deps non-major [[#575](https://github.com/woodpecker-ci/autoscaler/pull/575)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/config to v1.32.14 [[#574](https://github.com/woodpecker-ci/autoscaler/pull/574)]
- fix(deps): update golang deps non-major [[#573](https://github.com/woodpecker-ci/autoscaler/pull/573)]
- fix(deps): update golang deps non-major [[#572](https://github.com/woodpecker-ci/autoscaler/pull/572)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v2.11.4 [[#569](https://github.com/woodpecker-ci/autoscaler/pull/569)]
- fix(deps): update golang deps non-major [[#571](https://github.com/woodpecker-ci/autoscaler/pull/571)]
- fix(deps): update module github.com/urfave/cli/v3 to v3.8.0 [[#570](https://github.com/woodpecker-ci/autoscaler/pull/570)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.296.0 [[#562](https://github.com/woodpecker-ci/autoscaler/pull/562)]
- fix(deps): update golang deps non-major [[#561](https://github.com/woodpecker-ci/autoscaler/pull/561)]
- fix(deps): update golang deps non-major [[#557](https://github.com/woodpecker-ci/autoscaler/pull/557)]
- fix(deps): update golang.org/x/exp digest to 3dfff04 [[#548](https://github.com/woodpecker-ci/autoscaler/pull/548)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v2.11.3 [[#555](https://github.com/woodpecker-ci/autoscaler/pull/555)]
- chore(deps): update dependency go to v1.26.1 [[#553](https://github.com/woodpecker-ci/autoscaler/pull/553)]
- chore(deps): update dependency golangci/golangci-lint to v2.11.0 [[#543](https://github.com/woodpecker-ci/autoscaler/pull/543)]
- fix(deps): update golang deps non-major [[#552](https://github.com/woodpecker-ci/autoscaler/pull/552)]
- fix(deps): update golang deps non-major [[#551](https://github.com/woodpecker-ci/autoscaler/pull/551)]
- fix(deps): update golang deps non-major [[#541](https://github.com/woodpecker-ci/autoscaler/pull/541)]
- chore(deps): update docker.io/golang docker tag to v1.26 [[#544](https://github.com/woodpecker-ci/autoscaler/pull/544)]
- chore(deps): update pre-commit non-major [[#549](https://github.com/woodpecker-ci/autoscaler/pull/549)]
- fix(deps): update golang.org/x/exp digest to 81e46e3 [[#542](https://github.com/woodpecker-ci/autoscaler/pull/542)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v2.9.0 [[#546](https://github.com/woodpecker-ci/autoscaler/pull/546)]
- chore(deps): update golang docker tag to v1.26 [[#545](https://github.com/woodpecker-ci/autoscaler/pull/545)]
- fix(deps): update module golang.org/x/oauth2 to v0.35.0 [[#540](https://github.com/woodpecker-ci/autoscaler/pull/540)]
- fix(deps): update module github.com/vultr/govultr/v3 to v3.27.0 [[#536](https://github.com/woodpecker-ci/autoscaler/pull/536)]
- chore(deps): update dependency go to v1.25.7 [[#535](https://github.com/woodpecker-ci/autoscaler/pull/535)]
- fix(deps): update module github.com/linode/linodego to v1.65.0 [[#533](https://github.com/woodpecker-ci/autoscaler/pull/533)]
## [1.2.0](https://github.com/woodpecker-ci/autoscaler/releases/tag/1.2.0) - 2026-01-30
### ❤️ Thanks to all contributors! ❤️
@jooola, @xoxys
### 📈 Enhancement
- feat: add version flag [[#523](https://github.com/woodpecker-ci/autoscaler/pull/523)]
- feat: add Woodpecker to Hetzner Cloud client user agent [[#519](https://github.com/woodpecker-ci/autoscaler/pull/519)]
### 📦️ Dependency
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.285.0 [[#527](https://github.com/woodpecker-ci/autoscaler/pull/527)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.284.0 [[#526](https://github.com/woodpecker-ci/autoscaler/pull/526)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.283.0 [[#525](https://github.com/woodpecker-ci/autoscaler/pull/525)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.282.0 [[#524](https://github.com/woodpecker-ci/autoscaler/pull/524)]
- fix(deps): update golang deps non-major [[#521](https://github.com/woodpecker-ci/autoscaler/pull/521)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.280.0 [[#520](https://github.com/woodpecker-ci/autoscaler/pull/520)]
- fix(deps): update golang.org/x/exp digest to 716be56 [[#515](https://github.com/woodpecker-ci/autoscaler/pull/515)]
- fix(deps): update module github.com/urfave/cli/v3 to v3.6.2 [[#518](https://github.com/woodpecker-ci/autoscaler/pull/518)]
- chore(deps): update pre-commit hook adrienverge/yamllint to v1.38.0 [[#516](https://github.com/woodpecker-ci/autoscaler/pull/516)]
- fix(deps): update module github.com/hetznercloud/hcloud-go/v2 to v2.35.0 [[#517](https://github.com/woodpecker-ci/autoscaler/pull/517)]
- fix(deps): update golang deps non-major [[#514](https://github.com/woodpecker-ci/autoscaler/pull/514)]
- fix(deps): update module go.woodpecker-ci.org/woodpecker/v3 to v3.13.0 [[#512](https://github.com/woodpecker-ci/autoscaler/pull/512)]
## [1.1.5](https://github.com/woodpecker-ci/autoscaler/releases/tag/1.1.5) - 2026-01-13
### ❤️ Thanks to all contributors! ❤️
@qwerty287
### 🐛 Bug Fixes
- Fix log format messages [[#502](https://github.com/woodpecker-ci/autoscaler/pull/502)]
### 📦️ Dependency
- chore(deps): update pre-commit non-major [[#511](https://github.com/woodpecker-ci/autoscaler/pull/511)]
- chore(deps): update docker.io/woodpeckerci/plugin-ready-release-go docker tag to v3.4.1 [[#509](https://github.com/woodpecker-ci/autoscaler/pull/509)]
- fix(deps): update golang deps non-major [[#507](https://github.com/woodpecker-ci/autoscaler/pull/507)]
- chore(deps): update docker.io/woodpeckerci/plugin-editorconfig-checker docker tag to v0.3.3 [[#506](https://github.com/woodpecker-ci/autoscaler/pull/506)]
- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v6.0.4 [[#508](https://github.com/woodpecker-ci/autoscaler/pull/508)]
- chore(deps): update dependency golangci/golangci-lint to v2.8.0 [[#510](https://github.com/woodpecker-ci/autoscaler/pull/510)]
- fix(deps): update module github.com/hetznercloud/hcloud-go/v2 to v2.34.0 [[#505](https://github.com/woodpecker-ci/autoscaler/pull/505)]
- fix(deps): update module github.com/linode/linodego to v1.64.0 [[#504](https://github.com/woodpecker-ci/autoscaler/pull/504)]
## [1.1.4](https://github.com/woodpecker-ci/autoscaler/releases/tag/1.1.4) - 2025-12-23
### ❤️ Thanks to all contributors! ❤️
@mossylion
### 🐛 Bug Fixes
- Allow setting Scaleway storage type and default to l_ssd [[#501](https://github.com/woodpecker-ci/autoscaler/pull/501)]
### 📦️ Dependency
- fix(deps): update golang.org/x/exp digest to 944ab1f [[#499](https://github.com/woodpecker-ci/autoscaler/pull/499)]
- fix(deps): update golang deps non-major [[#500](https://github.com/woodpecker-ci/autoscaler/pull/500)]
- fix(deps): update golang deps non-major [[#498](https://github.com/woodpecker-ci/autoscaler/pull/498)]
- fix(deps): update golang deps non-major [[#496](https://github.com/woodpecker-ci/autoscaler/pull/496)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.277.0 [[#495](https://github.com/woodpecker-ci/autoscaler/pull/495)]
- chore(deps): update pre-commit hook igorshubovych/markdownlint-cli to v0.47.0 [[#494](https://github.com/woodpecker-ci/autoscaler/pull/494)]
- fix(deps): update golang.org/x/exp digest to 8475f28 [[#493](https://github.com/woodpecker-ci/autoscaler/pull/493)]
- fix(deps): update module github.com/vultr/govultr/v3 to v3.26.0 [[#492](https://github.com/woodpecker-ci/autoscaler/pull/492)]
- fix(deps): update golang deps non-major [[#491](https://github.com/woodpecker-ci/autoscaler/pull/491)]
- chore(deps): update dependency golangci/golangci-lint to v2.7.2 [[#489](https://github.com/woodpecker-ci/autoscaler/pull/489)]
- fix(deps): update golang deps non-major [[#490](https://github.com/woodpecker-ci/autoscaler/pull/490)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v2.7.2 [[#487](https://github.com/woodpecker-ci/autoscaler/pull/487)]
## [1.1.3](https://github.com/woodpecker-ci/autoscaler/releases/tag/1.1.3) - 2025-12-06
### ❤️ Thanks to all contributors! ❤️
@6543, @xoxys
### 🐛 Bug Fixes
- Ensure latest qemu packages are installed [[#484](https://github.com/woodpecker-ci/autoscaler/pull/484)]
### 📦️ Dependency
- chore(deps): update dependency golangci/golangci-lint to v2.7.1 [[#485](https://github.com/woodpecker-ci/autoscaler/pull/485)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v2.7.1 [[#486](https://github.com/woodpecker-ci/autoscaler/pull/486)]
- fix(deps): update golang deps non-major [[#483](https://github.com/woodpecker-ci/autoscaler/pull/483)]
- chore(deps): update dependency golangci/golangci-lint to v2.6.2 [[#471](https://github.com/woodpecker-ci/autoscaler/pull/471)]
- fix(deps): update golang.org/x/exp digest to 87e1e73 [[#482](https://github.com/woodpecker-ci/autoscaler/pull/482)]
- fix(deps): update golang deps non-major [[#481](https://github.com/woodpecker-ci/autoscaler/pull/481)]
- chore(deps): update pre-commit hook igorshubovych/markdownlint-cli to v0.46.0 [[#479](https://github.com/woodpecker-ci/autoscaler/pull/479)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/config to v1.32.1 [[#480](https://github.com/woodpecker-ci/autoscaler/pull/480)]
- fix(deps): update golang deps non-major [[#478](https://github.com/woodpecker-ci/autoscaler/pull/478)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.273.0 [[#477](https://github.com/woodpecker-ci/autoscaler/pull/477)]
- fix(deps): update golang deps non-major [[#476](https://github.com/woodpecker-ci/autoscaler/pull/476)]
- fix(deps): update golang deps non-major [[#475](https://github.com/woodpecker-ci/autoscaler/pull/475)]
- fix(deps): update module github.com/urfave/cli/v3 to v3.6.1 [[#474](https://github.com/woodpecker-ci/autoscaler/pull/474)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.269.0 [[#473](https://github.com/woodpecker-ci/autoscaler/pull/473)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v2.6.2 [[#472](https://github.com/woodpecker-ci/autoscaler/pull/472)]
- fix(deps): update golang.org/x/exp digest to e25ba8c [[#470](https://github.com/woodpecker-ci/autoscaler/pull/470)]
- fix(deps): update golang deps non-major [[#469](https://github.com/woodpecker-ci/autoscaler/pull/469)]
- fix(deps): update golang deps non-major [[#468](https://github.com/woodpecker-ci/autoscaler/pull/468)]
- fix(deps): update module github.com/urfave/cli/v3 to v3.6.0 [[#467](https://github.com/woodpecker-ci/autoscaler/pull/467)]
- chore(deps): update dependency golangci/golangci-lint to v2.6.1 [[#464](https://github.com/woodpecker-ci/autoscaler/pull/464)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v2.6.1 [[#465](https://github.com/woodpecker-ci/autoscaler/pull/465)]
- fix(deps): update golang deps non-major [[#466](https://github.com/woodpecker-ci/autoscaler/pull/466)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.263.0 [[#463](https://github.com/woodpecker-ci/autoscaler/pull/463)]
- fix(deps): update golang deps non-major [[#462](https://github.com/woodpecker-ci/autoscaler/pull/462)]
- fix(deps): update golang deps non-major [[#461](https://github.com/woodpecker-ci/autoscaler/pull/461)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.261.0 [[#460](https://github.com/woodpecker-ci/autoscaler/pull/460)]
- chore(deps): update node.js to v24 [[#458](https://github.com/woodpecker-ci/autoscaler/pull/458)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v2.6.0 [[#457](https://github.com/woodpecker-ci/autoscaler/pull/457)]
- chore(deps): update dependency golangci/golangci-lint to v2.6.0 [[#456](https://github.com/woodpecker-ci/autoscaler/pull/456)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.260.0 [[#459](https://github.com/woodpecker-ci/autoscaler/pull/459)]
- fix(deps): update golang deps non-major [[#455](https://github.com/woodpecker-ci/autoscaler/pull/455)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.259.0 [[#454](https://github.com/woodpecker-ci/autoscaler/pull/454)]
- fix(deps): update golang.org/x/exp digest to a4bb9ff [[#451](https://github.com/woodpecker-ci/autoscaler/pull/451)]
- chore(deps): update dependency mvdan/gofumpt to v0.9.2 [[#452](https://github.com/woodpecker-ci/autoscaler/pull/452)]
- fix(deps): update golang deps non-major [[#453](https://github.com/woodpecker-ci/autoscaler/pull/453)]
- fix(deps): update golang deps non-major [[#450](https://github.com/woodpecker-ci/autoscaler/pull/450)]
- Use our own editorconfig checker plugin [[#447](https://github.com/woodpecker-ci/autoscaler/pull/447)]
- fix(deps): update module go.woodpecker-ci.org/woodpecker/v3 to v3.11.0 [[#448](https://github.com/woodpecker-ci/autoscaler/pull/448)]
## [1.1.2](https://github.com/woodpecker-ci/autoscaler/releases/tag/1.1.2) - 2025-10-19
### ❤️ Thanks to all contributors! ❤️
@xoxys
### 📦️ Dependency
- fix(deps): update golang.org/x/exp digest to 90e834f [[#444](https://github.com/woodpecker-ci/autoscaler/pull/444)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.257.2 [[#445](https://github.com/woodpecker-ci/autoscaler/pull/445)]
- fix(deps): update golang deps non-major [[#443](https://github.com/woodpecker-ci/autoscaler/pull/443)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.257.0 [[#442](https://github.com/woodpecker-ci/autoscaler/pull/442)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.256.0 [[#441](https://github.com/woodpecker-ci/autoscaler/pull/441)]
- fix(deps): update golang deps non-major [[#440](https://github.com/woodpecker-ci/autoscaler/pull/440)]
- chore(deps): update docker.io/mstruebing/editorconfig-checker docker tag to v3.4.1 [[#439](https://github.com/woodpecker-ci/autoscaler/pull/439)]
- fix(deps): update golang.org/x/exp digest to d2f985d [[#438](https://github.com/woodpecker-ci/autoscaler/pull/438)]
- fix(deps): update golang deps non-major [[#437](https://github.com/woodpecker-ci/autoscaler/pull/437)]
- fix(deps): update golang.org/x/exp digest to 27f1f14 [[#434](https://github.com/woodpecker-ci/autoscaler/pull/434)]
- fix(deps): update module github.com/hetznercloud/hcloud-go/v2 to v2.27.0 [[#436](https://github.com/woodpecker-ci/autoscaler/pull/436)]
- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v6.0.3 [[#435](https://github.com/woodpecker-ci/autoscaler/pull/435)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/config to v1.31.12 [[#433](https://github.com/woodpecker-ci/autoscaler/pull/433)]
- fix(deps): update module go.woodpecker-ci.org/woodpecker/v3 to v3.10.0 [[#432](https://github.com/woodpecker-ci/autoscaler/pull/432)]
- chore(deps): update pre-commit hook hadolint/hadolint to v2.14.0 [[#431](https://github.com/woodpecker-ci/autoscaler/pull/431)]
- fix(deps): update golang deps non-major [[#430](https://github.com/woodpecker-ci/autoscaler/pull/430)]
- fix(deps): update golang deps non-major [[#429](https://github.com/woodpecker-ci/autoscaler/pull/429)]
- fix(deps): update golang deps non-major [[#427](https://github.com/woodpecker-ci/autoscaler/pull/427)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/config to v1.31.9 [[#426](https://github.com/woodpecker-ci/autoscaler/pull/426)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v2.5.0 [[#425](https://github.com/woodpecker-ci/autoscaler/pull/425)]
- fix(deps): update module github.com/hetznercloud/hcloud-go/v2 to v2.24.0 [[#424](https://github.com/woodpecker-ci/autoscaler/pull/424)]
- fix(deps): update module github.com/vultr/govultr/v3 to v3.24.0 [[#423](https://github.com/woodpecker-ci/autoscaler/pull/423)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.253.0 [[#422](https://github.com/woodpecker-ci/autoscaler/pull/422)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.252.0 [[#421](https://github.com/woodpecker-ci/autoscaler/pull/421)]
- fix(deps): update golang deps non-major [[#420](https://github.com/woodpecker-ci/autoscaler/pull/420)]
- chore(deps): update docker.io/mstruebing/editorconfig-checker docker tag to v3.4.0 [[#419](https://github.com/woodpecker-ci/autoscaler/pull/419)]
- fix(deps): update golang.org/x/exp digest to df92998 [[#418](https://github.com/woodpecker-ci/autoscaler/pull/418)]
- fix(deps): update golang deps non-major [[#417](https://github.com/woodpecker-ci/autoscaler/pull/417)]
- fix(deps): update golang deps non-major [[#416](https://github.com/woodpecker-ci/autoscaler/pull/416)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.245.0 [[#414](https://github.com/woodpecker-ci/autoscaler/pull/414)]
### Misc
- Regenerate woodpecker-client mocks [[#446](https://github.com/woodpecker-ci/autoscaler/pull/446)]
- Migrate mockery to v3 [[#428](https://github.com/woodpecker-ci/autoscaler/pull/428)]
## [1.1.1](https://github.com/woodpecker-ci/autoscaler/releases/tag/1.1.1) - 2025-08-18
### ❤️ Thanks to all contributors! ❤️
@xoxys
### 🐛 Bug Fixes
- Improve logging for fallback server types in hcloud provider [[#413](https://github.com/woodpecker-ci/autoscaler/pull/413)]
### 📦️ Dependency
- chore(deps): update golang docker tag to v1.25 [[#409](https://github.com/woodpecker-ci/autoscaler/pull/409)]
- fix(deps): update golang.org/x/exp digest to 42675ad [[#407](https://github.com/woodpecker-ci/autoscaler/pull/407)]
- chore(deps): update docker.io/golang docker tag to v1.25 [[#408](https://github.com/woodpecker-ci/autoscaler/pull/408)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v2.4.0 [[#411](https://github.com/woodpecker-ci/autoscaler/pull/411)]
- fix(deps): update golang deps non-major [[#406](https://github.com/woodpecker-ci/autoscaler/pull/406)]
- fix(deps): update golang deps non-major [[#405](https://github.com/woodpecker-ci/autoscaler/pull/405)]
- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v3.4.0 [[#402](https://github.com/woodpecker-ci/autoscaler/pull/402)]
- chore(deps): update pre-commit hook pre-commit/pre-commit-hooks to v6 [[#403](https://github.com/woodpecker-ci/autoscaler/pull/403)]
- fix(deps): update golang deps non-major [[#404](https://github.com/woodpecker-ci/autoscaler/pull/404)]
- fix(deps): update golang.org/x/exp digest to 51f8813 [[#401](https://github.com/woodpecker-ci/autoscaler/pull/401)]
- fix(deps): update module golang.org/x/net to v0.43.0 [[#400](https://github.com/woodpecker-ci/autoscaler/pull/400)]
- fix(deps): update golang deps non-major [[#399](https://github.com/woodpecker-ci/autoscaler/pull/399)]
- fix(deps): update golang deps non-major [[#398](https://github.com/woodpecker-ci/autoscaler/pull/398)]
- fix(deps): update golang deps non-major [[#395](https://github.com/woodpecker-ci/autoscaler/pull/395)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.236.0 [[#394](https://github.com/woodpecker-ci/autoscaler/pull/394)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v2.3.0 [[#393](https://github.com/woodpecker-ci/autoscaler/pull/393)]
- fix(deps): update golang.org/x/exp digest to 645b1fa [[#389](https://github.com/woodpecker-ci/autoscaler/pull/389)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.235.0 [[#390](https://github.com/woodpecker-ci/autoscaler/pull/390)]
- fix(deps): update golang deps non-major [[#388](https://github.com/woodpecker-ci/autoscaler/pull/388)]
- fix(deps): update module github.com/vultr/govultr/v3 to v3.21.1 [[#387](https://github.com/woodpecker-ci/autoscaler/pull/387)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.232.0 [[#385](https://github.com/woodpecker-ci/autoscaler/pull/385)]
### Misc
- Use list format for pipelines [[#412](https://github.com/woodpecker-ci/autoscaler/pull/412)]
- [pre-commit.ci] pre-commit autoupdate [[#397](https://github.com/woodpecker-ci/autoscaler/pull/397)]
## [1.1.0](https://github.com/woodpecker-ci/autoscaler/releases/tag/1.1.0) - 2025-07-13
### ❤️ Thanks to all contributors! ❤️
@xoxys
### 📈 Enhancement
- Introduce global user-data flag [[#337](https://github.com/woodpecker-ci/autoscaler/pull/337)]
### 📦️ Dependency
- chore(deps): update pre-commit hook golangci/golangci-lint to v2.2.2 [[#383](https://github.com/woodpecker-ci/autoscaler/pull/383)]
- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v3.3.0 [[#377](https://github.com/woodpecker-ci/autoscaler/pull/377)]
- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v6.0.2 [[#376](https://github.com/woodpecker-ci/autoscaler/pull/376)]
- fix(deps): update golang.org/x/exp digest to 6ae5c78 [[#382](https://github.com/woodpecker-ci/autoscaler/pull/382)]
- fix(deps): update module github.com/hetznercloud/hcloud-go/v2 to v2.22.0 [[#384](https://github.com/woodpecker-ci/autoscaler/pull/384)]
- fix(deps): update module golang.org/x/net to v0.42.0 [[#381](https://github.com/woodpecker-ci/autoscaler/pull/381)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.231.0 [[#380](https://github.com/woodpecker-ci/autoscaler/pull/380)]
- fix(deps): update golang deps non-major [[#379](https://github.com/woodpecker-ci/autoscaler/pull/379)]
- fix(deps): update module go.woodpecker-ci.org/woodpecker/v3 to v3.8.0 [[#378](https://github.com/woodpecker-ci/autoscaler/pull/378)]
- fix(deps): update golang deps non-major [[#375](https://github.com/woodpecker-ci/autoscaler/pull/375)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.229.0 [[#374](https://github.com/woodpecker-ci/autoscaler/pull/374)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.228.0 [[#373](https://github.com/woodpecker-ci/autoscaler/pull/373)]
- fix(deps): update module github.com/linode/linodego to v1.52.2 [[#372](https://github.com/woodpecker-ci/autoscaler/pull/372)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v2.2.1 [[#371](https://github.com/woodpecker-ci/autoscaler/pull/371)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v2.2.0 [[#370](https://github.com/woodpecker-ci/autoscaler/pull/370)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.227.0 [[#369](https://github.com/woodpecker-ci/autoscaler/pull/369)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.226.0 [[#368](https://github.com/woodpecker-ci/autoscaler/pull/368)]
- fix(deps): update module github.com/vultr/govultr/v3 to v3.21.0 [[#367](https://github.com/woodpecker-ci/autoscaler/pull/367)]
- fix(deps): update golang.org/x/exp digest to b7579e2 [[#366](https://github.com/woodpecker-ci/autoscaler/pull/366)]
- fix(deps): update golang deps non-major [[#365](https://github.com/woodpecker-ci/autoscaler/pull/365)]
- fix(deps): update module github.com/urfave/cli/v3 to v3.3.8 [[#364](https://github.com/woodpecker-ci/autoscaler/pull/364)]
- fix(deps): update golang deps non-major [[#363](https://github.com/woodpecker-ci/autoscaler/pull/363)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.225.0 [[#362](https://github.com/woodpecker-ci/autoscaler/pull/362)]
- fix(deps): update golang deps non-major [[#361](https://github.com/woodpecker-ci/autoscaler/pull/361)]
- fix(deps): update golang.org/x/exp digest to dcc06ee [[#360](https://github.com/woodpecker-ci/autoscaler/pull/360)]
- fix(deps): update module github.com/hetznercloud/hcloud-go/v2 to v2.21.1 [[#359](https://github.com/woodpecker-ci/autoscaler/pull/359)]
- fix(deps): update module github.com/linode/linodego to v1.52.1 [[#358](https://github.com/woodpecker-ci/autoscaler/pull/358)]
- fix(deps): update golang.org/x/exp digest to b6e5de4 [[#357](https://github.com/woodpecker-ci/autoscaler/pull/357)]
- fix(deps): update golang.org/x/exp digest to 65e9200 [[#356](https://github.com/woodpecker-ci/autoscaler/pull/356)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.224.0 [[#355](https://github.com/woodpecker-ci/autoscaler/pull/355)]
- fix(deps): update golang deps non-major [[#354](https://github.com/woodpecker-ci/autoscaler/pull/354)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.222.0 [[#353](https://github.com/woodpecker-ci/autoscaler/pull/353)]
- fix(deps): update golang deps non-major [[#352](https://github.com/woodpecker-ci/autoscaler/pull/352)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.220.0 [[#351](https://github.com/woodpecker-ci/autoscaler/pull/351)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.219.0 [[#350](https://github.com/woodpecker-ci/autoscaler/pull/350)]
- chore(deps): update pre-commit hook igorshubovych/markdownlint-cli to v0.45.0 [[#349](https://github.com/woodpecker-ci/autoscaler/pull/349)]
- fix(deps): update golang.org/x/exp digest to ce4c2cf [[#345](https://github.com/woodpecker-ci/autoscaler/pull/345)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.218.0 [[#348](https://github.com/woodpecker-ci/autoscaler/pull/348)]
- chore(deps): update mstruebing/editorconfig-checker docker tag to v3.3.0 [[#346](https://github.com/woodpecker-ci/autoscaler/pull/346)]
- fix(deps): update golang deps non-major [[#347](https://github.com/woodpecker-ci/autoscaler/pull/347)]
- fix(deps): update golang deps non-major [[#344](https://github.com/woodpecker-ci/autoscaler/pull/344)]
- fix(deps): update golang deps non-major [[#343](https://github.com/woodpecker-ci/autoscaler/pull/343)]
- fix(deps): update golang deps non-major [[#342](https://github.com/woodpecker-ci/autoscaler/pull/342)]
- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v6 [[#340](https://github.com/woodpecker-ci/autoscaler/pull/340)]
- chore(deps): update pre-commit non-major [[#341](https://github.com/woodpecker-ci/autoscaler/pull/341)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.213.0 [[#335](https://github.com/woodpecker-ci/autoscaler/pull/335)]
- fix(deps): update module github.com/urfave/cli/v3 to v3.3.2 [[#334](https://github.com/woodpecker-ci/autoscaler/pull/334)]
- fix(deps): update module github.com/urfave/cli/v3 to v3.3.1 [[#333](https://github.com/woodpecker-ci/autoscaler/pull/333)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v2.1.5 [[#331](https://github.com/woodpecker-ci/autoscaler/pull/331)]
## [1.0.0](https://github.com/woodpecker-ci/autoscaler/releases/tag/1.0.0) - 2025-04-24
### ❤️ Thanks to all contributors! ❤️
@gsaslis, @xoxys
### 💥 Breaking changes
- fix(deps): update module github.com/urfave/cli/v2 to v3 [[#317](https://github.com/woodpecker-ci/autoscaler/pull/317)]
### 📚 Documentation
- Fix link to caddy docs [[#326](https://github.com/woodpecker-ci/autoscaler/pull/326)]
### 📦️ Dependency
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.212.0 [[#330](https://github.com/woodpecker-ci/autoscaler/pull/330)]
- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v3.2.0 [[#328](https://github.com/woodpecker-ci/autoscaler/pull/328)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v2.1.2 [[#327](https://github.com/woodpecker-ci/autoscaler/pull/327)]
- fix(deps): update module go.woodpecker-ci.org/woodpecker/v3 to v3.5.2 [[#325](https://github.com/woodpecker-ci/autoscaler/pull/325)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v2.1.1 [[#324](https://github.com/woodpecker-ci/autoscaler/pull/324)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v2.1.0 [[#323](https://github.com/woodpecker-ci/autoscaler/pull/323)]
- fix(deps): update golang.org/x/exp digest to 7e4ce0a [[#322](https://github.com/woodpecker-ci/autoscaler/pull/322)]
- fix(deps): update golang deps non-major [[#321](https://github.com/woodpecker-ci/autoscaler/pull/321)]
- fix(deps): update module github.com/vultr/govultr/v3 to v3.19.1 [[#320](https://github.com/woodpecker-ci/autoscaler/pull/320)]
- fix(deps): update golang deps non-major [[#319](https://github.com/woodpecker-ci/autoscaler/pull/319)]
- fix(deps): update module golang.org/x/oauth2 to v0.29.0 [[#318](https://github.com/woodpecker-ci/autoscaler/pull/318)]
- fix(deps): update golang deps non-major [[#316](https://github.com/woodpecker-ci/autoscaler/pull/316)]
- fix(deps): update golang deps non-major [[#315](https://github.com/woodpecker-ci/autoscaler/pull/315)]
- fix(deps): update module go.woodpecker-ci.org/woodpecker/v3 to v3.5.0 [[#314](https://github.com/woodpecker-ci/autoscaler/pull/314)]
- chore(deps): update dependency go to v1.24.2 [[#313](https://github.com/woodpecker-ci/autoscaler/pull/313)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.211.0 [[#312](https://github.com/woodpecker-ci/autoscaler/pull/312)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v2 [[#309](https://github.com/woodpecker-ci/autoscaler/pull/309)]
- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v3.1.4 [[#307](https://github.com/woodpecker-ci/autoscaler/pull/307)]
- fix(deps): update module github.com/vultr/govultr/v3 to v3.18.0 [[#308](https://github.com/woodpecker-ci/autoscaler/pull/308)]
- fix(deps): update golang deps non-major [[#306](https://github.com/woodpecker-ci/autoscaler/pull/306)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/config to v1.29.11 [[#305](https://github.com/woodpecker-ci/autoscaler/pull/305)]
- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v5.2.2 [[#302](https://github.com/woodpecker-ci/autoscaler/pull/302)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/config to v1.29.10 [[#304](https://github.com/woodpecker-ci/autoscaler/pull/304)]
- chore(deps): update pre-commit hook adrienverge/yamllint to v1.37.0 [[#303](https://github.com/woodpecker-ci/autoscaler/pull/303)]
- chore(deps): update pre-commit non-major [[#300](https://github.com/woodpecker-ci/autoscaler/pull/300)]
- fix(deps): update module github.com/rs/zerolog to v1.34.0 [[#301](https://github.com/woodpecker-ci/autoscaler/pull/301)]
- fix(deps): update module github.com/hetznercloud/hcloud-go/v2 to v2.20.1 [[#299](https://github.com/woodpecker-ci/autoscaler/pull/299)]
- fix(deps): update golang deps non-major [[#298](https://github.com/woodpecker-ci/autoscaler/pull/298)]
- fix(deps): update module go.woodpecker-ci.org/woodpecker/v3 to v3.4.0 [[#297](https://github.com/woodpecker-ci/autoscaler/pull/297)]
- chore(deps): update mstruebing/editorconfig-checker docker tag to v3.2.1 [[#296](https://github.com/woodpecker-ci/autoscaler/pull/296)]
- chore(deps): update pre-commit hook adrienverge/yamllint to v1.36.1 [[#295](https://github.com/woodpecker-ci/autoscaler/pull/295)]
- chore(deps): update pre-commit non-major [[#294](https://github.com/woodpecker-ci/autoscaler/pull/294)]
- fix(deps): update golang deps non-major [[#293](https://github.com/woodpecker-ci/autoscaler/pull/293)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.209.0 [[#292](https://github.com/woodpecker-ci/autoscaler/pull/292)]
- fix(deps): update module github.com/vultr/govultr/v3 to v3.16.1 [[#291](https://github.com/woodpecker-ci/autoscaler/pull/291)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.208.0 [[#290](https://github.com/woodpecker-ci/autoscaler/pull/290)]
- fix(deps): update golang.org/x/exp digest to 054e65f [[#289](https://github.com/woodpecker-ci/autoscaler/pull/289)]
- fix(deps): update golang deps non-major [[#288](https://github.com/woodpecker-ci/autoscaler/pull/288)]
- fix(deps): update golang deps non-major [[#287](https://github.com/woodpecker-ci/autoscaler/pull/287)]
- fix(deps): update golang deps non-major [[#286](https://github.com/woodpecker-ci/autoscaler/pull/286)]
- fix(deps): update golang.org/x/exp digest to dead583 [[#284](https://github.com/woodpecker-ci/autoscaler/pull/284)]
- fix(deps): update golang deps non-major [[#282](https://github.com/woodpecker-ci/autoscaler/pull/282)]
### Misc
- Bump golangci-lint to v2 [[#311](https://github.com/woodpecker-ci/autoscaler/pull/311)]
- Run tests also on makefile changes [[#310](https://github.com/woodpecker-ci/autoscaler/pull/310)]
- [pre-commit.ci] pre-commit autoupdate [[#285](https://github.com/woodpecker-ci/autoscaler/pull/285)]
## [0.6.0](https://github.com/woodpecker-ci/autoscaler/releases/tag/0.6.0) - 2025-02-27
### ❤️ Thanks to all contributors! ❤️
@xoxys
### 📈 Enhancement
- Add hcloud server type fallback list [[#275](https://github.com/woodpecker-ci/autoscaler/pull/275)]
### 📦️ Dependency
- fix(deps): update golang deps non-major [[#281](https://github.com/woodpecker-ci/autoscaler/pull/281)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.204.0 [[#280](https://github.com/woodpecker-ci/autoscaler/pull/280)]
- fix(deps): update module golang.org/x/oauth2 to v0.27.0 [[#279](https://github.com/woodpecker-ci/autoscaler/pull/279)]
- fix(deps): update golang.org/x/exp digest to aa4b98e [[#276](https://github.com/woodpecker-ci/autoscaler/pull/276)]
## [0.5.1](https://github.com/woodpecker-ci/autoscaler/releases/tag/0.5.1) - 2025-02-21
### ❤️ Thanks to all contributors! ❤️
@henkka
### 📚 Documentation
- docs: fix typos [[#259](https://github.com/woodpecker-ci/autoscaler/pull/259)]
### 📦️ Dependency
- fix(deps): update module go.woodpecker-ci.org/woodpecker/v2 to v3 [[#266](https://github.com/woodpecker-ci/autoscaler/pull/266)]
- fix(deps): update golang deps non-major [[#265](https://github.com/woodpecker-ci/autoscaler/pull/265)]
- chore(deps): update golang docker tag to v1.24 [[#273](https://github.com/woodpecker-ci/autoscaler/pull/273)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v1.64.5 [[#271](https://github.com/woodpecker-ci/autoscaler/pull/271)]
- fix(deps): update golang.org/x/exp digest to eff6e97 [[#274](https://github.com/woodpecker-ci/autoscaler/pull/274)]
- fix(deps): update golang.org/x/exp digest to 939b2ce [[#270](https://github.com/woodpecker-ci/autoscaler/pull/270)]
- chore(deps): update docker.io/golang docker tag to v1.24 [[#272](https://github.com/woodpecker-ci/autoscaler/pull/272)]
- chore(deps): update pre-commit non-major [[#264](https://github.com/woodpecker-ci/autoscaler/pull/264)]
- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v5.2.1 [[#263](https://github.com/woodpecker-ci/autoscaler/pull/263)]
- chore(deps): update mstruebing/editorconfig-checker docker tag to v3.2.0 [[#269](https://github.com/woodpecker-ci/autoscaler/pull/269)]
- fix(deps): update golang.org/x/exp digest to f9890c6 [[#267](https://github.com/woodpecker-ci/autoscaler/pull/267)]
- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v3.1.3 [[#262](https://github.com/woodpecker-ci/autoscaler/pull/262)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.201.0 [[#261](https://github.com/woodpecker-ci/autoscaler/pull/261)]
- fix(deps): update module github.com/hetznercloud/hcloud-go/v2 to v2.18.0 [[#260](https://github.com/woodpecker-ci/autoscaler/pull/260)]
- fix(deps): update golang deps non-major [[#257](https://github.com/woodpecker-ci/autoscaler/pull/257)]
### Misc
- [pre-commit.ci] pre-commit autoupdate [[#268](https://github.com/woodpecker-ci/autoscaler/pull/268)]
## [0.5.0](https://github.com/woodpecker-ci/autoscaler/releases/tag/0.5.0) - 2025-01-17
### ❤️ Thanks to all contributors! ❤️
@xoxys
### ✨ Features
- Add Scaleway provider [[#252](https://github.com/woodpecker-ci/autoscaler/pull/252)]
- Add Vultr provider [[#251](https://github.com/woodpecker-ci/autoscaler/pull/251)]
### 📦️ Dependency
- chore(deps): update dependency go to v1.23.5 [[#255](https://github.com/woodpecker-ci/autoscaler/pull/255)]
## [0.4.0](https://github.com/woodpecker-ci/autoscaler/releases/tag/0.4.0) - 2025-01-16
### ❤️ Thanks to all contributors! ❤️
@keslerm, @pat-s, @xoxys
### 📈 Enhancement
- Wait for AWS instance availablity before returning [[#227](https://github.com/woodpecker-ci/autoscaler/pull/227)]
### 📦️ Dependency
- fix(deps): update golang deps non-major [[#254](https://github.com/woodpecker-ci/autoscaler/pull/254)]
- fix(deps): update golang deps non-major [[#253](https://github.com/woodpecker-ci/autoscaler/pull/253)]
- fix(deps): update golang deps non-major [[#248](https://github.com/woodpecker-ci/autoscaler/pull/248)]
- fix(deps): update module go.woodpecker-ci.org/woodpecker/v2 to v2.8.3 [[#247](https://github.com/woodpecker-ci/autoscaler/pull/247)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/config to v1.28.10 [[#245](https://github.com/woodpecker-ci/autoscaler/pull/245)]
- fix(deps): update golang.org/x/exp digest to 7588d65 [[#244](https://github.com/woodpecker-ci/autoscaler/pull/244)]
- chore(deps): update mstruebing/editorconfig-checker docker tag to v3.1.2 [[#246](https://github.com/woodpecker-ci/autoscaler/pull/246)]
- fix(deps): update golang deps non-major [[#243](https://github.com/woodpecker-ci/autoscaler/pull/243)]
- fix(deps): update golang deps non-major [[#242](https://github.com/woodpecker-ci/autoscaler/pull/242)]
- fix(deps): update golang deps non-major [[#241](https://github.com/woodpecker-ci/autoscaler/pull/241)]
- fix(deps): update module golang.org/x/oauth2 to v0.25.0 [[#239](https://github.com/woodpecker-ci/autoscaler/pull/239)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v1.63.4 [[#238](https://github.com/woodpecker-ci/autoscaler/pull/238)]
- fix(deps): update module go.woodpecker-ci.org/woodpecker/v2 to v2.8.2 [[#236](https://github.com/woodpecker-ci/autoscaler/pull/236)]
- fix(deps): update golang.org/x/exp digest to 7d7fa50 [[#235](https://github.com/woodpecker-ci/autoscaler/pull/235)]
- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v3.1.1 [[#237](https://github.com/woodpecker-ci/autoscaler/pull/237)]
- fix(deps): update golang deps non-major [[#234](https://github.com/woodpecker-ci/autoscaler/pull/234)]
- fix(deps): update golang deps non-major [[#233](https://github.com/woodpecker-ci/autoscaler/pull/233)]
- fix(deps): update module github.com/hetznercloud/hcloud-go/v2 to v2.17.1 [[#232](https://github.com/woodpecker-ci/autoscaler/pull/232)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.198.0 [[#231](https://github.com/woodpecker-ci/autoscaler/pull/231)]
- fix(deps): update golang deps non-major [[#229](https://github.com/woodpecker-ci/autoscaler/pull/229)]
- fix(deps): update golang.org/x/exp digest to 4a55095 [[#228](https://github.com/woodpecker-ci/autoscaler/pull/228)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.196.0 [[#221](https://github.com/woodpecker-ci/autoscaler/pull/221)]
- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v3.1.0 [[#220](https://github.com/woodpecker-ci/autoscaler/pull/220)]
- chore(deps): update docker.io/woodpeckerci/plugin-docker-buildx docker tag to v5.1.0 [[#219](https://github.com/woodpecker-ci/autoscaler/pull/219)]
- fix(deps): update golang.org/x/exp digest to 43b7b7c [[#218](https://github.com/woodpecker-ci/autoscaler/pull/218)]
- fix(deps): update module golang.org/x/net to v0.32.0 [[#217](https://github.com/woodpecker-ci/autoscaler/pull/217)]
- chore(deps): update dependency go to v1.23.4 [[#216](https://github.com/woodpecker-ci/autoscaler/pull/216)]
- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v3 [[#215](https://github.com/woodpecker-ci/autoscaler/pull/215)]
- fix(deps): update golang deps non-major [[#213](https://github.com/woodpecker-ci/autoscaler/pull/213)]
### Misc
- Fix deprecated editorconfig filename [[#250](https://github.com/woodpecker-ci/autoscaler/pull/250)]
- Include tags from AWS config in instance creation [[#223](https://github.com/woodpecker-ci/autoscaler/pull/223)]
- Make sure to use the AWS Region when specified [[#224](https://github.com/woodpecker-ci/autoscaler/pull/224)]
- Rename linter [[#240](https://github.com/woodpecker-ci/autoscaler/pull/240)]
## [0.3.1](https://github.com/woodpecker-ci/autoscaler/releases/tag/0.3.1) - 2024-11-30
### ❤️ Thanks to all contributors! ❤️
@henkka, @pat-s, @xoxys
### 📦️ Dependency
- chore(deps): update pre-commit hook golangci/golangci-lint to v1.62.2 [[#211](https://github.com/woodpecker-ci/autoscaler/pull/211)]
- fix(deps): update module go.woodpecker-ci.org/woodpecker/v2 to v2.8.0 [[#210](https://github.com/woodpecker-ci/autoscaler/pull/210)]
- fix(deps): update module github.com/aws/aws-sdk-go-v2/service/ec2 to v1.194.0 [[#208](https://github.com/woodpecker-ci/autoscaler/pull/208)]
- fix(deps): update module github.com/stretchr/testify to v1.10.0 [[#207](https://github.com/woodpecker-ci/autoscaler/pull/207)]
- chore(deps): update pre-commit hook igorshubovych/markdownlint-cli to v0.43.0 [[#206](https://github.com/woodpecker-ci/autoscaler/pull/206)]
- fix(deps): update module github.com/hetznercloud/hcloud-go/v2 to v2.17.0 [[#205](https://github.com/woodpecker-ci/autoscaler/pull/205)]
- fix(deps): update golang deps non-major [[#203](https://github.com/woodpecker-ci/autoscaler/pull/203)]
- fix(deps): update golang deps non-major [[#201](https://github.com/woodpecker-ci/autoscaler/pull/201)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v1.62.0 [[#200](https://github.com/woodpecker-ci/autoscaler/pull/200)]
- fix(deps): update golang deps non-major [[#197](https://github.com/woodpecker-ci/autoscaler/pull/197)]
- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v2.1.1 [[#199](https://github.com/woodpecker-ci/autoscaler/pull/199)]
- fix(deps): update golang.org/x/exp digest to 2d47ceb [[#198](https://github.com/woodpecker-ci/autoscaler/pull/198)]
- fix(deps): update module go.woodpecker-ci.org/woodpecker/v2 to v2.7.2 [[#196](https://github.com/woodpecker-ci/autoscaler/pull/196)]
- chore(deps): update node.js to v22 [[#193](https://github.com/woodpecker-ci/autoscaler/pull/193)]
- fix(deps): update module github.com/hetznercloud/hcloud-go/v2 to v2.15.0 [[#194](https://github.com/woodpecker-ci/autoscaler/pull/194)]
- fix(deps): update golang.org/x/exp digest to f66d83c [[#188](https://github.com/woodpecker-ci/autoscaler/pull/188)]
- chore(deps): update pre-commit hook pre-commit/pre-commit-hooks to v5 [[#187](https://github.com/woodpecker-ci/autoscaler/pull/187)]
- fix(deps): update golang deps non-major [[#186](https://github.com/woodpecker-ci/autoscaler/pull/186)]
- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v2 [[#185](https://github.com/woodpecker-ci/autoscaler/pull/185)]
- chore(deps): update golang docker tag to v1.23 [[#181](https://github.com/woodpecker-ci/autoscaler/pull/181)]
- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v1.2.0 [[#182](https://github.com/woodpecker-ci/autoscaler/pull/182)]
- fix(deps): update golang deps non-major [[#183](https://github.com/woodpecker-ci/autoscaler/pull/183)]
- chore(deps): update pre-commit non-major [[#184](https://github.com/woodpecker-ci/autoscaler/pull/184)]
### Misc
- docs: add AWS as supported cloud provider [[#209](https://github.com/woodpecker-ci/autoscaler/pull/209)]
- ci: remove `renovate` branch triggers [[#204](https://github.com/woodpecker-ci/autoscaler/pull/204)]
- [pre-commit.ci] pre-commit autoupdate [[#195](https://github.com/woodpecker-ci/autoscaler/pull/195)]
- Bump buildx plugin image for pipeline [[#192](https://github.com/woodpecker-ci/autoscaler/pull/192)]
- [pre-commit.ci] pre-commit autoupdate [[#176](https://github.com/woodpecker-ci/autoscaler/pull/176)]
## [0.3.0](https://github.com/woodpecker-ci/autoscaler/releases/tag/0.3.0) - 2024-09-20
### ❤️ Thanks to all contributors! ❤️
@anbraten, @hhamalai, @qwerty287, @xoxys
### ✨ Features
- Add AWS provider [[#118](https://github.com/woodpecker-ci/autoscaler/pull/118)]
- Reactivate agents and prevent draining recently active agents [[#163](https://github.com/woodpecker-ci/autoscaler/pull/163)]
- Add agent idle timeout [[#162](https://github.com/woodpecker-ci/autoscaler/pull/162)]
- Allow to filter for specific tasks [[#134](https://github.com/woodpecker-ci/autoscaler/pull/134)]
### 🐛 Bug Fixes
- Fix drain agents [[#156](https://github.com/woodpecker-ci/autoscaler/pull/156)]
- Return error on unknown server types [[#151](https://github.com/woodpecker-ci/autoscaler/pull/151)]
### 📈 Enhancement
- Allow to remove an agent as soon as it connected once, but has no more tasks left [[#92](https://github.com/woodpecker-ci/autoscaler/pull/92)]
- Improve error handling [[#155](https://github.com/woodpecker-ci/autoscaler/pull/155)]
- Use docker gpg key from download.docker.com [[#154](https://github.com/woodpecker-ci/autoscaler/pull/154)]
### Misc
- fix(deps): update golang.org/x/exp digest to 701f63a [[#178](https://github.com/woodpecker-ci/autoscaler/pull/178)]
- chore(deps): update mstruebing/editorconfig-checker docker tag to v3.0.3 [[#179](https://github.com/woodpecker-ci/autoscaler/pull/179)]
- fix(deps): update golang deps non-major [[#173](https://github.com/woodpecker-ci/autoscaler/pull/173)]
- fix(deps): update golang.org/x/exp digest to 778ce7b [[#174](https://github.com/woodpecker-ci/autoscaler/pull/174)]
- chore(deps): update golang deps non-major [[#170](https://github.com/woodpecker-ci/autoscaler/pull/170)]
- fix(deps): update go.woodpecker-ci.org/woodpecker/v2 digest to 987c201 [[#169](https://github.com/woodpecker-ci/autoscaler/pull/169)]
- [pre-commit.ci] pre-commit autoupdate [[#160](https://github.com/woodpecker-ci/autoscaler/pull/160)]
- fix(deps): update module github.com/linode/linodego to v1.36.1 [[#161](https://github.com/woodpecker-ci/autoscaler/pull/161)]
- fix(deps): update golang deps non-major [[#159](https://github.com/woodpecker-ci/autoscaler/pull/159)]
- fix(deps): update golang.org/x/exp digest to 7f521ea [[#158](https://github.com/woodpecker-ci/autoscaler/pull/158)]
- fix(deps): update golang deps non-major [[#150](https://github.com/woodpecker-ci/autoscaler/pull/150)]
- [pre-commit.ci] pre-commit autoupdate [[#149](https://github.com/woodpecker-ci/autoscaler/pull/149)]
- Fix deprecations and run on renovate branches [[#147](https://github.com/woodpecker-ci/autoscaler/pull/147)]
- chore(deps): update mstruebing/editorconfig-checker docker tag to v3 [[#138](https://github.com/woodpecker-ci/autoscaler/pull/138)]
- chore(deps): update woodpeckerci/plugin-docker-buildx docker tag to v4 [[#143](https://github.com/woodpecker-ci/autoscaler/pull/143)]
- fix(deps): update golang deps non-major [[#142](https://github.com/woodpecker-ci/autoscaler/pull/142)]
- chore(deps): update golang docker tag to v1.22.3 [[#144](https://github.com/woodpecker-ci/autoscaler/pull/144)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v1.58.1 [[#145](https://github.com/woodpecker-ci/autoscaler/pull/145)]
- chore(deps): update pre-commit non-major [[#139](https://github.com/woodpecker-ci/autoscaler/pull/139)]
- fix(deps): update module golang.org/x/oauth2 to v0.20.0 [[#140](https://github.com/woodpecker-ci/autoscaler/pull/140)]
- fix(deps): update module github.com/urfave/cli/v2 to v2.27.2 [[#136](https://github.com/woodpecker-ci/autoscaler/pull/136)]
- fix(deps): update module github.com/linode/linodego to v1.33.0 [[#135](https://github.com/woodpecker-ci/autoscaler/pull/135)]
- fix(deps): update module github.com/hetznercloud/hcloud-go/v2 to v2.7.2 [[#132](https://github.com/woodpecker-ci/autoscaler/pull/132)]
- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v1.1.1 [[#131](https://github.com/woodpecker-ci/autoscaler/pull/131)]
- fix(deps): update golang.org/x/exp digest to fe59bbe [[#130](https://github.com/woodpecker-ci/autoscaler/pull/130)]
- chore(deps): update pre-commit hook pre-commit/pre-commit-hooks to v4.6.0 [[#128](https://github.com/woodpecker-ci/autoscaler/pull/128)]
- chore(deps): update golang docker tag to v1.22.2 [[#127](https://github.com/woodpecker-ci/autoscaler/pull/127)]
- fix(deps): update golang.org/x/exp digest to c0f41cb [[#126](https://github.com/woodpecker-ci/autoscaler/pull/126)]
- fix(deps): update golang deps non-major [[#125](https://github.com/woodpecker-ci/autoscaler/pull/125)]
- chore(deps): update woodpeckerci/plugin-docker-buildx docker tag to v3.2.1 [[#124](https://github.com/woodpecker-ci/autoscaler/pull/124)]
- fix(deps): update golang.org/x/exp digest to a685a6e [[#122](https://github.com/woodpecker-ci/autoscaler/pull/122)]
- chore(deps): update pre-commit hook golangci/golangci-lint to v1.57.2 [[#123](https://github.com/woodpecker-ci/autoscaler/pull/123)]
- fix(deps): update module github.com/hetznercloud/hcloud-go/v2 to v2.7.0 [[#121](https://github.com/woodpecker-ci/autoscaler/pull/121)]
- chore(deps): update pre-commit non-major [[#120](https://github.com/woodpecker-ci/autoscaler/pull/120)]
- fix(deps): update golang.org/x/exp digest to a85f2c6 [[#119](https://github.com/woodpecker-ci/autoscaler/pull/119)]
- fix(deps): update golang deps non-major [[#116](https://github.com/woodpecker-ci/autoscaler/pull/116)]
## [0.2.0](https://github.com/woodpecker-ci/autoscaler/releases/tag/0.2.0) - 2024-03-17
### ❤️ Thanks to all contributors! ❤️
@6543, @guisea, @maltejur, @pat-s, @qwerty287, @xoxys
### ✨ Features
- Add linode provider [[#15](https://github.com/woodpecker-ci/autoscaler/pull/15)]
### 📚 Documentation
- Document `WOODPECKER_PROVIDER` [[#19](https://github.com/woodpecker-ci/autoscaler/pull/19)]
### 📈 Enhancement
- Ignore WaitingOnDeps for agent calculation [[#14](https://github.com/woodpecker-ci/autoscaler/pull/14)]
- don't require amd64 CPU architecture [[#10](https://github.com/woodpecker-ci/autoscaler/pull/10)]
### 🐛 Bug Fixes
- Update hetznercloud provider [[#12](https://github.com/woodpecker-ci/autoscaler/pull/12)]
### Misc
- Temp disable linode provider [[#115](https://github.com/woodpecker-ci/autoscaler/pull/115)]
- Enable more linters and cleanup code [[#114](https://github.com/woodpecker-ci/autoscaler/pull/114)]
- chore(deps): update woodpeckerci/plugin-docker-buildx docker tag to v3.2.0 [[#113](https://github.com/woodpecker-ci/autoscaler/pull/113)]
- fix(deps): update golang.org/x/exp digest to c7f7c64 [[#112](https://github.com/woodpecker-ci/autoscaler/pull/112)]
- fix(deps): update module github.com/linode/linodego to v1.30.0 [[#111](https://github.com/woodpecker-ci/autoscaler/pull/111)]
- chore(deps): update golang docker tag to v1.22.1 [[#110](https://github.com/woodpecker-ci/autoscaler/pull/110)]
- fix(deps): update golang deps non-major [[#109](https://github.com/woodpecker-ci/autoscaler/pull/109)]
- fix(deps): update golang.org/x/exp digest to 814bf88 [[#108](https://github.com/woodpecker-ci/autoscaler/pull/108)]
- fix(deps): update module github.com/linode/linodego to v1.29.0 [[#107](https://github.com/woodpecker-ci/autoscaler/pull/107)]
- fix(deps): update golang.org/x/exp digest to ec58324 [[#106](https://github.com/woodpecker-ci/autoscaler/pull/106)]
- chore(deps): update woodpeckerci/plugin-docker-buildx docker tag to v3.1.0 [[#105](https://github.com/woodpecker-ci/autoscaler/pull/105)]
- chore(deps): update golang docker tag [[#101](https://github.com/woodpecker-ci/autoscaler/pull/101)]
- fix(deps): update golang.org/x/exp digest to 2c58cdc [[#100](https://github.com/woodpecker-ci/autoscaler/pull/100)]
- fix(deps): update golang deps non-major [[#99](https://github.com/woodpecker-ci/autoscaler/pull/99)]
- fix(deps): update module github.com/rs/zerolog to v1.32.0 [[#98](https://github.com/woodpecker-ci/autoscaler/pull/98)]
- [pre-commit.ci] pre-commit autoupdate [[#97](https://github.com/woodpecker-ci/autoscaler/pull/97)]
- chore(deps): update woodpeckerci/plugin-docker-buildx docker tag to v3.0.1 [[#96](https://github.com/woodpecker-ci/autoscaler/pull/96)]
- fix(deps): update module go.woodpecker-ci.org/woodpecker/v2 to v2.3.0 [[#95](https://github.com/woodpecker-ci/autoscaler/pull/95)]
- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v1.1.0 [[#89](https://github.com/woodpecker-ci/autoscaler/pull/89)]
- fix(deps): update module go.woodpecker-ci.org/woodpecker/v2 to v2.2.2 [[#87](https://github.com/woodpecker-ci/autoscaler/pull/87)]
- chore(deps): update woodpeckerci/plugin-docker-buildx docker tag to v3 [[#85](https://github.com/woodpecker-ci/autoscaler/pull/85)]
- fix(deps): update golang.org/x/exp digest to 1b97071 [[#86](https://github.com/woodpecker-ci/autoscaler/pull/86)]
- Use cleartext username [[#84](https://github.com/woodpecker-ci/autoscaler/pull/84)]
- chore(deps): update golang docker tag to v1.21.6 [[#83](https://github.com/woodpecker-ci/autoscaler/pull/83)]
- fix(deps): update golang.org/x/exp digest to db7319d [[#82](https://github.com/woodpecker-ci/autoscaler/pull/82)]
- fix(deps): update golang deps non-major [[#81](https://github.com/woodpecker-ci/autoscaler/pull/81)]
- chore(deps): update woodpeckerci/plugin-docker-buildx docker tag to v2.3.0 [[#80](https://github.com/woodpecker-ci/autoscaler/pull/80)]
- fix(deps): update golang.org/x/exp digest to be819d1 [[#79](https://github.com/woodpecker-ci/autoscaler/pull/79)]
- fix(deps): update module github.com/urfave/cli/v2 to v2.27.1 [[#78](https://github.com/woodpecker-ci/autoscaler/pull/78)]
- [pre-commit.ci] pre-commit autoupdate [[#77](https://github.com/woodpecker-ci/autoscaler/pull/77)]
- fix(deps): update golang.org/x/exp digest to 02704c9 [[#76](https://github.com/woodpecker-ci/autoscaler/pull/76)]
- fix(deps): update module go.woodpecker-ci.org/woodpecker to v2 [[#75](https://github.com/woodpecker-ci/autoscaler/pull/75)]
- fix(deps): update module github.com/urfave/cli/v2 to v2.27.0 [[#74](https://github.com/woodpecker-ci/autoscaler/pull/74)]
- fix(deps): update golang.org/x/exp digest to dc181d7 [[#71](https://github.com/woodpecker-ci/autoscaler/pull/71)]
- fix(deps): update golang.org/x/exp digest to aacd6d4 [[#69](https://github.com/woodpecker-ci/autoscaler/pull/69)]
- fix(deps): update module github.com/hetznercloud/hcloud-go/v2 to v2.5.1 [[#68](https://github.com/woodpecker-ci/autoscaler/pull/68)]
- chore(deps): update golang docker tag to v1.21.5 [[#67](https://github.com/woodpecker-ci/autoscaler/pull/67)]
- fix(deps): update golang.org/x/exp digest to f3f8817 [[#66](https://github.com/woodpecker-ci/autoscaler/pull/66)]
- fix(deps): update module github.com/urfave/cli/v2 to v2.26.0 [[#65](https://github.com/woodpecker-ci/autoscaler/pull/65)]
- fix(deps): update golang.org/x/exp digest to 6522937 [[#64](https://github.com/woodpecker-ci/autoscaler/pull/64)]
- fix(deps): update golang deps non-major [[#63](https://github.com/woodpecker-ci/autoscaler/pull/63)]
- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v1.0.3 [[#62](https://github.com/woodpecker-ci/autoscaler/pull/62)]
- chore(deps): update woodpeckerci/plugin-docker-buildx docker tag to v2.2.1 [[#61](https://github.com/woodpecker-ci/autoscaler/pull/61)]
- fix(deps): update golang deps non-major [[#60](https://github.com/woodpecker-ci/autoscaler/pull/60)]
- chore(deps): update golang docker tag to v1.21.4 [[#59](https://github.com/woodpecker-ci/autoscaler/pull/59)]
- fix(deps): update golang.org/x/exp digest to 9a3e603 [[#58](https://github.com/woodpecker-ci/autoscaler/pull/58)]
- Add linters and `pre-commit` [[#57](https://github.com/woodpecker-ci/autoscaler/pull/57)]
- fix(deps): update module go.woodpecker-ci.org/woodpecker to v1 [[#54](https://github.com/woodpecker-ci/autoscaler/pull/54)]
- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v1 [[#53](https://github.com/woodpecker-ci/autoscaler/pull/53)]
- Go vanity urls for autoscaler [[#48](https://github.com/woodpecker-ci/autoscaler/pull/48)]
- fix(deps): update module github.com/woodpecker-ci/woodpecker to v1.0.4 [[#47](https://github.com/woodpecker-ci/autoscaler/pull/47)]
- chore(deps): update woodpeckerci/plugin-ready-release-go docker tag to v0.7.0 [[#45](https://github.com/woodpecker-ci/autoscaler/pull/45)]
- renovate: use org config [[#43](https://github.com/woodpecker-ci/autoscaler/pull/43)]
- Fix indentation in cloudconfig multiline string [[#42](https://github.com/woodpecker-ci/autoscaler/pull/42)]
- Update woodpeckerci/plugin-docker-buildx Docker tag to v2.2.0 [[#41](https://github.com/woodpecker-ci/autoscaler/pull/41)]
- Update Node.js to v21 [[#40](https://github.com/woodpecker-ci/autoscaler/pull/40)]
- Update module github.com/woodpecker-ci/woodpecker to v1.0.3 [[#39](https://github.com/woodpecker-ci/autoscaler/pull/39)]
- Update module github.com/hetznercloud/hcloud-go/v2 to v2.4.0 [[#38](https://github.com/woodpecker-ci/autoscaler/pull/38)]
- Update golang Docker tag to v1.21.3 [[#37](https://github.com/woodpecker-ci/autoscaler/pull/37)]
- Update module golang.org/x/net to v0.17.0 [[#36](https://github.com/woodpecker-ci/autoscaler/pull/36)]
- Update mstruebing/editorconfig-checker Docker tag to v2.7.2 [[#35](https://github.com/woodpecker-ci/autoscaler/pull/35)]
- Update module github.com/woodpecker-ci/woodpecker to v1 [[#34](https://github.com/woodpecker-ci/autoscaler/pull/34)]
- Update Node.js to v20 [[#33](https://github.com/woodpecker-ci/autoscaler/pull/33)]
- Update module golang.org/x/oauth2 to v0.13.0 [[#32](https://github.com/woodpecker-ci/autoscaler/pull/32)]
- Update module github.com/hetznercloud/hcloud-go/v2 to v2.3.0 [[#27](https://github.com/woodpecker-ci/autoscaler/pull/27)]
- Update golang Docker tag to v1.21.2 [[#30](https://github.com/woodpecker-ci/autoscaler/pull/30)]
- Update golang.org/x/exp digest to 7918f67 [[#29](https://github.com/woodpecker-ci/autoscaler/pull/29)]
- Update module golang.org/x/net to v0.16.0 [[#31](https://github.com/woodpecker-ci/autoscaler/pull/31)]
- Update module github.com/rs/zerolog to v1.31.0 [[#28](https://github.com/woodpecker-ci/autoscaler/pull/28)]
- Update golang Docker tag [[#26](https://github.com/woodpecker-ci/autoscaler/pull/26)]
- Update module github.com/urfave/cli/v2 to v2.25.7 [[#22](https://github.com/woodpecker-ci/autoscaler/pull/22)]
- Update golang.org/x/exp digest to 9212866 [[#21](https://github.com/woodpecker-ci/autoscaler/pull/21)]
- Add renovate [[#20](https://github.com/woodpecker-ci/autoscaler/pull/20)]
## [0.1.0](https://github.com/woodpecker-ci/autoscaler/releases/tag/0.1.0) - 2023-07-28
### ❤️ Thanks to all contributors! ❤️
@anbraten, @xoxys
### ✨ Features
- Refactor project structure and improve agent calculation [[#3](https://github.com/woodpecker-ci/autoscaler/pull/3)]
### 📈 Enhancement
- Renamings and failing on parsing errors [[#9](https://github.com/woodpecker-ci/autoscaler/pull/9)]
- Add release helper [[#7](https://github.com/woodpecker-ci/autoscaler/pull/7)]
- Add hetznercloud network, firewall and ssh-key options [[#4](https://github.com/woodpecker-ci/autoscaler/pull/4)]
### Misc
- Allow to set agent min-time-alive as user [[#5](https://github.com/woodpecker-ci/autoscaler/pull/5)]
- Add ci test workflow [[#6](https://github.com/woodpecker-ci/autoscaler/pull/6)]
- Add container image [[#1](https://github.com/woodpecker-ci/autoscaler/pull/1)]

17
Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
FROM golang:1.23-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN GOMAXPROCS=1 go build -p 1 -o woodpecker-autoscaler ./cmd/woodpecker-autoscaler
FROM alpine:latest
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /build/woodpecker-autoscaler /usr/local/bin/
ENTRYPOINT ["woodpecker-autoscaler"]

201
LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

133
Makefile Normal file
View File

@@ -0,0 +1,133 @@
# renovate: datasource=github-releases depName=mvdan/gofumpt
GOFUMPT_VERSION := v0.10.0
# renovate: datasource=github-releases depName=golangci/golangci-lint
GOLANGCI_LINT_VERSION := v2.12.2
GO_PACKAGES ?= $(shell go list ./... | grep -v /vendor/)
TARGETOS ?= linux
TARGETARCH ?= amd64
VERSION ?= next
CI_COMMIT_SHA ?= $(shell git rev-parse HEAD)
# it's a tagged release
ifneq ($(CI_COMMIT_TAG),)
VERSION := $(CI_COMMIT_TAG:v%=%)
else
# append commit-sha to next version
ifeq ($(VERSION),next)
VERSION := $(shell echo "next-$(shell echo ${CI_COMMIT_SHA} | cut -c -10)")
endif
# append commit-sha to release branch version
ifeq ($(shell echo ${CI_COMMIT_BRANCH} | cut -c -9),release/v)
VERSION := $(shell echo "$(shell echo ${CI_COMMIT_BRANCH} | cut -c 10-)-$(shell echo ${CI_COMMIT_SHA} | cut -c -10)")
endif
endif
LDFLAGS := -s -w -extldflags "-static" -X go.woodpecker-ci.org/autoscaler/version.Version=${VERSION}
CGO_ENABLED := 0
HAS_GO = $(shell hash go > /dev/null 2>&1 && echo "GO" || echo "NOGO" )
ifeq ($(HAS_GO),GO)
CGO_CFLAGS ?= $(shell go env CGO_CFLAGS)
endif
CGO_CFLAGS ?=
# If the first argument is "in_docker"...
ifeq (in_docker,$(firstword $(MAKECMDGOALS)))
# use the rest as arguments for "in_docker"
MAKE_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
# Ignore the next args
$(eval $(MAKE_ARGS):;@:)
in_docker:
@[ "1" -eq "$(shell docker image ls woodpecker/make:local -a | wc -l)" ] && docker buildx build -f ./docker/Dockerfile.make -t woodpecker/make:local --load . || echo reuse existing docker image
@echo run in docker:
@docker run -it \
--user $(shell id -u):$(shell id -g) \
-e VERSION="$(VERSION)" \
-e CI_COMMIT_SHA="$(CI_COMMIT_SHA)" \
-e TARGETOS="$(TARGETOS)" \
-e TARGETARCH="$(TARGETARCH)" \
-e CGO_ENABLED="$(CGO_ENABLED)" \
-e GOPATH=/tmp/go \
-e HOME=/tmp/home \
-v $(PWD):/build --rm woodpecker/make:local make $(MAKE_ARGS)
else
# Proceed with normal make
##@ General
.PHONY: all
all: help
# The help target prints out all targets with their descriptions organized
# beneath their categories. The categories are represented by '##@' and the
# target descriptions by '##'. The awk commands is responsible for reading the
# entire set of makefiles included in this invocation, looking for lines of the
# file as xyz: ## something, and then pretty-format the target and help. Then,
# if there's a line with ##@ something, that gets pretty-printed as a category.
# More info on the usage of ANSI control characters for terminal formatting:
# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters
# More info on the awk command:
# http://linuxcommand.org/lc3_adv_awk.php
.PHONY: help
help: ## Display this help.
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
.PHONY: version
version: ## Print the current version
@echo ${VERSION}
format: install-tools ## Format source code
@gofumpt -extra -w .
.PHONY: clean
clean: ## Clean build artifacts
go clean -i ./...
rm -rf build
@[ "1" != "$(shell docker image ls woodpecker/make:local -a | wc -l)" ] && docker image rm woodpecker/make:local || echo no docker image to clean
install-tools: ## Install development tools
@hash golangci-lint > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION); \
fi ; \
hash lint > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
go install github.com/rs/zerolog/cmd/lint@latest; \
fi ; \
hash gofumpt > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
go install mvdan.cc/gofumpt@$(GOFUMPT_VERSION); \
fi ; \
hash mockery > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
go install github.com/vektra/mockery/v3@latest; \
fi ; \
##@ Test
.PHONY: lint
lint: install-tools ## Lint code
@echo "Running golangci-lint"
golangci-lint run
@echo "Running zerolog linter"
lint go.woodpecker-ci.org/autoscaler/cmd/woodpecker-autoscaler
test-autoscaler: ## Test autoscaler code
go test -race -cover -coverprofile autoscaler-coverage.out -timeout 30s ${GO_PACKAGES}
.PHONY: test
test: test-autoscaler ## Run all tests
.PHONY: generate
generate:
mockery
##@ Build
build:
CGO_ENABLED=${CGO_ENABLED} GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags '${LDFLAGS}' -o dist/woodpecker-autoscaler go.woodpecker-ci.org/autoscaler/cmd/woodpecker-autoscaler
endif

105
README.md Normal file
View File

@@ -0,0 +1,105 @@
# Timeweb Cloud Autoscaler Provider for Woodpecker CI
Динамический autoscaler для Woodpecker CI с поддержкой хостера Timeweb Cloud.
## Архитектура
```
┌──────────────────────┐
│ Woodpecker Server │◄──── постоянно запущен на VDS
│ (gRPC + Web UI) │
└──────────┬───────────┘
▼ gRPC
┌──────────────────────┐
│ Woodpecker Agent │◄──── постоянно запущен на VDS (можно оставить 1 статический)
│ (Docker Compose) │
└──────────────────────┘
┌──────────────────────────────────────────┐
│ Woodpecker Autoscaler │◄──── постоянно запущен
│ (форк woodpecker-ci/autoscaler + │
│ провайдер timewebcloud) │
└──────────┬───────────────────────────────┘
▼ HTTP / JWT
┌──────────────────────────────────────────┐
│ Timeweb Cloud API │
│ api.timeweb.cloud │
│ - CreateServer │
│ - DeleteServer │
│ - ListServers │
└──────────────────────────────────────────┘
Flow:
1. Появляется задача в очереди Woodpecker
2. Autoscaler создает новый VDS через Timeweb API
3. VDS загружается, cloud-init устанавливает Docker и запускает Agent
4. Agent подключается к Server по gRPC и забирает задачу
5. По завершении задачи и истечении idle-timeout, autoscaler удаляет VDS
```
## Сборка
Требования: Go 1.23+
```bash
go build -o woodpecker-autoscaler ./cmd/woodpecker-autoscaler
```
При сборке на машине с ограниченной памятью используйте:
```bash
GOMAXPROCS=1 go build -p 1 -o woodpecker-autoscaler ./cmd/woodpecker-autoscaler
```
## Запуск
```bash
export WOODPECKER_SERVER=https://your-woodpecker-server
export WOODPECKER_TOKEN=your-token
export WOODPECKER_PROVIDER=timewebcloud
export WOODPECKER_TIMEWEBCLOUD_API_TOKEN=your-jwt-token
export WOODPECKER_TIMEWEBCLOUD_OS_ID=123
export WOODPECKER_TIMEWEBCLOUD_PRESET_ID=456
export WOODPECKER_TIMEWEBCLOUD_AVAILABILITY_ZONE=msk-1
./woodpecker-autoscaler
```
## Структура проекта
```
twcloud-scaler/
├── agents.md # Контекст для AI-агентов
├── README.md # Этот файл
├── go.mod # Модуль Go
├── woodpecker-autoscaler # Собранный бинарник
├── cmd/
│ └── woodpecker-autoscaler/
│ ├── main.go # Точка входа
│ └── flags.go # Глобальные CLI-флаги
├── providers/
│ └── timewebcloud/
│ ├── provider.go # Реализация Provider interface
│ ├── flags.go # CLI-флаги провайдера
│ └── api/
│ └── client.go # Минимальный HTTP-клиент для Timeweb API
├── engine/ # Ядро autoscaler (из upstream)
├── config/ # Конфигурация (из upstream)
├── server/ # Woodpecker API client (из upstream)
└── utils/ # Утилиты (из upstream)
```
## Что реализовано
- [x] Провайдер `timewebcloud` для Woodpecker Autoscaler
- [x] Создание VDS (`DeployAgent`) с cloud-init
- [x] Удаление VDS (`RemoveAgent`) по имени агента
- [x] Список развернутых агентов (`ListDeployedAgentNames`) с фильтрацией по префиксу
- [x] Минимальный HTTP-клиент для Timeweb Cloud API (вместо сломанного SDK)
- [x] Интеграция в main.go
- [x] Успешная сборка бинарника
## Ссылки
- [Woodpecker Autoscaler](https://github.com/woodpecker-ci/autoscaler)
- [Timeweb Cloud API Docs](https://timeweb.cloud/api-docs)

151
agents.md Normal file
View File

@@ -0,0 +1,151 @@
# Project: Woodpecker CI Autoscaler — Timeweb Cloud Provider
## Goal
Add a Timeweb Cloud provider to the Woodpecker CI autoscaler so that:
1. The Woodpecker server runs permanently on one VDS.
2. When a CI job appears, the autoscaler dynamically creates a new VDS on Timeweb Cloud.
3. The VDS is bootstrapped via cloud-init, connects to the server as an agent, and runs the job.
4. After the job finishes and the idle timeout expires, the VDS is destroyed.
## Background
### Current Setup
- Woodpecker server and agent run permanently on a single VDS via Docker Compose.
- The goal is to move to a dynamic model where agents are created on demand.
### Woodpecker CI Autoscaler Architecture
- **Repository**: `woodpecker-ci/autoscaler` (separate from the main `woodpecker-ci/woodpecker` repo).
- **Language**: Go.
- **Provider Interface** (3 methods):
```go
type Provider interface {
DeployAgent(context.Context, *woodpecker.Agent) error
RemoveAgent(context.Context, *woodpecker.Agent) error
ListDeployedAgentNames(context.Context) ([]string, error)
}
```
- **Provisioning Flow**:
1. Autoscaler monitors the Woodpecker queue.
2. When pending tasks exceed capacity, it calls `AgentCreate()` to get a token, then `DeployAgent()`.
3. `DeployAgent` creates a VM and passes cloud-init user-data.
4. The VM boots, installs Docker, and runs the Woodpecker agent container via docker compose.
5. The agent connects to the server via gRPC using the provided token.
6. On scale-down, `RemoveAgent()` terminates the VM, and the agent is deleted from Woodpecker.
- **Cloud-init**: The autoscaler generates a cloud-init YAML that installs Docker and starts the agent. Custom templates are supported via `WOODPECKER_PROVIDER_USERDATA` / `WOODPECKER_PROVIDER_USERDATA_FILE`.
- **Agent Environment Variables** (set in cloud-init):
- `WOODPECKER_SERVER` — gRPC address of the server.
- `WOODPECKER_AGENT_SECRET` — token generated by `AgentCreate()`.
- `WOODPECKER_MAX_WORKFLOWS` — parallelism per agent.
- `WOODPECKER_GRPC_SECURE` — TLS flag.
- **Configuration**: The autoscaler uses `urfave/cli` for CLI flags. Providers define their own flags (e.g., `--hetznercloud-api-token`).
- **Registration**: To add a new provider, you must:
1. Implement the `Provider` interface in a new package under `providers/<name>/`.
2. Create a `flags.go` file with CLI flags.
3. Import the package and add a case in `cmd/woodpecker-autoscaler/main.go`.
4. Append the provider's flags to the global app flags.
### Timeweb Cloud API
- **Public API**: Yes — `https://api.timeweb.cloud`.
- **Official Go SDK**: `github.com/timeweb-cloud/sdk-go` (OpenAPI-generated).
- **Authentication**: JWT Bearer token (`Authorization: Bearer <token>`).
- **VDS Lifecycle Endpoints**:
- Create: `POST /api/v1/servers`
- Delete: `DELETE /api/v1/servers/{server_id}`
- Get: `GET /api/v1/servers/{server_id}`
- List: `GET /api/v1/servers`
- Start: `POST /api/v1/servers/{server_id}/start`
- Shutdown: `POST /api/v1/servers/{server_id}/shutdown`
- Clone: `POST /api/v1/servers/{server_id}/clone`
- **Create Server Parameters**:
- `name` (required)
- `os_id` or `image_id`
- `preset_id` or `configuration` (CPU, RAM, disk)
- `ssh_keys_ids`
- `cloud_init` — **this is critical** for passing user-data.
- `availability_zone`
- `hostname`
- **Rate Limit**: 20 requests per second per endpoint.
- **Tags/Labels**: The API does not seem to have a native "label" or "tag" system for servers. We may need to track pool association by server name prefix or by storing state locally. **This is an open question.**
## Implementation Plan
### Phase 1: Project Setup
1. Fork / vendor `woodpecker-ci/autoscaler` as the base.
2. Add `github.com/timeweb-cloud/sdk-go` as a dependency.
3. Create the provider package: `providers/timewebcloud/`.
### Phase 2: Provider Implementation
1. **Struct & Constructor** (`provider.go`):
- Fields: API client, config, pool ID, default image/preset/zone.
- `New(ctx, cli.Command, *config.Config) (types.Provider, error)`.
2. **Flags** (`flags.go`):
- `--timewebcloud-api-token` (env: `WOODPECKER_TIMEWEBCLOUD_API_TOKEN`)
- `--timewebcloud-os-id` / `--timewebcloud-image-id`
- `--timewebcloud-preset-id` / `--timewebcloud-configuration`
- `--timewebcloud-availability-zone`
- `--timewebcloud-ssh-key-id`
- `--timewebcloud-hostname-prefix`
3. **DeployAgent**:
- Generate cloud-init user-data via `cloudinit.RenderUserDataTemplate()`.
- Call `CreateServer` with the agent name and user-data.
- Store the mapping `agent.Name -> server_id` (in memory or via naming convention).
4. **RemoveAgent**:
- Find server by agent name (list all servers and filter by name, or use a stored mapping).
- Call `DeleteServer`.
- Handle "not found" gracefully.
5. **ListDeployedAgentNames**:
- List all servers.
- Filter by name prefix (e.g., `pool-<pool-id>-agent-`).
- Return matching names.
### Phase 3: Integration
1. Import the provider in `main.go`.
2. Add `case "timewebcloud":` to `setupProvider()`.
3. Append `timewebcloud.ProviderFlags` to the global flags.
### Phase 4: Testing & Deployment
1. Build the binary.
2. Test locally or on a staging VDS:
- Start the autoscaler with `--provider=timewebcloud`.
- Trigger a CI job.
- Verify VDS creation, agent connection, job execution, and cleanup.
3. Update Docker Compose / deployment docs.
## Key Technical Decisions
### 1. How to Track Agent-to-Server Mapping?
**Options**:
- **A. Name Prefix Convention**: Name servers as `wp-<pool>-<agent-name>`. `ListDeployedAgentNames` filters by prefix. Simple, no state needed.
- **B. In-Memory Map**: Store `map[string]int` (agent name -> server ID) in the provider struct. Lost on restart.
- **C. Local State File**: Persist the map to disk. Survives restart.
- **D. API Metadata**: If Timeweb API supports tags/labels, use them. (Currently unclear.)
**Recommendation**: Start with **A** (name prefix) as the simplest and most robust approach. If Timeweb adds tags later, migrate to **D**.
### 2. How to Handle Server Readiness?
**Question**: After `CreateServer`, the server may take time to boot. Does `DeployAgent` need to wait?
**Answer**: No. The autoscaler engine only requires that the VM creation is initiated. The agent will connect when ready. The engine has `AgentInactivityTimeout` (default 10m) to clean up agents that never connect.
### 3. OS Image Selection
**Question**: What base image should be used for the agent VMs?
**Answer**: Ubuntu 22.04 LTS or Debian 12 (stable, good Docker support). The `os_id` must be fetched from Timeweb's API (`GetOsList`). Alternatively, a custom image with Docker pre-installed could speed up boot time.
### 4. SSH Keys
**Question**: Are SSH keys needed if we use cloud-init?
**Answer**: Cloud-init handles everything. SSH keys are optional but useful for debugging. The provider should allow configuring `ssh_keys_ids`.
## Open Questions
1. Does Timeweb Cloud API support assigning custom tags/labels to servers? (Affects `ListDeployedAgentNames` implementation.)
2. What is the typical boot time for a new VDS? (Affects `AgentInactivityTimeout` tuning.)
3. Does the `cloud_init` field in `CreateServer` accept standard cloud-init YAML? (Needs testing.)
4. Is there a way to use a custom image (snapshot) to pre-install Docker and reduce boot time?
5. What are the `os_id` values for Ubuntu/Debian? (Need to call `GetOsList`.)
6. Does Timeweb charge for stopped (but not deleted) servers? (Affects whether we should stop vs. delete.)
## References
- Woodpecker Autoscaler Repo: `https://github.com/woodpecker-ci/autoscaler`
- Provider Interface: `engine/types/provider.go`
- Hetzner Provider (reference): `providers/hetznercloud/`
- Cloud-init Render: `engine/inits/cloudinit/cloudinit.go`
- Timeweb Cloud Go SDK: `https://github.com/timeweb-cloud/sdk-go`
- Timeweb Cloud API Docs: `https://timeweb.cloud/api-docs`

2
checkmake.ini Normal file
View File

@@ -0,0 +1,2 @@
[maxbodylength]
maxBodyLength = 12

19
config/config.go Normal file
View File

@@ -0,0 +1,19 @@
package config
import "time"
type Config struct {
MinAgents int
MaxAgents int
WorkflowsPerAgent int
PoolID string
Image string
Environment map[string]string
GRPCAddress string
GRPCSecure bool
AgentInactivityTimeout time.Duration
AgentIdleTimeout time.Duration
UserData string
FilterLabels string
ExtraAgentLabels map[string]string
}

32
docker-compose.yml Normal file
View File

@@ -0,0 +1,32 @@
version: "3.8"
services:
woodpecker-server:
image: woodpeckerci/woodpecker-server:next
ports:
- "8000:8000"
volumes:
- woodpecker-server-data:/var/lib/woodpecker/
environment:
- WOODPECKER_OPEN=true
- WOODPECKER_ADMIN=pi3c83
- WOODPECKER_GRPC_ADDR=:9000
- WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
restart: unless-stopped
woodpecker-autoscaler:
build:
context: .
dockerfile: Dockerfile
env_file: .env
restart: unless-stopped
depends_on:
- woodpecker-server
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
woodpecker-server-data:

18
docker/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM --platform=$BUILDPLATFORM golang:1.26 AS build
WORKDIR /src
COPY . .
ARG TARGETOS TARGETARCH
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
make build
FROM --platform=$BUILDPLATFORM scratch
ENV GODEBUG=netdns=go
# copy certs from build image
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
# copy agent binary
COPY --from=build /src/dist/woodpecker-autoscaler /bin/
ENTRYPOINT ["/bin/woodpecker-autoscaler"]

27
docker/Dockerfile.make Normal file
View File

@@ -0,0 +1,27 @@
# docker build --rm -f docker/Dockerfile.make -t woodpecker/make:local .
FROM docker.io/golang:1.26-alpine AS golang_image
FROM docker.io/node:24-alpine
RUN apk add --no-cache --update make gcc binutils-gold musl-dev && \
apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main protoc && \
corepack enable
# Build packages.
COPY --from=golang_image /usr/local/go /usr/local/go
COPY Makefile /
ENV PATH=$PATH:/usr/local/go/bin
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
ENV COREPACK_ENABLE_AUTO_PIN=0
# Cache tools
RUN GOBIN=/usr/local/go/bin make install-tools && \
rm -rf /Makefile
ENV GOPATH=/tmp/go
ENV HOME=/tmp/home
ENV PATH=$PATH:/usr/local/go/bin:/tmp/go/bin
WORKDIR /build
RUN chmod -R 777 /root
CMD [ "/bin/sh" ]

92
docs/DEPLOY.md Normal file
View File

@@ -0,0 +1,92 @@
# Инструкция по развертыванию
## 1. Получение API-токена Timeweb Cloud
1. Войдите в панель управления Timeweb Cloud: https://timeweb.cloud
2. Перейдите в раздел **"API и Terraform"**.
3. Создайте новый токен:
- Задайте имя (например, `woodpecker-autoscaler`).
- Выберите права: управление серверами (Servers).
- Можно отключить подтверждение через Telegram для автоматизации.
- Сохраните токен — он отображается только один раз.
## 2. Определение параметров сервера
Проверенные параметры для CI-агента:
| Параметр | Значение | Описание |
|----------|----------|----------|
| OS ID | `79` | Ubuntu 22.04 LTS |
| Preset ID | `2451` | Cloud-40 (2 CPU, 2GB RAM, 40GB NVMe, 800 руб/мес) |
| Availability Zone | `spb-1` | Санкт-Петербург (ru-1) |
Другие варианты:
- Ubuntu 24.04: `os_id=99`
- Debian 12: `os_id=95`
- Москва: `zone=msk-1`, `preset_id=4799` (Cloud MSK 40)
- Новосибирск: `zone=nsk-1`, `preset_id=4241` (Cloud NSK 40)
Для получения полного списка:
```bash
./timeweb-list
```
## 3. Настройка Woodpecker Autoscaler
Создайте `.env` файл:
```bash
# Woodpecker Server
WOODPECKER_SERVER=http://woodpecker-server:8000
WOODPECKER_TOKEN=your-woodpecker-admin-token
WOODPECKER_GRPC_ADDR=woodpecker-server:9000
WOODPECKER_GRPC_SECURE=false
# Autoscaler
WOODPECKER_POOL_ID=twcloud
WOODPECKER_MIN_AGENTS=0
WOODPECKER_MAX_AGENTS=5
WOODPECKER_WORKFLOWS_PER_AGENT=2
WOODPECKER_AGENT_IDLE_TIMEOUT=10m
WOODPECKER_RECONCILIATION_INTERVAL=1m
WOODPECKER_PROVIDER=timewebcloud
# Timeweb Cloud
WOODPECKER_TIMEWEBCLOUD_API_TOKEN=your-jwt-token
WOODPECKER_TIMEWEBCLOUD_OS_ID=79
WOODPECKER_TIMEWEBCLOUD_PRESET_ID=2451
WOODPECKER_TIMEWEBCLOUD_AVAILABILITY_ZONE=spb-1
# Опционально: SSH-ключи для доступа к агентам
# WOODPECKER_TIMEWEBCLOUD_SSH_KEY_IDS=1234
```
## 4. Запуск через Docker Compose
```bash
docker-compose up -d
```
Просмотр логов:
```bash
docker-compose logs -f woodpecker-autoscaler
```
## 5. Проверка
1. Запушьте коммит в репозиторий, подключенный к Woodpecker.
2. В логах autoscaler должно появиться `create agent` и `create server`.
3. В панели Timeweb Cloud должен создаться новый сервер с именем `pool-twcloud-agent-<random>`.
4. Через 2-3 минуты агент подключится к серверу и заберет задачу.
5. После завершения задачи и истечения `AGENT_IDLE_TIMEOUT` сервер будет удален.
## 6. Тестирование API (опционально)
Для проверки корректности API-клиента:
```bash
# Список ОС и тарифов
./timeweb-list
# Тест создания/удаления сервера
TIMEWEB_API_TOKEN=your-token ./timeweb-tester
```

400
engine/autoscaler.go Normal file
View File

@@ -0,0 +1,400 @@
package engine
import (
"context"
"fmt"
"math"
"regexp"
"strings"
"time"
"github.com/rs/zerolog/log"
"go.woodpecker-ci.org/autoscaler/config"
"go.woodpecker-ci.org/autoscaler/engine/types"
"go.woodpecker-ci.org/autoscaler/server"
"go.woodpecker-ci.org/autoscaler/utils"
"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker"
)
type Autoscaler struct {
client server.Client
agents []*woodpecker.Agent
config *config.Config
provider types.Provider
}
// NewAutoscaler creates a new Autoscaler instance.
// It takes in a Provider, Client and Config, and returns a configured
// Autoscaler struct.
func NewAutoscaler(p types.Provider, client server.Client, config *config.Config) Autoscaler {
return Autoscaler{
provider: p,
client: client,
config: config,
}
}
func (a *Autoscaler) loadAgents(_ context.Context) error {
a.agents = []*woodpecker.Agent{}
agents, err := a.client.AgentList()
if err != nil {
return fmt.Errorf("client.AgentList: %w", err)
}
r, err := regexp.Compile(fmt.Sprintf("pool-%s-agent-.*?", a.config.PoolID))
if err != nil {
return fmt.Errorf("could not create regex matcher for agent names by pool ID: %w", err)
}
for _, agent := range agents {
if r.MatchString(agent.Name) {
a.agents = append(a.agents, agent)
}
}
return nil
}
func (a *Autoscaler) getPoolAgents(excludeNoSchedule bool) []*woodpecker.Agent {
agents := make([]*woodpecker.Agent, 0)
for _, agent := range a.agents {
if excludeNoSchedule && agent.NoSchedule {
continue
}
agents = append(agents, agent)
}
return agents
}
func (a *Autoscaler) createAgents(ctx context.Context, amount int) error {
suffixLength := 4
reactivatedAgents := 0
// try to re-activate agents that are in no-schedule state
for i := 0; i < amount; i++ {
for _, agent := range a.agents {
if agent.NoSchedule {
log.Info().Str("agent", agent.Name).Msg("reactivate agent")
agent.NoSchedule = false
_, err := a.client.AgentUpdate(agent)
if err != nil {
return fmt.Errorf("client.AgentUpdate: %w", err)
}
reactivatedAgents++
}
}
}
// create new agents
for i := 0; i < amount-reactivatedAgents; i++ {
agent, err := a.client.AgentCreate(&woodpecker.Agent{
Name: fmt.Sprintf("pool-%s-agent-%s", a.config.PoolID, utils.RandomString(suffixLength)),
})
if err != nil {
return fmt.Errorf("client.AgentCreate: %w", err)
}
log.Info().Str("agent", agent.Name).Msg("deploying agent")
err = a.provider.DeployAgent(ctx, agent)
if err != nil {
return fmt.Errorf("types.DeployAgent: %w", err)
}
a.agents = append(a.agents, agent)
}
return nil
}
func (a *Autoscaler) drainAgents(_ context.Context, amount int) error {
for i := 0; i < amount; i++ {
for _, agent := range a.agents {
// agent is already marked for draining
if agent.NoSchedule {
continue
}
// agent has recently done work => not ready for draining
if time.Since(time.Unix(agent.LastWork, 0)) < a.config.AgentIdleTimeout {
continue
}
// agent has never contacted the server => not ready for draining
if agent.LastContact == 0 {
continue
}
log.Info().Str("agent", agent.Name).Msg("drain agent")
agent.NoSchedule = true
_, err := a.client.AgentUpdate(agent)
if err != nil {
return fmt.Errorf("client.AgentUpdate: %w", err)
}
break
}
}
return nil
}
func (a *Autoscaler) isAgentIdle(agent *woodpecker.Agent) (bool, error) {
tasks, err := a.client.AgentTasksList(agent.ID)
if err != nil {
return false, fmt.Errorf("client.AgentTasksList: %w", err)
}
// agent still has tasks => not idle
if len(tasks) > 0 {
return false, nil
}
// agent has done work recently => not idle
if time.Since(time.Unix(agent.LastWork, 0)) < a.config.AgentIdleTimeout {
return false, nil
}
return true, nil
}
func (a *Autoscaler) removeAgent(ctx context.Context, agent *woodpecker.Agent, reason string) error {
isIdle, err := a.isAgentIdle(agent)
if err != nil {
return err
}
if !isIdle {
log.Info().Str("agent", agent.Name).Msg("agent is still processing workload")
return nil
}
log.Info().Str("agent", agent.Name).Str("reason", reason).Msgf("removing agent")
err = a.provider.RemoveAgent(ctx, agent)
if err != nil {
return err
}
err = a.client.AgentDelete(agent.ID)
if err != nil {
return fmt.Errorf("client.AgentDelete: %w", err)
}
filteredAgents := make([]*woodpecker.Agent, 0)
for _, a := range a.agents {
if a.ID != agent.ID {
filteredAgents = append(filteredAgents, a)
}
}
a.agents = filteredAgents
return nil
}
func (a *Autoscaler) removeDrainedAgents(ctx context.Context) error {
for _, agent := range a.getPoolAgents(false) {
if !agent.NoSchedule {
continue
}
err := a.removeAgent(ctx, agent, "was drained")
if err != nil {
return err
}
}
return nil
}
func (a *Autoscaler) cleanupDanglingAgents(ctx context.Context) error {
woodpeckerAgents := a.getPoolAgents(false)
providerAgentNames, err := a.provider.ListDeployedAgentNames(ctx)
if err != nil {
return err
}
// remove agents that are not in the woodpecker agent list anymore
for _, agentName := range providerAgentNames {
found := false
for _, agent := range woodpeckerAgents {
if agent.Name == agentName {
found = true
break
}
}
if !found {
log.Info().Str("agent", agentName).Str("reason", "not found on woodpecker").Msg("remove agent")
if err := a.provider.RemoveAgent(ctx, &woodpecker.Agent{Name: agentName}); err != nil {
return fmt.Errorf("types.RemoveAgent: %w", err)
}
// remove agent from providerAgentNames
_providerAgentNames := make([]string, 0)
for _, a := range providerAgentNames {
if a != agentName {
_providerAgentNames = append(_providerAgentNames, a)
}
}
providerAgentNames = _providerAgentNames
}
}
// remove agents that do not exist on the provider anymore
for _, agent := range woodpeckerAgents {
found := false
for _, agentName := range providerAgentNames {
if agent.Name == agentName {
found = true
break
}
}
if !found {
log.Info().Str("agent", agent.Name).Str("reason", "not found on provider").Msg("remove agent")
if err = a.client.AgentDelete(agent.ID); err != nil {
return fmt.Errorf("client.AgentDelete: %w", err)
}
// remove agent from woodpeckerAgents
_woodpeckerAgents := make([]*woodpecker.Agent, 0)
for _, a := range a.agents {
if a.Name != agent.Name {
woodpeckerAgents = append(woodpeckerAgents, a)
}
}
a.agents = _woodpeckerAgents
}
}
return nil
}
func (a *Autoscaler) cleanupStaleAgents(ctx context.Context) error {
// remove agents that haven't contacted the server for a while (including agents that never contacted the server)
for _, agent := range a.getPoolAgents(false) {
if agent.NoSchedule {
continue
}
lastContact := agent.LastContact
// if agent has never contacted the server, use the creation time
if lastContact == 0 {
lastContact = agent.Created
}
if time.Since(time.Unix(lastContact, 0)) > a.config.AgentInactivityTimeout {
err := a.removeAgent(ctx, agent, "hasn't connected to the server for a while")
if err != nil {
return err
}
}
}
return nil
}
func (a *Autoscaler) getQueueInfo(_ context.Context) (freeTasks, runningTasks, pendingTasks int, err error) {
queueInfo, err := a.client.QueueInfo()
if err != nil {
return 0, 0, 0, fmt.Errorf("error from QueueInfo: %s", err.Error())
}
if a.config.FilterLabels == "" {
return queueInfo.Stats.Workers, queueInfo.Stats.Running, queueInfo.Stats.Pending, nil
}
labelFilterKey, labelFilterValue, ok := strings.Cut(a.config.FilterLabels, "=")
if !ok {
return 0, 0, 0, fmt.Errorf("invalid labels filter: %s", a.config.FilterLabels)
}
running := countTasksByLabel(queueInfo.Running, labelFilterKey, labelFilterValue)
pending := countTasksByLabel(queueInfo.Pending, labelFilterKey, labelFilterValue)
return queueInfo.Stats.Workers, running, pending, nil
}
func (a *Autoscaler) calcAgents(ctx context.Context) (float64, error) {
freeTasks, runningTasks, pendingTasks, err := a.getQueueInfo(ctx)
if err != nil {
return 0, err
}
log.Debug().Msgf("queue info: freeTasks = %v runningTasks = %v pendingTasks = %v", freeTasks, runningTasks, pendingTasks)
availableAgents := math.Ceil(float64(freeTasks+runningTasks) / float64((a.config.WorkflowsPerAgent)))
reqAgents := math.Ceil(float64(pendingTasks+runningTasks) / float64(a.config.WorkflowsPerAgent))
availablePoolAgents := len(a.getPoolAgents(true))
maxUp := float64(a.config.MaxAgents - availablePoolAgents)
maxDown := float64(availablePoolAgents - a.config.MinAgents)
reqPoolAgents := math.Ceil(reqAgents - (availableAgents + float64(availablePoolAgents)))
reqPoolAgents = math.Max(reqPoolAgents, -maxDown)
reqPoolAgents = math.Min(reqPoolAgents, maxUp)
log.Debug().Msgf("capacity info: agents = %v/%v pool = %v/%v limits = %v/%v", availableAgents, reqAgents, availablePoolAgents, reqPoolAgents, maxUp, maxDown)
return reqPoolAgents, nil
}
// Reconcile periodically checks the status of the agent pool and adjusts it to match
// the desired capacity based on the current queue state.
func (a *Autoscaler) Reconcile(ctx context.Context) error {
if err := a.loadAgents(ctx); err != nil {
return fmt.Errorf("loading agents failed: %w", err)
}
reqPoolAgents, err := a.calcAgents(ctx)
if err != nil {
return fmt.Errorf("calculating agents failed: %w", err)
}
if reqPoolAgents > 0 {
num := int(math.Abs(reqPoolAgents))
log.Debug().Msgf("starting %d additional agents", num)
if err := a.createAgents(ctx, num); err != nil {
return fmt.Errorf("creating agents failed: %w", err)
}
}
if reqPoolAgents < 0 {
num := int(math.Abs(reqPoolAgents))
log.Debug().Msgf("checking %d agents if ready for draining", num)
if err := a.drainAgents(ctx, num); err != nil {
return fmt.Errorf("draining agents failed: %w", err)
}
}
// cleanup agents that are only present at the provider or woodpecker
if err := a.cleanupDanglingAgents(ctx); err != nil {
return fmt.Errorf("cleaning up dangling agents failed: %w", err)
}
// cleanup agents that haven't contacted the server for a while
if err := a.cleanupStaleAgents(ctx); err != nil {
return fmt.Errorf("cleaning up stale agents failed: %w", err)
}
// remove agents that are drained
if err := a.removeDrainedAgents(ctx); err != nil {
return fmt.Errorf("removing drained agents failed: %w", err)
}
return nil
}
func countTasksByLabel(jobs []woodpecker.Task, labelKey, labelValue string) int {
count := 0
for _, job := range jobs {
val, exists := job.Labels[labelKey]
if exists && val == labelValue {
count++
}
}
return count
}

546
engine/autoscaler_test.go Normal file
View File

@@ -0,0 +1,546 @@
package engine
import (
"testing"
"time"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"go.woodpecker-ci.org/autoscaler/config"
mocks_provider "go.woodpecker-ci.org/autoscaler/engine/types/mocks"
mocks_server "go.woodpecker-ci.org/autoscaler/server/mocks"
"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker"
)
type MockClient struct {
workers int
running int
pending int
waitingOnDeps int
woodpecker.Client
}
func (m MockClient) QueueInfo() (*woodpecker.Info, error) {
info := &woodpecker.Info{}
info.Stats.Workers = m.workers
info.Stats.Running = m.running
info.Stats.Pending = m.pending
info.Stats.WaitingOnDeps = m.waitingOnDeps
info.Pending = []woodpecker.Task{
{
Labels: map[string]string{
"arch": "amd64",
},
},
}
info.Running = []woodpecker.Task{
{
Labels: map[string]string{
"arch": "amd64",
},
},
}
return info, nil
}
func Test_calcAgents(t *testing.T) {
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
t.Run("should create new agent (MinAgents)", func(t *testing.T) {
autoscaler := Autoscaler{client: &MockClient{
pending: 0,
}, config: &config.Config{
WorkflowsPerAgent: 1,
MaxAgents: 2,
MinAgents: 1,
}}
value, _ := autoscaler.calcAgents(t.Context())
assert.Equal(t, float64(1), value)
})
t.Run("should create single agent", func(t *testing.T) {
autoscaler := Autoscaler{client: &MockClient{
pending: 2,
}, config: &config.Config{
WorkflowsPerAgent: 5,
MaxAgents: 3,
}}
value, _ := autoscaler.calcAgents(t.Context())
assert.Equal(t, float64(1), value)
})
t.Run("should create multiple agents", func(t *testing.T) {
autoscaler := Autoscaler{client: &MockClient{
pending: 6,
}, config: &config.Config{
WorkflowsPerAgent: 5,
MaxAgents: 3,
}}
value, _ := autoscaler.calcAgents(t.Context())
assert.Equal(t, float64(2), value)
})
t.Run("should create new agent (MaxAgents)", func(t *testing.T) {
autoscaler := Autoscaler{client: &MockClient{
pending: 2,
}, config: &config.Config{
WorkflowsPerAgent: 1,
MaxAgents: 2,
}, agents: []*woodpecker.Agent{
{Name: "pool-1-agent-1234"},
}}
value, _ := autoscaler.calcAgents(t.Context())
assert.Equal(t, float64(1), value)
})
t.Run("should not create new agent (availableAgents)", func(t *testing.T) {
autoscaler := Autoscaler{client: &MockClient{
workers: 2,
pending: 2,
}, config: &config.Config{
WorkflowsPerAgent: 1,
MaxAgents: 2,
}}
value, _ := autoscaler.calcAgents(t.Context())
assert.Equal(t, float64(0), value)
})
}
func Test_getQueueInfo(t *testing.T) {
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
t.Run("should not filter", func(t *testing.T) {
autoscaler := Autoscaler{
client: &MockClient{
pending: 2,
},
config: &config.Config{},
}
free, running, pending, _ := autoscaler.getQueueInfo(t.Context())
assert.Equal(t, 0, free)
assert.Equal(t, 0, running)
assert.Equal(t, 2, pending)
})
t.Run("should filter one by label", func(t *testing.T) {
autoscaler := Autoscaler{
client: &MockClient{
pending: 2,
},
config: &config.Config{
FilterLabels: "arch=amd64",
},
}
free, running, pending, _ := autoscaler.getQueueInfo(t.Context())
assert.Equal(t, 0, free)
assert.Equal(t, 1, running)
assert.Equal(t, 1, pending)
})
t.Run("should filter all by label", func(t *testing.T) {
autoscaler := Autoscaler{
client: &MockClient{
pending: 2,
},
config: &config.Config{
FilterLabels: "arch=arm64",
},
}
free, running, pending, _ := autoscaler.getQueueInfo(t.Context())
assert.Equal(t, 0, free)
assert.Equal(t, 0, running)
assert.Equal(t, 0, pending)
})
}
func Test_getPoolAgents(t *testing.T) {
autoscaler := Autoscaler{
agents: []*woodpecker.Agent{
{ID: 1, Name: "pool-1-agent-1", NoSchedule: false},
{ID: 2, Name: "pool-1-agent-2", NoSchedule: true},
{ID: 3, Name: "pool-1-agent-3", NoSchedule: false},
},
}
agents := autoscaler.getPoolAgents(false)
assert.Equal(t, 3, len(agents))
agents = autoscaler.getPoolAgents(true)
assert.Equal(t, 2, len(agents))
}
func Test_createAgents(t *testing.T) {
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
t.Run("should create a new agent", func(t *testing.T) {
ctx := t.Context()
client := mocks_server.NewMockClient(t)
provider := mocks_provider.NewMockProvider(t)
autoscaler := Autoscaler{
client: client,
provider: provider,
config: &config.Config{
PoolID: "1",
},
}
client.On("AgentCreate", mock.Anything).Return(&woodpecker.Agent{Name: "pool-1-agent-1"}, nil)
provider.On("DeployAgent", ctx, mock.Anything).Return(nil)
err := autoscaler.createAgents(ctx, 1)
assert.NoError(t, err)
})
t.Run("should reuse an no-schedule agent first before creating a new one", func(t *testing.T) {
ctx := t.Context()
client := mocks_server.NewMockClient(t)
provider := mocks_provider.NewMockProvider(t)
autoscaler := Autoscaler{
client: client,
provider: provider,
agents: []*woodpecker.Agent{
{
ID: 1,
NoSchedule: true,
},
},
config: &config.Config{
PoolID: "1",
},
}
client.On("AgentUpdate", mock.MatchedBy(func(agent *woodpecker.Agent) bool {
return agent.ID == 1 && agent.NoSchedule == false
})).Return(nil, nil)
client.On("AgentCreate", mock.Anything).Return(&woodpecker.Agent{Name: "pool-1-agent-1"}, nil)
provider.On("DeployAgent", ctx, mock.Anything).Return(nil)
err := autoscaler.createAgents(ctx, 2)
assert.NoError(t, err)
})
}
func Test_cleanupDanglingAgents(t *testing.T) {
t.Run("should remove agent that is only present on woodpecker (not provider)", func(t *testing.T) {
ctx := t.Context()
client := mocks_server.NewMockClient(t)
provider := mocks_provider.NewMockProvider(t)
autoscaler := Autoscaler{
agents: []*woodpecker.Agent{
{ID: 1, Name: "pool-1-agent-1", NoSchedule: false},
},
provider: provider,
client: client,
}
provider.On("ListDeployedAgentNames", mock.Anything).Return(nil, nil)
client.On("AgentDelete", int64(1)).Return(nil)
err := autoscaler.cleanupDanglingAgents(ctx)
assert.NoError(t, err)
})
t.Run("should remove agent that is only present on provider (not woodpecker)", func(t *testing.T) {
ctx := t.Context()
client := mocks_server.NewMockClient(t)
provider := mocks_provider.NewMockProvider(t)
autoscaler := Autoscaler{
agents: []*woodpecker.Agent{
{ID: 1, Name: "pool-1-agent-1", NoSchedule: false},
},
provider: provider,
client: client,
}
provider.On("ListDeployedAgentNames", mock.Anything).Return([]string{"pool-1-agent-1", "pool-1-agent-2"}, nil)
provider.On("RemoveAgent", mock.Anything, mock.MatchedBy(func(agent *woodpecker.Agent) bool {
return agent.Name == "pool-1-agent-2"
})).Return(nil)
err := autoscaler.cleanupDanglingAgents(ctx)
assert.NoError(t, err)
})
}
func Test_cleanupStaleAgents(t *testing.T) {
t.Run("should remove agent that never connected (last contact = 0) in over 15 minutes", func(t *testing.T) {
ctx := t.Context()
client := mocks_server.NewMockClient(t)
provider := mocks_provider.NewMockProvider(t)
autoscaler := Autoscaler{
agents: []*woodpecker.Agent{
{
ID: 1,
Name: "active agent",
NoSchedule: false,
Created: time.Now().Add(-time.Minute * 20).Unix(), // created 20 minutes ago
LastContact: time.Now().Add(-time.Minute * 5).Unix(), // last contact 5 minutes ago
},
{
ID: 2,
Name: "never contacted agent",
NoSchedule: false,
Created: time.Now().Add(-time.Minute * 20).Unix(), // created 20 minutes ago
LastContact: 0, // never contacted
},
},
provider: provider,
client: client,
config: &config.Config{
AgentInactivityTimeout: time.Minute * 15,
},
}
client.On("AgentTasksList", int64(2)).Return(nil, nil)
client.On("AgentDelete", int64(2)).Return(nil)
provider.On("RemoveAgent", mock.Anything, mock.MatchedBy(func(agent *woodpecker.Agent) bool {
return agent.ID == 2
})).Return(nil)
err := autoscaler.cleanupStaleAgents(ctx)
assert.NoError(t, err)
})
t.Run("should remove agent that has lost connection for more than 15 minutes", func(t *testing.T) {
ctx := t.Context()
client := mocks_server.NewMockClient(t)
provider := mocks_provider.NewMockProvider(t)
autoscaler := Autoscaler{
agents: []*woodpecker.Agent{
{
ID: 1,
Name: "active agent",
NoSchedule: false,
Created: time.Now().Add(-time.Minute * 20).Unix(), // created 20 minutes ago
LastContact: time.Now().Add(-time.Minute * 5).Unix(), // last contact 5 minutes ago
},
{
ID: 2,
Name: "stale agent",
NoSchedule: false,
Created: time.Now().Add(-time.Minute * 20).Unix(), // created 20 minutes ago
LastContact: time.Now().Add(-time.Minute * 20).Unix(), // last contact 20 minutes ago
},
},
provider: provider,
client: client,
config: &config.Config{
AgentInactivityTimeout: time.Minute * 15,
},
}
client.On("AgentTasksList", int64(2)).Return(nil, nil)
client.On("AgentDelete", int64(2)).Return(nil)
provider.On("RemoveAgent", mock.Anything, mock.MatchedBy(func(agent *woodpecker.Agent) bool {
return agent.ID == 2
})).Return(nil)
err := autoscaler.cleanupStaleAgents(ctx)
assert.NoError(t, err)
})
}
func Test_isAgentIdle(t *testing.T) {
t.Run("should return false if agent has tasks", func(t *testing.T) {
client := mocks_server.NewMockClient(t)
autoscaler := Autoscaler{
client: client,
config: &config.Config{
AgentIdleTimeout: time.Minute * 15,
},
}
client.On("AgentTasksList", int64(1)).Return([]*woodpecker.Task{
{ID: "1"},
}, nil)
idle, err := autoscaler.isAgentIdle(&woodpecker.Agent{
ID: 1,
Name: "pool-1-agent-1",
NoSchedule: false,
})
assert.NoError(t, err)
assert.False(t, idle)
})
t.Run("should return false if agent has done work recently", func(t *testing.T) {
client := mocks_server.NewMockClient(t)
autoscaler := Autoscaler{
client: client,
config: &config.Config{
AgentIdleTimeout: time.Minute * 15,
},
}
client.On("AgentTasksList", int64(1)).Return(nil, nil)
idle, err := autoscaler.isAgentIdle(&woodpecker.Agent{
ID: 1,
Name: "pool-1-agent-1",
NoSchedule: false,
LastWork: time.Now().Add(-time.Minute * 10).Unix(),
})
assert.NoError(t, err)
assert.False(t, idle)
})
t.Run("should return true if agent is idle", func(t *testing.T) {
client := mocks_server.NewMockClient(t)
autoscaler := Autoscaler{
client: client,
config: &config.Config{
AgentIdleTimeout: time.Minute * 15,
},
}
client.On("AgentTasksList", int64(1)).Return(nil, nil) // no tasks
idle, err := autoscaler.isAgentIdle(&woodpecker.Agent{
ID: 1,
Name: "pool-1-agent-1",
NoSchedule: false,
LastWork: time.Now().Add(-time.Minute * 20).Unix(), // last work 20 minutes ago
})
assert.NoError(t, err)
assert.True(t, idle)
})
}
func Test_drainAgents(t *testing.T) {
t.Run("should drain agents and skip no-schedule ones", func(t *testing.T) {
ctx := t.Context()
client := mocks_server.NewMockClient(t)
provider := mocks_provider.NewMockProvider(t)
autoscaler := Autoscaler{
agents: []*woodpecker.Agent{
{ID: 1, Name: "pool-1-agent-1", NoSchedule: false, LastContact: time.Now().Add(-time.Minute * 2).Unix()},
{ID: 2, Name: "pool-1-agent-2", NoSchedule: true, LastContact: time.Now().Add(-time.Minute * 2).Unix()},
{ID: 3, Name: "pool-1-agent-3", NoSchedule: true, LastContact: time.Now().Add(-time.Minute * 2).Unix()},
{ID: 4, Name: "pool-1-agent-4", NoSchedule: false, LastContact: time.Now().Add(-time.Minute * 2).Unix()},
},
provider: provider,
client: client,
config: &config.Config{
AgentIdleTimeout: time.Minute * 15,
},
}
client.On("AgentUpdate", mock.MatchedBy(func(agent *woodpecker.Agent) bool {
return (agent.ID == 1 || agent.ID == 4) && agent.NoSchedule == true
})).Return(nil, nil)
err := autoscaler.drainAgents(ctx, 2)
assert.NoError(t, err)
assert.True(t, autoscaler.agents[0].NoSchedule)
assert.True(t, autoscaler.agents[3].NoSchedule)
})
t.Run("should not remove an agent that never connected", func(t *testing.T) {
ctx := t.Context()
client := mocks_server.NewMockClient(t)
provider := mocks_provider.NewMockProvider(t)
autoscaler := Autoscaler{
agents: []*woodpecker.Agent{
{ID: 1, Name: "pool-1-agent-1", NoSchedule: false, LastContact: 0},
},
provider: provider,
client: client,
config: &config.Config{
AgentIdleTimeout: time.Minute * 15,
},
}
err := autoscaler.drainAgents(ctx, 1)
assert.NoError(t, err)
assert.False(t, autoscaler.agents[0].NoSchedule)
})
t.Run("should not remove an agent that has recently done some work", func(t *testing.T) {
ctx := t.Context()
client := mocks_server.NewMockClient(t)
provider := mocks_provider.NewMockProvider(t)
autoscaler := Autoscaler{
agents: []*woodpecker.Agent{
{
ID: 1,
Name: "pool-1-agent-1",
NoSchedule: false,
LastContact: time.Now().Add(-time.Minute * 2).Unix(), // last contact 2 minutes ago
LastWork: time.Now().Add(-time.Minute * 5).Unix(), // last work 5 minutes ago
},
},
provider: provider,
client: client,
config: &config.Config{
AgentIdleTimeout: time.Minute * 15,
},
}
err := autoscaler.drainAgents(ctx, 1)
assert.NoError(t, err)
assert.False(t, autoscaler.agents[0].NoSchedule)
})
}
func Test_removeDrainedAgents(t *testing.T) {
t.Run("should remove agent", func(t *testing.T) {
ctx := t.Context()
client := mocks_server.NewMockClient(t)
provider := mocks_provider.NewMockProvider(t)
autoscaler := Autoscaler{
agents: []*woodpecker.Agent{
{ID: 1, Name: "pool-1-agent-1", NoSchedule: false},
{ID: 2, Name: "pool-1-agent-2", NoSchedule: true},
{ID: 3, Name: "pool-1-agent-3", NoSchedule: false},
},
provider: provider,
client: client,
config: &config.Config{
AgentIdleTimeout: time.Minute * 15,
},
}
client.On("AgentTasksList", int64(2)).Return(nil, nil)
provider.On("RemoveAgent", mock.Anything, mock.MatchedBy(func(agent *woodpecker.Agent) bool {
return agent.ID == 2
})).Return(nil)
client.On("AgentDelete", int64(2)).Return(nil)
err := autoscaler.removeDrainedAgents(ctx)
assert.NoError(t, err)
})
t.Run("should not remove agent with tasks", func(t *testing.T) {
ctx := t.Context()
client := mocks_server.NewMockClient(t)
provider := mocks_provider.NewMockProvider(t)
autoscaler := Autoscaler{
agents: []*woodpecker.Agent{
{ID: 1, Name: "pool-1-agent-1", NoSchedule: false},
{ID: 2, Name: "pool-1-agent-2", NoSchedule: true},
{ID: 3, Name: "pool-1-agent-3", NoSchedule: false},
},
provider: provider,
client: client,
}
client.On("AgentTasksList", int64(2)).Return([]*woodpecker.Task{
{ID: "1"},
}, nil)
err := autoscaler.removeDrainedAgents(ctx)
assert.NoError(t, err)
})
}

9
engine/const.go Normal file
View File

@@ -0,0 +1,9 @@
package engine
import "fmt"
var (
LabelPrefix = "wp.autoscaler/"
LabelPool = fmt.Sprintf("%spool", LabelPrefix)
LabelImage = fmt.Sprintf("%simage", LabelPrefix)
)

View File

@@ -0,0 +1,116 @@
package cloudinit
import (
"bytes"
"fmt"
"strings"
"text/template"
"go.woodpecker-ci.org/autoscaler/config"
"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker"
)
// RenderUserDataTemplate renders the user data template for an Agent
// using the provided configuration.
func RenderUserDataTemplate(config *config.Config, agent *woodpecker.Agent, tmpl *template.Template) (string, error) {
var err error
switch {
case tmpl != nil:
case config.UserData != "":
tmpl, err = template.New("user-data").Parse(config.UserData)
default:
tmpl, err = template.New("user-data").Parse(CloudInitUserDataUbuntuDefault)
}
if err != nil {
return "", fmt.Errorf("template.New.Parse %w", err)
}
params := struct {
Image string
Environment map[string]string
}{
Image: config.Image,
Environment: map[string]string{
"WOODPECKER_SERVER": config.GRPCAddress,
"WOODPECKER_AGENT_SECRET": agent.Token,
"WOODPECKER_MAX_WORKFLOWS": fmt.Sprintf("%d", config.WorkflowsPerAgent),
},
}
if config.GRPCSecure {
params.Environment["WOODPECKER_GRPC_SECURE"] = "true"
}
for key, value := range config.Environment {
params.Environment[key] = value
}
params.Environment["WOODPECKER_AGENT_LABELS"] = genExtraAgentLabels(config.ExtraAgentLabels)
var userData bytes.Buffer
if err := tmpl.Execute(&userData, params); err != nil {
return "", err
}
return userData.String(), nil
}
func genExtraAgentLabels(conf map[string]string) string {
out := make([]string, 0, len(conf))
for k, v := range conf {
out = append(out, fmt.Sprintf("%s=%s", k, v))
}
return strings.Join(out, ",")
}
// editorconfig-checker-disable
var CloudInitUserDataUbuntuDefault = `
#cloud-config
package_reboot_if_required: false
package_update: true
package_upgrade: false
groups:
- docker
system_info:
default_user:
groups: [ docker ]
apt:
sources:
docker.list:
keyid: 9DC858229FC7DD38854AE2D88D81803C0EBFCD88
keyserver: https://download.docker.com/linux/ubuntu/gpg
source: deb [signed-by=$KEY_FILE] https://download.docker.com/linux/ubuntu $RELEASE stable
packages:
- docker-ce
- docker-compose-plugin
- binfmt-support
- qemu-user-static
write_files:
- path: /root/docker-compose.yml
content: |
# docker-compose.yml
version: '3'
services:
woodpecker-agent:
image: {{ .Image }}
restart: always
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
{{- range $key, $value := .Environment }}
- {{ $key }}={{ $value }}
{{- end }}
runcmd:
- sh -xc "cd /root; docker compose up -d"
final_message: "The system is finally up, after $UPTIME seconds"
` // editorconfig-checker-enable

View File

@@ -0,0 +1,65 @@
package cloudinit_test
import (
"testing"
"text/template"
"github.com/stretchr/testify/assert"
"go.woodpecker-ci.org/autoscaler/config"
"go.woodpecker-ci.org/autoscaler/engine/inits/cloudinit"
"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker"
)
var testUserDataStr = `
image: {{ .Image }}
environment:
{{- range $key, $value := .Environment }}
- {{ $key }}={{ $value }}
{{- end }}
`
var testUserDataTmpl = template.Must(template.New("test").Parse(testUserDataStr))
func TestRenderUserDataTemplate(t *testing.T) {
config := &config.Config{
Image: "test-image",
GRPCAddress: "test-address",
GRPCSecure: false,
Environment: map[string]string{
"FOO": "bar",
},
}
agent := &woodpecker.Agent{
Token: "test-token",
}
userData, err := cloudinit.RenderUserDataTemplate(config, agent, testUserDataTmpl)
assert.NoError(t, err)
assert.Contains(t, userData, "test-image")
assert.Contains(t, userData, "bar")
assert.Contains(t, userData, "WOODPECKER_SERVER=test-address")
assert.Contains(t, userData, "WOODPECKER_AGENT_SECRET=test-token")
}
func TestRenderUserDataTemplate_Secure(t *testing.T) {
config := &config.Config{
GRPCSecure: true,
}
agent := &woodpecker.Agent{}
userData, err := cloudinit.RenderUserDataTemplate(config, agent, testUserDataTmpl)
assert.NoError(t, err)
assert.Contains(t, userData, "WOODPECKER_GRPC_SECURE=true")
}
func TestRenderUserDataTemplate_Error(t *testing.T) {
config := &config.Config{}
agent := &woodpecker.Agent{}
tmpl := template.Must(template.New("test").Parse("{{.Missing}}"))
_, err := cloudinit.RenderUserDataTemplate(config, agent, tmpl)
assert.Error(t, err)
}

View File

@@ -0,0 +1,215 @@
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package mocks
import (
"context"
mock "github.com/stretchr/testify/mock"
"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker"
)
// NewMockProvider creates a new instance of MockProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockProvider(t interface {
mock.TestingT
Cleanup(func())
}) *MockProvider {
mock := &MockProvider{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// MockProvider is an autogenerated mock type for the Provider type
type MockProvider struct {
mock.Mock
}
type MockProvider_Expecter struct {
mock *mock.Mock
}
func (_m *MockProvider) EXPECT() *MockProvider_Expecter {
return &MockProvider_Expecter{mock: &_m.Mock}
}
// DeployAgent provides a mock function for the type MockProvider
func (_mock *MockProvider) DeployAgent(context1 context.Context, agent *woodpecker.Agent) error {
ret := _mock.Called(context1, agent)
if len(ret) == 0 {
panic("no return value specified for DeployAgent")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *woodpecker.Agent) error); ok {
r0 = returnFunc(context1, agent)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockProvider_DeployAgent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeployAgent'
type MockProvider_DeployAgent_Call struct {
*mock.Call
}
// DeployAgent is a helper method to define mock.On call
// - context1 context.Context
// - agent *woodpecker.Agent
func (_e *MockProvider_Expecter) DeployAgent(context1 interface{}, agent interface{}) *MockProvider_DeployAgent_Call {
return &MockProvider_DeployAgent_Call{Call: _e.mock.On("DeployAgent", context1, agent)}
}
func (_c *MockProvider_DeployAgent_Call) Run(run func(context1 context.Context, agent *woodpecker.Agent)) *MockProvider_DeployAgent_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *woodpecker.Agent
if args[1] != nil {
arg1 = args[1].(*woodpecker.Agent)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockProvider_DeployAgent_Call) Return(err error) *MockProvider_DeployAgent_Call {
_c.Call.Return(err)
return _c
}
func (_c *MockProvider_DeployAgent_Call) RunAndReturn(run func(context1 context.Context, agent *woodpecker.Agent) error) *MockProvider_DeployAgent_Call {
_c.Call.Return(run)
return _c
}
// ListDeployedAgentNames provides a mock function for the type MockProvider
func (_mock *MockProvider) ListDeployedAgentNames(context1 context.Context) ([]string, error) {
ret := _mock.Called(context1)
if len(ret) == 0 {
panic("no return value specified for ListDeployedAgentNames")
}
var r0 []string
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok {
return returnFunc(context1)
}
if returnFunc, ok := ret.Get(0).(func(context.Context) []string); ok {
r0 = returnFunc(context1)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]string)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = returnFunc(context1)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockProvider_ListDeployedAgentNames_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListDeployedAgentNames'
type MockProvider_ListDeployedAgentNames_Call struct {
*mock.Call
}
// ListDeployedAgentNames is a helper method to define mock.On call
// - context1 context.Context
func (_e *MockProvider_Expecter) ListDeployedAgentNames(context1 interface{}) *MockProvider_ListDeployedAgentNames_Call {
return &MockProvider_ListDeployedAgentNames_Call{Call: _e.mock.On("ListDeployedAgentNames", context1)}
}
func (_c *MockProvider_ListDeployedAgentNames_Call) Run(run func(context1 context.Context)) *MockProvider_ListDeployedAgentNames_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
run(
arg0,
)
})
return _c
}
func (_c *MockProvider_ListDeployedAgentNames_Call) Return(strings []string, err error) *MockProvider_ListDeployedAgentNames_Call {
_c.Call.Return(strings, err)
return _c
}
func (_c *MockProvider_ListDeployedAgentNames_Call) RunAndReturn(run func(context1 context.Context) ([]string, error)) *MockProvider_ListDeployedAgentNames_Call {
_c.Call.Return(run)
return _c
}
// RemoveAgent provides a mock function for the type MockProvider
func (_mock *MockProvider) RemoveAgent(context1 context.Context, agent *woodpecker.Agent) error {
ret := _mock.Called(context1, agent)
if len(ret) == 0 {
panic("no return value specified for RemoveAgent")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *woodpecker.Agent) error); ok {
r0 = returnFunc(context1, agent)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockProvider_RemoveAgent_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveAgent'
type MockProvider_RemoveAgent_Call struct {
*mock.Call
}
// RemoveAgent is a helper method to define mock.On call
// - context1 context.Context
// - agent *woodpecker.Agent
func (_e *MockProvider_Expecter) RemoveAgent(context1 interface{}, agent interface{}) *MockProvider_RemoveAgent_Call {
return &MockProvider_RemoveAgent_Call{Call: _e.mock.On("RemoveAgent", context1, agent)}
}
func (_c *MockProvider_RemoveAgent_Call) Run(run func(context1 context.Context, agent *woodpecker.Agent)) *MockProvider_RemoveAgent_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *woodpecker.Agent
if args[1] != nil {
arg1 = args[1].(*woodpecker.Agent)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockProvider_RemoveAgent_Call) Return(err error) *MockProvider_RemoveAgent_Call {
_c.Call.Return(err)
return _c
}
func (_c *MockProvider_RemoveAgent_Call) RunAndReturn(run func(context1 context.Context, agent *woodpecker.Agent) error) *MockProvider_RemoveAgent_Call {
_c.Call.Return(run)
return _c
}

13
engine/types/provider.go Normal file
View File

@@ -0,0 +1,13 @@
package types
import (
"context"
"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker"
)
type Provider interface {
DeployAgent(context.Context, *woodpecker.Agent) error
RemoveAgent(context.Context, *woodpecker.Agent) error
ListDeployedAgentNames(context.Context) ([]string, error)
}

28
go.mod Normal file
View File

@@ -0,0 +1,28 @@
module go.woodpecker-ci.org/autoscaler
go 1.26.0
toolchain go1.26.3
require (
github.com/joho/godotenv v1.5.1
github.com/rs/zerolog v1.35.1
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v3 v3.9.0
go.woodpecker-ci.org/woodpecker/v3 v3.14.1
golang.org/x/net v0.54.0
golang.org/x/oauth2 v0.36.0
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
golang.org/x/sys v0.44.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

44
go.sum Normal file
View File

@@ -0,0 +1,44 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/urfave/cli/v3 v3.9.0 h1:AV9lIiPv3ukYnxunaCUsHnEozptYmDN2F0+yWqLMn/c=
github.com/urfave/cli/v3 v3.9.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
go.woodpecker-ci.org/woodpecker/v3 v3.14.1 h1:/HrTfGQOJxVHx5+ReCAf/4BBZe+HoE7s/rYgOzZlRi0=
go.woodpecker-ci.org/woodpecker/v3 v3.14.1/go.mod h1:b0Rz7zEMUG1T2C0djxV1euWkNlSDH5uqJB/5fCQtvBE=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,222 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
)
const defaultBaseURL = "https://api.timeweb.cloud"
// Client is a minimal HTTP client for the Timeweb Cloud API.
type Client struct {
baseURL string
token string
client *http.Client
}
// NewClient creates a new Timeweb Cloud API client.
func NewClient(token string) *Client {
return &Client{
baseURL: defaultBaseURL,
token: token,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *Client) request(ctx context.Context, method, path string, body []byte) (*http.Response, error) {
url := c.baseURL + path
var bodyReader io.Reader
if body != nil {
bodyReader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
return c.client.Do(req)
}
// CreateServerRequest is the payload for creating a server.
type CreateServerRequest struct {
Name string `json:"name"`
OsID int32 `json:"os_id,omitempty"`
ImageID string `json:"image_id,omitempty"`
PresetID int32 `json:"preset_id,omitempty"`
Bandwidth int32 `json:"bandwidth,omitempty"`
SSHKeysIds []int32 `json:"ssh_keys_ids,omitempty"`
CloudInit string `json:"cloud_init,omitempty"`
AvailabilityZone string `json:"availability_zone,omitempty"`
Hostname string `json:"hostname,omitempty"`
Comment string `json:"comment,omitempty"`
}
// CreateServerResponse is the response from creating a server.
type CreateServerResponse struct {
Server Server `json:"server"`
}
// Server represents a Timeweb Cloud VDS.
type Server struct {
ID int32 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Location string `json:"location"`
}
// CreateServer creates a new cloud server.
func (c *Client) CreateServer(ctx context.Context, req CreateServerRequest) (*CreateServerResponse, error) {
payload, err := json.Marshal(req)
if err != nil {
return nil, err
}
resp, err := c.request(ctx, http.MethodPost, "/api/v1/servers", payload)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
}
var result CreateServerResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}
// DeleteServer deletes a cloud server by ID.
func (c *Client) DeleteServer(ctx context.Context, id int32) error {
path := "/api/v1/servers/" + strconv.FormatInt(int64(id), 10)
resp, err := c.request(ctx, http.MethodDelete, path, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
}
return nil
}
// GetServersResponse is the response for listing servers.
type GetServersResponse struct {
Meta Meta `json:"meta"`
Servers []Server `json:"servers"`
}
// Meta contains pagination info.
type Meta struct {
Total int32 `json:"total"`
}
// GetServers lists all cloud servers.
func (c *Client) GetServers(ctx context.Context) (*GetServersResponse, error) {
resp, err := c.request(ctx, http.MethodGet, "/api/v1/servers", nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
}
var result GetServersResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}
type OS struct {
ID int32 `json:"id"`
Family string `json:"family"`
Name string `json:"name"`
Version string `json:"version"`
VersionCodename string `json:"version_codename"`
Description string `json:"description"`
}
type GetOSListResponse struct {
OsList []OS `json:"servers_os"`
}
func (c *Client) GetOSList(ctx context.Context) (*GetOSListResponse, error) {
resp, err := c.request(ctx, http.MethodGet, "/api/v1/os/servers", nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
}
var result GetOSListResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}
type Preset struct {
ID int32 `json:"id"`
Location string `json:"location"`
Price float64 `json:"price"`
CPU int32 `json:"cpu"`
CPUFrequency string `json:"cpu_frequency"`
RAM int32 `json:"ram"`
Disk int32 `json:"disk"`
DiskType string `json:"disk_type"`
Bandwidth int32 `json:"bandwidth"`
DescriptionShort string `json:"description_short"`
IsDedicatedCPU bool `json:"is_dedicated_cpu"`
Tags []string `json:"tags"`
}
type GetPresetsResponse struct {
Presets []Preset `json:"server_presets"`
}
func (c *Client) GetServerPresets(ctx context.Context) (*GetPresetsResponse, error) {
resp, err := c.request(ctx, http.MethodGet, "/api/v1/presets/servers", nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
}
var result GetPresetsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}

View File

@@ -0,0 +1,47 @@
package timewebcloud
import (
"github.com/urfave/cli/v3"
)
const category = "Timeweb Cloud"
var ProviderFlags = []cli.Flag{
&cli.StringFlag{
Name: "timewebcloud-api-token",
Usage: "Timeweb Cloud API JWT token",
Sources: cli.EnvVars("WOODPECKER_TIMEWEBCLOUD_API_TOKEN"),
Category: category,
},
&cli.IntFlag{
Name: "timewebcloud-os-id",
Usage: "OS image ID to use for new servers (from GetOsList)",
Sources: cli.EnvVars("WOODPECKER_TIMEWEBCLOUD_OS_ID"),
Category: category,
},
&cli.IntFlag{
Name: "timewebcloud-preset-id",
Usage: "Pricing preset ID (from GetServersPresets)",
Sources: cli.EnvVars("WOODPECKER_TIMEWEBCLOUD_PRESET_ID"),
Category: category,
},
&cli.StringFlag{
Name: "timewebcloud-availability-zone",
Usage: "Availability zone for new servers",
Sources: cli.EnvVars("WOODPECKER_TIMEWEBCLOUD_AVAILABILITY_ZONE"),
Category: category,
},
&cli.StringSliceFlag{
Name: "timewebcloud-ssh-key-ids",
Usage: "SSH key IDs to inject into new servers",
Sources: cli.EnvVars("WOODPECKER_TIMEWEBCLOUD_SSH_KEY_IDS"),
Category: category,
},
// TODO: Deprecated remove in v2.0
&cli.StringFlag{
Name: "timewebcloud-user-data",
Usage: "timeweb cloud userdata template (deprecated)",
Sources: cli.EnvVars("WOODPECKER_TIMEWEBCLOUD_USERDATA"),
Category: category,
},
}

View File

@@ -0,0 +1,122 @@
package timewebcloud
import (
"context"
"fmt"
"strconv"
"strings"
"text/template"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v3"
"go.woodpecker-ci.org/autoscaler/config"
"go.woodpecker-ci.org/autoscaler/engine/inits/cloudinit"
"go.woodpecker-ci.org/autoscaler/engine/types"
"go.woodpecker-ci.org/autoscaler/providers/timewebcloud/api"
woodpecker "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker"
)
type Provider struct {
name string
config *config.Config
client *api.Client
osID int32
presetID int32
availabilityZone string
sshKeyIDs []int32
userDataTemplate *template.Template
}
func New(_ context.Context, c *cli.Command, config *config.Config) (types.Provider, error) {
p := &Provider{
name: "timewebcloud",
config: config,
client: api.NewClient(c.String("timewebcloud-api-token")),
osID: int32(c.Int("timewebcloud-os-id")),
presetID: int32(c.Int("timewebcloud-preset-id")),
availabilityZone: c.String("timewebcloud-availability-zone"),
}
for _, raw := range c.StringSlice("timewebcloud-ssh-key-ids") {
id, err := strconv.ParseInt(raw, 10, 32)
if err != nil {
return nil, fmt.Errorf("%s: invalid ssh key id: %s", p.name, raw)
}
p.sshKeyIDs = append(p.sshKeyIDs, int32(id))
}
if u := c.String("timewebcloud-user-data"); u != "" {
log.Warn().Msg("timewebcloud-user-data is deprecated, please use provider-user-data instead")
tmpl, err := template.New("user-data").Parse(u)
if err != nil {
return nil, fmt.Errorf("%s: template.New.Parse %w", p.name, err)
}
p.userDataTemplate = tmpl
}
return p, nil
}
func (p *Provider) DeployAgent(ctx context.Context, agent *woodpecker.Agent) error {
userData, err := cloudinit.RenderUserDataTemplate(p.config, agent, p.userDataTemplate)
if err != nil {
return fmt.Errorf("%s: cloudinit.RenderUserDataTemplate: %w", p.name, err)
}
req := api.CreateServerRequest{
Name: agent.Name,
OsID: p.osID,
PresetID: p.presetID,
CloudInit: userData,
AvailabilityZone: p.availabilityZone,
SSHKeysIds: p.sshKeyIDs,
Comment: fmt.Sprintf("woodpecker autoscaler agent for pool %s", p.config.PoolID),
}
log.Info().Str("agent", agent.Name).Msg("create agent")
_, err = p.client.CreateServer(ctx, req)
if err != nil {
return fmt.Errorf("%s: CreateServer: %w", p.name, err)
}
return nil
}
func (p *Provider) RemoveAgent(ctx context.Context, agent *woodpecker.Agent) error {
servers, err := p.client.GetServers(ctx)
if err != nil {
return fmt.Errorf("%s: GetServers: %w", p.name, err)
}
for _, server := range servers.Servers {
if server.Name == agent.Name {
log.Info().Str("agent", agent.Name).Int32("server_id", server.ID).Msg("delete agent")
if err := p.client.DeleteServer(ctx, server.ID); err != nil {
return fmt.Errorf("%s: DeleteServer: %w", p.name, err)
}
return nil
}
}
log.Warn().Str("agent", agent.Name).Msg("agent not found, skipping deletion")
return nil
}
func (p *Provider) ListDeployedAgentNames(ctx context.Context) ([]string, error) {
servers, err := p.client.GetServers(ctx)
if err != nil {
return nil, fmt.Errorf("%s: GetServers: %w", p.name, err)
}
prefix := fmt.Sprintf("pool-%s-agent-", p.config.PoolID)
var names []string
for _, server := range servers.Servers {
if strings.HasPrefix(server.Name, prefix) {
names = append(names, server.Name)
}
}
return names, nil
}

3
release-config.ts Normal file
View File

@@ -0,0 +1,3 @@
export default {
commentOnReleasedPullRequests: false,
};

4
renovate.json Normal file
View File

@@ -0,0 +1,4 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>woodpecker-ci/renovate-config"]
}

79
server/client.go Normal file
View File

@@ -0,0 +1,79 @@
package server
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"strings"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v3"
"golang.org/x/net/proxy"
"golang.org/x/oauth2"
"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker"
)
type Client interface {
woodpecker.Client
}
// NewClient returns a new client from the CLI context.
func NewClient(ctx context.Context, c *cli.Command) (Client, error) {
var (
skip = c.Bool("skip-verify")
socks = c.String("socks-proxy")
socksoff = c.Bool("socks-proxy-off")
serverToken = c.String("server-token")
serverURL = c.String("server-url")
)
serverURL = strings.TrimRight(serverURL, "/")
if len(serverURL) == 0 {
return nil, fmt.Errorf("please provide the woodpecker server address")
}
if len(serverToken) == 0 {
return nil, fmt.Errorf("please provide a woodpecker access token")
}
// attempt to find system CA certs
certs, err := x509.SystemCertPool()
if err != nil {
log.Error().Err(err).Msg("ca certs not found")
}
tlsConfig := &tls.Config{
RootCAs: certs,
InsecureSkipVerify: skip,
}
config := new(oauth2.Config)
client := config.Client(
ctx,
&oauth2.Token{
AccessToken: serverToken,
},
)
trans, _ := client.Transport.(*oauth2.Transport)
if len(socks) != 0 && !socksoff {
dialer, err := proxy.SOCKS5("tcp", socks, nil, proxy.Direct)
if err != nil {
return nil, err
}
trans.Base = &http.Transport{
TLSClientConfig: tlsConfig,
Proxy: http.ProxyFromEnvironment,
Dial: dialer.Dial,
}
} else {
trans.Base = &http.Transport{
TLSClientConfig: tlsConfig,
Proxy: http.ProxyFromEnvironment,
}
}
return woodpecker.NewClient(serverURL, client), nil
}

4982
server/mocks/mock_Client.go Normal file

File diff suppressed because it is too large Load Diff

18
utils/random.go Normal file
View File

@@ -0,0 +1,18 @@
package utils
import (
"math/rand"
"time"
)
// RandomString generates a random string of length n using alphanumeric characters.
func RandomString(n int) string {
letterRunes := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[rng.Intn(len(letterRunes))]
}
return string(b)
}

42
utils/random_test.go Normal file
View File

@@ -0,0 +1,42 @@
package utils_test
import (
"testing"
"github.com/stretchr/testify/assert"
"go.woodpecker-ci.org/autoscaler/utils"
)
func TestRandomString(t *testing.T) {
tests := []struct {
name string
n int
want int
}{
{
name: "zero length",
n: 0,
want: 0,
},
{
name: "length 10",
n: 10,
want: 10,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
str := utils.RandomString(tt.n)
assert.Equal(t, tt.want, len(str))
})
t.Run("alphanumeric", func(t *testing.T) {
str1 := utils.RandomString(10)
for _, r := range str1 {
assert.Contains(t, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", string(r))
}
})
}
}

39
utils/stringmaps.go Normal file
View File

@@ -0,0 +1,39 @@
package utils
import (
"fmt"
"strings"
)
// SliceToMap converts a slice of strings in the format "key=value"
// into a string map, using the provided delimiter to split the pieces.
// Returns a map and nil error on success, or nil and an error if a
// slice element does not contain the delimiter.
func SliceToMap(list []string, del string) (map[string]string, error) {
m := make(map[string]string)
for _, e := range list {
before, after, _ := strings.Cut(e, del)
if before == "" || after == "" {
return nil, fmt.Errorf("could not split '%s' into key value pair with '=' delimiter", e)
}
m[strings.TrimSpace(before)] = strings.TrimSpace(after)
}
return m, nil
}
// MergeMaps merges two string maps m1 and m2 into a new map.
// It copies all key-value pairs from m1 into the result.
// It then copies all key-value pairs from m2 into the result,
// overwriting any keys that are present in both m1 and m2.
// The merged map is returned.
func MergeMaps(m1, m2 map[string]string) map[string]string {
merged := make(map[string]string)
for k, v := range m1 {
merged[k] = v
}
for key, value := range m2 {
merged[key] = value
}
return merged
}

96
utils/stringmaps_test.go Normal file
View File

@@ -0,0 +1,96 @@
package utils_test
import (
"testing"
"github.com/stretchr/testify/assert"
"go.woodpecker-ci.org/autoscaler/utils"
)
func TestSliceToMap(t *testing.T) {
testCases := []struct {
name string
input []string
del string
want map[string]string
wantErr error
}{
{
name: "basic",
input: []string{"key1=value1", "key2=value2"},
del: "=",
want: map[string]string{"key1": "value1", "key2": "value2"},
wantErr: nil,
},
{
name: "whitespace",
input: []string{"key1 = value1", "key2=value2"},
del: "=",
want: map[string]string{"key1": "value1", "key2": "value2"},
wantErr: nil,
},
{
name: "missing delimiter",
input: []string{"key1", "key2=value2"},
del: "=",
want: nil,
wantErr: assert.AnError,
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
actual, err := utils.SliceToMap(tt.input, tt.del)
if tt.wantErr != nil {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.want, actual)
})
}
}
func TestMergeMaps(t *testing.T) {
testCases := []struct {
name string
m1 map[string]string
m2 map[string]string
want map[string]string
}{
{
name: "nil maps",
m1: nil,
m2: nil,
want: map[string]string{},
},
{
name: "empty maps",
m1: map[string]string{},
m2: map[string]string{},
want: map[string]string{},
},
{
name: "overwrite",
m1: map[string]string{"key1": "value1", "key2": "value2"},
m2: map[string]string{"key2": "newvalue2", "key3": "value3"},
want: map[string]string{"key1": "value1", "key2": "newvalue2", "key3": "value3"},
},
{
name: "no overwrite",
m1: map[string]string{"key1": "value1", "key2": "value2"},
m2: map[string]string{"key3": "value3", "key4": "value4"},
want: map[string]string{"key1": "value1", "key2": "value2", "key3": "value3", "key4": "value4"},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
merged := utils.MergeMaps(tt.m1, tt.m2)
assert.Equal(t, tt.want, merged)
})
}
}

13
version/version.go Normal file
View File

@@ -0,0 +1,13 @@
package version
// Version of Woodpecker Autoscaler, set with ldflags, from Git tag.
var Version string
// String returns the Version set at build time or "dev".
func String() string {
if Version == "" {
return "dev"
}
return Version
}