e75c8b37c8
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
338 lines
14 KiB
Go
338 lines
14 KiB
Go
package backendapp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"strconv"
|
|
"time"
|
|
|
|
agentruntime "github.com/kandev/kandev/internal/agent/runtime"
|
|
agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl"
|
|
"github.com/kandev/kandev/internal/agent/runtime/agentctl/launcher"
|
|
"github.com/kandev/kandev/internal/common/config"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/common/ownershipperiod"
|
|
"github.com/kandev/kandev/internal/secrets"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// agentctlLauncherResult holds the outputs of provideAgentctlLauncher.
|
|
type agentctlLauncherResult struct {
|
|
cleanup func() error
|
|
binaryPath string
|
|
// recoveryDeadlineStart is the AC-EXECUTORS-SURVIVAL-003.7 recovery
|
|
// deadline's clock start (the instant this launch first contacted a
|
|
// recorded control endpoint). Zero when this launch never contacted one
|
|
// (adoption disabled, no record, or a fresh spawn) -- the lifecycle
|
|
// manager falls back to its own Start-time default in that case.
|
|
recoveryDeadlineStart time.Time
|
|
// inheritedRecordScope is what this launch found at the recorded control
|
|
// endpoint. It governs how recovery classifies recovery-inventory records
|
|
// left by an earlier launch, which cannot be judged against a server this
|
|
// backend started itself.
|
|
inheritedRecordScope agentruntime.InheritedRecordScope
|
|
}
|
|
|
|
// provideAgentctlLauncher starts or adopts the agentctl control server for
|
|
// standalone runtime. agentctl is a core service that always runs - it's used
|
|
// by the Standalone runtime for agent execution on the host machine.
|
|
//
|
|
// When the agent-survival capability is enabled (Layer 6.1 of
|
|
// agent-survival-across-restart), a surviving control server from a prior
|
|
// launch is tried first via agentruntime.AttemptAdoptControlServer; only when
|
|
// that fails or there is no record does this spawn a fresh one, same as
|
|
// when the capability is disabled. Either way, cfg.Agent.Standalone* is
|
|
// updated to point at whichever server this launch ends up using.
|
|
func provideAgentctlLauncher(
|
|
ctx context.Context,
|
|
cfg *config.Config,
|
|
log *logger.Logger,
|
|
availability *agentctlclient.Availability,
|
|
store agentruntime.AdoptionRecordStore,
|
|
secretStore secrets.SecretStore,
|
|
) (*agentctlLauncherResult, error) {
|
|
result, scope := resolveSurvivingAgentctl(ctx, cfg, log, store, secretStore)
|
|
if result != nil {
|
|
availability.MarkAvailable()
|
|
return result, nil
|
|
}
|
|
fresh, err := spawnFreshAgentctl(ctx, cfg, log, availability, store, secretStore)
|
|
if fresh != nil {
|
|
// The server this launch just started never launched the instances a
|
|
// prior launch recorded, so recovery needs to know what was found at
|
|
// the recorded endpoint rather than judging them against it.
|
|
fresh.inheritedRecordScope = scope
|
|
}
|
|
return fresh, err
|
|
}
|
|
|
|
// resolveSurvivingAgentctl applies the agent-survival capability gate to a
|
|
// control server a prior launch left running, and returns non-nil only when
|
|
// this launch took that server over. With the capability disabled nothing is
|
|
// ever adopted, but a server an earlier survival-enabled launch detached is
|
|
// still running with no backend attached to it, so it is stopped here rather
|
|
// than left to its own unowned-shutdown timer. Either way a nil return means
|
|
// the caller spawns fresh.
|
|
func resolveSurvivingAgentctl(
|
|
ctx context.Context,
|
|
cfg *config.Config,
|
|
log *logger.Logger,
|
|
store agentruntime.AdoptionRecordStore,
|
|
secretStore secrets.SecretStore,
|
|
) (*agentctlLauncherResult, agentruntime.InheritedRecordScope) {
|
|
if !cfg.Features.AgentSurvival {
|
|
agentruntime.ReclaimUnneededControlServer(ctx, store, secretStore, controlClientFactory(log), cfg.ResolvedHomeDir(),
|
|
cfg.Agentctl.RecoveryReadTimeout, cfg.Agentctl.RecoveryReadRetries, log)
|
|
return nil, agentruntime.InheritedRecordScopeNoServer
|
|
}
|
|
return adoptSurvivingAgentctl(ctx, cfg, log, store, secretStore)
|
|
}
|
|
|
|
// refusedAdoptionScope maps a refused adoption to what recovery may conclude
|
|
// about records an earlier launch left behind. A refusal that never reached
|
|
// the server -- nothing answered at the recorded endpoint, or no endpoint was
|
|
// recorded -- means no control server holds those instances, so they can be
|
|
// probed and repaired. Every refusal that did reach a reachable server leaves
|
|
// that server running, and an instance may still be alive on it, so nothing
|
|
// may be concluded from its absence from a server this launch started.
|
|
func refusedAdoptionScope(outcome agentruntime.AdoptionOutcome) agentruntime.InheritedRecordScope {
|
|
if outcome.ContactedAt.IsZero() {
|
|
return agentruntime.InheritedRecordScopeNoServer
|
|
}
|
|
return agentruntime.InheritedRecordScopeForeignServer
|
|
}
|
|
|
|
// adoptSurvivingAgentctl attempts to adopt a control server recorded by a
|
|
// prior launch. Returns nil when adoption did not complete for any reason
|
|
// (design 02's "Startup" steps 4-6): the caller then falls back to spawning
|
|
// a fresh server exactly as it would with the capability disabled.
|
|
func adoptSurvivingAgentctl(
|
|
ctx context.Context,
|
|
cfg *config.Config,
|
|
log *logger.Logger,
|
|
store agentruntime.AdoptionRecordStore,
|
|
secretStore secrets.SecretStore,
|
|
) (*agentctlLauncherResult, agentruntime.InheritedRecordScope) {
|
|
outcome := agentruntime.AttemptAdoptControlServer(ctx, store, secretStore, controlClientFactory(log),
|
|
cfg.ResolvedHomeDir(), agentruntime.RequiredSurvivalCapabilities,
|
|
cfg.Agentctl.RecoveryReadTimeout, cfg.Agentctl.RecoveryReadRetries, log)
|
|
if !outcome.Adopted {
|
|
log.Info("control server adoption did not complete; spawning a fresh one",
|
|
zap.String("reason", string(outcome.Reason)))
|
|
return nil, refusedAdoptionScope(outcome)
|
|
}
|
|
|
|
host, port, err := splitEndpoint(outcome.Endpoint)
|
|
if err != nil {
|
|
log.Warn("adopted control server endpoint is malformed; spawning fresh instead", zap.Error(err))
|
|
return nil, refusedAdoptionScope(outcome)
|
|
}
|
|
|
|
log.Info("adopted a surviving control server", zap.String("endpoint", outcome.Endpoint))
|
|
cfg.Agent.StandaloneHost = host
|
|
cfg.Agent.StandalonePort = port
|
|
cfg.Agent.StandaloneAuthToken = outcome.Credential
|
|
// This process never spawned the adopted server, so it has no PID to
|
|
// record here. Standalone liveness for an adopted record is judged by
|
|
// enumeration against the adopted server (design 02, "Persistence"),
|
|
// not by a stale PID probe -- a later layer of this feature.
|
|
cfg.Agent.StandalonePID = 0
|
|
|
|
// AC-EXECUTORS-CONTROL-OWNERSHIP-003.8: the rotation AttemptAdoptControlServer
|
|
// just performed already counted as the first renewal, but nothing renews
|
|
// ownership again after that without this loop. The renewal cadence is
|
|
// computed from the adopted server's OWN reported unowned period
|
|
// (outcome.UnownedPeriod), never this launch's local config: the two can
|
|
// disagree across a restart that changed agentctl.unownedPeriod, and
|
|
// renewing on a stale/mismatched cadence lets the server's own reaper
|
|
// fire while this backend still believes it owns it (Review round 3,
|
|
// finding 5).
|
|
renewer := startOwnershipRenewal(ctx, log, outcome.Endpoint, outcome.Credential,
|
|
resolveAdoptedRenewalPeriod(outcome.UnownedPeriod))
|
|
|
|
return &agentctlLauncherResult{
|
|
// Survival is enabled and this server was adopted, not spawned: the
|
|
// registered cleanup must not stop it (AC-EXECUTORS-SURVIVAL-001.1),
|
|
// and there is no local process for this launcher to own regardless.
|
|
// It must still stop the renewal loop this backend started, or the
|
|
// goroutine leaks past backend shutdown.
|
|
cleanup: func() error {
|
|
if renewer != nil {
|
|
renewer.Stop()
|
|
}
|
|
return nil
|
|
},
|
|
binaryPath: launcher.FindAgentctlBinary(),
|
|
recoveryDeadlineStart: outcome.ContactedAt,
|
|
inheritedRecordScope: agentruntime.InheritedRecordScopeAdopted,
|
|
}, agentruntime.InheritedRecordScopeAdopted
|
|
}
|
|
|
|
// spawnFreshAgentctl is today's unconditional launch path. If the
|
|
// agent-survival capability is enabled, the freshly spawned server is also
|
|
// durably recorded as the installation's control server (failure-table row
|
|
// "Own server started after a refused or failed adoption"); recording
|
|
// failure is logged and non-fatal to startup, since the next restart simply
|
|
// finds no record and spawns fresh again.
|
|
func spawnFreshAgentctl(
|
|
ctx context.Context,
|
|
cfg *config.Config,
|
|
log *logger.Logger,
|
|
availability *agentctlclient.Availability,
|
|
store agentruntime.AdoptionRecordStore,
|
|
secretStore secrets.SecretStore,
|
|
) (*agentctlLauncherResult, error) {
|
|
l, cleanup, err := launcher.Provide(ctx, launcher.Config{
|
|
Host: cfg.Agent.StandaloneHost,
|
|
Port: cfg.Agent.StandalonePort,
|
|
StartupConfig: cfg.ManagedAgentctlStartupConfig(),
|
|
OnUnexpectedExit: availability.MarkUnavailable,
|
|
}, log)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
availability.MarkAvailable()
|
|
// Update config with the actual port (may differ if fallback was used)
|
|
if actualPort := l.Port(); actualPort != cfg.Agent.StandalonePort {
|
|
log.Info("agentctl port changed from configured value",
|
|
zap.Int("configured_port", cfg.Agent.StandalonePort),
|
|
zap.Int("actual_port", actualPort))
|
|
cfg.Agent.StandalonePort = actualPort
|
|
}
|
|
// Store the per-launch auth token so downstream clients can authenticate.
|
|
cfg.Agent.StandaloneAuthToken = l.AuthToken()
|
|
// Store the agentctl control-server PID so local/standalone executor rows can
|
|
// carry a real host-local liveness handle (executors_running.local_pid).
|
|
cfg.Agent.StandalonePID = l.Pid()
|
|
|
|
var renewer *agentruntime.OwnershipRenewer
|
|
if cfg.Features.AgentSurvival {
|
|
endpoint := net.JoinHostPort(cfg.Agent.StandaloneHost, strconv.Itoa(cfg.Agent.StandalonePort))
|
|
client, err := controlClientFactory(log)(endpoint)
|
|
if err != nil {
|
|
log.Warn("failed to build control client for the freshly spawned agentctl; adoption record not written", zap.Error(err))
|
|
} else if err := agentruntime.RecordFreshControlServer(ctx, store, secretStore, client, endpoint, l.AuthToken()); err != nil {
|
|
log.Warn("failed to record freshly spawned control server", zap.Error(err))
|
|
}
|
|
// AC-EXECUTORS-CONTROL-OWNERSHIP-003.8: the bootstrap handshake this
|
|
// launch just completed already counted as the first renewal, but
|
|
// nothing renews ownership again after that without this loop. This
|
|
// launch spawned the server moments ago from this same config, so
|
|
// (unlike the adopted path above) the local resolution IS the value
|
|
// the server itself enforces.
|
|
period, _ := ownershipperiod.Resolve(cfg.Agentctl.UnownedPeriod, cfg.Agentctl.IdleTimeout)
|
|
renewer = startOwnershipRenewal(ctx, log, endpoint, l.AuthToken(), period)
|
|
}
|
|
|
|
return &agentctlLauncherResult{
|
|
cleanup: func() error {
|
|
if renewer != nil {
|
|
renewer.Stop()
|
|
}
|
|
return cleanup()
|
|
},
|
|
binaryPath: l.BinaryPath(),
|
|
}, nil
|
|
}
|
|
|
|
// startOwnershipRenewal builds a dedicated control client authenticated with
|
|
// the given credential and starts the periodic ownership-claim loop against
|
|
// it (AC-EXECUTORS-CONTROL-OWNERSHIP-003.2/.8), computing the renewal
|
|
// interval from the given already-resolved unowned period. The caller
|
|
// decides where that period comes from: this launch's own local config is
|
|
// only correct for a server this launch just spawned itself -- an adopted
|
|
// server may have been spawned by a prior launch with different config, so
|
|
// its renewal period must come from what that server itself reports (see
|
|
// resolveAdoptedRenewalPeriod). Returns nil (logged, not fatal) when the
|
|
// endpoint can't be parsed; startup already succeeded by this point, so
|
|
// refusing to run the surviving server over a renewal-loop failure would
|
|
// trade a smaller problem for a bigger one.
|
|
func startOwnershipRenewal(
|
|
ctx context.Context,
|
|
log *logger.Logger,
|
|
endpoint string,
|
|
credential string,
|
|
period time.Duration,
|
|
) *agentruntime.OwnershipRenewer {
|
|
host, port, err := splitEndpoint(endpoint)
|
|
if err != nil {
|
|
log.Warn("failed to start ownership renewal loop; endpoint malformed", zap.Error(err))
|
|
return nil
|
|
}
|
|
client := agentctlclient.NewControlClient(host, port, log)
|
|
client.SetAuthToken(credential)
|
|
renewer := agentruntime.NewOwnershipRenewer(client, ownershipperiod.RenewalInterval(period), log)
|
|
renewer.Start(ctx)
|
|
return renewer
|
|
}
|
|
|
|
// resolveAdoptedRenewalPeriod picks the unowned period an adopted server's
|
|
// ownership-renewal loop renews against: the value that server itself
|
|
// reported on /identity during adoption (outcome.UnownedPeriod), never this
|
|
// launch's local config -- the two can disagree across a restart that
|
|
// changed agentctl.unownedPeriod, and renewing on this launch's own
|
|
// (possibly longer) cadence lets the adopted server's own reaper fire while
|
|
// this backend still believes it owns it. Falls back to the shared floor
|
|
// only for a legacy pre-upgrade server that answered /identity without an
|
|
// unowned_period_ms field at all (reported as zero).
|
|
func resolveAdoptedRenewalPeriod(reported time.Duration) time.Duration {
|
|
if reported <= 0 {
|
|
return ownershipperiod.MinPeriod
|
|
}
|
|
return reported
|
|
}
|
|
|
|
// controlClientFactory builds the real agentruntime.AdoptionControlClientFactory
|
|
// used in production: it parses "host:port" and returns a live
|
|
// agentctlclient.ControlClient targeting it.
|
|
func controlClientFactory(log *logger.Logger) agentruntime.AdoptionControlClientFactory {
|
|
return func(endpoint string) (agentruntime.AdoptionControlClient, error) {
|
|
host, port, err := splitEndpoint(endpoint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return agentctlclient.NewControlClient(host, port, log), nil
|
|
}
|
|
}
|
|
|
|
func splitEndpoint(endpoint string) (host string, port int, err error) {
|
|
host, portStr, err := net.SplitHostPort(endpoint)
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("split control server endpoint %q: %w", endpoint, err)
|
|
}
|
|
port, err = strconv.Atoi(portStr)
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("parse control server port from endpoint %q: %w", endpoint, err)
|
|
}
|
|
return host, port, nil
|
|
}
|
|
|
|
// waitForAgentctlControlHealthy waits for the agentctl control server to be healthy.
|
|
// This is called during startup to ensure agentctl is ready before accepting requests.
|
|
func waitForAgentctlControlHealthy(ctx context.Context, cfg *config.Config, log *logger.Logger) {
|
|
client := agentctlclient.NewControlClient(cfg.Agent.StandaloneHost, cfg.Agent.StandalonePort, log)
|
|
healthCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
ticker := time.NewTicker(250 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
var lastErr error
|
|
for {
|
|
attemptCtx, attemptCancel := context.WithTimeout(healthCtx, 1*time.Second)
|
|
err := client.Health(attemptCtx)
|
|
attemptCancel()
|
|
if err == nil {
|
|
log.Info("agentctl control server is healthy")
|
|
return
|
|
}
|
|
lastErr = err
|
|
if healthCtx.Err() != nil {
|
|
log.Warn("agentctl control server not ready; skipping resume wait", zap.Error(lastErr))
|
|
return
|
|
}
|
|
<-ticker.C
|
|
}
|
|
}
|