130 строки
4.0 KiB
Go
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
|
|
}
|