Skip to content

Commit

Permalink
runnerv2client: add client and "beta run --server" (#672)
Browse files Browse the repository at this point in the history
With `beta run --server` it's possible to execute a Markdown code block
on the server from the CLI.

This is a prerequisite for supporting the execution of code blocks in
the remote locations using the CLI, even if the remote location is a
well-defined sandbox Docker container running locally.

## Testing

First, start the server:

```
go run . beta server start
```

Next, execute a code block on the server using the CLI:

```
go run . beta run -s=local print-name
```

---------

Co-authored-by: Sebastian (Tiedtke) Huckleberry <sebastiantiedtke@gmail.com>
  • Loading branch information
adambabik and sourishkrout authored Nov 3, 2024
1 parent f238750 commit e7c0a6b
Show file tree
Hide file tree
Showing 24 changed files with 651 additions and 202 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"--proto_path=/usr/local/include/protoc"
]
},
"go.buildTags": "test_with_docker,test_with_txtar"
"go.buildTags": "test_with_docker,test_with_txtar",
"makefile.configureOnOpen": false
// Uncomment if you want to work on files in ./web.
// "go.buildTags": "js,wasm",
// Uncomment if you want to check compilation errors on Windows.
Expand Down
1 change: 0 additions & 1 deletion cmd/gqltool/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"github.com/stateful/runme/v3/internal/client/graphql"
)

// var tokenDir = flag.String("token-dir", cmd.GetUserConfigHome(), "The directory with tokens")
var apiURL = flag.String("api-url", "http://localhost:4000", "The API base address")

func init() {
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/beta/beta_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ All commands use the runme.yaml configuration file.`,

return nil
})

// Print the error to stderr but don't return it because error modes
// are neither fully baked yet nor ready for users to consume.
if err != nil {
Expand Down
127 changes: 106 additions & 21 deletions internal/cmd/beta/run_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,27 @@ package beta

import (
"context"
"io"
"os"

"github.com/creack/pty"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"go.uber.org/zap"

"github.com/stateful/runme/v3/internal/command"
"github.com/stateful/runme/v3/internal/config/autoconfig"
rcontext "github.com/stateful/runme/v3/internal/runner/context"
"github.com/stateful/runme/v3/internal/runnerv2client"
"github.com/stateful/runme/v3/internal/session"
runnerv2 "github.com/stateful/runme/v3/pkg/api/gen/proto/go/runme/runner/v2"
"github.com/stateful/runme/v3/pkg/document"
"github.com/stateful/runme/v3/pkg/project"
)

func runCmd(*commonFlags) *cobra.Command {
var remote bool

cmd := cobra.Command{
Use: "run [command1 command2 ...]",
Aliases: []string{"exec"},
Expand All @@ -36,6 +42,7 @@ Run all blocks from the "setup" and "teardown" tags:
RunE: func(cmd *cobra.Command, args []string) error {
return autoconfig.InvokeForCommand(
func(
clientFactory autoconfig.ClientFactory,
cmdFactory command.Factory,
filters []project.Filter,
logger *zap.Logger,
Expand Down Expand Up @@ -67,21 +74,57 @@ Run all blocks from the "setup" and "teardown" tags:
return errors.WithStack(err)
}

session, err := session.New(
session.WithOwl(false),
session.WithProject(proj),
session.WithSeedEnv(nil),
)
if err != nil {
return err
}
options := getCommandOptions(cmd, session)
ctx := cmd.Context()

if remote {
client, err := clientFactory()
if err != nil {
return err
}

sessionResp, err := client.CreateSession(
ctx,
&runnerv2.CreateSessionRequest{
Project: &runnerv2.Project{
Root: proj.Root(),
EnvLoadOrder: proj.EnvFilesReadOrder(),
},
},
)
if err != nil {
return errors.WithMessage(err, "failed to create session")
}

for _, t := range tasks {
err := runCodeBlock(cmd.Context(), t.CodeBlock, cmdFactory, options)
for _, t := range tasks {
err := runCodeBlockWithClient(
ctx,
cmd,
client,
t.CodeBlock,
sessionResp.GetSession().GetId(),
)
if err != nil {
return err
}
}
} else {
session, err := session.New(
session.WithOwl(false),
session.WithProject(proj),
session.WithSeedEnv(nil),
)
if err != nil {
return err
}

options := createCommandOptions(cmd, session)

for _, t := range tasks {
err := runCodeBlock(ctx, t.CodeBlock, cmdFactory, options)
if err != nil {
return err
}
}
}

return nil
Expand All @@ -90,10 +133,12 @@ Run all blocks from the "setup" and "teardown" tags:
},
}

cmd.Flags().BoolVarP(&remote, "remote", "r", false, "Run commands on a remote server.")

return &cmd
}

func getCommandOptions(
func createCommandOptions(
cmd *cobra.Command,
sess *session.Session,
) command.CommandOptions {
Expand All @@ -105,12 +150,7 @@ func getCommandOptions(
}
}

func runCodeBlock(
ctx context.Context,
block *document.CodeBlock,
factory command.Factory,
options command.CommandOptions,
) error {
func createProgramConfigFromCodeBlock(block *document.CodeBlock, opts ...command.ConfigBuilderOption) (*command.ProgramConfig, error) {
// TODO(adamb): [command.Config] is generated exclusively from the [document.CodeBlock].
// As we introduce some document- and block-related configs in runme.yaml (root but also nested),
// this [Command.Config] should be further extended.
Expand All @@ -121,7 +161,16 @@ func runCodeBlock(
// the last element of the returned config chain. Finally, [command.Config] should be updated.
// This algorithm should be likely encapsulated in the [internal/config] and [internal/command]
// packages.
cfg, err := command.NewProgramConfigFromCodeBlock(block)
return command.NewProgramConfigFromCodeBlock(block, opts...)
}

func runCodeBlock(
ctx context.Context,
block *document.CodeBlock,
factory command.Factory,
options command.CommandOptions,
) error {
cfg, err := createProgramConfigFromCodeBlock(block)
if err != nil {
return err
}
Expand All @@ -138,9 +187,45 @@ func runCodeBlock(
if err != nil {
return err
}
err = cmd.Start(ctx)
if err != nil {
if err := cmd.Start(ctx); err != nil {
return err
}
return cmd.Wait(ctx)
}

func runCodeBlockWithClient(
ctx context.Context,
cobraCommand *cobra.Command,
client *runnerv2client.Client,
block *document.CodeBlock,
sessionID string,
) error {
cfg, err := createProgramConfigFromCodeBlock(block, command.WithInteractiveLegacy())
if err != nil {
return err
}

opts := runnerv2client.ExecuteProgramOptions{
SessionID: sessionID,
Stdin: io.NopCloser(cobraCommand.InOrStdin()),
Stdout: cobraCommand.OutOrStdout(),
Stderr: cobraCommand.ErrOrStderr(),
StoreStdoutInEnv: true,
}

if stdin, ok := cobraCommand.InOrStdin().(*os.File); ok {
size, err := pty.GetsizeFull(stdin)
if err != nil {
return errors.WithMessage(err, "failed to get terminal size")
}

opts.Winsize = &runnerv2.Winsize{
Rows: uint32(size.Rows),
Cols: uint32(size.Cols),
X: uint32(size.X),
Y: uint32(size.Y),
}
}

return client.ExecuteProgram(ctx, cfg, opts)
}
20 changes: 14 additions & 6 deletions internal/command/command_terminal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ func TestTerminalCommand_EnvPropagation(t *testing.T) {

// Terminal command sets up a trap on EXIT.
// Wait for it before starting to send commands.
expectContainLines(t, stdout, []string{"trap -- \"__cleanup\" EXIT"})
expectContainLines(ctx, t, stdout, []string{"trap -- \"__cleanup\" EXIT"})

_, err = stdinW.Write([]byte("export TEST_ENV=1\n"))
require.NoError(t, err)
// Wait for the prompt before sending the next command.
expectContainLines(t, stdout, []string{"$"})
expectContainLines(ctx, t, stdout, []string{"$"})
_, err = stdinW.Write([]byte("exit\n"))
require.NoError(t, err)

Expand Down Expand Up @@ -96,15 +96,19 @@ func TestTerminalCommand_Intro(t *testing.T) {

require.NoError(t, cmd.Start(ctx))

expectContainLines(t, stdout, []string{envSourceCmd, introSecondLine})
expectContainLines(ctx, t, stdout, []string{envSourceCmd, introSecondLine})
}

func expectContainLines(t *testing.T, r io.Reader, expected []string) {
func expectContainLines(ctx context.Context, t *testing.T, r io.Reader, expected []string) {
t.Helper()
var output strings.Builder

hits := make(map[string]bool, len(expected))
output := new(strings.Builder)

for {
buf := new(bytes.Buffer)
r := io.TeeReader(r, buf)

scanner := bufio.NewScanner(r)
for scanner.Scan() {
_, _ = output.WriteString(scanner.Text())
Expand All @@ -121,7 +125,11 @@ func expectContainLines(t *testing.T, r io.Reader, expected []string) {
return
}

time.Sleep(time.Millisecond * 400)
select {
case <-time.After(100 * time.Millisecond):
case <-ctx.Done():
t.Fatalf("error waiting for line %q, instead read %q: %s", expected, buf.Bytes(), ctx.Err())
}
}
}

Expand Down
16 changes: 0 additions & 16 deletions internal/command/command_virtual.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"io"
"os"
"os/exec"
"reflect"
"sync"
"syscall"

Expand Down Expand Up @@ -224,21 +223,6 @@ func SetWinsize(cmd Command, winsize *Winsize) (err error) {
return errors.WithStack(err)
}

func isNil(val any) bool {
if val == nil {
return true
}

v := reflect.ValueOf(val)

switch v.Type().Kind() {
case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer, reflect.UnsafePointer:
return v.IsNil()
default:
return false
}
}

// readCloser wraps [io.Reader] into [io.ReadCloser].
//
// When Close is called, the underlying read operation is ignored.
Expand Down
2 changes: 1 addition & 1 deletion internal/command/command_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,4 @@ func disableEcho(uintptr) error {
"and join our Discord server at https://discord.gg/runme if you have further questions!")
}

func signalPgid(int, os.Signal) error { return errors.New("unsupported") }
func signalPgid(int, os.Signal) error { return errors.New("signalPgid: unsupported") }
31 changes: 27 additions & 4 deletions internal/command/config_code_block.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,43 @@ import (
"github.com/stateful/runme/v3/pkg/document"
)

func NewProgramConfigFromCodeBlock(block *document.CodeBlock) (*ProgramConfig, error) {
return (&configBuilder{block: block}).Build()
type ConfigBuilderOption func(*configBuilder) error

func WithInteractiveLegacy() ConfigBuilderOption {
return func(b *configBuilder) error {
b.useInteractiveLegacy = true
return nil
}
}

func NewProgramConfigFromCodeBlock(block *document.CodeBlock, opts ...ConfigBuilderOption) (*ProgramConfig, error) {
b := &configBuilder{block: block}

for _, opt := range opts {
if err := opt(b); err != nil {
return nil, err
}
}

return b.Build()
}

type configBuilder struct {
block *document.CodeBlock
block *document.CodeBlock
useInteractiveLegacy bool
}

func (b *configBuilder) Build() (*ProgramConfig, error) {
cfg := &ProgramConfig{
ProgramName: b.programPath(),
LanguageId: b.block.Language(),
Directory: b.dir(),
Interactive: b.block.Interactive(),
}

if b.useInteractiveLegacy {
cfg.Interactive = b.block.InteractiveLegacy()
} else {
cfg.Interactive = b.block.Interactive()
}

if isShell(cfg) {
Expand Down
16 changes: 16 additions & 0 deletions internal/command/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package command

import (
"io"
"reflect"

"go.uber.org/zap"

Expand Down Expand Up @@ -303,3 +304,18 @@ func (f *commandFactory) getLogger(name string) *zap.Logger {
id := ulid.GenerateID()
return f.logger.Named(name).With(zap.String("instanceID", id))
}

func isNil(val any) bool {
if val == nil {
return true
}

v := reflect.ValueOf(val)

switch v.Type().Kind() {
case reflect.Chan, reflect.Func, reflect.Map, reflect.Pointer, reflect.UnsafePointer:
return v.IsNil()
default:
return false
}
}
Loading

0 comments on commit e7c0a6b

Please sign in to comment.