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

WIP: Self-execute the same path #1816

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@
packaging/docker/*/buildkite-agent
packaging/docker/*/hooks/

.idea
.idea
.DS_Store
6 changes: 3 additions & 3 deletions bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -1445,14 +1445,14 @@ func (b *Bootstrap) defaultCheckoutPhase(ctx context.Context) error {
// we'll check to see if someone else has done
// it first.
b.shell.Commentf("Checking to see if Git data needs to be sent to Buildkite")
if err := b.shell.Run("buildkite-agent", "meta-data", "exists", "buildkite:git:commit"); err != nil {
if err := b.shell.Run(utils.BuildkiteAgentPath(), "meta-data", "exists", "buildkite:git:commit"); err != nil {
b.shell.Commentf("Sending Git commit information back to Buildkite")
out, err := b.shell.RunAndCapture("git", "--no-pager", "show", "HEAD", "-s", "--format=fuller", "--no-color", "--")
if err != nil {
return err
}
stdin := strings.NewReader(out)
if err := b.shell.WithStdin(stdin).Run("buildkite-agent", "meta-data", "set", "buildkite:git:commit"); err != nil {
if err := b.shell.WithStdin(stdin).Run(utils.BuildkiteAgentPath(), "meta-data", "set", "buildkite:git:commit"); err != nil {
return err
}
}
Expand Down Expand Up @@ -1863,7 +1863,7 @@ func (b *Bootstrap) uploadArtifacts(ctx context.Context) error {
args = append(args, b.ArtifactUploadDestination)
}

if err = b.shell.Run("buildkite-agent", args...); err != nil {
if err = b.shell.Run(utils.BuildkiteAgentPath(), args...); err != nil {
return err
}

Expand Down
62 changes: 36 additions & 26 deletions hook/scriptwrapper.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package hook

import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"text/template"

"github.com/buildkite/agent/v3/bootstrap/shell"
Expand Down Expand Up @@ -35,11 +35,11 @@ $Env:BUILDKITE_HOOK_WORKING_DIR = $PWD | Select-Object -ExpandProperty Path
Get-ChildItem Env: | Foreach-Object {"$($_.Name)=$($_.Value)"} | Set-Content "{{.AfterEnvFileName}}"
exit $Env:BUILDKITE_HOOK_EXIT_STATUS`

bashScript = `buildkite-agent env > "{{.BeforeEnvFileName}}"
bashScript = `{{.BuildkiteAgentPath}} env > "{{.BeforeEnvFileName}}"
. "{{.PathToHook}}"
export BUILDKITE_HOOK_EXIT_STATUS=$?
export BUILDKITE_HOOK_WORKING_DIR=$PWD
buildkite-agent env > "{{.AfterEnvFileName}}"
{{.BuildkiteAgentPath}} env > "{{.AfterEnvFileName}}"
exit $BUILDKITE_HOOK_EXIT_STATUS`
)

Expand All @@ -50,9 +50,10 @@ var (
)

type scriptTemplateInput struct {
BeforeEnvFileName string
AfterEnvFileName string
PathToHook string
BuildkiteAgentPath string
BeforeEnvFileName string
AfterEnvFileName string
PathToHook string
}

type HookScriptChanges struct {
Expand Down Expand Up @@ -92,11 +93,18 @@ type scriptWrapperOpt func(*ScriptWrapper)
// a way to get the difference between the environment before the hook is run and
// after it
type ScriptWrapper struct {
hookPath string
os string
scriptFile *os.File
beforeEnvFile *os.File
afterEnvFile *os.File
buildkiteAgentPath string
hookPath string
os string
scriptFile *os.File
beforeEnvFile *os.File
afterEnvFile *os.File
}

func withBuildkiteAgentPath(path string) scriptWrapperOpt {
return func(wrap *ScriptWrapper) {
wrap.buildkiteAgentPath = path
}
}

func WithHookPath(path string) scriptWrapperOpt {
Expand All @@ -115,7 +123,8 @@ func WithOS(os string) scriptWrapperOpt {
// Writes temporary files to the filesystem.
func NewScriptWrapper(opts ...scriptWrapperOpt) (*ScriptWrapper, error) {
wrap := &ScriptWrapper{
os: runtime.GOOS,
buildkiteAgentPath: utils.BuildkiteAgentPath(),
os: runtime.GOOS,
}

for _, o := range opts {
Expand All @@ -135,12 +144,13 @@ func NewScriptWrapper(opts ...scriptWrapperOpt) (*ScriptWrapper, error) {

// we use bash hooks for scripts with no extension, otherwise on windows
// we probably need a .bat extension
if filepath.Ext(wrap.hookPath) == ".ps1" {
switch {
case filepath.Ext(wrap.hookPath) == ".ps1":
isPwshHook = true
scriptFileName += ".ps1"
} else if filepath.Ext(wrap.hookPath) == "" {
case filepath.Ext(wrap.hookPath) == "":
isBashHook = true
} else if isWindows {
case isWindows:
scriptFileName += ".bat"
}

Expand Down Expand Up @@ -175,31 +185,31 @@ func NewScriptWrapper(opts ...scriptWrapperOpt) (*ScriptWrapper, error) {
}

tmplInput := scriptTemplateInput{
BeforeEnvFileName: wrap.beforeEnvFile.Name(),
AfterEnvFileName: wrap.afterEnvFile.Name(),
PathToHook: absolutePathToHook,
BuildkiteAgentPath: wrap.buildkiteAgentPath,
BeforeEnvFileName: wrap.beforeEnvFile.Name(),
AfterEnvFileName: wrap.afterEnvFile.Name(),
PathToHook: absolutePathToHook,
}

// Create the hook runner code
buf := &bytes.Buffer{}
if isWindows && !isBashHook && !isPwshHook {
buf := &strings.Builder{}
switch {
case isWindows && !isBashHook && !isPwshHook:
batchScriptTmpl.Execute(buf, tmplInput)
} else if isWindows && isPwshHook {
case isWindows && isPwshHook:
powershellScriptTmpl.Execute(buf, tmplInput)
} else {
default:
bashScriptTmpl.Execute(buf, tmplInput)
}
script := buf.String()

// Write the hook script to the runner then close the file so we can run it
_, err = wrap.scriptFile.WriteString(script)
if err != nil {
if _, err := wrap.scriptFile.WriteString(script); err != nil {
return nil, err
}

// Make script executable
err = utils.ChmodExecutable(wrap.scriptFile.Name())
if err != nil {
if err := utils.ChmodExecutable(wrap.scriptFile.Name()); err != nil {
return wrap, err
}

Expand Down
95 changes: 37 additions & 58 deletions hook/scriptwrapper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/buildkite/agent/v3/bootstrap/shell"
Expand All @@ -20,16 +18,14 @@ import (

func TestRunningHookDetectsChangedEnvironment(t *testing.T) {
ctx := context.Background()
var script []string

if runtime.GOOS != "windows" {
script = []string{
"#!/bin/bash",
"export LLAMAS=rock",
"export Alpacas=\"are ok\"",
"echo hello world",
}
} else {
script := []string{
"#!/bin/bash",
"export LLAMAS=rock",
"export Alpacas=\"are ok\"",
"echo hello world",
}
if runtime.GOOS == "windows" {
script = []string{
"@echo off",
"set LLAMAS=rock",
Expand Down Expand Up @@ -106,18 +102,13 @@ func TestHookScriptsAreGeneratedCorrectlyOnWindowsBatch(t *testing.T) {

defer wrapper.Close()

// The double percent signs %% are sprintf-escaped literal percent signs. Escaping hell is impossible to get out of.
// See: https://pkg.go.dev/fmt > ctrl-f for "%%"
scriptTemplate := `@echo off
SETLOCAL ENABLEDELAYEDEXPANSION
SET > "%s"
CALL "%s"
SET BUILDKITE_HOOK_EXIT_STATUS=!ERRORLEVEL!
SET BUILDKITE_HOOK_WORKING_DIR=%%CD%%
SET > "%s"
EXIT %%BUILDKITE_HOOK_EXIT_STATUS%%`

assertScriptLike(t, scriptTemplate, hookFile.Name(), wrapper)
fi, err := os.Stat(hookFile.Name())
if err != nil {
t.Errorf("os.Stat(hookFile.Name()) error = %v", err)
}
if fi.Size() == 0 {
t.Error("hookFile is empty")
}
}

func TestHookScriptsAreGeneratedCorrectlyOnWindowsPowershell(t *testing.T) {
Expand All @@ -139,15 +130,13 @@ func TestHookScriptsAreGeneratedCorrectlyOnWindowsPowershell(t *testing.T) {

defer wrapper.Close()

scriptTemplate := `$ErrorActionPreference = "STOP"
Get-ChildItem Env: | Foreach-Object {"$($_.Name)=$($_.Value)"} | Set-Content "%s"
%s
if ($LASTEXITCODE -eq $null) {$Env:BUILDKITE_HOOK_EXIT_STATUS = 0} else {$Env:BUILDKITE_HOOK_EXIT_STATUS = $LASTEXITCODE}
$Env:BUILDKITE_HOOK_WORKING_DIR = $PWD | Select-Object -ExpandProperty Path
Get-ChildItem Env: | Foreach-Object {"$($_.Name)=$($_.Value)"} | Set-Content "%s"
exit $Env:BUILDKITE_HOOK_EXIT_STATUS`

assertScriptLike(t, scriptTemplate, hookFile.Name(), wrapper)
fi, err := os.Stat(hookFile.Name())
if err != nil {
t.Errorf("os.Stat(hookFile.Name()) error = %v", err)
}
if fi.Size() == 0 {
t.Error("hookFile is empty")
}
}

func TestHookScriptsAreGeneratedCorrectlyOnUnix(t *testing.T) {
Expand All @@ -169,14 +158,13 @@ func TestHookScriptsAreGeneratedCorrectlyOnUnix(t *testing.T) {

defer wrapper.Close()

scriptTemplate := `buildkite-agent env > "%s"
. "%s"
export BUILDKITE_HOOK_EXIT_STATUS=$?
export BUILDKITE_HOOK_WORKING_DIR=$PWD
buildkite-agent env > "%s"
exit $BUILDKITE_HOOK_EXIT_STATUS`

assertScriptLike(t, scriptTemplate, hookFile.Name(), wrapper)
fi, err := os.Stat(hookFile.Name())
if err != nil {
t.Errorf("os.Stat(hookFile.Name()) error = %v", err)
}
if fi.Size() == 0 {
t.Error("hookFile is empty")
}
}

func TestRunningHookDetectsChangedWorkingDirectory(t *testing.T) {
Expand All @@ -190,7 +178,7 @@ func TestRunningHookDetectsChangedWorkingDirectory(t *testing.T) {
defer cleanup()
}

tempDir, err := ioutil.TempDir("", "hookwrapperdir")
tempDir, err := os.MkdirTemp("", "hookwrapperdir")
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -273,26 +261,16 @@ func newTestScriptWrapper(t *testing.T, script []string) *ScriptWrapper {

hookFile.Close()

wrapper, err := NewScriptWrapper(WithHookPath(hookFile.Name()))
wrapper, err := NewScriptWrapper(
// The test binary is not a substitute for the whole agent.
withBuildkiteAgentPath("buildkite-agent"),
WithHookPath(hookFile.Name()),
)
assert.NoError(t, err)

return wrapper
}

func assertScriptLike(t *testing.T, scriptTemplate, hookFileName string, wrapper *ScriptWrapper) {
file, err := os.Open(wrapper.scriptFile.Name())
assert.NoError(t, err)

defer file.Close()

wrapperScriptContents, err := ioutil.ReadAll(file)
assert.NoError(t, err)

expected := fmt.Sprintf(scriptTemplate, wrapper.beforeEnvFile.Name(), hookFileName, wrapper.afterEnvFile.Name())

assert.Equal(t, expected, string(wrapperScriptContents))
}

func mockAgent() (*bintest.Mock, func(), error) {
tmpPathDir, err := os.MkdirTemp("", "scriptwrapper-path")
if err != nil {
Expand Down Expand Up @@ -325,8 +303,9 @@ func mockAgent() (*bintest.Mock, func(), error) {
envMap := map[string]string{}

for _, e := range c.Env { // The env from the call
k, v, _ := strings.Cut(e, "=")
envMap[k] = v
if k, v, ok := env.Split(e); ok {
envMap[k] = v
}
}

envJSON, err := json.Marshal(envMap)
Expand Down
21 changes: 21 additions & 0 deletions utils/selfpath.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package utils

import "os"

var pathToSelf = "buildkite-agent"

func init() {
p, err := os.Executable()
if err != nil {
return
}
pathToSelf = p
}

// BuildkiteAgentPath returns a file path to buildkite-agent. If an absolute
// path cannot be found, it defaults to "buildkite-agent" on the assumption it
// is in $PATH. Self-executing with this path can still fail if someone is
// playing games (e.g. unlinking the binary after starting it).
func BuildkiteAgentPath() string {
return pathToSelf
}