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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ cryptosctl --endpoint pki-root.example:443 reboot --confirm "Example Root CA G1"
cryptosctl --endpoint pki-root.example:443 reboot --confirm "Example Root CA G1" --power-off
```

`--confirm` must be the node's CA common name, and over mTLS the call needs the bootstrap admin client certificate. The node replies, then stops its listeners, closes etcd and the audit log, unmounts and locks the state volume, and restarts (or powers off). A hard reset skips all of that. The management certificate gets a new key on every boot, so refresh a pinned `--trust` afterwards; a `--trust` that holds the CA keeps working.
`--confirm` must be the node's CA common name, and over mTLS the call needs the bootstrap admin client certificate. The node replies, then stops its listeners, closes etcd and the audit log, unmounts and locks the state volume, and restarts (or powers off). A hard reset skips all of that. The management certificate gets a new key on every boot, so refresh a pinned `--trust` afterwards; a `--trust` that holds the CA keeps working. As soon as a reboot or power-off is chosen, by any of the paths below, the console shows a branded "Rebooting..." or "Shutting down..." screen in place of the dashboard.

The same orderly shutdown also runs, without the API, when:

Expand Down
11 changes: 11 additions & 0 deletions internal/console/console.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,14 @@ func (r *Renderer) Step(name string, state StepState) error {
_, err := fmt.Fprintf(r.w, "[%s] %s\n", state.marker(), name)
return err
}

// Shutdown clears the screen and writes a branded teardown message -- the
// mark and version, then message (for example "Rebooting..." or "Shutting
// down...") -- matching the boot banner's style. It is the last thing drawn
// on the console before the node restarts or powers off, so an admin
// watching it sees why the dashboard stopped updating instead of it just
// freezing on its last frame.
func (r *Renderer) Shutdown(version, message string) error {
_, err := io.WriteString(r.w, clearHome+Banner(version)+message+"\n")
return err
}
17 changes: 17 additions & 0 deletions internal/console/console_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,23 @@ func TestRendererBanner(t *testing.T) {
}
}

func TestRendererShutdown(t *testing.T) {
var buf bytes.Buffer
if err := console.NewRenderer(&buf).Shutdown("v0.1.0", "Rebooting..."); err != nil {
t.Fatal(err)
}
got := buf.String()
if !strings.HasPrefix(got, "\x1b[2J\x1b[H") {
t.Fatalf("Shutdown did not clear the screen first:\n%q", got)
}
if !strings.Contains(got, console.Banner("v0.1.0")) {
t.Fatalf("Shutdown missing the boot banner's mark and version:\n%s", got)
}
if !strings.HasSuffix(got, "Rebooting...\n") {
t.Fatalf("Shutdown missing the message:\n%s", got)
}
}

func TestRendererStep(t *testing.T) {
cases := []struct {
name string
Expand Down
34 changes: 34 additions & 0 deletions internal/init/shutdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import (
"syscall"
"time"

"github.com/CryptOS-PKI/cryptos-node/internal/buildinfo"
"github.com/CryptOS-PKI/cryptos-node/internal/console"
"github.com/CryptOS-PKI/cryptos-node/internal/reset"
)

Expand All @@ -66,6 +68,16 @@ func (a ShutdownAction) String() string {
return "reboot"
}

// consoleMessage is the branded line shown on the console while the action
// runs, matching the boot banner's style.
func (a ShutdownAction) consoleMessage() string {
if a == ShutdownPowerOff {
return "Shutting down..."
}

return "Rebooting..."
}

// rebootRPCDelay lets the RebootResponse flush before the listeners stop,
// the same grace the reset and image-activate paths give their replies.
const rebootRPCDelay = 2 * time.Second
Expand All @@ -86,6 +98,10 @@ type shutdownRequests struct {
// watchdog arms the teardown watchdog for the chosen action.
watchdog func(ShutdownAction)

// announce tells the console the chosen action, so the screen says what
// is happening instead of freezing on its last frame.
announce func(ShutdownAction)

mu sync.Mutex
chosen ShutdownAction
}
Expand All @@ -99,9 +115,24 @@ func newShutdownRequests() *shutdownRequests {
forceHalt(a)
})
},
announce: announceShutdown,
}
}

// announceShutdown writes a branded status line to the console saying the
// node is rebooting or shutting down. Best-effort: a console that cannot be
// opened (a dev host, or one that is already gone) does not block shutdown.
func announceShutdown(a ShutdownAction) {
cons, err := openConsole()
if err != nil {
return
}
if c, ok := cons.(io.Closer); ok {
defer func() { _ = c.Close() }()
}
_ = console.NewRenderer(cons).Shutdown(buildinfo.Get().Version, a.consoleMessage())
}

// Request asks for a shutdown. It never blocks.
func (r *shutdownRequests) Request(a ShutdownAction) {
select {
Expand All @@ -123,6 +154,9 @@ func (r *shutdownRequests) Wait(ctx context.Context) ShutdownAction {
r.mu.Lock()
r.chosen = a
r.mu.Unlock()
if r.announce != nil {
r.announce(a)
}
if r.watchdog != nil {
r.watchdog(a)
}
Expand Down
5 changes: 5 additions & 0 deletions internal/init/shutdown_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ const forceSyncTimeout = 10 * time.Second
// within forceSyncTimeout and then restarts or powers off the node directly
// with reboot(2), skipping whatever part of the orderly teardown has hung.
func forceHalt(action ShutdownAction) {
// The orderly path already announced this when it was chosen, but the
// console dashboard may have kept redrawing over it while the teardown
// hung, so say it again right before the forced reboot.
announceShutdown(action)

synced := make(chan struct{})
go func() {
unix.Sync()
Expand Down
35 changes: 32 additions & 3 deletions internal/init/shutdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,19 @@ import (
"github.com/CryptOS-PKI/cryptos-node/internal/reset"
)

// testShutdownRequests is newShutdownRequests with the teardown watchdog
// replaced: a real one would reboot the test host after a minute. armed, when
// non-nil, records every action the watchdog was armed for.
// testShutdownRequests is newShutdownRequests with the teardown watchdog and
// the console announcement replaced: a real watchdog would reboot the test
// host after a minute, and a real announcement would write to the test
// host's actual console. armed, when non-nil, records every action the
// watchdog was armed for.
func testShutdownRequests(armed *[]ShutdownAction) *shutdownRequests {
sd := newShutdownRequests()
sd.watchdog = func(a ShutdownAction) {
if armed != nil {
*armed = append(*armed, a)
}
}
sd.announce = func(ShutdownAction) {}

return sd
}
Expand Down Expand Up @@ -90,6 +93,32 @@ func TestShutdownRequests_DefaultsToReboot(t *testing.T) {
}
}

// Wait announces the chosen action to the console as soon as it is chosen,
// before arming the watchdog, so an admin watching the screen is told what
// is about to happen even if the teardown that follows hangs.
func TestShutdownRequests_WaitAnnouncesTheAction(t *testing.T) {
sd := testShutdownRequests(nil)
var announced []ShutdownAction
sd.announce = func(a ShutdownAction) { announced = append(announced, a) }
sd.Request(ShutdownPowerOff)

sd.Wait(context.Background())
if !slices.Equal(announced, []ShutdownAction{ShutdownPowerOff}) {
t.Fatalf("announced = %v, want [%v]", announced, ShutdownPowerOff)
}
}

// The console message names the action in the same words an admin sees
// elsewhere in the UI, not the log's terse "reboot"/"power-off".
func TestShutdownAction_ConsoleMessage(t *testing.T) {
if got, want := ShutdownReboot.consoleMessage(), "Rebooting..."; got != want {
t.Fatalf("ShutdownReboot.consoleMessage() = %q, want %q", got, want)
}
if got, want := ShutdownPowerOff.consoleMessage(), "Shutting down..."; got != want {
t.Fatalf("ShutdownPowerOff.consoleMessage() = %q, want %q", got, want)
}
}

func TestShutdownRequests_WaitReturnsWhenTheContextEnds(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
Expand Down
Loading