diff --git a/azure/azevents/BUILD.bazel b/azure/azevents/BUILD.bazel index 2d6d0e0..d145701 100644 --- a/azure/azevents/BUILD.bazel +++ b/azure/azevents/BUILD.bazel @@ -6,6 +6,8 @@ go_library( importpath = "github.com/RMI/pacta/azure/azevents", visibility = ["//visibility:public"], deps = [ + "//db", + "//pacta", "//task", "@com_github_go_chi_chi_v5//:chi", "@org_uber_go_zap//:zap", diff --git a/azure/azevents/azevents.go b/azure/azevents/azevents.go index 7eb188b..439bac1 100644 --- a/azure/azevents/azevents.go +++ b/azure/azevents/azevents.go @@ -4,6 +4,7 @@ package azevents import ( + "context" "encoding/json" "errors" "fmt" @@ -12,6 +13,8 @@ import ( "strings" "time" + "github.com/RMI/pacta/db" + "github.com/RMI/pacta/pacta" "github.com/RMI/pacta/task" "github.com/go-chi/chi/v5" "go.uber.org/zap" @@ -19,6 +22,8 @@ import ( type Config struct { Logger *zap.Logger + DB DB + Now func() time.Time Subscription string ResourceGroup string @@ -31,6 +36,16 @@ type Config struct { ParsedPortfolioTopicName string } +type DB interface { + Transactional(context.Context, func(tx db.Tx) error) error + + CreateBlob(tx db.Tx, b *pacta.Blob) (pacta.BlobID, error) + CreatePortfolio(tx db.Tx, p *pacta.Portfolio) (pacta.PortfolioID, error) + + IncompleteUploads(tx db.Tx, ids []pacta.IncompleteUploadID) (map[pacta.IncompleteUploadID]*pacta.IncompleteUpload, error) + UpdateIncompleteUpload(tx db.Tx, id pacta.IncompleteUploadID, mutations ...db.UpdateIncompleteUploadFn) error +} + const parsedPortfolioPath = "/events/parsed_portfolio" func (c *Config) validate() error { @@ -49,6 +64,12 @@ func (c *Config) validate() error { if c.ParsedPortfolioTopicName == "" { return errors.New("no parsed portfolio topic name given") } + if c.DB == nil { + return errors.New("no DB was given") + } + if c.Now == nil { + return errors.New("no Now function was given") + } return nil } @@ -61,6 +82,8 @@ type Server struct { subscription string resourceGroup string pathToTopic map[string]string + db DB + now func() time.Time } func NewServer(cfg *Config) (*Server, error) { @@ -73,6 +96,8 @@ func NewServer(cfg *Config) (*Server, error) { allowedAuthSecrets: cfg.AllowedAuthSecrets, subscription: cfg.Subscription, resourceGroup: cfg.ResourceGroup, + db: cfg.DB, + now: cfg.Now, pathToTopic: map[string]string{ parsedPortfolioPath: cfg.ParsedPortfolioTopicName, }, @@ -213,7 +238,91 @@ func (s *Server) RegisterHandlers(r chi.Router) { return } - // TODO: Add any database persistence and other things we'd want to do after a portfolio was parsed. - s.logger.Info("parsed portfolio", zap.String("task_id", string(req.Data.TaskID))) + if len(req.Data.Outputs) == 0 { + s.logger.Error("webhook response had no processed portfolios", zap.String("event_grid_id", req.ID)) + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + portfolioIDs := []pacta.PortfolioID{} + var ranAt time.Time + now := s.now() + err := s.db.Transactional(r.Context(), func(tx db.Tx) error { + incompleteUploads, err := s.db.IncompleteUploads(tx, req.Data.Request.IncompleteUploadIDs) + if err != nil { + return fmt.Errorf("reading incomplete uploads: %w", err) + } + if len(incompleteUploads) == 0 { + return fmt.Errorf("no incomplete uploads found for ids: %v", req.Data.Request.IncompleteUploadIDs) + } + var holdingsDate *pacta.HoldingsDate + var ownerID pacta.OwnerID + for _, iu := range incompleteUploads { + if ownerID == "" { + ownerID = iu.Owner.ID + } else if ownerID != iu.Owner.ID { + return fmt.Errorf("multiple owners found for incomplete uploads: %+v", incompleteUploads) + } + if iu.HoldingsDate == nil { + return fmt.Errorf("incomplete upload %s had no holdings date", iu.ID) + } + if holdingsDate == nil { + holdingsDate = iu.HoldingsDate + } else if *holdingsDate != *iu.HoldingsDate { + return fmt.Errorf("multiple holdings dates found for incomplete uploads: %+v", incompleteUploads) + } + ranAt = iu.RanAt + } + for i, output := range req.Data.Outputs { + blobID, err := s.db.CreateBlob(tx, &output.Blob) + if err != nil { + return fmt.Errorf("creating blob %d: %w", i, err) + } + portfolioID, err := s.db.CreatePortfolio(tx, &pacta.Portfolio{ + Owner: &pacta.Owner{ID: ownerID}, + Name: output.Blob.FileName, + NumberOfRows: output.LineCount, + Blob: &pacta.Blob{ID: blobID}, + HoldingsDate: holdingsDate, + }) + if err != nil { + return fmt.Errorf("creating portfolio %d: %w", i, err) + } + portfolioIDs = append(portfolioIDs, portfolioID) + } + for i, iu := range incompleteUploads { + err := s.db.UpdateIncompleteUpload( + tx, + iu.ID, + db.SetIncompleteUploadCompletedAt(now), + db.SetIncompleteUploadFailureMessage(""), + db.SetIncompleteUploadFailureCode("")) + if err != nil { + return fmt.Errorf("updating incomplete upload %d: %w", i, err) + } + } + return nil + }) + if err != nil { + s.logger.Error("failed to save response to database", zap.Error(err)) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + s.logger.Info("parsed portfolio", + zap.String("task_id", string(req.Data.TaskID)), + zap.Duration("run_time", now.Sub(ranAt)), + zap.Strings("incomplete_upload_ids", asStrs(req.Data.Request.IncompleteUploadIDs)), + zap.Int("incomplete_upload_count", len(req.Data.Request.IncompleteUploadIDs)), + zap.Strings("portfolio_ids", asStrs(portfolioIDs)), + zap.Int("portfolio_count", len(portfolioIDs))) }) } + +func asStrs[T ~string](ts []T) []string { + ss := make([]string, len(ts)) + for i, t := range ts { + ss[i] = string(t) + } + return ss +} diff --git a/cmd/runner/BUILD.bazel b/cmd/runner/BUILD.bazel index 080d643..27b2f8b 100644 --- a/cmd/runner/BUILD.bazel +++ b/cmd/runner/BUILD.bazel @@ -71,4 +71,4 @@ oci_tarball( name = "image_tarball", image = ":image", repo_tags = [], -) +) \ No newline at end of file diff --git a/cmd/runner/taskrunner/taskrunner.go b/cmd/runner/taskrunner/taskrunner.go index a709222..96ed40e 100644 --- a/cmd/runner/taskrunner/taskrunner.go +++ b/cmd/runner/taskrunner/taskrunner.go @@ -98,7 +98,7 @@ func (tr *TaskRunner) ParsePortfolio(ctx context.Context, req *task.ParsePortfol }, { Key: "PARSE_PORTFOLIO_REQUEST", - Value: buf.String(), + Value: value, }, }) } diff --git a/cmd/server/main.go b/cmd/server/main.go index 7f365c5..db4c3e7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -234,6 +234,7 @@ func run(args []string) error { } runner = tmp } else { + logger.Info("initializing local task runner client") tmp, err := dockertask.NewRunner(logger, &dockertask.ServicePrincipal{ TenantID: *localDockerTenantID, ClientID: *localDockerClientID, @@ -270,6 +271,7 @@ func run(args []string) error { Logger: logger, DB: db, TaskRunner: tr, + Now: time.Now, } pactaStrictHandler := oapipacta.NewStrictHandlerWithOptions(srv, nil /* middleware */, oapipacta.StrictHTTPServerOptions{ @@ -290,6 +292,8 @@ func run(args []string) error { Subscription: *azEventSubscription, ResourceGroup: *azEventResourceGroup, ParsedPortfolioTopicName: *azEventParsedPortfolioTopic, + DB: db, + Now: time.Now, }) if err != nil { return fmt.Errorf("failed to init Azure Event Grid handler: %w", err) @@ -313,7 +317,7 @@ func run(args []string) error { // LogEntry created by the logging middleware. chimiddleware.RequestID, chimiddleware.RealIP, - zaphttplog.NewMiddleware(logger, zaphttplog.WithConcise(true)), + zaphttplog.NewMiddleware(logger, zaphttplog.WithConcise(false)), chimiddleware.Recoverer, jwtauth.Verifier(jwtauth.New("EdDSA", nil, jwKey)), diff --git a/cmd/server/pactasrv/BUILD.bazel b/cmd/server/pactasrv/BUILD.bazel index 1dce345..b03dab7 100644 --- a/cmd/server/pactasrv/BUILD.bazel +++ b/cmd/server/pactasrv/BUILD.bazel @@ -3,6 +3,7 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library") go_library( name = "pactasrv", srcs = [ + "incomplete_upload.go", "initiative.go", "initiative_invitation.go", "initiative_user_relationship.go", @@ -25,6 +26,7 @@ go_library( "//session", "//task", "@com_github_go_chi_jwtauth_v5//:jwtauth", + "@com_github_google_uuid//:uuid", "@org_uber_go_zap//:zap", ], ) diff --git a/cmd/server/pactasrv/conv/BUILD.bazel b/cmd/server/pactasrv/conv/BUILD.bazel index 831e564..280a289 100644 --- a/cmd/server/pactasrv/conv/BUILD.bazel +++ b/cmd/server/pactasrv/conv/BUILD.bazel @@ -3,6 +3,7 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library") go_library( name = "conv", srcs = [ + "helpers.go", "oapi_to_pacta.go", "pacta_to_oapi.go", ], diff --git a/cmd/server/pactasrv/conv/helpers.go b/cmd/server/pactasrv/conv/helpers.go new file mode 100644 index 0000000..d967dfd --- /dev/null +++ b/cmd/server/pactasrv/conv/helpers.go @@ -0,0 +1,41 @@ +package conv + +import "time" + +func ptr[T any](t T) *T { + return &t +} + +func ifNil[T any](t *T, fallback T) T { + if t == nil { + return fallback + } + return *t +} + +func timeToNilable(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + return &t +} + +func stringToNilable[T ~string](t T) *string { + if t == "" { + return nil + } + s := string(t) + return &s +} + +func convAll[I any, O any](is []I, f func(I) (O, error)) ([]O, error) { + os := make([]O, len(is)) + for i, v := range is { + o, err := f(v) + if err != nil { + return nil, err + } + os[i] = o + } + return os, nil +} diff --git a/cmd/server/pactasrv/conv/oapi_to_pacta.go b/cmd/server/pactasrv/conv/oapi_to_pacta.go index be59d80..7d0fb06 100644 --- a/cmd/server/pactasrv/conv/oapi_to_pacta.go +++ b/cmd/server/pactasrv/conv/oapi_to_pacta.go @@ -67,9 +67,11 @@ func InitiativeInvitationFromOAPI(i *api.InitiativeInvitationCreate) (*pacta.Ini }, nil } -func ifNil[T any](t *T, fallback T) T { - if t == nil { - return fallback +func HoldingsDateFromOAPI(hd *api.HoldingsDate) (*pacta.HoldingsDate, error) { + if hd == nil { + return nil, nil } - return *t + return &pacta.HoldingsDate{ + Time: hd.Time, + }, nil } diff --git a/cmd/server/pactasrv/conv/pacta_to_oapi.go b/cmd/server/pactasrv/conv/pacta_to_oapi.go index c259633..6bf6c9d 100644 --- a/cmd/server/pactasrv/conv/pacta_to_oapi.go +++ b/cmd/server/pactasrv/conv/pacta_to_oapi.go @@ -4,6 +4,7 @@ import ( "github.com/RMI/pacta/oapierr" api "github.com/RMI/pacta/openapi/pacta" "github.com/RMI/pacta/pacta" + "go.uber.org/zap" ) func InitiativeToOAPI(i *pacta.Initiative) (*api.Initiative, error) { @@ -94,6 +95,60 @@ func InitiativeUserRelationshipToOAPI(i *pacta.InitiativeUserRelationship) (*api }, nil } -func ptr[T any](t T) *T { - return &t +func HoldingsDateToOAPI(hd *pacta.HoldingsDate) (*api.HoldingsDate, error) { + if hd == nil { + return nil, nil + } + return &api.HoldingsDate{ + Time: hd.Time, + }, nil +} + +func IncompleteUploadsToOAPI(ius []*pacta.IncompleteUpload) ([]*api.IncompleteUpload, error) { + return convAll(ius, IncompleteUploadToOAPI) +} + +func IncompleteUploadToOAPI(iu *pacta.IncompleteUpload) (*api.IncompleteUpload, error) { + if iu == nil { + return nil, oapierr.Internal("incompleteUploadToOAPI: can't convert nil pointer") + } + hd, err := HoldingsDateToOAPI(iu.HoldingsDate) + if err != nil { + return nil, oapierr.Internal("incompleteUploadToOAPI: holdingsDateToOAPI failed", zap.Error(err)) + } + return &api.IncompleteUpload{ + Id: string(iu.ID), + Name: iu.Name, + Description: iu.Description, + HoldingsDate: hd, + CreatedAt: iu.CreatedAt, + RanAt: timeToNilable(iu.RanAt), + CompletedAt: timeToNilable(iu.CompletedAt), + FailureCode: stringToNilable(iu.FailureCode), + FailureMessage: stringToNilable(iu.FailureMessage), + AdminDebugEnabled: iu.AdminDebugEnabled, + }, nil +} + +func PortfoliosToOAPI(ius []*pacta.Portfolio) ([]*api.Portfolio, error) { + return convAll(ius, PortfolioToOAPI) +} + +func PortfolioToOAPI(p *pacta.Portfolio) (*api.Portfolio, error) { + if p == nil { + return nil, oapierr.Internal("portfolioToOAPI: can't convert nil pointer") + } + hd, err := HoldingsDateToOAPI(p.HoldingsDate) + if err != nil { + return nil, oapierr.Internal("portfolioToOAPI: holdingsDateToOAPI failed", zap.Error(err)) + } + return &api.Portfolio{ + Id: string(p.ID), + Name: p.Name, + Description: p.Description, + HoldingsDate: hd, + CreatedAt: p.CreatedAt, + NumberOfRows: p.NumberOfRows, + AdminDebugEnabled: p.AdminDebugEnabled, + }, nil } diff --git a/cmd/server/pactasrv/incomplete_upload.go b/cmd/server/pactasrv/incomplete_upload.go new file mode 100644 index 0000000..c905264 --- /dev/null +++ b/cmd/server/pactasrv/incomplete_upload.go @@ -0,0 +1,113 @@ +package pactasrv + +import ( + "context" + "fmt" + + "github.com/RMI/pacta/cmd/server/pactasrv/conv" + "github.com/RMI/pacta/db" + "github.com/RMI/pacta/oapierr" + api "github.com/RMI/pacta/openapi/pacta" + "github.com/RMI/pacta/pacta" + "go.uber.org/zap" +) + +// (GET /incomplete-uploads) +func (s *Server) ListIncompleteUploads(ctx context.Context, request api.ListIncompleteUploadsRequestObject) (api.ListIncompleteUploadsResponseObject, error) { + // TODO(#12) Implement Authorization + ownerID, err := s.getUserOwnerID(ctx) + if err != nil { + return nil, err + } + ius, err := s.DB.IncompleteUploadsByOwner(s.DB.NoTxn(ctx), ownerID) + if err != nil { + return nil, oapierr.Internal("failed to query incomplete uploads", zap.Error(err)) + } + s.Logger.Info("queried incomplete uploads", zap.Int("count", len(ius)), zap.String("owner_id", string(ownerID))) + items, err := dereference(conv.IncompleteUploadsToOAPI(ius)) + if err != nil { + return nil, err + } + return api.ListIncompleteUploads200JSONResponse{Items: items}, nil +} + +// Deletes an incomplete upload by ID +// (DELETE /incomplete-upload/{id}) +func (s *Server) DeleteIncompleteUpload(ctx context.Context, request api.DeleteIncompleteUploadRequestObject) (api.DeleteIncompleteUploadResponseObject, error) { + id := pacta.IncompleteUploadID(request.Id) + _, err := s.checkIncompleteUploadAuthorization(ctx, id) + if err != nil { + return nil, err + } + blobURI, err := s.DB.DeleteIncompleteUpload(s.DB.NoTxn(ctx), id) + if err != nil { + return nil, oapierr.Internal("failed to delete incomplete upload", zap.Error(err)) + } + if err := s.deleteBlobs(ctx, []string{string(blobURI)}); err != nil { + return nil, oapierr.Internal("failed to delete blob", zap.Error(err)) + } + return api.DeleteIncompleteUpload204Response{}, nil +} + +// Returns an incomplete upload by ID +// (GET /incomplete-upload/{id}) +func (s *Server) FindIncompleteUploadById(ctx context.Context, request api.FindIncompleteUploadByIdRequestObject) (api.FindIncompleteUploadByIdResponseObject, error) { + iu, err := s.checkIncompleteUploadAuthorization(ctx, pacta.IncompleteUploadID(request.Id)) + if err != nil { + return nil, err + } + converted, err := conv.IncompleteUploadToOAPI(iu) + if err != nil { + return nil, err + } + return api.FindIncompleteUploadById200JSONResponse(*converted), nil +} + +// Updates incomplete upload properties +// (PATCH /incomplete-upload/{id}) +func (s *Server) UpdateIncompleteUpload(ctx context.Context, request api.UpdateIncompleteUploadRequestObject) (api.UpdateIncompleteUploadResponseObject, error) { + id := pacta.IncompleteUploadID(request.Id) + _, err := s.checkIncompleteUploadAuthorization(ctx, id) + if err != nil { + return nil, err + } + // TODO(#12) Implement Authorization + mutations := []db.UpdateIncompleteUploadFn{} + if request.Body.Name != nil { + mutations = append(mutations, db.SetIncompleteUploadName(*request.Body.Name)) + } + if request.Body.Description != nil { + mutations = append(mutations, db.SetIncompleteUploadDescription(*request.Body.Description)) + } + if request.Body.AdminDebugEnabled != nil { + mutations = append(mutations, db.SetIncompleteUploadAdminDebugEnabled(*request.Body.AdminDebugEnabled)) + } + err = s.DB.UpdateIncompleteUpload(s.DB.NoTxn(ctx), id, mutations...) + if err != nil { + return nil, oapierr.Internal("failed to update incomplete upload", zap.Error(err)) + } + return api.UpdateIncompleteUpload204Response{}, nil +} + +func (s *Server) checkIncompleteUploadAuthorization(ctx context.Context, id pacta.IncompleteUploadID) (*pacta.IncompleteUpload, error) { + // TODO(#12) Implement Authorization + actorOwnerID, err := s.getUserOwnerID(ctx) + if err != nil { + return nil, err + } + // Extracted to a common variable so that we return the same response for not found and unauthorized. + notFoundErr := func(err error) error { + return oapierr.NotFound("incomplete upload not found", zap.String("incomplete_upload_id", string(id)), zap.Error(err)) + } + iu, err := s.DB.IncompleteUpload(s.DB.NoTxn(ctx), id) + if err != nil { + if db.IsNotFound(err) { + return nil, notFoundErr(err) + } + return nil, oapierr.Internal("failed to look up incomplete upload", zap.String("incomplete_upload_id", string(id)), zap.Error(err)) + } + if iu.Owner.ID != actorOwnerID { + return nil, notFoundErr(fmt.Errorf("incomplete upload does not belong to user: owner=%s actor=%s", iu.Owner.ID, actorOwnerID)) + } + return iu, nil +} diff --git a/cmd/server/pactasrv/pactasrv.go b/cmd/server/pactasrv/pactasrv.go index fb5d057..8b40623 100644 --- a/cmd/server/pactasrv/pactasrv.go +++ b/cmd/server/pactasrv/pactasrv.go @@ -3,6 +3,7 @@ package pactasrv import ( "context" "fmt" + "time" "github.com/RMI/pacta/blob" "github.com/RMI/pacta/db" @@ -67,12 +68,21 @@ type DB interface { CreatePortfolioInitiativeMembership(tx db.Tx, pim *pacta.PortfolioInitiativeMembership) error DeletePortfolioInitiativeMembership(tx db.Tx, pid pacta.PortfolioID, iid pacta.InitiativeID) error + Portfolio(tx db.Tx, id pacta.PortfolioID) (*pacta.Portfolio, error) + PortfoliosByOwner(tx db.Tx, owner pacta.OwnerID) ([]*pacta.Portfolio, error) + CreatePortfolio(tx db.Tx, i *pacta.Portfolio) (pacta.PortfolioID, error) + UpdatePortfolio(tx db.Tx, id pacta.PortfolioID, mutations ...db.UpdatePortfolioFn) error + DeletePortfolio(tx db.Tx, id pacta.PortfolioID) ([]pacta.BlobURI, error) + IncompleteUpload(tx db.Tx, id pacta.IncompleteUploadID) (*pacta.IncompleteUpload, error) IncompleteUploads(tx db.Tx, ids []pacta.IncompleteUploadID) (map[pacta.IncompleteUploadID]*pacta.IncompleteUpload, error) + IncompleteUploadsByOwner(tx db.Tx, owner pacta.OwnerID) ([]*pacta.IncompleteUpload, error) CreateIncompleteUpload(tx db.Tx, i *pacta.IncompleteUpload) (pacta.IncompleteUploadID, error) UpdateIncompleteUpload(tx db.Tx, id pacta.IncompleteUploadID, mutations ...db.UpdateIncompleteUploadFn) error DeleteIncompleteUpload(tx db.Tx, id pacta.IncompleteUploadID) (pacta.BlobURI, error) + GetOrCreateOwnerForUser(tx db.Tx, uID pacta.UserID) (pacta.OwnerID, error) + GetOrCreateUserByAuthn(tx db.Tx, mech pacta.AuthnMechanism, authnID, email, canonicalEmail string) (*pacta.User, error) User(tx db.Tx, id pacta.UserID) (*pacta.User, error) Users(tx db.Tx, ids []pacta.UserID) (map[pacta.UserID]*pacta.User, error) @@ -83,9 +93,7 @@ type DB interface { type Blob interface { Scheme() blob.Scheme - // For uploading portfolios SignedUploadURL(ctx context.Context, uri string) (string, error) - // For downloading reports SignedDownloadURL(ctx context.Context, uri string) (string, error) DeleteBlob(ctx context.Context, uri string) error } @@ -95,6 +103,7 @@ type Server struct { TaskRunner TaskRunner Logger *zap.Logger Blob Blob + Now func() time.Time PorfolioUploadURI string } @@ -131,3 +140,24 @@ func getUserID(ctx context.Context) (pacta.UserID, error) { } return pacta.UserID(userID), nil } + +func (s *Server) getUserOwnerID(ctx context.Context) (pacta.OwnerID, error) { + userID, err := getUserID(ctx) + if err != nil { + return "", err + } + ownerID, err := s.DB.GetOrCreateOwnerForUser(s.DB.NoTxn(ctx), userID) + if err != nil { + return "", oapierr.Internal("failed to find or create owner for user", + zap.String("user_id", string(userID)), zap.Error(err)) + } + return ownerID, nil +} + +func asStrs[T ~string](ts []T) []string { + result := make([]string, len(ts)) + for i, t := range ts { + result[i] = string(t) + } + return result +} diff --git a/cmd/server/pactasrv/portfolio.go b/cmd/server/pactasrv/portfolio.go index 799cee6..9826a1f 100644 --- a/cmd/server/pactasrv/portfolio.go +++ b/cmd/server/pactasrv/portfolio.go @@ -2,21 +2,111 @@ package pactasrv import ( "context" + "fmt" + "github.com/RMI/pacta/cmd/server/pactasrv/conv" + "github.com/RMI/pacta/db" "github.com/RMI/pacta/oapierr" api "github.com/RMI/pacta/openapi/pacta" + "github.com/RMI/pacta/pacta" + "go.uber.org/zap" ) -func (s *Server) CreatePortfolioAsset(ctx context.Context, req api.CreatePortfolioAssetRequestObject) (api.CreatePortfolioAssetResponseObject, error) { - return nil, oapierr.NotImplemented("no longer implemented") +// (GET /portfolios) +func (s *Server) ListPortfolios(ctx context.Context, request api.ListPortfoliosRequestObject) (api.ListPortfoliosResponseObject, error) { + // TODO(#12) Implement Authorization + ownerID, err := s.getUserOwnerID(ctx) + if err != nil { + return nil, err + } + ius, err := s.DB.PortfoliosByOwner(s.DB.NoTxn(ctx), ownerID) + if err != nil { + return nil, oapierr.Internal("failed to query portfolios", zap.Error(err)) + } + items, err := dereference(conv.PortfoliosToOAPI(ius)) + if err != nil { + return nil, err + } + return api.ListPortfolios200JSONResponse{Items: items}, nil } -func (s *Server) ParsePortfolio(ctx context.Context, req api.ParsePortfolioRequestObject) (api.ParsePortfolioResponseObject, error) { - return nil, oapierr.NotImplemented("no longer implemented") +// Deletes an portfolio by ID +// (DELETE /portfolio/{id}) +func (s *Server) DeletePortfolio(ctx context.Context, request api.DeletePortfolioRequestObject) (api.DeletePortfolioResponseObject, error) { + id := pacta.PortfolioID(request.Id) + _, err := s.checkPortfolioAuthorization(ctx, id) + if err != nil { + return nil, err + } + blobURIs, err := s.DB.DeletePortfolio(s.DB.NoTxn(ctx), id) + if err != nil { + return nil, oapierr.Internal("failed to delete portfolio", zap.Error(err)) + } + if err := s.deleteBlobs(ctx, asStrs(blobURIs)); err != nil { + return nil, oapierr.Internal("failed to delete blob", zap.Error(err)) + } + return api.DeletePortfolio204Response{}, nil } -// (GET /portfolios) +// Returns an portfolio by ID +// (GET /portfolio/{id}) +func (s *Server) FindPortfolioById(ctx context.Context, request api.FindPortfolioByIdRequestObject) (api.FindPortfolioByIdResponseObject, error) { + iu, err := s.checkPortfolioAuthorization(ctx, pacta.PortfolioID(request.Id)) + if err != nil { + return nil, err + } + converted, err := conv.PortfolioToOAPI(iu) + if err != nil { + return nil, err + } + return api.FindPortfolioById200JSONResponse(*converted), nil +} -func (s *Server) ListPortfolios(ctx context.Context, request api.ListPortfoliosRequestObject) (api.ListPortfoliosResponseObject, error) { - return nil, oapierr.NotImplemented("not implemented") +// Updates portfolio properties +// (PATCH /portfolio/{id}) +func (s *Server) UpdatePortfolio(ctx context.Context, request api.UpdatePortfolioRequestObject) (api.UpdatePortfolioResponseObject, error) { + id := pacta.PortfolioID(request.Id) + _, err := s.checkPortfolioAuthorization(ctx, id) + if err != nil { + return nil, err + } + // TODO(#12) Implement Authorization + mutations := []db.UpdatePortfolioFn{} + if request.Body.Name != nil { + mutations = append(mutations, db.SetPortfolioName(*request.Body.Name)) + } + if request.Body.Description != nil { + mutations = append(mutations, db.SetPortfolioDescription(*request.Body.Description)) + } + if request.Body.AdminDebugEnabled != nil { + mutations = append(mutations, db.SetPortfolioAdminDebugEnabled(*request.Body.AdminDebugEnabled)) + } + err = s.DB.UpdatePortfolio(s.DB.NoTxn(ctx), id, mutations...) + if err != nil { + return nil, oapierr.Internal("failed to update portfolio", zap.Error(err)) + } + return api.UpdatePortfolio204Response{}, nil +} + +func (s *Server) checkPortfolioAuthorization(ctx context.Context, id pacta.PortfolioID) (*pacta.Portfolio, error) { + // TODO(#12) Implement Authorization + actorOwnerID, err := s.getUserOwnerID(ctx) + if err != nil { + return nil, err + } + // Extracted to a common variable so that we return the same response for not found and unauthorized. + notFoundErr := func(err error) error { + return oapierr.NotFound("portfolio not found", zap.String("portfolio_id", string(id)), zap.Error(err)) + } + iu, err := s.DB.Portfolio(s.DB.NoTxn(ctx), id) + if err != nil { + if db.IsNotFound(err) { + return nil, notFoundErr(err) + } + return nil, oapierr.Internal("failed to look up portfolio", zap.String("portfolio_id", string(id)), zap.Error(err)) + } + if iu.Owner.ID != actorOwnerID { + return nil, notFoundErr(fmt.Errorf("portfolio does not belong to user: owner=%s actor=%s", iu.Owner.ID, actorOwnerID)) + } + return iu, nil } diff --git a/cmd/server/pactasrv/upload.go b/cmd/server/pactasrv/upload.go index 55335cd..22bd3a9 100644 --- a/cmd/server/pactasrv/upload.go +++ b/cmd/server/pactasrv/upload.go @@ -2,24 +2,168 @@ package pactasrv import ( "context" + "fmt" + "path/filepath" + "github.com/RMI/pacta/blob" + "github.com/RMI/pacta/cmd/server/pactasrv/conv" + "github.com/RMI/pacta/db" "github.com/RMI/pacta/oapierr" api "github.com/RMI/pacta/openapi/pacta" + "github.com/RMI/pacta/pacta" + "github.com/RMI/pacta/task" + "github.com/google/uuid" + "go.uber.org/zap" ) // Starts the process of uploading one or more portfolio files // (POST /portfolio-upload) func (s *Server) StartPortfolioUpload(ctx context.Context, request api.StartPortfolioUploadRequestObject) (api.StartPortfolioUploadResponseObject, error) { - return nil, oapierr.NotImplemented("not implemented") + // TODO(#12) Implement Authorization + // TODO(#71) Implement basic limits + ownerID, err := s.getUserOwnerID(ctx) + if err != nil { + return nil, err + } + owner := &pacta.Owner{ID: ownerID} + holdingsDate, err := conv.HoldingsDateFromOAPI(&request.Body.HoldingsDate) + if err != nil { + return nil, err + } + n := len(request.Body.Items) + blobs := make([]*pacta.Blob, n) + resp := api.StartPortfolioUpload200JSONResponse{ + Items: make([]api.StartPortfolioUploadRespItem, n), + } + for i, item := range request.Body.Items { + fn := item.FileName + extStr := filepath.Ext(fn) + ft, err := pacta.ParseFileType(extStr) + if err != nil { + return nil, oapierr.BadRequest( + fmt.Sprintf("invalid file type: %q", extStr), + zap.String("file_type", extStr), + zap.String("file_name", fn), + zap.Error(err)). + WithErrorID("INVALID_FILE_EXTENSION"). + WithMessage(fmt.Sprintf("File extension %q from file %q is not supported", extStr, fn)) + } + blobs[i] = &pacta.Blob{ + FileType: ft, + FileName: item.FileName, + } + resp.Items[i].FileName = string(fn) + } + + for i := range request.Body.Items { + id := uuid.NewString() + uri := blob.Join(s.Blob.Scheme(), s.PorfolioUploadURI, id) + signed, err := s.Blob.SignedUploadURL(ctx, uri) + if err != nil { + return nil, oapierr.Internal("failed to sign blob URI", zap.String("uri", uri), zap.Error(err)) + } + blobs[i].BlobURI = pacta.BlobURI(uri) + resp.Items[i].UploadUrl = signed + } + + err = s.DB.Transactional(ctx, func(tx db.Tx) error { + for i := 0; i < n; i++ { + blob := blobs[i] + blobID, err := s.DB.CreateBlob(tx, blob) + if err != nil { + return fmt.Errorf("creating blob %d: %w", i, err) + } + blob.ID = blobID + iuid, err := s.DB.CreateIncompleteUpload(tx, &pacta.IncompleteUpload{ + Blob: blob, + Name: blob.FileName, + HoldingsDate: holdingsDate, + Owner: owner, + }) + if err != nil { + return fmt.Errorf("creating incomplete upload %d: %w", i, err) + } + resp.Items[i].IncompleteUploadId = string(iuid) + } + return nil + }) + if err != nil { + return nil, oapierr.Internal("failed to create uploads", zap.Error(err)) + } + + return resp, nil } // Called after uploads of portfolios to cloud storage are complete. // (POST /portfolio-upload:complete) func (s *Server) CompletePortfolioUpload(ctx context.Context, request api.CompletePortfolioUploadRequestObject) (api.CompletePortfolioUploadResponseObject, error) { - return nil, oapierr.NotImplemented("not implemented") -} + ownerID, err := s.getUserOwnerID(ctx) + if err != nil { + return nil, err + } + // TODO (#12) Implement Authorization + ids := []pacta.IncompleteUploadID{} + for _, item := range request.Body.Items { + ids = append(ids, pacta.IncompleteUploadID(item.IncompleteUploadId)) + } + if len(ids) == 0 { + return nil, oapierr.BadRequest("no incomplete upload IDs provided") + } + // TODO(#71) Implement basic limits + validation + blobURIs := []pacta.BlobURI{} + err = s.DB.Transactional(ctx, func(tx db.Tx) error { + ius, err := s.DB.IncompleteUploads(tx, ids) + if err != nil { + return oapierr.Internal("failed to query incomplete uploads", zap.Error(err)) + } + blobIDs := []pacta.BlobID{} + for _, id := range ids { + iu := ius[id] + if iu == nil || iu.Owner.ID != ownerID { + return oapierr.NotFound( + fmt.Sprintf("incomplete upload %s does not belong to user", id), + zap.String("incomplete_upload_id", string(id)), + zap.String("owner_id", string(ownerID)), + ) + } + blobIDs = append(blobIDs, iu.Blob.ID) + } + blobs, err := s.DB.Blobs(s.DB.NoTxn(ctx), blobIDs) + if err != nil { + return oapierr.Internal("failed to query blobs", zap.Error(err)) + } + for _, blob := range blobs { + blobURIs = append(blobURIs, blob.BlobURI) + } + return nil + }) + if err != nil { + return nil, err + } + + taskID, runnerID, err := s.TaskRunner.ParsePortfolio(ctx, &task.ParsePortfolioRequest{ + BlobURIs: blobURIs, + IncompleteUploadIDs: ids, + }) + if err != nil { + return nil, oapierr.Internal("failed to start task", zap.Error(err)) + } + s.Logger.Info("triggered parse portfolio task", + zap.String("task_id", string(taskID)), + zap.String("task_runner_id", string(runnerID))) -// (GET /incomplete-uploads) -func (s *Server) ListIncompleteUploads(ctx context.Context, request api.ListIncompleteUploadsRequestObject) (api.ListIncompleteUploadsResponseObject, error) { - return nil, oapierr.NotImplemented("not implemented") + now := s.Now() + err = s.DB.Transactional(ctx, func(tx db.Tx) error { + for _, id := range ids { + err := s.DB.UpdateIncompleteUpload(tx, id, db.SetIncompleteUploadRanAt(now)) + if err != nil { + return oapierr.Internal("failed to update incomplete upload with ran_at", zap.Error(err)) + } + } + return nil + }) + if err != nil { + return nil, err + } + return api.CompletePortfolioUpload200JSONResponse{}, nil } diff --git a/db/sqldb/incomplete_upload.go b/db/sqldb/incomplete_upload.go index 039e572..38699e5 100644 --- a/db/sqldb/incomplete_upload.go +++ b/db/sqldb/incomplete_upload.go @@ -33,11 +33,11 @@ func (d *DB) IncompleteUpload(tx db.Tx, id pacta.IncompleteUploadID) (*pacta.Inc if err != nil { return nil, fmt.Errorf("querying incomplete_upload: %w", err) } - pvs, err := rowsToIncompleteUploads(rows) + ius, err := rowsToIncompleteUploads(rows) if err != nil { return nil, fmt.Errorf("translating rows to incomplete_uploads: %w", err) } - return exactlyOne("incomplete_upload", id, pvs) + return exactlyOne("incomplete_upload", id, ius) } func (d *DB) IncompleteUploads(tx db.Tx, ids []pacta.IncompleteUploadID) (map[pacta.IncompleteUploadID]*pacta.IncompleteUpload, error) { @@ -49,17 +49,32 @@ func (d *DB) IncompleteUploads(tx db.Tx, ids []pacta.IncompleteUploadID) (map[pa if err != nil { return nil, fmt.Errorf("querying incomplete_uploads: %w", err) } - pvs, err := rowsToIncompleteUploads(rows) + ius, err := rowsToIncompleteUploads(rows) if err != nil { return nil, fmt.Errorf("translating rows to incomplete_uploads: %w", err) } - result := make(map[pacta.IncompleteUploadID]*pacta.IncompleteUpload, len(pvs)) - for _, pv := range pvs { - result[pv.ID] = pv + result := make(map[pacta.IncompleteUploadID]*pacta.IncompleteUpload, len(ius)) + for _, iu := range ius { + result[iu.ID] = iu } return result, nil } +func (d *DB) IncompleteUploadsByOwner(tx db.Tx, ownerID pacta.OwnerID) ([]*pacta.IncompleteUpload, error) { + rows, err := d.query(tx, ` + SELECT `+incompleteUploadSelectColumns+` + FROM incomplete_upload + WHERE owner_id = $1;`, ownerID) + if err != nil { + return nil, fmt.Errorf("querying incomplete_uploads: %w", err) + } + ius, err := rowsToIncompleteUploads(rows) + if err != nil { + return nil, fmt.Errorf("translating rows to incomplete_uploads: %w", err) + } + return ius, nil +} + func (d *DB) CreateIncompleteUpload(tx db.Tx, i *pacta.IncompleteUpload) (pacta.IncompleteUploadID, error) { if err := validateIncompleteUploadForCreation(i); err != nil { return "", fmt.Errorf("validating incomplete_upload for creation: %w", err) diff --git a/db/sqldb/incomplete_upload_test.go b/db/sqldb/incomplete_upload_test.go index 692981f..7eb6057 100644 --- a/db/sqldb/incomplete_upload_test.go +++ b/db/sqldb/incomplete_upload_test.go @@ -91,6 +91,21 @@ func TestIncompleteUploadCRUD(t *testing.T) { t.Fatalf("mismatch (-want +got):\n%s", diff) } + iusbyo, err := tdb.IncompleteUploadsByOwner(tx, o2.ID) + if err != nil { + t.Fatalf("reading incomplete_uploads by owner: %v", err) + } + if diff := cmp.Diff([]*pacta.IncompleteUpload{iu}, iusbyo, cmpOpts); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) + } + iusbyo, err = tdb.IncompleteUploadsByOwner(tx, o1.ID) + if err != nil { + t.Fatalf("reading incomplete_uploads by owner: %v", err) + } + if diff := cmp.Diff([]*pacta.IncompleteUpload{}, iusbyo, cmpOpts); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) + } + buris, err := tdb.DeleteIncompleteUpload(tx, iu.ID) if err != nil { t.Fatalf("deleting incompleteUpload: %v", err) diff --git a/db/sqldb/portfolio.go b/db/sqldb/portfolio.go index c05a76c..bc3d881 100644 --- a/db/sqldb/portfolio.go +++ b/db/sqldb/portfolio.go @@ -57,6 +57,21 @@ func (d *DB) Portfolios(tx db.Tx, ids []pacta.PortfolioID) (map[pacta.PortfolioI return result, nil } +func (d *DB) PortfoliosByOwner(tx db.Tx, ownerID pacta.OwnerID) ([]*pacta.Portfolio, error) { + rows, err := d.query(tx, ` + SELECT `+portfolioSelectColumns+` + FROM portfolio + WHERE owner_id = $1;`, ownerID) + if err != nil { + return nil, fmt.Errorf("querying portfolios: %w", err) + } + pvs, err := rowsToPortfolios(rows) + if err != nil { + return nil, fmt.Errorf("translating rows to portfolios: %w", err) + } + return pvs, nil +} + func (d *DB) CreatePortfolio(tx db.Tx, p *pacta.Portfolio) (pacta.PortfolioID, error) { if err := validatePortfolioForCreation(p); err != nil { return "", fmt.Errorf("validating portfolio for creation: %w", err) @@ -206,5 +221,8 @@ func validatePortfolioForCreation(p *pacta.Portfolio) error { if p.NumberOfRows < 0 { return fmt.Errorf("portfolio number_of_rows must be non-negative") } + if p.HoldingsDate == nil || p.HoldingsDate.Time.IsZero() { + return fmt.Errorf("portfolio holdings_date must be non-nil and non-zero") + } return nil } diff --git a/db/sqldb/portfolio_test.go b/db/sqldb/portfolio_test.go index 8a48fc2..f74998b 100644 --- a/db/sqldb/portfolio_test.go +++ b/db/sqldb/portfolio_test.go @@ -80,6 +80,20 @@ func TestPortfolioCRUD(t *testing.T) { if diff := cmp.Diff(p, actual, portfolioCmpOpts()); diff != "" { t.Fatalf("portfolio mismatch (-want +got):\n%s", diff) } + pls, err := tdb.PortfoliosByOwner(tx, o2.ID) + if err != nil { + t.Fatalf("reading portfolios: %w", err) + } + if diff := cmp.Diff([]*pacta.Portfolio{p}, pls, portfolioCmpOpts()); diff != "" { + t.Fatalf("portfolio mismatch (-want +got):\n%s", diff) + } + pls, err = tdb.PortfoliosByOwner(tx, o1.ID) + if err != nil { + t.Fatalf("reading portfolios: %w", err) + } + if diff := cmp.Diff([]*pacta.Portfolio{}, pls, portfolioCmpOpts()); diff != "" { + t.Fatalf("portfolio mismatch (-want +got):\n%s", diff) + } buris, err := tdb.DeletePortfolio(tx, p.ID) if err != nil { diff --git a/openapi/pacta.yaml b/openapi/pacta.yaml index 0f08a94..22d9aef 100644 --- a/openapi/pacta.yaml +++ b/openapi/pacta.yaml @@ -375,36 +375,130 @@ paths: get: description: Gets the incomplete uploads that the user is the owner of operationId: listIncompleteUploads - requestBody: - description: A request describing the incomplete uploads to return - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ListIncompleteUploadsReq' responses: '200': content: application/json: schema: $ref: '#/components/schemas/ListIncompleteUploadsResp' - /portfolios: + /incomplete-upload/{id}: get: - description: Gets the list of portfolios that the user is the owner of - operationId: listPortfolios + summary: Returns an incomplete upload by ID + description: Returns an incomplete upload based on a single ID + operationId: findIncompleteUploadById + parameters: + - name: id + in: path + description: ID of incomplete upload to fetch + required: true + schema: + type: string + responses: + '200': + description: incomplete upload response + content: + application/json: + schema: + $ref: '#/components/schemas/IncompleteUpload' + patch: + summary: Updates incomplete upload properties + description: Updates a incomplete upload's settable properties + operationId: updateIncompleteUpload + parameters: + - name: id + in: path + description: ID of incomplete upload to update + required: true + schema: + type: string requestBody: - description: A request describing the portfolios to return + description: Incomplete Upload object properties to update required: true content: application/json: schema: - $ref: '#/components/schemas/ListPortfoliosReq' + $ref: '#/components/schemas/IncompleteUploadChanges' + responses: + '204': + description: the user changes were applied successfully + delete: + summary: Deletes an incomplete upload by ID + description: deletes an incomplete upload based on the ID supplied + operationId: deleteIncompleteUpload + parameters: + - name: id + in: path + description: ID of incomplete upload to delete + required: true + schema: + type: string + responses: + '204': + description: incomplete upload deleted + /portfolios: + get: + description: Gets the list of portfolios that the user is the owner of + operationId: listPortfolios responses: '200': content: application/json: schema: $ref: '#/components/schemas/ListPortfoliosResp' + /portfolio/{id}: + get: + summary: Returns an portfolio by ID + description: Returns an portfolio based on a single ID + operationId: findPortfolioById + parameters: + - name: id + in: path + description: ID of portfolio to fetch + required: true + schema: + type: string + responses: + '200': + description: portfolio response + content: + application/json: + schema: + $ref: '#/components/schemas/Portfolio' + patch: + summary: Updates portfolio properties + description: Updates a portfolio's settable properties + operationId: updatePortfolio + parameters: + - name: id + in: path + description: ID of portfolio to update + required: true + schema: + type: string + requestBody: + description: portfolio object properties to update + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PortfolioChanges' + responses: + '204': + description: the user changes were applied successfully + delete: + summary: Deletes an portfolio by ID + description: deletes an portfolio based on the ID supplied + operationId: deletePortfolio + parameters: + - name: id + in: path + description: ID of portfolio to delete + required: true + schema: + type: string + responses: + '204': + description: portfolio deleted /user/me: get: description: Returns the logged in user, if the user is logged in, otherwise returns empty @@ -483,21 +577,6 @@ paths: responses: '204': description: user deleted - /test:createPortfolioAsset: - post: - summary: Test endpoint, creates a new portfolio asset - description: | - Creates a new asset for a portfolio - - Returns a signed URL where the portfolio can be uploaded to. - operationId: createPortfolioAsset - responses: - '200': - description: The asset can now be uploaded via the given signed URL. - content: - application/json: - schema: - $ref: '#/components/schemas/NewPortfolioAsset' /portfolio-upload: post: summary: Starts the process of uploading one or more portfolio files @@ -536,26 +615,6 @@ paths: application/json: schema: $ref: '#/components/schemas/CompletePortfolioUploadResp' - /test:parsePortfolio: - post: - summary: Test endpoint, triggers a task to parse the portfolio - description: | - Starts processing raw uploaded files - operationId: parsePortfolio - requestBody: - description: The raw portfolio files to process - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ParsePortfolioReq' - responses: - '200': - description: The task has been started successfully - content: - application/json: - schema: - $ref: '#/components/schemas/ParsePortfolioResp' components: responses: Unauthorized: @@ -914,11 +973,14 @@ components: type: object required: - items + - holdings_date properties: items: type: array items: $ref: '#/components/schemas/StartPortfolioUploadReqItem' + holdings_date: + $ref: '#/components/schemas/HoldingsDate' StartPortfolioUploadReqItem: type: object required: @@ -972,12 +1034,6 @@ components: description: The unique identifier for the uploaded asset CompletePortfolioUploadResp: type: object - required: - - analysis_id - properties: - analysis_id: - type: string - description: The analysis id to track for the parsing task. HoldingsDate: type: object required: @@ -1028,6 +1084,19 @@ components: adminDebugEnabled: type: boolean description: Flag to indicate whether admin debug mode is enabled + IncompleteUploadChanges: + type: object + required: + properties: + name: + type: string + description: Name of the upload + description: + type: string + description: Description of the upload + adminDebugEnabled: + type: boolean + description: Flag to indicate whether admin debug mode is enabled Portfolio: type: object required: @@ -1059,6 +1128,18 @@ components: numberOfRows: type: integer description: The number of rows in the portfolio + PortfolioChanges: + type: object + properties: + name: + type: string + description: the human meaningful name of the portfolio + description: + type: string + description: Additional information about the portfolio + adminDebugEnabled: + type: boolean + description: Whether the admin debug mode is enabled for this portfolio ListIncompleteUploadsReq: type: object ListIncompleteUploadsResp: diff --git a/pacta/pacta.go b/pacta/pacta.go index 8984de0..02e9409 100644 --- a/pacta/pacta.go +++ b/pacta/pacta.go @@ -212,6 +212,7 @@ var FileTypeValues = []FileType{ FileType_CSV, FileType_YAML, FileType_ZIP, + FileType_JSON, FileType_HTML, FileType_JSON, } diff --git a/secrets/local.enc.json b/secrets/local.enc.json index 8ff837b..c9ac8e5 100644 --- a/secrets/local.enc.json +++ b/secrets/local.enc.json @@ -10,7 +10,7 @@ "token": "ENC[AES256_GCM,data:qe9Hy0tKKrQ7rFzOQuTlDKGDqA2489hvEbjbtYFMuJ3wd5gy,iv:gV+OasDEHtI28TVBFmyxrPjMqEv5J1jpWMAzR1N70MQ=,tag:543oFV21kJj9qBgkyewdIg==,type:str]" }, "webhook": { - "topic_id": "ENC[AES256_GCM,data:7G0ePwXfG/ePhZu20SI43UOMOTrJ7uS63hAT7vq7yN/ef8WPemEOaQG2/Zs9/4FE01p2eaCntaowE1Sb7FxYnL3SCYN6LyL27wKAESNVW/4uxBqMlhFiNkB/DucahyrA8ko3MIpsT3wqnkMPJScaqhtyBiyv9pLmGrulyaj2DPLyVvnZQxb4D57jVWrRnGZNMik=,iv:Jdnb/9B4MugRcTKpR8oJsb+UksU1FOQnV5Oikut73NM=,tag:ZzJQ9qBWWVDSSzdWQq8LQw==,type:str]", + "topic_id": "ENC[AES256_GCM,data:EvesYWnD9kB8/6YetDZeB5Z637xrbrfKCO6G4Cryv9s1xrD2IZNAtogV8FD5cUjk+IVsv3JWUZMu90v+xeWNoBAZMGrnGrbZniF0KKBIdJIk/jRzk9yRa5zZrLvvhF74OUcPTvoZg/iAzQ9RmT3U2Ibut9MB8qEaKib79JtOrQ6ihatkKCgdEytWH2gq0qw=,iv:DrVqTRsEsx/t27xjcrZYaY2YAclx2wPTsT1rMgs/uv8=,tag:oqsrfZxGFJKc2rWjv6zKIw==,type:str]", "shared_secret": "ENC[AES256_GCM,data:qzSiBGwCIX4pC8jGB2FKo1bu2EQYXj/T,iv:s94r3EzfnDWvWDXtvpktAnA9Htfd1vcGSH7iF/z6Mxs=,tag:RmhDMwasvOZvccX+QGXBhA==,type:str]" }, "sops": { @@ -27,8 +27,8 @@ ], "hc_vault": null, "age": null, - "lastmodified": "2023-11-01T22:30:13Z", - "mac": "ENC[AES256_GCM,data:rTQnmHo5AAfJLAEDcnkvP3WI8AvFuDXtajJkrVrsQjccs9gOYR5Zg5w9+Y4pQNuxcHxL/Q3AIGUr/SRMfAyJNsVHOQWdvgbxz0EsxVgc5993kl08uFddL4O4CrZEbfdr8LZEKKIL8fCa5HQHszA1rt2xS/zRJMj6d9+z3ed3O34=,iv:RmJj2I9ki/MxtUN3mYpnOAorSLG0M59gYhJ9Er1szxI=,tag:emPSYpnCz+oPVBznOXt6OA==,type:str]", + "lastmodified": "2023-11-19T18:37:45Z", + "mac": "ENC[AES256_GCM,data:2Fm9DBQl058NR1B6dagVYE0P2Z25IaYpUvTPV94ZUFE9pXhneoAFd4pLBWjb8ciMUBX2IQ3npLn5O+Spve1UGgB6YpLbGTpxqgjOITSvcyiYqQMTAUsYIhJYoZ9NgqjkqzLExcdAAQroY83voxiKa4ycy+5WSNicZxveMhgwP80=,iv:xl11VTaad00kmktmvyQDQWuTPqxuyK/k/fRkdA/g18Y=,tag:OX+/vXbChnx6xGm66XMYwg==,type:str]", "pgp": null, "unencrypted_suffix": "_unencrypted", "version": "3.8.1"