Compare commits
4 Commits
d8fd4110b0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c6b962e24 | |||
| e64075d5d7 | |||
| 0f5110f3d9 | |||
| 0fbacf9f98 |
@@ -6,6 +6,44 @@ and the project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-06-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Always-On vs intermittent host mode.** A host can now be marked as
|
||||||
|
not always-on — for laptops/workstations that legitimately sleep,
|
||||||
|
travel, or shut down outside hours. An intermittent host no longer
|
||||||
|
raises "agent offline" alerts when it disappears; instead it shows a
|
||||||
|
calm "asleep" state in the UI ("asleep · last seen … · will catch up
|
||||||
|
on return") and is covered by a longer-horizon staleness alert (raised
|
||||||
|
only when it has an enabled schedule and no successful backup in 7
|
||||||
|
days). When such a host reconnects, the server waits a short settle
|
||||||
|
window and then automatically dispatches any scheduled backup whose
|
||||||
|
window elapsed while it was asleep. Toggle per host from the host
|
||||||
|
detail page (operator-band, audited as `host.mode_updated`). New and
|
||||||
|
existing hosts default to always-on, so current fleets are unaffected.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Host-detail header redesign: tags and presence are grouped into
|
||||||
|
labelled, boxed pills with click-to-edit; presence shows a `24x7` /
|
||||||
|
`Free` chip; the agent "out of date" indicator is simplified (the full
|
||||||
|
version detail remains in the Agent-update panel and on hover).
|
||||||
|
- Relative timestamps ("2h ago") now tick client-side, so a tab left
|
||||||
|
open no longer shows a stale value as wall-clock time moves on.
|
||||||
|
- Release and CI container images are now published to and pulled from
|
||||||
|
the zot OCI registry (`docker.dcglab.co.uk`).
|
||||||
|
|
||||||
|
## [1.0.1] - 2026-05-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Build version is now single-sourced from `internal/version`, and the
|
||||||
|
server Dockerfile's ldflags were corrected so docker-built binaries
|
||||||
|
report their real version. Previously `internal/version.Version` stayed
|
||||||
|
at its "dev" default in docker images, which made every host look
|
||||||
|
permanently out-of-date to the update logic.
|
||||||
|
|
||||||
## [1.0.0] - 2026-05-09
|
## [1.0.0] - 2026-05-09
|
||||||
|
|
||||||
First tagged release. Six development phases brought the project from
|
First tagged release. Six development phases brought the project from
|
||||||
|
|||||||
@@ -512,11 +512,27 @@ func TestDrainPendingSerializesPerHost(t *testing.T) {
|
|||||||
// Connect the agent so DrainPending can dispatch.
|
// Connect the agent so DrainPending can dispatch.
|
||||||
c := agentDial(t, srv, ts, hostID, token)
|
c := agentDial(t, srv, ts, hostID, token)
|
||||||
sendHello(t, c, "serialise-host")
|
sendHello(t, c, "serialise-host")
|
||||||
// Drain the on-hello goroutine's pass first (no pending rows yet),
|
// Wait for the on-hello push to settle.
|
||||||
// then wait for the schedule.set so the connection is fully settled.
|
|
||||||
_ = drainUntil(t, c, api.MsgScheduleSet)
|
_ = drainUntil(t, c, api.MsgScheduleSet)
|
||||||
|
|
||||||
// Insert 5 pending rows now that the on-hello drain has already run.
|
// A real agent is always in a read loop. Keep this test client
|
||||||
|
// reading in the background for the rest of the test: without an
|
||||||
|
// active reader the server-side conn can be dropped under parallel
|
||||||
|
// load, which unregisters it from the hub and makes DrainPending
|
||||||
|
// no-op (conn == nil) — the historical source of this test's
|
||||||
|
// flakiness (it would observe 0 or a partial drain). The reader also
|
||||||
|
// consumes the command.run envelopes our drains emit.
|
||||||
|
readerCtx, stopReader := context.WithCancel(context.Background())
|
||||||
|
defer stopReader()
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
if _, _, err := c.Read(readerCtx); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Insert 5 due pending rows.
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
for i := range 5 {
|
for i := range 5 {
|
||||||
pid := ulid.Make().String()
|
pid := ulid.Make().String()
|
||||||
@@ -533,7 +549,8 @@ func TestDrainPendingSerializesPerHost(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn 10 goroutines all calling DrainPending concurrently.
|
// Fire 10 concurrent DrainPending calls. The per-host mutex must
|
||||||
|
// ensure each row is dispatched at most once (no double-dispatch).
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for range 10 {
|
for range 10 {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
@@ -544,24 +561,26 @@ func TestDrainPendingSerializesPerHost(t *testing.T) {
|
|||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
// Drain any envelopes the agent received so we don't block below.
|
// Drain to completion. The fire-and-forget on-hello DrainPending
|
||||||
// We read with short timeouts and stop when the connection goes quiet.
|
// shares the same per-host mutex and can hold it during the burst,
|
||||||
drainDeadline := time.Now().Add(500 * time.Millisecond)
|
// leaving rows for a later pass — exactly how production drains
|
||||||
for time.Now().Before(drainDeadline) {
|
// (repeatedly, via the 30s tick / on reconnect). Re-drain until the
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
// queue is empty; because every drain is still serialised, each row
|
||||||
_, _, err := c.Read(ctx)
|
// is dispatched at most once, so the exactly-5 job count below proves
|
||||||
cancel()
|
// there was no double-dispatch.
|
||||||
if err != nil {
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
break
|
for countPendingForHost(t, st, hostID) > 0 && time.Now().Before(deadline) {
|
||||||
}
|
srv.DrainPending(context.Background(), hostID)
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
// All 5 pending rows must be gone.
|
// All 5 pending rows must be drained.
|
||||||
if n := countPendingForHost(t, st, hostID); n != 0 {
|
if n := countPendingForHost(t, st, hostID); n != 0 {
|
||||||
t.Errorf("pending rows after concurrent drain: got %d, want 0", n)
|
t.Errorf("pending rows after drain-to-completion: got %d, want 0", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exactly 5 backup job rows (one per pending row), not 10+ from a race.
|
// Exactly 5 backup job rows (one per pending row) — never more, which
|
||||||
|
// would mean the per-host mutex failed to prevent double-dispatch.
|
||||||
var n int
|
var n int
|
||||||
_ = st.DB().QueryRow(
|
_ = st.DB().QueryRow(
|
||||||
`SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = 'backup' AND actor_kind = 'schedule'`,
|
`SELECT COUNT(*) FROM jobs WHERE host_id = ? AND kind = 'backup' AND actor_kind = 'schedule'`,
|
||||||
|
|||||||
Reference in New Issue
Block a user