fix(worker): harden control-plane lifecycle
Все проверки выполнены успешно
CI / test (push) Successful in 2m32s
Docker / Build and publish worker image (push) Successful in 18m17s
Все проверки выполнены успешно
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.
Этот коммит содержится в:
@@ -2,6 +2,9 @@
|
|||||||
RSMON_URL=https://rsmon.ru
|
RSMON_URL=https://rsmon.ru
|
||||||
RSMON_TOKEN=replace-with-worker-token
|
RSMON_TOKEN=replace-with-worker-token
|
||||||
|
|
||||||
|
# Required for Docker Compose. Replace with a published 64-character digest.
|
||||||
|
RSMON_WORKER_IMAGE_DIGEST=replace-with-64-lowercase-hex-digest
|
||||||
|
|
||||||
# Local operator console. Both credentials are required while the web UI is on.
|
# Local operator console. Both credentials are required while the web UI is on.
|
||||||
WORKER_HOST=0.0.0.0
|
WORKER_HOST=0.0.0.0
|
||||||
WORKER_PORT=27401
|
WORKER_PORT=27401
|
||||||
|
|||||||
36
README.md
36
README.md
@@ -37,8 +37,10 @@ docker compose up -d
|
|||||||
docker compose logs -f worker
|
docker compose logs -f worker
|
||||||
```
|
```
|
||||||
|
|
||||||
Compose pulls `reg.rsxx.ru/rsmon/rsmon-worker:latest` by default. Set
|
Compose constructs an immutable image reference from
|
||||||
`RSMON_WORKER_IMAGE` to use another published tag.
|
`RSMON_WORKER_IMAGE_DIGEST`. Set that variable in `.env` to the published
|
||||||
|
64-character lowercase digest before running Compose; a mutable tag cannot be
|
||||||
|
selected through this configuration.
|
||||||
|
|
||||||
The operator console is bound to `127.0.0.1:27401` by default. Set
|
The operator console is bound to `127.0.0.1:27401` by default. Set
|
||||||
`WORKER_BIND_IP` only when a firewall or TLS reverse proxy protects the port.
|
`WORKER_BIND_IP` only when a firewall or TLS reverse proxy protects the port.
|
||||||
@@ -47,21 +49,27 @@ Persistent web and cluster state is stored in the `worker-data` volume.
|
|||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull reg.rsxx.ru/rsmon/rsmon-worker:latest
|
IMAGE='reg.rsxx.ru/rsmon/rsmon-worker@sha256:<published-64-character-digest>'
|
||||||
|
docker pull "$IMAGE"
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
--cap-add NET_RAW \
|
--cap-add NET_RAW \
|
||||||
--env-file .env \
|
--env-file .env \
|
||||||
-p 127.0.0.1:27401:27401 \
|
-p 127.0.0.1:27401:27401 \
|
||||||
-v rsmon-worker-data:/var/lib/rsmon-worker \
|
-v rsmon-worker-data:/var/lib/rsmon-worker \
|
||||||
reg.rsxx.ru/rsmon/rsmon-worker:latest
|
"$IMAGE"
|
||||||
```
|
```
|
||||||
|
|
||||||
Published images use these tags:
|
Published images have tags for discovery:
|
||||||
|
|
||||||
- `sha-<12-character-commit>` for every push;
|
- `sha-<12-character-commit>` for every push;
|
||||||
- `latest` for `master`;
|
- `latest` for `master`;
|
||||||
- the `v*` release ref, with Docker-invalid characters replaced by `-`.
|
- the `v*` release ref, with Docker-invalid characters replaced by `-`.
|
||||||
|
|
||||||
|
Resolve a trusted published tag through the registry, then deploy the resulting
|
||||||
|
`repository@sha256:...` digest. Tags are mutable and are not accepted by the
|
||||||
|
installer or deploy command; Compose requires the resolved digest to be
|
||||||
|
configured explicitly.
|
||||||
|
|
||||||
The Gitea workflow reads `HARBOR_REGISTRY`, `HARBOR_USER`, and
|
The Gitea workflow reads `HARBOR_REGISTRY`, `HARBOR_USER`, and
|
||||||
`HARBOR_PASSWORD`. `HARBOR_REGISTRY` may be a host such as `reg.rsxx.ru` or an
|
`HARBOR_PASSWORD`. `HARBOR_REGISTRY` may be a host such as `reg.rsxx.ru` or an
|
||||||
HTTP(S) URL; the workflow strips the scheme and trailing slash before composing
|
HTTP(S) URL; the workflow strips the scheme and trailing slash before composing
|
||||||
@@ -91,17 +99,24 @@ rm worker-token
|
|||||||
mode-0600 configuration at `/etc/rsmon-worker/worker.env`, installs a simple
|
mode-0600 configuration at `/etc/rsmon-worker/worker.env`, installs a simple
|
||||||
root-run systemd unit, and enables and starts it. Use `--url` to override
|
root-run systemd unit, and enables and starts it. Use `--url` to override
|
||||||
`https://rsmon.ru`, `--binary` to install another binary, or `--no-start` to
|
`https://rsmon.ru`, `--binary` to install another binary, or `--no-start` to
|
||||||
configure without starting.
|
configure without starting. When using `--env-file`, installation verifies its
|
||||||
|
`RSMON_URL` and `RSMON_TOKEN` before changing the host. Environment files use
|
||||||
|
portable `KEY=VALUE` lines (plus blank lines and `#` comments); quoting,
|
||||||
|
interpolation, whitespace in values, and YAML-style assignments are rejected
|
||||||
|
because systemd and Docker interpret them differently.
|
||||||
|
|
||||||
The Docker alternative pulls the prebuilt image and installs a systemd unit
|
The Docker alternative pulls the prebuilt image and installs a systemd unit
|
||||||
that runs it:
|
that runs it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./bin/rsmon-worker install --docker --token-file worker-token
|
sudo ./bin/rsmon-worker install --docker --token-file worker-token \
|
||||||
|
--image 'reg.rsxx.ru/rsmon/rsmon-worker@sha256:<published-64-character-digest>'
|
||||||
```
|
```
|
||||||
|
|
||||||
The image defaults to `reg.rsxx.ru/rsmon/rsmon-worker:latest`; override it with
|
Docker installation requires `--image` with an immutable
|
||||||
`--image`.
|
`repository@sha256:<64-lowercase-hex-characters>` reference. This is a breaking
|
||||||
|
change: prior `--docker` invocations that relied on the `latest` default, or
|
||||||
|
passed only a tag, now fail before Docker is invoked or any host file is changed.
|
||||||
|
|
||||||
The token can also be passed as `--token` or `--api-key`, but that can expose it
|
The token can also be passed as `--token` or `--api-key`, but that can expose it
|
||||||
through shell history and process inspection. The legacy repository-based
|
through shell history and process inspection. The legacy repository-based
|
||||||
@@ -141,7 +156,8 @@ sudo access.
|
|||||||
Add `--docker` to install remotely by uploading only the configuration and
|
Add `--docker` to install remotely by uploading only the configuration and
|
||||||
systemd unit, then running `docker pull` on the target. This mode does not upload
|
systemd unit, then running `docker pull` on the target. This mode does not upload
|
||||||
or execute the local worker binary, so the local and remote architectures may
|
or execute the local worker binary, so the local and remote architectures may
|
||||||
differ.
|
differ. It also requires `--image repository@sha256:...`; tag-only references
|
||||||
|
are rejected before connecting to the remote host.
|
||||||
|
|
||||||
The default SSH port is 22 and the default RSMon URL is `https://rsmon.ru`.
|
The default SSH port is 22 and the default RSMon URL is `https://rsmon.ru`.
|
||||||
Encrypted keys use `--key-passphrase-file`; password authentication uses
|
Encrypted keys use `--key-passphrase-file`; password authentication uses
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ func TestTasksForWorker_SkipsLockedAndLeases(t *testing.T) {
|
|||||||
for _, p := range picked {
|
for _, p := range picked {
|
||||||
assert.Equal(t, models.TaskStateLeased, p.State)
|
assert.Equal(t, models.TaskStateLeased, p.State)
|
||||||
assert.Equal(t, w.WorkerID, p.LeaseOwner)
|
assert.Equal(t, w.WorkerID, p.LeaseOwner)
|
||||||
|
assert.NotEmpty(t, p.LeaseToken)
|
||||||
assert.NotNil(t, p.LeaseExpiresAt)
|
assert.NotNil(t, p.LeaseExpiresAt)
|
||||||
assert.Equal(t, 1, p.Attempts)
|
assert.Equal(t, 1, p.Attempts)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hashicorp/raft"
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
|
|
||||||
"rocketgit.ru/rsmon/worker/internal/distworker"
|
"rocketgit.ru/rsmon/worker/internal/distworker"
|
||||||
@@ -33,19 +32,6 @@ var (
|
|||||||
// Phase 1 keeps it on; the flag exists so a Phase 2 basic-auth
|
// Phase 1 keeps it on; the flag exists so a Phase 2 basic-auth
|
||||||
// install can opt out without recompiling.
|
// install can opt out without recompiling.
|
||||||
webappEnabled = true
|
webappEnabled = true
|
||||||
|
|
||||||
// clusterDebugApplyTestConfig, when true, submits the hardcoded
|
|
||||||
// CriticalCheckConfig from workercluster.DefaultDebugCriticalCheck
|
|
||||||
// to the cluster on startup. Wired via the
|
|
||||||
// --cluster-debug-apply-test-config CLI flag; the e2e script
|
|
||||||
// uses this so it can verify FSM replication without the
|
|
||||||
// signed-config-adoption producer (which lands in a later phase).
|
|
||||||
//
|
|
||||||
// DEBUG: this flag is a placeholder. It must be removed (or
|
|
||||||
// guarded behind a build tag) before any production build.
|
|
||||||
//
|
|
||||||
// TODO(phase-N): remove once the real producer is wired.
|
|
||||||
clusterDebugApplyTestConfig = false
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -57,8 +43,6 @@ func main() {
|
|||||||
|
|
||||||
versionFlag := flag.Bool("version", false, "Print version and exit")
|
versionFlag := flag.Bool("version", false, "Print version and exit")
|
||||||
noWeb := flag.Bool("no-web", false, "Disable the local web UI (Phase 1 ships with it on)")
|
noWeb := flag.Bool("no-web", false, "Disable the local web UI (Phase 1 ships with it on)")
|
||||||
debugApplyConfig := flag.Bool("cluster-debug-apply-test-config", false,
|
|
||||||
"Submit a hardcoded CriticalCheckConfig to the cluster on startup. DEBUG: remove once the real config.adopt producer is wired.")
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *versionFlag {
|
if *versionFlag {
|
||||||
@@ -66,7 +50,6 @@ func main() {
|
|||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
webappEnabled = !*noWeb
|
webappEnabled = !*noWeb
|
||||||
clusterDebugApplyTestConfig = *debugApplyConfig
|
|
||||||
|
|
||||||
if len(flag.Args()) > 0 && flag.Arg(0) == "health" {
|
if len(flag.Args()) > 0 && flag.Arg(0) == "health" {
|
||||||
os.Exit(healthCheck())
|
os.Exit(healthCheck())
|
||||||
@@ -295,46 +278,12 @@ func buildCluster(ctx context.Context, cfg *distworker.Config) (*workercluster.C
|
|||||||
log.Printf("worker cluster: started node_id=%s addr=%s bootstrap=%t peers=%d",
|
log.Printf("worker cluster: started node_id=%s addr=%s bootstrap=%t peers=%d",
|
||||||
nodeID, localAddr, bootstrap, len(peers))
|
nodeID, localAddr, bootstrap, len(peers))
|
||||||
|
|
||||||
if clusterDebugApplyTestConfig && bootstrap {
|
|
||||||
// The debug apply only fires on bootstrap nodes; a
|
|
||||||
// joiner cannot commit a log entry until it has been
|
|
||||||
// promoted to voter. Wait for this node to win an
|
|
||||||
// election first (a fresh single-voter cluster elects
|
|
||||||
// itself immediately but the goroutine may run before
|
|
||||||
// the state has flipped).
|
|
||||||
go func() {
|
|
||||||
deadline := time.Now().Add(15 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if c.Raft() != nil && c.Raft().State() == raft.Leader {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
}
|
|
||||||
if c.Raft() == nil || c.Raft().State() != raft.Leader {
|
|
||||||
log.Printf("worker cluster: applying debug test config id=%d: not leader after 15s",
|
|
||||||
workercluster.DefaultDebugCriticalCheck().ID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
check := workercluster.DefaultDebugCriticalCheck()
|
|
||||||
applied, err := c.ApplyTestConfig(&check)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("worker cluster: applying debug test config id=%d: %v",
|
|
||||||
check.ID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("worker cluster: applying debug test config id=%d applied_index=%d",
|
|
||||||
check.ID, applied)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
return c, &clusterAdapter{c: c}, nil
|
return c, &clusterAdapter{c: c}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// clusterAdapter wraps *workercluster.Cluster so it implements the
|
// clusterAdapter wraps *workercluster.Cluster so it implements the
|
||||||
// webapp.ClusterView interface without webapp importing the raft
|
// webapp.ClusterView interface without webapp importing the raft
|
||||||
// code path. The ApplyTestConfig signature is the one webapp expects
|
// code path.
|
||||||
// (no config argument; the cluster subsystem owns the hardcoded
|
|
||||||
// payload so the two sides cannot drift).
|
|
||||||
type clusterAdapter struct {
|
type clusterAdapter struct {
|
||||||
c *workercluster.Cluster
|
c *workercluster.Cluster
|
||||||
}
|
}
|
||||||
@@ -359,11 +308,6 @@ func (a *clusterAdapter) Stats() webapp.ClusterStats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *clusterAdapter) ApplyTestConfig() (uint64, error) {
|
|
||||||
check := workercluster.DefaultDebugCriticalCheck()
|
|
||||||
return a.c.ApplyTestConfig(&check)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *clusterAdapter) ClusterID() string { return a.c.ClusterID() }
|
func (a *clusterAdapter) ClusterID() string { return a.c.ClusterID() }
|
||||||
func (a *clusterAdapter) LocalAddr() string { return a.c.LocalAddr() }
|
func (a *clusterAdapter) LocalAddr() string { return a.c.LocalAddr() }
|
||||||
|
|
||||||
@@ -535,6 +479,10 @@ func (w runnerWrapper) RecentNotifications(n int) []webapp.NotificationRow {
|
|||||||
OK: r.OK,
|
OK: r.OK,
|
||||||
Error: r.Error,
|
Error: r.Error,
|
||||||
At: r.At,
|
At: r.At,
|
||||||
|
JobID: r.JobID,
|
||||||
|
Method: r.Method,
|
||||||
|
Status: r.Status,
|
||||||
|
DurationMs: r.DurationMs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,3 +56,23 @@ func TestProbeLiveness(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemovedClusterDebugApplyTestConfigFlagIsRejected(t *testing.T) {
|
||||||
|
binary := filepath.Join(t.TempDir(), "rsmon-worker")
|
||||||
|
build := exec.Command("go", "build", "-o", binary, ".")
|
||||||
|
if output, err := build.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("build worker binary: %v\n%s", err, output)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := exec.Command(binary, "--cluster-debug-apply-test-config").CombinedOutput()
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if !errors.As(err, &exitErr) {
|
||||||
|
t.Fatalf("removed flag error = %v, want parser exit\n%s", err, output)
|
||||||
|
}
|
||||||
|
if exitErr.ExitCode() != 2 {
|
||||||
|
t.Fatalf("removed flag exit code = %d, want 2\n%s", exitErr.ExitCode(), output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(output), "flag provided but not defined: -cluster-debug-apply-test-config") {
|
||||||
|
t.Fatalf("removed flag output = %q, want undefined-flag error", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func installCommand(args []string) int {
|
|||||||
fs.StringVar(&tokenFile, "token-file", "", "file containing the worker API token")
|
fs.StringVar(&tokenFile, "token-file", "", "file containing the worker API token")
|
||||||
fs.StringVar(&opts.URL, "url", installer.DefaultURL, "RSMon server URL")
|
fs.StringVar(&opts.URL, "url", installer.DefaultURL, "RSMon server URL")
|
||||||
fs.BoolVar(&opts.Docker, "docker", false, "run the prebuilt Docker image instead of the binary")
|
fs.BoolVar(&opts.Docker, "docker", false, "run the prebuilt Docker image instead of the binary")
|
||||||
fs.StringVar(&opts.Image, "image", installer.DefaultImage, "Docker image used with --docker")
|
fs.StringVar(&opts.Image, "image", installer.DefaultImage, "immutable Docker repository@sha256 digest required with --docker")
|
||||||
fs.BoolVar(&opts.NoStart, "no-start", false, "install and enable without starting")
|
fs.BoolVar(&opts.NoStart, "no-start", false, "install and enable without starting")
|
||||||
fs.Usage = func() {
|
fs.Usage = func() {
|
||||||
fmt.Fprintln(fs.Output(), "Usage: rsmon-worker install --token TOKEN [--url URL] [--no-start]")
|
fmt.Fprintln(fs.Output(), "Usage: rsmon-worker install --token TOKEN [--url URL] [--no-start]")
|
||||||
@@ -89,7 +89,7 @@ func deployCommand(args []string) int {
|
|||||||
fs.StringVar(&tokenFile, "token-file", "", "file containing the worker API token")
|
fs.StringVar(&tokenFile, "token-file", "", "file containing the worker API token")
|
||||||
fs.StringVar(&opts.URL, "url", installer.DefaultURL, "RSMon server URL")
|
fs.StringVar(&opts.URL, "url", installer.DefaultURL, "RSMon server URL")
|
||||||
fs.BoolVar(&opts.Docker, "docker", false, "install the prebuilt Docker image remotely")
|
fs.BoolVar(&opts.Docker, "docker", false, "install the prebuilt Docker image remotely")
|
||||||
fs.StringVar(&opts.Image, "image", installer.DefaultImage, "Docker image used with --docker")
|
fs.StringVar(&opts.Image, "image", installer.DefaultImage, "immutable Docker repository@sha256 digest required with --docker")
|
||||||
fs.BoolVar(&opts.NoStart, "no-start", false, "install and enable without starting")
|
fs.BoolVar(&opts.NoStart, "no-start", false, "install and enable without starting")
|
||||||
fs.Usage = func() {
|
fs.Usage = func() {
|
||||||
fmt.Fprintln(fs.Output(), "Usage: rsmon-worker deploy --host HOST --user USER --token TOKEN [SSH options]")
|
fmt.Fprintln(fs.Output(), "Usage: rsmon-worker deploy --host HOST --user USER --token TOKEN [SSH options]")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
worker:
|
worker:
|
||||||
image: ${RSMON_WORKER_IMAGE:-reg.rsxx.ru/rsmon/rsmon-worker:latest}
|
image: "reg.rsxx.ru/rsmon/rsmon-worker@sha256:${RSMON_WORKER_IMAGE_DIGEST:?set RSMON_WORKER_IMAGE_DIGEST to the published 64-character digest}"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|||||||
@@ -11,9 +11,10 @@
|
|||||||
through `known_hosts` or a pinned fingerprint.
|
through `known_hosts` or a pinned fingerprint.
|
||||||
- Added `--token-file`, `--url`, `--api-key`, and `--no-start` deployment
|
- Added `--token-file`, `--url`, `--api-key`, and `--no-start` deployment
|
||||||
options.
|
options.
|
||||||
- Added optional `--docker` deployment using the prebuilt
|
- Added optional `--docker` deployment using a prebuilt image. Docker install
|
||||||
`reg.rsxx.ru/rsmon/rsmon-worker:latest` image. Remote Docker deployment pulls
|
and deploy now require an immutable `repository@sha256:...` reference before
|
||||||
the image on the target host and does not depend on the local architecture.
|
any Docker or remote-host mutation; the former mutable `latest` default is no
|
||||||
|
longer accepted.
|
||||||
- Simplified the default systemd service to `Type=simple`, `User=root`, and
|
- Simplified the default systemd service to `Type=simple`, `User=root`, and
|
||||||
`Restart=on-failure`.
|
`Restart=on-failure`.
|
||||||
- Reworked the legacy `scripts/install-systemd.sh` script as a compatibility
|
- Reworked the legacy `scripts/install-systemd.sh` script as a compatibility
|
||||||
@@ -34,3 +35,27 @@
|
|||||||
execution, and production worker job reporting.
|
execution, and production worker job reporting.
|
||||||
- Published the changes as commit `3256dcd` (`feat: add worker install and
|
- Published the changes as commit `3256dcd` (`feat: add worker install and
|
||||||
deploy`) on `master`, triggering the Docker image workflow.
|
deploy`) on `master`, triggering the Docker image workflow.
|
||||||
|
|
||||||
|
### Task protocol and local audit hardening
|
||||||
|
|
||||||
|
- Required one task-envelope branch, matching outer/inner job IDs, and a
|
||||||
|
non-empty lease token before local execution.
|
||||||
|
- Added structured terminal failures for unsupported check kinds and safely
|
||||||
|
attributable malformed envelopes.
|
||||||
|
- Recorded delegated notification outcomes in the bounded `/notifications`
|
||||||
|
view using only job ID, method, status, duration, and time.
|
||||||
|
- Added static permanent handling for invalid deadlines and recovered
|
||||||
|
notification executor panics without retaining secret-bearing text.
|
||||||
|
- Removed the critical-cluster test-config endpoint, CLI flag, environment
|
||||||
|
switch, and production helper; hardcoded config application is test-only.
|
||||||
|
- Made runner token rotation connection-scoped and in-memory: it reconnects
|
||||||
|
without stopping web, inventory, metrics, or cluster subsystems. Durable token
|
||||||
|
storage, bootstrap exchange, rotation acknowledgement, and revocation remain
|
||||||
|
unimplemented.
|
||||||
|
- Added bounded resend of dequeued check and notification result envelopes after
|
||||||
|
websocket reconnect; control-plane application remains at-least-once and must
|
||||||
|
deduplicate by leased job and lease token. Failed metric snapshots are dropped
|
||||||
|
and replaced by the next periodic tick, not replayed.
|
||||||
|
- SIGTERM stops new dispatch and waits for active work, but stale-lease
|
||||||
|
acknowledgement, bounded graceful final-result drain, and duplicate-frame
|
||||||
|
coverage remain open.
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ branch.
|
|||||||
| Server to worker | diagnostic task | new task-envelope variant | Planned |
|
| Server to worker | diagnostic task | new task-envelope variant | Planned |
|
||||||
|
|
||||||
The legacy top-level `task` and `notification_task` branches are accepted for
|
The legacy top-level `task` and `notification_task` branches are accepted for
|
||||||
rollout compatibility. If a frame includes both a valid current
|
rollout compatibility. A current `task_envelope` must activate exactly one
|
||||||
`task_envelope` and a legacy branch, the worker executes only
|
matching branch with a non-empty, matching inner and outer job ID and lease
|
||||||
`task_envelope`. The current runner ignores malformed or unsupported task
|
token. Unsupported check kinds and attributable malformed envelopes produce
|
||||||
branches and does not yet compare the envelope job ID with the inner job ID;
|
terminal reports; malformed envelopes without a usable identity are rejected
|
||||||
strict rejection and reporting are P1 work below.
|
without execution or reporting.
|
||||||
|
|
||||||
## Initialization And Refresh
|
## Initialization And Refresh
|
||||||
|
|
||||||
@@ -86,9 +86,10 @@ selection remains the primary isolation boundary.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`type=notification` activates `notification` instead. Job IDs in the envelope
|
`type=notification` activates `notification` instead. The worker enforces the
|
||||||
and active branch must match once P1 validation lands. Today a task without a
|
matching job IDs and non-empty lease token before dispatch. A task without one
|
||||||
recognized populated branch is ignored without execution.
|
recognized populated branch is rejected without execution; it is reported only
|
||||||
|
when the active branch provides attributable job and lease identity.
|
||||||
|
|
||||||
## Result Invariants
|
## Result Invariants
|
||||||
|
|
||||||
@@ -117,20 +118,26 @@ Files:
|
|||||||
- `internal/wire/types_test.go`
|
- `internal/wire/types_test.go`
|
||||||
- `internal/distworker/runner_protocol_test.go`
|
- `internal/distworker/runner_protocol_test.go`
|
||||||
|
|
||||||
Tests:
|
Implemented:
|
||||||
|
|
||||||
- decode every current frame branch;
|
- strict single-branch envelope selection with matching job-ID and lease
|
||||||
- prefer the current envelope over duplicate legacy branches;
|
validation;
|
||||||
- reject mismatched envelope and inner job IDs;
|
- unsupported-kind and attributable malformed-task terminal reporting;
|
||||||
- preserve unknown optional fields during compatible rollout;
|
- atomic config and credential replacement.
|
||||||
- reject missing lease tokens before execution result submission;
|
|
||||||
- atomically replace config and credentials.
|
Still required:
|
||||||
|
|
||||||
|
- decode conformance coverage for every current frame branch and unknown
|
||||||
|
optional fields;
|
||||||
|
- explicit drain behavior and stale-lease acknowledgement coverage.
|
||||||
|
|
||||||
### P2: Safe Token Rotation
|
### P2: Safe Token Rotation
|
||||||
|
|
||||||
Current `Runner.RotateToken` closes the runner stop channel. Replace this with a
|
`Runner.RotateToken` replaces the token in memory, closes only the active
|
||||||
connection-scoped cancellation path that stores the new token, closes only the
|
control-plane WebSocket, and reconnects without terminating worker services.
|
||||||
active WebSocket, and reconnects without terminating worker services.
|
The replacement is not persisted: a process restart still uses its configured
|
||||||
|
startup token. The control plane must tolerate connection-scoped result resend
|
||||||
|
using the leased job and lease token for idempotency.
|
||||||
|
|
||||||
Acceptance: rotating from the web console produces a reconnect using the new
|
Acceptance: rotating from the web console produces a reconnect using the new
|
||||||
token while the HTTP listener, collectors, and optional cluster stay running.
|
token while the HTTP listener, collectors, and optional cluster stay running.
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ It does not yet execute `distributed_critical` checks, verify signed config,
|
|||||||
evaluate observation/region/notification quorum, encrypt snapshots, deliver a
|
evaluate observation/region/notification quorum, encrypt snapshots, deliver a
|
||||||
commit-backed outbox, or consume an external witness report.
|
commit-backed outbox, or consume an external witness report.
|
||||||
|
|
||||||
The hardcoded test-config endpoint and startup flag are development-only and
|
The former hardcoded test-config endpoint, startup flag, environment switch,
|
||||||
must stay disabled in production.
|
and production helper have been removed. Hardcoded config application now
|
||||||
|
exists only as an unexported `_test.go` helper and is absent from production
|
||||||
|
builds.
|
||||||
|
|
||||||
## Non-Negotiable Separation
|
## Non-Negotiable Separation
|
||||||
|
|
||||||
@@ -163,16 +165,16 @@ but cannot commit or fabricate incidents.
|
|||||||
|
|
||||||
## Ordered Implementation
|
## Ordered Implementation
|
||||||
|
|
||||||
1. Remove production exposure of debug config application.
|
1. [x] Remove production exposure of debug config application.
|
||||||
2. Complete versioned FSM types, command validation, and deterministic tests.
|
2. [ ] Complete versioned FSM types, command validation, and deterministic tests.
|
||||||
3. Add mTLS identity and safe one-claim cluster bootstrap.
|
3. [ ] Add mTLS identity and safe one-claim cluster bootstrap.
|
||||||
4. Add signed config and observer-set adoption.
|
4. [ ] Add signed config and observer-set adoption.
|
||||||
5. Add deterministic scheduler and checkexec bridge in shadow mode.
|
5. [ ] Add deterministic scheduler and checkexec bridge in shadow mode.
|
||||||
6. Implement observation aggregation, incident policy, and idempotency.
|
6. [ ] Implement observation aggregation, incident policy, and idempotency.
|
||||||
7. Implement encrypted snapshots and restore/migration tests.
|
7. [ ] Implement encrypted snapshots and restore/migration tests.
|
||||||
8. Add metadata outbox executor and failover-safe delivery.
|
8. [ ] Add metadata outbox executor and failover-safe delivery.
|
||||||
9. Add external witness, replay, metrics, and operational runbooks.
|
9. [ ] Add external witness, replay, metrics, and operational runbooks.
|
||||||
10. Run synthetic 3/5-node fault campaigns before any customer check.
|
10. [ ] Run synthetic 3/5-node fault campaigns before any customer check.
|
||||||
|
|
||||||
## Release Gates
|
## Release Gates
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,15 @@ Worker repository:
|
|||||||
- wire local inventory and metrics collector lifecycle into web server start
|
- wire local inventory and metrics collector lifecycle into web server start
|
||||||
and shutdown;
|
and shutdown;
|
||||||
- validate full HTTP config, including `WORKER_URL`, in main startup;
|
- validate full HTTP config, including `WORKER_URL`, in main startup;
|
||||||
- make token rotation reconnect rather than stop the runner;
|
- [x] reconnect in memory on token rotation without stopping the runner;
|
||||||
|
- [x] resend bounded check/notification results after websocket reconnect;
|
||||||
- define process policy when the web listener exits unexpectedly;
|
- define process policy when the web listener exits unexpectedly;
|
||||||
- add WebSocket reconnect, drain, duplicate frame, and backpressure tests.
|
- add process-exit, bounded final-drain, duplicate-frame, and backpressure
|
||||||
|
coverage.
|
||||||
|
|
||||||
Gate: collectors populate real pages, rotation preserves all subsystems, and
|
Gate: collectors populate real pages, rotation preserves all subsystems, result
|
||||||
SIGTERM leaves no listener, collector, task, or SQLite goroutine behind.
|
resend remains bounded and idempotent, and SIGTERM leaves no listener,
|
||||||
|
collector, task, or SQLite goroutine behind.
|
||||||
|
|
||||||
## R2: Protocol And Credential Hardening
|
## R2: Protocol And Credential Hardening
|
||||||
|
|
||||||
|
|||||||
@@ -55,19 +55,22 @@ Bootstrap replay, expiry, worker-ID mismatch, or account mismatch fails closed.
|
|||||||
|
|
||||||
## Token Rotation
|
## Token Rotation
|
||||||
|
|
||||||
Rotation is a two-token handoff:
|
The current worker receives a replacement token in memory. In-memory reconnect
|
||||||
|
rotation is implemented: the control plane
|
||||||
|
invalidates the old token immediately, then `Runner.RotateToken` closes only
|
||||||
|
the active control-plane connection and reconnects with the replacement token.
|
||||||
|
It does not stop the runner or its web, inventory, metrics, or cluster
|
||||||
|
subsystems. There is no durable token write, overlap, rollback, or rotation
|
||||||
|
acknowledgement contract yet; persistent replacement-token failures can require
|
||||||
|
operator action.
|
||||||
|
|
||||||
1. Control plane issues a replacement token and keeps the old token valid for a
|
If a websocket write fails after the worker has dequeued a check or notification
|
||||||
short bounded overlap.
|
result, it resends that envelope after reconnect. Delivery is therefore
|
||||||
2. Worker atomically writes the replacement secret.
|
at-least-once; the control plane must deduplicate by the leased job and lease
|
||||||
3. Worker closes only the active control-plane connection and reconnects.
|
token before applying a resent result. Failed server-metric snapshots are
|
||||||
4. Successful authentication acknowledges rotation; control plane revokes the
|
dropped because they have no idempotency key and the next periodic collection
|
||||||
old token.
|
replaces them. Metric snapshots are bound to the active control connection;
|
||||||
5. Failure retains the old token until overlap expires and emits an operator
|
snapshots collected while disconnected or for an older connection are dropped.
|
||||||
warning.
|
|
||||||
|
|
||||||
The current `Runner.RotateToken` terminates the runner by closing its stop
|
|
||||||
channel. This must be fixed before claiming unattended rotation.
|
|
||||||
|
|
||||||
## Runtime Config Authentication
|
## Runtime Config Authentication
|
||||||
|
|
||||||
@@ -126,7 +129,8 @@ The standalone binary provides two systemd installation paths:
|
|||||||
installs a systemd-managed prebuilt image instead;
|
installs a systemd-managed prebuilt image instead;
|
||||||
- `rsmon-worker deploy` verifies an SSH host key, uploads the binary and a
|
- `rsmon-worker deploy` verifies an SSH host key, uploads the binary and a
|
||||||
temporary mode-0600 environment file, and invokes `install` remotely;
|
temporary mode-0600 environment file, and invokes `install` remotely;
|
||||||
`--docker` uploads only the environment and unit, then pulls on the target.
|
`--docker` uploads only the environment and unit, then pulls an explicitly
|
||||||
|
supplied immutable image digest on the target.
|
||||||
|
|
||||||
Both default to `https://rsmon.ru` and accept `--token-file` for automation.
|
Both default to `https://rsmon.ru` and accept `--token-file` for automation.
|
||||||
Direct secret flags are supported but can be visible in process listings; file
|
Direct secret flags are supported but can be visible in process listings; file
|
||||||
@@ -143,22 +147,25 @@ monitor execution.
|
|||||||
|
|
||||||
## Implementation Work Packages
|
## Implementation Work Packages
|
||||||
|
|
||||||
1. Add signed account/config identity to `internal/wire` and runner state.
|
1. [ ] Add signed account/config identity to `internal/wire` and runner state.
|
||||||
2. Validate task account and credential scope locally before dispatch.
|
2. [ ] Validate task account and credential scope locally before dispatch.
|
||||||
3. Implement one-time bootstrap and atomic token storage/rotation.
|
3. [x] Implement in-memory reconnect token rotation without stopping worker
|
||||||
4. Add worker disable/revoke behavior and visible stale-config state.
|
subsystems.
|
||||||
5. Add mTLS as an optional first transport, then require it for Raft clusters.
|
4. [ ] Implement one-time bootstrap, durable token storage, and rotation
|
||||||
6. Add public-task grant and SSRF-safe executor only after private isolation is
|
acknowledgement.
|
||||||
|
5. [ ] Add worker disable/revoke behavior and visible stale-config state.
|
||||||
|
6. [ ] Add mTLS as an optional first transport, then require it for Raft clusters.
|
||||||
|
7. [ ] Add public-task grant and SSRF-safe executor only after private isolation is
|
||||||
proven.
|
proven.
|
||||||
|
|
||||||
## Acceptance Tests
|
## Acceptance Tests
|
||||||
|
|
||||||
- A private worker never leases or executes another account's normal task.
|
- [ ] A private worker never leases or executes another account's normal task.
|
||||||
- A forged capability or account field cannot widen scope.
|
- [ ] A forged capability or account field cannot widen scope.
|
||||||
- Invalid, expired, downgraded, or differently scoped signed config is rejected.
|
- [ ] Invalid, expired, downgraded, or differently scoped signed config is rejected.
|
||||||
- Bootstrap tokens are single-use and absent from disk after exchange.
|
- [ ] Bootstrap tokens are single-use and absent from disk after exchange.
|
||||||
- Rotation reconnects without stopping web, inventory, metrics, or cluster.
|
- [x] Rotation reconnects without stopping web, inventory, metrics, or cluster.
|
||||||
- Credential snapshots contain only the worker account and allowed methods.
|
- [ ] Credential snapshots contain only the worker account and allowed methods.
|
||||||
- Public execution cannot reach loopback, link-local, RFC1918, metadata, Unix
|
- [ ] Public execution cannot reach loopback, link-local, RFC1918, metadata, Unix
|
||||||
sockets, or a private redirect target.
|
sockets, or a private redirect target.
|
||||||
- Revocation prevents reconnect and clears in-memory credentials.
|
- [ ] Revocation prevents reconnect and clears in-memory credentials.
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ default and offer the prebuilt Docker image as an explicit alternative.
|
|||||||
|
|
||||||
- The default service uses `Type=simple`, runs as `root`, and restarts only on
|
- The default service uses `Type=simple`, runs as `root`, and restarts only on
|
||||||
failure.
|
failure.
|
||||||
- Docker deployment pulls `reg.rsxx.ru/rsmon/rsmon-worker:latest` on the target
|
- Docker deployment pulls an explicitly selected immutable image digest on the
|
||||||
host instead of uploading a local image or binary.
|
target host instead of uploading a local image or binary. The original
|
||||||
|
mutable `latest` default was superseded by a supply-chain hardening change.
|
||||||
- The existing source-available license remains unchanged until public-release
|
- The existing source-available license remains unchanged until public-release
|
||||||
licensing is decided.
|
licensing is decided.
|
||||||
- Worker credentials are supplied at deployment time and are not stored in the
|
- Worker credentials are supplied at deployment time and are not stored in the
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ The implemented path is:
|
|||||||
|
|
||||||
Implemented check kinds are HTTP, SSL, SSH, FTP, DNS, WHOIS, BSSL, LLM,
|
Implemented check kinds are HTTP, SSL, SSH, FTP, DNS, WHOIS, BSSL, LLM,
|
||||||
LLM-HTTP, ping, TCP, and UDP. Control-plane selection uses capabilities. The
|
LLM-HTTP, ping, TCP, and UDP. Control-plane selection uses capabilities. The
|
||||||
local dispatcher supports only its explicit switch cases, but strict malformed
|
runner and local executor share one supported-kind registry. Websocket task
|
||||||
envelope and unsupported-kind result handling remains work package N1 rather
|
envelopes require exactly one payload branch, matching non-empty outer/inner
|
||||||
than a complete fail-closed protocol response.
|
job IDs, and a lease token. Unsupported checks return one terminal failed
|
||||||
|
result without execution. A safely attributable malformed single branch
|
||||||
|
returns a protocol failure; ambiguous and mismatched envelopes fail closed.
|
||||||
|
|
||||||
## Queue And Shutdown Rules
|
## Queue And Shutdown Rules
|
||||||
|
|
||||||
@@ -29,8 +31,9 @@ than a complete fail-closed protocol response.
|
|||||||
maximum.
|
maximum.
|
||||||
- A task panic is recovered at the task boundary and reported as a failed
|
- A task panic is recovered at the task boundary and reported as a failed
|
||||||
attempt; it must not kill the runner.
|
attempt; it must not kill the runner.
|
||||||
- SIGTERM stops accepting tasks, allows bounded in-flight completion, attempts
|
- SIGTERM stops accepting new tasks and waits for active dispatchers before the
|
||||||
final result delivery, and then exits.
|
execution pool closes. A bounded final-result drain across websocket shutdown
|
||||||
|
is not implemented yet.
|
||||||
- WebSocket reconnect does not re-run an in-flight or completed task.
|
- WebSocket reconnect does not re-run an in-flight or completed task.
|
||||||
- HTTP polling remains compatibility-only and must not become a second normal
|
- HTTP polling remains compatibility-only and must not become a second normal
|
||||||
scheduler.
|
scheduler.
|
||||||
@@ -83,26 +86,27 @@ unreachable. They use system contacts and credentials from config, local
|
|||||||
jitter, and local deduplication. They must not impersonate customer delivery or
|
jitter, and local deduplication. They must not impersonate customer delivery or
|
||||||
mutate control-plane message state.
|
mutate control-plane message state.
|
||||||
|
|
||||||
The local notifications page currently records selfcheck deliveries. Normal
|
The local notifications page records both selfcheck and delegated delivery
|
||||||
delegated delivery attempts should be added to the same bounded view after
|
attempts. Delegated rows contain job ID, method, status, duration, and time
|
||||||
redaction, with job ID, method, status, duration, and time only.
|
only. Recipient, body, credentials, hook URL, authorization, provider response,
|
||||||
|
and error text are never copied into the bounded ring. Invalid deadlines and
|
||||||
|
recovered executor panics produce static permanent results and one redacted
|
||||||
|
local row.
|
||||||
|
|
||||||
## Implementation Work Packages
|
## Implementation Work Packages
|
||||||
|
|
||||||
### N1: Runner Integration Coverage
|
### N1: Runner Integration Coverage (partial)
|
||||||
|
|
||||||
Add strict envelope/inner job-ID validation and structured unsupported-kind
|
Strict envelope/inner job-ID/lease validation and structured unsupported-kind
|
||||||
results. Add WebSocket integration tests for reconnect, task panic recovery,
|
results are implemented with runner-boundary tests. Live WebSocket reconnect
|
||||||
queue backpressure, result resend, stale lease behavior, malformed envelopes,
|
and result-resend tests are implemented. Stale-lease acknowledgement and
|
||||||
unsupported kinds, and graceful drain. Target
|
bounded graceful final-drain tests remain.
|
||||||
`internal/distworker/runner_protocol_test.go` and a local test WebSocket server.
|
|
||||||
|
|
||||||
### N2: Complete Local Delivery Audit
|
### N2: Complete Local Delivery Audit (implemented)
|
||||||
|
|
||||||
Record delegated notification outcomes in the bounded local notification ring.
|
Delegated notification outcomes are recorded in the bounded local notification
|
||||||
Never store recipient values, rendered body, credentials, or full provider
|
ring. `/notifications` renders selfcheck and delegated attempts without storing
|
||||||
responses in SQLite. Verify `/notifications` shows both selfcheck and delegated
|
recipient values, rendered body, credentials, provider responses, or errors.
|
||||||
attempts without leaking secrets.
|
|
||||||
|
|
||||||
### N3: Per-Account Webhook And Mattermost Credentials
|
### N3: Per-Account Webhook And Mattermost Credentials
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ type ExecutedCheck struct {
|
|||||||
Metrics []wire.MetricPoint
|
Metrics []wire.MetricPoint
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SupportsKind reports whether Execute has a local executor for kind.
|
||||||
|
func SupportsKind(kind string) bool {
|
||||||
|
switch kind {
|
||||||
|
case "http", "ssl", "ssh", "ftp", "dns", "whois", "bssl", "llm", "llm-http", "ping", "tcp", "udp":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Execute runs checks without saving to DB or InfluxDB.
|
// Execute runs checks without saving to DB or InfluxDB.
|
||||||
// Results are returned for reporting via API to the control plane.
|
// Results are returned for reporting via API to the control plane.
|
||||||
// This is designed for distributed workers that have no direct DB access.
|
// This is designed for distributed workers that have no direct DB access.
|
||||||
@@ -35,6 +45,9 @@ func Execute(m *models.Monitor, checks []models.Check) []ExecutedCheck {
|
|||||||
for i := range checks {
|
for i := range checks {
|
||||||
c := &checks[i]
|
c := &checks[i]
|
||||||
c.Monitor = m
|
c.Monitor = m
|
||||||
|
if !SupportsKind(c.Kind) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
switch c.Kind {
|
switch c.Kind {
|
||||||
case "http":
|
case "http":
|
||||||
r := chttp.Perform(c)
|
r := chttp.Perform(c)
|
||||||
|
|||||||
16
internal/checkexec/exec_test.go
Обычный файл
16
internal/checkexec/exec_test.go
Обычный файл
@@ -0,0 +1,16 @@
|
|||||||
|
package checkexec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSupportsKindMatchesExecuteDispatch(t *testing.T) {
|
||||||
|
for _, kind := range []string{"http", "ssl", "ssh", "ftp", "dns", "whois", "bssl", "llm", "llm-http", "ping", "tcp", "udp"} {
|
||||||
|
assert.Truef(t, SupportsKind(kind), "kind %q", kind)
|
||||||
|
}
|
||||||
|
for _, kind := range []string{"", "rkn", "smtp"} {
|
||||||
|
assert.Falsef(t, SupportsKind(kind), "kind %q", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,15 @@ package distworker
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
@@ -49,12 +52,16 @@ func NewClient(endpoint, authToken string) *Client {
|
|||||||
|
|
||||||
// postJSON is a helper for sending JSON POST requests
|
// postJSON is a helper for sending JSON POST requests
|
||||||
func (c *Client) postJSON(path string, payload interface{}) (*http.Response, error) {
|
func (c *Client) postJSON(path string, payload interface{}) (*http.Response, error) {
|
||||||
|
return c.postJSONContext(context.Background(), path, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) postJSONContext(ctx context.Context, path string, payload interface{}) (*http.Response, error) {
|
||||||
body, err := json.Marshal(payload)
|
body, err := json.Marshal(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
httpReq, err := http.NewRequest("POST", c.endpoint+path, bytes.NewReader(body))
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.endpoint+path, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -91,10 +98,10 @@ func (c *Client) Heartbeat(req wire.HeartbeatRequest) error {
|
|||||||
// bearer token for this worker. The current bearer is used for
|
// bearer token for this worker. The current bearer is used for
|
||||||
// authentication; the response carries the freshly issued token.
|
// authentication; the response carries the freshly issued token.
|
||||||
//
|
//
|
||||||
// Returns the new token string. The main app invalidates the old
|
// Returns the new token string. The main app invalidates the old token
|
||||||
// token immediately.
|
// immediately.
|
||||||
func (c *Client) RotateToken() (string, error) {
|
func (c *Client) RotateToken(ctx context.Context) (string, error) {
|
||||||
resp, err := c.postJSON("/api/internal/workers/rotate-token", struct{}{})
|
resp, err := c.postJSONContext(ctx, "/api/internal/workers/rotate-token", struct{}{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -156,6 +163,12 @@ func (c *Client) ReportResults(req wire.ResultsRequest) error {
|
|||||||
|
|
||||||
// WorkerSocket connects to the websocket task channel.
|
// WorkerSocket connects to the websocket task channel.
|
||||||
func (c *Client) WorkerSocket() (*websocket.Conn, error) {
|
func (c *Client) WorkerSocket() (*websocket.Conn, error) {
|
||||||
|
return c.WorkerSocketContext(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkerSocketContext connects to the websocket task channel, cancelling the
|
||||||
|
// dial when the caller's context ends.
|
||||||
|
func (c *Client) WorkerSocketContext(ctx context.Context) (*websocket.Conn, error) {
|
||||||
endpoint := strings.TrimRight(c.endpoint, "/")
|
endpoint := strings.TrimRight(c.endpoint, "/")
|
||||||
wsURL := endpoint
|
wsURL := endpoint
|
||||||
if !strings.HasSuffix(wsURL, "/worker") && !strings.HasSuffix(wsURL, "/api/worker") {
|
if !strings.HasSuffix(wsURL, "/worker") && !strings.HasSuffix(wsURL, "/api/worker") {
|
||||||
@@ -175,7 +188,33 @@ func (c *Client) WorkerSocket() (*websocket.Conn, error) {
|
|||||||
q.Set("token", c.authToken)
|
q.Set("token", c.authToken)
|
||||||
u.RawQuery = q.Encode()
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
conn, resp, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
dialer := *websocket.DefaultDialer
|
||||||
|
var (
|
||||||
|
connMu sync.Mutex
|
||||||
|
dialConn net.Conn
|
||||||
|
)
|
||||||
|
stopClose := context.AfterFunc(ctx, func() {
|
||||||
|
connMu.Lock()
|
||||||
|
if dialConn != nil {
|
||||||
|
_ = dialConn.Close()
|
||||||
|
}
|
||||||
|
connMu.Unlock()
|
||||||
|
})
|
||||||
|
defer stopClose()
|
||||||
|
dialer.NetDialContext = func(dialCtx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
conn, err := (&net.Dialer{}).DialContext(dialCtx, network, address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
connMu.Lock()
|
||||||
|
dialConn = conn
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
connMu.Unlock()
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
conn, resp, err := dialer.DialContext(ctx, u.String(), nil)
|
||||||
if err != nil && resp != nil {
|
if err != nil && resp != nil {
|
||||||
defer resp.Body.Close() //nolint:errcheck
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|||||||
@@ -25,14 +25,27 @@ import (
|
|||||||
// silently dropping it as the production plan forbids).
|
// silently dropping it as the production plan forbids).
|
||||||
//
|
//
|
||||||
//nolint:gocritic // task model is shared with the rest of the dispatcher; keep by-value
|
//nolint:gocritic // task model is shared with the rest of the dispatcher; keep by-value
|
||||||
func (r *Runner) ExecuteNotification(ctx context.Context, task models.Task) wire.NotificationResultReport {
|
func (r *Runner) ExecuteNotification(ctx context.Context, task models.Task) (report wire.NotificationResultReport) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
method := ""
|
||||||
report := wire.NotificationResultReport{
|
report = wire.NotificationResultReport{
|
||||||
JobID: task.JobID,
|
JobID: task.JobID,
|
||||||
Status: wire.NotificationResultPermanent,
|
Status: wire.NotificationResultPermanent,
|
||||||
DurationMs: 0,
|
DurationMs: 0,
|
||||||
}
|
}
|
||||||
|
defer func() {
|
||||||
|
if recover() != nil {
|
||||||
|
report.Status = wire.NotificationResultPermanent
|
||||||
|
report.ProviderResponse = nil
|
||||||
|
report.RetryAfterSeconds = nil
|
||||||
|
report.Error = stringPtr("notification executor panic")
|
||||||
|
}
|
||||||
|
report.DurationMs = int(time.Since(start) / time.Millisecond)
|
||||||
|
r.recordDelegatedNotification(report.JobID, method, report)
|
||||||
|
}()
|
||||||
|
if r.notificationExecutor != nil {
|
||||||
|
return r.notificationExecutor(ctx, task)
|
||||||
|
}
|
||||||
|
|
||||||
if len(task.Payload) == 0 {
|
if len(task.Payload) == 0 {
|
||||||
report.Status = wire.NotificationResultPermanent
|
report.Status = wire.NotificationResultPermanent
|
||||||
@@ -50,6 +63,7 @@ func (r *Runner) ExecuteNotification(ctx context.Context, task models.Task) wire
|
|||||||
if nt.JobID == "" {
|
if nt.JobID == "" {
|
||||||
nt.JobID = task.JobID
|
nt.JobID = task.JobID
|
||||||
}
|
}
|
||||||
|
method = nt.Method
|
||||||
if nt.MessageID == 0 && task.MessageID != nil {
|
if nt.MessageID == 0 && task.MessageID != nil {
|
||||||
nt.MessageID = *task.MessageID
|
nt.MessageID = *task.MessageID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package distworker
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -228,6 +231,177 @@ func TestExecuteNotification_ReportsJobIDFromPayload(t *testing.T) {
|
|||||||
assert.Equal(t, "outer-job", report.JobID)
|
assert.Equal(t, "outer-job", report.JobID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDelegatedNotificationAuditCoexistsWithSelfcheckWithoutSecrets(t *testing.T) {
|
||||||
|
const (
|
||||||
|
recipient = "ops-private@example.com"
|
||||||
|
renderedBody = "private rendered body"
|
||||||
|
credentialSecret = "smtp-password-secret"
|
||||||
|
hookURL = "https://hooks.example.com/private"
|
||||||
|
authorization = "Bearer private-authorization"
|
||||||
|
providerResponse = "private provider response"
|
||||||
|
)
|
||||||
|
r := runnerWithCreds(&wire.NotificationCredentials{
|
||||||
|
SMTP: []wire.SMTPCredential{{Password: credentialSecret}},
|
||||||
|
})
|
||||||
|
r.notifyResults = make(chan notifyResultEnvelope, 1)
|
||||||
|
r.RecordNotification(&NotificationRow{
|
||||||
|
Kind: "email", Channel: notificationChannelSMTP, Subject: "selfcheck", Body: "main API down", OK: true,
|
||||||
|
})
|
||||||
|
r.executeAndForwardNotification(wire.NotificationTask{
|
||||||
|
JobID: "delegated-job", Method: "sms", Subject: authorization,
|
||||||
|
BodyText: renderedBody, BodyHTML: providerResponse,
|
||||||
|
Contact: wire.NotificationContact{Kind: "sms", Value: recipient, Name: hookURL},
|
||||||
|
})
|
||||||
|
|
||||||
|
rows := r.RecentNotifications(10)
|
||||||
|
require.Len(t, rows, 2)
|
||||||
|
assert.Equal(t, "selfcheck", rows[0].Subject)
|
||||||
|
delegated := rows[1]
|
||||||
|
assert.Equal(t, "delegated-job", delegated.JobID)
|
||||||
|
assert.Equal(t, "sms", delegated.Method)
|
||||||
|
assert.Equal(t, wire.NotificationResultPermanent, delegated.Status)
|
||||||
|
assert.Empty(t, delegated.Subject)
|
||||||
|
assert.Empty(t, delegated.Body)
|
||||||
|
assert.Empty(t, delegated.Error)
|
||||||
|
assert.False(t, delegated.At.IsZero())
|
||||||
|
|
||||||
|
stored, err := json.Marshal(delegated)
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, secret := range []string{recipient, renderedBody, credentialSecret, hookURL, authorization, providerResponse} {
|
||||||
|
assert.NotContains(t, string(stored), secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelegatedNotificationAuditRecordsOneTerminalRow(t *testing.T) {
|
||||||
|
const secret = "private-notification-secret"
|
||||||
|
validTask := func(jobID string) wire.NotificationTask {
|
||||||
|
return wire.NotificationTask{
|
||||||
|
JobID: jobID, LeaseToken: "lease", Method: "sms", Subject: secret, BodyText: secret,
|
||||||
|
Contact: wire.NotificationContact{Value: secret, Name: secret},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
invoke func(*Runner)
|
||||||
|
method string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "normal result",
|
||||||
|
invoke: func(r *Runner) { r.executeAndForwardNotification(validTask("normal")) },
|
||||||
|
method: "sms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid deadline",
|
||||||
|
invoke: func(r *Runner) {
|
||||||
|
task := validTask("invalid-deadline")
|
||||||
|
deadline := "not-a-timestamp"
|
||||||
|
task.Deadline = &deadline
|
||||||
|
r.executeAndForwardNotification(task)
|
||||||
|
},
|
||||||
|
method: "sms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expired deadline",
|
||||||
|
invoke: func(r *Runner) {
|
||||||
|
task := validTask("expired-deadline")
|
||||||
|
deadline := time.Now().Add(-time.Second).Format(time.RFC3339Nano)
|
||||||
|
task.Deadline = &deadline
|
||||||
|
r.executeAndForwardNotification(task)
|
||||||
|
},
|
||||||
|
method: "sms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed payload",
|
||||||
|
invoke: func(r *Runner) {
|
||||||
|
r.ExecuteNotification(context.Background(), models.Task{JobID: "malformed-payload", Payload: []byte(`{"subject":"` + secret)})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "executor panic",
|
||||||
|
invoke: func(r *Runner) {
|
||||||
|
r.notificationExecutor = func(context.Context, models.Task) wire.NotificationResultReport {
|
||||||
|
panic(secret)
|
||||||
|
}
|
||||||
|
r.ExecuteNotification(context.Background(), models.Task{JobID: "panic", Payload: []byte(`{}`)})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed envelope",
|
||||||
|
invoke: func(r *Runner) {
|
||||||
|
r.enqueueTaskMessage(wire.WorkerMessage{TaskEnvelope: &wire.TaskEnvelope{
|
||||||
|
Type: "invalid", JobID: "bad-envelope", Notify: ptrNotificationTask(validTask("bad-envelope")),
|
||||||
|
}})
|
||||||
|
},
|
||||||
|
method: "sms",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
r := runnerWithCreds(&wire.NotificationCredentials{})
|
||||||
|
r.notifyResults = make(chan notifyResultEnvelope, 1)
|
||||||
|
tc.invoke(r)
|
||||||
|
|
||||||
|
rows := r.RecentNotifications(10)
|
||||||
|
require.Len(t, rows, 1)
|
||||||
|
assert.Equal(t, wire.NotificationResultPermanent, rows[0].Status)
|
||||||
|
assert.Equal(t, tc.method, rows[0].Method)
|
||||||
|
stored, err := json.Marshal(rows[0])
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotContains(t, string(stored), secret)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelegatedNotificationAuditConcurrentRowsAreSecretFree(t *testing.T) {
|
||||||
|
const attempts = 32
|
||||||
|
const secret = "concurrent-private-secret"
|
||||||
|
r := runnerWithCreds(&wire.NotificationCredentials{})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < attempts; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
r.ExecuteNotification(context.Background(), models.Task{
|
||||||
|
JobID: fmt.Sprintf("job-%d", i),
|
||||||
|
Payload: []byte(`{"job_id":"job-` + fmt.Sprint(i) + `","method":"sms","body_text":"` + secret + `","contact":{"value":"` + secret + `"}}`),
|
||||||
|
})
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
rows := r.RecentNotifications(attempts)
|
||||||
|
require.Len(t, rows, attempts)
|
||||||
|
for _, row := range rows {
|
||||||
|
stored, err := json.Marshal(row)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotContains(t, string(stored), secret)
|
||||||
|
assert.Equal(t, wire.NotificationResultPermanent, row.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidNotificationDeadlineIsNotExecuted(t *testing.T) {
|
||||||
|
r := runnerWithCreds(&wire.NotificationCredentials{})
|
||||||
|
r.notifyResults = make(chan notifyResultEnvelope, 1)
|
||||||
|
called := false
|
||||||
|
r.notificationExecutor = func(context.Context, models.Task) wire.NotificationResultReport {
|
||||||
|
called = true
|
||||||
|
return wire.NotificationResultReport{Status: wire.NotificationResultDelivered}
|
||||||
|
}
|
||||||
|
deadline := "not-a-timestamp"
|
||||||
|
r.executeAndForwardNotification(wire.NotificationTask{
|
||||||
|
JobID: "invalid-deadline", LeaseToken: "lease", Method: "sms", Deadline: &deadline,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.False(t, called)
|
||||||
|
env := <-r.notifyResults
|
||||||
|
assert.Equal(t, wire.NotificationResultPermanent, env.report.Status)
|
||||||
|
require.NotNil(t, env.report.Error)
|
||||||
|
assert.Equal(t, "invalid notification deadline", *env.report.Error)
|
||||||
|
require.Len(t, r.RecentNotifications(10), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrNotificationTask(task wire.NotificationTask) *wire.NotificationTask { return &task }
|
||||||
|
|
||||||
// guard against time import being unused if the above compile-time helpers
|
// guard against time import being unused if the above compile-time helpers
|
||||||
// are dropped in a future refactor.
|
// are dropped in a future refactor.
|
||||||
var _ = time.Second
|
var _ = time.Second
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ import (
|
|||||||
const recentResultsSize = 200
|
const recentResultsSize = 200
|
||||||
|
|
||||||
// recentNotificationsSize mirrors recentResultsSize for emitted
|
// recentNotificationsSize mirrors recentResultsSize for emitted
|
||||||
// notifications. Phase 1 only writes selfcheck alerts to this
|
// notifications.
|
||||||
// buffer (the main app's notification flow still lives in the main
|
|
||||||
// app); the buffer is shape-stable so future phases can append
|
|
||||||
// without changing the page contract.
|
|
||||||
const recentNotificationsSize = 100
|
const recentNotificationsSize = 100
|
||||||
|
|
||||||
// ResultRow is one row from the worker's in-memory result ring
|
// ResultRow is one row from the worker's in-memory result ring
|
||||||
@@ -35,9 +32,9 @@ type ResultRow struct {
|
|||||||
At time.Time
|
At time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotificationRow is one row from the worker's notification ring
|
// NotificationRow is one row from the worker's notification ring buffer.
|
||||||
// buffer. Phase 1 only fills this from selfcheck alerts; the row
|
// Delegated rows use only JobID, Method, Status, DurationMs, and At. They
|
||||||
// shape is forward-compatible with main-app-issued notifications.
|
// deliberately omit delivery inputs and provider output.
|
||||||
type NotificationRow struct {
|
type NotificationRow struct {
|
||||||
Kind string // "email", "telegram_private", "telegram_group"
|
Kind string // "email", "telegram_private", "telegram_group"
|
||||||
Channel string
|
Channel string
|
||||||
@@ -46,6 +43,11 @@ type NotificationRow struct {
|
|||||||
OK bool
|
OK bool
|
||||||
Error string
|
Error string
|
||||||
At time.Time
|
At time.Time
|
||||||
|
|
||||||
|
JobID string
|
||||||
|
Method string
|
||||||
|
Status string
|
||||||
|
DurationMs int
|
||||||
}
|
}
|
||||||
|
|
||||||
// resultBuffer is a thread-safe FIFO ring buffer of ResultRow. The
|
// resultBuffer is a thread-safe FIFO ring buffer of ResultRow. The
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const (
|
|||||||
// minQueueCapacity is the lower bound for the bounded job/result
|
// minQueueCapacity is the lower bound for the bounded job/result
|
||||||
// channels so that a small pool still has some backpressure headroom.
|
// channels so that a small pool still has some backpressure headroom.
|
||||||
minQueueCapacity = 16
|
minQueueCapacity = 16
|
||||||
|
|
||||||
|
malformedTaskEnvelopeError = "malformed_task_envelope"
|
||||||
)
|
)
|
||||||
|
|
||||||
// jobPool is the minimal interface the Runner needs from a worker pool. It
|
// jobPool is the minimal interface the Runner needs from a worker pool. It
|
||||||
@@ -43,6 +45,11 @@ type resultEnvelope struct {
|
|||||||
reports []wire.CheckResultReport
|
reports []wire.CheckResultReport
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type metricEnvelope struct {
|
||||||
|
generation uint64
|
||||||
|
report wire.ServerMetricReport
|
||||||
|
}
|
||||||
|
|
||||||
// Runner manages the worker execution loop.
|
// Runner manages the worker execution loop.
|
||||||
//
|
//
|
||||||
// Concurrency design:
|
// Concurrency design:
|
||||||
@@ -70,9 +77,18 @@ type Runner struct {
|
|||||||
results chan resultEnvelope
|
results chan resultEnvelope
|
||||||
notifyQueue chan wire.NotificationTask
|
notifyQueue chan wire.NotificationTask
|
||||||
notifyResults chan notifyResultEnvelope
|
notifyResults chan notifyResultEnvelope
|
||||||
metricResults chan wire.ServerMetricReport
|
metricResults chan metricEnvelope
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
|
controlWG sync.WaitGroup
|
||||||
|
backgroundWG sync.WaitGroup
|
||||||
|
lifecycleMu sync.Mutex
|
||||||
|
controlCtx context.Context
|
||||||
|
controlCancel context.CancelFunc
|
||||||
|
backgroundCtx context.Context
|
||||||
|
backgroundCancel context.CancelFunc
|
||||||
|
rotationCtx context.Context
|
||||||
|
rotationCancel context.CancelFunc
|
||||||
started atomic.Bool
|
started atomic.Bool
|
||||||
queueDepth int64
|
queueDepth int64
|
||||||
activeCount int64
|
activeCount int64
|
||||||
@@ -121,15 +137,15 @@ type Runner struct {
|
|||||||
masterStatusUp *bool
|
masterStatusUp *bool
|
||||||
masterStatusAt time.Time
|
masterStatusAt time.Time
|
||||||
|
|
||||||
// selfcheckCancel terminates the periodic selfcheck goroutine started
|
|
||||||
// by Start(). Nil until Start runs.
|
|
||||||
selfcheckCancel context.CancelFunc
|
|
||||||
|
|
||||||
// executor is the function the pool runs for each job. It is a
|
// executor is the function the pool runs for each job. It is a
|
||||||
// field so tests can swap it for a deterministic stub without
|
// field so tests can swap it for a deterministic stub without
|
||||||
// touching the websocket plumbing.
|
// touching the websocket plumbing.
|
||||||
executor func(payload interface{}) interface{}
|
executor func(payload interface{}) interface{}
|
||||||
|
|
||||||
|
// notificationExecutor is a test seam for the notification task
|
||||||
|
// boundary. Production uses ExecuteNotification's normal delivery path.
|
||||||
|
notificationExecutor func(context.Context, models.Task) wire.NotificationResultReport
|
||||||
|
|
||||||
// resultsBuf holds the most recent result rows. The webapp
|
// resultsBuf holds the most recent result rows. The webapp
|
||||||
// reads from it via RecentResults(n). Cleared by Stop so the
|
// reads from it via RecentResults(n). Cleared by Stop so the
|
||||||
// ring does not leak between worker runs.
|
// ring does not leak between worker runs.
|
||||||
@@ -161,17 +177,31 @@ type Runner struct {
|
|||||||
capsMu sync.RWMutex
|
capsMu sync.RWMutex
|
||||||
workerCaps []string
|
workerCaps []string
|
||||||
serverID atomic.Int64
|
serverID atomic.Int64
|
||||||
|
metricGeneration atomic.Uint64
|
||||||
|
nextMetricGeneration atomic.Uint64
|
||||||
|
|
||||||
// clientMu guards swap of the http/websocket client during
|
// clientMu guards the control-plane client and its active websocket.
|
||||||
// token rotation. The websocket loop reads r.client under the
|
// Rotation swaps the client and closes only controlConn, leaving the
|
||||||
// lock; RotateToken swaps a fresh client in under the lock
|
// runner's global stop signal and local subsystems untouched.
|
||||||
// before closing the old connection.
|
|
||||||
clientMu sync.Mutex
|
clientMu sync.Mutex
|
||||||
|
controlConn *websocket.Conn
|
||||||
|
controlWriteMu *sync.Mutex
|
||||||
|
reconnectCh chan struct{}
|
||||||
|
rotationMu sync.Mutex
|
||||||
|
|
||||||
// closeOnce guards Close against double-close on the websocket
|
// outbox holds messages removed from a per-connection writer queue that
|
||||||
// from RotateToken. Phase 1 has a single websocket; RotateToken
|
// could not be written before its websocket closed.
|
||||||
// closes it so the reconnect loop picks up the new token.
|
outboxMu sync.Mutex
|
||||||
closeOnce sync.Once
|
outbox []wire.WorkerMessage
|
||||||
|
outboxWake chan struct{}
|
||||||
|
|
||||||
|
// beforeControlWrite is a test seam used to hold a dequeued message while
|
||||||
|
// a connection rotates.
|
||||||
|
beforeControlWrite func()
|
||||||
|
|
||||||
|
// beforeTokenCommit lets tests make shutdown win between a successful HTTP
|
||||||
|
// rotation response and the lifecycle-protected in-memory commit.
|
||||||
|
beforeTokenCommit func()
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRunner creates a new worker runner. Config is taken by pointer to
|
// NewRunner creates a new worker runner. Config is taken by pointer to
|
||||||
@@ -183,10 +213,21 @@ func NewRunner(cfg *Config) *Runner {
|
|||||||
if maxConc <= 0 {
|
if maxConc <= 0 {
|
||||||
maxConc = DefaultMaxConcurrency
|
maxConc = DefaultMaxConcurrency
|
||||||
}
|
}
|
||||||
|
controlCtx, controlCancel := context.WithCancel(context.Background())
|
||||||
|
backgroundCtx, backgroundCancel := context.WithCancel(context.Background())
|
||||||
|
rotationCtx, rotationCancel := context.WithCancel(context.Background())
|
||||||
return &Runner{
|
return &Runner{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
maxConcurrency: maxConc,
|
maxConcurrency: maxConc,
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
|
reconnectCh: make(chan struct{}, 1),
|
||||||
|
outboxWake: make(chan struct{}, 1),
|
||||||
|
controlCtx: controlCtx,
|
||||||
|
controlCancel: controlCancel,
|
||||||
|
backgroundCtx: backgroundCtx,
|
||||||
|
backgroundCancel: backgroundCancel,
|
||||||
|
rotationCtx: rotationCtx,
|
||||||
|
rotationCancel: rotationCancel,
|
||||||
resultsBuf: newResultBuffer(),
|
resultsBuf: newResultBuffer(),
|
||||||
notificationsBuf: newNotificationBuffer(),
|
notificationsBuf: newNotificationBuffer(),
|
||||||
peerCache: newPeerCache(),
|
peerCache: newPeerCache(),
|
||||||
@@ -196,23 +237,32 @@ func NewRunner(cfg *Config) *Runner {
|
|||||||
// Start begins the worker execution
|
// Start begins the worker execution
|
||||||
func (r *Runner) Start() error {
|
func (r *Runner) Start() error {
|
||||||
log.Println("worker: starting...")
|
log.Println("worker: starting...")
|
||||||
|
r.lifecycleMu.Lock()
|
||||||
|
|
||||||
if r.config.URL == "" || r.config.Token == "" {
|
if r.config.URL == "" || r.config.Token == "" {
|
||||||
|
r.lifecycleMu.Unlock()
|
||||||
return fmt.Errorf("RSMON_URL and RSMON_TOKEN must be set")
|
return fmt.Errorf("RSMON_URL and RSMON_TOKEN must be set")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !r.started.CompareAndSwap(false, true) {
|
if !r.started.CompareAndSwap(false, true) {
|
||||||
|
r.lifecycleMu.Unlock()
|
||||||
return fmt.Errorf("worker: runner already started")
|
return fmt.Errorf("worker: runner already started")
|
||||||
}
|
}
|
||||||
|
if r.stopped() {
|
||||||
|
r.lifecycleMu.Unlock()
|
||||||
|
return fmt.Errorf("worker: runner stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.clientMu.Lock()
|
||||||
r.client = NewClient(r.config.URL, r.config.Token)
|
r.client = NewClient(r.config.URL, r.config.Token)
|
||||||
|
r.clientMu.Unlock()
|
||||||
|
|
||||||
queueCap := r.queueCapacity()
|
queueCap := r.queueCapacity()
|
||||||
r.jobQueue = make(chan wire.CheckJob, queueCap)
|
r.jobQueue = make(chan wire.CheckJob, queueCap)
|
||||||
r.results = make(chan resultEnvelope, queueCap)
|
r.results = make(chan resultEnvelope, queueCap)
|
||||||
r.notifyQueue = make(chan wire.NotificationTask, queueCap)
|
r.notifyQueue = make(chan wire.NotificationTask, queueCap)
|
||||||
r.notifyResults = make(chan notifyResultEnvelope, queueCap)
|
r.notifyResults = make(chan notifyResultEnvelope, queueCap)
|
||||||
r.metricResults = make(chan wire.ServerMetricReport, queueCap)
|
r.metricResults = make(chan metricEnvelope, queueCap)
|
||||||
|
|
||||||
if r.executor == nil {
|
if r.executor == nil {
|
||||||
r.executor = r.defaultExecuteJob
|
r.executor = r.defaultExecuteJob
|
||||||
@@ -237,31 +287,48 @@ func (r *Runner) Start() error {
|
|||||||
go r.notifyDispatcher()
|
go r.notifyDispatcher()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start websocket task loop
|
// Start websocket task loop.
|
||||||
go r.websocketLoop()
|
r.controlWG.Add(1)
|
||||||
go r.serverMetricLoop()
|
go func() {
|
||||||
|
defer r.controlWG.Done()
|
||||||
|
r.websocketLoop()
|
||||||
|
}()
|
||||||
|
r.backgroundWG.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer r.backgroundWG.Done()
|
||||||
|
r.serverMetricLoop(r.backgroundCtx)
|
||||||
|
}()
|
||||||
|
|
||||||
// Start periodic selfcheck loop. This probes the main API and, on
|
// Start periodic selfcheck loop. This probes the main API and, on
|
||||||
// sustained unreachability, notifies system contacts directly via
|
// sustained unreachability, notifies system contacts directly via
|
||||||
// the cached credentials. Lifetimes of selfcheck goroutines are
|
// the cached credentials. Lifetimes of selfcheck goroutines are
|
||||||
// bound to stopCh (and the explicit cancel, kept for symmetry).
|
// bound to stopCh (and the explicit cancel, kept for symmetry).
|
||||||
selfcheckCtx, selfcheckCancel := context.WithCancel(context.Background())
|
r.backgroundWG.Add(1)
|
||||||
r.selfcheckCancel = selfcheckCancel
|
go func() {
|
||||||
go r.startSelfcheck(selfcheckCtx)
|
defer r.backgroundWG.Done()
|
||||||
|
r.startSelfcheck(r.backgroundCtx)
|
||||||
|
}()
|
||||||
|
|
||||||
// Start the peer poller. It refreshes r.peerCache with each
|
// Start the peer poller. It refreshes r.peerCache with each
|
||||||
// peer's latest /api/peer/status verdict. The selfcheck
|
// peer's latest /api/peer/status verdict. The selfcheck
|
||||||
// consumes the cache to drive consensus. The poller is bound
|
// consumes the cache to drive consensus. The poller is bound
|
||||||
// to selfcheckCtx so it shuts down together with the
|
// to selfcheckCtx so it shuts down together with the
|
||||||
// selfcheck loop on Stop.
|
// selfcheck loop on Stop.
|
||||||
go r.peerPollerLoop(selfcheckCtx)
|
r.backgroundWG.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer r.backgroundWG.Done()
|
||||||
|
r.peerPollerLoop(r.backgroundCtx)
|
||||||
|
}()
|
||||||
|
r.lifecycleMu.Unlock()
|
||||||
|
|
||||||
// Wait for stop signal
|
// Wait for stop signal
|
||||||
<-r.stopCh
|
<-r.stopCh
|
||||||
log.Println("worker: shutting down...")
|
log.Println("worker: shutting down...")
|
||||||
if r.selfcheckCancel != nil {
|
if r.backgroundCancel != nil {
|
||||||
r.selfcheckCancel()
|
r.backgroundCancel()
|
||||||
}
|
}
|
||||||
|
r.controlWG.Wait()
|
||||||
|
r.backgroundWG.Wait()
|
||||||
|
|
||||||
// Dispatchers exit via stopCh. Do not close jobQueue here: the websocket
|
// Dispatchers exit via stopCh. Do not close jobQueue here: the websocket
|
||||||
// reader can still be unwinding and may otherwise race with a send.
|
// reader can still be unwinding and may otherwise race with a send.
|
||||||
@@ -271,21 +338,42 @@ func (r *Runner) Start() error {
|
|||||||
// otherwise an in-flight pool.Process would panic.
|
// otherwise an in-flight pool.Process would panic.
|
||||||
r.pool.Close()
|
r.pool.Close()
|
||||||
|
|
||||||
// Close results so any future writers exit promptly. (At this point
|
|
||||||
// the websocket connection is also gone, so this is just defensive.)
|
|
||||||
close(r.results)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop gracefully stops the worker
|
// Stop gracefully stops the worker
|
||||||
func (r *Runner) Stop() {
|
func (r *Runner) Stop() {
|
||||||
|
r.lifecycleMu.Lock()
|
||||||
select {
|
select {
|
||||||
case <-r.stopCh:
|
case <-r.stopCh:
|
||||||
// already closed
|
// already closed
|
||||||
default:
|
default:
|
||||||
close(r.stopCh)
|
close(r.stopCh)
|
||||||
}
|
}
|
||||||
|
if r.controlCancel != nil {
|
||||||
|
r.controlCancel()
|
||||||
|
}
|
||||||
|
if r.backgroundCancel != nil {
|
||||||
|
r.backgroundCancel()
|
||||||
|
}
|
||||||
|
if r.rotationCancel != nil {
|
||||||
|
r.rotationCancel()
|
||||||
|
}
|
||||||
|
r.clientMu.Lock()
|
||||||
|
conn := r.controlConn
|
||||||
|
r.clientMu.Unlock()
|
||||||
|
if conn != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
r.lifecycleMu.Unlock()
|
||||||
|
|
||||||
|
// Cancel and close before waiting for rotation. A rotation can be waiting
|
||||||
|
// on controlWriteMu while a blocked writer needs that close to return.
|
||||||
|
r.rotationMu.Lock()
|
||||||
|
r.rotationMu.Unlock()
|
||||||
|
r.controlWG.Wait()
|
||||||
|
r.backgroundWG.Wait()
|
||||||
|
r.wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue submits a job to the worker pool. It returns false if the runner
|
// Enqueue submits a job to the worker pool. It returns false if the runner
|
||||||
@@ -295,6 +383,9 @@ func (r *Runner) Enqueue(job wire.CheckJob) bool { //nolint:lll,gocritic // wire
|
|||||||
if r.jobQueue == nil {
|
if r.jobQueue == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if r.stopped() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case r.jobQueue <- job:
|
case r.jobQueue <- job:
|
||||||
atomic.AddInt64(&r.queueDepth, 1)
|
atomic.AddInt64(&r.queueDepth, 1)
|
||||||
@@ -318,6 +409,9 @@ func (r *Runner) EnqueueNotification(task wire.NotificationTask) bool {
|
|||||||
if r.notifyQueue == nil {
|
if r.notifyQueue == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if r.stopped() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case r.notifyQueue <- task:
|
case r.notifyQueue <- task:
|
||||||
atomic.AddInt64(&r.notifyDepth, 1)
|
atomic.AddInt64(&r.notifyDepth, 1)
|
||||||
@@ -350,23 +444,30 @@ func (r *Runner) notifyDispatcher() {
|
|||||||
// shape, runs the executor, and pushes the result into notifyResults. The
|
// shape, runs the executor, and pushes the result into notifyResults. The
|
||||||
// writer goroutine picks it up and serializes the websocket write.
|
// writer goroutine picks it up and serializes the websocket write.
|
||||||
func (r *Runner) executeAndForwardNotification(task wire.NotificationTask) { //nolint:gocritic // wire payload is shared
|
func (r *Runner) executeAndForwardNotification(task wire.NotificationTask) { //nolint:gocritic // wire payload is shared
|
||||||
if r.notifyResults == nil {
|
started := time.Now()
|
||||||
|
deadline := started.Add(models.DefaultNotificationExecutionTimeout)
|
||||||
|
var taskDeadline *time.Time
|
||||||
|
if task.Deadline != nil {
|
||||||
|
parsedDeadline, err := time.Parse(time.RFC3339Nano, *task.Deadline)
|
||||||
|
if err != nil {
|
||||||
|
r.completeNotification(task, notificationPermanentReport(task, "invalid notification deadline", started))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
deadline := time.Now().Add(models.DefaultNotificationExecutionTimeout)
|
taskDeadline = &parsedDeadline
|
||||||
if task.Deadline != nil {
|
if !parsedDeadline.After(started) {
|
||||||
if taskDeadline, err := time.Parse(time.RFC3339Nano, *task.Deadline); err == nil && taskDeadline.Before(deadline) {
|
r.completeNotification(task, notificationPermanentReport(task, "notification deadline expired", started))
|
||||||
deadline = taskDeadline
|
return
|
||||||
|
}
|
||||||
|
if parsedDeadline.Before(deadline) {
|
||||||
|
deadline = parsedDeadline
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithDeadline(context.Background(), deadline)
|
ctx, cancel := context.WithDeadline(context.Background(), deadline)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
payload, _ := json.Marshal(task)
|
payload, _ := json.Marshal(task)
|
||||||
dbTask := models.Task{JobID: task.JobID, LeaseToken: task.LeaseToken, Payload: payload}
|
dbTask := models.Task{JobID: task.JobID, LeaseToken: task.LeaseToken, Payload: payload}
|
||||||
if task.Deadline != nil {
|
if taskDeadline != nil {
|
||||||
if deadline, err := time.Parse(time.RFC3339Nano, *task.Deadline); err == nil {
|
dbTask.Deadline = taskDeadline
|
||||||
dbTask.Deadline = &deadline
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if task.MessageID != 0 {
|
if task.MessageID != 0 {
|
||||||
msgID := task.MessageID
|
msgID := task.MessageID
|
||||||
@@ -377,15 +478,33 @@ func (r *Runner) executeAndForwardNotification(task wire.NotificationTask) { //n
|
|||||||
_ = ev
|
_ = ev
|
||||||
}
|
}
|
||||||
report := r.ExecuteNotification(ctx, dbTask)
|
report := r.ExecuteNotification(ctx, dbTask)
|
||||||
env := notifyResultEnvelope{task: task, report: report}
|
r.forwardNotificationResult(task, report)
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
func notificationPermanentReport(task wire.NotificationTask, message string, started time.Time) wire.NotificationResultReport {
|
||||||
case <-r.stopCh:
|
return wire.NotificationResultReport{
|
||||||
|
JobID: task.JobID,
|
||||||
|
LeaseToken: task.LeaseToken,
|
||||||
|
MessageID: task.MessageID,
|
||||||
|
Status: wire.NotificationResultPermanent,
|
||||||
|
Error: stringPtr(message),
|
||||||
|
DurationMs: int(time.Since(started) / time.Millisecond),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// completeNotification records the terminal local outcome before forwarding
|
||||||
|
// the result. Rejected tasks use this path because they do not enter the executor.
|
||||||
|
func (r *Runner) completeNotification(task wire.NotificationTask, report wire.NotificationResultReport) {
|
||||||
|
r.recordDelegatedNotification(report.JobID, task.Method, report)
|
||||||
|
r.forwardNotificationResult(task, report)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) forwardNotificationResult(task wire.NotificationTask, report wire.NotificationResultReport) {
|
||||||
|
if r.notifyResults == nil || r.stopped() {
|
||||||
return
|
return
|
||||||
default:
|
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
case r.notifyResults <- env:
|
case r.notifyResults <- notifyResultEnvelope{task: task, report: report}:
|
||||||
case <-r.stopCh:
|
case <-r.stopCh:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -482,6 +601,7 @@ func (r *Runner) websocketLoop() {
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case <-time.After(3 * time.Second):
|
case <-time.After(3 * time.Second):
|
||||||
|
case <-r.reconnectCh:
|
||||||
case <-r.stopCh:
|
case <-r.stopCh:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -489,29 +609,75 @@ func (r *Runner) websocketLoop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) runWebsocket() error {
|
func (r *Runner) runWebsocket() error {
|
||||||
conn, err := r.client.WorkerSocket()
|
var writeMu sync.Mutex
|
||||||
|
r.clientMu.Lock()
|
||||||
|
client := r.client
|
||||||
|
r.clientMu.Unlock()
|
||||||
|
if client == nil {
|
||||||
|
return fmt.Errorf("worker: client not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := client.WorkerSocketContext(r.controlCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer conn.Close() //nolint:errcheck
|
|
||||||
|
// A rotation can complete while the websocket dial is in flight. Do not
|
||||||
|
// install a connection authenticated with the superseded token.
|
||||||
|
r.clientMu.Lock()
|
||||||
|
if r.client != client || r.stopped() {
|
||||||
|
r.clientMu.Unlock()
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r.controlConn = conn
|
||||||
|
r.controlWriteMu = &writeMu
|
||||||
|
metricGeneration := r.nextMetricGeneration.Add(1)
|
||||||
|
r.metricGeneration.Store(metricGeneration)
|
||||||
|
r.clientMu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
r.clientMu.Lock()
|
||||||
|
if r.controlConn == conn {
|
||||||
|
r.controlConn = nil
|
||||||
|
r.controlWriteMu = nil
|
||||||
|
r.metricGeneration.CompareAndSwap(metricGeneration, 0)
|
||||||
|
}
|
||||||
|
r.clientMu.Unlock()
|
||||||
|
_ = conn.Close()
|
||||||
|
}()
|
||||||
log.Println("worker: websocket connected")
|
log.Println("worker: websocket connected")
|
||||||
|
|
||||||
var writeMu sync.Mutex
|
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
|
var doneOnce sync.Once
|
||||||
|
closeDone := func() { doneOnce.Do(func() { close(done) }) }
|
||||||
|
var connectionWG sync.WaitGroup
|
||||||
|
defer func() {
|
||||||
|
closeDone()
|
||||||
|
_ = conn.Close()
|
||||||
|
connectionWG.Wait()
|
||||||
|
}()
|
||||||
|
|
||||||
// Heartbeat goroutine — shares writeMu with the writer.
|
// Heartbeat goroutine — shares writeMu with the writer.
|
||||||
go r.heartbeat(conn, &writeMu, done)
|
connectionWG.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer connectionWG.Done()
|
||||||
|
r.heartbeat(conn, &writeMu, done)
|
||||||
|
}()
|
||||||
|
|
||||||
// Single writer goroutine for this connection: serializes result
|
// Single writer goroutine for this connection: serializes result
|
||||||
// and heartbeat writes through writeMu so websocket.WriteJSON is
|
// and heartbeat writes through writeMu so websocket.WriteJSON is
|
||||||
// never called concurrently. The dispatcher loop feeds it via the
|
// never called concurrently. The dispatcher loop feeds it via the
|
||||||
// bounded results channel.
|
// bounded results channel.
|
||||||
go r.writer(conn, &writeMu, done)
|
connectionWG.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer connectionWG.Done()
|
||||||
|
r.writer(conn, &writeMu, done, metricGeneration)
|
||||||
|
}()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
var msg wire.WorkerMessage
|
var msg wire.WorkerMessage
|
||||||
if err := conn.ReadJSON(&msg); err != nil {
|
if err := conn.ReadJSON(&msg); err != nil {
|
||||||
close(done)
|
closeDone()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if (msg.Kind == "init" || msg.Kind == "config") && msg.Init != nil {
|
if (msg.Kind == "init" || msg.Kind == "config") && msg.Init != nil {
|
||||||
@@ -522,32 +688,113 @@ func (r *Runner) runWebsocket() error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !r.enqueueTaskMessage(msg) {
|
if !r.enqueueTaskMessage(msg) {
|
||||||
close(done)
|
closeDone()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// enqueueTaskMessage prefers the v2 envelope over sibling legacy fields. Some
|
// enqueueTaskMessage prefers the v2 envelope over sibling legacy fields. An
|
||||||
// rollout frames contain both check representations; executing the first match
|
// envelope is accepted only when it selects exactly one matching payload with
|
||||||
// only keeps a current runner from running one check twice.
|
// the same non-empty outer and inner job IDs. Invalid envelopes never fall
|
||||||
|
// back to a sibling legacy payload, which could otherwise execute a task the
|
||||||
|
// control plane did not intend to send.
|
||||||
func (r *Runner) enqueueTaskMessage(msg wire.WorkerMessage) bool { //nolint:gocritic // wire envelope is the dispatcher boundary
|
func (r *Runner) enqueueTaskMessage(msg wire.WorkerMessage) bool { //nolint:gocritic // wire envelope is the dispatcher boundary
|
||||||
|
if msg.TaskEnvelope != nil {
|
||||||
|
return r.enqueueTaskEnvelope(msg.TaskEnvelope)
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case msg.TaskEnvelope != nil && msg.TaskEnvelope.Type == wire.TaskTypeNotification && msg.TaskEnvelope.Notify != nil:
|
|
||||||
return r.EnqueueNotification(*msg.TaskEnvelope.Notify)
|
|
||||||
case msg.TaskEnvelope != nil && msg.TaskEnvelope.Type == wire.TaskTypeCheck && msg.TaskEnvelope.Job != nil:
|
|
||||||
return r.Enqueue(*msg.TaskEnvelope.Job)
|
|
||||||
case msg.NotificationTask != nil:
|
case msg.NotificationTask != nil:
|
||||||
|
if msg.NotificationTask.JobID == "" || msg.NotificationTask.LeaseToken == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
log.Printf("worker: received websocket notification task %s method=%s", msg.NotificationTask.JobID, msg.NotificationTask.Method)
|
log.Printf("worker: received websocket notification task %s method=%s", msg.NotificationTask.JobID, msg.NotificationTask.Method)
|
||||||
return r.EnqueueNotification(*msg.NotificationTask)
|
return r.EnqueueNotification(*msg.NotificationTask)
|
||||||
case msg.Task != nil:
|
case msg.Task != nil:
|
||||||
|
if msg.Task.JobID == "" || msg.Task.LeaseToken == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
log.Printf("worker: received websocket task %s", msg.Task.JobID)
|
log.Printf("worker: received websocket task %s", msg.Task.JobID)
|
||||||
|
if !checkexec.SupportsKind(msg.Task.Kind) {
|
||||||
|
return r.enqueueUnsupportedCheck(*msg.Task)
|
||||||
|
}
|
||||||
return r.Enqueue(*msg.Task)
|
return r.Enqueue(*msg.Task)
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Runner) enqueueTaskEnvelope(envelope *wire.TaskEnvelope) bool {
|
||||||
|
if (envelope.Job == nil) == (envelope.Notify == nil) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if envelope.Job != nil {
|
||||||
|
if !matchingEnvelopeJobID(envelope.JobID, envelope.Job.JobID) || envelope.Job.LeaseToken == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if envelope.Type != wire.TaskTypeCheck {
|
||||||
|
return r.enqueueFailedCheck(*envelope.Job, malformedTaskEnvelopeError)
|
||||||
|
}
|
||||||
|
if !checkexec.SupportsKind(envelope.Job.Kind) {
|
||||||
|
return r.enqueueUnsupportedCheck(*envelope.Job)
|
||||||
|
}
|
||||||
|
return r.Enqueue(*envelope.Job)
|
||||||
|
}
|
||||||
|
if !matchingEnvelopeJobID(envelope.JobID, envelope.Notify.JobID) || envelope.Notify.LeaseToken == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if envelope.Type != wire.TaskTypeNotification {
|
||||||
|
return r.enqueueFailedNotification(*envelope.Notify, malformedTaskEnvelopeError)
|
||||||
|
}
|
||||||
|
return r.EnqueueNotification(*envelope.Notify)
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchingEnvelopeJobID(outer, inner string) bool {
|
||||||
|
return outer != "" && outer == inner
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) enqueueUnsupportedCheck(job wire.CheckJob) bool {
|
||||||
|
return r.enqueueFailedCheck(job, "unsupported_kind: "+job.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) enqueueFailedCheck(job wire.CheckJob, errorCode string) bool {
|
||||||
|
if r.results == nil || r.stopped() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
report := wire.CheckResultReport{
|
||||||
|
JobID: job.JobID,
|
||||||
|
CheckID: job.CheckID,
|
||||||
|
MonitorID: job.MonitorID,
|
||||||
|
State: "FAIL",
|
||||||
|
Error: stringPtr(errorCode),
|
||||||
|
DurationMs: 0,
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case r.results <- resultEnvelope{job: job, reports: []wire.CheckResultReport{report}}:
|
||||||
|
return true
|
||||||
|
case <-r.stopCh:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) enqueueFailedNotification(task wire.NotificationTask, errorCode string) bool {
|
||||||
|
if r.stopped() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r.completeNotification(task, notificationPermanentReport(task, errorCode, time.Now()))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) stopped() bool {
|
||||||
|
select {
|
||||||
|
case <-r.stopCh:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Runner) heartbeat(conn *websocket.Conn, writeMu *sync.Mutex, done <-chan struct{}) {
|
func (r *Runner) heartbeat(conn *websocket.Conn, writeMu *sync.Mutex, done <-chan struct{}) {
|
||||||
ticker := time.NewTicker(heartbeatInterval)
|
ticker := time.NewTicker(heartbeatInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -601,23 +848,39 @@ func (r *Runner) LastHeartbeatAck() time.Time {
|
|||||||
return r.lastHeartbeatAt
|
return r.lastHeartbeatAt
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) writer(conn *websocket.Conn, writeMu *sync.Mutex, done <-chan struct{}) {
|
func (r *Runner) writer(conn *websocket.Conn, writeMu *sync.Mutex, done <-chan struct{}, metricGeneration uint64) {
|
||||||
for {
|
for {
|
||||||
|
if message, ok := r.takeOutbox(); ok {
|
||||||
|
if err := r.writeControlMessage(conn, writeMu, message); err != nil {
|
||||||
|
r.requeueOutbox(message)
|
||||||
|
_ = conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case env, ok := <-r.results:
|
case env, ok := <-r.results:
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeMu.Lock()
|
|
||||||
for i := range env.reports {
|
for i := range env.reports {
|
||||||
report := &env.reports[i]
|
report := &env.reports[i]
|
||||||
report.LeaseToken = env.job.LeaseToken
|
report.LeaseToken = env.job.LeaseToken
|
||||||
if err := conn.WriteJSON(wire.WorkerMessage{Kind: "result", Result: report}); err != nil {
|
message := wire.WorkerMessage{Kind: "result", Result: report}
|
||||||
|
if err := r.writeControlMessage(conn, writeMu, message); err != nil {
|
||||||
|
remaining := []wire.WorkerMessage{message}
|
||||||
|
for j := i + 1; j < len(env.reports); j++ {
|
||||||
|
next := env.reports[j]
|
||||||
|
next.LeaseToken = env.job.LeaseToken
|
||||||
|
remaining = append(remaining, wire.WorkerMessage{Kind: "result", Result: &next})
|
||||||
|
}
|
||||||
|
r.requeueOutbox(remaining...)
|
||||||
log.Printf(
|
log.Printf(
|
||||||
"worker: failed to report result job=%s check=%d kind=%s state=%s: %v",
|
"worker: failed to report result job=%s check=%d kind=%s state=%s: %v",
|
||||||
report.JobID, report.CheckID, env.job.Kind, report.State, err,
|
report.JobID, report.CheckID, env.job.Kind, report.State, err,
|
||||||
)
|
)
|
||||||
writeMu.Unlock()
|
_ = conn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf(
|
log.Printf(
|
||||||
@@ -626,32 +889,37 @@ func (r *Runner) writer(conn *websocket.Conn, writeMu *sync.Mutex, done <-chan s
|
|||||||
)
|
)
|
||||||
r.resultsBuf.add(resultRowFromReport(&env, report, time.Now().UTC()))
|
r.resultsBuf.add(resultRowFromReport(&env, report, time.Now().UTC()))
|
||||||
}
|
}
|
||||||
writeMu.Unlock()
|
|
||||||
case env, ok := <-r.notifyResults:
|
case env, ok := <-r.notifyResults:
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeMu.Lock()
|
message := wire.WorkerMessage{Kind: "result", NotificationResult: &env.report}
|
||||||
if err := conn.WriteJSON(wire.WorkerMessage{Kind: "result", NotificationResult: &env.report}); err != nil {
|
if err := r.writeControlMessage(conn, writeMu, message); err != nil {
|
||||||
|
r.requeueOutbox(message)
|
||||||
log.Printf(
|
log.Printf(
|
||||||
"worker: failed to report notification result job=%s method=%s status=%s: %v",
|
"worker: failed to report notification result job=%s method=%s status=%s: %v",
|
||||||
env.report.JobID, env.task.Method, env.report.Status, err,
|
env.report.JobID, env.task.Method, env.report.Status, err,
|
||||||
)
|
)
|
||||||
writeMu.Unlock()
|
_ = conn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf(
|
log.Printf(
|
||||||
"worker: completed notification job=%s method=%s status=%s message=%d duration_ms=%d",
|
"worker: completed notification job=%s method=%s status=%s message=%d duration_ms=%d",
|
||||||
env.report.JobID, env.task.Method, env.report.Status, env.task.MessageID, env.report.DurationMs,
|
env.report.JobID, env.task.Method, env.report.Status, env.task.MessageID, env.report.DurationMs,
|
||||||
)
|
)
|
||||||
writeMu.Unlock()
|
case metric := <-r.metricResults:
|
||||||
case report := <-r.metricResults:
|
if metric.generation != metricGeneration {
|
||||||
writeMu.Lock()
|
continue
|
||||||
if err := conn.WriteJSON(wire.WorkerMessage{Kind: "result", ServerMetric: &report}); err != nil {
|
}
|
||||||
writeMu.Unlock()
|
message := wire.WorkerMessage{Kind: "result", ServerMetric: &metric.report}
|
||||||
|
if err := r.writeControlMessage(conn, writeMu, message); err != nil {
|
||||||
|
// Metrics are periodic snapshots without a lease or idempotency key.
|
||||||
|
// Dropping a failed snapshot avoids duplicate control-plane inserts;
|
||||||
|
// the next collection tick supplies a fresh replacement.
|
||||||
|
_ = conn.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeMu.Unlock()
|
case <-r.outboxWake:
|
||||||
case <-done:
|
case <-done:
|
||||||
return
|
return
|
||||||
case <-r.stopCh:
|
case <-r.stopCh:
|
||||||
@@ -660,6 +928,39 @@ func (r *Runner) writer(conn *websocket.Conn, writeMu *sync.Mutex, done <-chan s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Runner) takeOutbox() (wire.WorkerMessage, bool) {
|
||||||
|
r.outboxMu.Lock()
|
||||||
|
defer r.outboxMu.Unlock()
|
||||||
|
if len(r.outbox) == 0 {
|
||||||
|
return wire.WorkerMessage{}, false
|
||||||
|
}
|
||||||
|
message := r.outbox[0]
|
||||||
|
r.outbox = r.outbox[1:]
|
||||||
|
return message, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) requeueOutbox(messages ...wire.WorkerMessage) {
|
||||||
|
if len(messages) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.outboxMu.Lock()
|
||||||
|
r.outbox = append(messages, r.outbox...)
|
||||||
|
r.outboxMu.Unlock()
|
||||||
|
select {
|
||||||
|
case r.outboxWake <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) writeControlMessage(conn *websocket.Conn, writeMu *sync.Mutex, message wire.WorkerMessage) error {
|
||||||
|
writeMu.Lock()
|
||||||
|
defer writeMu.Unlock()
|
||||||
|
if r.beforeControlWrite != nil {
|
||||||
|
r.beforeControlWrite()
|
||||||
|
}
|
||||||
|
return conn.WriteJSON(message)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Runner) applyInit(init *wire.WorkerInit) {
|
func (r *Runner) applyInit(init *wire.WorkerInit) {
|
||||||
if init.Concurrency > 0 && init.Concurrency != r.Concurrency() {
|
if init.Concurrency > 0 && init.Concurrency != r.Concurrency() {
|
||||||
size := init.Concurrency
|
size := init.Concurrency
|
||||||
@@ -881,9 +1182,6 @@ func (r *Runner) RecentResults(n int) []ResultRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RecentNotifications returns the most recent n notification rows.
|
// RecentNotifications returns the most recent n notification rows.
|
||||||
// Phase 1 only fills this buffer from selfcheck alerts via
|
|
||||||
// RecordNotification; the main-app-issued notifications still live
|
|
||||||
// in the main app's database.
|
|
||||||
func (r *Runner) RecentNotifications(n int) []NotificationRow {
|
func (r *Runner) RecentNotifications(n int) []NotificationRow {
|
||||||
if r == nil || r.notificationsBuf == nil {
|
if r == nil || r.notificationsBuf == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -892,8 +1190,7 @@ func (r *Runner) RecentNotifications(n int) []NotificationRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RecordNotification appends one row to the notification ring buffer.
|
// RecordNotification appends one row to the notification ring buffer.
|
||||||
// Called from selfcheck.sendSystemAlert so the webapp /notifications
|
// Called from selfcheck.sendSystemAlert. Safe before Start.
|
||||||
// page can show what the worker emitted. Safe before Start.
|
|
||||||
func (r *Runner) RecordNotification(n *NotificationRow) {
|
func (r *Runner) RecordNotification(n *NotificationRow) {
|
||||||
if r == nil || r.notificationsBuf == nil {
|
if r == nil || r.notificationsBuf == nil {
|
||||||
return
|
return
|
||||||
@@ -904,12 +1201,24 @@ func (r *Runner) RecordNotification(n *NotificationRow) {
|
|||||||
r.notificationsBuf.add(n)
|
r.notificationsBuf.add(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Runner) recordDelegatedNotification(jobID, method string, report wire.NotificationResultReport) {
|
||||||
|
r.RecordNotification(&NotificationRow{
|
||||||
|
JobID: jobID,
|
||||||
|
Method: method,
|
||||||
|
Status: report.Status,
|
||||||
|
DurationMs: report.DurationMs,
|
||||||
|
At: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Token returns the current bearer token. The webapp settings page
|
// Token returns the current bearer token. The webapp settings page
|
||||||
// masks this for display.
|
// masks this for display.
|
||||||
func (r *Runner) Token() string {
|
func (r *Runner) Token() string {
|
||||||
if r == nil || r.config == nil {
|
if r == nil || r.config == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
r.clientMu.Lock()
|
||||||
|
defer r.clientMu.Unlock()
|
||||||
return r.config.Token
|
return r.config.Token
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -983,48 +1292,95 @@ func (r *Runner) HTTPConfig() HTTPConfig {
|
|||||||
//
|
//
|
||||||
// Returns the new token string. On any failure the old token and
|
// Returns the new token string. On any failure the old token and
|
||||||
// client are kept untouched.
|
// client are kept untouched.
|
||||||
func (r *Runner) RotateToken(_ context.Context) (string, error) {
|
func (r *Runner) RotateToken(ctx context.Context) (string, error) {
|
||||||
if r == nil || r.config == nil {
|
if r == nil || r.config == nil {
|
||||||
return "", fmt.Errorf("worker: runner not initialized")
|
return "", fmt.Errorf("worker: runner not initialized")
|
||||||
}
|
}
|
||||||
if r.client == nil {
|
|
||||||
|
// The endpoint authenticates with the current token, so concurrent
|
||||||
|
// rotations must not mint replacements from the same stale client.
|
||||||
|
r.rotationMu.Lock()
|
||||||
|
defer r.rotationMu.Unlock()
|
||||||
|
if r.stopped() {
|
||||||
|
return "", fmt.Errorf("worker: runner stopped")
|
||||||
|
}
|
||||||
|
requestCtx, cancelRequest := context.WithCancel(ctx)
|
||||||
|
rotationDone := make(chan struct{})
|
||||||
|
defer func() {
|
||||||
|
close(rotationDone)
|
||||||
|
cancelRequest()
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-r.rotationCtx.Done():
|
||||||
|
cancelRequest()
|
||||||
|
case <-rotationDone:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
r.clientMu.Lock()
|
||||||
|
client := r.client
|
||||||
|
r.clientMu.Unlock()
|
||||||
|
if client == nil {
|
||||||
return "", fmt.Errorf("worker: client not yet started")
|
return "", fmt.Errorf("worker: client not yet started")
|
||||||
}
|
}
|
||||||
newToken, err := r.client.RotateToken()
|
newToken, err := client.RotateToken(requestCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if r.beforeTokenCommit != nil {
|
||||||
|
r.beforeTokenCommit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop and the post-HTTP commit share lifecycleMu. Once Stop has closed
|
||||||
|
// stopCh, this rotation cannot install or report a replacement token.
|
||||||
|
r.lifecycleMu.Lock()
|
||||||
|
r.clientMu.Lock()
|
||||||
|
if r.stopped() {
|
||||||
|
r.clientMu.Unlock()
|
||||||
|
r.lifecycleMu.Unlock()
|
||||||
|
return "", fmt.Errorf("worker: runner stopped")
|
||||||
|
}
|
||||||
if newToken == "" || newToken == r.config.Token {
|
if newToken == "" || newToken == r.config.Token {
|
||||||
|
r.clientMu.Unlock()
|
||||||
|
r.lifecycleMu.Unlock()
|
||||||
return "", fmt.Errorf("worker: rotate-token returned unchanged or empty token")
|
return "", fmt.Errorf("worker: rotate-token returned unchanged or empty token")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swap config + client under lock so a concurrent heartbeat
|
// Swap client and detach only the active control-plane connection. stopCh
|
||||||
// cannot race with the rotation.
|
// remains exclusively owned by Stop, so dispatch, metrics, selfcheck, peer
|
||||||
r.clientMu.Lock()
|
// polling, and services started alongside the runner keep running.
|
||||||
r.config.Token = newToken
|
r.config.Token = newToken
|
||||||
oldClient := r.client
|
|
||||||
r.client = NewClient(r.config.URL, newToken)
|
r.client = NewClient(r.config.URL, newToken)
|
||||||
|
conn := r.controlConn
|
||||||
|
writeMu := r.controlWriteMu
|
||||||
r.clientMu.Unlock()
|
r.clientMu.Unlock()
|
||||||
|
r.lifecycleMu.Unlock()
|
||||||
|
|
||||||
// Stamp the rotation time so the settings page can show it.
|
// Stamp the rotation time so the settings page can show it.
|
||||||
r.tokenRotatedMu.Lock()
|
r.tokenRotatedMu.Lock()
|
||||||
r.tokenRotatedAt = time.Now().UTC()
|
r.tokenRotatedAt = time.Now().UTC()
|
||||||
r.tokenRotatedMu.Unlock()
|
r.tokenRotatedMu.Unlock()
|
||||||
|
|
||||||
// Force the websocket loop to reconnect with the new token. The
|
// Interrupt the current connection before waiting for its writer. A stalled
|
||||||
// old connection's next heartbeat will fail with 401; closing
|
// WriteJSON holds writeMu; closing the socket makes that write fail so the
|
||||||
// the connection now shortens that window.
|
// writer can requeue its dequeued envelope and release the mutex.
|
||||||
r.closeOnce.Do(func() {
|
if conn != nil {
|
||||||
// Close the underlying websocket by triggering the runner's
|
_ = conn.Close()
|
||||||
// normal stop path; the websocketLoop goroutine will reconnect
|
}
|
||||||
// after we re-arm stopCh. This is the cleanest way to drive
|
if writeMu != nil {
|
||||||
// the loop without exposing internals.
|
writeMu.Lock()
|
||||||
select {
|
writeMu.Unlock()
|
||||||
case <-r.stopCh:
|
}
|
||||||
default:
|
r.lifecycleMu.Lock()
|
||||||
close(r.stopCh)
|
stopped := r.stopped()
|
||||||
|
r.lifecycleMu.Unlock()
|
||||||
|
if stopped {
|
||||||
|
return "", fmt.Errorf("worker: runner stopped")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case r.reconnectCh <- struct{}{}:
|
||||||
|
default:
|
||||||
}
|
}
|
||||||
})
|
|
||||||
_ = oldClient // client has no Close; the websocket layer owns it.
|
|
||||||
return newToken, nil
|
return newToken, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,162 @@ import (
|
|||||||
|
|
||||||
func TestEnqueueTaskMessagePrefersEnvelopeOverLegacyCheck(t *testing.T) {
|
func TestEnqueueTaskMessagePrefersEnvelopeOverLegacyCheck(t *testing.T) {
|
||||||
r := &Runner{jobQueue: make(chan wire.CheckJob, 2), stopCh: make(chan struct{})}
|
r := &Runner{jobQueue: make(chan wire.CheckJob, 2), stopCh: make(chan struct{})}
|
||||||
message := wire.WorkerMessage{Kind: "task", TaskEnvelope: &wire.TaskEnvelope{Type: wire.TaskTypeCheck, Job: &wire.CheckJob{JobID: "v2"}}, Task: &wire.CheckJob{JobID: "v1"}}
|
message := wire.WorkerMessage{Kind: "task", TaskEnvelope: &wire.TaskEnvelope{Type: wire.TaskTypeCheck, JobID: "v2", Job: &wire.CheckJob{JobID: "v2", LeaseToken: "lease-2", Kind: "http"}}, Task: &wire.CheckJob{JobID: "v1"}}
|
||||||
require.True(t, r.enqueueTaskMessage(message))
|
require.True(t, r.enqueueTaskMessage(message))
|
||||||
job := <-r.jobQueue
|
job := <-r.jobQueue
|
||||||
assert.Equal(t, "v2", job.JobID)
|
assert.Equal(t, "v2", job.JobID)
|
||||||
assert.Empty(t, r.jobQueue, "the sibling legacy field must not run a second check")
|
assert.Empty(t, r.jobQueue, "the sibling legacy field must not run a second check")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnqueueTaskMessageRejectsMismatchedEnvelopeJobIDs(t *testing.T) {
|
||||||
|
r := &Runner{jobQueue: make(chan wire.CheckJob, 1), results: make(chan resultEnvelope, 1), stopCh: make(chan struct{})}
|
||||||
|
message := wire.WorkerMessage{Kind: "task", TaskEnvelope: &wire.TaskEnvelope{
|
||||||
|
Type: wire.TaskTypeCheck,
|
||||||
|
JobID: "outer-job",
|
||||||
|
Job: &wire.CheckJob{JobID: "inner-job", LeaseToken: "lease-1", Kind: "http"},
|
||||||
|
}}
|
||||||
|
|
||||||
|
require.True(t, r.enqueueTaskMessage(message))
|
||||||
|
assert.Empty(t, r.jobQueue)
|
||||||
|
assert.Empty(t, r.results, "a task without an unambiguous job ID cannot be reported safely")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnqueueTaskMessageRejectsMissingEnvelopeJobID(t *testing.T) {
|
||||||
|
r := &Runner{jobQueue: make(chan wire.CheckJob, 1), results: make(chan resultEnvelope, 1), stopCh: make(chan struct{})}
|
||||||
|
message := wire.WorkerMessage{Kind: "task", TaskEnvelope: &wire.TaskEnvelope{
|
||||||
|
Type: wire.TaskTypeCheck,
|
||||||
|
Job: &wire.CheckJob{JobID: "inner-job", LeaseToken: "lease-1", Kind: "http"},
|
||||||
|
}}
|
||||||
|
|
||||||
|
require.True(t, r.enqueueTaskMessage(message))
|
||||||
|
assert.Empty(t, r.jobQueue)
|
||||||
|
assert.Empty(t, r.results, "a missing outer ID cannot be reported safely")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnqueueTaskMessageReportsMalformedCheckEnvelopeWithMatchingJobID(t *testing.T) {
|
||||||
|
r := &Runner{jobQueue: make(chan wire.CheckJob, 1), results: make(chan resultEnvelope, 1), stopCh: make(chan struct{})}
|
||||||
|
message := wire.WorkerMessage{Kind: "task", TaskEnvelope: &wire.TaskEnvelope{
|
||||||
|
Type: wire.TaskTypeNotification,
|
||||||
|
JobID: "job-1",
|
||||||
|
Job: &wire.CheckJob{JobID: "job-1", LeaseToken: "lease-1", CheckID: 4, MonitorID: 5, Kind: "http"},
|
||||||
|
}}
|
||||||
|
|
||||||
|
require.True(t, r.enqueueTaskMessage(message))
|
||||||
|
assert.Empty(t, r.jobQueue)
|
||||||
|
env := <-r.results
|
||||||
|
require.Len(t, env.reports, 1)
|
||||||
|
assert.Equal(t, "job-1", env.reports[0].JobID)
|
||||||
|
assert.Equal(t, "FAIL", env.reports[0].State)
|
||||||
|
require.NotNil(t, env.reports[0].Error)
|
||||||
|
assert.Equal(t, malformedTaskEnvelopeError, *env.reports[0].Error)
|
||||||
|
assert.Empty(t, r.results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnqueueTaskMessageRejectsAmbiguousEnvelope(t *testing.T) {
|
||||||
|
r := &Runner{
|
||||||
|
jobQueue: make(chan wire.CheckJob, 1),
|
||||||
|
results: make(chan resultEnvelope, 1),
|
||||||
|
notifyQueue: make(chan wire.NotificationTask, 1),
|
||||||
|
notifyResults: make(chan notifyResultEnvelope, 1),
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
message := wire.WorkerMessage{Kind: "task", TaskEnvelope: &wire.TaskEnvelope{
|
||||||
|
Type: wire.TaskTypeCheck,
|
||||||
|
JobID: "job-1",
|
||||||
|
Job: &wire.CheckJob{JobID: "job-1", LeaseToken: "lease-1", Kind: "http"},
|
||||||
|
Notify: &wire.NotificationTask{JobID: "job-1", LeaseToken: "lease-1"},
|
||||||
|
}}
|
||||||
|
|
||||||
|
require.True(t, r.enqueueTaskMessage(message))
|
||||||
|
assert.Empty(t, r.jobQueue)
|
||||||
|
assert.Empty(t, r.results)
|
||||||
|
assert.Empty(t, r.notifyQueue)
|
||||||
|
assert.Empty(t, r.notifyResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnqueueTaskMessageReportsUnsupportedCheckKind(t *testing.T) {
|
||||||
|
r := &Runner{jobQueue: make(chan wire.CheckJob, 1), results: make(chan resultEnvelope, 1), stopCh: make(chan struct{})}
|
||||||
|
message := wire.WorkerMessage{Kind: "task", TaskEnvelope: &wire.TaskEnvelope{
|
||||||
|
Type: wire.TaskTypeCheck,
|
||||||
|
JobID: "job-1",
|
||||||
|
Job: &wire.CheckJob{JobID: "job-1", LeaseToken: "lease-1", CheckID: 4, MonitorID: 5, Kind: "rkn"},
|
||||||
|
}}
|
||||||
|
|
||||||
|
require.True(t, r.enqueueTaskMessage(message))
|
||||||
|
assert.Empty(t, r.jobQueue, "unsupported work must never reach the executor")
|
||||||
|
env := <-r.results
|
||||||
|
require.Len(t, env.reports, 1)
|
||||||
|
report := env.reports[0]
|
||||||
|
assert.Equal(t, "job-1", report.JobID)
|
||||||
|
assert.Equal(t, int64(4), report.CheckID)
|
||||||
|
assert.Equal(t, int64(5), report.MonitorID)
|
||||||
|
assert.Equal(t, "FAIL", report.State)
|
||||||
|
require.NotNil(t, report.Error)
|
||||||
|
assert.Equal(t, "unsupported_kind: rkn", *report.Error)
|
||||||
|
assert.Empty(t, r.results, "each rejected task must generate one terminal result")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnqueueTaskMessageReportsMalformedNotificationForInvalidType(t *testing.T) {
|
||||||
|
r := &Runner{notifyQueue: make(chan wire.NotificationTask, 1), notifyResults: make(chan notifyResultEnvelope, 1), stopCh: make(chan struct{})}
|
||||||
|
message := wire.WorkerMessage{Kind: "task", TaskEnvelope: &wire.TaskEnvelope{
|
||||||
|
Type: "unknown",
|
||||||
|
JobID: "job-1",
|
||||||
|
Notify: &wire.NotificationTask{JobID: "job-1", LeaseToken: "lease-1", MessageID: 9, Method: "email"},
|
||||||
|
}}
|
||||||
|
|
||||||
|
require.True(t, r.enqueueTaskMessage(message))
|
||||||
|
assert.Empty(t, r.notifyQueue)
|
||||||
|
env := <-r.notifyResults
|
||||||
|
assert.Equal(t, "job-1", env.report.JobID)
|
||||||
|
assert.Equal(t, "lease-1", env.report.LeaseToken)
|
||||||
|
assert.Equal(t, int64(9), env.report.MessageID)
|
||||||
|
assert.Equal(t, wire.NotificationResultPermanent, env.report.Status)
|
||||||
|
require.NotNil(t, env.report.Error)
|
||||||
|
assert.Equal(t, malformedTaskEnvelopeError, *env.report.Error)
|
||||||
|
assert.Empty(t, r.notifyResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnqueueTaskMessageRejectsEmptyLeaseWithoutSideEffects(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
message wire.WorkerMessage
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "envelope check",
|
||||||
|
message: wire.WorkerMessage{Kind: "task", TaskEnvelope: &wire.TaskEnvelope{
|
||||||
|
Type: wire.TaskTypeCheck, JobID: "job-1", Job: &wire.CheckJob{JobID: "job-1", Kind: "http"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "envelope notification",
|
||||||
|
message: wire.WorkerMessage{Kind: "task", TaskEnvelope: &wire.TaskEnvelope{
|
||||||
|
Type: wire.TaskTypeNotification, JobID: "job-1", Notify: &wire.NotificationTask{JobID: "job-1", Method: "email"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "legacy check",
|
||||||
|
message: wire.WorkerMessage{Kind: "task", Task: &wire.CheckJob{JobID: "job-1", Kind: "http"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "legacy notification",
|
||||||
|
message: wire.WorkerMessage{Kind: "task", NotificationTask: &wire.NotificationTask{JobID: "job-1", Method: "email"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
r := &Runner{
|
||||||
|
jobQueue: make(chan wire.CheckJob, 1),
|
||||||
|
results: make(chan resultEnvelope, 1),
|
||||||
|
notifyQueue: make(chan wire.NotificationTask, 1),
|
||||||
|
notifyResults: make(chan notifyResultEnvelope, 1),
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
require.True(t, r.enqueueTaskMessage(tc.message))
|
||||||
|
assert.Empty(t, r.jobQueue)
|
||||||
|
assert.Empty(t, r.results)
|
||||||
|
assert.Empty(t, r.notifyQueue)
|
||||||
|
assert.Empty(t, r.notifyResults)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package distworker
|
package distworker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -8,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Jeffail/tunny"
|
"github.com/Jeffail/tunny"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
@@ -174,6 +178,835 @@ func TestEnqueueRespectsBackpressure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStopRejectsTerminalResultsWithoutClosingResultChannel(t *testing.T) {
|
||||||
|
r := NewRunner(&Config{MaxConcurrency: 1})
|
||||||
|
r.results = make(chan resultEnvelope, 1)
|
||||||
|
r.Stop()
|
||||||
|
|
||||||
|
assert.False(t, r.enqueueFailedCheck(wire.CheckJob{JobID: "job-1", LeaseToken: "lease-1"}, "unsupported_kind: rkn"))
|
||||||
|
assert.Empty(t, r.results)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case r.results <- resultEnvelope{}:
|
||||||
|
default:
|
||||||
|
t.Fatal("results channel should remain open after Stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRotateTokenReconnectsWithoutStoppingRunner(t *testing.T) {
|
||||||
|
const (
|
||||||
|
oldToken = "old-token"
|
||||||
|
newToken = "new-token"
|
||||||
|
)
|
||||||
|
|
||||||
|
connections := make(chan string, 2)
|
||||||
|
results := make(chan wire.WorkerMessage, 1)
|
||||||
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
switch req.URL.Path {
|
||||||
|
case "/api/internal/workers/rotate-token":
|
||||||
|
if req.Header.Get("Authorization") != "Bearer "+oldToken {
|
||||||
|
http.Error(w, "unexpected rotation token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(struct {
|
||||||
|
AuthToken string `json:"auth_token"`
|
||||||
|
}{AuthToken: newToken})
|
||||||
|
case "/worker":
|
||||||
|
conn, err := upgrader.Upgrade(w, req, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
token := req.URL.Query().Get("token")
|
||||||
|
connections <- token
|
||||||
|
if token == oldToken {
|
||||||
|
_, _, _ = conn.ReadMessage() // Rotation must close this connection.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if token != newToken {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if conn.WriteJSON(wire.WorkerMessage{Kind: "task", Task: &wire.CheckJob{
|
||||||
|
JobID: "after-rotation", LeaseToken: "lease", CheckID: 1, Kind: "http",
|
||||||
|
}}) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
var message wire.WorkerMessage
|
||||||
|
if err := conn.ReadJSON(&message); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if message.Kind == "result" && message.Result != nil && message.Result.JobID == "after-rotation" {
|
||||||
|
results <- message
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
http.NotFound(w, req)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
r := NewRunner(&Config{URL: server.URL, Token: oldToken, MaxConcurrency: 1})
|
||||||
|
r.executor = func(payload interface{}) interface{} {
|
||||||
|
job := payload.(wire.CheckJob)
|
||||||
|
return []wire.CheckResultReport{{JobID: job.JobID, CheckID: job.CheckID, State: "OK"}}
|
||||||
|
}
|
||||||
|
startDone := make(chan error, 1)
|
||||||
|
go func() { startDone <- r.Start() }()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
r.Stop()
|
||||||
|
select {
|
||||||
|
case err := <-startDone:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("runner did not stop")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case token := <-connections:
|
||||||
|
require.Equal(t, oldToken, token)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("worker did not establish its initial control connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
gotToken, err := r.RotateToken(t.Context())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, newToken, gotToken)
|
||||||
|
require.Equal(t, newToken, r.Token())
|
||||||
|
|
||||||
|
select {
|
||||||
|
case token := <-connections:
|
||||||
|
require.Equal(t, newToken, token)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("worker did not reconnect with the replacement token")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case result := <-results:
|
||||||
|
require.NotNil(t, result.Result)
|
||||||
|
assert.Equal(t, "OK", result.Result.State)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("runner did not execute work after token rotation")
|
||||||
|
}
|
||||||
|
assert.False(t, r.stopped(), "rotation must not stop runner-owned subsystems")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopClosesAndJoinsIdleControlConnection(t *testing.T) {
|
||||||
|
closed := make(chan struct{}, 1)
|
||||||
|
connected := make(chan struct{}, 1)
|
||||||
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.URL.Path != "/worker" {
|
||||||
|
http.NotFound(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn, err := upgrader.Upgrade(w, req, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
connected <- struct{}{}
|
||||||
|
_, _, _ = conn.ReadMessage()
|
||||||
|
closed <- struct{}{}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
r := NewRunner(&Config{URL: server.URL, Token: "token", MaxConcurrency: 1})
|
||||||
|
startDone := make(chan error, 1)
|
||||||
|
go func() { startDone <- r.Start() }()
|
||||||
|
t.Cleanup(func() { r.Stop() })
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("worker did not establish idle control connection")
|
||||||
|
case <-connected:
|
||||||
|
}
|
||||||
|
r.Stop()
|
||||||
|
select {
|
||||||
|
case <-closed:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Stop did not close the idle control connection")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-startDone:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Stop did not join the control loop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopCancelsDialInProgress(t *testing.T) {
|
||||||
|
dialStarted := make(chan struct{})
|
||||||
|
allowUpgrade := make(chan struct{})
|
||||||
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.URL.Path != "/worker" {
|
||||||
|
http.NotFound(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
close(dialStarted)
|
||||||
|
<-allowUpgrade
|
||||||
|
_, _ = upgrader.Upgrade(w, req, nil)
|
||||||
|
}))
|
||||||
|
defer func() {
|
||||||
|
close(allowUpgrade)
|
||||||
|
server.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
r := NewRunner(&Config{URL: server.URL, Token: "token", MaxConcurrency: 1})
|
||||||
|
startDone := make(chan error, 1)
|
||||||
|
go func() { startDone <- r.Start() }()
|
||||||
|
select {
|
||||||
|
case <-dialStarted:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("worker did not begin websocket dial")
|
||||||
|
}
|
||||||
|
r.Stop()
|
||||||
|
select {
|
||||||
|
case err := <-startDone:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Stop did not join a canceled websocket dial")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartAndStopRegisterControlLoopSafely(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
defer server.Close()
|
||||||
|
for i := 0; i < 25; i++ {
|
||||||
|
r := NewRunner(&Config{URL: server.URL, Token: "token", MaxConcurrency: 1})
|
||||||
|
startDone := make(chan error, 1)
|
||||||
|
stopDone := make(chan struct{})
|
||||||
|
go func() { startDone <- r.Start() }()
|
||||||
|
go func() {
|
||||||
|
r.Stop()
|
||||||
|
close(stopDone)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-stopDone:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Stop did not complete")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-startDone:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Start did not return after concurrent Stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRotateTokenPreservesDequeuedResult(t *testing.T) {
|
||||||
|
const (
|
||||||
|
oldToken = "old-token"
|
||||||
|
newToken = "new-token"
|
||||||
|
)
|
||||||
|
connected := make(chan string, 2)
|
||||||
|
delivered := make(chan wire.WorkerMessage, 1)
|
||||||
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
switch req.URL.Path {
|
||||||
|
case "/api/internal/workers/rotate-token":
|
||||||
|
_ = json.NewEncoder(w).Encode(struct {
|
||||||
|
AuthToken string `json:"auth_token"`
|
||||||
|
}{AuthToken: newToken})
|
||||||
|
case "/worker":
|
||||||
|
conn, err := upgrader.Upgrade(w, req, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
token := req.URL.Query().Get("token")
|
||||||
|
connected <- token
|
||||||
|
if token == oldToken {
|
||||||
|
var message wire.WorkerMessage
|
||||||
|
if conn.ReadJSON(&message) == nil {
|
||||||
|
delivered <- message
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if token != newToken {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var message wire.WorkerMessage
|
||||||
|
if conn.ReadJSON(&message) == nil {
|
||||||
|
delivered <- message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
r := NewRunner(&Config{URL: server.URL, Token: oldToken, MaxConcurrency: 1})
|
||||||
|
enteredWrite := make(chan struct{})
|
||||||
|
releaseWrite := make(chan struct{})
|
||||||
|
var once sync.Once
|
||||||
|
r.beforeControlWrite = func() {
|
||||||
|
once.Do(func() {
|
||||||
|
close(enteredWrite)
|
||||||
|
<-releaseWrite
|
||||||
|
})
|
||||||
|
}
|
||||||
|
startDone := make(chan error, 1)
|
||||||
|
go func() { startDone <- r.Start() }()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
r.Stop()
|
||||||
|
select {
|
||||||
|
case <-startDone:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("runner did not stop")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case token := <-connected:
|
||||||
|
require.Equal(t, oldToken, token)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("worker did not establish its initial control connection")
|
||||||
|
}
|
||||||
|
r.results <- resultEnvelope{job: wire.CheckJob{JobID: "result", LeaseToken: "lease"}, reports: []wire.CheckResultReport{{JobID: "result", State: "OK"}}}
|
||||||
|
select {
|
||||||
|
case <-enteredWrite:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("writer did not dequeue result")
|
||||||
|
}
|
||||||
|
rotated := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := r.RotateToken(t.Context())
|
||||||
|
rotated <- err
|
||||||
|
}()
|
||||||
|
close(releaseWrite)
|
||||||
|
require.NoError(t, <-rotated)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case message := <-delivered:
|
||||||
|
require.NotNil(t, message.Result)
|
||||||
|
assert.Equal(t, "result", message.Result.JobID)
|
||||||
|
assert.Equal(t, "lease", message.Result.LeaseToken)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("dequeued result was lost during rotation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRotateTokenSerializesWithStop(t *testing.T) {
|
||||||
|
rotationStarted := make(chan struct{})
|
||||||
|
releaseHandler := make(chan struct{})
|
||||||
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
switch req.URL.Path {
|
||||||
|
case "/api/internal/workers/rotate-token":
|
||||||
|
close(rotationStarted)
|
||||||
|
<-releaseHandler
|
||||||
|
case "/worker":
|
||||||
|
conn, err := upgrader.Upgrade(w, req, nil)
|
||||||
|
if err == nil {
|
||||||
|
defer conn.Close()
|
||||||
|
_, _, _ = conn.ReadMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer func() {
|
||||||
|
close(releaseHandler)
|
||||||
|
server.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
r := NewRunner(&Config{URL: server.URL, Token: "old-token", MaxConcurrency: 1})
|
||||||
|
startDone := make(chan error, 1)
|
||||||
|
go func() { startDone <- r.Start() }()
|
||||||
|
t.Cleanup(func() { r.Stop() })
|
||||||
|
|
||||||
|
// Wait for Start to install its client before beginning rotation.
|
||||||
|
deadline := time.After(time.Second)
|
||||||
|
for {
|
||||||
|
r.clientMu.Lock()
|
||||||
|
started := r.client != nil
|
||||||
|
r.clientMu.Unlock()
|
||||||
|
if started {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("runner did not start")
|
||||||
|
default:
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rotated := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := r.RotateToken(t.Context())
|
||||||
|
rotated <- err
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-rotationStarted:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("rotation request did not start")
|
||||||
|
}
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
r.Stop()
|
||||||
|
close(stopped)
|
||||||
|
}()
|
||||||
|
require.Error(t, <-rotated, "Stop must cancel an in-flight rotation request")
|
||||||
|
select {
|
||||||
|
case <-stopped:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Stop did not complete after canceling rotation")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-startDone:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("runner did not stop")
|
||||||
|
}
|
||||||
|
_, err := r.RotateToken(t.Context())
|
||||||
|
require.Error(t, err, "rotation cannot succeed after shutdown")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopUnblocksRotationWaitingForWriter(t *testing.T) {
|
||||||
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||||
|
connected := make(chan struct{}, 1)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
switch req.URL.Path {
|
||||||
|
case "/api/internal/workers/rotate-token":
|
||||||
|
_ = json.NewEncoder(w).Encode(struct {
|
||||||
|
AuthToken string `json:"auth_token"`
|
||||||
|
}{AuthToken: "new-token"})
|
||||||
|
case "/worker":
|
||||||
|
conn, err := upgrader.Upgrade(w, req, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
connected <- struct{}{}
|
||||||
|
_, _, _ = conn.ReadMessage()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
r := NewRunner(&Config{URL: server.URL, Token: "old-token", MaxConcurrency: 1})
|
||||||
|
writeBlocked := make(chan struct{})
|
||||||
|
var once sync.Once
|
||||||
|
r.beforeControlWrite = func() {
|
||||||
|
once.Do(func() {
|
||||||
|
close(writeBlocked)
|
||||||
|
<-r.controlCtx.Done()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
rotationReady := make(chan struct{})
|
||||||
|
r.beforeTokenCommit = func() { close(rotationReady) }
|
||||||
|
startDone := make(chan error, 1)
|
||||||
|
go func() { startDone <- r.Start() }()
|
||||||
|
t.Cleanup(func() { r.Stop() })
|
||||||
|
select {
|
||||||
|
case <-connected:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("worker did not connect")
|
||||||
|
}
|
||||||
|
r.results <- resultEnvelope{job: wire.CheckJob{JobID: "blocked", LeaseToken: "lease"}, reports: []wire.CheckResultReport{{JobID: "blocked", State: "OK"}}}
|
||||||
|
select {
|
||||||
|
case <-writeBlocked:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("writer did not block")
|
||||||
|
}
|
||||||
|
rotated := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := r.RotateToken(t.Context())
|
||||||
|
rotated <- err
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-rotationReady:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("rotation did not reach writer serialization")
|
||||||
|
}
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
r.Stop()
|
||||||
|
close(stopped)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-stopped:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Stop deadlocked behind rotation waiting for writer")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-rotated:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("rotation did not unblock after Stop closed the connection")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-startDone:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("runner did not stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStopWinsBeforePostHTTPRotationCommit(t *testing.T) {
|
||||||
|
commitReady := make(chan struct{})
|
||||||
|
releaseCommit := make(chan struct{})
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.URL.Path != "/api/internal/workers/rotate-token" {
|
||||||
|
http.NotFound(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(struct {
|
||||||
|
AuthToken string `json:"auth_token"`
|
||||||
|
}{AuthToken: "new-token"})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
r := NewRunner(&Config{URL: server.URL, Token: "old-token", MaxConcurrency: 1})
|
||||||
|
r.client = NewClient(server.URL, "old-token")
|
||||||
|
r.beforeTokenCommit = func() {
|
||||||
|
close(commitReady)
|
||||||
|
<-releaseCommit
|
||||||
|
}
|
||||||
|
rotated := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := r.RotateToken(t.Context())
|
||||||
|
rotated <- err
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-commitReady:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("rotation did not reach post-HTTP commit")
|
||||||
|
}
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
r.Stop()
|
||||||
|
close(stopped)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-r.stopCh:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Stop did not win lifecycle ownership")
|
||||||
|
}
|
||||||
|
close(releaseCommit)
|
||||||
|
require.Error(t, <-rotated, "rotation cannot succeed after Stop wins")
|
||||||
|
select {
|
||||||
|
case <-stopped:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Stop did not finish")
|
||||||
|
}
|
||||||
|
assert.Equal(t, "old-token", r.Token())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRotateTokenUnchangedResponseReleasesLifecycle(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(struct {
|
||||||
|
AuthToken string `json:"auth_token"`
|
||||||
|
}{AuthToken: "old-token"})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
r := NewRunner(&Config{URL: server.URL, Token: "old-token", MaxConcurrency: 1})
|
||||||
|
r.client = NewClient(server.URL, "old-token")
|
||||||
|
_, err := r.RotateToken(t.Context())
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
r.Stop()
|
||||||
|
close(stopped)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-stopped:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Stop deadlocked after unchanged rotation response")
|
||||||
|
}
|
||||||
|
_, err = r.RotateToken(t.Context())
|
||||||
|
require.Error(t, err, "later rotation must observe shutdown")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRotateTokenClosesStalledWriterBeforeWaiting(t *testing.T) {
|
||||||
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||||
|
connected := make(chan struct{}, 1)
|
||||||
|
connectionClosed := make(chan struct{})
|
||||||
|
resent := make(chan wire.WorkerMessage, 1)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
switch req.URL.Path {
|
||||||
|
case "/api/internal/workers/rotate-token":
|
||||||
|
_ = json.NewEncoder(w).Encode(struct {
|
||||||
|
AuthToken string `json:"auth_token"`
|
||||||
|
}{AuthToken: "new-token"})
|
||||||
|
case "/worker":
|
||||||
|
conn, err := upgrader.Upgrade(w, req, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if req.URL.Query().Get("token") == "old-token" {
|
||||||
|
connected <- struct{}{}
|
||||||
|
_, _, _ = conn.ReadMessage()
|
||||||
|
connectionClosed <- struct{}{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var message wire.WorkerMessage
|
||||||
|
if conn.ReadJSON(&message) == nil {
|
||||||
|
resent <- message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
r := NewRunner(&Config{URL: server.URL, Token: "old-token", MaxConcurrency: 1})
|
||||||
|
writerBlocked := make(chan struct{})
|
||||||
|
var once sync.Once
|
||||||
|
r.beforeControlWrite = func() {
|
||||||
|
once.Do(func() {
|
||||||
|
close(writerBlocked)
|
||||||
|
<-connectionClosed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
startDone := make(chan error, 1)
|
||||||
|
go func() { startDone <- r.Start() }()
|
||||||
|
t.Cleanup(func() { r.Stop() })
|
||||||
|
select {
|
||||||
|
case <-connected:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("worker did not connect")
|
||||||
|
}
|
||||||
|
r.results <- resultEnvelope{job: wire.CheckJob{JobID: "stalled", LeaseToken: "lease"}, reports: []wire.CheckResultReport{{JobID: "stalled", State: "OK"}}}
|
||||||
|
select {
|
||||||
|
case <-writerBlocked:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("writer did not stall")
|
||||||
|
}
|
||||||
|
rotated := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := r.RotateToken(t.Context())
|
||||||
|
rotated <- err
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case err := <-rotated:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("rotation waited for stalled writer before closing its connection")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case message := <-resent:
|
||||||
|
require.NotNil(t, message.Result)
|
||||||
|
assert.Equal(t, "stalled", message.Result.JobID)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("failed check result was not requeued after rotation")
|
||||||
|
}
|
||||||
|
r.Stop()
|
||||||
|
select {
|
||||||
|
case err := <-startDone:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("runner did not stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriterDropsFailedServerMetricSnapshot(t *testing.T) {
|
||||||
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||||
|
upgraded := make(chan struct{})
|
||||||
|
closeServer := make(chan struct{})
|
||||||
|
serverClosed := make(chan struct{})
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
conn, err := upgrader.Upgrade(w, req, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
close(upgraded)
|
||||||
|
<-closeServer
|
||||||
|
close(serverClosed)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
conn, err := NewClient(server.URL, "token").WorkerSocket()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer conn.Close()
|
||||||
|
select {
|
||||||
|
case <-upgraded:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("websocket did not connect")
|
||||||
|
}
|
||||||
|
|
||||||
|
r := NewRunner(&Config{})
|
||||||
|
r.metricResults = make(chan metricEnvelope, 1)
|
||||||
|
enteredWrite := make(chan struct{})
|
||||||
|
releaseWrite := make(chan struct{})
|
||||||
|
r.beforeControlWrite = func() {
|
||||||
|
close(enteredWrite)
|
||||||
|
<-releaseWrite
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
writerDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
var writeMu sync.Mutex
|
||||||
|
r.writer(conn, &writeMu, done, 1)
|
||||||
|
close(writerDone)
|
||||||
|
}()
|
||||||
|
r.metricResults <- metricEnvelope{generation: 1, report: wire.ServerMetricReport{ServerID: 1}}
|
||||||
|
select {
|
||||||
|
case <-enteredWrite:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("writer did not dequeue metric snapshot")
|
||||||
|
}
|
||||||
|
close(closeServer)
|
||||||
|
select {
|
||||||
|
case <-serverClosed:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("server did not close websocket")
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
close(releaseWrite)
|
||||||
|
select {
|
||||||
|
case <-writerDone:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("writer did not return after failed metric write")
|
||||||
|
}
|
||||||
|
_, replayable := r.takeOutbox()
|
||||||
|
assert.False(t, replayable, "failed metric snapshot must not enter result outbox")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriterRequeuesFailedNotificationResult(t *testing.T) {
|
||||||
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||||
|
upgraded := make(chan struct{})
|
||||||
|
closeServer := make(chan struct{})
|
||||||
|
serverClosed := make(chan struct{})
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
conn, err := upgrader.Upgrade(w, req, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
close(upgraded)
|
||||||
|
<-closeServer
|
||||||
|
close(serverClosed)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
conn, err := NewClient(server.URL, "token").WorkerSocket()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer conn.Close()
|
||||||
|
select {
|
||||||
|
case <-upgraded:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("websocket did not connect")
|
||||||
|
}
|
||||||
|
|
||||||
|
r := NewRunner(&Config{})
|
||||||
|
r.notifyResults = make(chan notifyResultEnvelope, 1)
|
||||||
|
enteredWrite := make(chan struct{})
|
||||||
|
releaseWrite := make(chan struct{})
|
||||||
|
r.beforeControlWrite = func() {
|
||||||
|
close(enteredWrite)
|
||||||
|
<-releaseWrite
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
writerDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
var writeMu sync.Mutex
|
||||||
|
r.writer(conn, &writeMu, done, 0)
|
||||||
|
close(writerDone)
|
||||||
|
}()
|
||||||
|
r.notifyResults <- notifyResultEnvelope{report: wire.NotificationResultReport{JobID: "notification", LeaseToken: "lease"}}
|
||||||
|
select {
|
||||||
|
case <-enteredWrite:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("writer did not dequeue notification result")
|
||||||
|
}
|
||||||
|
close(closeServer)
|
||||||
|
select {
|
||||||
|
case <-serverClosed:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("server did not close websocket")
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
close(releaseWrite)
|
||||||
|
select {
|
||||||
|
case <-writerDone:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("writer did not return after failed notification write")
|
||||||
|
}
|
||||||
|
message, replayable := r.takeOutbox()
|
||||||
|
require.True(t, replayable, "failed notification result must enter result outbox")
|
||||||
|
require.NotNil(t, message.NotificationResult)
|
||||||
|
assert.Equal(t, "notification", message.NotificationResult.JobID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetricGenerationRejectsDisconnectedAndSendsFreshMetric(t *testing.T) {
|
||||||
|
upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||||
|
firstClosed := make(chan struct{})
|
||||||
|
secondConnected := make(chan struct{})
|
||||||
|
received := make(chan wire.WorkerMessage, 1)
|
||||||
|
var connections atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
conn, err := upgrader.Upgrade(w, req, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if connections.Add(1) == 1 {
|
||||||
|
close(firstClosed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
close(secondConnected)
|
||||||
|
var message wire.WorkerMessage
|
||||||
|
if conn.ReadJSON(&message) == nil {
|
||||||
|
received <- message
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
r := NewRunner(&Config{URL: server.URL, Token: "token", MaxConcurrency: 1})
|
||||||
|
startDone := make(chan error, 1)
|
||||||
|
go func() { startDone <- r.Start() }()
|
||||||
|
t.Cleanup(func() { r.Stop() })
|
||||||
|
select {
|
||||||
|
case <-firstClosed:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("worker did not establish initial websocket")
|
||||||
|
}
|
||||||
|
// Wait until the old connection has fully torn down, then try to enqueue a
|
||||||
|
// metric in the old-drain/disconnected interleaving.
|
||||||
|
deadline := time.After(time.Second)
|
||||||
|
for r.metricGeneration.Load() != 0 {
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("old control generation did not clear")
|
||||||
|
default:
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
staleCount := 1
|
||||||
|
r.enqueueMetric(wire.ServerMetricReport{ServerID: 1, ProcessCount: &staleCount})
|
||||||
|
assert.Empty(t, r.metricResults, "disconnected metric must not enter bounded channel")
|
||||||
|
select {
|
||||||
|
case r.reconnectCh <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-secondConnected:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("worker did not reconnect")
|
||||||
|
}
|
||||||
|
deadline = time.After(time.Second)
|
||||||
|
for r.metricGeneration.Load() == 0 {
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("new control generation did not install")
|
||||||
|
default:
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// This enqueue occurs immediately after the new connection installation.
|
||||||
|
freshCount := 2
|
||||||
|
r.enqueueMetric(wire.ServerMetricReport{ServerID: 1, ProcessCount: &freshCount})
|
||||||
|
select {
|
||||||
|
case message := <-received:
|
||||||
|
require.NotNil(t, message.ServerMetric)
|
||||||
|
assert.Equal(t, 2, *message.ServerMetric.ProcessCount)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("fresh metric was not sent after reconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyInitResizesPool(t *testing.T) {
|
func TestApplyInitResizesPool(t *testing.T) {
|
||||||
executor := func(payload interface{}) interface{} {
|
executor := func(payload interface{}) interface{} {
|
||||||
return []wire.CheckResultReport{}
|
return []wire.CheckResultReport{}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package distworker
|
package distworker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -35,31 +36,40 @@ const (
|
|||||||
// the real Linux /proc and statfs collector; unsupported platforms return no
|
// the real Linux /proc and statfs collector; unsupported platforms return no
|
||||||
// report rather than fabricated values. The worker has no control-plane DB
|
// report rather than fabricated values. The worker has no control-plane DB
|
||||||
// access and forwards snapshots on its authenticated websocket.
|
// access and forwards snapshots on its authenticated websocket.
|
||||||
func (r *Runner) serverMetricLoop() {
|
func (r *Runner) serverMetricLoop(ctx context.Context) {
|
||||||
ticker := time.NewTicker(serverMetricInterval)
|
ticker := time.NewTicker(serverMetricInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-r.stopCh:
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
serverID := r.serverID.Load()
|
serverID := r.serverID.Load()
|
||||||
if serverID == 0 || r.metricResults == nil {
|
if serverID == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
report, ok := collectServerMetric(serverID)
|
report, ok := collectServerMetric(serverID)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
select {
|
r.enqueueMetric(report)
|
||||||
case r.metricResults <- report:
|
}
|
||||||
case <-r.stopCh:
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) enqueueMetric(report wire.ServerMetricReport) {
|
||||||
|
if r.metricResults == nil {
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
generation := r.metricGeneration.Load()
|
||||||
|
if generation == 0 || r.metricGeneration.Load() != generation {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case r.metricResults <- metricEnvelope{generation: generation, report: report}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectServerMetric(serverID int64) (wire.ServerMetricReport, bool) {
|
func collectServerMetric(serverID int64) (wire.ServerMetricReport, bool) {
|
||||||
memTotal, memAvailable, load1, load5, load15, uptime, ok := readLinuxHostMetrics("/proc")
|
memTotal, memAvailable, load1, load5, load15, uptime, ok := readLinuxHostMetrics("/proc")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"net"
|
"net"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
@@ -39,3 +40,16 @@ func TestKnownHostsMissingFile(t *testing.T) {
|
|||||||
t.Fatal("missing known_hosts file accepted")
|
t.Fatal("missing known_hosts file accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeployRejectsMutableDockerImageBeforeConnecting(t *testing.T) {
|
||||||
|
err := Deploy(DeployOptions{
|
||||||
|
Host: "unreachable.example.test",
|
||||||
|
User: "deploy",
|
||||||
|
Token: "token",
|
||||||
|
Docker: true,
|
||||||
|
Image: "reg.rsxx.ru/rsmon/rsmon-worker:latest",
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "immutable") {
|
||||||
|
t.Fatalf("Deploy() error = %v, want immutable image error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
var dockerImagePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/:@-]*$`)
|
var (
|
||||||
|
dockerImagePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/:-]*@sha256:[a-f0-9]{64}$`)
|
||||||
|
envKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultURL = "https://rsmon.ru"
|
DefaultURL = "https://rsmon.ru"
|
||||||
DefaultImage = "reg.rsxx.ru/rsmon/rsmon-worker:latest"
|
DefaultImage = ""
|
||||||
binaryPath = "/usr/local/bin/rsmon-worker"
|
binaryPath = "/usr/local/bin/rsmon-worker"
|
||||||
envPath = "/etc/rsmon-worker/worker.env"
|
envPath = "/etc/rsmon-worker/worker.env"
|
||||||
unitPath = "/etc/systemd/system/rsmon-worker.service"
|
unitPath = "/etc/systemd/system/rsmon-worker.service"
|
||||||
@@ -74,7 +78,11 @@ func Install(opts InstallOptions) error {
|
|||||||
if err := ValidateURL(opts.URL); err != nil {
|
if err := ValidateURL(opts.URL); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if opts.EnvFile == "" {
|
if opts.EnvFile != "" {
|
||||||
|
if err := ValidateEnvironmentFile(opts.EnvFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if err := ValidateToken(opts.Token); err != nil {
|
if err := ValidateToken(opts.Token); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -156,9 +164,50 @@ func ValidateToken(token string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateEnvironmentFile checks the worker credentials before installation
|
||||||
|
// changes the binary, systemd unit, or Docker image on the host.
|
||||||
|
func ValidateEnvironmentFile(path string) error {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read worker environment: %w", err)
|
||||||
|
}
|
||||||
|
values := make(map[string]string)
|
||||||
|
seenRequired := make(map[string]bool)
|
||||||
|
for number, line := range strings.Split(string(data), "\n") {
|
||||||
|
lineNumber := number + 1
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.ContainsRune(line, '\r') {
|
||||||
|
return fmt.Errorf("worker environment line %d contains a carriage return", lineNumber)
|
||||||
|
}
|
||||||
|
key, value, ok := strings.Cut(line, "=")
|
||||||
|
if !ok || !envKeyPattern.MatchString(key) {
|
||||||
|
return fmt.Errorf("worker environment line %d must use KEY=VALUE syntax", lineNumber)
|
||||||
|
}
|
||||||
|
if strings.IndexFunc(value, unicode.IsSpace) >= 0 || strings.ContainsAny(value, "$\\\"'") {
|
||||||
|
return fmt.Errorf("worker environment line %d uses unsupported quoting, interpolation, or whitespace", lineNumber)
|
||||||
|
}
|
||||||
|
if key == "RSMON_URL" || key == "RSMON_TOKEN" {
|
||||||
|
if seenRequired[key] {
|
||||||
|
return fmt.Errorf("worker environment line %d duplicates %s", lineNumber, key)
|
||||||
|
}
|
||||||
|
seenRequired[key] = true
|
||||||
|
}
|
||||||
|
values[key] = value
|
||||||
|
}
|
||||||
|
if err := ValidateURL(values["RSMON_URL"]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ValidateToken(values["RSMON_TOKEN"]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func ValidateImage(image string) error {
|
func ValidateImage(image string) error {
|
||||||
if !dockerImagePattern.MatchString(image) {
|
if !dockerImagePattern.MatchString(image) {
|
||||||
return errors.New("Docker image must be one non-option argument")
|
return errors.New("Docker image must be an immutable repository@sha256:<64 lowercase hex characters> reference")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package installer
|
package installer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -29,6 +31,65 @@ func TestValidateToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateEnvironmentFile(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
contents string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{name: "valid", contents: "# Worker credentials\nRSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret\n\n", valid: true},
|
||||||
|
{name: "missing URL", contents: "RSMON_TOKEN=secret\n"},
|
||||||
|
{name: "missing token", contents: "RSMON_URL=https://rsmon.ru\n"},
|
||||||
|
{name: "invalid URL", contents: "RSMON_URL=file:///tmp/worker\nRSMON_TOKEN=secret\n"},
|
||||||
|
{name: "additional settings", contents: "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret\nWORKER_HOST=0.0.0.0\nWORKER_URL=\n", valid: true},
|
||||||
|
{name: "dotenv interpolation", contents: "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=${TOKEN}\n"},
|
||||||
|
{name: "dotenv export", contents: "export RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret\n"},
|
||||||
|
{name: "YAML assignment", contents: "RSMON_URL: https://rsmon.ru\nRSMON_TOKEN=secret\n"},
|
||||||
|
{name: "malformed key", contents: "RSMON-URL=https://rsmon.ru\nRSMON_TOKEN=secret\n"},
|
||||||
|
{name: "missing assignment", contents: "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN\n"},
|
||||||
|
{name: "quoted value", contents: "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=\"secret\"\n"},
|
||||||
|
{name: "whitespace", contents: "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret value\n"},
|
||||||
|
{name: "duplicate URL", contents: "RSMON_URL=https://rsmon.ru\nRSMON_URL=https://evil.test\nRSMON_TOKEN=secret\n"},
|
||||||
|
{name: "duplicate empty token", contents: "RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=\nRSMON_TOKEN=secret\n"},
|
||||||
|
{name: "carriage return", contents: "RSMON_URL=https://rsmon.ru\r\nRSMON_TOKEN=secret\r\n"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "worker.env")
|
||||||
|
if err := os.WriteFile(path, []byte(tt.contents), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := ValidateEnvironmentFile(path)
|
||||||
|
if tt.valid && err != nil {
|
||||||
|
t.Fatalf("ValidateEnvironmentFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if !tt.valid && err == nil {
|
||||||
|
t.Fatal("ValidateEnvironmentFile() succeeded")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateEnvironmentFileInputErrors(t *testing.T) {
|
||||||
|
if err := ValidateEnvironmentFile(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||||
|
t.Fatal("missing environment file accepted")
|
||||||
|
}
|
||||||
|
if err := ValidateEnvironmentFile(t.TempDir()); err == nil {
|
||||||
|
t.Fatal("directory accepted as an environment file")
|
||||||
|
}
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
t.Skip("root can read mode-000 files")
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "unreadable")
|
||||||
|
if err := os.WriteFile(path, []byte("RSMON_URL=https://rsmon.ru\nRSMON_TOKEN=secret\n"), 0000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := ValidateEnvironmentFile(path); err == nil {
|
||||||
|
t.Fatal("unreadable environment file accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEnvironment(t *testing.T) {
|
func TestEnvironment(t *testing.T) {
|
||||||
got := string(Environment("https://example.test", "secret"))
|
got := string(Environment("https://example.test", "secret"))
|
||||||
for _, want := range []string{"RSMON_URL=https://example.test\n", "RSMON_TOKEN=secret\n", "WORKER_HOST=127.0.0.1\n"} {
|
for _, want := range []string{"RSMON_URL=https://example.test\n", "RSMON_TOKEN=secret\n", "WORKER_HOST=127.0.0.1\n"} {
|
||||||
@@ -45,10 +106,11 @@ func TestShellQuote(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateImage(t *testing.T) {
|
func TestValidateImage(t *testing.T) {
|
||||||
if err := ValidateImage(DefaultImage); err != nil {
|
const image = "reg.rsxx.ru/rsmon/rsmon-worker@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
if err := ValidateImage(image); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, image := range []string{"", "-bad", "image name", "image%stest", `image"test`} {
|
for _, image := range []string{"", "-bad", "image name", "image%stest", `image"test`, "reg.rsxx.ru/rsmon/rsmon-worker:latest", "reg.rsxx.ru/rsmon/rsmon-worker@sha256:short", "reg.rsxx.ru/rsmon/rsmon-worker@sha256:0123456789ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef"} {
|
||||||
if err := ValidateImage(image); err == nil {
|
if err := ValidateImage(image); err == nil {
|
||||||
t.Fatalf("ValidateImage(%q) succeeded", image)
|
t.Fatalf("ValidateImage(%q) succeeded", image)
|
||||||
}
|
}
|
||||||
@@ -59,8 +121,9 @@ func TestSystemdUnits(t *testing.T) {
|
|||||||
if !strings.Contains(systemdUnit, "Type=simple\nUser=root\n") || !strings.Contains(systemdUnit, "ExecStart=/usr/local/bin/rsmon-worker\n") {
|
if !strings.Contains(systemdUnit, "Type=simple\nUser=root\n") || !strings.Contains(systemdUnit, "ExecStart=/usr/local/bin/rsmon-worker\n") {
|
||||||
t.Fatal("binary systemd unit is not the simple root service")
|
t.Fatal("binary systemd unit is not the simple root service")
|
||||||
}
|
}
|
||||||
unit := DockerUnit(DefaultImage)
|
const image = "reg.rsxx.ru/rsmon/rsmon-worker@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
for _, want := range []string{"ExecStartPre=-docker rm -f rsmon-worker", "docker run --rm", DefaultImage} {
|
unit := DockerUnit(image)
|
||||||
|
for _, want := range []string{"ExecStartPre=-docker rm -f rsmon-worker", "docker run --rm", image} {
|
||||||
if !strings.Contains(unit, want) {
|
if !strings.Contains(unit, want) {
|
||||||
t.Fatalf("Docker systemd unit missing %q", want)
|
t.Fatalf("Docker systemd unit missing %q", want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ const (
|
|||||||
envWorkerLogin = "WORKER_LOGIN"
|
envWorkerLogin = "WORKER_LOGIN"
|
||||||
envWorkerPassword = "WORKER_PASSWORD"
|
envWorkerPassword = "WORKER_PASSWORD"
|
||||||
envClusterEnabled = "WORKER_CLUSTER_ENABLED"
|
envClusterEnabled = "WORKER_CLUSTER_ENABLED"
|
||||||
envClusterDebugApply = "WORKER_CLUSTER_DEBUG_APPLY"
|
|
||||||
envReleaseURL = "WORKER_RELEASE_URL"
|
envReleaseURL = "WORKER_RELEASE_URL"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -74,63 +74,6 @@ func (s *Server) handleClusterStatus(w http.ResponseWriter, _ *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleClusterApplyTestConfig applies a hardcoded config.adopt log
|
|
||||||
// entry to the cluster. It exists so the e2e script and any operator
|
|
||||||
// debugging session can verify FSM replication without having to wire
|
|
||||||
// up the real signed-config-adoption producer (which lives in a later
|
|
||||||
// phase).
|
|
||||||
//
|
|
||||||
// DEBUG: this endpoint is a placeholder for the real producer. It must
|
|
||||||
// be replaced (or removed) before any production deployment.
|
|
||||||
//
|
|
||||||
// The handler is gated behind Config.DebugClusterApply (env
|
|
||||||
// WORKER_CLUSTER_DEBUG_APPLY=true). When the flag is false the
|
|
||||||
// handler returns 404 — the route is still registered so the auth
|
|
||||||
// + CSRF paths are exercised in tests, but no real FSM entry is ever
|
|
||||||
// appended from a production webapp.
|
|
||||||
//
|
|
||||||
// TODO(worker-cluster-real-producer): remove the apply-test-config
|
|
||||||
// endpoint entirely once the signed-config-adoption producer ships.
|
|
||||||
func (s *Server) handleClusterApplyTestConfig(w http.ResponseWriter, r *http.Request) {
|
|
||||||
writeNoStore(w)
|
|
||||||
if !s.cfg.DebugClusterApply {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if s.cluster == nil {
|
|
||||||
http.Error(w, "cluster not configured", http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !s.requireCSRF(sessionFromContextOrFail(w, r), r) {
|
|
||||||
http.Error(w, "csrf token required", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
applied, err := s.cluster.ApplyTestConfig()
|
|
||||||
if err != nil {
|
|
||||||
s.deps.Logger.Printf("cluster apply test config: %v", err)
|
|
||||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
if err := json.NewEncoder(w).Encode(map[string]uint64{"applied_index": applied}); err != nil {
|
|
||||||
s.deps.Logger.Printf("cluster apply encode: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sessionFromContextOrFail is a tiny adapter so requireCSRF can be
|
|
||||||
// called from this handler without leaking the middleware into the
|
|
||||||
// cluster package. If no session is attached (should not happen
|
|
||||||
// because requireSession already ran) we return a stub session with
|
|
||||||
// no CSRF token, which causes requireCSRF to refuse the request.
|
|
||||||
func sessionFromContextOrFail(_ http.ResponseWriter, r *http.Request) *Session {
|
|
||||||
sess, _ := sessionFromContext(r.Context())
|
|
||||||
if sess != nil {
|
|
||||||
return sess
|
|
||||||
}
|
|
||||||
return &Session{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrClusterNotConfigured is returned when a cluster-admin endpoint is
|
// ErrClusterNotConfigured is returned when a cluster-admin endpoint is
|
||||||
// hit on a server without a cluster attached.
|
// hit on a server without a cluster attached.
|
||||||
var ErrClusterNotConfigured = errors.New("webapp: cluster not configured")
|
var ErrClusterNotConfigured = errors.New("webapp: cluster not configured")
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
package webapp
|
package webapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -21,21 +16,11 @@ import (
|
|||||||
// pinned without standing up a real raft group.
|
// pinned without standing up a real raft group.
|
||||||
type stubCluster struct {
|
type stubCluster struct {
|
||||||
stats ClusterStats
|
stats ClusterStats
|
||||||
applyIndex uint64
|
|
||||||
applyErr error
|
|
||||||
applyCalled int
|
|
||||||
applyMu sync.Mutex
|
|
||||||
clusterIDOut string
|
clusterIDOut string
|
||||||
addrOut string
|
addrOut string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubCluster) Stats() ClusterStats { return s.stats }
|
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) ClusterID() string { return s.clusterIDOut }
|
||||||
func (s *stubCluster) LocalAddr() string { return s.addrOut }
|
func (s *stubCluster) LocalAddr() string { return s.addrOut }
|
||||||
|
|
||||||
@@ -166,151 +151,23 @@ func TestClusterStatus_RequiresSession(t *testing.T) {
|
|||||||
assert.Equal(t, "/web/login", resp.Header.Get("Location"))
|
assert.Equal(t, "/web/login", resp.Header.Get("Location"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestClusterApplyTestConfig_NotConfigured verifies the 404 path
|
// TestClusterApplyTestConfig_NotExposed verifies production requests cannot
|
||||||
// when WORKER_CLUSTER_DEBUG_APPLY is false (the production default)
|
// append a hardcoded config through the former debug endpoint.
|
||||||
// and no cluster is attached. The handler must refuse before it
|
func TestClusterApplyTestConfig_NotExposed(t *testing.T) {
|
||||||
// even checks the cluster because the debug flag is off.
|
t.Setenv("WORKER_CLUSTER_DEBUG_APPLY", "true")
|
||||||
func TestClusterApplyTestConfig_NotConfigured(t *testing.T) {
|
ts, _, c := withClusterServer(t, &stubCluster{})
|
||||||
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)
|
|
||||||
|
|
||||||
resp, err := c.PostForm(ts.URL+"/web/api/cluster/apply-test-config", url.Values{})
|
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,
|
|
||||||
"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)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close() //nolint:errcheck
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
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
|
// TestSetClusterDetaches verifies SetCluster(nil) returns the server
|
||||||
// to the no-cluster-attached state (503 from the endpoints).
|
// to the no-cluster-attached state (503 from the endpoints).
|
||||||
func TestSetClusterDetaches(t *testing.T) {
|
func TestSetClusterDetaches(t *testing.T) {
|
||||||
srv := newTestServer(t, &stubRunner{id: "w-1"})
|
srv := newTestServer(t, &stubRunner{id: "w-1"})
|
||||||
stub := &stubCluster{applyIndex: 7}
|
stub := &stubCluster{}
|
||||||
srv.SetCluster(stub)
|
srv.SetCluster(stub)
|
||||||
require.NotNil(t, srv.Cluster())
|
require.NotNil(t, srv.Cluster())
|
||||||
|
|
||||||
@@ -326,11 +183,3 @@ func TestSetClusterDetaches(t *testing.T) {
|
|||||||
defer resp.Body.Close() //nolint:errcheck
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
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
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -224,7 +224,10 @@ func TestChecksPage_RunNowDisabledButton(t *testing.T) {
|
|||||||
// TestNotificationsPage_ResendDisabledButton mirrors the checks
|
// TestNotificationsPage_ResendDisabledButton mirrors the checks
|
||||||
// page test for the resend button on /notifications.
|
// page test for the resend button on /notifications.
|
||||||
func TestNotificationsPage_ResendDisabledButton(t *testing.T) {
|
func TestNotificationsPage_ResendDisabledButton(t *testing.T) {
|
||||||
srv := newTestServer(t, &stubRunner{id: "w-1"})
|
srv := newTestServer(t, &stubRunner{id: "w-1", notifs: []NotificationRow{
|
||||||
|
{Kind: "email", Channel: "smtp", Subject: "selfcheck", Body: "main API down", OK: true, At: time.Now()},
|
||||||
|
{JobID: "delegated-job", Method: "sms", Status: "permanent", DurationMs: 12, At: time.Now()},
|
||||||
|
}})
|
||||||
ts := newHTTPTestServer(t, srv)
|
ts := newHTTPTestServer(t, srv)
|
||||||
c, _ := loginAsFirstRun(t, ts.URL, srv)
|
c, _ := loginAsFirstRun(t, ts.URL, srv)
|
||||||
clearRequiresChange(t, srv)
|
clearRequiresChange(t, srv)
|
||||||
@@ -239,6 +242,11 @@ func TestNotificationsPage_ResendDisabledButton(t *testing.T) {
|
|||||||
assert.Contains(t, page, "Resend")
|
assert.Contains(t, page, "Resend")
|
||||||
assert.Contains(t, page, "disabled")
|
assert.Contains(t, page, "disabled")
|
||||||
assert.Contains(t, page, "worker-notifier-mvp")
|
assert.Contains(t, page, "worker-notifier-mvp")
|
||||||
|
assert.Contains(t, page, "selfcheck")
|
||||||
|
assert.Contains(t, page, "delegated-job")
|
||||||
|
assert.Contains(t, page, "sms")
|
||||||
|
assert.Contains(t, page, "permanent")
|
||||||
|
assert.Contains(t, page, "12 ms")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAppsPage_ReferencesInventoryPlan ensures the copy on
|
// TestAppsPage_ReferencesInventoryPlan ensures the copy on
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ func (s *Server) routes() {
|
|||||||
// Both routes require a session (the worker webapp is single-tenant
|
// Both routes require a session (the worker webapp is single-tenant
|
||||||
// so every logged-in operator is effectively an admin).
|
// so every logged-in operator is effectively an admin).
|
||||||
s.mux.Handle("GET /web/api/cluster/status", s.requireSession(s.handleClusterStatus))
|
s.mux.Handle("GET /web/api/cluster/status", s.requireSession(s.handleClusterStatus))
|
||||||
s.mux.Handle("POST /web/api/cluster/apply-test-config", s.requireSession(s.handleClusterApplyTestConfig))
|
|
||||||
|
|
||||||
// Cross-worker peer status. The path is intentionally under
|
// Cross-worker peer status. The path is intentionally under
|
||||||
// /api/ (not /web/api/) so the basic-auth middleware does not
|
// /api/ (not /web/api/) so the basic-auth middleware does not
|
||||||
|
|||||||
@@ -80,16 +80,6 @@ type Config struct {
|
|||||||
BasicAuthLogin string
|
BasicAuthLogin string
|
||||||
BasicAuthPassword string
|
BasicAuthPassword string
|
||||||
|
|
||||||
// DebugClusterApply gates the /web/api/cluster/apply-test-config
|
|
||||||
// endpoint. When false (the default) the route is registered but
|
|
||||||
// the handler returns 404 so the endpoint is invisible in
|
|
||||||
// production. Operators who want to poke the cluster FSM during
|
|
||||||
// development set WORKER_CLUSTER_DEBUG_APPLY=true. The endpoint
|
|
||||||
// must NEVER be reachable in production — it appends hardcoded
|
|
||||||
// log entries to the Raft FSM without going through the real
|
|
||||||
// config-adoption producer.
|
|
||||||
DebugClusterApply bool
|
|
||||||
|
|
||||||
// ReleaseURL is the optional URL the worker polls to discover
|
// ReleaseURL is the optional URL the worker polls to discover
|
||||||
// the latest published version of the worker binary. When empty
|
// the latest published version of the worker binary. When empty
|
||||||
// the /updates page shows the placeholder "v1 (dev)". The URL
|
// the /updates page shows the placeholder "v1 (dev)". The URL
|
||||||
@@ -186,7 +176,6 @@ func ConfigFromEnv(env map[string]string, defaultDataDir string) (Config, error)
|
|||||||
if v := env["RSMON_WEBAPP_STORE_PATH"]; v != "" {
|
if v := env["RSMON_WEBAPP_STORE_PATH"]; v != "" {
|
||||||
cfg.StorePath = v
|
cfg.StorePath = v
|
||||||
}
|
}
|
||||||
cfg.DebugClusterApply = parseBool(env[envClusterDebugApply])
|
|
||||||
cfg.ReleaseURL = strings.TrimSpace(env[envReleaseURL])
|
cfg.ReleaseURL = strings.TrimSpace(env[envReleaseURL])
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
@@ -212,7 +201,7 @@ func ConfigFromEnvOrDefault() Config {
|
|||||||
envWorkerHost, envWorkerPort, envWorkerURL, envWorkerLogin, envWorkerPassword,
|
envWorkerHost, envWorkerPort, envWorkerURL, envWorkerLogin, envWorkerPassword,
|
||||||
"RSMON_WEBAPP_DATA_DIR", "RSMON_WEBAPP_STORE_PATH",
|
"RSMON_WEBAPP_DATA_DIR", "RSMON_WEBAPP_STORE_PATH",
|
||||||
"WORKER_CLUSTER_ENABLED",
|
"WORKER_CLUSTER_ENABLED",
|
||||||
envClusterDebugApply, envReleaseURL,
|
envReleaseURL,
|
||||||
} {
|
} {
|
||||||
if v := os.Getenv(k); v != "" {
|
if v := os.Getenv(k); v != "" {
|
||||||
env[k] = v
|
env[k] = v
|
||||||
@@ -254,7 +243,6 @@ type Deps struct {
|
|||||||
// pulling in the raft package or bbolt.
|
// pulling in the raft package or bbolt.
|
||||||
type ClusterView interface {
|
type ClusterView interface {
|
||||||
Stats() ClusterStats
|
Stats() ClusterStats
|
||||||
ApplyTestConfig() (uint64, error)
|
|
||||||
ClusterID() string
|
ClusterID() string
|
||||||
LocalAddr() string
|
LocalAddr() string
|
||||||
}
|
}
|
||||||
@@ -320,9 +308,7 @@ type ResultRow struct {
|
|||||||
At time.Time
|
At time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotificationRow is one row from the worker's in-memory notification
|
// NotificationRow is one row from the worker's in-memory notification ring.
|
||||||
// ring buffer. Phase 1 only emits selfcheck alerts; main-app-issued
|
|
||||||
// notifications still live in the main app's DB.
|
|
||||||
type NotificationRow struct {
|
type NotificationRow struct {
|
||||||
Kind string // "email", "telegram_private", "telegram_group"
|
Kind string // "email", "telegram_private", "telegram_group"
|
||||||
Channel string
|
Channel string
|
||||||
@@ -331,6 +317,11 @@ type NotificationRow struct {
|
|||||||
OK bool
|
OK bool
|
||||||
Error string
|
Error string
|
||||||
At time.Time
|
At time.Time
|
||||||
|
|
||||||
|
JobID string
|
||||||
|
Method string
|
||||||
|
Status string
|
||||||
|
DurationMs int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the local HTTP server for the worker webapp. It owns the
|
// Server is the local HTTP server for the worker webapp. It owns the
|
||||||
|
|||||||
@@ -88,40 +88,6 @@ func TestConfigFromEnvBasicAuthRejectsXOR(t *testing.T) {
|
|||||||
assert.Error(t, err, "XOR (password only) must be rejected")
|
assert.Error(t, err, "XOR (password only) must be rejected")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConfigFromEnvDebugClusterApply pins the default-off behavior
|
|
||||||
// of the cluster-apply debug gate and verifies the env flag flips
|
|
||||||
// it on. Production builds must not accidentally expose the
|
|
||||||
// endpoint, so the default is false.
|
|
||||||
func TestConfigFromEnvDebugClusterApply(t *testing.T) {
|
|
||||||
cfg, err := ConfigFromEnv(map[string]string{}, t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, cfg.DebugClusterApply, "default must leave the debug flag off")
|
|
||||||
|
|
||||||
cfg, err = ConfigFromEnv(map[string]string{
|
|
||||||
"WORKER_CLUSTER_DEBUG_APPLY": "true",
|
|
||||||
}, t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, cfg.DebugClusterApply)
|
|
||||||
|
|
||||||
// Other truthy spellings accepted.
|
|
||||||
for _, v := range []string{"yes", "1", "TRUE", "YeS"} {
|
|
||||||
cfg, err = ConfigFromEnv(map[string]string{
|
|
||||||
"WORKER_CLUSTER_DEBUG_APPLY": v,
|
|
||||||
}, t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, cfg.DebugClusterApply, "must accept truthy value %q", v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty / unknown values stay false.
|
|
||||||
for _, v := range []string{"", "false", "0", "no"} {
|
|
||||||
cfg, err = ConfigFromEnv(map[string]string{
|
|
||||||
"WORKER_CLUSTER_DEBUG_APPLY": v,
|
|
||||||
}, t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, cfg.DebugClusterApply, "must reject non-truthy value %q", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfigFromEnvReleaseURL pins the env-driven WORKER_RELEASE_URL
|
// TestConfigFromEnvReleaseURL pins the env-driven WORKER_RELEASE_URL
|
||||||
// plumbing. The handler reads cfg.ReleaseURL when the page renders,
|
// plumbing. The handler reads cfg.ReleaseURL when the page renders,
|
||||||
// so the value must survive ConfigFromEnv exactly.
|
// so the value must survive ConfigFromEnv exactly.
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
{{define "body"}}<section class="card">
|
{{define "body"}}<section class="card">
|
||||||
<h1>Recent notifications</h1>
|
<h1>Recent notifications</h1>
|
||||||
<p class="muted">Last 50 notifications emitted by this worker (Phase 1: selfcheck alerts only).</p>
|
<p class="muted">Last 50 notification attempts emitted by this worker.</p>
|
||||||
<p><button type="button" disabled title="{{.ResendTooltip}}">Resend selected</button>
|
<p><button type="button" disabled title="{{.ResendTooltip}}">Resend selected</button>
|
||||||
<span class="muted">{{.ResendTooltip}}</span></p>
|
<span class="muted">{{.ResendTooltip}}</span></p>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Channel</th>
|
<th>Type</th>
|
||||||
<th>Subject</th>
|
<th>Details</th>
|
||||||
<th>Body</th>
|
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Duration</th>
|
||||||
<th>Error</th>
|
<th>Error</th>
|
||||||
<th>When</th>
|
<th>When</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -17,11 +17,19 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{range .Rows}}
|
{{range .Rows}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{.Channel}} ({{.Kind}})</td>
|
{{if .JobID}}
|
||||||
<td>{{.Subject}}</td>
|
<td>delegated</td>
|
||||||
<td><code>{{.Body}}</code></td>
|
<td>job <code>{{.JobID}}</code>, {{.Method}}</td>
|
||||||
|
<td>{{.Status}}</td>
|
||||||
|
<td>{{.DurationMs}} ms</td>
|
||||||
|
<td></td>
|
||||||
|
{{else}}
|
||||||
|
<td>selfcheck</td>
|
||||||
|
<td>{{.Channel}} ({{.Kind}}): {{.Subject}} <code>{{.Body}}</code></td>
|
||||||
<td>{{if .OK}}<span class="ok">delivered</span>{{else}}<span class="error">failed</span>{{end}}</td>
|
<td>{{if .OK}}<span class="ok">delivered</span>{{else}}<span class="error">failed</span>{{end}}</td>
|
||||||
|
<td>-</td>
|
||||||
<td>{{.Error}}</td>
|
<td>{{.Error}}</td>
|
||||||
|
{{end}}
|
||||||
<td>{{fmtTime .At}}</td>
|
<td>{{fmtTime .At}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@@ -72,11 +72,9 @@ func TestClusterSmokeStats(t *testing.T) {
|
|||||||
assert.Equal(t, addr, c.LocalAddr())
|
assert.Equal(t, addr, c.LocalAddr())
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestClusterApplyTestConfig_Smoke verifies the ApplyTestConfig helper
|
// TestClusterTestConfig_Smoke verifies the test-only helper commits a
|
||||||
// commits a config.adopt entry and the FSM reflects the new version.
|
// config.adopt entry and the FSM reflects the new version.
|
||||||
// This is the function the webapp admin endpoint and the CLI flag
|
func TestClusterTestConfig_Smoke(t *testing.T) {
|
||||||
// both go through.
|
|
||||||
func TestClusterApplyTestConfig_Smoke(t *testing.T) {
|
|
||||||
addr := pickPort(t)
|
addr := pickPort(t)
|
||||||
dataDir := t.TempDir()
|
dataDir := t.TempDir()
|
||||||
creds := HTTPCreds{Login: "alice", Password: "secret"}
|
creds := HTTPCreds{Login: "alice", Password: "secret"}
|
||||||
@@ -108,8 +106,8 @@ func TestClusterApplyTestConfig_Smoke(t *testing.T) {
|
|||||||
require.Equal(t, raft.Leader, c.Raft().State(),
|
require.Equal(t, raft.Leader, c.Raft().State(),
|
||||||
"smoke test requires the local node to be leader")
|
"smoke test requires the local node to be leader")
|
||||||
|
|
||||||
check := DefaultDebugCriticalCheck()
|
check := defaultTestCriticalCheck()
|
||||||
idx, err := c.ApplyTestConfig(&check)
|
idx, err := c.applyTestConfig(&check)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotZero(t, idx, "applied index must be non-zero")
|
assert.NotZero(t, idx, "applied index must be non-zero")
|
||||||
|
|
||||||
@@ -164,11 +162,11 @@ func TestClusterStats_FSMFieldsOnFreshCluster(t *testing.T) {
|
|||||||
"newly created FSM defaults Partition.State to 'steady'")
|
"newly created FSM defaults Partition.State to 'steady'")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestApplyTestConfig_NonLeaderErrors pins the precondition that the
|
// TestTestConfig_NonLeaderErrors pins the precondition that the
|
||||||
// helper refuses to submit an entry on a non-leader (the raft library
|
// helper refuses to submit an entry on a non-leader (the raft library
|
||||||
// would reject the apply anyway, but we want the failure to be
|
// would reject the apply anyway, but we want the failure to be
|
||||||
// deterministic and informative).
|
// deterministic and informative).
|
||||||
func TestApplyTestConfig_NonLeaderErrors(t *testing.T) {
|
func TestTestConfig_NonLeaderErrors(t *testing.T) {
|
||||||
// Three-node fixture so we have a clear "not the leader" node.
|
// Three-node fixture so we have a clear "not the leader" node.
|
||||||
if testing.Short() {
|
if testing.Short() {
|
||||||
t.Skip("3-node smoke skipped in -short mode")
|
t.Skip("3-node smoke skipped in -short mode")
|
||||||
@@ -187,9 +185,9 @@ func TestApplyTestConfig_NonLeaderErrors(t *testing.T) {
|
|||||||
require.NotNil(t, leader)
|
require.NotNil(t, leader)
|
||||||
require.NotNil(t, follower)
|
require.NotNil(t, follower)
|
||||||
|
|
||||||
check := DefaultDebugCriticalCheck()
|
check := defaultTestCriticalCheck()
|
||||||
_, err := follower.ApplyTestConfig(&check)
|
_, err := follower.applyTestConfig(&check)
|
||||||
require.Error(t, err, "non-leader must refuse ApplyTestConfig")
|
require.Error(t, err, "non-leader must refuse test config application")
|
||||||
assert.Contains(t, strings.ToLower(err.Error()), "not leader")
|
assert.Contains(t, strings.ToLower(err.Error()), "not leader")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package workercluster
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -325,63 +324,6 @@ func (c *Cluster) Snapshot() error {
|
|||||||
return r.Snapshot().Error()
|
return r.Snapshot().Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultDebugCriticalCheck returns the hardcoded CriticalCheckConfig
|
|
||||||
// the cluster admin debug endpoint and the
|
|
||||||
// --cluster-debug-apply-test-config CLI flag apply. A fresh Epoch is
|
|
||||||
// stamped on every call so repeated applies produce distinct entries
|
|
||||||
// (handy for verifying replication timing).
|
|
||||||
func DefaultDebugCriticalCheck() CriticalCheckConfig {
|
|
||||||
return CriticalCheckConfig{
|
|
||||||
ID: 9999,
|
|
||||||
Kind: "distributed_critical",
|
|
||||||
IntervalS: 30,
|
|
||||||
Target: "http://example.com",
|
|
||||||
Epoch: time.Now().UTC().UnixNano(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyTestConfig submits a hardcoded config.adopt log entry with the
|
|
||||||
// supplied CriticalCheckConfig. Returns the applied log index. This
|
|
||||||
// is a debug convenience used by the e2e script and the
|
|
||||||
// --cluster-debug-apply-test-config CLI flag; production code should
|
|
||||||
// build entries from the real signed-config-adoption producer
|
|
||||||
// (Phase-N work).
|
|
||||||
//
|
|
||||||
// DEBUG: this exists only so the e2e shell script can verify FSM
|
|
||||||
// replication without a real producer wired in.
|
|
||||||
//
|
|
||||||
// TODO(phase-N): remove once the real producer lands.
|
|
||||||
func (c *Cluster) ApplyTestConfig(check *CriticalCheckConfig) (uint64, error) {
|
|
||||||
c.mu.Lock()
|
|
||||||
r := c.raft
|
|
||||||
c.mu.Unlock()
|
|
||||||
if r == nil {
|
|
||||||
return 0, errors.New("workercluster: not started")
|
|
||||||
}
|
|
||||||
if r.State() != raft.Leader {
|
|
||||||
return 0, errors.New("workercluster: not leader; submit on the leader")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := ConfigAdoptPayload{
|
|
||||||
Version: 1,
|
|
||||||
Actor: c.opts.NodeID,
|
|
||||||
Checks: []CriticalCheckConfig{*check},
|
|
||||||
}
|
|
||||||
raw, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("workercluster: encode payload: %w", err)
|
|
||||||
}
|
|
||||||
entry, err := EncodeEntry(&Entry{Type: EntryConfigAdopt, Adopted: raw})
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("workercluster: encode entry: %w", err)
|
|
||||||
}
|
|
||||||
fut := r.Apply(entry, 10*time.Second)
|
|
||||||
if err := fut.Error(); err != nil {
|
|
||||||
return 0, fmt.Errorf("workercluster: apply test config: %w", err)
|
|
||||||
}
|
|
||||||
return fut.Index(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClusterID returns the NodeID this cluster was constructed with. It
|
// ClusterID returns the NodeID this cluster was constructed with. It
|
||||||
// is exposed so HTTP handlers can label status responses with a
|
// is exposed so HTTP handlers can label status responses with a
|
||||||
// stable identifier even when the raft library's own State() reports
|
// stable identifier even when the raft library's own State() reports
|
||||||
|
|||||||
49
internal/workercluster/test_config_test.go
Обычный файл
49
internal/workercluster/test_config_test.go
Обычный файл
@@ -0,0 +1,49 @@
|
|||||||
|
package workercluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/raft"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultTestCriticalCheck is test-only scaffold input for replication tests.
|
||||||
|
func defaultTestCriticalCheck() CriticalCheckConfig {
|
||||||
|
return CriticalCheckConfig{
|
||||||
|
ID: 9999,
|
||||||
|
Kind: "distributed_critical",
|
||||||
|
IntervalS: 30,
|
||||||
|
Target: "http://example.com",
|
||||||
|
Epoch: time.Now().UTC().UnixNano(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyTestConfig is deliberately compiled only into workercluster tests.
|
||||||
|
func (c *Cluster) applyTestConfig(check *CriticalCheckConfig) (uint64, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
r := c.raft
|
||||||
|
c.mu.Unlock()
|
||||||
|
if r == nil {
|
||||||
|
return 0, errors.New("workercluster: not started")
|
||||||
|
}
|
||||||
|
if r.State() != raft.Leader {
|
||||||
|
return 0, errors.New("workercluster: not leader; submit on the leader")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := ConfigAdoptPayload{Version: 1, Actor: c.opts.NodeID, Checks: []CriticalCheckConfig{*check}}
|
||||||
|
raw, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("workercluster: encode payload: %w", err)
|
||||||
|
}
|
||||||
|
entry, err := EncodeEntry(&Entry{Type: EntryConfigAdopt, Adopted: raw})
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("workercluster: encode entry: %w", err)
|
||||||
|
}
|
||||||
|
fut := r.Apply(entry, 10*time.Second)
|
||||||
|
if err := fut.Error(); err != nil {
|
||||||
|
return 0, fmt.Errorf("workercluster: apply test config: %w", err)
|
||||||
|
}
|
||||||
|
return fut.Index(), nil
|
||||||
|
}
|
||||||
Ссылка в новой задаче
Block a user