From 9b22d600a8c7b56b236cb9d068bccb61f68509ec Mon Sep 17 00:00:00 2001 From: John McBride Date: Wed, 4 Mar 2026 15:32:18 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20"mb=20apply"=20for=20applyi?= =?UTF-8?q?ng=20agent=20tomls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: John McBride --- cmd/apply/apply.go | 94 +++++++++++++++++++++++++++++ cmd/vmhost/platform_darwin_arm64.go | 4 ++ cmd/vmhost/vmhost.go | 4 ++ main.go | 2 + pkg/daemon/client/client.go | 9 +++ pkg/daemon/daemon.go | 56 +++++++++++++++++ pkg/daemon/rpc.go | 1 + pkg/vm/applevirt.go | 34 +++++++++++ pkg/vm/qemu.go | 26 ++++++++ pkg/vmhost/client.go | 11 ++++ pkg/vmhost/protocol.go | 5 ++ pkg/vmhost/server.go | 24 ++++++++ 12 files changed, 270 insertions(+) create mode 100644 cmd/apply/apply.go diff --git a/cmd/apply/apply.go b/cmd/apply/apply.go new file mode 100644 index 0000000..7efa5ce --- /dev/null +++ b/cmd/apply/apply.go @@ -0,0 +1,94 @@ +// Package applycmder provides the apply command for updating agent +// configuration on a running StereOS sandbox VM. +package applycmder + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/papercomputeco/masterblaster/pkg/daemon/client" + "github.com/papercomputeco/masterblaster/pkg/ui" +) + +const applyLongDesc string = `Apply updated agent configuration to a running StereOS sandbox. + +Reads the jcard.toml (or the file given with -f/--config), sends the +[[agents]] configuration and [secrets] to the sandbox via stereosd, and +agentd reconciles agents within ~5 seconds: starting new agents, +stopping removed ones, and restarting any whose config changed. + +The sandbox must already be running (use 'mb up' to create one first). + +Examples: + mb apply + mb apply -f ./jcard.toml + mb apply -f ./agents-only.toml --name my-sandbox` + +const applyShortDesc string = "Apply agent configuration to a running sandbox" + +// NewApplyCmd creates the apply command. +func NewApplyCmd(configDirFn func() string) *cobra.Command { + var ( + cfgPath string + name string + ) + + cmd := &cobra.Command{ + Use: "apply", + Short: applyShortDesc, + Long: applyLongDesc, + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return runApply(configDirFn(), cfgPath, name) + }, + } + + cmd.Flags().StringVarP(&cfgPath, "config", "f", "", "Path to jcard.toml (default: ./jcard.toml)") + cmd.Flags().StringVar(&name, "name", "", "Target sandbox name (default: name from config)") + + return cmd +} + +func runApply(baseDir, cfgPath, name string) error { + // Resolve config path + if cfgPath == "" { + cwd, err := os.Getwd() + if err != nil { + return err + } + cfgPath = filepath.Join(cwd, "jcard.toml") + } + + var err error + cfgPath, err = filepath.Abs(cfgPath) + if err != nil { + return fmt.Errorf("resolving config path: %w", err) + } + + if _, err := os.Stat(cfgPath); err != nil { + return fmt.Errorf("config not found at %s\n\nCreate one with: mb init", cfgPath) + } + + if err := client.EnsureDaemon(baseDir); err != nil { + return err + } + + c := client.New(baseDir) + if err := ui.Step(os.Stderr, "Applying configuration...", func() error { + _, err := c.Apply(name, cfgPath) + return err + }); err != nil { + return err + } + + fmt.Fprintln(os.Stderr) + ui.Success("Configuration applied") + fmt.Fprintln(os.Stderr) + ui.Info("Agents will converge within ~5 seconds") + ui.Info("Check status with: mb status") + + return nil +} diff --git a/cmd/vmhost/platform_darwin_arm64.go b/cmd/vmhost/platform_darwin_arm64.go index 8c5c375..c7ce726 100644 --- a/cmd/vmhost/platform_darwin_arm64.go +++ b/cmd/vmhost/platform_darwin_arm64.go @@ -39,6 +39,10 @@ func (c *appleVirtController) Backend() string { return "applevirt" } +func (c *appleVirtController) Apply(ctx context.Context, configContent string, secrets map[string]string) error { + return c.backend.ControlPlaneApply(ctx, c.inst.Name, configContent, secrets) +} + func (c *appleVirtController) Wait() error { return c.backend.WaitVM(c.inst.Name) } diff --git a/cmd/vmhost/vmhost.go b/cmd/vmhost/vmhost.go index 958cb5e..4a624a0 100644 --- a/cmd/vmhost/vmhost.go +++ b/cmd/vmhost/vmhost.go @@ -133,6 +133,10 @@ func (c *qemuController) Backend() string { return "qemu" } +func (c *qemuController) Apply(ctx context.Context, configContent string, secrets map[string]string) error { + return c.backend.ControlPlaneApply(ctx, c.inst, configContent, secrets) +} + func (c *qemuController) Wait() error { return c.backend.WaitQEMU() } diff --git a/main.go b/main.go index 3d10817..cc64aa0 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "github.com/papercomputeco/masterblaster/pkg/ui" + applycmder "github.com/papercomputeco/masterblaster/cmd/apply" destroycmder "github.com/papercomputeco/masterblaster/cmd/destroy" downcmder "github.com/papercomputeco/masterblaster/cmd/down" initcmder "github.com/papercomputeco/masterblaster/cmd/init" @@ -49,6 +50,7 @@ func NewMbCmd() *cobra.Command { cmd.AddCommand(servecmder.NewServeCmd(mbconfig.ConfigDir)) cmd.AddCommand(initcmder.NewInitCmd()) cmd.AddCommand(upcmder.NewUpCmd(mbconfig.ConfigDir)) + cmd.AddCommand(applycmder.NewApplyCmd(mbconfig.ConfigDir)) cmd.AddCommand(downcmder.NewDownCmd(mbconfig.ConfigDir)) cmd.AddCommand(statuscmder.NewStatusCmd(mbconfig.ConfigDir)) cmd.AddCommand(destroycmder.NewDestroyCmd(mbconfig.ConfigDir)) diff --git a/pkg/daemon/client/client.go b/pkg/daemon/client/client.go index 3d132aa..29da738 100644 --- a/pkg/daemon/client/client.go +++ b/pkg/daemon/client/client.go @@ -101,6 +101,15 @@ func (c *Client) Destroy(name string) (*daemon.Response, error) { }) } +// Apply sends updated agent configuration to a running sandbox. +func (c *Client) Apply(name, configPath string) (*daemon.Response, error) { + return c.call(&daemon.Request{ + Method: daemon.MethodApply, + Name: name, + ConfigPath: configPath, + }) +} + // List returns all known sandboxes. func (c *Client) List() (*daemon.Response, error) { return c.call(&daemon.Request{Method: daemon.MethodList}) diff --git a/pkg/daemon/daemon.go b/pkg/daemon/daemon.go index 34c5462..be249a8 100644 --- a/pkg/daemon/daemon.go +++ b/pkg/daemon/daemon.go @@ -259,6 +259,9 @@ func (d *Daemon) handleRequest(ctx context.Context, req *Request) Response { case MethodList: return d.handleList(ctx) + case MethodApply: + return d.handleApply(ctx, req) + default: return Response{Error: fmt.Sprintf("unknown method: %s", req.Method)} } @@ -446,6 +449,59 @@ func (d *Daemon) spawnVMHost(_ context.Context, mvm *managedVM, backend string) return fmt.Errorf("vmhost for %q did not become ready within 120s (check %s)", inst.Name, inst.VMHostLogPath()) } +func (d *Daemon) handleApply(_ context.Context, req *Request) Response { + if req.ConfigPath == "" { + return Response{Error: "config_path is required"} + } + + cfg, err := config.Load(req.ConfigPath) + if err != nil { + return Response{Error: fmt.Sprintf("loading config: %v", err)} + } + + // Use name from request or config + name := req.Name + if name == "" { + name = cfg.Name + } + + // Serialize the full config for transmission to the guest + cfgBytes, err := config.Marshal(cfg) + if err != nil { + return Response{Error: fmt.Sprintf("marshaling config: %v", err)} + } + + // Find the running sandbox + mvm, err := d.resolveVM(name) + if err != nil { + return Response{Error: err.Error()} + } + + // Verify the vmhost is alive + if mvm.client == nil || !mvm.client.IsAlive() { + return Response{Error: fmt.Sprintf("sandbox %q is not running", name)} + } + + // Send the config and secrets through the vmhost to stereosd + d.logger.Printf("applying config to sandbox %q (%d bytes, %d secrets)", name, len(cfgBytes), len(cfg.Secrets)) + + if _, err := mvm.client.Apply(string(cfgBytes), cfg.Secrets); err != nil { + return Response{Error: fmt.Sprintf("applying config to sandbox %q: %v", name, err)} + } + + // Update the saved jcard.toml on the host side + if err := os.WriteFile(mvm.inst.JcardPath(), cfgBytes, 0644); err != nil { + d.logger.Printf("warning: failed to update host-side jcard.toml: %v", err) + } + + d.logger.Printf("config applied to sandbox %q", name) + + return Response{ + OK: true, + Sandboxes: []SandboxInfo{d.instanceToInfo(mvm)}, + } +} + func (d *Daemon) handleDown(_ context.Context, req *Request) Response { mvm, err := d.resolveVM(req.Name) if err != nil { diff --git a/pkg/daemon/rpc.go b/pkg/daemon/rpc.go index 0930681..14a5712 100644 --- a/pkg/daemon/rpc.go +++ b/pkg/daemon/rpc.go @@ -14,6 +14,7 @@ const ( MethodDestroy RPCMethod = "destroy" MethodList RPCMethod = "list" MethodPing RPCMethod = "ping" + MethodApply RPCMethod = "apply" ) // Request is the wire format for CLI -> daemon RPC calls. diff --git a/pkg/vm/applevirt.go b/pkg/vm/applevirt.go index 34f507f..c585c74 100644 --- a/pkg/vm/applevirt.go +++ b/pkg/vm/applevirt.go @@ -873,6 +873,40 @@ func newVzSocketTransport(socketDevice *vz.VirtioSocketDevice, port int, label s } } +// ControlPlaneApply sends updated configuration and secrets to stereosd +// via the virtio-socket device. It connects to stereosd, sends the +// serialized jcard.toml via set_config, and re-injects all secrets. +func (a *AppleVirtBackend) ControlPlaneApply(ctx context.Context, name string, configContent string, secrets map[string]string) error { + a.mu.RLock() + avVM := a.live[name] + a.mu.RUnlock() + + if avVM == nil { + return fmt.Errorf("VM %q not found in live map", name) + } + + transport := newVzSocketTransport(avVM.socketDevice, vsock.VsockPort, "Apple Virt apply") + client, err := vsock.Connect(transport, 10*time.Second) + if err != nil { + return fmt.Errorf("connecting to stereosd: %w", err) + } + defer func() { _ = client.Close() }() + + // Send the updated configuration + if err := client.SetConfig(ctx, configContent); err != nil { + return fmt.Errorf("setting guest config: %w", err) + } + + // Re-inject secrets + for sname, value := range secrets { + if err := client.InjectSecret(ctx, sname, value); err != nil { + return fmt.Errorf("injecting secret %q: %w", sname, err) + } + } + + return nil +} + // controlPlaneShutdown sends a shutdown message to stereosd via the // virtio-socket device. func (a *AppleVirtBackend) controlPlaneShutdown(ctx context.Context, socketDevice *vz.VirtioSocketDevice) error { diff --git a/pkg/vm/qemu.go b/pkg/vm/qemu.go index 90f1c1c..8eaceb7 100644 --- a/pkg/vm/qemu.go +++ b/pkg/vm/qemu.go @@ -712,6 +712,32 @@ func (q *QEMUBackend) postBoot(ctx context.Context, inst *Instance, cfg *config. return nil } +// ControlPlaneApply sends updated configuration and secrets to stereosd +// via the control plane transport. It connects to stereosd, sends the +// serialized jcard.toml via set_config, and re-injects all secrets. +func (q *QEMUBackend) ControlPlaneApply(ctx context.Context, inst *Instance, configContent string, secrets map[string]string) error { + transport := q.controlPlaneTransport(inst) + client, err := vsock.Connect(transport, 10*time.Second) + if err != nil { + return fmt.Errorf("connecting to stereosd: %w", err) + } + defer func() { _ = client.Close() }() + + // Send the updated configuration + if err := client.SetConfig(ctx, configContent); err != nil { + return fmt.Errorf("setting guest config: %w", err) + } + + // Re-inject secrets + for name, value := range secrets { + if err := client.InjectSecret(ctx, name, value); err != nil { + return fmt.Errorf("injecting secret %q: %w", name, err) + } + } + + return nil +} + // controlPlaneShutdown sends a shutdown command to stereosd via the // control plane transport. func (q *QEMUBackend) controlPlaneShutdown(ctx context.Context, inst *Instance) error { diff --git a/pkg/vmhost/client.go b/pkg/vmhost/client.go index d7d7eb5..a4f0555 100644 --- a/pkg/vmhost/client.go +++ b/pkg/vmhost/client.go @@ -72,6 +72,17 @@ func (c *Client) Info() (*Response, error) { return c.call(&Request{Method: MethodInfo}) } +// Apply sends updated configuration and secrets to the guest via the +// vmhost process. The vmhost connects to stereosd and sends set_config +// and inject_secret messages. +func (c *Client) Apply(configContent string, secrets map[string]string) (*Response, error) { + return c.call(&Request{ + Method: MethodApply, + ConfigContent: configContent, + Secrets: secrets, + }) +} + // IsAlive checks if the vmhost process is reachable by sending a status // request. Returns true if the vmhost responds, false otherwise. func (c *Client) IsAlive() bool { diff --git a/pkg/vmhost/protocol.go b/pkg/vmhost/protocol.go index 72a2da9..081718e 100644 --- a/pkg/vmhost/protocol.go +++ b/pkg/vmhost/protocol.go @@ -11,6 +11,7 @@ const ( MethodStop Method = "stop" MethodForceStop Method = "force_stop" MethodInfo Method = "info" + MethodApply Method = "apply" ) // Request is the wire format for daemon -> vmhost RPC calls. @@ -19,6 +20,10 @@ type Request struct { // Stop parameters TimeoutSeconds int `json:"timeout_seconds,omitempty"` + + // Apply parameters + ConfigContent string `json:"config_content,omitempty"` // serialized jcard.toml + Secrets map[string]string `json:"secrets,omitempty"` // name -> value } // Response is the wire format for vmhost -> daemon responses. diff --git a/pkg/vmhost/server.go b/pkg/vmhost/server.go index 46a0337..69f11fd 100644 --- a/pkg/vmhost/server.go +++ b/pkg/vmhost/server.go @@ -33,6 +33,11 @@ type VMController interface { // Backend returns the backend name ("qemu" or "applevirt"). Backend() string + // Apply sends updated configuration and secrets to stereosd inside the + // guest. It connects to the guest control plane, sends set_config with + // the serialized jcard.toml content, and re-injects all secrets. + Apply(ctx context.Context, configContent string, secrets map[string]string) error + // Wait blocks until the VM exits. It returns nil on clean exit // or an error if the VM crashed. Wait() error @@ -175,6 +180,9 @@ func (s *Server) handleRequest(ctx context.Context, req *Request) Response { case MethodInfo: return s.handleInfo() + case MethodApply: + return s.handleApply(ctx, req) + default: return Response{Error: fmt.Sprintf("unknown method: %s", req.Method)} } @@ -242,3 +250,19 @@ func (s *Server) handleInfo() Response { Backend: s.controller.Backend(), } } + +func (s *Server) handleApply(ctx context.Context, req *Request) Response { + if req.ConfigContent == "" { + return Response{Error: "config_content is required for apply"} + } + + s.logger.Printf("applying configuration (%d bytes, %d secrets)", len(req.ConfigContent), len(req.Secrets)) + + if err := s.controller.Apply(ctx, req.ConfigContent, req.Secrets); err != nil { + s.logger.Printf("apply failed: %v", err) + return Response{Error: fmt.Sprintf("apply failed: %v", err)} + } + + s.logger.Println("apply completed successfully") + return Response{OK: true, State: s.controller.State()} +}