Skip to content

Commit

Permalink
feat: Add support for s3 as a backend store
Browse files Browse the repository at this point in the history
  • Loading branch information
pguinard-public-com committed Dec 19, 2023
1 parent 6db0be3 commit e3f56b4
Show file tree
Hide file tree
Showing 6 changed files with 314 additions and 1 deletion.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Supported Terraform protocols:
Supported backends:
- `MemoryStore`: a dumb in-memory store currently only used for testing
- [`GitHubStore`](#github-store): queries the GitHub API for modules, version tags and SSH download URLs
- [`S3Store`](#S3-store): queries a S3 bucket for modules, version tags and HTTPS download URLs

Authentication:
- Reads a set of tokens from a file and authenticates requests based on the
Expand Down Expand Up @@ -113,6 +114,30 @@ Command line arguments:
- `-github-owner-filter`: GitHub org/user repository filter
- `-github-topic-filter`: GitHub topic repository filter

### S3 Store

This store uses S3 as a backend. A query for the module address
`namespace/name/provider` will be translated directly to a S3 bucket key.
This request happens server side and either a list of modules will be returned
or a link to a s3 bucket for the terraform client to use.
Required permissions for the registry: `s3:ListBucket`
Requests to download are authenticated by S3 using credentials on the client side.
Required permissions for registry clients: `s3:GetObject`
[terraform S3 configuration]: (https://developer.hashicorp.com/terraform/language/modules/sources#s3-bucket)

Version strings are matched with [repository topics][]. Upon loading the list of
repositories, tags prefixed with `v` will have their prefix removed.
Storage of modules in S3 must match `namespace/name/provider/v1.2.3/v1.2.3.zip`
I.e., a repository tag `v1.2.3` will be made available as version `1.2.3`.

No verification is performed to check if the repo actually contains Terraform
modules. This is left for Terraform to determine.

Command line arguments:
- `-store s3`: Switch store to S3
- `-s3-region`: Region such as us-east-1
- `-s3-bucket`: S3 bucket name

[repository topics]: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/classifying-your-repository-with-topics

## Development
Expand Down
42 changes: 41 additions & 1 deletion cmd/terraform-registry/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ import (
"strings"
"time"

"github.com/aws/aws-sdk-go/aws/session"
awss3 "github.com/aws/aws-sdk-go/service/s3"
"github.com/nrkno/terraform-registry/pkg/registry"
"github.com/nrkno/terraform-registry/pkg/store/github"
"github.com/nrkno/terraform-registry/pkg/store/s3"
"go.uber.org/zap"
)

Expand All @@ -37,6 +40,9 @@ var (
logLevelStr string
logFormatStr string

S3Region string
S3Bucket string

gitHubToken string
gitHubOwnerFilter string
gitHubTopicFilter string
Expand All @@ -62,13 +68,15 @@ func init() {
flag.BoolVar(&tlsEnabled, "tls-enabled", false, "")
flag.StringVar(&tlsCertFile, "tls-cert-file", "", "")
flag.StringVar(&tlsKeyFile, "tls-key-file", "", "")
flag.StringVar(&storeType, "store", "", "Store backend to use (choices: github)")
flag.StringVar(&storeType, "store", "", "Store backend to use (choices: github, s3)")
flag.StringVar(&logLevelStr, "log-level", "info", "Levels: debug, info, warn, error")
flag.StringVar(&logFormatStr, "log-format", "console", "Formats: json, console")

flag.StringVar(&gitHubOwnerFilter, "github-owner-filter", "", "GitHub org/user repository filter")
flag.StringVar(&gitHubTopicFilter, "github-topic-filter", "", "GitHub topic repository filter")

flag.StringVar(&S3Region, "s3-region", "", "S3 region such as us-east-1")
flag.StringVar(&S3Bucket, "s3-bucket", "", "S3 bucket name")
}

func main() {
Expand Down Expand Up @@ -148,6 +156,8 @@ func main() {
switch storeType {
case "github":
gitHubRegistry(reg)
case "s3":
s3Registry(reg)
default:
logger.Fatal("invalid store type", zap.String("selected", storeType))
}
Expand Down Expand Up @@ -264,6 +274,36 @@ func gitHubRegistry(reg *registry.Registry) {
}()
}

// s3Registry configures the registry to use S3Store.
func s3Registry(reg *registry.Registry) {
if S3Region == "" {
logger.Fatal("Missing flag '-s3-region'")
}
if S3Bucket == "" {
logger.Fatal("Missing flag '-s3-bucket'")
}

sess, err := session.NewSession()
if err != nil {
logger.Fatal("AWS session creation failed")
}
logger.Debug("AWS session created successfully")

_, err = sess.Config.Credentials.Get()
if err != nil {
logger.Fatal("AWS session credentials not found")
}
s3Sess := awss3.New(sess)

store := s3.NewS3Store(s3Sess, S3Region, S3Bucket, logger.Named("s3 store"))
if err != nil {
logger.Fatal("failed to create S3 store",
zap.Errors("err", []error{err}),
)
}
reg.SetModuleStore(store)
}

// parseAuthTokens returns a map of all elements in the JSON object contained in `b`.
func parseAuthTokens(b []byte) (map[string]string, error) {
tokens := make(map[string]string)
Expand Down
7 changes: 7 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,31 @@ go 1.21
toolchain go1.21.4

require (
github.com/aws/aws-sdk-go v1.49.4
github.com/go-chi/chi/v5 v5.0.10
github.com/google/go-github/v43 v43.0.0
github.com/hashicorp/go-version v1.6.0
github.com/matryer/is v1.4.1
github.com/migueleliasweb/go-github-mock v0.0.22
github.com/stretchr/testify v1.8.1
go.uber.org/zap v1.26.0
golang.org/x/oauth2 v0.15.0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-github/v56 v56.0.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/time v0.3.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
20 changes: 20 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
github.com/aws/aws-sdk-go v1.49.4 h1:qiXsqEeLLhdLgUIyfr5ot+N/dGPWALmtM1SetRmbUlY=
github.com/aws/aws-sdk-go v1.49.4/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
Expand All @@ -23,12 +26,22 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/migueleliasweb/go-github-mock v0.0.22 h1:iUvUKmYd7sFq/wrb9TrbEdvc30NaYxLZNtz7Uv2D+AQ=
github.com/migueleliasweb/go-github-mock v0.0.22/go.mod h1:UVvZ3S9IdTTRqThr1lgagVaua3Jl1bmY4E+C/Vybbn4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
Expand All @@ -48,6 +61,8 @@ golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCA
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
Expand All @@ -58,5 +73,10 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
146 changes: 146 additions & 0 deletions pkg/store/s3/s3.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package s3

import (
"context"
"fmt"
"path"
"path/filepath"
"regexp"
"strings"
"sync"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3iface"
"github.com/nrkno/terraform-registry/pkg/core"
"go.uber.org/zap"
)

// S3API defines the subset of S3 client methods used by S3Store
type S3API interface {
ListObjectsV2WithContext(ctx aws.Context, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error)
HeadObjectWithContext(ctx aws.Context, input *s3.HeadObjectInput) (*s3.HeadObjectOutput, error)
}

// S3StoreInterface defines the interface for S3Store
type S3StoreInterface interface {
ListModuleVersions(ctx context.Context, namespace, name, system string) ([]*core.ModuleVersion, error)
GetModuleVersion(ctx context.Context, namespace, name, system, version string) (*core.ModuleVersion, error)
}

// S3Store implements S3StoreInterface
type S3Store struct {
client s3iface.S3API
cache map[string][]*core.ModuleVersion
region string
bucket string
logger *zap.Logger
mut sync.Mutex
}

func NewS3Store(client s3iface.S3API, region string, bucket string, logger *zap.Logger) *S3Store {
if logger == nil {
logger = zap.NewNop()
}

return &S3Store{
client: client,
cache: make(map[string][]*core.ModuleVersion),
region: region,
bucket: bucket,
logger: logger,
}
}

func (s *S3Store) ListModuleVersions(ctx context.Context, namespace, name, system string) ([]*core.ModuleVersion, error) {
addr := filepath.Join(namespace, name, system)
vers, err := s.fetchModuleVersions(ctx, addr)
if err != nil {
return nil, err
}

return vers, nil
}

func (s *S3Store) fetchModuleVersions(ctx context.Context, address string) ([]*core.ModuleVersion, error) {
s.mut.Lock()
defer s.mut.Unlock()

p := address + "/"
in := &s3.ListObjectsV2Input{
Bucket: aws.String(s.bucket),
Prefix: aws.String(p),
}
out, err := s.client.ListObjectsV2WithContext(ctx, in)
if err != nil {
return nil, err
}

vers := make([]*core.ModuleVersion, 0)
for _, o := range out.Contents {
path := o.Key
if isValidModuleSourcePath(*path) {
vers = append(vers, &core.ModuleVersion{
Version: strings.Split(*path, "/")[3],
SourceURL: fmt.Sprintf("s3::https://%s.s3.%s.amazonaws.com/%s", s.bucket, s.region, *path),
})
}
}

s.cache[address] = vers

return vers, nil
}

func (s *S3Store) GetModuleVersion(ctx context.Context, namespace, name, system, version string) (*core.ModuleVersion, error) {
addr := filepath.Join(namespace, name, system)
ver, err := s.fetchModuleVersion(ctx, addr, version)
if err != nil {
return nil, err
}

return ver, nil
}

func (s *S3Store) fetchModuleVersion(ctx context.Context, address, version string) (*core.ModuleVersion, error) {
s.mut.Lock()
defer s.mut.Unlock()

vers := s.cache[address]
for _, o := range vers {
if o.Version == version {
return o, nil
}
}

path := path.Join(address, version)
keySuffix := version + ".zip"
if !isValidModuleSourcePath(path) {
s.logger.Warn("invalid module path requested: " + path)
return nil, fmt.Errorf("module version path '%s' is not valid", path)
}
_, err := s.client.HeadObjectWithContext(ctx, &s3.HeadObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(path + "/" + keySuffix),
})
if err != nil {
return nil, err
}

ver := &core.ModuleVersion{
Version: version,
SourceURL: fmt.Sprintf("s3::https://%s.s3.%s.amazonaws.com/%s/%s", s.bucket, s.region, path, keySuffix),
}

s.cache[address] = append(vers, ver)

return ver, nil
}

func isValidModuleSourcePath(path string) bool {
// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
verRegExp := `(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?`
addrRegExp := `\w+/\w+/\w+`
r := regexp.MustCompile("^" + addrRegExp + "/" + verRegExp)
return r.MatchString(path)
}
Loading

0 comments on commit e3f56b4

Please sign in to comment.