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
54 changes: 51 additions & 3 deletions cmd/kuke/daemon/recreate/recreate.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import (
"github.com/eminwux/kukeon/internal/consts"
"github.com/eminwux/kukeon/internal/controller"
"github.com/eminwux/kukeon/internal/errdefs"
"github.com/eminwux/kukeon/internal/sysuser"
"github.com/eminwux/kukeon/pkg/api/kukeonv1"
v1beta1 "github.com/eminwux/kukeon/pkg/api/model/v1beta1"
"github.com/spf13/cobra"
Expand All @@ -60,6 +61,12 @@ type MockProvisionKukeondCellKey struct{}
// tests can verify the teardown phase without a real listening socket.
type MockWaitForReadyKey struct{}

// MockApplySocketOwnershipKey injects a stub for the post-ready socket
// chown/chmod step so unit tests can drive the recreate flow without root or
// a real kukeon group. Production resolves the kukeon GID and applies
// root:kukeon 0o660 to the socket.
type MockApplySocketOwnershipKey struct{}

// resolveProvisionKukeondCell picks the provisioning implementation. Tests
// inject a stub via MockProvisionKukeondCellKey; production builds a real
// controller and delegates to ProvisionKukeondCell.
Expand All @@ -82,17 +89,28 @@ func resolveProvisionKukeondCell(
}
containerdSocket := viper.GetString(config.KUKEON_ROOT_CONTAINERD_SOCKET.ViperKey)

// Pass the kukeon group GID so the provisioned daemon binds the socket
// 0o660 and chowns it to root:kukeon, matching `kuke init`. The host is
// already initialized at this point (runRecreate rejects an uninitialized
// host before reaching here), so the group exists. The post-ready
// resolveApplySocketOwnership step re-asserts this on the host, since the
// daemon resets socket ownership on every restart.
gid, err := lifecycle.LookupKukeonGID()
if err != nil {
return fmt.Errorf("resolve kukeon group gid: %w", err)
}

ctrl := controller.NewControllerExec(cmd.Context(), logger, controller.Options{
RunPath: runPath,
ContainerdSocket: containerdSocket,
KukeondSocket: socketPath,
KukeondImage: image,
KukeondSocketGID: 0,
KukeondSocketGID: gid,
KukeondConfiguration: serverConfigPath,
})
defer ctrl.Close()

_, err := ctrl.ProvisionKukeondCell()
_, err = ctrl.ProvisionKukeondCell()
return err
}

Expand All @@ -105,6 +123,23 @@ func resolveWaitForReady(cmd *cobra.Command, socketPath string) error {
return lifecycle.WaitForKukeondReady(cmd.Context(), socketPath, lifecycle.KukeondReadyTimeout)
}

// resolveApplySocketOwnership picks the post-ready socket-ownership step. Tests
// inject a stub via MockApplySocketOwnershipKey (the production implementation
// chowns to uid 0, which only root may do, and needs the kukeon group a CI box
// lacks); production resolves the kukeon GID and re-asserts root:kukeon 0o660
// on the socket the daemon just bound — mirroring `kuke init` so a non-root
// kukeon-group member can dial it without sudo.
func resolveApplySocketOwnership(cmd *cobra.Command, socketPath string) error {
if stub, ok := cmd.Context().Value(MockApplySocketOwnershipKey{}).(func() error); ok && stub != nil {
return stub()
}
gid, err := lifecycle.LookupKukeonGID()
if err != nil {
return err
}
return sysuser.ChownAndChmod(socketPath, 0, gid, consts.KukeonSocketMode)
}

// NewRecreateCmd builds the `kuke daemon recreate` cobra command.
func NewRecreateCmd() *cobra.Command {
cmd := &cobra.Command{
Expand Down Expand Up @@ -249,6 +284,19 @@ func runRecreate(cmd *cobra.Command, _ []string) error {
if waitErr := resolveWaitForReady(cmd, socketPath); waitErr != nil {
return fmt.Errorf("kukeond did not become ready after recreate: %w", waitErr)
}

// The daemon created the socket once it bound the listener, but it resets
// ownership to root-only (0o600) on every start. Re-apply kukeon ownership
// on the host now — mirroring `kuke init` — so a non-root kukeon-group
// member can dial it without sudo.
if ownErr := resolveApplySocketOwnership(cmd, socketPath); ownErr != nil {
return fmt.Errorf("apply kukeon ownership to %q: %w", socketPath, ownErr)
}
cmd.Printf(
"kukeond socket %q: chown root:%s mode %#o\n",
socketPath, consts.KukeonSystemGroup, consts.KukeonSocketMode.Perm(),
)

cmd.Printf("kukeond is ready (unix://%s)\n", socketPath)
return nil
}
Expand Down Expand Up @@ -314,4 +362,4 @@ func removeFileIfExists(path string) (bool, error) {
return false, rmErr
}
return true, nil
}
}
108 changes: 103 additions & 5 deletions cmd/kuke/daemon/recreate/recreate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ import (
"testing"
"time"

"github.com/eminwux/kukeon/cmd/kuke/internal/lifecycle"
recreate "github.com/eminwux/kukeon/cmd/kuke/daemon/recreate"
"github.com/eminwux/kukeon/cmd/kuke/internal/lifecycle"
kukshared "github.com/eminwux/kukeon/cmd/kuke/shared"
"github.com/eminwux/kukeon/cmd/types"
"github.com/eminwux/kukeon/internal/consts"
Expand Down Expand Up @@ -145,8 +145,8 @@ func TestDaemonRecreate(t *testing.T) {
wantErr: "--kukeond-image is required",
},
{
name: "GetCell error is wrapped",
args: []string{"--kukeond-image", "test-img:dev"},
name: "GetCell error is wrapped",
args: []string{"--kukeond-image", "test-img:dev"},
fake: &fakeClient{
getCellFn: func(_ v1beta1.CellDoc) (kukeonv1.GetCellResult, error) {
return kukeonv1.GetCellResult{}, errors.New("io: read failed")
Expand Down Expand Up @@ -231,6 +231,7 @@ func TestDaemonRecreate(t *testing.T) {
ctx = context.WithValue(ctx, lifecycle.EnsureSocketDirKey{}, func() error { return nil })
ctx = context.WithValue(ctx, recreate.MockProvisionKukeondCellKey{}, func() error { return nil })
ctx = context.WithValue(ctx, recreate.MockWaitForReadyKey{}, func() error { return nil })
ctx = context.WithValue(ctx, recreate.MockApplySocketOwnershipKey{}, func() error { return nil })
ctx = context.WithValue(ctx, recreate.MockSocketDirKey{}, t.TempDir())
cmd.SetContext(ctx)
cmd.SetArgs(tt.args)
Expand Down Expand Up @@ -293,10 +294,11 @@ func TestDaemonRecreate_RemovesSocketAndPidFiles(t *testing.T) {
cmd.SetErr(buf)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := context.WithValue(context.Background(), types.CtxLogger, logger)
ctx = context.WithValue(ctx, lifecycle.MockClientKey{}, kukeonv1.Client(fake))
ctx = context.WithValue(ctx, lifecycle.MockClientKey{}, kukeonv1.Client(fake))
ctx = context.WithValue(ctx, lifecycle.EnsureSocketDirKey{}, func() error { return nil })
ctx = context.WithValue(ctx, recreate.MockProvisionKukeondCellKey{}, func() error { return nil })
ctx = context.WithValue(ctx, recreate.MockWaitForReadyKey{}, func() error { return nil })
ctx = context.WithValue(ctx, recreate.MockApplySocketOwnershipKey{}, func() error { return nil })
ctx = context.WithValue(ctx, recreate.MockSocketDirKey{}, t.TempDir())
cmd.SetContext(ctx)
cmd.SetArgs([]string{"--kukeond-image", "test-img:dev"})
Expand All @@ -309,6 +311,101 @@ ctx = context.WithValue(ctx, lifecycle.MockClientKey{}, kukeonv1.Client(fake))
}
}

// TestDaemonRecreate_AppliesSocketOwnership confirms the post-ready step that
// re-asserts root:kukeon ownership on the socket runs after the daemon becomes
// reachable and that the chown notice is printed — the regression guard for the
// bug where `kuke daemon recreate` left the socket 0o600 root-only, forcing
// non-root kukeon-group members to sudo.
func TestDaemonRecreate_AppliesSocketOwnership(t *testing.T) {
withFreshViper(t)

var ownershipCalled bool
fake := &fakeClient{
getCellFn: func(_ v1beta1.CellDoc) (kukeonv1.GetCellResult, error) {
return kukeonv1.GetCellResult{
Cell: v1beta1.CellDoc{
Status: v1beta1.CellStatus{State: v1beta1.CellStateStopped},
},
MetadataExists: true,
}, nil
},
deleteCellFn: func(doc v1beta1.CellDoc) (kukeonv1.DeleteCellResult, error) {
return kukeonv1.DeleteCellResult{Cell: doc, MetadataDeleted: true}, nil
},
}

cmd := recreate.NewRecreateCmd()
buf := &bytes.Buffer{}
cmd.SetOut(buf)
cmd.SetErr(buf)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := context.WithValue(context.Background(), types.CtxLogger, logger)
ctx = context.WithValue(ctx, lifecycle.MockClientKey{}, kukeonv1.Client(fake))
ctx = context.WithValue(ctx, lifecycle.EnsureSocketDirKey{}, func() error { return nil })
ctx = context.WithValue(ctx, recreate.MockProvisionKukeondCellKey{}, func() error { return nil })
ctx = context.WithValue(ctx, recreate.MockWaitForReadyKey{}, func() error { return nil })
ctx = context.WithValue(ctx, recreate.MockApplySocketOwnershipKey{}, func() error {
ownershipCalled = true
return nil
})
ctx = context.WithValue(ctx, recreate.MockSocketDirKey{}, t.TempDir())
cmd.SetContext(ctx)
cmd.SetArgs([]string{"--kukeond-image", "test-img:dev"})

if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ownershipCalled {
t.Fatal("expected the post-ready socket-ownership step to be invoked")
}
if out := buf.String(); !strings.Contains(out, "chown root:kukeon mode 0660") {
t.Errorf("output missing socket chown notice; got:\n%s", out)
}
}

// TestDaemonRecreate_SocketOwnershipFailureIsWrapped confirms a failure in the
// post-ready socket-ownership step surfaces as a wrapped error rather than a
// silent success that leaves the socket inaccessible.
func TestDaemonRecreate_SocketOwnershipFailureIsWrapped(t *testing.T) {
withFreshViper(t)

fake := &fakeClient{
getCellFn: func(_ v1beta1.CellDoc) (kukeonv1.GetCellResult, error) {
return kukeonv1.GetCellResult{
Cell: v1beta1.CellDoc{
Status: v1beta1.CellStatus{State: v1beta1.CellStateStopped},
},
MetadataExists: true,
}, nil
},
deleteCellFn: func(doc v1beta1.CellDoc) (kukeonv1.DeleteCellResult, error) {
return kukeonv1.DeleteCellResult{Cell: doc, MetadataDeleted: true}, nil
},
}

cmd := recreate.NewRecreateCmd()
buf := &bytes.Buffer{}
cmd.SetOut(buf)
cmd.SetErr(buf)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := context.WithValue(context.Background(), types.CtxLogger, logger)
ctx = context.WithValue(ctx, lifecycle.MockClientKey{}, kukeonv1.Client(fake))
ctx = context.WithValue(ctx, lifecycle.EnsureSocketDirKey{}, func() error { return nil })
ctx = context.WithValue(ctx, recreate.MockProvisionKukeondCellKey{}, func() error { return nil })
ctx = context.WithValue(ctx, recreate.MockWaitForReadyKey{}, func() error { return nil })
ctx = context.WithValue(ctx, recreate.MockApplySocketOwnershipKey{}, func() error {
return errors.New("chown blew up")
})
ctx = context.WithValue(ctx, recreate.MockSocketDirKey{}, t.TempDir())
cmd.SetContext(ctx)
cmd.SetArgs([]string{"--kukeond-image", "test-img:dev"})

err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "apply kukeon ownership") {
t.Fatalf("want wrapped ownership error, got %v", err)
}
}

// TestDaemonRecreate_GracefulTimeoutEscalatesToKill exercises the SIGTERM ->
// SIGKILL escalation path: StopCell blocks past --timeout, KillCell must be
// invoked, the escalation notice is printed, and DeleteCell still runs.
Expand Down Expand Up @@ -360,6 +457,7 @@ func TestDaemonRecreate_GracefulTimeoutEscalatesToKill(t *testing.T) {
ctx = context.WithValue(ctx, lifecycle.EnsureSocketDirKey{}, func() error { return nil })
ctx = context.WithValue(ctx, recreate.MockProvisionKukeondCellKey{}, func() error { return nil })
ctx = context.WithValue(ctx, recreate.MockWaitForReadyKey{}, func() error { return nil })
ctx = context.WithValue(ctx, recreate.MockApplySocketOwnershipKey{}, func() error { return nil })
ctx = context.WithValue(ctx, recreate.MockSocketDirKey{}, t.TempDir())
cmd.SetContext(ctx)
cmd.SetArgs([]string{"--kukeond-image", "test-img:dev", "--timeout", "50ms"})
Expand Down Expand Up @@ -515,4 +613,4 @@ func (f *fakeClient) DeleteCell(_ context.Context, doc v1beta1.CellDoc) (kukeonv
return kukeonv1.DeleteCellResult{}, errors.New("unexpected DeleteCell call")
}
return f.deleteCellFn(doc)
}
}
5 changes: 4 additions & 1 deletion cmd/kuke/init/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ const (
// `--no-daemon` reads for non-root operators.
kukeonRunPathDirMode os.FileMode = os.ModeSetgid | 0o750
kukeonRunPathFileMode os.FileMode = 0o640
kukeonSocketMode os.FileMode = 0o660
// kukeonSocketMode mirrors consts.KukeonSocketMode — the single source of
// truth shared with `kuke daemon recreate` so the init and recreate socket
// permissions cannot drift.
kukeonSocketMode os.FileMode = consts.KukeonSocketMode

// kukepauseBinaryName is the binary `kuke init` stages under <RunPath>/bin
// so every cell's root container — including kukeond's own — can bind-mount
Expand Down
24 changes: 18 additions & 6 deletions cmd/kuke/internal/lifecycle/socketdir.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@ func ResolveEnsureSocketDir(cmd *cobra.Command) func() error {
return func() error { return EnsureSocketDir(ResolveSocketPath()) }
}

// LookupKukeonGID resolves the numeric GID of the kukeon system group that
// `kuke init` provisions on the host. The socket-dir ownership step and
// `kuke daemon recreate`'s post-ready socket chown both need it to grant
// non-root kukeon-group members access to the daemon without sudo.
func LookupKukeonGID() (int, error) {
grp, err := user.LookupGroup(consts.KukeonSystemGroup)
if err != nil {
return 0, fmt.Errorf("lookup group %q: %w", consts.KukeonSystemGroup, err)
}
gid, err := strconv.Atoi(grp.Gid)
if err != nil {
return 0, fmt.Errorf("parse gid %q for group %q: %w", grp.Gid, consts.KukeonSystemGroup, err)
}
return gid, nil
}

// applyRunDirOwnership re-asserts root:kukeon ownership and the kukeon SGID
// mode on the kukeond socket's parent directory. It is a package var so the
// unit suite can substitute a non-root stub: the production implementation
Expand All @@ -54,13 +70,9 @@ func ResolveEnsureSocketDir(cmd *cobra.Command) func() error {
//
//nolint:gochecknoglobals // test seam for the production run-dir ownership step
var applyRunDirOwnership = func(dir string) error {
grp, err := user.LookupGroup(consts.KukeonSystemGroup)
if err != nil {
return fmt.Errorf("lookup group %q: %w", consts.KukeonSystemGroup, err)
}
gid, err := strconv.Atoi(grp.Gid)
gid, err := LookupKukeonGID()
if err != nil {
return fmt.Errorf("parse gid %q for group %q: %w", grp.Gid, consts.KukeonSystemGroup, err)
return err
}
return sysuser.ChownAndChmod(dir, 0, gid, consts.KukeonRunDirMode)
}
Expand Down
9 changes: 9 additions & 0 deletions internal/consts/consts.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,15 @@ const (
// so the two cannot drift on what `kuke init` produces.
KukeonRunDirMode os.FileMode = os.ModeSetgid | 0o750

// KukeonSocketMode is the mode applied to the kukeond socket itself
// (root:kukeon) after the daemon binds the listener, by `kuke init` and
// re-asserted by `kuke daemon recreate` so a non-root kukeon-group member
// can dial it without sudo. The daemon binds 0o600 root-only by default and
// resets ownership on every restart, so the host-side chown is the
// authoritative correction. Single source of truth shared by the init
// bootstrap and the daemon recreate path so the two cannot drift.
KukeonSocketMode os.FileMode = 0o660

// DefaultRealmNamespaceSuffix is the in-binary default for the
// containerd namespace suffix appended to every realm name (without a
// leading dot — RealmNamespace adds the dot when joining). Operators
Expand Down
Loading