Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions cmd/apply/apply.go
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions cmd/vmhost/platform_darwin_arm64.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 4 additions & 0 deletions cmd/vmhost/vmhost.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand Down
9 changes: 9 additions & 0 deletions pkg/daemon/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
56 changes: 56 additions & 0 deletions pkg/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
}
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions pkg/daemon/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions pkg/vm/applevirt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions pkg/vm/qemu.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions pkg/vmhost/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions pkg/vmhost/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions pkg/vmhost/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)}
}
Expand Down Expand Up @@ -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()}
}