update: roll back what the restart actually deploys
On the deployment deploy/README.md documents, source_dir and install_dir are
the same tree and the restart command rebuilds the image from it. The
Dockerfile builds from cmd/ and internal/ and .dockerignore keeps the host
binaries out, so restoring the snapshotted binaries restored bytes nothing
reads. A bad commit therefore cost two health timeouts and two image builds
and ended in ErrRollbackFailed with an instruction to copy files back by hand,
which would not have helped either.
A deployment that rebuilds from source now has to say how the source is put
back. source_rollback "git" records the commit before the update and checks it
back out before the rollback restart. It refuses a dirty tree, because the
recorded commit does not describe one and a forced checkout would delete his
work. A build-from-source config that says nothing is refused by Validate, at
startup, rather than at the one rollback that mattered.
Also in this change, all from the same review:
- MethodPing, the one method a locked daemon answers. Preflight passed on an
unlocked daemon and the post-restart Presence read failed on a locked one,
so a good update read as SHE IS PROBABLY DOWN once the env key is gone.
- A dial failure is reported apart from a read failure. The documented
socket is under /var/lib/docker, which a non-root operator cannot
traverse, and "she is not answering" was the wrong diagnosis.
- Verify refuses to run as root over a tree owned by someone else. It runs
make build and make test in place, and root-owned artifacts break his next
ordinary make.
- A rollback no longer reverts config_files. That undid every config edit
since the last apply, phraser.model_path among them.
- The verify-failure path no longer reports rolled_back for a compile error.
- waitHealthy caps each attempt at the remaining budget, so a 90s timeout
cannot run to 99s.
- tail cuts on a rune boundary. Russian test names showed the seam.
- The claim that mavend does not import internal/update is replaced with
what is enforced: mavend constructs no Updater and nothing can call Apply.
- snapshot_dir inside source_dir is refused. It landed in the build context.
Found in review of #69.
This commit is contained in:
@@ -390,6 +390,15 @@ type ModelStatusResp struct {
|
||||
Swappable []string `json:"swappable,omitempty"`
|
||||
}
|
||||
|
||||
// PingResp — the answer to MethodPing. Alive is always true (the reply itself
|
||||
// is the proof); Locked says whether the daemon is still waiting for a passkey
|
||||
// assertion, which is the one state where a CoreAPI read cannot tell an
|
||||
// operator anything.
|
||||
type PingResp struct {
|
||||
Alive bool `json:"alive"`
|
||||
Locked bool `json:"locked"`
|
||||
}
|
||||
|
||||
type listTasksReq struct {
|
||||
Status string `json:"status"` // "" all | "live" | candidate|open|done|dropped
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ var readOnlyMethods = map[Method]bool{
|
||||
MethodMCPServers: true,
|
||||
MethodDayPlan: true,
|
||||
MethodRecentEvents: true,
|
||||
MethodPing: true,
|
||||
}
|
||||
|
||||
// Dial connects to a core socket at path and returns a Client. The module
|
||||
@@ -636,5 +637,16 @@ func (c *Client) RevertFact(ctx context.Context, key string) (int64, error) {
|
||||
return result.NewID, nil
|
||||
}
|
||||
|
||||
// Ping asks whether the daemon is there, and whether it is locked. It is not a
|
||||
// CoreAPI method: it touches no store, so it answers before the passkey
|
||||
// assertion that every other read waits for.
|
||||
func (c *Client) Ping(ctx context.Context) (PingResp, error) {
|
||||
var r PingResp
|
||||
if err := c.call(ctx, MethodPing, nil, &r); err != nil {
|
||||
return PingResp{}, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Compile-time check: *Client satisfies CoreAPI.
|
||||
var _ CoreAPI = (*Client)(nil)
|
||||
|
||||
@@ -486,6 +486,11 @@ type Server struct {
|
||||
ListSpeakersFn ListSpeakersFunc
|
||||
ForgetSpeakerFn ForgetSpeakerFunc
|
||||
|
||||
// LockedFn — reports whether the daemon is in locked (pre-unlock) mode.
|
||||
// Read by MethodPing only. Nil ⇒ not locked, which is what an embedded or
|
||||
// test Server without the unlock dance is.
|
||||
LockedFn func() bool
|
||||
|
||||
// UnlockFn — unwraps the store encryption key from the wrapped blob using
|
||||
// the passkey PRF secret, opens the encrypted store, and wires
|
||||
// the rest of the daemon (voice, loop, delivery). Set by the daemon when
|
||||
@@ -933,6 +938,17 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
||||
// directly by the daemon (StepUp / WrapKeyFn / UnlockFn), not store
|
||||
// state, so they can never be table entries keyed on a CoreAPI method.
|
||||
switch req.Method {
|
||||
case MethodPing:
|
||||
// Deliberately reaches nothing: no store, no CoreAPI, no daemon
|
||||
// component. That is what makes it answerable in locked mode, and it is
|
||||
// the whole point — an update that restarts her into locked mode has to
|
||||
// be able to tell that apart from a daemon that did not come up.
|
||||
locked := false
|
||||
if s.LockedFn != nil {
|
||||
locked = s.LockedFn()
|
||||
}
|
||||
return marshalResult(PingResp{Alive: true, Locked: locked}), nil
|
||||
|
||||
case MethodAssertStepUp:
|
||||
if s.StepUp != nil {
|
||||
return marshalResult(nil), s.StepUp(ctx)
|
||||
|
||||
@@ -122,3 +122,39 @@ func TestLockedCheckDefaultDenies(t *testing.T) {
|
||||
t.Error("UnlockFn never ran")
|
||||
}
|
||||
}
|
||||
|
||||
// A locked daemon has to be able to say it is alive. Every CoreAPI method is
|
||||
// refused before unlock, so a health check built on one of those cannot tell a
|
||||
// daemon waiting for a passkey apart from a daemon that failed to start. That
|
||||
// is what turned a good update into the manual-recovery case in
|
||||
// internal/update. MethodPing reaches no store, so it answers either way.
|
||||
func TestPingAnswersWhileLocked(t *testing.T) {
|
||||
_, srv, cli, _ := newServerWithStore(t)
|
||||
srv.LockedFn = func() bool { return true }
|
||||
locked := errors.New("daemon locked")
|
||||
srv.Check = func(_ context.Context, m Method, _ json.RawMessage) error {
|
||||
switch m {
|
||||
case MethodAssertStepUp, MethodUnlock, MethodPing:
|
||||
return nil
|
||||
default:
|
||||
return locked
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
p, err := cli.Ping(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Ping while locked: %v", err)
|
||||
}
|
||||
if !p.Alive || !p.Locked {
|
||||
t.Errorf("Ping = %+v; want alive and locked", p)
|
||||
}
|
||||
// And the read it replaces is still refused, which is the whole point.
|
||||
if _, err := cli.Presence(ctx); err == nil {
|
||||
t.Error("Presence answered while locked")
|
||||
}
|
||||
|
||||
srv.LockedFn = func() bool { return false }
|
||||
if p, err := cli.Ping(ctx); err != nil || p.Locked {
|
||||
t.Errorf("Ping after unlock = %+v, %v; want alive and not locked", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,14 @@ const (
|
||||
MethodListSpeakers Method = "list_speakers"
|
||||
MethodForgetSpeaker Method = "forget_speaker"
|
||||
MethodRecentEvents Method = "recent_events"
|
||||
|
||||
// MethodPing — liveness, and the only method that answers in locked mode
|
||||
// without a passkey assertion. It reaches no store, takes no arguments and
|
||||
// returns whether the daemon is locked, so an operator tool can tell "she is
|
||||
// up and waiting for a passkey" apart from "she is not there at all".
|
||||
// Everything else about her state needs the store, and the store needs the
|
||||
// key.
|
||||
MethodPing Method = "ping"
|
||||
)
|
||||
|
||||
// Request — one frame from module to core. Params is the JSON-encoded argument
|
||||
|
||||
Reference in New Issue
Block a user