diff --git a/.circleci/config.yml b/.circleci/config.yml index 8b6b2a97..f51b3bf5 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -32,6 +32,8 @@ jobs: - run: name: Run tests command: go test ./... -count=1 + - run: + command: make -C vervet-underground test lint: docker: @@ -43,6 +45,16 @@ jobs: - run: command: golangci-lint run -v ./... + lint-vu: + docker: + - image: golangci/golangci-lint:v1.42.1 + steps: + - checkout + - attach_workspace: + at: ~/vervet/vervet-underground + - run: + command: golangci-lint run -v ./... + workflows: version: 2 test: @@ -53,3 +65,5 @@ workflows: jobs: - lint: name: Lint + - lint-vu: + name: Lint VU diff --git a/vervet-underground/Makefile b/vervet-underground/Makefile index 5e081463..40108fd8 100644 --- a/vervet-underground/Makefile +++ b/vervet-underground/Makefile @@ -18,7 +18,7 @@ lint-docker: tidy: $(GOMOD) tidy -v test: - go test ./... -count=1 -ginkgo.failFast + go test ./... -count=1 -race build: go build server.go diff --git a/vervet-underground/config/config.go b/vervet-underground/config/config.go index 7aeb634d..c4a0ce08 100644 --- a/vervet-underground/config/config.go +++ b/vervet-underground/config/config.go @@ -1,15 +1,22 @@ +// Package config supports configuring the Vervet Underground service. package config import ( "encoding/json" "os" - - "vervet-underground/lib" ) -func Load(configPath string) (*lib.ServerConfig, error) { +// ServerConfig defines the configuration options for the Vervet Underground service. +type ServerConfig struct { + Host string `json:"host"` + Services []string `json:"services"` +} + +// Load returns a ServerConfig instance loaded from the given path to a JSON +// config file. +func Load(configPath string) (*ServerConfig, error) { file, err := os.Open(configPath) - var config lib.ServerConfig + var config ServerConfig if err != nil { return nil, err } @@ -19,4 +26,4 @@ func Load(configPath string) (*lib.ServerConfig, error) { return nil, err } return &config, nil -} \ No newline at end of file +} diff --git a/vervet-underground/go.mod b/vervet-underground/go.mod index aa5e17f5..1390769d 100644 --- a/vervet-underground/go.mod +++ b/vervet-underground/go.mod @@ -26,8 +26,10 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.6.1 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.11.0 // indirect github.com/aws/smithy-go v1.9.0 // indirect + github.com/frankban/quicktest v1.14.0 // indirect github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/nxadm/tail v1.4.8 // indirect + go.uber.org/multierr v1.7.0 // indirect golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d // indirect golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e // indirect golang.org/x/text v0.3.6 // indirect diff --git a/vervet-underground/go.sum b/vervet-underground/go.sum index 59f20483..548a7564 100644 --- a/vervet-underground/go.sum +++ b/vervet-underground/go.sum @@ -29,8 +29,11 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.11.0/go.mod h1:+BmlPeQ1Y+PuIho93MMKD github.com/aws/smithy-go v1.9.0 h1:c7FUdEqrQA1/UVKKCNDFQPNKGp4FQg3YW4Ck5SLTG58= github.com/aws/smithy-go v1.9.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.0 h1:+cqqvzZV87b4adx/5ayVOaYZ2CrvM4ejQvUdBzPPUss= +github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= @@ -58,6 +61,13 @@ github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB7 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= @@ -73,13 +83,21 @@ github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAl github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.26.0 h1:ORM4ibhEZeTeQlCojCK2kPz1ogAY4bGs4tD+SaAdGaE= github.com/rs/zerolog v1.26.0/go.mod h1:yBiM87lvSqX8h0Ww4sdzNSkVYZ8dL2xjZJG1lAuGZEo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.7.0 h1:zaiO/rmgFjbmCXdSYJWQcdvOCsthmdaHfr3Gm2Kx4Ec= +go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -135,6 +153,8 @@ google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/l google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 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/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= @@ -144,3 +164,5 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/vervet-underground/internal/scraper/scraper.go b/vervet-underground/internal/scraper/scraper.go new file mode 100644 index 00000000..3aca089b --- /dev/null +++ b/vervet-underground/internal/scraper/scraper.go @@ -0,0 +1,245 @@ +// Package scraper provides support for scraping OpenAPI versions from +// services. +package scraper + +import ( + "context" + "encoding/json" + "io/ioutil" + "net/http" + "net/url" + "sync" + "time" + + "github.com/pkg/errors" + "go.uber.org/multierr" + + "vervet-underground/config" + "vervet-underground/internal/storage" +) + +// Storage defines the storage functionality needed in order to store service +// API version spec snapshots. +type Storage interface { + // NotifyVersions tells the storage which versions are currently available. + // This is the primary mechanism by which the storage layer discovers and + // processes versions which are removed post-sunset. + NotifyVersions(ctx context.Context, name string, versions []string, scrapeTime time.Time) error + + // HasVersion returns whether the storage has already stored the service + // API spec version at the given content digest. + HasVersion(ctx context.Context, name string, version string, digest string) (bool, error) + + // NotifyVersion tells the storage to store the given version contents at + // the scrapeTime. The storage implementation must detect and ignore + // duplicate version contents, as some services may not provide content + // digest headers in their responses. + NotifyVersion(ctx context.Context, name string, version string, contents []byte, scrapeTime time.Time) error +} + +// Scraper gets OpenAPI specs from a collection of services and updates storage +// accordingly. +type Scraper struct { + storage Storage + services []service + http *http.Client + timeNow func() time.Time +} + +type service struct { + base string + url *url.URL +} + +// Option defines an option that may be specified when creating a new Scraper. +type Option func(*Scraper) error + +// New returns a new Scraper instance. +func New(cfg *config.ServerConfig, storage Storage, options ...Option) (*Scraper, error) { + s := &Scraper{ + storage: storage, + http: &http.Client{}, + timeNow: time.Now, + } + s.services = make([]service, len(cfg.Services)) + for i := range cfg.Services { + u, err := url.Parse(cfg.Services[i] + "/openapi") + if err != nil { + return nil, errors.Wrapf(err, "invalid service %q", cfg.Services[i]) + } + s.services[i] = service{base: cfg.Services[i], url: u} + } + for i := range options { + err := options[i](s) + if err != nil { + return nil, err + } + } + return s, nil +} + +// HTTPClient is a Scraper constructor Option that allows providing an +// *http.Client instance. This may be used to configure the transport and +// timeouts on the HTTP client. +func HTTPClient(cl *http.Client) Option { + return func(s *Scraper) error { + s.http = cl + return nil + } +} + +// Clock is a Scraper constructor Option that allows providing an alternative +// clock used to determine the scrape timestamps used to record changes in +// service spec versions. +func Clock(c func() time.Time) Option { + return func(s *Scraper) error { + s.timeNow = c + return nil + } +} + +// Run executes the OpenAPI version scraping on all configured services. +func (s *Scraper) Run(ctx context.Context) error { + scrapeTime := s.timeNow().UTC() + var wg sync.WaitGroup + errCh := make(chan error) + for i := range s.services { + svc := s.services[i] + wg.Add(1) + go func() { + defer wg.Done() + errCh <- s.scrape(ctx, scrapeTime, svc) + }() + } + go func() { + wg.Wait() + close(errCh) + }() + var errs error + for err := range errCh { + errs = multierr.Append(errs, err) + } + return errs +} + +func (s *Scraper) scrape(ctx context.Context, scrapeTime time.Time, svc service) error { + versions, err := s.getVersions(ctx, svc) + if err != nil { + return errors.WithStack(err) + } + err = s.storage.NotifyVersions(ctx, svc.base, versions, scrapeTime) + if err != nil { + return errors.WithStack(err) + } + for i := range versions { + // TODO: we might run this concurrently per live service pod if/when + // we're more k8s aware, but we won't do that yet. + contents, isNew, err := s.getNewVersion(ctx, svc, versions[i]) + if err != nil { + return errors.WithStack(err) + } + if !isNew { + continue + } + err = s.storage.NotifyVersion(ctx, svc.base, versions[i], contents, scrapeTime) + if err != nil { + return errors.WithStack(err) + } + } + return nil +} + +func (s *Scraper) getVersions(ctx context.Context, svc service) ([]string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", svc.url.String(), nil) + if err != nil { + return nil, errors.Wrap(err, "failed to create request") + } + resp, err := s.http.Do(req) + if err != nil { + return nil, errors.Wrap(err, "request failed") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, httpError(resp) + } + var versions []string + err = json.NewDecoder(resp.Body).Decode(&versions) + if err != nil { + return nil, errors.WithStack(err) + } + return versions, nil +} + +func httpError(r *http.Response) error { + if contents, err := ioutil.ReadAll(r.Body); err == nil { + return errors.Errorf("request failed: HTTP %d: %s", r.StatusCode, string(contents)) + } + return errors.Errorf("request failed: HTTP %d", r.StatusCode) +} + +func (s *Scraper) getNewVersion(ctx context.Context, svc service, version string) ([]byte, bool, error) { + isNew, err := s.hasNewVersion(ctx, svc, version) + if err != nil { + return nil, false, errors.WithStack(err) + } + if !isNew { + return nil, false, nil + } + + req, err := http.NewRequestWithContext(ctx, "GET", svc.url.String()+"/"+version, nil) + if err != nil { + return nil, false, errors.Wrap(err, "failed to create request") + } + resp, err := s.http.Do(req) + if err != nil { + return nil, false, errors.Wrap(err, "request failed") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, false, httpError(resp) + } + if ct := resp.Header.Get("Content-Type"); ct != "application/json" { + return nil, false, errors.Errorf("unexpected content type: %s", ct) + } + respContents, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, false, errors.WithStack(err) + } + // For now, let's just see if the response can be unmarshaled + // TODO: Load w/kin-openapi and validate it? + var doc map[string]interface{} + err = json.Unmarshal(respContents, &doc) + if err != nil { + return nil, false, errors.WithStack(err) + } + return respContents, true, nil +} + +func (s *Scraper) hasNewVersion(ctx context.Context, svc service, version string) (bool, error) { + // Check Digest to see if there's a new version + req, err := http.NewRequestWithContext(ctx, "HEAD", svc.url.String()+"/"+version, nil) + if err != nil { + return false, errors.Wrap(err, "failed to create request") + } + resp, err := s.http.Do(req) + if err != nil { + return false, errors.Wrap(err, "request failed") + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusMethodNotAllowed { + // Not supporting HEAD is fine, we'll just come back with a GET + return true, nil + } + if resp.StatusCode != http.StatusOK { + return false, httpError(resp) + } + if ct := resp.Header.Get("Content-Type"); ct != "application/json" { + return false, errors.Errorf("unexpected content type: %s", ct) + } + digest := storage.DigestHeader(resp.Header.Get("Digest")) + if digest == "" { + // Not providing a digest is fine, we'll just come back with a GET + return true, nil + } + return s.storage.HasVersion(ctx, svc.base, version, digest) +} diff --git a/vervet-underground/internal/scraper/scraper_test.go b/vervet-underground/internal/scraper/scraper_test.go new file mode 100644 index 00000000..a1b0b2d8 --- /dev/null +++ b/vervet-underground/internal/scraper/scraper_test.go @@ -0,0 +1,153 @@ +package scraper_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + qt "github.com/frankban/quicktest" + "github.com/gorilla/mux" + + "vervet-underground/config" + "vervet-underground/internal/scraper" + "vervet-underground/internal/storage/mem" +) + +var t0 = time.Date(2021, time.December, 3, 20, 49, 51, 0, time.UTC) + +type testService struct { + versions []string + contents map[string]string +} + +func (t *testService) Handler() http.Handler { + r := mux.NewRouter() + r.HandleFunc("/openapi", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(&t.versions) + if err != nil { + panic(err) + } + }) + r.HandleFunc("/openapi/{version}", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(t.contents[mux.Vars(r)["version"]])) + if err != nil { + panic(err) + } + }) + return r +} + +func TestScraper(t *testing.T) { + c := qt.New(t) + petfood := &testService{ + versions: []string{"2021-09-01", "2021-09-16"}, + contents: map[string]string{ + "2021-09-01": `{"paths":{"/crickets": {}}}`, + "2021-09-16": `{"paths":{"/crickets": {}, "/kibble": {}}}`, + }, + } + petfoodService := httptest.NewServer(petfood.Handler()) + c.Cleanup(petfoodService.Close) + + animals := &testService{ + versions: []string{"2021-10-01", "2021-10-16"}, + contents: map[string]string{ + "2021-10-01": `{"paths":{"/geckos": {}}}`, + "2021-10-16": `{"paths":{"/geckos": {}, "/puppies": {}}}`, + }, + } + animalsService := httptest.NewServer(animals.Handler()) + c.Cleanup(animalsService.Close) + + tests := []struct { + service, version, digest string + }{ + {petfoodService.URL, "2021-09-01", "sha256:I20cAQ3VEjDrY7O0B678yq+0pYN2h3sxQy7vmdlo4+w="}, + {animalsService.URL, "2021-10-16", "sha256:P1FEFvnhtxJSqXr/p6fMNKE+HYwN6iwKccBGHIVZbyg="}, + } + + cfg := &config.ServerConfig{ + Services: []string{ + petfoodService.URL, + animalsService.URL, + }, + } + st := mem.New() + sc, err := scraper.New(cfg, st, scraper.Clock(func() time.Time { return t0 })) + c.Assert(err, qt.IsNil) + + // Cancel the scrape context after a timeout so we don't hang the test + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + c.Cleanup(cancel) + + // No version digests should be known + for _, test := range tests { + ok, err := st.HasVersion(ctx, test.service, test.version, test.digest) + c.Assert(err, qt.IsNil) + c.Assert(ok, qt.IsFalse) + } + + // Run the scrape + err = sc.Run(ctx) + c.Assert(err, qt.IsNil) + + // Version digests now known to storage + for _, test := range tests { + ok, err := st.HasVersion(ctx, test.service, test.version, test.digest) + c.Assert(err, qt.IsNil) + c.Assert(ok, qt.IsTrue) + } +} + +func TestEmptyScrape(t *testing.T) { + c := qt.New(t) + cfg := &config.ServerConfig{ + Services: nil, + } + st := mem.New() + sc, err := scraper.New(cfg, st, scraper.Clock(func() time.Time { return t0 })) + c.Assert(err, qt.IsNil) + + // Cancel after a short timeout so we don't hang the test + ctx, cancel := context.WithTimeout(context.Background(), time.Second*1) + c.Cleanup(cancel) + + // Run the scrape + err = sc.Run(ctx) + c.Assert(err, qt.IsNil) +} + +func TestScrapeClientError(t *testing.T) { + c := qt.New(t) + cfg := &config.ServerConfig{ + Services: []string{"http://example.com/nope"}, + } + st := mem.New() + sc, err := scraper.New(cfg, st, + scraper.Clock(func() time.Time { return t0 }), + scraper.HTTPClient(&http.Client{ + Transport: &errorTransport{}, + }), + ) + c.Assert(err, qt.IsNil) + + // Cancel after a short timeout so we don't hang the test + ctx, cancel := context.WithTimeout(context.Background(), time.Second*1) + c.Cleanup(cancel) + + // Run the scrape + err = sc.Run(ctx) + c.Assert(err, qt.ErrorMatches, `.*: bad wolf`) +} + +type errorTransport struct{} + +func (*errorTransport) RoundTrip(*http.Request) (*http.Response, error) { + return nil, fmt.Errorf("bad wolf") +} diff --git a/vervet-underground/internal/storage/digest.go b/vervet-underground/internal/storage/digest.go new file mode 100644 index 00000000..5cef3163 --- /dev/null +++ b/vervet-underground/internal/storage/digest.go @@ -0,0 +1,40 @@ +// Package storage provides common functionality supporting Vervet Underground +// storage. +package storage + +import ( + "crypto/sha256" + "encoding/base64" + "strings" +) + +// DigestHeader returns a content digest parsed from a Digest HTTP response +// header as defined in +// https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-digest-headers-05#section-3. +// The returned digest is algorithm-prefixed so that other digest schemes may +// be supported later if needed. +// +// Returns "" if no digest is available. +func DigestHeader(value string) string { + digests := strings.Split(value, ",") + for i := range digests { + digests[i] = strings.TrimSpace(digests[i]) + kv := strings.SplitN(digests[i], "=", 2) + if len(kv) < 2 { + continue + } + if kv[0] == "id-sha-256" || kv[0] == "sha-256" { + // Use the no-encoding digest if specified, otherwise assume no + // encoding as a fallback. HTTP compression is likely to be handled + // transparently. + return "sha256:" + kv[1] + } + } + return "" +} + +// Digest returns the digest of the given contents. +func Digest(contents []byte) string { + buf := sha256.Sum256(contents) + return "sha256:" + base64.StdEncoding.EncodeToString(buf[:]) +} diff --git a/vervet-underground/internal/storage/mem/mem.go b/vervet-underground/internal/storage/mem/mem.go new file mode 100644 index 00000000..c4e3fb80 --- /dev/null +++ b/vervet-underground/internal/storage/mem/mem.go @@ -0,0 +1,86 @@ +// Package mem provides an in-memory implementation of the storage used in +// Vervet Underground. It's not intended for production use, but as a +// functionally complete reference implementation that can be used to validate +// the other parts of the VU system. +package mem + +import ( + "context" + "sync" + "time" + + "vervet-underground/internal/storage" +) + +type serviceVersion struct { + service string + version string +} + +type contentRevision struct { + timestamp time.Time + digest string + + // TODO: store the sunset time when a version is removed + //sunset *time.Time +} + +type serviceVersions map[serviceVersion][]contentRevision + +type contents map[serviceVersion]map[string][]byte + +// Storage provides an in-memory implementation of Vervet Underground storage. +type Storage struct { + mu sync.RWMutex + serviceVersions serviceVersions + contents contents +} + +// New returns a new Storage instance. +func New() *Storage { + return &Storage{ + serviceVersions: serviceVersions{}, + contents: contents{}, + } +} + +// NotifyVersions implements scraper.Storage. +func (s *Storage) NotifyVersions(ctx context.Context, name string, versions []string, scrapeTime time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + // TODO: implement notify versions; update sunset when versions are removed + return nil +} + +// HasVersion implements scraper.Storage. +func (s *Storage) HasVersion(ctx context.Context, name string, version string, digest string) (bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + digests, ok := s.contents[serviceVersion{service: name, version: version}] + if !ok { + return false, nil + } + _, ok = digests[digest] + return ok, nil +} + +// NotifyVersion implements scraper.Storage. +func (s *Storage) NotifyVersion(ctx context.Context, name string, version string, contents []byte, scrapeTime time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + k := serviceVersion{service: name, version: version} + digest := storage.Digest(contents) + if digests, ok := s.contents[k]; ok { + if _, ok := digests[digest]; ok { + return nil + } + } else { + s.contents[k] = map[string][]byte{} + } + s.contents[k][digest] = contents + s.serviceVersions[k] = append(s.serviceVersions[k], contentRevision{ + timestamp: scrapeTime, + digest: digest, + }) + return nil +} diff --git a/vervet-underground/internal/storage/mem/mem_test.go b/vervet-underground/internal/storage/mem/mem_test.go new file mode 100644 index 00000000..1b0ae373 --- /dev/null +++ b/vervet-underground/internal/storage/mem/mem_test.go @@ -0,0 +1,56 @@ +package mem + +import ( + "context" + "testing" + "time" + + qt "github.com/frankban/quicktest" +) + +var t0 = time.Date(2021, time.December, 3, 20, 49, 51, 0, time.UTC) + +func TestNotifyVersions(t *testing.T) { + c := qt.New(t) + ctx := context.Background() + s := New() + err := s.NotifyVersions(ctx, "petfood", []string{"2021-09-01", "2021-09-16"}, t0) + c.Assert(err, qt.IsNil) + // TODO: verify side-effects when there are some... +} + +func TestHasVersion(t *testing.T) { + c := qt.New(t) + ctx := context.Background() + s := New() + + const cricketsDigest = "sha256:mWpHX0/hIZS9mVd8eobfHWm6OkUsKZLiqd6ShRnNzA4=" + const geckosDigest = "sha256:c5JD7m0g4DVhoaX4z8HFcTP8S/yUOEsjgP8ECkuEHqM=" + for _, digest := range []string{cricketsDigest, geckosDigest} { + ok, err := s.HasVersion(ctx, "petfood", "2021-09-16", digest) + c.Assert(err, qt.IsNil) + c.Assert(ok, qt.IsFalse) + } + err := s.NotifyVersion(ctx, "petfood", "2021-09-16", []byte("crickets"), t0) + c.Assert(err, qt.IsNil) + err = s.NotifyVersion(ctx, "animals", "2021-09-16", []byte("geckos"), t0) + c.Assert(err, qt.IsNil) + + tests := []struct { + service, version, digest string + shouldHave bool + }{ + {"petfood", "2021-09-16", cricketsDigest, true}, + {"animals", "2021-09-16", geckosDigest, true}, + {"petfood", "2021-09-16", geckosDigest, false}, + {"animals", "2021-09-16", cricketsDigest, false}, + {"petfood", "2021-10-16", cricketsDigest, false}, + {"animals", "2021-09-17", geckosDigest, false}, + } + for i, t := range tests { + c.Logf("test#%d: %v", i, t) + ok, err := s.HasVersion(ctx, t.service, t.version, t.digest) + c.Assert(err, qt.IsNil) + c.Assert(ok, qt.Equals, t.shouldHave) + } +} diff --git a/vervet-underground/storage/aws_s3_client.go b/vervet-underground/internal/storage/s3/aws_s3_client.go similarity index 90% rename from vervet-underground/storage/aws_s3_client.go rename to vervet-underground/internal/storage/s3/aws_s3_client.go index ae1bf11a..bfa84938 100644 --- a/vervet-underground/storage/aws_s3_client.go +++ b/vervet-underground/internal/storage/s3/aws_s3_client.go @@ -1,4 +1,6 @@ -package storage +// Package s3 provides an implementation of Vervet Underground storage backed +// by Amazon S3. +package s3 import ( "context" diff --git a/vervet-underground/storage/aws_s3_client_suite_test.go b/vervet-underground/internal/storage/s3/aws_s3_client_suite_test.go similarity index 88% rename from vervet-underground/storage/aws_s3_client_suite_test.go rename to vervet-underground/internal/storage/s3/aws_s3_client_suite_test.go index e99a2266..04d98d73 100644 --- a/vervet-underground/storage/aws_s3_client_suite_test.go +++ b/vervet-underground/internal/storage/s3/aws_s3_client_suite_test.go @@ -1,4 +1,4 @@ -package storage_test +package s3_test import ( "testing" @@ -10,4 +10,4 @@ import ( func TestOasRouter(t *testing.T) { gomega.RegisterFailHandler(ginkgo.Fail) ginkgo.RunSpecs(t, "") -} \ No newline at end of file +} diff --git a/vervet-underground/storage/aws_s3_client_test.go b/vervet-underground/internal/storage/s3/aws_s3_client_test.go similarity index 95% rename from vervet-underground/storage/aws_s3_client_test.go rename to vervet-underground/internal/storage/s3/aws_s3_client_test.go index ad68dcc5..147a1969 100644 --- a/vervet-underground/storage/aws_s3_client_test.go +++ b/vervet-underground/internal/storage/s3/aws_s3_client_test.go @@ -1,4 +1,4 @@ -package storage +package s3 import ( "github.com/onsi/ginkgo" @@ -14,4 +14,4 @@ var _ = ginkgo.Describe("Aws S3 Client Initialization", func() { }) }) }) -}) \ No newline at end of file +}) diff --git a/vervet-underground/lib/types.go b/vervet-underground/lib/types.go deleted file mode 100644 index f3dcc085..00000000 --- a/vervet-underground/lib/types.go +++ /dev/null @@ -1,6 +0,0 @@ -package lib - -type ServerConfig struct { - Host string `json:"host"` - Services []string `json:"services"` -} diff --git a/vervet-underground/server.go b/vervet-underground/server.go index 5347f03a..ec9c3063 100644 --- a/vervet-underground/server.go +++ b/vervet-underground/server.go @@ -9,25 +9,23 @@ import ( "os/signal" "time" - gorillaMux "github.com/gorilla/mux" + "github.com/gorilla/mux" "github.com/pkg/errors" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "vervet-underground/config" - "vervet-underground/lib" ) func main() { - var wait time.Duration flag.DurationVar(&wait, "graceful-timeout", time.Second*15, "the duration for which the server gracefully wait for existing connections to finish - e.g. 15s or 1m") flag.Parse() zerolog.TimeFieldFormat = zerolog.TimeFormatUnix zerolog.SetGlobalLevel(zerolog.DebugLevel) - router := gorillaMux.NewRouter() - var cfg *lib.ServerConfig + router := mux.NewRouter() + var cfg *config.ServerConfig var err error if cfg, err = config.Load("config.default.json"); err != nil { logError(err)