From ef83ed0596ee9b3ba8c3f68d6fd4f0f23fd6ccdd Mon Sep 17 00:00:00 2001 From: yusing Date: Mon, 7 Oct 2024 17:41:08 +0800 Subject: [PATCH] improved idlewatcher and content type matching, update CI --- .github/workflows/docker-image.yml | 4 +-- internal/docker/idlewatcher/waker.go | 24 ++++++++++++-- internal/docker/idlewatcher/watcher.go | 32 +++++++++++-------- internal/net/http/content_type.go | 44 ++++++++++++++++++++++++++ internal/net/http/content_type_test.go | 41 ++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 19 deletions(-) create mode 100644 internal/net/http/content_type_test.go diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index aeb395f..1411cab 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -11,7 +11,7 @@ env: jobs: build: name: Build multi-platform Docker image - runs-on: self-hosted + runs-on: ubuntu-22.04 permissions: contents: read @@ -85,7 +85,7 @@ jobs: if-no-files-found: error retention-days: 1 merge: - runs-on: self-hosted + runs-on: ubuntu-22.04 needs: - build permissions: diff --git a/internal/docker/idlewatcher/waker.go b/internal/docker/idlewatcher/waker.go index 92aa68e..0acc395 100644 --- a/internal/docker/idlewatcher/waker.go +++ b/internal/docker/idlewatcher/waker.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "net/http" + "strconv" "time" gphttp "github.com/yusing/go-proxy/internal/net/http" @@ -38,6 +39,7 @@ func (w *Waker) ServeHTTP(rw http.ResponseWriter, r *http.Request) { func (w *Waker) wake(next http.HandlerFunc, rw http.ResponseWriter, r *http.Request) { // pass through if container is ready if w.ready.Load() { + w.resetIdleTimer() next(rw, r) return } @@ -45,11 +47,23 @@ func (w *Waker) wake(next http.HandlerFunc, rw http.ResponseWriter, r *http.Requ ctx, cancel := context.WithTimeout(r.Context(), w.WakeTimeout) defer cancel() - isCheckRedirect := r.Header.Get(headerCheckRedirect) != "" + accept := gphttp.GetAccept(r.Header) + acceptHTML := accept.AcceptHTML() || accept.IsEmpty() + + if !acceptHTML { + w.l.Debugf("Accept %v", accept) + } + + isCheckRedirect := r.Header.Get(headerCheckRedirect) != "" && acceptHTML if !isCheckRedirect { // Send a loading response to the client + body := w.makeRespBody("%s waking up...", w.ContainerName) rw.Header().Set("Content-Type", "text/html; charset=utf-8") - rw.Write(w.makeRespBody("%s waking up...", w.ContainerName)) + rw.Header().Set("Content-Length", strconv.Itoa(len(body))) + rw.Header().Add("Cache-Control", "no-cache") + rw.Header().Add("Cache-Control", "no-store") + rw.Header().Add("Cache-Control", "must-revalidate") + rw.Write(body) return } @@ -96,7 +110,11 @@ func (w *Waker) wake(next http.HandlerFunc, rw http.ResponseWriter, r *http.Requ _, err = w.client.Do(wakeReq) if err == nil { w.ready.Store(true) - rw.WriteHeader(http.StatusOK) + if isCheckRedirect { + rw.WriteHeader(http.StatusOK) + } else { + next(rw, r) + } return } diff --git a/internal/docker/idlewatcher/watcher.go b/internal/docker/idlewatcher/watcher.go index 3ae21cf..34c7d0d 100644 --- a/internal/docker/idlewatcher/watcher.go +++ b/internal/docker/idlewatcher/watcher.go @@ -26,6 +26,7 @@ type ( wakeCh chan struct{} wakeDone chan E.NestedError + ticker *time.Ticker ctx context.Context cancel context.CancelFunc @@ -79,8 +80,9 @@ func Register(entry *P.ReverseProxyEntry) (*watcher, E.NestedError) { ReverseProxyEntry: entry, client: client, refCount: &sync.WaitGroup{}, - wakeCh: make(chan struct{}), + wakeCh: make(chan struct{}, 1), wakeDone: make(chan E.NestedError), + ticker: time.NewTicker(entry.IdleTimeout), l: logger.WithField("container", entry.ContainerName), } w.refCount.Add(1) @@ -116,7 +118,6 @@ func Start() { w.watchUntilCancel() w.refCount.Wait() // wait for 0 ref count - w.client.Close() delete(watcherMap, w.ContainerID) w.l.Debug("unregistered") mainLoopWg.Done() @@ -207,10 +208,14 @@ func (w *watcher) getStopCallback() StopCallback { } } +func (w *watcher) resetIdleTimer() { + w.ticker.Reset(w.IdleTimeout) +} + func (w *watcher) watchUntilCancel() { defer close(w.wakeCh) - w.ctx, w.cancel = context.WithCancel(context.Background()) + w.ctx, w.cancel = context.WithCancel(mainLoopCtx) dockerWatcher := W.NewDockerWatcherWithClient(w.client) dockerEventCh, dockerEventErrCh := dockerWatcher.EventsWithOptions(w.ctx, W.DockerListOptions{ @@ -225,14 +230,11 @@ func (w *watcher) watchUntilCancel() { W.DockerFilterUnpause, ), }) - - ticker := time.NewTicker(w.IdleTimeout) - defer ticker.Stop() + defer w.ticker.Stop() + defer w.client.Close() for { select { - case <-mainLoopCtx.Done(): - w.cancel() case <-w.ctx.Done(): w.l.Debug("stopped") return @@ -244,22 +246,24 @@ func (w *watcher) watchUntilCancel() { switch { // create / start / unpause case e.Action.IsContainerWake(): - ticker.Reset(w.IdleTimeout) + w.ContainerRunning = true + w.resetIdleTimer() w.l.Info(e) - default: // stop / pause / kill - ticker.Stop() + default: // stop / pause / kil + w.ContainerRunning = false + w.ticker.Stop() w.ready.Store(false) w.l.Info(e) } - case <-ticker.C: + case <-w.ticker.C: w.l.Debug("idle timeout") - ticker.Stop() + w.ticker.Stop() if err := w.stopByMethod(); err != nil && err.IsNot(context.Canceled) { w.l.Error(E.FailWith("stop", err).Extraf("stop method: %s", w.StopMethod)) } case <-w.wakeCh: w.l.Debug("wake signal received") - ticker.Reset(w.IdleTimeout) + w.resetIdleTimer() err := w.wakeIfStopped() if err != nil { w.l.Error(E.FailWith("wake", err)) diff --git a/internal/net/http/content_type.go b/internal/net/http/content_type.go index 826f020..59b021d 100644 --- a/internal/net/http/content_type.go +++ b/internal/net/http/content_type.go @@ -6,6 +6,7 @@ import ( ) type ContentType string +type AcceptContentType []ContentType func GetContentType(h http.Header) ContentType { ct := h.Get("Content-Type") @@ -19,6 +20,18 @@ func GetContentType(h http.Header) ContentType { return ContentType(ct) } +func GetAccept(h http.Header) AcceptContentType { + var accepts []ContentType + for _, v := range h["Accept"] { + ct, _, err := mime.ParseMediaType(v) + if err != nil { + continue + } + accepts = append(accepts, ContentType(ct)) + } + return accepts +} + func (ct ContentType) IsHTML() bool { return ct == "text/html" || ct == "application/xhtml+xml" } @@ -30,3 +43,34 @@ func (ct ContentType) IsJSON() bool { func (ct ContentType) IsPlainText() bool { return ct == "text/plain" } + +func (act AcceptContentType) IsEmpty() bool { + return len(act) == 0 +} + +func (act AcceptContentType) AcceptHTML() bool { + for _, v := range act { + if v.IsHTML() || v == "text/*" || v == "*/*" { + return true + } + } + return false +} + +func (act AcceptContentType) AcceptJSON() bool { + for _, v := range act { + if v.IsJSON() || v == "*/*" { + return true + } + } + return false +} + +func (act AcceptContentType) AcceptPlainText() bool { + for _, v := range act { + if v.IsPlainText() || v == "text/*" || v == "*/*" { + return true + } + } + return false +} diff --git a/internal/net/http/content_type_test.go b/internal/net/http/content_type_test.go new file mode 100644 index 0000000..ee4ea56 --- /dev/null +++ b/internal/net/http/content_type_test.go @@ -0,0 +1,41 @@ +package http + +import ( + "net/http" + "testing" + + . "github.com/yusing/go-proxy/internal/utils/testing" +) + +func TestContentTypes(t *testing.T) { + ExpectTrue(t, GetContentType(http.Header{"Content-Type": {"text/html"}}).IsHTML()) + ExpectTrue(t, GetContentType(http.Header{"Content-Type": {"text/html; charset=utf-8"}}).IsHTML()) + ExpectTrue(t, GetContentType(http.Header{"Content-Type": {"application/xhtml+xml"}}).IsHTML()) + ExpectFalse(t, GetContentType(http.Header{"Content-Type": {"text/plain"}}).IsHTML()) + + ExpectTrue(t, GetContentType(http.Header{"Content-Type": {"application/json"}}).IsJSON()) + ExpectTrue(t, GetContentType(http.Header{"Content-Type": {"application/json; charset=utf-8"}}).IsJSON()) + ExpectFalse(t, GetContentType(http.Header{"Content-Type": {"text/html"}}).IsJSON()) + + ExpectTrue(t, GetContentType(http.Header{"Content-Type": {"text/plain"}}).IsPlainText()) + ExpectTrue(t, GetContentType(http.Header{"Content-Type": {"text/plain; charset=utf-8"}}).IsPlainText()) + ExpectFalse(t, GetContentType(http.Header{"Content-Type": {"text/html"}}).IsPlainText()) +} + +func TestAcceptContentTypes(t *testing.T) { + ExpectTrue(t, GetAccept(http.Header{"Accept": {"text/html", "text/plain"}}).AcceptPlainText()) + ExpectTrue(t, GetAccept(http.Header{"Accept": {"text/html", "text/plain; charset=utf-8"}}).AcceptPlainText()) + ExpectTrue(t, GetAccept(http.Header{"Accept": {"text/html", "text/plain"}}).AcceptHTML()) + ExpectTrue(t, GetAccept(http.Header{"Accept": {"application/json"}}).AcceptJSON()) + ExpectTrue(t, GetAccept(http.Header{"Accept": {"*/*"}}).AcceptPlainText()) + ExpectTrue(t, GetAccept(http.Header{"Accept": {"*/*"}}).AcceptHTML()) + ExpectTrue(t, GetAccept(http.Header{"Accept": {"*/*"}}).AcceptJSON()) + ExpectTrue(t, GetAccept(http.Header{"Accept": {"text/*"}}).AcceptPlainText()) + ExpectTrue(t, GetAccept(http.Header{"Accept": {"text/*"}}).AcceptHTML()) + + ExpectFalse(t, GetAccept(http.Header{"Accept": {"text/plain"}}).AcceptHTML()) + ExpectFalse(t, GetAccept(http.Header{"Accept": {"text/plain; charset=utf-8"}}).AcceptHTML()) + ExpectFalse(t, GetAccept(http.Header{"Accept": {"text/html"}}).AcceptPlainText()) + ExpectFalse(t, GetAccept(http.Header{"Accept": {"text/html"}}).AcceptJSON()) + ExpectFalse(t, GetAccept(http.Header{"Accept": {"text/*"}}).AcceptJSON()) +}