- PATCH /agents/{id} switches the mode and reconnects the agent
- POST /revisions/{n}/deploy creates pending push deployments, sends
ApplyRevisionCommand with the deployment ID, re-sends open pushes on
connect and announces them in GetDesiredState; reports with the ID
complete the deployment
- POST /revisions/{n}/rollback re-renders the stored generator input
with the next revision number
- GET /revisions/{n}/diff and GET /config/pending with unified diffs
- GET /events streams agent, deployment, revision and pending events
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
350 lines
13 KiB
Go
350 lines
13 KiB
Go
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
|
|
|
package agentapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/types/known/durationpb"
|
|
|
|
"git.beugsiud.de/beug.k/DDI/internal/bundle"
|
|
agentv1 "git.beugsiud.de/beug.k/DDI/proto/gen/ddi/agent/v1"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/service"
|
|
)
|
|
|
|
// maxHealthChecks bounds the checks stored per report.
|
|
const maxHealthChecks = 100
|
|
|
|
func roleName(r agentv1.Role) string {
|
|
switch r {
|
|
case agentv1.Role_ROLE_DNS:
|
|
return service.RoleDNS
|
|
case agentv1.Role_ROLE_DHCP:
|
|
return service.RoleDHCP
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func modeProto(m string) agentv1.OperatingMode {
|
|
if m == agenthub.ModeManual {
|
|
return agentv1.OperatingMode_OPERATING_MODE_MANUAL
|
|
}
|
|
return agentv1.OperatingMode_OPERATING_MODE_AUTO_PULL
|
|
}
|
|
|
|
func healthStateName(h agentv1.HealthState) string {
|
|
switch h {
|
|
case agentv1.HealthState_HEALTH_STATE_OK:
|
|
return service.HealthOK
|
|
case agentv1.HealthState_HEALTH_STATE_DEGRADED:
|
|
return service.HealthDegraded
|
|
case agentv1.HealthState_HEALTH_STATE_FAILED:
|
|
return service.HealthFailed
|
|
}
|
|
return service.HealthUnknown
|
|
}
|
|
|
|
func healthFromProto(h *agentv1.HealthStatus) *service.AgentHealth {
|
|
if h == nil {
|
|
return nil
|
|
}
|
|
out := &service.AgentHealth{State: healthStateName(h.GetState()), Message: h.GetMessage(), Checks: []service.HealthCheck{}}
|
|
if h.GetCheckedAt() != nil {
|
|
t := h.GetCheckedAt().AsTime()
|
|
out.CheckedAt = &t
|
|
}
|
|
for i, c := range h.GetChecks() {
|
|
if i == maxHealthChecks {
|
|
break
|
|
}
|
|
out.Checks = append(out.Checks, service.HealthCheck{Name: c.GetName(), State: healthStateName(c.GetState()), Message: c.GetMessage()})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Enroll implements agentv1.AgentServiceServer.
|
|
func (s *Server) Enroll(ctx context.Context, req *agentv1.EnrollRequest) (*agentv1.EnrollResponse, error) {
|
|
c := callerFrom(ctx)
|
|
role := roleName(req.GetRole())
|
|
if role == "" {
|
|
return nil, status.Error(codes.InvalidArgument, "unknown role")
|
|
}
|
|
res, err := s.svc.EnrollAgent(ctx, service.EnrollInput{
|
|
Token: req.GetToken(), CSR: req.GetCsrDer(), Role: role, Hostname: req.GetHostname(),
|
|
AgentVersion: req.GetAgentVersion(), RemoteIP: c.RemoteIP, RequestID: c.RequestID,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.AgentID = res.AgentID
|
|
s.log.Info("agent enrolled", slog.String("request_id", c.RequestID), slog.String("agent_id", res.AgentID),
|
|
slog.String("role", role), slog.Any("remote", c.RemoteIP))
|
|
return &agentv1.EnrollResponse{
|
|
AgentId: res.AgentID,
|
|
CertificateDer: res.Certificate.Raw,
|
|
CaCertificatesDer: [][]byte{s.pki.CACertificate().Raw},
|
|
BundleSigningPublicKey: s.pki.BundlePublicKey(),
|
|
}, nil
|
|
}
|
|
|
|
// RenewCertificate implements agentv1.AgentServiceServer.
|
|
func (s *Server) RenewCertificate(ctx context.Context, req *agentv1.RenewCertificateRequest) (*agentv1.RenewCertificateResponse, error) {
|
|
c := callerFrom(ctx)
|
|
cert, err := s.svc.RenewAgentCertificate(ctx, c.AgentID, req.GetCsrDer())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.log.Info("agent certificate renewed", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID),
|
|
slog.Time("not_after", cert.NotAfter))
|
|
return &agentv1.RenewCertificateResponse{
|
|
CertificateDer: cert.Raw, CaCertificatesDer: [][]byte{s.pki.CACertificate().Raw},
|
|
}, nil
|
|
}
|
|
|
|
// GetDesiredState implements agentv1.AgentServiceServer.
|
|
func (s *Server) GetDesiredState(ctx context.Context, _ *agentv1.GetDesiredStateRequest) (*agentv1.GetDesiredStateResponse, error) {
|
|
c := callerFrom(ctx)
|
|
st, err := s.svc.AgentDesiredState(ctx, c.AgentID, c.RemoteIP)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &agentv1.GetDesiredStateResponse{
|
|
DesiredRevision: st.DesiredRevision, Mode: modeProto(st.Mode), PullInterval: durationpb.New(service.PullInterval),
|
|
DeploymentId: st.PushDeploymentID,
|
|
}, nil
|
|
}
|
|
|
|
// FetchBundle implements agentv1.AgentServiceServer. The bundle is signed
|
|
// for the calling agent and its role; equal input gives equal bytes.
|
|
func (s *Server) FetchBundle(ctx context.Context, req *agentv1.FetchBundleRequest) (*agentv1.FetchBundleResponse, error) {
|
|
c := callerFrom(ctx)
|
|
createdAt, arts, err := s.svc.RevisionArtifacts(ctx, req.GetRevision(), c.Role)
|
|
if errors.Is(err, service.ErrNotFound) {
|
|
return nil, status.Errorf(codes.NotFound, "revision %d does not exist", req.GetRevision())
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
manifest, sig, err := bundle.Sign(s.pki.BundleSigningKey(), c.AgentID, c.Role, req.GetRevision(), createdAt, arts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := &agentv1.FetchBundleResponse{Manifest: manifest, Signature: sig, Artifacts: make([]*agentv1.Artifact, len(arts))}
|
|
for i, a := range arts {
|
|
out.Artifacts[i] = &agentv1.Artifact{Path: a.Path, Content: a.Content}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
var (
|
|
triggerNames = map[agentv1.DeploymentTrigger]string{
|
|
agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_PULL: "pull",
|
|
agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_PUSH: "push",
|
|
}
|
|
outcomeNames = map[agentv1.DeploymentOutcome]string{
|
|
agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_SUCCEEDED: "succeeded",
|
|
agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_REJECTED: "rejected",
|
|
agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_ROLLED_BACK: "rolled_back",
|
|
agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_FAILED: "failed",
|
|
}
|
|
leaseStateNames = map[agentv1.LeaseState]string{
|
|
agentv1.LeaseState_LEASE_STATE_ACTIVE: "active",
|
|
agentv1.LeaseState_LEASE_STATE_EXPIRED: "expired",
|
|
agentv1.LeaseState_LEASE_STATE_RELEASED: "released",
|
|
agentv1.LeaseState_LEASE_STATE_DECLINED: "declined",
|
|
}
|
|
)
|
|
|
|
// ReportDeployment implements agentv1.AgentServiceServer.
|
|
func (s *Server) ReportDeployment(ctx context.Context, req *agentv1.ReportDeploymentRequest) (*agentv1.ReportDeploymentResponse, error) {
|
|
c := callerFrom(ctx)
|
|
trigger, ok := triggerNames[req.GetTrigger()]
|
|
if !ok {
|
|
return nil, status.Error(codes.InvalidArgument, "unknown trigger")
|
|
}
|
|
outcome, ok := outcomeNames[req.GetOutcome()]
|
|
if !ok {
|
|
return nil, status.Error(codes.InvalidArgument, "unknown outcome")
|
|
}
|
|
rep := service.DeploymentReport{
|
|
CorrelationID: req.GetDeploymentId(), Revision: req.GetRevision(), Trigger: trigger, Outcome: outcome,
|
|
Message: req.GetMessage(), ActiveRevision: req.GetActiveRevision(),
|
|
}
|
|
if req.GetStartedAt() != nil {
|
|
rep.StartedAt = req.GetStartedAt().AsTime()
|
|
}
|
|
if req.GetFinishedAt() != nil {
|
|
rep.FinishedAt = req.GetFinishedAt().AsTime()
|
|
}
|
|
if err := s.svc.RecordDeployment(ctx, c.AgentID, rep); err != nil {
|
|
return nil, err
|
|
}
|
|
s.log.Info("deployment reported", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID),
|
|
slog.Uint64("revision", req.GetRevision()), slog.String("outcome", outcome),
|
|
slog.Uint64("active_revision", req.GetActiveRevision()), slog.String("deployment_id", req.GetDeploymentId()))
|
|
return &agentv1.ReportDeploymentResponse{}, nil
|
|
}
|
|
|
|
// ReportLeases implements agentv1.AgentServiceServer.
|
|
func (s *Server) ReportLeases(ctx context.Context, req *agentv1.ReportLeasesRequest) (*agentv1.ReportLeasesResponse, error) {
|
|
c := callerFrom(ctx)
|
|
in := make([]service.LeaseInput, len(req.GetLeases()))
|
|
for i, l := range req.GetLeases() {
|
|
in[i] = service.LeaseInput{IP: l.GetIp(), MAC: l.GetMac(), Hostname: l.GetHostname(),
|
|
State: leaseStateNames[l.GetState()], SubnetID: l.GetSubnetId(), ClientID: l.GetClientId()}
|
|
if l.GetExpiresAt() != nil {
|
|
t := l.GetExpiresAt().AsTime()
|
|
in[i].ExpiresAt = &t
|
|
}
|
|
if l.GetUpdatedAt() != nil {
|
|
t := l.GetUpdatedAt().AsTime()
|
|
in[i].UpdatedAt = &t
|
|
}
|
|
}
|
|
res, err := s.svc.StoreLeaseBatch(ctx, c.AgentID, req.GetSequence(), req.GetFullSync(), in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
attrs := []any{slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID),
|
|
slog.Uint64("sequence", req.GetSequence()), slog.Bool("full_sync", req.GetFullSync()), slog.Int("leases", len(in))}
|
|
switch {
|
|
case res.Duplicate:
|
|
s.log.Info("dropped duplicate lease batch", append(attrs, slog.Uint64("acknowledged", res.Acknowledged))...)
|
|
case res.Skipped > 0:
|
|
s.log.Warn("skipped invalid leases", append(attrs, slog.Int("skipped", res.Skipped))...)
|
|
default:
|
|
s.log.Debug("stored lease batch", attrs...)
|
|
}
|
|
return &agentv1.ReportLeasesResponse{AcknowledgedSequence: res.Acknowledged}, nil
|
|
}
|
|
|
|
// Connect implements agentv1.AgentServiceServer: Hello → Welcome, then
|
|
// heartbeats and acknowledgements from the agent and commands from the
|
|
// server until either side ends the stream.
|
|
func (s *Server) Connect(stream agentv1.AgentService_ConnectServer) error {
|
|
ctx := stream.Context()
|
|
c := callerFrom(ctx)
|
|
first, err := stream.Recv()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hello := first.GetHello()
|
|
if hello == nil {
|
|
return status.Error(codes.InvalidArgument, "the first message must be Hello")
|
|
}
|
|
// Register before reading the desired revision, so that a revision
|
|
// created in between is pushed rather than missed.
|
|
conn := s.hub.Attach(c.AgentID, c.Role, c.Mode)
|
|
defer func() {
|
|
s.hub.Detach(conn)
|
|
s.svc.AgentDisconnected(c.AgentID)
|
|
}()
|
|
settings, err := s.svc.AgentHello(ctx, c.AgentID, service.HelloInput{
|
|
Hostname: hello.GetHostname(), AgentVersion: hello.GetAgentVersion(), ServiceVersion: hello.GetServiceVersion(),
|
|
AppliedRevision: hello.GetAppliedRevision(), LastGoodRevision: hello.GetLastGoodRevision(),
|
|
Health: healthFromProto(hello.GetHealth()), RemoteIP: c.RemoteIP,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// The mode read with Hello is the one Welcome announces; it may be
|
|
// newer than the one from the authentication (a concurrent mode change).
|
|
s.hub.SetMode(c.AgentID, settings.Mode)
|
|
if err := stream.Send(&agentv1.ConnectResponse{Message: &agentv1.ConnectResponse_Welcome{Welcome: &agentv1.Welcome{
|
|
Mode: modeProto(settings.Mode), PullInterval: durationpb.New(service.PullInterval),
|
|
HeartbeatInterval: durationpb.New(service.HeartbeatInterval), DesiredRevision: settings.DesiredRevision,
|
|
}}}); err != nil {
|
|
return err
|
|
}
|
|
s.log.Info("agent connected", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID),
|
|
slog.String("role", c.Role), slog.Uint64("applied_revision", hello.GetAppliedRevision()),
|
|
slog.Uint64("desired_revision", settings.DesiredRevision))
|
|
|
|
// An open push is sent again: the agent may have missed it while it was
|
|
// offline (ADR 0022). Only the latest one is still pending.
|
|
if settings.PushDeploymentID != "" {
|
|
cmd := agenthub.ApplyCommand(settings.DesiredRevision, settings.PushDeploymentID)
|
|
if err := stream.Send(&agentv1.ConnectResponse{Message: &agentv1.ConnectResponse_Command{Command: cmd}}); err != nil {
|
|
return err
|
|
}
|
|
s.log.Info("pending push sent", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID),
|
|
slog.String("command_id", cmd.GetCommandId()), slog.Uint64("revision", settings.DesiredRevision),
|
|
slog.String("deployment_id", settings.PushDeploymentID))
|
|
}
|
|
|
|
recvErr := make(chan error, 1)
|
|
go func() {
|
|
for {
|
|
msg, err := stream.Recv()
|
|
if err != nil {
|
|
recvErr <- err
|
|
return
|
|
}
|
|
s.handleMessage(ctx, c, msg)
|
|
}
|
|
}()
|
|
for {
|
|
select {
|
|
case cmd := <-conn.Commands():
|
|
if err := stream.Send(&agentv1.ConnectResponse{Message: &agentv1.ConnectResponse_Command{Command: cmd}}); err != nil {
|
|
return err
|
|
}
|
|
s.log.Info("command sent", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID),
|
|
slog.String("command_id", cmd.GetCommandId()), slog.Uint64("revision", cmd.GetApplyRevision().GetRevision()),
|
|
slog.String("deployment_id", cmd.GetApplyRevision().GetDeploymentId()))
|
|
case <-conn.Done():
|
|
reason := conn.Reason()
|
|
s.log.Info("agent disconnected by server", slog.String("request_id", c.RequestID),
|
|
slog.String("agent_id", c.AgentID), slog.String("reason", reason))
|
|
switch reason {
|
|
case agenthub.ReasonRevoked, agenthub.ReasonDeleted:
|
|
return status.Error(codes.PermissionDenied, reason)
|
|
case agenthub.ReasonReplaced:
|
|
return status.Error(codes.Aborted, reason)
|
|
default:
|
|
return status.Error(codes.Unavailable, reason)
|
|
}
|
|
case err := <-recvErr:
|
|
if errors.Is(err, io.EOF) {
|
|
s.log.Info("agent closed its stream", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID))
|
|
return nil
|
|
}
|
|
return err
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleMessage(ctx context.Context, c *caller, msg *agentv1.ConnectRequest) {
|
|
switch m := msg.GetMessage().(type) {
|
|
case *agentv1.ConnectRequest_Heartbeat:
|
|
hb := m.Heartbeat
|
|
err := s.svc.AgentHeartbeat(ctx, c.AgentID, service.HeartbeatInput{
|
|
AppliedRevision: hb.GetAppliedRevision(), LastGoodRevision: hb.GetLastGoodRevision(),
|
|
Health: healthFromProto(hb.GetHealth()),
|
|
})
|
|
if errors.Is(err, service.ErrAgentRevoked) {
|
|
s.hub.Disconnect(c.AgentID, agenthub.ReasonRevoked)
|
|
} else if err != nil && ctx.Err() == nil {
|
|
s.log.Warn("heartbeat not stored", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID),
|
|
slog.Any("error", err))
|
|
}
|
|
case *agentv1.ConnectRequest_CommandAck:
|
|
ack := m.CommandAck
|
|
s.log.Info("command acknowledged", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID),
|
|
slog.String("command_id", ack.GetCommandId()), slog.Bool("accepted", ack.GetAccepted()),
|
|
slog.String("message", ack.GetMessage()))
|
|
case *agentv1.ConnectRequest_Hello:
|
|
s.log.Warn("ignoring repeated Hello", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID))
|
|
default:
|
|
s.log.Warn("ignoring unknown stream message", slog.String("request_id", c.RequestID), slog.String("agent_id", c.AgentID))
|
|
}
|
|
}
|