refactor: move docker/idlewatcher to idlewatcher

This commit is contained in:
yusing
2025-04-06 04:05:26 +08:00
parent edb4b59254
commit 5da9dd6082
16 changed files with 11 additions and 11 deletions

View File

@@ -1,60 +0,0 @@
package idlewatcher
import (
"context"
"errors"
"github.com/docker/docker/api/types/container"
)
type (
containerMeta struct {
ContainerID, ContainerName string
}
containerState struct {
running bool
ready bool
err error
}
)
func (w *Watcher) ContainerID() string {
return w.route.ContainerInfo().ContainerID
}
func (w *Watcher) ContainerName() string {
return w.route.ContainerInfo().ContainerName
}
func (w *Watcher) containerStop(ctx context.Context) error {
return w.client.ContainerStop(ctx, w.ContainerID(), container.StopOptions{
Signal: string(w.Config().StopSignal),
Timeout: &w.Config().StopTimeout,
})
}
func (w *Watcher) containerPause(ctx context.Context) error {
return w.client.ContainerPause(ctx, w.ContainerID())
}
func (w *Watcher) containerKill(ctx context.Context) error {
return w.client.ContainerKill(ctx, w.ContainerID(), string(w.Config().StopSignal))
}
func (w *Watcher) containerUnpause(ctx context.Context) error {
return w.client.ContainerUnpause(ctx, w.ContainerID())
}
func (w *Watcher) containerStart(ctx context.Context) error {
return w.client.ContainerStart(ctx, w.ContainerID(), container.StartOptions{})
}
func (w *Watcher) containerStatus() (string, error) {
ctx, cancel := context.WithTimeoutCause(w.task.Context(), dockerReqTimeout, errors.New("docker request timeout"))
defer cancel()
json, err := w.client.ContainerInspect(ctx, w.ContainerID())
if err != nil {
return "", err
}
return json.State.Status, nil
}

View File

@@ -1,138 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{.Title}}</title>
<style>
/* size variables */
:root {
--dot-size: 12px;
--logo-size: 100px;
}
/* Global Styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Open Sans",
"Helvetica Neue",
sans-serif;
font-size: 16px;
line-height: 1.5;
color: #f8f9fa;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
gap: 32px;
background: linear-gradient(135deg, #121212 0%, #1e1e1e 100%);
}
/* Container */
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
border-radius: 16px;
background-color: rgba(30, 30, 30, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(8px);
max-width: 90%;
transition: all 0.3s ease;
}
/* Spinner Styles */
.loading-dots {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
padding-top: 20px;
padding-bottom: 6px;
}
.dot {
width: var(--dot-size);
height: var(--dot-size);
background-color: #66d9ef;
border-radius: 50%;
animation: bounce 1.3s infinite ease-in-out;
}
.dot:nth-child(1) {
animation-delay: -0.32s;
}
.dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes bounce {
0%,
80%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
}
/* Message Styles */
.message {
font-size: 20px;
font-weight: 500;
text-align: center;
color: #f8f9fa;
max-width: 500px;
letter-spacing: 0.3px;
white-space: nowrap;
}
/* Logo */
.logo {
width: var(--logo-size);
height: var(--logo-size);
}
</style>
</head>
<body>
<div class="container">
<!-- icon handled by waker_http -->
<img class="logo" src="/favicon.ico" />
<div id="loading-dots" class="loading-dots">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
<div id="message" class="message">{{.Message}}</div>
</div>
<script>
window.onload = async function () {
let resp = await fetch(window.location.href, {
headers: {
"{{.CheckRedirectHeader}}": "1",
},
});
if (resp.ok) {
window.location.href = resp.url;
} else {
document.getElementById("message").innerText = await resp.text();
document.getElementById("loading-dots").remove();
}
};
</script>
</body>
</html>

View File

@@ -1,35 +0,0 @@
package idlewatcher
import (
"bytes"
_ "embed"
"text/template"
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
)
type templateData struct {
CheckRedirectHeader string
Title string
Message string
}
//go:embed html/loading_page.html
var loadingPage []byte
var loadingPageTmpl = template.Must(template.New("loading_page").Parse(string(loadingPage)))
func (w *Watcher) makeLoadingPageBody() []byte {
msg := w.ContainerName() + " is starting..."
data := new(templateData)
data.CheckRedirectHeader = httpheaders.HeaderGoDoxyCheckRedirect
data.Title = w.route.HomepageItem().Name
data.Message = msg
buf := bytes.NewBuffer(make([]byte, len(loadingPage)+len(data.Title)+len(data.Message)+len(httpheaders.HeaderGoDoxyCheckRedirect)))
err := loadingPageTmpl.Execute(buf, data)
if err != nil { // should never happen in production
panic(err)
}
return buf.Bytes()
}

View File

@@ -1,39 +0,0 @@
package idlewatcher
func (w *Watcher) running() bool {
return w.state.Load().running
}
func (w *Watcher) ready() bool {
return w.state.Load().ready
}
func (w *Watcher) error() error {
return w.state.Load().err
}
func (w *Watcher) setReady() {
w.state.Store(&containerState{
running: true,
ready: true,
})
}
func (w *Watcher) setStarting() {
w.state.Store(&containerState{
running: true,
ready: false,
})
}
func (w *Watcher) setNapping() {
w.setError(nil)
}
func (w *Watcher) setError(err error) {
w.state.Store(&containerState{
running: false,
ready: false,
err: err,
})
}

View File

@@ -1,110 +0,0 @@
package types
import (
"errors"
"net/url"
"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"`
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
)
const (
StopMethodPause StopMethod = "pause"
StopMethodStop StopMethod = "stop"
StopMethodKill StopMethod = "kill"
)
var validSignals = map[string]struct{}{
"": {},
"SIGINT": {}, "SIGTERM": {}, "SIGHUP": {}, "SIGQUIT": {},
"INT": {}, "TERM": {}, "HUP": {}, "QUIT": {},
}
func ValidateConfig(cont *docker.Container) (*Config, gperr.Error) {
if cont == nil || cont.IdleTimeout == "" {
return nil, nil
}
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
}
func validateDurationPostitive(value string) (time.Duration, error) {
d, err := time.ParseDuration(value)
if err != nil {
return 0, err
}
if d < 0 {
return 0, errors.New("duration must be positive")
}
return d, nil
}
func validateSignal(s string) (Signal, error) {
if _, ok := validSignals[s]; ok {
return Signal(s), nil
}
return "", errors.New("invalid signal " + s)
}
func validateStopMethod(s string) (StopMethod, error) {
sm := StopMethod(s)
switch sm {
case StopMethodPause, StopMethodStop, StopMethodKill:
return sm, nil
default:
return "", errors.New("invalid stop method " + s)
}
}
func validateStartEndpoint(s string) (string, error) {
if s == "" {
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 len(s) == 0 {
return "", errors.New("start endpoint must not be empty if defined")
}
if _, err := url.ParseRequestURI(s); err != nil {
return "", err
}
return s, nil
}

View File

@@ -1,47 +0,0 @@
package types
import (
"testing"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestValidateStartEndpoint(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{
name: "valid",
input: "/start",
wantErr: false,
},
{
name: "invalid",
input: "../foo",
wantErr: true,
},
{
name: "single fragment",
input: "#",
wantErr: true,
},
{
name: "empty",
input: "",
wantErr: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s, err := validateStartEndpoint(tc.input)
if err == nil {
ExpectEqual(t, s, tc.input)
}
if (err != nil) != tc.wantErr {
t.Errorf("validateStartEndpoint() error = %v, wantErr %t", err, tc.wantErr)
}
})
}
}

View File

@@ -1,15 +0,0 @@
package types
import (
"net/http"
net "github.com/yusing/go-proxy/internal/net/types"
"github.com/yusing/go-proxy/internal/watcher/health"
)
type Waker interface {
health.HealthMonitor
http.Handler
net.Stream
Wake() error
}

View File

@@ -1,181 +0,0 @@
package idlewatcher
import (
"time"
"github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/metrics"
"github.com/yusing/go-proxy/internal/net/gphttp/reverseproxy"
net "github.com/yusing/go-proxy/internal/net/types"
route "github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/task"
U "github.com/yusing/go-proxy/internal/utils"
"github.com/yusing/go-proxy/internal/watcher/health"
"github.com/yusing/go-proxy/internal/watcher/health/monitor"
)
type (
Waker = types.Waker
waker struct {
_ U.NoCopy
rp *reverseproxy.ReverseProxy
stream net.Stream
hc health.HealthChecker
metric *metrics.Gauge
}
)
const (
idleWakerCheckInterval = 100 * time.Millisecond
idleWakerCheckTimeout = time.Second
)
// TODO: support stream
func newWaker(parent task.Parent, route route.Route, rp *reverseproxy.ReverseProxy, stream net.Stream) (Waker, gperr.Error) {
hcCfg := route.HealthCheckConfig()
hcCfg.Timeout = idleWakerCheckTimeout
waker := &waker{
rp: rp,
stream: stream,
}
watcher, err := registerWatcher(parent, route, waker)
if err != nil {
return nil, gperr.Errorf("register watcher: %w", err)
}
switch {
case route.IsAgent():
waker.hc = monitor.NewAgentProxiedMonitor(route.Agent(), hcCfg, monitor.AgentTargetFromURL(route.TargetURL()))
case rp != nil:
waker.hc = monitor.NewHTTPHealthChecker(route.TargetURL(), hcCfg)
case stream != nil:
waker.hc = monitor.NewRawHealthChecker(route.TargetURL(), hcCfg)
default:
panic("both nil")
}
return watcher, nil
}
// lifetime should follow route provider.
func NewHTTPWaker(parent task.Parent, route route.Route, rp *reverseproxy.ReverseProxy) (Waker, gperr.Error) {
return newWaker(parent, route, rp, nil)
}
func NewStreamWaker(parent task.Parent, route route.Route, stream net.Stream) (Waker, gperr.Error) {
return newWaker(parent, route, nil, stream)
}
// Start implements health.HealthMonitor.
func (w *Watcher) Start(parent task.Parent) gperr.Error {
w.task.OnCancel("route_cleanup", func() {
parent.Finish(w.task.FinishCause())
if w.metric != nil {
w.metric.Reset()
}
})
return nil
}
// Task implements health.HealthMonitor.
func (w *Watcher) Task() *task.Task {
return w.task
}
// Finish implements health.HealthMonitor.
func (w *Watcher) Finish(reason any) {
if w.stream != nil {
w.stream.Close()
}
}
// Name implements health.HealthMonitor.
func (w *Watcher) Name() string {
return w.String()
}
// String implements health.HealthMonitor.
func (w *Watcher) String() string {
return w.ContainerName()
}
// Uptime implements health.HealthMonitor.
func (w *Watcher) Uptime() time.Duration {
return 0
}
// Latency implements health.HealthMonitor.
func (w *Watcher) Latency() time.Duration {
return 0
}
// Status implements health.HealthMonitor.
func (w *Watcher) Status() health.Status {
state := w.state.Load()
if state.err != nil {
return health.StatusError
}
if state.ready {
return health.StatusHealthy
}
if state.running {
return health.StatusStarting
}
return health.StatusNapping
}
func (w *Watcher) checkUpdateState() (ready bool, err error) {
// already ready
if w.ready() {
return true, nil
}
if !w.running() {
return false, nil
}
if w.metric != nil {
defer w.metric.Set(float64(w.Status()))
}
// the new container info not yet updated
if w.hc.URL().Host == "" {
return false, nil
}
res, err := w.hc.CheckHealth()
if err != nil {
w.setError(err)
return false, err
}
if res.Healthy {
w.setReady()
return true, nil
}
w.setStarting()
return false, nil
}
// MarshalJSON implements health.HealthMonitor.
func (w *Watcher) MarshalJSON() ([]byte, error) {
var url *net.URL
if w.hc.URL().Port() != "0" {
url = w.hc.URL()
}
var detail string
if err := w.error(); err != nil {
detail = err.Error()
}
return (&monitor.JSONRepresentation{
Name: w.Name(),
Status: w.Status(),
Config: w.hc.Config(),
URL: url,
Detail: detail,
}).MarshalJSON()
}

View File

@@ -1,142 +0,0 @@
package idlewatcher
import (
"context"
"errors"
"net/http"
"strconv"
"time"
"github.com/yusing/go-proxy/internal/api/v1/favicon"
gphttp "github.com/yusing/go-proxy/internal/net/gphttp"
"github.com/yusing/go-proxy/internal/net/gphttp/httpheaders"
)
type ForceCacheControl struct {
expires string
http.ResponseWriter
}
func (f *ForceCacheControl) WriteHeader(code int) {
f.ResponseWriter.Header().Set("Cache-Control", "must-revalidate")
f.ResponseWriter.Header().Set("Expires", f.expires)
f.ResponseWriter.WriteHeader(code)
}
func (f *ForceCacheControl) Unwrap() http.ResponseWriter {
return f.ResponseWriter
}
// ServeHTTP implements http.Handler.
func (w *Watcher) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
shouldNext := w.wakeFromHTTP(rw, r)
if !shouldNext {
return
}
select {
case <-r.Context().Done():
return
default:
f := &ForceCacheControl{expires: w.expires().Format(http.TimeFormat), ResponseWriter: rw}
w.rp.ServeHTTP(f, r)
}
}
func (w *Watcher) cancelled(reqCtx context.Context, rw http.ResponseWriter) bool {
select {
case <-reqCtx.Done():
w.WakeDebug().Str("cause", context.Cause(reqCtx).Error()).Msg("canceled")
return true
case <-w.task.Context().Done():
w.WakeDebug().Str("cause", w.task.FinishCause().Error()).Msg("canceled")
http.Error(rw, "Service unavailable", http.StatusServiceUnavailable)
return true
default:
return false
}
}
func isFaviconPath(path string) bool {
return path == "/favicon.ico"
}
func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldNext bool) {
w.resetIdleTimer()
// pass through if container is already ready
if w.ready() {
return true
}
// handle favicon request
if isFaviconPath(r.URL.Path) {
r.URL.RawQuery = "alias=" + w.route.TargetName()
favicon.GetFavIcon(rw, r)
return false
}
// Check if start endpoint is configured and request path matches
if w.Config().StartEndpoint != "" && r.URL.Path != w.Config().StartEndpoint {
http.Error(rw, "Forbidden: Container can only be started via configured start endpoint", http.StatusForbidden)
return false
}
accept := gphttp.GetAccept(r.Header)
acceptHTML := (r.Method == http.MethodGet && accept.AcceptHTML() || r.RequestURI == "/" && accept.IsEmpty())
isCheckRedirect := r.Header.Get(httpheaders.HeaderGoDoxyCheckRedirect) != ""
if !isCheckRedirect && acceptHTML {
// Send a loading response to the client
body := w.makeLoadingPageBody()
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
rw.Header().Set("Content-Length", strconv.Itoa(len(body)))
rw.Header().Set("Cache-Control", "no-cache")
rw.Header().Add("Cache-Control", "no-store")
rw.Header().Add("Cache-Control", "must-revalidate")
rw.Header().Add("Connection", "close")
if _, err := rw.Write(body); err != nil {
w.Err(err).Msg("error writing http response")
}
return false
}
ctx, cancel := context.WithTimeoutCause(r.Context(), w.Config().WakeTimeout, errors.New("wake timeout"))
defer cancel()
if w.cancelled(ctx, rw) {
return false
}
w.WakeTrace().Msg("signal received")
err := w.wakeIfStopped()
if err != nil {
w.WakeError(err)
http.Error(rw, "Error waking container", http.StatusInternalServerError)
return false
}
for {
if w.cancelled(ctx, rw) {
return false
}
ready, err := w.checkUpdateState()
if err != nil {
http.Error(rw, "Error waking container", http.StatusInternalServerError)
return false
}
if ready {
w.resetIdleTimer()
if isCheckRedirect {
w.Debug().Msgf("redirecting to %s ...", w.hc.URL())
rw.WriteHeader(http.StatusOK)
return false
}
w.Debug().Msgf("passing through to %s ...", w.hc.URL())
return true
}
// retry until the container is ready or timeout
time.Sleep(idleWakerCheckInterval)
}
}

View File

@@ -1,91 +0,0 @@
package idlewatcher
import (
"context"
"errors"
"fmt"
"net"
"time"
"github.com/yusing/go-proxy/internal/net/types"
)
// Setup implements types.Stream.
func (w *Watcher) Addr() net.Addr {
return w.stream.Addr()
}
// Setup implements types.Stream.
func (w *Watcher) Setup() error {
return w.stream.Setup()
}
// Accept implements types.Stream.
func (w *Watcher) Accept() (conn types.StreamConn, err error) {
conn, err = w.stream.Accept()
if err != nil {
return
}
if wakeErr := w.wakeFromStream(); wakeErr != nil {
w.WakeError(wakeErr)
}
return
}
// Handle implements types.Stream.
func (w *Watcher) Handle(conn types.StreamConn) error {
if err := w.wakeFromStream(); err != nil {
return err
}
return w.stream.Handle(conn)
}
// Close implements types.Stream.
func (w *Watcher) Close() error {
return w.stream.Close()
}
func (w *Watcher) wakeFromStream() error {
w.resetIdleTimer()
// pass through if container is already ready
if w.ready() {
return nil
}
w.WakeDebug().Msg("wake signal received")
wakeErr := w.wakeIfStopped()
if wakeErr != nil {
wakeErr = fmt.Errorf("%s failed: %w", w.String(), wakeErr)
w.WakeError(wakeErr)
return wakeErr
}
ctx, cancel := context.WithTimeoutCause(w.task.Context(), w.Config().WakeTimeout, errors.New("wake timeout"))
defer cancel()
for {
select {
case <-w.task.Context().Done():
cause := w.task.FinishCause()
w.WakeDebug().Str("cause", cause.Error()).Msg("canceled")
return cause
case <-ctx.Done():
cause := context.Cause(ctx)
w.WakeDebug().Str("cause", cause.Error()).Msg("timeout")
return cause
default:
}
if ready, err := w.checkUpdateState(); err != nil {
return err
} else if ready {
w.resetIdleTimer()
w.Debug().Msg("container is ready, passing through to " + w.hc.URL().String())
return nil
}
// retry until the container is ready or timeout
time.Sleep(idleWakerCheckInterval)
}
}

View File

@@ -1,279 +0,0 @@
package idlewatcher
import (
"context"
"errors"
"sync"
"time"
"github.com/rs/zerolog"
"github.com/yusing/go-proxy/internal/docker"
idlewatcher "github.com/yusing/go-proxy/internal/docker/idlewatcher/types"
"github.com/yusing/go-proxy/internal/gperr"
"github.com/yusing/go-proxy/internal/logging"
route "github.com/yusing/go-proxy/internal/route/types"
"github.com/yusing/go-proxy/internal/task"
U "github.com/yusing/go-proxy/internal/utils"
"github.com/yusing/go-proxy/internal/utils/atomic"
"github.com/yusing/go-proxy/internal/watcher"
"github.com/yusing/go-proxy/internal/watcher/events"
)
type (
Watcher struct {
_ U.NoCopy
zerolog.Logger
*waker
route route.Route
client *docker.SharedClient
state atomic.Value[*containerState]
stopByMethod StopCallback // send a docker command w.r.t. `stop_method`
ticker *time.Ticker
lastReset time.Time
task *task.Task
}
StopCallback func() error
)
var (
watcherMap = make(map[string]*Watcher)
watcherMapMu sync.RWMutex
errShouldNotReachHere = errors.New("should not reach here")
)
const dockerReqTimeout = 3 * time.Second
func registerWatcher(parent task.Parent, route route.Route, waker *waker) (*Watcher, error) {
cfg := route.IdlewatcherConfig()
cont := route.ContainerInfo()
key := cont.ContainerID
watcherMapMu.Lock()
defer watcherMapMu.Unlock()
w, ok := watcherMap[key]
if !ok {
client, err := docker.NewClient(cont.DockerHost)
if err != nil {
return nil, err
}
w = &Watcher{
Logger: logging.With().Str("name", cont.ContainerName).Logger(),
client: client,
task: parent.Subtask("idlewatcher." + cont.ContainerName),
ticker: time.NewTicker(cfg.IdleTimeout),
}
}
// FIXME: possible race condition here
w.waker = waker
w.route = route
w.ticker.Reset(cfg.IdleTimeout)
if cont.Running {
w.setStarting()
} else {
w.setNapping()
}
if !ok {
w.stopByMethod = w.getStopCallback()
watcherMap[key] = w
go func() {
cause := w.watchUntilDestroy()
watcherMapMu.Lock()
defer watcherMapMu.Unlock()
delete(watcherMap, key)
w.ticker.Stop()
w.client.Close()
w.task.Finish(cause)
}()
}
return w, nil
}
func (w *Watcher) Config() *idlewatcher.Config {
return w.route.IdlewatcherConfig()
}
func (w *Watcher) Wake() error {
return w.wakeIfStopped()
}
// WakeDebug logs a debug message related to waking the container.
func (w *Watcher) WakeDebug() *zerolog.Event {
//nolint:zerologlint
return w.Debug().Str("action", "wake")
}
func (w *Watcher) WakeTrace() *zerolog.Event {
//nolint:zerologlint
return w.Trace().Str("action", "wake")
}
func (w *Watcher) WakeError(err error) {
w.Err(err).Str("action", "wake").Msg("error")
}
func (w *Watcher) wakeIfStopped() error {
if w.running() {
return nil
}
status, err := w.containerStatus()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(w.task.Context(), w.Config().WakeTimeout)
defer cancel()
// !Hard coded here since theres no constants from Docker API
switch status {
case "exited", "dead":
return w.containerStart(ctx)
case "paused":
return w.containerUnpause(ctx)
case "running":
return nil
default:
return gperr.Errorf("unexpected container status: %s", status)
}
}
func (w *Watcher) getStopCallback() StopCallback {
var cb func(context.Context) error
switch w.Config().StopMethod {
case idlewatcher.StopMethodPause:
cb = w.containerPause
case idlewatcher.StopMethodStop:
cb = w.containerStop
case idlewatcher.StopMethodKill:
cb = w.containerKill
default:
panic(errShouldNotReachHere)
}
return func() error {
ctx, cancel := context.WithTimeout(w.task.Context(), time.Duration(w.Config().StopTimeout)*time.Second)
defer cancel()
return cb(ctx)
}
}
func (w *Watcher) resetIdleTimer() {
w.Trace().Msg("reset idle timer")
w.ticker.Reset(w.Config().IdleTimeout)
w.lastReset = time.Now()
}
func (w *Watcher) expires() time.Time {
return w.lastReset.Add(w.Config().IdleTimeout)
}
func (w *Watcher) getEventCh(ctx context.Context, dockerWatcher *watcher.DockerWatcher) (eventCh <-chan events.Event, errCh <-chan gperr.Error) {
eventCh, errCh = dockerWatcher.EventsWithOptions(ctx, watcher.DockerListOptions{
Filters: watcher.NewDockerFilter(
watcher.DockerFilterContainer,
watcher.DockerFilterContainerNameID(w.route.ContainerInfo().ContainerID),
watcher.DockerFilterStart,
watcher.DockerFilterStop,
watcher.DockerFilterDie,
watcher.DockerFilterKill,
watcher.DockerFilterDestroy,
watcher.DockerFilterPause,
watcher.DockerFilterUnpause,
),
})
return
}
// watchUntilDestroy waits for the container to be created, started, or unpaused,
// and then reset the idle timer.
//
// When the container is stopped, paused,
// or killed, the idle timer is stopped and the ContainerRunning flag is set to false.
//
// When the idle timer fires, the container is stopped according to the
// stop method.
//
// it exits only if the context is canceled, the container is destroyed,
// errors occurred on docker client, or route provider died (mainly caused by config reload).
func (w *Watcher) watchUntilDestroy() (returnCause error) {
eventCtx, eventCancel := context.WithCancel(w.task.Context())
defer eventCancel()
dockerWatcher := watcher.NewDockerWatcher(w.client.DaemonHost())
dockerEventCh, dockerEventErrCh := w.getEventCh(eventCtx, dockerWatcher)
for {
select {
case <-w.task.Context().Done():
return w.task.FinishCause()
case err := <-dockerEventErrCh:
if !err.Is(context.Canceled) {
gperr.LogError("idlewatcher error", err, &w.Logger)
}
return err
case e := <-dockerEventCh:
switch {
case e.Action == events.ActionContainerDestroy:
w.setError(errors.New("container destroyed"))
w.Info().Str("reason", "container destroyed").Msg("watcher stopped")
return errors.New("container destroyed")
// create / start / unpause
case e.Action.IsContainerWake():
w.setStarting()
w.resetIdleTimer()
w.Info().Msg("awaken")
case e.Action.IsContainerSleep(): // stop / pause / kil
w.setNapping()
w.resetIdleTimer()
w.ticker.Stop()
default:
w.Error().Msg("unexpected docker event: " + e.String())
}
// container name changed should also change the container id
// if w.ContainerName != e.ActorName {
// w.Debug().Msgf("renamed %s -> %s", w.ContainerName, e.ActorName)
// w.ContainerName = e.ActorName
// }
// if w.ContainerID != e.ActorID {
// w.Debug().Msgf("id changed %s -> %s", w.ContainerID, e.ActorID)
// w.ContainerID = e.ActorID
// // recreate event stream
// eventCancel()
// eventCtx, eventCancel = context.WithCancel(w.task.Context())
// defer eventCancel()
// dockerEventCh, dockerEventErrCh = w.getEventCh(eventCtx, dockerWatcher)
// }
case <-w.ticker.C:
w.ticker.Stop()
if w.running() {
err := w.stopByMethod()
switch {
case errors.Is(err, context.Canceled):
continue
case err != nil:
if errors.Is(err, context.DeadlineExceeded) {
err = errors.New("timeout waiting for container to stop, please set a higher value for `stop_timeout`")
}
w.Err(err).Msgf("container stop with method %q failed", w.Config().StopMethod)
default:
w.Info().Str("reason", "idle timeout").Msg("container stopped")
}
}
}
}
}