Skip to content

Commit 22baee0

Browse files
committed
s3: add Kubernetes Secrets credentials provider
1 parent a0dd21b commit 22baee0

File tree

5 files changed

+306
-4
lines changed

5 files changed

+306
-4
lines changed

Diff for: backends/s3/credentials.go

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package s3
2+
3+
import (
4+
"errors"
5+
"os"
6+
7+
"github.com/minio/minio-go/v7/pkg/credentials"
8+
)
9+
10+
// K8sSecretProvider is an implementation of Minio's credentials.Provider,
11+
// allowing to read credentials from Kubernetes secrets, as described in
12+
// https://kubernetes.io/docs/tasks/inject-data-application/distribute-credentials-secure.
13+
type K8sSecretProvider struct {
14+
// Path to the fiel containing the access key,
15+
// e.g. /etc/s3-secrets/access-key.
16+
AccessKeyFilename string `json:"access_key_file"`
17+
18+
// Path to the fiel containing the secret key,
19+
// e.g. /etc/s3-secrets/secret-key.
20+
SecretKeyFilename string `json:"secret_key_file"`
21+
}
22+
23+
// IsExpired implements credentials.Provider.
24+
// As there is no totally reliable way to tell
25+
// if a file was modified accross all filesystems except opening it,
26+
// we always return true, and p.Retrieve will open it regardless.
27+
func (*K8sSecretProvider) IsExpired() bool {
28+
return true
29+
}
30+
31+
// Retrieve implements credentials.Provider.
32+
// It reads files pointed to by p.AccessKeyFilename and p.SecretKeyFilename.
33+
func (p *K8sSecretProvider) Retrieve() (value credentials.Value, err error) {
34+
load := func(filename string, dst *string) {
35+
b, err1 := os.ReadFile(filename)
36+
if err1 != nil {
37+
err = errors.Join(err, err1)
38+
return
39+
}
40+
41+
*dst = string(b)
42+
}
43+
44+
load(p.AccessKeyFilename, &value.AccessKeyID)
45+
load(p.SecretKeyFilename, &value.SecretAccessKey)
46+
47+
return value, err
48+
}
49+
50+
var _ credentials.Provider = new(K8sSecretProvider)

Diff for: backends/s3/credentials_test.go

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package s3_test
2+
3+
import (
4+
"context"
5+
"io"
6+
"os"
7+
"testing"
8+
9+
"github.com/PowerDNS/simpleblob/backends/s3"
10+
"github.com/PowerDNS/simpleblob/backends/s3/s3testing"
11+
"github.com/minio/minio-go/v7"
12+
"github.com/minio/minio-go/v7/pkg/credentials"
13+
)
14+
15+
func TestPodProvider(t *testing.T) {
16+
if testing.Short() && !s3testing.HasLocalMinio() {
17+
t.Skip("Skipping test requiring downloading minio")
18+
}
19+
20+
_ = os.Chdir(t.TempDir())
21+
22+
// Instanciate provider (what we're testing).
23+
provider := &s3.K8sSecretProvider{
24+
AccessKeyFilename: "access-key",
25+
SecretKeyFilename: "secret-key",
26+
}
27+
28+
// writeFiles creates or overwrites provider files
29+
// with the same content.
30+
writeFiles := func(content string) {
31+
writeContent := func(filename string) {
32+
f, err := os.Create(filename)
33+
if err != nil {
34+
t.Fatal(err)
35+
}
36+
defer f.Close()
37+
if content == "" {
38+
return
39+
}
40+
_, err = io.WriteString(f, content)
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
}
45+
writeContent(provider.AccessKeyFilename)
46+
writeContent(provider.SecretKeyFilename)
47+
}
48+
49+
ctx := context.Background()
50+
ctx, cancel := context.WithCancel(ctx)
51+
defer cancel()
52+
53+
// Create server
54+
addr, stop, err := s3testing.ServeMinio(ctx, ".")
55+
if err != nil {
56+
t.Fatal(err)
57+
}
58+
defer stop()
59+
60+
// First credential files creation.
61+
// Keep them empty for now,
62+
// so that calls to the server will fail.
63+
writeFiles("")
64+
65+
// Create minio client, using our provider.
66+
creds := credentials.New(provider)
67+
clt, err := minio.New(addr, &minio.Options{
68+
Creds: creds,
69+
Region: "us-east-1",
70+
})
71+
if err != nil {
72+
t.Fatal(err)
73+
}
74+
75+
assertClientSuccess := func(want bool, when string) {
76+
_, err = clt.BucketExists(ctx, "doesnotmatter")
77+
s := "fail"
78+
if want {
79+
s = "succeed"
80+
}
81+
ok := (err == nil) == want
82+
if !ok {
83+
t.Fatalf("expected call to %s %s", s, when)
84+
}
85+
}
86+
87+
// The files do not hold the right values,
88+
// so a call to the server should fail.
89+
assertClientSuccess(false, "just after init")
90+
91+
// Write the right keys to the files.
92+
writeFiles(s3testing.AdminUserOrPassword)
93+
assertClientSuccess(true, "after changing files content")
94+
95+
// Change content of the files.
96+
writeFiles("badcredentials")
97+
assertClientSuccess(false, "after changing again, to bad credentials")
98+
}

Diff for: backends/s3/s3.go

+13-4
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ type Options struct {
4545
AccessKey string `yaml:"access_key"`
4646
SecretKey string `yaml:"secret_key"`
4747

48+
// Allow using custom credentials provider.
49+
Provider K8sSecretProvider `yaml:"kubernetes_secrets,omitempty"`
50+
4851
// Region defaults to "us-east-1", which also works for Minio
4952
Region string `yaml:"region"`
5053
Bucket string `yaml:"bucket"`
@@ -93,10 +96,11 @@ type Options struct {
9396
}
9497

9598
func (o Options) Check() error {
96-
if o.AccessKey == "" {
99+
hasProvider := o.Provider != K8sSecretProvider{}
100+
if !hasProvider && o.AccessKey == "" {
97101
return fmt.Errorf("s3 storage.options: access_key is required")
98102
}
99-
if o.SecretKey == "" {
103+
if !hasProvider && o.SecretKey == "" {
100104
return fmt.Errorf("s3 storage.options: secret_key is required")
101105
}
102106
if o.Bucket == "" {
@@ -342,8 +346,13 @@ func New(ctx context.Context, opt Options) (*Backend, error) {
342346
return nil, fmt.Errorf("unsupported scheme for S3: '%s', use http or https.", u.Scheme)
343347
}
344348

349+
creds := credentials.NewStaticV4(opt.AccessKey, opt.SecretKey, "")
350+
if opt.Provider != (K8sSecretProvider{}) {
351+
creds = credentials.New(&opt.Provider)
352+
}
353+
345354
cfg := &minio.Options{
346-
Creds: credentials.NewStaticV4(opt.AccessKey, opt.SecretKey, ""),
355+
Creds: creds,
347356
Secure: useSSL,
348357
Transport: hc.Transport,
349358
Region: opt.Region,
@@ -404,7 +413,7 @@ func convertMinioError(err error, isList bool) error {
404413
}
405414
errRes := minio.ToErrorResponse(err)
406415
if !isList && errRes.StatusCode == 404 {
407-
return fmt.Errorf("%w: %s", os.ErrNotExist, err.Error())
416+
return fmt.Errorf("%w: %s", os.ErrNotExist, err.Error())
408417
}
409418
if errRes.Code == "BucketAlreadyOwnedByYou" {
410419
return nil

Diff for: backends/s3/s3testing/minio.go

+131
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package s3testing
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"os"
10+
"os/exec"
11+
"runtime"
12+
"time"
13+
)
14+
15+
const (
16+
AdminUserOrPassword = "simpleblob"
17+
)
18+
19+
// ServeMinio starts a Minio server in the background,
20+
// and waits for it to be ready.
21+
// It returns its address,
22+
// and a function to stop the server gracefully.
23+
//
24+
// The admin username and password for the server are both "simpleblob".
25+
//
26+
// If the minio binary cannot be found locally,
27+
// it is downloaded by calling MinioBin.
28+
func ServeMinio(ctx context.Context, dir string) (string, func() error, error) {
29+
port, err := FreePort()
30+
if err != nil {
31+
return "", nil, err
32+
}
33+
addr := fmt.Sprintf("127.0.0.1:%d", port)
34+
35+
cmdname, err := MinioBin()
36+
if err != nil {
37+
return "", nil, err
38+
}
39+
40+
cmd := exec.CommandContext(ctx, cmdname, "server", "--quiet", "--address", addr, dir)
41+
cmd.Env = append(cmd.Environ(), "MINIO_BROWSER=off")
42+
cmd.Env = append(cmd.Env, "MINIO_ROOT_USER="+AdminUserOrPassword, "MINIO_ROOT_PASSWORD="+AdminUserOrPassword)
43+
cmd.Stdout = os.Stdout
44+
cmd.Stderr = os.Stderr
45+
if err := cmd.Start(); err != nil {
46+
return "", nil, err
47+
}
48+
49+
// Wait for server to accept requests.
50+
readyURL := "http://" + addr + "/minio/health/ready"
51+
ticker := time.NewTicker(30 * time.Millisecond)
52+
defer ticker.Stop()
53+
for {
54+
select {
55+
case <-ctx.Done():
56+
return "", nil, ctx.Err()
57+
case <-ticker.C:
58+
}
59+
60+
resp, err := http.Get(readyURL)
61+
if err == nil && resp.StatusCode == 200 {
62+
break
63+
}
64+
}
65+
66+
stop := func() error {
67+
_ = cmd.Process.Signal(os.Interrupt)
68+
return cmd.Wait()
69+
}
70+
return addr, stop, nil
71+
}
72+
73+
// minioExecPath is the cached path to minio binary,
74+
// that is returned when calling MinioBin.
75+
var minioExecPath string
76+
77+
// HasLocalMinio returns true if minio can be found in PATH.
78+
// If an error occurs while searching,
79+
// the function panics.
80+
func HasLocalMinio() bool {
81+
_, err := exec.LookPath("minio")
82+
if err != nil && !errors.Is(err, exec.ErrNotFound) {
83+
panic(err)
84+
}
85+
return err == nil
86+
}
87+
88+
// MinioBin tries to find minio in PATH,
89+
// or downloads it to a temporary file.
90+
//
91+
// The result is cached and shared among callers.
92+
func MinioBin() (string, error) {
93+
if minioExecPath != "" {
94+
return minioExecPath, nil
95+
}
96+
97+
binpath, err := exec.LookPath("minio")
98+
if err == nil {
99+
minioExecPath = binpath
100+
return binpath, nil
101+
}
102+
if !errors.Is(err, exec.ErrNotFound) {
103+
return "", err
104+
}
105+
106+
f, err := os.CreateTemp("", "minio")
107+
if err != nil {
108+
return "", err
109+
}
110+
defer f.Close()
111+
112+
url := fmt.Sprintf("https://dl.min.io/server/minio/release/%s-%s/minio", runtime.GOOS, runtime.GOARCH)
113+
resp, err := http.Get(url)
114+
if err != nil {
115+
return "", err
116+
}
117+
defer resp.Body.Close()
118+
119+
_, err = io.Copy(f, resp.Body)
120+
if err != nil {
121+
return "", err
122+
}
123+
124+
err = f.Chmod(0755)
125+
if err != nil {
126+
return "", err
127+
}
128+
129+
minioExecPath = f.Name()
130+
return minioExecPath, nil
131+
}

Diff for: backends/s3/s3testing/port.go

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package s3testing
2+
3+
import "net"
4+
5+
// FreePort returns a port number free for use.
6+
func FreePort() (int, error) {
7+
l, err := net.Listen("tcp", ":0")
8+
if err != nil {
9+
return 0, err
10+
}
11+
defer l.Close()
12+
addr := l.Addr().(*net.TCPAddr)
13+
return addr.Port, nil
14+
}

0 commit comments

Comments
 (0)