Compare commits

...
13 Commits
Author SHA1 Message Date
mrhid6 4ff8fc8d51 docs: document the workload registry
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 8m45s
Agent Release / build (push) Successful in 10m54s
Agent Release / msi (push) Successful in 2m31s
2026-08-07 09:09:18 +01:00
mrhid6 483053b9a2 feat: workload registry UI 2026-08-07 09:06:36 +01:00
mrhid6 fd4c51f3db feat: workload registry REST API 2026-08-07 09:01:46 +01:00
mrhid6 1b351cfca4 feat: agent reports workloads and handles workload commands 2026-08-07 08:58:46 +01:00
mrhid6 cf9d85b3cd feat: store workload reports and route log results 2026-08-07 08:56:26 +01:00
mrhid6 6a4ef5b6c6 feat: workload registry proto messages 2026-08-07 08:53:33 +01:00
mrhid6 501cf4e733 feat: agent reads bounded workload logs 2026-08-07 08:49:42 +01:00
mrhid6 89c21d752a feat: agent control actions with self-protection 2026-08-07 08:48:27 +01:00
mrhid6 0e38d9d500 feat: agent enumerates systemd services 2026-08-07 08:46:59 +01:00
mrhid6 3511c34daa feat: agent enumerates docker containers 2026-08-07 08:46:03 +01:00
mrhid6 0838d1d735 feat: models and indexes for the workload registry 2026-08-07 08:45:00 +01:00
mrhid6 d1769fc886 feat: Updated vuln style
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m36s
2026-08-06 16:35:04 +01:00
mrhid6 6dced22499 fix: Fixed agent collect packages
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 21s
Agent Release / build (push) Successful in 58s
Agent Release / msi (push) Successful in 1m34s
2026-08-06 16:20:34 +01:00
39 changed files with 2581 additions and 137 deletions
+78 -2
View File
@@ -360,6 +360,76 @@ Two environment variables: `VANTAGE_TRIVY_DB_REF` mirrors the artifact for
air-gapped installs, and `VANTAGE_VULNDB_DISABLED` switches the puller and
scheduler off entirely.
### Workload registry
A **workload** is one Docker container or one systemd unit — one word for the
page, the collection and the commands, rather than saying "container or
service" in every identifier. Linux only, and **not gated by licence**: this
reads as core fleet management, so v1 ships everywhere with no `HasFeature`
check. If that changes the check belongs at `ReportWorkloads`, gating collection
rather than display, exactly as sub-project A does.
Agents collect on a 60-second ticker and report through `ReportWorkloads` with
the **offer-then-send** handshake the package report already uses. The offer is
identified by an explicit `full` flag, **not by an empty workloads list**: a
host genuinely running nothing sends an empty list as its full report, and
inferring the offer from emptiness leaves that host answering `need_full` every
60 seconds forever and never storing anything.
**The on-demand refresh returns no data.** `RefreshWorkloadsCmd` carries nothing
back; it makes the agent report through the normal RPC and the UI refetches. A
refresh that returned workloads inline would be a second writer for
`server_workloads`, arriving by a different route with its own serialisation and
its own opportunity to disagree with the periodic one. One writer, one shape.
Opening the panel dispatches a refresh because the panel has a Restart button on
it, and a stale row is a wrong action aimed at a container that already died.
Two operations do answer back, both over the bus, both with `Await` called
**before** dispatch: control actions reuse the existing `CommandResult`, and log
reads get `WorkloadLogsResult`. `CommandStream` republishes **every**
`CommandResult` onto `bus.ResultChannel` — publishing with no subscriber is a
no-op, so this costs nothing and avoids a second result path.
**The protected set is computed agent-side and enforced agent-side.**
`vantage-agent.service`, plus the container ID read from `/proc/self/cgroup`
should the agent ever run in a container. As with the console relay hardcoding
`127.0.0.1`, the control plane may name a target but the agent decides what it
will do to itself; a server-side denylist alone would be bypassed by the next
dispatch path someone adds, and the failure is unrecoverable from the UI. The
reported `Protected` flag is the courtesy that greys the button; the agent's own
check is the boundary. The API answers **409** when it fires — nothing failed.
Collection avoids parsing English: `docker ps -aq` then
`docker inspect --format '{{json .}}'`, because `docker ps` reports health and
uptime inside a human `Status` string that is localised and reworded between
releases. Compose stacks come from the `com.docker.compose.project` label, never
from YAML on disk — a compose file there may not be what is running. systemd
uses **column** output, not `--output=json`, which needs systemd 246+.
`DockerOK`/`DockerError` are two fields because there are three states: not
installed (common on this fleet, and not a fault), installed but not responding,
and running nothing. The UI must render the first as "not in use here" rather
than an empty list.
Logs are capped at **500 lines and 256KB, whichever binds first** — a line count
alone does not bound size, and 500 lines of 4KB JSON is 2MB across the bus. The
cap is mirrored in `services.MaxWorkloadLogLines` because `agent/` is a separate
module with an `internal/` tree and the constant cannot be shared; change one,
change the other. There is **no follow mode**: the browser console already gives
a real terminal where `docker logs -f` works properly. Log reads and control
actions are **owner|admin and audited**, unlike the read-only snapshot — a
container's stdout is arbitrary and cannot be masked the way a workflow's can.
`server_workloads` is one document per server, mirroring `server_packages`, and
is in `ScopedCollections` (which `scopedCollectionsForPurge` derives from). There
is no history: a workload list is state, not a record.
**`proto/vantage/v1/vantage.proto` is documentation, not a generator input.**
Both `pb` packages are hand-written JSON-tagged structs over a custom codec, and
there are two copies — `agent/internal/grpc/pb` and `server/internal/grpc/pb`.
A message added to one must be added to the other and to the `.proto`, in the
same commit.
### Agent self-update
`UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself.
@@ -512,6 +582,7 @@ service Vantage {
rpc SyncKeys(SyncRequest) returns (SyncResponse);
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
rpc ReportWorkloads(ReportWorkloadsRequest) returns (ReportWorkloadsResponse);
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
@@ -521,7 +592,8 @@ service Vantage {
`CommandStream` is the only streaming RPC: the agent authenticates once with `AgentReady`, then the server pushes `ServerCommand`s and the agent replies with `CommandResult`, `StepResult`, or `StepOutputChunk`.
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`, `OpenProxyCmd`, `PingCmd`.
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`, `OpenProxyCmd`, `PingCmd`, `RefreshWorkloadsCmd`, `ControlWorkloadCmd`,
`WorkloadLogsCmd`.
**`PingCmd` is a liveness beat, and it is not redundant with gRPC keepalive.**
The server sends one every 20s on an otherwise idle command stream; the agent
@@ -578,6 +650,10 @@ vulns GET /vulnerabilities · GET /vulnerabilities/summary
GET /servers/:id/vulnerabilities · GET /servers/:id/packages
GET /packages/search?name=
GET,POST /vuln-rules · PUT,DELETE /vuln-rules/:id (owner|admin)
workloads GET /workloads · GET /servers/:id/workloads
POST /servers/:id/workloads/refresh
POST /servers/:id/workloads/:wid/action (owner|admin)
GET /servers/:id/workloads/:wid/logs (owner|admin)
audit GET /audit
agent GET /agent/latest-version
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
@@ -658,7 +734,7 @@ Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no
## MongoDB Collections
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `migrations`
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `migrations`
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
+13
View File
@@ -113,6 +113,19 @@ func (c *Client) ReportPackages(req *pb.ReportPackagesRequest) (bool, error) {
return resp.NeedFull, nil
}
// ReportWorkloads sends a workload report and returns whether the server wants
// the full list.
func (c *Client) ReportWorkloads(req *pb.ReportWorkloadsRequest) (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
resp, err := c.client.ReportWorkloads(ctx, req)
if err != nil {
return false, err
}
return resp.NeedFull, nil
}
func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, privateKey, label string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
+7
View File
@@ -206,6 +206,10 @@ type ServerCommand struct {
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
OpenProxy *OpenProxyCmd `json:"open_proxy,omitempty"`
Ping *PingCmd `json:"ping,omitempty"`
RefreshWorkloads *RefreshWorkloadsCmd `json:"refresh_workloads,omitempty"`
ControlWorkload *ControlWorkloadCmd `json:"control_workload,omitempty"`
WorkloadLogs *WorkloadLogsCmd `json:"workload_logs,omitempty"`
}
// PingCmd is a server-originated liveness beat. It carries nothing and expects
@@ -243,6 +247,8 @@ type AgentMessage struct {
Result *CommandResult `json:"result,omitempty"`
StepResult *StepResult `json:"step_result,omitempty"`
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
WorkloadLogsResult *WorkloadLogsResult `json:"workload_logs_result,omitempty"`
}
type AgentReady struct{}
@@ -377,6 +383,7 @@ type VantageClient interface {
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error)
ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, error)
ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error)
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
+80
View File
@@ -0,0 +1,80 @@
package pb
import (
"context"
"google.golang.org/grpc"
)
// Workload registry messages. Hand-written like the rest of this package: the
// .proto is the contract, this file is the Go side of it, and the two must be
// changed together.
// Workload is one container or one systemd unit.
type Workload struct {
Kind string `json:"kind"`
Id string `json:"id"`
Name string `json:"name"`
State string `json:"state"`
Health string `json:"health,omitempty"`
Image string `json:"image,omitempty"`
Stack string `json:"stack,omitempty"`
Ports []string `json:"ports,omitempty"`
Restarts int32 `json:"restarts,omitempty"`
StartedAt string `json:"started_at,omitempty"` // RFC3339, empty when not running
Protected bool `json:"protected,omitempty"`
}
// ReportWorkloadsRequest carries what a server is running.
//
// Offer-then-send, the same handshake as ReportPackages: the agent calls once
// with Workloads empty, and resends with the body only if NeedFull is set.
type ReportWorkloadsRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Hash string `json:"hash"`
DockerOk bool `json:"docker_ok"`
DockerError string `json:"docker_error,omitempty"`
SystemdOk bool `json:"systemd_ok"`
SystemdError string `json:"systemd_error,omitempty"`
Workloads []Workload `json:"workloads,omitempty"` // empty on the offer call
// Full marks the second call. It is not inferred from an empty Workloads
// slice: a host running nothing sends an empty list as its full report.
Full bool `json:"full,omitempty"`
}
type ReportWorkloadsResponse struct {
NeedFull bool `json:"need_full"`
}
// RefreshWorkloadsCmd carries no payload back. It makes the agent report
// immediately through ReportWorkloads, so there is exactly one writer for the
// server_workloads collection rather than two arriving by different routes.
type RefreshWorkloadsCmd struct{}
type ControlWorkloadCmd struct {
Kind string `json:"kind"`
Id string `json:"id"`
Action string `json:"action"` // start | stop | restart
}
type WorkloadLogsCmd struct {
Kind string `json:"kind"`
Id string `json:"id"`
Tail int32 `json:"tail,omitempty"`
}
type WorkloadLogsResult struct {
CommandId string `json:"command_id"`
Text string `json:"text,omitempty"`
Truncated bool `json:"truncated,omitempty"`
Error string `json:"error,omitempty"`
}
func (c *keyManagerClient) ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, error) {
out := new(ReportWorkloadsResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportWorkloads", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
+35
View File
@@ -1,9 +1,12 @@
package agentsync
import (
"context"
"log"
"runtime"
"sync"
"sync/atomic"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
@@ -20,6 +23,38 @@ import (
// are not paying for.
var collectPackagesFlag atomic.Bool
// firstPoll closes once a SyncKeys response has set the flag above.
//
// Without it the boot-time package report loses a race it can only lose: the
// hourly loop starts before the first poll, reads a flag that is still false by
// construction, and skips — so a freshly installed agent reports no packages for
// an hour and the server shows nothing to scan.
// How long the boot package report waits for that first poll. Two poll
// intervals plus slack: long enough to cover one failed attempt, short enough
// that a dead control plane does not hold the OS-update report hostage.
const firstPollWait = 90 * time.Second
var (
firstPoll = make(chan struct{})
firstPollOnce sync.Once
)
func markFirstPoll() { firstPollOnce.Do(func() { close(firstPoll) }) }
// waitFirstPoll blocks until the flag is known, or gives up. The wait is
// bounded because this loop also reports OS updates, which do not depend on the
// flag at all — a control plane that cannot be polled must not silence those too.
func waitFirstPoll(ctx context.Context, limit time.Duration) {
t := time.NewTimer(limit)
defer t.Stop()
select {
case <-firstPoll:
case <-t.C:
log.Printf("package collection: no SyncKeys response within %s, collecting nothing this round", limit)
case <-ctx.Done():
}
}
func collectPackagesEnabled() bool { return collectPackagesFlag.Load() }
// reportPackages offers a hash of the installed package set and sends the full
+16
View File
@@ -70,6 +70,8 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
go runInventory(ctx, cfg)
go runWorkloads(ctx, cfg)
go monitors.Run(ctx, cfg)
ticker := time.NewTicker(cfg.PollInterval)
@@ -101,6 +103,7 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
// goroutine. Absent on the wire decodes as false, so an older server leaves
// collection off rather than on.
collectPackagesFlag.Store(resp.CollectPackages)
markFirstPoll()
desired := resp.PublicKeys
@@ -346,6 +349,15 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
if cmd.OpenProxy != nil {
go handleOpenProxy(ctx, cfg, cmd.OpenProxy)
}
if cmd.RefreshWorkloads != nil {
go handleRefreshWorkloads(cfg)
}
if cmd.ControlWorkload != nil {
go handleControlWorkload(send, cfg, cmd.CommandId, cmd.ControlWorkload)
}
if cmd.WorkloadLogs != nil {
go handleWorkloadLogs(send, cfg, cmd.CommandId, cmd.WorkloadLogs)
}
if cmd.RunStep != nil {
go func(rc *pb.RunStepCmd, cid string) {
emit := func(seq uint64, data []byte) {
@@ -409,6 +421,10 @@ func runUpdateCheck(ctx context.Context, cfg *config.Config) {
reportPackages(client, cfg)
}
// The boot round only: after this the flag has long been set, and every
// later tick is an hour past a poll that runs every 30s.
waitFirstPoll(ctx, firstPollWait)
doCheck()
ticker := time.NewTicker(interval)
defer ticker.Stop()
+161
View File
@@ -0,0 +1,161 @@
package agentsync
import (
"context"
"log"
"runtime"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/workloads"
)
// workloadInterval is the report cadence. Sixty seconds is affordable because
// an unchanged list costs one small offer message, not the body.
const workloadInterval = 60 * time.Second
// runWorkloads reports what this host runs, on its own ticker.
func runWorkloads(ctx context.Context, cfg *config.Config) {
if runtime.GOOS != "linux" {
return
}
reportWorkloads(cfg)
ticker := time.NewTicker(workloadInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
reportWorkloads(cfg)
}
}
}
// reportWorkloads offers a hash of the current workload set and sends the full
// list only if the server does not already hold it.
//
// This is the ONLY writer of the server_workloads collection. RefreshWorkloadsCmd
// calls straight into here rather than answering with data of its own.
func reportWorkloads(cfg *config.Config) {
if runtime.GOOS != "linux" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
res := workloads.Collect(ctx)
hash := workloads.Hash(res.Workloads)
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
log.Printf("workload report dial error: %v", err)
return
}
defer client.Close()
base := func() *pb.ReportWorkloadsRequest {
return &pb.ReportWorkloadsRequest{
ServerId: cfg.ServerID,
AgentToken: cfg.AgentToken,
Hash: hash,
DockerOk: res.DockerOK,
DockerError: res.DockerError,
SystemdOk: res.SystemdOK,
SystemdError: res.SystemdError,
}
}
// The offer: hash only, no body. On an unchanged host this is the whole
// exchange, which is the point of the handshake.
needFull, err := client.ReportWorkloads(base())
if err != nil {
log.Printf("ReportWorkloads offer error: %v", err)
return
}
if !needFull {
return
}
req := base()
req.Full = true
req.Workloads = make([]pb.Workload, len(res.Workloads))
for i, w := range res.Workloads {
req.Workloads[i] = pb.Workload{
Kind: w.Kind,
Id: w.ID,
Name: w.Name,
State: w.State,
Health: w.Health,
Image: w.Image,
Stack: w.Stack,
Ports: w.Ports,
Restarts: int32(w.Restarts),
Protected: w.Protected,
}
if !w.StartedAt.IsZero() {
req.Workloads[i].StartedAt = w.StartedAt.Format(time.RFC3339)
}
}
if _, err := client.ReportWorkloads(req); err != nil {
log.Printf("ReportWorkloads error: %v", err)
return
}
log.Printf("reported %d workload(s)", len(res.Workloads))
}
// handleRefreshWorkloads makes the agent report immediately. It sends nothing
// back beyond the stream ack: the refresh is a nudge, not a channel, so there
// is one writer for the collection rather than two.
func handleRefreshWorkloads(cfg *config.Config) {
reportWorkloads(cfg)
}
// handleControlWorkload starts, stops or restarts a workload and answers with
// the ordinary CommandResult.
//
// The agent's own protected check inside workloads.Control is the boundary; the
// Protected flag it reports is only there so the UI can grey the button.
func handleControlWorkload(send func(*pb.AgentMessage) error, cfg *config.Config, commandID string, cmd *pb.ControlWorkloadCmd) {
err := workloads.Control(context.Background(), cmd.Kind, cmd.Id, cmd.Action)
res := &pb.CommandResult{CommandId: commandID, Success: err == nil}
if err != nil {
res.Message = err.Error()
log.Printf("workload %s %s failed (cmd=%s): %v", cmd.Action, cmd.Id, commandID, err)
} else {
res.Message = cmd.Action + " " + cmd.Id + " ok"
}
_ = send(&pb.AgentMessage{
ServerId: cfg.ServerID,
AgentToken: cfg.AgentToken,
Result: res,
})
// Report straight away on success so the UI's refetch shows the new state
// rather than the old one.
if err == nil {
reportWorkloads(cfg)
}
}
func handleWorkloadLogs(send func(*pb.AgentMessage) error, cfg *config.Config, commandID string, cmd *pb.WorkloadLogsCmd) {
text, truncated, err := workloads.Logs(context.Background(), cmd.Kind, cmd.Id, int(cmd.Tail))
res := &pb.WorkloadLogsResult{CommandId: commandID, Text: text, Truncated: truncated}
if err != nil {
res.Error = err.Error()
}
_ = send(&pb.AgentMessage{
ServerId: cfg.ServerID,
AgentToken: cfg.AgentToken,
WorkloadLogsResult: res,
})
}
+105
View File
@@ -0,0 +1,105 @@
package workloads
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"time"
)
// ErrProtected is returned for a workload the agent will not act on.
var ErrProtected = errors.New("workload is protected")
// AgentUnit is the systemd unit this agent runs as.
const AgentUnit = "vantage-agent.service"
// controlTimeout bounds a stop that may never finish on its own. `docker stop`
// waits on a container that may ignore SIGTERM, and `systemctl stop` on a unit
// with a long TimeoutStopSec blocks for exactly as long as that says. A
// timeout must return a real error rather than an ack implying success.
const controlTimeout = 90 * time.Second
// ownContainerID is read once: the container this agent runs in, if any.
var ownContainerID = detectOwnContainer()
var cgroupContainerRe = regexp.MustCompile(`[0-9a-f]{64}`)
// detectOwnContainer returns this process's container ID, or "" on a host
// install. The agent is normally a systemd service, so "" is the common case;
// this exists so containerising it later cannot silently remove the guard.
func detectOwnContainer() string {
b, err := os.ReadFile("/proc/self/cgroup")
if err != nil {
return ""
}
if m := cgroupContainerRe.FindString(string(b)); m != "" {
return m
}
return ""
}
// isProtected reports whether the agent refuses to act on this workload.
//
// The refusal lives here, in the agent, and not in the control plane. As with
// the console relay hardcoding 127.0.0.1 agent-side: the control plane may name
// a target, but the agent decides what it will do to itself. A server-side
// denylist alone would be bypassed by the next dispatch path someone adds.
func isProtected(kind, id, name string) bool {
if kind == "unit" {
return id == AgentUnit || name == strings.TrimSuffix(AgentUnit, ".service")
}
if ownContainerID == "" {
return false
}
// Container IDs are commonly abbreviated to 12 characters; compare on the
// shorter of the two so a short id still matches a full one.
return strings.HasPrefix(ownContainerID, id) || strings.HasPrefix(id, ownContainerID)
}
// markProtected stamps the flag onto a collected list so the UI can render the
// action disabled with a reason.
func markProtected(wls []Workload) {
for i := range wls {
wls[i].Protected = isProtected(wls[i].Kind, wls[i].ID, wls[i].Name)
}
}
// Control starts, stops or restarts a workload.
func Control(ctx context.Context, kind, id, action string) error {
switch action {
case "start", "stop", "restart":
default:
return fmt.Errorf("unknown action %q", action)
}
// Checked before anything else happens, and checked here rather than only
// on the server. See isProtected.
if isProtected(kind, id, strings.TrimSuffix(id, ".service")) {
return fmt.Errorf("%w: %s", ErrProtected, id)
}
ctx, cancel := context.WithTimeout(ctx, controlTimeout)
defer cancel()
var cmd *exec.Cmd
switch kind {
case "container":
cmd = exec.CommandContext(ctx, "docker", action, id)
case "unit":
cmd = exec.CommandContext(ctx, "systemctl", action, id)
default:
return fmt.Errorf("unknown workload kind %q", kind)
}
if out, err := cmd.CombinedOutput(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout)
}
return fmt.Errorf("%s %s: %s", action, id, strings.TrimSpace(string(out)))
}
return nil
}
+141
View File
@@ -0,0 +1,141 @@
package workloads
import (
"context"
"encoding/json"
"os/exec"
"sort"
"strings"
"time"
)
// Workload is one container or one systemd unit, agent-side. It mirrors
// models.Workload on the server.
type Workload struct {
Kind string
ID string
Name string
State string
Health string
Image string
Stack string
Ports []string
Restarts int
StartedAt time.Time
Protected bool
}
const dockerTimeout = 30 * time.Second
// dockerInspect is the subset of `docker inspect` output we read.
//
// We use inspect rather than `docker ps --format '{{json .}}'` because ps
// reports health and uptime inside a human Status string — "Up 2 hours
// (healthy)" — and anything built on that is parsing English that is
// localised, reworded between releases, and silently different for a paused or
// restarting container. inspect gives typed fields instead.
type dockerInspect struct {
ID string `json:"Id"`
Name string `json:"Name"`
State struct {
Status string `json:"Status"`
StartedAt string `json:"StartedAt"`
Restarting bool `json:"Restarting"`
Health *struct {
Status string `json:"Status"`
} `json:"Health"`
} `json:"State"`
Config struct {
Image string `json:"Image"`
Labels map[string]string `json:"Labels"`
} `json:"Config"`
RestartCount int `json:"RestartCount"`
NetworkSettings struct {
Ports map[string][]struct {
HostIP string `json:"HostIp"`
HostPort string `json:"HostPort"`
} `json:"Ports"`
} `json:"NetworkSettings"`
}
// collectDocker enumerates containers. It returns ok=false with an empty error
// string when Docker is simply not installed — the common case on this fleet,
// and not a fault.
func collectDocker(ctx context.Context) ([]Workload, bool, string) {
if _, err := exec.LookPath("docker"); err != nil {
return nil, false, "" // not installed; not an error
}
ctx, cancel := context.WithTimeout(ctx, dockerTimeout)
defer cancel()
idsOut, err := exec.CommandContext(ctx, "docker", "ps", "-aq").Output()
if err != nil {
// Installed but not answering: a different problem with a different
// fix, so it carries a message where "not installed" does not.
return nil, false, "docker ps failed: " + errText(err)
}
ids := strings.Fields(string(idsOut))
if len(ids) == 0 {
return []Workload{}, true, "" // Docker present, nothing running
}
args := append([]string{"inspect", "--format", "{{json .}}"}, ids...)
out, err := exec.CommandContext(ctx, "docker", args...).Output()
if err != nil {
return nil, false, "docker inspect failed: " + errText(err)
}
var wls []Workload
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var di dockerInspect
if err := json.Unmarshal([]byte(line), &di); err != nil {
continue
}
wls = append(wls, dockerToWorkload(di))
}
return wls, true, ""
}
func dockerToWorkload(di dockerInspect) Workload {
w := Workload{
Kind: "container",
ID: di.ID,
Name: strings.TrimPrefix(di.Name, "/"),
State: di.State.Status,
Image: di.Config.Image,
Restarts: di.RestartCount,
}
if di.State.Health != nil {
w.Health = strings.ToLower(di.State.Health.Status)
}
// The compose project label is what Docker itself treats as authoritative.
// No YAML is read from disk: a compose file there may not be what is running.
if v := di.Config.Labels["com.docker.compose.project"]; v != "" {
w.Stack = v
}
if t, err := time.Parse(time.RFC3339Nano, di.State.StartedAt); err == nil {
w.StartedAt = t
}
for container, bindings := range di.NetworkSettings.Ports {
for _, b := range bindings {
w.Ports = append(w.Ports, b.HostIP+":"+b.HostPort+"->"+container)
}
}
// Map iteration order is random; sort so a stored snapshot does not reorder
// its own ports between two otherwise identical reports.
sort.Strings(w.Ports)
return w
}
func errText(err error) string {
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
return strings.TrimSpace(string(ee.Stderr))
}
return err.Error()
}
+83
View File
@@ -0,0 +1,83 @@
package workloads
import (
"context"
"fmt"
"os/exec"
"strconv"
"strings"
"time"
)
const (
// MaxLogLines and MaxLogBytes are BOTH enforced, whichever binds first.
//
// A line count alone does not bound size: 500 lines of a container printing
// 4KB JSON blobs is 2MB travelling over the bus. This is the same reasoning
// that gave workflow logs a per-line cap as well as a per-run one.
MaxLogLines = 500
MaxLogBytes = 256 * 1024
logTimeout = 60 * time.Second
)
// Logs returns a bounded snapshot of a workload's recent output.
//
// There is no follow mode. The browser console already offers a real terminal
// on the same server where `docker logs -f` works properly, with its own
// scrollback and cancellation. A snapshot answers "why did this restart",
// which is the question that sends people to the console in the first place.
func Logs(ctx context.Context, kind, id string, tail int) (string, bool, error) {
if tail <= 0 || tail > MaxLogLines {
tail = MaxLogLines
}
ctx, cancel := context.WithTimeout(ctx, logTimeout)
defer cancel()
var cmd *exec.Cmd
switch kind {
case "container":
cmd = exec.CommandContext(ctx, "docker", "logs",
"--tail", strconv.Itoa(tail), "--timestamps", id)
case "unit":
cmd = exec.CommandContext(ctx, "journalctl", "-u", id,
"-n", strconv.Itoa(tail), "--no-pager", "--output=short-iso")
default:
return "", false, fmt.Errorf("unknown workload kind %q", kind)
}
// docker logs writes container stderr to our stderr, so both streams must
// be captured or half the output silently disappears.
out, err := cmd.CombinedOutput()
if err != nil && len(out) == 0 {
return "", false, fmt.Errorf("read logs for %s: %s", id, errText(err))
}
text, truncated := capLog(string(out))
return text, truncated, nil
}
// capLog enforces both limits, trimming from the FRONT: the most recent lines
// are the ones worth keeping.
func capLog(s string) (string, bool) {
truncated := false
lines := strings.Split(s, "\n")
if len(lines) > MaxLogLines {
lines = lines[len(lines)-MaxLogLines:]
truncated = true
}
s = strings.Join(lines, "\n")
if len(s) > MaxLogBytes {
s = s[len(s)-MaxLogBytes:]
// Drop the leading partial line left by a byte-wise cut.
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[i+1:]
}
truncated = true
}
return s, truncated
}
+94
View File
@@ -0,0 +1,94 @@
package workloads
import (
"context"
"os/exec"
"strings"
"time"
)
const systemdTimeout = 30 * time.Second
// excludedPrefixes drops the platform's own units. A typical host carries 300+
// units and systemd accounts for most of them; listing all of them buries the
// ten anyone cares about.
var excludedPrefixes = []string{"systemd-", "user@", "user-", "session-", "init.scope"}
// collectSystemd enumerates services in two passes, because "running or
// failed" and "enabled but stopped" are different questions — and an enabled
// unit that is not running is exactly the one worth seeing.
func collectSystemd(ctx context.Context) ([]Workload, bool, string) {
if _, err := exec.LookPath("systemctl"); err != nil {
return nil, false, ""
}
ctx, cancel := context.WithTimeout(ctx, systemdTimeout)
defer cancel()
// Column output rather than --output=json: the JSON flag needs systemd
// 246+, and this fleet includes older stable distributions. The columns
// have been stable considerably longer than the JSON has existed.
unitsOut, err := exec.CommandContext(ctx, "systemctl",
"list-units", "--type=service", "--state=running,failed",
"--no-legend", "--plain", "--no-pager").Output()
if err != nil {
return nil, false, "systemctl list-units failed: " + errText(err)
}
seen := map[string]bool{}
var wls []Workload
for _, line := range strings.Split(string(unitsOut), "\n") {
f := strings.Fields(line)
// UNIT LOAD ACTIVE SUB DESCRIPTION…
if len(f) < 4 {
continue
}
name := f[0]
if excluded(name) || seen[name] {
continue
}
seen[name] = true
wls = append(wls, Workload{
Kind: "unit",
ID: name,
Name: strings.TrimSuffix(name, ".service"),
State: f[2], // ACTIVE: active | failed | activating | inactive
})
}
filesOut, err := exec.CommandContext(ctx, "systemctl",
"list-unit-files", "--type=service", "--state=enabled",
"--no-legend", "--plain", "--no-pager").Output()
if err == nil {
for _, line := range strings.Split(string(filesOut), "\n") {
f := strings.Fields(line)
// UNIT FILE STATE [PRESET]
if len(f) < 2 {
continue
}
name := f[0]
if excluded(name) || seen[name] {
continue
}
seen[name] = true
wls = append(wls, Workload{
Kind: "unit",
ID: name,
Name: strings.TrimSuffix(name, ".service"),
State: "inactive", // enabled but not currently running
})
}
}
return wls, true, ""
}
func excluded(name string) bool {
for _, p := range excludedPrefixes {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
+64
View File
@@ -0,0 +1,64 @@
package workloads
import (
"context"
"crypto/sha256"
"encoding/hex"
"runtime"
"sort"
"strconv"
"strings"
)
// Result is one collection pass.
type Result struct {
Workloads []Workload
DockerOK bool
DockerError string
SystemdOK bool
SystemdError string
}
// Collect enumerates every workload on this host. Linux only.
func Collect(ctx context.Context) Result {
if runtime.GOOS != "linux" {
return Result{}
}
var r Result
containers, dockerOK, dockerErr := collectDocker(ctx)
units, systemdOK, systemdErr := collectSystemd(ctx)
r.DockerOK, r.DockerError = dockerOK, dockerErr
r.SystemdOK, r.SystemdError = systemdOK, systemdErr
r.Workloads = append(append([]Workload{}, containers...), units...)
markProtected(r.Workloads)
return r
}
// Hash fingerprints a workload set so an unchanged set never has to be sent.
//
// It sorts first: `docker ps` output ordering is not stable, and an
// ordering-sensitive hash would resend the full list every 60 seconds forever
// — a cost visible only as traffic.
//
// StartedAt is deliberately excluded: it does not change while a container
// runs, and including it would add nothing. Restarts IS included, because a
// container cycling is exactly the change worth reporting.
func Hash(wls []Workload) string {
lines := make([]string, 0, len(wls))
for _, w := range wls {
lines = append(lines, strings.Join([]string{
w.Kind, w.ID, w.Name, w.State, w.Health, w.Image, w.Stack,
strconv.Itoa(w.Restarts),
}, "\x00"))
}
sort.Strings(lines)
h := sha256.New()
for _, l := range lines {
h.Write([]byte(l))
h.Write([]byte("\n"))
}
return hex.EncodeToString(h.Sum(nil))
}
+90
View File
@@ -0,0 +1,90 @@
---
id: workloads
title: Workloads
sidebar_label: Workloads
---
A **workload** is one Docker container or one systemd service. Each Linux
server reports what it runs, and you can start, stop and restart those
workloads — and read a snapshot of their logs — without opening a console.
Available on every instance. No licence feature is required.
## What gets reported
Linux servers only. Agents report every 60 seconds, and an unchanged list costs
a single small message rather than the whole thing again.
- **Containers** — every container, running or not, with its image, published
ports, health, restart count and the compose stack it belongs to.
- **Services** — systemd units that are running or failed, plus units that are
enabled but currently stopped. The platform's own units (`systemd-*`,
`user@*`, `session-*`) are filtered out; a typical host has 300 of them and
they bury the ten you care about.
Windows servers report no workloads at all.
## Docker not in use is not an error
Three different things look identical if you are careless, and only one of them
is a problem:
| What you see | What it means |
| ------------ | ------------- |
| "Docker is not in use on this server" | Docker is not installed. Normal, and not a fault |
| "Docker is installed but not responding" | The daemon is down or the socket is unreachable |
| An empty container list | Docker is running and there are no containers |
## Stacks are grouped
Compose stacks appear first, grouped under the stack name, then loose
containers, then services. A stack is one thing even when it is six containers,
and a flat list turns one decision into six rows.
The stack name comes from Docker's own `com.docker.compose.project` label. No
compose file is read from disk — a file on disk may not be what is running.
## Controlling a workload
Start, stop and restart are **owner or admin only**, and every action is
written to the audit log naming you, the server and the target.
The agent refuses to act on itself. `vantage-agent.service` is shown with its
buttons disabled: a server that stops its own agent goes offline, and the only
way back is SSH or physical access — which is exactly what this page exists to
avoid needing.
A stop that never finishes is not reported as success. Both `docker stop` and
`systemctl stop` run under a 90-second limit, and a timeout comes back as a
real error.
## Reading logs
Logs are **owner or admin only** and every read is audited. Unlike workflow
logs, a container's output cannot be masked: it is arbitrary, and a startup
banner or a stack trace may contain credentials nobody declared.
A log read returns a snapshot of at most **500 lines or 256KB**, whichever
limit is reached first, with the most recent output kept. When either limit
binds, the dialog says so — a truncated log must never be read as a complete
one.
There is no live following. The [browser console](./browser-console.md) already
gives you a real terminal on the same server, where `docker logs -f` works
properly with its own scrollback.
## Refreshing
Opening a server's Workloads panel asks its agent to report immediately, so
what is on screen is current rather than up to a minute old. That matters
because the panel has a Restart button on it: a stale row is not just a wrong
impression, it is a wrong action aimed at something that already died.
If the agent is offline the refresh fails visibly rather than queueing. A
command whose target cannot be reached must say so.
## Fleet view
**Workloads** in the sidebar searches the whole fleet by image, stack or state
— "which of these servers is still on the old image" — and links each result
back to its server.
+1
View File
@@ -27,6 +27,7 @@ const sidebars: SidebarsConfig = {
"vantage/workflows",
"vantage/monitors",
"vantage/vulnerabilities",
"vantage/workloads",
"vantage/notification-channels",
"vantage/secrets",
"vantage/browser-console",
+8 -15
View File
@@ -5,7 +5,6 @@ github.com/Intevation/jsonpath v0.2.1/go.mod h1:WnZ8weMmwAx/fAO3SutjYFU+v7DFreNY
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986/go.mod h1:NT+jyeCzXk6vXR5MTkdn4z64TgGfE5HMLC8qfj5unl8=
github.com/aquasecurity/go-gem-version v0.0.0-20201115065557-8eed6fe000ce/go.mod h1:HXgVzOPvXhVGLJs4ZKO817idqr/xhwsTcj17CLYY74s=
github.com/aquasecurity/go-npm-version v0.0.1/go.mod h1:hxbJZtKlO4P8sZ9nztizR6XLoE33O+BkPmuYQ4ACyz0=
github.com/aquasecurity/go-pep440-version v0.0.1/go.mod h1:3naPe+Bp6wi3n4l5iBFCZgS0JG8vY6FT0H4NGhFJ+i4=
@@ -19,9 +18,10 @@ github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t
github.com/envoyproxy/go-control-plane v0.12.0/go.mod h1:ZBTaoJ23lqITozF0M6G4/IragXCQKCnYbmlmtHvwRG0=
github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gocsaf/csaf/v3 v3.1.1/go.mod h1:EpUCrQg69i+Y66MphmQvVbcj333GFLjXOYHg1zoXVso=
github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
@@ -29,6 +29,7 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/josephburnett/jd/v2 v2.3.0/go.mod h1:0I5+gbo7y8diuajJjm79AF44eqTheSJy1K7DSbIUFAQ=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
@@ -36,28 +37,20 @@ github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ
github.com/masahiro331/go-mvn-version v0.0.0-20250131095131-f4974fa13b8a/go.mod h1:jZ3F25l7DbD7l7DcA8aj7eo1EZ84nbzcQHBB4lCSrI8=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/package-url/packageurl-go v0.1.3/go.mod h1:nKAWB8E6uk1MHqiS/lQb9pYBGH2+mdJ2PJc2s50dQY0=
github.com/pandatix/go-cvss v0.6.2/go.mod h1:jDXYlQBZrc8nvrMUVVvTG8PhmuShOnKrxP53nOFkt8Q=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY=
github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc=
github.com/samber/oops v1.18.1 h1:qjhZbqbdyhWBKntkY8sxrDNKA8b4c5VHlmI1rli7X7M=
github.com/samber/oops v1.18.1/go.mod h1:xYqvimigkKV70HyLXiBZJFpIWi2CGcc6Xx7eV+2HycI=
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.etcd.io/gofail v0.2.0/go.mod h1:nL3ILMGfkXTekKI3clMBNazKnjUZjYLKmBHzsVAnC1o=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
+68
View File
@@ -10,6 +10,7 @@ service Vantage {
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
rpc ReportPackages(ReportPackagesRequest) returns (ReportPackagesResponse);
rpc ReportWorkloads(ReportWorkloadsRequest) returns (ReportWorkloadsResponse);
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
@@ -107,6 +108,7 @@ message AgentMessage {
CommandResult result = 4;
StepResult step_result = 5;
StepOutputChunk step_output = 6;
WorkloadLogsResult workload_logs_result = 7;
}
}
@@ -229,6 +231,9 @@ message ServerCommand {
CleanupWorkspaceCmd cleanup_workspace = 7;
OpenProxyCmd open_proxy = 8;
PingCmd ping = 9;
RefreshWorkloadsCmd refresh_workloads = 10;
ControlWorkloadCmd control_workload = 11;
WorkloadLogsCmd workload_logs = 12;
}
}
@@ -319,3 +324,66 @@ message ProxyServerMsg {
ProxyClose close = 2;
}
}
// ---------------------------------------------------------------------------
// Workload registry
// ReportWorkloads carries what a server is running.
//
// Offer-then-send, the same handshake as ReportPackages: the agent calls once
// with workloads empty, and resends with the body only if need_full is set.
message ReportWorkloadsRequest {
string server_id = 1;
string agent_token = 2;
string hash = 3;
bool docker_ok = 4;
string docker_error = 5;
bool systemd_ok = 6;
string systemd_error = 7;
repeated Workload workloads = 8; // empty on the offer call
// full marks the second call. It is not inferred from an empty workloads
// list: a host running nothing sends an empty list as its full report.
bool full = 9;
}
message ReportWorkloadsResponse {
bool need_full = 1;
}
message Workload {
string kind = 1; // "container" | "unit"
string id = 2;
string name = 3;
string state = 4;
string health = 5;
string image = 6;
string stack = 7;
repeated string ports = 8;
int32 restarts = 9;
string started_at = 10; // RFC3339, empty when not running
bool protected = 11;
}
// RefreshWorkloadsCmd carries no payload back. It makes the agent report
// immediately through ReportWorkloads, so there is exactly one writer for the
// server_workloads collection rather than two arriving by different routes.
message RefreshWorkloadsCmd {}
message ControlWorkloadCmd {
string kind = 1;
string id = 2;
string action = 3; // "start" | "stop" | "restart"
}
message WorkloadLogsCmd {
string kind = 1;
string id = 2;
int32 tail = 3;
}
message WorkloadLogsResult {
string command_id = 1;
string text = 2;
bool truncated = 3;
string error = 4;
}
+4
View File
@@ -136,6 +136,10 @@ func runSchemaSetup() {
log.Printf("warning: failed to ensure vuln indexes: %v", err)
}
if err := services.EnsureWorkloadIndexes(); err != nil {
log.Printf("warning: failed to ensure workload indexes: %v", err)
}
if instanceIDs, err := services.ListInstanceIDs(); err != nil {
log.Printf("warning: failed to list instances for default step seeding: %v", err)
} else {
+9
View File
@@ -132,6 +132,15 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.POST("/vuln-rules", auth.RequireRole("owner", "admin"), createVulnRule)
apiGroup.PUT("/vuln-rules/:id", auth.RequireRole("owner", "admin"), updateVulnRule)
apiGroup.DELETE("/vuln-rules/:id", auth.RequireRole("owner", "admin"), deleteVulnRule)
// Control actions and log reads are owner|admin: container output is
// arbitrary and cannot be masked, so a member who can see the fleet
// still cannot read its logs.
apiGroup.GET("/workloads", listWorkloads)
apiGroup.GET("/servers/:id/workloads", getServerWorkloads)
apiGroup.POST("/servers/:id/workloads/refresh", refreshServerWorkloads)
apiGroup.POST("/servers/:id/workloads/:wid/action", auth.RequireRole("owner", "admin"), controlWorkload)
apiGroup.GET("/servers/:id/workloads/:wid/logs", auth.RequireRole("owner", "admin"), getWorkloadLogs)
}
}
+196
View File
@@ -0,0 +1,196 @@
package api
import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// getServerWorkloads returns the stored snapshot.
//
// A server that has never reported answers an empty list rather than 404: the
// agent may simply not have got there yet, and 404 reads as "no such server".
func getServerWorkloads(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
if _, err := services.GetServer(instanceID, id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
sw, err := services.GetWorkloads(instanceID, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if sw == nil {
c.JSON(http.StatusOK, models.ServerWorkloads{
ServerID: id,
Workloads: []models.Workload{},
})
return
}
if sw.Workloads == nil {
sw.Workloads = []models.Workload{}
}
c.JSON(http.StatusOK, sw)
}
// refreshServerWorkloads nudges the agent to report now. It returns no data:
// the client refetches the stored document once the agent has written it.
func refreshServerWorkloads(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
s, err := services.GetServer(instanceID, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if err := services.DispatchRefreshWorkloads(s.ServerID); err != nil {
// Not queued: a command whose owner died must fail loudly, so the
// client can show the stored snapshot as stale rather than pretend.
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusAccepted, gin.H{"message": "refresh requested"})
}
func controlWorkload(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
wid, ok := workloadIDParam(c)
if !ok {
return
}
var body struct {
Action string `json:"action"`
Kind string `json:"kind"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
switch body.Action {
case models.WorkloadStart, models.WorkloadStop, models.WorkloadRestart:
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "action must be start, stop or restart"})
return
}
if body.Kind != models.WorkloadContainer && body.Kind != models.WorkloadUnit {
c.JSON(http.StatusBadRequest, gin.H{"error": "kind must be container or unit"})
return
}
s, err := services.GetServer(instanceID, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
err = services.DispatchControlWorkload(s.ServerID, body.Kind, wid, body.Action)
if err != nil {
switch {
case errors.Is(err, services.ErrAgentNotConnected):
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
case services.IsWorkloadProtected(err):
// Nothing failed — the agent refused, which is the design. 409, not
// 500, and the reason is carried through.
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
return
}
services.LogEvent(instanceID, "workload."+body.Action, actorFromCtx(c), s.ServerID, "",
fmt.Sprintf("%s %s %s on %s", body.Action, body.Kind, wid, s.Hostname))
c.JSON(http.StatusOK, gin.H{"message": body.Action + " ok"})
}
func getWorkloadLogs(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
wid, ok := workloadIDParam(c)
if !ok {
return
}
kind := c.DefaultQuery("kind", models.WorkloadContainer)
if kind != models.WorkloadContainer && kind != models.WorkloadUnit {
c.JSON(http.StatusBadRequest, gin.H{"error": "kind must be container or unit"})
return
}
// Clamped rather than refused: a client asking for more than the cap gets
// the cap, which is what it would have got anyway.
tail, _ := strconv.Atoi(c.Query("tail"))
if tail <= 0 || tail > services.MaxWorkloadLogLines {
tail = services.MaxWorkloadLogLines
}
s, err := services.GetServer(instanceID, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
text, truncated, err := services.DispatchWorkloadLogs(s.ServerID, kind, wid, tail)
if err != nil {
if errors.Is(err, services.ErrAgentNotConnected) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
// Audited because container output is arbitrary and cannot be masked: a
// startup banner or a stack trace may carry credentials nobody declared.
services.LogEvent(instanceID, "workload.logs_read", actorFromCtx(c), s.ServerID, "",
fmt.Sprintf("read %s logs for %s on %s", kind, wid, s.Hostname))
c.JSON(http.StatusOK, gin.H{"text": text, "truncated": truncated})
}
// listWorkloads answers the fleet-wide question, which is the reason the
// snapshot is stored rather than fetched on demand and discarded.
func listWorkloads(c *gin.Context) {
hits, err := services.SearchWorkloads(auth.InstanceID(c),
c.Query("image"), c.Query("stack"), c.Query("state"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, hits)
}
// workloadIDParam decodes :wid. Unit names carry dots and '@', so the client
// encodes it and this is where it comes back.
func workloadIDParam(c *gin.Context) (string, bool) {
raw := c.Param("wid")
decoded, err := url.PathUnescape(raw)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workload id"})
return "", false
}
decoded = strings.TrimSpace(decoded)
if decoded == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "workload id is required"})
return "", false
}
return decoded, true
}
+9
View File
@@ -198,6 +198,10 @@ type ServerCommand struct {
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
OpenProxy *OpenProxyCmd `json:"open_proxy,omitempty"`
Ping *PingCmd `json:"ping,omitempty"`
RefreshWorkloads *RefreshWorkloadsCmd `json:"refresh_workloads,omitempty"`
ControlWorkload *ControlWorkloadCmd `json:"control_workload,omitempty"`
WorkloadLogs *WorkloadLogsCmd `json:"workload_logs,omitempty"`
}
// PingCmd is a server-originated liveness beat. It carries nothing and expects
@@ -233,6 +237,8 @@ type AgentMessage struct {
Result *CommandResult `json:"result,omitempty"`
StepResult *StepResult `json:"step_result,omitempty"`
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
WorkloadLogsResult *WorkloadLogsResult `json:"workload_logs_result,omitempty"`
}
type AgentReady struct{}
@@ -366,6 +372,7 @@ type VantageServer interface {
UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error)
ReportUpdates(context.Context, *ReportUpdatesRequest) (*ReportUpdatesResponse, error)
ReportPackages(context.Context, *ReportPackagesRequest) (*ReportPackagesResponse, error)
ReportWorkloads(context.Context, *ReportWorkloadsRequest) (*ReportWorkloadsResponse, error)
ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error)
SyncMonitors(context.Context, *SyncMonitorsRequest) (*SyncMonitorsResponse, error)
ReportChecks(context.Context, *ReportChecksRequest) (*ReportChecksResponse, error)
@@ -421,6 +428,7 @@ type VantageClient interface {
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error)
ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, error)
ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error)
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
@@ -529,6 +537,7 @@ var Vantage_ServiceDesc = grpc.ServiceDesc{
{MethodName: "UploadGeneratedKey", Handler: _Vantage_UploadGeneratedKey_Handler},
{MethodName: "ReportUpdates", Handler: _Vantage_ReportUpdates_Handler},
{MethodName: "ReportPackages", Handler: _Vantage_ReportPackages_Handler},
{MethodName: "ReportWorkloads", Handler: _Vantage_ReportWorkloads_Handler},
{MethodName: "ReportInventory", Handler: _Vantage_ReportInventory_Handler},
{MethodName: "SyncMonitors", Handler: _Vantage_SyncMonitors_Handler},
{MethodName: "ReportChecks", Handler: _Vantage_ReportChecks_Handler},
+101
View File
@@ -0,0 +1,101 @@
package pb
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Workload registry messages. Hand-written like the rest of this package: the
// .proto is the contract, this file is the Go side of it, and the two must be
// changed together. The agent module carries the same declarations.
// Workload is one container or one systemd unit.
type Workload struct {
Kind string `json:"kind"`
Id string `json:"id"`
Name string `json:"name"`
State string `json:"state"`
Health string `json:"health,omitempty"`
Image string `json:"image,omitempty"`
Stack string `json:"stack,omitempty"`
Ports []string `json:"ports,omitempty"`
Restarts int32 `json:"restarts,omitempty"`
StartedAt string `json:"started_at,omitempty"` // RFC3339, empty when not running
Protected bool `json:"protected,omitempty"`
}
// ReportWorkloadsRequest carries what a server is running.
//
// Offer-then-send, the same handshake as ReportPackages: the agent calls once
// with Workloads empty, and resends with the body only if NeedFull is set.
type ReportWorkloadsRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Hash string `json:"hash"`
DockerOk bool `json:"docker_ok"`
DockerError string `json:"docker_error,omitempty"`
SystemdOk bool `json:"systemd_ok"`
SystemdError string `json:"systemd_error,omitempty"`
Workloads []Workload `json:"workloads,omitempty"` // empty on the offer call
// Full marks the second call. It is not inferred from an empty Workloads
// slice: a host running nothing sends an empty list as its full report.
Full bool `json:"full,omitempty"`
}
type ReportWorkloadsResponse struct {
NeedFull bool `json:"need_full"`
}
// RefreshWorkloadsCmd carries no payload back. It makes the agent report
// immediately through ReportWorkloads, so there is exactly one writer for the
// server_workloads collection rather than two arriving by different routes.
type RefreshWorkloadsCmd struct{}
type ControlWorkloadCmd struct {
Kind string `json:"kind"`
Id string `json:"id"`
Action string `json:"action"` // start | stop | restart
}
type WorkloadLogsCmd struct {
Kind string `json:"kind"`
Id string `json:"id"`
Tail int32 `json:"tail,omitempty"`
}
type WorkloadLogsResult struct {
CommandId string `json:"command_id"`
Text string `json:"text,omitempty"`
Truncated bool `json:"truncated,omitempty"`
Error string `json:"error,omitempty"`
}
func (UnimplementedVantageServer) ReportWorkloads(context.Context, *ReportWorkloadsRequest) (*ReportWorkloadsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReportWorkloads not implemented")
}
func (c *keyManagerClient) ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, error) {
out := new(ReportWorkloadsResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportWorkloads", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func _Vantage_ReportWorkloads_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ReportWorkloadsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VantageServer).ReportWorkloads(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/ReportWorkloads"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VantageServer).ReportWorkloads(ctx, req.(*ReportWorkloadsRequest))
}
return interceptor(ctx, in, info, handler)
}
+71
View File
@@ -163,6 +163,70 @@ func (s *vantageServer) ReportPackages(ctx context.Context, req *pb.ReportPackag
return &pb.ReportPackagesResponse{NeedFull: false}, nil
}
// ReportWorkloads stores what a server is running.
//
// It is not gated by licence: the workload registry reads as core fleet
// management rather than a premium add-on. If that ever changes, the check
// belongs here — gating collection, not display — for the same reason it does
// in ReportPackages.
func (s *vantageServer) ReportWorkloads(ctx context.Context, req *pb.ReportWorkloadsRequest) (*pb.ReportWorkloadsResponse, error) {
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
// The offer call: a hash and no body. Answering NeedFull=false here is what
// keeps an unchanged 60-second report to one small message.
//
// The offer is identified by Full, not by an empty Workloads slice: a host
// genuinely running nothing sends an empty list as its FULL report, and
// inferring the offer from emptiness would leave that host answering
// NeedFull=true forever and never storing anything.
if !req.Full {
known, err := services.HasWorkloadHash(srv.InstanceID, srv.ServerID, req.Hash)
if err != nil {
log.Printf("workload hash lookup for %s: %v", srv.ServerID, err)
return nil, status.Errorf(codes.Internal, "workload hash lookup failed")
}
return &pb.ReportWorkloadsResponse{NeedFull: !known}, nil
}
wls := make([]models.Workload, len(req.Workloads))
for i, w := range req.Workloads {
wls[i] = models.Workload{
Kind: w.Kind,
ID: w.Id,
Name: w.Name,
State: w.State,
Health: w.Health,
Image: w.Image,
Stack: w.Stack,
Ports: w.Ports,
Restarts: int(w.Restarts),
Protected: w.Protected,
}
if w.StartedAt != "" {
if t, err := time.Parse(time.RFC3339, w.StartedAt); err == nil {
wls[i].StartedAt = t
}
}
}
if err := storeWorkloadReport(srv.InstanceID, srv.ServerID, req, wls); err != nil {
log.Printf("store workloads for %s: %v", srv.ServerID, err)
return nil, status.Errorf(codes.Internal, "failed to store workloads")
}
return &pb.ReportWorkloadsResponse{NeedFull: false}, nil
}
func storeWorkloadReport(instanceID, serverID string, req *pb.ReportWorkloadsRequest, wls []models.Workload) error {
if wls == nil {
wls = []models.Workload{}
}
return services.StoreWorkloads(instanceID, serverID, req.Hash, wls,
req.DockerOk, req.DockerError, req.SystemdOk, req.SystemdError)
}
func (s *vantageServer) ReportInventory(ctx context.Context, req *pb.InventoryReport) (*pb.InventoryReportResponse, error) {
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
if err != nil {
@@ -257,6 +321,13 @@ func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) err
if m.Result != nil {
r := m.Result
log.Printf("agent %s cmd %s: success=%v %s", srv.ServerID, r.CommandId, r.Success, r.Message)
// Republished so a control action waiting on another pod sees
// it. Publishing with no subscriber is a no-op, so this is safe
// for every command result rather than only the awaited ones.
services.WorkloadResults.DeliverCommand(r)
}
if m.WorkloadLogsResult != nil {
services.WorkloadResults.Deliver(m.WorkloadLogsResult)
}
if m.StepResult != nil {
services.StepResults.Deliver(m.StepResult)
+70
View File
@@ -0,0 +1,70 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Workload kinds.
const (
WorkloadContainer = "container"
WorkloadUnit = "unit"
)
// Control actions.
const (
WorkloadStart = "start"
WorkloadStop = "stop"
WorkloadRestart = "restart"
)
// Workload is one container or one systemd unit.
type Workload struct {
Kind string `bson:"kind" json:"kind"` // container | unit
ID string `bson:"id" json:"id"` // container id, or unit name
Name string `bson:"name" json:"name"`
// State is deliberately NOT collapsed into a shared vocabulary across the
// two kinds. Containers report running/exited/paused/restarting/created;
// units report active/inactive/failed/activating. A failed unit and an
// exited container mean different things, and flattening them loses the
// distinction the operator needs.
State string `bson:"state" json:"state"`
Health string `bson:"health,omitempty" json:"health,omitempty"`
Image string `bson:"image,omitempty" json:"image,omitempty"`
Stack string `bson:"stack,omitempty" json:"stack,omitempty"` // compose project label
Ports []string `bson:"ports,omitempty" json:"ports,omitempty"`
Restarts int `bson:"restarts,omitempty" json:"restarts,omitempty"`
StartedAt time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`
// Protected is computed agent-side and reported so the UI can render the
// action disabled with a reason rather than offering a button whose refusal
// is already known. The field is the courtesy; the agent's own check is the
// boundary.
Protected bool `bson:"protected" json:"protected"`
}
// ServerWorkloads holds one server's whole workload list in ONE document.
type ServerWorkloads struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"-"`
ServerID string `bson:"server_id" json:"server_id"`
Hash string `bson:"hash" json:"hash"`
Workloads []Workload `bson:"workloads" json:"workloads"`
CollectedAt time.Time `bson:"collected_at" json:"collected_at"`
// A host with no Docker and a host with Docker running nothing both produce
// an empty list. One should read "not in use here", the other "nothing
// running", and only the second deserves any alarm.
//
// The error strings separate a third case the booleans cannot: installed
// with the daemon down. "Not installed" and "installed but not responding"
// are different problems with different fixes.
DockerOK bool `bson:"docker_ok" json:"docker_ok"`
DockerError string `bson:"docker_error,omitempty" json:"docker_error,omitempty"`
SystemdOK bool `bson:"systemd_ok" json:"systemd_ok"`
SystemdError string `bson:"systemd_error,omitempty" json:"systemd_error,omitempty"`
}
@@ -43,6 +43,7 @@ var ScopedCollections = []string{
"server_packages",
"vuln_findings",
"vuln_alert_rules",
"server_workloads",
}
// collectionRenames maps the two collections whose names change. Ordered so the
@@ -0,0 +1,34 @@
package services
import (
"context"
"log"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// EnsureWorkloadIndexes declares the indexes for the workload registry.
//
// It warns rather than being fatal, matching EnsureSecretIndexes and
// EnsureVulnIndexes: a missing index degrades these queries to a collection
// scan, which is no reason to refuse to serve the fleet.
func EnsureWorkloadIndexes() error {
ctx := context.Background()
idx := []mongo.IndexModel{
{
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "server_id", Value: 1}},
Options: options.Index().SetUnique(true),
},
// Multikey, for the fleet-wide "which servers run image X" query, which
// is the reason the snapshot is stored rather than fetched and discarded.
{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "workloads.image", Value: 1}}},
}
if _, err := db.Col("server_workloads").Indexes().CreateMany(ctx, idx); err != nil {
log.Printf("warning: server_workloads indexes: %v", err)
}
return nil
}
+128
View File
@@ -0,0 +1,128 @@
package services
import (
"context"
"encoding/json"
"log"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/bus"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
)
// Workload results travel back over the bus for the same reason commands travel
// out over it: the pod serving the HTTP request and the pod holding the agent's
// stream are two different processes, and a map in one cannot be read by the
// other.
//
// Await MUST be called before the command is dispatched, or a fast agent
// answers into a channel nobody is listening on yet. See stepresults.go.
type workloadResultRegistry struct{}
var WorkloadResults = &workloadResultRegistry{}
// Await subscribes to a command's result channel for a log snapshot.
func (r *workloadResultRegistry) Await(commandID string) (<-chan *pb.WorkloadLogsResult, func()) {
out := make(chan *pb.WorkloadLogsResult, 1)
ctx, cancel := context.WithCancel(context.Background())
raw, unsub, err := bus.Subscribe(ctx, bus.ResultChannel+commandID)
if err != nil {
log.Printf("workload results: subscribe for %s: %v", commandID, err)
cancel()
close(out)
return out, func() {}
}
go func() {
defer close(out)
select {
case <-ctx.Done():
return
case b, ok := <-raw:
if !ok {
return
}
var res pb.WorkloadLogsResult
if err := json.Unmarshal(b, &res); err != nil {
log.Printf("workload results: undecodable result for %s: %v", commandID, err)
return
}
out <- &res
}
}()
return out, func() {
cancel()
unsub()
}
}
// AwaitCommand subscribes to a command's result channel for a plain
// CommandResult, which is what a control action answers with.
//
// A control action reuses CommandResult rather than growing a message of its
// own: start, stop and restart succeed or fail, and that is exactly what
// CommandResult already says.
func (r *workloadResultRegistry) AwaitCommand(commandID string) (<-chan *pb.CommandResult, func()) {
out := make(chan *pb.CommandResult, 1)
ctx, cancel := context.WithCancel(context.Background())
raw, unsub, err := bus.Subscribe(ctx, bus.ResultChannel+commandID)
if err != nil {
log.Printf("workload results: subscribe for %s: %v", commandID, err)
cancel()
close(out)
return out, func() {}
}
go func() {
defer close(out)
select {
case <-ctx.Done():
return
case b, ok := <-raw:
if !ok {
return
}
var res pb.CommandResult
if err := json.Unmarshal(b, &res); err != nil {
log.Printf("workload results: undecodable command result for %s: %v", commandID, err)
return
}
out <- &res
}
}()
return out, func() {
cancel()
unsub()
}
}
// Deliver publishes a log result received from an agent. Called on the pod
// holding that agent's stream, which is not usually the pod waiting for it.
func (r *workloadResultRegistry) Deliver(res *pb.WorkloadLogsResult) {
if res == nil || res.CommandId == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), dispatchAckTimeout)
defer cancel()
if _, err := bus.Publish(ctx, bus.ResultChannel+res.CommandId, res); err != nil {
log.Printf("workload results: publish for %s: %v", res.CommandId, err)
}
}
// DeliverCommand republishes a CommandResult onto the bus so a waiting pod can
// see it. Publishing with no subscriber is a no-op, so this is safe to call for
// every CommandResult rather than only the ones somebody is waiting on.
func (r *workloadResultRegistry) DeliverCommand(res *pb.CommandResult) {
if res == nil || res.CommandId == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), dispatchAckTimeout)
defer cancel()
if _, err := bus.Publish(ctx, bus.ResultChannel+res.CommandId, res); err != nil {
log.Printf("workload results: publish command result for %s: %v", res.CommandId, err)
}
}
+206
View File
@@ -0,0 +1,206 @@
package services
import (
"context"
"fmt"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// workloadResultTimeout bounds how long an API request waits for an agent to
// answer a control action or a log read. It is well above the agent's own
// 90-second control timeout and 60-second log timeout, so a slow-but-working
// agent reports its real error rather than being cut off by this side.
const workloadResultTimeout = 120 * time.Second
// MaxWorkloadLogLines mirrors the agent's own cap. It is declared again here
// rather than imported: agent/ is a separate module with an internal/ tree, so
// the two cannot share a constant. Change one, change the other — the same
// shape of hazard as the mirrored token blocks in the web apps.
const MaxWorkloadLogLines = 500
// workloadProtectedMarker is the text the agent's ErrProtected carries. The
// refusal crosses the wire as a string, so this is how the control plane knows
// a 409 is owed rather than a 502.
const workloadProtectedMarker = "workload is protected"
// IsWorkloadProtected reports whether an agent refused because the target is
// protected — the agent's own guard, which is the boundary. Nothing failed, so
// the API answers 409 rather than an error status.
func IsWorkloadProtected(err error) bool {
return err != nil && strings.Contains(err.Error(), workloadProtectedMarker)
}
func HasWorkloadHash(instanceID, serverID, hash string) (bool, error) {
err := db.Col("server_workloads").FindOne(context.Background(), bson.M{
"instance_id": instanceID,
"server_id": serverID,
"hash": hash,
}, options.FindOne().SetProjection(bson.M{"_id": 1})).Err()
if err == mongo.ErrNoDocuments {
return false, nil
}
return err == nil, err
}
// StoreWorkloads replaces a server's workload list.
func StoreWorkloads(instanceID, serverID, hash string, wls []models.Workload,
dockerOK bool, dockerErr string, systemdOK bool, systemdErr string) error {
_, err := db.Col("server_workloads").UpdateOne(context.Background(),
bson.M{"instance_id": instanceID, "server_id": serverID},
bson.M{"$set": bson.M{
"hash": hash,
"workloads": wls,
"collected_at": time.Now(),
"docker_ok": dockerOK,
"docker_error": dockerErr,
"systemd_ok": systemdOK,
"systemd_error": systemdErr,
}},
options.UpdateOne().SetUpsert(true),
)
return err
}
func GetWorkloads(instanceID, serverID string) (*models.ServerWorkloads, error) {
var sw models.ServerWorkloads
err := db.Col("server_workloads").FindOne(context.Background(), bson.M{
"instance_id": instanceID,
"server_id": serverID,
}).Decode(&sw)
if err == mongo.ErrNoDocuments {
return nil, nil
}
if err != nil {
return nil, err
}
return &sw, nil
}
type WorkloadHit struct {
ServerID string `json:"server_id"`
Workload models.Workload `json:"workload"`
}
// SearchWorkloads answers "which servers run image X" — the reason the snapshot
// is stored rather than fetched on demand and discarded.
func SearchWorkloads(instanceID, image, stack, state string) ([]WorkloadHit, error) {
ctx := context.Background()
filter := bson.M{"instance_id": instanceID}
if image != "" {
filter["workloads.image"] = image
}
cur, err := db.Col("server_workloads").Find(ctx, filter)
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var docs []models.ServerWorkloads
if err := cur.All(ctx, &docs); err != nil {
return nil, err
}
hits := []WorkloadHit{}
for _, d := range docs {
for _, w := range d.Workloads {
if image != "" && w.Image != image {
continue
}
if stack != "" && w.Stack != stack {
continue
}
if state != "" && w.State != state {
continue
}
hits = append(hits, WorkloadHit{ServerID: d.ServerID, Workload: w})
}
}
return hits, nil
}
// DispatchRefreshWorkloads asks an agent to report immediately. It returns as
// soon as the owning pod acks; the caller refetches the stored document.
//
// The refresh carries nothing back on purpose: the agent answers through the
// normal ReportWorkloads RPC, so server_workloads has exactly one writer.
func DispatchRefreshWorkloads(serverID string) error {
return Dispatcher.dispatch(serverID, &pb.ServerCommand{
CommandId: uuid.New().String(),
RefreshWorkloads: &pb.RefreshWorkloadsCmd{},
})
}
// DispatchControlWorkload runs a control action and waits for the agent's
// CommandResult.
//
// Await is called BEFORE dispatch. Reversing those two lines introduces a race
// that only shows under load, on a fast agent answering into a channel nobody
// has joined yet.
func DispatchControlWorkload(serverID, kind, id, action string) error {
commandID := uuid.New().String()
results, done := WorkloadResults.AwaitCommand(commandID)
defer done()
if err := Dispatcher.dispatch(serverID, &pb.ServerCommand{
CommandId: commandID,
ControlWorkload: &pb.ControlWorkloadCmd{Kind: kind, Id: id, Action: action},
}); err != nil {
return err
}
select {
case res, ok := <-results:
if !ok || res == nil {
return fmt.Errorf("no result from agent for %s %s", action, id)
}
if !res.Success {
return fmt.Errorf("%s", res.Message)
}
return nil
case <-time.After(workloadResultTimeout):
return fmt.Errorf("timed out waiting for the agent to %s %s", action, id)
}
}
// DispatchWorkloadLogs fetches a bounded log snapshot.
//
// Await is called BEFORE dispatch, for the same reason as above.
func DispatchWorkloadLogs(serverID, kind, id string, tail int) (string, bool, error) {
commandID := uuid.New().String()
results, done := WorkloadResults.Await(commandID)
defer done()
if err := Dispatcher.dispatch(serverID, &pb.ServerCommand{
CommandId: commandID,
WorkloadLogs: &pb.WorkloadLogsCmd{Kind: kind, Id: id, Tail: int32(tail)},
}); err != nil {
return "", false, err
}
select {
case res, ok := <-results:
if !ok || res == nil {
return "", false, fmt.Errorf("no log result from agent for %s", id)
}
if res.Error != "" {
return "", false, fmt.Errorf("%s", res.Error)
}
return res.Text, res.Truncated, nil
case <-time.After(workloadResultTimeout):
return "", false, fmt.Errorf("timed out waiting for logs for %s", id)
}
}
+9
View File
@@ -10,6 +10,8 @@ import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { useLicense } from "@/lib/useLicense";
import { TagChips } from "@/components/servers/TagChips";
import { ServerVulnerabilities } from "@/components/vulnerabilities/ServerVulnerabilities";
import { WorkloadList } from "@/components/workloads/WorkloadList";
import { useAuth } from "@/components/AuthProvider";
function statusVariant(status: ServerStatus) {
switch (status) {
@@ -311,6 +313,9 @@ export default function ServerDetailPage() {
const [showUpdatesModal, setShowUpdatesModal] = useState(false);
const [applySuccess, setApplySuccess] = useState(false);
const { hasFeature } = useLicense();
// Control actions and log reads are owner|admin server-side; the UI matches
// so a member is not offered buttons the API will refuse.
const { isAdmin } = useAuth();
const consoleAllowed = hasFeature("console");
const {
@@ -554,6 +559,10 @@ export default function ServerDetailPage() {
<ServerVulnerabilities serverId={server.server_id} />
</div>
<div className="lg:col-span-3">
<WorkloadList serverId={server.server_id} canControl={isAdmin} />
</div>
<div className="lg:col-span-2">
<Card padding={false}>
<div className="flex items-center justify-between border-b border-border px-6 py-4">
+16 -9
View File
@@ -7,16 +7,21 @@ import { useAuth } from "@/components/AuthProvider";
import { Button, Card } from "@/components/ui";
import { AcceptDialog } from "@/components/vulnerabilities/AcceptDialog";
import { DBFreshness } from "@/components/vulnerabilities/DBFreshness";
import { FindingRow } from "@/components/vulnerabilities/FindingRow";
import { PackageRow } from "@/components/vulnerabilities/PackageRow";
import { SEVERITY_ORDER, SeverityBadge } from "@/components/vulnerabilities/SeverityVisuals";
import { groupByPackage } from "@/lib/vulnPackages";
/*
* The fleet vulnerability board.
*
* Grouped by CVE, defaulting to open findings, with database freshness always
* on screen. The three things this page must never do: imply freshness it does
* not have, present an unsupported distribution as clean, or make one CVE on
* forty servers look like forty problems.
* Grouped by package, defaulting to open findings, with database freshness
* always on screen. The three things this page must never do: imply freshness
* it does not have, present an unsupported distribution as clean, or make one
* upgrade look like several problems.
*
* The API groups by CVE; the rollup to packages happens here, in
* `lib/vulnPackages`, because a finding is still per-CVE everywhere it is
* stored, accepted or remediated.
*/
const STATES: FindingState[] = ["open", "accepted", "fixed"];
@@ -41,6 +46,8 @@ export default function VulnerabilitiesPage() {
const servers = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const packages = useMemo(() => groupByPackage(groups.data ?? []), [groups.data]);
const serverName = useMemo(() => {
const byId = new Map((servers.data ?? []).map((s) => [s.server_id, s.hostname]));
// Falls back to the raw id rather than an empty cell: an unnamed row is
@@ -142,10 +149,10 @@ export default function VulnerabilitiesPage() {
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : groups.data && groups.data.length > 0 ? (
groups.data.map((g) => (
<FindingRow
key={g.cve_id}
) : packages.length > 0 ? (
packages.map((g) => (
<PackageRow
key={g.package_name}
group={g}
serverName={serverName}
canAct={isAdmin}
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Badge, Button, Card, Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { api, workloads } from "@/lib/api";
/*
* The fleet view answers "which servers run image X", which is the reason the
* snapshot is stored at all rather than fetched on demand and discarded.
*/
export default function WorkloadsPage() {
const [image, setImage] = useState("");
const [stack, setStack] = useState("");
const [state, setState] = useState("");
const [applied, setApplied] = useState<{ image?: string; stack?: string; state?: string }>({});
const hits = useQuery({
queryKey: ["workloads", "fleet", applied],
queryFn: () => workloads.search(applied),
});
const servers = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const hostnames = useMemo(() => {
const m = new Map<string, string>();
for (const s of servers.data ?? []) m.set(s.server_id, s.hostname);
return m;
}, [servers.data]);
const inputClass =
"w-full rounded border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent focus:outline-none";
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Workloads</h1>
<p className="mt-1 text-sm text-text-secondary">Containers and systemd services across the fleet, as last reported by each agent.</p>
</div>
<Card className="mb-6">
<form
className="grid gap-3 sm:grid-cols-4"
onSubmit={(e) => {
e.preventDefault();
setApplied({ image: image.trim(), stack: stack.trim(), state: state.trim() });
}}
>
<input className={inputClass} placeholder="image (exact)" value={image} onChange={(e) => setImage(e.target.value)} />
<input className={inputClass} placeholder="stack" value={stack} onChange={(e) => setStack(e.target.value)} />
<input className={inputClass} placeholder="state" value={state} onChange={(e) => setState(e.target.value)} />
<Button type="submit" variant="primary">
Search
</Button>
</form>
</Card>
<Card padding={false}>
{hits.isLoading ? (
<p className="px-6 py-5 text-sm text-text-secondary">Loading</p>
) : (hits.data ?? []).length === 0 ? (
<p className="px-6 py-5 text-sm text-text-secondary">No workloads match.</p>
) : (
<Table>
<Thead>
<Tr>
<Th>Server</Th>
<Th>Workload</Th>
<Th>Kind</Th>
<Th>State</Th>
<Th>Image</Th>
<Th>Stack</Th>
</Tr>
</Thead>
<Tbody>
{(hits.data ?? []).map((h) => (
<Tr key={`${h.server_id}:${h.workload.kind}:${h.workload.id}`}>
<Td>
<Link href={`/servers/${h.server_id}`} className="text-accent hover:underline">
{hostnames.get(h.server_id) ?? h.server_id}
</Link>
</Td>
<Td className="font-mono text-xs">{h.workload.name}</Td>
<Td>
<Badge variant="neutral">{h.workload.kind}</Badge>
</Td>
<Td>{h.workload.state}</Td>
<Td className="font-mono text-xs">{h.workload.image ?? "—"}</Td>
<Td>{h.workload.stack ?? "—"}</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Card>
</div>
);
}
+13
View File
@@ -133,10 +133,23 @@ function ShieldIcon() {
);
}
function WorkloadIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"
/>
</svg>
);
}
const navItems: NavItem[] = [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
{ href: "/vulnerabilities", label: "Vulnerabilities", icon: <ShieldIcon /> },
{ href: "/workloads", label: "Workloads", icon: <WorkloadIcon /> },
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
@@ -1,110 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui";
import type { VulnFinding, VulnGroup } from "@/lib/api";
import { SeverityBadge, StateBadge, relativeTime } from "./SeverityVisuals";
/*
* One row per CVE, expandable to the servers it affects.
*
* The grouping is the point. The same CVE across forty servers is one decision
* patch it, or accept it and say why and a flat list of forty findings
* makes it look like forty decisions, which is how a board stops being read.
*/
interface Props {
group: VulnGroup;
serverName: (serverId: string) => string;
canAct: boolean;
onAccept: (f: VulnFinding) => void;
onUnaccept: (f: VulnFinding) => void;
onApplyUpdates: (serverId: string) => void;
applying?: string;
}
export function FindingRow({ group, serverName, canAct, onAccept, onUnaccept, onApplyUpdates, applying }: Props) {
const [open, setOpen] = useState(false);
// A CVE with no fix anywhere cannot be patched, only accepted. Saying so on
// the collapsed row saves opening it to find there is nothing to do.
const anyFix = group.findings.some((f) => f.fixed_in);
return (
// A row inside the page's one bordered container, not a card of its
// own — the same stack idiom as the monitors and workflows lists.
<div className="border-t border-border-soft first:border-t-0">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent sm:px-5"
aria-expanded={open}
>
<span className="font-mono text-xs text-text-tertiary">{open ? "▾" : "▸"}</span>
<SeverityBadge severity={group.severity} />
<span className="font-mono text-sm font-medium text-text-primary">{group.cve_id}</span>
{group.title && <span className="hidden truncate text-sm text-text-secondary sm:block">{group.title}</span>}
<span className="ml-auto whitespace-nowrap font-mono text-[11px] text-text-tertiary">
{group.server_count} {group.server_count === 1 ? "server" : "servers"}
</span>
{!anyFix && (
<span className="hidden whitespace-nowrap rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-tertiary sm:block">
no fix
</span>
)}
</button>
{open && (
<div className="border-t border-border-soft bg-surface-2/40">
{group.findings.map((f) => (
<div key={f.id} className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border-soft px-4 py-3 last:border-b-0 sm:px-5">
<Link href={`/servers/${f.server_id}`} className="text-sm text-accent hover:underline">
{serverName(f.server_id)}
</Link>
<span className="font-mono text-xs text-text-secondary">
{f.package_name} {f.installed_version}
</span>
<span className="font-mono text-xs text-text-tertiary">
{f.fixed_in ? `${f.fixed_in}` : "no fix published"}
</span>
<StateBadge state={f.state} />
{f.state === "accepted" && f.accepted && (
<span className="text-xs text-text-tertiary">
{f.accepted.reason} · reopens {new Date(f.accepted.until).toLocaleDateString()}
</span>
)}
{f.state !== "accepted" && <span className="text-xs text-text-tertiary">first seen {relativeTime(f.first_seen)}</span>}
{canAct && (
<div className="ml-auto flex gap-2">
{/* Remediation is the existing endpoint, not a new
mechanism: see it, patch it, one place. */}
{f.fixed_in && f.state !== "fixed" && (
<Button size="sm" variant="secondary" loading={applying === f.server_id} onClick={() => onApplyUpdates(f.server_id)}>
Apply updates
</Button>
)}
{f.state === "accepted" ? (
<Button size="sm" variant="ghost" onClick={() => onUnaccept(f)}>
Reopen
</Button>
) : (
f.state === "open" && (
<Button size="sm" variant="ghost" onClick={() => onAccept(f)}>
Accept
</Button>
)
)}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,172 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui";
import type { VulnFinding } from "@/lib/api";
import type { PackageGroup, PackageServer } from "@/lib/vulnPackages";
import { SeverityBadge, StateBadge, relativeTime } from "./SeverityVisuals";
/*
* One row per package, expandable to the servers carrying it and the CVEs on
* each.
*
* The grouping is the point, and it is the same argument as the CVE grouping it
* replaced, one level in: an operator upgrades a package, not a CVE. Two CVEs
* on one apache2 are one upgrade to the higher of the two fix versions, and
* showing them as two rows with two different targets is how a fleet gets
* patched to the lower one.
*/
interface Props {
group: PackageGroup;
serverName: (serverId: string) => string;
canAct: boolean;
onAccept: (f: VulnFinding) => void;
onUnaccept: (f: VulnFinding) => void;
onApplyUpdates: (serverId: string) => void;
applying?: string;
}
export function PackageRow({ group, serverName, canAct, onAccept, onUnaccept, onApplyUpdates, applying }: Props) {
const [open, setOpen] = useState(false);
// A package with no fix anywhere cannot be patched, only accepted. Saying so
// on the collapsed row saves opening it to find there is nothing to do.
const anyFix = group.servers.some((s) => s.target);
return (
// A row inside the page's one bordered container, not a card of its
// own — the same stack idiom as the monitors and workflows lists.
<div className="border-t border-border-soft first:border-t-0">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent sm:px-5"
aria-expanded={open}
>
<span className="font-mono text-xs text-text-tertiary">{open ? "▾" : "▸"}</span>
<SeverityBadge severity={group.severity} />
<span className="font-mono text-sm font-medium text-text-primary">{group.package_name}</span>
<span className="whitespace-nowrap font-mono text-[11px] text-text-tertiary">
{group.cve_count} {group.cve_count === 1 ? "CVE" : "CVEs"}
</span>
<span className="ml-auto whitespace-nowrap font-mono text-[11px] text-text-tertiary">
{group.server_count} {group.server_count === 1 ? "server" : "servers"}
</span>
{!anyFix && (
<span className="hidden whitespace-nowrap rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-tertiary sm:block">
no fix
</span>
)}
</button>
{open && (
<div className="border-t border-border-soft bg-surface-2/40">
{group.servers.map((s) => (
<ServerBlock
key={`${s.package_name}:${s.server_id}`}
row={s}
serverName={serverName}
canAct={canAct}
onAccept={onAccept}
onUnaccept={onUnaccept}
onApplyUpdates={onApplyUpdates}
applying={applying}
/>
))}
</div>
)}
</div>
);
}
function ServerBlock({
row,
serverName,
canAct,
onAccept,
onUnaccept,
onApplyUpdates,
applying,
}: Omit<Props, "group"> & { row: PackageServer }) {
const patchable = row.target && row.findings.some((f) => f.state !== "fixed");
return (
<div className="border-b border-border-soft px-4 py-3 last:border-b-0 sm:px-5">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<Link href={`/servers/${row.server_id}`} className="text-sm text-accent hover:underline">
{serverName(row.server_id)}
</Link>
<span className="font-mono text-xs text-text-secondary">{row.installed_version}</span>
{/* One target, the highest across every CVE on this package.
A lower fix version does not remediate a higher one, so it
is never the number offered. */}
<span className="font-mono text-xs text-text-tertiary">{row.target ? `${row.target}` : "no fix published"}</span>
{row.superseded && (
<span
className="whitespace-nowrap rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-tertiary"
title="Several CVEs name different fix versions; the highest is shown and covers the rest."
>
supersedes lower fixes
</span>
)}
{canAct && patchable && (
<div className="ml-auto">
{/* Remediation is the existing endpoint, not a new
mechanism: see it, patch it, one place. */}
<Button size="sm" variant="secondary" loading={applying === row.server_id} onClick={() => onApplyUpdates(row.server_id)}>
Apply updates
</Button>
</div>
)}
</div>
<div className="mt-2 space-y-1.5">
{row.findings.map((f) => (
<div key={f.id} className="flex flex-wrap items-center gap-x-3 gap-y-1.5 pl-1">
<SeverityBadge severity={f.severity} />
<span className="font-mono text-xs text-text-secondary">{f.cve_id}</span>
{f.title && <span className="hidden truncate text-xs text-text-tertiary sm:block">{f.title}</span>}
{/* The per-CVE fix stays visible when it differs from
the row's target, so the rollup can be checked
rather than taken on trust. */}
{f.fixed_in && f.fixed_in !== row.target && (
<span className="font-mono text-[11px] text-text-tertiary">fixed in {f.fixed_in}</span>
)}
{!f.fixed_in && <span className="font-mono text-[11px] text-text-tertiary">no fix</span>}
<StateBadge state={f.state} />
{f.state === "accepted" && f.accepted && (
<span className="text-xs text-text-tertiary">
{f.accepted.reason} · reopens {new Date(f.accepted.until).toLocaleDateString()}
</span>
)}
{f.state !== "accepted" && <span className="text-xs text-text-tertiary">first seen {relativeTime(f.first_seen)}</span>}
{canAct && (
<div className="ml-auto">
{f.state === "accepted" ? (
<Button size="sm" variant="ghost" onClick={() => onUnaccept(f)}>
Reopen
</Button>
) : (
f.state === "open" && (
<Button size="sm" variant="ghost" onClick={() => onAccept(f)}>
Accept
</Button>
)
)}
</div>
)}
</div>
))}
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { Modal } from "@/components/ui";
import { workloads, type WorkloadKind } from "@/lib/api";
/*
* A bounded snapshot, not a follow. The browser console already offers a real
* terminal on the same server where `docker logs -f` works properly, with its
* own scrollback and cancellation.
*/
export function LogDialog({
serverId,
kind,
id,
name,
onClose,
}: {
serverId: string;
kind: WorkloadKind;
id: string;
name: string;
onClose: () => void;
}) {
const pre = useRef<HTMLPreElement>(null);
const logs = useQuery({
queryKey: ["workload-logs", serverId, kind, id],
queryFn: () => workloads.logs(serverId, kind, id),
refetchOnWindowFocus: false,
});
// Newest output is the point of the snapshot, so it opens at the bottom.
useEffect(() => {
if (logs.data && pre.current) pre.current.scrollTop = pre.current.scrollHeight;
}, [logs.data]);
return (
<Modal open title={`Logs · ${name}`} onClose={onClose} wide>
{logs.isLoading && <p className="text-sm text-text-secondary">Reading logs from the agent</p>}
{logs.isError && <p className="text-sm text-danger">{(logs.error as Error).message}</p>}
{logs.data && (
<>
{/* Stated, not implied: a truncated log must never be read as
a complete one. */}
{logs.data.truncated && (
<p className="mb-3 rounded border border-warning/50 px-3 py-2 text-xs text-warning">
Output was capped at 500 lines or 256KB, whichever came first. Older lines are not shown.
</p>
)}
<pre ref={pre} className="max-h-[55dvh] overflow-auto rounded border border-border bg-well p-3 font-mono text-xs text-text-primary">
{logs.data.text || "(no output)"}
</pre>
</>
)}
</Modal>
);
}
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button, Card } from "@/components/ui";
import { workloads, type Workload, type WorkloadAction, type WorkloadKind } from "@/lib/api";
import { WorkloadRow } from "./WorkloadRow";
import { LogDialog } from "./LogDialog";
function relativeAge(iso?: string): string {
if (!iso) return "never";
const secs = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
if (secs < 60) return `${Math.round(secs)}s ago`;
if (secs < 3600) return `${Math.round(secs / 60)}m ago`;
return `${Math.round(secs / 3600)}h ago`;
}
/** Compose stacks first, grouped under the stack name; then loose containers;
* then units. Not cosmetic: a stack is one thing to an operator even when it
* is six containers, and a flat list turns one decision into six rows. */
function group(list: Workload[]) {
const stacks = new Map<string, Workload[]>();
const loose: Workload[] = [];
const units: Workload[] = [];
for (const w of list) {
if (w.kind === "unit") units.push(w);
else if (w.stack) stacks.set(w.stack, [...(stacks.get(w.stack) ?? []), w]);
else loose.push(w);
}
const byName = (a: Workload, b: Workload) => a.name.localeCompare(b.name);
return {
stacks: [...stacks.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([name, items]) => [name, items.sort(byName)] as const),
loose: loose.sort(byName),
units: units.sort(byName),
};
}
export function WorkloadList({ serverId, canControl }: { serverId: string; canControl: boolean }) {
const qc = useQueryClient();
const [error, setError] = useState<string | null>(null);
const [logTarget, setLogTarget] = useState<Workload | null>(null);
const snapshot = useQuery({
queryKey: ["workloads", serverId],
queryFn: () => workloads.forServer(serverId),
});
const refresh = useMutation({
mutationFn: () => workloads.refresh(serverId),
// The refresh returns no data — the agent reports through the normal
// path, so the only correct move is to refetch the stored document.
onSuccess: () => {
setError(null);
setTimeout(() => qc.invalidateQueries({ queryKey: ["workloads", serverId] }), 1500);
},
onError: (e: Error) => setError(e.message),
});
const control = useMutation({
mutationFn: ({ w, action }: { w: Workload; action: WorkloadAction }) => workloads.control(serverId, w.kind as WorkloadKind, w.id, action),
onSuccess: () => {
setError(null);
qc.invalidateQueries({ queryKey: ["workloads", serverId] });
},
onError: (e: Error) => setError(e.message),
});
// Opening the panel asks for a fresh list: this page carries a Restart
// button, and a stale row is a wrong action aimed at something already dead.
useEffect(() => {
refresh.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serverId]);
const data = snapshot.data;
const grouped = useMemo(() => group(data?.workloads ?? []), [data]);
const row = (w: Workload) => (
<WorkloadRow
key={`${w.kind}:${w.id}`}
workload={w}
canControl={canControl}
busy={control.isPending}
onAction={(action) => control.mutate({ w, action })}
onLogs={() => setLogTarget(w)}
/>
);
return (
<Card padding={false}>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-text-primary">Workloads</h2>
<p className="mt-0.5 text-xs text-text-secondary">Collected {relativeAge(data?.collected_at)}</p>
</div>
<Button variant="ghost" size="sm" loading={refresh.isPending} onClick={() => refresh.mutate()}>
Refresh
</Button>
</div>
<div className="px-6 py-4">
{error && <p className="mb-3 text-sm text-danger">{error}</p>}
{snapshot.isLoading ? (
<p className="text-sm text-text-secondary">Loading</p>
) : !data ? (
<p className="text-sm text-text-secondary">Nothing reported yet. Agents report every 60 seconds, on Linux only.</p>
) : (
<div className="space-y-2 text-sm">
{/* Docker absent is the common case on a fleet built
around SSH keys, and is not a fault. Installed but
not responding is a different problem, so it reads
differently. */}
{data.docker_error ? (
<p className="text-warning">Docker is installed but not responding: {data.docker_error}</p>
) : !data.docker_ok ? (
<p className="text-text-secondary">Docker is not in use on this server.</p>
) : null}
{data.systemd_error ? (
<p className="text-warning">systemd could not be read: {data.systemd_error}</p>
) : !data.systemd_ok ? (
<p className="text-text-secondary">systemd is not in use on this server.</p>
) : null}
</div>
)}
</div>
{grouped.stacks.map(([stack, items]) => (
<div key={stack}>
<div className="border-y border-border bg-surface-2 px-6 py-2 text-xs font-medium uppercase tracking-[0.08em] text-text-secondary">
stack · {stack}
</div>
{items.map(row)}
</div>
))}
{grouped.loose.length > 0 && (
<div>
<div className="border-y border-border bg-surface-2 px-6 py-2 text-xs font-medium uppercase tracking-[0.08em] text-text-secondary">containers</div>
{grouped.loose.map(row)}
</div>
)}
{grouped.units.length > 0 && (
<div>
<div className="border-y border-border bg-surface-2 px-6 py-2 text-xs font-medium uppercase tracking-[0.08em] text-text-secondary">services</div>
{grouped.units.map(row)}
</div>
)}
{logTarget && (
<LogDialog
serverId={serverId}
kind={logTarget.kind as WorkloadKind}
id={logTarget.id}
name={logTarget.name}
onClose={() => setLogTarget(null)}
/>
)}
</Card>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { Badge, Button } from "@/components/ui";
import type { Workload, WorkloadAction } from "@/lib/api";
/*
* Container and unit states are kept in their own vocabularies on purpose a
* failed unit and an exited container mean different things. Colour never
* carries the state on its own: Badge already pairs a dot with the word.
*/
function stateVariant(w: Workload): "success" | "warning" | "danger" | "neutral" {
if (w.kind === "container") {
switch (w.state) {
case "running":
return w.health === "unhealthy" ? "danger" : "success";
case "restarting":
case "paused":
case "created":
return "warning";
case "dead":
case "exited":
return "danger";
default:
return "neutral";
}
}
switch (w.state) {
case "active":
return "success";
case "activating":
case "reloading":
return "warning";
case "failed":
return "danger";
case "inactive":
return "neutral";
default:
return "neutral";
}
}
const ACTIONS: WorkloadAction[] = ["start", "stop", "restart"];
export function WorkloadRow({
workload,
canControl,
busy,
onAction,
onLogs,
}: {
workload: Workload;
canControl: boolean;
busy: boolean;
onAction: (action: WorkloadAction) => void;
onLogs: () => void;
}) {
const w = workload;
return (
<div className="flex flex-col gap-3 border-b border-border px-6 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-mono text-sm text-text-primary">{w.name}</span>
<Badge variant={stateVariant(w)}>{w.state}</Badge>
{w.health && <Badge variant={w.health === "healthy" ? "success" : "warning"}>{w.health}</Badge>}
{!!w.restarts && w.restarts > 0 && <Badge variant="warning">{w.restarts} restarts</Badge>}
</div>
<p className="mt-1 truncate text-xs text-text-secondary">
{w.kind === "container" ? w.image || "no image" : "systemd unit"}
{w.ports && w.ports.length > 0 && <span className="ml-2 font-mono">{w.ports.join(" ")}</span>}
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{canControl &&
ACTIONS.map((a) => (
<Button
key={a}
size="sm"
variant={a === "stop" ? "danger" : "secondary"}
/* Protected rows show the action disabled with the
reason rather than offering a button whose refusal
the agent has already told us about. */
disabled={w.protected || busy}
title={w.protected ? "This workload runs the Vantage agent and cannot be controlled from here" : undefined}
onClick={() => onAction(a)}
>
{a}
</Button>
))}
{canControl && (
<Button size="sm" variant="ghost" onClick={onLogs}>
Logs
</Button>
)}
</div>
</div>
);
}
+73
View File
@@ -1054,6 +1054,79 @@ export const vulnerabilities = {
},
};
export type WorkloadKind = "container" | "unit";
export type WorkloadAction = "start" | "stop" | "restart";
/** One container or one systemd unit.
*
* `state` is deliberately not a shared vocabulary across the two kinds:
* containers report running/exited/paused/restarting/created, units report
* active/inactive/failed/activating. A failed unit and an exited container
* mean different things. */
export interface Workload {
kind: WorkloadKind;
id: string;
name: string;
state: string;
health?: string;
image?: string;
stack?: string;
ports?: string[];
restarts?: number;
started_at?: string;
protected: boolean;
}
export interface ServerWorkloads {
server_id: string;
hash?: string;
workloads: Workload[];
collected_at?: string;
/** false with no error means "Docker not in use here", which is not a
* fault. With an error it means installed but not responding. */
docker_ok: boolean;
docker_error?: string;
systemd_ok: boolean;
systemd_error?: string;
}
export interface WorkloadHit {
server_id: string;
workload: Workload;
}
export const workloads = {
forServer(serverId: string): Promise<ServerWorkloads> {
return request<ServerWorkloads>(`/servers/${serverId}/workloads`);
},
refresh(serverId: string): Promise<{ message: string }> {
return request<{ message: string }>(`/servers/${serverId}/workloads/refresh`, { method: "POST" });
},
control(serverId: string, kind: WorkloadKind, id: string, action: WorkloadAction): Promise<{ message: string }> {
return request<{ message: string }>(
`/servers/${serverId}/workloads/${encodeURIComponent(id)}/action`,
{ method: "POST", body: JSON.stringify({ kind, action }) },
);
},
logs(serverId: string, kind: WorkloadKind, id: string, tail = 500): Promise<{ text: string; truncated: boolean }> {
return request<{ text: string; truncated: boolean }>(
`/servers/${serverId}/workloads/${encodeURIComponent(id)}/logs?kind=${kind}&tail=${tail}`,
);
},
search(params?: { image?: string; stack?: string; state?: string }): Promise<WorkloadHit[]> {
const q = new URLSearchParams();
if (params?.image) q.set("image", params.image);
if (params?.stack) q.set("stack", params.stack);
if (params?.state) q.set("state", params.state);
const qs = q.toString();
return request<WorkloadHit[]>(`/workloads${qs ? `?${qs}` : ""}`);
},
};
// `request` already prefixes /api, so these paths do not repeat it.
export const licence = {
get(): Promise<LicenseInfo> {
Binary file not shown.
File diff suppressed because one or more lines are too long