diff --git a/README.md b/README.md index 67978ef..7a98a1e 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,9 @@ ate-env create dev1 # Execute a shell command inside the environment. ate-env dev1 shell 'echo hello > /note.txt' +# Feed stdin to a command and bound its run time. +echo 'shout' | ate-env dev1 shell --stdin --timeout 30s 'tr a-z A-Z' + # Read and write files. ate-env dev1 read /note.txt echo "world" | ate-env dev1 write /note.txt @@ -159,14 +162,17 @@ Manages the lifecycle of isolated execution environments (defined in [`proto/ate ### ProcessService -Manages asynchronous process execution and output streaming inside the environment container (defined in [`proto/ateenv/v1alpha/guest.proto`](proto/ateenv/v1alpha/guest.proto)). Requests are proxied by `ate-env-api` directly to the `ate-env-guest` daemon: +Manages process execution, I/O streaming, and signals inside the environment container (defined in [`proto/ateenv/v1alpha/guest.proto`](proto/ateenv/v1alpha/guest.proto)). Requests are proxied by `ate-env-api` directly to the `ate-env-guest` daemon: | RPC | Description | | --- | ----------- | -| `StartProcess` | Launches a process and returns a process ID. | -| `GetProcess` | Retrieves process metadata and status | -| `StreamProcessOutputs` | Streams stdout and stderr chunks | -| `KillProcess` | Terminates a running background process | +| `StartProcess` | Launches a process (optionally with a stdin pipe and a timeout) and returns the `Process` resource | +| `GetProcess` | Retrieves the process state, exit code, and timestamps | +| `StreamProcessOutput` | Streams stdout and stderr chunks; with `follow`, ends with an `exit` message carrying the final `Process` | +| `WriteProcessInput` | Streams bytes to the process's stdin; a message with `close` sends EOF | +| `SignalProcess` | Delivers a POSIX signal (`TERM`, `INT`, `KILL`, `USR1`, ...) to the process group | + +`exit_code` follows the shell convention: the process's exit code, or 128 + signal number if it was killed by a signal. To wait for a process without receiving its output, follow the output stream with offsets past the end of the spool. ### FileSystemService diff --git a/clients/go/client.go b/clients/go/client.go index 60ea9c9..84b7e0d 100644 --- a/clients/go/client.go +++ b/clients/go/client.go @@ -32,9 +32,13 @@ import ( "google.golang.org/grpc/status" ) -// ErrNotFound is returned when an env, file, or directory does not exist. +// ErrNotFound is returned when an env, process, file, or directory does not exist. var ErrNotFound = errors.New("not found") +// ErrProcessExited is returned when an operation needs a running process +// (signalling it, writing to its stdin) but it has already exited. +var ErrProcessExited = errors.New("process has exited") + // ClientOptions configures a Client. type ClientOptions struct { // Endpoint is the address or base URL of the ate-env-api service, e.g. @@ -151,8 +155,13 @@ func fromGRPCError(err error) error { if err == nil { return nil } - if status.Code(err) == codes.NotFound { + switch status.Code(err) { + case codes.NotFound: return fmt.Errorf("env: %w: %s", ErrNotFound, status.Convert(err).Message()) + case codes.FailedPrecondition: + if strings.Contains(status.Convert(err).Message(), "has exited") { + return fmt.Errorf("env: %w: %s", ErrProcessExited, status.Convert(err).Message()) + } } return fmt.Errorf("env: %w", err) } diff --git a/clients/go/client_test.go b/clients/go/client_test.go index de0965a..12caa56 100644 --- a/clients/go/client_test.go +++ b/clients/go/client_test.go @@ -22,6 +22,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/agent-substrate/env/clients/go" "github.com/agent-substrate/env/guest" @@ -215,6 +216,31 @@ func TestShellStderrAndExitCode(t *testing.T) { } } +func TestWriteFileAt(t *testing.T) { + f := newFixture(t) + sb := f.create(t, "sb-seek") + ctx := t.Context() + + if err := sb.WriteFile(ctx, "seek.txt", strings.NewReader("hello world"), 0o644); err != nil { + t.Fatal(err) + } + if err := sb.WriteFileAt(ctx, "seek.txt", 6, strings.NewReader("W"), 0o644); err != nil { + t.Fatal(err) + } + rc, err := sb.ReadFile(ctx, "seek.txt") + if err != nil { + t.Fatal(err) + } + data, _ := io.ReadAll(rc) + rc.Close() + if string(data) != "hello World" { + t.Errorf("after WriteFileAt: %q, want %q", data, "hello World") + } + if err := sb.WriteFileAt(ctx, "seek.txt", -1, strings.NewReader("x"), 0o644); err == nil { + t.Error("negative offset should be rejected") + } +} + func TestReadFileMissing(t *testing.T) { f := newFixture(t) sb := f.create(t, "sb-missing") @@ -256,3 +282,149 @@ func TestLargeFileStreaming(t *testing.T) { t.Error("readBack content mismatch") } } + +func TestRunWithStdin(t *testing.T) { + f := newFixture(t) + sb := f.create(t, "sb-stdin") + ctx := t.Context() + + res, err := sb.Run(ctx, env.ShellRequest{Command: "tr a-z A-Z", Stdin: []byte("shout\n")}) + if err != nil { + t.Fatal(err) + } + if res.Stdout != "SHOUT\n" || res.ExitCode != 0 { + t.Errorf("run result = %+v, want stdout %q", res, "SHOUT\n") + } +} + +func TestProcessInteractiveStdinAndOutput(t *testing.T) { + f := newFixture(t) + sb := f.create(t, "sb-proc") + ctx := t.Context() + + proc, err := sb.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{Command: []string{"cat"}, Stdin: true}) + if err != nil { + t.Fatal(err) + } + stream, err := proc.Output(ctx, &ateenvv1alpha.StreamProcessOutputRequest{Follow: true}) + if err != nil { + t.Fatal(err) + } + + stdin, err := proc.Stdin(ctx) + if err != nil { + t.Fatal(err) + } + if _, err := stdin.Write([]byte("first\n")); err != nil { + t.Fatal(err) + } + out, err := stream.Recv() + if err != nil { + t.Fatal(err) + } + if string(out.GetStdout()) != "first\n" { + t.Fatalf("first output = %v", out) + } + if _, err := stdin.Write([]byte("second\n")); err != nil { + t.Fatal(err) + } + if err := stdin.Close(); err != nil { + t.Fatal(err) + } + + var rest string + var exit *ateenvv1alpha.Process + for { + out, err := stream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatal(err) + } + rest += string(out.GetStdout()) + if out.GetExit() != nil { + exit = out.GetExit() + } + } + if rest != "second\n" { + t.Errorf("remaining output = %q", rest) + } + if exit == nil || exit.GetState() != ateenvv1alpha.ProcessState_PROCESS_STATE_EXITED || exit.GetExitCode() != 0 { + t.Errorf("exit = %v, want exited with 0", exit) + } + + // The handle still resolves after exit; stdin is refused. + info, err := sb.FindProcess(ctx, proc.ID()) + if err != nil { + t.Fatal(err) + } + if info.GetState() != ateenvv1alpha.ProcessState_PROCESS_STATE_EXITED || info.GetPid() == 0 || len(info.GetCommand()) != 1 { + t.Errorf("info = %v", info) + } + w, err := proc.Stdin(ctx) + if err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte("late")) + if err := w.Close(); !errors.Is(err, env.ErrProcessExited) { + t.Errorf("stdin after exit: err = %v, want ErrProcessExited", err) + } +} + +func TestProcessSignalAndKill(t *testing.T) { + f := newFixture(t) + sb := f.create(t, "sb-sig") + ctx := t.Context() + + proc, err := sb.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{Command: []string{"sleep", "60"}}) + if err != nil { + t.Fatal(err) + } + if err := proc.Signal(ctx, ateenvv1alpha.Signal_SIGNAL_TERM); err != nil { + t.Fatal(err) + } + info, err := proc.Wait(ctx) + if err != nil { + t.Fatal(err) + } + if info.GetState() != ateenvv1alpha.ProcessState_PROCESS_STATE_EXITED || info.GetExitCode() != 143 { + t.Errorf("after SIGTERM: %v", info) + } + if err := proc.Signal(ctx, ateenvv1alpha.Signal_SIGNAL_KILL); !errors.Is(err, env.ErrProcessExited) { + t.Errorf("signal after exit: err = %v, want ErrProcessExited", err) + } + + sleeper, err := sb.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{Command: []string{"sleep", "60"}}) + if err != nil { + t.Fatal(err) + } + killed, err := sleeper.Kill(ctx) + if err != nil { + t.Fatal(err) + } + if killed.GetExitCode() != 137 { + t.Errorf("kill: %v", killed) + } + // Kill is idempotent. + if _, err := sleeper.Kill(ctx); err != nil { + t.Errorf("second kill: %v", err) + } + + if _, err := sb.FindProcess(ctx, "bogus"); !errors.Is(err, env.ErrNotFound) { + t.Errorf("bogus process: err = %v, want ErrNotFound", err) + } +} + +func TestShellKilledByTimeout(t *testing.T) { + f := newFixture(t) + sb := f.create(t, "sb-timeout") + + res, err := sb.Run(t.Context(), env.ShellRequest{Command: "sleep 30", Timeout: 100 * time.Millisecond}) + if err != nil { + t.Fatal(err) + } + if res.ExitCode != 137 { + t.Errorf("timed out run = %+v, want exit code 137", res) + } +} diff --git a/clients/go/env.go b/clients/go/env.go index 8a48634..a1c24e2 100644 --- a/clients/go/env.go +++ b/clients/go/env.go @@ -21,10 +21,10 @@ import ( "fmt" "io" "io/fs" - "time" ateenvv1alpha "github.com/agent-substrate/env/proto/ateenv/v1alpha" "google.golang.org/grpc/metadata" + "google.golang.org/protobuf/types/known/durationpb" ) // Env is a handle to a single environment. @@ -37,7 +37,6 @@ type Env struct { // ID returns the environment's identifier. func (e *Env) ID() string { return e.id } - func (e *Env) withEnv(ctx context.Context) context.Context { return metadata.AppendToOutgoingContext(ctx, "x-env-id", e.id, "x-env-atespace", e.atespace) } @@ -52,66 +51,74 @@ func (e *Env) Delete(ctx context.Context) error { return e.client.Delete(ctx, e.atespace, e.id) } -// Shell runs a shell command line inside the environment using ProcessService. +// Shell runs a shell command line inside the environment and captures its +// output. It is shorthand for Run with only Command set. func (e *Env) Shell(ctx context.Context, commandLine string) (*ShellResponse, error) { - ctx = e.withEnv(ctx) - startResp, err := e.client.process.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{ - Command: []string{"sh", "-c", commandLine}, - }) + return e.Run(ctx, ShellRequest{Command: commandLine}) +} + +// Run executes `sh -c req.Command` inside the environment, feeds it +// req.Stdin, and returns its buffered output and exit status once it exits. +// For incremental output or interactive input use StartProcess. +func (e *Env) Run(ctx context.Context, req ShellRequest) (*ShellResponse, error) { + start := &ateenvv1alpha.StartProcessRequest{ + Command: []string{"sh", "-c", req.Command}, + Cwd: req.Cwd, + Env: req.Env, + Stdin: req.Stdin != nil, + } + if req.Timeout > 0 { + start.Timeout = durationpb.New(req.Timeout) + } + proc, err := e.StartProcess(ctx, start) if err != nil { - return nil, fromGRPCError(err) + return nil, err } - pid := startResp.GetProcessId() - outStream, err := e.client.process.StreamProcessOutputs(ctx, &ateenvv1alpha.StreamProcessOutputsRequest{ - ProcessId: pid, - Follow: true, - }) - if err != nil { - return nil, fromGRPCError(err) + if req.Stdin != nil { + w, err := proc.Stdin(ctx) + if err != nil { + return nil, err + } + if _, err := w.Write(req.Stdin); err != nil { + return nil, err + } + if err := w.Close(); err != nil { + return nil, err + } } + stream, err := proc.Output(ctx, &ateenvv1alpha.StreamProcessOutputRequest{Follow: true}) + if err != nil { + return nil, err + } var stdoutBuf, stderrBuf bytes.Buffer + var exit *ateenvv1alpha.Process for { - chunk, err := outStream.Recv() + msg, err := stream.Recv() if errors.Is(err, io.EOF) { break } if err != nil { return nil, fromGRPCError(err) } - switch chunk.GetSource() { - case ateenvv1alpha.OutputSource_OUTPUT_SOURCE_STDOUT: - stdoutBuf.Write(chunk.GetData()) - case ateenvv1alpha.OutputSource_OUTPUT_SOURCE_STDERR: - stderrBuf.Write(chunk.GetData()) + switch out := msg.GetOutput().(type) { + case *ateenvv1alpha.ProcessOutput_Stdout: + stdoutBuf.Write(out.Stdout) + case *ateenvv1alpha.ProcessOutput_Stderr: + stderrBuf.Write(out.Stderr) + case *ateenvv1alpha.ProcessOutput_Exit: + exit = out.Exit } } - - // Retrieve final process state / exit code - var exitCode int - for { - proc, err := e.client.process.GetProcess(ctx, &ateenvv1alpha.GetProcessRequest{ - ProcessId: pid, - }) - if err != nil { - return nil, fromGRPCError(err) - } - if proc.GetStatus() != ateenvv1alpha.ProcessStatus_PROCESS_STATUS_RUNNING { - exitCode = int(proc.GetExitCode()) - break - } - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(20 * time.Millisecond): - } + if exit == nil { + return nil, errors.New("env: output stream ended before the process exited") } return &ShellResponse{ Stdout: stdoutBuf.String(), Stderr: stderrBuf.String(), - ExitCode: exitCode, + ExitCode: int(exit.GetExitCode()), }, nil } @@ -138,8 +145,8 @@ func (e *Env) ReadFile(ctx context.Context, p string) (io.ReadCloser, error) { pr, pw := io.Pipe() go func() { - if len(firstChunk.GetData()) > 0 { - if _, writeErr := pw.Write(firstChunk.GetData()); writeErr != nil { + if len(firstChunk.GetChunk()) > 0 { + if _, writeErr := pw.Write(firstChunk.GetChunk()); writeErr != nil { return } } @@ -153,8 +160,8 @@ func (e *Env) ReadFile(ctx context.Context, p string) (io.ReadCloser, error) { _ = pw.CloseWithError(fromGRPCError(err)) return } - if len(chunk.GetData()) > 0 { - if _, writeErr := pw.Write(chunk.GetData()); writeErr != nil { + if len(chunk.GetChunk()) > 0 { + if _, writeErr := pw.Write(chunk.GetChunk()); writeErr != nil { return } } @@ -164,9 +171,25 @@ func (e *Env) ReadFile(ctx context.Context, p string) (io.ReadCloser, error) { return pr, nil } -// WriteFile streams the contents of r to the file at path inside the -// environment with the given permissions using FileSystemService. +// WriteFile replaces the file at path inside the environment with the +// contents of r, creating it with the given permissions if needed. func (e *Env) WriteFile(ctx context.Context, p string, r io.Reader, mode fs.FileMode) error { + return e.writeFile(ctx, p, r, mode, 0) +} + +// WriteFileAt writes the contents of r into the file at path starting at +// byte offset, keeping existing content outside the written range. The file +// is created with the given permissions if it does not exist, and extended +// with zero bytes if offset is past its end. An offset of zero replaces the +// file, like WriteFile. +func (e *Env) WriteFileAt(ctx context.Context, p string, offset int64, r io.Reader, mode fs.FileMode) error { + if offset < 0 { + return fmt.Errorf("env: negative offset %d for %q", offset, p) + } + return e.writeFile(ctx, p, r, mode, offset) +} + +func (e *Env) writeFile(ctx context.Context, p string, r io.Reader, mode fs.FileMode, seekOffset int64) error { ctx = e.withEnv(ctx) stream, err := e.client.filesystem.WriteFile(ctx) if err != nil { @@ -180,9 +203,10 @@ func (e *Env) WriteFile(ctx context.Context, p string, r io.Reader, mode fs.File } firstReq := &ateenvv1alpha.WriteFileRequest{ - Path: p, - Mode: uint32(mode.Perm()), - Chunk: buf[:n], + Path: p, + Mode: uint32(mode.Perm()), + Chunk: buf[:n], + SeekOffset: seekOffset, } if err := stream.Send(firstReq); err != nil { return fromGRPCError(err) diff --git a/clients/go/process.go b/clients/go/process.go new file mode 100644 index 0000000..fca6e13 --- /dev/null +++ b/clients/go/process.go @@ -0,0 +1,182 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package env + +import ( + "context" + "errors" + "io" + "math" + + ateenvv1alpha "github.com/agent-substrate/env/proto/ateenv/v1alpha" + "google.golang.org/grpc" + "google.golang.org/protobuf/proto" +) + +// Process is a handle to a process inside an environment. Process state is +// reported with the ateenvv1alpha.Process message; its exit_code follows the +// shell convention (128 + signal number when killed by a signal). +type Process struct { + id string + env *Env +} + +// ID returns the process identifier. +func (p *Process) ID() string { return p.id } + +// StartProcess launches a process and returns a handle to it. +func (e *Env) StartProcess(ctx context.Context, req *ateenvv1alpha.StartProcessRequest) (*Process, error) { + pb, err := e.client.process.StartProcess(e.withEnv(ctx), req) + if err != nil { + return nil, fromGRPCError(err) + } + return e.Process(pb.GetProcessId()), nil +} + +// Process returns a handle to an existing process without checking that it exists. +func (e *Env) Process(id string) *Process { + return &Process{id: id, env: e} +} + +// FindProcess returns the current state of the process with the given ID, +// or an error wrapping ErrNotFound if the guest no longer tracks it. +func (e *Env) FindProcess(ctx context.Context, id string) (*ateenvv1alpha.Process, error) { + pb, err := e.client.process.GetProcess(e.withEnv(ctx), &ateenvv1alpha.GetProcessRequest{ProcessId: id}) + if err != nil { + return nil, fromGRPCError(err) + } + return pb, nil +} + +// Wait blocks until the process exits or ctx is done, and returns its final +// state. It follows the output stream with offsets past the end of the +// spool, so no output is transferred. +func (p *Process) Wait(ctx context.Context) (*ateenvv1alpha.Process, error) { + stream, err := p.Output(ctx, &ateenvv1alpha.StreamProcessOutputRequest{ + Follow: true, + StdoutOffset: math.MaxInt64, + StderrOffset: math.MaxInt64, + }) + if err != nil { + return nil, err + } + for { + msg, err := stream.Recv() + if errors.Is(err, io.EOF) { + return nil, errors.New("env: output stream ended before the process exited") + } + if err != nil { + return nil, fromGRPCError(err) + } + if exit := msg.GetExit(); exit != nil { + return exit, nil + } + } +} + +// Signal delivers sig to the process and its process group. +func (p *Process) Signal(ctx context.Context, sig ateenvv1alpha.Signal) error { + _, err := p.env.client.process.SignalProcess(p.env.withEnv(ctx), &ateenvv1alpha.SignalProcessRequest{ProcessId: p.id, Signal: sig}) + return fromGRPCError(err) +} + +// Kill sends SIGKILL and waits for the process to exit. Killing a process +// that has already exited is not an error. +func (p *Process) Kill(ctx context.Context) (*ateenvv1alpha.Process, error) { + if err := p.Signal(ctx, ateenvv1alpha.Signal_SIGNAL_KILL); err != nil && !errors.Is(err, ErrProcessExited) { + return nil, err + } + return p.Wait(ctx) +} + +// Output streams the process's stdout and stderr as ateenvv1alpha.ProcessOutput +// messages. req may be nil for a snapshot of the output so far; its +// process_id is always set to this process. With follow, the stream ends +// with an exit message once the process has exited and all output has been +// delivered. Cancel ctx to stop early. +func (p *Process) Output(ctx context.Context, req *ateenvv1alpha.StreamProcessOutputRequest) (grpc.ServerStreamingClient[ateenvv1alpha.ProcessOutput], error) { + if req == nil { + req = &ateenvv1alpha.StreamProcessOutputRequest{} + } else { + req = proto.Clone(req).(*ateenvv1alpha.StreamProcessOutputRequest) + } + req.ProcessId = p.id + stream, err := p.env.client.process.StreamProcessOutput(p.env.withEnv(ctx), req) + if err != nil { + return nil, fromGRPCError(err) + } + return stream, nil +} + +// Stdin opens a writer to the process's standard input. Writes are delivered +// as they happen; Close sends EOF. The process must have been started with +// stdin enabled. Errors from the guest surface on Write or Close. +func (p *Process) Stdin(ctx context.Context) (io.WriteCloser, error) { + stream, err := p.env.client.process.WriteProcessInput(p.env.withEnv(ctx)) + if err != nil { + return nil, fromGRPCError(err) + } + return &stdinWriter{stream: stream, id: p.id}, nil +} + +type stdinWriter struct { + stream ateenvv1alpha.ProcessService_WriteProcessInputClient + id string + sent bool + closed bool +} + +func (w *stdinWriter) Write(data []byte) (int, error) { + if w.closed { + return 0, errors.New("env: stdin is closed") + } + for i := 0; i < len(data); i += chunkSize { + end := min(i+chunkSize, len(data)) + if err := w.send(&ateenvv1alpha.WriteProcessInputRequest{Data: data[i:end]}); err != nil { + return i, err + } + } + return len(data), nil +} + +// Close signals EOF on stdin and waits for the guest to acknowledge. +func (w *stdinWriter) Close() error { + if w.closed { + return nil + } + w.closed = true + if err := w.send(&ateenvv1alpha.WriteProcessInputRequest{Close: true}); err != nil { + return err + } + _, err := w.stream.CloseAndRecv() + return fromGRPCError(err) +} + +// send forwards one message, resolving the guest's real error when the +// stream has already been torn down (grpc reports that as io.EOF on Send). +func (w *stdinWriter) send(req *ateenvv1alpha.WriteProcessInputRequest) error { + if !w.sent { + req.ProcessId = w.id + w.sent = true + } + if err := w.stream.Send(req); err != nil { + w.closed = true + if errors.Is(err, io.EOF) { + _, err = w.stream.CloseAndRecv() + } + return fromGRPCError(err) + } + return nil +} diff --git a/clients/go/types.go b/clients/go/types.go index d40d856..be3e44a 100644 --- a/clients/go/types.go +++ b/clients/go/types.go @@ -14,7 +14,12 @@ package env -// ShellRequest describes a command to run inside an env. +import "time" + +// chunkSize is the maximum payload per streamed message. +const chunkSize = 64 * 1024 + +// ShellRequest describes a shell command line to run inside an env. type ShellRequest struct { // Command is the shell command line to run inside the environment. Command string `json:"command"` @@ -27,9 +32,13 @@ type ShellRequest struct { // daemon's working directory. Cwd string `json:"cwd,omitempty"` - // Stdin is fed to the process's standard input. It is base64-encoded - // in JSON. + // Stdin is fed to the process's standard input, then stdin is closed. + // Nil leaves stdin empty. It is base64-encoded in JSON. Stdin []byte `json:"stdin,omitempty"` + + // Timeout kills the command with SIGKILL after this duration. Zero uses + // the guest default. + Timeout time.Duration `json:"timeout,omitempty"` } // ShellResponse is the outcome of a ShellRequest. @@ -38,8 +47,7 @@ type ShellResponse struct { Stdout string `json:"stdout"` Stderr string `json:"stderr"` - // ExitCode is the process exit code. -1 if the process was killed by - // a signal or failed to start. + // ExitCode is the process exit code, or 128 + signal number if the + // process was killed by a signal (e.g. 137 for SIGKILL). ExitCode int `json:"exit_code"` } - diff --git a/clients/python/README.md b/clients/python/README.md index bdaea8f..bac229e 100644 --- a/clients/python/README.md +++ b/clients/python/README.md @@ -24,10 +24,11 @@ plain gRPC (h2c). Behind that endpoint there are two distinct paths: │ delete() env() │ (unary) │ │ ╰────────╯ ╰────────────────────────────╯ │ ate-env-api │ Substrate control plane ╭────────────────────────────╮ │ (guest proxy) │ - │ Env │ ProcessService │ │ x-env-id ╭────────╮ ╭──────────────────╮ + │ Env / Process │ ProcessService │ │ x-env-id ╭────────╮ ╭──────────────────╮ │ shell() start_process() │ FileSystemService │ │───────────▶│ atenet │──▶│ actor │ - │ stream_outputs() wait() │───────────────────▶│ │ routing │ router │ │ └ ate-env-guest │ - │ read_file() write_file() │ + routing metadata ╰───────────────╯ ╰────────╯ ╰──────────────────╯ + │ output() write_input() │───────────────────▶│ │ routing │ router │ │ └ ate-env-guest │ + │ signal() wait() │ + routing metadata ╰───────────────╯ ╰────────╯ ╰──────────────────╯ + │ read_file() write_file() │ ╰────────────────────────────╯ ``` @@ -73,16 +74,18 @@ actor boots. Retry until it serves (see the how-to below). | `Client.delete()` / `Env.delete()` | `EnvironmentService.DeleteEnvironment` | ate-env-api → control plane | | `Client.env()` | *(no RPC — returns a handle)* | — | | `Env.start_process()` | `ProcessService.StartProcess` | proxied to guest | -| `Env.get_process()` / `Env.wait()` | `ProcessService.GetProcess` | proxied to guest | -| `Env.stream_outputs()` | `ProcessService.StreamProcessOutputs` *(server-streaming)* | proxied to guest | -| `Env.kill_process()` | `ProcessService.KillProcess` | proxied to guest | -| `Env.shell()` | `StartProcess` + `StreamProcessOutputs` + `GetProcess` | proxied to guest | +| `Env.process()` | *(no RPC — returns a handle)* | — | +| `Process.info()` | `ProcessService.GetProcess` | proxied to guest | +| `Process.output()` / `Process.wait()` | `ProcessService.StreamProcessOutput` *(server-streaming)* | proxied to guest | +| `Process.write_input()` / `close_input()` | `ProcessService.WriteProcessInput` *(client-streaming)* | proxied to guest | +| `Process.signal()` / `Process.kill()` | `ProcessService.SignalProcess` | proxied to guest | +| `Env.shell()` | `StartProcess` (+ `WriteProcessInput`) + `StreamProcessOutput` | proxied to guest | | `Env.read_file()` / `read_file_bytes()` | `FileSystemService.ReadFile` *(server-streaming)* | proxied to guest | | `Env.write_file()` | `FileSystemService.WriteFile` *(client-streaming)* | proxied to guest | `Env.shell()` is a convenience composed from the process primitives: it -starts `sh -c `, follows the log stream until the process exits, -then polls `GetProcess` for the final exit code. +starts `sh -c `, feeds it stdin if given, and follows the output +stream, which ends with the process's exit state. ## How-to @@ -168,39 +171,71 @@ async def wait_until_serving(env, timeout=180): ```python result = await env.shell("echo hello && uname -a") -print(result.exit_code) # int +print(result.exit_code) # int; 128 + signal number if killed by a signal print(result.stdout) # str (utf-8, invalid bytes replaced) print(result.stderr) + +result = await env.shell("tr a-z A-Z", stdin="shout\n", timeout=30) ``` `shell()` buffers all output in memory and returns after the command -exits. For long-running or chatty commands, use the process API instead. +exits. `stdin` (bytes or str) is fed to the command and then closed; +`timeout` (seconds or `timedelta`) kills the command with SIGKILL when +it elapses. For long-running, chatty, or interactive commands, use the +process API instead. -### Run background processes and stream logs +### Run background processes and stream output ```python -pid = await env.start_process( +proc = await env.start_process( ["python", "train.py"], cwd="/workspace", env={"EPOCHS": "10"}, + timeout=3600, ) -# Follow output live until the process exits: -async for chunk in env.stream_outputs(pid, follow=True): - print(chunk.source.name, chunk.data.decode(), end="") - -proc = await env.wait(pid) # final state: status, exit_code, timestamps +# Follow output live; the last message carries the exit state: +async for out in proc.output(follow=True): + if out.stdout is not None: + print(out.stdout.decode(), end="") + elif out.stderr is not None: + print(out.stderr.decode(), end="", file=sys.stderr) + else: + print("exited:", out.exit.exit_code) + +info = await proc.wait() # or: block until exit without reading output +info = await proc.info() # snapshot: state, pid, exit_code, timestamps ``` Notes: -- `stream_outputs(follow=True)` blocks until the process exits — consume it +- `output(follow=True)` blocks until the process exits — consume it under `asyncio.timeout()` or in a task you can cancel. Breaking out of the `async for` cancels the underlying RPC cleanly. - Replay from a byte offset with `stdout_offset=` / `stderr_offset=`; - without `follow` you get the logs written so far and the stream ends. -- `await env.kill_process(pid)` terminates the process tree and returns - its exit code (128 + signal, e.g. 137 for SIGKILL). + without `follow` you get the output written so far and the stream ends + (with an `exit` message only if the process has already exited). +- `env.process(process_id)` returns a handle to a process started earlier. + +### Feed stdin and send signals + +```python +proc = await env.start_process(["python", "-i"], stdin=True) +await proc.write_input(b"print(6 * 7)\n") # stdin stays open across calls +await proc.write_input(b"exit()\n", close=True) # close=True sends EOF + +server = await env.start_process(["./serve"]) +await server.signal(Signal.HUP) # any POSIX signal, to the process group +await server.signal(Signal.TERM) +info = await server.wait() +assert info.exit_code == 143 # 128 + SIGTERM + +info = await server.kill() # SIGKILL + wait; idempotent +``` + +Signals and stdin need a running process; once it has exited they raise +`ProcessExitedError`. `write_input()` on a process started without +`stdin=True`, or after `close=True`, raises `FailedPreconditionError`. ### Read and write files @@ -225,7 +260,13 @@ await env.write_file("/workspace/dataset.bin", produce(), mode=0o600) `write_file` accepts `bytes`/`bytearray`/`memoryview` (chunked for you) or any sync/async iterable of bytes chunks (sent as-is). Writing `b""` -creates an empty file. +creates an empty file. By default the file is replaced; pass a positive +`seek_offset=` to keep existing content and write starting at that byte +(the file is zero-extended if it is shorter): + +```python +await env.write_file("/workspace/data.bin", patch, seek_offset=4096) +``` ### Suspend and delete @@ -246,6 +287,8 @@ All failures raise subclasses of `ate_env.EnvError`: | `NotFoundError` | `NOT_FOUND` | unknown environment, process id, or file path | | `InvalidArgumentError` | `INVALID_ARGUMENT` | empty id, missing routing metadata, empty command | | `PermissionDeniedError` | `PERMISSION_DENIED` | file path escapes the workspace sandbox | +| `FailedPreconditionError` | `FAILED_PRECONDITION` | stdin not opened or already closed | +| `ProcessExitedError` | `FAILED_PRECONDITION` | signal or stdin write to a process that has exited (subclass of `FailedPreconditionError`) | | `RpcError` | anything else | transport failures, `ALREADY_EXISTS`, actor still waking, … (`.code` holds the status) | ```python diff --git a/clients/python/examples/quickstart.py b/clients/python/examples/quickstart.py index 6483b91..6d312ba 100644 --- a/clients/python/examples/quickstart.py +++ b/clients/python/examples/quickstart.py @@ -21,7 +21,7 @@ import asyncio -from ate_env import Client +from ate_env import Client, Signal async def main(): @@ -36,11 +36,25 @@ async def main(): await env.write_file("/workspace/notes.txt", b"hi\n", mode=0o644) print(await env.read_file_bytes("/workspace/notes.txt")) - pid = await env.start_process( + proc = await env.start_process( ["sh", "-c", "for i in 1 2 3; do echo $i; sleep 1; done"] ) - async for chunk in env.stream_outputs(pid, follow=True): - print(chunk.source.name, chunk.data.decode()) + async for out in proc.output(follow=True): + if out.stdout is not None: + print("stdout", out.stdout.decode(), end="") + elif out.stderr is not None: + print("stderr", out.stderr.decode(), end="") + else: + print("exited with", out.exit.exit_code) + + # Interactive stdin and signals: + cat = await env.start_process(["cat"], stdin=True) + await cat.write_input(b"hello\n", close=True) + print(await cat.wait()) + + sleeper = await env.start_process(["sleep", "300"]) + await sleeper.signal(Signal.TERM) + print((await sleeper.wait()).exit_code) # 143 = 128 + SIGTERM await env.delete() finally: diff --git a/clients/python/src/ate_env/__init__.py b/clients/python/src/ate_env/__init__.py index 0b9d0dc..08375d9 100644 --- a/clients/python/src/ate_env/__init__.py +++ b/clients/python/src/ate_env/__init__.py @@ -33,23 +33,25 @@ async def main(): """ from .client import DEFAULT_ATESPACE, Client -from .env import Env +from .env import Env, Process from .errors import ( EnvError, + FailedPreconditionError, InvalidArgumentError, NotFoundError, PermissionDeniedError, + ProcessExitedError, RpcError, map_rpc_error, ) from .types import ( EnvironmentInfo, EnvironmentStatus, - OutputChunk, - OutputSource, ProcessInfo, - ProcessStatus, + ProcessOutput, + ProcessState, ShellResult, + Signal, Template, ) @@ -60,15 +62,18 @@ async def main(): "EnvError", "EnvironmentInfo", "EnvironmentStatus", + "FailedPreconditionError", "InvalidArgumentError", - "OutputChunk", - "OutputSource", "NotFoundError", "PermissionDeniedError", + "Process", + "ProcessExitedError", "ProcessInfo", - "ProcessStatus", + "ProcessOutput", + "ProcessState", "RpcError", "ShellResult", + "Signal", "Template", "map_rpc_error", ] diff --git a/clients/python/src/ate_env/_gen/ateenv/v1alpha/env_pb2.py b/clients/python/src/ate_env/_gen/ateenv/v1alpha/env_pb2.py index 36748dd..199d872 100644 --- a/clients/python/src/ate_env/_gen/ateenv/v1alpha/env_pb2.py +++ b/clients/python/src/ate_env/_gen/ateenv/v1alpha/env_pb2.py @@ -14,13 +14,23 @@ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE # source: ateenv/v1alpha/env.proto -# Protobuf Python Version: 4.25.1 +# Protobuf Python Version: 6.33.5 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version from google.protobuf import symbol_database as _symbol_database from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 33, + 5, + '', + 'ateenv/v1alpha/env.proto' +) # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() @@ -33,8 +43,8 @@ _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'ateenv.v1alpha.env_pb2', _globals) -if _descriptor._USE_C_DESCRIPTORS == False: - _globals['DESCRIPTOR']._options = None +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'ZAgithub.com/agent-substrate/env/proto/ateenv/v1alpha;ateenvv1alpha' _globals['_ENVIRONMENTSTATUS']._serialized_start=718 _globals['_ENVIRONMENTSTATUS']._serialized_end=1035 diff --git a/clients/python/src/ate_env/_gen/ateenv/v1alpha/env_pb2.pyi b/clients/python/src/ate_env/_gen/ateenv/v1alpha/env_pb2.pyi index 103cc7a..08ab40e 100644 --- a/clients/python/src/ate_env/_gen/ateenv/v1alpha/env_pb2.pyi +++ b/clients/python/src/ate_env/_gen/ateenv/v1alpha/env_pb2.pyi @@ -1,21 +1,22 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper from google.protobuf import descriptor as _descriptor from google.protobuf import message as _message -from typing import ClassVar as _ClassVar, Mapping as _Mapping, Optional as _Optional, Union as _Union +from collections.abc import Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union DESCRIPTOR: _descriptor.FileDescriptor diff --git a/clients/python/src/ate_env/_gen/ateenv/v1alpha/env_pb2_grpc.py b/clients/python/src/ate_env/_gen/ateenv/v1alpha/env_pb2_grpc.py index 5331193..0723df8 100644 --- a/clients/python/src/ate_env/_gen/ateenv/v1alpha/env_pb2_grpc.py +++ b/clients/python/src/ate_env/_gen/ateenv/v1alpha/env_pb2_grpc.py @@ -15,11 +15,31 @@ # Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! """Client and server classes corresponding to protobuf-defined services.""" import grpc +import warnings from . import env_pb2 as ateenv_dot_v1alpha_dot_env__pb2 +GRPC_GENERATED_VERSION = '1.81.1' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False -class EnvironmentServiceStub(object): +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in ateenv/v1alpha/env_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class EnvironmentServiceStub: """TODO(jbd): Migrate data plane (shell/fs) to guest.proto (ProcessService/FileSystemService). TODO(jbd): Remove env legacy HTTP REST endpoints for environment lifecycle. TODO(jbd): Update remaining examples and documentation. @@ -42,25 +62,25 @@ def __init__(self, channel): '/ateenv.v1alpha.EnvironmentService/CreateEnvironment', request_serializer=ateenv_dot_v1alpha_dot_env__pb2.CreateEnvironmentRequest.SerializeToString, response_deserializer=ateenv_dot_v1alpha_dot_env__pb2.CreateEnvironmentResponse.FromString, - ) + _registered_method=True) self.GetEnvironment = channel.unary_unary( '/ateenv.v1alpha.EnvironmentService/GetEnvironment', request_serializer=ateenv_dot_v1alpha_dot_env__pb2.GetEnvironmentRequest.SerializeToString, response_deserializer=ateenv_dot_v1alpha_dot_env__pb2.GetEnvironmentResponse.FromString, - ) + _registered_method=True) self.SuspendEnvironment = channel.unary_unary( '/ateenv.v1alpha.EnvironmentService/SuspendEnvironment', request_serializer=ateenv_dot_v1alpha_dot_env__pb2.SuspendEnvironmentRequest.SerializeToString, response_deserializer=ateenv_dot_v1alpha_dot_env__pb2.SuspendEnvironmentResponse.FromString, - ) + _registered_method=True) self.DeleteEnvironment = channel.unary_unary( '/ateenv.v1alpha.EnvironmentService/DeleteEnvironment', request_serializer=ateenv_dot_v1alpha_dot_env__pb2.DeleteEnvironmentRequest.SerializeToString, response_deserializer=ateenv_dot_v1alpha_dot_env__pb2.DeleteEnvironmentResponse.FromString, - ) + _registered_method=True) -class EnvironmentServiceServicer(object): +class EnvironmentServiceServicer: """TODO(jbd): Migrate data plane (shell/fs) to guest.proto (ProcessService/FileSystemService). TODO(jbd): Remove env legacy HTTP REST endpoints for environment lifecycle. TODO(jbd): Update remaining examples and documentation. @@ -128,10 +148,11 @@ def add_EnvironmentServiceServicer_to_server(servicer, server): generic_handler = grpc.method_handlers_generic_handler( 'ateenv.v1alpha.EnvironmentService', rpc_method_handlers) server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('ateenv.v1alpha.EnvironmentService', rpc_method_handlers) # This class is part of an EXPERIMENTAL API. -class EnvironmentService(object): +class EnvironmentService: """TODO(jbd): Migrate data plane (shell/fs) to guest.proto (ProcessService/FileSystemService). TODO(jbd): Remove env legacy HTTP REST endpoints for environment lifecycle. TODO(jbd): Update remaining examples and documentation. @@ -155,11 +176,21 @@ def CreateEnvironment(request, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.unary_unary(request, target, '/ateenv.v1alpha.EnvironmentService/CreateEnvironment', + return grpc.experimental.unary_unary( + request, + target, + '/ateenv.v1alpha.EnvironmentService/CreateEnvironment', ateenv_dot_v1alpha_dot_env__pb2.CreateEnvironmentRequest.SerializeToString, ateenv_dot_v1alpha_dot_env__pb2.CreateEnvironmentResponse.FromString, - options, channel_credentials, - insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) @staticmethod def GetEnvironment(request, @@ -172,11 +203,21 @@ def GetEnvironment(request, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.unary_unary(request, target, '/ateenv.v1alpha.EnvironmentService/GetEnvironment', + return grpc.experimental.unary_unary( + request, + target, + '/ateenv.v1alpha.EnvironmentService/GetEnvironment', ateenv_dot_v1alpha_dot_env__pb2.GetEnvironmentRequest.SerializeToString, ateenv_dot_v1alpha_dot_env__pb2.GetEnvironmentResponse.FromString, - options, channel_credentials, - insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) @staticmethod def SuspendEnvironment(request, @@ -189,11 +230,21 @@ def SuspendEnvironment(request, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.unary_unary(request, target, '/ateenv.v1alpha.EnvironmentService/SuspendEnvironment', + return grpc.experimental.unary_unary( + request, + target, + '/ateenv.v1alpha.EnvironmentService/SuspendEnvironment', ateenv_dot_v1alpha_dot_env__pb2.SuspendEnvironmentRequest.SerializeToString, ateenv_dot_v1alpha_dot_env__pb2.SuspendEnvironmentResponse.FromString, - options, channel_credentials, - insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) @staticmethod def DeleteEnvironment(request, @@ -206,8 +257,18 @@ def DeleteEnvironment(request, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.unary_unary(request, target, '/ateenv.v1alpha.EnvironmentService/DeleteEnvironment', + return grpc.experimental.unary_unary( + request, + target, + '/ateenv.v1alpha.EnvironmentService/DeleteEnvironment', ateenv_dot_v1alpha_dot_env__pb2.DeleteEnvironmentRequest.SerializeToString, ateenv_dot_v1alpha_dot_env__pb2.DeleteEnvironmentResponse.FromString, - options, channel_credentials, - insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/clients/python/src/ate_env/_gen/ateenv/v1alpha/guest_pb2.py b/clients/python/src/ate_env/_gen/ateenv/v1alpha/guest_pb2.py index e0d6e8b..07853ff 100644 --- a/clients/python/src/ate_env/_gen/ateenv/v1alpha/guest_pb2.py +++ b/clients/python/src/ate_env/_gen/ateenv/v1alpha/guest_pb2.py @@ -14,63 +14,74 @@ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE # source: ateenv/v1alpha/guest.proto -# Protobuf Python Version: 4.25.1 +# Protobuf Python Version: 6.33.5 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version from google.protobuf import symbol_database as _symbol_database from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 33, + 5, + '', + 'ateenv/v1alpha/guest.proto' +) # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() +from google.protobuf import duration_pb2 as google_dot_protobuf_dot_duration__pb2 from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1a\x61teenv/v1alpha/guest.proto\x12\x0e\x61teenv.v1alpha\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc0\x01\n\x07Process\x12\x12\n\nprocess_id\x18\x01 \x01(\t\x12-\n\x06status\x18\x02 \x01(\x0e\x32\x1d.ateenv.v1alpha.ProcessStatus\x12\x11\n\texit_code\x18\x03 \x01(\x05\x12.\n\nstarted_at\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0b\x66inished_at\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x9a\x01\n\x13StartProcessRequest\x12\x0f\n\x07\x63ommand\x18\x01 \x03(\t\x12\x0b\n\x03\x63wd\x18\x02 \x01(\t\x12\x39\n\x03\x65nv\x18\x03 \x03(\x0b\x32,.ateenv.v1alpha.StartProcessRequest.EnvEntry\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"*\n\x14StartProcessResponse\x12\x12\n\nprocess_id\x18\x01 \x01(\t\"\'\n\x11GetProcessRequest\x12\x12\n\nprocess_id\x18\x01 \x01(\t\"o\n\x1bStreamProcessOutputsRequest\x12\x12\n\nprocess_id\x18\x01 \x01(\t\x12\x15\n\rstdout_offset\x18\x02 \x01(\x03\x12\x15\n\rstderr_offset\x18\x03 \x01(\x03\x12\x0e\n\x06\x66ollow\x18\x04 \x01(\x08\"I\n\x0bOutputChunk\x12,\n\x06source\x18\x01 \x01(\x0e\x32\x1c.ateenv.v1alpha.OutputSource\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\"(\n\x12KillProcessRequest\x12\x12\n\nprocess_id\x18\x01 \x01(\t\"(\n\x13KillProcessResponse\x12\x11\n\texit_code\x18\x01 \x01(\x05\"\x1f\n\x0fReadFileRequest\x12\x0c\n\x04path\x18\x01 \x01(\t\"\x19\n\tFileChunk\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\"=\n\x10WriteFileRequest\x12\x0c\n\x04path\x18\x01 \x01(\t\x12\r\n\x05\x63hunk\x18\x02 \x01(\x0c\x12\x0c\n\x04mode\x18\x03 \x01(\r\"*\n\x11WriteFileResponse\x12\x15\n\rbytes_written\x18\x01 \x01(\x03*\xa3\x01\n\rProcessStatus\x12\x1e\n\x1aPROCESS_STATUS_UNSPECIFIED\x10\x00\x12\x1a\n\x16PROCESS_STATUS_RUNNING\x10\x01\x12\x1c\n\x18PROCESS_STATUS_COMPLETED\x10\x02\x12\x19\n\x15PROCESS_STATUS_FAILED\x10\x03\x12\x1d\n\x19PROCESS_STATUS_TERMINATED\x10\x04*a\n\x0cOutputSource\x12\x1d\n\x19OUTPUT_SOURCE_UNSPECIFIED\x10\x00\x12\x18\n\x14OUTPUT_SOURCE_STDOUT\x10\x01\x12\x18\n\x14OUTPUT_SOURCE_STDERR\x10\x02\x32\xf1\x02\n\x0eProcessService\x12Y\n\x0cStartProcess\x12#.ateenv.v1alpha.StartProcessRequest\x1a$.ateenv.v1alpha.StartProcessResponse\x12H\n\nGetProcess\x12!.ateenv.v1alpha.GetProcessRequest\x1a\x17.ateenv.v1alpha.Process\x12\x62\n\x14StreamProcessOutputs\x12+.ateenv.v1alpha.StreamProcessOutputsRequest\x1a\x1b.ateenv.v1alpha.OutputChunk0\x01\x12V\n\x0bKillProcess\x12\".ateenv.v1alpha.KillProcessRequest\x1a#.ateenv.v1alpha.KillProcessResponse2\xb1\x01\n\x11\x46ileSystemService\x12H\n\x08ReadFile\x12\x1f.ateenv.v1alpha.ReadFileRequest\x1a\x19.ateenv.v1alpha.FileChunk0\x01\x12R\n\tWriteFile\x12 .ateenv.v1alpha.WriteFileRequest\x1a!.ateenv.v1alpha.WriteFileResponse(\x01\x42\x43ZAgithub.com/agent-substrate/env/proto/ateenv/v1alpha;ateenvv1alphab\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1a\x61teenv/v1alpha/guest.proto\x12\x0e\x61teenv.v1alpha\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xdc\x01\n\x07Process\x12\x12\n\nprocess_id\x18\x01 \x01(\t\x12\x0f\n\x07\x63ommand\x18\x02 \x03(\t\x12\x0b\n\x03pid\x18\x03 \x01(\x05\x12+\n\x05state\x18\x04 \x01(\x0e\x32\x1c.ateenv.v1alpha.ProcessState\x12\x11\n\texit_code\x18\x05 \x01(\x05\x12.\n\nstarted_at\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12/\n\x0b\x66inished_at\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xd5\x01\n\x13StartProcessRequest\x12\x0f\n\x07\x63ommand\x18\x01 \x03(\t\x12\x0b\n\x03\x63wd\x18\x02 \x01(\t\x12\x39\n\x03\x65nv\x18\x03 \x03(\x0b\x32,.ateenv.v1alpha.StartProcessRequest.EnvEntry\x12\r\n\x05stdin\x18\x04 \x01(\x08\x12*\n\x07timeout\x18\x05 \x01(\x0b\x32\x19.google.protobuf.Duration\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\'\n\x11GetProcessRequest\x12\x12\n\nprocess_id\x18\x01 \x01(\t\"n\n\x1aStreamProcessOutputRequest\x12\x12\n\nprocess_id\x18\x01 \x01(\t\x12\x15\n\rstdout_offset\x18\x02 \x01(\x03\x12\x15\n\rstderr_offset\x18\x03 \x01(\x03\x12\x0e\n\x06\x66ollow\x18\x04 \x01(\x08\"f\n\rProcessOutput\x12\x10\n\x06stdout\x18\x01 \x01(\x0cH\x00\x12\x10\n\x06stderr\x18\x02 \x01(\x0cH\x00\x12\'\n\x04\x65xit\x18\x03 \x01(\x0b\x32\x17.ateenv.v1alpha.ProcessH\x00\x42\x08\n\x06output\"K\n\x18WriteProcessInputRequest\x12\x12\n\nprocess_id\x18\x01 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\r\n\x05\x63lose\x18\x03 \x01(\x08\"2\n\x19WriteProcessInputResponse\x12\x15\n\rbytes_written\x18\x01 \x01(\x03\"R\n\x14SignalProcessRequest\x12\x12\n\nprocess_id\x18\x01 \x01(\t\x12&\n\x06signal\x18\x02 \x01(\x0e\x32\x16.ateenv.v1alpha.Signal\"-\n\x0fReadFileRequest\x12\x0c\n\x04path\x18\x01 \x01(\t\x12\x0c\n\x04mode\x18\x02 \x01(\r\"!\n\x10ReadFileResponse\x12\r\n\x05\x63hunk\x18\x01 \x01(\x0c\"R\n\x10WriteFileRequest\x12\x0c\n\x04path\x18\x01 \x01(\t\x12\x0c\n\x04mode\x18\x02 \x01(\r\x12\x13\n\x0bseek_offset\x18\x03 \x01(\x03\x12\r\n\x05\x63hunk\x18\x04 \x01(\x0c\"*\n\x11WriteFileResponse\x12\x15\n\rbytes_written\x18\x01 \x01(\x03*b\n\x0cProcessState\x12\x1d\n\x19PROCESS_STATE_UNSPECIFIED\x10\x00\x12\x19\n\x15PROCESS_STATE_RUNNING\x10\x01\x12\x18\n\x14PROCESS_STATE_EXITED\x10\x02*\x87\x04\n\x06Signal\x12\x16\n\x12SIGNAL_UNSPECIFIED\x10\x00\x12\x0e\n\nSIGNAL_HUP\x10\x01\x12\x0e\n\nSIGNAL_INT\x10\x02\x12\x0f\n\x0bSIGNAL_QUIT\x10\x03\x12\x0e\n\nSIGNAL_ILL\x10\x04\x12\x0f\n\x0bSIGNAL_TRAP\x10\x05\x12\x0f\n\x0bSIGNAL_ABRT\x10\x06\x12\x0e\n\nSIGNAL_BUS\x10\x07\x12\x0e\n\nSIGNAL_FPE\x10\x08\x12\x0f\n\x0bSIGNAL_KILL\x10\t\x12\x0f\n\x0bSIGNAL_USR1\x10\n\x12\x0f\n\x0bSIGNAL_SEGV\x10\x0b\x12\x0f\n\x0bSIGNAL_USR2\x10\x0c\x12\x0f\n\x0bSIGNAL_PIPE\x10\r\x12\x0f\n\x0bSIGNAL_ALRM\x10\x0e\x12\x0f\n\x0bSIGNAL_TERM\x10\x0f\x12\x0f\n\x0bSIGNAL_CHLD\x10\x11\x12\x0f\n\x0bSIGNAL_CONT\x10\x12\x12\x0f\n\x0bSIGNAL_STOP\x10\x13\x12\x0f\n\x0bSIGNAL_TSTP\x10\x14\x12\x0f\n\x0bSIGNAL_TTIN\x10\x15\x12\x0f\n\x0bSIGNAL_TTOU\x10\x16\x12\x0e\n\nSIGNAL_URG\x10\x17\x12\x0f\n\x0bSIGNAL_XCPU\x10\x18\x12\x0f\n\x0bSIGNAL_XFSZ\x10\x19\x12\x11\n\rSIGNAL_VTALRM\x10\x1a\x12\x0f\n\x0bSIGNAL_PROF\x10\x1b\x12\x10\n\x0cSIGNAL_WINCH\x10\x1c\x12\r\n\tSIGNAL_IO\x10\x1d\x12\x0e\n\nSIGNAL_SYS\x10\x1f\x32\xc8\x03\n\x0eProcessService\x12L\n\x0cStartProcess\x12#.ateenv.v1alpha.StartProcessRequest\x1a\x17.ateenv.v1alpha.Process\x12H\n\nGetProcess\x12!.ateenv.v1alpha.GetProcessRequest\x1a\x17.ateenv.v1alpha.Process\x12\x62\n\x13StreamProcessOutput\x12*.ateenv.v1alpha.StreamProcessOutputRequest\x1a\x1d.ateenv.v1alpha.ProcessOutput0\x01\x12j\n\x11WriteProcessInput\x12(.ateenv.v1alpha.WriteProcessInputRequest\x1a).ateenv.v1alpha.WriteProcessInputResponse(\x01\x12N\n\rSignalProcess\x12$.ateenv.v1alpha.SignalProcessRequest\x1a\x17.ateenv.v1alpha.Process2\xb8\x01\n\x11\x46ileSystemService\x12O\n\x08ReadFile\x12\x1f.ateenv.v1alpha.ReadFileRequest\x1a .ateenv.v1alpha.ReadFileResponse0\x01\x12R\n\tWriteFile\x12 .ateenv.v1alpha.WriteFileRequest\x1a!.ateenv.v1alpha.WriteFileResponse(\x01\x42\x43ZAgithub.com/agent-substrate/env/proto/ateenv/v1alpha;ateenvv1alphab\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'ateenv.v1alpha.guest_pb2', _globals) -if _descriptor._USE_C_DESCRIPTORS == False: - _globals['DESCRIPTOR']._options = None +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'ZAgithub.com/agent-substrate/env/proto/ateenv/v1alpha;ateenvv1alpha' - _globals['_STARTPROCESSREQUEST_ENVENTRY']._options = None + _globals['_STARTPROCESSREQUEST_ENVENTRY']._loaded_options = None _globals['_STARTPROCESSREQUEST_ENVENTRY']._serialized_options = b'8\001' - _globals['_PROCESSSTATUS']._serialized_start=956 - _globals['_PROCESSSTATUS']._serialized_end=1119 - _globals['_OUTPUTSOURCE']._serialized_start=1121 - _globals['_OUTPUTSOURCE']._serialized_end=1218 - _globals['_PROCESS']._serialized_start=80 - _globals['_PROCESS']._serialized_end=272 - _globals['_STARTPROCESSREQUEST']._serialized_start=275 - _globals['_STARTPROCESSREQUEST']._serialized_end=429 - _globals['_STARTPROCESSREQUEST_ENVENTRY']._serialized_start=387 - _globals['_STARTPROCESSREQUEST_ENVENTRY']._serialized_end=429 - _globals['_STARTPROCESSRESPONSE']._serialized_start=431 - _globals['_STARTPROCESSRESPONSE']._serialized_end=473 - _globals['_GETPROCESSREQUEST']._serialized_start=475 - _globals['_GETPROCESSREQUEST']._serialized_end=514 - _globals['_STREAMPROCESSOUTPUTSREQUEST']._serialized_start=516 - _globals['_STREAMPROCESSOUTPUTSREQUEST']._serialized_end=627 - _globals['_OUTPUTCHUNK']._serialized_start=629 - _globals['_OUTPUTCHUNK']._serialized_end=702 - _globals['_KILLPROCESSREQUEST']._serialized_start=704 - _globals['_KILLPROCESSREQUEST']._serialized_end=744 - _globals['_KILLPROCESSRESPONSE']._serialized_start=746 - _globals['_KILLPROCESSRESPONSE']._serialized_end=786 - _globals['_READFILEREQUEST']._serialized_start=788 - _globals['_READFILEREQUEST']._serialized_end=819 - _globals['_FILECHUNK']._serialized_start=821 - _globals['_FILECHUNK']._serialized_end=846 - _globals['_WRITEFILEREQUEST']._serialized_start=848 - _globals['_WRITEFILEREQUEST']._serialized_end=909 - _globals['_WRITEFILERESPONSE']._serialized_start=911 - _globals['_WRITEFILERESPONSE']._serialized_end=953 - _globals['_PROCESSSERVICE']._serialized_start=1221 - _globals['_PROCESSSERVICE']._serialized_end=1590 - _globals['_FILESYSTEMSERVICE']._serialized_start=1593 - _globals['_FILESYSTEMSERVICE']._serialized_end=1770 + _globals['_PROCESSSTATE']._serialized_start=1230 + _globals['_PROCESSSTATE']._serialized_end=1328 + _globals['_SIGNAL']._serialized_start=1331 + _globals['_SIGNAL']._serialized_end=1850 + _globals['_PROCESS']._serialized_start=112 + _globals['_PROCESS']._serialized_end=332 + _globals['_STARTPROCESSREQUEST']._serialized_start=335 + _globals['_STARTPROCESSREQUEST']._serialized_end=548 + _globals['_STARTPROCESSREQUEST_ENVENTRY']._serialized_start=506 + _globals['_STARTPROCESSREQUEST_ENVENTRY']._serialized_end=548 + _globals['_GETPROCESSREQUEST']._serialized_start=550 + _globals['_GETPROCESSREQUEST']._serialized_end=589 + _globals['_STREAMPROCESSOUTPUTREQUEST']._serialized_start=591 + _globals['_STREAMPROCESSOUTPUTREQUEST']._serialized_end=701 + _globals['_PROCESSOUTPUT']._serialized_start=703 + _globals['_PROCESSOUTPUT']._serialized_end=805 + _globals['_WRITEPROCESSINPUTREQUEST']._serialized_start=807 + _globals['_WRITEPROCESSINPUTREQUEST']._serialized_end=882 + _globals['_WRITEPROCESSINPUTRESPONSE']._serialized_start=884 + _globals['_WRITEPROCESSINPUTRESPONSE']._serialized_end=934 + _globals['_SIGNALPROCESSREQUEST']._serialized_start=936 + _globals['_SIGNALPROCESSREQUEST']._serialized_end=1018 + _globals['_READFILEREQUEST']._serialized_start=1020 + _globals['_READFILEREQUEST']._serialized_end=1065 + _globals['_READFILERESPONSE']._serialized_start=1067 + _globals['_READFILERESPONSE']._serialized_end=1100 + _globals['_WRITEFILEREQUEST']._serialized_start=1102 + _globals['_WRITEFILEREQUEST']._serialized_end=1184 + _globals['_WRITEFILERESPONSE']._serialized_start=1186 + _globals['_WRITEFILERESPONSE']._serialized_end=1228 + _globals['_PROCESSSERVICE']._serialized_start=1853 + _globals['_PROCESSSERVICE']._serialized_end=2309 + _globals['_FILESYSTEMSERVICE']._serialized_start=2312 + _globals['_FILESYSTEMSERVICE']._serialized_end=2496 # @@protoc_insertion_point(module_scope) diff --git a/clients/python/src/ate_env/_gen/ateenv/v1alpha/guest_pb2.pyi b/clients/python/src/ate_env/_gen/ateenv/v1alpha/guest_pb2.pyi index 51fbd72..09c4227 100644 --- a/clients/python/src/ate_env/_gen/ateenv/v1alpha/guest_pb2.pyi +++ b/clients/python/src/ate_env/_gen/ateenv/v1alpha/guest_pb2.pyi @@ -1,64 +1,122 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime + +from google.protobuf import duration_pb2 as _duration_pb2 from google.protobuf import timestamp_pb2 as _timestamp_pb2 from google.protobuf.internal import containers as _containers from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper from google.protobuf import descriptor as _descriptor from google.protobuf import message as _message -from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union DESCRIPTOR: _descriptor.FileDescriptor -class ProcessStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): +class ProcessState(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () - PROCESS_STATUS_UNSPECIFIED: _ClassVar[ProcessStatus] - PROCESS_STATUS_RUNNING: _ClassVar[ProcessStatus] - PROCESS_STATUS_COMPLETED: _ClassVar[ProcessStatus] - PROCESS_STATUS_FAILED: _ClassVar[ProcessStatus] - PROCESS_STATUS_TERMINATED: _ClassVar[ProcessStatus] + PROCESS_STATE_UNSPECIFIED: _ClassVar[ProcessState] + PROCESS_STATE_RUNNING: _ClassVar[ProcessState] + PROCESS_STATE_EXITED: _ClassVar[ProcessState] -class OutputSource(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): +class Signal(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () - OUTPUT_SOURCE_UNSPECIFIED: _ClassVar[OutputSource] - OUTPUT_SOURCE_STDOUT: _ClassVar[OutputSource] - OUTPUT_SOURCE_STDERR: _ClassVar[OutputSource] -PROCESS_STATUS_UNSPECIFIED: ProcessStatus -PROCESS_STATUS_RUNNING: ProcessStatus -PROCESS_STATUS_COMPLETED: ProcessStatus -PROCESS_STATUS_FAILED: ProcessStatus -PROCESS_STATUS_TERMINATED: ProcessStatus -OUTPUT_SOURCE_UNSPECIFIED: OutputSource -OUTPUT_SOURCE_STDOUT: OutputSource -OUTPUT_SOURCE_STDERR: OutputSource + SIGNAL_UNSPECIFIED: _ClassVar[Signal] + SIGNAL_HUP: _ClassVar[Signal] + SIGNAL_INT: _ClassVar[Signal] + SIGNAL_QUIT: _ClassVar[Signal] + SIGNAL_ILL: _ClassVar[Signal] + SIGNAL_TRAP: _ClassVar[Signal] + SIGNAL_ABRT: _ClassVar[Signal] + SIGNAL_BUS: _ClassVar[Signal] + SIGNAL_FPE: _ClassVar[Signal] + SIGNAL_KILL: _ClassVar[Signal] + SIGNAL_USR1: _ClassVar[Signal] + SIGNAL_SEGV: _ClassVar[Signal] + SIGNAL_USR2: _ClassVar[Signal] + SIGNAL_PIPE: _ClassVar[Signal] + SIGNAL_ALRM: _ClassVar[Signal] + SIGNAL_TERM: _ClassVar[Signal] + SIGNAL_CHLD: _ClassVar[Signal] + SIGNAL_CONT: _ClassVar[Signal] + SIGNAL_STOP: _ClassVar[Signal] + SIGNAL_TSTP: _ClassVar[Signal] + SIGNAL_TTIN: _ClassVar[Signal] + SIGNAL_TTOU: _ClassVar[Signal] + SIGNAL_URG: _ClassVar[Signal] + SIGNAL_XCPU: _ClassVar[Signal] + SIGNAL_XFSZ: _ClassVar[Signal] + SIGNAL_VTALRM: _ClassVar[Signal] + SIGNAL_PROF: _ClassVar[Signal] + SIGNAL_WINCH: _ClassVar[Signal] + SIGNAL_IO: _ClassVar[Signal] + SIGNAL_SYS: _ClassVar[Signal] +PROCESS_STATE_UNSPECIFIED: ProcessState +PROCESS_STATE_RUNNING: ProcessState +PROCESS_STATE_EXITED: ProcessState +SIGNAL_UNSPECIFIED: Signal +SIGNAL_HUP: Signal +SIGNAL_INT: Signal +SIGNAL_QUIT: Signal +SIGNAL_ILL: Signal +SIGNAL_TRAP: Signal +SIGNAL_ABRT: Signal +SIGNAL_BUS: Signal +SIGNAL_FPE: Signal +SIGNAL_KILL: Signal +SIGNAL_USR1: Signal +SIGNAL_SEGV: Signal +SIGNAL_USR2: Signal +SIGNAL_PIPE: Signal +SIGNAL_ALRM: Signal +SIGNAL_TERM: Signal +SIGNAL_CHLD: Signal +SIGNAL_CONT: Signal +SIGNAL_STOP: Signal +SIGNAL_TSTP: Signal +SIGNAL_TTIN: Signal +SIGNAL_TTOU: Signal +SIGNAL_URG: Signal +SIGNAL_XCPU: Signal +SIGNAL_XFSZ: Signal +SIGNAL_VTALRM: Signal +SIGNAL_PROF: Signal +SIGNAL_WINCH: Signal +SIGNAL_IO: Signal +SIGNAL_SYS: Signal class Process(_message.Message): - __slots__ = ("process_id", "status", "exit_code", "started_at", "finished_at") + __slots__ = ("process_id", "command", "pid", "state", "exit_code", "started_at", "finished_at") PROCESS_ID_FIELD_NUMBER: _ClassVar[int] - STATUS_FIELD_NUMBER: _ClassVar[int] + COMMAND_FIELD_NUMBER: _ClassVar[int] + PID_FIELD_NUMBER: _ClassVar[int] + STATE_FIELD_NUMBER: _ClassVar[int] EXIT_CODE_FIELD_NUMBER: _ClassVar[int] STARTED_AT_FIELD_NUMBER: _ClassVar[int] FINISHED_AT_FIELD_NUMBER: _ClassVar[int] process_id: str - status: ProcessStatus + command: _containers.RepeatedScalarFieldContainer[str] + pid: int + state: ProcessState exit_code: int started_at: _timestamp_pb2.Timestamp finished_at: _timestamp_pb2.Timestamp - def __init__(self, process_id: _Optional[str] = ..., status: _Optional[_Union[ProcessStatus, str]] = ..., exit_code: _Optional[int] = ..., started_at: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., finished_at: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... + def __init__(self, process_id: _Optional[str] = ..., command: _Optional[_Iterable[str]] = ..., pid: _Optional[int] = ..., state: _Optional[_Union[ProcessState, str]] = ..., exit_code: _Optional[int] = ..., started_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ..., finished_at: _Optional[_Union[datetime.datetime, _timestamp_pb2.Timestamp, _Mapping]] = ...) -> None: ... class StartProcessRequest(_message.Message): - __slots__ = ("command", "cwd", "env") + __slots__ = ("command", "cwd", "env", "stdin", "timeout") class EnvEntry(_message.Message): __slots__ = ("key", "value") KEY_FIELD_NUMBER: _ClassVar[int] @@ -69,16 +127,14 @@ class StartProcessRequest(_message.Message): COMMAND_FIELD_NUMBER: _ClassVar[int] CWD_FIELD_NUMBER: _ClassVar[int] ENV_FIELD_NUMBER: _ClassVar[int] + STDIN_FIELD_NUMBER: _ClassVar[int] + TIMEOUT_FIELD_NUMBER: _ClassVar[int] command: _containers.RepeatedScalarFieldContainer[str] cwd: str env: _containers.ScalarMap[str, str] - def __init__(self, command: _Optional[_Iterable[str]] = ..., cwd: _Optional[str] = ..., env: _Optional[_Mapping[str, str]] = ...) -> None: ... - -class StartProcessResponse(_message.Message): - __slots__ = ("process_id",) - PROCESS_ID_FIELD_NUMBER: _ClassVar[int] - process_id: str - def __init__(self, process_id: _Optional[str] = ...) -> None: ... + stdin: bool + timeout: _duration_pb2.Duration + def __init__(self, command: _Optional[_Iterable[str]] = ..., cwd: _Optional[str] = ..., env: _Optional[_Mapping[str, str]] = ..., stdin: _Optional[bool] = ..., timeout: _Optional[_Union[datetime.timedelta, _duration_pb2.Duration, _Mapping]] = ...) -> None: ... class GetProcessRequest(_message.Message): __slots__ = ("process_id",) @@ -86,7 +142,7 @@ class GetProcessRequest(_message.Message): process_id: str def __init__(self, process_id: _Optional[str] = ...) -> None: ... -class StreamProcessOutputsRequest(_message.Message): +class StreamProcessOutputRequest(_message.Message): __slots__ = ("process_id", "stdout_offset", "stderr_offset", "follow") PROCESS_ID_FIELD_NUMBER: _ClassVar[int] STDOUT_OFFSET_FIELD_NUMBER: _ClassVar[int] @@ -96,49 +152,67 @@ class StreamProcessOutputsRequest(_message.Message): stdout_offset: int stderr_offset: int follow: bool - def __init__(self, process_id: _Optional[str] = ..., stdout_offset: _Optional[int] = ..., stderr_offset: _Optional[int] = ..., follow: bool = ...) -> None: ... - -class OutputChunk(_message.Message): - __slots__ = ("source", "data") - SOURCE_FIELD_NUMBER: _ClassVar[int] + def __init__(self, process_id: _Optional[str] = ..., stdout_offset: _Optional[int] = ..., stderr_offset: _Optional[int] = ..., follow: _Optional[bool] = ...) -> None: ... + +class ProcessOutput(_message.Message): + __slots__ = ("stdout", "stderr", "exit") + STDOUT_FIELD_NUMBER: _ClassVar[int] + STDERR_FIELD_NUMBER: _ClassVar[int] + EXIT_FIELD_NUMBER: _ClassVar[int] + stdout: bytes + stderr: bytes + exit: Process + def __init__(self, stdout: _Optional[bytes] = ..., stderr: _Optional[bytes] = ..., exit: _Optional[_Union[Process, _Mapping]] = ...) -> None: ... + +class WriteProcessInputRequest(_message.Message): + __slots__ = ("process_id", "data", "close") + PROCESS_ID_FIELD_NUMBER: _ClassVar[int] DATA_FIELD_NUMBER: _ClassVar[int] - source: OutputSource + CLOSE_FIELD_NUMBER: _ClassVar[int] + process_id: str data: bytes - def __init__(self, source: _Optional[_Union[OutputSource, str]] = ..., data: _Optional[bytes] = ...) -> None: ... + close: bool + def __init__(self, process_id: _Optional[str] = ..., data: _Optional[bytes] = ..., close: _Optional[bool] = ...) -> None: ... -class KillProcessRequest(_message.Message): - __slots__ = ("process_id",) +class WriteProcessInputResponse(_message.Message): + __slots__ = ("bytes_written",) + BYTES_WRITTEN_FIELD_NUMBER: _ClassVar[int] + bytes_written: int + def __init__(self, bytes_written: _Optional[int] = ...) -> None: ... + +class SignalProcessRequest(_message.Message): + __slots__ = ("process_id", "signal") PROCESS_ID_FIELD_NUMBER: _ClassVar[int] + SIGNAL_FIELD_NUMBER: _ClassVar[int] process_id: str - def __init__(self, process_id: _Optional[str] = ...) -> None: ... - -class KillProcessResponse(_message.Message): - __slots__ = ("exit_code",) - EXIT_CODE_FIELD_NUMBER: _ClassVar[int] - exit_code: int - def __init__(self, exit_code: _Optional[int] = ...) -> None: ... + signal: Signal + def __init__(self, process_id: _Optional[str] = ..., signal: _Optional[_Union[Signal, str]] = ...) -> None: ... class ReadFileRequest(_message.Message): - __slots__ = ("path",) + __slots__ = ("path", "mode") PATH_FIELD_NUMBER: _ClassVar[int] + MODE_FIELD_NUMBER: _ClassVar[int] path: str - def __init__(self, path: _Optional[str] = ...) -> None: ... + mode: int + def __init__(self, path: _Optional[str] = ..., mode: _Optional[int] = ...) -> None: ... -class FileChunk(_message.Message): - __slots__ = ("data",) - DATA_FIELD_NUMBER: _ClassVar[int] - data: bytes - def __init__(self, data: _Optional[bytes] = ...) -> None: ... +class ReadFileResponse(_message.Message): + __slots__ = ("chunk",) + CHUNK_FIELD_NUMBER: _ClassVar[int] + chunk: bytes + def __init__(self, chunk: _Optional[bytes] = ...) -> None: ... class WriteFileRequest(_message.Message): - __slots__ = ("path", "chunk", "mode") + __slots__ = ("path", "mode", "seek_offset", "chunk") PATH_FIELD_NUMBER: _ClassVar[int] - CHUNK_FIELD_NUMBER: _ClassVar[int] MODE_FIELD_NUMBER: _ClassVar[int] + SEEK_OFFSET_FIELD_NUMBER: _ClassVar[int] + CHUNK_FIELD_NUMBER: _ClassVar[int] path: str - chunk: bytes mode: int - def __init__(self, path: _Optional[str] = ..., chunk: _Optional[bytes] = ..., mode: _Optional[int] = ...) -> None: ... + seek_offset: int + chunk: bytes + def __init__(self, path: _Optional[str] = ..., mode: _Optional[int] = ..., seek_offset: _Optional[int] = ..., chunk: _Optional[bytes] = ...) -> None: ... class WriteFileResponse(_message.Message): __slots__ = ("bytes_written",) diff --git a/clients/python/src/ate_env/_gen/ateenv/v1alpha/guest_pb2_grpc.py b/clients/python/src/ate_env/_gen/ateenv/v1alpha/guest_pb2_grpc.py index 6a2d212..fbf0b04 100644 --- a/clients/python/src/ate_env/_gen/ateenv/v1alpha/guest_pb2_grpc.py +++ b/clients/python/src/ate_env/_gen/ateenv/v1alpha/guest_pb2_grpc.py @@ -15,17 +15,42 @@ # Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! """Client and server classes corresponding to protobuf-defined services.""" import grpc +import warnings from . import guest_pb2 as ateenv_dot_v1alpha_dot_guest__pb2 +GRPC_GENERATED_VERSION = '1.81.1' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False -class ProcessServiceStub(object): +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in ateenv/v1alpha/guest_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class ProcessServiceStub: """============================================================================ --- SERVICES --- ============================================================================ - ProcessService manages the asynchronous lifecycle and output streaming of - processes running inside the container. + ProcessService manages the lifecycle and I/O of processes running inside + the container. + + A process is started asynchronously and addressed by its process_id. Output + is spooled by the guest and can be streamed live or replayed from an offset. + Input is fed through a client stream, and any POSIX signal can be delivered + to the process group. """ def __init__(self, channel): @@ -37,58 +62,83 @@ def __init__(self, channel): self.StartProcess = channel.unary_unary( '/ateenv.v1alpha.ProcessService/StartProcess', request_serializer=ateenv_dot_v1alpha_dot_guest__pb2.StartProcessRequest.SerializeToString, - response_deserializer=ateenv_dot_v1alpha_dot_guest__pb2.StartProcessResponse.FromString, - ) + response_deserializer=ateenv_dot_v1alpha_dot_guest__pb2.Process.FromString, + _registered_method=True) self.GetProcess = channel.unary_unary( '/ateenv.v1alpha.ProcessService/GetProcess', request_serializer=ateenv_dot_v1alpha_dot_guest__pb2.GetProcessRequest.SerializeToString, response_deserializer=ateenv_dot_v1alpha_dot_guest__pb2.Process.FromString, - ) - self.StreamProcessOutputs = channel.unary_stream( - '/ateenv.v1alpha.ProcessService/StreamProcessOutputs', - request_serializer=ateenv_dot_v1alpha_dot_guest__pb2.StreamProcessOutputsRequest.SerializeToString, - response_deserializer=ateenv_dot_v1alpha_dot_guest__pb2.OutputChunk.FromString, - ) - self.KillProcess = channel.unary_unary( - '/ateenv.v1alpha.ProcessService/KillProcess', - request_serializer=ateenv_dot_v1alpha_dot_guest__pb2.KillProcessRequest.SerializeToString, - response_deserializer=ateenv_dot_v1alpha_dot_guest__pb2.KillProcessResponse.FromString, - ) - - -class ProcessServiceServicer(object): + _registered_method=True) + self.StreamProcessOutput = channel.unary_stream( + '/ateenv.v1alpha.ProcessService/StreamProcessOutput', + request_serializer=ateenv_dot_v1alpha_dot_guest__pb2.StreamProcessOutputRequest.SerializeToString, + response_deserializer=ateenv_dot_v1alpha_dot_guest__pb2.ProcessOutput.FromString, + _registered_method=True) + self.WriteProcessInput = channel.stream_unary( + '/ateenv.v1alpha.ProcessService/WriteProcessInput', + request_serializer=ateenv_dot_v1alpha_dot_guest__pb2.WriteProcessInputRequest.SerializeToString, + response_deserializer=ateenv_dot_v1alpha_dot_guest__pb2.WriteProcessInputResponse.FromString, + _registered_method=True) + self.SignalProcess = channel.unary_unary( + '/ateenv.v1alpha.ProcessService/SignalProcess', + request_serializer=ateenv_dot_v1alpha_dot_guest__pb2.SignalProcessRequest.SerializeToString, + response_deserializer=ateenv_dot_v1alpha_dot_guest__pb2.Process.FromString, + _registered_method=True) + + +class ProcessServiceServicer: """============================================================================ --- SERVICES --- ============================================================================ - ProcessService manages the asynchronous lifecycle and output streaming of - processes running inside the container. + ProcessService manages the lifecycle and I/O of processes running inside + the container. + + A process is started asynchronously and addressed by its process_id. Output + is spooled by the guest and can be streamed live or replayed from an offset. + Input is fed through a client stream, and any POSIX signal can be delivered + to the process group. """ def StartProcess(self, request, context): - """StartProcess launches a long-running process asynchronously in the background - and immediately returns a unique process_id for tracking. + """StartProcess launches a process in the background and returns its + Process resource immediately. """ context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') def GetProcess(self, request, context): - """GetProcess retrieves the current metadata, lifecycle state, and timestamps of a process. + """GetProcess returns the current state of a process. """ context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') - def StreamProcessOutputs(self, request, context): - """StreamProcessOutputs streams real-time stdout and stderr from a process. + def StreamProcessOutput(self, request, context): + """StreamProcessOutput streams stdout and stderr from a process. Once the + process has exited and all output has been delivered, the stream ends + with a final exit message carrying the finished Process. Following with + no interest in output (offsets past the end of the spool) is the way to + wait for a process to exit. """ context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') - def KillProcess(self, request, context): - """KillProcess terminates a running asynchronous process and its child process tree. + def WriteProcessInput(self, request_iterator, context): + """WriteProcessInput feeds bytes to the stdin of a process started with + stdin enabled. Multiple calls may be made over the life of a process; + stdin stays open until a message sets close, or the process exits. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def SignalProcess(self, request, context): + """SignalProcess delivers a signal to the process and its process group and + returns the process state right after delivery. Use StreamProcessOutput + to observe the effect. """ context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') @@ -100,37 +150,48 @@ def add_ProcessServiceServicer_to_server(servicer, server): 'StartProcess': grpc.unary_unary_rpc_method_handler( servicer.StartProcess, request_deserializer=ateenv_dot_v1alpha_dot_guest__pb2.StartProcessRequest.FromString, - response_serializer=ateenv_dot_v1alpha_dot_guest__pb2.StartProcessResponse.SerializeToString, + response_serializer=ateenv_dot_v1alpha_dot_guest__pb2.Process.SerializeToString, ), 'GetProcess': grpc.unary_unary_rpc_method_handler( servicer.GetProcess, request_deserializer=ateenv_dot_v1alpha_dot_guest__pb2.GetProcessRequest.FromString, response_serializer=ateenv_dot_v1alpha_dot_guest__pb2.Process.SerializeToString, ), - 'StreamProcessOutputs': grpc.unary_stream_rpc_method_handler( - servicer.StreamProcessOutputs, - request_deserializer=ateenv_dot_v1alpha_dot_guest__pb2.StreamProcessOutputsRequest.FromString, - response_serializer=ateenv_dot_v1alpha_dot_guest__pb2.OutputChunk.SerializeToString, + 'StreamProcessOutput': grpc.unary_stream_rpc_method_handler( + servicer.StreamProcessOutput, + request_deserializer=ateenv_dot_v1alpha_dot_guest__pb2.StreamProcessOutputRequest.FromString, + response_serializer=ateenv_dot_v1alpha_dot_guest__pb2.ProcessOutput.SerializeToString, ), - 'KillProcess': grpc.unary_unary_rpc_method_handler( - servicer.KillProcess, - request_deserializer=ateenv_dot_v1alpha_dot_guest__pb2.KillProcessRequest.FromString, - response_serializer=ateenv_dot_v1alpha_dot_guest__pb2.KillProcessResponse.SerializeToString, + 'WriteProcessInput': grpc.stream_unary_rpc_method_handler( + servicer.WriteProcessInput, + request_deserializer=ateenv_dot_v1alpha_dot_guest__pb2.WriteProcessInputRequest.FromString, + response_serializer=ateenv_dot_v1alpha_dot_guest__pb2.WriteProcessInputResponse.SerializeToString, + ), + 'SignalProcess': grpc.unary_unary_rpc_method_handler( + servicer.SignalProcess, + request_deserializer=ateenv_dot_v1alpha_dot_guest__pb2.SignalProcessRequest.FromString, + response_serializer=ateenv_dot_v1alpha_dot_guest__pb2.Process.SerializeToString, ), } generic_handler = grpc.method_handlers_generic_handler( 'ateenv.v1alpha.ProcessService', rpc_method_handlers) server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('ateenv.v1alpha.ProcessService', rpc_method_handlers) # This class is part of an EXPERIMENTAL API. -class ProcessService(object): +class ProcessService: """============================================================================ --- SERVICES --- ============================================================================ - ProcessService manages the asynchronous lifecycle and output streaming of - processes running inside the container. + ProcessService manages the lifecycle and I/O of processes running inside + the container. + + A process is started asynchronously and addressed by its process_id. Output + is spooled by the guest and can be streamed live or replayed from an offset. + Input is fed through a client stream, and any POSIX signal can be delivered + to the process group. """ @staticmethod @@ -144,11 +205,21 @@ def StartProcess(request, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.unary_unary(request, target, '/ateenv.v1alpha.ProcessService/StartProcess', + return grpc.experimental.unary_unary( + request, + target, + '/ateenv.v1alpha.ProcessService/StartProcess', ateenv_dot_v1alpha_dot_guest__pb2.StartProcessRequest.SerializeToString, - ateenv_dot_v1alpha_dot_guest__pb2.StartProcessResponse.FromString, - options, channel_credentials, - insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + ateenv_dot_v1alpha_dot_guest__pb2.Process.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) @staticmethod def GetProcess(request, @@ -161,14 +232,24 @@ def GetProcess(request, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.unary_unary(request, target, '/ateenv.v1alpha.ProcessService/GetProcess', + return grpc.experimental.unary_unary( + request, + target, + '/ateenv.v1alpha.ProcessService/GetProcess', ateenv_dot_v1alpha_dot_guest__pb2.GetProcessRequest.SerializeToString, ateenv_dot_v1alpha_dot_guest__pb2.Process.FromString, - options, channel_credentials, - insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) @staticmethod - def StreamProcessOutputs(request, + def StreamProcessOutput(request, target, options=(), channel_credentials=None, @@ -178,14 +259,24 @@ def StreamProcessOutputs(request, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.unary_stream(request, target, '/ateenv.v1alpha.ProcessService/StreamProcessOutputs', - ateenv_dot_v1alpha_dot_guest__pb2.StreamProcessOutputsRequest.SerializeToString, - ateenv_dot_v1alpha_dot_guest__pb2.OutputChunk.FromString, - options, channel_credentials, - insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + return grpc.experimental.unary_stream( + request, + target, + '/ateenv.v1alpha.ProcessService/StreamProcessOutput', + ateenv_dot_v1alpha_dot_guest__pb2.StreamProcessOutputRequest.SerializeToString, + ateenv_dot_v1alpha_dot_guest__pb2.ProcessOutput.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) @staticmethod - def KillProcess(request, + def WriteProcessInput(request_iterator, target, options=(), channel_credentials=None, @@ -195,14 +286,51 @@ def KillProcess(request, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.unary_unary(request, target, '/ateenv.v1alpha.ProcessService/KillProcess', - ateenv_dot_v1alpha_dot_guest__pb2.KillProcessRequest.SerializeToString, - ateenv_dot_v1alpha_dot_guest__pb2.KillProcessResponse.FromString, - options, channel_credentials, - insecure, call_credentials, compression, wait_for_ready, timeout, metadata) - + return grpc.experimental.stream_unary( + request_iterator, + target, + '/ateenv.v1alpha.ProcessService/WriteProcessInput', + ateenv_dot_v1alpha_dot_guest__pb2.WriteProcessInputRequest.SerializeToString, + ateenv_dot_v1alpha_dot_guest__pb2.WriteProcessInputResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) -class FileSystemServiceStub(object): + @staticmethod + def SignalProcess(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/ateenv.v1alpha.ProcessService/SignalProcess', + ateenv_dot_v1alpha_dot_guest__pb2.SignalProcessRequest.SerializeToString, + ateenv_dot_v1alpha_dot_guest__pb2.Process.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + +class FileSystemServiceStub: """FileSystemService provides streaming file reading and writing capabilities inside the container rootfs/workspace to prevent memory exhaustion (OOM). """ @@ -216,16 +344,16 @@ def __init__(self, channel): self.ReadFile = channel.unary_stream( '/ateenv.v1alpha.FileSystemService/ReadFile', request_serializer=ateenv_dot_v1alpha_dot_guest__pb2.ReadFileRequest.SerializeToString, - response_deserializer=ateenv_dot_v1alpha_dot_guest__pb2.FileChunk.FromString, - ) + response_deserializer=ateenv_dot_v1alpha_dot_guest__pb2.ReadFileResponse.FromString, + _registered_method=True) self.WriteFile = channel.stream_unary( '/ateenv.v1alpha.FileSystemService/WriteFile', request_serializer=ateenv_dot_v1alpha_dot_guest__pb2.WriteFileRequest.SerializeToString, response_deserializer=ateenv_dot_v1alpha_dot_guest__pb2.WriteFileResponse.FromString, - ) + _registered_method=True) -class FileSystemServiceServicer(object): +class FileSystemServiceServicer: """FileSystemService provides streaming file reading and writing capabilities inside the container rootfs/workspace to prevent memory exhaustion (OOM). """ @@ -250,7 +378,7 @@ def add_FileSystemServiceServicer_to_server(servicer, server): 'ReadFile': grpc.unary_stream_rpc_method_handler( servicer.ReadFile, request_deserializer=ateenv_dot_v1alpha_dot_guest__pb2.ReadFileRequest.FromString, - response_serializer=ateenv_dot_v1alpha_dot_guest__pb2.FileChunk.SerializeToString, + response_serializer=ateenv_dot_v1alpha_dot_guest__pb2.ReadFileResponse.SerializeToString, ), 'WriteFile': grpc.stream_unary_rpc_method_handler( servicer.WriteFile, @@ -261,10 +389,11 @@ def add_FileSystemServiceServicer_to_server(servicer, server): generic_handler = grpc.method_handlers_generic_handler( 'ateenv.v1alpha.FileSystemService', rpc_method_handlers) server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('ateenv.v1alpha.FileSystemService', rpc_method_handlers) # This class is part of an EXPERIMENTAL API. -class FileSystemService(object): +class FileSystemService: """FileSystemService provides streaming file reading and writing capabilities inside the container rootfs/workspace to prevent memory exhaustion (OOM). """ @@ -280,11 +409,21 @@ def ReadFile(request, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.unary_stream(request, target, '/ateenv.v1alpha.FileSystemService/ReadFile', + return grpc.experimental.unary_stream( + request, + target, + '/ateenv.v1alpha.FileSystemService/ReadFile', ateenv_dot_v1alpha_dot_guest__pb2.ReadFileRequest.SerializeToString, - ateenv_dot_v1alpha_dot_guest__pb2.FileChunk.FromString, - options, channel_credentials, - insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + ateenv_dot_v1alpha_dot_guest__pb2.ReadFileResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) @staticmethod def WriteFile(request_iterator, @@ -297,8 +436,18 @@ def WriteFile(request_iterator, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.stream_unary(request_iterator, target, '/ateenv.v1alpha.FileSystemService/WriteFile', + return grpc.experimental.stream_unary( + request_iterator, + target, + '/ateenv.v1alpha.FileSystemService/WriteFile', ateenv_dot_v1alpha_dot_guest__pb2.WriteFileRequest.SerializeToString, ateenv_dot_v1alpha_dot_guest__pb2.WriteFileResponse.FromString, - options, channel_credentials, - insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/clients/python/src/ate_env/env.py b/clients/python/src/ate_env/env.py index f0c061c..dbd385d 100644 --- a/clients/python/src/ate_env/env.py +++ b/clients/python/src/ate_env/env.py @@ -12,36 +12,42 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Handle to a single environment.""" +"""Handles to a single environment and to processes running inside it.""" from __future__ import annotations -import asyncio from collections.abc import AsyncIterable, AsyncIterator, Iterable, Mapping, Sequence +from datetime import timedelta from typing import TYPE_CHECKING import grpc from ._gen.ateenv.v1alpha import guest_pb2 -from .errors import map_rpc_error +from .errors import ProcessExitedError, map_rpc_error from .types import ( EnvironmentInfo, - OutputChunk, - OutputSource, ProcessInfo, - ProcessStatus, + ProcessOutput, ShellResult, + Signal, _process_info_from_pb, + _process_output_from_pb, ) if TYPE_CHECKING: from .client import Client -__all__ = ["Env"] +__all__ = ["Env", "Process"] # Matches the server's file chunk size (guest/filesystem). _CHUNK_SIZE = 64 * 1024 +# Offsets past any spool: follow the stream without receiving output. +_SKIP_ALL = 2**63 - 1 + +_Bytes = bytes | bytearray | memoryview +_ByteSource = _Bytes | Iterable[bytes] | AsyncIterable[bytes] + class Env: """A handle to a single environment. @@ -89,96 +95,69 @@ async def start_process( *, cwd: str = "", env: Mapping[str, str] | None = None, - ) -> str: - """Launch a process asynchronously; returns its process id.""" + stdin: bool = False, + timeout: float | timedelta | None = None, + ) -> Process: + """Launch a process in the background and return a handle to it. + + stdin=True opens a pipe for standard input, fed with + Process.write_input(); otherwise the process reads EOF immediately. + timeout (seconds or timedelta) kills the process with SIGKILL when it + elapses; None applies the guest default. + """ req = guest_pb2.StartProcessRequest( - command=list(command), cwd=cwd, env=dict(env or {}) + command=list(command), cwd=cwd, env=dict(env or {}), stdin=stdin ) + if timeout is not None: + req.timeout.FromTimedelta( + timeout if isinstance(timeout, timedelta) else timedelta(seconds=timeout) + ) try: - resp = await self._client._processes.StartProcess(req, metadata=self._metadata()) - except grpc.RpcError as e: - raise map_rpc_error(e) from e - return resp.process_id - - async def get_process(self, process_id: str) -> ProcessInfo: - """Retrieve the current state of a process.""" - req = guest_pb2.GetProcessRequest(process_id=process_id) - try: - proc = await self._client._processes.GetProcess(req, metadata=self._metadata()) + proc = await self._client._processes.StartProcess(req, metadata=self._metadata()) except grpc.RpcError as e: raise map_rpc_error(e) from e - return _process_info_from_pb(proc) + return Process(self, proc.process_id) - async def kill_process(self, process_id: str) -> int: - """Terminate a running process; returns its exit code.""" - req = guest_pb2.KillProcessRequest(process_id=process_id) - try: - resp = await self._client._processes.KillProcess(req, metadata=self._metadata()) - except grpc.RpcError as e: - raise map_rpc_error(e) from e - return resp.exit_code + def process(self, process_id: str) -> Process: + """Return a handle to an existing process without checking that it exists.""" + return Process(self, process_id) - async def stream_outputs( + async def shell( self, - process_id: str, + command_line: str, *, - follow: bool = False, - stdout_offset: int = 0, - stderr_offset: int = 0, - ) -> AsyncIterator[OutputChunk]: - """Stream stdout/stderr chunks from a process. + stdin: bytes | str | None = None, + cwd: str = "", + env: Mapping[str, str] | None = None, + timeout: float | timedelta | None = None, + ) -> ShellResult: + """Run `sh -c command_line`, feed it stdin, and capture output and exit status. - With follow=True the stream ends when the process exits; on a - long-lived process it blocks until then, so consume it under a - timeout or cancel the task. Breaking out of the loop cancels the - underlying RPC. + Output is buffered fully in memory; for incremental output or + interactive input use start_process(). """ - req = guest_pb2.StreamProcessOutputsRequest( - process_id=process_id, - stdout_offset=stdout_offset, - stderr_offset=stderr_offset, - follow=follow, + proc = await self.start_process( + ["sh", "-c", command_line], cwd=cwd, env=env, stdin=stdin is not None, timeout=timeout ) - call = self._client._processes.StreamProcessOutputs(req, metadata=self._metadata()) - try: - async for chunk in call: - yield OutputChunk(source=OutputSource(chunk.source), data=chunk.data) - except grpc.RpcError as e: - raise map_rpc_error(e) from e - finally: - call.cancel() - - async def wait(self, process_id: str, *, poll_interval: float = 0.02) -> ProcessInfo: - """Poll until the process is no longer running; returns its final state.""" - while True: - proc = await self.get_process(process_id) - if proc.status != ProcessStatus.RUNNING: - return proc - await asyncio.sleep(poll_interval) - - async def shell(self, command_line: str) -> ShellResult: - """Run a shell command line and capture its output and exit code. - - Output is buffered fully in memory; for incremental output use - start_process() with stream_outputs(). - """ - process_id = await self.start_process(["sh", "-c", command_line]) + if stdin is not None: + await proc.write_input(stdin.encode() if isinstance(stdin, str) else stdin, close=True) stdout = bytearray() stderr = bytearray() - async for chunk in self.stream_outputs(process_id, follow=True): - if chunk.source == OutputSource.STDOUT: - stdout.extend(chunk.data) - elif chunk.source == OutputSource.STDERR: - stderr.extend(chunk.data) - - # The follow stream can end before the process record's status - # flips, so poll for the final state to get the exit code. - proc = await self.wait(process_id) + exit_info: ProcessInfo | None = None + async for out in proc.output(follow=True): + if out.stdout is not None: + stdout.extend(out.stdout) + elif out.stderr is not None: + stderr.extend(out.stderr) + elif out.exit is not None: + exit_info = out.exit + if exit_info is None: + raise RuntimeError("ate_env: output stream ended before the process exited") return ShellResult( stdout=stdout.decode("utf-8", errors="replace"), stderr=stderr.decode("utf-8", errors="replace"), - exit_code=proc.exit_code, + exit_code=exit_info.exit_code, ) # --- files --- @@ -191,9 +170,9 @@ async def read_file(self, path: str) -> AsyncIterator[bytes]: req = guest_pb2.ReadFileRequest(path=path) call = self._client._filesystem.ReadFile(req, metadata=self._metadata()) try: - async for chunk in call: - if chunk.data: - yield chunk.data + async for msg in call: + if msg.chunk: + yield msg.chunk except grpc.RpcError as e: raise map_rpc_error(e) from e finally: @@ -206,53 +185,196 @@ async def read_file_bytes(self, path: str) -> bytes: async def write_file( self, path: str, - data: bytes | bytearray | memoryview | Iterable[bytes] | AsyncIterable[bytes], + data: _ByteSource, *, mode: int = 0o644, + seek_offset: int = 0, ) -> int: """Write data to a file; returns the number of bytes written. data may be bytes-like (sent in 64 KiB chunks) or a sync/async - iterable of bytes chunks. + iterable of bytes chunks. With seek_offset=0 (the default) the file + is replaced. A positive seek_offset keeps existing content and + starts writing at that byte, extending the file with zeros if it is + shorter. mode applies when the file is created. """ - # Validate eagerly: errors raised inside the request generator are - # swallowed by grpc.aio and surface as an opaque CancelledError. - if isinstance(data, str): - raise TypeError("ate_env: write_file expects bytes, not str; encode it first") - if not isinstance(data, (bytes, bytearray, memoryview, Iterable, AsyncIterable)): - raise TypeError(f"ate_env: unsupported write_file data type: {type(data)!r}") + _validate_byte_source(data, "write_file") + if seek_offset < 0: + raise ValueError(f"ate_env: seek_offset must be >= 0, got {seek_offset}") try: resp = await self._client._filesystem.WriteFile( - self._write_requests(path, data, mode), metadata=self._metadata() + self._write_requests(path, data, mode, seek_offset), metadata=self._metadata() ) except grpc.RpcError as e: raise map_rpc_error(e) from e return resp.bytes_written async def _write_requests( - self, - path: str, - data: bytes | bytearray | memoryview | Iterable[bytes] | AsyncIterable[bytes], - mode: int, + self, path: str, data: _ByteSource, mode: int, seek_offset: int ) -> AsyncIterator[guest_pb2.WriteFileRequest]: chunks = _chunk_stream(data) - # The first message carries path and mode, even for empty input - # (required to create empty files). + # The first message carries path, mode and seek_offset, even for + # empty input (required to create empty files). first = b"" async for chunk in chunks: first = chunk break - yield guest_pb2.WriteFileRequest(path=path, mode=mode & 0o777, chunk=first) + yield guest_pb2.WriteFileRequest( + path=path, mode=mode & 0o777, chunk=first, seek_offset=seek_offset + ) async for chunk in chunks: if chunk: yield guest_pb2.WriteFileRequest(chunk=chunk) -async def _chunk_stream( - data: bytes | bytearray | memoryview | Iterable[bytes] | AsyncIterable[bytes], -) -> AsyncIterator[bytes]: +class Process: + """A handle to a process inside an environment. + + Constructed by Env.start_process() or Env.process(). + """ + + def __init__(self, env: Env, process_id: str): + self._env = env + self._id = process_id + + @property + def id(self) -> str: + """The guest-assigned process identifier.""" + return self._id + + @property + def env(self) -> Env: + """The environment the process runs in.""" + return self._env + + def __repr__(self) -> str: + return f"Process({self._id!r})" + + @property + def _stub(self): + return self._env._client._processes + + async def info(self) -> ProcessInfo: + """Retrieve the current state of the process.""" + req = guest_pb2.GetProcessRequest(process_id=self._id) + try: + proc = await self._stub.GetProcess(req, metadata=self._env._metadata()) + except grpc.RpcError as e: + raise map_rpc_error(e) from e + return _process_info_from_pb(proc) + + async def output( + self, + *, + follow: bool = False, + stdout_offset: int = 0, + stderr_offset: int = 0, + ) -> AsyncIterator[ProcessOutput]: + """Stream stdout and stderr; the final message carries the exit state. + + With follow=True the stream stays open until the process exits, so on + a long-lived process it blocks until then; consume it under a timeout + or cancel the task. Without follow, the output spooled so far is + returned and the exit message is present only if the process has + already exited. Breaking out of the loop cancels the underlying RPC. + """ + req = guest_pb2.StreamProcessOutputRequest( + process_id=self._id, + stdout_offset=stdout_offset, + stderr_offset=stderr_offset, + follow=follow, + ) + call = self._stub.StreamProcessOutput(req, metadata=self._env._metadata()) + try: + async for msg in call: + yield _process_output_from_pb(msg) + except grpc.RpcError as e: + raise map_rpc_error(e) from e + finally: + call.cancel() + + async def wait(self) -> ProcessInfo: + """Block until the process exits and return its final state. + + Follows the output stream with offsets past the end of the spool, so + no output is transferred. + """ + async for out in self.output( + follow=True, stdout_offset=_SKIP_ALL, stderr_offset=_SKIP_ALL + ): + if out.exit is not None: + return out.exit + raise RuntimeError("ate_env: output stream ended before the process exited") + + async def write_input(self, data: _ByteSource, *, close: bool = False) -> int: + """Write to the process's stdin; returns the number of bytes written. + + The process must have been started with stdin=True. stdin stays open + across calls until one sets close=True (EOF). data may be bytes-like + or a sync/async iterable of bytes chunks. + """ + _validate_byte_source(data, "write_input") + try: + resp = await self._stub.WriteProcessInput( + self._input_requests(data, close), metadata=self._env._metadata() + ) + except grpc.RpcError as e: + raise map_rpc_error(e) from e + return resp.bytes_written + + async def close_input(self) -> None: + """Close the process's stdin, delivering EOF.""" + await self.write_input(b"", close=True) + + async def _input_requests( + self, data: _ByteSource, close: bool + ) -> AsyncIterator[guest_pb2.WriteProcessInputRequest]: + first = True + async for chunk in _chunk_stream(data): + if not chunk: + continue + req = guest_pb2.WriteProcessInputRequest(data=chunk) + if first: + req.process_id = self._id + first = False + yield req + if first or close: + # Either nothing was sent (the first message must name the + # process) or EOF was requested; both need a final message. + yield guest_pb2.WriteProcessInputRequest( + process_id=self._id if first else "", close=close + ) + + async def signal(self, sig: Signal) -> None: + """Deliver a signal to the process and its process group.""" + req = guest_pb2.SignalProcessRequest(process_id=self._id, signal=int(sig)) + try: + await self._stub.SignalProcess(req, metadata=self._env._metadata()) + except grpc.RpcError as e: + raise map_rpc_error(e) from e + + async def kill(self) -> ProcessInfo: + """Send SIGKILL and wait for the process to exit. + + Killing a process that has already exited is not an error. + """ + try: + await self.signal(Signal.KILL) + except ProcessExitedError: + pass + return await self.wait() + + +def _validate_byte_source(data: object, what: str) -> None: + # Validate eagerly: errors raised inside a request generator are + # swallowed by grpc.aio and surface as an opaque CancelledError. if isinstance(data, str): - raise TypeError("ate_env: write_file expects bytes, not str; encode it first") + raise TypeError(f"ate_env: {what} expects bytes, not str; encode it first") + if not isinstance(data, (bytes, bytearray, memoryview, Iterable, AsyncIterable)): + raise TypeError(f"ate_env: unsupported {what} data type: {type(data)!r}") + + +async def _chunk_stream(data: _ByteSource) -> AsyncIterator[bytes]: if isinstance(data, (bytes, bytearray, memoryview)): buf = bytes(data) for i in range(0, len(buf), _CHUNK_SIZE): @@ -264,4 +386,4 @@ async def _chunk_stream( for chunk in data: yield bytes(chunk) else: - raise TypeError(f"ate_env: unsupported write_file data type: {type(data)!r}") + raise TypeError(f"ate_env: unsupported data type: {type(data)!r}") diff --git a/clients/python/src/ate_env/errors.py b/clients/python/src/ate_env/errors.py index 23bd883..3b8afea 100644 --- a/clients/python/src/ate_env/errors.py +++ b/clients/python/src/ate_env/errors.py @@ -23,6 +23,8 @@ "NotFoundError", "InvalidArgumentError", "PermissionDeniedError", + "FailedPreconditionError", + "ProcessExitedError", "RpcError", "map_rpc_error", ] @@ -44,6 +46,14 @@ class PermissionDeniedError(EnvError): """Access denied — e.g. a file path outside the workspace sandbox.""" +class FailedPreconditionError(EnvError): + """The operation is not valid in the target's current state.""" + + +class ProcessExitedError(FailedPreconditionError): + """The process has already exited (signals and stdin need a running one).""" + + class RpcError(EnvError): """Any other RPC failure; carries the gRPC status code.""" @@ -67,4 +77,8 @@ def map_rpc_error(err: BaseException) -> BaseException: return InvalidArgumentError(details) if code == grpc.StatusCode.PERMISSION_DENIED: return PermissionDeniedError(details) + if code == grpc.StatusCode.FAILED_PRECONDITION: + if "has exited" in details: + return ProcessExitedError(details) + return FailedPreconditionError(details) return RpcError(details, code) diff --git a/clients/python/src/ate_env/types.py b/clients/python/src/ate_env/types.py index 32b830f..d7aa381 100644 --- a/clients/python/src/ate_env/types.py +++ b/clients/python/src/ate_env/types.py @@ -28,12 +28,12 @@ __all__ = [ "EnvironmentStatus", - "ProcessStatus", - "OutputSource", + "ProcessState", + "Signal", "Template", "EnvironmentInfo", "ProcessInfo", - "OutputChunk", + "ProcessOutput", "ShellResult", ] @@ -52,22 +52,46 @@ class EnvironmentStatus(enum.IntEnum): DELETING = 8 -class ProcessStatus(enum.IntEnum): - """Execution status of an asynchronous process (ateenv.v1alpha.ProcessStatus).""" +class ProcessState(enum.IntEnum): + """Lifecycle state of a process (ateenv.v1alpha.ProcessState).""" UNSPECIFIED = 0 RUNNING = 1 - COMPLETED = 2 - FAILED = 3 - TERMINATED = 4 - - -class OutputSource(enum.IntEnum): - """Output log stream source (ateenv.v1alpha.OutputSource).""" - - UNSPECIFIED = 0 - STDOUT = 1 - STDERR = 2 + EXITED = 2 + + +class Signal(enum.IntEnum): + """POSIX signal deliverable with Process.signal(); values match Linux signal numbers.""" + + HUP = 1 + INT = 2 + QUIT = 3 + ILL = 4 + TRAP = 5 + ABRT = 6 + BUS = 7 + FPE = 8 + KILL = 9 + USR1 = 10 + SEGV = 11 + USR2 = 12 + PIPE = 13 + ALRM = 14 + TERM = 15 + CHLD = 17 + CONT = 18 + STOP = 19 + TSTP = 20 + TTIN = 21 + TTOU = 22 + URG = 23 + XCPU = 24 + XFSZ = 25 + VTALRM = 26 + PROF = 27 + WINCH = 28 + IO = 29 + SYS = 31 @dataclass(frozen=True) @@ -90,30 +114,42 @@ class EnvironmentInfo: @dataclass(frozen=True) class ProcessInfo: - """Execution state and metadata of a process.""" + """Identity, lifecycle state, and exit status of a process.""" process_id: str - status: ProcessStatus - exit_code: int # valid once status is not RUNNING + command: tuple[str, ...] + pid: int + state: ProcessState + exit_code: int # valid once EXITED; 128 + signal number if killed by a signal started_at: datetime | None finished_at: datetime | None + @property + def running(self) -> bool: + """True until the process has exited.""" + return self.state == ProcessState.RUNNING + @dataclass(frozen=True) -class OutputChunk: - """A chunk of process output from stdout or stderr.""" +class ProcessOutput: + """One message from a process output stream; exactly one field is set. + + exit is the final message: the process has exited and all output was + delivered. It is absent if the stream ends while the process runs. + """ - source: OutputSource - data: bytes + stdout: bytes | None = None + stderr: bytes | None = None + exit: ProcessInfo | None = None @dataclass(frozen=True) class ShellResult: - """Captured output and exit code of a shell command.""" + """Captured output and exit status of a shell command.""" stdout: str stderr: str - exit_code: int + exit_code: int # 128 + signal number if killed by a signal def _template_from_pb(pb: env_pb2.Template) -> Template: @@ -139,8 +175,21 @@ def _process_info_from_pb(pb: guest_pb2.Process) -> ProcessInfo: ) return ProcessInfo( process_id=pb.process_id, - status=ProcessStatus(pb.status), + command=tuple(pb.command), + pid=pb.pid, + state=ProcessState(pb.state), exit_code=pb.exit_code, started_at=started_at, finished_at=finished_at, ) + + +def _process_output_from_pb(pb: guest_pb2.ProcessOutput) -> ProcessOutput: + which = pb.WhichOneof("output") + if which == "stdout": + return ProcessOutput(stdout=pb.stdout) + if which == "stderr": + return ProcessOutput(stderr=pb.stderr) + if which == "exit": + return ProcessOutput(exit=_process_info_from_pb(pb.exit)) + raise ValueError(f"ate_env: unexpected process output {pb!r}") diff --git a/clients/python/tests/e2e/test_full_stack.py b/clients/python/tests/e2e/test_full_stack.py index 7d897d2..7705bcb 100644 --- a/clients/python/tests/e2e/test_full_stack.py +++ b/clients/python/tests/e2e/test_full_stack.py @@ -40,9 +40,9 @@ Client, EnvError, EnvironmentStatus, - OutputSource, NotFoundError, - ProcessStatus, + ProcessState, + Signal, ) TARGET = os.environ.get("ATE_ENV_API_TARGET") @@ -127,24 +127,31 @@ async def test_full_lifecycle(client): with pytest.raises(NotFoundError): await env.read_file_bytes("/tmp/pye2e-does-not-exist") - # Processes: follow-stream, final state, kill. - pid = await env.start_process(["sh", "-c", "echo one; echo two >&2"]) + # Processes: follow-stream with exit, stdin, signals. + proc = await env.start_process(["sh", "-c", "echo one; echo two >&2"]) stdout = bytearray() stderr = bytearray() - async for chunk in env.stream_outputs(pid, follow=True): - if chunk.source == OutputSource.STDOUT: - stdout.extend(chunk.data) + exit_info = None + async for out in proc.output(follow=True): + if out.stdout is not None: + stdout.extend(out.stdout) + elif out.stderr is not None: + stderr.extend(out.stderr) else: - stderr.extend(chunk.data) + exit_info = out.exit assert stdout == b"one\n" assert stderr == b"two\n" - proc = await env.wait(pid, poll_interval=0.5) - assert proc.status == ProcessStatus.COMPLETED + assert exit_info is not None and exit_info.state == ProcessState.EXITED + assert exit_info.exit_code == 0 + + cat = await env.start_process(["cat"], stdin=True) + await cat.write_input(b"through the proxy\n", close=True) + assert (await cat.wait()).exit_code == 0 + assert b"".join([o.stdout async for o in cat.output() if o.stdout]) == b"through the proxy\n" sleeper = await env.start_process(["sleep", "300"]) - assert await env.kill_process(sleeper) >= 128 - proc = await env.wait(sleeper, poll_interval=0.5) - assert proc.status == ProcessStatus.TERMINATED + await sleeper.signal(Signal.TERM) + assert (await sleeper.wait()).exit_code == 143 # 128 + SIGTERM # Lifecycle: suspend checkpoints the environment. await env.suspend() diff --git a/clients/python/tests/e2e/test_guest_daemon.py b/clients/python/tests/e2e/test_guest_daemon.py index a64f8d0..ac524fe 100644 --- a/clients/python/tests/e2e/test_guest_daemon.py +++ b/clients/python/tests/e2e/test_guest_daemon.py @@ -37,10 +37,12 @@ from ate_env import ( Client, - OutputSource, + FailedPreconditionError, NotFoundError, PermissionDeniedError, - ProcessStatus, + ProcessExitedError, + ProcessState, + Signal, ) TARGET = os.environ.get("ATE_ENV_GUEST_TARGET") @@ -80,54 +82,119 @@ async def test_file_roundtrip(guest_env): assert await guest_env.read_file_bytes(path) == content -async def test_stream_outputs_follow(guest_env): - pid = await guest_env.start_process(["sh", "-c", "echo one; echo two; echo err >&2"]) +async def _stdout(outputs) -> bytes: + return b"".join([o.stdout async for o in outputs if o.stdout is not None]) + + +async def test_write_file_seek_offset(guest_env): + path = f"ate-env-e2e-{uuid.uuid4().hex}.txt" + await guest_env.write_file(path, b"hello world") + await guest_env.write_file(path, b"W", seek_offset=6) + assert await guest_env.read_file_bytes(path) == b"hello World" + await guest_env.write_file(path, b"!", seek_offset=13) + assert await guest_env.read_file_bytes(path) == b"hello World\0\0!" + + +async def test_output_follow_ends_with_exit(guest_env): + proc = await guest_env.start_process(["sh", "-c", "echo one; echo two; echo err >&2; exit 4"]) stdout = bytearray() stderr = bytearray() - async for chunk in guest_env.stream_outputs(pid, follow=True): - if chunk.source == OutputSource.STDOUT: - stdout.extend(chunk.data) + exit_info = None + async for out in proc.output(follow=True): + if out.stdout is not None: + stdout.extend(out.stdout) + elif out.stderr is not None: + stderr.extend(out.stderr) else: - stderr.extend(chunk.data) + exit_info = out.exit assert stdout == b"one\ntwo\n" assert stderr == b"err\n" - proc = await guest_env.wait(pid) - assert proc.status == ProcessStatus.COMPLETED + assert exit_info is not None + assert exit_info.state == ProcessState.EXITED + assert exit_info.exit_code == 4 async def test_kill_process(guest_env): - pid = await guest_env.start_process(["sleep", "30"]) - exit_code = await guest_env.kill_process(pid) - assert exit_code >= 128 # 128 + signal number - proc = await guest_env.wait(pid) - assert proc.status == ProcessStatus.TERMINATED + proc = await guest_env.start_process(["sleep", "30"]) + info = await proc.kill() + assert info.state == ProcessState.EXITED + assert info.exit_code == 137 # 128 + SIGKILL + # Idempotent, and other signals are refused once exited. + assert (await proc.kill()).exit_code == 137 + with pytest.raises(ProcessExitedError): + await proc.signal(Signal.TERM) + + +async def test_signal_term(guest_env): + proc = await guest_env.start_process(["sleep", "30"]) + await proc.signal(Signal.TERM) + info = await proc.wait() + assert info.exit_code == 143 # 128 + SIGTERM + + +async def test_signal_trapped(guest_env): + proc = await guest_env.start_process( + ["sh", "-c", "trap 'echo got-usr1; exit 7' USR1; while :; do sleep 0.05; done"] + ) + await asyncio.sleep(0.3) + await proc.signal(Signal.USR1) + info = await proc.wait() + assert info.exit_code == 7 + assert b"got-usr1" in await _stdout(proc.output()) + + +async def test_stdin_streaming(guest_env): + proc = await guest_env.start_process(["cat"], stdin=True) + await proc.write_input(b"hello ") + await proc.write_input(b"world\n", close=True) + info = await proc.wait() + assert info.exit_code == 0 + assert await _stdout(proc.output()) == b"hello world\n" + + +async def test_stdin_not_requested(guest_env): + proc = await guest_env.start_process(["cat"]) + info = await proc.wait() + assert info.exit_code == 0 # immediate EOF + with pytest.raises(FailedPreconditionError, match="without stdin"): + await proc.write_input(b"x") + + +async def test_shell_with_stdin(guest_env): + result = await guest_env.shell("tr a-z A-Z", stdin="shout\n") + assert result.stdout == "SHOUT\n" + assert result.exit_code == 0 + + +async def test_shell_timeout(guest_env): + result = await guest_env.shell("sleep 30", timeout=0.2) + assert result.exit_code == 137 async def test_cwd_passthrough(guest_env): - pid = await guest_env.start_process(["pwd"], cwd="/") - proc = await guest_env.wait(pid) - assert proc.status == ProcessStatus.COMPLETED - output = b"".join( - [c.data async for c in guest_env.stream_outputs(pid) if c.source == OutputSource.STDOUT] - ) - assert output == b"/\n" + proc = await guest_env.start_process(["pwd"], cwd="/") + info = await proc.wait() + assert info.exit_code == 0 + assert await _stdout(proc.output()) == b"/\n" async def test_env_passthrough(guest_env): - result_pid = await guest_env.start_process( + proc = await guest_env.start_process( ["sh", "-c", "echo $ATE_E2E_MARKER"], env={"ATE_E2E_MARKER": "marker-42"} ) - await guest_env.wait(result_pid) - output = b"".join([c.data async for c in guest_env.stream_outputs(result_pid)]) - assert output == b"marker-42\n" + await proc.wait() + assert await _stdout(proc.output()) == b"marker-42\n" -async def test_process_timestamps(guest_env): - pid = await guest_env.start_process(["true"]) - proc = await guest_env.wait(pid) - assert proc.started_at is not None - assert proc.finished_at is not None - assert proc.finished_at >= proc.started_at +async def test_process_info(guest_env): + proc = await guest_env.start_process(["true"]) + info = await proc.wait() + assert info.command == ("true",) + assert info.pid > 0 + assert info.started_at is not None + assert info.finished_at is not None + assert info.finished_at >= info.started_at + assert (await guest_env.process(proc.id).info()) == info async def test_binary_file_roundtrip(guest_env): @@ -156,20 +223,18 @@ async def gen(): assert written == len(content) -async def test_stream_outputs_offset_replay(guest_env): - pid = await guest_env.start_process(["sh", "-c", "printf abcdef; printf 123456 >&2"]) - await guest_env.wait(pid) +async def test_output_offset_replay(guest_env): + proc = await guest_env.start_process(["sh", "-c", "printf abcdef; printf 123456 >&2"]) + await proc.wait() - full = [(c.source, c.data) async for c in guest_env.stream_outputs(pid)] - assert b"".join(d for s, d in full if s == OutputSource.STDOUT) == b"abcdef" - assert b"".join(d for s, d in full if s == OutputSource.STDERR) == b"123456" + full = [o async for o in proc.output()] + assert b"".join(o.stdout for o in full if o.stdout is not None) == b"abcdef" + assert b"".join(o.stderr for o in full if o.stderr is not None) == b"123456" + assert full[-1].exit is not None - replay = [ - (c.source, c.data) - async for c in guest_env.stream_outputs(pid, stdout_offset=4, stderr_offset=2) - ] - assert b"".join(d for s, d in replay if s == OutputSource.STDOUT) == b"ef" - assert b"".join(d for s, d in replay if s == OutputSource.STDERR) == b"3456" + replay = [o async for o in proc.output(stdout_offset=4, stderr_offset=2)] + assert b"".join(o.stdout for o in replay if o.stdout is not None) == b"ef" + assert b"".join(o.stderr for o in replay if o.stderr is not None) == b"3456" async def test_concurrent_shells(guest_env): @@ -189,7 +254,7 @@ async def test_missing_file_maps_to_not_found(guest_env): async def test_missing_process_maps_to_not_found(guest_env): with pytest.raises(NotFoundError): - await guest_env.get_process("bogus-process-id") + await guest_env.process("bogus-process-id").info() async def test_sandbox_escape_maps_to_permission_denied(guest_env): @@ -198,16 +263,16 @@ async def test_sandbox_escape_maps_to_permission_denied(guest_env): async def test_kill_ends_follow_stream(guest_env): - pid = await guest_env.start_process(["sh", "-c", "echo started; sleep 30"]) + proc = await guest_env.start_process(["sh", "-c", "echo started; sleep 30"]) async def consume(): - return [c async for c in guest_env.stream_outputs(pid, follow=True)] + return [o async for o in proc.output(follow=True)] task = asyncio.create_task(consume()) # Give the process time to start and emit its first output. await asyncio.sleep(0.3) - await guest_env.kill_process(pid) - chunks = await asyncio.wait_for(task, timeout=10) - assert any(b"started" in c.data for c in chunks) - proc = await guest_env.wait(pid) - assert proc.status == ProcessStatus.TERMINATED + await proc.kill() + outputs = await asyncio.wait_for(task, timeout=10) + assert any(o.stdout is not None and b"started" in o.stdout for o in outputs) + assert outputs[-1].exit is not None + assert outputs[-1].exit.exit_code == 137 # 128 + SIGKILL diff --git a/clients/python/tests/fakes.py b/clients/python/tests/fakes.py index 0a04acd..5bf5f96 100644 --- a/clients/python/tests/fakes.py +++ b/clients/python/tests/fakes.py @@ -21,6 +21,7 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass, field import grpc @@ -98,20 +99,30 @@ async def _lookup(self, request, context): class FakeProc: """Scripted behavior for one process started against the fake. - running_polls is the number of GetProcess calls that still report - RUNNING before the final status is returned — this exercises the - client's wait()/shell() poll loop, analogous to the sibling SDK's - "resuming" counter. + chunks are (field, data) pairs where field is "stdout" or "stderr"; + they are replayed by StreamProcessOutput. exit_code/signal describe the + final state. running_polls is the number of GetProcess calls that still + report RUNNING before the process is considered exited; a follow stream + exits it immediately once the chunks are replayed. echo_stdin appends + written input as stdout, like cat. """ - chunks: list[tuple[int, bytes]] = field(default_factory=list) # (OutputSource, data) + chunks: list[tuple[str, bytes]] = field(default_factory=list) exit_code: int = 0 + signal: int = 0 running_polls: int = 0 - killed: bool = False - # request capture, filled by StartProcess: + echo_stdin: bool = False + hang_follow: bool = False # a follow stream never ends (process never exits) + # request capture, filled by StartProcess / WriteProcessInput / SignalProcess: command: list[str] = field(default_factory=list) cwd: str = "" env: dict[str, str] = field(default_factory=dict) + stdin: bool = False + timeout_seconds: float | None = None + stdin_data: bytearray = field(default_factory=bytearray) + stdin_closed: bool = False + signals: list[int] = field(default_factory=list) + exited: bool = False class FakeProcessService(guest_pb2_grpc.ProcessServiceServicer): @@ -121,61 +132,125 @@ def __init__(self): self.last_env: tuple[str, str] | None = None self._counter = 0 + def _to_pb(self, process_id: str, proc: FakeProc, *, exited: bool) -> guest_pb2.Process: + pb = guest_pb2.Process( + process_id=process_id, + command=proc.command, + pid=1000 + int(process_id.rsplit("-", 1)[1]), + state=guest_pb2.PROCESS_STATE_EXITED if exited else guest_pb2.PROCESS_STATE_RUNNING, + ) + pb.started_at.GetCurrentTime() + if exited: + pb.finished_at.GetCurrentTime() + pb.exit_code = 128 + proc.signal if proc.signal else proc.exit_code + return pb + async def StartProcess(self, request, context): self.last_env = await _require_env(context) + if not request.command: + await context.abort(grpc.StatusCode.INVALID_ARGUMENT, "command cannot be empty") proc = self.next_procs.pop(0) if self.next_procs else FakeProc() proc.command = list(request.command) proc.cwd = request.cwd proc.env = dict(request.env) + proc.stdin = request.stdin + if request.HasField("timeout"): + proc.timeout_seconds = request.timeout.ToTimedelta().total_seconds() self._counter += 1 process_id = f"proc-{self._counter}" self.procs[process_id] = proc - return guest_pb2.StartProcessResponse(process_id=process_id) + return self._to_pb(process_id, proc, exited=False) async def GetProcess(self, request, context): self.last_env = await _require_env(context) proc = await self._lookup(request.process_id, context) - if proc.running_polls > 0: + if not proc.exited and proc.running_polls > 0: proc.running_polls -= 1 - return guest_pb2.Process( - process_id=request.process_id, - status=guest_pb2.PROCESS_STATUS_RUNNING, - ) - if proc.killed: - status = guest_pb2.PROCESS_STATUS_TERMINATED - elif proc.exit_code == 0: - status = guest_pb2.PROCESS_STATUS_COMPLETED - else: - status = guest_pb2.PROCESS_STATUS_FAILED - final = guest_pb2.Process( - process_id=request.process_id, - status=status, - exit_code=proc.exit_code, - ) - final.started_at.GetCurrentTime() - final.finished_at.GetCurrentTime() - return final + return self._to_pb(request.process_id, proc, exited=False) + proc.exited = True + return self._to_pb(request.process_id, proc, exited=True) - async def StreamProcessOutputs(self, request, context): + async def StreamProcessOutput(self, request, context): self.last_env = await _require_env(context) proc = await self._lookup(request.process_id, context) stdout_skip = request.stdout_offset stderr_skip = request.stderr_offset for source, data in proc.chunks: - if source == guest_pb2.OUTPUT_SOURCE_STDOUT and stdout_skip: + if source == "stdout": data, stdout_skip = data[stdout_skip:], max(0, stdout_skip - len(data)) - elif source == guest_pb2.OUTPUT_SOURCE_STDERR and stderr_skip: + if data: + yield guest_pb2.ProcessOutput(stdout=data) + else: data, stderr_skip = data[stderr_skip:], max(0, stderr_skip - len(data)) - if data: - yield guest_pb2.OutputChunk(source=source, data=data) + if data: + yield guest_pb2.ProcessOutput(stderr=data) + if request.follow and proc.hang_follow: + await asyncio.Event().wait() + if request.follow: + # Simulate a live process: yield to the loop between chunks and exit. + for _ in range(proc.running_polls): + await asyncio.sleep(0) + proc.running_polls = 0 + proc.exited = True + if proc.exited or proc.running_polls == 0: + proc.exited = True + yield guest_pb2.ProcessOutput(exit=self._to_pb(request.process_id, proc, exited=True)) + + async def WriteProcessInput(self, request_iterator, context): + self.last_env = await _require_env(context) + proc = None + process_id = "" + written = 0 + async for request in request_iterator: + if proc is None: + process_id = request.process_id + if not process_id: + await context.abort( + grpc.StatusCode.INVALID_ARGUMENT, + "process_id is required on the first message", + ) + proc = await self._lookup(process_id, context) + if not proc.stdin: + await context.abort( + grpc.StatusCode.FAILED_PRECONDITION, + f'process "{process_id}" was started without stdin', + ) + if proc.exited: + await context.abort( + grpc.StatusCode.FAILED_PRECONDITION, f'process "{process_id}" has exited' + ) + if request.data and proc.stdin_closed: + await context.abort( + grpc.StatusCode.FAILED_PRECONDITION, + f'stdin of process "{process_id}" is closed', + ) + proc.stdin_data.extend(request.data) + written += len(request.data) + if request.data and proc.echo_stdin: + proc.chunks.append(("stdout", bytes(request.data))) + if request.close: + proc.stdin_closed = True + if proc is None: + await context.abort( + grpc.StatusCode.INVALID_ARGUMENT, "process_id is required on the first message" + ) + return guest_pb2.WriteProcessInputResponse(bytes_written=written) - async def KillProcess(self, request, context): + async def SignalProcess(self, request, context): self.last_env = await _require_env(context) proc = await self._lookup(request.process_id, context) - proc.killed = True - proc.exit_code = 137 - proc.running_polls = 0 - return guest_pb2.KillProcessResponse(exit_code=137) + if request.signal == guest_pb2.SIGNAL_UNSPECIFIED: + await context.abort(grpc.StatusCode.INVALID_ARGUMENT, "unsupported signal") + if proc.exited: + await context.abort( + grpc.StatusCode.FAILED_PRECONDITION, + f'process "{request.process_id}" has exited', + ) + proc.signals.append(request.signal) + if request.signal in (guest_pb2.SIGNAL_KILL, guest_pb2.SIGNAL_TERM): + proc.signal = request.signal + proc.running_polls = 0 + return self._to_pb(request.process_id, proc, exited=False) async def _lookup(self, process_id, context): proc = self.procs.get(process_id) @@ -211,7 +286,7 @@ async def ReadFile(self, request, context): f"open {request.path}: no such file or directory", ) for i in range(0, len(data), CHUNK_SIZE): - yield guest_pb2.FileChunk(data=data[i : i + CHUNK_SIZE]) + yield guest_pb2.ReadFileResponse(chunk=data[i : i + CHUNK_SIZE]) async def WriteFile(self, request_iterator, context): self.last_env = await _require_env(context) @@ -226,11 +301,23 @@ async def WriteFile(self, request_iterator, context): await self._validate_path(request.path, context) path = request.path mode = request.mode - buf.extend(request.chunk) + if request.seek_offset < 0: + await context.abort( + grpc.StatusCode.INVALID_ARGUMENT, "seek_offset cannot be negative" + ) + if request.seek_offset > 0: + # Keep existing content, zero-fill up to the offset. + buf = bytearray(self.files.get(path, b"")) + buf.extend(b"\0" * max(0, request.seek_offset - len(buf))) + pos = request.seek_offset + else: + pos = 0 + buf[pos : pos + len(request.chunk)] = request.chunk + pos += len(request.chunk) chunk_sizes.append(len(request.chunk)) self.last_write_chunk_sizes = chunk_sizes if path is None: await context.abort(grpc.StatusCode.INVALID_ARGUMENT, "path is required") self.files[path] = bytes(buf) - self.modes[path] = mode - return guest_pb2.WriteFileResponse(bytes_written=len(buf)) + self.modes.setdefault(path, mode) + return guest_pb2.WriteFileResponse(bytes_written=sum(chunk_sizes)) diff --git a/clients/python/tests/test_env_files.py b/clients/python/tests/test_env_files.py index b60725b..3c3d18e 100644 --- a/clients/python/tests/test_env_files.py +++ b/clients/python/tests/test_env_files.py @@ -60,6 +60,20 @@ async def test_write_file_bytes(fake_stack): assert fakes.filesystem.modes["/big.bin"] == 0o755 +async def test_write_file_seek_offset(fake_stack): + client, fakes = fake_stack + env = client.env("dev1") + await env.write_file("/seek.txt", b"hello world") + assert await env.write_file("/seek.txt", b"W", seek_offset=6) == 1 + assert fakes.filesystem.files["/seek.txt"] == b"hello World" + await env.write_file("/seek.txt", b"!", seek_offset=13) + assert fakes.filesystem.files["/seek.txt"] == b"hello World\0\0!" + await env.write_file("/seek.txt", b"new") # no offset: replaced + assert fakes.filesystem.files["/seek.txt"] == b"new" + with pytest.raises(ValueError, match="seek_offset"): + await env.write_file("/seek.txt", b"x", seek_offset=-1) + + async def test_write_file_empty_creates_file(fake_stack): client, fakes = fake_stack env = client.env("dev1") diff --git a/clients/python/tests/test_env_process.py b/clients/python/tests/test_env_process.py index 93532fa..1b72c85 100644 --- a/clients/python/tests/test_env_process.py +++ b/clients/python/tests/test_env_process.py @@ -15,23 +15,24 @@ from __future__ import annotations import asyncio +from datetime import timedelta import pytest from ate_env import ( + FailedPreconditionError, InvalidArgumentError, - OutputSource, NotFoundError, - ProcessStatus, + Process, + ProcessExitedError, + ProcessState, ShellResult, + Signal, ) from ate_env._gen.ateenv.v1alpha import guest_pb2 from .fakes import FakeProc -STDOUT = guest_pb2.OUTPUT_SOURCE_STDOUT -STDERR = guest_pb2.OUTPUT_SOURCE_STDERR - async def test_metadata_injected(fake_stack): client, fakes = fake_stack @@ -56,91 +57,160 @@ async def test_missing_metadata_rejected(fake_stack): async def test_start_process_passthrough(fake_stack): client, fakes = fake_stack env = client.env("dev1") - pid = await env.start_process( - ["pytest", "tests/"], cwd="/workspace", env={"CI": "1"} + proc = await env.start_process( + ["pytest", "tests/"], cwd="/workspace", env={"CI": "1"}, stdin=True, timeout=90 ) - proc = fakes.processes.procs[pid] - assert proc.command == ["pytest", "tests/"] - assert proc.cwd == "/workspace" - assert proc.env == {"CI": "1"} + assert isinstance(proc, Process) + assert proc.env is env + fake = fakes.processes.procs[proc.id] + assert fake.command == ["pytest", "tests/"] + assert fake.cwd == "/workspace" + assert fake.env == {"CI": "1"} + assert fake.stdin is True + assert fake.timeout_seconds == 90 + + +async def test_start_process_timeout_timedelta_and_default(fake_stack): + client, fakes = fake_stack + env = client.env("dev1") + proc = await env.start_process(["job"], timeout=timedelta(minutes=2)) + assert fakes.processes.procs[proc.id].timeout_seconds == 120 + proc = await env.start_process(["job"]) + assert fakes.processes.procs[proc.id].timeout_seconds is None + assert fakes.processes.procs[proc.id].stdin is False -async def test_get_process_missing(fake_stack): +async def test_info_missing(fake_stack): client, _ = fake_stack env = client.env("dev1") with pytest.raises(NotFoundError, match='process "nope" not found'): - await env.get_process("nope") + await env.process("nope").info() -async def test_stream_outputs_yields_typed_chunks(fake_stack): +async def test_info_running_then_exited(fake_stack): + client, fakes = fake_stack + env = client.env("dev1") + fakes.processes.next_procs.append(FakeProc(exit_code=4, running_polls=1)) + proc = await env.start_process(["job"]) + info = await proc.info() + assert info.state == ProcessState.RUNNING and info.running + assert info.command == ("job",) + assert info.pid > 0 + assert info.finished_at is None + info = await proc.info() + assert info.state == ProcessState.EXITED and not info.running + assert info.exit_code == 4 + assert info.started_at is not None and info.started_at.tzinfo is not None + assert info.finished_at is not None and info.finished_at.tzinfo is not None + + +async def test_output_follow_yields_chunks_then_exit(fake_stack): client, fakes = fake_stack env = client.env("dev1") fakes.processes.next_procs.append( - FakeProc(chunks=[(STDOUT, b"out1"), (STDERR, b"err1"), (STDOUT, b"out2")]) + FakeProc(chunks=[("stdout", b"out1"), ("stderr", b"err1"), ("stdout", b"out2")], exit_code=2) ) - pid = await env.start_process(["job"]) - chunks = [chunk async for chunk in env.stream_outputs(pid, follow=True)] - assert [(c.source, c.data) for c in chunks] == [ - (OutputSource.STDOUT, b"out1"), - (OutputSource.STDERR, b"err1"), - (OutputSource.STDOUT, b"out2"), + proc = await env.start_process(["job"]) + outputs = [out async for out in proc.output(follow=True)] + assert [(o.stdout, o.stderr) for o in outputs[:-1]] == [ + (b"out1", None), + (None, b"err1"), + (b"out2", None), ] + assert outputs[-1].exit is not None + assert outputs[-1].exit.exit_code == 2 + assert outputs[-1].exit.state == ProcessState.EXITED + +async def test_output_snapshot_of_running_process_has_no_exit(fake_stack): + client, fakes = fake_stack + env = client.env("dev1") + fakes.processes.next_procs.append(FakeProc(chunks=[("stdout", b"partial")], running_polls=5)) + proc = await env.start_process(["job"]) + outputs = [out async for out in proc.output()] + assert [o.stdout for o in outputs] == [b"partial"] + assert all(o.exit is None for o in outputs) -async def test_stream_outputs_respects_offsets(fake_stack): + +async def test_output_respects_offsets(fake_stack): client, fakes = fake_stack env = client.env("dev1") fakes.processes.next_procs.append( - FakeProc(chunks=[(STDOUT, b"abcdef"), (STDERR, b"123456")]) + FakeProc(chunks=[("stdout", b"abcdef"), ("stderr", b"123456")]) ) - pid = await env.start_process(["job"]) - chunks = [ - (c.source, c.data) - async for c in env.stream_outputs(pid, stdout_offset=4, stderr_offset=2) + proc = await env.start_process(["job"]) + outputs = [ + (o.stdout, o.stderr) + async for o in proc.output(stdout_offset=4, stderr_offset=2) + if o.exit is None ] - assert chunks == [(OutputSource.STDOUT, b"ef"), (OutputSource.STDERR, b"3456")] + assert outputs == [(b"ef", None), (None, b"3456")] -async def test_stream_outputs_consumer_break_cancels(fake_stack): +async def test_output_consumer_break_cancels(fake_stack): client, fakes = fake_stack env = client.env("dev1") fakes.processes.next_procs.append( - FakeProc(chunks=[(STDOUT, b"a"), (STDOUT, b"b"), (STDOUT, b"c")]) + FakeProc(chunks=[("stdout", b"a"), ("stdout", b"b"), ("stdout", b"c")]) ) - pid = await env.start_process(["job"]) + proc = await env.start_process(["job"]) seen = [] - async for chunk in env.stream_outputs(pid): - seen.append(chunk.data) + async for out in proc.output(): + seen.append(out.stdout) break assert seen == [b"a"] +async def test_wait_skips_output(fake_stack): + client, fakes = fake_stack + env = client.env("dev1") + fakes.processes.next_procs.append( + FakeProc(chunks=[("stdout", b"lots of output")], exit_code=5, running_polls=3) + ) + proc = await env.start_process(["job"]) + info = await proc.wait() + assert info.state == ProcessState.EXITED + assert info.exit_code == 5 + + async def test_shell_collects_output_and_exit_code(fake_stack): client, fakes = fake_stack env = client.env("dev1") fakes.processes.next_procs.append( FakeProc( - chunks=[(STDOUT, b"hello "), (STDERR, b"warn\n"), (STDOUT, b"world\n")], + chunks=[("stdout", b"hello "), ("stderr", b"warn\n"), ("stdout", b"world\n")], exit_code=3, - running_polls=2, # forces the poll loop to actually iterate + running_polls=2, ) ) - result = await env.shell("echo hello world; warn") - proc = fakes.processes.procs["proc-1"] - assert proc.command == ["sh", "-c", "echo hello world; warn"] - assert result.stdout == "hello world\n" - assert result.stderr == "warn\n" - assert result.exit_code == 3 - assert proc.running_polls == 0 + result = await env.shell("echo hello world; warn", cwd="/w", env={"A": "1"}) + fake = fakes.processes.procs["proc-1"] + assert fake.command == ["sh", "-c", "echo hello world; warn"] + assert fake.cwd == "/w" and fake.env == {"A": "1"} + assert fake.stdin is False + assert result == ShellResult(stdout="hello world\n", stderr="warn\n", exit_code=3) + + +async def test_shell_with_stdin(fake_stack): + client, fakes = fake_stack + env = client.env("dev1") + fakes.processes.next_procs.append(FakeProc(echo_stdin=True)) + result = await env.shell("cat", stdin="typed in\n") + fake = fakes.processes.procs["proc-1"] + assert fake.stdin is True + assert bytes(fake.stdin_data) == b"typed in\n" + assert fake.stdin_closed + assert result.stdout == "typed in\n" + assert result.exit_code == 0 -async def test_stream_outputs_empty(fake_stack): +async def test_shell_killed_by_signal(fake_stack): client, fakes = fake_stack env = client.env("dev1") - fakes.processes.next_procs.append(FakeProc(chunks=[])) - pid = await env.start_process(["true"]) - chunks = [chunk async for chunk in env.stream_outputs(pid, follow=True)] - assert chunks == [] + fakes.processes.next_procs.append(FakeProc(signal=guest_pb2.SIGNAL_KILL)) + result = await env.shell("sleep 30", timeout=0.5) + assert result.exit_code == 137 + assert fakes.processes.procs["proc-1"].timeout_seconds == 0.5 async def test_shell_empty_output(fake_stack): @@ -154,9 +224,7 @@ async def test_shell_empty_output(fake_stack): async def test_shell_invalid_utf8_replaced(fake_stack): client, fakes = fake_stack env = client.env("dev1") - fakes.processes.next_procs.append( - FakeProc(chunks=[(STDOUT, b"ok\xff\xfe")], exit_code=0) - ) + fakes.processes.next_procs.append(FakeProc(chunks=[("stdout", b"ok\xff\xfe")], exit_code=0)) result = await env.shell("binary") assert result.stdout == "ok��" @@ -166,7 +234,7 @@ async def test_concurrent_shells(fake_stack): env = client.env("dev1") for i in range(5): fakes.processes.next_procs.append( - FakeProc(chunks=[(STDOUT, f"job-{i}\n".encode())], exit_code=i, running_polls=1) + FakeProc(chunks=[("stdout", f"job-{i}\n".encode())], exit_code=i, running_polls=1) ) results = await asyncio.gather(*(env.shell(f"job {i}") for i in range(5))) # StartProcess order over one channel is not guaranteed; match by output. @@ -174,50 +242,116 @@ async def test_concurrent_shells(fake_stack): assert sorted(r.exit_code for r in results) == list(range(5)) -async def test_get_process_terminal_has_timestamps(fake_stack): +async def test_write_input_incremental_and_close(fake_stack): client, fakes = fake_stack env = client.env("dev1") - fakes.processes.next_procs.append(FakeProc(exit_code=0)) - pid = await env.start_process(["true"]) - proc = await env.get_process(pid) - assert proc.status == ProcessStatus.COMPLETED - assert proc.started_at is not None and proc.started_at.tzinfo is not None - assert proc.finished_at is not None and proc.finished_at.tzinfo is not None + proc = await env.start_process(["cat"], stdin=True) + fake = fakes.processes.procs[proc.id] + assert await proc.write_input(b"first ") == 6 + assert not fake.stdin_closed + assert await proc.write_input([b"sec", b"ond"]) == 6 -async def test_kill_missing_process(fake_stack): + async def gen(): + yield b" th" + yield b"ird" + + assert await proc.write_input(gen(), close=True) == 6 + assert bytes(fake.stdin_data) == b"first second third" + assert fake.stdin_closed + + with pytest.raises(FailedPreconditionError, match="is closed"): + await proc.write_input(b"late") + + +async def test_write_input_chunked(fake_stack): + client, fakes = fake_stack + env = client.env("dev1") + proc = await env.start_process(["cat"], stdin=True) + data = bytes(range(256)) * 1024 # 256 KiB: several chunks on the wire + assert await proc.write_input(data) == len(data) + assert bytes(fakes.processes.procs[proc.id].stdin_data) == data + + +async def test_close_input_only(fake_stack): + client, fakes = fake_stack + env = client.env("dev1") + proc = await env.start_process(["cat"], stdin=True) + await proc.close_input() + fake = fakes.processes.procs[proc.id] + assert fake.stdin_closed and bytes(fake.stdin_data) == b"" + + +async def test_write_input_without_stdin_rejected(fake_stack): + client, _ = fake_stack + env = client.env("dev1") + proc = await env.start_process(["cat"]) + with pytest.raises(FailedPreconditionError, match="without stdin"): + await proc.write_input(b"x") + + +async def test_write_input_rejects_str(fake_stack): + client, _ = fake_stack + env = client.env("dev1") + proc = await env.start_process(["cat"], stdin=True) + with pytest.raises(TypeError, match="encode it first"): + await proc.write_input("text") # type: ignore[arg-type] + + +async def test_write_input_missing_process(fake_stack): client, _ = fake_stack env = client.env("dev1") with pytest.raises(NotFoundError, match='process "nope" not found'): - await env.kill_process("nope") + await env.process("nope").write_input(b"x") -async def test_wait_polls_until_done(fake_stack): +async def test_signal_passthrough(fake_stack): client, fakes = fake_stack env = client.env("dev1") - fakes.processes.next_procs.append(FakeProc(exit_code=0, running_polls=3)) - pid = await env.start_process(["job"]) - proc = await env.wait(pid, poll_interval=0.001) - assert proc.status == ProcessStatus.COMPLETED - assert proc.exit_code == 0 + fakes.processes.next_procs.append(FakeProc(running_polls=10**9)) + proc = await env.start_process(["server"]) + await proc.signal(Signal.HUP) + await proc.signal(Signal.USR1) + assert fakes.processes.procs[proc.id].signals == [guest_pb2.SIGNAL_HUP, guest_pb2.SIGNAL_USR1] + info = await proc.info() + assert info.running -async def test_wait_cancellable(fake_stack): +async def test_signal_term_then_wait(fake_stack): client, fakes = fake_stack env = client.env("dev1") - fakes.processes.next_procs.append(FakeProc(running_polls=10**9)) # never finishes - pid = await env.start_process(["sleep", "infinity"]) - with pytest.raises(TimeoutError): - async with asyncio.timeout(0.05): - await env.wait(pid) + fakes.processes.next_procs.append(FakeProc(running_polls=10**9)) + proc = await env.start_process(["sleep", "infinity"]) + await proc.signal(Signal.TERM) + info = await proc.wait() + assert info.exit_code == 143 + with pytest.raises(ProcessExitedError): + await proc.signal(Signal.KILL) -async def test_kill_process(fake_stack): +async def test_kill_is_idempotent(fake_stack): client, fakes = fake_stack env = client.env("dev1") fakes.processes.next_procs.append(FakeProc(running_polls=10**9)) - pid = await env.start_process(["sleep", "infinity"]) - exit_code = await env.kill_process(pid) - assert exit_code == 137 - proc = await env.get_process(pid) - assert proc.status == ProcessStatus.TERMINATED + proc = await env.start_process(["sleep", "infinity"]) + info = await proc.kill() + assert info.exit_code == 137 + info = await proc.kill() + assert info.exit_code == 137 + + +async def test_kill_missing_process(fake_stack): + client, _ = fake_stack + env = client.env("dev1") + with pytest.raises(NotFoundError, match='process "nope" not found'): + await env.process("nope").kill() + + +async def test_wait_cancellable(fake_stack): + client, fakes = fake_stack + env = client.env("dev1") + fakes.processes.next_procs.append(FakeProc(hang_follow=True)) # never exits + proc = await env.start_process(["sleep", "infinity"]) + with pytest.raises(TimeoutError): + async with asyncio.timeout(0.05): + await proc.wait() diff --git a/clients/python/tests/test_types.py b/clients/python/tests/test_types.py index 809755a..e9b7538 100644 --- a/clients/python/tests/test_types.py +++ b/clients/python/tests/test_types.py @@ -16,19 +16,30 @@ from datetime import datetime, timezone -from ate_env import EnvironmentStatus, OutputSource, ProcessStatus +import pytest + +from ate_env import EnvironmentStatus, ProcessOutput, ProcessState, Signal from ate_env._gen.ateenv.v1alpha import env_pb2, guest_pb2 -from ate_env.types import _environment_info_from_pb, _process_info_from_pb +from ate_env.types import ( + _environment_info_from_pb, + _process_info_from_pb, + _process_output_from_pb, +) -def test_process_status_values_match_proto(): - for member in ProcessStatus: - assert guest_pb2.ProcessStatus.Value(f"PROCESS_STATUS_{member.name}") == member +def test_process_state_values_match_proto(): + for member in ProcessState: + assert guest_pb2.ProcessState.Value(f"PROCESS_STATE_{member.name}") == member + assert len(ProcessState) == len(guest_pb2.ProcessState.keys()) -def test_log_source_values_match_proto(): - for member in OutputSource: - assert guest_pb2.OutputSource.Value(f"OUTPUT_SOURCE_{member.name}") == member +def test_signal_values_match_proto(): + for member in Signal: + assert guest_pb2.Signal.Value(f"SIGNAL_{member.name}") == member + # Every proto signal except UNSPECIFIED has a Python member. + assert {f"SIGNAL_{m.name}" for m in Signal} == set(guest_pb2.Signal.keys()) - { + "SIGNAL_UNSPECIFIED" + } def test_environment_info_without_template(): @@ -54,23 +65,44 @@ def test_environment_info_with_template(): assert (info.template.name, info.template.atespace) == ("big-env", "ns1") -def test_process_info_without_timestamps(): +def test_process_info_running(): pb = guest_pb2.Process( - process_id="p1", status=guest_pb2.PROCESS_STATUS_RUNNING, exit_code=0 + process_id="p1", command=["sleep", "1"], pid=42, state=guest_pb2.PROCESS_STATE_RUNNING ) info = _process_info_from_pb(pb) assert info.process_id == "p1" - assert info.status == ProcessStatus.RUNNING + assert info.command == ("sleep", "1") + assert info.pid == 42 + assert info.state == ProcessState.RUNNING and info.running assert info.started_at is None assert info.finished_at is None +def test_process_info_exited(): + pb = guest_pb2.Process(process_id="p1", state=guest_pb2.PROCESS_STATE_EXITED, exit_code=143) + info = _process_info_from_pb(pb) + assert not info.running + assert info.exit_code == 143 + + +def test_process_output_variants(): + assert _process_output_from_pb(guest_pb2.ProcessOutput(stdout=b"a")) == ProcessOutput(stdout=b"a") + assert _process_output_from_pb(guest_pb2.ProcessOutput(stderr=b"b")) == ProcessOutput(stderr=b"b") + out = _process_output_from_pb( + guest_pb2.ProcessOutput( + exit=guest_pb2.Process(process_id="p", state=guest_pb2.PROCESS_STATE_EXITED, exit_code=9) + ) + ) + assert out.stdout is None and out.stderr is None + assert out.exit is not None and out.exit.exit_code == 9 + with pytest.raises(ValueError): + _process_output_from_pb(guest_pb2.ProcessOutput()) + + def test_process_info_timestamps_are_utc(): started = datetime(2026, 8, 25, 12, 0, 0, tzinfo=timezone.utc) finished = datetime(2026, 8, 25, 12, 0, 5, 500_000, tzinfo=timezone.utc) - pb = guest_pb2.Process( - process_id="p1", status=guest_pb2.PROCESS_STATUS_COMPLETED, exit_code=0 - ) + pb = guest_pb2.Process(process_id="p1", state=guest_pb2.PROCESS_STATE_EXITED, exit_code=0) pb.started_at.FromDatetime(started) pb.finished_at.FromDatetime(finished) info = _process_info_from_pb(pb) diff --git a/cmd/ate-env/main.go b/cmd/ate-env/main.go index a7a6502..fff6767 100644 --- a/cmd/ate-env/main.go +++ b/cmd/ate-env/main.go @@ -25,6 +25,7 @@ import ( "io" "os" "strings" + "time" "github.com/agent-substrate/env/clients/go" "github.com/agent-substrate/env/internal/apiservice" @@ -89,13 +90,25 @@ func newGuestCommand(id string) *cobra.Command { }, }) - guestCmd.AddCommand(&cobra.Command{ + var ( + shellStdin bool + shellTimeout time.Duration + ) + shellCmd := &cobra.Command{ Use: "shell ", Aliases: []string{"cmd"}, Short: "Run a shell command line in the environment", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - res, err := client.Env(atespace, id).Shell(cmd.Context(), strings.Join(args, " ")) + req := env.ShellRequest{Command: strings.Join(args, " "), Timeout: shellTimeout} + if shellStdin { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("reading stdin: %w", err) + } + req.Stdin = data + } + res, err := client.Env(atespace, id).Run(cmd.Context(), req) if err != nil { return err } @@ -110,7 +123,10 @@ func newGuestCommand(id string) *cobra.Command { } return nil }, - }) + } + shellCmd.Flags().BoolVarP(&shellStdin, "stdin", "i", false, "feed this process's stdin to the command") + shellCmd.Flags().DurationVar(&shellTimeout, "timeout", 0, "kill the command after this duration (default: server default)") + guestCmd.AddCommand(shellCmd) return guestCmd } diff --git a/examples/guest-daemon/README.md b/examples/guest-daemon/README.md index 7f38f46..0f97860 100644 --- a/examples/guest-daemon/README.md +++ b/examples/guest-daemon/README.md @@ -2,7 +2,7 @@ This directory contains an example implementation and guide for running an in-container **Guest Daemon** for Agent Substrate environments. -It demonstrates how to configure and run the gRPC guest services using the **`github.com/agent-substrate/env/guest`** package, exposing **`ProcessService`** (asynchronous process execution, output spooling, log streaming) and **`FileSystemService`** (chunked streaming file manipulation). +It demonstrates how to configure and run the gRPC guest services using the **`github.com/agent-substrate/env/guest`** package, exposing **`ProcessService`** (asynchronous process execution, stdin/stdout/stderr streaming, signals) and **`FileSystemService`** (chunked streaming file manipulation). --- @@ -82,7 +82,9 @@ grpcurl -plaintext -d '{"command": ["echo", "Hello Substrate!"]}' \ localhost:8080 ateenv.v1alpha.ProcessService/StartProcess ``` -### Inspect Process Status +Add `"stdin": true` to open a stdin pipe and `"timeout": "30s"` to bound the run time. + +### Inspect Process State ```bash grpcurl -plaintext -d '{"process_id": ""}' \ localhost:8080 ateenv.v1alpha.ProcessService/GetProcess @@ -91,13 +93,21 @@ grpcurl -plaintext -d '{"process_id": ""}' \ ### Stream Real-Time Output ```bash grpcurl -plaintext -d '{"process_id": "", "follow": true}' \ - localhost:8080 ateenv.v1alpha.ProcessService/StreamProcessOutputs + localhost:8080 ateenv.v1alpha.ProcessService/StreamProcessOutput +``` + +The stream ends with an `exit` message carrying the final `Process` once the command has exited. + +### Write to stdin (Streamed) +```bash +echo '{"process_id": "", "data": "aGVsbG8K", "close": true}' | \ + grpcurl -plaintext -d @ localhost:8080 ateenv.v1alpha.ProcessService/WriteProcessInput ``` -### Terminate a Process +### Signal a Process ```bash -grpcurl -plaintext -d '{"process_id": ""}' \ - localhost:8080 ateenv.v1alpha.ProcessService/KillProcess +grpcurl -plaintext -d '{"process_id": "", "signal": "SIGNAL_TERM"}' \ + localhost:8080 ateenv.v1alpha.ProcessService/SignalProcess ``` ### Write a File (Streamed) @@ -106,6 +116,8 @@ echo '{"path": "hello.txt", "chunk": "SGVsbG8gU3Vic3RyYXRlIQo=", "mode": 420}' | grpcurl -plaintext -d @ localhost:8080 ateenv.v1alpha.FileSystemService/WriteFile ``` +Add `"seek_offset": N` to keep the existing content and write starting at byte `N` instead of replacing the file. + ### Read a File (Streamed) ```bash grpcurl -plaintext -d '{"path": "hello.txt"}' \ diff --git a/examples/guest-daemon/main_test.go b/examples/guest-daemon/main_test.go index 31768b4..0fc2622 100644 --- a/examples/guest-daemon/main_test.go +++ b/examples/guest-daemon/main_test.go @@ -104,29 +104,32 @@ sys.stderr.write("Job stderr log\n") t.Fatalf("StartProcess failed: %v", err) } - // 3. Stream real-time output - outStream, err := procClient.StreamProcessOutputs(ctx, &ateenvv1alpha.StreamProcessOutputsRequest{ - ProcessId: startRes.ProcessId, + // 3. Stream real-time output; the stream ends with the exit message. + outStream, err := procClient.StreamProcessOutput(ctx, &ateenvv1alpha.StreamProcessOutputRequest{ + ProcessId: startRes.GetProcessId(), Follow: true, }) if err != nil { - t.Fatalf("StreamProcessOutputs failed: %v", err) + t.Fatalf("StreamProcessOutput failed: %v", err) } - var stdout strings.Builder - var stderr strings.Builder + var stdout, stderr strings.Builder + var exit *ateenvv1alpha.Process for { - chunk, err := outStream.Recv() + msg, err := outStream.Recv() if err == io.EOF { break } if err != nil { - t.Fatalf("error reading output chunk: %v", err) + t.Fatalf("error reading output: %v", err) } - if chunk.Source == ateenvv1alpha.OutputSource_OUTPUT_SOURCE_STDOUT { - stdout.Write(chunk.Data) - } else if chunk.Source == ateenvv1alpha.OutputSource_OUTPUT_SOURCE_STDERR { - stderr.Write(chunk.Data) + switch out := msg.GetOutput().(type) { + case *ateenvv1alpha.ProcessOutput_Stdout: + stdout.Write(out.Stdout) + case *ateenvv1alpha.ProcessOutput_Stderr: + stderr.Write(out.Stderr) + case *ateenvv1alpha.ProcessOutput_Exit: + exit = out.Exit } } @@ -137,17 +140,14 @@ sys.stderr.write("Job stderr log\n") t.Fatalf("expected stderr to contain 'Job stderr log', got %q", stderr.String()) } - // 4. Verify Process metadata - proc, err := procClient.GetProcess(ctx, &ateenvv1alpha.GetProcessRequest{ - ProcessId: startRes.ProcessId, - }) - if err != nil { - t.Fatalf("GetProcess failed: %v", err) + // 4. Verify the final Process state + if exit == nil { + t.Fatalf("expected an exit message at the end of the stream") } - if proc.Status != ateenvv1alpha.ProcessStatus_PROCESS_STATUS_COMPLETED { - t.Fatalf("expected status COMPLETED, got %v", proc.Status) + if exit.GetState() != ateenvv1alpha.ProcessState_PROCESS_STATE_EXITED { + t.Fatalf("expected state EXITED, got %v", exit.GetState()) } - if proc.ExitCode != 0 { - t.Fatalf("expected exit code 0, got %d", proc.ExitCode) + if exit.GetExitCode() != 0 { + t.Fatalf("expected exit code 0, got %d", exit.GetExitCode()) } } diff --git a/examples/mcp/README.md b/examples/mcp/README.md index 0fc942e..21e44ee 100644 --- a/examples/mcp/README.md +++ b/examples/mcp/README.md @@ -30,10 +30,10 @@ sequenceDiagram Note over Client,Guest: 3. Process Execution (shell) Client->>+API: POST /v1alpha/envs/{id}/mcp (tools/call: shell) - API->>+Router: ProcessService.StartProcess & StreamProcessLogs (gRPC) + API->>+Router: ProcessService.StartProcess, WriteProcessInput & StreamProcessOutput (gRPC) Router->>+Guest: Execute command & stream stdout/stderr - Guest-->>-Router: Stream Log Chunks & Exit Code - Router-->>-API: Forward Log Chunks & Exit Code + Guest-->>-Router: Stream Output Chunks & Exit Message + Router-->>-API: Forward Output Chunks & Exit Message API-->>-Client: JSON-RPC Response (mcp.CallToolResult) ``` diff --git a/guest/filesystem/service.go b/guest/filesystem/service.go index 5162969..bdc7846 100644 --- a/guest/filesystem/service.go +++ b/guest/filesystem/service.go @@ -119,7 +119,18 @@ func (s *Service) ReadFile(req *ateenvv1alpha.ReadFileRequest, stream ateenvv1al return err } - f, err := os.Open(filePath) + // With a mode, a missing file is created empty instead of being an error. + flags := os.O_RDONLY + if req.GetMode() != 0 { + flags |= os.O_CREATE + if dir := filepath.Dir(filePath); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return status.Errorf(codes.Internal, "failed to create parent directories for %q: %v", req.GetPath(), err) + } + } + } + + f, err := os.OpenFile(filePath, flags, os.FileMode(req.GetMode())) if err != nil { if errors.Is(err, os.ErrNotExist) { return status.Errorf(codes.NotFound, "file %q not found", req.GetPath()) @@ -135,8 +146,8 @@ func (s *Service) ReadFile(req *ateenvv1alpha.ReadFileRequest, stream ateenvv1al for { n, readErr := f.Read(buf) if n > 0 { - if err := stream.Send(&ateenvv1alpha.FileChunk{ - Data: buf[:n], + if err := stream.Send(&ateenvv1alpha.ReadFileResponse{ + Chunk: buf[:n], }); err != nil { return err } @@ -207,13 +218,28 @@ func (s *Service) WriteFile(stream ateenvv1alpha.FileSystemService_WriteFileServ mode = os.FileMode(req.GetMode()) } - f, err = os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + // Without a seek offset the file is replaced; with one, existing + // content is preserved and writing starts at the offset. + if req.GetSeekOffset() < 0 { + return status.Error(codes.InvalidArgument, "seek_offset cannot be negative") + } + flags := os.O_CREATE | os.O_WRONLY | os.O_TRUNC + if req.GetSeekOffset() > 0 { + flags = os.O_CREATE | os.O_WRONLY + } + + f, err = os.OpenFile(filePath, flags, mode) if err != nil { if errors.Is(err, os.ErrPermission) { return status.Errorf(codes.PermissionDenied, "permission denied opening %q: %v", reqPath, err) } return status.Errorf(codes.Internal, "failed to create file %q: %v", reqPath, err) } + if req.GetSeekOffset() > 0 { + if _, err := f.Seek(req.GetSeekOffset(), io.SeekStart); err != nil { + return status.Errorf(codes.Internal, "failed to seek in file %q: %v", reqPath, err) + } + } } // Write chunk data diff --git a/guest/filesystem/service_test.go b/guest/filesystem/service_test.go index bc78615..1ad2c91 100644 --- a/guest/filesystem/service_test.go +++ b/guest/filesystem/service_test.go @@ -18,6 +18,7 @@ import ( "bytes" "context" "crypto/rand" + "errors" "io" "net" "os" @@ -123,7 +124,7 @@ func TestWriteAndReadFileSmall(t *testing.T) { if err != nil { t.Fatalf("ReadFile recv error: %v", err) } - readBuffer.Write(chunk.Data) + readBuffer.Write(chunk.Chunk) } if !bytes.Equal(readBuffer.Bytes(), testData) { @@ -206,7 +207,7 @@ func TestWriteAndReadFileMultiChunk(t *testing.T) { if err != nil { t.Fatalf("read stream error: %v", err) } - readBuffer.Write(chunk.Data) + readBuffer.Write(chunk.Chunk) } if !bytes.Equal(readBuffer.Bytes(), largeData) { @@ -349,3 +350,130 @@ func TestWriteFileMissingPath(t *testing.T) { t.Fatalf("expected InvalidArgument for missing path, got %v", err) } } + +func TestWriteFileSeekOffset(t *testing.T) { + tempDir := t.TempDir() + client, cleanup := setupTestFileSystemServer(t, Config{RootDirectory: tempDir}) + defer cleanup() + ctx := context.Background() + target := filepath.Join(tempDir, "seek.txt") + + write := func(req *ateenvv1alpha.WriteFileRequest) (*ateenvv1alpha.WriteFileResponse, error) { + stream, err := client.WriteFile(ctx) + if err != nil { + t.Fatalf("WriteFile: %v", err) + } + if err := stream.Send(req); err != nil { + t.Fatalf("send: %v", err) + } + return stream.CloseAndRecv() + } + content := func() string { + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("reading %s: %v", target, err) + } + return string(data) + } + + if _, err := write(&ateenvv1alpha.WriteFileRequest{Path: target, Chunk: []byte("hello world")}); err != nil { + t.Fatalf("initial write: %v", err) + } + + // Overwrite in the middle: bytes before and after the range are kept. + res, err := write(&ateenvv1alpha.WriteFileRequest{Path: target, Chunk: []byte("W"), SeekOffset: 6}) + if err != nil { + t.Fatalf("seek write: %v", err) + } + if res.GetBytesWritten() != 1 || content() != "hello World" { + t.Fatalf("after seek write: bytes=%d content=%q", res.GetBytesWritten(), content()) + } + + // Past the end: the gap is zero-filled. + if _, err := write(&ateenvv1alpha.WriteFileRequest{Path: target, Chunk: []byte("!"), SeekOffset: 13}); err != nil { + t.Fatalf("seek past end: %v", err) + } + if content() != "hello World\x00\x00!" { + t.Fatalf("after seek past end: %q", content()) + } + + // Offset zero (the default) replaces the file. + if _, err := write(&ateenvv1alpha.WriteFileRequest{Path: target, Chunk: []byte("new")}); err != nil { + t.Fatalf("plain write: %v", err) + } + if content() != "new" { + t.Fatalf("after plain write: %q", content()) + } + + if _, err := write(&ateenvv1alpha.WriteFileRequest{Path: target, Chunk: []byte("x"), SeekOffset: -1}); status.Code(err) != codes.InvalidArgument { + t.Fatalf("negative offset: expected InvalidArgument, got %v", err) + } +} + +func TestReadFileWithModeCreatesMissingFile(t *testing.T) { + tempDir := t.TempDir() + client, cleanup := setupTestFileSystemServer(t, Config{RootDirectory: tempDir}) + defer cleanup() + ctx := context.Background() + + readAll := func(req *ateenvv1alpha.ReadFileRequest) ([]byte, error) { + stream, err := client.ReadFile(ctx, req) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + var buf bytes.Buffer + for { + msg, err := stream.Recv() + if err == io.EOF { + return buf.Bytes(), nil + } + if err != nil { + return nil, err + } + buf.Write(msg.GetChunk()) + } + } + + // Without a mode a missing file is NotFound and nothing is created. + target := filepath.Join(tempDir, "sub", "new.txt") + if _, err := readAll(&ateenvv1alpha.ReadFileRequest{Path: target}); status.Code(err) != codes.NotFound { + t.Fatalf("expected NotFound, got %v", err) + } + if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("file should not exist yet: %v", err) + } + + // With a mode the file (and parent) is created empty with that mode. + data, err := readAll(&ateenvv1alpha.ReadFileRequest{Path: target, Mode: 0600}) + if err != nil { + t.Fatalf("read with mode: %v", err) + } + if len(data) != 0 { + t.Fatalf("expected empty content, got %q", data) + } + info, err := os.Stat(target) + if err != nil { + t.Fatalf("stat created file: %v", err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("mode = %o, want 600", info.Mode().Perm()) + } + + // An existing file is returned unchanged, mode or not. + if err := os.WriteFile(target, []byte("content"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(target, 0644); err != nil { + t.Fatal(err) + } + data, err = readAll(&ateenvv1alpha.ReadFileRequest{Path: target, Mode: 0755}) + if err != nil { + t.Fatalf("read existing with mode: %v", err) + } + if string(data) != "content" { + t.Fatalf("content = %q", data) + } + if info, _ := os.Stat(target); info.Mode().Perm() != 0644 { + t.Fatalf("existing file mode changed to %o", info.Mode().Perm()) + } +} diff --git a/guest/process/service.go b/guest/process/service.go index a8d90e0..98af666 100644 --- a/guest/process/service.go +++ b/guest/process/service.go @@ -17,16 +17,15 @@ package process import ( "context" "errors" - "os" - "time" + "io" ateenvv1alpha "github.com/agent-substrate/env/proto/ateenv/v1alpha" + "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) -// Service implements ateenvv1alpha.ProcessServiceServer. -// It provides in-actor asynchronous process execution and log streaming for cmd/ate-env-guest. +// Service implements ateenvv1alpha.ProcessServiceServer on top of a Tracker. type Service struct { ateenvv1alpha.UnimplementedProcessServiceServer tracker *Tracker @@ -34,141 +33,210 @@ type Service struct { // NewService creates a new ProcessServiceServer instance. func NewService(tracker *Tracker) *Service { - return &Service{ - tracker: tracker, - } + return &Service{tracker: tracker} } -// StartProcess launches a process asynchronously in the background. -func (s *Service) StartProcess(ctx context.Context, req *ateenvv1alpha.StartProcessRequest) (*ateenvv1alpha.StartProcessResponse, error) { +// StartProcess launches a process in the background and returns its resource. +func (s *Service) StartProcess(ctx context.Context, req *ateenvv1alpha.StartProcessRequest) (*ateenvv1alpha.Process, error) { if len(req.GetCommand()) == 0 { return nil, status.Error(codes.InvalidArgument, "command cannot be empty") } + if req.GetTimeout() != nil && !req.GetTimeout().IsValid() { + return nil, status.Error(codes.InvalidArgument, "timeout is invalid") + } - state, err := s.tracker.Start(req.GetCommand(), req.GetCwd(), req.GetEnv()) + state, err := s.tracker.Start(StartOptions{ + Command: req.GetCommand(), + Cwd: req.GetCwd(), + Env: req.GetEnv(), + Stdin: req.GetStdin(), + Timeout: req.GetTimeout().AsDuration(), + }) if err != nil { - return nil, err + return nil, rpcError(err, "") } - - return &ateenvv1alpha.StartProcessResponse{ - ProcessId: state.ProcessID, - }, nil + return state.ToProto(), nil } -// GetProcess returns the metadata, status, and exit code of a process. +// GetProcess returns the current state of a process. func (s *Service) GetProcess(ctx context.Context, req *ateenvv1alpha.GetProcessRequest) (*ateenvv1alpha.Process, error) { - if req.GetProcessId() == "" { - return nil, status.Error(codes.InvalidArgument, "process_id cannot be empty") - } - - state, ok := s.tracker.Get(req.GetProcessId()) - if !ok { - return nil, status.Errorf(codes.NotFound, "process %q not found", req.GetProcessId()) + state, err := s.lookup(req.GetProcessId()) + if err != nil { + return nil, err } - return state.ToProto(), nil } -// StreamProcessOutputs streams stdout and stderr in real-time or as a snapshot. -func (s *Service) StreamProcessOutputs(req *ateenvv1alpha.StreamProcessOutputsRequest, stream ateenvv1alpha.ProcessService_StreamProcessOutputsServer) error { - if req.GetProcessId() == "" { - return status.Error(codes.InvalidArgument, "process_id cannot be empty") +// StreamProcessOutput streams stdout and stderr, ending with an exit message +// once the process has exited and its output has been fully delivered. +func (s *Service) StreamProcessOutput(req *ateenvv1alpha.StreamProcessOutputRequest, stream grpc.ServerStreamingServer[ateenvv1alpha.ProcessOutput]) error { + state, err := s.lookup(req.GetProcessId()) + if err != nil { + return err } - - state, ok := s.tracker.Get(req.GetProcessId()) - if !ok { - return status.Errorf(codes.NotFound, "process %q not found", req.GetProcessId()) + if req.GetStdoutOffset() < 0 || req.GetStderrOffset() < 0 { + return status.Error(codes.InvalidArgument, "offsets cannot be negative") } - stdoutOffset := req.GetStdoutOffset() - stderrOffset := req.GetStderrOffset() - follow := req.GetFollow() ctx := stream.Context() + cursor := &outputCursor{ + state: state, + stream: stream, + stdout: req.GetStdoutOffset(), + stderr: req.GetStderrOffset(), + } for { - // Read stdout delta - stdoutBytes, newStdoutOffset, err := ReadLogs(state.StdoutPath, stdoutOffset) - if err != nil { - return status.Errorf(codes.Internal, "reading stdout: %v", err) + // Grab the change signal before reading so a write that lands between + // the read and the select still wakes us. + changed := state.OutputChanged() + if err := cursor.flush(); err != nil { + return err } - if len(stdoutBytes) > 0 { - if err := stream.Send(&ateenvv1alpha.OutputChunk{ - Source: ateenvv1alpha.OutputSource_OUTPUT_SOURCE_STDOUT, - Data: stdoutBytes, - }); err != nil { + + if state.Exited() { + // The reaper flushed the spool before marking the process exited; + // pick up anything written after our last read, then finish. + if err := cursor.flush(); err != nil { return err } - stdoutOffset = newStdoutOffset + return stream.Send(&ateenvv1alpha.ProcessOutput{ + Output: &ateenvv1alpha.ProcessOutput_Exit{Exit: state.ToProto()}, + }) + } + if !req.GetFollow() { + return nil } - // Read stderr delta - stderrBytes, newStderrOffset, err := ReadLogs(state.StderrPath, stderrOffset) - if err != nil { - return status.Errorf(codes.Internal, "reading stderr: %v", err) + select { + case <-ctx.Done(): + return status.FromContextError(ctx.Err()).Err() + case <-changed: + case <-state.Done(): } - if len(stderrBytes) > 0 { - if err := stream.Send(&ateenvv1alpha.OutputChunk{ - Source: ateenvv1alpha.OutputSource_OUTPUT_SOURCE_STDERR, - Data: stderrBytes, - }); err != nil { - return err - } - stderrOffset = newStderrOffset + } +} + +// outputCursor tracks read offsets into a process's spool files and sends deltas. +type outputCursor struct { + state *ProcessState + stream grpc.ServerStreamingServer[ateenvv1alpha.ProcessOutput] + stdout int64 + stderr int64 +} + +func (c *outputCursor) flush() error { + data, next, err := ReadSpool(c.state.StdoutPath, c.stdout) + if err != nil { + return status.Errorf(codes.Internal, "reading stdout: %v", err) + } + if len(data) > 0 { + if err := c.stream.Send(&ateenvv1alpha.ProcessOutput{Output: &ateenvv1alpha.ProcessOutput_Stdout{Stdout: data}}); err != nil { + return err } + } + c.stdout = next - if !follow { - // Snapshot mode: finish after reading available output up to this point - return nil + data, next, err = ReadSpool(c.state.StderrPath, c.stderr) + if err != nil { + return status.Errorf(codes.Internal, "reading stderr: %v", err) + } + if len(data) > 0 { + if err := c.stream.Send(&ateenvv1alpha.ProcessOutput{Output: &ateenvv1alpha.ProcessOutput_Stderr{Stderr: data}}); err != nil { + return err } + } + c.stderr = next + return nil +} - // Check if process has finished and we consumed all output - state.mu.RLock() - isTerminated := state.Status != ateenvv1alpha.ProcessStatus_PROCESS_STATUS_RUNNING - state.mu.RUnlock() - - if isTerminated { - // Final check to see if there were any remaining bytes flushed on exit - finalStdout, _, _ := ReadLogs(state.StdoutPath, stdoutOffset) - if len(finalStdout) > 0 { - _ = stream.Send(&ateenvv1alpha.OutputChunk{ - Source: ateenvv1alpha.OutputSource_OUTPUT_SOURCE_STDOUT, - Data: finalStdout, - }) +// WriteProcessInput feeds stdin from a client stream. stdin stays open across +// calls until a message sets close. +func (s *Service) WriteProcessInput(stream grpc.ClientStreamingServer[ateenvv1alpha.WriteProcessInputRequest, ateenvv1alpha.WriteProcessInputResponse]) error { + var ( + processID string + total int64 + ) + for { + req, err := stream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return err + } + if processID == "" { + processID = req.GetProcessId() + if processID == "" { + return status.Error(codes.InvalidArgument, "process_id is required on the first message") } - finalStderr, _, _ := ReadLogs(state.StderrPath, stderrOffset) - if len(finalStderr) > 0 { - _ = stream.Send(&ateenvv1alpha.OutputChunk{ - Source: ateenvv1alpha.OutputSource_OUTPUT_SOURCE_STDERR, - Data: finalStderr, - }) + if _, err := s.tracker.Get(processID); err != nil { + return rpcError(err, processID) } - return nil } - - // Sleep or wait for context cancellation - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(50 * time.Millisecond): + if len(req.GetData()) > 0 { + n, err := s.tracker.WriteInput(processID, req.GetData()) + total += int64(n) + if err != nil { + return rpcError(err, processID) + } } + if req.GetClose() { + if err := s.tracker.CloseInput(processID); err != nil { + return rpcError(err, processID) + } + } + } + if processID == "" { + return status.Error(codes.InvalidArgument, "process_id is required on the first message") } + return stream.SendAndClose(&ateenvv1alpha.WriteProcessInputResponse{BytesWritten: total}) } -// KillProcess terminates a running process and returns its exit code. -func (s *Service) KillProcess(ctx context.Context, req *ateenvv1alpha.KillProcessRequest) (*ateenvv1alpha.KillProcessResponse, error) { - if req.GetProcessId() == "" { - return nil, status.Error(codes.InvalidArgument, "process_id cannot be empty") +// SignalProcess delivers a signal to the process group and returns the +// process state right after delivery. +func (s *Service) SignalProcess(ctx context.Context, req *ateenvv1alpha.SignalProcessRequest) (*ateenvv1alpha.Process, error) { + state, err := s.lookup(req.GetProcessId()) + if err != nil { + return nil, err + } + sig, ok := ToSyscallSignal(req.GetSignal()) + if !ok { + return nil, status.Errorf(codes.InvalidArgument, "unsupported signal %v", req.GetSignal()) + } + if err := s.tracker.Signal(req.GetProcessId(), sig); err != nil { + return nil, rpcError(err, req.GetProcessId()) } + return state.ToProto(), nil +} - exitCode, err := s.tracker.Kill(req.GetProcessId()) +func (s *Service) lookup(processID string) (*ProcessState, error) { + if processID == "" { + return nil, status.Error(codes.InvalidArgument, "process_id cannot be empty") + } + state, err := s.tracker.Get(processID) if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil, status.Errorf(codes.NotFound, "process %q not found", req.GetProcessId()) - } - return nil, status.Errorf(codes.Internal, "killing process: %v", err) + return nil, rpcError(err, processID) } + return state, nil +} - return &ateenvv1alpha.KillProcessResponse{ - ExitCode: exitCode, - }, nil +// rpcError maps Tracker errors to gRPC statuses. +func rpcError(err error, processID string) error { + switch { + case err == nil: + return nil + case errors.Is(err, ErrNotFound): + return status.Errorf(codes.NotFound, "process %q not found", processID) + case errors.Is(err, ErrExited): + return status.Errorf(codes.FailedPrecondition, "process %q has exited", processID) + case errors.Is(err, ErrNoStdin): + return status.Errorf(codes.FailedPrecondition, "process %q was started without stdin", processID) + case errors.Is(err, ErrStdinClosed): + return status.Errorf(codes.FailedPrecondition, "stdin of process %q is closed", processID) + case errors.Is(err, ErrTooManyProcesses): + return status.Errorf(codes.ResourceExhausted, "%v; wait for running processes to exit or signal them", err) + default: + return status.Errorf(codes.Internal, "%v", err) + } } diff --git a/guest/process/service_test.go b/guest/process/service_test.go index 02974d2..1b4aa6c 100644 --- a/guest/process/service_test.go +++ b/guest/process/service_test.go @@ -16,7 +16,9 @@ package process import ( "context" + "errors" "io" + "math" "net" "os" "strings" @@ -29,14 +31,15 @@ import ( "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/status" "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/types/known/durationpb" ) -func setupTestServer(t *testing.T) (ateenvv1alpha.ProcessServiceClient, func()) { +func setupTestServer(t *testing.T) ateenvv1alpha.ProcessServiceClient { t.Helper() return setupTestServerWithConfig(t, DefaultConfig(t.TempDir())) } -func setupTestServerWithConfig(t *testing.T, cfg TrackerConfig) (ateenvv1alpha.ProcessServiceClient, func()) { +func setupTestServerWithConfig(t *testing.T, cfg TrackerConfig) ateenvv1alpha.ProcessServiceClient { t.Helper() if cfg.LogDir == "" { cfg.LogDir = t.TempDir() @@ -49,466 +52,481 @@ func setupTestServerWithConfig(t *testing.T, cfg TrackerConfig) (ateenvv1alpha.P lis := bufconn.Listen(1024 * 1024) server := grpc.NewServer() - svc := NewService(tracker) - ateenvv1alpha.RegisterProcessServiceServer(server, svc) - - go func() { - _ = server.Serve(lis) - }() + ateenvv1alpha.RegisterProcessServiceServer(server, NewService(tracker)) + go func() { _ = server.Serve(lis) }() conn, err := grpc.NewClient("passthrough://bufnet", - grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { - return lis.Dial() - }), + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return lis.Dial() }), grpc.WithTransportCredentials(insecure.NewCredentials()), ) if err != nil { t.Fatalf("failed to dial bufnet: %v", err) } - client := ateenvv1alpha.NewProcessServiceClient(conn) - - cleanup := func() { + t.Cleanup(func() { tracker.Close() conn.Close() server.Stop() lis.Close() _ = os.RemoveAll(cfg.LogDir) - } + }) + return ateenvv1alpha.NewProcessServiceClient(conn) +} - return client, cleanup +func start(t *testing.T, client ateenvv1alpha.ProcessServiceClient, req *ateenvv1alpha.StartProcessRequest) *ateenvv1alpha.Process { + t.Helper() + proc, err := client.StartProcess(context.Background(), req) + if err != nil { + t.Fatalf("StartProcess failed: %v", err) + } + if proc.GetProcessId() == "" || proc.GetPid() == 0 { + t.Fatalf("expected process id and pid, got %v", proc) + } + if proc.GetState() != ateenvv1alpha.ProcessState_PROCESS_STATE_RUNNING { + t.Fatalf("expected RUNNING right after start, got %v", proc.GetState()) + } + return proc } -func TestStartAndGetProcess(t *testing.T) { - client, cleanup := setupTestServer(t) - defer cleanup() +func sh(t *testing.T, client ateenvv1alpha.ProcessServiceClient, script string) *ateenvv1alpha.Process { + t.Helper() + return start(t, client, &ateenvv1alpha.StartProcessRequest{Command: []string{"sh", "-c", script}}) +} - ctx := context.Background() - startRes, err := client.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{ - Command: []string{"sh", "-c", "echo 'hello from substrate'"}, +// wait follows the output stream with offsets past the spool end, so only +// the exit message is delivered. +func wait(t *testing.T, client ateenvv1alpha.ProcessServiceClient, id string) *ateenvv1alpha.Process { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + stream, err := client.StreamProcessOutput(ctx, &ateenvv1alpha.StreamProcessOutputRequest{ + ProcessId: id, Follow: true, StdoutOffset: math.MaxInt64, StderrOffset: math.MaxInt64, }) if err != nil { - t.Fatalf("StartProcess failed: %v", err) + t.Fatalf("StreamProcessOutput failed: %v", err) } - - if startRes.ProcessId == "" { - t.Fatalf("expected non-empty process_id") + msg, err := stream.Recv() + if err != nil { + t.Fatalf("waiting for process: %v", err) + } + proc := msg.GetExit() + if proc == nil { + t.Fatalf("expected only an exit message with offsets past the end, got %v", msg) + } + if proc.GetState() != ateenvv1alpha.ProcessState_PROCESS_STATE_EXITED { + t.Fatalf("expected EXITED after wait, got %v", proc.GetState()) } + if _, err := stream.Recv(); !errors.Is(err, io.EOF) { + t.Fatalf("expected EOF after exit, got %v", err) + } + return proc +} - // Poll until completed - var proc *ateenvv1alpha.Process - for i := 0; i < 20; i++ { - proc, err = client.GetProcess(ctx, &ateenvv1alpha.GetProcessRequest{ - ProcessId: startRes.ProcessId, - }) +// collect drains an output stream into stdout, stderr and the final exit message. +func collect(t *testing.T, client ateenvv1alpha.ProcessServiceClient, req *ateenvv1alpha.StreamProcessOutputRequest) (string, string, *ateenvv1alpha.Process) { + t.Helper() + stream, err := client.StreamProcessOutput(context.Background(), req) + if err != nil { + t.Fatalf("StreamProcessOutput failed: %v", err) + } + var stdout, stderr strings.Builder + var exit *ateenvv1alpha.Process + for { + msg, err := stream.Recv() + if errors.Is(err, io.EOF) { + return stdout.String(), stderr.String(), exit + } if err != nil { - t.Fatalf("GetProcess failed: %v", err) + t.Fatalf("stream recv: %v", err) } - if proc.Status == ateenvv1alpha.ProcessStatus_PROCESS_STATUS_COMPLETED { - break + switch out := msg.GetOutput().(type) { + case *ateenvv1alpha.ProcessOutput_Stdout: + stdout.Write(out.Stdout) + case *ateenvv1alpha.ProcessOutput_Stderr: + stderr.Write(out.Stderr) + case *ateenvv1alpha.ProcessOutput_Exit: + if exit != nil { + t.Fatalf("received two exit messages") + } + exit = out.Exit + default: + t.Fatalf("unexpected output %T", out) + } + if exit != nil { + // exit must be the last message. + if _, err := stream.Recv(); !errors.Is(err, io.EOF) { + t.Fatalf("expected EOF after exit message, got %v", err) + } + return stdout.String(), stderr.String(), exit } - time.Sleep(50 * time.Millisecond) } +} - if proc.Status != ateenvv1alpha.ProcessStatus_PROCESS_STATUS_COMPLETED { - t.Fatalf("expected status COMPLETED, got %v", proc.Status) +func TestStartAndWait(t *testing.T) { + client := setupTestServer(t) + started := sh(t, client, "echo 'hello from substrate'") + if got := started.GetCommand(); len(got) != 3 || got[0] != "sh" { + t.Fatalf("command not echoed back: %v", got) } - if proc.ExitCode != 0 { - t.Fatalf("expected exit_code 0, got %d", proc.ExitCode) + + proc := wait(t, client, started.GetProcessId()) + if proc.GetExitCode() != 0 { + t.Fatalf("expected exit_code 0, got %d", proc.GetExitCode()) } - if proc.StartedAt == nil || proc.FinishedAt == nil { + if proc.GetStartedAt() == nil || proc.GetFinishedAt() == nil { t.Fatalf("expected non-nil timestamps") } -} - -func TestProcessFailureExitCode(t *testing.T) { - client, cleanup := setupTestServer(t) - defer cleanup() - ctx := context.Background() - startRes, err := client.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{ - Command: []string{"sh", "-c", "exit 42"}, - }) + got, err := client.GetProcess(context.Background(), &ateenvv1alpha.GetProcessRequest{ProcessId: started.GetProcessId()}) if err != nil { - t.Fatalf("StartProcess failed: %v", err) + t.Fatalf("GetProcess: %v", err) } - - var proc *ateenvv1alpha.Process - for i := 0; i < 20; i++ { - proc, err = client.GetProcess(ctx, &ateenvv1alpha.GetProcessRequest{ - ProcessId: startRes.ProcessId, - }) - if err != nil { - t.Fatalf("GetProcess failed: %v", err) - } - if proc.Status == ateenvv1alpha.ProcessStatus_PROCESS_STATUS_FAILED { - break - } - time.Sleep(50 * time.Millisecond) + if got.GetState() != ateenvv1alpha.ProcessState_PROCESS_STATE_EXITED { + t.Fatalf("GetProcess state = %v, want EXITED", got.GetState()) } +} - if proc.Status != ateenvv1alpha.ProcessStatus_PROCESS_STATUS_FAILED { - t.Fatalf("expected status FAILED, got %v", proc.Status) +func TestNonZeroExitCode(t *testing.T) { + client := setupTestServer(t) + proc := wait(t, client, sh(t, client, "exit 42").GetProcessId()) + if proc.GetExitCode() != 42 { + t.Fatalf("expected exit_code 42, got %d", proc.GetExitCode()) } - if proc.ExitCode != 42 { - t.Fatalf("expected exit_code 42, got %d", proc.ExitCode) +} + +func TestSelfSignalDeath(t *testing.T) { + client := setupTestServer(t) + proc := wait(t, client, sh(t, client, "kill -15 $$").GetProcessId()) + if proc.GetExitCode() != 143 { + t.Fatalf("expected exit_code 143 (128+SIGTERM), got %d", proc.GetExitCode()) } } -func TestStreamProcessOutputs(t *testing.T) { - client, cleanup := setupTestServer(t) - defer cleanup() +func TestStreamOutputFollowEndsWithExit(t *testing.T) { + client := setupTestServer(t) + started := sh(t, client, "echo 'out1'; echo 'err1' >&2; sleep 0.1; echo 'out2'; exit 3") - ctx := context.Background() - startRes, err := client.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{ - Command: []string{"sh", "-c", "echo 'out1'; echo 'err1' >&2; sleep 0.1; echo 'out2'"}, + stdout, stderr, exit := collect(t, client, &ateenvv1alpha.StreamProcessOutputRequest{ + ProcessId: started.GetProcessId(), Follow: true, }) - if err != nil { - t.Fatalf("StartProcess failed: %v", err) + if stdout != "out1\nout2\n" { + t.Fatalf("stdout = %q", stdout) } - - stream, err := client.StreamProcessOutputs(ctx, &ateenvv1alpha.StreamProcessOutputsRequest{ - ProcessId: startRes.ProcessId, - Follow: true, - }) - if err != nil { - t.Fatalf("StreamProcessOutputs failed: %v", err) + if stderr != "err1\n" { + t.Fatalf("stderr = %q", stderr) } + if exit == nil || exit.GetExitCode() != 3 { + t.Fatalf("expected exit message with code 3, got %v", exit) + } +} - var stdoutBuilder strings.Builder - var stderrBuilder strings.Builder +func TestStreamOutputOffsets(t *testing.T) { + client := setupTestServer(t) + started := sh(t, client, "printf abcdef; printf 123456 >&2") + wait(t, client, started.GetProcessId()) - for { - chunk, err := stream.Recv() - if err == io.EOF { - break - } - if err != nil { - t.Fatalf("error reading output chunk: %v", err) - } - if chunk.Source == ateenvv1alpha.OutputSource_OUTPUT_SOURCE_STDOUT { - stdoutBuilder.Write(chunk.Data) - } else if chunk.Source == ateenvv1alpha.OutputSource_OUTPUT_SOURCE_STDERR { - stderrBuilder.Write(chunk.Data) - } + stdout, stderr, exit := collect(t, client, &ateenvv1alpha.StreamProcessOutputRequest{ + ProcessId: started.GetProcessId(), StdoutOffset: 4, StderrOffset: 2, + }) + if stdout != "ef" || stderr != "3456" { + t.Fatalf("got stdout %q stderr %q", stdout, stderr) + } + if exit == nil { + t.Fatalf("snapshot of an exited process should end with exit") } +} - stdout := stdoutBuilder.String() - stderr := stderrBuilder.String() +func TestStreamOutputSnapshotOfRunningProcess(t *testing.T) { + client := setupTestServer(t) + started := sh(t, client, "echo 'instant-output'; sleep 5") + time.Sleep(100 * time.Millisecond) - if !strings.Contains(stdout, "out1") || !strings.Contains(stdout, "out2") { - t.Fatalf("expected stdout to contain out1 and out2, got %q", stdout) + begin := time.Now() + stdout, _, exit := collect(t, client, &ateenvv1alpha.StreamProcessOutputRequest{ProcessId: started.GetProcessId()}) + if d := time.Since(begin); d > 2*time.Second { + t.Fatalf("snapshot took %v, should return immediately", d) + } + if stdout != "instant-output\n" { + t.Fatalf("stdout = %q", stdout) } - if !strings.Contains(stderr, "err1") { - t.Fatalf("expected stderr to contain err1, got %q", stderr) + if exit != nil { + t.Fatalf("running process must not produce an exit message") } + _, _ = client.SignalProcess(context.Background(), &ateenvv1alpha.SignalProcessRequest{ + ProcessId: started.GetProcessId(), Signal: ateenvv1alpha.Signal_SIGNAL_KILL, + }) } -func TestStreamProcessOutputsWithOffset(t *testing.T) { - client, cleanup := setupTestServer(t) - defer cleanup() - +func TestSignalProcess(t *testing.T) { + client := setupTestServer(t) ctx := context.Background() - startRes, err := client.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{ - Command: []string{"sh", "-c", "echo 'prefix-to-skip'; echo 'streamed-line'"}, + started := sh(t, client, "sleep 60") + + proc, err := client.SignalProcess(ctx, &ateenvv1alpha.SignalProcessRequest{ + ProcessId: started.GetProcessId(), Signal: ateenvv1alpha.Signal_SIGNAL_TERM, }) if err != nil { - t.Fatalf("StartProcess failed: %v", err) + t.Fatalf("SignalProcess failed: %v", err) + } + if proc.GetProcessId() != started.GetProcessId() { + t.Fatalf("SignalProcess returned wrong process %v", proc) } - time.Sleep(100 * time.Millisecond) + final := wait(t, client, started.GetProcessId()) + if final.GetExitCode() != 143 { + t.Fatalf("expected exit_code 143 (128+SIGTERM), got %d", final.GetExitCode()) + } - skipLen := int64(len("prefix-to-skip\n")) - stream, err := client.StreamProcessOutputs(ctx, &ateenvv1alpha.StreamProcessOutputsRequest{ - ProcessId: startRes.ProcessId, - StdoutOffset: skipLen, - Follow: false, + // Signalling an exited process is a failed precondition. + _, err = client.SignalProcess(ctx, &ateenvv1alpha.SignalProcessRequest{ + ProcessId: started.GetProcessId(), Signal: ateenvv1alpha.Signal_SIGNAL_KILL, }) - if err != nil { - t.Fatalf("StreamProcessOutputs failed: %v", err) + if status.Code(err) != codes.FailedPrecondition { + t.Fatalf("expected FailedPrecondition, got %v", err) } - var stdoutBuilder strings.Builder - for { - chunk, err := stream.Recv() - if err == io.EOF { - break - } - if err != nil { - t.Fatalf("error reading output chunk: %v", err) - } - if chunk.Source == ateenvv1alpha.OutputSource_OUTPUT_SOURCE_STDOUT { - stdoutBuilder.Write(chunk.Data) - } + // Unspecified signal is rejected. + _, err = client.SignalProcess(ctx, &ateenvv1alpha.SignalProcessRequest{ProcessId: started.GetProcessId()}) + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("expected InvalidArgument, got %v", err) } +} + +func TestSignalTrappedByProcess(t *testing.T) { + client := setupTestServer(t) + started := sh(t, client, `trap 'echo got-usr1; exit 7' USR1; while :; do sleep 0.05; done`) + time.Sleep(150 * time.Millisecond) - out := stdoutBuilder.String() - if strings.Contains(out, "prefix-to-skip") { - t.Fatalf("expected prefix-to-skip to be skipped, got %q", out) + if _, err := client.SignalProcess(context.Background(), &ateenvv1alpha.SignalProcessRequest{ + ProcessId: started.GetProcessId(), Signal: ateenvv1alpha.Signal_SIGNAL_USR1, + }); err != nil { + t.Fatalf("SignalProcess failed: %v", err) } - if !strings.Contains(out, "streamed-line") { - t.Fatalf("expected streamed-line in output, got %q", out) + + stdout, _, exit := collect(t, client, &ateenvv1alpha.StreamProcessOutputRequest{ProcessId: started.GetProcessId(), Follow: true}) + if !strings.Contains(stdout, "got-usr1") { + t.Fatalf("handler did not run, stdout = %q", stdout) + } + if exit.GetExitCode() != 7 { + t.Fatalf("expected exit code 7 from handler, got %d", exit.GetExitCode()) } } -func TestStreamProcessOutputsSnapshotNoFollow(t *testing.T) { - client, cleanup := setupTestServer(t) - defer cleanup() - +func TestStopAndContinue(t *testing.T) { + client := setupTestServer(t) ctx := context.Background() - startRes, err := client.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{ - Command: []string{"sh", "-c", "echo 'instant-output'; sleep 5"}, - }) + started := sh(t, client, "sleep 0.2; echo done") + + signal := func(sig ateenvv1alpha.Signal) { + if _, err := client.SignalProcess(ctx, &ateenvv1alpha.SignalProcessRequest{ProcessId: started.GetProcessId(), Signal: sig}); err != nil { + t.Fatalf("signal %v: %v", sig, err) + } + } + signal(ateenvv1alpha.Signal_SIGNAL_STOP) + + // Well past the sleep: a stopped process must not have progressed. + time.Sleep(500 * time.Millisecond) + proc, err := client.GetProcess(ctx, &ateenvv1alpha.GetProcessRequest{ProcessId: started.GetProcessId()}) if err != nil { - t.Fatalf("StartProcess failed: %v", err) + t.Fatalf("GetProcess: %v", err) + } + if proc.GetState() != ateenvv1alpha.ProcessState_PROCESS_STATE_RUNNING { + t.Fatalf("stopped process should still be RUNNING, got %v", proc.GetState()) } - time.Sleep(100 * time.Millisecond) + signal(ateenvv1alpha.Signal_SIGNAL_CONT) + if final := wait(t, client, started.GetProcessId()); final.GetExitCode() != 0 { + t.Fatalf("expected clean exit after CONT, got %d", final.GetExitCode()) + } +} - start := time.Now() - stream, err := client.StreamProcessOutputs(ctx, &ateenvv1alpha.StreamProcessOutputsRequest{ - ProcessId: startRes.ProcessId, - Follow: false, - }) +func TestStdinStreaming(t *testing.T) { + client := setupTestServer(t) + ctx := context.Background() + started := start(t, client, &ateenvv1alpha.StartProcessRequest{Command: []string{"cat"}, Stdin: true}) + + in, err := client.WriteProcessInput(ctx) + if err != nil { + t.Fatalf("WriteProcessInput: %v", err) + } + if err := in.Send(&ateenvv1alpha.WriteProcessInputRequest{ProcessId: started.GetProcessId(), Data: []byte("hello ")}); err != nil { + t.Fatalf("send: %v", err) + } + if err := in.Send(&ateenvv1alpha.WriteProcessInputRequest{Data: []byte("world\n")}); err != nil { + t.Fatalf("send: %v", err) + } + resp, err := in.CloseAndRecv() if err != nil { - t.Fatalf("StreamProcessOutputs failed: %v", err) + t.Fatalf("CloseAndRecv: %v", err) + } + if resp.GetBytesWritten() != int64(len("hello world\n")) { + t.Fatalf("bytes_written = %d", resp.GetBytesWritten()) } - var stdoutBuilder strings.Builder + // cat is still running: stdin was not closed. Snapshot shows the echo. + deadline := time.Now().Add(2 * time.Second) for { - chunk, err := stream.Recv() - if err == io.EOF { + stdout, _, _ := collect(t, client, &ateenvv1alpha.StreamProcessOutputRequest{ProcessId: started.GetProcessId()}) + if stdout == "hello world\n" { break } - if err != nil { - t.Fatalf("error reading chunk: %v", err) + if time.Now().After(deadline) { + t.Fatalf("cat did not echo input, stdout = %q", stdout) } - stdoutBuilder.Write(chunk.Data) + time.Sleep(20 * time.Millisecond) + } + if p, _ := client.GetProcess(ctx, &ateenvv1alpha.GetProcessRequest{ProcessId: started.GetProcessId()}); p.GetState() != ateenvv1alpha.ProcessState_PROCESS_STATE_RUNNING { + t.Fatalf("cat should still be running with stdin open") } - duration := time.Since(start) - if duration > 2*time.Second { - t.Fatalf("snapshot mode (follow=false) took %v, should have returned immediately", duration) + // Second call: more data plus EOF. + in, err = client.WriteProcessInput(ctx) + if err != nil { + t.Fatalf("WriteProcessInput: %v", err) } - if !strings.Contains(stdoutBuilder.String(), "instant-output") { - t.Fatalf("expected output in snapshot, got %q", stdoutBuilder.String()) + _ = in.Send(&ateenvv1alpha.WriteProcessInputRequest{ProcessId: started.GetProcessId(), Data: []byte("bye\n"), Close: true}) + if _, err := in.CloseAndRecv(); err != nil { + t.Fatalf("CloseAndRecv: %v", err) } -} -func TestKillProcess(t *testing.T) { - client, cleanup := setupTestServer(t) - defer cleanup() + stdout, _, exit := collect(t, client, &ateenvv1alpha.StreamProcessOutputRequest{ProcessId: started.GetProcessId(), Follow: true}) + if stdout != "hello world\nbye\n" { + t.Fatalf("stdout = %q", stdout) + } + if exit.GetExitCode() != 0 { + t.Fatalf("cat exit = %d", exit.GetExitCode()) + } - ctx := context.Background() - startRes, err := client.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{ - Command: []string{"sh", "-c", "sleep 60"}, - }) - if err != nil { - t.Fatalf("StartProcess failed: %v", err) + // Writing after close fails. + in, _ = client.WriteProcessInput(ctx) + _ = in.Send(&ateenvv1alpha.WriteProcessInputRequest{ProcessId: started.GetProcessId(), Data: []byte("late")}) + if _, err := in.CloseAndRecv(); status.Code(err) != codes.FailedPrecondition { + t.Fatalf("expected FailedPrecondition writing after close, got %v", err) } +} - time.Sleep(50 * time.Millisecond) +func TestStdinRejectedWhenNotRequested(t *testing.T) { + client := setupTestServer(t) + ctx := context.Background() + started := start(t, client, &ateenvv1alpha.StartProcessRequest{Command: []string{"cat"}}) - killRes, err := client.KillProcess(ctx, &ateenvv1alpha.KillProcessRequest{ - ProcessId: startRes.ProcessId, - }) - if err != nil { - t.Fatalf("KillProcess failed: %v", err) + // Without a stdin pipe cat sees EOF immediately and exits 0. + if proc := wait(t, client, started.GetProcessId()); proc.GetExitCode() != 0 { + t.Fatalf("cat without stdin should exit 0, got %d", proc.GetExitCode()) } - if killRes.ExitCode != 137 { - t.Fatalf("expected exit code 137 after kill, got %d", killRes.ExitCode) + in, _ := client.WriteProcessInput(ctx) + _ = in.Send(&ateenvv1alpha.WriteProcessInputRequest{ProcessId: started.GetProcessId(), Data: []byte("x")}) + if _, err := in.CloseAndRecv(); status.Code(err) != codes.FailedPrecondition { + t.Fatalf("expected FailedPrecondition, got %v", err) } - proc, err := client.GetProcess(ctx, &ateenvv1alpha.GetProcessRequest{ - ProcessId: startRes.ProcessId, - }) - if err != nil { - t.Fatalf("GetProcess failed: %v", err) + in, _ = client.WriteProcessInput(ctx) + _ = in.Send(&ateenvv1alpha.WriteProcessInputRequest{ProcessId: "nope", Data: []byte("x")}) + if _, err := in.CloseAndRecv(); status.Code(err) != codes.NotFound { + t.Fatalf("expected NotFound, got %v", err) } - if proc.Status != ateenvv1alpha.ProcessStatus_PROCESS_STATUS_TERMINATED { - t.Fatalf("expected status TERMINATED, got %v", proc.Status) - } - if proc.ExitCode != 137 { - t.Fatalf("expected exit code 137, got %d", proc.ExitCode) + in, _ = client.WriteProcessInput(ctx) + if _, err := in.CloseAndRecv(); status.Code(err) != codes.InvalidArgument { + t.Fatalf("expected InvalidArgument for empty stream, got %v", err) } } -func TestProcessSignalDeath(t *testing.T) { - client, cleanup := setupTestServer(t) - defer cleanup() - +func TestNotFound(t *testing.T) { + client := setupTestServer(t) ctx := context.Background() - startRes, err := client.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{ - Command: []string{"sh", "-c", "kill -15 $$"}, - }) + if _, err := client.GetProcess(ctx, &ateenvv1alpha.GetProcessRequest{ProcessId: "nope"}); status.Code(err) != codes.NotFound { + t.Fatalf("GetProcess: expected NotFound, got %v", err) + } + stream, err := client.StreamProcessOutput(ctx, &ateenvv1alpha.StreamProcessOutputRequest{ProcessId: "nope"}) if err != nil { - t.Fatalf("StartProcess failed: %v", err) + t.Fatalf("StreamProcessOutput: %v", err) } - - var proc *ateenvv1alpha.Process - for i := 0; i < 20; i++ { - proc, err = client.GetProcess(ctx, &ateenvv1alpha.GetProcessRequest{ - ProcessId: startRes.ProcessId, - }) - if err != nil { - t.Fatalf("GetProcess failed: %v", err) - } - if proc.Status == ateenvv1alpha.ProcessStatus_PROCESS_STATUS_TERMINATED { - break - } - time.Sleep(50 * time.Millisecond) + if _, err := stream.Recv(); status.Code(err) != codes.NotFound { + t.Fatalf("StreamProcessOutput: expected NotFound, got %v", err) } - - if proc.Status != ateenvv1alpha.ProcessStatus_PROCESS_STATUS_TERMINATED { - t.Fatalf("expected status TERMINATED, got %v", proc.Status) + if _, err := client.GetProcess(ctx, &ateenvv1alpha.GetProcessRequest{}); status.Code(err) != codes.InvalidArgument { + t.Fatalf("empty id: expected InvalidArgument, got %v", err) } - if proc.ExitCode != 143 { // 128 + 15 (SIGTERM) - t.Fatalf("expected exit code 143 (128+SIGTERM), got %d", proc.ExitCode) + if _, err := client.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{}); status.Code(err) != codes.InvalidArgument { + t.Fatalf("empty command: expected InvalidArgument, got %v", err) } } func TestConcurrencyLimiter(t *testing.T) { - // Configure tracker with max 2 concurrent jobs cfg := DefaultConfig("") cfg.MaxConcurrentProcesses = 2 - - client, cleanup := setupTestServerWithConfig(t, cfg) - defer cleanup() - + client := setupTestServerWithConfig(t, cfg) ctx := context.Background() - // Launch job 1 (running for 5s) - res1, err := client.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{ - Command: []string{"sh", "-c", "sleep 5"}, - }) - if err != nil { - t.Fatalf("job 1 failed: %v", err) - } - - // Launch job 2 (running for 5s) - res2, err := client.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{ - Command: []string{"sh", "-c", "sleep 5"}, - }) - if err != nil { - t.Fatalf("job 2 failed: %v", err) - } + res1 := sh(t, client, "sleep 5") + res2 := sh(t, client, "sleep 5") - // Launch job 3 -> Must be rejected with ResourceExhausted! - _, err = client.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{ - Command: []string{"sh", "-c", "echo 'should fail'"}, - }) + _, err := client.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{Command: []string{"sh", "-c", "echo 'should fail'"}}) if status.Code(err) != codes.ResourceExhausted { t.Fatalf("expected ResourceExhausted for job 3, got: %v", err) } - // Kill job 1 to free up a slot - _, err = client.KillProcess(ctx, &ateenvv1alpha.KillProcessRequest{ - ProcessId: res1.ProcessId, - }) - if err != nil { + // Kill job 1 and wait for the slot to free up. + if _, err := client.SignalProcess(ctx, &ateenvv1alpha.SignalProcessRequest{ProcessId: res1.GetProcessId(), Signal: ateenvv1alpha.Signal_SIGNAL_KILL}); err != nil { t.Fatalf("failed to kill job 1: %v", err) } - - // Now job 3 should succeed - res3, err := client.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{ - Command: []string{"sh", "-c", "echo 'now succeeds'"}, - }) - if err != nil { - t.Fatalf("job 3 failed after slot freed: %v", err) - } - if res3.ProcessId == "" { - t.Fatalf("expected non-empty process ID for job 3") + if proc := wait(t, client, res1.GetProcessId()); proc.GetExitCode() != 137 { + t.Fatalf("expected 137 (128+SIGKILL), got %d", proc.GetExitCode()) } - // Clean up job 2 - _, _ = client.KillProcess(ctx, &ateenvv1alpha.KillProcessRequest{ProcessId: res2.ProcessId}) + sh(t, client, "echo 'now succeeds'") + _, _ = client.SignalProcess(ctx, &ateenvv1alpha.SignalProcessRequest{ProcessId: res2.GetProcessId(), Signal: ateenvv1alpha.Signal_SIGNAL_KILL}) } func TestLogCapping(t *testing.T) { - // Configure max log size to 256 bytes cfg := DefaultConfig("") cfg.MaxLogBytes = 256 + client := setupTestServerWithConfig(t, cfg) - client, cleanup := setupTestServerWithConfig(t, cfg) - defer cleanup() - - ctx := context.Background() + started := sh(t, client, "for i in $(seq 1 500); do echo 'spamming-log-line-0123456789'; done") + stdout, _, _ := collect(t, client, &ateenvv1alpha.StreamProcessOutputRequest{ProcessId: started.GetProcessId(), Follow: true}) - // Command outputs 10,000 bytes of spam - res, err := client.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{ - Command: []string{"sh", "-c", "for i in $(seq 1 500); do echo 'spamming-log-line-0123456789'; done"}, - }) - if err != nil { - t.Fatalf("StartProcess failed: %v", err) + if !strings.Contains(stdout, "maximum log limit") || !strings.Contains(stdout, "truncated") { + t.Fatalf("expected truncation warning in capped logs, got:\n%s", stdout) } - - stream, err := client.StreamProcessOutputs(ctx, &ateenvv1alpha.StreamProcessOutputsRequest{ - ProcessId: res.ProcessId, - Follow: true, - }) - if err != nil { - t.Fatalf("StreamProcessOutputs failed: %v", err) - } - - var stdout strings.Builder - for { - chunk, err := stream.Recv() - if err == io.EOF { - break - } - if err != nil { - t.Fatalf("stream recv error: %v", err) - } - if chunk.Source == ateenvv1alpha.OutputSource_OUTPUT_SOURCE_STDOUT { - stdout.Write(chunk.Data) - } - } - - outStr := stdout.String() - if !strings.Contains(outStr, "maximum log limit") || !strings.Contains(outStr, "truncated") { - t.Fatalf("expected truncation warning in capped logs, got:\n%s", outStr) - } - - // Total length should be close to 256 bytes + truncation banner (~350 bytes), not 15,000 bytes! - if len(outStr) > 1000 { - t.Fatalf("log size %d exceeded capped expectation", len(outStr)) + if len(stdout) > 1000 { + t.Fatalf("log size %d exceeded capped expectation", len(stdout)) } } -func TestWatchdogTimeout(t *testing.T) { - // Configure 100ms watchdog timeout +func TestDefaultTimeoutKills(t *testing.T) { cfg := DefaultConfig("") cfg.DefaultProcessTimeout = 100 * time.Millisecond + client := setupTestServerWithConfig(t, cfg) - client, cleanup := setupTestServerWithConfig(t, cfg) - defer cleanup() - - ctx := context.Background() - - // Process attempts to sleep 30 seconds - res, err := client.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{ - Command: []string{"sh", "-c", "sleep 30"}, - }) - if err != nil { - t.Fatalf("StartProcess failed: %v", err) + started := sh(t, client, "sleep 30") + proc := wait(t, client, started.GetProcessId()) + if proc.GetExitCode() != 137 { + t.Fatalf("expected 137 (128+SIGKILL) from watchdog, got %d", proc.GetExitCode()) } +} - // Wait 250ms for watchdog timer to trigger - time.Sleep(250 * time.Millisecond) - - proc, err := client.GetProcess(ctx, &ateenvv1alpha.GetProcessRequest{ - ProcessId: res.ProcessId, +func TestPerProcessTimeout(t *testing.T) { + client := setupTestServer(t) + started := start(t, client, &ateenvv1alpha.StartProcessRequest{ + Command: []string{"sleep", "30"}, + Timeout: durationpb.New(100 * time.Millisecond), }) - if err != nil { - t.Fatalf("GetProcess failed: %v", err) + proc := wait(t, client, started.GetProcessId()) + if proc.GetExitCode() != 137 { + t.Fatalf("expected 137 (128+SIGKILL) from per-process timeout, got %d", proc.GetExitCode()) } +} - if proc.Status != ateenvv1alpha.ProcessStatus_PROCESS_STATUS_TERMINATED { - t.Fatalf("expected status TERMINATED by watchdog timeout, got %v", proc.Status) +func TestSignalRoundTrip(t *testing.T) { + for api, sys := range signalTable { + if got := FromSyscallSignal(sys); got != api { + t.Errorf("FromSyscallSignal(%v) = %v, want %v", sys, got, api) + } } - if proc.ExitCode != 137 { - t.Fatalf("expected exit code 137, got %d", proc.ExitCode) + if _, ok := ToSyscallSignal(ateenvv1alpha.Signal_SIGNAL_UNSPECIFIED); ok { + t.Errorf("UNSPECIFIED must not map to a signal") } } diff --git a/guest/process/signal.go b/guest/process/signal.go new file mode 100644 index 0000000..8c31e80 --- /dev/null +++ b/guest/process/signal.go @@ -0,0 +1,76 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package process + +import ( + "syscall" + + ateenvv1alpha "github.com/agent-substrate/env/proto/ateenv/v1alpha" +) + +// signalTable maps API signals to the host's syscall signals by name, so the +// mapping stays correct on platforms whose signal numbers differ from Linux. +var signalTable = map[ateenvv1alpha.Signal]syscall.Signal{ + ateenvv1alpha.Signal_SIGNAL_HUP: syscall.SIGHUP, + ateenvv1alpha.Signal_SIGNAL_INT: syscall.SIGINT, + ateenvv1alpha.Signal_SIGNAL_QUIT: syscall.SIGQUIT, + ateenvv1alpha.Signal_SIGNAL_ILL: syscall.SIGILL, + ateenvv1alpha.Signal_SIGNAL_TRAP: syscall.SIGTRAP, + ateenvv1alpha.Signal_SIGNAL_ABRT: syscall.SIGABRT, + ateenvv1alpha.Signal_SIGNAL_BUS: syscall.SIGBUS, + ateenvv1alpha.Signal_SIGNAL_FPE: syscall.SIGFPE, + ateenvv1alpha.Signal_SIGNAL_KILL: syscall.SIGKILL, + ateenvv1alpha.Signal_SIGNAL_USR1: syscall.SIGUSR1, + ateenvv1alpha.Signal_SIGNAL_SEGV: syscall.SIGSEGV, + ateenvv1alpha.Signal_SIGNAL_USR2: syscall.SIGUSR2, + ateenvv1alpha.Signal_SIGNAL_PIPE: syscall.SIGPIPE, + ateenvv1alpha.Signal_SIGNAL_ALRM: syscall.SIGALRM, + ateenvv1alpha.Signal_SIGNAL_TERM: syscall.SIGTERM, + ateenvv1alpha.Signal_SIGNAL_CHLD: syscall.SIGCHLD, + ateenvv1alpha.Signal_SIGNAL_CONT: syscall.SIGCONT, + ateenvv1alpha.Signal_SIGNAL_STOP: syscall.SIGSTOP, + ateenvv1alpha.Signal_SIGNAL_TSTP: syscall.SIGTSTP, + ateenvv1alpha.Signal_SIGNAL_TTIN: syscall.SIGTTIN, + ateenvv1alpha.Signal_SIGNAL_TTOU: syscall.SIGTTOU, + ateenvv1alpha.Signal_SIGNAL_URG: syscall.SIGURG, + ateenvv1alpha.Signal_SIGNAL_XCPU: syscall.SIGXCPU, + ateenvv1alpha.Signal_SIGNAL_XFSZ: syscall.SIGXFSZ, + ateenvv1alpha.Signal_SIGNAL_VTALRM: syscall.SIGVTALRM, + ateenvv1alpha.Signal_SIGNAL_PROF: syscall.SIGPROF, + ateenvv1alpha.Signal_SIGNAL_WINCH: syscall.SIGWINCH, + ateenvv1alpha.Signal_SIGNAL_IO: syscall.SIGIO, + ateenvv1alpha.Signal_SIGNAL_SYS: syscall.SIGSYS, +} + +var syscallTable = func() map[syscall.Signal]ateenvv1alpha.Signal { + m := make(map[syscall.Signal]ateenvv1alpha.Signal, len(signalTable)) + for api, sys := range signalTable { + m[sys] = api + } + return m +}() + +// ToSyscallSignal converts an API signal to the host's syscall signal. +// The second result is false for SIGNAL_UNSPECIFIED or unknown values. +func ToSyscallSignal(sig ateenvv1alpha.Signal) (syscall.Signal, bool) { + sys, ok := signalTable[sig] + return sys, ok +} + +// FromSyscallSignal converts a host syscall signal to the API signal, or +// SIGNAL_UNSPECIFIED if it has no API equivalent. +func FromSyscallSignal(sig syscall.Signal) ateenvv1alpha.Signal { + return syscallTable[sig] +} diff --git a/guest/process/tracker.go b/guest/process/tracker.go index 7773385..2971c7b 100644 --- a/guest/process/tracker.go +++ b/guest/process/tracker.go @@ -23,13 +23,12 @@ import ( "os" "os/exec" "path/filepath" + "sort" "sync" "syscall" "time" ateenvv1alpha "github.com/agent-substrate/env/proto/ateenv/v1alpha" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -48,6 +47,21 @@ const ( DefaultMaxRetainedProcesses = 100 ) +// Errors returned by Tracker operations. The gRPC service maps them to +// status codes. +var ( + // ErrNotFound is returned when no process has the given ID. + ErrNotFound = errors.New("process not found") + // ErrExited is returned when an operation needs a running process but it has exited. + ErrExited = errors.New("process has exited") + // ErrNoStdin is returned when writing input to a process started without stdin. + ErrNoStdin = errors.New("process was started without stdin") + // ErrStdinClosed is returned when writing input after stdin has been closed. + ErrStdinClosed = errors.New("stdin is closed") + // ErrTooManyProcesses is returned when the concurrency limit is reached. + ErrTooManyProcesses = errors.New("maximum concurrent processes limit reached") +) + // TrackerConfig holds resource management and isolation options for the process tracker. type TrackerConfig struct { // LogDir is the directory where process stdout/stderr logs are stored. @@ -58,7 +72,8 @@ type TrackerConfig struct { MaxConcurrentProcesses int // MaxLogBytes caps stdout and stderr logs per command. 0 means unlimited. MaxLogBytes int64 - // DefaultProcessTimeout is the maximum duration a process is allowed to run before being killed. + // DefaultProcessTimeout is the maximum duration a process is allowed to run + // before being killed, unless the start request sets its own timeout. DefaultProcessTimeout time.Duration // RetentionPeriod is how long completed process logs are kept before being pruned. RetentionPeriod time.Duration @@ -92,22 +107,106 @@ func DefaultConfig(logDir string) TrackerConfig { } } -// ProcessState tracks the execution state and log handles of a process. +// StartOptions describes a process to launch. +type StartOptions struct { + // Command is the binary and its arguments. + Command []string + // Cwd is the working directory; empty means the tracker's Workspace. + Cwd string + // Env holds extra environment variables layered on the guest's environment. + Env map[string]string + // Stdin opens a pipe for standard input fed by WriteInput. When false, + // stdin reads as empty. + Stdin bool + // Timeout kills the process group after this duration. Zero uses the + // tracker's DefaultProcessTimeout. + Timeout time.Duration +} + +// ProcessState tracks the execution state and I/O handles of a process. type ProcessState struct { mu sync.RWMutex ProcessID string Command []string - Cmd *exec.Cmd - Status ateenvv1alpha.ProcessStatus - ExitCode int32 + Pid int + State ateenvv1alpha.ProcessState + ExitCode int32 // valid once exited and Signal == 0 + Signal syscall.Signal // nonzero if the process was terminated by a signal StartedAt time.Time FinishedAt time.Time StdoutPath string StderrPath string - doneChan chan struct{} - timer *time.Timer + stdinMu sync.Mutex + stdin io.WriteCloser // nil when started without stdin + stdinClosed bool + + output *notifier + done chan struct{} + timer *time.Timer +} + +// Exited reports whether the process has been reaped. +func (p *ProcessState) Exited() bool { + p.mu.RLock() + defer p.mu.RUnlock() + return p.State == ateenvv1alpha.ProcessState_PROCESS_STATE_EXITED +} + +// Done returns a channel closed once the process has exited and its output +// has been flushed to the log spool. +func (p *ProcessState) Done() <-chan struct{} { return p.done } + +// OutputChanged returns a channel closed the next time bytes are appended to +// either output spool. Obtain it before reading the spool to avoid missing a +// wakeup. +func (p *ProcessState) OutputChanged() <-chan struct{} { return p.output.wait() } + +// ToProto converts a ProcessState to the protobuf Process message. +func (p *ProcessState) ToProto() *ateenvv1alpha.Process { + p.mu.RLock() + defer p.mu.RUnlock() + + proto := &ateenvv1alpha.Process{ + ProcessId: p.ProcessID, + Command: append([]string(nil), p.Command...), + Pid: int32(p.Pid), + State: p.State, + StartedAt: timestamppb.New(p.StartedAt), + } + if p.State == ateenvv1alpha.ProcessState_PROCESS_STATE_EXITED { + proto.ExitCode = p.ExitCode + if p.Signal != 0 { + proto.ExitCode = 128 + int32(FromSyscallSignal(p.Signal)) + } + } + if !p.FinishedAt.IsZero() { + proto.FinishedAt = timestamppb.New(p.FinishedAt) + } + return proto +} + +// notifier broadcasts "something changed" to any number of waiters by +// closing and replacing a channel. +type notifier struct { + mu sync.Mutex + ch chan struct{} +} + +func newNotifier() *notifier { return ¬ifier{ch: make(chan struct{})} } + +func (n *notifier) wait() <-chan struct{} { + n.mu.Lock() + defer n.mu.Unlock() + return n.ch +} + +func (n *notifier) notify() { + n.mu.Lock() + close(n.ch) + n.ch = make(chan struct{}) + n.mu.Unlock() } // Tracker manages process lifecycles, resource limits, and log cleanup. @@ -119,7 +218,7 @@ type Tracker struct { stopPruner chan struct{} } -// NewTracker creates a new Process Tracker with Layer 1 resource controls. +// NewTracker creates a new process Tracker. func NewTracker(cfg TrackerConfig) (*Tracker, error) { if cfg.LogDir == "" { cfg.LogDir = filepath.Join(os.TempDir(), "ate-jobs") @@ -133,10 +232,7 @@ func NewTracker(cfg TrackerConfig) (*Tracker, error) { processes: make(map[string]*ProcessState), stopPruner: make(chan struct{}), } - - // Start background pruner routine go t.prunerLoop() - return t, nil } @@ -162,23 +258,25 @@ func (t *Tracker) generateUniqueID() string { } } -// cappedWriter limits total bytes written and appends a warning when exceeded. +// cappedWriter limits total bytes written, appends a warning when exceeded, +// and reports every write to onWrite. type cappedWriter struct { w io.Writer limit int64 written int64 warned bool + onWrite func() } -func (cw *cappedWriter) Write(p []byte) (n int, err error) { +func (cw *cappedWriter) Write(p []byte) (int, error) { + if cw.onWrite != nil { + defer cw.onWrite() + } if cw.limit <= 0 { return cw.w.Write(p) } if cw.written >= cw.limit { - if !cw.warned { - cw.warned = true - _, _ = cw.w.Write([]byte(fmt.Sprintf("\n\n[guest: maximum log limit of %d MB exceeded; remaining output truncated]\n", cw.limit/(1024*1024)))) - } + cw.warnOnce() return len(p), nil } @@ -188,91 +286,108 @@ func (cw *cappedWriter) Write(p []byte) (n int, err error) { toWrite = p[:remaining] } - n, err = cw.w.Write(toWrite) + n, err := cw.w.Write(toWrite) cw.written += int64(n) - if int64(len(p)) > remaining && !cw.warned { - cw.warned = true - _, _ = cw.w.Write([]byte(fmt.Sprintf("\n\n[guest: maximum log limit of %d MB exceeded; remaining output truncated]\n", cw.limit/(1024*1024)))) + if int64(len(p)) > remaining { + cw.warnOnce() } return len(p), err } +func (cw *cappedWriter) warnOnce() { + if cw.warned { + return + } + cw.warned = true + _, _ = fmt.Fprintf(cw.w, "\n\n[guest: maximum log limit of %d MB exceeded; remaining output truncated]\n", cw.limit/(1024*1024)) +} + // Start launches a new background process, enforcing concurrency and resource limits. -func (t *Tracker) Start(command []string, cwd string, env map[string]string) (*ProcessState, error) { - if len(command) == 0 { - return nil, status.Error(codes.InvalidArgument, "command list cannot be empty") +func (t *Tracker) Start(opts StartOptions) (*ProcessState, error) { + if len(opts.Command) == 0 { + return nil, errors.New("command cannot be empty") } t.mu.Lock() if t.config.MaxConcurrentProcesses > 0 && t.activeProcesses >= t.config.MaxConcurrentProcesses { t.mu.Unlock() - return nil, status.Errorf(codes.ResourceExhausted, - "maximum concurrent processes limit (%d) reached; please wait for running processes to complete or kill them", - t.config.MaxConcurrentProcesses) + return nil, fmt.Errorf("%w (%d)", ErrTooManyProcesses, t.config.MaxConcurrentProcesses) } t.activeProcesses++ t.mu.Unlock() processID := t.generateUniqueID() - stdoutPath := filepath.Join(t.config.LogDir, fmt.Sprintf("%s.stdout", processID)) - stderrPath := filepath.Join(t.config.LogDir, fmt.Sprintf("%s.stderr", processID)) + stdoutPath := filepath.Join(t.config.LogDir, processID+".stdout") + stderrPath := filepath.Join(t.config.LogDir, processID+".stderr") stdoutFile, err := os.OpenFile(stdoutPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { t.decrementActive() - return nil, status.Errorf(codes.Internal, "creating stdout log: %v", err) + return nil, fmt.Errorf("creating stdout log: %w", err) } - stderrFile, err := os.OpenFile(stderrPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { stdoutFile.Close() t.decrementActive() - return nil, status.Errorf(codes.Internal, "creating stderr log: %v", err) + return nil, fmt.Errorf("creating stderr log: %w", err) } - cmd := exec.Command(command[0], command[1:]...) - if cwd != "" { - cmd.Dir = cwd + state := &ProcessState{ + ProcessID: processID, + Command: append([]string(nil), opts.Command...), + State: ateenvv1alpha.ProcessState_PROCESS_STATE_RUNNING, + StdoutPath: stdoutPath, + StderrPath: stderrPath, + output: newNotifier(), + done: make(chan struct{}), + } + + cmd := exec.Command(opts.Command[0], opts.Command[1:]...) + if opts.Cwd != "" { + cmd.Dir = opts.Cwd } else if t.config.Workspace != "" { cmd.Dir = t.config.Workspace } - if len(env) > 0 { + if len(opts.Env) > 0 { cmd.Env = os.Environ() - for k, v := range env { - cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v)) + for k, v := range opts.Env { + cmd.Env = append(cmd.Env, k+"="+v) } } - - // Wrap output writers with byte limiters to prevent disk fill-up - cmd.Stdout = &cappedWriter{w: stdoutFile, limit: t.config.MaxLogBytes} - cmd.Stderr = &cappedWriter{w: stderrFile, limit: t.config.MaxLogBytes} - - // Assign independent Linux Process Group ID (PGID) to kill all child subprocesses cleanly + // Wrap output writers with byte limiters to prevent disk fill-up and + // wake output streamers on every write. + cmd.Stdout = &cappedWriter{w: stdoutFile, limit: t.config.MaxLogBytes, onWrite: state.output.notify} + cmd.Stderr = &cappedWriter{w: stderrFile, limit: t.config.MaxLogBytes, onWrite: state.output.notify} + // Give the process its own group so signals reach the whole tree. cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - startedAt := time.Now() + if opts.Stdin { + stdin, err := cmd.StdinPipe() + if err != nil { + stdoutFile.Close() + stderrFile.Close() + t.decrementActive() + return nil, fmt.Errorf("opening stdin pipe: %w", err) + } + state.stdin = stdin + } + + state.StartedAt = time.Now() if err := cmd.Start(); err != nil { stdoutFile.Close() stderrFile.Close() t.decrementActive() - return nil, status.Errorf(codes.Internal, "starting process: %v", err) + return nil, fmt.Errorf("starting process: %w", err) } + state.Pid = cmd.Process.Pid - state := &ProcessState{ - ProcessID: processID, - Command: command, - Cmd: cmd, - Status: ateenvv1alpha.ProcessStatus_PROCESS_STATUS_RUNNING, - StartedAt: startedAt, - StdoutPath: stdoutPath, - StderrPath: stderrPath, - doneChan: make(chan struct{}), + timeout := opts.Timeout + if timeout <= 0 { + timeout = t.config.DefaultProcessTimeout } - - // Watchdog timeout to prevent runaway processes - if t.config.DefaultProcessTimeout > 0 { - state.timer = time.AfterFunc(t.config.DefaultProcessTimeout, func() { - _, _ = t.Kill(processID) + if timeout > 0 { + state.timer = time.AfterFunc(timeout, func() { + _ = t.Signal(processID, syscall.SIGKILL) }) } @@ -280,57 +395,56 @@ func (t *Tracker) Start(command []string, cwd string, env map[string]string) (*P t.processes[processID] = state t.mu.Unlock() - // Background reaper goroutine - go func() { - waitErr := cmd.Wait() - finishedAt := time.Now() + go t.reap(state, cmd, stdoutFile, stderrFile) + return state, nil +} + +// reap waits for the process to exit, records its exit status, and flushes +// the output spool before signalling waiters. +func (t *Tracker) reap(state *ProcessState, cmd *exec.Cmd, stdoutFile, stderrFile *os.File) { + _ = cmd.Wait() // Wait also closes the stdin pipe, if any. + finishedAt := time.Now() - state.mu.Lock() - if state.timer != nil { - state.timer.Stop() - } - state.FinishedAt = finishedAt - _ = stdoutFile.Sync() - _ = stderrFile.Sync() - _ = stdoutFile.Close() - _ = stderrFile.Close() - - if state.Status == ateenvv1alpha.ProcessStatus_PROCESS_STATUS_TERMINATED { - // Already marked as terminated; preserve or refine exit code from wait status if signaled - var exitErr *exec.ExitError - if errors.As(waitErr, &exitErr) { - if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() { - state.ExitCode = 128 + int32(ws.Signal()) - } - } - } else if waitErr == nil { - state.Status = ateenvv1alpha.ProcessStatus_PROCESS_STATUS_COMPLETED - state.ExitCode = 0 - } else { - var exitErr *exec.ExitError - if errors.As(waitErr, &exitErr) { - if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() { - state.Status = ateenvv1alpha.ProcessStatus_PROCESS_STATUS_TERMINATED - state.ExitCode = 128 + int32(ws.Signal()) - } else if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Exited() { - state.Status = ateenvv1alpha.ProcessStatus_PROCESS_STATUS_FAILED - state.ExitCode = int32(ws.ExitStatus()) - } else { - state.Status = ateenvv1alpha.ProcessStatus_PROCESS_STATUS_FAILED - state.ExitCode = int32(exitErr.ExitCode()) - } - } else { - state.Status = ateenvv1alpha.ProcessStatus_PROCESS_STATUS_FAILED - state.ExitCode = -1 - } - } - close(state.doneChan) - state.mu.Unlock() + // Wait returned only after the stdout/stderr copiers finished, so the + // spool is complete once synced. + _ = stdoutFile.Sync() + _ = stderrFile.Sync() + _ = stdoutFile.Close() + _ = stderrFile.Close() - t.decrementActive() - }() + state.mu.Lock() + if state.timer != nil { + state.timer.Stop() + } + state.FinishedAt = finishedAt + state.State = ateenvv1alpha.ProcessState_PROCESS_STATE_EXITED + state.ExitCode, state.Signal = exitStatus(cmd.ProcessState) + state.mu.Unlock() - return state, nil + state.stdinMu.Lock() + state.stdinClosed = true + state.stdinMu.Unlock() + + close(state.done) + state.output.notify() + t.decrementActive() +} + +// exitStatus decodes how a waited process ended: (code, 0) for a normal +// exit or (0, signal) when terminated by a signal. +func exitStatus(ps *os.ProcessState) (int32, syscall.Signal) { + if ps == nil { + return -1, 0 + } + if ws, ok := ps.Sys().(syscall.WaitStatus); ok { + if ws.Signaled() { + return 0, ws.Signal() + } + if ws.Exited() { + return int32(ws.ExitStatus()), 0 + } + } + return int32(ps.ExitCode()), 0 } func (t *Tracker) decrementActive() { @@ -342,52 +456,90 @@ func (t *Tracker) decrementActive() { } // Get returns the process state for the given process ID. -func (t *Tracker) Get(processID string) (*ProcessState, bool) { +func (t *Tracker) Get(processID string) (*ProcessState, error) { t.mu.RLock() defer t.mu.RUnlock() p, ok := t.processes[processID] - return p, ok + if !ok { + return nil, ErrNotFound + } + return p, nil } -// Kill terminates a running process and its process tree. -func (t *Tracker) Kill(processID string) (int32, error) { - t.mu.RLock() - state, ok := t.processes[processID] - t.mu.RUnlock() - - if !ok { - return 0, status.Errorf(codes.NotFound, "process %q not found", processID) +// Signal delivers sig to the process group of a running process. +func (t *Tracker) Signal(processID string, sig syscall.Signal) error { + state, err := t.Get(processID) + if err != nil { + return err } - state.mu.Lock() - if state.Status != ateenvv1alpha.ProcessStatus_PROCESS_STATUS_RUNNING { - exitCode := state.ExitCode - state.mu.Unlock() - return exitCode, nil + state.mu.RLock() + running := state.State == ateenvv1alpha.ProcessState_PROCESS_STATE_RUNNING + pid := state.Pid + state.mu.RUnlock() + if !running { + return ErrExited } - state.Status = ateenvv1alpha.ProcessStatus_PROCESS_STATUS_TERMINATED - state.ExitCode = 128 + int32(syscall.SIGKILL) // 137 - if state.timer != nil { - state.timer.Stop() + // Negative PID targets the whole process group (the leader's PGID == PID + // thanks to Setpgid). + if err := syscall.Kill(-pid, sig); err != nil { + if errors.Is(err, syscall.ESRCH) { + return ErrExited + } + return fmt.Errorf("sending %v: %w", sig, err) } - pid := state.Cmd.Process.Pid - state.mu.Unlock() + return nil +} - // Send SIGKILL to the entire process group (-PID) - _ = syscall.Kill(-pid, syscall.SIGKILL) +// WriteInput writes data to the stdin of a process started with Stdin. +func (t *Tracker) WriteInput(processID string, data []byte) (int, error) { + state, err := t.Get(processID) + if err != nil { + return 0, err + } + if state.stdin == nil { + return 0, ErrNoStdin + } - // Wait for reaper goroutine - select { - case <-state.doneChan: - case <-time.After(2 * time.Second): + state.stdinMu.Lock() + defer state.stdinMu.Unlock() + if state.stdinClosed { + if state.Exited() { + return 0, ErrExited + } + return 0, ErrStdinClosed } + n, err := state.stdin.Write(data) + if err != nil { + if state.Exited() || errors.Is(err, syscall.EPIPE) { + return n, ErrExited + } + return n, fmt.Errorf("writing stdin: %w", err) + } + return n, nil +} - state.mu.RLock() - exitCode := state.ExitCode - state.mu.RUnlock() +// CloseInput closes the stdin of a process, delivering EOF. It is idempotent. +func (t *Tracker) CloseInput(processID string) error { + state, err := t.Get(processID) + if err != nil { + return err + } + if state.stdin == nil { + return ErrNoStdin + } - return exitCode, nil + state.stdinMu.Lock() + defer state.stdinMu.Unlock() + if state.stdinClosed { + return nil + } + state.stdinClosed = true + if err := state.stdin.Close(); err != nil { + return fmt.Errorf("closing stdin: %w", err) + } + return nil } // prunerLoop periodically removes expired process states and log files. @@ -405,70 +557,71 @@ func (t *Tracker) prunerLoop() { } } -// pruneExpired cleans up completed processes older than RetentionPeriod. +// pruneExpired cleans up exited processes older than RetentionPeriod and +// trims history beyond MaxRetainedProcesses, oldest first. func (t *Tracker) pruneExpired() { t.mu.Lock() defer t.mu.Unlock() now := time.Now() - for id, state := range t.processes { + var exited []*ProcessState + for _, state := range t.processes { state.mu.RLock() - isDone := state.Status != ateenvv1alpha.ProcessStatus_PROCESS_STATUS_RUNNING + done := state.State == ateenvv1alpha.ProcessState_PROCESS_STATE_EXITED finishedAt := state.FinishedAt state.mu.RUnlock() + if !done { + continue + } + if t.config.RetentionPeriod > 0 && now.Sub(finishedAt) > t.config.RetentionPeriod { + t.forget(state) + continue + } + exited = append(exited, state) + } - if isDone && !finishedAt.IsZero() && t.config.RetentionPeriod > 0 { - if now.Sub(finishedAt) > t.config.RetentionPeriod { - _ = os.Remove(state.StdoutPath) - _ = os.Remove(state.StderrPath) - delete(t.processes, id) - } + if t.config.MaxRetainedProcesses > 0 && len(exited) > t.config.MaxRetainedProcesses { + sort.Slice(exited, func(i, j int) bool { return exited[i].FinishedAt.Before(exited[j].FinishedAt) }) + for _, state := range exited[:len(exited)-t.config.MaxRetainedProcesses] { + t.forget(state) } } } -// ToProto converts a ProcessState to the protobuf Process message. -func (p *ProcessState) ToProto() *ateenvv1alpha.Process { - p.mu.RLock() - defer p.mu.RUnlock() - - proto := &ateenvv1alpha.Process{ - ProcessId: p.ProcessID, - Status: p.Status, - ExitCode: p.ExitCode, - StartedAt: timestamppb.New(p.StartedAt), - } - if !p.FinishedAt.IsZero() { - proto.FinishedAt = timestamppb.New(p.FinishedAt) - } - return proto +// forget drops a process and its spool. Caller holds t.mu. +func (t *Tracker) forget(state *ProcessState) { + _ = os.Remove(state.StdoutPath) + _ = os.Remove(state.StderrPath) + delete(t.processes, state.ProcessID) } -// ReadLogs reads log bytes from a log file at a specific byte offset. -func ReadLogs(filePath string, offset int64) ([]byte, int64, error) { +// ReadSpool reads bytes from a log spool file starting at offset and returns +// them with the new offset (the file size). +func ReadSpool(filePath string, offset int64) ([]byte, int64, error) { f, err := os.Open(filePath) if err != nil { if errors.Is(err, os.ErrNotExist) { - return nil, 0, nil + return nil, offset, nil } - return nil, 0, err + return nil, offset, err } defer f.Close() info, err := f.Stat() if err != nil { - return nil, 0, err + return nil, offset, err } size := info.Size() if offset >= size { - return nil, size, nil + // Never move the cursor backwards: an offset past the end means + // "skip everything written so far". + return nil, offset, nil } - length := size - offset - buf := make([]byte, length) + buf := make([]byte, size-offset) n, err := f.ReadAt(buf, offset) if err != nil && !errors.Is(err, io.EOF) { - return nil, 0, err + return nil, offset, err } - return buf[:n], size, nil + return buf[:n], offset + int64(n), nil } diff --git a/internal/apiservice/server.go b/internal/apiservice/server.go index f32a1f1..e134e15 100644 --- a/internal/apiservice/server.go +++ b/internal/apiservice/server.go @@ -176,85 +176,112 @@ func (s *Server) DeleteEnvironment(ctx context.Context, req *ateenvv1alpha.Delet // --- PROCESS SERVICE --- // ============================================================================ -// StartProcess launches a process inside the target environment container. -func (s *Server) StartProcess(ctx context.Context, req *ateenvv1alpha.StartProcessRequest) (*ateenvv1alpha.StartProcessResponse, error) { +// processClient dials the guest of the environment named in ctx's metadata +// and returns a ProcessService client plus the outgoing context to use with +// it. The caller must invoke closeFn when done. +func (s *Server) processClient(ctx context.Context) (ateenvv1alpha.ProcessServiceClient, context.Context, func(), error) { envID, atespace, err := envFromContext(ctx) if err != nil { - return nil, err + return nil, nil, nil, err } conn, err := s.guestConn(atespace, envID) if err != nil { - return nil, err + return nil, nil, nil, err } - defer conn.Close() - outCtx := forwardOutgoingContext(ctx, atespace, envID) - return ateenvv1alpha.NewProcessServiceClient(conn).StartProcess(outCtx, req) + return ateenvv1alpha.NewProcessServiceClient(conn), outCtx, func() { _ = conn.Close() }, nil } -// GetProcess retrieves the status of a process running inside the environment. -func (s *Server) GetProcess(ctx context.Context, req *ateenvv1alpha.GetProcessRequest) (*ateenvv1alpha.Process, error) { - envID, atespace, err := envFromContext(ctx) +// StartProcess launches a process inside the target environment container. +func (s *Server) StartProcess(ctx context.Context, req *ateenvv1alpha.StartProcessRequest) (*ateenvv1alpha.Process, error) { + client, outCtx, closeFn, err := s.processClient(ctx) if err != nil { return nil, err } - conn, err := s.guestConn(atespace, envID) + defer closeFn() + return client.StartProcess(outCtx, req) +} + +// GetProcess retrieves the state of a process inside the environment. +func (s *Server) GetProcess(ctx context.Context, req *ateenvv1alpha.GetProcessRequest) (*ateenvv1alpha.Process, error) { + client, outCtx, closeFn, err := s.processClient(ctx) if err != nil { return nil, err } - defer conn.Close() - - outCtx := forwardOutgoingContext(ctx, atespace, envID) - return ateenvv1alpha.NewProcessServiceClient(conn).GetProcess(outCtx, req) + defer closeFn() + return client.GetProcess(outCtx, req) } -// StreamProcessOutputs streams real-time stdout and stderr from a process. -func (s *Server) StreamProcessOutputs(req *ateenvv1alpha.StreamProcessOutputsRequest, stream grpc.ServerStreamingServer[ateenvv1alpha.OutputChunk]) error { - ctx := stream.Context() - envID, atespace, err := envFromContext(ctx) +// SignalProcess delivers a signal to a process inside the environment. +func (s *Server) SignalProcess(ctx context.Context, req *ateenvv1alpha.SignalProcessRequest) (*ateenvv1alpha.Process, error) { + client, outCtx, closeFn, err := s.processClient(ctx) if err != nil { - return err + return nil, err } - conn, err := s.guestConn(atespace, envID) + defer closeFn() + return client.SignalProcess(outCtx, req) +} + +// StreamProcessOutput streams stdout, stderr and the exit message from a process. +func (s *Server) StreamProcessOutput(req *ateenvv1alpha.StreamProcessOutputRequest, stream grpc.ServerStreamingServer[ateenvv1alpha.ProcessOutput]) error { + client, outCtx, closeFn, err := s.processClient(stream.Context()) if err != nil { return err } - defer conn.Close() + defer closeFn() - outCtx := forwardOutgoingContext(ctx, atespace, envID) - clientStream, err := ateenvv1alpha.NewProcessServiceClient(conn).StreamProcessOutputs(outCtx, req) + clientStream, err := client.StreamProcessOutput(outCtx, req) if err != nil { return err } - for { - chunk, err := clientStream.Recv() + msg, err := clientStream.Recv() if errors.Is(err, io.EOF) { return nil } if err != nil { return err } - if err := stream.Send(chunk); err != nil { + if err := stream.Send(msg); err != nil { return err } } } -// KillProcess terminates a running process inside the environment. -func (s *Server) KillProcess(ctx context.Context, req *ateenvv1alpha.KillProcessRequest) (*ateenvv1alpha.KillProcessResponse, error) { - envID, atespace, err := envFromContext(ctx) +// WriteProcessInput forwards stdin chunks to a process inside the environment. +func (s *Server) WriteProcessInput(stream grpc.ClientStreamingServer[ateenvv1alpha.WriteProcessInputRequest, ateenvv1alpha.WriteProcessInputResponse]) error { + client, outCtx, closeFn, err := s.processClient(stream.Context()) if err != nil { - return nil, err + return err } - conn, err := s.guestConn(atespace, envID) + defer closeFn() + + clientStream, err := client.WriteProcessInput(outCtx) if err != nil { - return nil, err + return err + } + for { + req, err := stream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return err + } + if err := clientStream.Send(req); err != nil { + // The guest rejected the stream; surface its status instead of io.EOF. + if errors.Is(err, io.EOF) { + _, err = clientStream.CloseAndRecv() + } + return err + } } - defer conn.Close() - outCtx := forwardOutgoingContext(ctx, atespace, envID) - return ateenvv1alpha.NewProcessServiceClient(conn).KillProcess(outCtx, req) + resp, err := clientStream.CloseAndRecv() + if err != nil { + return err + } + return stream.SendAndClose(resp) } // ============================================================================ @@ -262,7 +289,7 @@ func (s *Server) KillProcess(ctx context.Context, req *ateenvv1alpha.KillProcess // ============================================================================ // ReadFile streams file contents from the target environment. -func (s *Server) ReadFile(req *ateenvv1alpha.ReadFileRequest, stream grpc.ServerStreamingServer[ateenvv1alpha.FileChunk]) error { +func (s *Server) ReadFile(req *ateenvv1alpha.ReadFileRequest, stream grpc.ServerStreamingServer[ateenvv1alpha.ReadFileResponse]) error { ctx := stream.Context() envID, atespace, err := envFromContext(ctx) if err != nil { diff --git a/internal/apiservice/server_test.go b/internal/apiservice/server_test.go index 56578a9..28a9c2f 100644 --- a/internal/apiservice/server_test.go +++ b/internal/apiservice/server_test.go @@ -17,6 +17,7 @@ package apiservice_test import ( "context" "io" + "math" "net" "testing" @@ -371,7 +372,7 @@ func TestProxyGuestServices(t *testing.T) { if err != nil { t.Fatalf("ReadFile recv: %v", err) } - readBuf = append(readBuf, chunk.GetData()...) + readBuf = append(readBuf, chunk.GetChunk()...) } if string(readBuf) != "proxied content" { t.Errorf("read %q, want %q", string(readBuf), "proxied content") @@ -388,29 +389,36 @@ func TestProxyGuestServices(t *testing.T) { t.Fatal("empty process ID") } - logStream, err := te.procClient.StreamProcessOutputs(envCtx, &ateenvv1alpha.StreamProcessOutputsRequest{ + outStream, err := te.procClient.StreamProcessOutput(envCtx, &ateenvv1alpha.StreamProcessOutputRequest{ ProcessId: startResp.GetProcessId(), Follow: true, }) if err != nil { - t.Fatalf("StreamProcessOutputs: %v", err) + t.Fatalf("StreamProcessOutput: %v", err) } var stdout string + var exit *ateenvv1alpha.Process for { - chunk, err := logStream.Recv() + msg, err := outStream.Recv() if err == io.EOF { break } if err != nil { - t.Fatalf("StreamProcessOutputs recv: %v", err) + t.Fatalf("StreamProcessOutput recv: %v", err) } - if chunk.GetSource() == ateenvv1alpha.OutputSource_OUTPUT_SOURCE_STDOUT { - stdout += string(chunk.GetData()) + switch out := msg.GetOutput().(type) { + case *ateenvv1alpha.ProcessOutput_Stdout: + stdout += string(out.Stdout) + case *ateenvv1alpha.ProcessOutput_Exit: + exit = out.Exit } } if stdout != "hello-from-proxy\n" { t.Errorf("stdout = %q, want hello-from-proxy\\n", stdout) } + if exit == nil || exit.GetState() != ateenvv1alpha.ProcessState_PROCESS_STATE_EXITED || exit.GetExitCode() != 0 { + t.Errorf("exit message = %v, want EXITED with code 0", exit) + } getProc, err := te.procClient.GetProcess(envCtx, &ateenvv1alpha.GetProcessRequest{ ProcessId: startResp.GetProcessId(), @@ -422,6 +430,52 @@ func TestProxyGuestServices(t *testing.T) { t.Errorf("exit code = %d, want 0", getProc.GetExitCode()) } + // Stdin and signals proxy through the client stream and unary paths. + catProc, err := te.procClient.StartProcess(envCtx, &ateenvv1alpha.StartProcessRequest{ + Command: []string{"cat"}, + Stdin: true, + }) + if err != nil { + t.Fatalf("StartProcess cat: %v", err) + } + inStream, err := te.procClient.WriteProcessInput(envCtx) + if err != nil { + t.Fatalf("WriteProcessInput: %v", err) + } + if err := inStream.Send(&ateenvv1alpha.WriteProcessInputRequest{ + ProcessId: catProc.GetProcessId(), + Data: []byte("proxied stdin\n"), + Close: true, + }); err != nil { + t.Fatalf("WriteProcessInput send: %v", err) + } + inResp, err := inStream.CloseAndRecv() + if err != nil { + t.Fatalf("WriteProcessInput CloseAndRecv: %v", err) + } + if inResp.GetBytesWritten() != int64(len("proxied stdin\n")) { + t.Errorf("bytes written = %d", inResp.GetBytesWritten()) + } + catDone := waitProcess(t, te.procClient, envCtx, catProc.GetProcessId()) + if catDone.GetExitCode() != 0 { + t.Errorf("cat exit code = %d", catDone.GetExitCode()) + } + + sleeper, err := te.procClient.StartProcess(envCtx, &ateenvv1alpha.StartProcessRequest{Command: []string{"sleep", "30"}}) + if err != nil { + t.Fatalf("StartProcess sleep: %v", err) + } + if _, err := te.procClient.SignalProcess(envCtx, &ateenvv1alpha.SignalProcessRequest{ + ProcessId: sleeper.GetProcessId(), + Signal: ateenvv1alpha.Signal_SIGNAL_TERM, + }); err != nil { + t.Fatalf("SignalProcess: %v", err) + } + sleeperDone := waitProcess(t, te.procClient, envCtx, sleeper.GetProcessId()) + if sleeperDone.GetExitCode() != 143 { + t.Errorf("sleeper exit code = %d, want 143 (128+SIGTERM)", sleeperDone.GetExitCode()) + } + // 5. Test missing metadata error _, err = te.procClient.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{ Command: []string{"echo", "no-meta"}, @@ -431,6 +485,27 @@ func TestProxyGuestServices(t *testing.T) { } } +// waitProcess follows the output stream past the spool end and returns the exit message. +func waitProcess(t *testing.T, client ateenvv1alpha.ProcessServiceClient, ctx context.Context, id string) *ateenvv1alpha.Process { + t.Helper() + stream, err := client.StreamProcessOutput(ctx, &ateenvv1alpha.StreamProcessOutputRequest{ + ProcessId: id, Follow: true, StdoutOffset: math.MaxInt64, StderrOffset: math.MaxInt64, + }) + if err != nil { + t.Fatalf("StreamProcessOutput: %v", err) + } + for { + msg, err := stream.Recv() + if err != nil { + t.Fatalf("waiting for %s: %v", id, err) + } + if exit := msg.GetExit(); exit != nil { + return exit + } + t.Fatalf("unexpected output while waiting: %v", msg) + } +} + func TestActorStatusToEnvStatus(t *testing.T) { cases := []struct { name string diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index cfa72a9..26cfa53 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -23,10 +23,12 @@ import ( "fmt" "io" "strings" + "time" "github.com/agent-substrate/env/internal/tool" ateenvv1alpha "github.com/agent-substrate/env/proto/ateenv/v1alpha" mcp "github.com/modelcontextprotocol/go-sdk/mcp" + "google.golang.org/protobuf/types/known/durationpb" ) const defaultChunkSize = 64 * 1024 @@ -94,7 +96,7 @@ func readFileTool(client ateenvv1alpha.FileSystemServiceClient) tool.Tool { if err != nil { return "", fmt.Errorf("reading file chunk: %w", err) } - buf.Write(chunk.GetData()) + buf.Write(chunk.GetChunk()) } return buf.String(), nil }) @@ -172,6 +174,8 @@ type shellParams struct { Command string `json:"command"` Cwd string `json:"cwd,omitempty"` Env map[string]string `json:"env,omitempty"` + Stdin string `json:"stdin,omitempty"` + Timeout float64 `json:"timeout_seconds,omitempty"` } func shellTool(client ateenvv1alpha.ProcessServiceClient) tool.Tool { @@ -191,6 +195,11 @@ func shellTool(client ateenvv1alpha.ProcessServiceClient) tool.Tool { "additionalProperties": map[string]any{"type": "string"}, "description": "Optional environment variables.", }, + "stdin": map[string]any{"type": "string", "description": "Optional text fed to the command's standard input."}, + "timeout_seconds": map[string]any{ + "type": "number", + "description": "Optional wall-clock limit; the command is killed (SIGKILL) when it elapses.", + }, }, "required": []string{"command"}, }, @@ -200,49 +209,63 @@ func shellTool(client ateenvv1alpha.ProcessServiceClient) tool.Tool { if command == "" { return "", errors.New("command must not be empty") } - return runProcessToCompletion(ctx, client, []string{"sh", "-c", command}, p.Cwd, p.Env) + req := &ateenvv1alpha.StartProcessRequest{ + Command: []string{"sh", "-c", command}, + Cwd: p.Cwd, + Env: p.Env, + Stdin: p.Stdin != "", + } + if p.Timeout > 0 { + req.Timeout = durationpb.New(time.Duration(p.Timeout * float64(time.Second))) + } + return runProcessToCompletion(ctx, client, req, []byte(p.Stdin)) }) } -func runProcessToCompletion(ctx context.Context, client ateenvv1alpha.ProcessServiceClient, command []string, cwd string, env map[string]string) (string, error) { - startResp, err := client.StartProcess(ctx, &ateenvv1alpha.StartProcessRequest{ - Command: command, - Cwd: cwd, - Env: env, - }) +// runProcessToCompletion starts a process, feeds it stdin (if any), and +// collects its output and exit status into a single tool result. +func runProcessToCompletion(ctx context.Context, client ateenvv1alpha.ProcessServiceClient, req *ateenvv1alpha.StartProcessRequest, stdin []byte) (string, error) { + proc, err := client.StartProcess(ctx, req) if err != nil { return "", fmt.Errorf("process start failed: %w", err) } - procID := startResp.GetProcessId() + procID := proc.GetProcessId() + + if req.GetStdin() { + if err := writeInput(ctx, client, procID, stdin); err != nil { + return "", err + } + } - stream, err := client.StreamProcessOutputs(ctx, &ateenvv1alpha.StreamProcessOutputsRequest{ + stream, err := client.StreamProcessOutput(ctx, &ateenvv1alpha.StreamProcessOutputRequest{ ProcessId: procID, Follow: true, }) if err != nil { - return "", fmt.Errorf("stream outputs failed: %w", err) + return "", fmt.Errorf("stream output failed: %w", err) } var stdoutBuf, stderrBuf bytes.Buffer + var exit *ateenvv1alpha.Process for { - chunk, err := stream.Recv() + msg, err := stream.Recv() if errors.Is(err, io.EOF) { break } if err != nil { - return "", fmt.Errorf("reading process output chunk: %w", err) + return "", fmt.Errorf("reading process output: %w", err) } - switch chunk.GetSource() { - case ateenvv1alpha.OutputSource_OUTPUT_SOURCE_STDOUT: - stdoutBuf.Write(chunk.GetData()) - case ateenvv1alpha.OutputSource_OUTPUT_SOURCE_STDERR: - stderrBuf.Write(chunk.GetData()) + switch out := msg.GetOutput().(type) { + case *ateenvv1alpha.ProcessOutput_Stdout: + stdoutBuf.Write(out.Stdout) + case *ateenvv1alpha.ProcessOutput_Stderr: + stderrBuf.Write(out.Stderr) + case *ateenvv1alpha.ProcessOutput_Exit: + exit = out.Exit } } - - proc, err := client.GetProcess(ctx, &ateenvv1alpha.GetProcessRequest{ProcessId: procID}) - if err != nil { - return "", fmt.Errorf("get process status failed: %w", err) + if exit == nil { + return "", errors.New("output stream ended before the process exited") } var out strings.Builder @@ -255,14 +278,40 @@ func runProcessToCompletion(ctx context.Context, client ateenvv1alpha.ProcessSer } out.WriteString("[STDERR]\n" + stderrBuf.String()) } - if proc.GetExitCode() != 0 { + if exit.GetExitCode() != 0 { if out.Len() > 0 && !strings.HasSuffix(out.String(), "\n") { out.WriteString("\n") } - out.WriteString(fmt.Sprintf("[Exit Code: %d]", proc.GetExitCode())) + fmt.Fprintf(&out, "[Exit Code: %d]", exit.GetExitCode()) } if out.Len() == 0 { return "(no output)", nil } return out.String(), nil } + +// writeInput sends data to the process's stdin in chunks and closes it. +func writeInput(ctx context.Context, client ateenvv1alpha.ProcessServiceClient, procID string, data []byte) error { + stream, err := client.WriteProcessInput(ctx) + if err != nil { + return fmt.Errorf("write input failed: %w", err) + } + for i := 0; ; i += defaultChunkSize { + end := min(i+defaultChunkSize, len(data)) + last := end == len(data) + req := &ateenvv1alpha.WriteProcessInputRequest{Data: data[i:end], Close: last} + if i == 0 { + req.ProcessId = procID + } + if err := stream.Send(req); err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("sending stdin chunk: %w", err) + } + if last { + break + } + } + if _, err := stream.CloseAndRecv(); err != nil { + return fmt.Errorf("writing stdin: %w", err) + } + return nil +} diff --git a/proto/ateenv/v1alpha/guest.pb.go b/proto/ateenv/v1alpha/guest.pb.go index 53a1cb7..0157099 100644 --- a/proto/ateenv/v1alpha/guest.pb.go +++ b/proto/ateenv/v1alpha/guest.pb.go @@ -15,19 +15,21 @@ // Copyright 2026 The Agent Substrate Authors. // // Minimal Protocol Buffers definition for the Agent Substrate Guest Data Plane. -// Exposes asynchronous process execution, process output streaming, and streaming file I/O. +// Exposes asynchronous process execution with streaming I/O and signals, and +// streaming file I/O. // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.28.2 -// source: proto/ateenv/v1alpha/guest.proto +// protoc v7.34.1 +// source: ateenv/v1alpha/guest.proto package ateenvv1alpha import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" @@ -41,138 +43,216 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) -// Execution status of an asynchronous process. -type ProcessStatus int32 +// Lifecycle state of a process. +type ProcessState int32 const ( - ProcessStatus_PROCESS_STATUS_UNSPECIFIED ProcessStatus = 0 - // Process is actively running in the background. - ProcessStatus_PROCESS_STATUS_RUNNING ProcessStatus = 1 - // Process completed successfully with exit code 0. - ProcessStatus_PROCESS_STATUS_COMPLETED ProcessStatus = 2 - // Process completed with a non-zero exit code or failed to run. - ProcessStatus_PROCESS_STATUS_FAILED ProcessStatus = 3 - // Process was killed before completion. - ProcessStatus_PROCESS_STATUS_TERMINATED ProcessStatus = 4 + ProcessState_PROCESS_STATE_UNSPECIFIED ProcessState = 0 + // The process is running (or stopped by SIGSTOP) and has not been reaped. + ProcessState_PROCESS_STATE_RUNNING ProcessState = 1 + // The process has exited; see Process.exit_code. + ProcessState_PROCESS_STATE_EXITED ProcessState = 2 ) -// Enum value maps for ProcessStatus. +// Enum value maps for ProcessState. var ( - ProcessStatus_name = map[int32]string{ - 0: "PROCESS_STATUS_UNSPECIFIED", - 1: "PROCESS_STATUS_RUNNING", - 2: "PROCESS_STATUS_COMPLETED", - 3: "PROCESS_STATUS_FAILED", - 4: "PROCESS_STATUS_TERMINATED", - } - ProcessStatus_value = map[string]int32{ - "PROCESS_STATUS_UNSPECIFIED": 0, - "PROCESS_STATUS_RUNNING": 1, - "PROCESS_STATUS_COMPLETED": 2, - "PROCESS_STATUS_FAILED": 3, - "PROCESS_STATUS_TERMINATED": 4, + ProcessState_name = map[int32]string{ + 0: "PROCESS_STATE_UNSPECIFIED", + 1: "PROCESS_STATE_RUNNING", + 2: "PROCESS_STATE_EXITED", + } + ProcessState_value = map[string]int32{ + "PROCESS_STATE_UNSPECIFIED": 0, + "PROCESS_STATE_RUNNING": 1, + "PROCESS_STATE_EXITED": 2, } ) -func (x ProcessStatus) Enum() *ProcessStatus { - p := new(ProcessStatus) +func (x ProcessState) Enum() *ProcessState { + p := new(ProcessState) *p = x return p } -func (x ProcessStatus) String() string { +func (x ProcessState) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } -func (ProcessStatus) Descriptor() protoreflect.EnumDescriptor { - return file_proto_ateenv_v1alpha_guest_proto_enumTypes[0].Descriptor() +func (ProcessState) Descriptor() protoreflect.EnumDescriptor { + return file_ateenv_v1alpha_guest_proto_enumTypes[0].Descriptor() } -func (ProcessStatus) Type() protoreflect.EnumType { - return &file_proto_ateenv_v1alpha_guest_proto_enumTypes[0] +func (ProcessState) Type() protoreflect.EnumType { + return &file_ateenv_v1alpha_guest_proto_enumTypes[0] } -func (x ProcessStatus) Number() protoreflect.EnumNumber { +func (x ProcessState) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } -// Deprecated: Use ProcessStatus.Descriptor instead. -func (ProcessStatus) EnumDescriptor() ([]byte, []int) { - return file_proto_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{0} +// Deprecated: Use ProcessState.Descriptor instead. +func (ProcessState) EnumDescriptor() ([]byte, []int) { + return file_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{0} } -// Output stream source. -type OutputSource int32 +// POSIX signals that can be sent to a process. Values match the Linux signal +// numbers on x86/arm. +type Signal int32 const ( - OutputSource_OUTPUT_SOURCE_UNSPECIFIED OutputSource = 0 - OutputSource_OUTPUT_SOURCE_STDOUT OutputSource = 1 - OutputSource_OUTPUT_SOURCE_STDERR OutputSource = 2 + Signal_SIGNAL_UNSPECIFIED Signal = 0 + Signal_SIGNAL_HUP Signal = 1 + Signal_SIGNAL_INT Signal = 2 + Signal_SIGNAL_QUIT Signal = 3 + Signal_SIGNAL_ILL Signal = 4 + Signal_SIGNAL_TRAP Signal = 5 + Signal_SIGNAL_ABRT Signal = 6 + Signal_SIGNAL_BUS Signal = 7 + Signal_SIGNAL_FPE Signal = 8 + Signal_SIGNAL_KILL Signal = 9 + Signal_SIGNAL_USR1 Signal = 10 + Signal_SIGNAL_SEGV Signal = 11 + Signal_SIGNAL_USR2 Signal = 12 + Signal_SIGNAL_PIPE Signal = 13 + Signal_SIGNAL_ALRM Signal = 14 + Signal_SIGNAL_TERM Signal = 15 + Signal_SIGNAL_CHLD Signal = 17 + Signal_SIGNAL_CONT Signal = 18 + Signal_SIGNAL_STOP Signal = 19 + Signal_SIGNAL_TSTP Signal = 20 + Signal_SIGNAL_TTIN Signal = 21 + Signal_SIGNAL_TTOU Signal = 22 + Signal_SIGNAL_URG Signal = 23 + Signal_SIGNAL_XCPU Signal = 24 + Signal_SIGNAL_XFSZ Signal = 25 + Signal_SIGNAL_VTALRM Signal = 26 + Signal_SIGNAL_PROF Signal = 27 + Signal_SIGNAL_WINCH Signal = 28 + Signal_SIGNAL_IO Signal = 29 + Signal_SIGNAL_SYS Signal = 31 ) -// Enum value maps for OutputSource. +// Enum value maps for Signal. var ( - OutputSource_name = map[int32]string{ - 0: "OUTPUT_SOURCE_UNSPECIFIED", - 1: "OUTPUT_SOURCE_STDOUT", - 2: "OUTPUT_SOURCE_STDERR", + Signal_name = map[int32]string{ + 0: "SIGNAL_UNSPECIFIED", + 1: "SIGNAL_HUP", + 2: "SIGNAL_INT", + 3: "SIGNAL_QUIT", + 4: "SIGNAL_ILL", + 5: "SIGNAL_TRAP", + 6: "SIGNAL_ABRT", + 7: "SIGNAL_BUS", + 8: "SIGNAL_FPE", + 9: "SIGNAL_KILL", + 10: "SIGNAL_USR1", + 11: "SIGNAL_SEGV", + 12: "SIGNAL_USR2", + 13: "SIGNAL_PIPE", + 14: "SIGNAL_ALRM", + 15: "SIGNAL_TERM", + 17: "SIGNAL_CHLD", + 18: "SIGNAL_CONT", + 19: "SIGNAL_STOP", + 20: "SIGNAL_TSTP", + 21: "SIGNAL_TTIN", + 22: "SIGNAL_TTOU", + 23: "SIGNAL_URG", + 24: "SIGNAL_XCPU", + 25: "SIGNAL_XFSZ", + 26: "SIGNAL_VTALRM", + 27: "SIGNAL_PROF", + 28: "SIGNAL_WINCH", + 29: "SIGNAL_IO", + 31: "SIGNAL_SYS", } - OutputSource_value = map[string]int32{ - "OUTPUT_SOURCE_UNSPECIFIED": 0, - "OUTPUT_SOURCE_STDOUT": 1, - "OUTPUT_SOURCE_STDERR": 2, + Signal_value = map[string]int32{ + "SIGNAL_UNSPECIFIED": 0, + "SIGNAL_HUP": 1, + "SIGNAL_INT": 2, + "SIGNAL_QUIT": 3, + "SIGNAL_ILL": 4, + "SIGNAL_TRAP": 5, + "SIGNAL_ABRT": 6, + "SIGNAL_BUS": 7, + "SIGNAL_FPE": 8, + "SIGNAL_KILL": 9, + "SIGNAL_USR1": 10, + "SIGNAL_SEGV": 11, + "SIGNAL_USR2": 12, + "SIGNAL_PIPE": 13, + "SIGNAL_ALRM": 14, + "SIGNAL_TERM": 15, + "SIGNAL_CHLD": 17, + "SIGNAL_CONT": 18, + "SIGNAL_STOP": 19, + "SIGNAL_TSTP": 20, + "SIGNAL_TTIN": 21, + "SIGNAL_TTOU": 22, + "SIGNAL_URG": 23, + "SIGNAL_XCPU": 24, + "SIGNAL_XFSZ": 25, + "SIGNAL_VTALRM": 26, + "SIGNAL_PROF": 27, + "SIGNAL_WINCH": 28, + "SIGNAL_IO": 29, + "SIGNAL_SYS": 31, } ) -func (x OutputSource) Enum() *OutputSource { - p := new(OutputSource) +func (x Signal) Enum() *Signal { + p := new(Signal) *p = x return p } -func (x OutputSource) String() string { +func (x Signal) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } -func (OutputSource) Descriptor() protoreflect.EnumDescriptor { - return file_proto_ateenv_v1alpha_guest_proto_enumTypes[1].Descriptor() +func (Signal) Descriptor() protoreflect.EnumDescriptor { + return file_ateenv_v1alpha_guest_proto_enumTypes[1].Descriptor() } -func (OutputSource) Type() protoreflect.EnumType { - return &file_proto_ateenv_v1alpha_guest_proto_enumTypes[1] +func (Signal) Type() protoreflect.EnumType { + return &file_ateenv_v1alpha_guest_proto_enumTypes[1] } -func (x OutputSource) Number() protoreflect.EnumNumber { +func (x Signal) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } -// Deprecated: Use OutputSource.Descriptor instead. -func (OutputSource) EnumDescriptor() ([]byte, []int) { - return file_proto_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{1} +// Deprecated: Use Signal.Descriptor instead. +func (Signal) EnumDescriptor() ([]byte, []int) { + return file_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{1} } -// The Process resource representing execution state and metadata. +// The Process resource: identity, lifecycle state, and exit status. type Process struct { state protoimpl.MessageState `protogen:"open.v1"` - // Unique process identifier. + // Unique process identifier assigned by the guest. ProcessId string `protobuf:"bytes,1,opt,name=process_id,json=processId,proto3" json:"process_id,omitempty"` - // Current execution lifecycle state. - Status ProcessStatus `protobuf:"varint,2,opt,name=status,proto3,enum=ateenv.v1alpha.ProcessStatus" json:"status,omitempty"` - // Process exit status code (0 for success, 1-127 for program exit code, - // 128 + signal number if terminated by signal, e.g. 137 for SIGKILL, 143 for SIGTERM). - // Valid once status is COMPLETED, FAILED, or TERMINATED. - ExitCode int32 `protobuf:"varint,3,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + // Command binary and arguments the process was started with. + Command []string `protobuf:"bytes,2,rep,name=command,proto3" json:"command,omitempty"` + // Operating system PID inside the container. + Pid int32 `protobuf:"varint,3,opt,name=pid,proto3" json:"pid,omitempty"` + // Current lifecycle state. + State ProcessState `protobuf:"varint,4,opt,name=state,proto3,enum=ateenv.v1alpha.ProcessState" json:"state,omitempty"` + // Exit status, valid once state is EXITED: the process's exit code, or + // 128 + signal number if it was terminated by a signal (e.g. 137 for + // SIGKILL, 143 for SIGTERM). + ExitCode int32 `protobuf:"varint,5,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` // Timestamp when the process started. - StartedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"` - // Timestamp when the process terminated (if finished). - FinishedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=finished_at,json=finishedAt,proto3" json:"finished_at,omitempty"` + StartedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"` + // Timestamp when the process exited. Unset while RUNNING. + FinishedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=finished_at,json=finishedAt,proto3" json:"finished_at,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Process) Reset() { *x = Process{} - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[0] + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -184,7 +264,7 @@ func (x *Process) String() string { func (*Process) ProtoMessage() {} func (x *Process) ProtoReflect() protoreflect.Message { - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[0] + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -197,7 +277,7 @@ func (x *Process) ProtoReflect() protoreflect.Message { // Deprecated: Use Process.ProtoReflect.Descriptor instead. func (*Process) Descriptor() ([]byte, []int) { - return file_proto_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{0} + return file_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{0} } func (x *Process) GetProcessId() string { @@ -207,11 +287,25 @@ func (x *Process) GetProcessId() string { return "" } -func (x *Process) GetStatus() ProcessStatus { +func (x *Process) GetCommand() []string { + if x != nil { + return x.Command + } + return nil +} + +func (x *Process) GetPid() int32 { if x != nil { - return x.Status + return x.Pid } - return ProcessStatus_PROCESS_STATUS_UNSPECIFIED + return 0 +} + +func (x *Process) GetState() ProcessState { + if x != nil { + return x.State + } + return ProcessState_PROCESS_STATE_UNSPECIFIED } func (x *Process) GetExitCode() int32 { @@ -235,22 +329,28 @@ func (x *Process) GetFinishedAt() *timestamppb.Timestamp { return nil } -// Request to start a background asynchronous process. +// Request to start a process. type StartProcessRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Command binary and arguments to execute (e.g. ["pytest", "tests/"] or ["sh", "-c", "ls -la"]). Command []string `protobuf:"bytes,1,rep,name=command,proto3" json:"command,omitempty"` - // Working directory inside the container (defaults to container workdir). + // Working directory inside the container (defaults to the guest workspace). Cwd string `protobuf:"bytes,2,opt,name=cwd,proto3" json:"cwd,omitempty"` - // Environment variables to set for the background process. - Env map[string]string `protobuf:"bytes,3,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Environment variables to set for the process, on top of the guest's environment. + Env map[string]string `protobuf:"bytes,3,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // If true, stdin is an open pipe fed by WriteProcessInput. If false, stdin + // reads as empty (/dev/null) and WriteProcessInput is rejected. + Stdin bool `protobuf:"varint,4,opt,name=stdin,proto3" json:"stdin,omitempty"` + // Maximum wall-clock run time. On expiry the process group receives SIGKILL. + // Unset or zero applies the guest's default timeout. + Timeout *durationpb.Duration `protobuf:"bytes,5,opt,name=timeout,proto3" json:"timeout,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *StartProcessRequest) Reset() { *x = StartProcessRequest{} - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[1] + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -262,7 +362,7 @@ func (x *StartProcessRequest) String() string { func (*StartProcessRequest) ProtoMessage() {} func (x *StartProcessRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[1] + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -275,7 +375,7 @@ func (x *StartProcessRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartProcessRequest.ProtoReflect.Descriptor instead. func (*StartProcessRequest) Descriptor() ([]byte, []int) { - return file_proto_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{1} + return file_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{1} } func (x *StartProcessRequest) GetCommand() []string { @@ -299,53 +399,21 @@ func (x *StartProcessRequest) GetEnv() map[string]string { return nil } -// Response returned immediately after launching an asynchronous process. -type StartProcessResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Unique process identifier used for status inspection, log streaming, and killing. - ProcessId string `protobuf:"bytes,1,opt,name=process_id,json=processId,proto3" json:"process_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *StartProcessResponse) Reset() { - *x = StartProcessResponse{} - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *StartProcessResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*StartProcessResponse) ProtoMessage() {} - -func (x *StartProcessResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[2] +func (x *StartProcessRequest) GetStdin() bool { if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms + return x.Stdin } - return mi.MessageOf(x) -} - -// Deprecated: Use StartProcessResponse.ProtoReflect.Descriptor instead. -func (*StartProcessResponse) Descriptor() ([]byte, []int) { - return file_proto_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{2} + return false } -func (x *StartProcessResponse) GetProcessId() string { +func (x *StartProcessRequest) GetTimeout() *durationpb.Duration { if x != nil { - return x.ProcessId + return x.Timeout } - return "" + return nil } -// Request to get the Process resource. +// Request to get a Process. type GetProcessRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Identifier of the process to inspect. @@ -356,7 +424,7 @@ type GetProcessRequest struct { func (x *GetProcessRequest) Reset() { *x = GetProcessRequest{} - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[3] + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -368,7 +436,7 @@ func (x *GetProcessRequest) String() string { func (*GetProcessRequest) ProtoMessage() {} func (x *GetProcessRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[3] + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -381,7 +449,7 @@ func (x *GetProcessRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetProcessRequest.ProtoReflect.Descriptor instead. func (*GetProcessRequest) Descriptor() ([]byte, []int) { - return file_proto_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{3} + return file_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{2} } func (x *GetProcessRequest) GetProcessId() string { @@ -392,35 +460,39 @@ func (x *GetProcessRequest) GetProcessId() string { } // Request to stream output from a process. -type StreamProcessOutputsRequest struct { +type StreamProcessOutputRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Identifier of the process to stream output from. ProcessId string `protobuf:"bytes,1,opt,name=process_id,json=processId,proto3" json:"process_id,omitempty"` // Byte offset to start reading stdout from (defaults to 0 / beginning). + // Bytes before the offset are never delivered, so an offset past the end + // of the spool suppresses stdout entirely. StdoutOffset int64 `protobuf:"varint,2,opt,name=stdout_offset,json=stdoutOffset,proto3" json:"stdout_offset,omitempty"` // Byte offset to start reading stderr from (defaults to 0 / beginning). + // Same semantics as stdout_offset. StderrOffset int64 `protobuf:"varint,3,opt,name=stderr_offset,json=stderrOffset,proto3" json:"stderr_offset,omitempty"` - // If true, the stream follows new output in real-time until the process finishes. + // If true, the stream stays open and delivers new output until the process + // exits. If false, the output spooled so far is returned and the stream ends. Follow bool `protobuf:"varint,4,opt,name=follow,proto3" json:"follow,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *StreamProcessOutputsRequest) Reset() { - *x = StreamProcessOutputsRequest{} - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[4] +func (x *StreamProcessOutputRequest) Reset() { + *x = StreamProcessOutputRequest{} + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *StreamProcessOutputsRequest) String() string { +func (x *StreamProcessOutputRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*StreamProcessOutputsRequest) ProtoMessage() {} +func (*StreamProcessOutputRequest) ProtoMessage() {} -func (x *StreamProcessOutputsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[4] +func (x *StreamProcessOutputRequest) ProtoReflect() protoreflect.Message { + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -431,65 +503,67 @@ func (x *StreamProcessOutputsRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use StreamProcessOutputsRequest.ProtoReflect.Descriptor instead. -func (*StreamProcessOutputsRequest) Descriptor() ([]byte, []int) { - return file_proto_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{4} +// Deprecated: Use StreamProcessOutputRequest.ProtoReflect.Descriptor instead. +func (*StreamProcessOutputRequest) Descriptor() ([]byte, []int) { + return file_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{3} } -func (x *StreamProcessOutputsRequest) GetProcessId() string { +func (x *StreamProcessOutputRequest) GetProcessId() string { if x != nil { return x.ProcessId } return "" } -func (x *StreamProcessOutputsRequest) GetStdoutOffset() int64 { +func (x *StreamProcessOutputRequest) GetStdoutOffset() int64 { if x != nil { return x.StdoutOffset } return 0 } -func (x *StreamProcessOutputsRequest) GetStderrOffset() int64 { +func (x *StreamProcessOutputRequest) GetStderrOffset() int64 { if x != nil { return x.StderrOffset } return 0 } -func (x *StreamProcessOutputsRequest) GetFollow() bool { +func (x *StreamProcessOutputRequest) GetFollow() bool { if x != nil { return x.Follow } return false } -// Streamed chunk of process output. -type OutputChunk struct { +// One message on a process output stream. Exactly one field is set. +type ProcessOutput struct { state protoimpl.MessageState `protogen:"open.v1"` - // Stream source (stdout or stderr). - Source OutputSource `protobuf:"varint,1,opt,name=source,proto3,enum=ateenv.v1alpha.OutputSource" json:"source,omitempty"` - // Output content bytes. - Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + // Types that are valid to be assigned to Output: + // + // *ProcessOutput_Stdout + // *ProcessOutput_Stderr + // *ProcessOutput_Exit + Output isProcessOutput_Output `protobuf_oneof:"output"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *OutputChunk) Reset() { - *x = OutputChunk{} - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[5] +func (x *ProcessOutput) Reset() { + *x = ProcessOutput{} + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *OutputChunk) String() string { +func (x *ProcessOutput) String() string { return protoimpl.X.MessageStringOf(x) } -func (*OutputChunk) ProtoMessage() {} +func (*ProcessOutput) ProtoMessage() {} -func (x *OutputChunk) ProtoReflect() protoreflect.Message { - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[5] +func (x *ProcessOutput) ProtoReflect() protoreflect.Message { + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -500,49 +574,99 @@ func (x *OutputChunk) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use OutputChunk.ProtoReflect.Descriptor instead. -func (*OutputChunk) Descriptor() ([]byte, []int) { - return file_proto_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{5} +// Deprecated: Use ProcessOutput.ProtoReflect.Descriptor instead. +func (*ProcessOutput) Descriptor() ([]byte, []int) { + return file_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{4} } -func (x *OutputChunk) GetSource() OutputSource { +func (x *ProcessOutput) GetOutput() isProcessOutput_Output { if x != nil { - return x.Source + return x.Output } - return OutputSource_OUTPUT_SOURCE_UNSPECIFIED + return nil } -func (x *OutputChunk) GetData() []byte { +func (x *ProcessOutput) GetStdout() []byte { if x != nil { - return x.Data + if x, ok := x.Output.(*ProcessOutput_Stdout); ok { + return x.Stdout + } + } + return nil +} + +func (x *ProcessOutput) GetStderr() []byte { + if x != nil { + if x, ok := x.Output.(*ProcessOutput_Stderr); ok { + return x.Stderr + } } return nil } -// Request to terminate a running asynchronous process. -type KillProcessRequest struct { +func (x *ProcessOutput) GetExit() *Process { + if x != nil { + if x, ok := x.Output.(*ProcessOutput_Exit); ok { + return x.Exit + } + } + return nil +} + +type isProcessOutput_Output interface { + isProcessOutput_Output() +} + +type ProcessOutput_Stdout struct { + // A chunk of standard output. + Stdout []byte `protobuf:"bytes,1,opt,name=stdout,proto3,oneof"` +} + +type ProcessOutput_Stderr struct { + // A chunk of standard error. + Stderr []byte `protobuf:"bytes,2,opt,name=stderr,proto3,oneof"` +} + +type ProcessOutput_Exit struct { + // Final message: the process has exited and all output has been delivered. + // Absent if the stream ends while the process is still running (follow=false). + Exit *Process `protobuf:"bytes,3,opt,name=exit,proto3,oneof"` +} + +func (*ProcessOutput_Stdout) isProcessOutput_Output() {} + +func (*ProcessOutput_Stderr) isProcessOutput_Output() {} + +func (*ProcessOutput_Exit) isProcessOutput_Output() {} + +// One chunk of standard input for a process. +type WriteProcessInputRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Identifier of the background process to terminate. - ProcessId string `protobuf:"bytes,1,opt,name=process_id,json=processId,proto3" json:"process_id,omitempty"` + // Identifier of the target process. Required on the first message; ignored afterwards. + ProcessId string `protobuf:"bytes,1,opt,name=process_id,json=processId,proto3" json:"process_id,omitempty"` + // Bytes to write to stdin. May be empty, e.g. to only close stdin. + Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` + // If true, stdin is closed (EOF) after data is written. No further input is accepted. + Close bool `protobuf:"varint,3,opt,name=close,proto3" json:"close,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *KillProcessRequest) Reset() { - *x = KillProcessRequest{} - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[6] +func (x *WriteProcessInputRequest) Reset() { + *x = WriteProcessInputRequest{} + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *KillProcessRequest) String() string { +func (x *WriteProcessInputRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*KillProcessRequest) ProtoMessage() {} +func (*WriteProcessInputRequest) ProtoMessage() {} -func (x *KillProcessRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[6] +func (x *WriteProcessInputRequest) ProtoReflect() protoreflect.Message { + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -553,42 +677,56 @@ func (x *KillProcessRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use KillProcessRequest.ProtoReflect.Descriptor instead. -func (*KillProcessRequest) Descriptor() ([]byte, []int) { - return file_proto_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{6} +// Deprecated: Use WriteProcessInputRequest.ProtoReflect.Descriptor instead. +func (*WriteProcessInputRequest) Descriptor() ([]byte, []int) { + return file_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{5} } -func (x *KillProcessRequest) GetProcessId() string { +func (x *WriteProcessInputRequest) GetProcessId() string { if x != nil { return x.ProcessId } return "" } -// Response from terminating a process. -type KillProcessResponse struct { +func (x *WriteProcessInputRequest) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +func (x *WriteProcessInputRequest) GetClose() bool { + if x != nil { + return x.Close + } + return false +} + +// Response confirming the input write. +type WriteProcessInputResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - // Exit status code after process termination (typically 128 + signal, e.g. 137 for SIGKILL). - ExitCode int32 `protobuf:"varint,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + // Total number of bytes written to stdin across all messages in the stream. + BytesWritten int64 `protobuf:"varint,1,opt,name=bytes_written,json=bytesWritten,proto3" json:"bytes_written,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *KillProcessResponse) Reset() { - *x = KillProcessResponse{} - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[7] +func (x *WriteProcessInputResponse) Reset() { + *x = WriteProcessInputResponse{} + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *KillProcessResponse) String() string { +func (x *WriteProcessInputResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*KillProcessResponse) ProtoMessage() {} +func (*WriteProcessInputResponse) ProtoMessage() {} -func (x *KillProcessResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[7] +func (x *WriteProcessInputResponse) ProtoReflect() protoreflect.Message { + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -599,30 +737,90 @@ func (x *KillProcessResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use KillProcessResponse.ProtoReflect.Descriptor instead. -func (*KillProcessResponse) Descriptor() ([]byte, []int) { - return file_proto_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{7} +// Deprecated: Use WriteProcessInputResponse.ProtoReflect.Descriptor instead. +func (*WriteProcessInputResponse) Descriptor() ([]byte, []int) { + return file_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{6} } -func (x *KillProcessResponse) GetExitCode() int32 { +func (x *WriteProcessInputResponse) GetBytesWritten() int64 { if x != nil { - return x.ExitCode + return x.BytesWritten } return 0 } +// Request to send a signal to a process. +type SignalProcessRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Identifier of the target process. + ProcessId string `protobuf:"bytes,1,opt,name=process_id,json=processId,proto3" json:"process_id,omitempty"` + // Signal to deliver to the process group. + Signal Signal `protobuf:"varint,2,opt,name=signal,proto3,enum=ateenv.v1alpha.Signal" json:"signal,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SignalProcessRequest) Reset() { + *x = SignalProcessRequest{} + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SignalProcessRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SignalProcessRequest) ProtoMessage() {} + +func (x *SignalProcessRequest) ProtoReflect() protoreflect.Message { + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SignalProcessRequest.ProtoReflect.Descriptor instead. +func (*SignalProcessRequest) Descriptor() ([]byte, []int) { + return file_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{7} +} + +func (x *SignalProcessRequest) GetProcessId() string { + if x != nil { + return x.ProcessId + } + return "" +} + +func (x *SignalProcessRequest) GetSignal() Signal { + if x != nil { + return x.Signal + } + return Signal_SIGNAL_UNSPECIFIED +} + // Request to read a file from the container. type ReadFileRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Absolute or workspace-relative path of the file to read. - Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + // Optional POSIX file mode permission (e.g. 0644). When set and the file + // does not exist, it is created empty with this mode (parent directories + // included) and the stream ends without chunks. When zero, a missing file + // is a NOT_FOUND error. Existing files are never modified. + Mode uint32 `protobuf:"varint,2,opt,name=mode,proto3" json:"mode,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ReadFileRequest) Reset() { *x = ReadFileRequest{} - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[8] + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -634,7 +832,7 @@ func (x *ReadFileRequest) String() string { func (*ReadFileRequest) ProtoMessage() {} func (x *ReadFileRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[8] + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -647,7 +845,7 @@ func (x *ReadFileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReadFileRequest.ProtoReflect.Descriptor instead. func (*ReadFileRequest) Descriptor() ([]byte, []int) { - return file_proto_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{8} + return file_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{8} } func (x *ReadFileRequest) GetPath() string { @@ -657,30 +855,37 @@ func (x *ReadFileRequest) GetPath() string { return "" } +func (x *ReadFileRequest) GetMode() uint32 { + if x != nil { + return x.Mode + } + return 0 +} + // Streamed chunk of file data. -type FileChunk struct { +type ReadFileResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Raw binary or text chunk of the file. - Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + Chunk []byte `protobuf:"bytes,1,opt,name=chunk,proto3" json:"chunk,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *FileChunk) Reset() { - *x = FileChunk{} - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[9] +func (x *ReadFileResponse) Reset() { + *x = ReadFileResponse{} + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *FileChunk) String() string { +func (x *ReadFileResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*FileChunk) ProtoMessage() {} +func (*ReadFileResponse) ProtoMessage() {} -func (x *FileChunk) ProtoReflect() protoreflect.Message { - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[9] +func (x *ReadFileResponse) ProtoReflect() protoreflect.Message { + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -691,34 +896,43 @@ func (x *FileChunk) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use FileChunk.ProtoReflect.Descriptor instead. -func (*FileChunk) Descriptor() ([]byte, []int) { - return file_proto_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{9} +// Deprecated: Use ReadFileResponse.ProtoReflect.Descriptor instead. +func (*ReadFileResponse) Descriptor() ([]byte, []int) { + return file_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{9} } -func (x *FileChunk) GetData() []byte { +func (x *ReadFileResponse) GetChunk() []byte { if x != nil { - return x.Data + return x.Chunk } return nil } -// Streamed request chunk for writing data to a file. +// Streamed request chunk for writing data to a file. path, mode and +// seek_offset are read from the first message only and ignored on all +// subsequent messages; every message may carry a chunk. type WriteFileRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Absolute or workspace-relative path of the file to write (sent on first message). + // Absolute or workspace-relative path of the file to write. Required on the + // first message. Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + // Optional POSIX file mode permission (e.g. 0644 or 0755), applied when the + // file is created. + Mode uint32 `protobuf:"varint,2,opt,name=mode,proto3" json:"mode,omitempty"` + // Byte offset at which writing starts. Zero, the default, truncates the + // file and rewrites it from the beginning. A positive offset keeps existing + // content, begins writing at the offset (extending the file with zero bytes + // if it is shorter), and leaves bytes after the written range in place. + SeekOffset int64 `protobuf:"varint,3,opt,name=seek_offset,json=seekOffset,proto3" json:"seek_offset,omitempty"` // Raw binary or text chunk to write to the file. - Chunk []byte `protobuf:"bytes,2,opt,name=chunk,proto3" json:"chunk,omitempty"` - // Optional POSIX file mode permission (e.g. 0644 or 0755; processed on first message). - Mode uint32 `protobuf:"varint,3,opt,name=mode,proto3" json:"mode,omitempty"` + Chunk []byte `protobuf:"bytes,4,opt,name=chunk,proto3" json:"chunk,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *WriteFileRequest) Reset() { *x = WriteFileRequest{} - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[10] + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -730,7 +944,7 @@ func (x *WriteFileRequest) String() string { func (*WriteFileRequest) ProtoMessage() {} func (x *WriteFileRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[10] + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -743,7 +957,7 @@ func (x *WriteFileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WriteFileRequest.ProtoReflect.Descriptor instead. func (*WriteFileRequest) Descriptor() ([]byte, []int) { - return file_proto_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{10} + return file_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{10} } func (x *WriteFileRequest) GetPath() string { @@ -753,20 +967,27 @@ func (x *WriteFileRequest) GetPath() string { return "" } -func (x *WriteFileRequest) GetChunk() []byte { +func (x *WriteFileRequest) GetMode() uint32 { if x != nil { - return x.Chunk + return x.Mode } - return nil + return 0 } -func (x *WriteFileRequest) GetMode() uint32 { +func (x *WriteFileRequest) GetSeekOffset() int64 { if x != nil { - return x.Mode + return x.SeekOffset } return 0 } +func (x *WriteFileRequest) GetChunk() []byte { + if x != nil { + return x.Chunk + } + return nil +} + // Response confirming the write operation. type WriteFileResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -778,7 +999,7 @@ type WriteFileResponse struct { func (x *WriteFileResponse) Reset() { *x = WriteFileResponse{} - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[11] + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -790,7 +1011,7 @@ func (x *WriteFileResponse) String() string { func (*WriteFileResponse) ProtoMessage() {} func (x *WriteFileResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_ateenv_v1alpha_guest_proto_msgTypes[11] + mi := &file_ateenv_v1alpha_guest_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -803,7 +1024,7 @@ func (x *WriteFileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WriteFileResponse.ProtoReflect.Descriptor instead. func (*WriteFileResponse) Descriptor() ([]byte, []int) { - return file_proto_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{11} + return file_ateenv_v1alpha_guest_proto_rawDescGZIP(), []int{11} } func (x *WriteFileResponse) GetBytesWritten() int64 { @@ -813,155 +1034,211 @@ func (x *WriteFileResponse) GetBytesWritten() int64 { return 0 } -var File_proto_ateenv_v1alpha_guest_proto protoreflect.FileDescriptor +var File_ateenv_v1alpha_guest_proto protoreflect.FileDescriptor -const file_proto_ateenv_v1alpha_guest_proto_rawDesc = "" + +const file_ateenv_v1alpha_guest_proto_rawDesc = "" + "\n" + - " proto/ateenv/v1alpha/guest.proto\x12\x0eateenv.v1alpha\x1a\x1fgoogle/protobuf/timestamp.proto\"\xf4\x01\n" + + "\x1aateenv/v1alpha/guest.proto\x12\x0eateenv.v1alpha\x1a\x1egoogle/protobuf/duration.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\x9d\x02\n" + "\aProcess\x12\x1d\n" + "\n" + - "process_id\x18\x01 \x01(\tR\tprocessId\x125\n" + - "\x06status\x18\x02 \x01(\x0e2\x1d.ateenv.v1alpha.ProcessStatusR\x06status\x12\x1b\n" + - "\texit_code\x18\x03 \x01(\x05R\bexitCode\x129\n" + + "process_id\x18\x01 \x01(\tR\tprocessId\x12\x18\n" + + "\acommand\x18\x02 \x03(\tR\acommand\x12\x10\n" + + "\x03pid\x18\x03 \x01(\x05R\x03pid\x122\n" + + "\x05state\x18\x04 \x01(\x0e2\x1c.ateenv.v1alpha.ProcessStateR\x05state\x12\x1b\n" + + "\texit_code\x18\x05 \x01(\x05R\bexitCode\x129\n" + "\n" + - "started_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tstartedAt\x12;\n" + - "\vfinished_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\n" + - "finishedAt\"\xb9\x01\n" + + "started_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\tstartedAt\x12;\n" + + "\vfinished_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "finishedAt\"\x84\x02\n" + "\x13StartProcessRequest\x12\x18\n" + "\acommand\x18\x01 \x03(\tR\acommand\x12\x10\n" + "\x03cwd\x18\x02 \x01(\tR\x03cwd\x12>\n" + - "\x03env\x18\x03 \x03(\v2,.ateenv.v1alpha.StartProcessRequest.EnvEntryR\x03env\x1a6\n" + + "\x03env\x18\x03 \x03(\v2,.ateenv.v1alpha.StartProcessRequest.EnvEntryR\x03env\x12\x14\n" + + "\x05stdin\x18\x04 \x01(\bR\x05stdin\x123\n" + + "\atimeout\x18\x05 \x01(\v2\x19.google.protobuf.DurationR\atimeout\x1a6\n" + "\bEnvEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"5\n" + - "\x14StartProcessResponse\x12\x1d\n" + - "\n" + - "process_id\x18\x01 \x01(\tR\tprocessId\"2\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"2\n" + "\x11GetProcessRequest\x12\x1d\n" + "\n" + - "process_id\x18\x01 \x01(\tR\tprocessId\"\x9e\x01\n" + - "\x1bStreamProcessOutputsRequest\x12\x1d\n" + + "process_id\x18\x01 \x01(\tR\tprocessId\"\x9d\x01\n" + + "\x1aStreamProcessOutputRequest\x12\x1d\n" + "\n" + "process_id\x18\x01 \x01(\tR\tprocessId\x12#\n" + "\rstdout_offset\x18\x02 \x01(\x03R\fstdoutOffset\x12#\n" + "\rstderr_offset\x18\x03 \x01(\x03R\fstderrOffset\x12\x16\n" + - "\x06follow\x18\x04 \x01(\bR\x06follow\"W\n" + - "\vOutputChunk\x124\n" + - "\x06source\x18\x01 \x01(\x0e2\x1c.ateenv.v1alpha.OutputSourceR\x06source\x12\x12\n" + - "\x04data\x18\x02 \x01(\fR\x04data\"3\n" + - "\x12KillProcessRequest\x12\x1d\n" + + "\x06follow\x18\x04 \x01(\bR\x06follow\"|\n" + + "\rProcessOutput\x12\x18\n" + + "\x06stdout\x18\x01 \x01(\fH\x00R\x06stdout\x12\x18\n" + + "\x06stderr\x18\x02 \x01(\fH\x00R\x06stderr\x12-\n" + + "\x04exit\x18\x03 \x01(\v2\x17.ateenv.v1alpha.ProcessH\x00R\x04exitB\b\n" + + "\x06output\"c\n" + + "\x18WriteProcessInputRequest\x12\x1d\n" + "\n" + - "process_id\x18\x01 \x01(\tR\tprocessId\"2\n" + - "\x13KillProcessResponse\x12\x1b\n" + - "\texit_code\x18\x01 \x01(\x05R\bexitCode\"%\n" + + "process_id\x18\x01 \x01(\tR\tprocessId\x12\x12\n" + + "\x04data\x18\x02 \x01(\fR\x04data\x12\x14\n" + + "\x05close\x18\x03 \x01(\bR\x05close\"@\n" + + "\x19WriteProcessInputResponse\x12#\n" + + "\rbytes_written\x18\x01 \x01(\x03R\fbytesWritten\"e\n" + + "\x14SignalProcessRequest\x12\x1d\n" + + "\n" + + "process_id\x18\x01 \x01(\tR\tprocessId\x12.\n" + + "\x06signal\x18\x02 \x01(\x0e2\x16.ateenv.v1alpha.SignalR\x06signal\"9\n" + "\x0fReadFileRequest\x12\x12\n" + - "\x04path\x18\x01 \x01(\tR\x04path\"\x1f\n" + - "\tFileChunk\x12\x12\n" + - "\x04data\x18\x01 \x01(\fR\x04data\"P\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" + + "\x04mode\x18\x02 \x01(\rR\x04mode\"(\n" + + "\x10ReadFileResponse\x12\x14\n" + + "\x05chunk\x18\x01 \x01(\fR\x05chunk\"q\n" + "\x10WriteFileRequest\x12\x12\n" + - "\x04path\x18\x01 \x01(\tR\x04path\x12\x14\n" + - "\x05chunk\x18\x02 \x01(\fR\x05chunk\x12\x12\n" + - "\x04mode\x18\x03 \x01(\rR\x04mode\"8\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" + + "\x04mode\x18\x02 \x01(\rR\x04mode\x12\x1f\n" + + "\vseek_offset\x18\x03 \x01(\x03R\n" + + "seekOffset\x12\x14\n" + + "\x05chunk\x18\x04 \x01(\fR\x05chunk\"8\n" + "\x11WriteFileResponse\x12#\n" + - "\rbytes_written\x18\x01 \x01(\x03R\fbytesWritten*\xa3\x01\n" + - "\rProcessStatus\x12\x1e\n" + - "\x1aPROCESS_STATUS_UNSPECIFIED\x10\x00\x12\x1a\n" + - "\x16PROCESS_STATUS_RUNNING\x10\x01\x12\x1c\n" + - "\x18PROCESS_STATUS_COMPLETED\x10\x02\x12\x19\n" + - "\x15PROCESS_STATUS_FAILED\x10\x03\x12\x1d\n" + - "\x19PROCESS_STATUS_TERMINATED\x10\x04*a\n" + - "\fOutputSource\x12\x1d\n" + - "\x19OUTPUT_SOURCE_UNSPECIFIED\x10\x00\x12\x18\n" + - "\x14OUTPUT_SOURCE_STDOUT\x10\x01\x12\x18\n" + - "\x14OUTPUT_SOURCE_STDERR\x10\x022\xf1\x02\n" + - "\x0eProcessService\x12Y\n" + - "\fStartProcess\x12#.ateenv.v1alpha.StartProcessRequest\x1a$.ateenv.v1alpha.StartProcessResponse\x12H\n" + + "\rbytes_written\x18\x01 \x01(\x03R\fbytesWritten*b\n" + + "\fProcessState\x12\x1d\n" + + "\x19PROCESS_STATE_UNSPECIFIED\x10\x00\x12\x19\n" + + "\x15PROCESS_STATE_RUNNING\x10\x01\x12\x18\n" + + "\x14PROCESS_STATE_EXITED\x10\x02*\x87\x04\n" + + "\x06Signal\x12\x16\n" + + "\x12SIGNAL_UNSPECIFIED\x10\x00\x12\x0e\n" + + "\n" + + "SIGNAL_HUP\x10\x01\x12\x0e\n" + + "\n" + + "SIGNAL_INT\x10\x02\x12\x0f\n" + + "\vSIGNAL_QUIT\x10\x03\x12\x0e\n" + + "\n" + + "SIGNAL_ILL\x10\x04\x12\x0f\n" + + "\vSIGNAL_TRAP\x10\x05\x12\x0f\n" + + "\vSIGNAL_ABRT\x10\x06\x12\x0e\n" + + "\n" + + "SIGNAL_BUS\x10\a\x12\x0e\n" + + "\n" + + "SIGNAL_FPE\x10\b\x12\x0f\n" + + "\vSIGNAL_KILL\x10\t\x12\x0f\n" + + "\vSIGNAL_USR1\x10\n" + + "\x12\x0f\n" + + "\vSIGNAL_SEGV\x10\v\x12\x0f\n" + + "\vSIGNAL_USR2\x10\f\x12\x0f\n" + + "\vSIGNAL_PIPE\x10\r\x12\x0f\n" + + "\vSIGNAL_ALRM\x10\x0e\x12\x0f\n" + + "\vSIGNAL_TERM\x10\x0f\x12\x0f\n" + + "\vSIGNAL_CHLD\x10\x11\x12\x0f\n" + + "\vSIGNAL_CONT\x10\x12\x12\x0f\n" + + "\vSIGNAL_STOP\x10\x13\x12\x0f\n" + + "\vSIGNAL_TSTP\x10\x14\x12\x0f\n" + + "\vSIGNAL_TTIN\x10\x15\x12\x0f\n" + + "\vSIGNAL_TTOU\x10\x16\x12\x0e\n" + + "\n" + + "SIGNAL_URG\x10\x17\x12\x0f\n" + + "\vSIGNAL_XCPU\x10\x18\x12\x0f\n" + + "\vSIGNAL_XFSZ\x10\x19\x12\x11\n" + + "\rSIGNAL_VTALRM\x10\x1a\x12\x0f\n" + + "\vSIGNAL_PROF\x10\x1b\x12\x10\n" + + "\fSIGNAL_WINCH\x10\x1c\x12\r\n" + + "\tSIGNAL_IO\x10\x1d\x12\x0e\n" + + "\n" + + "SIGNAL_SYS\x10\x1f2\xc8\x03\n" + + "\x0eProcessService\x12L\n" + + "\fStartProcess\x12#.ateenv.v1alpha.StartProcessRequest\x1a\x17.ateenv.v1alpha.Process\x12H\n" + "\n" + "GetProcess\x12!.ateenv.v1alpha.GetProcessRequest\x1a\x17.ateenv.v1alpha.Process\x12b\n" + - "\x14StreamProcessOutputs\x12+.ateenv.v1alpha.StreamProcessOutputsRequest\x1a\x1b.ateenv.v1alpha.OutputChunk0\x01\x12V\n" + - "\vKillProcess\x12\".ateenv.v1alpha.KillProcessRequest\x1a#.ateenv.v1alpha.KillProcessResponse2\xb1\x01\n" + - "\x11FileSystemService\x12H\n" + - "\bReadFile\x12\x1f.ateenv.v1alpha.ReadFileRequest\x1a\x19.ateenv.v1alpha.FileChunk0\x01\x12R\n" + + "\x13StreamProcessOutput\x12*.ateenv.v1alpha.StreamProcessOutputRequest\x1a\x1d.ateenv.v1alpha.ProcessOutput0\x01\x12j\n" + + "\x11WriteProcessInput\x12(.ateenv.v1alpha.WriteProcessInputRequest\x1a).ateenv.v1alpha.WriteProcessInputResponse(\x01\x12N\n" + + "\rSignalProcess\x12$.ateenv.v1alpha.SignalProcessRequest\x1a\x17.ateenv.v1alpha.Process2\xb8\x01\n" + + "\x11FileSystemService\x12O\n" + + "\bReadFile\x12\x1f.ateenv.v1alpha.ReadFileRequest\x1a .ateenv.v1alpha.ReadFileResponse0\x01\x12R\n" + "\tWriteFile\x12 .ateenv.v1alpha.WriteFileRequest\x1a!.ateenv.v1alpha.WriteFileResponse(\x01BCZAgithub.com/agent-substrate/env/proto/ateenv/v1alpha;ateenvv1alphab\x06proto3" var ( - file_proto_ateenv_v1alpha_guest_proto_rawDescOnce sync.Once - file_proto_ateenv_v1alpha_guest_proto_rawDescData []byte + file_ateenv_v1alpha_guest_proto_rawDescOnce sync.Once + file_ateenv_v1alpha_guest_proto_rawDescData []byte ) -func file_proto_ateenv_v1alpha_guest_proto_rawDescGZIP() []byte { - file_proto_ateenv_v1alpha_guest_proto_rawDescOnce.Do(func() { - file_proto_ateenv_v1alpha_guest_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_ateenv_v1alpha_guest_proto_rawDesc), len(file_proto_ateenv_v1alpha_guest_proto_rawDesc))) +func file_ateenv_v1alpha_guest_proto_rawDescGZIP() []byte { + file_ateenv_v1alpha_guest_proto_rawDescOnce.Do(func() { + file_ateenv_v1alpha_guest_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ateenv_v1alpha_guest_proto_rawDesc), len(file_ateenv_v1alpha_guest_proto_rawDesc))) }) - return file_proto_ateenv_v1alpha_guest_proto_rawDescData -} - -var file_proto_ateenv_v1alpha_guest_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_proto_ateenv_v1alpha_guest_proto_msgTypes = make([]protoimpl.MessageInfo, 13) -var file_proto_ateenv_v1alpha_guest_proto_goTypes = []any{ - (ProcessStatus)(0), // 0: ateenv.v1alpha.ProcessStatus - (OutputSource)(0), // 1: ateenv.v1alpha.OutputSource - (*Process)(nil), // 2: ateenv.v1alpha.Process - (*StartProcessRequest)(nil), // 3: ateenv.v1alpha.StartProcessRequest - (*StartProcessResponse)(nil), // 4: ateenv.v1alpha.StartProcessResponse - (*GetProcessRequest)(nil), // 5: ateenv.v1alpha.GetProcessRequest - (*StreamProcessOutputsRequest)(nil), // 6: ateenv.v1alpha.StreamProcessOutputsRequest - (*OutputChunk)(nil), // 7: ateenv.v1alpha.OutputChunk - (*KillProcessRequest)(nil), // 8: ateenv.v1alpha.KillProcessRequest - (*KillProcessResponse)(nil), // 9: ateenv.v1alpha.KillProcessResponse - (*ReadFileRequest)(nil), // 10: ateenv.v1alpha.ReadFileRequest - (*FileChunk)(nil), // 11: ateenv.v1alpha.FileChunk - (*WriteFileRequest)(nil), // 12: ateenv.v1alpha.WriteFileRequest - (*WriteFileResponse)(nil), // 13: ateenv.v1alpha.WriteFileResponse - nil, // 14: ateenv.v1alpha.StartProcessRequest.EnvEntry - (*timestamppb.Timestamp)(nil), // 15: google.protobuf.Timestamp -} -var file_proto_ateenv_v1alpha_guest_proto_depIdxs = []int32{ - 0, // 0: ateenv.v1alpha.Process.status:type_name -> ateenv.v1alpha.ProcessStatus + return file_ateenv_v1alpha_guest_proto_rawDescData +} + +var file_ateenv_v1alpha_guest_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_ateenv_v1alpha_guest_proto_msgTypes = make([]protoimpl.MessageInfo, 13) +var file_ateenv_v1alpha_guest_proto_goTypes = []any{ + (ProcessState)(0), // 0: ateenv.v1alpha.ProcessState + (Signal)(0), // 1: ateenv.v1alpha.Signal + (*Process)(nil), // 2: ateenv.v1alpha.Process + (*StartProcessRequest)(nil), // 3: ateenv.v1alpha.StartProcessRequest + (*GetProcessRequest)(nil), // 4: ateenv.v1alpha.GetProcessRequest + (*StreamProcessOutputRequest)(nil), // 5: ateenv.v1alpha.StreamProcessOutputRequest + (*ProcessOutput)(nil), // 6: ateenv.v1alpha.ProcessOutput + (*WriteProcessInputRequest)(nil), // 7: ateenv.v1alpha.WriteProcessInputRequest + (*WriteProcessInputResponse)(nil), // 8: ateenv.v1alpha.WriteProcessInputResponse + (*SignalProcessRequest)(nil), // 9: ateenv.v1alpha.SignalProcessRequest + (*ReadFileRequest)(nil), // 10: ateenv.v1alpha.ReadFileRequest + (*ReadFileResponse)(nil), // 11: ateenv.v1alpha.ReadFileResponse + (*WriteFileRequest)(nil), // 12: ateenv.v1alpha.WriteFileRequest + (*WriteFileResponse)(nil), // 13: ateenv.v1alpha.WriteFileResponse + nil, // 14: ateenv.v1alpha.StartProcessRequest.EnvEntry + (*timestamppb.Timestamp)(nil), // 15: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 16: google.protobuf.Duration +} +var file_ateenv_v1alpha_guest_proto_depIdxs = []int32{ + 0, // 0: ateenv.v1alpha.Process.state:type_name -> ateenv.v1alpha.ProcessState 15, // 1: ateenv.v1alpha.Process.started_at:type_name -> google.protobuf.Timestamp 15, // 2: ateenv.v1alpha.Process.finished_at:type_name -> google.protobuf.Timestamp 14, // 3: ateenv.v1alpha.StartProcessRequest.env:type_name -> ateenv.v1alpha.StartProcessRequest.EnvEntry - 1, // 4: ateenv.v1alpha.OutputChunk.source:type_name -> ateenv.v1alpha.OutputSource - 3, // 5: ateenv.v1alpha.ProcessService.StartProcess:input_type -> ateenv.v1alpha.StartProcessRequest - 5, // 6: ateenv.v1alpha.ProcessService.GetProcess:input_type -> ateenv.v1alpha.GetProcessRequest - 6, // 7: ateenv.v1alpha.ProcessService.StreamProcessOutputs:input_type -> ateenv.v1alpha.StreamProcessOutputsRequest - 8, // 8: ateenv.v1alpha.ProcessService.KillProcess:input_type -> ateenv.v1alpha.KillProcessRequest - 10, // 9: ateenv.v1alpha.FileSystemService.ReadFile:input_type -> ateenv.v1alpha.ReadFileRequest - 12, // 10: ateenv.v1alpha.FileSystemService.WriteFile:input_type -> ateenv.v1alpha.WriteFileRequest - 4, // 11: ateenv.v1alpha.ProcessService.StartProcess:output_type -> ateenv.v1alpha.StartProcessResponse - 2, // 12: ateenv.v1alpha.ProcessService.GetProcess:output_type -> ateenv.v1alpha.Process - 7, // 13: ateenv.v1alpha.ProcessService.StreamProcessOutputs:output_type -> ateenv.v1alpha.OutputChunk - 9, // 14: ateenv.v1alpha.ProcessService.KillProcess:output_type -> ateenv.v1alpha.KillProcessResponse - 11, // 15: ateenv.v1alpha.FileSystemService.ReadFile:output_type -> ateenv.v1alpha.FileChunk - 13, // 16: ateenv.v1alpha.FileSystemService.WriteFile:output_type -> ateenv.v1alpha.WriteFileResponse - 11, // [11:17] is the sub-list for method output_type - 5, // [5:11] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name -} - -func init() { file_proto_ateenv_v1alpha_guest_proto_init() } -func file_proto_ateenv_v1alpha_guest_proto_init() { - if File_proto_ateenv_v1alpha_guest_proto != nil { + 16, // 4: ateenv.v1alpha.StartProcessRequest.timeout:type_name -> google.protobuf.Duration + 2, // 5: ateenv.v1alpha.ProcessOutput.exit:type_name -> ateenv.v1alpha.Process + 1, // 6: ateenv.v1alpha.SignalProcessRequest.signal:type_name -> ateenv.v1alpha.Signal + 3, // 7: ateenv.v1alpha.ProcessService.StartProcess:input_type -> ateenv.v1alpha.StartProcessRequest + 4, // 8: ateenv.v1alpha.ProcessService.GetProcess:input_type -> ateenv.v1alpha.GetProcessRequest + 5, // 9: ateenv.v1alpha.ProcessService.StreamProcessOutput:input_type -> ateenv.v1alpha.StreamProcessOutputRequest + 7, // 10: ateenv.v1alpha.ProcessService.WriteProcessInput:input_type -> ateenv.v1alpha.WriteProcessInputRequest + 9, // 11: ateenv.v1alpha.ProcessService.SignalProcess:input_type -> ateenv.v1alpha.SignalProcessRequest + 10, // 12: ateenv.v1alpha.FileSystemService.ReadFile:input_type -> ateenv.v1alpha.ReadFileRequest + 12, // 13: ateenv.v1alpha.FileSystemService.WriteFile:input_type -> ateenv.v1alpha.WriteFileRequest + 2, // 14: ateenv.v1alpha.ProcessService.StartProcess:output_type -> ateenv.v1alpha.Process + 2, // 15: ateenv.v1alpha.ProcessService.GetProcess:output_type -> ateenv.v1alpha.Process + 6, // 16: ateenv.v1alpha.ProcessService.StreamProcessOutput:output_type -> ateenv.v1alpha.ProcessOutput + 8, // 17: ateenv.v1alpha.ProcessService.WriteProcessInput:output_type -> ateenv.v1alpha.WriteProcessInputResponse + 2, // 18: ateenv.v1alpha.ProcessService.SignalProcess:output_type -> ateenv.v1alpha.Process + 11, // 19: ateenv.v1alpha.FileSystemService.ReadFile:output_type -> ateenv.v1alpha.ReadFileResponse + 13, // 20: ateenv.v1alpha.FileSystemService.WriteFile:output_type -> ateenv.v1alpha.WriteFileResponse + 14, // [14:21] is the sub-list for method output_type + 7, // [7:14] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name +} + +func init() { file_ateenv_v1alpha_guest_proto_init() } +func file_ateenv_v1alpha_guest_proto_init() { + if File_ateenv_v1alpha_guest_proto != nil { return } + file_ateenv_v1alpha_guest_proto_msgTypes[4].OneofWrappers = []any{ + (*ProcessOutput_Stdout)(nil), + (*ProcessOutput_Stderr)(nil), + (*ProcessOutput_Exit)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_ateenv_v1alpha_guest_proto_rawDesc), len(file_proto_ateenv_v1alpha_guest_proto_rawDesc)), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_ateenv_v1alpha_guest_proto_rawDesc), len(file_ateenv_v1alpha_guest_proto_rawDesc)), NumEnums: 2, NumMessages: 13, NumExtensions: 0, NumServices: 2, }, - GoTypes: file_proto_ateenv_v1alpha_guest_proto_goTypes, - DependencyIndexes: file_proto_ateenv_v1alpha_guest_proto_depIdxs, - EnumInfos: file_proto_ateenv_v1alpha_guest_proto_enumTypes, - MessageInfos: file_proto_ateenv_v1alpha_guest_proto_msgTypes, + GoTypes: file_ateenv_v1alpha_guest_proto_goTypes, + DependencyIndexes: file_ateenv_v1alpha_guest_proto_depIdxs, + EnumInfos: file_ateenv_v1alpha_guest_proto_enumTypes, + MessageInfos: file_ateenv_v1alpha_guest_proto_msgTypes, }.Build() - File_proto_ateenv_v1alpha_guest_proto = out.File - file_proto_ateenv_v1alpha_guest_proto_goTypes = nil - file_proto_ateenv_v1alpha_guest_proto_depIdxs = nil + File_ateenv_v1alpha_guest_proto = out.File + file_ateenv_v1alpha_guest_proto_goTypes = nil + file_ateenv_v1alpha_guest_proto_depIdxs = nil } diff --git a/proto/ateenv/v1alpha/guest.proto b/proto/ateenv/v1alpha/guest.proto index b979b7a..5ded286 100644 --- a/proto/ateenv/v1alpha/guest.proto +++ b/proto/ateenv/v1alpha/guest.proto @@ -15,7 +15,8 @@ // Copyright 2026 The Agent Substrate Authors. // // Minimal Protocol Buffers definition for the Agent Substrate Guest Data Plane. -// Exposes asynchronous process execution, process output streaming, and streaming file I/O. +// Exposes asynchronous process execution with streaming I/O and signals, and +// streaming file I/O. syntax = "proto3"; @@ -23,34 +24,51 @@ package ateenv.v1alpha; option go_package = "github.com/agent-substrate/env/proto/ateenv/v1alpha;ateenvv1alpha"; +import "google/protobuf/duration.proto"; import "google/protobuf/timestamp.proto"; // ============================================================================ // --- SERVICES --- // ============================================================================ -// ProcessService manages the asynchronous lifecycle and output streaming of -// processes running inside the container. +// ProcessService manages the lifecycle and I/O of processes running inside +// the container. +// +// A process is started asynchronously and addressed by its process_id. Output +// is spooled by the guest and can be streamed live or replayed from an offset. +// Input is fed through a client stream, and any POSIX signal can be delivered +// to the process group. service ProcessService { - // StartProcess launches a long-running process asynchronously in the background - // and immediately returns a unique process_id for tracking. - rpc StartProcess(StartProcessRequest) returns (StartProcessResponse); + // StartProcess launches a process in the background and returns its + // Process resource immediately. + rpc StartProcess(StartProcessRequest) returns (Process); - // GetProcess retrieves the current metadata, lifecycle state, and timestamps of a process. + // GetProcess returns the current state of a process. rpc GetProcess(GetProcessRequest) returns (Process); - // StreamProcessOutputs streams real-time stdout and stderr from a process. - rpc StreamProcessOutputs(StreamProcessOutputsRequest) returns (stream OutputChunk); + // StreamProcessOutput streams stdout and stderr from a process. Once the + // process has exited and all output has been delivered, the stream ends + // with a final exit message carrying the finished Process. Following with + // no interest in output (offsets past the end of the spool) is the way to + // wait for a process to exit. + rpc StreamProcessOutput(StreamProcessOutputRequest) returns (stream ProcessOutput); + + // WriteProcessInput feeds bytes to the stdin of a process started with + // stdin enabled. Multiple calls may be made over the life of a process; + // stdin stays open until a message sets close, or the process exits. + rpc WriteProcessInput(stream WriteProcessInputRequest) returns (WriteProcessInputResponse); - // KillProcess terminates a running asynchronous process and its child process tree. - rpc KillProcess(KillProcessRequest) returns (KillProcessResponse); + // SignalProcess delivers a signal to the process and its process group and + // returns the process state right after delivery. Use StreamProcessOutput + // to observe the effect. + rpc SignalProcess(SignalProcessRequest) returns (Process); } // FileSystemService provides streaming file reading and writing capabilities inside // the container rootfs/workspace to prevent memory exhaustion (OOM). service FileSystemService { // ReadFile streams the binary or text contents of a file in chunks. - rpc ReadFile(ReadFileRequest) returns (stream FileChunk); + rpc ReadFile(ReadFileRequest) returns (stream ReadFileResponse); // WriteFile streams binary or text chunks directly to a target file. rpc WriteFile(stream WriteFileRequest) returns (WriteFileResponse); @@ -60,94 +78,143 @@ service FileSystemService { // --- PROCESS SERVICE MESSAGES --- // ============================================================================ -// Execution status of an asynchronous process. -enum ProcessStatus { - PROCESS_STATUS_UNSPECIFIED = 0; - // Process is actively running in the background. - PROCESS_STATUS_RUNNING = 1; - // Process completed successfully with exit code 0. - PROCESS_STATUS_COMPLETED = 2; - // Process completed with a non-zero exit code or failed to run. - PROCESS_STATUS_FAILED = 3; - // Process was killed before completion. - PROCESS_STATUS_TERMINATED = 4; +// Lifecycle state of a process. +enum ProcessState { + PROCESS_STATE_UNSPECIFIED = 0; + // The process is running (or stopped by SIGSTOP) and has not been reaped. + PROCESS_STATE_RUNNING = 1; + // The process has exited; see Process.exit_code. + PROCESS_STATE_EXITED = 2; } -// Output stream source. -enum OutputSource { - OUTPUT_SOURCE_UNSPECIFIED = 0; - OUTPUT_SOURCE_STDOUT = 1; - OUTPUT_SOURCE_STDERR = 2; +// POSIX signals that can be sent to a process. Values match the Linux signal +// numbers on x86/arm. +enum Signal { + SIGNAL_UNSPECIFIED = 0; + SIGNAL_HUP = 1; + SIGNAL_INT = 2; + SIGNAL_QUIT = 3; + SIGNAL_ILL = 4; + SIGNAL_TRAP = 5; + SIGNAL_ABRT = 6; + SIGNAL_BUS = 7; + SIGNAL_FPE = 8; + SIGNAL_KILL = 9; + SIGNAL_USR1 = 10; + SIGNAL_SEGV = 11; + SIGNAL_USR2 = 12; + SIGNAL_PIPE = 13; + SIGNAL_ALRM = 14; + SIGNAL_TERM = 15; + SIGNAL_CHLD = 17; + SIGNAL_CONT = 18; + SIGNAL_STOP = 19; + SIGNAL_TSTP = 20; + SIGNAL_TTIN = 21; + SIGNAL_TTOU = 22; + SIGNAL_URG = 23; + SIGNAL_XCPU = 24; + SIGNAL_XFSZ = 25; + SIGNAL_VTALRM = 26; + SIGNAL_PROF = 27; + SIGNAL_WINCH = 28; + SIGNAL_IO = 29; + SIGNAL_SYS = 31; } -// The Process resource representing execution state and metadata. +// The Process resource: identity, lifecycle state, and exit status. message Process { - // Unique process identifier. + // Unique process identifier assigned by the guest. string process_id = 1; - // Current execution lifecycle state. - ProcessStatus status = 2; - // Process exit status code (0 for success, 1-127 for program exit code, - // 128 + signal number if terminated by signal, e.g. 137 for SIGKILL, 143 for SIGTERM). - // Valid once status is COMPLETED, FAILED, or TERMINATED. - int32 exit_code = 3; + // Command binary and arguments the process was started with. + repeated string command = 2; + // Operating system PID inside the container. + int32 pid = 3; + // Current lifecycle state. + ProcessState state = 4; + // Exit status, valid once state is EXITED: the process's exit code, or + // 128 + signal number if it was terminated by a signal (e.g. 137 for + // SIGKILL, 143 for SIGTERM). + int32 exit_code = 5; // Timestamp when the process started. - google.protobuf.Timestamp started_at = 4; - // Timestamp when the process terminated (if finished). - google.protobuf.Timestamp finished_at = 5; + google.protobuf.Timestamp started_at = 6; + // Timestamp when the process exited. Unset while RUNNING. + google.protobuf.Timestamp finished_at = 7; } -// Request to start a background asynchronous process. +// Request to start a process. message StartProcessRequest { // Command binary and arguments to execute (e.g. ["pytest", "tests/"] or ["sh", "-c", "ls -la"]). repeated string command = 1; - // Working directory inside the container (defaults to container workdir). + // Working directory inside the container (defaults to the guest workspace). string cwd = 2; - // Environment variables to set for the background process. + // Environment variables to set for the process, on top of the guest's environment. map env = 3; + // If true, stdin is an open pipe fed by WriteProcessInput. If false, stdin + // reads as empty (/dev/null) and WriteProcessInput is rejected. + bool stdin = 4; + // Maximum wall-clock run time. On expiry the process group receives SIGKILL. + // Unset or zero applies the guest's default timeout. + google.protobuf.Duration timeout = 5; } -// Response returned immediately after launching an asynchronous process. -message StartProcessResponse { - // Unique process identifier used for status inspection, log streaming, and killing. - string process_id = 1; -} - -// Request to get the Process resource. +// Request to get a Process. message GetProcessRequest { // Identifier of the process to inspect. string process_id = 1; } // Request to stream output from a process. -message StreamProcessOutputsRequest { +message StreamProcessOutputRequest { // Identifier of the process to stream output from. string process_id = 1; // Byte offset to start reading stdout from (defaults to 0 / beginning). + // Bytes before the offset are never delivered, so an offset past the end + // of the spool suppresses stdout entirely. int64 stdout_offset = 2; // Byte offset to start reading stderr from (defaults to 0 / beginning). + // Same semantics as stdout_offset. int64 stderr_offset = 3; - // If true, the stream follows new output in real-time until the process finishes. + // If true, the stream stays open and delivers new output until the process + // exits. If false, the output spooled so far is returned and the stream ends. bool follow = 4; } -// Streamed chunk of process output. -message OutputChunk { - // Stream source (stdout or stderr). - OutputSource source = 1; - // Output content bytes. - bytes data = 2; +// One message on a process output stream. Exactly one field is set. +message ProcessOutput { + oneof output { + // A chunk of standard output. + bytes stdout = 1; + // A chunk of standard error. + bytes stderr = 2; + // Final message: the process has exited and all output has been delivered. + // Absent if the stream ends while the process is still running (follow=false). + Process exit = 3; + } } -// Request to terminate a running asynchronous process. -message KillProcessRequest { - // Identifier of the background process to terminate. +// One chunk of standard input for a process. +message WriteProcessInputRequest { + // Identifier of the target process. Required on the first message; ignored afterwards. string process_id = 1; + // Bytes to write to stdin. May be empty, e.g. to only close stdin. + bytes data = 2; + // If true, stdin is closed (EOF) after data is written. No further input is accepted. + bool close = 3; } -// Response from terminating a process. -message KillProcessResponse { - // Exit status code after process termination (typically 128 + signal, e.g. 137 for SIGKILL). - int32 exit_code = 1; +// Response confirming the input write. +message WriteProcessInputResponse { + // Total number of bytes written to stdin across all messages in the stream. + int64 bytes_written = 1; +} + +// Request to send a signal to a process. +message SignalProcessRequest { + // Identifier of the target process. + string process_id = 1; + // Signal to deliver to the process group. + Signal signal = 2; } // ============================================================================ @@ -158,22 +225,36 @@ message KillProcessResponse { message ReadFileRequest { // Absolute or workspace-relative path of the file to read. string path = 1; + // Optional POSIX file mode permission (e.g. 0644). When set and the file + // does not exist, it is created empty with this mode (parent directories + // included) and the stream ends without chunks. When zero, a missing file + // is a NOT_FOUND error. Existing files are never modified. + uint32 mode = 2; } // Streamed chunk of file data. -message FileChunk { +message ReadFileResponse { // Raw binary or text chunk of the file. - bytes data = 1; + bytes chunk = 1; } -// Streamed request chunk for writing data to a file. +// Streamed request chunk for writing data to a file. path, mode and +// seek_offset are read from the first message only and ignored on all +// subsequent messages; every message may carry a chunk. message WriteFileRequest { - // Absolute or workspace-relative path of the file to write (sent on first message). + // Absolute or workspace-relative path of the file to write. Required on the + // first message. string path = 1; + // Optional POSIX file mode permission (e.g. 0644 or 0755), applied when the + // file is created. + uint32 mode = 2; + // Byte offset at which writing starts. Zero, the default, truncates the + // file and rewrites it from the beginning. A positive offset keeps existing + // content, begins writing at the offset (extending the file with zero bytes + // if it is shorter), and leaves bytes after the written range in place. + int64 seek_offset = 3; // Raw binary or text chunk to write to the file. - bytes chunk = 2; - // Optional POSIX file mode permission (e.g. 0644 or 0755; processed on first message). - uint32 mode = 3; + bytes chunk = 4; } // Response confirming the write operation. diff --git a/proto/ateenv/v1alpha/guest_grpc.pb.go b/proto/ateenv/v1alpha/guest_grpc.pb.go index 88af382..9919917 100644 --- a/proto/ateenv/v1alpha/guest_grpc.pb.go +++ b/proto/ateenv/v1alpha/guest_grpc.pb.go @@ -15,13 +15,14 @@ // Copyright 2026 The Agent Substrate Authors. // // Minimal Protocol Buffers definition for the Agent Substrate Guest Data Plane. -// Exposes asynchronous process execution, process output streaming, and streaming file I/O. +// Exposes asynchronous process execution with streaming I/O and signals, and +// streaming file I/O. // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.5.1 -// - protoc v5.28.2 -// source: proto/ateenv/v1alpha/guest.proto +// - protoc-gen-go-grpc v1.6.0 +// - protoc v7.34.1 +// source: ateenv/v1alpha/guest.proto package ateenvv1alpha @@ -38,28 +39,44 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - ProcessService_StartProcess_FullMethodName = "/ateenv.v1alpha.ProcessService/StartProcess" - ProcessService_GetProcess_FullMethodName = "/ateenv.v1alpha.ProcessService/GetProcess" - ProcessService_StreamProcessOutputs_FullMethodName = "/ateenv.v1alpha.ProcessService/StreamProcessOutputs" - ProcessService_KillProcess_FullMethodName = "/ateenv.v1alpha.ProcessService/KillProcess" + ProcessService_StartProcess_FullMethodName = "/ateenv.v1alpha.ProcessService/StartProcess" + ProcessService_GetProcess_FullMethodName = "/ateenv.v1alpha.ProcessService/GetProcess" + ProcessService_StreamProcessOutput_FullMethodName = "/ateenv.v1alpha.ProcessService/StreamProcessOutput" + ProcessService_WriteProcessInput_FullMethodName = "/ateenv.v1alpha.ProcessService/WriteProcessInput" + ProcessService_SignalProcess_FullMethodName = "/ateenv.v1alpha.ProcessService/SignalProcess" ) // ProcessServiceClient is the client API for ProcessService service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. // -// ProcessService manages the asynchronous lifecycle and output streaming of -// processes running inside the container. +// ProcessService manages the lifecycle and I/O of processes running inside +// the container. +// +// A process is started asynchronously and addressed by its process_id. Output +// is spooled by the guest and can be streamed live or replayed from an offset. +// Input is fed through a client stream, and any POSIX signal can be delivered +// to the process group. type ProcessServiceClient interface { - // StartProcess launches a long-running process asynchronously in the background - // and immediately returns a unique process_id for tracking. - StartProcess(ctx context.Context, in *StartProcessRequest, opts ...grpc.CallOption) (*StartProcessResponse, error) - // GetProcess retrieves the current metadata, lifecycle state, and timestamps of a process. + // StartProcess launches a process in the background and returns its + // Process resource immediately. + StartProcess(ctx context.Context, in *StartProcessRequest, opts ...grpc.CallOption) (*Process, error) + // GetProcess returns the current state of a process. GetProcess(ctx context.Context, in *GetProcessRequest, opts ...grpc.CallOption) (*Process, error) - // StreamProcessOutputs streams real-time stdout and stderr from a process. - StreamProcessOutputs(ctx context.Context, in *StreamProcessOutputsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[OutputChunk], error) - // KillProcess terminates a running asynchronous process and its child process tree. - KillProcess(ctx context.Context, in *KillProcessRequest, opts ...grpc.CallOption) (*KillProcessResponse, error) + // StreamProcessOutput streams stdout and stderr from a process. Once the + // process has exited and all output has been delivered, the stream ends + // with a final exit message carrying the finished Process. Following with + // no interest in output (offsets past the end of the spool) is the way to + // wait for a process to exit. + StreamProcessOutput(ctx context.Context, in *StreamProcessOutputRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ProcessOutput], error) + // WriteProcessInput feeds bytes to the stdin of a process started with + // stdin enabled. Multiple calls may be made over the life of a process; + // stdin stays open until a message sets close, or the process exits. + WriteProcessInput(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[WriteProcessInputRequest, WriteProcessInputResponse], error) + // SignalProcess delivers a signal to the process and its process group and + // returns the process state right after delivery. Use StreamProcessOutput + // to observe the effect. + SignalProcess(ctx context.Context, in *SignalProcessRequest, opts ...grpc.CallOption) (*Process, error) } type processServiceClient struct { @@ -70,9 +87,9 @@ func NewProcessServiceClient(cc grpc.ClientConnInterface) ProcessServiceClient { return &processServiceClient{cc} } -func (c *processServiceClient) StartProcess(ctx context.Context, in *StartProcessRequest, opts ...grpc.CallOption) (*StartProcessResponse, error) { +func (c *processServiceClient) StartProcess(ctx context.Context, in *StartProcessRequest, opts ...grpc.CallOption) (*Process, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(StartProcessResponse) + out := new(Process) err := c.cc.Invoke(ctx, ProcessService_StartProcess_FullMethodName, in, out, cOpts...) if err != nil { return nil, err @@ -90,13 +107,13 @@ func (c *processServiceClient) GetProcess(ctx context.Context, in *GetProcessReq return out, nil } -func (c *processServiceClient) StreamProcessOutputs(ctx context.Context, in *StreamProcessOutputsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[OutputChunk], error) { +func (c *processServiceClient) StreamProcessOutput(ctx context.Context, in *StreamProcessOutputRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ProcessOutput], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &ProcessService_ServiceDesc.Streams[0], ProcessService_StreamProcessOutputs_FullMethodName, cOpts...) + stream, err := c.cc.NewStream(ctx, &ProcessService_ServiceDesc.Streams[0], ProcessService_StreamProcessOutput_FullMethodName, cOpts...) if err != nil { return nil, err } - x := &grpc.GenericClientStream[StreamProcessOutputsRequest, OutputChunk]{ClientStream: stream} + x := &grpc.GenericClientStream[StreamProcessOutputRequest, ProcessOutput]{ClientStream: stream} if err := x.ClientStream.SendMsg(in); err != nil { return nil, err } @@ -107,12 +124,25 @@ func (c *processServiceClient) StreamProcessOutputs(ctx context.Context, in *Str } // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type ProcessService_StreamProcessOutputsClient = grpc.ServerStreamingClient[OutputChunk] +type ProcessService_StreamProcessOutputClient = grpc.ServerStreamingClient[ProcessOutput] -func (c *processServiceClient) KillProcess(ctx context.Context, in *KillProcessRequest, opts ...grpc.CallOption) (*KillProcessResponse, error) { +func (c *processServiceClient) WriteProcessInput(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[WriteProcessInputRequest, WriteProcessInputResponse], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(KillProcessResponse) - err := c.cc.Invoke(ctx, ProcessService_KillProcess_FullMethodName, in, out, cOpts...) + stream, err := c.cc.NewStream(ctx, &ProcessService_ServiceDesc.Streams[1], ProcessService_WriteProcessInput_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[WriteProcessInputRequest, WriteProcessInputResponse]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ProcessService_WriteProcessInputClient = grpc.ClientStreamingClient[WriteProcessInputRequest, WriteProcessInputResponse] + +func (c *processServiceClient) SignalProcess(ctx context.Context, in *SignalProcessRequest, opts ...grpc.CallOption) (*Process, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Process) + err := c.cc.Invoke(ctx, ProcessService_SignalProcess_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -123,18 +153,33 @@ func (c *processServiceClient) KillProcess(ctx context.Context, in *KillProcessR // All implementations must embed UnimplementedProcessServiceServer // for forward compatibility. // -// ProcessService manages the asynchronous lifecycle and output streaming of -// processes running inside the container. +// ProcessService manages the lifecycle and I/O of processes running inside +// the container. +// +// A process is started asynchronously and addressed by its process_id. Output +// is spooled by the guest and can be streamed live or replayed from an offset. +// Input is fed through a client stream, and any POSIX signal can be delivered +// to the process group. type ProcessServiceServer interface { - // StartProcess launches a long-running process asynchronously in the background - // and immediately returns a unique process_id for tracking. - StartProcess(context.Context, *StartProcessRequest) (*StartProcessResponse, error) - // GetProcess retrieves the current metadata, lifecycle state, and timestamps of a process. + // StartProcess launches a process in the background and returns its + // Process resource immediately. + StartProcess(context.Context, *StartProcessRequest) (*Process, error) + // GetProcess returns the current state of a process. GetProcess(context.Context, *GetProcessRequest) (*Process, error) - // StreamProcessOutputs streams real-time stdout and stderr from a process. - StreamProcessOutputs(*StreamProcessOutputsRequest, grpc.ServerStreamingServer[OutputChunk]) error - // KillProcess terminates a running asynchronous process and its child process tree. - KillProcess(context.Context, *KillProcessRequest) (*KillProcessResponse, error) + // StreamProcessOutput streams stdout and stderr from a process. Once the + // process has exited and all output has been delivered, the stream ends + // with a final exit message carrying the finished Process. Following with + // no interest in output (offsets past the end of the spool) is the way to + // wait for a process to exit. + StreamProcessOutput(*StreamProcessOutputRequest, grpc.ServerStreamingServer[ProcessOutput]) error + // WriteProcessInput feeds bytes to the stdin of a process started with + // stdin enabled. Multiple calls may be made over the life of a process; + // stdin stays open until a message sets close, or the process exits. + WriteProcessInput(grpc.ClientStreamingServer[WriteProcessInputRequest, WriteProcessInputResponse]) error + // SignalProcess delivers a signal to the process and its process group and + // returns the process state right after delivery. Use StreamProcessOutput + // to observe the effect. + SignalProcess(context.Context, *SignalProcessRequest) (*Process, error) mustEmbedUnimplementedProcessServiceServer() } @@ -145,17 +190,20 @@ type ProcessServiceServer interface { // pointer dereference when methods are called. type UnimplementedProcessServiceServer struct{} -func (UnimplementedProcessServiceServer) StartProcess(context.Context, *StartProcessRequest) (*StartProcessResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method StartProcess not implemented") +func (UnimplementedProcessServiceServer) StartProcess(context.Context, *StartProcessRequest) (*Process, error) { + return nil, status.Error(codes.Unimplemented, "method StartProcess not implemented") } func (UnimplementedProcessServiceServer) GetProcess(context.Context, *GetProcessRequest) (*Process, error) { - return nil, status.Errorf(codes.Unimplemented, "method GetProcess not implemented") + return nil, status.Error(codes.Unimplemented, "method GetProcess not implemented") +} +func (UnimplementedProcessServiceServer) StreamProcessOutput(*StreamProcessOutputRequest, grpc.ServerStreamingServer[ProcessOutput]) error { + return status.Error(codes.Unimplemented, "method StreamProcessOutput not implemented") } -func (UnimplementedProcessServiceServer) StreamProcessOutputs(*StreamProcessOutputsRequest, grpc.ServerStreamingServer[OutputChunk]) error { - return status.Errorf(codes.Unimplemented, "method StreamProcessOutputs not implemented") +func (UnimplementedProcessServiceServer) WriteProcessInput(grpc.ClientStreamingServer[WriteProcessInputRequest, WriteProcessInputResponse]) error { + return status.Error(codes.Unimplemented, "method WriteProcessInput not implemented") } -func (UnimplementedProcessServiceServer) KillProcess(context.Context, *KillProcessRequest) (*KillProcessResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method KillProcess not implemented") +func (UnimplementedProcessServiceServer) SignalProcess(context.Context, *SignalProcessRequest) (*Process, error) { + return nil, status.Error(codes.Unimplemented, "method SignalProcess not implemented") } func (UnimplementedProcessServiceServer) mustEmbedUnimplementedProcessServiceServer() {} func (UnimplementedProcessServiceServer) testEmbeddedByValue() {} @@ -168,7 +216,7 @@ type UnsafeProcessServiceServer interface { } func RegisterProcessServiceServer(s grpc.ServiceRegistrar, srv ProcessServiceServer) { - // If the following call pancis, it indicates UnimplementedProcessServiceServer was + // If the following call panics, it indicates UnimplementedProcessServiceServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. @@ -214,31 +262,38 @@ func _ProcessService_GetProcess_Handler(srv interface{}, ctx context.Context, de return interceptor(ctx, in, info, handler) } -func _ProcessService_StreamProcessOutputs_Handler(srv interface{}, stream grpc.ServerStream) error { - m := new(StreamProcessOutputsRequest) +func _ProcessService_StreamProcessOutput_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(StreamProcessOutputRequest) if err := stream.RecvMsg(m); err != nil { return err } - return srv.(ProcessServiceServer).StreamProcessOutputs(m, &grpc.GenericServerStream[StreamProcessOutputsRequest, OutputChunk]{ServerStream: stream}) + return srv.(ProcessServiceServer).StreamProcessOutput(m, &grpc.GenericServerStream[StreamProcessOutputRequest, ProcessOutput]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ProcessService_StreamProcessOutputServer = grpc.ServerStreamingServer[ProcessOutput] + +func _ProcessService_WriteProcessInput_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(ProcessServiceServer).WriteProcessInput(&grpc.GenericServerStream[WriteProcessInputRequest, WriteProcessInputResponse]{ServerStream: stream}) } // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type ProcessService_StreamProcessOutputsServer = grpc.ServerStreamingServer[OutputChunk] +type ProcessService_WriteProcessInputServer = grpc.ClientStreamingServer[WriteProcessInputRequest, WriteProcessInputResponse] -func _ProcessService_KillProcess_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(KillProcessRequest) +func _ProcessService_SignalProcess_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SignalProcessRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { - return srv.(ProcessServiceServer).KillProcess(ctx, in) + return srv.(ProcessServiceServer).SignalProcess(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: ProcessService_KillProcess_FullMethodName, + FullMethod: ProcessService_SignalProcess_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ProcessServiceServer).KillProcess(ctx, req.(*KillProcessRequest)) + return srv.(ProcessServiceServer).SignalProcess(ctx, req.(*SignalProcessRequest)) } return interceptor(ctx, in, info, handler) } @@ -259,18 +314,23 @@ var ProcessService_ServiceDesc = grpc.ServiceDesc{ Handler: _ProcessService_GetProcess_Handler, }, { - MethodName: "KillProcess", - Handler: _ProcessService_KillProcess_Handler, + MethodName: "SignalProcess", + Handler: _ProcessService_SignalProcess_Handler, }, }, Streams: []grpc.StreamDesc{ { - StreamName: "StreamProcessOutputs", - Handler: _ProcessService_StreamProcessOutputs_Handler, + StreamName: "StreamProcessOutput", + Handler: _ProcessService_StreamProcessOutput_Handler, ServerStreams: true, }, + { + StreamName: "WriteProcessInput", + Handler: _ProcessService_WriteProcessInput_Handler, + ClientStreams: true, + }, }, - Metadata: "proto/ateenv/v1alpha/guest.proto", + Metadata: "ateenv/v1alpha/guest.proto", } const ( @@ -286,7 +346,7 @@ const ( // the container rootfs/workspace to prevent memory exhaustion (OOM). type FileSystemServiceClient interface { // ReadFile streams the binary or text contents of a file in chunks. - ReadFile(ctx context.Context, in *ReadFileRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[FileChunk], error) + ReadFile(ctx context.Context, in *ReadFileRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ReadFileResponse], error) // WriteFile streams binary or text chunks directly to a target file. WriteFile(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[WriteFileRequest, WriteFileResponse], error) } @@ -299,13 +359,13 @@ func NewFileSystemServiceClient(cc grpc.ClientConnInterface) FileSystemServiceCl return &fileSystemServiceClient{cc} } -func (c *fileSystemServiceClient) ReadFile(ctx context.Context, in *ReadFileRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[FileChunk], error) { +func (c *fileSystemServiceClient) ReadFile(ctx context.Context, in *ReadFileRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ReadFileResponse], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) stream, err := c.cc.NewStream(ctx, &FileSystemService_ServiceDesc.Streams[0], FileSystemService_ReadFile_FullMethodName, cOpts...) if err != nil { return nil, err } - x := &grpc.GenericClientStream[ReadFileRequest, FileChunk]{ClientStream: stream} + x := &grpc.GenericClientStream[ReadFileRequest, ReadFileResponse]{ClientStream: stream} if err := x.ClientStream.SendMsg(in); err != nil { return nil, err } @@ -316,7 +376,7 @@ func (c *fileSystemServiceClient) ReadFile(ctx context.Context, in *ReadFileRequ } // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type FileSystemService_ReadFileClient = grpc.ServerStreamingClient[FileChunk] +type FileSystemService_ReadFileClient = grpc.ServerStreamingClient[ReadFileResponse] func (c *fileSystemServiceClient) WriteFile(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[WriteFileRequest, WriteFileResponse], error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) @@ -339,7 +399,7 @@ type FileSystemService_WriteFileClient = grpc.ClientStreamingClient[WriteFileReq // the container rootfs/workspace to prevent memory exhaustion (OOM). type FileSystemServiceServer interface { // ReadFile streams the binary or text contents of a file in chunks. - ReadFile(*ReadFileRequest, grpc.ServerStreamingServer[FileChunk]) error + ReadFile(*ReadFileRequest, grpc.ServerStreamingServer[ReadFileResponse]) error // WriteFile streams binary or text chunks directly to a target file. WriteFile(grpc.ClientStreamingServer[WriteFileRequest, WriteFileResponse]) error mustEmbedUnimplementedFileSystemServiceServer() @@ -352,11 +412,11 @@ type FileSystemServiceServer interface { // pointer dereference when methods are called. type UnimplementedFileSystemServiceServer struct{} -func (UnimplementedFileSystemServiceServer) ReadFile(*ReadFileRequest, grpc.ServerStreamingServer[FileChunk]) error { - return status.Errorf(codes.Unimplemented, "method ReadFile not implemented") +func (UnimplementedFileSystemServiceServer) ReadFile(*ReadFileRequest, grpc.ServerStreamingServer[ReadFileResponse]) error { + return status.Error(codes.Unimplemented, "method ReadFile not implemented") } func (UnimplementedFileSystemServiceServer) WriteFile(grpc.ClientStreamingServer[WriteFileRequest, WriteFileResponse]) error { - return status.Errorf(codes.Unimplemented, "method WriteFile not implemented") + return status.Error(codes.Unimplemented, "method WriteFile not implemented") } func (UnimplementedFileSystemServiceServer) mustEmbedUnimplementedFileSystemServiceServer() {} func (UnimplementedFileSystemServiceServer) testEmbeddedByValue() {} @@ -369,7 +429,7 @@ type UnsafeFileSystemServiceServer interface { } func RegisterFileSystemServiceServer(s grpc.ServiceRegistrar, srv FileSystemServiceServer) { - // If the following call pancis, it indicates UnimplementedFileSystemServiceServer was + // If the following call panics, it indicates UnimplementedFileSystemServiceServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. @@ -384,11 +444,11 @@ func _FileSystemService_ReadFile_Handler(srv interface{}, stream grpc.ServerStre if err := stream.RecvMsg(m); err != nil { return err } - return srv.(FileSystemServiceServer).ReadFile(m, &grpc.GenericServerStream[ReadFileRequest, FileChunk]{ServerStream: stream}) + return srv.(FileSystemServiceServer).ReadFile(m, &grpc.GenericServerStream[ReadFileRequest, ReadFileResponse]{ServerStream: stream}) } // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type FileSystemService_ReadFileServer = grpc.ServerStreamingServer[FileChunk] +type FileSystemService_ReadFileServer = grpc.ServerStreamingServer[ReadFileResponse] func _FileSystemService_WriteFile_Handler(srv interface{}, stream grpc.ServerStream) error { return srv.(FileSystemServiceServer).WriteFile(&grpc.GenericServerStream[WriteFileRequest, WriteFileResponse]{ServerStream: stream}) @@ -416,5 +476,5 @@ var FileSystemService_ServiceDesc = grpc.ServiceDesc{ ClientStreams: true, }, }, - Metadata: "proto/ateenv/v1alpha/guest.proto", + Metadata: "ateenv/v1alpha/guest.proto", }