Implement 'network leave'

This commit is contained in:
Brian Picciano 2024-12-17 16:47:33 +01:00
parent 53a1dc0cc2
commit 279b70124c
12 changed files with 266 additions and 26 deletions

View File

@ -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,
)

View File

@ -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,

View File

@ -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 {

View File

@ -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 (

View File

@ -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,
) (

View File

@ -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...)
}

View File

@ -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)

View File

@ -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)
})
}

View File

@ -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)
}

View File

@ -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

View File

@ -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)

View File

@ -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.