feat: add auto update

This commit is contained in:
2026-05-19 15:53:30 +02:00
parent bd3121d688
commit ba4de62a34
22 changed files with 3323 additions and 110 deletions

View File

@ -49,6 +49,16 @@ func (s *Store) migrate() error {
last_seen_at DATETIME,
online INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS auto_update_policies (
agent_id TEXT NOT NULL,
container_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
interval_minutes INTEGER NOT NULL DEFAULT 1440,
last_checked_at DATETIME,
last_updated_at DATETIME,
PRIMARY KEY (agent_id, container_id),
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
);
`)
if err != nil {
return err
@ -186,3 +196,106 @@ func boolToInt(b bool) int {
}
return 0
}
// ── AutoUpdatePolicies ────────────────────────────────────────────────────────
type AutoUpdatePolicy struct {
AgentID string
ContainerID string
Enabled bool
IntervalMinutes int
LastCheckedAt *time.Time
LastUpdatedAt *time.Time
}
func (s *Store) UpsertAutoUpdatePolicy(p *AutoUpdatePolicy) error {
_, err := s.db.Exec(`
INSERT OR REPLACE INTO auto_update_policies
(agent_id, container_id, enabled, interval_minutes, last_checked_at, last_updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`, p.AgentID, p.ContainerID, boolToInt(p.Enabled), p.IntervalMinutes, p.LastCheckedAt, p.LastUpdatedAt)
return err
}
func (s *Store) GetAutoUpdatePolicy(agentID, containerID string) (*AutoUpdatePolicy, error) {
row := s.db.QueryRow(`
SELECT agent_id, container_id, enabled, interval_minutes, last_checked_at, last_updated_at
FROM auto_update_policies WHERE agent_id = ? AND container_id = ?
`, agentID, containerID)
p := &AutoUpdatePolicy{}
var enabled int
var lastChecked, lastUpdated sql.NullTime
err := row.Scan(&p.AgentID, &p.ContainerID, &enabled, &p.IntervalMinutes, &lastChecked, &lastUpdated)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
p.Enabled = enabled == 1
if lastChecked.Valid {
t := lastChecked.Time
p.LastCheckedAt = &t
}
if lastUpdated.Valid {
t := lastUpdated.Time
p.LastUpdatedAt = &t
}
return p, nil
}
func (s *Store) ListDueAutoUpdatePolicies(now time.Time) ([]*AutoUpdatePolicy, error) {
rows, err := s.db.Query(`
SELECT agent_id, container_id, enabled, interval_minutes, last_checked_at, last_updated_at
FROM auto_update_policies
WHERE enabled = 1
AND (last_checked_at IS NULL
OR (julianday(?) - julianday(last_checked_at)) * 1440 >= interval_minutes)
`, now)
if err != nil {
return nil, err
}
defer rows.Close()
var policies []*AutoUpdatePolicy
for rows.Next() {
p := &AutoUpdatePolicy{}
var enabled int
var lastChecked, lastUpdated sql.NullTime
if err := rows.Scan(&p.AgentID, &p.ContainerID, &enabled, &p.IntervalMinutes, &lastChecked, &lastUpdated); err != nil {
return nil, err
}
p.Enabled = enabled == 1
if lastChecked.Valid {
t := lastChecked.Time
p.LastCheckedAt = &t
}
if lastUpdated.Valid {
t := lastUpdated.Time
p.LastUpdatedAt = &t
}
policies = append(policies, p)
}
return policies, rows.Err()
}
func (s *Store) UpdateAutoUpdateChecked(agentID, containerID string, at time.Time) error {
_, err := s.db.Exec(`
UPDATE auto_update_policies SET last_checked_at = ? WHERE agent_id = ? AND container_id = ?
`, at, agentID, containerID)
return err
}
func (s *Store) UpdateAutoUpdateDone(agentID, containerID string, at time.Time) error {
_, err := s.db.Exec(`
UPDATE auto_update_policies SET last_updated_at = ? WHERE agent_id = ? AND container_id = ?
`, at, agentID, containerID)
return err
}
func (s *Store) DeleteAutoUpdatePolicy(agentID, containerID string) error {
_, err := s.db.Exec(`
DELETE FROM auto_update_policies WHERE agent_id = ? AND container_id = ?
`, agentID, containerID)
return err
}

View File

@ -2,6 +2,7 @@ package store
import (
"testing"
"time"
)
func newTestStore(t *testing.T) *Store {
@ -254,3 +255,199 @@ func TestCreateAgentToken_IdempotentIgnore(t *testing.T) {
t.Fatalf("second call (should be idempotent): %v", err)
}
}
// ── AutoUpdatePolicies ────────────────────────────────────────────────────────
// helper: create an agent prerequisite for FK constraints.
func createAgent(t *testing.T, s *Store, id, token, hostname string) {
t.Helper()
if err := s.CreateAgentToken(id, token, hostname); err != nil {
t.Fatalf("createAgent: %v", err)
}
}
func TestUpsertAndGetAutoUpdatePolicy(t *testing.T) {
s := newTestStore(t)
createAgent(t, s, "ag1", "tok1", "host1")
p := &AutoUpdatePolicy{
AgentID: "ag1",
ContainerID: "ctr1",
Enabled: true,
IntervalMinutes: 60,
}
if err := s.UpsertAutoUpdatePolicy(p); err != nil {
t.Fatalf("UpsertAutoUpdatePolicy: %v", err)
}
got, err := s.GetAutoUpdatePolicy("ag1", "ctr1")
if err != nil {
t.Fatalf("GetAutoUpdatePolicy: %v", err)
}
if got == nil {
t.Fatal("expected policy, got nil")
}
if !got.Enabled || got.IntervalMinutes != 60 {
t.Errorf("unexpected policy: %+v", got)
}
if got.LastCheckedAt != nil || got.LastUpdatedAt != nil {
t.Error("expected nil timestamps on fresh policy")
}
}
func TestGetAutoUpdatePolicy_NotFound(t *testing.T) {
s := newTestStore(t)
p, err := s.GetAutoUpdatePolicy("nobody", "ctr")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p != nil {
t.Errorf("expected nil, got %+v", p)
}
}
func TestUpsertAutoUpdatePolicy_Update(t *testing.T) {
s := newTestStore(t)
createAgent(t, s, "ag1", "tok1", "host1")
_ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 60})
_ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: false, IntervalMinutes: 1440})
got, err := s.GetAutoUpdatePolicy("ag1", "ctr1")
if err != nil {
t.Fatalf("GetAutoUpdatePolicy: %v", err)
}
if got.Enabled || got.IntervalMinutes != 1440 {
t.Errorf("expected updated policy, got %+v", got)
}
}
func TestUpdateAutoUpdateChecked(t *testing.T) {
s := newTestStore(t)
createAgent(t, s, "ag1", "tok1", "host1")
_ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 60})
now := time.Now().Truncate(time.Second)
if err := s.UpdateAutoUpdateChecked("ag1", "ctr1", now); err != nil {
t.Fatalf("UpdateAutoUpdateChecked: %v", err)
}
got, _ := s.GetAutoUpdatePolicy("ag1", "ctr1")
if got.LastCheckedAt == nil {
t.Fatal("expected LastCheckedAt to be set")
}
if got.LastCheckedAt.UTC().Truncate(time.Second) != now.UTC() {
t.Errorf("expected %v, got %v", now.UTC(), got.LastCheckedAt.UTC().Truncate(time.Second))
}
}
func TestUpdateAutoUpdateDone(t *testing.T) {
s := newTestStore(t)
createAgent(t, s, "ag1", "tok1", "host1")
_ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 60})
now := time.Now().Truncate(time.Second)
if err := s.UpdateAutoUpdateDone("ag1", "ctr1", now); err != nil {
t.Fatalf("UpdateAutoUpdateDone: %v", err)
}
got, _ := s.GetAutoUpdatePolicy("ag1", "ctr1")
if got.LastUpdatedAt == nil {
t.Fatal("expected LastUpdatedAt to be set")
}
}
func TestListDueAutoUpdatePolicies_NullLastChecked(t *testing.T) {
s := newTestStore(t)
createAgent(t, s, "ag1", "tok1", "host1")
_ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 60})
// last_checked_at IS NULL → should be due immediately.
due, err := s.ListDueAutoUpdatePolicies(time.Now())
if err != nil {
t.Fatalf("ListDueAutoUpdatePolicies: %v", err)
}
if len(due) != 1 {
t.Fatalf("expected 1 due policy, got %d", len(due))
}
if due[0].ContainerID != "ctr1" {
t.Errorf("unexpected container: %q", due[0].ContainerID)
}
}
func TestListDueAutoUpdatePolicies_NotDueYet(t *testing.T) {
s := newTestStore(t)
createAgent(t, s, "ag1", "tok1", "host1")
_ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 1440})
// Mark as just checked — not due yet.
_ = s.UpdateAutoUpdateChecked("ag1", "ctr1", time.Now())
due, err := s.ListDueAutoUpdatePolicies(time.Now())
if err != nil {
t.Fatalf("ListDueAutoUpdatePolicies: %v", err)
}
if len(due) != 0 {
t.Fatalf("expected 0 due policies (just checked), got %d", len(due))
}
}
func TestListDueAutoUpdatePolicies_Due(t *testing.T) {
s := newTestStore(t)
createAgent(t, s, "ag1", "tok1", "host1")
_ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 60})
// Simulate last check 2 hours ago → should be due.
past := time.Now().Add(-2 * time.Hour)
_ = s.UpdateAutoUpdateChecked("ag1", "ctr1", past)
due, err := s.ListDueAutoUpdatePolicies(time.Now())
if err != nil {
t.Fatalf("ListDueAutoUpdatePolicies: %v", err)
}
if len(due) != 1 {
t.Fatalf("expected 1 due policy (overdue), got %d", len(due))
}
}
func TestListDueAutoUpdatePolicies_DisabledExcluded(t *testing.T) {
s := newTestStore(t)
createAgent(t, s, "ag1", "tok1", "host1")
_ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: false, IntervalMinutes: 60})
due, err := s.ListDueAutoUpdatePolicies(time.Now())
if err != nil {
t.Fatalf("ListDueAutoUpdatePolicies: %v", err)
}
if len(due) != 0 {
t.Fatalf("expected 0 due policies (disabled), got %d", len(due))
}
}
func TestDeleteAutoUpdatePolicy(t *testing.T) {
s := newTestStore(t)
createAgent(t, s, "ag1", "tok1", "host1")
_ = s.UpsertAutoUpdatePolicy(&AutoUpdatePolicy{AgentID: "ag1", ContainerID: "ctr1", Enabled: true, IntervalMinutes: 60})
if err := s.DeleteAutoUpdatePolicy("ag1", "ctr1"); err != nil {
t.Fatalf("DeleteAutoUpdatePolicy: %v", err)
}
got, err := s.GetAutoUpdatePolicy("ag1", "ctr1")
if err != nil {
t.Fatalf("GetAutoUpdatePolicy: %v", err)
}
if got != nil {
t.Error("expected nil after deletion")
}
}
func TestDeleteAutoUpdatePolicy_Idempotent(t *testing.T) {
s := newTestStore(t)
// Deleting a non-existent policy should not error.
if err := s.DeleteAutoUpdatePolicy("nobody", "ctr"); err != nil {
t.Fatalf("DeleteAutoUpdatePolicy on missing: %v", err)
}
}