From c1257debdafcb7b7c220a61c8daa0eb178abbe93 Mon Sep 17 00:00:00 2001 From: Grady Berry Ward Date: Tue, 2 Jan 2024 12:09:52 -0500 Subject: [PATCH] Backend Infra to Start Analyses (#92) --- cmd/server/pactasrv/BUILD.bazel | 1 + cmd/server/pactasrv/analysis.go | 343 +++++++++++++++++++++ cmd/server/pactasrv/conv/helpers.go | 8 + cmd/server/pactasrv/conv/oapi_to_pacta.go | 13 + cmd/server/pactasrv/conv/pacta_to_oapi.go | 145 ++++++++- cmd/server/pactasrv/incomplete_upload.go | 6 +- cmd/server/pactasrv/pactasrv.go | 16 + cmd/server/pactasrv/parallel.go | 13 +- cmd/server/pactasrv/populate.go | 46 +++ cmd/server/pactasrv/portfolio.go | 4 +- db/sqldb/analysis.go | 72 +++-- db/sqldb/snapshot.go | 12 +- openapi/pacta.yaml | 345 +++++++++++++++++++++- pacta/pacta.go | 20 +- 14 files changed, 987 insertions(+), 57 deletions(-) create mode 100644 cmd/server/pactasrv/analysis.go diff --git a/cmd/server/pactasrv/BUILD.bazel b/cmd/server/pactasrv/BUILD.bazel index 283e028..e287aa1 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 = [ + "analysis.go", "audit_logs.go", "blobs.go", "incomplete_upload.go", diff --git a/cmd/server/pactasrv/analysis.go b/cmd/server/pactasrv/analysis.go new file mode 100644 index 0000000..818da77 --- /dev/null +++ b/cmd/server/pactasrv/analysis.go @@ -0,0 +1,343 @@ +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" + "go.uber.org/zap/zapcore" +) + +// (GET /analyses) +func (s *Server) ListAnalyses(ctx context.Context, request api.ListAnalysesRequestObject) (api.ListAnalysesResponseObject, error) { + ownerID, err := s.getUserOwnerID(ctx) + if err != nil { + return nil, err + } + as, err := s.DB.AnalysesByOwner(s.DB.NoTxn(ctx), ownerID) + if err != nil { + return nil, oapierr.Internal("failed to query analyses", zap.Error(err)) + } + items, err := dereference(conv.AnalysesToOAPI(as)) + if err != nil { + return nil, err + } + return api.ListAnalyses200JSONResponse{Items: items}, nil +} + +// Deletes an analysis (and its artifacts) by ID +// (DELETE /analysis/{id}) +func (s *Server) DeleteAnalysis(ctx context.Context, request api.DeleteAnalysisRequestObject) (api.DeleteAnalysisResponseObject, error) { + id := pacta.AnalysisID(request.Id) + _, err := s.checkAnalysisAuthorization(ctx, id) + if err != nil { + return nil, err + } + blobURIs, err := s.DB.DeleteAnalysis(s.DB.NoTxn(ctx), id) + if err != nil { + return nil, oapierr.Internal("failed to delete analysis", zap.Error(err)) + } + if err := s.deleteBlobs(ctx, blobURIs...); err != nil { + return nil, err + } + return api.DeleteAnalysis204Response{}, nil +} + +// Returns an analysis by ID +// (GET /analysis/{id}) +func (s *Server) FindAnalysisById(ctx context.Context, request api.FindAnalysisByIdRequestObject) (api.FindAnalysisByIdResponseObject, error) { + a, err := s.checkAnalysisAuthorization(ctx, pacta.AnalysisID(request.Id)) + if err != nil { + return nil, err + } + if err := s.populateArtifactsInAnalyses(ctx, a); err != nil { + return nil, err + } + if err := s.populateBlobsInAnalysisArtifacts(ctx, a.Artifacts...); err != nil { + return nil, err + } + converted, err := conv.AnalysisToOAPI(a) + if err != nil { + return nil, err + } + return api.FindAnalysisById200JSONResponse(*converted), nil +} + +// Updates writable analysis properties +// (PATCH /analysis/{id}) +func (s *Server) UpdateAnalysis(ctx context.Context, request api.UpdateAnalysisRequestObject) (api.UpdateAnalysisResponseObject, error) { + id := pacta.AnalysisID(request.Id) + _, err := s.checkAnalysisAuthorization(ctx, id) + if err != nil { + return nil, err + } + mutations := []db.UpdateAnalysisFn{} + if request.Body.Name != nil { + mutations = append(mutations, db.SetAnalysisName(*request.Body.Name)) + } + if request.Body.Description != nil { + mutations = append(mutations, db.SetAnalysisDescription(*request.Body.Description)) + } + err = s.DB.UpdateAnalysis(s.DB.NoTxn(ctx), id, mutations...) + if err != nil { + return nil, oapierr.Internal("failed to update analysis", zap.Error(err)) + } + return api.UpdateAnalysis204Response{}, nil +} + +func (s *Server) checkAnalysisAuthorization(ctx context.Context, id pacta.AnalysisID) (*pacta.Analysis, error) { + 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(fields ...zapcore.Field) error { + fs := append(fields, zap.String("analysis_id", string(id))) + return oapierr.NotFound("analysis not found", fs...) + } + a, err := s.DB.Analysis(s.DB.NoTxn(ctx), id) + if err != nil { + if db.IsNotFound(err) { + return nil, notFoundErr(zap.Error(err)) + } + return nil, oapierr.Internal( + "failed to look up analysis", + zap.Error(err)) + } + if a.Owner.ID != actorOwnerID { + return nil, notFoundErr( + zap.Error(fmt.Errorf("analysis does not belong to user")), + zap.String("owner_id", string(a.Owner.ID)), + zap.String("actor_id", string(actorOwnerID))) + } + return a, nil +} + +// Deletes an analysis artifact by ID +// (DELETE /analysis-artifact/{id}) +func (s *Server) DeleteAnalysisArtifact(ctx context.Context, request api.DeleteAnalysisArtifactRequestObject) (api.DeleteAnalysisArtifactResponseObject, error) { + id := pacta.AnalysisArtifactID(request.Id) + err := s.checkAnalysisArtifactAuthorization(ctx, id) + if err != nil { + return nil, err + } + blobURI, err := s.DB.DeleteAnalysisArtifact(s.DB.NoTxn(ctx), id) + if err != nil { + return nil, oapierr.Internal("failed to delete analysis artifact", zap.Error(err)) + } + if err := s.deleteBlobs(ctx, blobURI); err != nil { + return nil, err + } + return api.DeleteAnalysisArtifact204Response{}, nil +} + +// Updates writable analysis artifact properties +// (PATCH /analysis-artifact/{id}) +func (s *Server) UpdateAnalysisArtifact(ctx context.Context, request api.UpdateAnalysisArtifactRequestObject) (api.UpdateAnalysisArtifactResponseObject, error) { + id := pacta.AnalysisArtifactID(request.Id) + err := s.checkAnalysisArtifactAuthorization(ctx, id) + if err != nil { + return nil, err + } + mutations := []db.UpdateAnalysisArtifactFn{} + if request.Body.AdminDebugEnabled != nil { + mutations = append(mutations, db.SetAnalysisArtifactAdminDebugEnabled(*request.Body.AdminDebugEnabled)) + } + if request.Body.SharedToPublic != nil { + mutations = append(mutations, db.SetAnalysisArtifactSharedToPublic(*request.Body.SharedToPublic)) + } + err = s.DB.UpdateAnalysisArtifact(s.DB.NoTxn(ctx), id, mutations...) + if err != nil { + return nil, oapierr.Internal("failed to update analysis artifact", zap.Error(err)) + } + return api.UpdateAnalysisArtifact204Response{}, nil +} + +func (s *Server) checkAnalysisArtifactAuthorization(ctx context.Context, id pacta.AnalysisArtifactID) error { + actorOwnerID, err := s.getUserOwnerID(ctx) + if err != nil { + return err + } + // Extracted to a common variable so that we return the same response for not found and unauthorized. + notFoundErr := func(fields ...zapcore.Field) error { + fs := append(fields, zap.String("analysis_artifact_id", string(id))) + return oapierr.NotFound("analysis artifact not found", fs...) + } + aa, err := s.DB.AnalysisArtifact(s.DB.NoTxn(ctx), id) + if err != nil { + if db.IsNotFound(err) { + return notFoundErr(zap.Error(err)) + } + return oapierr.Internal("failed to look up analysis artifact", zap.String("analysis_artifact_id", string(id)), zap.Error(err)) + } + a, err := s.DB.Analysis(s.DB.NoTxn(ctx), aa.AnalysisID) + if err != nil { + if db.IsNotFound(err) { + return notFoundErr(zap.Error(err)) + } + return oapierr.Internal("failed to look up analysis for analysis artifact", + zap.String("analysis_id", string(aa.AnalysisID)), + zap.Error(err)) + } + if a.Owner.ID != actorOwnerID { + return notFoundErr( + zap.Error(fmt.Errorf("analysis artifact does not belong to user")), + zap.String("owner_id", string(a.Owner.ID)), + zap.String("actor_id", string(actorOwnerID)), + zap.String("analysis_id", string(aa.AnalysisID))) + } + return nil +} + +// Requests an anslysis be run +// (POST /run-analysis) +func (s *Server) RunAnalysis(ctx context.Context, request api.RunAnalysisRequestObject) (api.RunAnalysisResponseObject, error) { + ownerID, err := s.getUserOwnerID(ctx) + if err != nil { + return nil, err + } + analysisType, err := conv.AnalysisTypeFromOAPI(&request.Body.AnalysisType) + if err != nil { + return nil, err + } + + found := 0 + var iID pacta.InitiativeID + var pgID pacta.PortfolioGroupID + var pID pacta.PortfolioID + if request.Body.InitiativeId != nil { + iID = pacta.InitiativeID(*request.Body.InitiativeId) + found++ + } + if request.Body.PortfolioGroupId != nil { + pgID = pacta.PortfolioGroupID(*request.Body.PortfolioGroupId) + found++ + } + if request.Body.PortfolioId != nil { + pID = pacta.PortfolioID(*request.Body.PortfolioId) + found++ + } + if found == 0 { + return nil, oapierr.BadRequest("one of initiative_id, portfolio_group_id, or portfolio_id is required") + } + if found > 1 { + return nil, oapierr.BadRequest("only one of initiative_id, portfolio_group_id, or portfolio_id may be set") + } + + // Allows consistent handling between NOT FOUND and UNAUTHORIZED. + notFoundErr := func(typeName string, id string, fields ...zapcore.Field) error { + fs := append(fields, zap.String(fmt.Sprintf("%s_id", typeName), string(id))) + return oapierr.NotFound(fmt.Sprintf("%s not found", typeName), fs...) + } + var result pacta.AnalysisID + var endUserErr error + err = s.DB.Transactional(ctx, func(tx db.Tx) error { + var pvID pacta.PACTAVersionID + if request.Body.PactaVersionId == nil { + pv, err := s.DB.DefaultPACTAVersion(tx) + if err != nil { + return fmt.Errorf("looking up default pacta version: %w", err) + } + pvID = pv.ID + } else { + pvID = pacta.PACTAVersionID(*request.Body.PactaVersionId) + _, err := s.DB.PACTAVersion(tx, pvID) + if err != nil { + endUserErr = oapierr.BadRequest("pacta_version_id is invalid", zap.Error(err), zap.String("pacta_version_id", string(pvID))) + return nil + } + } + + var snapshotID pacta.PortfolioSnapshotID + if pID != "" { + p, err := s.DB.Portfolio(tx, pID) + if err != nil { + if db.IsNotFound(err) { + endUserErr = notFoundErr("portfolio", string(pID), zap.Error(err)) + return nil + } + return fmt.Errorf("looking up portfolio: %w", err) + } + if p.Owner.ID != ownerID { + endUserErr = notFoundErr("portfolio", string(pID), + zap.Error(fmt.Errorf("portfolio does not belong to user")), + zap.String("portfolio_owner_id", string(p.Owner.ID)), + zap.String("actor_owner_id", string(ownerID))) + return nil + } + sID, err := s.DB.CreateSnapshotOfPortfolio(tx, pID) + if err != nil { + return fmt.Errorf("creating snapshot of portfolio: %w", err) + } + snapshotID = sID + } else if pgID != "" { + pg, err := s.DB.PortfolioGroup(tx, pgID) + if err != nil { + if db.IsNotFound(err) { + endUserErr = notFoundErr("portfolio_group", string(pgID), zap.Error(err)) + return nil + } + return fmt.Errorf("looking up portfolio_group: %w", err) + } + if pg.Owner.ID != ownerID { + endUserErr = notFoundErr("portfolio_group", string(pgID), + zap.Error(fmt.Errorf("portfolio group does not belong to user")), + zap.String("pg_owner_id", string(pg.Owner.ID)), + zap.String("actor_owner_id", string(ownerID))) + return nil + } + sID, err := s.DB.CreateSnapshotOfPortfolioGroup(tx, pgID) + if err != nil { + return fmt.Errorf("creating snapshot of portfolio group: %w", err) + } + snapshotID = sID + } else if iID != "" { + _, err := s.DB.Initiative(tx, iID) + if err != nil { + if db.IsNotFound(err) { + endUserErr = notFoundErr("initiative", string(iID), zap.Error(err)) + return nil + } + return fmt.Errorf("looking up initiative: %w", err) + } + // TODO(#12) Implement Authorization Here + sID, err := s.DB.CreateSnapshotOfInitiative(tx, iID) + if err != nil { + return fmt.Errorf("creating snapshot of initiative: %w", err) + } + snapshotID = sID + } + if snapshotID == "" { + return fmt.Errorf("snapshot id is empty, something is wrong in the bizlogic") + } + + analysisID, err := s.DB.CreateAnalysis(tx, &pacta.Analysis{ + AnalysisType: *analysisType, + PortfolioSnapshot: &pacta.PortfolioSnapshot{ID: snapshotID}, + PACTAVersion: &pacta.PACTAVersion{ID: pvID}, + Owner: &pacta.Owner{ID: ownerID}, + Name: request.Body.Name, + Description: request.Body.Description, + }) + if err != nil { + return fmt.Errorf("creating analysis: %w", err) + } + result = analysisID + return nil + }) + if endUserErr != nil { + return nil, endUserErr + } + if err != nil { + return nil, oapierr.Internal("failed to create analysis", zap.Error(err)) + } + + // TODO - here this is where we'd kick off the analysis run. + + return api.RunAnalysis200JSONResponse{AnalysisId: string(result)}, nil +} diff --git a/cmd/server/pactasrv/conv/helpers.go b/cmd/server/pactasrv/conv/helpers.go index 4be8a26..010350f 100644 --- a/cmd/server/pactasrv/conv/helpers.go +++ b/cmd/server/pactasrv/conv/helpers.go @@ -51,3 +51,11 @@ func convAll[I any, O any](is []I, f func(I) (O, error)) ([]O, error) { } return os, nil } + +func dereferenceAll[T any](ts []*T) []T { + result := make([]T, len(ts)) + for i, t := range ts { + result[i] = *t + } + return result +} diff --git a/cmd/server/pactasrv/conv/oapi_to_pacta.go b/cmd/server/pactasrv/conv/oapi_to_pacta.go index ae605e8..25c749f 100644 --- a/cmd/server/pactasrv/conv/oapi_to_pacta.go +++ b/cmd/server/pactasrv/conv/oapi_to_pacta.go @@ -69,6 +69,19 @@ func InitiativeInvitationFromOAPI(i *api.InitiativeInvitationCreate) (*pacta.Ini }, nil } +func AnalysisTypeFromOAPI(at *api.AnalysisType) (*pacta.AnalysisType, error) { + if at == nil { + return nil, oapierr.BadRequest("analysisTypeFromOAPI: can't convert nil pointer") + } + switch string(*at) { + case "audit": + return ptr(pacta.AnalysisType_Audit), nil + case "report": + return ptr(pacta.AnalysisType_Report), nil + } + return nil, oapierr.BadRequest("analysisTypeFromOAPI: unknown analysis type", zap.String("analysis_type", string(*at))) +} + func HoldingsDateFromOAPI(hd *api.HoldingsDate) (*pacta.HoldingsDate, error) { if hd == nil { return nil, nil diff --git a/cmd/server/pactasrv/conv/pacta_to_oapi.go b/cmd/server/pactasrv/conv/pacta_to_oapi.go index 36725a8..4210961 100644 --- a/cmd/server/pactasrv/conv/pacta_to_oapi.go +++ b/cmd/server/pactasrv/conv/pacta_to_oapi.go @@ -9,6 +9,21 @@ import ( "go.uber.org/zap" ) +func LanguageToOAPI(l pacta.Language) (api.Language, error) { + switch l { + case pacta.Language_DE: + return api.De, nil + case pacta.Language_ES: + return api.Es, nil + case pacta.Language_EN: + return api.En, nil + case pacta.Language_FR: + return api.Fr, nil + default: + return "", fmt.Errorf("unknown language: %q", l) + } +} + func InitiativeToOAPI(i *pacta.Initiative) (*api.Initiative, error) { if i == nil { return nil, oapierr.Internal("initiativeToOAPI: can't convert nil pointer") @@ -17,6 +32,10 @@ func InitiativeToOAPI(i *pacta.Initiative) (*api.Initiative, error) { if err != nil { return nil, oapierr.Internal("initiativeToOAPI: portfolioInitiativeMembershipToOAPIInitiative failed", zap.Error(err)) } + lang, err := LanguageToOAPI(i.Language) + if err != nil { + return nil, oapierr.Internal("initiativeToOAPI: languageToOAPI failed", zap.Error(err)) + } return &api.Initiative{ Affiliation: i.Affiliation, CreatedAt: i.CreatedAt, @@ -24,7 +43,7 @@ func InitiativeToOAPI(i *pacta.Initiative) (*api.Initiative, error) { InternalDescription: i.InternalDescription, IsAcceptingNewMembers: i.IsAcceptingNewMembers, IsAcceptingNewPortfolios: i.IsAcceptingNewPortfolios, - Language: api.InitiativeLanguage(i.Language), + Language: lang, Name: i.Name, PactaVersion: strPtr(i.PACTAVersion.ID), PublicDescription: i.PublicDescription, @@ -71,13 +90,17 @@ func UserToOAPI(user *pacta.User) (*api.User, error) { if user == nil { return nil, oapierr.Internal("userToOAPI: can't convert nil pointer") } + lang, err := LanguageToOAPI(user.PreferredLanguage) + if err != nil { + return nil, oapierr.Internal("userToOAPI: languageToOAPI failed", zap.Error(err)) + } return &api.User{ Admin: user.Admin, CanonicalEmail: &user.CanonicalEmail, EnteredEmail: user.EnteredEmail, Id: string(user.ID), Name: user.Name, - PreferredLanguage: api.UserPreferredLanguage(user.PreferredLanguage), + PreferredLanguage: lang, SuperAdmin: user.SuperAdmin, }, nil } @@ -149,6 +172,13 @@ func IncompleteUploadsToOAPI(ius []*pacta.IncompleteUpload) ([]*api.IncompleteUp return convAll(ius, IncompleteUploadToOAPI) } +func FailureCodeToOAPI(f pacta.FailureCode) (*api.FailureCode, error) { + if f == "" { + return nil, nil + } + return ptr(api.FailureCode(f)), nil +} + func IncompleteUploadToOAPI(iu *pacta.IncompleteUpload) (*api.IncompleteUpload, error) { if iu == nil { return nil, oapierr.Internal("incompleteUploadToOAPI: can't convert nil pointer") @@ -157,6 +187,10 @@ func IncompleteUploadToOAPI(iu *pacta.IncompleteUpload) (*api.IncompleteUpload, if err != nil { return nil, oapierr.Internal("incompleteUploadToOAPI: holdingsDateToOAPI failed", zap.Error(err)) } + fc, err := FailureCodeToOAPI(iu.FailureCode) + if err != nil { + return nil, oapierr.Internal("incompleteUploadToOAPI: failureCodeToOAPI failed", zap.Error(err)) + } return &api.IncompleteUpload{ Id: string(iu.ID), Name: iu.Name, @@ -165,7 +199,7 @@ func IncompleteUploadToOAPI(iu *pacta.IncompleteUpload) (*api.IncompleteUpload, CreatedAt: iu.CreatedAt, RanAt: timeToNilable(iu.RanAt), CompletedAt: timeToNilable(iu.CompletedAt), - FailureCode: stringToNilable(iu.FailureCode), + FailureCode: fc, FailureMessage: stringToNilable(iu.FailureMessage), AdminDebugEnabled: iu.AdminDebugEnabled, }, nil @@ -239,6 +273,111 @@ func PortfolioGroupsToOAPI(pgs []*pacta.PortfolioGroup) ([]*api.PortfolioGroup, return convAll(pgs, PortfolioGroupToOAPI) } +func BlobToOAPI(b *pacta.Blob) (*api.Blob, error) { + if b == nil { + return nil, oapierr.Internal("blobToOAPI: can't convert nil pointer") + } + return &api.Blob{ + Id: string(b.ID), + FileName: b.FileName, + FileType: api.FileType(b.FileType), + CreatedAt: b.CreatedAt, + }, nil +} + +func PortfolioSnapshotToOAPI(ps *pacta.PortfolioSnapshot) (*api.PortfolioSnapshot, error) { + if ps == nil { + return nil, oapierr.Internal("portfolioSnapshotToOAPI: can't convert nil pointer") + } + portfolioIds := make([]string, len(ps.PortfolioIDs)) + for i, pid := range ps.PortfolioIDs { + portfolioIds[i] = string(pid) + } + result := &api.PortfolioSnapshot{ + Id: string(ps.ID), + PortfolioIds: portfolioIds, + } + if ps.Portfolio != nil { + portfolio, err := PortfolioToOAPI(ps.Portfolio) + if err != nil { + return nil, oapierr.Internal("portfolioSnapshotToOAPI: portfolioToOAPI failed", zap.Error(err)) + } + result.Portfolio = portfolio + } + if ps.PortfolioGroup != nil { + portfolioGroup, err := PortfolioGroupToOAPI(ps.PortfolioGroup) + if err != nil { + return nil, oapierr.Internal("portfolioSnapshotToOAPI: portfolioGroupToOAPI failed", zap.Error(err)) + } + result.PortfolioGroup = portfolioGroup + } + if ps.Initiatiative != nil { + initiative, err := InitiativeToOAPI(ps.Initiatiative) + if err != nil { + return nil, oapierr.Internal("portfolioSnapshotToOAPI: initiativeToOAPI failed", zap.Error(err)) + } + result.Initiative = initiative + } + return result, nil +} + +func AnalysisArtifactToOAPI(aa *pacta.AnalysisArtifact) (*api.AnalysisArtifact, error) { + if aa == nil { + return nil, oapierr.Internal("analysisArtifactToOAPI: can't convert nil pointer") + } + blob, err := BlobToOAPI(aa.Blob) + if err != nil { + return nil, oapierr.Internal("analysisArtifactToOAPI: blobToOAPI failed", zap.Error(err)) + } + return &api.AnalysisArtifact{ + Id: string(aa.ID), + AdminDebugEnabled: aa.AdminDebugEnabled, + SharedToPublic: aa.AdminDebugEnabled, + Blob: *blob, + }, nil +} + +func AnalysisToOAPI(a *pacta.Analysis) (*api.Analysis, error) { + if a == nil { + return nil, oapierr.Internal("analysisToOAPI: can't convert nil pointer") + } + aas, err := convAll(a.Artifacts, AnalysisArtifactToOAPI) + if err != nil { + return nil, oapierr.Internal("analysisToOAPI: analysisArtifactsToOAPI failed", zap.Error(err)) + } + snapshot, err := PortfolioSnapshotToOAPI(a.PortfolioSnapshot) + if err != nil { + return nil, oapierr.Internal("analysisToOAPI: portfolioSnapshotToOAPI failed", zap.Error(err)) + } + var fc *api.FailureCode + if a.FailureCode != "" { + fc = ptr(api.FailureCode(a.FailureCode)) + } + var fm *string + if a.FailureMessage != "" { + fm = ptr(a.FailureMessage) + } + + return &api.Analysis{ + Id: string(a.ID), + AnalysisType: api.AnalysisType(a.AnalysisType), + PactaVersion: string(a.PACTAVersion.ID), + PortfolioSnapshot: *snapshot, + Name: a.Name, + Description: a.Description, + CreatedAt: a.CreatedAt, + RanAt: timeToNilable(a.RanAt), + CompletedAt: timeToNilable(a.CompletedAt), + FailureCode: fc, + FailureMessage: fm, + Artifacts: dereferenceAll(aas), + }, nil +} + +func AnalysesToOAPI(as []*pacta.Analysis) ([]*api.Analysis, error) { + return convAll(as, AnalysisToOAPI) +} + func auditLogActorTypeToOAPI(i pacta.AuditLogActorType) (api.AuditLogActorType, error) { switch i { case pacta.AuditLogActorType_Public: diff --git a/cmd/server/pactasrv/incomplete_upload.go b/cmd/server/pactasrv/incomplete_upload.go index 3d3a1f9..bf40eb6 100644 --- a/cmd/server/pactasrv/incomplete_upload.go +++ b/cmd/server/pactasrv/incomplete_upload.go @@ -23,7 +23,6 @@ func (s *Server) ListIncompleteUploads(ctx context.Context, request api.ListInco 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 @@ -43,8 +42,8 @@ func (s *Server) DeleteIncompleteUpload(ctx context.Context, request api.DeleteI 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)) + if err := s.deleteBlobs(ctx, blobURI); err != nil { + return nil, err } return api.DeleteIncompleteUpload204Response{}, nil } @@ -71,7 +70,6 @@ func (s *Server) UpdateIncompleteUpload(ctx context.Context, request api.UpdateI 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)) diff --git a/cmd/server/pactasrv/pactasrv.go b/cmd/server/pactasrv/pactasrv.go index 8f8aae5..ad150eb 100644 --- a/cmd/server/pactasrv/pactasrv.go +++ b/cmd/server/pactasrv/pactasrv.go @@ -83,6 +83,22 @@ type DB interface { UpdateIncompleteUpload(tx db.Tx, id pacta.IncompleteUploadID, mutations ...db.UpdateIncompleteUploadFn) error DeleteIncompleteUpload(tx db.Tx, id pacta.IncompleteUploadID) (pacta.BlobURI, error) + CreateAnalysis(tx db.Tx, a *pacta.Analysis) (pacta.AnalysisID, error) + UpdateAnalysis(tx db.Tx, id pacta.AnalysisID, mutations ...db.UpdateAnalysisFn) error + DeleteAnalysis(tx db.Tx, id pacta.AnalysisID) ([]pacta.BlobURI, error) + Analysis(tx db.Tx, id pacta.AnalysisID) (*pacta.Analysis, error) + Analyses(tx db.Tx, ids []pacta.AnalysisID) (map[pacta.AnalysisID]*pacta.Analysis, error) + AnalysesByOwner(tx db.Tx, ownerID pacta.OwnerID) ([]*pacta.Analysis, error) + + AnalysisArtifacts(tx db.Tx, ids []pacta.AnalysisArtifactID) (map[pacta.AnalysisArtifactID]*pacta.AnalysisArtifact, error) + AnalysisArtifact(tx db.Tx, id pacta.AnalysisArtifactID) (*pacta.AnalysisArtifact, error) + UpdateAnalysisArtifact(tx db.Tx, id pacta.AnalysisArtifactID, mutations ...db.UpdateAnalysisArtifactFn) error + DeleteAnalysisArtifact(tx db.Tx, id pacta.AnalysisArtifactID) (pacta.BlobURI, error) + + CreateSnapshotOfPortfolio(tx db.Tx, pID pacta.PortfolioID) (pacta.PortfolioSnapshotID, error) + CreateSnapshotOfPortfolioGroup(tx db.Tx, pgID pacta.PortfolioGroupID) (pacta.PortfolioSnapshotID, error) + CreateSnapshotOfInitiative(tx db.Tx, iID pacta.InitiativeID) (pacta.PortfolioSnapshotID, error) + GetOwnerForUser(tx db.Tx, uID pacta.UserID) (pacta.OwnerID, error) PortfolioGroup(tx db.Tx, id pacta.PortfolioGroupID) (*pacta.PortfolioGroup, error) diff --git a/cmd/server/pactasrv/parallel.go b/cmd/server/pactasrv/parallel.go index 16938f5..0e9552c 100644 --- a/cmd/server/pactasrv/parallel.go +++ b/cmd/server/pactasrv/parallel.go @@ -3,6 +3,10 @@ package pactasrv import ( "context" "fmt" + + "github.com/RMI/pacta/oapierr" + "github.com/RMI/pacta/pacta" + "go.uber.org/zap" ) type blobDeleter interface { @@ -13,12 +17,15 @@ func deleteBlobs(ctx context.Context, bd blobDeleter, uris []string) error { // Implement parallel delete if slow - not prematurely optimizing. for i, uri := range uris { if err := bd.DeleteBlob(ctx, uri); err != nil { - return fmt.Errorf("deleting blob %d/%d: %w", i, len(uris), err) + return oapierr.Internal("failed to delete blob", + zap.String("uri", uri), + zap.String("number_in_order", fmt.Sprintf("%d/%d", i+1, len(uris))), + zap.Error(err)) } } return nil } -func (s *Server) deleteBlobs(ctx context.Context, uris []string) error { - return deleteBlobs(ctx, s.Blob, uris) +func (s *Server) deleteBlobs(ctx context.Context, uris ...pacta.BlobURI) error { + return deleteBlobs(ctx, s.Blob, asStrs(uris)) } diff --git a/cmd/server/pactasrv/populate.go b/cmd/server/pactasrv/populate.go index dad79fa..f12efd7 100644 --- a/cmd/server/pactasrv/populate.go +++ b/cmd/server/pactasrv/populate.go @@ -78,6 +78,52 @@ func (s *Server) populatePortfolioGroupsInPortfolios( return nil } +func (s *Server) populateArtifactsInAnalyses( + ctx context.Context, + ts ...*pacta.Analysis, +) error { + getFn := func(a *pacta.Analysis) ([]*pacta.AnalysisArtifact, error) { + result := []*pacta.AnalysisArtifact{} + for _, aa := range a.Artifacts { + result = append(result, aa) + } + return result, nil + } + lookupFn := func(ids []pacta.AnalysisArtifactID) (map[pacta.AnalysisArtifactID]*pacta.AnalysisArtifact, error) { + return s.DB.AnalysisArtifacts(s.DB.NoTxn(ctx), ids) + } + getIDFn := func(a *pacta.AnalysisArtifact) pacta.AnalysisArtifactID { + return a.ID + } + if err := populateAll(ts, getFn, getIDFn, lookupFn); err != nil { + return oapierr.Internal("populating analysis artifacts in analysis failed", zap.Error(err)) + } + return nil +} + +func (s *Server) populateBlobsInAnalysisArtifacts( + ctx context.Context, + ts ...*pacta.AnalysisArtifact, +) error { + getFn := func(a *pacta.AnalysisArtifact) ([]*pacta.Blob, error) { + result := []*pacta.Blob{} + if a.Blob != nil { + result = append(result, a.Blob) + } + return result, nil + } + lookupFn := func(ids []pacta.BlobID) (map[pacta.BlobID]*pacta.Blob, error) { + return s.DB.Blobs(s.DB.NoTxn(ctx), ids) + } + getIDFn := func(a *pacta.Blob) pacta.BlobID { + return a.ID + } + if err := populateAll(ts, getFn, getIDFn, lookupFn); err != nil { + return oapierr.Internal("populating blobs in analysis artifacts failed", zap.Error(err)) + } + return nil +} + // This helper function populates the given targets in the given sources, // to allow for generic population of nested data structures. // sources = entities that you want to populate sub-entity references in. diff --git a/cmd/server/pactasrv/portfolio.go b/cmd/server/pactasrv/portfolio.go index 7cd06f9..2542f83 100644 --- a/cmd/server/pactasrv/portfolio.go +++ b/cmd/server/pactasrv/portfolio.go @@ -49,8 +49,8 @@ func (s *Server) DeletePortfolio(ctx context.Context, request api.DeletePortfoli 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)) + if err := s.deleteBlobs(ctx, blobURIs...); err != nil { + return nil, err } return api.DeletePortfolio204Response{}, nil } diff --git a/db/sqldb/analysis.go b/db/sqldb/analysis.go index e544c07..55dd411 100644 --- a/db/sqldb/analysis.go +++ b/db/sqldb/analysis.go @@ -9,25 +9,41 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -const analyseSelectColumns = ` - analysis.id, - analysis.analysis_type, - analysis.owner_id, - analysis.pacta_version_id, - analysis.portfolio_snapshot_id, - analysis.name, - analysis.description, - analysis.created_at, - analysis.ran_at, - analysis.completed_at, - analysis.failure_code, - analysis.failure_message` +func analysisQuery(where string) string { + return fmt.Sprintf(` + WITH selected_analysis_ids AS ( + SELECT id FROM analysis %[1]s + ) + SELECT + analysis.id, + analysis.analysis_type, + analysis.owner_id, + analysis.pacta_version_id, + analysis.portfolio_snapshot_id, + analysis.name, + analysis.description, + analysis.created_at, + analysis.ran_at, + analysis.completed_at, + analysis.failure_code, + analysis.failure_message, + aas.analysis_artifact_ids + FROM + analysis + LEFT JOIN ( + SELECT + analysis_id, + ARRAY_AGG(analysis_artifact.id) AS analysis_artifact_ids + FROM analysis_artifact + WHERE analysis_id IN (SELECT id FROM selected_analysis_ids) + GROUP BY analysis_id + ) aas ON aas.analysis_id = analysis.id + %[1]s; +`, where) +} func (d *DB) Analysis(tx db.Tx, id pacta.AnalysisID) (*pacta.Analysis, error) { - rows, err := d.query(tx, ` - SELECT `+analyseSelectColumns+` - FROM analysis - WHERE id = $1;`, id) + rows, err := d.query(tx, analysisQuery(`WHERE analysis.id = $1`), id) if err != nil { return nil, fmt.Errorf("querying analysis: %w", err) } @@ -40,10 +56,7 @@ func (d *DB) Analysis(tx db.Tx, id pacta.AnalysisID) (*pacta.Analysis, error) { func (d *DB) Analyses(tx db.Tx, ids []pacta.AnalysisID) (map[pacta.AnalysisID]*pacta.Analysis, error) { ids = dedupeIDs(ids) - rows, err := d.query(tx, ` - SELECT `+analyseSelectColumns+` - FROM analysis - WHERE id IN `+createWhereInFmt(len(ids))+`;`, idsToInterface(ids)...) + rows, err := d.query(tx, analysisQuery(`WHERE id IN `+createWhereInFmt(len(ids))), idsToInterface(ids)...) if err != nil { return nil, fmt.Errorf("querying analyses: %w", err) } @@ -58,6 +71,18 @@ func (d *DB) Analyses(tx db.Tx, ids []pacta.AnalysisID) (map[pacta.AnalysisID]*p return result, nil } +func (d *DB) AnalysesByOwner(tx db.Tx, ownerID pacta.OwnerID) ([]*pacta.Analysis, error) { + rows, err := d.query(tx, analysisQuery(`WHERE analysis.owner_id = $1`), ownerID) + if err != nil { + return nil, fmt.Errorf("querying analyses: %w", err) + } + as, err := rowsToAnalyses(rows) + if err != nil { + return nil, fmt.Errorf("translating rows to analyses: %w", err) + } + return as, nil +} + func (d *DB) CreateAnalysis(tx db.Tx, a *pacta.Analysis) (pacta.AnalysisID, error) { if err := validateAnalysisForCreation(a); err != nil { return "", fmt.Errorf("validating analysis for creation: %w", err) @@ -149,6 +174,7 @@ func rowToAnalysis(row rowScanner) (*pacta.Analysis, error) { aType string failureCode, failureMessage pgtype.Text ranAt, completedAt pgtype.Timestamptz + artifactIDs []pacta.AnalysisArtifactID ) err := row.Scan( &a.ID, @@ -163,6 +189,7 @@ func rowToAnalysis(row rowScanner) (*pacta.Analysis, error) { &completedAt, &failureCode, &failureMessage, + &artifactIDs, ) if err != nil { return nil, fmt.Errorf("scanning into analysis: %w", err) @@ -186,6 +213,9 @@ func rowToAnalysis(row rowScanner) (*pacta.Analysis, error) { if failureMessage.Valid { a.FailureMessage = failureMessage.String } + for _, id := range artifactIDs { + a.Artifacts = append(a.Artifacts, &pacta.AnalysisArtifact{ID: id}) + } return a, nil } diff --git a/db/sqldb/snapshot.go b/db/sqldb/snapshot.go index 74b52fd..f129a34 100644 --- a/db/sqldb/snapshot.go +++ b/db/sqldb/snapshot.go @@ -107,13 +107,19 @@ func rowToPortfolioSnapshot(row rowScanner) (*pacta.PortfolioSnapshot, error) { PortfolioIDs: stringsToIDs[pacta.PortfolioID](portfolioIDs), } if pID.Valid { - ps.PortfolioID = pacta.PortfolioID(pID.String) + ps.Portfolio = &pacta.Portfolio{ + ID: pacta.PortfolioID(pID.String), + } } if pgID.Valid { - ps.PortfolioGroupID = pacta.PortfolioGroupID(pgID.String) + ps.PortfolioGroup = &pacta.PortfolioGroup{ + ID: pacta.PortfolioGroupID(pgID.String), + } } if iID.Valid { - ps.InitiatiativeID = pacta.InitiativeID(iID.String) + ps.Initiatiative = &pacta.Initiative{ + ID: pacta.InitiativeID(iID.String), + } } return ps, nil } diff --git a/openapi/pacta.yaml b/openapi/pacta.yaml index d81b4db..46cef99 100644 --- a/openapi/pacta.yaml +++ b/openapi/pacta.yaml @@ -27,6 +27,23 @@ definitions: - fr - es - de + FileType: + type: string + enum: &FILE_TYPES + - csv + - yaml + - zip + - json + - html + AnalysisType: + type: string + enum: &ANALYSIS_TYPES + - audit + - report + FailureCode: + type: string + enum: &FAILURE_CODES + - UNKNOWN basePath: /v1 paths: /access-blob-content: @@ -807,6 +824,125 @@ paths: application/json: schema: $ref: '#/components/schemas/CompletePortfolioUploadResp' + /analyses: + get: + description: Gets the analyses that the user is the owner of + operationId: listAnalyses + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ListAnalysesResp' + /analysis/{id}: + get: + summary: Returns an analysis by ID + description: Returns an analysis based on a single ID + operationId: findAnalysisById + parameters: + - name: id + in: path + description: ID of analysis to fetch + required: true + schema: + type: string + responses: + '200': + description: analysis response + content: + application/json: + schema: + $ref: '#/components/schemas/Analysis' + patch: + summary: Updates writable analysis properties + description: Updates an analysis' settable properties + operationId: updateAnalysis + parameters: + - name: id + in: path + description: ID of analysis to update + required: true + schema: + type: string + requestBody: + description: Analayis object properties to update + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AnalysisChanges' + responses: + '204': + description: the changes were applied successfully + delete: + summary: Deletes an analysis (and its artifacts) by ID + description: deletes an analysis based on the ID supplied + operationId: deleteAnalysis + parameters: + - name: id + in: path + description: ID of analysis to delete + required: true + schema: + type: string + responses: + '204': + description: analysis deleted + /analysis-artifact/{id}: + patch: + summary: Updates writable analysis artifact properties + description: Updates an analysis artifact's settable properties + operationId: updateAnalysisArtifact + parameters: + - name: id + in: path + description: ID of analysis artifact to update + required: true + schema: + type: string + requestBody: + description: Analysis artifact's object properties to update + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AnalysisArtifactChanges' + responses: + '204': + description: the changes were applied successfully + delete: + summary: Deletes an analysis artifact by ID + description: deletes an analysis artifact based on the ID supplied + operationId: deleteAnalysisArtifact + parameters: + - name: id + in: path + description: ID of analysis artifact to delete + required: true + schema: + type: string + responses: + '204': + description: analysis artifact deleted + /run-analysis: + post: + summary: Requests an anslysis be run + description: Creates a snapshot of the requested entity, and starts it running + operationId: runAnalysis + requestBody: + description: Properties of the analysis to run + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RunAnalysisReq' + responses: + '200': + description: information about the requested analysis + content: + application/json: + schema: + $ref: '#/components/schemas/RunAnalysisResp' components: responses: Unauthorized: @@ -855,6 +991,15 @@ components: Language: type: string enum: *LANGUAGES + FileType: + type: string + enum: *FILE_TYPES + AnalysisType: + type: string + enum: *ANALYSIS_TYPES + FailureCode: + type: string + enum: *FAILURE_CODES PactaVersionCreate: type: object required: @@ -1058,8 +1203,7 @@ components: description: If set, users that are members of this initiative can add portfolios to it. language: description: The language this initiative should be conducted in. - type: string - enum: *LANGUAGES + $ref: '#/components/schemas/Language' pactaVersion: type: string description: The id of the PACTA model that this initiative should use, if not specified, the default PACTA model will be used. @@ -1105,8 +1249,7 @@ components: description: If set, users that are members of this initiative can add portfolios to it. language: description: The language this initiative should be conducted in. - type: string - enum: *LANGUAGES + $ref: '#/components/schemas/Language' pactaVersion: type: string description: The pacta model that this initiative should use, if not specified, the default pacta model will be used. @@ -1145,8 +1288,7 @@ components: description: If set, users that are members of this initiative can add portfolios to it. language: description: The language this initiative should be conducted in. - type: string - enum: *LANGUAGES + $ref: '#/components/schemas/Language' pactaVersion: type: string description: The pacta model that this initiative should use, if not specified, the default pacta model will be used. @@ -1250,8 +1392,7 @@ components: description: Name of the user preferredLanguage: description: The user's preferred language, if present - type: string - enum: *LANGUAGES + $ref: '#/components/schemas/Language' UserChanges: type: object properties: @@ -1260,8 +1401,7 @@ components: description: The new name of the user preferredLanguage: description: The user's new preferred language - type: string - enum: *LANGUAGES + $ref: '#/components/schemas/Language' admin: type: boolean description: Whether the given user is an admin @@ -1431,8 +1571,8 @@ components: format: date-time description: The time when the upload was completed failureCode: - type: string description: Code describing the failure, if any + $ref: '#/components/schemas/FailureCode' failureMessage: type: string description: Message describing the failure, if any @@ -1505,6 +1645,142 @@ components: adminDebugEnabled: type: boolean description: Whether the admin debug mode is enabled for this portfolio + PortfolioSnapshot: + type: object + description: represents an immutable description of a collection of portfolios at a point in time, used to ensure reproducibility and change detection + required: + - id + - portfolioIds + properties: + id: + type: string + description: the system assigned unique identifier of the snapshot + portfolioIds: + type: array + items: + type: string + description: the full set of denormalized portfolios included in this analysis + initiative: + description: if populated, this snapshot represents a snapshot of this initiative + $ref: '#/components/schemas/Initiative' + portfolioGroup: + description: if populated, this snapshot represents a snapshot of this portfolio group + $ref: '#/components/schemas/PortfolioGroup' + portfolio: + description: if populated, this snapshot represents a snapshot of this solitary portfolio + $ref: '#/components/schemas/Portfolio' + Blob: + type: object + required: + - id + - fileType + - fileName + - createdAt + properties: + id: + type: string + description: the system assigned unique identifier of the blob + fileName: + type: string + description: the human meaningful name of the file + fileType: + description: the type (extension) of the file + $ref: '#/components/schemas/FileType' + createdAt: + type: string + format: date-time + description: The time at which this blob was created within the system + AnalysisArtifact: + type: object + required: + - id + - adminDebugEnabled + - sharedToPublic + - blob + properties: + id: + type: string + description: the system assigned unique identifier of the artifact + adminDebugEnabled: + type: boolean + description: Whether the admin debug mode is enabled for this artifact + sharedToPublic: + type: boolean + description: Whether this artifact is publicly accessible + blob: + description: Information about the file/artifact itself + $ref: '#/components/schemas/Blob' + AnalysisArtifactChanges: + type: object + properties: + adminDebugEnabled: + type: boolean + description: Whether the admin debug mode is enabled for this artifact + sharedToPublic: + type: boolean + description: Whether this artifact is publicly accessible + Analysis: + type: object + required: + - id + - analysisType + - pactaVersion + - portfolioSnapshot + - name + - description + - createdAt + - artifacts + properties: + id: + type: string + description: the system assigned unique identifier of the analysis + analysisType: + description: the type of analysis that was run on this object + $ref: '#/components/schemas/AnalysisType' + pactaVersion: + type: string + description: The pacta model that was used to generate this analysis + portfolioSnapshot: + description: The snapshot of portfolios that was used to generate this analysis + $ref: '#/components/schemas/PortfolioSnapshot' + name: + type: string + description: the human meaningful name of the analysis, editable by the user + description: + type: string + description: Additional information about the analysis, editable by the user + createdAt: + type: string + format: date-time + description: The time at which this analysis was created + ranAt: + type: string + format: date-time + description: The time at which this analysis was run, if set + completedAt: + type: string + format: date-time + description: The time at which this analysis completed its run (successfully or not), if set + failureCode: + description: The code describing the failure, if any + $ref: '#/components/schemas/FailureCode' + failureMessage: + type: string + description: The english description of the failure, if any + artifacts: + type: array + description: The list of artifacts that were generated by this analysis + items: + $ref: '#/components/schemas/AnalysisArtifact' + AnalysisChanges: + type: object + properties: + name: + type: string + description: the human meaningful name of the analysis, editable by the user + description: + type: string + description: Additional information about the analysis, editable by the user ListIncompleteUploadsReq: type: object ListIncompleteUploadsResp: @@ -1516,6 +1792,53 @@ components: type: array items: $ref: '#/components/schemas/IncompleteUpload' + ListAnalysesReq: + type: object + ListAnalysesResp: + type: object + required: + - items + properties: + items: + type: array + items: + $ref: '#/components/schemas/Analysis' + RunAnalysisReq: + type: object + required: + - analysisType + - name + - description + properties: + analysisType: + description: the type of analysis that should be run + $ref: '#/components/schemas/AnalysisType' + pactaVersionId: + type: string + description: The pacta model that should be used to generate this analysis + name: + type: string + description: the human meaningful name of the analysis, editable by the user + description: + type: string + description: Additional information about the analysis, editable by the user + portfolioId: + type: string + description: If populated, this analysis should be run on this portfolio + portfolioGroupId: + type: string + description: If populated, this analysis should be run on this portfolio group + initiativeId: + type: string + description: If populated, this analysis should be run on this initiative + RunAnalysisResp: + type: object + required: + - analysisId + properties: + analysisId: + type: string + description: the system assigned unique identifier of the analysis that has been requested ListPortfoliosReq: type: object ListPortfoliosResp: diff --git a/pacta/pacta.go b/pacta/pacta.go index 7997d6d..3d2836a 100644 --- a/pacta/pacta.go +++ b/pacta/pacta.go @@ -462,11 +462,11 @@ func (o *PortfolioInitiativeMembership) Clone() *PortfolioInitiativeMembership { type PortfolioSnapshotID string type PortfolioSnapshot struct { - ID PortfolioSnapshotID - PortfolioID PortfolioID - PortfolioGroupID PortfolioGroupID - InitiatiativeID InitiativeID - PortfolioIDs []PortfolioID + ID PortfolioSnapshotID + PortfolioIDs []PortfolioID + Portfolio *Portfolio + PortfolioGroup *PortfolioGroup + Initiatiative *Initiative } func (o *PortfolioSnapshot) Clone() *PortfolioSnapshot { @@ -476,11 +476,11 @@ func (o *PortfolioSnapshot) Clone() *PortfolioSnapshot { pids := make([]PortfolioID, len(o.PortfolioIDs)) copy(pids, o.PortfolioIDs) return &PortfolioSnapshot{ - ID: o.ID, - PortfolioID: o.PortfolioID, - PortfolioGroupID: o.PortfolioGroupID, - InitiatiativeID: o.InitiatiativeID, - PortfolioIDs: pids, + ID: o.ID, + Portfolio: o.Portfolio.Clone(), + PortfolioGroup: o.PortfolioGroup.Clone(), + Initiatiative: o.Initiatiative.Clone(), + PortfolioIDs: pids, } }