Add support for namespace clone, export, import

This commit is contained in:
Tomaž Jerman
2021-09-23 13:05:08 +02:00
parent d90c11153b
commit f7922d9736
18 changed files with 1416 additions and 28 deletions
+47
View File
@@ -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
+57
View File
@@ -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)
+135
View File
@@ -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
+227
View File
@@ -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{}
+323 -4
View File
@@ -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()
+185 -3
View File
@@ -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
//
//
+23
View File
@@ -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"
+16
View File
@@ -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)
}
+13 -4
View File
@@ -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 == "" {
-11
View File
@@ -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,
-5
View File
@@ -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 {
+1 -1
View File
@@ -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
}
@@ -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"))
}
+93
View File
@@ -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
}
@@ -0,0 +1,3 @@
namespaces:
ns1:
name: ns1 name
@@ -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
@@ -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
@@ -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