feat: add Unix control service and rvc queue path
This commit is contained in:
+285
-2
@@ -1,4 +1,287 @@
|
||||
// Command rvc is the RVBox control-plane command-line client.
|
||||
// Command rvc is the RVBox local control-plane CLI. It never opens the server
|
||||
// database; all state changes and reads go through the Unix gRPC socket.
|
||||
package main
|
||||
|
||||
func main() {}
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||
"github.com/rvbox/rvbox/internal/domain"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
)
|
||||
|
||||
const defaultControlSocket = "/run/rvbox/server.sock"
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "rvc:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string, output, diagnostics io.Writer) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("a command is required (stat or run)")
|
||||
}
|
||||
socket, args, err := globalSocket(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return errors.New("a command is required (stat or run)")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
connection, err := dial(ctx, socket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer connection.Close()
|
||||
client := rvboxv1.NewControlClient(connection)
|
||||
switch args[0] {
|
||||
case "stat":
|
||||
return stat(ctx, client, args[1:], output)
|
||||
case "run":
|
||||
return runCommand(ctx, client, args[1:], output, diagnostics)
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func globalSocket(args []string) (string, []string, error) {
|
||||
socket := defaultControlSocket
|
||||
remaining := make([]string, 0, len(args))
|
||||
for index := 0; index < len(args); index++ {
|
||||
if args[index] == "--socket" {
|
||||
if index+1 >= len(args) {
|
||||
return "", nil, errors.New("--socket requires a path")
|
||||
}
|
||||
socket = args[index+1]
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(args[index], "--socket=") {
|
||||
socket = strings.TrimPrefix(args[index], "--socket=")
|
||||
continue
|
||||
}
|
||||
remaining = append(remaining, args[index])
|
||||
}
|
||||
if socket == "" || !filepath.IsAbs(socket) {
|
||||
return "", nil, errors.New("--socket must be an absolute path")
|
||||
}
|
||||
return socket, remaining, nil
|
||||
}
|
||||
|
||||
func dial(ctx context.Context, socket string) (*grpc.ClientConn, error) {
|
||||
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
|
||||
}
|
||||
return grpc.DialContext(ctx, "passthrough:///rvbox-control", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithContextDialer(dialer), grpc.WithBlock())
|
||||
}
|
||||
|
||||
func stat(ctx context.Context, client rvboxv1.ControlClient, args []string, output io.Writer) error {
|
||||
flags := flag.NewFlagSet("stat", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
all := flags.Bool("all", false, "show all command pages")
|
||||
pageSize := flags.Uint("per-page", 100, "number of records per page")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
positionals := flags.Args()
|
||||
if len(positionals) == 0 {
|
||||
var token string
|
||||
for {
|
||||
response, err := client.ListClients(ctx, &rvboxv1.ListClientsRequest{PageSize: uint32(*pageSize), PageToken: token})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range response.GetClients() {
|
||||
fmt.Fprintf(output, "client %s connected=%t platform=%s running=%d queued=%d\n", item.GetClientId(), item.GetConnected(), item.GetPlatform(), item.GetRunningCommands(), item.GetQueuedCommands())
|
||||
}
|
||||
if !*all || response.GetNextPageToken() == "" {
|
||||
return nil
|
||||
}
|
||||
token = response.GetNextPageToken()
|
||||
}
|
||||
}
|
||||
if len(positionals) > 2 {
|
||||
return errors.New("stat accepts at most CLIENT and ISSUE_UUID")
|
||||
}
|
||||
clientID := positionals[0]
|
||||
if len(positionals) == 1 {
|
||||
response, err := client.GetClient(ctx, &rvboxv1.GetClientRequest{ClientId: clientID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item := response.GetClient()
|
||||
fmt.Fprintf(output, "client %s connected=%t platform=%s running=%d queued=%d instance=%s\n", item.GetClientId(), item.GetConnected(), item.GetPlatform(), item.GetRunningCommands(), item.GetQueuedCommands(), item.GetClientInstanceId())
|
||||
if item.GetPendingInstanceId() != "" {
|
||||
fmt.Fprintf(output, "pending_instance=%s\n", item.GetPendingInstanceId())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
response, err := client.GetCommand(ctx, &rvboxv1.GetCommandRequest{ClientId: clientID, IssueUuid: positionals[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item := response.GetCommand()
|
||||
fmt.Fprintf(output, "command %s client=%s lifecycle=%s revision=%d events=%d\n", item.GetIssueUuid(), item.GetTargetClientId(), item.GetLifecycle(), item.GetCommandRevision(), item.GetLastEventSeq())
|
||||
if item.GetQueueExpiryTime() != nil {
|
||||
fmt.Fprintf(output, "queue_expiry=%s\n", item.GetQueueExpiryTime().AsTime().UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
if item.GetTerminalTime() != nil {
|
||||
fmt.Fprintf(output, "terminal_time=%s\n", item.GetTerminalTime().AsTime().UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCommand(ctx context.Context, client rvboxv1.ControlClient, args []string, output, diagnostics io.Writer) error {
|
||||
flags := flag.NewFlagSet("run", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
background := flags.Bool("background", false, "return after durable admission")
|
||||
cwd := flags.String("cwd", "", "command working directory")
|
||||
shell := flags.String("shell", "", "shell (sh, bash, cmd, powershell)")
|
||||
requestID := flags.String("request-id", "", "canonical UUIDv7 used for idempotent admission")
|
||||
queueTTL := flags.Duration("queue-ttl", -1, "queue TTL; zero means no expiry")
|
||||
scriptPath := flags.String("script", "", "script file path")
|
||||
envValues := repeatedFlag{}
|
||||
profileValues := repeatedFlag{}
|
||||
flags.Var(&envValues, "env", "environment override KEY=VALUE")
|
||||
flags.Var(&profileValues, "profile", "execution profile")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
positionals := flags.Args()
|
||||
if len(positionals) == 0 {
|
||||
return errors.New("run requires CLIENT and COMMAND (or --script PATH CLIENT)")
|
||||
}
|
||||
if *requestID == "" {
|
||||
generated, err := domain.NewUUIDv7()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*requestID = generated.String()
|
||||
}
|
||||
if _, err := domain.ParseUUIDv7(*requestID); err != nil {
|
||||
return fmt.Errorf("--request-id: %w", err)
|
||||
}
|
||||
spec := &rvboxv1.ExecutionSpec{Cwd: *cwd}
|
||||
spec.ShellType, _ = parseShell(*shell)
|
||||
for _, value := range envValues {
|
||||
parts := strings.SplitN(value, "=", 2)
|
||||
if len(parts) != 2 || parts[0] == "" {
|
||||
return fmt.Errorf("--env must be KEY=VALUE: %q", value)
|
||||
}
|
||||
if spec.EnvOverrides == nil {
|
||||
spec.EnvOverrides = make(map[string]string)
|
||||
}
|
||||
spec.EnvOverrides[parts[0]] = parts[1]
|
||||
}
|
||||
for _, value := range profileValues {
|
||||
profile, err := parseProfile(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec.ExecutionProfiles = append(spec.ExecutionProfiles, profile)
|
||||
}
|
||||
var clientID string
|
||||
if *scriptPath != "" {
|
||||
if len(positionals) != 1 {
|
||||
return errors.New("script form accepts exactly CLIENT")
|
||||
}
|
||||
clientID = positionals[0]
|
||||
data, err := os.ReadFile(*scriptPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) > 10<<20 {
|
||||
return errors.New("script exceeds the 10 MiB limit")
|
||||
}
|
||||
digest := sha256.Sum256(data)
|
||||
spec.Source = &rvboxv1.ExecutionSpec_Script{Script: &rvboxv1.ScriptDescriptor{Filename: filepath.Base(*scriptPath), SizeBytes: uint64(len(data)), Sha256: digest[:]}}
|
||||
request := &rvboxv1.RunCommandRequest{TargetClientId: clientID, Spec: spec, ScriptContent: data, RequestId: *requestID, QueueTtl: queueDuration(*queueTTL)}
|
||||
return printRunResponse(ctx, client, request, output, *background)
|
||||
}
|
||||
if len(positionals) < 2 {
|
||||
return errors.New("run requires CLIENT and COMMAND")
|
||||
}
|
||||
clientID = positionals[0]
|
||||
spec.Source = &rvboxv1.ExecutionSpec_CommandText{CommandText: strings.Join(positionals[1:], " ")}
|
||||
request := &rvboxv1.RunCommandRequest{TargetClientId: clientID, Spec: spec, RequestId: *requestID, QueueTtl: queueDuration(*queueTTL)}
|
||||
_ = diagnostics
|
||||
return printRunResponse(ctx, client, request, output, *background)
|
||||
}
|
||||
|
||||
func printRunResponse(ctx context.Context, client rvboxv1.ControlClient, request *rvboxv1.RunCommandRequest, output io.Writer, _ bool) error {
|
||||
response, err := client.RunCommand(ctx, request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(output, "%s %s\n", response.GetIssueUuid(), response.GetLifecycle())
|
||||
return nil
|
||||
}
|
||||
|
||||
func queueDuration(value time.Duration) *durationpb.Duration {
|
||||
if value < 0 {
|
||||
return nil
|
||||
}
|
||||
return durationpb.New(value)
|
||||
}
|
||||
|
||||
func parseShell(value string) (rvboxv1.ShellType, error) {
|
||||
switch strings.ToLower(value) {
|
||||
case "", "default":
|
||||
return rvboxv1.ShellType_SHELL_TYPE_UNSPECIFIED, nil
|
||||
case "sh":
|
||||
return rvboxv1.ShellType_SHELL_SH, nil
|
||||
case "bash":
|
||||
return rvboxv1.ShellType_SHELL_BASH, nil
|
||||
case "cmd":
|
||||
return rvboxv1.ShellType_SHELL_CMD, nil
|
||||
case "powershell", "pwsh":
|
||||
return rvboxv1.ShellType_SHELL_POWERSHELL, nil
|
||||
default:
|
||||
return rvboxv1.ShellType_SHELL_TYPE_UNSPECIFIED, fmt.Errorf("unknown shell %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func parseProfile(value string) (rvboxv1.ExecutionProfile, error) {
|
||||
switch strings.ToLower(value) {
|
||||
case "light":
|
||||
return rvboxv1.ExecutionProfile_EXECUTION_PROFILE_LIGHT, nil
|
||||
case "cpu-medium":
|
||||
return rvboxv1.ExecutionProfile_EXECUTION_PROFILE_CPU_MEDIUM, nil
|
||||
case "cpu-heavy":
|
||||
return rvboxv1.ExecutionProfile_EXECUTION_PROFILE_CPU_HEAVY, nil
|
||||
case "mem-medium":
|
||||
return rvboxv1.ExecutionProfile_EXECUTION_PROFILE_MEM_MEDIUM, nil
|
||||
case "mem-heavy":
|
||||
return rvboxv1.ExecutionProfile_EXECUTION_PROFILE_MEM_HEAVY, nil
|
||||
case "disk-medium":
|
||||
return rvboxv1.ExecutionProfile_EXECUTION_PROFILE_DISK_MEDIUM, nil
|
||||
case "disk-heavy":
|
||||
return rvboxv1.ExecutionProfile_EXECUTION_PROFILE_DISK_HEAVY, nil
|
||||
default:
|
||||
return rvboxv1.ExecutionProfile_EXECUTION_PROFILE_UNSPECIFIED, fmt.Errorf("unknown profile %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
type repeatedFlag []string
|
||||
|
||||
func (flag *repeatedFlag) String() string { return strings.Join(*flag, ",") }
|
||||
func (flag *repeatedFlag) Set(value string) error {
|
||||
*flag = append(*flag, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user