Enterprise-Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme. Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes geht in keine Bewertung als "gut" ein. Umfang (Phasen 0-23): - Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll, Katalogaufbau allein aus den Manifesten — ohne Datenbank - Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck - Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell) - Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive - Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit - Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center, Ransomware-Heuristik (meldet, handelt nie) - Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing - Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository - Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64 Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs. Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md und docs/release-candidate.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
557 lines
19 KiB
Go
557 lines
19 KiB
Go
package agentregistry
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/syncova/syncova/packages/audit"
|
|
"github.com/syncova/syncova/packages/auth"
|
|
)
|
|
|
|
// memoryStore ist eine Ablage im Arbeitsspeicher für Tests.
|
|
//
|
|
// Sie erlaubt es, die Sicherheitslogik ohne Datenbank zu prüfen — die Regeln
|
|
// selbst sind wichtiger als ihre Ablage.
|
|
type memoryStore struct {
|
|
// mutex schützt die Karten gegen gleichzeitige Zugriffe.
|
|
mutex sync.Mutex
|
|
// enrollmentTokens hält die Aufnahme-Tokens nach ihrem Hash.
|
|
enrollmentTokens map[string]*memoryEnrollmentToken
|
|
// agents hält die Agents nach ihrer Kennung.
|
|
agents map[uuid.UUID]*Agent
|
|
// agentTokens ordnet Tokenhashes den Agents zu.
|
|
agentTokens map[string]uuid.UUID
|
|
// revokedTokens hält widerrufene Tokenhashes fest.
|
|
revokedTokens map[string]bool
|
|
}
|
|
|
|
// memoryEnrollmentToken ist ein Aufnahme-Token im Arbeitsspeicher.
|
|
type memoryEnrollmentToken struct {
|
|
// agentName ist der vorgesehene Name des Agents.
|
|
agentName string
|
|
// expiresAt ist die Ablaufzeit.
|
|
expiresAt time.Time
|
|
// usedAt ist der Einlösezeitpunkt; nil bedeutet noch offen.
|
|
usedAt *time.Time
|
|
}
|
|
|
|
// newMemoryStore erzeugt eine leere Ablage.
|
|
func newMemoryStore() *memoryStore {
|
|
return &memoryStore{
|
|
enrollmentTokens: make(map[string]*memoryEnrollmentToken),
|
|
agents: make(map[uuid.UUID]*Agent),
|
|
agentTokens: make(map[string]uuid.UUID),
|
|
revokedTokens: make(map[string]bool),
|
|
}
|
|
}
|
|
|
|
func (store *memoryStore) CreateEnrollmentToken(createContext context.Context, tokenHash string, agentName string, expiresAt time.Time, createdBy *uuid.UUID) (uuid.UUID, error) {
|
|
store.mutex.Lock()
|
|
defer store.mutex.Unlock()
|
|
|
|
store.enrollmentTokens[tokenHash] = &memoryEnrollmentToken{agentName: agentName, expiresAt: expiresAt}
|
|
|
|
return uuid.New(), nil
|
|
}
|
|
|
|
func (store *memoryStore) PeekEnrollmentToken(peekContext context.Context, tokenHash string) (string, error) {
|
|
store.mutex.Lock()
|
|
defer store.mutex.Unlock()
|
|
|
|
enrollmentToken, tokenExists := store.enrollmentTokens[tokenHash]
|
|
if !tokenExists || enrollmentToken.usedAt != nil || time.Now().After(enrollmentToken.expiresAt) {
|
|
return "", ErrEnrollmentTokenInvalid
|
|
}
|
|
|
|
return enrollmentToken.agentName, nil
|
|
}
|
|
|
|
func (store *memoryStore) ConsumeEnrollmentToken(consumeContext context.Context, tokenHash string, agentID uuid.UUID) (string, error) {
|
|
store.mutex.Lock()
|
|
defer store.mutex.Unlock()
|
|
|
|
enrollmentToken, tokenExists := store.enrollmentTokens[tokenHash]
|
|
if !tokenExists || enrollmentToken.usedAt != nil || time.Now().After(enrollmentToken.expiresAt) {
|
|
return "", ErrEnrollmentTokenInvalid
|
|
}
|
|
|
|
consumedAt := time.Now()
|
|
enrollmentToken.usedAt = &consumedAt
|
|
|
|
return enrollmentToken.agentName, nil
|
|
}
|
|
|
|
func (store *memoryStore) CreateAgent(createContext context.Context, agentToCreate Agent) (uuid.UUID, error) {
|
|
store.mutex.Lock()
|
|
defer store.mutex.Unlock()
|
|
|
|
agentToCreate.ID = uuid.New()
|
|
store.agents[agentToCreate.ID] = &agentToCreate
|
|
|
|
return agentToCreate.ID, nil
|
|
}
|
|
|
|
func (store *memoryStore) CreateAgentToken(createContext context.Context, agentID uuid.UUID, tokenHash string) (uuid.UUID, error) {
|
|
store.mutex.Lock()
|
|
defer store.mutex.Unlock()
|
|
|
|
store.agentTokens[tokenHash] = agentID
|
|
|
|
return uuid.New(), nil
|
|
}
|
|
|
|
func (store *memoryStore) FindAgentByTokenHash(queryContext context.Context, tokenHash string) (Agent, error) {
|
|
store.mutex.Lock()
|
|
defer store.mutex.Unlock()
|
|
|
|
if store.revokedTokens[tokenHash] {
|
|
return Agent{}, ErrAgentTokenInvalid
|
|
}
|
|
|
|
agentID, tokenExists := store.agentTokens[tokenHash]
|
|
if !tokenExists {
|
|
return Agent{}, ErrAgentTokenInvalid
|
|
}
|
|
|
|
foundAgent, agentExists := store.agents[agentID]
|
|
if !agentExists {
|
|
return Agent{}, ErrAgentTokenInvalid
|
|
}
|
|
|
|
return *foundAgent, nil
|
|
}
|
|
|
|
func (store *memoryStore) FindAgentByID(queryContext context.Context, agentID uuid.UUID) (Agent, error) {
|
|
store.mutex.Lock()
|
|
defer store.mutex.Unlock()
|
|
|
|
foundAgent, agentExists := store.agents[agentID]
|
|
if !agentExists {
|
|
return Agent{}, ErrAgentNotFound
|
|
}
|
|
|
|
return *foundAgent, nil
|
|
}
|
|
|
|
func (store *memoryStore) ListAgents(queryContext context.Context, agentFilter AgentFilter) ([]Agent, int64, error) {
|
|
store.mutex.Lock()
|
|
defer store.mutex.Unlock()
|
|
|
|
listedAgents := make([]Agent, 0, len(store.agents))
|
|
for _, storedAgent := range store.agents {
|
|
listedAgents = append(listedAgents, *storedAgent)
|
|
}
|
|
|
|
return listedAgents, int64(len(listedAgents)), nil
|
|
}
|
|
|
|
func (store *memoryStore) RecordHeartbeat(updateContext context.Context, agentID uuid.UUID, agentVersion string, ipAddress string) error {
|
|
store.mutex.Lock()
|
|
defer store.mutex.Unlock()
|
|
|
|
storedAgent, agentExists := store.agents[agentID]
|
|
if !agentExists {
|
|
return ErrAgentNotFound
|
|
}
|
|
|
|
heartbeatTime := time.Now().UTC()
|
|
storedAgent.LastHeartbeatAt = &heartbeatTime
|
|
|
|
if agentVersion != "" {
|
|
storedAgent.Version = agentVersion
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (store *memoryStore) RevokeAgent(updateContext context.Context, agentID uuid.UUID) error {
|
|
store.mutex.Lock()
|
|
defer store.mutex.Unlock()
|
|
|
|
storedAgent, agentExists := store.agents[agentID]
|
|
if !agentExists {
|
|
return ErrAgentNotFound
|
|
}
|
|
|
|
storedAgent.Status = AgentStatusRevoked
|
|
|
|
// Sämtliche Tokens des Agents werden mit widerrufen.
|
|
for tokenHash, tokenAgentID := range store.agentTokens {
|
|
if tokenAgentID == agentID {
|
|
store.revokedTokens[tokenHash] = true
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (store *memoryStore) RotateAgentToken(updateContext context.Context, agentID uuid.UUID, newTokenHash string) error {
|
|
store.mutex.Lock()
|
|
defer store.mutex.Unlock()
|
|
|
|
for tokenHash, tokenAgentID := range store.agentTokens {
|
|
if tokenAgentID == agentID {
|
|
store.revokedTokens[tokenHash] = true
|
|
}
|
|
}
|
|
|
|
store.agentTokens[newTokenHash] = agentID
|
|
|
|
return nil
|
|
}
|
|
|
|
// noopAuditRecorder verwirft Auditereignisse.
|
|
type noopAuditRecorder struct{}
|
|
|
|
func (recorder *noopAuditRecorder) Record(recordContext context.Context, auditEvent audit.Event) error {
|
|
return nil
|
|
}
|
|
|
|
func (recorder *noopAuditRecorder) Query(queryContext context.Context, queryFilter audit.Filter) ([]audit.StoredEvent, int64, error) {
|
|
return nil, 0, nil
|
|
}
|
|
|
|
// newTestService baut einen Agent-Dienst mit Ablage im Arbeitsspeicher.
|
|
func newTestService() (*Service, *memoryStore) {
|
|
testStore := newMemoryStore()
|
|
testLogger := slog.New(slog.NewJSONHandler(io.Discard, nil))
|
|
|
|
return NewService(testStore, &noopAuditRecorder{}, testLogger), testStore
|
|
}
|
|
|
|
// testUser ist der handelnde Benutzer in den Tests.
|
|
var testUser = auth.User{ID: uuid.New(), Username: "administrator"}
|
|
|
|
// enrollTestAgent nimmt einen Agent auf und liefert das Ergebnis.
|
|
func enrollTestAgent(testInstance *testing.T, agentService *Service, agentName string) RegistrationResult {
|
|
testInstance.Helper()
|
|
|
|
enrollmentToken, issueError := agentService.IssueEnrollmentToken(context.Background(),
|
|
agentName, time.Hour, testUser, auth.RequestContext{})
|
|
if issueError != nil {
|
|
testInstance.Fatalf("das Aufnahme-Token konnte nicht ausgestellt werden: %v", issueError)
|
|
}
|
|
|
|
registrationResult, registerError := agentService.Register(context.Background(), RegistrationRequest{
|
|
EnrollmentToken: enrollmentToken.Token,
|
|
Hostname: "testhost",
|
|
Platform: PlatformLinux,
|
|
Architecture: "amd64",
|
|
Version: "0.1.0-test",
|
|
IPAddress: "192.0.2.10",
|
|
})
|
|
|
|
if registerError != nil {
|
|
testInstance.Fatalf("die Registrierung schlug fehl: %v", registerError)
|
|
}
|
|
|
|
return registrationResult
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Aufnahme
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestEnrollmentTokenIsReturnedOnlyOnce(testInstance *testing.T) {
|
|
agentService, testStore := newTestService()
|
|
|
|
enrollmentToken, _ := agentService.IssueEnrollmentToken(context.Background(),
|
|
"Server01", time.Hour, testUser, auth.RequestContext{})
|
|
|
|
if enrollmentToken.Token == "" {
|
|
testInstance.Fatal("es wurde kein Token ausgegeben")
|
|
}
|
|
|
|
// Gespeichert wird nur der Hash: ein Datenbankleck erlaubt damit keine
|
|
// Aufnahme fremder Agents.
|
|
testStore.mutex.Lock()
|
|
defer testStore.mutex.Unlock()
|
|
|
|
for storedHash := range testStore.enrollmentTokens {
|
|
if storedHash == enrollmentToken.Token {
|
|
testInstance.Fatal("das Token liegt im Klartext in der Ablage")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRegistrationConsumesEnrollmentToken(testInstance *testing.T) {
|
|
agentService, _ := newTestService()
|
|
|
|
enrollmentToken, _ := agentService.IssueEnrollmentToken(context.Background(),
|
|
"Server01", time.Hour, testUser, auth.RequestContext{})
|
|
|
|
registrationRequest := RegistrationRequest{
|
|
EnrollmentToken: enrollmentToken.Token,
|
|
Hostname: "server01",
|
|
Platform: PlatformWindows,
|
|
Version: "0.1.0",
|
|
}
|
|
|
|
if _, firstError := agentService.Register(context.Background(), registrationRequest); firstError != nil {
|
|
testInstance.Fatalf("die erste Registrierung schlug fehl: %v", firstError)
|
|
}
|
|
|
|
// Ein Aufnahme-Token gilt genau einmal: sonst könnte ein Angreifer damit
|
|
// beliebig viele Agents anmelden.
|
|
_, secondError := agentService.Register(context.Background(), registrationRequest)
|
|
if !errors.Is(secondError, ErrEnrollmentTokenInvalid) {
|
|
testInstance.Fatalf("ein zweites Einlösen muss scheitern, war: %v", secondError)
|
|
}
|
|
}
|
|
|
|
func TestRegistrationRejectsExpiredToken(testInstance *testing.T) {
|
|
agentService, _ := newTestService()
|
|
|
|
// Ein Token, das bereits abgelaufen ist.
|
|
enrollmentToken, _ := agentService.IssueEnrollmentToken(context.Background(),
|
|
"Server01", time.Millisecond, testUser, auth.RequestContext{})
|
|
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
_, registerError := agentService.Register(context.Background(), RegistrationRequest{
|
|
EnrollmentToken: enrollmentToken.Token,
|
|
Platform: PlatformLinux,
|
|
})
|
|
|
|
if !errors.Is(registerError, ErrEnrollmentTokenInvalid) {
|
|
testInstance.Fatalf("ein abgelaufenes Token muss abgelehnt werden, war: %v", registerError)
|
|
}
|
|
}
|
|
|
|
func TestRegistrationRejectsUnknownToken(testInstance *testing.T) {
|
|
agentService, _ := newTestService()
|
|
|
|
_, registerError := agentService.Register(context.Background(), RegistrationRequest{
|
|
EnrollmentToken: "voellig-erfundenes-token", // secretscan:erlaubt: erfundener Testwert
|
|
Platform: PlatformLinux,
|
|
})
|
|
|
|
if !errors.Is(registerError, ErrEnrollmentTokenInvalid) {
|
|
testInstance.Fatalf("ein unbekanntes Token muss abgelehnt werden, war: %v", registerError)
|
|
}
|
|
}
|
|
|
|
func TestEnrollmentErrorDoesNotRevealReason(testInstance *testing.T) {
|
|
// Unbekannt, abgelaufen und bereits eingelöst liefern denselben Fehler:
|
|
// eine Unterscheidung erlaubte es, gültige Tokens zu erraten.
|
|
errorMessage := ErrEnrollmentTokenInvalid.Error()
|
|
|
|
for _, revealingWord := range []string{"existiert nicht", "unbekannt"} {
|
|
if errors.Is(ErrEnrollmentTokenInvalid, errors.New(revealingWord)) {
|
|
testInstance.Errorf("die Meldung verrät die Ursache: %q", errorMessage)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAgentNameComesFromTokenNotFromAgent(testInstance *testing.T) {
|
|
agentService, _ := newTestService()
|
|
|
|
enrollmentToken, _ := agentService.IssueEnrollmentToken(context.Background(),
|
|
"Vorgegebener-Name", time.Hour, testUser, auth.RequestContext{})
|
|
|
|
registrationResult, _ := agentService.Register(context.Background(), RegistrationRequest{
|
|
EnrollmentToken: enrollmentToken.Token,
|
|
Hostname: "beliebiger-hostname",
|
|
Platform: PlatformLinux,
|
|
})
|
|
|
|
// Der Agent bestimmt seinen Namen nicht selbst, sondern die ausstellende
|
|
// Administration. Sonst könnte er sich als ein anderes System ausgeben.
|
|
if registrationResult.Agent.Name != "Vorgegebener-Name" {
|
|
testInstance.Errorf("der Name soll vom Token stammen, war %q", registrationResult.Agent.Name)
|
|
}
|
|
}
|
|
|
|
func TestRegistrationRejectsUnsupportedPlatform(testInstance *testing.T) {
|
|
agentService, _ := newTestService()
|
|
|
|
enrollmentToken, _ := agentService.IssueEnrollmentToken(context.Background(),
|
|
"Server01", time.Hour, testUser, auth.RequestContext{})
|
|
|
|
_, registerError := agentService.Register(context.Background(), RegistrationRequest{
|
|
EnrollmentToken: enrollmentToken.Token,
|
|
Platform: Platform("solaris"),
|
|
})
|
|
|
|
if !errors.Is(registerError, ErrUnsupportedPlatform) {
|
|
testInstance.Fatalf("eine unbekannte Plattform muss abgelehnt werden, war: %v", registerError)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Betriebstoken
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestAgentTokenAuthenticatesAgent(testInstance *testing.T) {
|
|
agentService, _ := newTestService()
|
|
|
|
registrationResult := enrollTestAgent(testInstance, agentService, "Server01")
|
|
|
|
authenticatedAgent, authenticateError := agentService.Authenticate(context.Background(), registrationResult.AgentToken)
|
|
if authenticateError != nil {
|
|
testInstance.Fatalf("die Anmeldung mit dem Betriebstoken schlug fehl: %v", authenticateError)
|
|
}
|
|
|
|
if authenticatedAgent.ID != registrationResult.Agent.ID {
|
|
testInstance.Error("es wurde der falsche Agent geliefert")
|
|
}
|
|
}
|
|
|
|
func TestUnknownAgentTokenIsRejected(testInstance *testing.T) {
|
|
agentService, _ := newTestService()
|
|
|
|
_, authenticateError := agentService.Authenticate(context.Background(), "erfundenes-token")
|
|
if !errors.Is(authenticateError, ErrAgentTokenInvalid) {
|
|
testInstance.Fatalf("ein unbekanntes Token muss abgelehnt werden, war: %v", authenticateError)
|
|
}
|
|
}
|
|
|
|
func TestEnrollmentTokenCannotBeUsedAsAgentToken(testInstance *testing.T) {
|
|
agentService, _ := newTestService()
|
|
|
|
enrollmentToken, _ := agentService.IssueEnrollmentToken(context.Background(),
|
|
"Server01", time.Hour, testUser, auth.RequestContext{})
|
|
|
|
// Ein Aufnahme-Token taugt ausschließlich zur Registrierung, nicht zum
|
|
// Betrieb. Andernfalls wäre die Trennung der beiden Tokenarten wirkungslos.
|
|
_, authenticateError := agentService.Authenticate(context.Background(), enrollmentToken.Token)
|
|
if authenticateError == nil {
|
|
testInstance.Fatal("ein Aufnahme-Token darf nicht zur Anmeldung taugen")
|
|
}
|
|
}
|
|
|
|
func TestRevokedAgentLosesAccessImmediately(testInstance *testing.T) {
|
|
agentService, _ := newTestService()
|
|
|
|
registrationResult := enrollTestAgent(testInstance, agentService, "Server01")
|
|
|
|
// Vor der Sperre funktioniert die Anmeldung.
|
|
if _, authenticateError := agentService.Authenticate(context.Background(), registrationResult.AgentToken); authenticateError != nil {
|
|
testInstance.Fatalf("die Anmeldung schlug vor der Sperre fehl: %v", authenticateError)
|
|
}
|
|
|
|
if revokeError := agentService.RevokeAgent(context.Background(), registrationResult.Agent.ID,
|
|
testUser, auth.RequestContext{}); revokeError != nil {
|
|
testInstance.Fatalf("die Sperre schlug fehl: %v", revokeError)
|
|
}
|
|
|
|
// Ein gesperrter Agent darf sich nicht über sein altes Token zurückmelden.
|
|
if _, authenticateError := agentService.Authenticate(context.Background(), registrationResult.AgentToken); authenticateError == nil {
|
|
testInstance.Fatal("ein gesperrter Agent konnte sich weiterhin anmelden")
|
|
}
|
|
}
|
|
|
|
func TestRotationInvalidatesPreviousToken(testInstance *testing.T) {
|
|
agentService, _ := newTestService()
|
|
|
|
registrationResult := enrollTestAgent(testInstance, agentService, "Server01")
|
|
previousToken := registrationResult.AgentToken
|
|
|
|
newToken, rotateError := agentService.RotateCredentials(context.Background(),
|
|
registrationResult.Agent.ID, testUser, auth.RequestContext{})
|
|
if rotateError != nil {
|
|
testInstance.Fatalf("der Tokenwechsel schlug fehl: %v", rotateError)
|
|
}
|
|
|
|
if newToken == previousToken {
|
|
testInstance.Fatal("das neue Token entspricht dem alten")
|
|
}
|
|
|
|
// Das bisherige Token muss ungültig sein.
|
|
if _, authenticateError := agentService.Authenticate(context.Background(), previousToken); authenticateError == nil {
|
|
testInstance.Error("das alte Token gilt weiterhin")
|
|
}
|
|
|
|
// Das neue muss funktionieren.
|
|
if _, authenticateError := agentService.Authenticate(context.Background(), newToken); authenticateError != nil {
|
|
testInstance.Errorf("das neue Token wurde abgelehnt: %v", authenticateError)
|
|
}
|
|
}
|
|
|
|
func TestRotationRefusedForRevokedAgent(testInstance *testing.T) {
|
|
agentService, _ := newTestService()
|
|
|
|
registrationResult := enrollTestAgent(testInstance, agentService, "Server01")
|
|
_ = agentService.RevokeAgent(context.Background(), registrationResult.Agent.ID, testUser, auth.RequestContext{})
|
|
|
|
// Einem gesperrten Agent ein neues Token auszustellen hübe die Sperre auf.
|
|
if _, rotateError := agentService.RotateCredentials(context.Background(),
|
|
registrationResult.Agent.ID, testUser, auth.RequestContext{}); !errors.Is(rotateError, ErrAgentRevoked) {
|
|
testInstance.Fatalf("für einen gesperrten Agent darf kein Token ausgestellt werden, war: %v", rotateError)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lebendmeldung
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestHeartbeatUpdatesTimestampAndVersion(testInstance *testing.T) {
|
|
agentService, _ := newTestService()
|
|
|
|
registrationResult := enrollTestAgent(testInstance, agentService, "Server01")
|
|
|
|
if heartbeatError := agentService.RecordHeartbeat(context.Background(), registrationResult.Agent,
|
|
HeartbeatRequest{Version: "0.2.0", IPAddress: "192.0.2.10"}); heartbeatError != nil {
|
|
testInstance.Fatalf("die Lebendmeldung schlug fehl: %v", heartbeatError)
|
|
}
|
|
|
|
updatedAgent, _ := agentService.GetAgent(context.Background(), registrationResult.Agent.ID)
|
|
|
|
if updatedAgent.LastHeartbeatAt == nil {
|
|
testInstance.Fatal("der Zeitpunkt der Lebendmeldung wurde nicht vermerkt")
|
|
}
|
|
|
|
// Eine neue Programmversion muss sofort sichtbar sein, damit veraltete
|
|
// Agents erkennbar bleiben.
|
|
if updatedAgent.Version != "0.2.0" {
|
|
testInstance.Errorf("die Version soll übernommen werden, war %q", updatedAgent.Version)
|
|
}
|
|
}
|
|
|
|
func TestIsOfflineDetectsSilentAgent(testInstance *testing.T) {
|
|
referenceTime := time.Now()
|
|
allowedSilence := 10 * time.Minute
|
|
|
|
recentHeartbeat := referenceTime.Add(-time.Minute)
|
|
activeAgent := Agent{LastHeartbeatAt: &recentHeartbeat, RegisteredAt: referenceTime.Add(-time.Hour)}
|
|
|
|
if activeAgent.IsOffline(referenceTime, allowedSilence) {
|
|
testInstance.Error("ein Agent mit frischer Meldung gilt nicht als offline")
|
|
}
|
|
|
|
staleHeartbeat := referenceTime.Add(-time.Hour)
|
|
silentAgent := Agent{LastHeartbeatAt: &staleHeartbeat, RegisteredAt: referenceTime.Add(-2 * time.Hour)}
|
|
|
|
if !silentAgent.IsOffline(referenceTime, allowedSilence) {
|
|
testInstance.Error("ein stummer Agent muss als offline gelten")
|
|
}
|
|
}
|
|
|
|
func TestAgentWithoutHeartbeatIsOfflineAfterGracePeriod(testInstance *testing.T) {
|
|
referenceTime := time.Now()
|
|
allowedSilence := 10 * time.Minute
|
|
|
|
// Ein gerade aufgenommener Agent hatte noch keine Gelegenheit, sich zu melden.
|
|
freshAgent := Agent{RegisteredAt: referenceTime.Add(-time.Minute)}
|
|
if freshAgent.IsOffline(referenceTime, allowedSilence) {
|
|
testInstance.Error("ein gerade aufgenommener Agent gilt noch nicht als offline")
|
|
}
|
|
|
|
// Meldet er sich dauerhaft nicht, ist das ein Problem.
|
|
neverSeenAgent := Agent{RegisteredAt: referenceTime.Add(-time.Hour)}
|
|
if !neverSeenAgent.IsOffline(referenceTime, allowedSilence) {
|
|
testInstance.Error("ein nie erschienener Agent muss als offline gelten")
|
|
}
|
|
|
|
// Ein Agent ohne Meldung ist etwas anderes als einer mit alter Meldung.
|
|
if _, hasHeartbeat := neverSeenAgent.HeartbeatAge(referenceTime); hasHeartbeat {
|
|
testInstance.Error("ohne Lebendmeldung darf kein Alter gemeldet werden")
|
|
}
|
|
}
|