13 Commits

Author SHA1 Message Date
2d1aa62c61 chore(): increase test coverage and update dependencies
Some checks are pending
ci/woodpecker/pr/pr Pipeline is pending
2025-12-18 23:12:27 +01:00
e779c5a38b chore(): update dependencies
All checks were successful
ci/woodpecker/push/main Pipeline was successful
2025-01-25 22:05:40 +01:00
a467baf847 no need to ship patch version
All checks were successful
ci/woodpecker/push/main Pipeline was successful
also update ci ref
2024-06-04 22:35:43 +02:00
ae783cf9d6 update dependencies and go version
Some checks failed
ci/woodpecker/push/main Pipeline failed
2024-06-04 22:28:10 +02:00
9858f89140 remove label binding for ci
Some checks failed
ci/woodpecker/push/main Pipeline failed
2024-06-04 22:02:17 +02:00
69d6147582 fix nil dereference
Some checks are pending
ci/woodpecker/push/main Pipeline is pending
2024-06-04 22:01:24 +02:00
c392bfe7eb Merge pull request '(chore) update dependencies and ship multiarch builds' (#10) from chore/tt/update-dependencies-03-2024 into main
All checks were successful
ci/woodpecker/push/main Pipeline was successful
Reviewed-on: #10
2024-03-04 22:15:48 +00:00
31da09ef6f update dependencies and ship multiarch builds
All checks were successful
ci/woodpecker/pr/pr Pipeline was successful
2024-03-04 23:00:51 +01:00
45ebc96c13 Merge pull request 'hopefully fix time comparison' (#9) from bugfix/tt/costoptimizedmode into main
All checks were successful
ci/woodpecker/push/main Pipeline was successful
Reviewed-on: #9
2024-02-03 23:14:55 +00:00
cb1a931b4c hopefully fix time comparison
All checks were successful
ci/woodpecker/pr/pr Pipeline was successful
2024-02-04 00:08:43 +01:00
d4357767f5 Merge pull request '(feat) Cost optimized mode' (#8) from feature/tt/implement-cost-optimization into main
All checks were successful
ci/woodpecker/push/main Pipeline was successful
Reviewed-on: #8
2024-01-03 15:23:55 +00:00
30a219c91f (feat) Cost optimized mode
All checks were successful
ci/woodpecker/pr/pr Pipeline was successful
2023-12-31 23:02:32 +01:00
a190c1e0fe update dependencies
All checks were successful
ci/woodpecker/push/main Pipeline was successful
2023-12-19 20:55:05 +01:00
17 changed files with 548 additions and 173 deletions

View File

@@ -2,15 +2,18 @@ when:
- event: push
branch: main
labels:
uploadfilter24.eu/instance-role: Woodpecker
steps:
test:
image: golang:1.21
image: golang:1.25
commands:
- go test ./...
pre-release:
image: woodpeckerci/plugin-docker-buildx
settings:
platforms: linux/arm64/v8
platforms: linux/arm64/v8,linux/amd64
repo: lerentis/woodpecker-autoscaler
tags:
- latest

View File

@@ -1,15 +1,18 @@
when:
- event: pull_request
labels:
uploadfilter24.eu/instance-role: Woodpecker
steps:
test:
image: golang:1.21
image: golang:1.25
commands:
- go test ./...
pr-build:
image: woodpeckerci/plugin-docker-buildx
settings:
platforms: linux/arm64/v8
platforms: linux/arm64/v8,linux/amd64
repo: lerentis/woodpecker-autoscaler
tags:
- latest

View File

@@ -1,15 +1,18 @@
when:
- event: tag
labels:
uploadfilter24.eu/instance-role: Woodpecker
steps:
test:
image: golang:1.21
image: golang:1.25
commands:
- go test ./...
release:
image: woodpeckerci/plugin-docker-buildx
settings:
platforms: linux/arm64/v8
platforms: linux/arm64/v8,linux/amd64
repo: lerentis/woodpecker-autoscaler
tags:
- latest

32
CHANGELOG Normal file
View File

@@ -0,0 +1,32 @@
CHANGELOG
v1.1.1
- Updated Dependencies
- Added: unit tests
- Added `internal/hetzner/hetzneragent_extra_test.go` covering userdata generation and mocked runtime checks.
- Added `internal/woodpecker/agent_test.go` covering `CreateWoodpeckerAgent`, `GetAgentIdByName`, `DecomAgent` (httptest-based).
- Added `internal/woodpecker/metrics_test.go` covering `QueueInfo`, `CheckPending`, and `CheckRunning`.
- Implemented `internal/logging/logging_test.go` assertions for `ConfigureLogger` levels.
- Changed: code to improve testability
- Introduced `refreshNodeInfo` indirection in `internal/hetzner/hetzneragent.go` to allow mocking in tests.
- Updated `hetzner` userdata test expectations and fixed JSON encoding in tests.
- Notes:
- No functional behavior changes except testability refactor (indirection).
v1.1.0
Updated Dependencies
Restructured Main event loop
Cost optimized mode to make use of the fully hour that is billed by hetzner
v1.0.1
Fix woodpecker agent decom
v1.0.0
First stable release
v0.0.1
First test release

View File

@@ -1,4 +1,4 @@
FROM golang:1.21 as build
FROM golang:1.25 AS build
WORKDIR /app

View File

@@ -32,6 +32,8 @@ env:
value: "define_it"
- name: WOODPECKER_AUTOSCALER_HCLOUD_SSH_KEY
value: "define_it"
- name: WOODPECKER_AUTOSCALER_COST_OPTIMIZED
value: "true"
```
you can also create a secret manually with these information and reference the existing secret like this in the `values.yaml`:
@@ -79,6 +81,7 @@ WOODPECKER_AUTOSCALER_HCLOUD_TOKEN="define_it"
WOODPECKER_AUTOSCALER_HCLOUD_INSTANCE_TYPE=cpx21
WOODPECKER_AUTOSCALER_HCLOUD_LOCATION="define_it"
WOODPECKER_AUTOSCALER_HCLOUD_SSH_KEY="define_it"
WOODPECKER_AUTOSCALER_COST_OPTIMIZED="true"
```
Now reload the systemd daemons and start the service:

View File

@@ -13,6 +13,106 @@ import (
log "github.com/sirupsen/logrus"
)
func SpawnNewAgent(cfg *config.Config) {
agent, err := woodpecker.CreateWoodpeckerAgent(cfg)
if err != nil {
log.WithFields(log.Fields{
"Caller": "SpawnNewAgent",
}).Fatal(fmt.Sprintf("Error creating new agent: %s", err.Error()))
}
server, err := hetzner.CreateNewAgent(cfg, agent)
if err != nil {
log.WithFields(log.Fields{
"Caller": "SpawnNewAgent",
}).Fatal(fmt.Sprintf("Error spawning new agent: %s", err.Error()))
}
for {
server, err = hetzner.RefreshNodeInfo(cfg, server.ID)
if err != nil {
log.WithFields(log.Fields{
"Caller": "SpawnNewAgent",
}).Fatal(fmt.Sprintf("Failed to start Agent: %s", err.Error()))
}
if server.Status == hcloud.ServerStatusRunning {
log.WithFields(log.Fields{
"Caller": "SpawnNewAgent",
}).Infof("%s started!", server.Name)
break
}
log.WithFields(log.Fields{
"Caller": "SpawnNewAgent",
}).Infof("%s is in status %s", server.Name, server.Status)
time.Sleep(30 * time.Second)
}
}
func CheckJobs(cfg *config.Config, ownedNodes []hcloud.Server, pendingTasks int) {
log.WithFields(log.Fields{
"Caller": "CheckJobs",
}).Info("Checking if agents can be removed")
runningTasks, err := woodpecker.CheckRunning(cfg)
if err != nil {
log.WithFields(log.Fields{
"Caller": "CheckJobs",
}).Fatal(fmt.Sprintf("Error checking woodpecker queue: %s", err.Error()))
}
if (runningTasks <= len(ownedNodes) && runningTasks != 0) || pendingTasks > 0 {
log.WithFields(log.Fields{
"Caller": "CheckJobs",
}).Info("Still found running tasks. No agent to be removed")
} else {
if len(ownedNodes) == 0 {
log.WithFields(log.Fields{
"Caller": "CheckJobs",
}).Info("Nothing running and not owning any nodes")
} else {
log.WithFields(log.Fields{
"Caller": "CheckJobs",
}).Info("No tasks running. Will remove agents")
Decom(cfg, ownedNodes)
}
}
}
func Decom(cfg *config.Config, ownedNodes []hcloud.Server) {
for _, server := range ownedNodes {
if cfg.CostOptimizedMode {
runtime, err := hetzner.CheckRuntime(cfg, &server)
if err != nil {
log.WithFields(log.Fields{
"Caller": "Decom",
}).Warnf("Error while checking runtime of node %s: %s", server.Name, err.Error())
}
log.WithFields(log.Fields{
"Caller": "Decom",
}).Debugf("Node %s is running for %d", server.Name, runtime.Minute())
// Check if next check if sooner than the 60 Minute mark of the next hetzner check
// https://docs.hetzner.com/cloud/billing/faq/#how-do-you-bill-your-servers
if time.Duration(runtime.Add(time.Duration(cfg.CheckInterval)*time.Minute).Minute()) < (60 * time.Minute) {
log.WithFields(log.Fields{
"Caller": "Decom",
}).Infof("Skipping node termination of %s (running for %d Minutes) in Cost Optimized Mode", server.Name, runtime.Minute())
continue
}
}
agentId, err := hetzner.DecomNode(cfg, &server)
if err != nil {
log.WithFields(log.Fields{
"Caller": "Decom",
}).Warnf("Error while deleting node %s: %s", server.Name, err.Error())
}
err = woodpecker.DecomAgent(cfg, agentId)
if err != nil {
log.WithFields(log.Fields{
"Caller": "Decom",
}).Warnf("Could not delete node %s in woodpecker: %s", server.Name, err.Error())
}
log.WithFields(log.Fields{
"Caller": "Decom",
}).Infof("Deleted node %s", server.Name)
}
}
func main() {
cfg, err := config.GenConfig()
@@ -52,75 +152,9 @@ func main() {
"Caller": "Main",
}).Infof("Currently owning %d Agents", len(ownedNodes))
if pendingTasks > len(ownedNodes) {
agent, err := woodpecker.CreateWoodpeckerAgent(cfg)
if err != nil {
log.WithFields(log.Fields{
"Caller": "Main",
}).Fatal(fmt.Sprintf("Error creating new agent: %s", err.Error()))
}
server, err := hetzner.CreateNewAgent(cfg, agent)
if err != nil {
log.WithFields(log.Fields{
"Caller": "Main",
}).Fatal(fmt.Sprintf("Error spawning new agent: %s", err.Error()))
}
for {
server, err = hetzner.RefreshNodeInfo(cfg, server.ID)
if err != nil {
log.WithFields(log.Fields{
"Caller": "Main",
}).Fatal(fmt.Sprintf("Failed to start Agent: %s", err.Error()))
}
if server.Status == hcloud.ServerStatusRunning {
log.WithFields(log.Fields{
"Caller": "Main",
}).Infof("%s started!", server.Name)
break
}
log.WithFields(log.Fields{
"Caller": "Main",
}).Infof("%s is in status %s", server.Name, server.Status)
time.Sleep(30 * time.Second)
}
SpawnNewAgent(cfg)
} else {
log.WithFields(log.Fields{
"Caller": "Main",
}).Info("Checking if agents can be removed")
runningTasks, err := woodpecker.CheckRunning(cfg)
if err != nil {
log.WithFields(log.Fields{
"Caller": "Main",
}).Fatal(fmt.Sprintf("Error checking woodpecker queue: %s", err.Error()))
}
if (runningTasks <= len(ownedNodes) && runningTasks != 0) || pendingTasks > 0 {
log.WithFields(log.Fields{
"Caller": "Main",
}).Info("Still found running tasks. No agent to be removed")
} else {
if len(ownedNodes) == 0 {
log.WithFields(log.Fields{
"Caller": "Main",
}).Info("Nothing running and not owning any nodes")
} else {
log.WithFields(log.Fields{
"Caller": "Main",
}).Info("No tasks running. Will remove agents")
for _, server := range ownedNodes {
agentId, err := hetzner.DecomNode(cfg, &server)
if err != nil {
log.WithFields(log.Fields{
"Caller": "Main",
}).Warnf("Error while deleting node %s: %s", server.Name, err.Error())
}
err = woodpecker.DecomAgent(cfg, agentId)
if err != nil {
log.WithFields(log.Fields{
"Caller": "Main",
}).Warnf("Could not delete node %s in woodpecker: %s", server.Name, err.Error())
}
}
}
}
CheckJobs(cfg, ownedNodes, pendingTasks)
}
log.WithFields(log.Fields{
"Caller": "Main",

37
go.mod
View File

@@ -1,29 +1,28 @@
module git.uploadfilter24.eu/covidnetes/woodpecker-autoscaler
go 1.21.1
go 1.25.0
require (
github.com/gorilla/mux v1.8.0
github.com/hetznercloud/hcloud-go v1.52.0
github.com/jinzhu/configor v1.2.1
github.com/gorilla/mux v1.8.1
github.com/hetznercloud/hcloud-go v1.59.2
github.com/jinzhu/configor v1.2.2
github.com/sirupsen/logrus v1.9.3
)
require (
github.com/BurntSushi/toml v0.3.1 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/prometheus/client_golang v1.16.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.42.0 // indirect
github.com/prometheus/procfs v0.10.1 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
golang.org/x/net v0.12.0 // indirect
golang.org/x/sys v0.10.0 // indirect
golang.org/x/text v0.11.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

91
go.sum
View File

@@ -1,70 +1,65 @@
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/hetznercloud/hcloud-go v1.52.0 h1:3r9pEulTOBB9BoArSgpQYUQVTy+Xjkg0k/QAU4c6dQ8=
github.com/hetznercloud/hcloud-go v1.52.0/go.mod h1:VzDWThl47lOnZXY0q5/LPFD+M62pfe/52TV+mOrpp9Q=
github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/hetznercloud/hcloud-go v1.59.2 h1:NkCPwYiPv85FnOV3IW9/gxfW61TPIUSwyPHRSLwCkHA=
github.com/hetznercloud/hcloud-go v1.59.2/go.mod h1:oTebZCjd+osj75jlI76Z+zjN1sTxmMiQ1MWoO8aRl1c=
github.com/jinzhu/configor v1.2.2 h1:sLgh6KMzpCmaQB4e+9Fu/29VErtBUqsS2t8C9BNIVsA=
github.com/jinzhu/configor v1.2.2/go.mod h1:iFFSfOBKP3kC2Dku0ZGB3t3aulfQgTGJknodhFavsU8=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8=
github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc=
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM=
github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc=
github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg=
github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM=
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/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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

@@ -1,7 +1,6 @@
package config
import (
"errors"
"fmt"
"time"
@@ -12,6 +11,7 @@ type Config = struct {
LogLevel string `default:"Info" env:"WOODPECKER_AUTOSCALER_LOGLEVEL"`
CheckInterval int `default:"15" env:"WOODPECKER_AUTOSCALER_CHECK_INTERVAL"`
DryRun bool `default:"false" env:"WOODPECKER_AUTOSCALER_DRY_RUN"`
CostOptimizedMode bool `default:"false" env:"WOODPECKER_AUTOSCALER_COST_OPTIMIZED"`
WoodpeckerLabelSelector string `default:"uploadfilter24.eu/instance-role=Woodpecker" env:"WOODPECKER_AUTOSCALER_WOODPECKER_LABEL_SELECTOR"`
WoodpeckerInstance string `default:"" env:"WOODPECKER_AUTOSCALER_WOODPECKER_INSTANCE"`
WoodpeckerGrpc string `default:"" env:"WOODPECKER_AUTOSCALER_WOODPECKER_GRPC"`
@@ -35,7 +35,7 @@ func GenConfig() (cfg *Config, err error) {
Silent: true,
AutoReloadInterval: time.Minute}).Load(cfg, "config.json")
if err != nil {
return nil, errors.New(fmt.Sprintf("Error generating Config: %s", err.Error()))
return nil, fmt.Errorf("Error generating Config: %s", err.Error())
}
return cfg, nil
}

View File

@@ -3,11 +3,11 @@ package hetzner
import (
"bytes"
"context"
"errors"
"fmt"
"strconv"
"strings"
"text/template"
"time"
"git.uploadfilter24.eu/covidnetes/woodpecker-autoscaler/internal/config"
"git.uploadfilter24.eu/covidnetes/woodpecker-autoscaler/internal/models"
@@ -39,6 +39,8 @@ runcmd:
- [ sh, -xc, "cd /root; docker run --rm --privileged multiarch/qemu-user-static --reset -p yes; docker compose up -d" ]
`
var refreshNodeInfo = RefreshNodeInfo
type UserDataConfig struct {
Image string
EnvConfig map[string]interface{}
@@ -59,12 +61,12 @@ func generateConfig(cfg *config.Config, name string, agentToken string) (string,
}
tmpl, err := template.New("userdata").Parse(USER_DATA_TEMPLATE)
if err != nil {
return "", errors.New(fmt.Sprintf("Errors in userdata template: %s", err.Error()))
return "", fmt.Errorf("Errors in userdata template: %s", err.Error())
}
var buf bytes.Buffer
err = tmpl.Execute(&buf, &config)
if err != nil {
return "", errors.New(fmt.Sprintf("Could not render userdata template: %s", err.Error()))
return "", fmt.Errorf("Could not render userdata template: %s", err.Error())
}
return buf.String(), nil
}
@@ -112,7 +114,7 @@ func CreateNewAgent(cfg *config.Config, woodpeckerAgent *models.Agent) (*hcloud.
})
if err != nil {
return nil, errors.New(fmt.Sprintf("Could not create new Agent: %s", err.Error()))
return nil, fmt.Errorf("Could not create new Agent: %s", err.Error())
}
log.WithFields(log.Fields{
@@ -126,7 +128,7 @@ func ListAgents(cfg *config.Config) ([]hcloud.Server, error) {
client := hcloud.NewClient(hcloud.WithToken(cfg.HcloudToken))
allServers, err := client.Server.All(context.Background())
if err != nil {
return nil, errors.New(fmt.Sprintf("Could not query Server list: %s", err.Error()))
return nil, fmt.Errorf("Could not query Server list: %s", err.Error())
}
myServers := []hcloud.Server{}
for _, server := range allServers {
@@ -160,7 +162,7 @@ func DecomNode(cfg *config.Config, server *hcloud.Server) (int64, error) {
}).Debugf("Deleting %s node", server.Name)
_, _, err := client.Server.DeleteWithResult(context.Background(), server)
if err != nil {
return woodpeckerAgentID, errors.New(fmt.Sprintf("Could not delete Agent: %s", err.Error()))
return woodpeckerAgentID, fmt.Errorf("Could not delete Agent: %s", err.Error())
}
return woodpeckerAgentID, nil
}
@@ -169,7 +171,16 @@ func RefreshNodeInfo(cfg *config.Config, serverID int) (*hcloud.Server, error) {
client := hcloud.NewClient(hcloud.WithToken(cfg.HcloudToken))
server, _, err := client.Server.GetByID(context.Background(), serverID)
if err != nil {
return nil, errors.New(fmt.Sprintf("Could not refresh server info: %s", err.Error()))
return nil, fmt.Errorf("Could not refresh server info: %s", err.Error())
}
return server, nil
}
func CheckRuntime(cfg *config.Config, server *hcloud.Server) (time.Time, error) {
server, err := refreshNodeInfo(cfg, server.ID)
now := time.Now()
if err != nil {
return time.Time{}, fmt.Errorf("Could not check Runtime: %s", err.Error())
}
return server.Created.Add(time.Duration(now.Minute())), nil
}

View File

@@ -1,9 +1,12 @@
package hetzner
import (
"strings"
"testing"
"time"
"git.uploadfilter24.eu/covidnetes/woodpecker-autoscaler/internal/config"
"github.com/hetznercloud/hcloud-go/hcloud"
)
func TestGenerateUserData(t *testing.T) {
@@ -54,3 +57,78 @@ runcmd:
t.Errorf("got:\n%v\n, wanted:\n%v", got, wanted)
}
}
func TestGenerateUserData_MultipleCases(t *testing.T) {
base := config.Config{
WoodpeckerGrpc: "grpc-test.woodpecker.test.tld:443",
WoodpeckerLabelSelector: "uploadfilter24.eu/instance-role=WoodpeckerTest",
WoodpeckerAgentVersion: "latest",
}
cases := []struct {
name string
cfg config.Config
agentName string
agentToken string
wantContains []string
}{
{
name: "basic",
cfg: base,
agentName: "test-instance",
agentToken: "Geheim1!",
wantContains: []string{
"image: woodpeckerci/woodpecker-agent:latest",
"- WOODPECKER_AGENT_SECRET=Geheim1!",
"- WOODPECKER_FILTER_LABELS=uploadfilter24.eu/instance-role=WoodpeckerTest",
"- WOODPECKER_SERVER=grpc-test.woodpecker.test.tld:443",
},
},
{
name: "empty token",
cfg: base,
agentName: "no-token",
agentToken: "",
wantContains: []string{
"image: woodpeckerci/woodpecker-agent:latest",
"- WOODPECKER_AGENT_SECRET=",
"- WOODPECKER_HOSTNAME=no-token",
},
},
}
for _, tc := range cases {
got, err := generateConfig(&tc.cfg, tc.agentName, tc.agentToken)
if err != nil {
t.Fatalf("%s: generateConfig returned error: %v", tc.name, err)
}
for _, want := range tc.wantContains {
if !strings.Contains(got, want) {
t.Errorf("%s: expected generated userdata to contain %q, got:\n%s", tc.name, want, got)
}
}
}
}
func TestCheckRuntime_MockedRefresh(t *testing.T) {
// Mock refreshNodeInfo to return a server with a known Created time
orig := refreshNodeInfo
defer func() { refreshNodeInfo = orig }()
created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
refreshNodeInfo = func(cfg *config.Config, serverID int) (*hcloud.Server, error) {
return &hcloud.Server{Created: created}, nil
}
cfg := config.Config{}
// Capture minute before call to avoid flakiness across minute boundary
minute := time.Now().Minute()
got, err := CheckRuntime(&cfg, &hcloud.Server{ID: 123})
if err != nil {
t.Fatalf("CheckRuntime returned error: %v", err)
}
want := created.Add(time.Duration(minute))
if !got.Equal(want) {
t.Fatalf("unexpected runtime: got %v, want %v", got, want)
}
}

View File

@@ -0,0 +1,40 @@
package logging
import (
"testing"
"git.uploadfilter24.eu/covidnetes/woodpecker-autoscaler/internal/config"
log "github.com/sirupsen/logrus"
)
func TestLoggingDebug(t *testing.T) {
cfg := config.Config{LogLevel: "Debug"}
ConfigureLogger(&cfg)
if log.GetLevel() != log.DebugLevel {
t.Fatalf("expected DebugLevel, got %v", log.GetLevel())
}
}
func TestLoggingInfo(t *testing.T) {
cfg := config.Config{LogLevel: "Info"}
ConfigureLogger(&cfg)
if log.GetLevel() != log.InfoLevel {
t.Fatalf("expected InfoLevel, got %v", log.GetLevel())
}
}
func TestLoggingWarning(t *testing.T) {
cfg := config.Config{LogLevel: "Warn"}
ConfigureLogger(&cfg)
if log.GetLevel() != log.WarnLevel {
t.Fatalf("expected WarnLevel, got %v", log.GetLevel())
}
}
func TestLoggingError(t *testing.T) {
cfg := config.Config{LogLevel: "Error"}
ConfigureLogger(&cfg)
if log.GetLevel() != log.ErrorLevel {
t.Fatalf("expected ErrorLevel, got %v", log.GetLevel())
}
}

View File

@@ -3,7 +3,6 @@ package woodpecker
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -18,7 +17,7 @@ func DecomAgent(cfg *config.Config, agentId int64) error {
apiRoute := fmt.Sprintf("%s/api/agents/%d", cfg.WoodpeckerInstance, agentId)
req, err := http.NewRequest("DELETE", apiRoute, nil)
if err != nil {
return errors.New(fmt.Sprintf("Could not create delete request: %s", err.Error()))
return fmt.Errorf("Could not create delete request: %s", err.Error())
}
req.Header.Set("Accept", "text/plain")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.WoodpeckerApiToken))
@@ -29,7 +28,7 @@ func DecomAgent(cfg *config.Config, agentId int64) error {
resp, err := http.DefaultClient.Do(req)
if err != nil {
return errors.New(fmt.Sprintf("Could not delete agent: %s", err.Error()))
return fmt.Errorf("Could not delete agent: %s", err.Error())
}
defer resp.Body.Close()
return nil
@@ -39,24 +38,24 @@ func GetAgentIdByName(cfg *config.Config, name string) (int, error) {
apiRoute := fmt.Sprintf("%s/api/agents?page=1&perPage=100", cfg.WoodpeckerInstance)
req, err := http.NewRequest(http.MethodGet, apiRoute, nil)
if err != nil {
return 0, errors.New(fmt.Sprintf("Could not create agent query request: %s", err.Error()))
return 0, fmt.Errorf("Could not create agent query request: %s", err.Error())
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.WoodpeckerApiToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, errors.New(fmt.Sprintf("Could not query agent list: %s", err.Error()))
return 0, fmt.Errorf("Could not query agent list: %s", err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, errors.New(fmt.Sprintf("Invalid status code from API: %d", resp.StatusCode))
return 0, fmt.Errorf("Invalid status code from API: %d", resp.StatusCode)
}
agentList := new(models.AgentList)
err = json.NewDecoder(resp.Body).Decode(agentList)
if err != nil {
return 0, errors.New(fmt.Sprintf("Could not unmarshal api response: %s", err.Error()))
return 0, fmt.Errorf("Could not unmarshal api response: %s", err.Error())
}
for _, agent := range agentList.Agents {
@@ -67,7 +66,7 @@ func GetAgentIdByName(cfg *config.Config, name string) (int, error) {
return int(agent.ID), nil
}
}
return 0, errors.New(fmt.Sprintf("Agent with name %s is not in server", name))
return 0, fmt.Errorf("Agent with name %s is not in server", name)
}
func ListAgents(cfg *config.Config) (*models.AgentList, error) {
@@ -75,23 +74,23 @@ func ListAgents(cfg *config.Config) (*models.AgentList, error) {
apiRoute := fmt.Sprintf("%s/api/agents?page=1&perPage=100", cfg.WoodpeckerInstance)
req, err := http.NewRequest(http.MethodGet, apiRoute, nil)
if err != nil {
return agentList, errors.New(fmt.Sprintf("Could not create agent query request: %s", err.Error()))
return agentList, fmt.Errorf("Could not create agent query request: %s", err.Error())
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.WoodpeckerApiToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return agentList, errors.New(fmt.Sprintf("Could not query agent list: %s", err.Error()))
return agentList, fmt.Errorf("Could not query agent list: %s", err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return agentList, errors.New(fmt.Sprintf("Invalid status code from API: %d", resp.StatusCode))
return agentList, fmt.Errorf("Invalid status code from API: %d", resp.StatusCode)
}
err = json.NewDecoder(resp.Body).Decode(agentList)
if err != nil {
return agentList, errors.New(fmt.Sprintf("Could not unmarshal api response: %s", err.Error()))
return agentList, fmt.Errorf("Could not unmarshal api response: %s", err.Error())
}
return agentList, nil
}
@@ -111,24 +110,24 @@ func CreateWoodpeckerAgent(cfg *config.Config) (*models.Agent, error) {
}).Debugf("Sending the following data to %s: %s", apiRoute, jsonBody)
req, err := http.NewRequest(http.MethodPost, apiRoute, bodyReader)
if err != nil {
return nil, errors.New(fmt.Sprintf("Could not create agent request: %s", err.Error()))
return nil, fmt.Errorf("Could not create agent request: %s", err.Error())
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.WoodpeckerApiToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, errors.New(fmt.Sprintf("Could not create new Agent: %s", err.Error()))
return nil, fmt.Errorf("Could not create new Agent: %s", err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New(fmt.Sprintf("Invalid status code from API: %d", resp.StatusCode))
return nil, fmt.Errorf("Invalid status code from API: %d", resp.StatusCode)
}
newAgent := new(models.Agent)
err = json.NewDecoder(resp.Body).Decode(newAgent)
if err != nil {
return nil, errors.New(fmt.Sprintf("Could not unmarshal api response: %s", err.Error()))
return nil, fmt.Errorf("Could not unmarshal api response: %s", err.Error())
}
return newAgent, nil

View File

@@ -0,0 +1,110 @@
package woodpecker
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.uploadfilter24.eu/covidnetes/woodpecker-autoscaler/internal/config"
"git.uploadfilter24.eu/covidnetes/woodpecker-autoscaler/internal/models"
)
func TestCreateAndGetAndDeleteAgent(t *testing.T) {
// prepare a fake agent to return
createdAgent := models.Agent{
ID: 42,
Name: "woodpecker-autoscaler-agent-abcde",
Token: "tok",
}
mux := http.NewServeMux()
mux.HandleFunc("/api/agents", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
// ensure content-type
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Fatalf("expected json content-type, got %s", ct)
}
body, _ := io.ReadAll(r.Body)
defer r.Body.Close()
if !strings.Contains(string(body), "woodpecker-autoscaler-agent-") {
t.Fatalf("unexpected agent request body: %s", string(body))
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(createdAgent)
return
}
// For GET listing, return an AgentList
w.WriteHeader(http.StatusOK)
list := models.AgentList{Agents: []models.Agent{createdAgent}}
_ = json.NewEncoder(w).Encode(list)
})
mux.HandleFunc("/api/agents?page=1&perPage=100", func(w http.ResponseWriter, r *http.Request) {
// return list in expected format for GetAgentIdByName
w.WriteHeader(http.StatusOK)
// GetAgentIdByName expects a models.AgentList; encode accordingly
list := models.AgentList{Agents: []models.Agent{createdAgent}}
_ = json.NewEncoder(w).Encode(list)
})
// handle delete
mux.HandleFunc(fmt.Sprintf("/api/agents/%d", createdAgent.ID), func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Fatalf("expected DELETE, got %s", r.Method)
}
w.WriteHeader(http.StatusOK)
})
srv := httptest.NewServer(mux)
defer srv.Close()
cfg := config.Config{
WoodpeckerInstance: srv.URL,
WoodpeckerApiToken: "testtoken",
}
// Test CreateWoodpeckerAgent
a, err := CreateWoodpeckerAgent(&cfg)
if err != nil {
t.Fatalf("CreateWoodpeckerAgent failed: %v", err)
}
if a == nil || !strings.HasPrefix(a.Name, "woodpecker-autoscaler-agent-") {
t.Fatalf("unexpected agent returned: %#v", a)
}
// Test GetAgentIdByName
id, err := GetAgentIdByName(&cfg, a.Name)
if err != nil {
t.Fatalf("GetAgentIdByName failed: %v", err)
}
if id != int(a.ID) {
t.Fatalf("unexpected id: got %d want %d", id, a.ID)
}
// Test DecomAgent
if err := DecomAgent(&cfg, a.ID); err != nil {
t.Fatalf("DecomAgent failed: %v", err)
}
}
func TestGetAgentIdByName_NotFound(t *testing.T) {
// server returns empty list
mux := http.NewServeMux()
mux.HandleFunc("/api/agents?page=1&perPage=100", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
list := models.AgentList{Agents: []models.Agent{{ID: 1, Name: "other"}}}
_ = json.NewEncoder(w).Encode(list)
})
srv := httptest.NewServer(mux)
defer srv.Close()
cfg := config.Config{WoodpeckerInstance: srv.URL, WoodpeckerApiToken: "t"}
_, err := GetAgentIdByName(&cfg, "nonexistent")
if err == nil {
t.Fatalf("expected error for unknown agent name")
}
}

View File

@@ -2,7 +2,6 @@ package woodpecker
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -17,19 +16,19 @@ func QueueInfo(cfg *config.Config, target interface{}) error {
apiRoute := fmt.Sprintf("%s/api/queue/info", cfg.WoodpeckerInstance)
req, err := http.NewRequest(http.MethodGet, apiRoute, nil)
if err != nil {
return errors.New(fmt.Sprintf("Could not create queue request: %s", err.Error()))
return fmt.Errorf("Could not create queue request: %s", err.Error())
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.WoodpeckerApiToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return errors.New(fmt.Sprintf("Could not query queue info: %s", err.Error()))
return fmt.Errorf("Could not query queue info: %s", err.Error())
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return errors.New(fmt.Sprintf("Error from queue info api: %s", err.Error()))
return fmt.Errorf("Error from queue info api: %s", resp.Status)
}
return json.NewDecoder(resp.Body).Decode(target)
@@ -40,7 +39,7 @@ func CheckPending(cfg *config.Config) (int, error) {
queueInfo := new(models.QueueInfo)
err := QueueInfo(cfg, queueInfo)
if err != nil {
return 0, errors.New(fmt.Sprintf("Error from QueueInfo: %s", err.Error()))
return 0, fmt.Errorf("Error from QueueInfo: %s", err.Error())
}
count := 0
if queueInfo.Stats.PendingCount > 0 {
@@ -64,7 +63,7 @@ func CheckRunning(cfg *config.Config) (int, error) {
queueInfo := new(models.QueueInfo)
err := QueueInfo(cfg, queueInfo)
if err != nil {
return 0, errors.New(fmt.Sprintf("Error from QueueInfo: %s", err.Error()))
return 0, fmt.Errorf("Error from QueueInfo: %s", err.Error())
}
count := 0
if queueInfo.Stats.RunningCount > 0 {

View File

@@ -0,0 +1,66 @@
package woodpecker
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.uploadfilter24.eu/covidnetes/woodpecker-autoscaler/internal/config"
"git.uploadfilter24.eu/covidnetes/woodpecker-autoscaler/internal/models"
)
func TestQueueInfoAndChecks(t *testing.T) {
// Create queue info with one pending job matching label and one running matching
qi := models.QueueInfo{
Pending: []models.JobInformation{
{ID: "1", Labels: map[string]string{"role": "worker"}},
},
Running: []models.JobInformation{
{ID: "2", Labels: map[string]string{"role": "worker"}},
},
Stats: models.Stats{PendingCount: 1, RunningCount: 1},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/queue/info" {
w.WriteHeader(http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(qi)
}))
defer srv.Close()
cfg := config.Config{
WoodpeckerInstance: srv.URL,
WoodpeckerApiToken: "t",
WoodpeckerLabelSelector: "role=worker",
}
// Test QueueInfo
var got models.QueueInfo
if err := QueueInfo(&cfg, &got); err != nil {
t.Fatalf("QueueInfo failed: %v", err)
}
if got.Stats.PendingCount != 1 || got.Stats.RunningCount != 1 {
t.Fatalf("unexpected stats: %#v", got.Stats)
}
// Test CheckPending
pending, err := CheckPending(&cfg)
if err != nil {
t.Fatalf("CheckPending error: %v", err)
}
if pending != 1 {
t.Fatalf("expected 1 pending, got %d", pending)
}
// Test CheckRunning
running, err := CheckRunning(&cfg)
if err != nil {
t.Fatalf("CheckRunning error: %v", err)
}
if running != 1 {
t.Fatalf("expected 1 running, got %d", running)
}
}