Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experiment: Polyglot hooks #2040

Merged
merged 11 commits into from
May 22, 2023
3 changes: 3 additions & 0 deletions .buildkite/Dockerfile-compile
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
FROM golang:1.20.4@sha256:6876eff5b20336c5c2896b0c3055f3258bdeba7aa38bbdabcb5a4abb5cdd39c7
COPY build/ssh.conf /etc/ssh/ssh_config.d/
RUN go install github.com/google/go-licenses@latest

# Ruby used for polyglot hook integration tests
RUN apt update && apt install -y ruby
68 changes: 66 additions & 2 deletions bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"os"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -297,11 +298,74 @@ func (b *Bootstrap) executeHook(ctx context.Context, hookCfg HookConfig) error {

b.shell.Headerf("Running %s hook", hookName)

if !experiments.IsEnabled(experiments.PolyglotHooks) {
return b.runWrappedShellScriptHook(ctx, hookName, hookCfg)
}

hookType, err := hook.Type(hookCfg.Path)
if err != nil {
return fmt.Errorf("determining hook type for %q hook: %w", hookName, err)
}

switch hookType {
case hook.TypeScript:
if runtime.GOOS == "windows" {
// We use shebangs to figure out how to run scripts, and Windows has no way to interpret a shebang
// ie, on linux, we can just point the OS to a file of some sort and say "run that", and as part of that it will try to
// read a shebang, and run the script using the interpreter specified. Windows can't do this, so we can't run scripts
// directly on Windows

// Potentially there's something that we could do with file extensions, but that's a bit of a minefield, and would
// probably mean that we have to have a list of blessed hook runtimes on windows only... or something.

// Regardless: not supported right now, or potentially ever.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💯% reasonable to cut this from scope.

sheb, _ := shellscript.ShebangLine(hookCfg.Path) // we know this won't error because it must have a shebang to be a script

err := fmt.Sprintf(`when trying to run the hook at %q, the agent found that it was a script with a shebang that isn't for a shellscripting language - in this case, %q.
Hooks of this kind are unfortunately not supported on Windows, as we have no way of interpreting a shebang on Windows`, hookCfg.Path, sheb)
return fmt.Errorf(err)
moskyb marked this conversation as resolved.
Show resolved Hide resolved
}

// It's a script, and we can rely on the OS to figure out how to run it (because we're not on windows), so run it
// directly without wrapping
if err := b.runUnwrappedHook(ctx, hookName, hookCfg); err != nil {
return fmt.Errorf("running %q script hook: %w", hookName, err)
}

return nil
case hook.TypeBinary:
// It's a binary, so we'll just run it directly, no wrapping needed or possible
if err := b.runUnwrappedHook(ctx, hookName, hookCfg); err != nil {
return fmt.Errorf("running %q binary hook: %w", hookName, err)
}

return nil
case hook.TypeShell:
// It's definitely a shell script, wrap it so that we can snaffle the changed environment variables
if err := b.runWrappedShellScriptHook(ctx, hookName, hookCfg); err != nil {
return fmt.Errorf("running %q shell hook: %w", hookName, err)
}

return nil
default:
return fmt.Errorf("unknown hook type %q for %q hook", hookType, hookName)
}
}

func (b *Bootstrap) runUnwrappedHook(ctx context.Context, hookName string, hookCfg HookConfig) error {
environ := hookCfg.Env.Copy()

environ.Set("BUILDKITE_HOOK_PHASE", hookCfg.Name)
environ.Set("BUILDKITE_HOOK_PATH", hookCfg.Path)
environ.Set("BUILDKITE_HOOK_TRIGGERED_FROM", hookCfg.Scope)

return b.shell.RunWithEnv(ctx, environ, hookCfg.Path)
}

func (b *Bootstrap) runWrappedShellScriptHook(ctx context.Context, hookName string, hookCfg HookConfig) error {
redactors := b.setupRedactors()
defer redactors.Flush()

// We need a script to wrap the hook script so that we can snaffle the changed
// environment variables
script, err := hook.NewScriptWrapper(hook.WithHookPath(hookCfg.Path))
if err != nil {
b.shell.Errorf("Error creating hook script: %v", err)
Expand Down
18 changes: 3 additions & 15 deletions bootstrap/integration/checkout_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,9 @@ var commitPattern = bintest.MatchPattern(`(?ms)\Acommit [0-9a-f]+\nabbrev-commit
// We expect this arg multiple times, just define it once.
var gitShowFormatArg = "--format=commit %H%nabbrev-commit %h%nAuthor: %an <%ae>%n%n%w(0,4,4)%B"

// Enable an experiment, returning a function to restore the previous state.
// Usage: defer experimentWithUndo("foo")()
func experimentWithUndo(name string) func() {
prev := experiments.IsEnabled(name)
experiments.Enable(name)
return func() {
if !prev {
experiments.Disable(name)
}
}
}

func TestCheckingOutGitHubPullRequestsWithGitMirrorsExperiment(t *testing.T) {
// t.Parallel() cannot be used with experiments.Enable()
defer experimentWithUndo(experiments.GitMirrors)()
defer experiments.EnableWithUndo(experiments.GitMirrors)()

tester, err := NewBootstrapTester()
if err != nil {
Expand Down Expand Up @@ -81,7 +69,7 @@ func TestCheckingOutGitHubPullRequestsWithGitMirrorsExperiment(t *testing.T) {

func TestWithResolvingCommitExperiment(t *testing.T) {
// t.Parallel() cannot be used with experiments.Enable()
defer experimentWithUndo(experiments.ResolveCommitAfterCheckout)()
defer experiments.EnableWithUndo(experiments.ResolveCommitAfterCheckout)()

tester, err := NewBootstrapTester()
if err != nil {
Expand Down Expand Up @@ -691,7 +679,7 @@ func TestRepositorylessCheckout(t *testing.T) {

func TestGitMirrorEnv(t *testing.T) {
// t.Parallel() cannot test experiment flags in parallel
defer experimentWithUndo(experiments.GitMirrors)()
defer experiments.EnableWithUndo(experiments.GitMirrors)()

tester, err := NewBootstrapTester()
if err != nil {
Expand Down
84 changes: 84 additions & 0 deletions bootstrap/integration/hooks_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package integration
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
Expand All @@ -11,6 +12,7 @@ import (
"time"

"github.com/buildkite/agent/v3/bootstrap/shell"
"github.com/buildkite/agent/v3/experiments"
"github.com/buildkite/bintest/v3"
)

Expand Down Expand Up @@ -488,3 +490,85 @@ func TestPreExitHooksFireAfterCancel(t *testing.T) {

tester.CheckMocks(t)
}

func TestPolyglotScriptHooksCanBeRun(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("script hooks aren't supported on windows")
}

path, err := exec.LookPath("ruby")
if err != nil {
t.Fatal("error finding path to ruby executable: %w", err)
moskyb marked this conversation as resolved.
Show resolved Hide resolved
}

if path == "" {
t.Fatal("ruby not found in $PATH. This test requires ruby to be installed on the host")
}

defer experiments.EnableWithUndo(experiments.PolyglotHooks)()

tester, err := NewBootstrapTester()
if err != nil {
t.Fatalf("NewBootstrapTester() error = %v", err)
}
defer tester.Close()

filename := "environment"
script := []string{
"#!/usr/bin/env ruby",
`puts "ohai, it's ruby!"`,
}

if err := os.WriteFile(filepath.Join(tester.HooksDir, filename), []byte(strings.Join(script, "\n")), 0755); err != nil {
t.Fatalf("os.WriteFile(%q, script, 0755 = %v", filename, err)
moskyb marked this conversation as resolved.
Show resolved Hide resolved
}

tester.RunAndCheck(t)

if !strings.Contains(tester.Output, "ohai, it's ruby!") {
t.Fatalf("tester.Output %s does not contain expected output: %q", tester.Output, "ohai, it's ruby!")
moskyb marked this conversation as resolved.
Show resolved Hide resolved
}
}

func TestPolyglotBinaryHooksCanBeRun(t *testing.T) {
defer experiments.EnableWithUndo(experiments.PolyglotHooks)()
defer experiments.EnableWithUndo(experiments.JobAPI)()

tester, err := NewBootstrapTester()
if err != nil {
t.Fatalf("NewBootstrapTester() error = %v", err)
}
defer tester.Close()

// We build a binary as part of this test so that we can produce a binary hook
// Forgive me for my sins, RSC, but it's better than the alternatives.
// The code that we're building is in ./test-binary-hook/main.go, it's pretty straightforward.

fmt.Println("Building test-binary-hook")
hookPath := filepath.Join(tester.HooksDir, "environment")

if runtime.GOOS == "windows" {
hookPath += ".exe"
}

output, err := exec.Command("go", "build", "-o", hookPath, "./test-binary-hook").CombinedOutput()
if err != nil {
t.Fatalf("Failed to build test-binary-hook: %v, output: %s", err, string(output))
}

tester.ExpectGlobalHook("post-command").Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) {
// Set via ./test-binary-hook/main.go
if c.GetEnv("OCEAN") != "Pacífico" {
fmt.Fprintf(c.Stderr, "Expected OCEAN to be Pacífico, got %q", c.GetEnv("OCEAN"))
c.Exit(1)
} else {
c.Exit(0)
}
})

tester.RunAndCheck(t)

if !strings.Contains(tester.Output, "hi there from golang 🌊") {
t.Fatalf("tester.Output %s does not contain expected output: %q", tester.Output, "hi there from golang 🌊")
moskyb marked this conversation as resolved.
Show resolved Hide resolved
}
}
4 changes: 2 additions & 2 deletions bootstrap/integration/job_api_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
)

func TestBootstrapRunsJobAPI(t *testing.T) {
defer experimentWithUndo(experiments.JobAPI)()
defer experiments.EnableWithUndo(experiments.JobAPI)()

tester, err := NewBootstrapTester()
if err != nil {
Expand Down Expand Up @@ -87,7 +87,7 @@ func TestBootstrapRunsJobAPI(t *testing.T) {
}

mtn := "chimborazo"
b, err := json.Marshal(jobapi.EnvUpdateRequest{Env: map[string]*string{"MOUNTAIN": &mtn}})
b, err := json.Marshal(jobapi.EnvUpdateRequest{Env: map[string]string{"MOUNTAIN": mtn}})
if err != nil {
t.Errorf("marshaling env update request: %v", err)
c.Exit(1)
Expand Down
31 changes: 31 additions & 0 deletions bootstrap/integration/test-binary-hook/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package main

import (
"context"
"fmt"
"log"

"github.com/buildkite/agent/v3/jobapi"
)

// This file gets built and commited to this repo, then used as part of the hooks integration test to ensure that the
// bootstrap can run binary hooks
func main() {
c, err := jobapi.NewDefaultClient(context.TODO())
if err != nil {
log.Fatalf("error: %v", fmt.Errorf("creating job api client: %w", err))
}

_, err = c.EnvUpdate(context.TODO(), &jobapi.EnvUpdateRequest{
Env: map[string]string{
"OCEAN": "Pacífico",
"MOUNTAIN": "chimborazo",
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good thinking to test the jobapi as well 🧠

})

if err != nil {
log.Fatalf("error: %v", fmt.Errorf("updating env: %w", err))
}

fmt.Println("hi there from golang 🌊")
}
28 changes: 27 additions & 1 deletion bootstrap/shell/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,30 @@ func (s *Shell) Run(ctx context.Context, command string, arg ...string) error {
return s.RunWithoutPrompt(ctx, command, arg...)
}

func (s *Shell) RunWithEnv(ctx context.Context, environ *env.Environment, command string, arg ...string) error {
formatted := process.FormatCommand(command, arg)
if s.stdin == nil {
s.Promptf("%s", formatted)
} else {
// bash-syntax-compatible indication that input is coming from somewhere
s.Promptf("%s < /dev/stdin", formatted)
}

cmd, err := s.buildCommand(command, arg...)
if err != nil {
s.Errorf("Error building command: %v", err)
return err
}

cmd.Env = append(cmd.Env, environ.ToSlice()...)

return s.executeCommand(ctx, cmd, s.Writer, executeFlags{
Stdout: true,
Stderr: true,
PTY: s.PTY,
})
}

Comment on lines +285 to +308
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function is just the Run method and the RunWithPrompt methods garglemeshed together with an env-y thing going on in the middle. earlier on, i tried to make a nice refactor so that you could compose bits and bobs of the different Run* methods together, but it wasn't super important to make this change. i'd like to clean it up at some point though

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes please

// RunWithoutPrompt runs a command, writes stdout and err to the logger,
// and returns an error if it fails. It doesn't show a prompt.
func (s *Shell) RunWithoutPrompt(ctx context.Context, command string, arg ...string) error {
Expand Down Expand Up @@ -379,6 +403,8 @@ func (s *Shell) RunScript(ctx context.Context, path string, extra *env.Environme
case !isWindows && isSh:
// If the script contains a shebang line, it can be run directly,
// with the shebang line choosing the interpreter.
// note that this means that it isn't necessarily a shell script in this case!
// #!/usr/bin/env python would be totally valid, and would execute as a python script
sb, err := shellscript.ShebangLine(path)
if err == nil && sb != "" {
command = path
Expand All @@ -397,7 +423,7 @@ func (s *Shell) RunScript(ctx context.Context, path string, extra *env.Environme
s.Warningf("Couldn't find bash (%v). Attempting to fall back to sh. This may cause issues for hooks and plugins that assume Bash features.", err)
shPath, err = s.AbsolutePath("sh")
if err != nil {
return fmt.Errorf("Error finding a shell, needed to run scripts: %v.", err)
return fmt.Errorf("error finding a shell, needed to run scripts: %v", err)
}
}
command = shPath
Expand Down
4 changes: 2 additions & 2 deletions clicommand/env_set.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ func envSetAction(c *cli.Context) error {
}

req := &jobapi.EnvUpdateRequest{
Env: make(map[string]*string),
Env: make(map[string]string),
}

var parse func(string) error
Expand All @@ -84,7 +84,7 @@ func envSetAction(c *cli.Context) error {
if !ok {
return fmt.Errorf("%q is not in key=value format", input)
}
req.Env[e] = &v
req.Env[e] = v
return nil
}

Expand Down
4 changes: 4 additions & 0 deletions env/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ func (e *Environment) Apply(diff Diff) {

// Copy returns a copy of the env
func (e *Environment) Copy() *Environment {
if e == nil {
return New()
}

Comment on lines +200 to +203
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gotta catch 'em all ('em being nil pointer dereference panics)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I sort of expect a copy of nil to be nil, but it's probably more pragmatic for it to be New().

c := New()

e.underlying.Range(func(k, v string) bool {
Expand Down
7 changes: 7 additions & 0 deletions experiments/experiments.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package experiments

const (
PolyglotHooks = "polyglot-hooks"
JobAPI = "job-api"
KubernetesExec = "kubernetes-exec"
ANSITimestamps = "ansi-timestamps"
Expand All @@ -19,6 +20,7 @@ const (

var (
Available = map[string]struct{}{
PolyglotHooks: {},
JobAPI: {},
KubernetesExec: {},
ANSITimestamps: {},
Expand All @@ -34,6 +36,11 @@ var (
experiments = make(map[string]bool, len(Available))
)

func EnableWithUndo(key string) func() {
Enable(key)
return func() { Disable(key) }
}

// Enable a particular experiment in the agent.
func Enable(key string) (known bool) {
experiments[key] = true
Expand Down
Loading