From 780e3e546581dd4a46b887310a665eea06f3279a Mon Sep 17 00:00:00 2001 From: Ophestra Date: Sun, 31 Aug 2025 11:57:59 +0900 Subject: [PATCH] container/msg: optionally provide error messages This makes handling of fatal errors a lot less squirmy. Signed-off-by: Ophestra --- container/container.go | 18 +++++++++++ container/container_test.go | 60 ++++++++++++++++++++++++++++++++----- container/msg.go | 18 +++++++++++ container/msg_test.go | 29 ++++++++++++++++++ 4 files changed, 117 insertions(+), 8 deletions(-) diff --git a/container/container.go b/container/container.go index 2301ce7..dee4a44 100644 --- a/container/container.go +++ b/container/container.go @@ -132,6 +132,24 @@ func (e *StartError) Error() string { return e.Step + ": " + e.Err.Error() } +// Message returns a user-facing error message. +func (e *StartError) Message() string { + if e.Passthrough { + switch { + case errors.As(e.Err, new(*os.PathError)), + errors.As(e.Err, new(*os.SyscallError)): + return "cannot " + e.Err.Error() + + default: + return e.Err.Error() + } + } + if e.Origin { + return e.Step + } + return "cannot " + e.Error() +} + // Start starts the container init. The init process blocks until Serve is called. func (p *Container) Start() error { if p.cmd != nil { diff --git a/container/container_test.go b/container/container_test.go index 0d408bf..c7f78aa 100644 --- a/container/container_test.go +++ b/container/container_test.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "log" + "net" "os" "os/exec" "os/signal" @@ -34,6 +35,7 @@ func TestStartError(t *testing.T) { s string is error isF error + msg string }{ {"params env", &container.StartError{ Fatal: true, @@ -41,7 +43,8 @@ func TestStartError(t *testing.T) { Err: container.ErrReceiveEnv, }, "set up params stream: environment variable not set", - container.ErrReceiveEnv, syscall.EBADF}, + container.ErrReceiveEnv, syscall.EBADF, + "cannot set up params stream: environment variable not set"}, {"params", &container.StartError{ Fatal: true, @@ -49,7 +52,8 @@ func TestStartError(t *testing.T) { Err: &os.SyscallError{Syscall: "pipe2", Err: syscall.EBADF}, }, "set up params stream pipe2: bad file descriptor", - syscall.EBADF, os.ErrInvalid}, + syscall.EBADF, os.ErrInvalid, + "cannot set up params stream pipe2: bad file descriptor"}, {"PR_SET_NO_NEW_PRIVS", &container.StartError{ Fatal: true, @@ -57,14 +61,16 @@ func TestStartError(t *testing.T) { Err: syscall.EPERM, }, "prctl(PR_SET_NO_NEW_PRIVS): operation not permitted", - syscall.EPERM, syscall.EACCES}, + syscall.EPERM, syscall.EACCES, + "cannot prctl(PR_SET_NO_NEW_PRIVS): operation not permitted"}, {"landlock abi", &container.StartError{ Step: "get landlock ABI", Err: syscall.ENOSYS, }, "get landlock ABI: function not implemented", - syscall.ENOSYS, syscall.ENOEXEC}, + syscall.ENOSYS, syscall.ENOEXEC, + "cannot get landlock ABI: function not implemented"}, {"landlock old", &container.StartError{ Step: "kernel version too old for LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET", @@ -72,7 +78,8 @@ func TestStartError(t *testing.T) { Origin: true, }, "kernel version too old for LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET", - syscall.ENOSYS, syscall.ENOSPC}, + syscall.ENOSYS, syscall.ENOSPC, + "kernel version too old for LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET"}, {"landlock create", &container.StartError{ Fatal: true, @@ -80,7 +87,8 @@ func TestStartError(t *testing.T) { Err: syscall.EBADFD, }, "create landlock ruleset: file descriptor in bad state", - syscall.EBADFD, syscall.EBADF}, + syscall.EBADFD, syscall.EBADF, + "cannot create landlock ruleset: file descriptor in bad state"}, {"landlock enforce", &container.StartError{ Fatal: true, @@ -88,7 +96,8 @@ func TestStartError(t *testing.T) { Err: syscall.ENOTRECOVERABLE, }, "enforce landlock ruleset: state not recoverable", - syscall.ENOTRECOVERABLE, syscall.ETIMEDOUT}, + syscall.ENOTRECOVERABLE, syscall.ETIMEDOUT, + "cannot enforce landlock ruleset: state not recoverable"}, {"start", &container.StartError{ Step: "start container init", @@ -99,7 +108,31 @@ func TestStartError(t *testing.T) { }, Passthrough: true, }, "fork/exec /proc/nonexistent: no such file or directory", - syscall.ENOENT, syscall.ENOSYS}, + syscall.ENOENT, syscall.ENOSYS, + "cannot fork/exec /proc/nonexistent: no such file or directory"}, + + {"start syscall", &container.StartError{ + Step: "start container init", + Err: &os.SyscallError{ + Syscall: "open", + Err: syscall.ENOSYS, + }, Passthrough: true, + }, + "open: function not implemented", + syscall.ENOSYS, syscall.ENOENT, + "cannot open: function not implemented"}, + + {"start other", &container.StartError{ + Step: "start container init", + Err: &net.OpError{ + Op: "dial", + Net: "unix", + Err: syscall.ECONNREFUSED, + }, Passthrough: true, + }, + "dial unix: connection refused", + syscall.ECONNREFUSED, syscall.ECONNABORTED, + "dial unix: connection refused"}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { @@ -117,6 +150,17 @@ func TestStartError(t *testing.T) { t.Errorf("Is: unexpected true") } }) + + t.Run("msg", func(t *testing.T) { + if got, ok := container.GetErrorMessage(tc.err); !ok { + if tc.msg != "" { + t.Errorf("GetErrorMessage: err does not implement MessageError") + } + return + } else if got != tc.msg { + t.Errorf("GetErrorMessage: %q, want %q", got, tc.msg) + } + }) }) } } diff --git a/container/msg.go b/container/msg.go index 40f0bd4..4be0fd2 100644 --- a/container/msg.go +++ b/container/msg.go @@ -1,10 +1,28 @@ package container import ( + "errors" "log" "sync/atomic" ) +// MessageError is an error with a user-facing message. +type MessageError interface { + // Message returns a user-facing error message. + Message() string + + error +} + +// GetErrorMessage returns whether an error implements [MessageError], and the message if it does. +func GetErrorMessage(err error) (string, bool) { + var e MessageError + if !errors.As(err, &e) || e == nil { + return zeroString, false + } + return e.Message(), true +} + type Msg interface { IsVerbose() bool Verbose(v ...any) diff --git a/container/msg_test.go b/container/msg_test.go index 3c68a50..ba62e9d 100644 --- a/container/msg_test.go +++ b/container/msg_test.go @@ -1,14 +1,43 @@ package container_test import ( + "errors" "log" "strings" "sync/atomic" + "syscall" "testing" "hakurei.app/container" ) +func TestMessageError(t *testing.T) { + testCases := []struct { + name string + err error + want string + wantOk bool + }{ + {"nil", nil, "", false}, + {"new", errors.New(":3"), "", false}, + {"start", &container.StartError{ + Step: "meow", + Err: syscall.ENOTRECOVERABLE, + }, "cannot meow: state not recoverable", true}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, ok := container.GetErrorMessage(tc.err) + if got != tc.want { + t.Errorf("GetErrorMessage: %q, want %q", got, tc.want) + } + if ok != tc.wantOk { + t.Errorf("GetErrorMessage: ok = %v, want %v", ok, tc.wantOk) + } + }) + } +} + func TestDefaultMsg(t *testing.T) { { w := log.Writer()