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

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
}