feat(ops/buildkite-api-proxy): init

This provides a very simple http server, receiving a git sha1 and
querying the buildkite api for the status - the same that's previously
done by the frontend, but now without exposing the (read-only) token
to users.

We can add caching / rate-limiting if the need arises, for now we
just propagate the `cache-control` headers (which seem to be set at
"cache-control: max-age=0, private, must-revalidate" currently anyways)

Part of #118.

Change-Id: I8989a74cb2b278139d988089ff8d6e59e00969e4
Reviewed-on: https://cl.snix.dev/c/snix/+/30403
Reviewed-by: edef <edef@edef.eu>
Tested-by: besadii
Autosubmit: Florian Klink <flokli@flokli.de>
This commit is contained in:
Florian Klink 2025-05-04 01:05:09 +03:00 committed by clbot
parent ba1e30cfa3
commit 54c313c9b2
4 changed files with 149 additions and 0 deletions

View file

@ -0,0 +1,14 @@
{ pkgs, lib, ... }:
pkgs.buildGoModule {
name = "buildkite-api-proxy";
src = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions [
./main.go
./go.mod
./go.sum
];
};
vendorHash = "sha256-YtLQYW1W+i9Zkw01kZf/xxJd1X5ttIutqlRpHNWvp4Y=";
}

View file

@ -0,0 +1,12 @@
module snix.dev/ops/buildkite-api-proxy
go 1.24.2
require (
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/samber/slog-http v1.6.0 // indirect
go.opentelemetry.io/otel v1.29.0 // indirect
go.opentelemetry.io/otel/trace v1.29.0 // indirect
golang.org/x/sync v0.13.0 // indirect
)

View file

@ -0,0 +1,15 @@
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU=
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/samber/slog-http v1.6.0 h1:+rD5QtOWGTcFT7jq8Yf0EgGy87krv0pcgh9jtWkrqjQ=
github.com/samber/slog-http v1.6.0/go.mod h1:PAcQQrYFo5KM7Qbk50gNNwKEAMGCyfsw6GN5dI0iv9g=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=

View file

@ -0,0 +1,108 @@
package main
import (
"context"
"fmt"
"io"
"log"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"github.com/coreos/go-systemd/v22/activation"
sloghttp "github.com/samber/slog-http"
"golang.org/x/sync/errgroup"
)
const BUILDKITE_BUILDS_ENDPOINT = "https://api.buildkite.com/v2/organizations/snix/pipelines/snix/builds"
var BUILDKITE_ACCESS_TOKEN = ""
func handler(w http.ResponseWriter, r *http.Request) {
// We only support /$commitSha1 requests, with $commitSha1 being 40 characters.
p := strings.TrimPrefix(r.URL.Path, "/")
if len(p) != 40 {
http.Error(w, "invalid commit hash", http.StatusNotFound)
return
}
// Only allow lowerhex
for _, c := range p {
if !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') {
http.Error(w, "invalid commit hash", http.StatusNotFound)
return
}
}
commit_sha1 := p
url := fmt.Sprintf("%v?commit=%v", BUILDKITE_BUILDS_ENDPOINT, commit_sha1)
rq, err := http.NewRequestWithContext(r.Context(), "GET", url, nil)
if err != nil {
panic(fmt.Errorf("unable to construct request: %w", err))
}
val := fmt.Sprintf("Bearer %s", BUILDKITE_ACCESS_TOKEN)
rq.Header.Add("Authorization", val)
resp, err := http.DefaultClient.Do(rq)
if err != nil {
panic(fmt.Errorf("unable to send request: %w", err))
}
w.Header().Add("content-type", resp.Header.Get("content-type"))
w.Header().Add("cache-control", resp.Header.Get("cache-control"))
io.Copy(w, resp.Body)
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
credentialsDirectory, found := os.LookupEnv("CREDENTIALS_DIRECTORY")
if !found {
log.Fatal("CREDENTIALS_DIRECTORY needs to be set")
}
p := filepath.Join(credentialsDirectory, "buildkite-api-token")
buildkiteToken, err := os.ReadFile(p)
if err != nil {
log.Fatalf("unable to read buildkite token: %s", err)
}
BUILDKITE_ACCESS_TOKEN = strings.TrimSuffix(string(buildkiteToken), "\n")
listeners, err := activation.Listeners()
if err != nil {
log.Fatalf("unable to get listeners: %s", err)
}
if len(listeners) == 0 {
log.Fatal("no listeners specified, did you configure socket activation correctly?")
}
g, ctx := errgroup.WithContext(ctx)
server := &http.Server{
Handler: sloghttp.New(logger)(http.HandlerFunc(handler)),
BaseContext: func(l net.Listener) context.Context {
return ctx
},
}
for _, listener := range listeners {
g.Go(func() error {
return server.Serve(listener)
})
}
if err := g.Wait(); err != nil {
panic(err)
}
<-ctx.Done()
}