- GetDesiredStateResponse.deployment_id set: the desired revision is applied as a push with that ID, also in manual mode (ADR 0022). - The reports of the last 20 push deployments are kept in state.json. A resent push with a handled deployment ID is not applied again; its report is delivered again unless it is still queued. - A push already queued with the same deployment ID is not queued twice. - Reports the server refuses for good (InvalidArgument, NotFound, AlreadyExists, FailedPrecondition, OutOfRange) are dropped instead of blocking the report queue. - Tests for polling pushes, resent pushes, mode changes via a new Welcome, persistence and bounds of handled deployments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
623 lines
18 KiB
Go
623 lines
18 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package core
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math/rand/v2"
|
|
"sync"
|
|
"time"
|
|
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/credentials"
|
|
"google.golang.org/grpc/keepalive"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
|
|
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
|
|
"git.beugsiud.de/beug.k/DDI/internal/buildinfo"
|
|
"git.beugsiud.de/beug.k/DDI/internal/bundle"
|
|
agentv1 "git.beugsiud.de/beug.k/DDI/proto/gen/ddi/agent/v1"
|
|
)
|
|
|
|
// Defaults of the server connection (docs/adr/0021-agent-strecke-m3.md).
|
|
const (
|
|
defaultPullInterval = 60 * time.Second
|
|
defaultHeartbeatInterval = 30 * time.Second
|
|
keepaliveInterval = 30 * time.Second
|
|
keepaliveTimeout = 10 * time.Second
|
|
backoffMin = time.Second
|
|
backoffMax = 60 * time.Second
|
|
welcomeTimeout = 30 * time.Second
|
|
rpcTimeout = 30 * time.Second
|
|
// renewRetryInterval spaces failed renewal attempts (also with an
|
|
// expired certificate, which the server accepts for 30 days).
|
|
renewRetryInterval = 15 * time.Minute
|
|
// oldConnGrace keeps the connection of a replaced certificate open for
|
|
// calls in flight.
|
|
oldConnGrace = 30 * time.Second
|
|
// maxBundleMessage allows bundles up to the bundle size limit.
|
|
maxBundleMessage = bundle.MaxTotalSize + 16<<20
|
|
)
|
|
|
|
// errReconnect ends a session after the certificate was renewed.
|
|
var errReconnect = errors.New("reconnecting with the renewed certificate")
|
|
|
|
// ServerSource is the Source backed by the management server: an mTLS gRPC
|
|
// connection with the Connect control stream and GetDesiredState polling as
|
|
// fallback (docs/adr/0007-server-agent-grpc-mtls.md). It also renews the
|
|
// client certificate.
|
|
type ServerSource struct {
|
|
store *Store
|
|
agentID string
|
|
ca *x509.Certificate
|
|
server string
|
|
serverName string
|
|
hostname string
|
|
log *slog.Logger
|
|
|
|
// Timing, shortened by tests.
|
|
backoffMin, backoffMax time.Duration
|
|
welcomeTimeout time.Duration
|
|
renewRetry time.Duration
|
|
// dialOptions are appended to the defaults (tests).
|
|
dialOptions []grpc.DialOption
|
|
|
|
mu sync.Mutex
|
|
cert tls.Certificate
|
|
conn *grpc.ClientConn
|
|
pullInterval time.Duration
|
|
heartbeatInterval time.Duration
|
|
lastRenewTry time.Time
|
|
|
|
renewMu sync.Mutex
|
|
}
|
|
|
|
// NewServerSource creates the source for an enrolled identity.
|
|
func NewServerSource(store *Store, id *Identity, hostname string, log *slog.Logger) *ServerSource {
|
|
return &ServerSource{
|
|
store: store, agentID: id.AgentID, ca: id.CA, server: id.Server, serverName: id.ServerName,
|
|
hostname: hostname, log: log, cert: id.Certificate,
|
|
backoffMin: backoffMin, backoffMax: backoffMax, welcomeTimeout: welcomeTimeout,
|
|
renewRetry: renewRetryInterval, pullInterval: defaultPullInterval,
|
|
heartbeatInterval: defaultHeartbeatInterval,
|
|
}
|
|
}
|
|
|
|
// client returns the gRPC client, creating the connection on first use.
|
|
// Connections are established lazily by gRPC.
|
|
func (s *ServerSource) client() (agentv1.AgentServiceClient, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.conn == nil {
|
|
opts := append([]grpc.DialOption{
|
|
grpc.WithTransportCredentials(credentials.NewTLS(agentpki.ClientTLSConfig(s.ca, s.cert, s.serverName))),
|
|
grpc.WithKeepaliveParams(keepalive.ClientParameters{
|
|
Time: keepaliveInterval, Timeout: keepaliveTimeout, PermitWithoutStream: true,
|
|
}),
|
|
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxBundleMessage)),
|
|
}, s.dialOptions...)
|
|
conn, err := grpc.NewClient(s.server, opts...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("connect to %s: %w", s.server, err)
|
|
}
|
|
s.conn = conn
|
|
}
|
|
return agentv1.NewAgentServiceClient(s.conn), nil
|
|
}
|
|
|
|
// replaceConn drops the current connection (after a certificate renewal);
|
|
// the next call dials with the new certificate.
|
|
func (s *ServerSource) replaceConn() {
|
|
s.mu.Lock()
|
|
old := s.conn
|
|
s.conn = nil
|
|
s.mu.Unlock()
|
|
if old != nil {
|
|
time.AfterFunc(oldConnGrace, func() { _ = old.Close() })
|
|
}
|
|
}
|
|
|
|
func (s *ServerSource) closeConn() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.conn != nil {
|
|
_ = s.conn.Close()
|
|
s.conn = nil
|
|
}
|
|
}
|
|
|
|
func (s *ServerSource) intervals() (pull, heartbeat time.Duration) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.pullInterval, s.heartbeatInterval
|
|
}
|
|
|
|
func (s *ServerSource) setIntervals(pull, heartbeat time.Duration) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if pull > 0 {
|
|
s.pullInterval = pull
|
|
}
|
|
if heartbeat > 0 {
|
|
s.heartbeatInterval = heartbeat
|
|
}
|
|
}
|
|
|
|
// Run implements Source: it keeps a Connect stream open, reconnecting with
|
|
// exponential backoff and jitter, and polls GetDesiredState every pull
|
|
// interval while no stream can be established.
|
|
func (s *ServerSource) Run(ctx context.Context, h SourceHandler) error {
|
|
defer s.closeConn()
|
|
backoff := s.backoffMin
|
|
var lastPoll time.Time
|
|
reachable := false
|
|
for ctx.Err() == nil {
|
|
s.maybeRenew(ctx)
|
|
established, err := s.session(ctx, h)
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
wait := true
|
|
switch {
|
|
case errors.Is(err, errReconnect):
|
|
wait = false
|
|
backoff = s.backoffMin
|
|
case established:
|
|
s.log.Warn("control stream to the server closed", "error", err)
|
|
backoff = s.backoffMin
|
|
reachable = false
|
|
default:
|
|
s.log.Warn("cannot open control stream", "server", s.server, "error", err)
|
|
pull, _ := s.intervals()
|
|
if time.Since(lastPoll) >= pull {
|
|
lastPoll = time.Now()
|
|
if err := s.poll(ctx, h); err != nil {
|
|
s.log.Warn("cannot poll the desired state", "error", err)
|
|
reachable = false
|
|
} else if !reachable {
|
|
reachable = true
|
|
h.Connected()
|
|
}
|
|
}
|
|
}
|
|
if wait {
|
|
if !sleepCtx(ctx, jitter(backoff)) {
|
|
break
|
|
}
|
|
backoff = min(backoff*2, s.backoffMax)
|
|
}
|
|
}
|
|
return ctx.Err()
|
|
}
|
|
|
|
// session runs one Connect stream. established reports whether the server
|
|
// answered the Hello with a Welcome.
|
|
func (s *ServerSource) session(ctx context.Context, h SourceHandler) (established bool, err error) {
|
|
cl, err := s.client()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
sctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
stream, err := cl.Connect(sctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer func() { _ = stream.CloseSend() }()
|
|
|
|
st := h.Status(sctx)
|
|
if err := stream.Send(&agentv1.ConnectRequest{Message: &agentv1.ConnectRequest_Hello{Hello: &agentv1.Hello{
|
|
AgentVersion: buildinfo.Get().Version, AppliedRevision: st.Applied, LastGoodRevision: st.LastGood,
|
|
Health: healthToProto(st.Health, st.CheckedAt), ServiceVersion: st.ServiceVersion, Hostname: s.hostname,
|
|
}}}); err != nil {
|
|
return false, err
|
|
}
|
|
|
|
msgs := make(chan *agentv1.ConnectResponse)
|
|
recvErr := make(chan error, 1)
|
|
go func() {
|
|
for {
|
|
msg, err := stream.Recv()
|
|
if err != nil {
|
|
recvErr <- err
|
|
return
|
|
}
|
|
select {
|
|
case msgs <- msg:
|
|
case <-sctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
timer := time.NewTimer(s.welcomeTimeout)
|
|
defer timer.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return false, ctx.Err()
|
|
case err := <-recvErr:
|
|
return false, err
|
|
case <-timer.C:
|
|
return false, errors.New("server did not answer the Hello in time")
|
|
case msg := <-msgs:
|
|
w := msg.GetWelcome()
|
|
if w == nil {
|
|
return false, errors.New("server did not answer the Hello with a Welcome")
|
|
}
|
|
s.log.Info("connected to the server", "server", s.server, "mode", w.GetMode().String(),
|
|
"desired_revision", w.GetDesiredRevision())
|
|
s.welcome(w, h, true)
|
|
}
|
|
|
|
_, hbInterval := s.intervals()
|
|
heartbeat := time.NewTicker(hbInterval)
|
|
defer heartbeat.Stop()
|
|
renew := time.NewTimer(s.untilRenewal())
|
|
defer renew.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return true, ctx.Err()
|
|
case err := <-recvErr:
|
|
return true, err
|
|
case msg := <-msgs:
|
|
switch m := msg.GetMessage().(type) {
|
|
case *agentv1.ConnectResponse_Welcome:
|
|
s.welcome(m.Welcome, h, false)
|
|
_, hb := s.intervals()
|
|
heartbeat.Reset(hb)
|
|
case *agentv1.ConnectResponse_Command:
|
|
if err := s.command(ctx, stream, m.Command, h); err != nil {
|
|
return true, err
|
|
}
|
|
}
|
|
case <-heartbeat.C:
|
|
st := h.Status(sctx)
|
|
if err := stream.Send(&agentv1.ConnectRequest{Message: &agentv1.ConnectRequest_Heartbeat{Heartbeat: &agentv1.Heartbeat{
|
|
AppliedRevision: st.Applied, LastGoodRevision: st.LastGood, Health: healthToProto(st.Health, st.CheckedAt),
|
|
}}}); err != nil {
|
|
return true, err
|
|
}
|
|
case <-renew.C:
|
|
if s.maybeRenew(ctx) {
|
|
return true, errReconnect
|
|
}
|
|
renew.Reset(s.untilRenewal())
|
|
}
|
|
}
|
|
}
|
|
|
|
// welcome applies the settings of a Welcome and announces its desired
|
|
// revision.
|
|
func (s *ServerSource) welcome(w *agentv1.Welcome, h SourceHandler, first bool) {
|
|
s.setIntervals(w.GetPullInterval().AsDuration(), w.GetHeartbeatInterval().AsDuration())
|
|
if first {
|
|
h.Connected()
|
|
}
|
|
h.Desired(w.GetDesiredRevision(), w.GetMode() != agentv1.OperatingMode_OPERATING_MODE_MANUAL)
|
|
}
|
|
|
|
// command acknowledges and executes a server command.
|
|
func (s *ServerSource) command(ctx context.Context, stream agentv1.AgentService_ConnectClient, c *agentv1.Command, h SourceHandler) error {
|
|
ack := func(accepted bool, msg string) error {
|
|
return stream.Send(&agentv1.ConnectRequest{Message: &agentv1.ConnectRequest_CommandAck{CommandAck: &agentv1.CommandAck{
|
|
CommandId: c.GetCommandId(), Accepted: accepted, Message: msg,
|
|
}}})
|
|
}
|
|
log := s.log.With("command_id", c.GetCommandId())
|
|
switch a := c.GetAction().(type) {
|
|
case *agentv1.Command_ApplyRevision:
|
|
rev, dep := a.ApplyRevision.GetRevision(), a.ApplyRevision.GetDeploymentId()
|
|
if rev == 0 {
|
|
log.Warn("apply command without revision")
|
|
return ack(false, "revision is missing")
|
|
}
|
|
log.Info("apply command received", "revision", rev, "deployment_id", dep)
|
|
if err := ack(true, ""); err != nil {
|
|
return err
|
|
}
|
|
h.Push(rev, dep)
|
|
return nil
|
|
case *agentv1.Command_RenewCertificate:
|
|
log.Info("certificate renewal requested by the server")
|
|
if err := ack(true, ""); err != nil {
|
|
return err
|
|
}
|
|
if err := s.renew(ctx); err != nil {
|
|
log.Error("certificate renewal failed", "error", err)
|
|
return nil
|
|
}
|
|
return errReconnect
|
|
default:
|
|
log.Warn("unknown command")
|
|
return ack(false, "unknown command")
|
|
}
|
|
}
|
|
|
|
// poll asks for the desired state once.
|
|
func (s *ServerSource) poll(ctx context.Context, h SourceHandler) error {
|
|
cl, err := s.client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
st := h.Status(ctx)
|
|
rctx, cancel := context.WithTimeout(ctx, rpcTimeout)
|
|
defer cancel()
|
|
resp, err := cl.GetDesiredState(rctx, &agentv1.GetDesiredStateRequest{AppliedRevision: st.Applied})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.setIntervals(resp.GetPullInterval().AsDuration(), 0)
|
|
rev, dep := resp.GetDesiredRevision(), resp.GetDeploymentId()
|
|
s.log.Debug("desired state polled", "desired_revision", rev, "mode", resp.GetMode().String(), "deployment_id", dep)
|
|
if dep != "" && rev != 0 {
|
|
// An open push: applied in every mode (docs/adr/0022-ausrollen-pull-push-m4.md).
|
|
s.log.Info("open push received by polling", "revision", rev, "deployment_id", dep)
|
|
h.Push(rev, dep)
|
|
return nil
|
|
}
|
|
h.Desired(rev, resp.GetMode() != agentv1.OperatingMode_OPERATING_MODE_MANUAL)
|
|
return nil
|
|
}
|
|
|
|
// leaf returns the current client certificate.
|
|
func (s *ServerSource) leaf() *x509.Certificate {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.cert.Leaf
|
|
}
|
|
|
|
// renewalDue returns when the certificate should be renewed: when half of
|
|
// its lifetime has passed.
|
|
func renewalDue(c *x509.Certificate) time.Time {
|
|
return c.NotBefore.Add(c.NotAfter.Sub(c.NotBefore) / 2)
|
|
}
|
|
|
|
// untilRenewal returns the time until the next renewal attempt.
|
|
func (s *ServerSource) untilRenewal() time.Duration {
|
|
due := renewalDue(s.leaf())
|
|
s.mu.Lock()
|
|
last := s.lastRenewTry
|
|
s.mu.Unlock()
|
|
if !last.IsZero() && time.Now().After(due) {
|
|
due = last.Add(s.renewRetry)
|
|
}
|
|
return max(time.Until(due), 0)
|
|
}
|
|
|
|
// maybeRenew renews the certificate if half of its lifetime has passed
|
|
// (or it expired), spacing failed attempts by renewRetry.
|
|
func (s *ServerSource) maybeRenew(ctx context.Context) bool {
|
|
leaf := s.leaf()
|
|
now := time.Now()
|
|
if now.Before(renewalDue(leaf)) {
|
|
return false
|
|
}
|
|
s.mu.Lock()
|
|
if !s.lastRenewTry.IsZero() && now.Sub(s.lastRenewTry) < s.renewRetry {
|
|
s.mu.Unlock()
|
|
return false
|
|
}
|
|
s.lastRenewTry = now
|
|
s.mu.Unlock()
|
|
if err := s.renew(ctx); err != nil {
|
|
if now.After(leaf.NotAfter) {
|
|
s.log.Error("client certificate expired and renewal failed; the agent must be enrolled again if this persists",
|
|
"not_after", leaf.NotAfter, "error", err)
|
|
} else {
|
|
s.log.Warn("certificate renewal failed", "not_after", leaf.NotAfter, "error", err)
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// renew obtains a certificate for a new key and stores it.
|
|
func (s *ServerSource) renew(ctx context.Context) error {
|
|
s.renewMu.Lock()
|
|
defer s.renewMu.Unlock()
|
|
key, csr, err := newKeyAndCSR(s.agentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cl, err := s.client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rctx, cancel := context.WithTimeout(ctx, rpcTimeout)
|
|
defer cancel()
|
|
resp, err := cl.RenewCertificate(rctx, &agentv1.RenewCertificateRequest{CsrDer: csr})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if chain := resp.GetCaCertificatesDer(); len(chain) > 0 {
|
|
if root, err := x509.ParseCertificate(chain[len(chain)-1]); err != nil || !root.Equal(s.ca) {
|
|
s.log.Warn("server sent a different CA certificate; keeping the enrolled CA")
|
|
}
|
|
}
|
|
cert, err := checkIssued(resp.GetCertificateDer(), s.ca, s.agentID, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.store.SaveCertificate(key, cert); err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
s.cert = keyPair(key, cert)
|
|
s.lastRenewTry = time.Time{}
|
|
s.mu.Unlock()
|
|
s.replaceConn()
|
|
s.log.Info("client certificate renewed", "not_after", cert.NotAfter)
|
|
return nil
|
|
}
|
|
|
|
// FetchBundle implements Source.
|
|
func (s *ServerSource) FetchBundle(ctx context.Context, revision uint64) (*Bundle, error) {
|
|
cl, err := s.client()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := cl.FetchBundle(ctx, &agentv1.FetchBundleRequest{Revision: revision})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b := &Bundle{Manifest: resp.GetManifest(), Signature: resp.GetSignature()}
|
|
for _, a := range resp.GetArtifacts() {
|
|
b.Artifacts = append(b.Artifacts, BundleArtifact{Path: a.GetPath(), Content: a.GetContent()})
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// ReportDeployment implements Source.
|
|
func (s *ServerSource) ReportDeployment(ctx context.Context, r DeploymentReport) error {
|
|
cl, err := s.client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = cl.ReportDeployment(ctx, &agentv1.ReportDeploymentRequest{
|
|
DeploymentId: r.DeploymentID, Revision: r.Revision, Trigger: triggerToProto(r.Trigger),
|
|
Outcome: outcomeToProto(r.Outcome), Message: r.Message, ActiveRevision: r.ActiveRevision,
|
|
StartedAt: timestamppb.New(r.StartedAt), FinishedAt: timestamppb.New(r.FinishedAt),
|
|
})
|
|
if refusedReportCodes[status.Code(err)] {
|
|
return fmt.Errorf("%w: %w", ErrReportRefused, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// refusedReportCodes are the answers to ReportDeployment that a retry
|
|
// cannot change: the server considers the report invalid or does not
|
|
// expect it (any more).
|
|
var refusedReportCodes = map[codes.Code]bool{
|
|
codes.InvalidArgument: true,
|
|
codes.NotFound: true,
|
|
codes.AlreadyExists: true,
|
|
codes.FailedPrecondition: true,
|
|
codes.OutOfRange: true,
|
|
}
|
|
|
|
// ReportLeases implements Source.
|
|
func (s *ServerSource) ReportLeases(ctx context.Context, b LeaseBatch) error {
|
|
cl, err := s.client()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req := &agentv1.ReportLeasesRequest{Sequence: b.Sequence, FullSync: b.FullSync, Leases: make([]*agentv1.Lease, len(b.Leases))}
|
|
for i, l := range b.Leases {
|
|
req.Leases[i] = leaseToProto(l)
|
|
}
|
|
rctx, cancel := context.WithTimeout(ctx, rpcTimeout)
|
|
defer cancel()
|
|
resp, err := cl.ReportLeases(rctx, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ack := resp.GetAcknowledgedSequence(); ack != b.Sequence {
|
|
s.log.Debug("lease report acknowledged with another sequence", "sequence", b.Sequence, "acknowledged", ack)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func healthToProto(h Health, at time.Time) *agentv1.HealthStatus {
|
|
out := &agentv1.HealthStatus{State: healthStateToProto(h.State), Message: h.Message}
|
|
if !at.IsZero() {
|
|
out.CheckedAt = timestamppb.New(at)
|
|
}
|
|
for _, c := range h.Checks {
|
|
out.Checks = append(out.Checks, &agentv1.HealthCheck{Name: c.Name, State: healthStateToProto(c.State), Message: c.Message})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func healthStateToProto(s HealthState) agentv1.HealthState {
|
|
switch s {
|
|
case HealthOK:
|
|
return agentv1.HealthState_HEALTH_STATE_OK
|
|
case HealthDegraded:
|
|
return agentv1.HealthState_HEALTH_STATE_DEGRADED
|
|
case HealthFailed:
|
|
return agentv1.HealthState_HEALTH_STATE_FAILED
|
|
default:
|
|
return agentv1.HealthState_HEALTH_STATE_UNSPECIFIED
|
|
}
|
|
}
|
|
|
|
func triggerToProto(t Trigger) agentv1.DeploymentTrigger {
|
|
switch t {
|
|
case TriggerPull:
|
|
return agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_PULL
|
|
case TriggerPush:
|
|
return agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_PUSH
|
|
default:
|
|
return agentv1.DeploymentTrigger_DEPLOYMENT_TRIGGER_UNSPECIFIED
|
|
}
|
|
}
|
|
|
|
func outcomeToProto(o Outcome) agentv1.DeploymentOutcome {
|
|
switch o {
|
|
case OutcomeSucceeded:
|
|
return agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_SUCCEEDED
|
|
case OutcomeRejected:
|
|
return agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_REJECTED
|
|
case OutcomeRolledBack:
|
|
return agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_ROLLED_BACK
|
|
case OutcomeFailed:
|
|
return agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_FAILED
|
|
default:
|
|
return agentv1.DeploymentOutcome_DEPLOYMENT_OUTCOME_UNSPECIFIED
|
|
}
|
|
}
|
|
|
|
func leaseToProto(l Lease) *agentv1.Lease {
|
|
out := &agentv1.Lease{
|
|
Ip: l.IP.String(), Mac: l.MAC, Hostname: l.Hostname, State: leaseStateToProto(l.State),
|
|
SubnetId: l.SubnetID, ClientId: l.ClientID,
|
|
}
|
|
if !l.Expires.IsZero() {
|
|
out.ExpiresAt = timestamppb.New(l.Expires)
|
|
}
|
|
if !l.Updated.IsZero() {
|
|
out.UpdatedAt = timestamppb.New(l.Updated)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func leaseStateToProto(s LeaseState) agentv1.LeaseState {
|
|
switch s {
|
|
case LeaseActive:
|
|
return agentv1.LeaseState_LEASE_STATE_ACTIVE
|
|
case LeaseExpired:
|
|
return agentv1.LeaseState_LEASE_STATE_EXPIRED
|
|
case LeaseReleased:
|
|
return agentv1.LeaseState_LEASE_STATE_RELEASED
|
|
case LeaseDeclined:
|
|
return agentv1.LeaseState_LEASE_STATE_DECLINED
|
|
default:
|
|
return agentv1.LeaseState_LEASE_STATE_UNSPECIFIED
|
|
}
|
|
}
|
|
|
|
// jitter returns d randomized by ±20 %.
|
|
func jitter(d time.Duration) time.Duration {
|
|
return time.Duration(float64(d) * (0.8 + 0.4*rand.Float64())) //nolint:gosec // jitter needs no cryptographic randomness
|
|
}
|
|
|
|
// sleepCtx waits for d; it returns false if ctx ended first.
|
|
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
|
t := time.NewTimer(d)
|
|
defer t.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return false
|
|
case <-t.C:
|
|
return true
|
|
}
|
|
}
|