Skip to content

Commit ed31616

Browse files
committed
Add AWS storage backend support
This change enables users to deploy Rekor v2 on AWS infrastructure. The implementation uses AWS S3 for object storage and Aurora MySQL (or RDS MySQL) for database operations. The AWS backend includes: - S3 storage with support for S3-compatible services (e.g., MinIO) - Aurora MySQL/RDS MySQL for sequencing and deduplication - Docker Compose configuration for local development - Table-driven e2e tests supporting multiple backend configurations - Updated freeze-checkpoint tool to work with S3 All existing GCP backend functionality remains unchanged. Resolves: #572 Signed-off-by: James Carnegie <[email protected]>
1 parent eb8f925 commit ed31616

File tree

13 files changed

+869
-348
lines changed

13 files changed

+869
-348
lines changed

README.md

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,79 @@ We provide prebuilt binaries and containers for private deployments.
5858
If you find any issues, follow Sigstore's [security policy](https://github.com/sigstore/rekor-tiles/security/policy)
5959
to report them.
6060

61+
## Storage Backends
62+
63+
Rekor v2 supports multiple storage backends for flexibility in deployment:
64+
65+
### Google Cloud Platform (GCP)
66+
- **Object Storage**: Google Cloud Storage (GCS)
67+
- **Database**: Cloud Spanner
68+
- **Use case**: Preferred for global deployments requiring strong consistency and automatic scaling
69+
70+
### Amazon Web Services (AWS)
71+
- **Object Storage**: Amazon S3
72+
- **Database**: Aurora MySQL (or RDS MySQL)
73+
- **Use case**: Cost-effective option for regional deployments with MySQL compatibility
74+
6175
## Local Development
6276

63-
### Deployment
77+
### Deployment with GCP Emulators (Default)
6478

6579
Run `docker compose up --build --wait` to start the service along with emulated Google Cloud Storage and Spanner instances.
6680

6781
Run `docker compose down` to turn down the service, or `docker compose down --volumes` to turn down the service and delete
6882
persisted tiles.
6983

84+
### Deployment with AWS Emulators
85+
86+
Run `docker compose -f docker-compose-aws.yml up --build --wait` to start the service with MinIO (S3-compatible) and MySQL.
87+
88+
Run `docker compose -f docker-compose-aws.yml down` to turn down the service, or add `--volumes` to delete persisted data.
89+
90+
### Server Configuration
91+
92+
When deploying your own instance, configure the storage backend using command-line flags:
93+
94+
**GCP Backend:**
95+
```bash
96+
rekor-server serve \
97+
--hostname=your-hostname \
98+
--gcp-bucket=your-gcs-bucket \
99+
--gcp-spanner=projects/PROJECT/instances/INSTANCE/databases/DATABASE \
100+
--signer-filepath=/path/to/key.pem
101+
```
102+
103+
**AWS Backend:**
104+
```bash
105+
rekor-server serve \
106+
--hostname=your-hostname \
107+
--aws-bucket=your-s3-bucket \
108+
--aws-mysql-dsn="user:password@tcp(host:3306)/database?parseTime=true" \
109+
--signer-filepath=/path/to/key.pem
110+
```
111+
112+
**AWS Environment Variables:**
113+
114+
The AWS backend requires standard AWS SDK environment variables for authentication and configuration:
115+
116+
Required:
117+
- `AWS_ACCESS_KEY_ID`: AWS access key ID for authentication
118+
- `AWS_SECRET_ACCESS_KEY`: AWS secret access key for authentication
119+
- `AWS_REGION`: AWS region for S3 bucket (e.g., `us-east-1`)
120+
121+
Optional (for S3-compatible storage like MinIO):
122+
- `AWS_ENDPOINT_URL`: Custom S3 endpoint URL (e.g., `http://localhost:9000`)
123+
- `AWS_S3_FORCE_PATH_STYLE`: Set to `true` to use path-style addressing instead of virtual-hosted-style
124+
125+
The `--aws-mysql-dsn` format is `user:password@tcp(host:port)/database?parseTime=true`. The `parseTime=true` parameter is required for proper timestamp handling.
126+
127+
Optional flags for both backends:
128+
- `--persistent-antispam`: Enable persistent deduplication (requires Spanner or MySQL)
129+
- `--checkpoint-interval`: Frequency of checkpoint publishing (default: 30s)
130+
- `--batch-max-size`: Maximum entries per batch (default: 1024)
131+
132+
See `rekor-server serve --help` for all available options.
133+
70134
### Making a request
71135

72136
Follow the [client documentation](https://github.com/sigstore/rekor-tiles/blob/main/CLIENTS.md#rekor-v2-the-bash-way)

cmd/freeze-checkpoint/app/root.go

Lines changed: 108 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import (
2727
"time"
2828

2929
gcs "cloud.google.com/go/storage"
30+
"github.com/aws/aws-sdk-go-v2/config"
31+
"github.com/aws/aws-sdk-go-v2/service/s3"
3032
grpc_retry "github.com/grpc-ecosystem/go-grpc-middleware/retry"
3133
"github.com/sigstore/rekor-tiles/v2/internal/signerverifier"
3234
rekornote "github.com/sigstore/rekor-tiles/v2/pkg/note"
@@ -48,12 +50,19 @@ const (
4850
var rootCmd = &cobra.Command{
4951
Use: "freeze-checkpoint",
5052
Short: "Freeze the log checkpoint",
51-
Long: `Add an extension line to the final checkpoint to indicate to consumers that no more checkpoints are going to be published. Only supported for GCP.`,
53+
Long: `Add an extension line to the final checkpoint to indicate to consumers that no more checkpoints are going to be published. Supports both GCP (GCS) and AWS (S3) backends.`,
5254
Run: func(cmd *cobra.Command, _ []string) {
5355
ctx := cmd.Context()
5456

55-
if viper.GetString("gcp-bucket") == "" {
56-
slog.Error("must provide --gcs-bucket")
57+
gcpBucket := viper.GetString("gcp-bucket")
58+
awsBucket := viper.GetString("aws-bucket")
59+
60+
if gcpBucket == "" && awsBucket == "" {
61+
slog.Error("must provide either --gcp-bucket or --aws-bucket")
62+
os.Exit(1)
63+
}
64+
if gcpBucket != "" && awsBucket != "" {
65+
slog.Error("cannot provide both --gcp-bucket and --aws-bucket")
5766
os.Exit(1)
5867
}
5968
if viper.GetString("hostname") == "" {
@@ -77,13 +86,13 @@ var rootCmd = &cobra.Command{
7786
os.Exit(1)
7887
}
7988

80-
objReader, objWriter, err := objectAccess(ctx)
89+
storage, err := objectAccess(ctx)
8190
if err != nil {
8291
slog.Error(err.Error())
8392
os.Exit(1)
8493
}
8594

86-
checkpoint, err := getCheckpoint(objReader, noteVerifier)
95+
checkpoint, err := getCheckpoint(ctx, storage, noteVerifier)
8796
if err != nil {
8897
slog.Error(err.Error())
8998
os.Exit(1)
@@ -93,16 +102,12 @@ var rootCmd = &cobra.Command{
93102
return
94103
}
95104

96-
err = updateCheckpoint(objWriter, noteSigner, checkpoint)
105+
err = updateCheckpoint(ctx, storage, noteSigner, checkpoint)
97106
if err != nil {
98107
slog.Error(err.Error())
99108
os.Exit(1)
100109
}
101110

102-
if err := objWriter.Close(); err != nil {
103-
slog.Error("closing object writer", "error", err.Error())
104-
os.Exit(1)
105-
}
106111
slog.Info("Log frozen")
107112
},
108113
}
@@ -116,6 +121,7 @@ func Execute() {
116121

117122
func init() {
118123
rootCmd.Flags().String("gcp-bucket", "", "GCS bucket for tile and checkpoint storage")
124+
rootCmd.Flags().String("aws-bucket", "", "S3 bucket for tile and checkpoint storage")
119125
rootCmd.Flags().String("hostname", "", "public hostname, used as the checkpoint origin")
120126
rootCmd.Flags().String("signer-filepath", "", "path to the signing key")
121127
rootCmd.Flags().String("signer-password", "", "password to decrypt the signing key")
@@ -183,27 +189,105 @@ func getNoteVerifier(verifier signature.Verifier) (note.Verifier, error) {
183189
return noteVerifier, nil
184190
}
185191

186-
func objectAccess(ctx context.Context) (*gcs.Reader, *gcs.Writer, error) {
192+
// objectStorage provides a backend-agnostic interface for reading and writing checkpoints
193+
type objectStorage interface {
194+
Read(ctx context.Context) ([]byte, error)
195+
Write(ctx context.Context, data []byte) error
196+
}
197+
198+
// gcsStorage implements objectStorage for Google Cloud Storage
199+
type gcsStorage struct {
200+
bucket string
201+
}
202+
203+
func (g *gcsStorage) Read(ctx context.Context) ([]byte, error) {
187204
client, err := gcs.NewClient(ctx, gcs.WithJSONReads())
188205
if err != nil {
189-
return nil, nil, fmt.Errorf("getting GCS client: %w", err)
206+
return nil, fmt.Errorf("getting GCS client: %w", err)
190207
}
191-
bucketName := viper.GetString("gcp-bucket")
192-
object := client.Bucket(bucketName).Object(layout.CheckpointPath)
208+
object := client.Bucket(g.bucket).Object(layout.CheckpointPath)
193209
objReader, err := object.NewReader(ctx)
194210
if err != nil {
195-
return nil, nil, fmt.Errorf("getting object reader: %w", err)
211+
return nil, fmt.Errorf("getting object reader: %w", err)
196212
}
197-
contentType := "text/plain; charset=utf-8"
213+
defer objReader.Close()
214+
return io.ReadAll(objReader)
215+
}
216+
217+
func (g *gcsStorage) Write(ctx context.Context, data []byte) error {
218+
client, err := gcs.NewClient(ctx, gcs.WithJSONReads())
219+
if err != nil {
220+
return fmt.Errorf("getting GCS client: %w", err)
221+
}
222+
object := client.Bucket(g.bucket).Object(layout.CheckpointPath)
198223
objWriter := object.NewWriter(ctx)
199-
objWriter.ContentType = contentType
200-
return objReader, objWriter, nil
224+
objWriter.ContentType = "text/plain; charset=utf-8"
225+
if _, err := objWriter.Write(data); err != nil {
226+
objWriter.Close()
227+
return fmt.Errorf("writing checkpoint: %w", err)
228+
}
229+
return objWriter.Close()
230+
}
231+
232+
// s3Storage implements objectStorage for AWS S3
233+
type s3Storage struct {
234+
bucket string
235+
}
236+
237+
func (s *s3Storage) Read(ctx context.Context) ([]byte, error) {
238+
cfg, err := config.LoadDefaultConfig(ctx)
239+
if err != nil {
240+
return nil, fmt.Errorf("loading AWS config: %w", err)
241+
}
242+
client := s3.NewFromConfig(cfg)
243+
result, err := client.GetObject(ctx, &s3.GetObjectInput{
244+
Bucket: &s.bucket,
245+
Key: stringPtr(layout.CheckpointPath),
246+
})
247+
if err != nil {
248+
return nil, fmt.Errorf("getting object from S3: %w", err)
249+
}
250+
defer result.Body.Close()
251+
return io.ReadAll(result.Body)
201252
}
202253

203-
func getCheckpoint(objReader *gcs.Reader, noteVerifier note.Verifier) (*logformat.Checkpoint, error) {
204-
rawCheckpoint, err := io.ReadAll(objReader)
254+
func (s *s3Storage) Write(ctx context.Context, data []byte) error {
255+
cfg, err := config.LoadDefaultConfig(ctx)
205256
if err != nil {
206-
return nil, fmt.Errorf("reading object: %w", err)
257+
return fmt.Errorf("loading AWS config: %w", err)
258+
}
259+
client := s3.NewFromConfig(cfg)
260+
contentType := "text/plain; charset=utf-8"
261+
_, err = client.PutObject(ctx, &s3.PutObjectInput{
262+
Bucket: &s.bucket,
263+
Key: stringPtr(layout.CheckpointPath),
264+
Body: bytes.NewReader(data),
265+
ContentType: &contentType,
266+
})
267+
if err != nil {
268+
return fmt.Errorf("writing checkpoint to S3: %w", err)
269+
}
270+
return nil
271+
}
272+
273+
func stringPtr(s string) *string {
274+
return &s
275+
}
276+
277+
func objectAccess(_ context.Context) (objectStorage, error) {
278+
if gcpBucket := viper.GetString("gcp-bucket"); gcpBucket != "" {
279+
return &gcsStorage{bucket: gcpBucket}, nil
280+
}
281+
if awsBucket := viper.GetString("aws-bucket"); awsBucket != "" {
282+
return &s3Storage{bucket: awsBucket}, nil
283+
}
284+
return nil, fmt.Errorf("no storage backend configured")
285+
}
286+
287+
func getCheckpoint(ctx context.Context, storage objectStorage, noteVerifier note.Verifier) (*logformat.Checkpoint, error) {
288+
rawCheckpoint, err := storage.Read(ctx)
289+
if err != nil {
290+
return nil, fmt.Errorf("reading checkpoint: %w", err)
207291
}
208292
noteObj, err := note.Open(rawCheckpoint, note.VerifierList(noteVerifier))
209293
if err != nil {
@@ -221,8 +305,8 @@ func getCheckpoint(objReader *gcs.Reader, noteVerifier note.Verifier) (*logforma
221305
}
222306

223307
// updateCheckpoint writes an extension line to the checkpoint note to indicate the checkpoint is frozen,
224-
// re-signs it and re-uploads it to the GCS backend.
225-
func updateCheckpoint(objWriter *gcs.Writer, noteSigner note.Signer, checkpoint *logformat.Checkpoint) error {
308+
// re-signs it and re-uploads it to the storage backend.
309+
func updateCheckpoint(ctx context.Context, storage objectStorage, noteSigner note.Signer, checkpoint *logformat.Checkpoint) error {
226310
// Marshaled checkpoint contains the origin, size, and hash of the checkpoint, not the signatures.
227311
// The final checkpoint object will contain the origin, size, hash, extension line, and signature.
228312
buf := bytes.NewBuffer(checkpoint.Marshal())
@@ -234,8 +318,5 @@ func updateCheckpoint(objWriter *gcs.Writer, noteSigner note.Signer, checkpoint
234318
if err != nil {
235319
return fmt.Errorf("re-signing checkpoint: %w", err)
236320
}
237-
if _, err := objWriter.Write(signedNote); err != nil {
238-
return fmt.Errorf("writing checkpoint: %w", err)
239-
}
240-
return nil
321+
return storage.Write(ctx, signedNote)
241322
}

cmd/rekor-server/app/serve.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ var serveCmd = &cobra.Command{
137137
Hostname: viper.GetString("hostname"),
138138
GCPBucket: viper.GetString("gcp-bucket"),
139139
GCPSpannerDB: viper.GetString("gcp-spanner"),
140+
AWSBucket: viper.GetString("aws-bucket"),
141+
AWSMySQLDSN: viper.GetString("aws-mysql-dsn"),
140142
PersistentAntispam: viper.GetBool("persistent-antispam"),
141143
ASMaxBatchSize: viper.GetUint("antispam-max-batch-size"),
142144
ASPushbackThreshold: viper.GetUint("antispam-pushback-threshold"),
@@ -216,6 +218,10 @@ func init() {
216218
serveCmd.Flags().String("gcp-bucket", "", "GCS bucket for tile and checkpoint storage")
217219
serveCmd.Flags().String("gcp-spanner", "", "Spanner database URI")
218220

221+
// aws configs
222+
serveCmd.Flags().String("aws-bucket", "", "S3 bucket for tile and checkpoint storage")
223+
serveCmd.Flags().String("aws-mysql-dsn", "", "MySQL DSN for Aurora/RDS (e.g., user:pass@tcp(host:3306)/dbname)")
224+
219225
// checkpoint signing configs
220226
serveCmd.Flags().String("signer-filepath", "", "path to the signing key")
221227
serveCmd.Flags().String("signer-password", "", "password to decrypt the signing key")
@@ -234,7 +240,7 @@ func init() {
234240
serveCmd.Flags().Duration("tlog-timeout", 30*time.Second, "timeout for terminating the tiles log queue")
235241

236242
// antispam configs
237-
serveCmd.Flags().Bool("persistent-antispam", false, "whether to enable persistent antispam measures; only available for GCP storage backend and not supported by the Spanner storage emulator")
243+
serveCmd.Flags().Bool("persistent-antispam", false, "whether to enable persistent antispam measures; available for GCP (Spanner) and AWS (MySQL) storage backends; not supported by the Spanner storage emulator")
238244
serveCmd.Flags().Uint("antispam-max-batch-size", 0, "maximum batch size for deduplication operations; will default to Tessera recommendation if unset; for Spanner, recommend around 1500 with 300 or more PU, or around 64 for smaller (e.g. 100 PU) instances")
239245
serveCmd.Flags().Uint("antispam-pushback-threshold", 0, "maximum number of 'in-flight' add requests the antispam operator will allow before pushing back; will default to Tessera recommendation if unset")
240246

0 commit comments

Comments
 (0)