feat: proxmox idlewatcher (#88)

* feat: idle sleep for proxmox LXCs

* refactor: replace deprecated docker api types

* chore(api): remove debug task list endpoint

* refactor: move servemux to gphttp/servemux; favicon.go to v1/favicon

* refactor: introduce Pool interface, move agent_pool to agent module

* refactor: simplify api code

* feat: introduce debug api

* refactor: remove net.URL and net.CIDR types, improved unmarshal handling

* chore: update Makefile for debug build tag, update README

* chore: add gperr.Unwrap method

* feat: relative time and duration formatting

* chore: add ROOT_DIR environment variable, refactor

* migration: move homepage override and icon cache to $BASE_DIR/data, add migration code

* fix: nil dereference on marshalling service health

* fix: wait for route deletion

* chore: enhance tasks debuggability

* feat: stdout access logger and MultiWriter

* fix(agent): remove agent properly on verify error

* fix(metrics): disk exclusion logic and added corresponding tests

* chore: update schema and prettify, fix package.json and Makefile

* fix: I/O buffer not being shrunk before putting back to pool

* feat: enhanced error handling module

* chore: deps upgrade

* feat: better value formatting and handling

---------

Co-authored-by: yusing <yusing@6uo.me>
This commit is contained in:
Yuzerion
2025-04-16 14:52:33 +08:00
committed by GitHub
parent 88f3a95b61
commit 57292f0fe8
173 changed files with 4131 additions and 2096 deletions

View File

@@ -1,110 +1,128 @@
package idlewatcher
import (
"errors"
"net/url"
"strconv"
"strings"
"time"
"github.com/yusing/go-proxy/internal/docker"
"github.com/yusing/go-proxy/internal/gperr"
)
type (
Config struct {
IdleTimeout time.Duration `json:"idle_timeout,omitempty"`
WakeTimeout time.Duration `json:"wake_timeout,omitempty"`
StopTimeout int `json:"stop_timeout,omitempty"` // docker api takes integer seconds for timeout argument
StopMethod StopMethod `json:"stop_method,omitempty"`
Proxmox *ProxmoxConfig `json:"proxmox,omitempty"`
Docker *DockerConfig `json:"docker,omitempty"`
IdleTimeout time.Duration `json:"idle_timeout"`
WakeTimeout time.Duration `json:"wake_timeout"`
StopTimeout time.Duration `json:"stop_timeout"`
StopMethod StopMethod `json:"stop_method"`
StopSignal Signal `json:"stop_signal,omitempty"`
StartEndpoint string `json:"start_endpoint,omitempty"` // Optional path that must be hit to start container
}
StopMethod string
Signal string
DockerConfig struct {
DockerHost string `json:"docker_host" validate:"required"`
ContainerID string `json:"container_id" validate:"required"`
ContainerName string `json:"container_name" validate:"required"`
}
ProxmoxConfig struct {
Node string `json:"node" validate:"required"`
VMID int `json:"vmid" validate:"required"`
}
)
const (
WakeTimeoutDefault = 30 * time.Second
StopTimeoutDefault = 1 * time.Minute
StopMethodPause StopMethod = "pause"
StopMethodStop StopMethod = "stop"
StopMethodKill StopMethod = "kill"
)
var validSignals = map[string]struct{}{
"": {},
"SIGINT": {}, "SIGTERM": {}, "SIGHUP": {}, "SIGQUIT": {},
"INT": {}, "TERM": {}, "HUP": {}, "QUIT": {},
func (c *Config) Key() string {
if c.Docker != nil {
return c.Docker.ContainerID
}
return c.Proxmox.Node + ":" + strconv.Itoa(c.Proxmox.VMID)
}
func ValidateConfig(cont *docker.Container) (*Config, gperr.Error) {
if cont == nil || cont.IdleTimeout == "" {
return nil, nil
func (c *Config) ContainerName() string {
if c.Docker != nil {
return c.Docker.ContainerName
}
errs := gperr.NewBuilder("invalid idlewatcher config")
idleTimeout := gperr.Collect(errs, validateDurationPostitive, cont.IdleTimeout)
wakeTimeout := gperr.Collect(errs, validateDurationPostitive, cont.WakeTimeout)
stopTimeout := gperr.Collect(errs, validateDurationPostitive, cont.StopTimeout)
stopMethod := gperr.Collect(errs, validateStopMethod, cont.StopMethod)
signal := gperr.Collect(errs, validateSignal, cont.StopSignal)
startEndpoint := gperr.Collect(errs, validateStartEndpoint, cont.StartEndpoint)
if errs.HasError() {
return nil, errs.Error()
}
return &Config{
IdleTimeout: idleTimeout,
WakeTimeout: wakeTimeout,
StopTimeout: int(stopTimeout.Seconds()),
StopMethod: stopMethod,
StopSignal: signal,
StartEndpoint: startEndpoint,
}, nil
return "lxc " + strconv.Itoa(c.Proxmox.VMID)
}
func validateDurationPostitive(value string) (time.Duration, error) {
d, err := time.ParseDuration(value)
if err != nil {
return 0, err
func (c *Config) Validate() gperr.Error {
if c.IdleTimeout == 0 { // no idle timeout means no idle watcher
return nil
}
if d < 0 {
return 0, errors.New("duration must be positive")
}
return d, nil
errs := gperr.NewBuilder("idlewatcher config validation error")
errs.AddRange(
c.validateProvider(),
c.validateTimeouts(),
c.validateStopMethod(),
c.validateStopSignal(),
c.validateStartEndpoint(),
)
return errs.Error()
}
func validateSignal(s string) (Signal, error) {
if _, ok := validSignals[s]; ok {
return Signal(s), nil
func (c *Config) validateProvider() error {
if c.Docker == nil && c.Proxmox == nil {
return gperr.New("missing idlewatcher provider config")
}
return "", errors.New("invalid signal " + s)
return nil
}
func validateStopMethod(s string) (StopMethod, error) {
sm := StopMethod(s)
switch sm {
func (c *Config) validateTimeouts() error {
if c.WakeTimeout == 0 {
c.WakeTimeout = WakeTimeoutDefault
}
if c.StopTimeout == 0 {
c.StopTimeout = StopTimeoutDefault
}
return nil
}
func (c *Config) validateStopMethod() error {
switch c.StopMethod {
case "":
c.StopMethod = StopMethodStop
return nil
case StopMethodPause, StopMethodStop, StopMethodKill:
return sm, nil
return nil
default:
return "", errors.New("invalid stop method " + s)
return gperr.New("invalid stop method").Subject(string(c.StopMethod))
}
}
func validateStartEndpoint(s string) (string, error) {
if s == "" {
return "", nil
func (c *Config) validateStopSignal() error {
switch c.StopSignal {
case "", "SIGINT", "SIGTERM", "SIGQUIT", "SIGHUP", "INT", "TERM", "QUIT", "HUP":
return nil
default:
return gperr.New("invalid stop signal").Subject(string(c.StopSignal))
}
}
func (c *Config) validateStartEndpoint() error {
if c.StartEndpoint == "" {
return nil
}
// checks needed as of Go 1.6 because of change https://github.com/golang/go/commit/617c93ce740c3c3cc28cdd1a0d712be183d0b328#diff-6c2d018290e298803c0c9419d8739885L195
// emulate browser and strip the '#' suffix prior to validation. see issue-#237
if i := strings.Index(s, "#"); i > -1 {
s = s[:i]
if i := strings.Index(c.StartEndpoint, "#"); i > -1 {
c.StartEndpoint = c.StartEndpoint[:i]
}
if len(s) == 0 {
return "", errors.New("start endpoint must not be empty if defined")
if len(c.StartEndpoint) == 0 {
return gperr.New("start endpoint must not be empty if defined")
}
if _, err := url.ParseRequestURI(s); err != nil {
return "", err
}
return s, nil
_, err := url.ParseRequestURI(c.StartEndpoint)
return err
}

View File

@@ -35,9 +35,10 @@ func TestValidateStartEndpoint(t *testing.T) {
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s, err := validateStartEndpoint(tc.input)
cfg := Config{StartEndpoint: tc.input}
err := cfg.validateStartEndpoint()
if err == nil {
ExpectEqual(t, s, tc.input)
ExpectEqual(t, cfg.StartEndpoint, tc.input)
}
if (err != nil) != tc.wantErr {
t.Errorf("validateStartEndpoint() error = %v, wantErr %t", err, tc.wantErr)

View File

@@ -0,0 +1,14 @@
package idlewatcher
import "github.com/yusing/go-proxy/internal/gperr"
type ContainerStatus string
const (
ContainerStatusError ContainerStatus = "error"
ContainerStatusRunning ContainerStatus = "running"
ContainerStatusPaused ContainerStatus = "paused"
ContainerStatusStopped ContainerStatus = "stopped"
)
var ErrUnexpectedContainerStatus = gperr.New("unexpected container status")

View File

@@ -0,0 +1,19 @@
package idlewatcher
import (
"context"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/watcher/events"
)
type Provider interface {
ContainerPause(ctx context.Context) error
ContainerUnpause(ctx context.Context) error
ContainerStart(ctx context.Context) error
ContainerStop(ctx context.Context, signal Signal, timeout int) error
ContainerKill(ctx context.Context, signal Signal) error
ContainerStatus(ctx context.Context) (ContainerStatus, error)
Watch(ctx context.Context) (eventCh <-chan events.Event, errCh <-chan gperr.Error)
Close()
}