Skip to content

Commit 11c2e5a

Browse files
committed
feat(grpc): allow files to be read by add_metadata_jmespath_expression
This allows add_metadata_jmespath_expression to refer to the contents of certain pre-declared files which are reloaded on a user-specified schedule. For example, if you want to use k8s bound service account tokens you could add the following to your Pod: volumes: - name: token-vol projected: sources: - serviceAccountToken: audience: my-buildbarn-instance expirationSeconds: 3600 path: buildbarn Assuming this is mounted at '/tokens', you could then specify in your buildbarn config: addMetadataJmespathExpression: ||| { "authorization": [std.format('bearer %s', files.token)] } |||, addMetadataJmespathFiles: [ { key: "token", path: "/tokens/buildbarn", refreshInterval: "1800s", } ] This is quite useful for k8s service account tokens, as the maximum validity is often capped. Likewise this can also be used for Google service account id tokens, which also have a relatively short maximum validity.
1 parent 89b9202 commit 11c2e5a

6 files changed

Lines changed: 300 additions & 73 deletions

File tree

pkg/grpc/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ go_test(
143143
"@org_golang_google_grpc//peer",
144144
"@org_golang_google_grpc//status",
145145
"@org_golang_google_protobuf//proto",
146+
"@org_golang_google_protobuf//types/known/durationpb",
146147
"@org_golang_google_protobuf//types/known/emptypb",
147148
"@org_golang_google_protobuf//types/known/structpb",
148149
"@org_uber_go_mock//gomock",

pkg/grpc/base_client_factory.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,11 +179,15 @@ func (cf baseClientFactory) NewClientFromConfiguration(config *configuration.Cli
179179

180180
// Optional: metadata extraction.
181181
if jmesExpression := config.AddMetadataJmespathExpression; jmesExpression != "" {
182+
filesProvider, err := NewJMESPathMetadataFileProvider(context.Background(), config.AddMetadataJmespathFiles)
183+
if err != nil {
184+
return nil, util.StatusWrap(err, "Failed to create JMESPath metadata file provider")
185+
}
182186
expr, err := jmespath.Compile(jmesExpression)
183187
if err != nil {
184188
return nil, util.StatusWrap(err, "Failed to compile JMESPath expression")
185189
}
186-
extractor, err := NewJMESPathMetadataExtractor(expr)
190+
extractor, err := NewJMESPathMetadataExtractor(expr, filesProvider)
187191
if err != nil {
188192
return nil, util.StatusWrap(err, "Failed to create JMESPath extractor")
189193
}

pkg/grpc/jmespath_extractor.go

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ package grpc
22

33
import (
44
"context"
5+
"log"
6+
"os"
7+
"sync"
8+
"time"
59

610
"github.com/buildbarn/bb-storage/pkg/auth"
11+
pb "github.com/buildbarn/bb-storage/pkg/proto/configuration/grpc"
712
"github.com/buildbarn/bb-storage/pkg/util"
813
"github.com/jmespath/go-jmespath"
914

@@ -22,19 +27,24 @@ import (
2227
//
2328
// {
2429
// "authenticationMetadata": value,
30+
// "files": map<string, string>,
2531
// "incomingGRPCMetadata": map<string, repeated string>
2632
// }
27-
func NewJMESPathMetadataExtractor(expression *jmespath.JMESPath) (MetadataExtractor, error) {
33+
func NewJMESPathMetadataExtractor(expression *jmespath.JMESPath, files *MetadataFileProvider) (MetadataExtractor, error) {
2834
return func(ctx context.Context) (MetadataHeaderValues, error) {
29-
searchContext := make(map[string]interface{}, 2)
35+
searchContext := make(map[string]interface{}, 3)
3036
if authenticationMetadata := auth.AuthenticationMetadataFromContext(ctx); authenticationMetadata != nil {
3137
searchContext["authenticationMetadata"] = authenticationMetadata.GetRaw()
3238
}
3339

40+
if files != nil {
41+
searchContext["files"] = files.getMetadata()
42+
}
43+
3444
if md, ok := metadata.FromIncomingContext(ctx); ok {
3545
// JMESPath only treats map[string]interface{}, struct, or *struct as map types,
3646
// so we need to copy from the map[string][]string.
37-
incomingGRPCMetadata := make(map[string]interface{}, len(md))
47+
incomingGRPCMetadata := make(map[string]any, len(md))
3848
for k, rawVs := range md {
3949
vs := make([]interface{}, 0, len(rawVs))
4050
for _, rawV := range rawVs {
@@ -84,3 +94,67 @@ func matchToHeaders(rawMatch interface{}) (MetadataHeaderValues, error) {
8494
}
8595
return headers, nil
8696
}
97+
98+
// MetadataFileProvider makes the contents of files available as
99+
// metadata to JMESPath expressions.
100+
type MetadataFileProvider struct {
101+
lock sync.RWMutex
102+
files []*pb.ClientConfiguration_RefreshedFile
103+
currentContents map[string]string
104+
}
105+
106+
// NewJMESPathMetadataFileProvider creates a MetadataFileProvider that
107+
// reads files from the filesystem and makes their contents available
108+
// as metadata. The contents of the files are reloaded periodically.
109+
func NewJMESPathMetadataFileProvider(ctx context.Context, files []*pb.ClientConfiguration_RefreshedFile) (*MetadataFileProvider, error) {
110+
provider := &MetadataFileProvider{
111+
files: files,
112+
currentContents: make(map[string]string),
113+
}
114+
for _, file := range files {
115+
contents, err := readFile(file.Path)
116+
if err != nil {
117+
return nil, err
118+
}
119+
provider.currentContents[file.Key] = contents
120+
go func() {
121+
t := time.NewTicker(file.RefreshInterval.AsDuration())
122+
for {
123+
select {
124+
case <-t.C:
125+
case <-ctx.Done():
126+
t.Stop()
127+
return
128+
}
129+
contents, err := readFile(file.Path)
130+
if err != nil {
131+
log.Printf("Failed to reload %s file: %v", file.Path, err)
132+
} else {
133+
provider.lock.Lock()
134+
provider.currentContents[file.Key] = contents
135+
provider.lock.Unlock()
136+
}
137+
}
138+
}()
139+
}
140+
141+
return provider, nil
142+
}
143+
144+
func readFile(path string) (string, error) {
145+
content, err := os.ReadFile(path)
146+
if err != nil {
147+
return "", util.StatusWrapf(err, "Failed to read %q", path)
148+
}
149+
return string(content), nil
150+
}
151+
152+
func (p *MetadataFileProvider) getMetadata() map[string]any {
153+
p.lock.RLock()
154+
defer p.lock.RUnlock()
155+
metadata := make(map[string]any, len(p.currentContents))
156+
for k, v := range p.currentContents {
157+
metadata[k] = v
158+
}
159+
return metadata
160+
}

pkg/grpc/jmespath_extractor_test.go

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ package grpc_test
22

33
import (
44
"context"
5+
"os"
6+
"path/filepath"
57
"testing"
8+
"time"
69

710
"github.com/buildbarn/bb-storage/pkg/auth"
811
"github.com/buildbarn/bb-storage/pkg/grpc"
912
auth_pb "github.com/buildbarn/bb-storage/pkg/proto/auth"
13+
pb "github.com/buildbarn/bb-storage/pkg/proto/configuration/grpc"
1014
"github.com/buildbarn/bb-storage/pkg/testutil"
1115
"github.com/buildbarn/bb-storage/pkg/util"
1216
"github.com/jmespath/go-jmespath"
@@ -15,6 +19,7 @@ import (
1519
"google.golang.org/grpc/codes"
1620
"google.golang.org/grpc/metadata"
1721
"google.golang.org/grpc/status"
22+
"google.golang.org/protobuf/types/known/durationpb"
1823
"google.golang.org/protobuf/types/known/structpb"
1924
)
2025

@@ -25,7 +30,7 @@ func TestJMESPathMetadataExtractorSimple(t *testing.T) {
2530
"this-is-static": ['and great'],
2631
"hdr-from-both": [incomingGRPCMetadata.whiz[0], authenticationMetadata.public],
2732
"optional-hdr": incomingGRPCMetadata.missing
28-
}`))
33+
}`), nil)
2934
require.NoError(t, err)
3035

3136
// We compare with metadata.Pairs because JMESPath evaluation traverses maps
@@ -52,7 +57,7 @@ func TestJMESPathMetadataExtractorSimple(t *testing.T) {
5257
}
5358

5459
func TestJMESPathMetadataExtractorAuthMatchToString(t *testing.T) {
55-
extractor, err := grpc.NewJMESPathMetadataExtractor(jmespath.MustCompile(`{"hdr": authenticationMetadata.public}`))
60+
extractor, err := grpc.NewJMESPathMetadataExtractor(jmespath.MustCompile(`{"hdr": authenticationMetadata.public}`), nil)
5661
require.NoError(t, err)
5762

5863
// The resulting header value must be a list. Yielding a string
@@ -66,7 +71,7 @@ func TestJMESPathMetadataExtractorAuthMatchToString(t *testing.T) {
6671
}
6772

6873
func TestJMESPathMetadataExtractorAuthMatchToHeterogenousSlice(t *testing.T) {
69-
extractor, err := grpc.NewJMESPathMetadataExtractor(jmespath.MustCompile(`{"hdr": authenticationMetadata.public}`))
74+
extractor, err := grpc.NewJMESPathMetadataExtractor(jmespath.MustCompile(`{"hdr": authenticationMetadata.public}`), nil)
7075
require.NoError(t, err)
7176

7277
// Each of the header values should be a valid string. Integer
@@ -83,3 +88,54 @@ func TestJMESPathMetadataExtractorAuthMatchToHeterogenousSlice(t *testing.T) {
8388
_, err = extractor(ctx)
8489
testutil.RequireEqualStatus(t, status.Errorf(codes.InvalidArgument, "Failed to extract JMESPath result: Non-string metadata value"), err)
8590
}
91+
92+
func TestNewJMESPathMetadataFileProvider(t *testing.T) {
93+
// Create a temporary file with test content.
94+
tempDir := t.TempDir()
95+
filePath := filepath.Join(tempDir, "test-token")
96+
err := os.WriteFile(filePath, []byte("token-value1"), 0o644)
97+
require.NoError(t, err)
98+
99+
// Build the extractor.
100+
context, cancel := context.WithCancel(context.Background())
101+
provider, err := grpc.NewJMESPathMetadataFileProvider(context, []*pb.ClientConfiguration_RefreshedFile{
102+
{
103+
Key: "token",
104+
Path: filePath,
105+
RefreshInterval: durationpb.New(time.Millisecond),
106+
},
107+
})
108+
require.NoError(t, err)
109+
extractor, err := grpc.NewJMESPathMetadataExtractor(
110+
jmespath.MustCompile(`{"authorization": [files.token]}`),
111+
provider,
112+
)
113+
require.NoError(t, err)
114+
115+
// Validate the initial contents are correct.
116+
headers, err := extractor(context)
117+
require.NoError(t, err)
118+
want := grpc.MetadataHeaderValues([]string{
119+
"authorization", "token-value1",
120+
})
121+
require.Equal(t, want, headers)
122+
123+
// Modify the file.
124+
err = os.WriteFile(filePath, []byte("token-value2"), 0o644)
125+
require.NoError(t, err)
126+
127+
// Wait for the file to be reloaded. This is potentially fragile.
128+
time.Sleep(time.Second)
129+
130+
// Validate the updated contents are correct.
131+
headers, err = extractor(context)
132+
require.NoError(t, err)
133+
want = grpc.MetadataHeaderValues([]string{
134+
"authorization", "token-value2",
135+
})
136+
require.Equal(t, want, headers)
137+
138+
// Cancel the context to stop the file reloading.
139+
cancel()
140+
time.Sleep(time.Second)
141+
}

0 commit comments

Comments
 (0)