Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions lib/instances/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1545,6 +1545,13 @@ func TestStandbyAndRestore(t *testing.T) {
err = waitForVMReady(ctx, inst.SocketPath, 5*time.Second)
require.NoError(t, err, "VM should reach running state")

// Skew the guest clock back an hour so the post-restore check proves the
// clock keeper corrected it, rather than passing on a small natural lag.
skewOut, skewCode, err := execInInstance(ctx, inst, "date", "-u", "-s",
fmt.Sprintf("@%d", time.Now().Add(-time.Hour).Unix()))
require.NoError(t, err)
require.Equal(t, 0, skewCode, "skewing guest clock should succeed: %s", skewOut)

// Standby instance
t.Log("Standing by instance...")
inst, err = manager.StandbyInstance(ctx, inst.Id, StandbyInstanceRequest{})
Expand Down Expand Up @@ -1585,6 +1592,23 @@ func TestStandbyAndRestore(t *testing.T) {
require.NoError(t, err)
t.Log("Instance restored and running")

// The guest-agent clock keeper should step the skewed wall clock back to
// host time; without it the guest stays about an hour behind.
require.Eventually(t, func() bool {
out, code, err := execInInstance(ctx, inst, "date", "+%s")
if err != nil || code != 0 {
return false
}
epoch, err := strconv.ParseInt(strings.TrimSpace(out), 10, 64)
if err != nil {
return false
}
lag := time.Now().Unix() - epoch
t.Logf("guest clock lag: %ds", lag)
return lag >= -5 && lag <= 5
}, integrationTestTimeout(60*time.Second), 3*time.Second,
"guest clock should converge to host time after restore")

// DEBUG: Check app.log file size after restore
if info, err := os.Stat(consoleLogPath); err == nil {
consoleLogSizeAfter := info.Size()
Expand Down
115 changes: 115 additions & 0 deletions lib/system/guest_agent/clock_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package main

import (
"errors"
"fmt"
"io"
"log"
"os"
"strings"
"time"

"golang.org/x/sys/unix"
)

const (
ptpDevicePath = "/dev/ptp0"

// Logged by the vmgenid driver when the hypervisor bumps the VM
// generation ID, which happens on every snapshot restore.
vmForkKmsgSignal = "crng reseeded due to virtual machine fork"

driftCheckPeriod = 30 * time.Second
maxClockDrift = time.Second
)

// startClockKeeper keeps the guest realtime clock aligned with the host. The
// wall clock resumes from the snapshot's saved time after a standby restore
// and nothing else corrects it (the guest runs no NTP daemon, and VMGenID
// only reseeds the RNG). The keeper reads host time from the KVM PTP device
// and steps CLOCK_REALTIME whenever it drifts, checking periodically and
// immediately after a restore is detected via the vmgenid kmsg line.
func startClockKeeper() {
ptp, err := os.Open(ptpDevicePath)
if err != nil {
log.Printf("[guest-agent] clock keeper disabled: %v", err)
return
}

restored := make(chan struct{}, 1)
go watchVMForkKmsg(restored)
go runClockKeeper(ptp, restored)
}

func runClockKeeper(ptp *os.File, restored <-chan struct{}) {
ticker := time.NewTicker(driftCheckPeriod)
defer ticker.Stop()
for {
if err := syncClockFromPTP(ptp); err != nil {
log.Printf("[guest-agent] clock sync failed: %v", err)
}
select {
case <-ticker.C:
case <-restored:
}
}
}

func syncClockFromPTP(ptp *os.File) error {
// FD_TO_CLOCKID from linux/posix-timers.h
clockID := int32((^int(ptp.Fd()) << 3) | 3)

var hostTS unix.Timespec
if err := unix.ClockGettime(clockID, &hostTS); err != nil {
return fmt.Errorf("read %s: %w", ptpDevicePath, err)
}
var guestTS unix.Timespec
if err := unix.ClockGettime(unix.CLOCK_REALTIME, &guestTS); err != nil {
return fmt.Errorf("read realtime clock: %w", err)
}

drift := time.Duration(hostTS.Nano() - guestTS.Nano())
if drift.Abs() <= maxClockDrift {
return nil
}
if err := unix.ClockSettime(unix.CLOCK_REALTIME, &hostTS); err != nil {
return fmt.Errorf("set realtime clock: %w", err)
}
log.Printf("[guest-agent] stepped realtime clock by %s to %s",
drift, time.Unix(0, hostTS.Nano()).UTC().Format(time.RFC3339Nano))
return nil
}

func watchVMForkKmsg(restored chan<- struct{}) {
f, err := os.Open("/dev/kmsg")
if err != nil {
log.Printf("[guest-agent] clock keeper kmsg watch disabled: %v", err)
return
}
defer f.Close()

if _, err := f.Seek(0, io.SeekEnd); err != nil {
log.Printf("[guest-agent] warning: failed to seek /dev/kmsg to end: %v", err)
}

// Each read returns one log record. EPIPE means the buffer wrapped and
// records were overwritten; the next read continues from the oldest
// available record.
buf := make([]byte, 8192)
for {
n, err := f.Read(buf)
if err != nil {
if errors.Is(err, unix.EPIPE) {
continue
}
log.Printf("[guest-agent] clock keeper kmsg watch stopped: %v", err)
return
}
if strings.Contains(string(buf[:n]), vmForkKmsgSignal) {
select {
case restored <- struct{}{}:
default:
}
}
}
}
5 changes: 5 additions & 0 deletions lib/system/guest_agent/clock_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//go:build !linux

package main

func startClockKeeper() {}
2 changes: 2 additions & 0 deletions lib/system/guest_agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ func main() {
log.Printf("[guest-agent] warning: failed to write readiness file: %v", err)
}

startClockKeeper()

// Create gRPC server
grpcServer := grpc.NewServer()
pb.RegisterGuestServiceServer(grpcServer, &guestServer{})
Expand Down
Loading