mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
5d309eced2
Old boundary clients do not send session_id. Instead of returning a hard error that silently drops all logging and usage tracking, fall back to log-only mode when session_id is absent or unparseable. DB persistence is skipped but structured logging and the usage tracker still run. Update the agentapi unit tests to expect success (not error) for missing and invalid session_id cases, and convert the agent e2e test to a table test covering three client variants: old client (no session_id), new client with correlation disabled (empty session_id), and new client with a valid session_id.
219 lines
6.6 KiB
Go
219 lines
6.6 KiB
Go
package agentapi
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
agentproto "github.com/coder/coder/v2/agent/proto"
|
|
"github.com/coder/coder/v2/coderd/boundaryusage"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
|
)
|
|
|
|
// maxBoundaryLogsPerBatch limits the number of log entries a single
|
|
// ReportBoundaryLogs request may contain.
|
|
const maxBoundaryLogsPerBatch = 1000
|
|
|
|
type BoundaryLogsAPI struct {
|
|
Log slog.Logger
|
|
Database database.Store
|
|
AgentID uuid.UUID
|
|
WorkspaceID uuid.UUID
|
|
OwnerID uuid.UUID
|
|
TemplateID uuid.UUID
|
|
TemplateVersionID uuid.UUID
|
|
BoundaryUsageTracker *boundaryusage.Tracker
|
|
}
|
|
|
|
func (a *BoundaryLogsAPI) ReportBoundaryLogs(ctx context.Context, req *agentproto.ReportBoundaryLogsRequest) (*agentproto.ReportBoundaryLogsResponse, error) {
|
|
var allowed, denied int64
|
|
|
|
if len(req.Logs) == 0 {
|
|
a.Log.Debug(ctx, "empty boundary logs request, skipping")
|
|
return &agentproto.ReportBoundaryLogsResponse{}, nil
|
|
}
|
|
|
|
if len(req.Logs) > maxBoundaryLogsPerBatch {
|
|
return nil, xerrors.Errorf("batch size %d exceeds maximum of %d", len(req.Logs), maxBoundaryLogsPerBatch)
|
|
}
|
|
|
|
now := dbtime.Now()
|
|
|
|
// Parse session_id if present. Old boundary clients may not send it,
|
|
// so a missing or invalid session_id disables DB persistence but
|
|
// structured logging and usage tracking still run.
|
|
var sessionID uuid.UUID
|
|
persistEnabled := false
|
|
if raw := req.GetSessionId(); raw != "" {
|
|
parsed, parseErr := uuid.Parse(raw)
|
|
if parseErr != nil {
|
|
a.Log.Warn(ctx, "invalid session_id, persistence disabled for this batch",
|
|
slog.F("raw_session_id", raw),
|
|
slog.Error(parseErr))
|
|
} else {
|
|
sessionID = parsed
|
|
persistEnabled = true
|
|
}
|
|
}
|
|
|
|
if persistEnabled {
|
|
// Lazy-create the boundary session on first log arrival.
|
|
// If this fails (transient DB error), we continue so that
|
|
// logs are still persisted. The session will be created on
|
|
// a subsequent batch since every request carries the session
|
|
// details.
|
|
if sessionErr := a.ensureSession(ctx, sessionID, req.GetConfinedProcessName(), now); sessionErr != nil {
|
|
a.Log.Error(ctx, "failed to ensure boundary session",
|
|
slog.F("session_id", sessionID.String()),
|
|
slog.Error(sessionErr))
|
|
}
|
|
}
|
|
|
|
// Collect batch insert params while iterating.
|
|
batch := database.InsertBoundaryLogsParams{
|
|
SessionID: sessionID,
|
|
ID: nil,
|
|
SequenceNumber: nil,
|
|
CapturedAt: nil,
|
|
CreatedAt: nil,
|
|
Proto: nil,
|
|
Method: nil,
|
|
Detail: nil,
|
|
MatchedRule: nil,
|
|
}
|
|
|
|
for _, l := range req.Logs {
|
|
logTime := now
|
|
if l.Time != nil {
|
|
logTime = l.Time.AsTime()
|
|
}
|
|
|
|
switch r := l.Resource.(type) {
|
|
case *agentproto.BoundaryLog_HttpRequest_:
|
|
if r.HttpRequest == nil {
|
|
a.Log.Warn(ctx, "empty http request resource",
|
|
slog.F("workspace_id", a.WorkspaceID.String()))
|
|
continue
|
|
}
|
|
|
|
if l.Allowed {
|
|
allowed++
|
|
} else {
|
|
denied++
|
|
}
|
|
|
|
fields := []slog.Field{
|
|
slog.F("decision", allowBoolToString(l.Allowed)),
|
|
slog.F("workspace_id", a.WorkspaceID.String()),
|
|
slog.F("template_id", a.TemplateID.String()),
|
|
slog.F("template_version_id", a.TemplateVersionID.String()),
|
|
slog.F("http_method", r.HttpRequest.Method),
|
|
slog.F("http_url", r.HttpRequest.Url),
|
|
slog.F("event_time", logTime.Format(time.RFC3339Nano)),
|
|
}
|
|
if l.Allowed {
|
|
fields = append(fields, slog.F("matched_rule", r.HttpRequest.MatchedRule))
|
|
}
|
|
|
|
a.Log.With(fields...).Info(ctx, "boundary_request")
|
|
|
|
var matchedRule string
|
|
if l.Allowed && r.HttpRequest.MatchedRule != "" {
|
|
matchedRule = r.HttpRequest.MatchedRule
|
|
}
|
|
batch.ID = append(batch.ID, uuid.New())
|
|
batch.SequenceNumber = append(batch.SequenceNumber, l.SequenceNumber)
|
|
batch.CapturedAt = append(batch.CapturedAt, now)
|
|
batch.CreatedAt = append(batch.CreatedAt, logTime)
|
|
batch.Proto = append(batch.Proto, "http")
|
|
batch.Method = append(batch.Method, r.HttpRequest.Method)
|
|
batch.Detail = append(batch.Detail, r.HttpRequest.Url)
|
|
batch.MatchedRule = append(batch.MatchedRule, matchedRule)
|
|
default:
|
|
a.Log.Warn(ctx, "unknown resource type",
|
|
slog.F("workspace_id", a.WorkspaceID.String()))
|
|
}
|
|
}
|
|
|
|
// Batch-insert all collected logs in a single query.
|
|
if persistEnabled && len(batch.ID) > 0 {
|
|
if insertErr := a.insertLogs(ctx, batch); insertErr != nil {
|
|
a.Log.Error(ctx, "failed to insert boundary logs",
|
|
slog.F("session_id", sessionID.String()),
|
|
slog.F("count", len(batch.ID)),
|
|
slog.Error(insertErr))
|
|
}
|
|
}
|
|
|
|
if a.BoundaryUsageTracker != nil && (allowed > 0 || denied > 0) {
|
|
a.BoundaryUsageTracker.Track(a.WorkspaceID, a.OwnerID, allowed, denied)
|
|
}
|
|
|
|
return &agentproto.ReportBoundaryLogsResponse{}, nil
|
|
}
|
|
|
|
// ensureSession creates the boundary_sessions row if it does not
|
|
// already exist.
|
|
func (a *BoundaryLogsAPI) ensureSession(ctx context.Context, sessionID uuid.UUID, confinedProcess string, now time.Time) error {
|
|
if a.Database == nil {
|
|
return nil
|
|
}
|
|
|
|
// Check the database in case another replica or reconnection
|
|
// already created this session.
|
|
_, err := a.Database.GetBoundarySessionByID(ctx, sessionID)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
return xerrors.Errorf("check boundary session existence: %w", err)
|
|
}
|
|
|
|
// Session does not exist; create it. started_at is the time
|
|
// the first log is received by coderd, per the RFC.
|
|
_, err = a.Database.InsertBoundarySession(ctx, database.InsertBoundarySessionParams{
|
|
ID: sessionID,
|
|
WorkspaceAgentID: a.AgentID,
|
|
OwnerID: uuid.NullUUID{},
|
|
ConfinedProcessName: confinedProcess,
|
|
StartedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
if err != nil {
|
|
// Two coderd replicas may receive the first batch for
|
|
// this session simultaneously, both observe it missing,
|
|
// and both attempt the INSERT. The second INSERT fails
|
|
// with a primary-key unique violation. Treat it as
|
|
// success because the session now exists.
|
|
if database.IsUniqueViolation(err, database.UniqueBoundarySessionsPkey) {
|
|
return nil
|
|
}
|
|
return xerrors.Errorf("insert boundary session: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// insertLogs persists a batch of boundary log entries.
|
|
func (a *BoundaryLogsAPI) insertLogs(ctx context.Context, batch database.InsertBoundaryLogsParams) error {
|
|
if a.Database == nil {
|
|
return nil
|
|
}
|
|
_, err := a.Database.InsertBoundaryLogs(ctx, batch)
|
|
return err
|
|
}
|
|
|
|
//nolint:revive // This stringifies the boolean argument.
|
|
func allowBoolToString(b bool) string {
|
|
if b {
|
|
return "allow"
|
|
}
|
|
return "deny"
|
|
}
|