mirror of
https://github.com/cirruslabs/orchard.git
synced 2026-09-30 12:01:15 +02:00
* Support Tart Guest Agent transport for VM startup scripts * TestTartGuestAgentStartupScript: make ScriptContent more readable * Align Tart Guest Agent's startup script execution with SSH * Use mvdan.cc/sh/v3/syntax to validate "export" shell commands * Use Bash instead of Zsh on Linux * Invert logic when validating vm.StartupScript.Transport for simplicity * Document startup_script's transport field in OpenAPI specification * Prevent unbounded buffering of startup script output * grpc.WaitForReady(true) → retry.DoWithData()
160 lines
4.0 KiB
Go
160 lines
4.0 KiB
Go
package base
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
|
|
"github.com/avast/retry-go/v4"
|
|
"github.com/cirruslabs/orchard/internal/worker/socketalias"
|
|
v1 "github.com/cirruslabs/orchard/pkg/resource/v1"
|
|
guestagent "github.com/cirruslabs/tart-guest-agent/pkg/v1"
|
|
"github.com/dustin/go-humanize"
|
|
"github.com/samber/lo"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
)
|
|
|
|
func (vm *VM) shellTartGuestAgent(ctx context.Context, script string, consumeLine func(string)) error {
|
|
path, err := vm.onDiskName.ControlSocketPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
conn, err := grpc.NewClient("passthrough:///tart-guest-agent",
|
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
|
return socketalias.DialContext(ctx, path)
|
|
}),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create Tart Guest Agent client: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
stream, err := retry.DoWithData(func() (guestagent.Agent_ExecClient, error) {
|
|
return guestagent.NewAgentClient(conn).Exec(ctx)
|
|
}, retry.Context(ctx), retry.OnRetry(func(n uint, err error) {
|
|
consumeLine(fmt.Sprintf("attempt %d to open Tart Guest Agent execution stream failed: %v", n, err))
|
|
}))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open Tart Guest Agent execution stream: %w", err)
|
|
}
|
|
|
|
// Start the shell process
|
|
if err := stream.Send(&guestagent.ExecRequest{
|
|
Type: &guestagent.ExecRequest_Command_{
|
|
Command: &guestagent.ExecRequest_Command{
|
|
Name: lo.Ternary(vm.os == v1.OSDarwin, "/bin/zsh", "/bin/bash"),
|
|
Args: []string{"-l"},
|
|
Interactive: true,
|
|
},
|
|
},
|
|
}); err != nil {
|
|
return fmt.Errorf("failed to send Tart Guest Agent command: %w", err)
|
|
}
|
|
|
|
// Feed it our startup script
|
|
if err := stream.Send(&guestagent.ExecRequest{
|
|
Type: &guestagent.ExecRequest_StandardInput{
|
|
StandardInput: &guestagent.IOChunk{
|
|
Data: []byte(script),
|
|
},
|
|
},
|
|
}); err != nil {
|
|
return fmt.Errorf("failed to send Tart Guest Agent script: %w", err)
|
|
}
|
|
|
|
if err := stream.CloseSend(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Wait for the shell process to finish,
|
|
// retrieving its outputs and exit code
|
|
stdout := newScriptOutput(consumeLine)
|
|
stderr := newScriptOutput(consumeLine)
|
|
defer func() {
|
|
stdout.flush()
|
|
stderr.flush()
|
|
}()
|
|
|
|
for {
|
|
response, err := stream.Recv()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to receive Tart Guest Agent output: %w", err)
|
|
}
|
|
|
|
switch result := response.GetType().(type) {
|
|
case *guestagent.ExecResponse_StandardOutput:
|
|
stdout.write(result.StandardOutput.GetData())
|
|
case *guestagent.ExecResponse_StandardError:
|
|
stderr.write(result.StandardError.GetData())
|
|
case *guestagent.ExecResponse_Exit_:
|
|
if result.Exit.GetCode() != 0 {
|
|
return fmt.Errorf("process exited with status %d", result.Exit.GetCode())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
const (
|
|
scriptOutputBufferSize = 128 * humanize.KiByte
|
|
)
|
|
|
|
type scriptOutput struct {
|
|
consumeLine func(string)
|
|
pending *bytes.Buffer
|
|
stopped bool
|
|
}
|
|
|
|
func newScriptOutput(consumeLine func(string)) *scriptOutput {
|
|
return &scriptOutput{
|
|
consumeLine: consumeLine,
|
|
pending: bytes.NewBuffer(make([]byte, 0, scriptOutputBufferSize)),
|
|
}
|
|
}
|
|
|
|
func (output *scriptOutput) write(data []byte) {
|
|
if output.stopped {
|
|
return
|
|
}
|
|
|
|
for len(data) > 0 {
|
|
// Determine if we have a full line available in the incoming data
|
|
advance, _, _ := bufio.ScanLines(data, false)
|
|
|
|
if advance == 0 {
|
|
// No newline yet; buffer the remainder
|
|
output.pending.Write(data)
|
|
|
|
break
|
|
}
|
|
|
|
// Complete the pending line and emit it
|
|
output.pending.Write(data[:advance])
|
|
output.flush()
|
|
|
|
// Update slice to point to the next data chunk
|
|
data = data[advance:]
|
|
}
|
|
|
|
output.stopped = output.pending.Len() >= scriptOutputBufferSize
|
|
}
|
|
|
|
func (output *scriptOutput) flush() {
|
|
defer output.pending.Reset()
|
|
|
|
if output.stopped {
|
|
return
|
|
}
|
|
|
|
_, line, _ := bufio.ScanLines(output.pending.Bytes(), true)
|
|
if line != nil {
|
|
output.consumeLine(string(line))
|
|
}
|
|
}
|