diff --git a/compose/rest.yaml b/compose/rest.yaml index d6e22b504..2fa51002b 100644 --- a/compose/rest.yaml +++ b/compose/rest.yaml @@ -134,6 +134,53 @@ endpoints: type: "*multipart.FileHeader" required: true title: File to upload + - name: clone + path: "/{namespaceID}/clone" + method: POST + title: Clone compose namespace + parameters: + path: + - type: uint64 + name: namespaceID + required: true + title: ID + post: + - type: string + name: name + required: true + title: Duplicate name + - type: string + name: slug + required: true + title: Duplicate slug + - name: export + path: "/{namespaceID}/export/{filename}.zip" + method: GET + title: Export compose namespace + parameters: + path: + - type: uint64 + name: namespaceID + required: true + title: ID + - type: string + name: filename + required: true + title: Output file name + - type: string + name: ext + required: true + title: Output file ext + - name: import + path: "/import" + method: POST + title: Import namespace + parameters: + post: + - name: upload + type: "*multipart.FileHeader" + required: true + title: Namespace import - name: triggerScript method: POST title: Fire compose:namespace trigger diff --git a/compose/rest/handlers/namespace.go b/compose/rest/handlers/namespace.go index 8b387abfb..4ed76d0b3 100644 --- a/compose/rest/handlers/namespace.go +++ b/compose/rest/handlers/namespace.go @@ -25,6 +25,9 @@ type ( Update(context.Context, *request.NamespaceUpdate) (interface{}, error) Delete(context.Context, *request.NamespaceDelete) (interface{}, error) Upload(context.Context, *request.NamespaceUpload) (interface{}, error) + Clone(context.Context, *request.NamespaceClone) (interface{}, error) + Export(context.Context, *request.NamespaceExport) (interface{}, error) + Import(context.Context, *request.NamespaceImport) (interface{}, error) TriggerScript(context.Context, *request.NamespaceTriggerScript) (interface{}, error) ListTranslations(context.Context, *request.NamespaceListTranslations) (interface{}, error) UpdateTranslations(context.Context, *request.NamespaceUpdateTranslations) (interface{}, error) @@ -38,6 +41,9 @@ type ( Update func(http.ResponseWriter, *http.Request) Delete func(http.ResponseWriter, *http.Request) Upload func(http.ResponseWriter, *http.Request) + Clone func(http.ResponseWriter, *http.Request) + Export func(http.ResponseWriter, *http.Request) + Import func(http.ResponseWriter, *http.Request) TriggerScript func(http.ResponseWriter, *http.Request) ListTranslations func(http.ResponseWriter, *http.Request) UpdateTranslations func(http.ResponseWriter, *http.Request) @@ -142,6 +148,54 @@ func NewNamespace(h NamespaceAPI) *Namespace { api.Send(w, r, value) }, + Clone: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewNamespaceClone() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Clone(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Export: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewNamespaceExport() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Export(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, + Import: func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + params := request.NewNamespaceImport() + if err := params.Fill(r); err != nil { + api.Send(w, r, err) + return + } + + value, err := h.Import(r.Context(), params) + if err != nil { + api.Send(w, r, err) + return + } + + api.Send(w, r, value) + }, TriggerScript: func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() params := request.NewNamespaceTriggerScript() @@ -202,6 +256,9 @@ func (h Namespace) MountRoutes(r chi.Router, middlewares ...func(http.Handler) h r.Post("/namespace/{namespaceID}", h.Update) r.Delete("/namespace/{namespaceID}", h.Delete) r.Post("/namespace/upload", h.Upload) + r.Post("/namespace/{namespaceID}/clone", h.Clone) + r.Get("/namespace/{namespaceID}/export/{filename}.zip", h.Export) + r.Post("/namespace/import", h.Import) r.Post("/namespace/{namespaceID}/trigger", h.TriggerScript) r.Get("/namespace/{namespaceID}/translation", h.ListTranslations) r.Patch("/namespace/{namespaceID}/translation", h.UpdateTranslations) diff --git a/compose/rest/namespace.go b/compose/rest/namespace.go index 46cd9536a..84f94f612 100644 --- a/compose/rest/namespace.go +++ b/compose/rest/namespace.go @@ -2,6 +2,10 @@ package rest import ( "context" + "fmt" + "io" + "net/http" + "time" "github.com/cortezaproject/corteza-server/compose/rest/request" "github.com/cortezaproject/corteza-server/compose/service" @@ -9,6 +13,10 @@ import ( "github.com/cortezaproject/corteza-server/compose/types" "github.com/cortezaproject/corteza-server/pkg/api" "github.com/cortezaproject/corteza-server/pkg/corredor" + "github.com/cortezaproject/corteza-server/pkg/envoy" + "github.com/cortezaproject/corteza-server/pkg/envoy/resource" + envoyStore "github.com/cortezaproject/corteza-server/pkg/envoy/store" + "github.com/cortezaproject/corteza-server/pkg/envoy/yaml" "github.com/cortezaproject/corteza-server/pkg/filter" ) @@ -163,6 +171,133 @@ func (ctrl Namespace) Upload(ctx context.Context, r *request.NamespaceUpload) (i return makeAttachmentPayload(ctx, a, err) } +func (ctrl Namespace) Clone(ctx context.Context, r *request.NamespaceClone) (interface{}, error) { + dup := &types.Namespace{ + Name: r.Name, + Slug: r.Slug, + } + + // prepare filters + df := envoyStore.NewDecodeFilter() + + // - compose resources + df = df.ComposeNamespace(&types.NamespaceFilter{ + NamespaceID: []uint64{r.NamespaceID}, + }). + ComposeModule(&types.ModuleFilter{}). + ComposePage(&types.PageFilter{}). + ComposeChart(&types.ChartFilter{}) + + // - workflow + // @todo how do we want to handle these ones? + // do we handle these ones? + + decoder := func() (resource.InterfaceSet, error) { + // get from store + return envoyStore.Decoder().Decode(ctx, service.DefaultStore, df) + } + + encoder := func(nn resource.InterfaceSet) error { + // prepare for encoding + se := envoyStore.NewStoreEncoder(service.DefaultStore, &envoyStore.EncoderConfig{}) + bld := envoy.NewBuilder(se) + g, err := bld.Build(ctx, nn...) + if err != nil { + return err + } + + return envoy.Encode(ctx, g, se) + } + + ns, err := ctrl.namespace.Clone(ctx, r.NamespaceID, dup, decoder, encoder) + return ctrl.makePayload(ctx, ns, err) +} + +func (ctrl Namespace) Export(ctx context.Context, r *request.NamespaceExport) (interface{}, error) { + var ( + // @todo support multiple archive types + ext = "zip" + file = fmt.Sprintf("%s.%s", r.Filename, ext) + ) + + // prepare filters + df := envoyStore.NewDecodeFilter() + + // - compose resources + df = df.ComposeNamespace(&types.NamespaceFilter{ + NamespaceID: []uint64{r.NamespaceID}, + }). + ComposeModule(&types.ModuleFilter{}). + ComposePage(&types.PageFilter{}). + ComposeChart(&types.ChartFilter{}) + + // - workflow + // @todo how do we want to handle these ones? + // do we handle these ones? + + decoder := func() (resource.InterfaceSet, error) { + // get from store + sd := envoyStore.Decoder() + return sd.Decode(ctx, service.DefaultStore, df) + } + + encoder := func(nn resource.InterfaceSet) (envoy.Streamer, error) { + // prepare for encoding + ye := yaml.NewYamlEncoder(&yaml.EncoderConfig{}) + bld := envoy.NewBuilder(ye) + g, err := bld.Build(ctx, nn...) + if err != nil { + return nil, err + } + + err = envoy.Encode(ctx, g, ye) + return ye, err + } + + rs, err := ctrl.namespace.Export(ctx, r.NamespaceID, ext, decoder, encoder) + return ctrl.serveExport(ctx, file, rs, err) +} + +func (ctrl Namespace) Import(ctx context.Context, r *request.NamespaceImport) (interface{}, error) { + f, err := r.Upload.Open() + if err != nil { + return nil, err + } + defer f.Close() + + encoder := func(nn resource.InterfaceSet) error { + se := envoyStore.NewStoreEncoder(service.DefaultStore, &envoyStore.EncoderConfig{}) + + bld := envoy.NewBuilder(se) + g, err := bld.Build(ctx, nn...) + if err != nil { + return err + } + + err = envoy.Encode(ctx, g, se) + if err != nil { + return err + } + + return nil + } + + ns, err := ctrl.namespace.Import(ctx, f, r.Upload.Size, encoder) + return ctrl.makePayload(ctx, ns, err) +} + +func (ctrl Namespace) serveExport(ctx context.Context, fn string, archive io.ReadSeeker, err error) (interface{}, error) { + if err != nil { + return nil, err + } + + return func(w http.ResponseWriter, req *http.Request) { + w.Header().Add("Content-Disposition", "attachment; filename="+fn) + + http.ServeContent(w, req, fn, time.Now(), archive) + }, nil +} + func (ctrl *Namespace) TriggerScript(ctx context.Context, r *request.NamespaceTriggerScript) (rsp interface{}, err error) { var ( namespace *types.Namespace diff --git a/compose/rest/request/namespace.go b/compose/rest/request/namespace.go index 9954c0d9b..aa1526b61 100644 --- a/compose/rest/request/namespace.go +++ b/compose/rest/request/namespace.go @@ -154,6 +154,47 @@ type ( Upload *multipart.FileHeader } + NamespaceClone struct { + // NamespaceID PATH parameter + // + // ID + NamespaceID uint64 `json:",string"` + + // Name POST parameter + // + // Duplicate name + Name string + + // Slug POST parameter + // + // Duplicate slug + Slug string + } + + NamespaceExport struct { + // NamespaceID PATH parameter + // + // ID + NamespaceID uint64 `json:",string"` + + // Filename PATH parameter + // + // Output file name + Filename string + + // Ext PATH parameter + // + // Output file ext + Ext string + } + + NamespaceImport struct { + // Upload POST parameter + // + // Namespace import + Upload *multipart.FileHeader + } + NamespaceTriggerScript struct { // NamespaceID PATH parameter // @@ -646,6 +687,192 @@ func (r *NamespaceUpload) Fill(req *http.Request) (err error) { return err } +// NewNamespaceClone request +func NewNamespaceClone() *NamespaceClone { + return &NamespaceClone{} +} + +// Auditable returns all auditable/loggable parameters +func (r NamespaceClone) Auditable() map[string]interface{} { + return map[string]interface{}{ + "namespaceID": r.NamespaceID, + "name": r.Name, + "slug": r.Slug, + } +} + +// Auditable returns all auditable/loggable parameters +func (r NamespaceClone) GetNamespaceID() uint64 { + return r.NamespaceID +} + +// Auditable returns all auditable/loggable parameters +func (r NamespaceClone) GetName() string { + return r.Name +} + +// Auditable returns all auditable/loggable parameters +func (r NamespaceClone) GetSlug() string { + return r.Slug +} + +// Fill processes request and fills internal variables +func (r *NamespaceClone) Fill(req *http.Request) (err error) { + + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + if err = req.ParseForm(); err != nil { + return err + } + + // POST params + + if val, ok := req.Form["name"]; ok && len(val) > 0 { + r.Name, err = val[0], nil + if err != nil { + return err + } + } + + if val, ok := req.Form["slug"]; ok && len(val) > 0 { + r.Slug, err = val[0], nil + if err != nil { + return err + } + } + } + + { + var val string + // path params + + val = chi.URLParam(req, "namespaceID") + r.NamespaceID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + } + + return err +} + +// NewNamespaceExport request +func NewNamespaceExport() *NamespaceExport { + return &NamespaceExport{} +} + +// Auditable returns all auditable/loggable parameters +func (r NamespaceExport) Auditable() map[string]interface{} { + return map[string]interface{}{ + "namespaceID": r.NamespaceID, + "filename": r.Filename, + "ext": r.Ext, + } +} + +// Auditable returns all auditable/loggable parameters +func (r NamespaceExport) GetNamespaceID() uint64 { + return r.NamespaceID +} + +// Auditable returns all auditable/loggable parameters +func (r NamespaceExport) GetFilename() string { + return r.Filename +} + +// Auditable returns all auditable/loggable parameters +func (r NamespaceExport) GetExt() string { + return r.Ext +} + +// Fill processes request and fills internal variables +func (r *NamespaceExport) Fill(req *http.Request) (err error) { + + { + var val string + // path params + + val = chi.URLParam(req, "namespaceID") + r.NamespaceID, err = payload.ParseUint64(val), nil + if err != nil { + return err + } + + val = chi.URLParam(req, "filename") + r.Filename, err = val, nil + if err != nil { + return err + } + + val = chi.URLParam(req, "ext") + r.Ext, err = val, nil + if err != nil { + return err + } + + } + + return err +} + +// NewNamespaceImport request +func NewNamespaceImport() *NamespaceImport { + return &NamespaceImport{} +} + +// Auditable returns all auditable/loggable parameters +func (r NamespaceImport) Auditable() map[string]interface{} { + return map[string]interface{}{ + "upload": r.Upload, + } +} + +// Auditable returns all auditable/loggable parameters +func (r NamespaceImport) GetUpload() *multipart.FileHeader { + return r.Upload +} + +// Fill processes request and fills internal variables +func (r *NamespaceImport) Fill(req *http.Request) (err error) { + + if strings.ToLower(req.Header.Get("content-type")) == "application/json" { + err = json.NewDecoder(req.Body).Decode(r) + + switch { + case err == io.EOF: + err = nil + case err != nil: + return fmt.Errorf("error parsing http request body: %w", err) + } + } + + { + if err = req.ParseForm(); err != nil { + return err + } + + // POST params + + if _, r.Upload, err = req.FormFile("upload"); err != nil { + return fmt.Errorf("error processing uploaded file: %w", err) + } + + } + + return err +} + // NewNamespaceTriggerScript request func NewNamespaceTriggerScript() *NamespaceTriggerScript { return &NamespaceTriggerScript{} diff --git a/compose/service/namespace.go b/compose/service/namespace.go index d7174059f..823ba8de8 100644 --- a/compose/service/namespace.go +++ b/compose/service/namespace.go @@ -1,13 +1,23 @@ package service import ( + "archive/zip" + "bytes" "context" + "fmt" + "io" + "io/ioutil" + "mime/multipart" "reflect" "strconv" + automationTypes "github.com/cortezaproject/corteza-server/automation/types" "github.com/cortezaproject/corteza-server/compose/service/event" "github.com/cortezaproject/corteza-server/compose/types" "github.com/cortezaproject/corteza-server/pkg/actionlog" + "github.com/cortezaproject/corteza-server/pkg/envoy" + "github.com/cortezaproject/corteza-server/pkg/envoy/resource" + "github.com/cortezaproject/corteza-server/pkg/envoy/yaml" "github.com/cortezaproject/corteza-server/pkg/errors" "github.com/cortezaproject/corteza-server/pkg/eventbus" "github.com/cortezaproject/corteza-server/pkg/handle" @@ -15,6 +25,7 @@ import ( "github.com/cortezaproject/corteza-server/pkg/locale" "github.com/cortezaproject/corteza-server/pkg/rbac" "github.com/cortezaproject/corteza-server/store" + "github.com/gabriel-vasile/mimetype" "golang.org/x/text/language" ) @@ -22,9 +33,13 @@ type ( namespace struct { actionlog actionlog.Recorder ac namespaceAccessController - eventbus eventDispatcher - store store.Storer - locale ResourceTranslationsManagerService + modAc moduleAccessController + pageAc pageAccessController + chartAc chartAccessController + + eventbus eventDispatcher + store store.Storer + locale ResourceTranslationsManagerService } namespaceAccessController interface { @@ -45,6 +60,9 @@ type ( Create(ctx context.Context, namespace *types.Namespace) (*types.Namespace, error) Update(ctx context.Context, namespace *types.Namespace) (*types.Namespace, error) + Clone(ctx context.Context, namespaceID uint64, dup *types.Namespace, decoder func() (resource.InterfaceSet, error), encoder func(resource.InterfaceSet) error) (ns *types.Namespace, err error) + Export(ctx context.Context, namespaceID uint64, archive string, decoder func() (resource.InterfaceSet, error), encoder func(resource.InterfaceSet) (envoy.Streamer, error)) (r io.ReadSeeker, err error) + Import(ctx context.Context, f multipart.File, size int64, encoder func(resource.InterfaceSet) error) (ns *types.Namespace, err error) DeleteByID(ctx context.Context, namespaceID uint64) error } @@ -60,7 +78,11 @@ const ( func Namespace() *namespace { return &namespace{ - ac: DefaultAccessControl, + ac: DefaultAccessControl, + modAc: DefaultAccessControl, + pageAc: DefaultAccessControl, + chartAc: DefaultAccessControl, + eventbus: eventbus.Service(), actionlog: DefaultActionlog, store: DefaultStore, @@ -234,6 +256,245 @@ func (svc namespace) Update(ctx context.Context, upd *types.Namespace) (c *types return svc.updater(ctx, upd.ID, NamespaceActionUpdate, svc.handleUpdate(ctx, upd)) } +func (svc namespace) Clone(ctx context.Context, namespaceID uint64, dup *types.Namespace, decoder func() (resource.InterfaceSet, error), encoder func(resource.InterfaceSet) error) (ns *types.Namespace, err error) { + var ( + aProps = &namespaceActionProps{namespace: dup} + ) + + err = func() error { + // Preparation + // - target namespace + targetNs, err := loadNamespace(ctx, svc.store, namespaceID) + if errors.IsNotFound(err) { + return NamespaceErrNotFound() + } else if err != nil { + return err + } + aProps.setNamespace(targetNs) + + // - destination namespace + dstNs, err := store.LookupComposeNamespaceBySlug(ctx, svc.store, dup.Slug) + if err != nil && err != store.ErrNotFound { + return err + } + if dstNs != nil { + return NamespaceErrHandleNotUnique() + } + + // Access control + if err = svc.canExport(ctx, targetNs); err != nil { + return err + } + + // get namespace resources + nn, err := decoder() + if err != nil { + return err + } + + // some meta bits + sNsID := strconv.FormatUint(namespaceID, 10) + oldNsRef := resource.MakeRef(types.NamespaceResourceType, resource.MakeIdentifiers(sNsID)) + newNsRef := resource.MakeRef(types.NamespaceResourceType, resource.MakeIdentifiers(dup.Slug, dup.Name)) + prune := resource.RefSet{resource.MakeWildRef(automationTypes.WorkflowResourceType)} + + // rename the namespace + // + // For now we will find the namespace in set and change it's name, handle. + // The rest of the resources can stay as are. + // + // @todo add a more flexible system for such modifications + auxNs := resource.FindComposeNamespace(nn, oldNsRef.Identifiers) + auxNs.ID = 0 + auxNs.Name = dup.Name + auxNs.Slug = dup.Slug + dup = auxNs + aProps.setNamespace(dup) + + // Correct internal references + // - namespace identifiers + nn.SearchForIdentifiers(oldNsRef.Identifiers).Walk(func(r resource.Interface) error { + r.ReID(newNsRef.Identifiers) + return nil + }) + + // - relations + nn.SearchForReferences(oldNsRef).Walk(func(r resource.Interface) error { + r.ReRef(resource.RefSet{oldNsRef}, resource.RefSet{newNsRef}) + + // - additional pruning + pp, ok := r.(resource.PrunableInterface) + if !ok { + return nil + } + + for _, p := range prune { + pp.Prune(p) + } + return nil + }) + + // encode + return encoder(nn) + }() + + return dup, svc.recordAction(ctx, aProps, NamespaceActionClone, err) +} + +func (svc namespace) Export(ctx context.Context, namespaceID uint64, archive string, decoder func() (resource.InterfaceSet, error), encoder func(resource.InterfaceSet) (envoy.Streamer, error)) (r io.ReadSeeker, err error) { + var ( + aProps = &namespaceActionProps{archiveFormat: archive} + ) + + // make archive + buf := bytes.NewBuffer(nil) + w := zip.NewWriter(buf) + + err = func() error { + if archive != "zip" { + return NamespaceErrUnsupportedExportFormat() + } + + // initial validation + // - target namespace + targetNs, err := store.LookupComposeNamespaceByID(ctx, svc.store, namespaceID) + if err != nil && err != store.ErrNotFound { + return err + } + aProps.setNamespace(targetNs) + + // - ac + if err = svc.canExport(ctx, targetNs); err != nil { + return err + } + + // get namespace resources + nn, err := decoder() + if err != nil { + return err + } + + // some meta bits + sNsID := strconv.FormatUint(namespaceID, 10) + oldNsRef := resource.MakeRef(types.NamespaceResourceType, resource.MakeIdentifiers(sNsID)) + prune := resource.RefSet{resource.MakeWildRef(automationTypes.WorkflowResourceType)} + + // - prune resources we won't preserve + nn.SearchForReferences(oldNsRef).Walk(func(r resource.Interface) error { + pp, ok := r.(resource.PrunableInterface) + if !ok { + return nil + } + + for _, p := range prune { + pp.Prune(p) + } + return nil + }) + + // encode + ss, err := encoder(nn) + if err != nil { + return err + } + + // create archive + for _, s := range ss.Stream() { + // @todo generalize when needed + f, err := w.Create(fmt.Sprintf("%s.yaml", s.Resource)) + if err != nil { + return err + } + + bb, err := ioutil.ReadAll(s.Source) + if err != nil { + return err + } + + _, err = f.Write(bb) + if err != nil { + return err + } + } + + return w.Close() + }() + + return bytes.NewReader(buf.Bytes()), svc.recordAction(ctx, aProps, NamespaceActionExport, err) +} + +func (svc namespace) Import(ctx context.Context, f multipart.File, size int64, encoder func(resource.InterfaceSet) error) (ns *types.Namespace, err error) { + var ( + aProps = &namespaceActionProps{} + ) + + err = func() error { + // access control + if err := svc.canImport(ctx); err != nil { + return err + } + + // archive type check + mt, err := mimetype.DetectReader(f) + if err != nil { + return err + } + aProps.setArchiveFormat(mt.Extension()) + if !mt.Is("application/zip") { + return NamespaceErrUnsupportedImportFormat() + } + + _, err = f.Seek(0, 0) + if err != nil { + return err + } + + // un-archive + archive, err := zip.NewReader(f, size) + if err != nil { + return err + } + + // decode with Envoy + yd := yaml.Decoder() + nn := make([]resource.Interface, 0, 10) + + for _, f := range archive.File { + a, err := f.Open() + if err != nil { + return err + } + defer a.Close() + + mm, err := yd.Decode(ctx, a, nil) + if err != nil { + return err + } + nn = append(nn, mm...) + } + + // encode + err = encoder(nn) + if err != nil { + return err + } + + // find the ns node + for _, n := range nn { + if nsn, ok := n.(*resource.ComposeNamespace); ok { + ns = nsn.Res + break + } + } + + aProps.setNamespace(ns) + + return nil + }() + + return ns, svc.recordAction(ctx, aProps, NamespaceActionImport, err) +} + func (svc namespace) DeleteByID(ctx context.Context, namespaceID uint64) error { return trim1st(svc.updater(ctx, namespaceID, NamespaceActionDelete, svc.handleDelete)) } @@ -430,6 +691,64 @@ func (svc namespace) handleUndelete(ctx context.Context, ns *types.Namespace) (n return namespaceChanged, nil } +func (svc namespace) canExport(ctx context.Context, namespace *types.Namespace) error { + // Preload all of the relevant stuff for access control + // - modules + // no need to load fields + mm, _, err := store.SearchComposeModules(ctx, svc.store, types.ModuleFilter{NamespaceID: namespace.ID}) + if err != nil { + return err + } + // - pages + pp, _, err := store.SearchComposePages(ctx, svc.store, types.PageFilter{NamespaceID: namespace.ID}) + if err != nil { + return err + } + // - charts + cc, _, err := store.SearchComposeCharts(ctx, svc.store, types.ChartFilter{NamespaceID: namespace.ID}) + if err != nil { + return err + } + + // access control + // - namespace + if !svc.ac.CanReadNamespace(ctx, namespace) { + return NamespaceErrNotAllowedToRead() + } + // - modules + for _, m := range mm { + if !svc.modAc.CanReadModule(ctx, m) { + return ModuleErrNotAllowedToRead() + } + } + // - pages + for _, p := range pp { + if !svc.pageAc.CanReadPage(ctx, p) { + return PageErrNotAllowedToRead() + } + } + // - charts + for _, c := range cc { + if !svc.chartAc.CanReadChart(ctx, c) { + return ChartErrNotAllowedToRead() + } + } + return nil +} + +func (svc namespace) canImport(ctx context.Context) error { + + // If a user is allowed to create a namespace, they are considered to be allowed + // to create any underlying resource when it comes to importing. + // + // This was agreed upon internally and may change in the future. + + if !svc.ac.CanCreateNamespace(ctx) { + return NamespaceErrNotAllowedToCreate() + } + return nil +} + func loadNamespace(ctx context.Context, s store.Storer, namespaceID uint64) (ns *types.Namespace, err error) { if namespaceID == 0 { return nil, ChartErrInvalidNamespaceID() diff --git a/compose/service/namespace_actions.gen.go b/compose/service/namespace_actions.gen.go index 952a66a01..077ad7ad7 100644 --- a/compose/service/namespace_actions.gen.go +++ b/compose/service/namespace_actions.gen.go @@ -21,9 +21,10 @@ import ( type ( namespaceActionProps struct { - namespace *types.Namespace - changed *types.Namespace - filter *types.NamespaceFilter + namespace *types.Namespace + changed *types.Namespace + archiveFormat string + filter *types.NamespaceFilter } namespaceAction struct { @@ -73,6 +74,17 @@ func (p *namespaceActionProps) setChanged(changed *types.Namespace) *namespaceAc return p } +// setArchiveFormat updates namespaceActionProps's archiveFormat +// +// Allows method chaining +// +// This function is auto-generated. +// +func (p *namespaceActionProps) setArchiveFormat(archiveFormat string) *namespaceActionProps { + p.archiveFormat = archiveFormat + return p +} + // setFilter updates namespaceActionProps's filter // // Allows method chaining @@ -106,6 +118,7 @@ func (p namespaceActionProps) Serialize() actionlog.Meta { m.Set("changed.meta", p.changed.Meta, true) m.Set("changed.enabled", p.changed.Enabled, true) } + m.Set("archiveFormat", p.archiveFormat, true) if p.filter != nil { m.Set("filter.query", p.filter.Query, true) m.Set("filter.slug", p.filter.Slug, true) @@ -178,6 +191,7 @@ func (p namespaceActionProps) Format(in string, err error) string { pairs = append(pairs, "{{changed.meta}}", fns(p.changed.Meta)) pairs = append(pairs, "{{changed.enabled}}", fns(p.changed.Enabled)) } + pairs = append(pairs, "{{archiveFormat}}", fns(p.archiveFormat)) if p.filter != nil { // replacement for "{{filter}}" (in order how fields are defined) @@ -311,6 +325,66 @@ func NamespaceActionUpdate(props ...*namespaceActionProps) *namespaceAction { return a } +// NamespaceActionClone returns "compose:namespace.clone" action +// +// This function is auto-generated. +// +func NamespaceActionClone(props ...*namespaceActionProps) *namespaceAction { + a := &namespaceAction{ + timestamp: time.Now(), + resource: "compose:namespace", + action: "clone", + log: "cloned {namespace}", + severity: actionlog.Notice, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// NamespaceActionExport returns "compose:namespace.export" action +// +// This function is auto-generated. +// +func NamespaceActionExport(props ...*namespaceActionProps) *namespaceAction { + a := &namespaceAction{ + timestamp: time.Now(), + resource: "compose:namespace", + action: "export", + log: "exported {namespace}", + severity: actionlog.Notice, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + +// NamespaceActionImport returns "compose:namespace.import" action +// +// This function is auto-generated. +// +func NamespaceActionImport(props ...*namespaceActionProps) *namespaceAction { + a := &namespaceAction{ + timestamp: time.Now(), + resource: "compose:namespace", + action: "import", + log: "imported {namespace}", + severity: actionlog.Notice, + } + + if len(props) > 0 { + a.props = props[0] + } + + return a +} + // NamespaceActionDelete returns "compose:namespace.delete" action // // This function is auto-generated. @@ -583,6 +657,114 @@ func NamespaceErrStaleData(mm ...*namespaceActionProps) *errors.Error { return e } +// NamespaceErrUnsupportedExportFormat returns "compose:namespace.unsupportedExportFormat" as *errors.Error +// +// +// This function is auto-generated. +// +func NamespaceErrUnsupportedExportFormat(mm ...*namespaceActionProps) *errors.Error { + var p = &namespaceActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("unsupported export format", nil), + + errors.Meta("type", "unsupportedExportFormat"), + errors.Meta("resource", "compose:namespace"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(namespaceLogMetaKey{}, "could not export namespace {{namespace}}; unsupported format {{archiveFormat}}"), + errors.Meta(namespacePropsMetaKey{}, p), + + // translation namespace & key + errors.Meta(locale.ErrorMetaNamespace{}, "compose"), + errors.Meta(locale.ErrorMetaKey{}, "namespace.errors.unsupportedExportFormat"), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// NamespaceErrUnsupportedImportFormat returns "compose:namespace.unsupportedImportFormat" as *errors.Error +// +// +// This function is auto-generated. +// +func NamespaceErrUnsupportedImportFormat(mm ...*namespaceActionProps) *errors.Error { + var p = &namespaceActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("unsupported import format", nil), + + errors.Meta("type", "unsupportedImportFormat"), + errors.Meta("resource", "compose:namespace"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(namespaceLogMetaKey{}, "could not import namespace {{namespace}}; unsupported format {{archiveFormat}}"), + errors.Meta(namespacePropsMetaKey{}, p), + + // translation namespace & key + errors.Meta(locale.ErrorMetaNamespace{}, "compose"), + errors.Meta(locale.ErrorMetaKey{}, "namespace.errors.unsupportedImportFormat"), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + +// NamespaceErrCloneMultiple returns "compose:namespace.cloneMultiple" as *errors.Error +// +// +// This function is auto-generated. +// +func NamespaceErrCloneMultiple(mm ...*namespaceActionProps) *errors.Error { + var p = &namespaceActionProps{} + if len(mm) > 0 { + p = mm[0] + } + + var e = errors.New( + errors.KindInternal, + + p.Format("not allowed to clone multiple namespaces at once", nil), + + errors.Meta("type", "cloneMultiple"), + errors.Meta("resource", "compose:namespace"), + + // action log entry; no formatting, it will be applied inside recordAction fn. + errors.Meta(namespaceLogMetaKey{}, "could not clone namespaces; multiple duplications requested at once"), + errors.Meta(namespacePropsMetaKey{}, p), + + // translation namespace & key + errors.Meta(locale.ErrorMetaNamespace{}, "compose"), + errors.Meta(locale.ErrorMetaKey{}, "namespace.errors.cloneMultiple"), + + errors.StackSkip(1), + ) + + if len(mm) > 0 { + } + + return e +} + // NamespaceErrNotAllowedToRead returns "compose:namespace.notAllowedToRead" as *errors.Error // // diff --git a/compose/service/namespace_actions.yaml b/compose/service/namespace_actions.yaml index 9958fb1c9..1c1a2b262 100644 --- a/compose/service/namespace_actions.yaml +++ b/compose/service/namespace_actions.yaml @@ -19,6 +19,8 @@ props: - name: changed type: "*types.Namespace" fields: [ name, slug, ID, meta, enabled ] + - name: archiveFormat + type: string - name: filter type: "*types.NamespaceFilter" fields: [ query, slug, sort, limit ] @@ -38,6 +40,15 @@ actions: - action: update log: "updated {{namespace}}" + - action: clone + log: "cloned {namespace}" + + - action: export + log: "exported {namespace}" + + - action: import + log: "imported {namespace}" + - action: delete log: "deleted {{namespace}}" @@ -69,6 +80,18 @@ errors: message: "stale data" severity: warning + - error: unsupportedExportFormat + message: "unsupported export format" + log: "could not export namespace {{namespace}}; unsupported format {{archiveFormat}}" + + - error: unsupportedImportFormat + message: "unsupported import format" + log: "could not import namespace {{namespace}}; unsupported format {{archiveFormat}}" + + - error: cloneMultiple + message: "not allowed to clone multiple namespaces at once" + log: "could not clone namespaces; multiple duplications requested at once" + - error: notAllowedToRead message: "not allowed to read this namespace" log: "could not read {{namespace}}; insufficient permissions" diff --git a/compose/types/module_field_options.go b/compose/types/module_field_options.go index 715516b7e..3261d9023 100644 --- a/compose/types/module_field_options.go +++ b/compose/types/module_field_options.go @@ -7,6 +7,7 @@ import ( "strconv" "github.com/pkg/errors" + "github.com/spf13/cast" ) type ( @@ -55,6 +56,21 @@ func (opt ModuleFieldOptions) Bool(key string) bool { return false } +func (opt ModuleFieldOptions) UInt64(key string) uint64 { + return opt.UInt64Def(key, 0) +} + +func (opt ModuleFieldOptions) UInt64Def(key string, def uint64) uint64 { + if val, has := opt[key]; has { + v, err := cast.ToUint64E(val) + if err != nil { + return def + } + return v + } + return def +} + func (opt ModuleFieldOptions) Int64(key string) int64 { return opt.Int64Def(key, 0) } diff --git a/pkg/envoy/resource/compose_page.go b/pkg/envoy/resource/compose_page.go index 4c9eb3c7d..8dc3bfdf1 100644 --- a/pkg/envoy/resource/compose_page.go +++ b/pkg/envoy/resource/compose_page.go @@ -114,10 +114,10 @@ func NewComposePage(pg *types.Page, nsRef, modRef, parentRef string) *ComposePag } case "Comment": - id := ss(b.Options, "module", "moduleID") - if id != "" { - ref := r.AddRef(types.ModuleResourceType, id).Constraint(r.RefNs) - r.BlockRefs[i] = add(r.BlockRefs[i], ref) + ref = r.pbComment(b.Options) + if ref != nil { + r.addRef(ref.Constraint(r.RefNs)) + r.BlockRefs[i] = append(r.BlockRefs[i], ref) r.ModRefs = append(r.ModRefs, ref) } } @@ -257,6 +257,15 @@ func (r *ComposePage) pbRecordList(opt map[string]interface{}) (out *Ref) { return MakeRef(types.ModuleResourceType, MakeIdentifiers(id)).Constraint(r.RefNs) } +func (r *ComposePage) pbComment(opt map[string]interface{}) (out *Ref) { + id := r.optString(opt, "module", "moduleID") + if id == "" { + return + } + + return MakeRef(types.ModuleResourceType, MakeIdentifiers(id)).Constraint(r.RefNs) +} + func (r *ComposePage) pbAutomation(opt map[string]interface{}) (out *Ref) { id := r.optString(opt, "workflow", "workflowID") if id == "" { diff --git a/pkg/envoy/store/api_gateway_marshal.go b/pkg/envoy/store/api_gateway_marshal.go index 112ad68f2..264d6395e 100644 --- a/pkg/envoy/store/api_gateway_marshal.go +++ b/pkg/envoy/store/api_gateway_marshal.go @@ -26,10 +26,6 @@ func (n *apiGateway) Prepare(ctx context.Context, pl *payload) (err error) { } func (n *apiGateway) prepareRoute(ctx context.Context, pl *payload) (err error) { - if n.cfg.IgnoreStore { - n.res.Res.ID = 0 - return nil - } // Try to get the original workflow n.gwr, err = findAPIGatewayStore(ctx, pl.s, makeGenericFilter(n.res.Identifiers())) @@ -48,13 +44,6 @@ func (n *apiGateway) prepareFilters(ctx context.Context, pl *payload) (err error return nil } - if n.cfg.IgnoreStore { - for _, t := range n.ff { - t.ID = 0 - } - return nil - } - // Try to find any related filters for this route tt, _, err := store.SearchApigwFilters(ctx, pl.s, types.ApigwFilterFilter{ RouteID: n.gwr.ID, diff --git a/pkg/envoy/store/report_marshal.go b/pkg/envoy/store/report_marshal.go index f11b40867..257653f8f 100644 --- a/pkg/envoy/store/report_marshal.go +++ b/pkg/envoy/store/report_marshal.go @@ -17,11 +17,6 @@ func newReportFromResource(res *resource.Report, cfg *EncoderConfig) resourceSta } func (n *report) Prepare(ctx context.Context, pl *payload) (err error) { - if n.cfg.IgnoreStore { - n.res.Res.ID = 0 - return nil - } - // Try to get the original report n.rp, err = findReportStore(ctx, pl.s, makeGenericFilter(n.res.Identifiers())) if err != nil { diff --git a/pkg/envoy/store/system.go b/pkg/envoy/store/system.go index 1048a1ade..9edcbdc2b 100644 --- a/pkg/envoy/store/system.go +++ b/pkg/envoy/store/system.go @@ -743,7 +743,7 @@ func (df *DecodeFilter) RbacStrict(f *rbac.RuleFilter) *DecodeFilter { return df } - df.rbac = append(df.rbac, &rbacFilter{RuleFilter: *f, strict: true}) + df.rbac = append(df.rbac, &rbacFilter{RuleFilter: *f}) return df } diff --git a/tests/compose/0001_namespace_duplicate_test.go b/tests/compose/0001_namespace_duplicate_test.go new file mode 100644 index 000000000..4e8439c3c --- /dev/null +++ b/tests/compose/0001_namespace_duplicate_test.go @@ -0,0 +1,159 @@ +package compose + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/cortezaproject/corteza-server/compose/types" + "github.com/cortezaproject/corteza-server/store" + "github.com/cortezaproject/corteza-server/tests/helpers" + "github.com/spf13/cast" +) + +func fetchEntireNamespace(ctx context.Context, s store.Storer, slug string) (ns *types.Namespace, mm types.ModuleSet, pp types.PageSet, cc types.ChartSet, slg string, err error) { + slg = slug + + ns, err = store.LookupComposeNamespaceBySlug(ctx, s, slug) + if err != nil { + return + } + + mm, _, err = store.SearchComposeModules(ctx, s, types.ModuleFilter{NamespaceID: ns.ID}) + if err != nil { + return + } + + for i := 0; i < len(mm); i++ { + mm[i].Fields, _, err = store.SearchComposeModuleFields(ctx, s, types.ModuleFieldFilter{ModuleID: []uint64{mm[i].ID}}) + if err != nil { + return + } + } + + pp, _, err = store.SearchComposePages(ctx, s, types.PageFilter{NamespaceID: ns.ID}) + if err != nil { + return + } + + cc, _, err = store.SearchComposeCharts(ctx, s, types.ChartFilter{NamespaceID: ns.ID}) + if err != nil { + return + } + + return +} + +func findModuleByHandle(mm types.ModuleSet, h string) *types.Module { + for _, m := range mm { + if m.Handle == h { + return m + } + } + return nil +} + +func findChartByHandle(cc types.ChartSet, h string) *types.Chart { + for _, m := range cc { + if m.Handle == h { + return m + } + } + return nil +} + +func findPageByHandle(pp types.PageSet, h string) *types.Page { + for _, m := range pp { + if m.Handle == h { + return m + } + } + return nil +} + +func Test0001_namespace_duplicate(t *testing.T) { + ctx, h, s := setup(t) + loadScenario(ctx, defStore, t, h) + ns, _, _, _, _, err := fetchEntireNamespace(ctx, s, "ns1") + h.a.NoError(err) + + helpers.AllowMe(h, types.ComponentRbacResource(), "namespace.create") + helpers.AllowMe(h, types.NamespaceRbacResource(0), "read") + helpers.AllowMe(h, types.ModuleRbacResource(0, 0), "read") + helpers.AllowMe(h, types.PageRbacResource(0, 0), "read") + helpers.AllowMe(h, types.ChartRbacResource(0, 0), "read") + + h.apiInit(). + Post(fmt.Sprintf("/namespace/%d/clone", ns.ID)). + JSON(`{ "slug": "cloned", "name": "cloned name" }`). + Expect(t). + Status(http.StatusOK). + Assert(helpers.AssertNoErrors). + End() + + usedIDs := make(map[uint64]bool) + checkID := func(h helper, id uint64) { + if usedIDs[id] { + h.a.FailNow(fmt.Sprintf("the ID is not unique across cloned resources: %d", id)) + } + usedIDs[id] = true + } + + checker := func(ns *types.Namespace, mm types.ModuleSet, pp types.PageSet, cc types.ChartSet, slug string, err error) { + h.a.NoError(err) + + // NS + h.a.Equal(slug, ns.Slug) + + // Modules + mod1 := findModuleByHandle(mm, "mod1") + h.a.NotNil(mod1) + checkID(h, mod1.ID) + mod2 := findModuleByHandle(mm, "mod2") + h.a.NotNil(mod2) + checkID(h, mod2.ID) + mod3 := findModuleByHandle(mm, "mod3") + h.a.NotNil(mod3) + checkID(h, mod3.ID) + + h.a.Len(mod1.Fields, 4) + h.a.Equal("Record", mod1.Fields[3].Kind) + h.a.Equal(mod2.ID, mod1.Fields[3].Options.UInt64("moduleID")) + + h.a.Len(mod2.Fields, 4) + h.a.Equal("Record", mod2.Fields[1].Kind) + h.a.Equal(mod2.ID, mod2.Fields[1].Options.UInt64("moduleID")) + h.a.Equal("Record", mod2.Fields[3].Kind) + h.a.Equal(mod3.ID, mod2.Fields[3].Options.UInt64("moduleID")) + + h.a.Len(mod3.Fields, 1) + + // Charts + chr1 := findChartByHandle(cc, "chr1") + checkID(h, chr1.ID) + h.a.NotNil(chr1) + h.a.Len(chr1.Config.Reports, 1) + h.a.Equal(mod1.ID, chr1.Config.Reports[0].ModuleID) + + // Pages + pg1 := findPageByHandle(pp, "pg1") + checkID(h, pg1.ID) + h.a.NotNil(pg1) + rpg2 := findPageByHandle(pp, "rpg2") + checkID(h, rpg2.ID) + h.a.NotNil(rpg2) + + h.a.Len(pg1.Blocks, 3) + + h.a.Equal("RecordList", pg1.Blocks[1].Kind) + h.a.Equal(mod1.ID, cast.ToUint64(pg1.Blocks[1].Options["moduleID"])) + h.a.Equal("Chart", pg1.Blocks[2].Kind) + h.a.Equal(chr1.ID, cast.ToUint64(pg1.Blocks[2].Options["chartID"])) + + h.a.Equal(rpg2.ModuleID, mod1.ID) + } + + checker(fetchEntireNamespace(ctx, s, "ns1")) + checker(fetchEntireNamespace(ctx, s, "cloned")) +} diff --git a/tests/compose/main_test.go b/tests/compose/main_test.go index 7dde06a1e..0f27d4687 100644 --- a/tests/compose/main_test.go +++ b/tests/compose/main_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "os" + "path" "testing" "github.com/cortezaproject/corteza-server/app" @@ -12,12 +13,19 @@ import ( "github.com/cortezaproject/corteza-server/pkg/api/server" "github.com/cortezaproject/corteza-server/pkg/auth" "github.com/cortezaproject/corteza-server/pkg/cli" + "github.com/cortezaproject/corteza-server/pkg/envoy" + "github.com/cortezaproject/corteza-server/pkg/envoy/csv" + "github.com/cortezaproject/corteza-server/pkg/envoy/directory" + "github.com/cortezaproject/corteza-server/pkg/envoy/resource" + envoyStore "github.com/cortezaproject/corteza-server/pkg/envoy/store" + "github.com/cortezaproject/corteza-server/pkg/envoy/yaml" "github.com/cortezaproject/corteza-server/pkg/eventbus" "github.com/cortezaproject/corteza-server/pkg/id" "github.com/cortezaproject/corteza-server/pkg/logger" "github.com/cortezaproject/corteza-server/pkg/objstore/plain" "github.com/cortezaproject/corteza-server/pkg/rand" "github.com/cortezaproject/corteza-server/pkg/rbac" + "github.com/cortezaproject/corteza-server/store" sysTypes "github.com/cortezaproject/corteza-server/system/types" "github.com/cortezaproject/corteza-server/tests/helpers" "github.com/go-chi/chi" @@ -42,6 +50,7 @@ var ( r chi.Router eventBus = eventbus.New() + defStore store.Storer ) func init() { @@ -64,6 +73,7 @@ func InitTestApp() { testApp = helpers.NewIntegrationTestApp(ctx, func(app *app.CortezaApp) (err error) { service.DefaultStore = app.Store + defStore = app.Store service.DefaultObjectStore, err = plain.NewWithAfero(afero.NewMemMapFs(), "test") if err != nil { return err @@ -144,3 +154,86 @@ func (h helper) noError(err error) { h.a.NoError(err) } + +func collect(ee ...error) error { + for _, e := range ee { + if e != nil { + return e + } + } + return nil +} + +func cleanup(t *testing.T) { + var ( + ctx = context.Background() + ) + + err := collect( + defStore.TruncateComposeNamespaces(ctx), + defStore.TruncateComposePages(ctx), + defStore.TruncateComposeModuleFields(ctx), + defStore.TruncateComposeModules(ctx), + defStore.TruncateComposeRecords(ctx, nil), + ) + if err != nil { + t.Fatalf("failed to decode scenario data: %v", err) + } +} + +func loadScenario(ctx context.Context, s store.Storer, t *testing.T, h helper) { + loadScenarioWithName(ctx, s, t, h, "S"+t.Name()[4:]) +} + +func loadScenarioWithName(ctx context.Context, s store.Storer, t *testing.T, h helper, scenario string) { + cleanup(t) + parseEnvoy(ctx, s, h, path.Join("testdata", scenario, "data_model")) +} + +func parseEnvoy(ctx context.Context, s store.Storer, h helper, path string) { + nn, err := directory.Decode( + ctx, + path, + yaml.Decoder(), + csv.Decoder(), + ) + if err != nil { + h.t.Fatalf("failed to decode scenario data: %v", err) + } + + crs := resource.ComposeRecordShaper() + nn, err = resource.Shape(nn, crs) + h.a.NoError(err) + + // import into the store + se := envoyStore.NewStoreEncoder(s, nil) + bld := envoy.NewBuilder(se) + g, err := bld.Build(ctx, nn...) + h.a.NoError(err) + err = envoy.Encode(ctx, g, se) + h.a.NoError(err) +} + +func bypassRBAC(ctx context.Context) context.Context { + u := &sysTypes.User{ + ID: id.Next(), + } + + u.SetRoles(auth.BypassRoles().IDs()...) + + return auth.SetIdentityToContext(ctx, u) +} + +func setup(t *testing.T) (context.Context, helper, store.Storer) { + h := newHelper(t) + s := service.DefaultStore + + u := &sysTypes.User{ + ID: id.Next(), + } + u.SetRoles(auth.BypassRoles().IDs()...) + + ctx := auth.SetIdentityToContext(context.Background(), u) + + return ctx, h, s +} diff --git a/tests/compose/testdata/S0001_namespace_duplicate/data_model/1000_namespace.yaml b/tests/compose/testdata/S0001_namespace_duplicate/data_model/1000_namespace.yaml new file mode 100644 index 000000000..137005b54 --- /dev/null +++ b/tests/compose/testdata/S0001_namespace_duplicate/data_model/1000_namespace.yaml @@ -0,0 +1,3 @@ +namespaces: + ns1: + name: ns1 name diff --git a/tests/compose/testdata/S0001_namespace_duplicate/data_model/1100_modules.yaml b/tests/compose/testdata/S0001_namespace_duplicate/data_model/1100_modules.yaml new file mode 100644 index 000000000..bc28dbfee --- /dev/null +++ b/tests/compose/testdata/S0001_namespace_duplicate/data_model/1100_modules.yaml @@ -0,0 +1,69 @@ +# Provides a complete set of things to cover regarding modules and module fields. +# mod1: defines more complicated fields, relates to mod2 +# mod2: defines a self-ref, relates to mod3 +# mod3: just a placeholder + +namespace: ns1 +modules: + mod1: + name: mod1 name + fields: + f1: + label: f1 label + kind: String + required: true + f2: + label: f2 label + kind: Select + options: + options: + - f2 opt 1 + - f2 opt 2 + - f2 opt 3 + f3: + label: f3 label + kind: Select + options: + options: + - ☆☆☆☆☆ + - ★☆☆☆☆ + f4: + label: f4 label + kind: Record + options: + labelField: f_label + module: mod2 + queryFields: + - f1 + + mod2: + name: mod2 name + fields: + f_label: + label: f_label record label + f_ref_self: + label: f_ref_self label + kind: Record + options: + labelField: f_label + module: mod2 + queryFields: + - f1 + f1: + label: f1 label + kind: String + required: true + f2: + label: f2 label + kind: Record + options: + labelField: f_label + module: mod3 + queryFields: + - f1 + + mod3: + name: mod3 name + fields: + f_label: + label: f_label record label diff --git a/tests/compose/testdata/S0001_namespace_duplicate/data_model/1200_pages.yaml b/tests/compose/testdata/S0001_namespace_duplicate/data_model/1200_pages.yaml new file mode 100644 index 000000000..73dcafe40 --- /dev/null +++ b/tests/compose/testdata/S0001_namespace_duplicate/data_model/1200_pages.yaml @@ -0,0 +1,47 @@ +namespace: ns1 +pages: + pg1: + title: pg1 title + blocks: + - title: pg1 b1 + kind: Content + xywh: [0, 0, 1, 1] + options: + body: pg1 b1 content body + + - title: pg1 b2 + kind: RecordList + xywh: [0, 1, 1, 1] + options: + module: mod1 + fields: + - name: f1 + - name: f2 + variants: + bodyBg: white + border: primary + headerBg: white + headerText: primary + + - title: pg1 b3 + kind: Chart + xywh: [0, 2, 1, 1] + options: + chart: chr1 + variants: + bodyBg: white + border: primary + headerBg: white + headerText: primary + + rpg2: + handle: rpg2 + module: mod1 + title: Record page for module "mod1" + blocks: + - title: rpg2 b1 title + kind: Record + options: + fields: + - name: f1 + - name: f2 diff --git a/tests/compose/testdata/S0001_namespace_duplicate/data_model/1300_charts.yaml b/tests/compose/testdata/S0001_namespace_duplicate/data_model/1300_charts.yaml new file mode 100644 index 000000000..f08bbe9ba --- /dev/null +++ b/tests/compose/testdata/S0001_namespace_duplicate/data_model/1300_charts.yaml @@ -0,0 +1,18 @@ +namespace: ns1 +charts: + chr1: + name: chr1 name + config: + reports: + - dimensions: + - field: Status + modifier: (no grouping / buckets) + filter: "" + metrics: + - backgroundColor: '#11ff57' + field: count + fixTooltips: true + type: pie + module: mod1 + renderer: {} + colorScheme: tableau.Tableau10