fix(worker): harden control-plane lifecycle
Все проверки выполнены успешно
CI / test (push) Successful in 2m32s
Docker / Build and publish worker image (push) Successful in 18m17s

- reconnect safely after token rotation and retry leased results
- reject malformed tasks and remove production cluster debug mutation
- validate environment files and require immutable container images

BREAKING CHANGE: Docker install, deploy, and Compose now require an
immutable repository@sha256 image reference.
Этот коммит содержится в:
Gleb Tv
2026-07-19 23:11:43 +03:00
родитель 6937674449
Коммит e987f24903
38 изменённых файлов: 2203 добавлений и 674 удалений

Просмотреть файл

@@ -1,16 +1,11 @@
package webapp
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -21,23 +16,13 @@ import (
// pinned without standing up a real raft group.
type stubCluster struct {
stats ClusterStats
applyIndex uint64
applyErr error
applyCalled int
applyMu sync.Mutex
clusterIDOut string
addrOut string
}
func (s *stubCluster) Stats() ClusterStats { return s.stats }
func (s *stubCluster) ApplyTestConfig() (uint64, error) {
s.applyMu.Lock()
defer s.applyMu.Unlock()
s.applyCalled++
return s.applyIndex, s.applyErr
}
func (s *stubCluster) ClusterID() string { return s.clusterIDOut }
func (s *stubCluster) LocalAddr() string { return s.addrOut }
func (s *stubCluster) ClusterID() string { return s.clusterIDOut }
func (s *stubCluster) LocalAddr() string { return s.addrOut }
// withClusterServer returns a test server whose ClusterView is the
// supplied stub. The first-run password path is also exercised so
@@ -166,151 +151,23 @@ func TestClusterStatus_RequiresSession(t *testing.T) {
assert.Equal(t, "/web/login", resp.Header.Get("Location"))
}
// TestClusterApplyTestConfig_NotConfigured verifies the 404 path
// when WORKER_CLUSTER_DEBUG_APPLY is false (the production default)
// and no cluster is attached. The handler must refuse before it
// even checks the cluster because the debug flag is off.
func TestClusterApplyTestConfig_NotConfigured(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
require.False(t, srv.cfg.DebugClusterApply, "default config must leave the debug apply flag off")
ts := newHTTPTestServer(t, srv)
c, _ := loginAsFirstRun(t, ts.URL, srv)
clearRequiresChange(t, srv)
// TestClusterApplyTestConfig_NotExposed verifies production requests cannot
// append a hardcoded config through the former debug endpoint.
func TestClusterApplyTestConfig_NotExposed(t *testing.T) {
t.Setenv("WORKER_CLUSTER_DEBUG_APPLY", "true")
ts, _, c := withClusterServer(t, &stubCluster{})
resp, err := c.PostForm(ts.URL+"/web/api/cluster/apply-test-config", url.Values{})
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusNotFound, resp.StatusCode,
"debug apply must be invisible (404) when WORKER_CLUSTER_DEBUG_APPLY is unset")
}
// TestClusterApplyTestConfig_DebugOffReturns404 verifies that even
// with a cluster attached the apply endpoint stays 404 unless the
// debug flag is on. The flag, not cluster presence, gates the
// endpoint.
func TestClusterApplyTestConfig_DebugOffReturns404(t *testing.T) {
stub := &stubCluster{applyIndex: 42}
ts, _, c := withClusterServer(t, stub)
resp, err := c.Get(ts.URL + "/overview")
require.NoError(t, err)
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close() //nolint:errcheck
csrf := extractCSRFToken(t, string(bodyBytes))
form := url.Values{}
form.Set("csrf_token", csrf)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/cluster/apply-test-config",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err = c.Do(req)
resp, err := c.Post(ts.URL+"/web/api/cluster/apply-test-config", "", nil)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
assert.Equal(t, 0, stub.applyCalled,
"ApplyTestConfig must never be called when the debug flag is off")
}
// TestClusterApplyTestConfig_HappyPath verifies that the apply-test-
// config endpoint returns the applied index when the cluster
// subsystem accepts the entry. CSRF is checked. The DebugClusterApply
// flag must be on for the endpoint to be reachable.
func TestClusterApplyTestConfig_HappyPath(t *testing.T) {
stub := &stubCluster{
stats: ClusterStats{
NodeID: "worker1", State: "Leader", Leader: "worker1",
Voters: []string{"worker1"},
},
applyIndex: 13,
clusterIDOut: "worker1",
addrOut: "127.0.0.1:17401",
}
ts, srv, c := withClusterServer(t, stub)
srv.cfg.DebugClusterApply = true
// Fetch CSRF token from any authenticated page.
resp, err := c.Get(ts.URL + "/overview")
require.NoError(t, err)
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close() //nolint:errcheck
csrf := extractCSRFToken(t, string(bodyBytes))
form := url.Values{}
form.Set("csrf_token", csrf)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/cluster/apply-test-config",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err = c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusOK, resp.StatusCode)
bodyBytes, _ = io.ReadAll(resp.Body)
var got map[string]uint64
require.NoError(t, json.Unmarshal(bodyBytes, &got))
assert.EqualValues(t, 13, got["applied_index"])
assert.Equal(t, 1, stub.applyCalled)
}
// TestClusterApplyTestConfig_PropagatesError verifies that errors
// from the cluster subsystem surface as 502 Bad Gateway. Debug flag
// must be on.
func TestClusterApplyTestConfig_PropagatesError(t *testing.T) {
stub := &stubCluster{
applyErr: errStubApply,
clusterIDOut: "worker1",
addrOut: "127.0.0.1:17401",
}
ts, srv, c := withClusterServer(t, stub)
srv.cfg.DebugClusterApply = true
resp, err := c.Get(ts.URL + "/overview")
require.NoError(t, err)
bodyBytes, _ := io.ReadAll(resp.Body)
resp.Body.Close() //nolint:errcheck
csrf := extractCSRFToken(t, string(bodyBytes))
form := url.Values{}
form.Set("csrf_token", csrf)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/web/api/cluster/apply-test-config",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err = c.Do(req)
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusBadGateway, resp.StatusCode)
}
// TestClusterApplyTestConfig_RequiresCSRF ensures the apply-test-
// config POST is refused without a CSRF token. Debug flag must be
// on for the endpoint to be reachable; without the flag it returns
// 404 (priority over CSRF check).
func TestClusterApplyTestConfig_RequiresCSRF(t *testing.T) {
stub := &stubCluster{applyIndex: 99}
ts, srv, c := withClusterServer(t, stub)
srv.cfg.DebugClusterApply = true
resp, err := c.PostForm(ts.URL+"/web/api/cluster/apply-test-config", url.Values{})
require.NoError(t, err)
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusForbidden, resp.StatusCode,
"apply-test-config without CSRF must be 403")
assert.Equal(t, 0, stub.applyCalled, "ApplyTestConfig must not be called without CSRF")
}
// errStubApply is a sentinel error used by the apply-error test.
var errStubApply = errApply("worker not leader")
type errApply string
func (e errApply) Error() string { return string(e) }
// TestSetClusterDetaches verifies SetCluster(nil) returns the server
// to the no-cluster-attached state (503 from the endpoints).
func TestSetClusterDetaches(t *testing.T) {
srv := newTestServer(t, &stubRunner{id: "w-1"})
stub := &stubCluster{applyIndex: 7}
stub := &stubCluster{}
srv.SetCluster(stub)
require.NotNil(t, srv.Cluster())
@@ -326,11 +183,3 @@ func TestSetClusterDetaches(t *testing.T) {
defer resp.Body.Close() //nolint:errcheck
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
}
// _ = context.Background and time.Time keep the linter quiet about
// unused imports if the file shrinks.
var (
_ = context.Background
_ = time.Now
_ = url.Parse
)