feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
203
internal/distworker/config.go
Обычный файл
203
internal/distworker/config.go
Обычный файл
@@ -0,0 +1,203 @@
|
||||
package distworker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultMaxConcurrency is the default upper bound for the worker pool size
|
||||
// and the number of dispatch goroutines that drain the job queue.
|
||||
const DefaultMaxConcurrency = 32
|
||||
|
||||
const (
|
||||
// DefaultHTTPHost is the bind address used when WORKER_HOST is unset.
|
||||
DefaultHTTPHost = "0.0.0.0"
|
||||
// DefaultHTTPPort is the bind port used when WORKER_PORT is unset.
|
||||
// Picked >20000 to avoid colliding with the main RSMon app (which
|
||||
// binds 7401 by default) when the worker is co-located on the
|
||||
// same host. Operators are still free to override via WORKER_PORT.
|
||||
DefaultHTTPPort = 27401
|
||||
|
||||
// schemeHTTP / schemeHTTPS are the only schemes accepted on
|
||||
// WORKER_URL. Peer workers and the main app need an http(s) origin
|
||||
// they can dial with Go's net/http stack.
|
||||
schemeHTTP = "http"
|
||||
schemeHTTPS = "https"
|
||||
|
||||
// loopbackHostnames lists hostnames treated as loopback for the
|
||||
// http-on-non-loopback warning emitted by ValidateHTTPConfig.
|
||||
loopbackHostnames = "localhost,127.0.0.1,::1,0.0.0.0"
|
||||
)
|
||||
|
||||
// HTTPConfig holds the settings that govern the worker's local HTTP
|
||||
// listener (web app MVP in Task 3 and Raft peer connections in Task 4).
|
||||
// Host/Port are the bind interface. URL is the publicly-advertised
|
||||
// location peers and the main app use to reach the worker; it is NOT
|
||||
// derived from Host:Port because workers commonly sit behind a reverse
|
||||
// proxy / Traefik with HTTPS while listening on plain HTTP internally.
|
||||
//
|
||||
// Login/Password are basic auth credentials for the worker web app API
|
||||
// (Task 3). They are kept in memory only; Task 3 may hash them before
|
||||
// any persistent store.
|
||||
type HTTPConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
URL string
|
||||
Login string
|
||||
Password string
|
||||
}
|
||||
|
||||
// IsAuthConfigured reports whether both WORKER_LOGIN and WORKER_PASSWORD
|
||||
// are set. The HTTP listener refuses to start otherwise.
|
||||
func (c HTTPConfig) IsAuthConfigured() bool {
|
||||
return c.Login != "" && c.Password != ""
|
||||
}
|
||||
|
||||
// IsListenConfigured reports whether the listener should bind at all.
|
||||
// Currently always true while WORKER_PORT > 0; placeholder for Task 3
|
||||
// to wire "skip listen" semantics.
|
||||
func (c HTTPConfig) IsListenConfigured() bool {
|
||||
return c.Port > 0
|
||||
}
|
||||
|
||||
// Config holds the local worker connection settings.
|
||||
// Runtime settings are delivered by the control plane over websocket.
|
||||
type Config struct {
|
||||
URL string
|
||||
Token string
|
||||
|
||||
// MaxConcurrency caps the worker pool size and the number of dispatcher
|
||||
// goroutines. A value <= 0 falls back to DefaultMaxConcurrency.
|
||||
// Runtime configuration delivered by the control plane via the websocket
|
||||
// "init" or "config" message is clamped to this value when resizing.
|
||||
MaxConcurrency int
|
||||
|
||||
// HTTP holds the local web app / Raft listener settings (Task 2).
|
||||
HTTP HTTPConfig
|
||||
}
|
||||
|
||||
// ConfigFromEnv creates a Config from environment variables.
|
||||
func ConfigFromEnv() Config {
|
||||
return Config{
|
||||
URL: normalizeURL(os.Getenv("RSMON_URL")),
|
||||
Token: os.Getenv("RSMON_TOKEN"),
|
||||
HTTP: HTTPConfigFromEnv(),
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPConfigFromEnv reads HTTP listener settings from the environment.
|
||||
// Empty WORKER_HOST defaults to DefaultHTTPHost; empty WORKER_PORT defaults
|
||||
// to DefaultHTTPPort. A malformed WORKER_PORT falls back to the default.
|
||||
// URL is parsed loosely here; ValidateHTTPConfig does the real check.
|
||||
func HTTPConfigFromEnv() HTTPConfig {
|
||||
port := DefaultHTTPPort
|
||||
if raw := strings.TrimSpace(os.Getenv("WORKER_PORT")); raw != "" {
|
||||
if v, err := strconv.Atoi(raw); err == nil && v > 0 && v <= 65535 {
|
||||
port = v
|
||||
}
|
||||
}
|
||||
host := strings.TrimSpace(os.Getenv("WORKER_HOST"))
|
||||
if host == "" {
|
||||
host = DefaultHTTPHost
|
||||
}
|
||||
return HTTPConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
URL: strings.TrimSpace(os.Getenv("WORKER_URL")),
|
||||
Login: os.Getenv("WORKER_LOGIN"),
|
||||
Password: os.Getenv("WORKER_PASSWORD"),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateHTTPConfig enforces the Task 2 invariants:
|
||||
//
|
||||
// - WORKER_LOGIN and WORKER_PASSWORD must be both set or both empty.
|
||||
// A mixed state (XOR) is a config bug and must fail fast so an
|
||||
// operator notices immediately. Both empty is allowed while the HTTP
|
||||
// listener is not started yet.
|
||||
// - When the listener would actually start (WORKER_PORT > 0), both
|
||||
// must be set; otherwise we are going to expose an unauthenticated
|
||||
// endpoint.
|
||||
// - WORKER_URL, if set, must be a parseable absolute URL. Relative
|
||||
// URLs are rejected because peer workers and the main app need a
|
||||
// concrete origin to dial.
|
||||
// - A WORKER_URL with scheme=http on a non-loopback host logs a
|
||||
// warning: production deployments normally terminate TLS at a
|
||||
// reverse proxy (Traefik, nginx).
|
||||
func ValidateHTTPConfig(c HTTPConfig, willListen bool) error {
|
||||
loginSet := c.Login != ""
|
||||
passSet := c.Password != ""
|
||||
if loginSet != passSet {
|
||||
return fmt.Errorf(
|
||||
"WORKER_LOGIN and WORKER_PASSWORD must be both set or both empty (got login=%s password=%s)",
|
||||
boolStr(loginSet), boolStr(passSet))
|
||||
}
|
||||
if willListen && !c.IsAuthConfigured() {
|
||||
return fmt.Errorf("HTTP listener refused to start: WORKER_LOGIN and WORKER_PASSWORD must be set when WORKER_PORT > 0")
|
||||
}
|
||||
if c.URL == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := url.Parse(c.URL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("WORKER_URL is not a valid URL: %v", err)
|
||||
}
|
||||
if u.Scheme == "" || u.Host == "" {
|
||||
return fmt.Errorf("WORKER_URL must be an absolute URL with scheme and host (got %q)", c.URL)
|
||||
}
|
||||
if u.Scheme != schemeHTTP && u.Scheme != schemeHTTPS {
|
||||
return fmt.Errorf("WORKER_URL scheme must be http or https (got %q)", u.Scheme)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WarnInsecurePublicURL logs a warning when WORKER_URL uses plain http
|
||||
// for a non-loopback host. Returns the host so the caller can log it.
|
||||
// A no-op for the loopback case (typical local dev) and for https URLs.
|
||||
func WarnInsecurePublicURL(rawURL string) (host string, shouldWarn bool) {
|
||||
if rawURL == "" {
|
||||
return "", false
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if u.Scheme != schemeHTTP {
|
||||
return u.Host, false
|
||||
}
|
||||
if isLoopbackHost(u.Hostname()) {
|
||||
return u.Host, false
|
||||
}
|
||||
return u.Host, true
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
host = strings.ToLower(host)
|
||||
for _, h := range strings.Split(loopbackHostnames, ",") {
|
||||
if host == strings.TrimSpace(h) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func boolStr(b bool) string {
|
||||
if b {
|
||||
return "set"
|
||||
}
|
||||
return "empty"
|
||||
}
|
||||
|
||||
func normalizeURL(endpoint string) string {
|
||||
endpoint = strings.TrimRight(endpoint, "/")
|
||||
if strings.HasSuffix(endpoint, "/api/worker") {
|
||||
return strings.TrimSuffix(endpoint, "/api/worker")
|
||||
}
|
||||
if strings.HasSuffix(endpoint, "/worker") {
|
||||
return strings.TrimSuffix(endpoint, "/worker")
|
||||
}
|
||||
return endpoint
|
||||
}
|
||||
Ссылка в новой задаче
Block a user