From 4468a200b1702503ef9b84cf078e58ac517ad877 Mon Sep 17 00:00:00 2001 From: JBD Date: Fri, 11 Sep 2026 21:47:35 -0700 Subject: [PATCH] Redesign guest ProcessService and FileSystemService ProcessService is rebuilt around a five-RPC surface with streaming stdin, POSIX signals, and an output stream that carries the exit status: - StartProcess returns the full Process resource. The request gains `stdin` (open a pipe) and `timeout` (per-process SIGKILL deadline). - StreamProcessOutput streams a oneof of stdout/stderr chunks and ends with a final `exit` message carrying the finished Process, so clients no longer poll GetProcess for the exit code. Offsets past the end of the spool suppress output, which makes "follow with max offsets" the way to wait for exit without transferring data. - WriteProcessInput is client-streaming; stdin stays open across calls until a message sets `close`, enabling interactive use. - SignalProcess delivers any standard POSIX signal (new Signal enum) to the process group and replaces KillProcess. Process now carries command and pid, a two-state ProcessState (RUNNING/EXITED), and a single exit_code using the shell convention (128 + signal number when killed by a signal). The old COMPLETED/ FAILED/TERMINATED statuses are gone. Guest process tracker: return plain sentinel errors mapped to gRPC codes by the service layer, wake output streamers on write via a notifier instead of polling every 50ms, and map the Signal enum to host syscall signals by name so tests pass on non-Linux hosts. FileSystemService: ReadFile streams ReadFileResponse{chunk} instead of FileChunk{data}. ReadFileRequest gains `mode`: when set and the file is missing, the guest creates it empty with that mode (parent directories included) and ends the stream without chunks; when zero, a missing file remains NOT_FOUND; existing files are never modified. WriteFileRequest gains `seek_offset`: zero keeps the replace-the-file behaviour, a positive offset writes in place from that byte, zero-filling any gap and preserving bytes outside the written range; negative offsets are INVALID_ARGUMENT. Fields are reordered to path, mode, seek_offset, chunk, with path/mode/seek_offset read from the first message only. Update the API proxy, the MCP shell tool (new stdin and timeout_seconds params), the Go SDK (Process handle with Info/Wait/Output/Stdin/Signal/ Kill, Env.Run taking a ShellRequest, Env.WriteFileAt), the Python SDK (Process class, Signal/ProcessState enums, ProcessExitedError, write_file seek_offset), the CLI shell command (--stdin, --timeout), regenerated Go and Python stubs, and READMEs. Add server, SDK, fake, and e2e tests throughout. This is a breaking change to the alpha API. --- README.md | 16 +- clients/go/client.go | 13 +- clients/go/client_test.go | 172 ++++ clients/go/env.go | 124 ++- clients/go/process.go | 182 ++++ clients/go/types.go | 20 +- clients/python/README.md | 89 +- clients/python/examples/quickstart.py | 22 +- clients/python/src/ate_env/__init__.py | 19 +- .../ate_env/_gen/ateenv/v1alpha/env_pb2.py | 16 +- .../ate_env/_gen/ateenv/v1alpha/env_pb2.pyi | 29 +- .../_gen/ateenv/v1alpha/env_pb2_grpc.py | 99 +- .../ate_env/_gen/ateenv/v1alpha/guest_pb2.py | 89 +- .../ate_env/_gen/ateenv/v1alpha/guest_pb2.pyi | 220 ++-- .../_gen/ateenv/v1alpha/guest_pb2_grpc.py | 297 ++++-- clients/python/src/ate_env/env.py | 328 ++++-- clients/python/src/ate_env/errors.py | 14 + clients/python/src/ate_env/types.py | 101 +- clients/python/tests/e2e/test_full_stack.py | 33 +- clients/python/tests/e2e/test_guest_daemon.py | 169 +++- clients/python/tests/fakes.py | 169 +++- clients/python/tests/test_env_files.py | 14 + clients/python/tests/test_env_process.py | 290 ++++-- clients/python/tests/test_types.py | 60 +- cmd/ate-env/main.go | 22 +- examples/guest-daemon/README.md | 24 +- examples/guest-daemon/main_test.go | 44 +- examples/mcp/README.md | 6 +- guest/filesystem/service.go | 34 +- guest/filesystem/service_test.go | 132 ++- guest/process/service.go | 264 +++-- guest/process/service_test.go | 664 ++++++------ guest/process/signal.go | 76 ++ guest/process/tracker.go | 503 +++++---- internal/apiservice/server.go | 101 +- internal/apiservice/server_test.go | 89 +- internal/mcp/tools.go | 97 +- proto/ateenv/v1alpha/guest.pb.go | 957 +++++++++++------- proto/ateenv/v1alpha/guest.proto | 223 ++-- proto/ateenv/v1alpha/guest_grpc.pb.go | 202 ++-- 40 files changed, 4191 insertions(+), 1832 deletions(-) create mode 100644 clients/go/process.go create mode 100644 guest/process/signal.go 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", }