diff --git a/.gitignore b/.gitignore index d76a82c430..40b22a80c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +*.DS_STORE + /tmp /pkg /releases diff --git a/ACKNOWLEDGEMENTS.md b/ACKNOWLEDGEMENTS.md index 066ef5b785..f9fdc38ca8 100644 --- a/ACKNOWLEDGEMENTS.md +++ b/ACKNOWLEDGEMENTS.md @@ -1637,6 +1637,35 @@ SOFTWARE. ``` +--- + +## github.com/go-chi/chi/v5/LICENSE + +``` +Copyright (c) 2015-present Peter Kieltyka (https://github.com/pkieltyka), Google Inc. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +``` + + --- ## github.com/go-logr/logr/LICENSE @@ -7387,4 +7416,4 @@ limitations under the License. --- File generated using ./scripts/generate-acknowledgements.sh -Tue 14 Mar 2023 11:57:14 NZDT +Wed 15 Mar 2023 14:20:09 NZDT diff --git a/EXPERIMENTS.md b/EXPERIMENTS.md index 049ea90f24..a4fa61dd84 100644 --- a/EXPERIMENTS.md +++ b/EXPERIMENTS.md @@ -68,10 +68,19 @@ This will result in errors unless orchestrated in a similar manner to that proje **Status**: Being used in a preview release of agent-stack-k8s. As it has little applicability outside of Kubernetes, this will not be the default behaviour. -### `descending-spawn-priority` +### `job-api` -Changes the priority numbering when using `--spawn-with-priority`. By default, priorities start at 1 and increase. Using this experiment, priorities start at -1 and decrease. (Yes, negative priorities are allowed!) This experiment fixes imbalanced work assignment among different hosts with agents that have different values for `--spawn`. +Exposes a local API for the currently running job to introspect and mutate its state in the form of environment variables. This allows you to write scripts, hooks and plugins in languages other than bash, using them to interact with the agent. -For example, without this experiment and all other things being equal, a host with `--spawn=3` would normally need to be running at least two jobs before a host with `--spawn=1` would see any work, because the two extra spawn would have higher priorities. With this experiment, one job would be running on both hosts before the additional spawn on the first host are assigned work. +The API is exposed via a Unix Domain Socket, whose path is exposed to running jobs with the `BUILDKITE_AGENT_JOB_API_SOCKET` envar, and authenticated with a token exposed using the `BUILDKITE_AGENT_JOB_API_TOKEN` envar, using the `Bearer` HTTP Authorization scheme. -**Status**: Likely to become the default in a release soon. \ No newline at end of file +The API exposes the following endpoints: +- `GET /api/current-job/v0/env` - returns a JSON object of all environment variables for the current job +- `PATCH /api/current-job/v0/env` - accepts a JSON object of environment variables to set for the current job +- `DELETE /api/current-job/v0/env` - accepts a JSON array of environment variable names to unset for the current job + +See [jobapi/payloads.go](./jobapi/payloads.go) for the full API request/response definitions. + +The Job API is unavailable on windows agents running versions of windows prior to build 17063, as this was when windows added Unix Domain Socket support. Using this experiment on such agents will output a warning, and the API will be unavailable. + +**Status:** Experimental while we iron out the API and test it out in the wild. We'll probably promote this to non-experiment soon™️. diff --git a/agent/agent_configuration.go b/agent/agent_configuration.go index 07d407c0ea..6fe70880b2 100644 --- a/agent/agent_configuration.go +++ b/agent/agent_configuration.go @@ -7,6 +7,7 @@ type AgentConfiguration struct { BootstrapScript string BuildPath string HooksPath string + SocketsPath string GitMirrorsPath string GitMirrorsLockTimeout int GitMirrorsSkipUpdate bool diff --git a/agent/job_runner.go b/agent/job_runner.go index 23ee4dc422..eeb13ad2de 100644 --- a/agent/job_runner.go +++ b/agent/job_runner.go @@ -44,6 +44,33 @@ const ( BuildkiteMessageName = "BUILDKITE_MESSAGE" ) +// Certain env can only be set by agent configuration. +// We show the user a warning in the bootstrap if they use any of these at a job level. +var ProtectedEnv = map[string]struct{}{ + "BUILDKITE_AGENT_ENDPOINT": {}, + "BUILDKITE_AGENT_ACCESS_TOKEN": {}, + "BUILDKITE_AGENT_DEBUG": {}, + "BUILDKITE_AGENT_PID": {}, + "BUILDKITE_BIN_PATH": {}, + "BUILDKITE_CONFIG_PATH": {}, + "BUILDKITE_BUILD_PATH": {}, + "BUILDKITE_GIT_MIRRORS_PATH": {}, + "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE": {}, + "BUILDKITE_HOOKS_PATH": {}, + "BUILDKITE_PLUGINS_PATH": {}, + "BUILDKITE_SSH_KEYSCAN": {}, + "BUILDKITE_GIT_SUBMODULES": {}, + "BUILDKITE_COMMAND_EVAL": {}, + "BUILDKITE_PLUGINS_ENABLED": {}, + "BUILDKITE_LOCAL_HOOKS_ENABLED": {}, + "BUILDKITE_GIT_CLONE_FLAGS": {}, + "BUILDKITE_GIT_FETCH_FLAGS": {}, + "BUILDKITE_GIT_CLONE_MIRROR_FLAGS": {}, + "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT": {}, + "BUILDKITE_GIT_CLEAN_FLAGS": {}, + "BUILDKITE_SHELL": {}, +} + type JobRunnerConfig struct { // The configuration of the agent from the CLI AgentConfiguration AgentConfiguration @@ -534,41 +561,12 @@ func (r *JobRunner) createEnvironment() ([]string, error) { env["BUILDKITE_ENV_FILE"] = r.envFile.Name() } - // Certain env can only be set by agent configuration. - // We show the user a warning in the bootstrap if they use any of these at a job level. - - var protectedEnv = []string{ - "BUILDKITE_AGENT_ENDPOINT", - "BUILDKITE_AGENT_ACCESS_TOKEN", - "BUILDKITE_AGENT_DEBUG", - "BUILDKITE_AGENT_PID", - "BUILDKITE_BIN_PATH", - "BUILDKITE_CONFIG_PATH", - "BUILDKITE_BUILD_PATH", - "BUILDKITE_GIT_MIRRORS_PATH", - "BUILDKITE_GIT_MIRRORS_SKIP_UPDATE", - "BUILDKITE_HOOKS_PATH", - "BUILDKITE_PLUGINS_PATH", - "BUILDKITE_SSH_KEYSCAN", - "BUILDKITE_GIT_SUBMODULES", - "BUILDKITE_COMMAND_EVAL", - "BUILDKITE_PLUGINS_ENABLED", - "BUILDKITE_LOCAL_HOOKS_ENABLED", - "BUILDKITE_GIT_CHECKOUT_FLAGS", - "BUILDKITE_GIT_CLONE_FLAGS", - "BUILDKITE_GIT_FETCH_FLAGS", - "BUILDKITE_GIT_CLONE_MIRROR_FLAGS", - "BUILDKITE_GIT_MIRRORS_LOCK_TIMEOUT", - "BUILDKITE_GIT_CLEAN_FLAGS", - "BUILDKITE_SHELL", - } - var ignoredEnv []string // Check if the user has defined any protected env - for _, p := range protectedEnv { - if _, exists := r.job.Env[p]; exists { - ignoredEnv = append(ignoredEnv, p) + for k := range ProtectedEnv { + if _, exists := r.job.Env[k]; exists { + ignoredEnv = append(ignoredEnv, k) } } @@ -602,6 +600,7 @@ func (r *JobRunner) createEnvironment() ([]string, error) { // Add options from the agent configuration env["BUILDKITE_CONFIG_PATH"] = r.conf.AgentConfiguration.ConfigPath env["BUILDKITE_BUILD_PATH"] = r.conf.AgentConfiguration.BuildPath + env["BUILDKITE_SOCKETS_PATH"] = r.conf.AgentConfiguration.SocketsPath env["BUILDKITE_GIT_MIRRORS_PATH"] = r.conf.AgentConfiguration.GitMirrorsPath env["BUILDKITE_GIT_MIRRORS_SKIP_UPDATE"] = fmt.Sprintf("%t", r.conf.AgentConfiguration.GitMirrorsSkipUpdate) env["BUILDKITE_HOOKS_PATH"] = r.conf.AgentConfiguration.HooksPath diff --git a/bootstrap/api.go b/bootstrap/api.go new file mode 100644 index 0000000000..75dd9d626a --- /dev/null +++ b/bootstrap/api.go @@ -0,0 +1,50 @@ +package bootstrap + +import ( + "fmt" + + "github.com/buildkite/agent/v3/experiments" + "github.com/buildkite/agent/v3/jobapi" +) + +// startJobAPI starts the job API server, iff the job API experiment is enabled, and the OS of the box supports it +// otherwise it returns a noop cleanup function +// It also sets the BUILDKITE_AGENT_JOB_API_SOCKET and BUILDKITE_AGENT_JOB_API_TOKEN environment variables +func (b *Bootstrap) startJobAPI() (cleanup func(), err error) { + cleanup = func() {} + + if !experiments.IsEnabled("job-api") { + return cleanup, nil + } + + if !jobapi.Available() { + b.shell.Warningf("The Job API isn't available on this machine, as it's running an unsupported version of Windows") + b.shell.Warningf("The Job API is available on Unix agents, and agents running Windows versions after build 17063") + b.shell.Warningf("We'll continue to run your job, but you won't be able to use the Job API") + return cleanup, nil + } + + socketPath, err := jobapi.NewSocketPath(b.Config.SocketsPath) + if err != nil { + return cleanup, fmt.Errorf("creating job API socket path: %v", err) + } + + srv, token, err := jobapi.NewServer(b.shell.Logger, socketPath, b.shell.Env) + if err != nil { + return cleanup, fmt.Errorf("creating job API server: %v", err) + } + + b.shell.Env.Set("BUILDKITE_AGENT_JOB_API_SOCKET", socketPath) + b.shell.Env.Set("BUILDKITE_AGENT_JOB_API_TOKEN", token) + + if err := srv.Start(); err != nil { + return cleanup, fmt.Errorf("starting Job API server: %v", err) + } + + return func() { + err = srv.Stop() + if err != nil { + b.shell.Errorf("Error stopping Job API server: %v", err) + } + }, nil +} diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index 1d0d04a5d1..934ebb3adf 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -109,6 +109,18 @@ func (b *Bootstrap) Run(ctx context.Context) (exitCode int) { } }() + // Create an empty env for us to keep track of our env changes in + b.shell.Env = env.FromSlice(os.Environ()) + + // Initialize the job API, iff the experiment is enabled. Noop otherwise + cleanup, err := b.startJobAPI() + if err != nil { + b.shell.Errorf("Error setting up job API: %v", err) + return 1 + } + + defer cleanup() + // Tear down the environment (and fire pre-exit hook) before we exit defer func() { if err = b.tearDown(ctx); err != nil { @@ -516,9 +528,6 @@ func (b *Bootstrap) setUp(ctx context.Context) error { var err error defer func() { span.FinishWithError(err) }() - // Create an empty env for us to keep track of our env changes in - b.shell.Env = env.FromSlice(os.Environ()) - // Add the $BUILDKITE_BIN_PATH to the $PATH if we've been given one if b.BinPath != "" { path, _ := b.shell.Env.Get("PATH") diff --git a/bootstrap/config.go b/bootstrap/config.go index c2e2197f3a..112743c1d0 100644 --- a/bootstrap/config.go +++ b/bootstrap/config.go @@ -110,6 +110,9 @@ type Config struct { // Path where the builds will be run BuildPath string + // Path where the sockets are stored + SocketsPath string + // Path where the repository mirrors are stored GitMirrorsPath string diff --git a/bootstrap/integration/bootstrap_tester.go b/bootstrap/integration/bootstrap_tester.go index 9fead11df7..a75766a424 100644 --- a/bootstrap/integration/bootstrap_tester.go +++ b/bootstrap/integration/bootstrap_tester.go @@ -44,7 +44,15 @@ type BootstrapTester struct { } func NewBootstrapTester() (*BootstrapTester, error) { - homeDir, err := os.MkdirTemp("", "home") + // The job API experiment adds a unix domain socket to a directory in the home directory + // UDS names are limited to 108 characters, so we need to use a shorter home directory + // Who knows what's going on in windowsland + tmpHomeDir := "/tmp" + if runtime.GOOS == "windows" { + tmpHomeDir = "" + } + + homeDir, err := os.MkdirTemp(tmpHomeDir, "home") if err != nil { return nil, fmt.Errorf("making home directory: %w", err) } diff --git a/bootstrap/integration/job_api_integration_test.go b/bootstrap/integration/job_api_integration_test.go new file mode 100644 index 0000000000..bfed4d7430 --- /dev/null +++ b/bootstrap/integration/job_api_integration_test.go @@ -0,0 +1,146 @@ +package integration + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "testing" + + "github.com/buildkite/agent/v3/jobapi" + "github.com/buildkite/bintest/v3" +) + +func TestBootstrapRunsJobAPI(t *testing.T) { + defer experimentWithUndo("job-api")() + + tester, err := NewBootstrapTester() + if err != nil { + t.Fatalf("NewBootstrapTester() error = %v", err) + } + defer tester.Close() + + tester.ExpectGlobalHook("command").Once().AndCallFunc(func(c *bintest.Call) { + socketPath := c.GetEnv("BUILDKITE_AGENT_JOB_API_SOCKET") + if socketPath == "" { + t.Errorf("Expected BUILDKITE_AGENT_JOB_API_SOCKET to be set") + c.Exit(1) + return + } + + socketToken := c.GetEnv("BUILDKITE_AGENT_JOB_API_TOKEN") + if socketToken == "" { + t.Errorf("Expected BUILDKITE_AGENT_JOB_API_TOKEN to be set") + c.Exit(1) + return + } + + client := &http.Client{ + Transport: &http.Transport{ + DialContext: func(context.Context, string, string) (net.Conn, error) { + return net.Dial("unix", socketPath) + }, + }, + } + + req, err := http.NewRequest(http.MethodGet, "http://bootstrap/api/current-job/v0/env", nil) + if err != nil { + t.Errorf("creating request: %v", err) + c.Exit(1) + return + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", socketToken)) + + resp, err := client.Do(req) + if err != nil { + t.Errorf("sending request: %v", err) + c.Exit(1) + return + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Errorf("expected status 200, got %d, body: %s", resp.StatusCode, body) + c.Exit(1) + return + } + + var envResp jobapi.EnvGetResponse + err = json.NewDecoder(resp.Body).Decode(&envResp) + if err != nil { + t.Errorf("decoding env get response: %v", err) + c.Exit(1) + return + } + + for name, val := range envResp.Env { + if val != c.GetEnv(name) { + t.Errorf("expected c.GetEnv(%q) = %s, got %s", name, c.GetEnv(name), val) + c.Exit(1) + return + } + } + + mtn := "chimborazo" + 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) + return + } + + req, err = http.NewRequest(http.MethodPatch, "http://bootstrap/api/current-job/v0/env", bytes.NewBuffer(b)) + if err != nil { + t.Errorf("creating patch request: %v", err) + c.Exit(1) + return + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", socketToken)) + + resp, err = client.Do(req) + if err != nil { + t.Errorf("sending patch request: %v", err) + c.Exit(1) + return + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Errorf("expected status 200, got %d, body: %s", resp.StatusCode, body) + c.Exit(1) + return + } + + var patchResp jobapi.EnvUpdateResponse + err = json.NewDecoder(resp.Body).Decode(&patchResp) + if err != nil { + t.Errorf("decoding env get response: %v", err) + c.Exit(1) + return + } + + if patchResp.Added[0] != "MOUNTAIN" { + t.Errorf("expected patchResp.Added[0] = %q, got %s", mtn, patchResp.Added[0]) + c.Exit(1) + return + } + + c.Exit(0) + }) + + tester.ExpectGlobalHook("post-command").Once().AndExitWith(0).AndCallFunc(func(c *bintest.Call) { + if got, want := c.GetEnv("MOUNTAIN"), "chimborazo"; got != want { + fmt.Fprintf(c.Stderr, "MOUNTAIN = %q, want %q\n", got, want) + c.Exit(1) + } else { + c.Exit(0) + } + }) + + tester.RunAndCheck(t) +} diff --git a/bootstrap/integration/main_test.go b/bootstrap/integration/main_test.go index 86c01a46f7..b55c57b0e9 100644 --- a/bootstrap/integration/main_test.go +++ b/bootstrap/integration/main_test.go @@ -30,12 +30,12 @@ func TestMain(m *testing.M) { os.Exit(0) } - if os.Getenv(`BINTEST_DEBUG`) == "1" { + if os.Getenv("BINTEST_DEBUG") == "1" { bintest.Debug = true } // Support running the test suite against a given experiment - if exp := os.Getenv(`TEST_EXPERIMENT`); exp != "" { + if exp := os.Getenv("TEST_EXPERIMENT"); exp != "" { experiments.Enable(exp) fmt.Printf("!!! Enabling experiment %q for test suite\n", exp) } diff --git a/clicommand/ACKNOWLEDGEMENTS.md.gz b/clicommand/ACKNOWLEDGEMENTS.md.gz index 7d3a4d302e..9052d3c302 100644 Binary files a/clicommand/ACKNOWLEDGEMENTS.md.gz and b/clicommand/ACKNOWLEDGEMENTS.md.gz differ diff --git a/clicommand/agent_start.go b/clicommand/agent_start.go index d39e3734a4..f6691a0129 100644 --- a/clicommand/agent_start.go +++ b/clicommand/agent_start.go @@ -30,6 +30,7 @@ import ( "github.com/buildkite/agent/v3/utils" "github.com/buildkite/agent/v3/version" "github.com/buildkite/shellwords" + "github.com/mitchellh/go-homedir" "github.com/urfave/cli" "golang.org/x/exp/maps" ) @@ -69,6 +70,7 @@ type AgentStartConfig struct { EnableJobLogTmpfile bool `cli:"enable-job-log-tmpfile"` BuildPath string `cli:"build-path" normalize:"filepath" validate:"required"` HooksPath string `cli:"hooks-path" normalize:"filepath"` + SocketsPath string `cli:"sockets-path" normalize:"filepath"` PluginsPath string `cli:"plugins-path" normalize:"filepath"` Shell string `cli:"shell"` Tags []string `cli:"tags" normalize:"list"` @@ -432,6 +434,12 @@ var AgentStartCommand = cli.Command{ Usage: "Directory where the hook scripts are found", EnvVar: "BUILDKITE_HOOKS_PATH", }, + cli.StringFlag{ + Name: "sockets-path", + Value: defaultSocketsPath(), + Usage: "Directory where the agent will place sockets", + EnvVar: "BUILDKITE_SOCKETS_PATH", + }, cli.StringFlag{ Name: "plugins-path", Value: "", @@ -747,6 +755,7 @@ var AgentStartCommand = cli.Command{ agentConf := agent.AgentConfiguration{ BootstrapScript: cfg.BootstrapScript, BuildPath: cfg.BuildPath, + SocketsPath: cfg.SocketsPath, GitMirrorsPath: cfg.GitMirrorsPath, GitMirrorsLockTimeout: cfg.GitMirrorsLockTimeout, GitMirrorsSkipUpdate: cfg.GitMirrorsSkipUpdate, @@ -1076,3 +1085,12 @@ func agentLifecycleHook(hookName string, log logger.Logger, cfg AgentStartConfig wg.Wait() return nil } + +func defaultSocketsPath() string { + home, err := homedir.Dir() + if err != nil { + return filepath.Join(os.TempDir(), "buildkite-sockets") + } + + return filepath.Join(home, ".buildkite-agent", "sockets") +} diff --git a/clicommand/bootstrap.go b/clicommand/bootstrap.go index ba170525f6..a257ef7036 100644 --- a/clicommand/bootstrap.go +++ b/clicommand/bootstrap.go @@ -74,6 +74,7 @@ type BootstrapConfig struct { BinPath string `cli:"bin-path" normalize:"filepath"` BuildPath string `cli:"build-path" normalize:"filepath"` HooksPath string `cli:"hooks-path" normalize:"filepath"` + SocketsPath string `cli:"sockets-path" normalize:"filepath"` PluginsPath string `cli:"plugins-path" normalize:"filepath"` CommandEval bool `cli:"command-eval"` PluginsEnabled bool `cli:"plugins-enabled"` @@ -270,6 +271,12 @@ var BootstrapCommand = cli.Command{ Usage: "Directory where the hook scripts are found", EnvVar: "BUILDKITE_HOOKS_PATH", }, + cli.StringFlag{ + Name: "sockets-path", + Value: defaultSocketsPath(), + Usage: "Directory where the agent will place sockets", + EnvVar: "BUILDKITE_SOCKETS_PATH", + }, cli.StringFlag{ Name: "plugins-path", Value: "", @@ -411,6 +418,7 @@ var BootstrapCommand = cli.Command{ BinPath: cfg.BinPath, Branch: cfg.Branch, BuildPath: cfg.BuildPath, + SocketsPath: cfg.SocketsPath, CancelSignal: cancelSig, CleanCheckout: cfg.CleanCheckout, Command: cfg.Command, diff --git a/go.mod b/go.mod index 3cfa3b9ee9..313508e564 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/brunoscheufler/aws-ecs-metadata-go v0.0.0-20220812150832-b6b31c6eeeaf github.com/buildkite/roko v1.0.3-0.20221121010703-599521c80157 github.com/gliderlabs/ssh v0.3.5 + github.com/go-chi/chi/v5 v5.0.8 github.com/google/go-cmp v0.5.9 github.com/puzpuzpuz/xsync/v2 v2.4.0 go.opentelemetry.io/contrib/propagators/aws v1.15.0 diff --git a/go.sum b/go.sum index 714a973e3e..fb37f3d747 100644 --- a/go.sum +++ b/go.sum @@ -120,6 +120,8 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= +github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= +github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= diff --git a/jobapi/available.go b/jobapi/available.go new file mode 100644 index 0000000000..d817aafb85 --- /dev/null +++ b/jobapi/available.go @@ -0,0 +1,7 @@ +//go:build unix && !windows + +package jobapi + +func Available() bool { + return true +} diff --git a/jobapi/available_windows.go b/jobapi/available_windows.go new file mode 100644 index 0000000000..f4e775ca42 --- /dev/null +++ b/jobapi/available_windows.go @@ -0,0 +1,38 @@ +//go:build windows + +package jobapi + +import ( + "strconv" + + "golang.org/x/sys/windows/registry" +) + +// Available returns true if the job api is available on this machine, which is determined by the OS and OS version running the agent +// The job API uses unix domain sockets, which are only available on unix machines, and on windows machines after build 17063 +// So: +// On all unices, this function will return true +// On windows, it will return true if and only if the build is after 17063 (the first build to support unix sockets) +func Available() bool { + return isAfterBuild17063() +} + +// isAfterBuild17063 returns true if the current build (of windows, this file is only compiled for windows) is after 17063 +// stolen from: https://github.com/golang/go/blob/76c45877c9e72ccc84db787dc08299e0182e0efb/src/net/unixsock_windows_test.go#L17 +func isAfterBuild17063() bool { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.READ) + if err != nil { + return false + } + defer k.Close() + + s, _, err := k.GetStringValue("CurrentBuild") + if err != nil { + return false + } + ver, err := strconv.Atoi(s) + if err != nil { + return false + } + return ver >= 17063 +} diff --git a/jobapi/doc.go b/jobapi/doc.go new file mode 100644 index 0000000000..11ec707c37 --- /dev/null +++ b/jobapi/doc.go @@ -0,0 +1,3 @@ +// jobapi provides an API with which to interact with and mutate the currently executing job +// Pronunciation: /ˈdʒɑbapi/ /joh-bah-pee/ +package jobapi diff --git a/jobapi/middleware.go b/jobapi/middleware.go new file mode 100644 index 0000000000..3368b793a3 --- /dev/null +++ b/jobapi/middleware.go @@ -0,0 +1,62 @@ +package jobapi + +import ( + "errors" + "net/http" + "strings" + "time" + + "github.com/buildkite/agent/v3/bootstrap/shell" +) + +func LoggerMiddleware(l shell.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t := time.Now() + defer l.Commentf("Job API:\t%s\t%s\t%s", r.Method, r.URL.Path, time.Since(t)) + next.ServeHTTP(w, r) + }) + } +} + +// AuthMiddleware is a middleware that checks the Authorization header of an incoming request for a Bearer token +// and checks that that token is the correct one. +func AuthMiddleware(token string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth == "" { + writeError(w, errors.New("authorization header is required"), http.StatusUnauthorized) + return + } + + authType, reqToken, found := strings.Cut(auth, " ") + if !found { + writeError(w, errors.New("invalid authorization header: must be in the form `Bearer `"), http.StatusUnauthorized) + return + } + + if authType != "Bearer" { + writeError(w, errors.New("invalid authorization header: type must be Bearer"), http.StatusUnauthorized) + return + } + + if reqToken != token { + writeError(w, errors.New("invalid authorization token"), http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +// HeadersMiddleware is a middleware that sets the common headers for all responses. At the moment, this is just +// Content-Type: application/json. +func HeadersMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer next.ServeHTTP(w, r) + + w.Header().Set("Content-Type", "application/json") + }) +} diff --git a/jobapi/middleware_test.go b/jobapi/middleware_test.go new file mode 100644 index 0000000000..590e027471 --- /dev/null +++ b/jobapi/middleware_test.go @@ -0,0 +1,121 @@ +package jobapi_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/buildkite/agent/v3/jobapi" + "github.com/google/go-cmp/cmp" +) + +func testHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("{}")) +} + +func shouldCall(t *testing.T) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r) + }) + } +} + +func shouldNotCall(t *testing.T) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("next.ServeHTTP should not be called") + }) + } +} + +func TestAuthMiddleware(t *testing.T) { + t.Parallel() + + token := "llamas" + cases := []struct { + title string + auth string + wantCode int + wantBody map[string]string + next func(http.Handler) http.Handler + }{ + { + title: "valid token", + auth: fmt.Sprintf("Bearer %s", token), + wantCode: http.StatusOK, + wantBody: map[string]string{}, + next: shouldCall(t), + }, + { + title: "invalid token", + auth: "Bearer alpacas", + wantCode: http.StatusUnauthorized, + wantBody: map[string]string{"error": "invalid authorization token"}, + next: shouldNotCall(t), + }, + { + title: "non-bearer auth", + auth: fmt.Sprintf("Basic %s", token), + wantCode: http.StatusUnauthorized, + wantBody: map[string]string{"error": "invalid authorization header: type must be Bearer"}, + next: shouldNotCall(t), + }, + { + title: "no auth", + auth: "", + wantCode: http.StatusUnauthorized, + wantBody: map[string]string{"error": "authorization header is required"}, + next: shouldNotCall(t), + }, + } + + for _, c := range cases { + c := c + t.Run(c.title, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Add("Authorization", c.auth) + + w := httptest.NewRecorder() + + mdlw := jobapi.AuthMiddleware(token) + wrapped := mdlw(c.next(http.HandlerFunc(testHandler))) + wrapped.ServeHTTP(w, req) + + gotCode := w.Result().StatusCode + if c.wantCode != gotCode { + t.Errorf("w.Result().StatusCode = %d (wanted %d)", gotCode, c.wantCode) + } + + var gotBody map[string]string + if err := json.NewDecoder(w.Body).Decode(&gotBody); err != nil { + t.Errorf("json.NewDecoder(w.Body).Decode(&gotBody) = %v", err) + } + + if diff := cmp.Diff(c.wantBody, gotBody); diff != "" { + t.Errorf("cmp.Diff(c.wantBody, gotBody) = %s (-want +got)", diff) + } + }) + } +} + +func TestHeadersMiddleware(t *testing.T) { + t.Parallel() + + mdlw := jobapi.HeadersMiddleware + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + wrapped := mdlw(http.HandlerFunc(testHandler)) + wrapped.ServeHTTP(w, req) + + gotHeader := w.Header().Get("Content-Type") + if gotHeader != "application/json" { + t.Errorf("w.Header().Get(\"Content-Type\") = %s (wanted %s)", gotHeader, "application/json") + } +} diff --git a/jobapi/payloads.go b/jobapi/payloads.go new file mode 100644 index 0000000000..979e3cbff1 --- /dev/null +++ b/jobapi/payloads.go @@ -0,0 +1,43 @@ +package jobapi + +import "sort" + +// Error response is the response body for any errors that occur +type ErrorResponse struct { + Error string `json:"error"` +} + +// EnvGetResponse is the response body for the GET /env endpoint +type EnvGetResponse struct { + Env map[string]string `json:"env"` // Different to EnvUpdateRequest because we don't want to send nulls +} + +// EnvUpdateRequest is the request body for the GET /env endpoint +type EnvUpdateRequest struct { + Env map[string]*string `json:"env"` +} + +// EnvUpdateResponse is the response body for the PATCH /env endpoint +type EnvUpdateResponse struct { + Added []string `json:"added"` + Updated []string `json:"updated"` +} + +func (e EnvUpdateResponse) Normalize() { + sort.Strings(e.Added) + sort.Strings(e.Updated) +} + +// EnvDeleteRequest is the request body for the DELETE /env endpoint +type EnvDeleteRequest struct { + Keys []string `json:"keys"` +} + +// EnvDeleteResponse is the response body for the DELETE /env endpoint +type EnvDeleteResponse struct { + Deleted []string `json:"deleted"` +} + +func (e EnvDeleteResponse) Normalize() { + sort.Strings(e.Deleted) +} diff --git a/jobapi/routes.go b/jobapi/routes.go new file mode 100644 index 0000000000..1d54988fd9 --- /dev/null +++ b/jobapi/routes.go @@ -0,0 +1,154 @@ +package jobapi + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/buildkite/agent/v3/agent" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "golang.org/x/exp/maps" +) + +// router returns a chi router with the jobapi routes and appropriate middlewares mounted +func (s *Server) router() chi.Router { + r := chi.NewRouter() + r.Use( + LoggerMiddleware(s.Logger), + middleware.Recoverer, + HeadersMiddleware, + AuthMiddleware(s.token), + ) + + r.Route("/api/current-job/v0", func(r chi.Router) { + r.Get("/env", s.getEnv) + r.Patch("/env", s.patchEnv) + r.Delete("/env", s.deleteEnv) + }) + + return r +} + +func (s *Server) getEnv(w http.ResponseWriter, _ *http.Request) { + s.mtx.RLock() + normalizedEnv := s.environ.Dump() + s.mtx.RUnlock() + + resp := EnvGetResponse{Env: normalizedEnv} + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(resp) +} + +func (s *Server) patchEnv(w http.ResponseWriter, r *http.Request) { + var req EnvUpdateRequest + err := json.NewDecoder(r.Body).Decode(&req) + defer r.Body.Close() + if err != nil { + writeError(w, fmt.Errorf("failed to decode request body: %w", err), http.StatusBadRequest) + return + } + + added := make([]string, 0, len(req.Env)) + updated := make([]string, 0, len(req.Env)) + protected := checkProtected(maps.Keys(req.Env)) + + if len(protected) > 0 { + writeError( + w, + fmt.Sprintf("the following environment variables are protected, and cannot be modified: % v", protected), + http.StatusUnprocessableEntity, + ) + return + } + + nils := make([]string, 0, len(req.Env)) + + for k, v := range req.Env { + if v == nil { + nils = append(nils, k) + } + } + + if len(nils) > 0 { + writeError( + w, + fmt.Sprintf("removing environment variables (ie setting them to null) is not permitted on this endpoint. The following keys were set to null: % v", nils), + http.StatusUnprocessableEntity, + ) + return + } + + s.mtx.Lock() + for k, v := range req.Env { + if _, ok := s.environ.Get(k); ok { + updated = append(updated, k) + } else { + added = append(added, k) + } + s.environ.Set(k, *v) + } + s.mtx.Unlock() + + resp := EnvUpdateResponse{ + Added: added, + Updated: updated, + } + + resp.Normalize() + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(resp) +} + +func (s *Server) deleteEnv(w http.ResponseWriter, r *http.Request) { + var req EnvDeleteRequest + err := json.NewDecoder(r.Body).Decode(&req) + defer r.Body.Close() + if err != nil { + err = fmt.Errorf("failed to decode request body: %w", err) + writeError(w, err, http.StatusBadRequest) + return + } + + protected := checkProtected(req.Keys) + if len(protected) > 0 { + writeError( + w, + fmt.Sprintf("the following environment variables are protected, and cannot be modified: % v", protected), + http.StatusUnprocessableEntity, + ) + return + } + + s.mtx.Lock() + deleted := make([]string, 0, len(req.Keys)) + for _, k := range req.Keys { + if _, ok := s.environ.Get(k); ok { + deleted = append(deleted, k) + s.environ.Remove(k) + } + } + s.mtx.Unlock() + + resp := EnvDeleteResponse{Deleted: deleted} + resp.Normalize() + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(resp) +} + +func checkProtected(candidates []string) []string { + protected := make([]string, 0, len(candidates)) + for _, c := range candidates { + if _, ok := agent.ProtectedEnv[c]; ok { + protected = append(protected, c) + } + } + return protected +} + +func writeError(w http.ResponseWriter, err any, code int) { + w.WriteHeader(code) + json.NewEncoder(w).Encode(ErrorResponse{Error: fmt.Sprint(err)}) +} diff --git a/jobapi/server.go b/jobapi/server.go new file mode 100644 index 0000000000..0882a132ad --- /dev/null +++ b/jobapi/server.go @@ -0,0 +1,152 @@ +package jobapi + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net" + "net/http" + "os" + "runtime" + "strings" + "sync" + "time" + + "github.com/buildkite/agent/v3/bootstrap/shell" + "github.com/buildkite/agent/v3/env" +) + +// Server is a Job API server. It provides an HTTP API with which to interact with the job currently running in the buildkite agent +// and allows jobs to introspect and mutate their own state +type Server struct { + // SocketPath is the path to the socket that the server is (or will be) listening on + SocketPath string + Logger shell.Logger + + environ *env.Environment + token string + httpSvr *http.Server + started bool + mtx sync.RWMutex +} + +// NewServer creates a new Job API server +// socketPath is the path to the socket on which the server will listen +// environ is the environment which the server will mutate and inspect as part of its operation +func NewServer(logger shell.Logger, socketPath string, environ *env.Environment) (server *Server, token string, err error) { + if len(socketPath) >= socketPathLength() { + return nil, "", fmt.Errorf("socket path %s is too long (path length: %d, max %d characters). This is a limitation of your host OS", socketPath, len(socketPath), socketPathLength()) + } + + exists, err := socketExists(socketPath) + if err != nil { + return nil, "", err + } + + if exists { + return nil, "", fmt.Errorf("file already exists at socket path %s", socketPath) + } + + token, err = generateToken(32) + if err != nil { + return nil, "", fmt.Errorf("generating token: %w", err) + } + + return &Server{ + SocketPath: socketPath, + Logger: logger, + environ: environ, + token: token, + }, token, nil +} + +// Start starts the server in a goroutine, returning an error if the server can't be started +func (s *Server) Start() error { + if s.started { + return errors.New("server already started") + } + + r := s.router() + l, err := net.Listen("unix", s.SocketPath) + if err != nil { + return fmt.Errorf("listening on socket: %w", err) + } + + s.httpSvr = &http.Server{Handler: r} + go func() { + _ = s.httpSvr.Serve(l) + }() + s.started = true + + s.Logger.Commentf("Job API server listening on %s", s.SocketPath) + + return nil +} + +// Stop gracefully shuts the server down, blocking until all requests have been served or the grace period has expired +// It returns an error if the server has not been started +func (s *Server) Stop() error { + if !s.started { + return errors.New("server not started") + } + + // Shutdown signal with grace period of 10 seconds + shutdownCtx, serverStopCtx := context.WithTimeout(context.Background(), 10*time.Second) + defer serverStopCtx() + + // Trigger graceful shutdown + err := s.httpSvr.Shutdown(shutdownCtx) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + s.Logger.Warningf("Job API server shutdown timed out, server shutdown forced") + } + return fmt.Errorf("shutting down Job API server: %w", err) + } + + s.Logger.Commentf("Successfully shut down Job API server") + + return nil +} + +// socketExists returns true if the socket path exists on linux and darwin +// on windows it always returns false, because of https://github.com/golang/go/issues/33357 (stat on sockets is broken on windows) +func socketExists(path string) (bool, error) { + if runtime.GOOS == "windows" { + return false, nil + } + + _, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + + return false, fmt.Errorf("stat socket: %w", err) + } + + return true, nil +} + +func generateToken(len int) (string, error) { + b := make([]byte, len) + _, err := rand.Read(b) + if err != nil { + return "", fmt.Errorf("reading from random: %w", err) + } + + withEqualses := base64.URLEncoding.EncodeToString(b) + return strings.TrimRight(withEqualses, "="), nil // Trim the equals signs because they're not valid in env vars +} + +func socketPathLength() int { + switch runtime.GOOS { + case "darwin", "freebsd", "openbsd", "netbsd", "dragonfly", "solaris": + return 104 + case "linux": + fallthrough + default: + return 108 + } +} diff --git a/jobapi/server_test.go b/jobapi/server_test.go new file mode 100644 index 0000000000..e8acb42e3f --- /dev/null +++ b/jobapi/server_test.go @@ -0,0 +1,397 @@ +package jobapi_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/buildkite/agent/v3/bootstrap/shell" + "github.com/buildkite/agent/v3/env" + "github.com/buildkite/agent/v3/jobapi" + "github.com/google/go-cmp/cmp" +) + +func pt(s string) *string { + return &s +} + +func testEnviron() *env.Environment { + e := env.New() + e.Set("MOUNTAIN", "cotopaxi") + e.Set("CAPITAL", "quito") + + return e +} + +func testServer(t *testing.T, e *env.Environment) (*jobapi.Server, string, error) { + sockName, err := jobapi.NewSocketPath(os.TempDir()) + if err != nil { + return nil, "", fmt.Errorf("creating socket path: %w", err) + } + + return jobapi.NewServer(shell.TestingLogger{T: t}, sockName, e) +} + +func testSocketClient(socketPath string) *http.Client { + return &http.Client{ + Transport: &http.Transport{ + DialContext: func(context.Context, string, string) (net.Conn, error) { + return net.Dial("unix", socketPath) + }, + }, + } +} + +func TestServerStartStop(t *testing.T) { + t.Parallel() + + env := testEnviron() + srv, _, err := testServer(t, env) + if err != nil { + t.Fatalf("testServer(t, env) error = %v", err) + } + + err = srv.Start() + if err != nil { + t.Fatalf("srv.Start() = %v", err) + } + + // Check the socket path exists and is a socket. + // Note that os.ModeSocket might not be set on Windows. + // (https://github.com/golang/go/issues/33357) + if runtime.GOOS != "windows" { + fi, err := os.Stat(srv.SocketPath) + if err != nil { + t.Fatalf("os.Stat(%q) = %v", srv.SocketPath, err) + } + + if fi.Mode()&os.ModeSocket == 0 { + t.Fatalf("%q is not a socket", srv.SocketPath) + } + } + + // Try to connect to the socket. + test, err := net.Dial("unix", srv.SocketPath) + if err != nil { + t.Fatalf("socket test connection: %v", err) + } + + test.Close() + + err = srv.Stop() + if err != nil { + t.Fatalf("srv.Stop() = %v", err) + } + + time.Sleep(100 * time.Millisecond) // Wait for the socket file to be unlinked + _, err = os.Stat(srv.SocketPath) + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected os.Stat(%s) = _, os.ErrNotExist, got %v", srv.SocketPath, err) + } +} + +func TestServerStartStop_WithPreExistingSocket(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("socket collision detection isn't support on windows. If the current go version is >1.23, it might be worth re-enabling this test, because hopefully the bug (https://github.com/golang/go/issues/33357) is fixed") + } + + sockName := filepath.Join(os.TempDir(), "test-socket-collision.sock") + srv1, _, err := jobapi.NewServer(shell.TestingLogger{T: t}, sockName, env.New()) + if err != nil { + t.Fatalf("expected initial server creation to succeed, got %v", err) + } + + err = srv1.Start() + if err != nil { + t.Fatalf("expected initial server start to succeed, got %v", err) + } + defer srv1.Stop() + + expectedErr := fmt.Sprintf("file already exists at socket path %s", sockName) + _, _, err = jobapi.NewServer(shell.TestingLogger{T: t}, sockName, env.New()) + if err == nil { + t.Fatalf("expected second server creation to fail with %s, got nil", expectedErr) + } + + if err.Error() != expectedErr { + t.Fatalf("expected second server start to fail with %q, got %q", expectedErr, err) + } +} + +type apiTestCase[Req, Resp any] struct { + name string + requestBody *Req + expectedStatus int + expectedResponseBody *Resp + expectedEnv map[string]string + expectedError *jobapi.ErrorResponse +} + +func TestDeleteEnv(t *testing.T) { + t.Parallel() + + cases := []apiTestCase[jobapi.EnvDeleteRequest, jobapi.EnvDeleteResponse]{ + { + name: "happy case", + requestBody: &jobapi.EnvDeleteRequest{Keys: []string{"MOUNTAIN"}}, + expectedStatus: http.StatusOK, + expectedResponseBody: &jobapi.EnvDeleteResponse{Deleted: []string{"MOUNTAIN"}}, + expectedEnv: env.FromMap(map[string]string{"CAPITAL": "quito"}).Dump(), + }, + { + name: "deleting a non-existent key is a no-op", + requestBody: &jobapi.EnvDeleteRequest{Keys: []string{"NATIONAL_PARKS"}}, + expectedStatus: http.StatusOK, + expectedResponseBody: &jobapi.EnvDeleteResponse{Deleted: []string{}}, + expectedEnv: testEnviron().Dump(), // ie no change + }, + { + name: "deleting protected keys returns a 422", + requestBody: &jobapi.EnvDeleteRequest{ + Keys: []string{"MOUNTAIN", "CAPITAL", "BUILDKITE_AGENT_PID"}, + }, + expectedStatus: http.StatusUnprocessableEntity, + expectedError: &jobapi.ErrorResponse{ + Error: "the following environment variables are protected, and cannot be modified: [BUILDKITE_AGENT_PID]", + }, + expectedEnv: testEnviron().Dump(), // ie no change + }, + } + + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + environ := testEnviron() + srv, token, err := testServer(t, environ) + if err != nil { + t.Fatalf("creating server: %v", err) + } + + err = srv.Start() + if err != nil { + t.Fatalf("starting server: %v", err) + } + + client := testSocketClient(srv.SocketPath) + + defer func() { + err := srv.Stop() + if err != nil { + t.Fatalf("stopping server: %v", err) + } + }() + + buf := bytes.NewBuffer(nil) + err = json.NewEncoder(buf).Encode(c.requestBody) + if err != nil { + t.Fatalf("JSON-encoding c.requestBody into buf: %v", err) + } + + req, err := http.NewRequest(http.MethodDelete, "http://bootstrap/api/current-job/v0/env", buf) + if err != nil { + t.Fatalf("creating request: %v", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + testAPI(t, environ, req, client, c) // Ignore arguments, dial socket + }) + } +} + +func TestPatchEnv(t *testing.T) { + t.Parallel() + + cases := []apiTestCase[jobapi.EnvUpdateRequest, jobapi.EnvUpdateResponse]{ + { + name: "happy case", + requestBody: &jobapi.EnvUpdateRequest{ + Env: map[string]*string{ + "MOUNTAIN": pt("chimborazo"), + "CAPITAL": pt("quito"), + "NATIONAL_PARKS": pt("cayambe-coca,el-cajas,galápagos"), + }, + }, + expectedStatus: http.StatusOK, + expectedResponseBody: &jobapi.EnvUpdateResponse{ + Added: []string{"NATIONAL_PARKS"}, + Updated: []string{"CAPITAL", "MOUNTAIN"}, + }, + expectedEnv: env.FromMap(map[string]string{ + "MOUNTAIN": "chimborazo", + "NATIONAL_PARKS": "cayambe-coca,el-cajas,galápagos", + "CAPITAL": "quito", + }).Dump(), + }, + { + name: "setting to nil returns a 422", + requestBody: &jobapi.EnvUpdateRequest{ + Env: map[string]*string{ + "NATIONAL_PARKS": nil, + "MOUNTAIN": pt("chimborazo"), + }, + }, + expectedStatus: http.StatusUnprocessableEntity, + expectedError: &jobapi.ErrorResponse{ + Error: "removing environment variables (ie setting them to null) is not permitted on this endpoint. The following keys were set to null: [NATIONAL_PARKS]", + }, + expectedEnv: testEnviron().Dump(), // ie no changes + }, + { + name: "setting protected variables returns a 422", + requestBody: &jobapi.EnvUpdateRequest{ + Env: map[string]*string{ + "BUILDKITE_AGENT_PID": pt("12345"), + "MOUNTAIN": pt("antisana"), + }, + }, + expectedStatus: http.StatusUnprocessableEntity, + expectedError: &jobapi.ErrorResponse{ + Error: "the following environment variables are protected, and cannot be modified: [BUILDKITE_AGENT_PID]", + }, + expectedEnv: testEnviron().Dump(), // ie no changes + }, + } + + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + environ := testEnviron() + srv, token, err := testServer(t, environ) + if err != nil { + t.Fatalf("creating server: %v", err) + } + + err = srv.Start() + if err != nil { + t.Fatalf("starting server: %v", err) + } + + client := testSocketClient(srv.SocketPath) + + defer func() { + err := srv.Stop() + if err != nil { + t.Fatalf("stopping server: %v", err) + } + }() + + buf := bytes.NewBuffer(nil) + err = json.NewEncoder(buf).Encode(c.requestBody) + if err != nil { + t.Fatal(err) + } + + req, err := http.NewRequest(http.MethodPatch, "http://bootstrap/api/current-job/v0/env", buf) + if err != nil { + t.Fatalf("creating request: %v", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + testAPI(t, environ, req, client, c) + }) + } + +} + +func TestGetEnv(t *testing.T) { + t.Parallel() + + env := testEnviron() + srv, token, err := testServer(t, env) + if err != nil { + t.Fatalf("creating server: %v", err) + } + + err = srv.Start() + if err != nil { + t.Fatalf("starting server: %v", err) + } + + client := testSocketClient(srv.SocketPath) + + defer func() { + err := srv.Stop() + if err != nil { + t.Fatalf("stopping server: %v", err) + } + }() + + req, err := http.NewRequest(http.MethodGet, "http://bootstrap/api/current-job/v0/env", nil) + if err != nil { + t.Fatalf("creating request: %v", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + testAPI(t, env, req, client, apiTestCase[any, jobapi.EnvGetResponse]{ + expectedStatus: http.StatusOK, + expectedResponseBody: &jobapi.EnvGetResponse{ + Env: testEnviron().Dump(), + }, + }) + + env.Set("MOUNTAIN", "chimborazo") + env.Set("NATIONAL_PARKS", "cayambe-coca,el-cajas,galápagos") + + expectedEnv := map[string]string{ + "NATIONAL_PARKS": "cayambe-coca,el-cajas,galápagos", + "MOUNTAIN": "chimborazo", + "CAPITAL": "quito", + } + + // It responds to out-of-band changes to the environment + testAPI(t, env, req, client, apiTestCase[any, jobapi.EnvGetResponse]{ + expectedStatus: http.StatusOK, + expectedResponseBody: &jobapi.EnvGetResponse{ + Env: expectedEnv, + }, + }) +} + +func testAPI[Req, Resp any](t *testing.T, env *env.Environment, req *http.Request, client *http.Client, testCase apiTestCase[Req, Resp]) { + resp, err := client.Do(req) + if err != nil { + t.Fatalf("expected no error for client.Do(req) (got %v)", err) + } + + if resp.StatusCode != testCase.expectedStatus { + t.Fatalf("expected status code %d (got %d)", testCase.expectedStatus, resp.StatusCode) + } + + if testCase.expectedResponseBody != nil { + var got Resp + json.NewDecoder(resp.Body).Decode(&got) + if !cmp.Equal(testCase.expectedResponseBody, &got) { + t.Fatalf("\n\texpected response: % #v\n\tgot: % #v\n\tdiff = %s)", *testCase.expectedResponseBody, got, cmp.Diff(testCase.expectedResponseBody, &got)) + } + } + + if testCase.expectedError != nil { + var got jobapi.ErrorResponse + json.NewDecoder(resp.Body).Decode(&got) + if got.Error != testCase.expectedError.Error { + t.Fatalf("expected error %q (got %q)", testCase.expectedError.Error, got.Error) + } + } + + if testCase.expectedEnv != nil { + if !cmp.Equal(testCase.expectedEnv, env.Dump()) { + t.Fatalf("\n\texpected env: % #v\n\tgot: % #v\n\tdiff = %s)", testCase.expectedEnv, env, cmp.Diff(testCase.expectedEnv, env)) + } + } +} diff --git a/jobapi/socket.go b/jobapi/socket.go new file mode 100644 index 0000000000..10b1fb41a4 --- /dev/null +++ b/jobapi/socket.go @@ -0,0 +1,26 @@ +package jobapi + +import ( + "fmt" + "math/rand" + "os" + "path/filepath" + "time" +) + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +// NewSocketPath generates a path to a socket file (without actually creating the file itself) that can be used with the +// job api. +func NewSocketPath(base string) (string, error) { + path := filepath.Join(base, "job-api") + err := os.MkdirAll(path, 0700) + if err != nil { + return "", fmt.Errorf("creating socket directory: %w", err) + } + + sockNum := rand.Int63() % 100_000 + return filepath.Join(path, fmt.Sprintf("%d-%d.sock", os.Getpid(), sockNum)), nil +}