diff --git a/go/cmd/entrypoint/network.go b/go/cmd/entrypoint/network.go index 8fe2b46..82b9563 100644 --- a/go/cmd/entrypoint/network.go +++ b/go/cmd/entrypoint/network.go @@ -177,6 +177,31 @@ var subCmdNetworkJoin = subCmd{ }, } +var subCmdNetworkLeave = subCmd{ + name: "leave", + descr: "Leaves a network which was previously joined or created", + do: func(ctx subCmdCtx) error { + yesImSure := ctx.flags.Bool("yes-im-sure", false, "Must be given") + + ctx, err := ctx.withParsedFlags(nil) + if err != nil { + return fmt.Errorf("parsing flags: %w", err) + } + + if !*yesImSure { + return errors.New("--yes-im-sure must be given") + } + + daemonRPC, err := ctx.newDaemonRPC() + if err != nil { + return fmt.Errorf("creating daemon RPC client: %w", err) + } + defer daemonRPC.Close() + + return daemonRPC.LeaveNetwork(ctx) + }, +} + var subCmdNetworkList = subCmd{ name: "list", descr: "Lists all networks which have been joined", @@ -290,6 +315,7 @@ var subCmdNetwork = subCmd{ return ctx.doSubCmd( subCmdNetworkCreate, subCmdNetworkJoin, + subCmdNetworkLeave, subCmdNetworkList, subCmdNetworkGetConfig, ) diff --git a/go/daemon/client.go b/go/daemon/client.go index ddac843..4849a78 100644 --- a/go/daemon/client.go +++ b/go/daemon/client.go @@ -106,6 +106,15 @@ func (c *rpcClient) JoinNetwork(ctx context.Context, j1 network.JoiningBootstrap return } +func (c *rpcClient) LeaveNetwork(ctx context.Context) (err error) { + err = c.client.Call( + ctx, + nil, + "LeaveNetwork", + ) + return +} + func (c *rpcClient) RemoveHost(ctx context.Context, hostName nebula.HostName) (err error) { err = c.client.Call( ctx, diff --git a/go/daemon/daemon.go b/go/daemon/daemon.go index 5d1e2f8..e5e61dd 100644 --- a/go/daemon/daemon.go +++ b/go/daemon/daemon.go @@ -257,6 +257,30 @@ func withNetwork[Res any]( return fn(ctx, network) } +// LeaveNetwork picks the network out of the Context which was embedded by +// WithNetwork, and leaves it. The Network will no longer be considered joined +// and will not be active after this returns. +// +// Errors: +// - ErrNoNetwork +// - ErrNoMatchingNetworks +// - ErrMultipleMatchingNetworks +func (d *Daemon) LeaveNetwork(ctx context.Context) error { + d.l.Lock() + defer d.l.Unlock() + + network, err := pickNetwork(ctx, d.networkLoader, d.networks) + if err != nil { + return err + } + + shutdownErr := network.Shutdown() + loaderLeaveErr := d.networkLoader.Leave(ctx, network.creationParams) + delete(d.networks, network.creationParams.ID) + + return errors.Join(shutdownErr, loaderLeaveErr) +} + // GetNetworks returns all networks which have been joined by the Daemon, // ordered by their name. func (d *Daemon) GetNetworks( @@ -401,7 +425,13 @@ func (d *Daemon) GetConfig( ) } -// SetConfig implements the method for the network.RPC interface. +// SetConfig extends the [network.RPC] method of the same name such that +// [ErrManagedNetworkConfig] is returned if the picked network is +// configured as part of the [daecommon.Config] which the Daemon was +// initialized with. +// +// See the `network.RPC` documentation in this interface for more usage +// details. func (d *Daemon) SetConfig( ctx context.Context, networkConfig daecommon.NetworkConfig, ) error { diff --git a/go/daemon/daemon_test.go b/go/daemon/daemon_test.go index d78fa70..263071d 100644 --- a/go/daemon/daemon_test.go +++ b/go/daemon/daemon_test.go @@ -363,6 +363,35 @@ func TestDaemon_CreateNetwork(t *testing.T) { }) } +func TestDaemon_LeaveNetwork(t *testing.T) { + t.Run("success", func(t *testing.T) { + var ( + networkA = network.NewMockNetwork(t) + creationParamsA = bootstrap.NewCreationParams("AAA", "a.com") + h = newHarness(t, &harnessOpts{ + expectNetworksLoaded: []expectNetworkLoad{{ + creationParamsA, nil, networkA, + }}, + expectStoredConfigs: map[string]daecommon.NetworkConfig{ + creationParamsA.ID: daecommon.NewNetworkConfig(nil), + }, + }) + ) + + h.networkLoader. + On("Leave", toolkit.MockArg[context.Context](), creationParamsA). + Return(nil). + Once() + + ctx := WithNetwork(h.ctx, "AAA") + assert.NoError(t, h.daemon.LeaveNetwork(ctx)) + + joinedCreationParams, err := h.daemon.GetNetworks(h.ctx) + assert.NoError(t, err) + assert.Empty(t, joinedCreationParams) + }) +} + func TestDaemon_SetConfig(t *testing.T) { t.Run("success", func(t *testing.T) { var ( diff --git a/go/daemon/network/bootstrap.go b/go/daemon/network/bootstrap.go index fbbddf0..3fece36 100644 --- a/go/daemon/network/bootstrap.go +++ b/go/daemon/network/bootstrap.go @@ -30,6 +30,25 @@ func writeBootstrapToStateDir( return nil } +func loadBootstrapFromStateDir( + stateDirPath string, +) ( + bootstrap.Bootstrap, error, +) { + var ( + currBootstrap bootstrap.Bootstrap + bootstrapFilePath = bootstrap.StateDirPath(stateDirPath) + ) + + if err := jsonutil.LoadFile(&currBootstrap, bootstrapFilePath); err != nil { + return bootstrap.Bootstrap{}, fmt.Errorf( + "loading bootstrap from %q: %w", bootstrapFilePath, err, + ) + } + + return currBootstrap, nil +} + func coalesceNetworkConfigAndBootstrap( networkConfig daecommon.NetworkConfig, hostBootstrap bootstrap.Bootstrap, ) ( diff --git a/go/daemon/network/loader.go b/go/daemon/network/loader.go index 7575063..d23669e 100644 --- a/go/daemon/network/loader.go +++ b/go/daemon/network/loader.go @@ -12,6 +12,9 @@ import ( "isle/nebula" "isle/toolkit" "os" + "path/filepath" + "strings" + "time" "dev.mediocregopher.com/mediocre-go-lib.git/mlog" ) @@ -132,6 +135,9 @@ type Loader interface { ) ( Network, error, ) + + // Leave marks a previously loadable Network as being no longer loadable. + Leave(context.Context, bootstrap.CreationParams) error } // LoaderOpts are optional parameters which can be passed in when initializing a @@ -140,7 +146,8 @@ type LoaderOpts struct { // Defaults to that returned by daecommon.GetEnvVars. EnvVars daecommon.EnvVars - constructors constructors // defaults to newConstructors() + constructors constructors // defaults to newConstructors() + nowFunc func() time.Time // defaults to time.Now } func (o *LoaderOpts) withDefaults() *LoaderOpts { @@ -156,6 +163,10 @@ func (o *LoaderOpts) withDefaults() *LoaderOpts { o.constructors = newConstructors() } + if o.nowFunc == nil { + o.nowFunc = time.Now + } + return o } @@ -226,6 +237,10 @@ func (l *loader) Loadable( creationParams := make([]bootstrap.CreationParams, 0, len(networkStateDirs)) for _, networkStateDir := range networkStateDirs { + if n := filepath.Base(networkStateDir.Path); strings.HasPrefix(n, ".") { + continue + } + thisCreationParams, err := loadCreationParams(networkStateDir) if err != nil { return nil, fmt.Errorf( @@ -390,3 +405,50 @@ func (l *loader) Create( return n, nil } + +func (l *loader) Leave( + ctx context.Context, creationParams bootstrap.CreationParams, +) error { + networkID := creationParams.ID + + if isJoined, err := l.isJoined(ctx, networkID); err != nil { + return fmt.Errorf("checking if network is already joined: %w", err) + } else if !isJoined { + return errors.New("network is not yet joined") + } + + networkStateDir, networkRuntimeDir, err := networkDirs( + l.networksStateDir, l.networksRuntimeDir, networkID, true, + ) + if err != nil { + return fmt.Errorf( + "creating sub-directories for network %q: %w", networkID, err, + ) + } + + var ( + newNetworkStateDirName = fmt.Sprintf( + ".%s.%s.bak", + creationParams.ID, + l.opts.nowFunc().UTC().Format("20060102-150405"), + ) + newNetworkStateDirPath = filepath.Join( + l.networksStateDir.Path, + newNetworkStateDirName, + ) + ) + + var errs []error + + if err := os.Rename( + networkStateDir.Path, newNetworkStateDirPath, + ); err != nil { + errs = append(errs, err) + } + + if err := os.RemoveAll(networkRuntimeDir.Path); err != nil { + errs = append(errs, err) + } + + return errors.Join(errs...) +} diff --git a/go/daemon/network/loader_mock.go b/go/daemon/network/loader_mock.go index f88bc75..d0f4597 100644 --- a/go/daemon/network/loader_mock.go +++ b/go/daemon/network/loader_mock.go @@ -80,6 +80,24 @@ func (_m *MockLoader) Join(_a0 context.Context, _a1 *mlog.Logger, _a2 JoiningBoo return r0, r1 } +// Leave provides a mock function with given fields: _a0, _a1 +func (_m *MockLoader) Leave(_a0 context.Context, _a1 bootstrap.CreationParams) error { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for Leave") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, bootstrap.CreationParams) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Load provides a mock function with given fields: _a0, _a1, _a2, _a3 func (_m *MockLoader) Load(_a0 context.Context, _a1 *mlog.Logger, _a2 bootstrap.CreationParams, _a3 *Opts) (Network, error) { ret := _m.Called(_a0, _a1, _a2, _a3) diff --git a/go/daemon/network/loader_test.go b/go/daemon/network/loader_test.go index bb1371b..9a05d27 100644 --- a/go/daemon/network/loader_test.go +++ b/go/daemon/network/loader_test.go @@ -3,6 +3,7 @@ package network import ( "context" "errors" + "fmt" "io/fs" "isle/bootstrap" "isle/daemon/children" @@ -12,6 +13,7 @@ import ( "os" "path/filepath" "testing" + "time" "dev.mediocregopher.com/mediocre-go-lib.git/mlog" "github.com/stretchr/testify/assert" @@ -40,6 +42,7 @@ func newLoaderHarness(t *testing.T) *loaderHarness { stateDir, _ = rootDir.MkChildDir("state", false) runtimeDir, _ = rootDir.MkChildDir("runtime", false) constructors = newMockConstructors(t) + now = time.Date(2024, 12, 17, 16, 15, 14, 0, time.UTC) ) loader, err := NewLoader( @@ -49,6 +52,7 @@ func newLoaderHarness(t *testing.T) *loaderHarness { RuntimeDir: runtimeDir, }, constructors: constructors, + nowFunc: func() time.Time { return now }, }, ) require.NoError(t, err) @@ -159,6 +163,16 @@ func TestLoader_Loadable(t *testing.T) { assert.NoError(t, err) assert.ElementsMatch(t, allCreationParams, got) }) + + t.Run("after Leave", func(t *testing.T) { + h := newLoaderHarness(t) + h.join(t, allCreationParams[0]) + h.join(t, allCreationParams[1]) + assert.NoError(t, h.loader.Leave(h.ctx, allCreationParams[1])) + got, err := h.loader.Loadable(h.ctx) + assert.NoError(t, err) + assert.Equal(t, allCreationParams[:1], got) + }) } func TestLoader_Load(t *testing.T) { @@ -387,3 +401,35 @@ func TestLoader_Create(t *testing.T) { h.assertDirExists(t, false, networkRuntimeDirPath) }) } + +func TestLoader_Leave(t *testing.T) { + var ( + creationParams = bootstrap.NewCreationParams("AAA", "a.com") + ) + + t.Run("success", func(t *testing.T) { + var ( + h = newLoaderHarness(t) + networkStateDirPath = h.networkStateDirPath(creationParams.ID) + networkRuntimeDirPath = h.networkRuntimeDirPath(creationParams.ID) + ) + + h.join(t, creationParams) + + assert.NoError(t, h.loader.Leave(h.ctx, creationParams)) + h.assertDirExists(t, false, networkStateDirPath) + h.assertDirExists(t, false, networkRuntimeDirPath) + + wantStateDirRenamedTo := h.networkStateDirPath(fmt.Sprintf( + ".%s.20241217-161514.bak", + creationParams.ID, + )) + t.Logf("wantStateDirRenamedTo:%q", wantStateDirRenamedTo) + h.assertDirExists(t, true, wantStateDirRenamedTo) + + // Make sure the data inside the state directory was actually preserved. + bootstrap, err := loadBootstrapFromStateDir(wantStateDirRenamedTo) + assert.NoError(t, err) + assert.Equal(t, creationParams, bootstrap.NetworkCreationParams) + }) +} diff --git a/go/daemon/network/network.go b/go/daemon/network/network.go index de3a077..ecb5496 100644 --- a/go/daemon/network/network.go +++ b/go/daemon/network/network.go @@ -288,16 +288,12 @@ func (constructorsImpl) load( return nil, fmt.Errorf("instantiating Network: %w", err) } - var ( - currBootstrap bootstrap.Bootstrap - bootstrapFilePath = bootstrap.StateDirPath(n.stateDir.Path) - ) + currBootstrap, err := loadBootstrapFromStateDir(n.stateDir.Path) + if err != nil { + return nil, fmt.Errorf("loading bootstrap from state dir: %w", err) + } - if err := jsonutil.LoadFile(&currBootstrap, bootstrapFilePath); err != nil { - return nil, fmt.Errorf( - "loading bootstrap from %q: %w", bootstrapFilePath, err, - ) - } else if err := n.initialize(ctx, currBootstrap, false); err != nil { + if err := n.initialize(ctx, currBootstrap, false); err != nil { return nil, fmt.Errorf("initializing with bootstrap: %w", err) } diff --git a/go/daemon/rpc.go b/go/daemon/rpc.go index c927d94..a6b9d9a 100644 --- a/go/daemon/rpc.go +++ b/go/daemon/rpc.go @@ -43,15 +43,10 @@ type RPC interface { JoinNetwork(context.Context, network.JoiningBootstrap) error + LeaveNetwork(context.Context) error + GetNetworks(context.Context) ([]bootstrap.CreationParams, error) - // SetConfig extends the [network.RPC] method of the same name such that - // [ErrManagedNetworkConfig] is returned if the picked network is - // configured as part of the [daecommon.Config] which the Daemon was - // initialized with. - // - // See the `network.RPC` documentation in this interface for more usage - // details. SetConfig(context.Context, daecommon.NetworkConfig) error // All network.RPC methods are automatically implemented by Daemon using the diff --git a/go/daemon/rpc_mock.go b/go/daemon/rpc_mock.go index 6772aec..5900042 100644 --- a/go/daemon/rpc_mock.go +++ b/go/daemon/rpc_mock.go @@ -226,6 +226,24 @@ func (_m *MockRPC) JoinNetwork(_a0 context.Context, _a1 network.JoiningBootstrap return r0 } +// LeaveNetwork provides a mock function with given fields: _a0 +func (_m *MockRPC) LeaveNetwork(_a0 context.Context) error { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for LeaveNetwork") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // RemoveHost provides a mock function with given fields: ctx, hostName func (_m *MockRPC) RemoveHost(ctx context.Context, hostName nebula.HostName) error { ret := _m.Called(ctx, hostName) diff --git a/tasks/v0.0.3/network-leave.md b/tasks/v0.0.3/network-leave.md deleted file mode 100644 index 0a306b2..0000000 --- a/tasks/v0.0.3/network-leave.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -type: task ---- - -# Implement `network leave` sub-command - -When leaving a network the daemon should remove the now-defunct state-directory, -or at least mark it as inactive in some way.