Skip to content

Commit

Permalink
add support for remote execution APIs
Browse files Browse the repository at this point in the history
  • Loading branch information
malt3 committed Feb 22, 2025
1 parent 4108d0a commit 34169c3
Show file tree
Hide file tree
Showing 11 changed files with 397 additions and 0 deletions.
15 changes: 15 additions & 0 deletions authenticate/internal/lookupchain/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
load("@rules_go//go:def.bzl", "go_library")

go_library(
name = "lookupchain",
srcs = ["lookupchain.go"],
importpath = "github.com/tweag/credential-helper/authenticate/internal/lookupchain",
visibility = ["//authenticate:__subpackages__"],
deps = ["@com_github_zalando_go_keyring//:go-keyring"],
)

filegroup(
name = "all_files",
srcs = glob(["*"]),
visibility = ["//:__subpackages__"],
)
186 changes: 186 additions & 0 deletions authenticate/internal/lookupchain/lookupchain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package lookupchain

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"reflect"
"strings"

keyring "github.com/zalando/go-keyring"
)

const (
SourceEnv = "env"
SourceKeyring = "keyring"
)

type LookupChain struct {
config Config
}

func New(config Config) *LookupChain {
return &LookupChain{config: config}
}

// Lookup looks up a binding in the chain.
// It returns the first value found, or an error.
func (c *LookupChain) Lookup(binding string) (string, error) {
if len(c.config) == 0 {
return "", fmt.Errorf("no sources configured to look up binding %q", binding)
}
for _, entry := range c.config {
source, err := c.sourceFor(entry)
if err != nil {
return "", fmt.Errorf("looking up binding %q: %w", binding, err)
}
result, err := source.Lookup(binding)
if err == nil {
return result, nil
}
if errors.Is(err, notFoundError) {
continue
}
return "", fmt.Errorf("looking up binding %q: %w", binding, err)
}
sourceNames := make([]string, len(c.config))
for i, entry := range c.config {
sourceNames[i] = entry.Source
}
return "", fmt.Errorf("no value found for binding %q after querying %v", binding, strings.Join(sourceNames, ", "))
}

func (c *LookupChain) sourceFor(entry ConfigEntry) (Source, error) {
decoder := json.NewDecoder(bytes.NewReader(entry.RawMessage))
decoder.DisallowUnknownFields()
var source Source

switch entry.Source {
case SourceEnv:
var env Env
if err := decoder.Decode(&env); err != nil {
return nil, fmt.Errorf("unmarshalling env source: %w", err)
}
source = &env
case SourceKeyring:
var keyring Keyring
if err := decoder.Decode(&keyring); err != nil {
return nil, fmt.Errorf("unmarshalling keyring source: %w", err)
}
source = &keyring
default:
return nil, fmt.Errorf("unknown source %q", entry.Source)
}

source.Canonicalize()
return source, nil
}

type Config []ConfigEntry

// ConfigEntry is a single entry in the lookup chain.
// This form is used when unmarshalling the config.
type ConfigEntry struct {
// Source is the name of the source used to look up the secret.
Source string `json:"source"`
json.RawMessage
}

type Source interface {
Lookup(binding string) (string, error)
Canonicalize()
}

type Env struct {
// Source is the name of the source used to look up the secret.
// It must be "env".
Source string `json:"source"`
// Name is the name of the environment variable to look up.
Name string `json:"name"`
// Binding binds the value of the environment variable to a well-known name in the helper.
// If not specified, the value is bound to the default secret of the helper.
Binding string `json:"binding,omitempty"`
}

func (e *Env) Lookup(binding string) (string, error) {
if e.Binding != binding {
return "", notFoundError
}
val, ok := os.LookupEnv(e.Name)
if !ok {
return "", notFoundError
}
return val, nil
}

func (e *Env) Canonicalize() {
e.Source = "env"
if e.Binding == "" {
e.Binding = "default"
}
}

type Keyring struct {
// Source is the name of the source used to look up the secret.
// It must be "keyring".
Source string `json:"source"`
// Key is the name of the key to look up in the keyring.
Key string `json:"key"`
// Binding binds the value of the keyring secret to a well-known name in the helper.
// If not specified, the value is bound to the default secret of the helper.
Binding string `json:"binding,omitempty"`
}

func (k *Keyring) Lookup(binding string) (string, error) {
if k.Binding != binding {
return "", notFoundError
}
val, err := keyring.Get("gh:github.com", "")
if errors.Is(err, keyring.ErrNotFound) {
return "", notFoundError
}
if err != nil {
return "", err
}
return val, nil
}

func (k *Keyring) Canonicalize() {
k.Source = "keyring"
if k.Binding == "" {
k.Binding = "default"
}
}

// Default constructs a partially marshalled Config from a slice of specific config entries.
func Default(in []Source) Config {
out := make(Config, len(in))
for i, entry := range in {
// TODO: fix
canonicalizeMethod := reflect.ValueOf(entry).MethodByName("Canonicalize")

if !canonicalizeMethod.IsValid() {
panic(fmt.Sprintf("constructing default config: invalid value at index %d is missing Canonicalize method", i))
}
canonicalizeMethod.Call(nil)

sourceField := reflect.ValueOf(entry).Elem().FieldByName("Source")
if !sourceField.IsValid() || sourceField.Type().Kind() != reflect.String {
panic(fmt.Sprintf("constructing default config: invalid value at index %d is missing Source field", i))
}

raw, err := json.Marshal(entry)
if err != nil {
panic(fmt.Sprintf("constructing default config: invalid value at index %d when marshaling inner config: %v", i, err))
}
out[i] = ConfigEntry{
Source: sourceField.String(),
RawMessage: raw,
}
}
return out
}

var notFoundError = errors.New("not found")
20 changes: 20 additions & 0 deletions authenticate/remoteapis/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
load("@rules_go//go:def.bzl", "go_library")

go_library(
name = "remoteapis",
srcs = ["remoteapis.go"],
importpath = "github.com/tweag/credential-helper/authenticate/remoteapis",
visibility = ["//visibility:public"],
deps = [
"//api",
"//authenticate/internal/helperconfig",
"//authenticate/internal/lookupchain",
"//logging",
],
)

filegroup(
name = "all_files",
srcs = glob(["*"]),
visibility = ["//:__subpackages__"],
)
154 changes: 154 additions & 0 deletions authenticate/remoteapis/remoteapis.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package remoteapis

import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/url"
"strings"

"github.com/tweag/credential-helper/api"
"github.com/tweag/credential-helper/authenticate/internal/helperconfig"
"github.com/tweag/credential-helper/authenticate/internal/lookupchain"
"github.com/tweag/credential-helper/logging"
)

// well-known grpc names (name of the Java package and the name of the service in the .proto file)
const (
GOOGLE_BYTESTREAM_BYTESTREAM = "google.bytestream.ByteStream"
GOOGLE_DEVTOOLS_BUILD_V1_PUBLISHBUILDEVENT = "google.devtools.build.v1.PublishBuildEvent"
REMOTE_ASSET_V1_FETCH = "build.bazel.remote.asset.v1.Fetch"
REMOTE_ASSET_V1_PUSH = "build.bazel.remote.asset.v1.Push"
REMOTE_EXECUTION_V2_ACTIONCACHE = "build.bazel.remote.execution.v2.ActionCache"
REMOTE_EXECUTION_V2_CAPABILITIES = "build.bazel.remote.execution.v2.Capabilities"
REMOTE_EXECUTION_V2_CONTENTADDRESSABLESTORAGE = "build.bazel.remote.execution.v2.ContentAddressableStorage"
REMOTE_EXECUTION_V2_EXECUTION = "build.bazel.remote.execution.v2.Execution"
)

type RemoteAPIs struct{}

// CacheKey returns a cache key for the given request.
// For remote apis, the full URI is a good cache key.
func (g *RemoteAPIs) CacheKey(req api.GetCredentialsRequest) string {
return req.URI
}

func (RemoteAPIs) Resolver(ctx context.Context) (api.Resolver, error) {
return &RemoteAPIs{}, nil
}

// Get implements the get command of the credential-helper spec:
//
// https://github.com/EngFlow/credential-helper-spec/blob/main/spec.md#get
func (g *RemoteAPIs) Get(ctx context.Context, req api.GetCredentialsRequest) (api.GetCredentialsResponse, error) {
cfg, err := helperconfig.FromContext(ctx, configFragment{
AuthMethod: "header",
LookupChain: lookupchain.Default([]lookupchain.Source{
&lookupchain.Env{
Source: "env",
Name: "CREDENTIAL_HELPER_REMOTEAPIS_SECRET",
Binding: "default",
},
&lookupchain.Keyring{
Source: "keyring",
Key: "tweag-credential-helper:remoteapis",
Binding: "default",
},
}),
})
if err != nil {
return api.GetCredentialsResponse{}, fmt.Errorf("getting configuration fragment for remotapis helper and url %s: %w", req.URI, err)
}
chain := lookupchain.New(cfg.LookupChain)
secret, err := chain.Lookup("default")
if err != nil {
return api.GetCredentialsResponse{}, err
}

parsedURL, error := url.Parse(req.URI)
if error != nil {
return api.GetCredentialsResponse{}, error
}

// the scheme for remote APIs appears to be https for all of the following:
// - https://
// - grpc://
// - grpcs://
//
// only unencrypted http:// (using the HTTP/1.1 cache protocl) uses a different scheme
// for simplicity, we only support the grpc(s) remote APIs here
if parsedURL.Scheme != "https" && parsedURL.Scheme != "grpc" && parsedURL.Scheme != "grpcs" {
if parsedURL.Scheme == "grpc" || parsedURL.Scheme == "grpcs" {
logging.Errorf("expecting to see https scheme (which is the URI that Bazel norrmally forwards to the credential helper for remoteapis), but got %q", parsedURL.Scheme)
} else {
return api.GetCredentialsResponse{}, fmt.Errorf("only https, grpc, and grpcs are supported, but got %q", parsedURL.Scheme)
}
}

// the following only works for grpc (and not HTTP/1.1)
rpcName, hasPrefix := strings.CutPrefix(parsedURL.Path, "/")
if !hasPrefix {
return api.GetCredentialsResponse{}, errors.New("remote execution API path must start with /")
}
switch rpcName {
default:
return api.GetCredentialsResponse{}, fmt.Errorf("unknown remote execution API path %q - maybe you are trying to use HTTP/1.1, but currently only gRPC is supported", parsedURL.Path)
case GOOGLE_BYTESTREAM_BYTESTREAM:
case GOOGLE_DEVTOOLS_BUILD_V1_PUBLISHBUILDEVENT:
case REMOTE_ASSET_V1_FETCH:
case REMOTE_ASSET_V1_PUSH:
case REMOTE_EXECUTION_V2_ACTIONCACHE:
case REMOTE_EXECUTION_V2_CAPABILITIES:
case REMOTE_EXECUTION_V2_CONTENTADDRESSABLESTORAGE:
case REMOTE_EXECUTION_V2_EXECUTION:
}

headerName := cfg.HeaderName
secretEncoding := func(secret string) string {
// by default, the secret is directly used as a header value
return secret
}
switch cfg.AuthMethod {
case "header":
if len(cfg.HeaderName) == 0 {
return api.GetCredentialsResponse{}, errors.New(`header_name must be set for auth method "header"`)
}
case "basic_auth":
// bazel-remote only supports basic auth.
// It tries to read the standard grpc metadata key ":authority" to get the username and password.
// This is special header that the credential helper cannot provide.
// As a fallback for proxies, bazel-remote also reads the grpc metadata key "authorization" to get the username and password encoded as a base64 string.
if len(cfg.HeaderName) > 0 {
headerName = cfg.HeaderName
} else {
headerName = "authorization"
}
secretEncoding = func(secret string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(secret))
}
default:
return api.GetCredentialsResponse{}, fmt.Errorf(`unknown auth method %q. Possible values are "header" and "basic_auth"`, cfg.AuthMethod)
}

return api.GetCredentialsResponse{
Headers: map[string][]string{
headerName: {secretEncoding(secret)},
},
}, nil
}

type configFragment struct {
// AuthMethod is the method used to authenticate with the remote API.
// Valid values are:
// - "header", which works BuildBuddy and other services that use a HTTP header directly. The secret is used as the value of the header.
// - "basic_auth", which works for bazel-remote. The secret should be of the form "username:password".
// It defaults to "header".
AuthMethod string `json:"auth_method"`
// HeaderName is the name of the header to set the secret in.
HeaderName string `json:"header_name"`
// LookupChain defines the order in which secrets are looked up from sources.
// Each element is a string that identifies a secret source.
// It defaults to the sources "env", "keyring".
LookupChain lookupchain.Config `json:"lookup_chain"`
}
2 changes: 2 additions & 0 deletions bzl/private/source_files/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ release_files = [
"//authenticate/github:all_files",
"//authenticate/internal:all_files",
"//authenticate/internal/helperconfig:all_files",
"//authenticate/internal/lookupchain:all_files",
"//authenticate/null:all_files",
"//authenticate/oci:all_files",
"//authenticate/remoteapis:all_files",
"//authenticate/s3:all_files",
"//bzl:all_files",
"//bzl/config:all_files",
Expand Down
Loading

0 comments on commit 34169c3

Please sign in to comment.