From 8d66c9cedc6bc7f875b1e7984b337456fc7b26df Mon Sep 17 00:00:00 2001 From: Mikhail Swift Date: Tue, 6 Feb 2024 17:03:56 -0500 Subject: [PATCH] feat: add ability to distribute witness through archivista Support use case of private archivista deployments where getting witness may be difficult. Also support use case of folks having custom builds of witness to distribute. Signed-off-by: Mikhail Swift --- cmd/archivista/main.go | 20 ++- internal/config/config.go | 3 + internal/server/server.go | 205 ++++++++++++++++++++++-- internal/server/server_test.go | 24 ++- internal/witnessdistro/witnessdistro.go | 154 ++++++++++++++++++ 5 files changed, 388 insertions(+), 18 deletions(-) create mode 100644 internal/witnessdistro/witnessdistro.go diff --git a/cmd/archivista/main.go b/cmd/archivista/main.go index 7ab1c7a1..4345d847 100644 --- a/cmd/archivista/main.go +++ b/cmd/archivista/main.go @@ -37,6 +37,7 @@ import ( "github.com/in-toto/archivista/internal/objectstorage/blobstore" "github.com/in-toto/archivista/internal/objectstorage/filestore" "github.com/in-toto/archivista/internal/server" + "github.com/in-toto/archivista/internal/witnessdistro" "github.com/minio/minio-go/v7/pkg/credentials" "github.com/sirupsen/logrus" ) @@ -56,6 +57,7 @@ func main() { defer cancel() startTime := time.Now() + serverOpts := make([]server.Option, 0) logrus.Infof("executing phase 1: get config from environment (time since start: %s)", time.Since(startTime)) now := time.Now() @@ -81,6 +83,7 @@ func main() { if err != nil { logrus.Fatalf("error initializing storage clients: %+v", err) } + serverOpts = append(serverOpts, server.WithObjectStore(fileStore)) entClient, err := sqlstore.NewEntClient( cfg.SQLStoreBackend, @@ -96,6 +99,7 @@ func main() { if err != nil { logrus.Fatalf("error initializing mysql client: %+v", err) } + serverOpts = append(serverOpts, server.WithMetadataStore(sqlStore)) logrus.WithField("duration", time.Since(now)).Infof("completed phase 3: initializing storage clients") @@ -104,9 +108,23 @@ func main() { // ******************************************************************************** now = time.Now() + // initialize witness distro store + if cfg.EnableWitnessDistro { + wds, err := witnessdistro.New(witnessdistro.WithManifestFile(cfg.WitnessDistroManifestLocation)) + if err != nil { + logrus.Fatalf("could not load witness distro store: %+v", err) + } + + serverOpts = append(serverOpts, server.WithWitnessDistroStore(wds)) + } + // initialize the server sqlClient := sqlStore.GetClient() - server := server.New(sqlStore, fileStore, cfg, sqlClient) + serverOpts = append(serverOpts, server.WithEntSqlClient(sqlClient)) + server, err := server.New(cfg, serverOpts...) + if err != nil { + logrus.Fatalf("could not create archivista server: %+v", err) + } listenAddress := cfg.ListenOn listenAddress = strings.ToLower(strings.TrimSpace(listenAddress)) diff --git a/internal/config/config.go b/internal/config/config.go index bbdc3ae9..964c1972 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -51,6 +51,9 @@ type Config struct { EnableGraphql bool `default:"TRUE" desc:"*** Enable GraphQL Endpoint" split_words:"true"` GraphqlWebClientEnable bool `default:"TRUE" desc:"Enable GraphiQL, the GraphQL web client" split_words:"true"` + + EnableWitnessDistro bool `default:"FALSE" desc:"*** Enable Witness Distribution Endpoints" split_words:"true"` + WitnessDistroManifestLocation string `default:"/tmp/witness/manifest.yaml" desc:"Location of the manifest describing available versions of witness"` } // Process reads config from env diff --git a/internal/server/server.go b/internal/server/server.go index 4cc8347f..7a87c8ab 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -22,6 +22,9 @@ import ( "fmt" "io" "net/http" + "os" + "path/filepath" + "strconv" "strings" "entgo.io/contrib/entgql" @@ -33,15 +36,18 @@ import ( _ "github.com/in-toto/archivista/docs" "github.com/in-toto/archivista/ent" "github.com/in-toto/archivista/internal/config" + "github.com/in-toto/archivista/internal/witnessdistro" "github.com/in-toto/archivista/pkg/api" "github.com/sirupsen/logrus" httpSwagger "github.com/swaggo/http-swagger/v2" ) type Server struct { - metadataStore Storer - objectStore StorerGetter - router *mux.Router + metadataStore Storer + objectStore StorerGetter + witnessDistroStore witnessdistro.WitnessDistroStore + router *mux.Router + sqlClient *ent.Client } type Storer interface { @@ -57,16 +63,48 @@ type StorerGetter interface { Getter } -func New(metadataStore Storer, objectStore StorerGetter, cfg *config.Config, sqlClient *ent.Client) Server { +type Option func(*Server) + +func WithMetadataStore(metadataStore Storer) Option { + return func(s *Server) { + s.metadataStore = metadataStore + } +} + +func WithObjectStore(objectStore StorerGetter) Option { + return func(s *Server) { + s.objectStore = objectStore + } +} + +func WithEntSqlClient(sqlClient *ent.Client) Option { + return func(s *Server) { + s.sqlClient = sqlClient + } +} + +func WithWitnessDistroStore(wds witnessdistro.WitnessDistroStore) Option { + return func(s *Server) { + s.witnessDistroStore = wds + } +} + +func New(cfg *config.Config, opts ...Option) (Server, error) { r := mux.NewRouter() - s := &Server{metadataStore, objectStore, nil} + s := Server{ + router: r, + } + + for _, opt := range opts { + opt(&s) + } // TODO: remove from future version (v0.6.0) endpoint with version r.HandleFunc("/download/{gitoid}", s.DownloadHandler) r.HandleFunc("/upload", s.UploadHandler) if cfg.EnableGraphql { - r.Handle("/query", s.Query(sqlClient)) - r.Handle("/v1/query", s.Query(sqlClient)) + r.Handle("/query", s.Query(s.sqlClient)) + r.Handle("/v1/query", s.Query(s.sqlClient)) } r.HandleFunc("/v1/download/{gitoid}", s.DownloadHandler) @@ -76,11 +114,15 @@ func New(metadataStore Storer, objectStore StorerGetter, cfg *config.Config, sql playground.Handler("Archivista", "/v1/query"), ) } - r.PathPrefix("/swagger/").Handler(httpSwagger.WrapHandler) - s.router = r - return *s + if cfg.EnableWitnessDistro { + r.HandleFunc("/v1/witness/", s.WitnessAllVersionsHandler) + r.HandleFunc("/v1/witness/{version}", s.WitnessVersionHandler) + r.HandleFunc("/v1/witness/{version}/{distribution}", s.DownloadWitnessHandler) + } + r.PathPrefix("/swagger/").Handler(httpSwagger.WrapHandler) + return s, nil } // @title Archivista API @@ -233,3 +275,146 @@ func (s *Server) Query(sqlclient *ent.Client) *handler.Server { srv.Use(entgql.Transactioner{TxOpener: sqlclient}) return srv } + +// @Summary Witness List Versions +// @Description retrieves details about all available versions of witness +// @Produce json +// @Success 200 {object} witnessdistro.VersionList +// @Failure 500 {object} string +// @Failure 400 {object} string +// @Router /v1/witness/ [get] +func (s *Server) WitnessAllVersionsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, fmt.Sprintf("%s is an unsupported method", r.Method), http.StatusBadRequest) + return + } + + allVersions := s.witnessDistroStore.Versions() + allVersionsJson, err := json.Marshal(allVersions) + if err != nil { + http.Error(w, fmt.Errorf("could not marshal witness versions: %w", err).Error(), http.StatusInternalServerError) + return + } + + if _, err := io.Copy(w, bytes.NewReader(allVersionsJson)); err != nil { + http.Error(w, fmt.Errorf("could not send json response: %w", err).Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") +} + +// @Summary Witness Version Details +// @Description retrieves details about a specified version of witness +// @Produce json +// @Param version path string true "version of witness" +// @Success 200 {object} witnessdistro.Version +// @Failure 500 {object} string +// @Failure 404 {object} nil +// @Failure 400 {object} string +// @Router /v1/witness/{version} [get] +func (s *Server) WitnessVersionHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, fmt.Sprintf("%s is an unsupported method", r.Method), http.StatusBadRequest) + return + } + + vars := mux.Vars(r) + if vars == nil { + http.Error(w, "version parameter is required", http.StatusBadRequest) + return + } + + versionString := vars["version"] + if len(strings.TrimSpace(versionString)) == 0 { + http.Error(w, "version parameter is required", http.StatusBadRequest) + return + } + + version, ok := s.witnessDistroStore.Version(versionString) + if !ok { + http.Error(w, "version not found", http.StatusNotFound) + return + } + + versionJson, err := json.Marshal(version) + if err != nil { + http.Error(w, fmt.Errorf("could not marshal witness distros: %w", err).Error(), http.StatusInternalServerError) + return + } + + if _, err := io.Copy(w, bytes.NewReader(versionJson)); err != nil { + http.Error(w, fmt.Errorf("could not send json response: %w", err).Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") +} + +// @Summary Download Witness +// @Description downloads a specified distribution of witness +// @Produce octet-stream +// @Param version path string true "version of witness to download" +// @Param distribution path string true "distribution of witness to download" +// @Success 200 {object} octet-stream +// @Failure 500 {object} string +// @Failure 404 {object} nil +// @Failure 400 {object} string +// @Router /v1/witness/{version}/{distribution} [get] +func (s *Server) DownloadWitnessHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, fmt.Sprintf("%s is an unsupported method", r.Method), http.StatusBadRequest) + return + } + + vars := mux.Vars(r) + if vars == nil { + http.Error(w, "version and distribution parameter is required", http.StatusBadRequest) + return + } + + versionString := vars["version"] + if len(strings.TrimSpace(versionString)) == 0 { + http.Error(w, "version parameter is required", http.StatusBadRequest) + return + } + + distroString := vars["distribution"] + if len(strings.TrimSpace(distroString)) == 0 { + http.Error(w, "distribution parameter is required", http.StatusBadRequest) + return + } + + distro, ok := s.witnessDistroStore.Distribution(versionString, distroString) + if !ok { + http.Error(w, "distribution of witness not found", http.StatusNotFound) + return + } + + file, err := os.Open(distro.FileLocation) + if err != nil { + http.Error(w, "could not read distribution file", http.StatusBadRequest) + return + } + + defer func() { + if err := file.Close(); err != nil { + logrus.Errorf(fmt.Sprintf("failed to close witness distribution file %s: %+v", distro.FileLocation, err)) + } + }() + + fileInfo, err := file.Stat() + if err != nil { + http.Error(w, "could not stat distribution file", http.StatusBadRequest) + return + } + + fileName := filepath.Base(distro.FileLocation) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%v", fileName)) + w.Header().Set("Content-Length", strconv.FormatInt(fileInfo.Size(), 10)) + if _, err := io.Copy(w, file); err != nil { + http.Error(w, fmt.Errorf("could not send witness distribution: %w", err).Error(), http.StatusInternalServerError) + return + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 92a40856..5c1f213e 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -103,20 +103,25 @@ func (ut *UTServerSuite) SetupTest() { ut.mockedStorer = new(StorerMock) ut.mockedStorerGetter = new(StorerGetterMock) ut.mockedResposeRecorder = new(ResponseRecorderMock) - ut.testServer = Server{ut.mockedStorer, ut.mockedStorerGetter, nil} + ut.testServer = Server{ + metadataStore: ut.mockedStorer, + objectStore: ut.mockedStorerGetter, + } } func (ut *UTServerSuite) Test_New() { cfg := new(config.Config) cfg.EnableGraphql = true cfg.GraphqlWebClientEnable = true - ut.testServer = New(ut.mockedStorer, ut.mockedStorerGetter, cfg, nil) + var err error + ut.testServer, err = New(cfg, WithMetadataStore(ut.mockedStorer), WithObjectStore(ut.mockedStorerGetter)) + ut.NoError(err) ut.NotNil(ut.testServer) router := ut.testServer.Router() ut.NotNil(router) allPaths := []string{} - err := router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error { + err = router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error { pathTemplate, err := route.GetPathTemplate() if err != nil { ut.FailNow(err.Error()) @@ -124,6 +129,7 @@ func (ut *UTServerSuite) Test_New() { allPaths = append(allPaths, pathTemplate) return nil }) + if err != nil { ut.FailNow(err.Error()) } @@ -141,13 +147,15 @@ func (ut *UTServerSuite) Test_New_EnableGraphQL_False() { cfg := new(config.Config) cfg.EnableGraphql = false cfg.GraphqlWebClientEnable = true - ut.testServer = New(ut.mockedStorer, ut.mockedStorerGetter, cfg, nil) + var err error + ut.testServer, err = New(cfg, WithMetadataStore(ut.mockedStorer), WithObjectStore(ut.mockedStorerGetter)) + ut.NoError(err) ut.NotNil(ut.testServer) router := ut.testServer.Router() ut.NotNil(router) allPaths := []string{} - err := router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error { + err = router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error { pathTemplate, err := route.GetPathTemplate() if err != nil { ut.FailNow(err.Error()) @@ -173,13 +181,15 @@ func (ut *UTServerSuite) Test_New_GraphqlWebClientEnable_False() { cfg := new(config.Config) cfg.EnableGraphql = true cfg.GraphqlWebClientEnable = false - ut.testServer = New(ut.mockedStorer, ut.mockedStorerGetter, cfg, nil) + var err error + ut.testServer, err = New(cfg, WithMetadataStore(ut.mockedStorer), WithObjectStore(ut.mockedStorerGetter)) + ut.NoError(err) ut.NotNil(ut.testServer) router := ut.testServer.Router() ut.NotNil(router) allPaths := []string{} - err := router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error { + err = router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error { pathTemplate, err := route.GetPathTemplate() if err != nil { ut.FailNow(err.Error()) diff --git a/internal/witnessdistro/witnessdistro.go b/internal/witnessdistro/witnessdistro.go new file mode 100644 index 00000000..c5dd0895 --- /dev/null +++ b/internal/witnessdistro/witnessdistro.go @@ -0,0 +1,154 @@ +package witnessdistro + +import ( + "crypto" + "errors" + "fmt" + "os" + "strings" + + "github.com/in-toto/go-witness/cryptoutil" + "gopkg.in/yaml.v3" +) + +// Manifest represents the available versions of Witness +type Manifest struct { + Versions map[string]Version `json:"versions"` +} + +// Version represents a version of witness (ex 0.2.0) with the available Distributions of version +type Version struct { + Distributions map[string]Distribution `json:"distributions"` + Description string `json:"description"` +} + +// Distribution is a specific distribution of Witness (ex linux-amd64) +type Distribution struct { + FileLocation string `json:"-"` + SHA256Digest string `json:"sha256digest"` +} + +type WitnessDistroStore struct { + manifest Manifest +} + +type Option func(*WitnessDistroStore) error + +// WithManifest creates a WitnessDistroStore with the provided manifest +func WithManifest(manifest Manifest) Option { + return func(wds *WitnessDistroStore) error { + wds.manifest = manifest + return nil + } +} + +// WithManifestFile creates a WitnessDistroStore with a manifest loaded from a yaml file on disk +func WithManifestFile(manifestPath string) Option { + return func(wds *WitnessDistroStore) error { + manifestBytes, err := os.ReadFile(manifestPath) + if err != nil { + return err + } + + manifest := Manifest{} + if err := yaml.Unmarshal(manifestBytes, &manifest); err != nil { + return err + } + + WithManifest(manifest)(wds) + return nil + } +} + +// New creates a new WitnessDistroStore with the provided options +func New(opts ...Option) (WitnessDistroStore, error) { + wds := WitnessDistroStore{} + errs := make([]error, 0) + for _, opt := range opts { + if err := opt(&wds); err != nil { + errs = append(errs, err) + } + } + + if len(errs) > 0 { + return WitnessDistroStore{}, errors.Join(errs...) + } + + if err := verifyManifest(wds); err != nil { + return WitnessDistroStore{}, err + } + + return wds, nil +} + +// verifyManifest ensures that each file exists on disk and that the sha256sum of the +// files on disk match those of the manifest +func verifyManifest(wds WitnessDistroStore) error { + errs := make([]error, 0) + for versionString, version := range wds.manifest.Versions { + for distroString, distro := range version.Distributions { + if _, err := os.Stat(distro.FileLocation); err != nil { + errs = append(errs, fmt.Errorf("witness version %v-%v does not exist on disk: %w", versionString, distroString, err)) + continue + } + + digestSet, err := cryptoutil.CalculateDigestSetFromFile(distro.FileLocation, []crypto.Hash{crypto.SHA256}) + if err != nil { + errs = append(errs, fmt.Errorf("could not calculate sha256 digest for witness version %v-%v: %w", versionString, distroString, err)) + } + + sha256Digest := digestSet[cryptoutil.DigestValue{Hash: crypto.SHA256, GitOID: false}] + if !strings.EqualFold(sha256Digest, distro.SHA256Digest) { + errs = append(errs, fmt.Errorf("sha256 digest of witness version %v-%v does not match manifest: got %v, expected %v", versionString, distroString, sha256Digest, distro.SHA256Digest)) + } + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + + return nil +} + +// Versions creates a copy of the manifest's list of versions +func (wds WitnessDistroStore) Versions() map[string]Version { + out := make(map[string]Version) + for verString, version := range wds.manifest.Versions { + out[verString] = version + } + + return out +} + +// Versions creates a copy of the manifest's list of versions +func (wds WitnessDistroStore) Version(version string) (Version, bool) { + v, ok := wds.manifest.Versions[version] + return v, ok +} + +// Distributions creates a copy of the manifest's list of Distributions for a specific manifest +func (wds WitnessDistroStore) Distributions(version string) (map[string]Distribution, bool) { + out := make(map[string]Distribution) + vers, ok := wds.manifest.Versions[version] + if !ok { + return out, ok + } + + for distroString, distro := range vers.Distributions { + out[distroString] = distro + } + + return out, true +} + +// Distribution will return the entry for a specific distribution for a specific version +func (wds WitnessDistroStore) Distribution(version, distribution string) (Distribution, bool) { + vers, ok := wds.manifest.Versions[version] + if !ok { + return Distribution{}, ok + } + + distro, ok := vers.Distributions[distribution] + return distro, ok +}