Add nanoswitch and cluster testing

Adds nanoswitch and the `switched-multi2` launch target to launch two Smalltown instances on a switched
network and enroll them into a single cluster. Nanoswitch contains a Linux bridge and a minimal DHCP server
and connects to the two Smalltown instances over virtual Ethernet cables. Also moves out the DHCP client into
a package since nanoswitch needs it.

Test Plan:
Manually tested using `bazel run //:launch -- switched-multi2` and observing that the second VM
(whose serial port is mapped to stdout) prints that it is enrolled. Also validated by `bazel run //core/cmd/dbg -- kubectl get node -o wide` returning two ready nodes.

X-Origin-Diff: phab/D572
GitOrigin-RevId: 9f6e2b3d8268749dd81588205646ae3976ad14b3
diff --git a/core/internal/node/setup.go b/core/internal/node/setup.go
index cbbfd4d..a9e841c 100644
--- a/core/internal/node/setup.go
+++ b/core/internal/node/setup.go
@@ -18,10 +18,11 @@
 
 import (
 	"context"
-	"errors"
 	"fmt"
+	"os"
 
-	"go.uber.org/zap"
+	"git.monogon.dev/source/nexantic.git/core/internal/storage"
+
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
 
@@ -61,28 +62,34 @@
 
 	return &api.NewNodeInfo{
 		EnrolmentConfig: s.enrolmentConfig,
-		Ip:              []byte(*nodeIP),
+		Ip:              *nodeIP,
 		IdCert:          nodeCert,
 		GlobalUnlockKey: globalUnlockKey,
 	}, nodeID, nil
 }
 
-func (s *SmalltownNode) JoinCluster(context context.Context, req *api.JoinClusterRequest) (*api.JoinClusterResponse, error) {
+func (s *SmalltownNode) JoinCluster(ctx context.Context, req *api.JoinClusterRequest) (*api.JoinClusterResponse, error) {
 	if s.state != common.StateEnrollMode {
 		return nil, ErrNotInJoinMode
 	}
 
 	s.logger.Info("Joining Consenus")
 
+	dataPath, err := s.Storage.GetPathInPlace(storage.PlaceData, "etcd")
+	if err != nil {
+		return nil, status.Errorf(codes.Unavailable, "Data partition not available: %v", err)
+	}
+
+	if err := os.MkdirAll(dataPath, 0600); err != nil {
+		return nil, status.Errorf(codes.Internal, "Cannot create path on data partition: %v", err)
+	}
+
 	config := s.Consensus.GetConfig()
 	config.Name = s.hostname
-	config.InitialCluster = "default" // Clusters can't cross-join anyways due to cryptography
+	config.InitialCluster = req.InitialCluster
+	config.DataDir = dataPath
 	s.Consensus.SetConfig(config)
-	var err error
-	if err != nil {
-		s.logger.Warn("Invalid JoinCluster request", zap.Error(err))
-		return nil, errors.New("invalid join request")
-	}
+
 	if err := s.Consensus.WriteCertificateFiles(req.Certs); err != nil {
 		return nil, err
 	}
@@ -94,6 +101,8 @@
 	}
 
 	s.state = common.StateJoined
+	go s.Containerd.Run()(context.TODO())
+	s.Kubernetes.Start()
 
 	s.logger.Info("Joined cluster. Node is now syncing.")