Files
worker/internal/webapp/handlers_updates.go
Gleb Tv 2c7a0236da feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
2026-07-13 17:55:14 +03:00

130 строки
4.0 KiB
Go

package webapp
import (
"context"
"encoding/json"
"net/http"
"sync"
"time"
)
// placeholderLatestVersion is the fallback shown on the /updates
// page when Config.ReleaseURL is empty or the poll fails. Phase 1
// uses it permanently; once Config.ReleaseURL is set the handler
// replaces it with the polled tag_name (GitHub release JSON shape).
const placeholderLatestVersion = "v1 (dev)"
// defaultReleasePollTimeout bounds the time a single release-server
// HTTP fetch is allowed to take. 3 seconds keeps the page render
// fast; a slow upstream just shows the placeholder.
const defaultReleasePollTimeout = 3 * time.Second
// releaseCacheTTL bounds how often the worker re-fetches the
// release URL. One hour is short enough that a fresh release shows
// up reasonably quickly, long enough that the page never hammers the
// upstream.
const releaseCacheTTL = 1 * time.Hour
// releasePoller holds the cached release-server response. A single
// instance lives on the Server (one per process) so concurrent
// /updates hits share the same cache entry.
type releasePoller struct {
mu sync.RWMutex
url string
cached string
cachedAt time.Time
}
// latest returns the cached value if it is fresh, otherwise it
// fetches the URL, parses {"tag_name":"..."} from the response and
// caches the result. Errors fall back to placeholderLatestVersion
// without touching the cache, so a transient outage does not poison
// the next successful poll.
func (p *releasePoller) latest(ctx context.Context, httpClient *http.Client) string {
if p == nil || p.url == "" {
return placeholderLatestVersion
}
p.mu.RLock()
if !p.cachedAt.IsZero() && time.Since(p.cachedAt) < releaseCacheTTL && p.cached != "" {
out := p.cached
p.mu.RUnlock()
return out
}
p.mu.RUnlock()
client := httpClient
if client == nil {
client = &http.Client{Timeout: defaultReleasePollTimeout}
}
fetchCtx, cancel := context.WithTimeout(ctx, defaultReleasePollTimeout)
defer cancel()
req, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, p.url, http.NoBody)
if err != nil {
return placeholderLatestVersion
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return placeholderLatestVersion
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return placeholderLatestVersion
}
var body struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil || body.TagName == "" {
return placeholderLatestVersion
}
p.mu.Lock()
p.cached = body.TagName
p.cachedAt = time.Now()
p.mu.Unlock()
return body.TagName
}
// setURL configures the poller with a new release URL and resets
// the cache. Called from New() when Config.ReleaseURL is set.
func (p *releasePoller) setURL(u string) {
if p == nil {
return
}
p.mu.Lock()
p.url = u
p.cached = ""
p.cachedAt = time.Time{}
p.mu.Unlock()
}
// handleUpdates renders the updates page. The "pull and restart"
// button stays disabled until sudo / docker socket access lands
// (gated on the Phase 3 Docker management work). The version
// comparison above it IS real: it polls Config.ReleaseURL (env
// WORKER_RELEASE_URL) and falls back to placeholderLatestVersion on
// any network or parse error.
func (s *Server) handleUpdates(w http.ResponseWriter, r *http.Request) {
writeNoStore(w)
sess, _ := sessionFromContext(r.Context())
data := updatesPageData{
basePageData: s.newBasePage(r, "Updates", sess),
CurrentVersion: workerVersionOrDash(s.deps.Runner),
LatestKnown: s.releasePoller.latest(r.Context(), s.deps.ReleaseHTTPClient),
PullEnabled: false,
PullTooltip: "Pull and restart lands with Docker management (sudo / docker socket required).",
}
if err := s.templates.Execute(w, "updates.html", data); err != nil {
s.deps.Logger.Printf("render updates: %v", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
type updatesPageData struct {
basePageData
CurrentVersion string
LatestKnown string
PullEnabled bool
PullTooltip string
}