feat: add guest config injection and host nat wiring

This commit is contained in:
Harivansh Rathi 2026-04-08 19:43:20 +00:00
parent 28ca0219d9
commit a12f54ba5d
8 changed files with 332 additions and 6 deletions

View file

@ -20,6 +20,9 @@ func (d *Daemon) CreateMachine(ctx context.Context, req contracthost.CreateMachi
if err := validateArtifactRef(req.Artifact); err != nil {
return nil, err
}
if err := validateGuestConfig(req.GuestConfig); err != nil {
return nil, err
}
unlock := d.lockMachine(req.MachineID)
defer unlock()
@ -62,6 +65,17 @@ func (d *Daemon) CreateMachine(ctx context.Context, req contracthost.CreateMachi
if err := cloneFile(artifact.RootFSPath, systemVolumePath); err != nil {
return nil, err
}
removeSystemVolumeOnFailure := true
defer func() {
if !removeSystemVolumeOnFailure {
return
}
_ = os.Remove(systemVolumePath)
_ = os.RemoveAll(filepath.Dir(systemVolumePath))
}()
if err := injectGuestConfig(ctx, systemVolumePath, req.GuestConfig); err != nil {
return nil, err
}
spec, err := d.buildMachineSpec(req.MachineID, artifact, userVolumes, systemVolumePath)
if err != nil {
@ -140,6 +154,7 @@ func (d *Daemon) CreateMachine(ctx context.Context, req contracthost.CreateMachi
return nil, err
}
removeSystemVolumeOnFailure = false
clearOperation = true
return &contracthost.CreateMachineResponse{Machine: machineToContract(record)}, nil
}

View file

@ -215,6 +215,7 @@ func testConfig(root string) appconfig.Config {
MachineDisksDir: filepath.Join(root, "machine-disks"),
RuntimeDir: filepath.Join(root, "runtime"),
SocketPath: filepath.Join(root, "firecracker-host.sock"),
EgressInterface: "eth0",
FirecrackerBinaryPath: "/usr/bin/firecracker",
JailerBinaryPath: "/usr/bin/jailer",
}

View file

@ -4,11 +4,13 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
@ -179,6 +181,67 @@ func defaultMachinePorts() []contracthost.MachinePort {
}
}
func hasGuestConfig(config *contracthost.GuestConfig) bool {
if config == nil {
return false
}
return len(config.AuthorizedKeys) > 0 || config.LoginWebhook != nil
}
func injectGuestConfig(ctx context.Context, imagePath string, config *contracthost.GuestConfig) error {
if !hasGuestConfig(config) {
return nil
}
stagingDir, err := os.MkdirTemp(filepath.Dir(imagePath), "guest-config-*")
if err != nil {
return fmt.Errorf("create guest config staging dir: %w", err)
}
defer os.RemoveAll(stagingDir)
if len(config.AuthorizedKeys) > 0 {
authorizedKeysPath := filepath.Join(stagingDir, "authorized_keys")
payload := []byte(strings.Join(config.AuthorizedKeys, "\n") + "\n")
if err := os.WriteFile(authorizedKeysPath, payload, 0o600); err != nil {
return fmt.Errorf("write authorized_keys staging file: %w", err)
}
if err := replaceExt4File(ctx, imagePath, authorizedKeysPath, "/etc/microagent/authorized_keys"); err != nil {
return err
}
}
if config.LoginWebhook != nil {
guestConfigPath := filepath.Join(stagingDir, "guest-config.json")
payload, err := json.Marshal(config)
if err != nil {
return fmt.Errorf("marshal guest config: %w", err)
}
if err := os.WriteFile(guestConfigPath, append(payload, '\n'), 0o600); err != nil {
return fmt.Errorf("write guest config staging file: %w", err)
}
if err := replaceExt4File(ctx, imagePath, guestConfigPath, "/etc/microagent/guest-config.json"); err != nil {
return err
}
}
return nil
}
func replaceExt4File(ctx context.Context, imagePath string, sourcePath string, targetPath string) error {
_ = runDebugFS(ctx, imagePath, fmt.Sprintf("rm %s", targetPath))
if err := runDebugFS(ctx, imagePath, fmt.Sprintf("write %s %s", sourcePath, targetPath)); err != nil {
return fmt.Errorf("inject %q into %q: %w", targetPath, imagePath, err)
}
return nil
}
func runDebugFS(ctx context.Context, imagePath string, command string) error {
cmd := exec.CommandContext(ctx, "debugfs", "-w", "-R", command, imagePath)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("debugfs %q on %q: %w: %s", command, imagePath, err, strings.TrimSpace(string(output)))
}
return nil
}
func machineIDPtr(machineID contracthost.MachineID) *contracthost.MachineID {
value := machineID
return &value
@ -229,6 +292,23 @@ func validateArtifactRef(ref contracthost.ArtifactRef) error {
return nil
}
func validateGuestConfig(config *contracthost.GuestConfig) error {
if config == nil {
return nil
}
for i, key := range config.AuthorizedKeys {
if strings.TrimSpace(key) == "" {
return fmt.Errorf("guest_config.authorized_keys[%d] is required", i)
}
}
if config.LoginWebhook != nil {
if err := validateDownloadURL("guest_config.login_webhook.url", config.LoginWebhook.URL); err != nil {
return err
}
}
return nil
}
func validateMachineID(machineID contracthost.MachineID) error {
value := strings.TrimSpace(string(machineID))
if value == "" {

View file

@ -0,0 +1,97 @@
package daemon
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
contracthost "github.com/getcompanion-ai/computer-host/contract"
)
func TestInjectGuestConfigWritesAuthorizedKeysAndWebhook(t *testing.T) {
root := t.TempDir()
imagePath := filepath.Join(root, "rootfs.ext4")
if err := buildTestExt4Image(root, imagePath); err != nil {
t.Fatalf("build ext4 image: %v", err)
}
config := &contracthost.GuestConfig{
AuthorizedKeys: []string{
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGuestKeyOne test-1",
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGuestKeyTwo test-2",
},
LoginWebhook: &contracthost.GuestLoginWebhook{
URL: "https://example.com/login",
BearerToken: "secret-token",
},
}
if err := injectGuestConfig(context.Background(), imagePath, config); err != nil {
t.Fatalf("inject guest config: %v", err)
}
authorizedKeys, err := readExt4File(imagePath, "/etc/microagent/authorized_keys")
if err != nil {
t.Fatalf("read authorized_keys: %v", err)
}
wantKeys := strings.Join(config.AuthorizedKeys, "\n") + "\n"
if authorizedKeys != wantKeys {
t.Fatalf("authorized_keys mismatch: got %q want %q", authorizedKeys, wantKeys)
}
guestConfigPayload, err := readExt4File(imagePath, "/etc/microagent/guest-config.json")
if err != nil {
t.Fatalf("read guest-config.json: %v", err)
}
var guestConfig contracthost.GuestConfig
if err := json.Unmarshal([]byte(guestConfigPayload), &guestConfig); err != nil {
t.Fatalf("unmarshal guest-config.json: %v", err)
}
if guestConfig.LoginWebhook == nil || guestConfig.LoginWebhook.URL != config.LoginWebhook.URL || guestConfig.LoginWebhook.BearerToken != config.LoginWebhook.BearerToken {
t.Fatalf("login webhook mismatch: got %#v want %#v", guestConfig.LoginWebhook, config.LoginWebhook)
}
}
func buildTestExt4Image(root string, imagePath string) error {
sourceDir := filepath.Join(root, "source")
if err := os.MkdirAll(filepath.Join(sourceDir, "etc", "microagent"), 0o755); err != nil {
return err
}
if err := os.WriteFile(imagePath, nil, 0o644); err != nil {
return err
}
command := exec.Command("truncate", "-s", "16M", imagePath)
output, err := command.CombinedOutput()
if err != nil {
return fmt.Errorf("truncate: %w: %s", err, strings.TrimSpace(string(output)))
}
command = exec.Command("mkfs.ext4", "-q", "-d", sourceDir, "-L", "microagent-root", "-F", imagePath)
output, err = command.CombinedOutput()
if err != nil {
return fmt.Errorf("mkfs.ext4: %w: %s", err, strings.TrimSpace(string(output)))
}
return nil
}
func readExt4File(imagePath string, targetPath string) (string, error) {
command := exec.Command("debugfs", "-R", "cat "+targetPath, imagePath)
output, err := command.CombinedOutput()
if err != nil {
return "", fmt.Errorf("debugfs cat %q: %w: %s", targetPath, err, strings.TrimSpace(string(output)))
}
lines := strings.Split(string(output), "\n")
filtered := make([]string, 0, len(lines))
for _, line := range lines {
if strings.HasPrefix(line, "debugfs ") {
continue
}
filtered = append(filtered, line)
}
return strings.TrimPrefix(strings.Join(filtered, "\n"), "\n"), nil
}