Skip to content

Commit

Permalink
Integrate Job API into the bootstrap under the job-api experiment
Browse files Browse the repository at this point in the history
  • Loading branch information
moskyb committed Mar 2, 2023
1 parent 88477f2 commit 49f2581
Show file tree
Hide file tree
Showing 6 changed files with 230 additions and 6 deletions.
17 changes: 17 additions & 0 deletions EXPERIMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,20 @@ Changes the file lock implementation from github.com/nightlyone/lockfile to gith
When the experiment is enabled the agent will use different lock files from agents where the experiment is disabled, so agents with this experiment enabled should not be run on the same host as agents where the experiment is disabled.

**Status**: Being tested, but it's looking good. We plan to switch lock implementations over the course of a couple of releases, switching over in such a way that nothing gets broken.

### `job-api`

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.

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.

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™️.
52 changes: 52 additions & 0 deletions bootstrap/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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()
if err != nil {
return cleanup, fmt.Errorf("creating job API socket path: %v", err)
}

srv, token, err := jobapi.NewServer(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)
} else {
b.shell.Commentf("Stopped Job API server")
}
}, nil
}
15 changes: 12 additions & 3 deletions bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,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 {
Expand Down Expand Up @@ -515,9 +527,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")
Expand Down
2 changes: 1 addition & 1 deletion bootstrap/integration/bootstrap_tester.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ type BootstrapTester struct {
}

func NewBootstrapTester() (*BootstrapTester, error) {
homeDir, err := os.MkdirTemp("", "home")
homeDir, err := os.MkdirTemp("/tmp", "home")
if err != nil {
return nil, fmt.Errorf("making home directory: %w", err)
}
Expand Down
146 changes: 146 additions & 0 deletions bootstrap/integration/job_api_integration_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 2 additions & 2 deletions bootstrap/integration/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down

0 comments on commit 49f2581

Please sign in to comment.