Skip to content

Commit 2d62c48

Browse files
committed
Add support for STS endpoint in the Bucket API
Signed-off-by: Matheus Pimenta <[email protected]>
1 parent 58b4e6d commit 2d62c48

File tree

7 files changed

+270
-40
lines changed

7 files changed

+270
-40
lines changed

api/v1beta2/bucket_types.go

+8
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ type BucketSpec struct {
6666
// +required
6767
Endpoint string `json:"endpoint"`
6868

69+
// STSEndpoint is the HTTP/S endpoint of the Security Token Service from
70+
// where temporary credentials will automatically be fetched in the
71+
// absence of a Secret reference.
72+
//
73+
// This field is only supported for the `generic` and `aws` providers.
74+
// +optional
75+
STSEndpoint string `json:"stsEndpoint,omitempty"`
76+
6977
// Insecure allows connecting to a non-TLS HTTP Endpoint.
7078
// +optional
7179
Insecure bool `json:"insecure,omitempty"`

config/crd/bases/source.toolkit.fluxcd.io_buckets.yaml

+9
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,15 @@ spec:
420420
required:
421421
- name
422422
type: object
423+
stsEndpoint:
424+
description: |-
425+
STSEndpoint is the HTTP/S endpoint of the Security Token Service from
426+
where temporary credentials will automatically be fetched in the
427+
absence of a Secret reference.
428+
429+
430+
This field is only supported for the `generic` and `aws` providers.
431+
type: string
423432
suspend:
424433
description: |-
425434
Suspend tells the controller to suspend the reconciliation of this

docs/api/v1beta2/source.md

+30
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,21 @@ string
114114
</tr>
115115
<tr>
116116
<td>
117+
<code>stsEndpoint</code><br>
118+
<em>
119+
string
120+
</em>
121+
</td>
122+
<td>
123+
<em>(Optional)</em>
124+
<p>STSEndpoint is the HTTP/S endpoint of the Security Token Service from
125+
where temporary credentials will automatically be fetched in the
126+
absence of a Secret reference.</p>
127+
<p>This field is only supported for the <code>generic</code> and <code>aws</code> providers.</p>
128+
</td>
129+
</tr>
130+
<tr>
131+
<td>
117132
<code>insecure</code><br>
118133
<em>
119134
bool
@@ -1480,6 +1495,21 @@ string
14801495
</tr>
14811496
<tr>
14821497
<td>
1498+
<code>stsEndpoint</code><br>
1499+
<em>
1500+
string
1501+
</em>
1502+
</td>
1503+
<td>
1504+
<em>(Optional)</em>
1505+
<p>STSEndpoint is the HTTP/S endpoint of the Security Token Service from
1506+
where temporary credentials will automatically be fetched in the
1507+
absence of a Secret reference.</p>
1508+
<p>This field is only supported for the <code>generic</code> and <code>aws</code> providers.</p>
1509+
</td>
1510+
</tr>
1511+
<tr>
1512+
<td>
14831513
<code>insecure</code><br>
14841514
<em>
14851515
bool

docs/spec/v1beta2/buckets.md

+8
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,14 @@ HTTP endpoint requires enabling [`.spec.insecure`](#insecure).
749749
Some endpoints require the specification of a [`.spec.region`](#region),
750750
see [Provider](#provider) for more (provider specific) examples.
751751

752+
### STS Endpoint
753+
754+
`.spec.stsEndpoint` is an optional field that specifies the HTTP/S endpoint
755+
of the Security Token Service from where temporary credentials will automatically
756+
be fetched in the absence of a Secret reference.
757+
758+
This field is only supported for the `generic` and `aws` providers.
759+
752760
### Bucket name
753761

754762
`.spec.bucketName` is a required field that specifies which object storage

pkg/minio/minio.go

+26-15
Original file line numberDiff line numberDiff line change
@@ -88,21 +88,6 @@ func NewClient(bucket *sourcev1.Bucket, opts ...Option) (*MinioClient, error) {
8888
// auto access, which we believe can cover most use cases.
8989
}
9090

91-
if secret != nil {
92-
var accessKey, secretKey string
93-
if k, ok := secret.Data["accesskey"]; ok {
94-
accessKey = string(k)
95-
}
96-
if k, ok := secret.Data["secretkey"]; ok {
97-
secretKey = string(k)
98-
}
99-
if accessKey != "" && secretKey != "" {
100-
minioOpts.Creds = credentials.NewStaticV4(accessKey, secretKey, "")
101-
}
102-
} else if bucket.Spec.Provider == sourcev1.AmazonBucketProvider {
103-
minioOpts.Creds = credentials.NewIAM("")
104-
}
105-
10691
var transportOpts []func(*http.Transport)
10792

10893
if minioOpts.Secure && tlsConfig != nil {
@@ -117,6 +102,32 @@ func NewClient(bucket *sourcev1.Bucket, opts ...Option) (*MinioClient, error) {
117102
})
118103
}
119104

105+
if secret != nil {
106+
var accessKey, secretKey string
107+
if k, ok := secret.Data["accesskey"]; ok {
108+
accessKey = string(k)
109+
}
110+
if k, ok := secret.Data["secretkey"]; ok {
111+
secretKey = string(k)
112+
}
113+
if accessKey != "" && secretKey != "" {
114+
minioOpts.Creds = credentials.NewStaticV4(accessKey, secretKey, "")
115+
}
116+
} else if bucket.Spec.Provider == sourcev1.AmazonBucketProvider || bucket.Spec.STSEndpoint != "" {
117+
creds := credentials.NewIAM(bucket.Spec.STSEndpoint)
118+
if len(transportOpts) > 0 {
119+
transport := http.DefaultTransport.(*http.Transport).Clone()
120+
for _, opt := range transportOpts {
121+
opt(transport)
122+
}
123+
creds = credentials.New(&credentials.IAM{
124+
Client: &http.Client{Transport: transport},
125+
Endpoint: bucket.Spec.STSEndpoint,
126+
})
127+
}
128+
minioOpts.Creds = creds
129+
}
130+
120131
if len(transportOpts) > 0 {
121132
transport, err := minio.DefaultTransport(minioOpts.Secure)
122133
if err != nil {

pkg/minio/minio_test.go

+136-25
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"context"
2121
"crypto/tls"
2222
"crypto/x509"
23+
"encoding/json"
2324
"errors"
2425
"fmt"
2526
"log"
@@ -28,13 +29,14 @@ import (
2829
"net/url"
2930
"os"
3031
"path/filepath"
32+
"strconv"
3133
"strings"
3234
"testing"
3335
"time"
3436

35-
"github.com/elazarl/goproxy"
3637
"github.com/google/uuid"
3738
miniov7 "github.com/minio/minio-go/v7"
39+
"github.com/minio/minio-go/v7/pkg/credentials"
3840
"github.com/ory/dockertest/v3"
3941
"github.com/ory/dockertest/v3/docker"
4042
"gotest.tools/assert"
@@ -45,6 +47,7 @@ import (
4547
"github.com/fluxcd/pkg/sourceignore"
4648

4749
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
50+
testproxy "github.com/fluxcd/source-controller/tests/proxy"
4851
)
4952

5053
const (
@@ -244,34 +247,142 @@ func TestFGetObject(t *testing.T) {
244247
assert.NilError(t, err)
245248
}
246249

247-
func TestNewClientAndFGetObjectWithProxy(t *testing.T) {
250+
func TestNewClientAndFGetObjectWithSTSEndpoint(t *testing.T) {
251+
// start a mock STS server
252+
stsListener, err := net.Listen("tcp", ":0")
253+
assert.NilError(t, err, "could not start STS listener")
254+
defer stsListener.Close()
255+
stsAddr := stsListener.Addr().String()
256+
stsEndpoint := fmt.Sprintf("http://%s", stsAddr)
257+
stsAddrParts := strings.Split(stsAddr, ":")
258+
stsPortStr := stsAddrParts[len(stsAddrParts)-1]
259+
stsPort, err := strconv.Atoi(stsPortStr)
260+
assert.NilError(t, err)
261+
stsHandler := http.NewServeMux()
262+
stsHandler.HandleFunc("PUT "+credentials.TokenPath,
263+
func(w http.ResponseWriter, r *http.Request) {
264+
_, err := w.Write([]byte("mock-token"))
265+
assert.NilError(t, err)
266+
})
267+
stsHandler.HandleFunc("GET "+credentials.DefaultIAMSecurityCredsPath,
268+
func(w http.ResponseWriter, r *http.Request) {
269+
token := r.Header.Get(credentials.TokenRequestHeader)
270+
assert.Equal(t, token, "mock-token")
271+
_, err := w.Write([]byte("mock-role"))
272+
assert.NilError(t, err)
273+
})
274+
var roleCredsRetrieved bool
275+
stsHandler.HandleFunc("GET "+credentials.DefaultIAMSecurityCredsPath+"mock-role",
276+
func(w http.ResponseWriter, r *http.Request) {
277+
token := r.Header.Get(credentials.TokenRequestHeader)
278+
assert.Equal(t, token, "mock-token")
279+
err := json.NewEncoder(w).Encode(map[string]any{
280+
"Code": "Success",
281+
"AccessKeyID": testMinioRootUser,
282+
"SecretAccessKey": testMinioRootPassword,
283+
})
284+
assert.NilError(t, err)
285+
roleCredsRetrieved = true
286+
})
287+
stsServer := &http.Server{
288+
Addr: stsAddr,
289+
Handler: stsHandler,
290+
}
291+
go stsServer.Serve(stsListener)
292+
defer stsServer.Shutdown(context.Background())
293+
248294
// start proxy
249-
proxyListener, err := net.Listen("tcp", ":0")
250-
assert.NilError(t, err, "could not start proxy server")
251-
defer proxyListener.Close()
252-
proxyAddr := proxyListener.Addr().String()
253-
proxyHandler := goproxy.NewProxyHttpServer()
254-
proxyHandler.Verbose = true
255-
proxyServer := &http.Server{
256-
Addr: proxyAddr,
257-
Handler: proxyHandler,
295+
proxyAddr, proxyPort := testproxy.New(t)
296+
297+
tests := []struct {
298+
name string
299+
stsEndpoint string
300+
opts []Option
301+
errSubstring string
302+
}{
303+
{
304+
name: "with correct endpoint",
305+
stsEndpoint: stsEndpoint,
306+
},
307+
{
308+
name: "with incorrect endpoint",
309+
stsEndpoint: fmt.Sprintf("http://localhost:%d", stsPort+1),
310+
errSubstring: "connection refused",
311+
},
312+
{
313+
name: "with correct endpoint and proxy",
314+
stsEndpoint: stsEndpoint,
315+
opts: []Option{WithProxyURL(&url.URL{Scheme: "http", Host: proxyAddr})},
316+
},
317+
{
318+
name: "with correct endpoint and incorrect proxy",
319+
stsEndpoint: stsEndpoint,
320+
opts: []Option{WithProxyURL(&url.URL{Scheme: "http", Host: fmt.Sprintf("localhost:%d", proxyPort+1)})},
321+
errSubstring: "connection refused",
322+
},
323+
}
324+
325+
for _, tt := range tests {
326+
t.Run(tt.name, func(t *testing.T) {
327+
roleCredsRetrieved = false
328+
bucket := bucketStub(bucket, testMinioAddress)
329+
bucket.Spec.STSEndpoint = tt.stsEndpoint
330+
minioClient, err := NewClient(bucket, append(tt.opts, WithTLSConfig(testTLSConfig))...)
331+
assert.NilError(t, err)
332+
assert.Assert(t, minioClient != nil)
333+
ctx := context.Background()
334+
tempDir := t.TempDir()
335+
path := filepath.Join(tempDir, sourceignore.IgnoreFile)
336+
_, err = minioClient.FGetObject(ctx, bucketName, objectName, path)
337+
if tt.errSubstring != "" {
338+
assert.ErrorContains(t, err, tt.errSubstring)
339+
} else {
340+
assert.NilError(t, err)
341+
assert.Assert(t, roleCredsRetrieved)
342+
}
343+
})
344+
}
345+
}
346+
347+
func TestNewClientAndFGetObjectWithProxy(t *testing.T) {
348+
proxyAddr, proxyPort := testproxy.New(t)
349+
350+
tests := []struct {
351+
name string
352+
proxyURL *url.URL
353+
errSubstring string
354+
}{
355+
{
356+
name: "with correct proxy",
357+
proxyURL: &url.URL{Scheme: "http", Host: proxyAddr},
358+
},
359+
{
360+
name: "with incorrect proxy",
361+
proxyURL: &url.URL{Scheme: "http", Host: fmt.Sprintf("localhost:%d", proxyPort+1)},
362+
errSubstring: "connection refused",
363+
},
258364
}
259-
go proxyServer.Serve(proxyListener)
260-
defer proxyServer.Shutdown(context.Background())
261-
proxyURL := &url.URL{Scheme: "http", Host: proxyAddr}
262365

263366
// run test
264-
minioClient, err := NewClient(bucketStub(bucket, testMinioAddress),
265-
WithSecret(secret.DeepCopy()),
266-
WithTLSConfig(testTLSConfig),
267-
WithProxyURL(proxyURL))
268-
assert.NilError(t, err)
269-
assert.Assert(t, minioClient != nil)
270-
ctx := context.Background()
271-
tempDir := t.TempDir()
272-
path := filepath.Join(tempDir, sourceignore.IgnoreFile)
273-
_, err = minioClient.FGetObject(ctx, bucketName, objectName, path)
274-
assert.NilError(t, err)
367+
for _, tt := range tests {
368+
t.Run(tt.name, func(t *testing.T) {
369+
minioClient, err := NewClient(bucketStub(bucket, testMinioAddress),
370+
WithSecret(secret.DeepCopy()),
371+
WithTLSConfig(testTLSConfig),
372+
WithProxyURL(tt.proxyURL))
373+
assert.NilError(t, err)
374+
assert.Assert(t, minioClient != nil)
375+
ctx := context.Background()
376+
tempDir := t.TempDir()
377+
path := filepath.Join(tempDir, sourceignore.IgnoreFile)
378+
_, err = minioClient.FGetObject(ctx, bucketName, objectName, path)
379+
if tt.errSubstring != "" {
380+
assert.ErrorContains(t, err, tt.errSubstring)
381+
} else {
382+
assert.NilError(t, err)
383+
}
384+
})
385+
}
275386
}
276387

277388
func TestFGetObjectNotExists(t *testing.T) {

tests/proxy/proxy.go

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
Copyright 2024 The Flux authors
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
http://www.apache.org/licenses/LICENSE-2.0
7+
Unless required by applicable law or agreed to in writing, software
8+
distributed under the License is distributed on an "AS IS" BASIS,
9+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
See the License for the specific language governing permissions and
11+
limitations under the License.
12+
*/
13+
14+
package testproxy
15+
16+
import (
17+
"net"
18+
"net/http"
19+
"strconv"
20+
"strings"
21+
"testing"
22+
23+
"github.com/elazarl/goproxy"
24+
"gotest.tools/assert"
25+
)
26+
27+
// New creates a new goproxy server on a random port and returns
28+
// the address and the port of this server. It also registers a
29+
// cleanup functions to close the server and the listener when
30+
// the test ends.
31+
func New(t *testing.T) (string, int) {
32+
lis, err := net.Listen("tcp", ":0")
33+
assert.NilError(t, err, "could not start proxy server")
34+
t.Cleanup(func() { lis.Close() })
35+
36+
addr := lis.Addr().String()
37+
addrParts := strings.Split(addr, ":")
38+
portStr := addrParts[len(addrParts)-1]
39+
port, err := strconv.Atoi(portStr)
40+
assert.NilError(t, err)
41+
42+
handler := goproxy.NewProxyHttpServer()
43+
handler.Verbose = true
44+
45+
server := &http.Server{
46+
Addr: addr,
47+
Handler: handler,
48+
}
49+
go server.Serve(lis)
50+
t.Cleanup(func() { server.Close() })
51+
52+
return addr, port
53+
}

0 commit comments

Comments
 (0)