-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
add support for remote execution APIs
- Loading branch information
Showing
11 changed files
with
397 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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__"], | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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__"], | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"` | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.