feat: implement control history and mutation APIs
This commit is contained in:
+251
-1
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||
"github.com/rvbox/rvbox/internal/agentproto"
|
||||
"github.com/rvbox/rvbox/internal/domain"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
@@ -55,6 +56,16 @@ func run(args []string, output, diagnostics io.Writer) error {
|
||||
return stat(ctx, client, args[1:], output)
|
||||
case "run":
|
||||
return runCommand(ctx, client, args[1:], output, diagnostics)
|
||||
case "append":
|
||||
return appendStdin(ctx, client, args[1:], output)
|
||||
case "close-stdin":
|
||||
return closeStdin(ctx, client, args[1:], output)
|
||||
case "kill":
|
||||
return killCommand(ctx, client, args[1:], output)
|
||||
case "storage":
|
||||
return storage(ctx, client, args[1:], output)
|
||||
case "client":
|
||||
return clientCommand(ctx, client, args[1:], output)
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", args[0])
|
||||
}
|
||||
@@ -224,15 +235,254 @@ func runCommand(ctx context.Context, client rvboxv1.ControlClient, args []string
|
||||
return printRunResponse(ctx, client, request, output, *background)
|
||||
}
|
||||
|
||||
func printRunResponse(ctx context.Context, client rvboxv1.ControlClient, request *rvboxv1.RunCommandRequest, output io.Writer, _ bool) error {
|
||||
func printRunResponse(ctx context.Context, client rvboxv1.ControlClient, request *rvboxv1.RunCommandRequest, output io.Writer, background bool) error {
|
||||
response, err := client.RunCommand(ctx, request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(output, "%s %s\n", response.GetIssueUuid(), response.GetLifecycle())
|
||||
if background {
|
||||
return nil
|
||||
}
|
||||
follow, err := client.FollowCommand(ctx, &rvboxv1.FollowCommandRequest{ClientId: request.GetTargetClientId(), IssueUuid: response.GetIssueUuid(), IncludeExisting: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
item, err := follow.Recv()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event := item.GetEvent()
|
||||
if event == nil {
|
||||
continue
|
||||
}
|
||||
switch payload := event.Payload.(type) {
|
||||
case *rvboxv1.CommandEvent_Output:
|
||||
data, decodeErr := agentproto.DecodeOutputChunk(payload.Output, 64<<10)
|
||||
if decodeErr != nil {
|
||||
return decodeErr
|
||||
}
|
||||
if _, writeErr := output.Write(data); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
case *rvboxv1.CommandEvent_Lifecycle:
|
||||
fmt.Fprintf(output, "\n[%s] %s\n", payload.Lifecycle.GetLifecycle(), payload.Lifecycle.GetDetail())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendStdin(ctx context.Context, client rvboxv1.ControlClient, args []string, output io.Writer) error {
|
||||
flags := flag.NewFlagSet("append", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
filePath := flags.String("file", "", "read stdin data from a file")
|
||||
raw := flags.Bool("raw", false, "do not append a newline")
|
||||
requestID := flags.String("request-id", "", "canonical UUIDv7")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
positionals := flags.Args()
|
||||
if len(positionals) < 2 || len(positionals) > 3 || (*filePath != "" && len(positionals) == 3) {
|
||||
return errors.New("append requires CLIENT UUID TEXT, or --file PATH CLIENT UUID")
|
||||
}
|
||||
data := []byte{}
|
||||
var err error
|
||||
if *filePath != "" {
|
||||
data, err = os.ReadFile(*filePath)
|
||||
} else {
|
||||
data = []byte(positionals[2])
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *requestID == "" {
|
||||
*requestID, err = generatedRequestID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
issue := positionals[1]
|
||||
if _, err := domain.ParseUUIDv7(issue); err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := client.AppendStdin(ctx, &rvboxv1.AppendStdinRequest{ClientId: positionals[0], IssueUuid: issue, Data: data, AppendNewline: !*raw, RequestId: *requestID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(output, "%d\n", response.GetWriteSeq())
|
||||
return nil
|
||||
}
|
||||
|
||||
func closeStdin(ctx context.Context, client rvboxv1.ControlClient, args []string, output io.Writer) error {
|
||||
flags := flag.NewFlagSet("close-stdin", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
requestID := flags.String("request-id", "", "canonical UUIDv7")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(flags.Args()) != 2 {
|
||||
return errors.New("close-stdin requires CLIENT UUID")
|
||||
}
|
||||
if _, err := domain.ParseUUIDv7(flags.Args()[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
if *requestID == "" {
|
||||
var err error
|
||||
*requestID, err = generatedRequestID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
response, err := client.CloseStdin(ctx, &rvboxv1.CloseStdinRequest{ClientId: flags.Args()[0], IssueUuid: flags.Args()[1], RequestId: *requestID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(output, "%d\n", response.GetWriteSeq())
|
||||
return nil
|
||||
}
|
||||
|
||||
func killCommand(ctx context.Context, client rvboxv1.ControlClient, args []string, output io.Writer) error {
|
||||
flags := flag.NewFlagSet("kill", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
requestID := flags.String("request-id", "", "canonical UUIDv7")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
positionals := flags.Args()
|
||||
signal := rvboxv1.SignalKind_SIGNAL_TERM
|
||||
if len(positionals) == 3 {
|
||||
parsed, err := parseSignal(positionals[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signal = parsed
|
||||
positionals = positionals[1:]
|
||||
}
|
||||
if len(positionals) != 2 {
|
||||
return errors.New("kill requires [SIGNAL] CLIENT UUID")
|
||||
}
|
||||
if _, err := domain.ParseUUIDv7(positionals[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
if *requestID == "" {
|
||||
var err error
|
||||
*requestID, err = generatedRequestID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
response, err := client.SignalCommand(ctx, &rvboxv1.ControlSignalCommandRequest{ClientId: positionals[0], IssueUuid: positionals[1], Signal: signal, RequestId: *requestID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(output, "%d\n", response.GetCommandRevision())
|
||||
return nil
|
||||
}
|
||||
|
||||
func storage(ctx context.Context, client rvboxv1.ControlClient, args []string, output io.Writer) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("storage requires incidents, repair, or acknowledge")
|
||||
}
|
||||
switch args[0] {
|
||||
case "incidents":
|
||||
response, err := client.ListStorageIncidents(ctx, &rvboxv1.ListStorageIncidentsRequest{IncludeResolved: contains(args[1:], "--all")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, incident := range response.GetIncidents() {
|
||||
fmt.Fprintf(output, "%s state=%s scope=%s summary=%s\n", incident.GetIncidentId(), incident.GetState(), incident.GetScope(), incident.GetSummary())
|
||||
}
|
||||
return nil
|
||||
case "repair", "acknowledge":
|
||||
if len(args) < 2 {
|
||||
return errors.New("storage mutation requires INCIDENT_ID")
|
||||
}
|
||||
requestID, err := generatedRequestID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if args[0] == "repair" {
|
||||
response, callErr := client.RepairStorageIncident(ctx, &rvboxv1.RepairStorageIncidentRequest{IncidentId: args[1], RequestId: requestID})
|
||||
if callErr != nil {
|
||||
return callErr
|
||||
}
|
||||
fmt.Fprintf(output, "%s\n", response.GetIncident().GetState())
|
||||
return nil
|
||||
}
|
||||
note := strings.Join(args[2:], " ")
|
||||
if note == "" {
|
||||
return errors.New("storage acknowledge requires a note")
|
||||
}
|
||||
response, callErr := client.AcknowledgeStorageIncident(ctx, &rvboxv1.AcknowledgeStorageIncidentRequest{IncidentId: args[1], RequestId: requestID, Note: note})
|
||||
if callErr != nil {
|
||||
return callErr
|
||||
}
|
||||
fmt.Fprintf(output, "%s\n", response.GetIncident().GetState())
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown storage command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func clientCommand(ctx context.Context, client rvboxv1.ControlClient, args []string, output io.Writer) error {
|
||||
if len(args) != 3 || args[0] != "takeover" {
|
||||
return errors.New("client takeover requires CLIENT INSTANCE_ID")
|
||||
}
|
||||
if _, err := domain.ParseUUIDv7(args[2]); err != nil {
|
||||
return err
|
||||
}
|
||||
requestID, err := generatedRequestID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response, err := client.AuthorizeClientTakeover(ctx, &rvboxv1.AuthorizeClientTakeoverRequest{ClientId: args[1], ClientInstanceId: args[2], RequestId: requestID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(output, response.GetExpiresAt().AsTime().UTC().Format(time.RFC3339Nano))
|
||||
return nil
|
||||
}
|
||||
|
||||
func generatedRequestID() (string, error) {
|
||||
value, err := domain.NewUUIDv7()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return value.String(), nil
|
||||
}
|
||||
|
||||
func parseSignal(value string) (rvboxv1.SignalKind, error) {
|
||||
value = strings.ToUpper(strings.TrimPrefix(value, "SIG"))
|
||||
switch value {
|
||||
case "HUP":
|
||||
return rvboxv1.SignalKind_SIGNAL_HUP, nil
|
||||
case "INT":
|
||||
return rvboxv1.SignalKind_SIGNAL_INT, nil
|
||||
case "TERM":
|
||||
return rvboxv1.SignalKind_SIGNAL_TERM, nil
|
||||
case "KILL":
|
||||
return rvboxv1.SignalKind_SIGNAL_KILL, nil
|
||||
case "USR1":
|
||||
return rvboxv1.SignalKind_SIGNAL_USR1, nil
|
||||
case "USR2":
|
||||
return rvboxv1.SignalKind_SIGNAL_USR2, nil
|
||||
default:
|
||||
return rvboxv1.SignalKind_SIGNAL_KIND_UNSPECIFIED, fmt.Errorf("unsupported signal %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func queueDuration(value time.Duration) *durationpb.Duration {
|
||||
if value < 0 {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
|
||||
)
|
||||
|
||||
func TestGlobalSocketAndCLIValueParsing_HP_CTL_11(t *testing.T) {
|
||||
socket, args, err := globalSocket([]string{"--socket", filepath.Join(t.TempDir(), "rvbox.sock"), "run", "client", "echo"})
|
||||
if err != nil || args[0] != "run" || socket == "" {
|
||||
t.Fatalf("global socket = %q %#v, %v", socket, args, err)
|
||||
}
|
||||
if _, _, err := globalSocket([]string{"--socket", "relative.sock", "stat"}); err == nil {
|
||||
t.Fatal("relative control socket accepted")
|
||||
}
|
||||
for input, want := range map[string]rvboxv1.ShellType{"sh": rvboxv1.ShellType_SHELL_SH, "bash": rvboxv1.ShellType_SHELL_BASH, "cmd": rvboxv1.ShellType_SHELL_CMD, "pwsh": rvboxv1.ShellType_SHELL_POWERSHELL, "": rvboxv1.ShellType_SHELL_TYPE_UNSPECIFIED} {
|
||||
got, err := parseShell(input)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("parseShell(%q) = %v, %v", input, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := parseProfile("unknown"); err == nil {
|
||||
t.Fatal("unknown profile accepted")
|
||||
}
|
||||
if got := queueDuration(-time.Nanosecond); got != nil {
|
||||
t.Fatal("negative queue duration was encoded")
|
||||
}
|
||||
if got := queueDuration(0); got == nil || got.AsDuration() != 0 {
|
||||
t.Fatalf("zero queue duration = %v", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user