Add multi-network support to daemon

It's still not possible to pick a network from the command-line, so this
is a bit broken, but the daemon component should handle it correctly at
least.
This commit is contained in:
Brian Picciano 2024-09-23 19:04:14 +02:00
parent 6c036d1183
commit 16aca610b4
15 changed files with 294 additions and 92 deletions

View File

@ -12,6 +12,7 @@ import (
"net/netip" "net/netip"
"path/filepath" "path/filepath"
"sort" "sort"
"strings"
"dev.mediocregopher.com/mediocre-go-lib.git/mctx" "dev.mediocregopher.com/mediocre-go-lib.git/mctx"
) )
@ -52,6 +53,42 @@ func (p CreationParams) Annotate(aa mctx.Annotations) {
aa["networkDomain"] = p.Domain aa["networkDomain"] = p.Domain
} }
// Matches returns true if the given string matches some aspect of the
// CreationParams.
func (p CreationParams) Matches(str string) bool {
if strings.HasPrefix(p.ID, str) {
return true
}
if strings.EqualFold(p.Name, str) {
return true
}
if strings.EqualFold(p.Domain, str) {
return true
}
return false
}
// Conflicts returns true if either CreationParams has some parameter which
// overlaps with that of the other.
func (p CreationParams) Conflicts(p2 CreationParams) bool {
if p.ID == p2.ID {
return true
}
if strings.EqualFold(p.Name, p2.Name) {
return true
}
if strings.EqualFold(p.Domain, p2.Domain) {
return true
}
return false
}
// Bootstrap contains all information which is needed by a host daemon to join a // Bootstrap contains all information which is needed by a host daemon to join a
// network on boot. // network on boot.
type Bootstrap struct { type Bootstrap struct {

View File

@ -47,22 +47,15 @@ func pickNetworkConfig(
) ( ) (
daecommon.NetworkConfig, bool, daecommon.NetworkConfig, bool,
) { ) {
if c, ok := daemonConfig.Networks[creationParams.ID]; ok { if len(daemonConfig.Networks) == 1 { // DEPRECATED
if c, ok := daemonConfig.Networks[daecommon.DeprecatedNetworkID]; ok {
return c, true return c, true
} }
if c, ok := daemonConfig.Networks[creationParams.Name]; ok {
return c, true
} }
if c, ok := daemonConfig.Networks[creationParams.Domain]; ok { for searchStr, networkConfig := range daemonConfig.Networks {
return c, true if creationParams.Matches(searchStr) {
} return networkConfig, true
{ // DEPRECATED
c, ok := daemonConfig.Networks[daecommon.DeprecatedNetworkID]
if len(daemonConfig.Networks) == 1 && ok {
return c, true
} }
} }

20
go/daemon/ctx.go Normal file
View File

@ -0,0 +1,20 @@
package daemon
import (
"context"
"isle/daemon/jsonrpc2"
)
const metaKeyNetworkSearchStr = "daemon.networkSearchStr"
// WithNetwork returns the Context so that, when used against a daemon RPC
// endpoint, the endpoint knows which network is being targetted for the call.
// The network can be identified by its ID, name, or domain.
func WithNetwork(ctx context.Context, searchStr string) context.Context {
return jsonrpc2.WithMeta(ctx, metaKeyNetworkSearchStr, searchStr)
}
func getNetworkSearchStr(ctx context.Context) string {
v, _ := jsonrpc2.GetMeta(ctx)[metaKeyNetworkSearchStr].(string)
return v
}

View File

@ -66,7 +66,7 @@ type Daemon struct {
networksRuntimeDir toolkit.Dir networksRuntimeDir toolkit.Dir
l sync.RWMutex l sync.RWMutex
network network.Network networks map[string]network.Network
} }
// New initializes and returns a Daemon. // New initializes and returns a Daemon.
@ -96,6 +96,7 @@ func New(
daemonConfig: daemonConfig, daemonConfig: daemonConfig,
envBinDirPath: envBinDirPath, envBinDirPath: envBinDirPath,
opts: opts, opts: opts,
networks: map[string]network.Network{},
} }
{ {
@ -113,18 +114,14 @@ func New(
} }
} }
loadableNetworks, err := LoadableNetworks(d.networksStateDir) loadableNetworks, err := loadableNetworks(d.networksStateDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("listing loadable networks: %w", err) return nil, fmt.Errorf("listing loadable networks: %w", err)
} }
if len(loadableNetworks) > 1 { for _, creationParams := range loadableNetworks {
return nil, fmt.Errorf( id := creationParams.ID
"more then one loadable Network found: %+v", loadableNetworks, ctx = mctx.WithAnnotator(ctx, creationParams)
)
} else if len(loadableNetworks) == 1 {
id := loadableNetworks[0].ID
ctx = mctx.WithAnnotator(ctx, loadableNetworks[0])
networkStateDir, networkRuntimeDir, err := networkDirs( networkStateDir, networkRuntimeDir, err := networkDirs(
d.networksStateDir, d.networksRuntimeDir, id, true, d.networksStateDir, d.networksRuntimeDir, id, true,
@ -135,9 +132,9 @@ func New(
) )
} }
networkConfig, _ := pickNetworkConfig(daemonConfig, loadableNetworks[0]) networkConfig, _ := pickNetworkConfig(daemonConfig, creationParams)
d.network, err = network.Load( d.networks[id], err = network.Load(
ctx, ctx,
logger.WithNamespace("network"), logger.WithNamespace("network"),
id, id,
@ -187,7 +184,9 @@ func (d *Daemon) CreateNetwork(
d.l.Lock() d.l.Lock()
defer d.l.Unlock() defer d.l.Unlock()
if d.network != nil { if joined, err := alreadyJoined(ctx, d.networks, creationParams); err != nil {
return fmt.Errorf("checking if already joined to network: %w", err)
} else if joined {
return ErrAlreadyJoined return ErrAlreadyJoined
} }
@ -222,7 +221,7 @@ func (d *Daemon) CreateNetwork(
} }
d.logger.Info(ctx, "Network created successfully") d.logger.Info(ctx, "Network created successfully")
d.network = n d.networks[creationParams.ID] = n
return nil return nil
} }
@ -245,7 +244,9 @@ func (d *Daemon) JoinNetwork(
d.l.Lock() d.l.Lock()
defer d.l.Unlock() defer d.l.Unlock()
if d.network != nil { if joined, err := alreadyJoined(ctx, d.networks, creationParams); err != nil {
return fmt.Errorf("checking if already joined to network: %w", err)
} else if joined {
return ErrAlreadyJoined return ErrAlreadyJoined
} }
@ -278,7 +279,7 @@ func (d *Daemon) JoinNetwork(
} }
d.logger.Info(ctx, "Network joined successfully") d.logger.Info(ctx, "Network joined successfully")
d.network = n d.networks[networkID] = n
return nil return nil
} }
@ -292,12 +293,13 @@ func withNetwork[Res any](
d.l.RLock() d.l.RLock()
defer d.l.RUnlock() defer d.l.RUnlock()
if d.network == nil { network, err := pickNetwork(ctx, d.networks, d.networksStateDir)
if err != nil {
var zero Res var zero Res
return zero, ErrNoNetwork return zero, nil
} }
return fn(ctx, d.network) return fn(ctx, network)
} }
// GetHost implements the method for the network.RPC interface. // GetHost implements the method for the network.RPC interface.
@ -417,32 +419,28 @@ func (d *Daemon) Shutdown() error {
d.l.Lock() d.l.Lock()
defer d.l.Unlock() defer d.l.Unlock()
if d.network != nil { var (
return d.network.Shutdown() errCh = make(chan error, len(d.networks))
errs []error
)
for id := range d.networks {
var (
id = id
n = d.networks[id]
)
go func() {
if err := n.Shutdown(); err != nil {
errCh <- fmt.Errorf("shutting down network %q: %w", id, err)
} }
return nil errCh <- nil
}()
//var ( }
// errCh = make(chan error, len(d.networks))
// errs []error for range cap(errCh) {
//) errs = append(errs, <-errCh)
}
//for id := range d.networks {
// id := id return errors.Join(errs...)
// n := d.networks[id]
// go func() {
// if err := n.Shutdown(); err != nil {
// errCh <- fmt.Errorf("shutting down network %q: %w", id, err)
// }
// errCh <- nil
// }()
//}
//for range cap(errCh) {
// if err := <-errCh; err != nil {
// errs = append(errs, err)
// }
//}
//return errors.Join(errs...)
} }

View File

@ -8,6 +8,8 @@ import (
const ( const (
errCodeNoNetwork = daecommon.ErrorCodeRangeDaemon + iota errCodeNoNetwork = daecommon.ErrorCodeRangeDaemon + iota
errCodeAlreadyJoined errCodeAlreadyJoined
errCodeNoMatchingNetworks
errCodeMultipleMatchingNetworks
) )
var ( var (
@ -16,6 +18,19 @@ var (
ErrNoNetwork = jsonrpc2.NewError(errCodeNoNetwork, "No network configured") ErrNoNetwork = jsonrpc2.NewError(errCodeNoNetwork, "No network configured")
// ErrAlreadyJoined is returned when the daemon is instructed to create or // ErrAlreadyJoined is returned when the daemon is instructed to create or
// join a new network, but it is already joined to a network. // join a new network, but it is already joined to that network.
ErrAlreadyJoined = jsonrpc2.NewError(errCodeAlreadyJoined, "Already joined to a network") ErrAlreadyJoined = jsonrpc2.NewError(errCodeAlreadyJoined, "Already joined to a network")
// ErrNoMatchingNetworks is returned if the search string didn't match any
// networks.
ErrNoMatchingNetworks = jsonrpc2.NewError(
errCodeNoMatchingNetworks, "No networks matched the search string",
)
// ErrMultipleMatchingNetworks is returned if the search string matched
// multiple networks.
ErrMultipleMatchingNetworks = jsonrpc2.NewError(
errCodeMultipleMatchingNetworks,
"Multiple networks matched the search string",
)
) )

View File

@ -13,7 +13,11 @@ type Client interface {
// receiver pointer, unless it is nil in which case the result will be // receiver pointer, unless it is nil in which case the result will be
// discarded. // discarded.
// //
// If the Context was produced using WithMeta then that metadata will be
// carried with the request to the server via the Meta field of the
// RequestParams.
//
// If an error result is returned from the server that will be returned as // If an error result is returned from the server that will be returned as
// an Error struct. // an Error struct.
Call(ctx context.Context, rcv any, method string, params ...any) error Call(ctx context.Context, rcv any, method string, args ...any) error
} }

View File

@ -49,14 +49,14 @@ func NewUnixHTTPClient(unixSocketPath, reqPath string) Client {
} }
func (c *httpClient) Call( func (c *httpClient) Call(
ctx context.Context, rcv any, method string, params ...any, ctx context.Context, rcv any, method string, args ...any,
) error { ) error {
var ( var (
body = new(bytes.Buffer) body = new(bytes.Buffer)
enc = json.NewEncoder(body) enc = json.NewEncoder(body)
) )
id, err := encodeRequest(enc, method, params) id, err := encodeRequest(ctx, enc, method, args)
if err != nil { if err != nil {
return fmt.Errorf("encoding request: %w", err) return fmt.Errorf("encoding request: %w", err)
} }

View File

@ -19,9 +19,9 @@ func NewReadWriterClient(rw io.ReadWriter) Client {
} }
func (c rwClient) Call( func (c rwClient) Call(
ctx context.Context, rcv any, method string, params ...any, ctx context.Context, rcv any, method string, args ...any,
) error { ) error {
id, err := encodeRequest(c.enc, method, params) id, err := encodeRequest(ctx, c.enc, method, args)
if err != nil { if err != nil {
return fmt.Errorf("encoding request: %w", err) return fmt.Errorf("encoding request: %w", err)
} }

View File

@ -18,19 +18,21 @@ type methodDispatchFunc func(context.Context, Request) (any, error)
func newMethodDispatchFunc( func newMethodDispatchFunc(
method reflect.Value, method reflect.Value,
) methodDispatchFunc { ) methodDispatchFunc {
paramTs := make([]reflect.Type, method.Type().NumIn()-1) argTs := make([]reflect.Type, method.Type().NumIn()-1)
for i := range paramTs { for i := range argTs {
paramTs[i] = method.Type().In(i + 1) argTs[i] = method.Type().In(i + 1)
} }
return func(ctx context.Context, req Request) (any, error) { return func(ctx context.Context, req Request) (any, error) {
callVals := make([]reflect.Value, 0, len(paramTs)+1) ctx = context.WithValue(ctx, ctxKeyMeta(0), req.Params.Meta)
callVals := make([]reflect.Value, 0, len(argTs)+1)
callVals = append(callVals, reflect.ValueOf(ctx)) callVals = append(callVals, reflect.ValueOf(ctx))
for i, paramT := range paramTs { for i, argT := range argTs {
paramPtrV := reflect.New(paramT) argPtrV := reflect.New(argT)
err := json.Unmarshal(req.Params[i], paramPtrV.Interface()) err := json.Unmarshal(req.Params.Args[i], argPtrV.Interface())
if err != nil { if err != nil {
// The JSON has already been validated, so this is not an // The JSON has already been validated, so this is not an
// errCodeParse situation. We assume it's an invalid param then, // errCodeParse situation. We assume it's an invalid param then,
@ -38,13 +40,13 @@ func newMethodDispatchFunc(
// returning an Error of its own. // returning an Error of its own.
if !errors.As(err, new(Error)) { if !errors.As(err, new(Error)) {
err = NewInvalidParamsError( err = NewInvalidParamsError(
"JSON unmarshaling param %d into %T: %v", i, paramT, err, "JSON unmarshaling arg %d into %T: %v", i, argT, err,
) )
} }
return nil, err return nil, err
} }
callVals = append(callVals, paramPtrV.Elem()) callVals = append(callVals, argPtrV.Elem())
} }
var ( var (
@ -82,7 +84,8 @@ type dispatcher struct {
// MethodName(context.Context, ...ParamType) (ResponseType, error) // MethodName(context.Context, ...ParamType) (ResponseType, error)
// MethodName(context.Context, ...ParamType) error // MethodName(context.Context, ...ParamType) error
// //
// will be available via RPC calls. // will be available via RPC calls. Any Meta data in the request can be obtained
// within the method handler by calling GetMeta on the method's Context.
func NewDispatchHandler(i any) Handler { func NewDispatchHandler(i any) Handler {
v := reflect.ValueOf(i) v := reflect.ValueOf(i)
if v.Kind() != reflect.Pointer { if v.Kind() != reflect.Pointer {

View File

@ -57,15 +57,16 @@ func NewMLogMiddleware(logger *mlog.Logger) Middleware {
ctx, ctx,
"rpcRequestID", req.ID, "rpcRequestID", req.ID,
"rpcRequestMethod", req.Method, "rpcRequestMethod", req.Method,
"rpcRequestMeta", req.Params.Meta,
) )
if logger.MaxLevel() >= mlog.LevelDebug.Int() { if logger.MaxLevel() >= mlog.LevelDebug.Int() {
ctx := ctx ctx := ctx
for i := range req.Params { for i := range req.Params.Args {
ctx = mctx.Annotate( ctx = mctx.Annotate(
ctx, ctx,
fmt.Sprintf("rpcRequestParam%d", i), fmt.Sprintf("rpcRequestArgs%d", i),
string(req.Params[i]), string(req.Params.Args[i]),
) )
} }
logger.Debug(ctx, "Handling RPC request") logger.Debug(ctx, "Handling RPC request")

View File

@ -3,6 +3,7 @@
package jsonrpc2 package jsonrpc2
import ( import (
"context"
"crypto/rand" "crypto/rand"
"encoding/json" "encoding/json"
"fmt" "fmt"
@ -11,11 +12,19 @@ import (
const version = "2.0" const version = "2.0"
// RequestParams are the parameters passed in a Request. Meta contains
// information that is not directly related to what is being requested, while
// Args are the request's actual arguments.
type RequestParams struct {
Meta map[string]any `json:"meta,omitempty"`
Args []json.RawMessage `json:"args,omitempty"`
}
// Request encodes an RPC request according to the spec. // Request encodes an RPC request according to the spec.
type Request struct { type Request struct {
Version string `json:"jsonrpc"` // must be "2.0" Version string `json:"jsonrpc"` // must be "2.0"
Method string `json:"method"` Method string `json:"method"`
Params []json.RawMessage `json:"params,omitempty"` Params RequestParams `json:"params,omitempty"`
ID string `json:"id"` ID string `json:"id"`
} }
@ -37,18 +46,18 @@ func newID() string {
// encodeRequest writes a request to an io.Writer, returning the ID of the // encodeRequest writes a request to an io.Writer, returning the ID of the
// request. // request.
func encodeRequest( func encodeRequest(
enc *json.Encoder, method string, params []any, ctx context.Context, enc *json.Encoder, method string, args []any,
) ( ) (
string, error, string, error,
) { ) {
var ( var (
paramsBs = make([]json.RawMessage, len(params)) argsBs = make([]json.RawMessage, len(args))
err error err error
) )
for i := range params { for i := range args {
paramsBs[i], err = json.Marshal(params[i]) argsBs[i], err = json.Marshal(args[i])
if err != nil { if err != nil {
return "", fmt.Errorf("encoding param %d as JSON: %w", i, err) return "", fmt.Errorf("encoding arg %d as JSON: %w", i, err)
} }
} }
@ -57,7 +66,10 @@ func encodeRequest(
reqEnvelope = Request{ reqEnvelope = Request{
Version: version, Version: version,
Method: method, Method: method,
Params: paramsBs, Params: RequestParams{
Meta: GetMeta(ctx),
Args: argsBs,
},
ID: id, ID: id,
} }
) )

View File

@ -48,6 +48,16 @@ func (i dividerImpl) Noop(ctx context.Context) error {
return nil return nil
} }
func (i dividerImpl) Divide2FromMeta(ctx context.Context) (int, error) {
var (
meta = GetMeta(ctx)
top = int(meta["top"].(float64))
bottom = int(meta["bottom"].(float64))
)
return i.Divide2(ctx, top, bottom)
}
func (dividerImpl) Hidden(ctx context.Context, p struct{}) (int, error) { func (dividerImpl) Hidden(ctx context.Context, p struct{}) (int, error) {
return 0, errors.New("Shouldn't be possible to call this!") return 0, errors.New("Shouldn't be possible to call this!")
} }
@ -57,6 +67,7 @@ type divider interface {
Divide(ctx context.Context, p DivideParams) (int, error) Divide(ctx context.Context, p DivideParams) (int, error)
One(ctx context.Context) (int, error) One(ctx context.Context) (int, error)
Noop(ctx context.Context) error Noop(ctx context.Context) error
Divide2FromMeta(ctx context.Context) (int, error)
} }
var testHandler = func() Handler { var testHandler = func() Handler {
@ -124,6 +135,19 @@ func testClient(t *testing.T, client Client) {
} }
}) })
t.Run("success/meta", func(t *testing.T) {
ctx = WithMeta(ctx, "top", 6)
ctx = WithMeta(ctx, "bottom", 2)
var res int
err := client.Call(ctx, &res, "Divide2FromMeta")
if err != nil {
t.Fatal(err)
} else if res != 3 {
t.Fatalf("expected 2, got %d", res)
}
})
t.Run("err/application", func(t *testing.T) { t.Run("err/application", func(t *testing.T) {
err := client.Call(ctx, nil, "Divide", DivideParams{}) err := client.Call(ctx, nil, "Divide", DivideParams{})
if !errors.Is(err, ErrDivideByZero) { if !errors.Is(err, ErrDivideByZero) {

View File

@ -0,0 +1,30 @@
package jsonrpc2
import (
"context"
"maps"
)
type ctxKeyMeta int
// WithMeta returns a Context where the given key will be set to the given value
// in the Meta field of all JSONRPC2 requests made using Clients from this
// package.
func WithMeta(ctx context.Context, key string, value any) context.Context {
m, _ := ctx.Value(ctxKeyMeta(0)).(map[string]any)
if m == nil {
m = map[string]any{}
} else {
m = maps.Clone(m)
}
m[key] = value
return context.WithValue(ctx, ctxKeyMeta(0), m)
}
// GetMeta returns all key/values which have been set on the Context using
// WithMeta. This may return nil if WithMeta was never called.
func GetMeta(ctx context.Context) map[string]any {
m, _ := ctx.Value(ctxKeyMeta(0)).(map[string]any)
return m
}

View File

@ -1,6 +1,7 @@
package daemon package daemon
import ( import (
"context"
"fmt" "fmt"
"isle/bootstrap" "isle/bootstrap"
"isle/daemon/network" "isle/daemon/network"
@ -41,9 +42,9 @@ func networkDirs(
return return
} }
// LoadableNetworks returns the CreationParams for each Network which is able to // loadableNetworks returns the CreationParams for each Network which is able to
// be loaded. // be loaded.
func LoadableNetworks( func loadableNetworks(
networksStateDir toolkit.Dir, networksStateDir toolkit.Dir,
) ( ) (
[]bootstrap.CreationParams, error, []bootstrap.CreationParams, error,
@ -71,3 +72,60 @@ func LoadableNetworks(
return creationParams, nil return creationParams, nil
} }
func pickNetwork(
ctx context.Context,
networks map[string]network.Network,
networksStateDir toolkit.Dir,
) (
network.Network, error,
) {
if len(networks) == 0 {
return nil, ErrNoNetwork
}
creationParams, err := loadableNetworks(networksStateDir)
if err != nil {
return nil, fmt.Errorf("getting loadable networks: %w", err)
}
var (
networkSearchStr = getNetworkSearchStr(ctx)
matchingNetworkIDs = make([]string, 0, len(networks))
)
for _, creationParam := range creationParams {
if networkSearchStr == "" || creationParam.Matches(networkSearchStr) {
matchingNetworkIDs = append(matchingNetworkIDs, creationParam.ID)
}
}
if len(matchingNetworkIDs) == 0 {
return nil, ErrNoMatchingNetworks
} else if len(matchingNetworkIDs) > 1 {
return nil, ErrMultipleMatchingNetworks
}
return networks[matchingNetworkIDs[0]], nil
}
func alreadyJoined(
ctx context.Context,
networks map[string]network.Network,
creationParams bootstrap.CreationParams,
) (
bool, error,
) {
for networkID, network := range networks {
existingCreationParams, err := network.GetNetworkCreationParams(ctx)
if err != nil {
return false, fmt.Errorf(
"getting creation params of network %q: %w", networkID, err,
)
} else if existingCreationParams.Conflicts(creationParams) {
return true, nil
}
}
return false, nil
}

View File

@ -25,6 +25,13 @@ type RPC interface {
// All network.RPC methods are automatically implemented by Daemon using the // All network.RPC methods are automatically implemented by Daemon using the
// currently joined network. If no network is joined then any call to these // currently joined network. If no network is joined then any call to these
// methods will return ErrNoNetwork. // methods will return ErrNoNetwork.
//
// All calls to these methods must be accompanied with a context produced by
// WithNetwork, in order to choose the network. These methods may return
// these errors, in addition to those documented on the individual methods:
//
// - ErrNoMatchingNetworks
// - ErrMultipleMatchingNetworks
network.RPC network.RPC
} }