feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
129
internal/webapp/handlers_updates.go
Обычный файл
129
internal/webapp/handlers_updates.go
Обычный файл
@@ -0,0 +1,129 @@
|
||||
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
|
||||
}
|
||||
Ссылка в новой задаче
Block a user