diff --git a/api/pkg.go b/api/pkg.go index a72b3cc0f..b5b3e7b0f 100644 --- a/api/pkg.go +++ b/api/pkg.go @@ -86,6 +86,7 @@ func All() []Handler { &FileHandler{}, &MigrationWaveHandler{}, &BatchHandler{}, + &TargetHandler{}, } } diff --git a/api/ruleset.go b/api/ruleset.go index 47ac0e7dd..2db6f3019 100644 --- a/api/ruleset.go +++ b/api/ruleset.go @@ -137,6 +137,10 @@ func (h RuleSetHandler) Delete(ctx *gin.Context) { _ = ctx.Error(result.Error) return } + if ruleset.Builtin() { + h.Status(ctx, http.StatusForbidden) + return + } result = h.DB(ctx).Delete(ruleset, id) if result.Error != nil { _ = ctx.Error(result.Error) @@ -163,8 +167,6 @@ func (h RuleSetHandler) Update(ctx *gin.Context) { _ = ctx.Error(err) return } - // - // Delete unwanted ruleSets. m := &model.RuleSet{} db := h.preLoad(h.DB(ctx), clause.Associations) result := db.First(m, id) @@ -172,6 +174,12 @@ func (h RuleSetHandler) Update(ctx *gin.Context) { _ = ctx.Error(result.Error) return } + if m.Builtin() { + h.Status(ctx, http.StatusForbidden) + return + } + // + // Delete unwanted rules. for _, ruleset := range m.Rules { if !r.HasRule(ruleset.ID) { err := h.DB(ctx).Delete(ruleset).Error @@ -239,14 +247,7 @@ func (r *RuleSet) With(m *model.RuleSet) { r.Resource.With(&m.Model) r.Kind = m.Kind r.Name = m.Name - r.Description = m.Description - r.Custom = m.Custom r.Identity = r.refPtr(m.IdentityID, m.Identity) - imgRef := Ref{ID: m.ImageID} - if m.Image != nil { - imgRef.Name = m.Image.Name - } - r.Image = imgRef _ = json.Unmarshal(m.Repository, &r.Repository) r.Rules = []Rule{} for i := range m.Rules { @@ -268,13 +269,10 @@ func (r *RuleSet) With(m *model.RuleSet) { // Model builds a model. func (r *RuleSet) Model() (m *model.RuleSet) { m = &model.RuleSet{ - Kind: r.Kind, - Name: r.Name, - Description: r.Description, - Custom: r.Custom, + Kind: r.Kind, + Name: r.Name, } m.ID = r.ID - m.ImageID = r.Image.ID m.IdentityID = r.idPtr(r.Identity) m.Rules = []model.Rule{} for _, rule := range r.Rules { diff --git a/api/target.go b/api/target.go new file mode 100644 index 000000000..ba31f53d1 --- /dev/null +++ b/api/target.go @@ -0,0 +1,237 @@ +package api + +import ( + "encoding/json" + "github.com/gin-gonic/gin" + "github.com/konveyor/tackle2-hub/model" + "gorm.io/gorm/clause" + "net/http" +) + +// +// Routes +const ( + TargetsRoot = "/targets" + TargetRoot = TargetsRoot + "/:" + ID +) + +// +// TargetHandler handles Target resource routes. +type TargetHandler struct { + BaseHandler +} + +func (h TargetHandler) AddRoutes(e *gin.Engine) { + routeGroup := e.Group("/") + routeGroup.Use(Required("rulesets"), Transaction) + routeGroup.GET(TargetsRoot, h.List) + routeGroup.GET(TargetsRoot+"/", h.List) + routeGroup.POST(TargetsRoot, h.Create) + routeGroup.GET(TargetRoot, h.Get) + routeGroup.PUT(TargetRoot, h.Update) + routeGroup.DELETE(TargetRoot, h.Delete) +} + +// Get godoc +// @summary Get a Target by ID. +// @description Get a Target by ID. +// @tags targets +// @produce json +// @success 200 {object} Target +// @router /targets/{id} [get] +// @param id path string true "Target ID" +func (h TargetHandler) Get(ctx *gin.Context) { + id := h.pk(ctx) + target := &model.Target{} + db := h.preLoad(h.DB(ctx), clause.Associations) + result := db.First(target, id) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + r := Target{} + r.With(target) + + h.Respond(ctx, http.StatusOK, r) +} + +// List godoc +// @summary List all targets. +// @description List all targets. +// @tags targets +// @produce json +// @success 200 {object} []Target +// @router /targets [get] +func (h TargetHandler) List(ctx *gin.Context) { + var list []model.Target + db := h.preLoad(h.DB(ctx), clause.Associations) + result := db.Find(&list) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + resources := []Target{} + for i := range list { + r := Target{} + r.With(&list[i]) + resources = append(resources, r) + } + + h.Respond(ctx, http.StatusOK, resources) +} + +// Create godoc +// @summary Create a target. +// @description Create a target. +// @tags targets +// @accept json +// @produce json +// @success 201 {object} Target +// @router /targets [post] +// @param target body Target true "Target data" +func (h TargetHandler) Create(ctx *gin.Context) { + target := &Target{} + err := h.Bind(ctx, target) + if err != nil { + return + } + m := target.Model() + m.CreateUser = h.BaseHandler.CurrentUser(ctx) + result := h.DB(ctx).Create(m) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + db := h.preLoad(h.DB(ctx), clause.Associations) + result = db.First(m) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + target.With(m) + + h.Respond(ctx, http.StatusCreated, target) +} + +// Delete godoc +// @summary Delete a target. +// @description Delete a target. +// @tags targets +// @success 204 +// @router /targets/{id} [delete] +// @param id path string true "Target ID" +func (h TargetHandler) Delete(ctx *gin.Context) { + id := h.pk(ctx) + target := &model.Target{} + result := h.DB(ctx).First(target, id) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + if target.Builtin() { + h.Status(ctx, http.StatusForbidden) + return + } + result = h.DB(ctx).Delete(target, id) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + + h.Status(ctx, http.StatusNoContent) +} + +// Update godoc +// @summary Update a target. +// @description Update a target. +// @tags targets +// @accept json +// @success 204 +// @router /targets/{id} [put] +// @param id path string true "Target ID" +// @param target body Target true "Target data" +func (h TargetHandler) Update(ctx *gin.Context) { + id := h.pk(ctx) + r := &Target{} + err := h.Bind(ctx, r) + if err != nil { + _ = ctx.Error(err) + return + } + + m := &model.Target{} + db := h.preLoad(h.DB(ctx), clause.Associations) + result := db.First(m, id) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + if m.Builtin() { + h.Status(ctx, http.StatusForbidden) + return + } + // + // Update target. + m = r.Model() + m.ID = id + m.UpdateUser = h.BaseHandler.CurrentUser(ctx) + db = h.DB(ctx).Model(m) + db = db.Omit(clause.Associations) + result = db.Updates(h.fields(m)) + if result.Error != nil { + _ = ctx.Error(result.Error) + return + } + + h.Status(ctx, http.StatusNoContent) +} + +// +// Target REST resource. +type Target struct { + Resource + Name string `json:"name"` + Description string `json:"description"` + Choice bool `json:"choice,omitempty"` + Custom bool `json:"custom,omitempty"` + Labels []Label `json:"labels"` + Image Ref `json:"image"` + RuleSet *RuleSet `json:"ruleset,omitempty"` +} + +type Label struct { + Name string `json:"name"` + Label string `json:"label"` +} + +// +// With updates the resource with the model. +func (r *Target) With(m *model.Target) { + r.Resource.With(&m.Model) + r.Name = m.Name + r.Description = m.Description + r.Choice = m.Choice + r.Custom = !m.Builtin() + r.RuleSet = &RuleSet{} + r.RuleSet.With(m.RuleSet) + _ = json.Unmarshal(m.Labels, &r.Labels) + imgRef := Ref{ID: m.ImageID} + if m.Image != nil { + imgRef.Name = m.Image.Name + } + r.Image = imgRef +} + +// +// Model builds a model. +func (r *Target) Model() (m *model.Target) { + m = &model.Target{ + Name: r.Name, + Description: r.Description, + Choice: r.Choice, + } + m.ID = r.ID + m.ImageID = r.Image.ID + + return +} diff --git a/go.mod b/go.mod index e3443231b..ad857e5ba 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/konveyor/tackle2-hub go 1.18 +replace github.com/konveyor/tackle2-seed => ../tackle2-seed + require ( github.com/Nerzal/gocloak/v10 v10.0.1 github.com/andygrunwald/go-jira v1.16.0 diff --git a/migration/pkg.go b/migration/pkg.go index 7fce6a633..c8127d672 100644 --- a/migration/pkg.go +++ b/migration/pkg.go @@ -8,6 +8,7 @@ import ( v5 "github.com/konveyor/tackle2-hub/migration/v5" v6 "github.com/konveyor/tackle2-hub/migration/v6" v7 "github.com/konveyor/tackle2-hub/migration/v7" + v8 "github.com/konveyor/tackle2-hub/migration/v8" "github.com/konveyor/tackle2-hub/settings" "gorm.io/gorm" ) @@ -47,5 +48,6 @@ func All() []Migration { v5.Migration{}, v6.Migration{}, v7.Migration{}, + v8.Migration{}, } } diff --git a/migration/v8/migrate.go b/migration/v8/migrate.go new file mode 100644 index 000000000..e7fa1e5c6 --- /dev/null +++ b/migration/v8/migrate.go @@ -0,0 +1,76 @@ +package v8 + +import ( + "encoding/json" + liberr "github.com/jortel/go-utils/error" + "github.com/jortel/go-utils/logr" + v7 "github.com/konveyor/tackle2-hub/migration/v7/model" + "github.com/konveyor/tackle2-hub/migration/v8/model" + "gorm.io/gorm" +) + +var log = logr.WithName("migration|v8") + +type Migration struct{} + +func (r Migration) Apply(db *gorm.DB) (err error) { + result := db.Model(model.Setting{}).Where("key = ?", "ui.ruleset.order").Update("key", "ui.target.order") + if result.Error != nil { + err = liberr.Wrap(err) + return + } + + oldCustomRuleSets := []v7.RuleSet{} + result = db.Find(&oldCustomRuleSets, "uuid IS NULL") + if result.Error != nil { + err = liberr.Wrap(err) + return + } + + err = db.AutoMigrate(r.Models()...) + if err != nil { + err = liberr.Wrap(err) + return + } + + for _, rs := range oldCustomRuleSets { + target := model.Target{ + Name: rs.Name, + Description: rs.Description, + RuleSetID: &rs.ID, + ImageID: rs.ImageID, + } + target.CreateUser = rs.CreateUser + + type TargetLabel struct { + Name string `json:"name"` + Label string `json:"label"` + } + + uniqueLabels := make(map[string]bool) + for _, rule := range rs.Rules { + ruleLabels := []string{} + _ = json.Unmarshal(rule.Labels, &ruleLabels) + for _, label := range ruleLabels { + uniqueLabels[label] = true + } + } + + targetLabels := []TargetLabel{} + for k, _ := range uniqueLabels { + targetLabels = append(targetLabels, TargetLabel{Name: k, Label: k}) + } + target.Labels, _ = json.Marshal(targetLabels) + result = db.Save(target) + if result.Error != nil { + err = liberr.Wrap(result.Error) + return + } + } + + return +} + +func (r Migration) Models() []interface{} { + return model.All() +} diff --git a/migration/v8/model/pkg.go b/migration/v8/model/pkg.go new file mode 100644 index 000000000..269614bed --- /dev/null +++ b/migration/v8/model/pkg.go @@ -0,0 +1,82 @@ +package model + +import "github.com/konveyor/tackle2-hub/migration/v7/model" + +// +// JSON field (data) type. +type JSON = []byte + +type Model = model.Model +type Application = model.Application +type TechDependency = model.TechDependency +type Incident = model.Incident +type Analysis = model.Analysis +type Issue = model.Issue +type Bucket = model.Bucket +type BucketOwner = model.BucketOwner +type BusinessService = model.BusinessService +type Dependency = model.Dependency +type File = model.File +type Fact = model.Fact +type Identity = model.Identity +type Import = model.Import +type ImportSummary = model.ImportSummary +type ImportTag = model.ImportTag +type JobFunction = model.JobFunction +type MigrationWave = model.MigrationWave +type Proxy = model.Proxy +type Review = model.Review +type Setting = model.Setting +type Stakeholder = model.Stakeholder +type StakeholderGroup = model.StakeholderGroup +type Tag = model.Tag +type TagCategory = model.TagCategory +type Task = model.Task +type TaskGroup = model.TaskGroup +type TaskReport = model.TaskReport +type Ticket = model.Ticket +type Tracker = model.Tracker +type TTL = model.TTL +type ApplicationTag = model.ApplicationTag +type DependencyCyclicError = model.DependencyCyclicError + +// +// All builds all models. +// Models are enumerated such that each are listed after +// all the other models on which they may depend. +func All() []interface{} { + return []interface{}{ + Application{}, + TechDependency{}, + Incident{}, + Analysis{}, + Issue{}, + Bucket{}, + BusinessService{}, + Dependency{}, + File{}, + Fact{}, + Identity{}, + Import{}, + ImportSummary{}, + ImportTag{}, + JobFunction{}, + MigrationWave{}, + Proxy{}, + Review{}, + Setting{}, + RuleSet{}, + Rule{}, + Stakeholder{}, + StakeholderGroup{}, + Tag{}, + TagCategory{}, + Target{}, + Task{}, + TaskGroup{}, + TaskReport{}, + Ticket{}, + Tracker{}, + ApplicationTag{}, + } +} diff --git a/migration/v8/model/ruleset.go b/migration/v8/model/ruleset.go new file mode 100644 index 000000000..a07070f60 --- /dev/null +++ b/migration/v8/model/ruleset.go @@ -0,0 +1,68 @@ +package model + +import "gorm.io/gorm" + +// +// RuleSet - Analysis ruleset. +type RuleSet struct { + Model + UUID *string `gorm:"uniqueIndex"` + Kind string + Name string `gorm:"uniqueIndex;not null"` + Repository JSON `gorm:"type:json"` + IdentityID *uint `gorm:"index"` + Identity *Identity + Rules []Rule `gorm:"constraint:OnDelete:CASCADE"` + DependsOn []RuleSet `gorm:"many2many:RuleSetDependencies;constraint:OnDelete:CASCADE"` +} + +func (r *RuleSet) Builtin() bool { + return r.UUID != nil +} + +// +// BeforeUpdate hook to avoid cyclic dependencies. +func (r *RuleSet) BeforeUpdate(db *gorm.DB) (err error) { + seen := make(map[uint]bool) + var nextDeps []RuleSet + var nextRuleSetIDs []uint + for _, dep := range r.DependsOn { + nextRuleSetIDs = append(nextRuleSetIDs, dep.ID) + } + for len(nextRuleSetIDs) != 0 { + result := db.Preload("DependsOn").Where("ID IN ?", nextRuleSetIDs).Find(&nextDeps) + if result.Error != nil { + err = result.Error + return + } + nextRuleSetIDs = nextRuleSetIDs[:0] + for _, nextDep := range nextDeps { + for _, dep := range nextDep.DependsOn { + if seen[dep.ID] { + continue + } + if dep.ID == r.ID { + err = DependencyCyclicError{} + return + } + seen[dep.ID] = true + nextRuleSetIDs = append(nextRuleSetIDs, dep.ID) + } + } + } + + return +} + +// +// Rule - Analysis rule. +type Rule struct { + Model + Name string + Description string + Labels JSON `gorm:"type:json"` + RuleSetID uint `gorm:"uniqueIndex:RuleA;not null"` + RuleSet *RuleSet + FileID *uint `gorm:"uniqueIndex:RuleA" ref:"file"` + File *File +} diff --git a/migration/v8/model/target.go b/migration/v8/model/target.go new file mode 100644 index 000000000..85e941cb1 --- /dev/null +++ b/migration/v8/model/target.go @@ -0,0 +1,20 @@ +package model + +// +// Target - analysis rule selector. +type Target struct { + Model + UUID *string `gorm:"uniqueIndex"` + Name string `gorm:"uniqueIndex;not null"` + Description string + Choice bool + Labels JSON + ImageID uint `gorm:"index" ref:"file"` + Image *File + RuleSetID *uint `gorm:"index"` + RuleSet *RuleSet +} + +func (r *Target) Builtin() bool { + return r.UUID != nil +} diff --git a/model/pkg.go b/model/pkg.go index 2cebf736e..6b534753b 100644 --- a/model/pkg.go +++ b/model/pkg.go @@ -1,7 +1,7 @@ package model import ( - "github.com/konveyor/tackle2-hub/migration/v7/model" + "github.com/konveyor/tackle2-hub/migration/v8/model" "gorm.io/datatypes" ) @@ -38,6 +38,7 @@ type Stakeholder = model.Stakeholder type StakeholderGroup = model.StakeholderGroup type Tag = model.Tag type TagCategory = model.TagCategory +type Target = model.Target type Task = model.Task type TaskGroup = model.TaskGroup type TaskReport = model.TaskReport diff --git a/seed/ruleset.go b/seed/ruleset.go index 6b431d99d..4376960c2 100644 --- a/seed/ruleset.go +++ b/seed/ruleset.go @@ -79,16 +79,8 @@ func (r *RuleSet) Apply(db *gorm.DB) (err error) { ruleSet = &model.RuleSet{} } } - - file, fErr := r.file(db, rs.Image()) - if fErr != nil { - err = fErr - return - } ruleSet.Name = rs.Name - ruleSet.Description = rs.Description ruleSet.UUID = &rs.UUID - ruleSet.ImageID = file.ID result := db.Save(ruleSet) if result.Error != nil { err = liberr.Wrap(result.Error) @@ -140,7 +132,7 @@ func (r *RuleSet) applyRules(db *gorm.DB, ruleSet *model.RuleSet, rs libseed.Rul } for _, rl := range rs.Rules { labels, _ := json.Marshal(rl.Labels()) - file, fErr := r.file(db, rl.Path) + f, fErr := file(db, rl.Path) if fErr != nil { err = liberr.Wrap(fErr) return @@ -148,7 +140,7 @@ func (r *RuleSet) applyRules(db *gorm.DB, ruleSet *model.RuleSet, rs libseed.Rul rule := model.Rule{ Labels: labels, RuleSetID: ruleSet.ID, - FileID: &file.ID, + FileID: &f.ID, } result = db.Save(&rule) if result.Error != nil { @@ -161,7 +153,7 @@ func (r *RuleSet) applyRules(db *gorm.DB, ruleSet *model.RuleSet, rs libseed.Rul // // Create a File model and copy a real file to its path. -func (r *RuleSet) file(db *gorm.DB, filePath string) (file *model.File, err error) { +func file(db *gorm.DB, filePath string) (file *model.File, err error) { file = &model.File{ Name: path.Base(filePath), } diff --git a/seed/seed.go b/seed/seed.go index 83fde439d..b600b9477 100644 --- a/seed/seed.go +++ b/seed/seed.go @@ -17,6 +17,7 @@ type Hub struct { TagCategory JobFunction RuleSet + Target } // @@ -29,6 +30,8 @@ func (r *Hub) With(seed libseed.Seed) (err error) { err = r.JobFunction.With(seed) case libseed.KindRuleSet: err = r.RuleSet.With(seed) + case libseed.KindTarget: + err = r.Target.With(seed) default: err = liberr.New("unknown kind", "kind", seed.Kind, "file", seed.Filename()) } @@ -50,6 +53,10 @@ func (r *Hub) Apply(db *gorm.DB) (err error) { if err != nil { return } + err = r.Target.Apply(db) + if err != nil { + return + } return } diff --git a/seed/target.go b/seed/target.go new file mode 100644 index 000000000..5651a9f24 --- /dev/null +++ b/seed/target.go @@ -0,0 +1,147 @@ +package seed + +import ( + "encoding/json" + "errors" + "fmt" + liberr "github.com/jortel/go-utils/error" + "github.com/konveyor/tackle2-hub/model" + libseed "github.com/konveyor/tackle2-seed/pkg" + "gorm.io/gorm" +) + +// +// Target applies Target seeds. +type Target struct { + targets []libseed.Target +} + +// +// With collects all the Target seeds. +func (r *Target) With(seed libseed.Seed) (err error) { + items, err := seed.DecodeItems() + if err != nil { + return + } + for _, item := range items { + r.targets = append(r.targets, item.(libseed.Target)) + } + return +} + +// +// Apply seeds the database with JobFunctions. +func (r *Target) Apply(db *gorm.DB) (err error) { + log.Info("Applying Targets", "count", len(r.targets)) + + ids := []uint{} + for i := range r.targets { + t := r.targets[i] + target, found, fErr := r.find(db, "uuid = ?", t.UUID) + if fErr != nil { + err = fErr + return + } + // model exists and is being renamed + if found && target.Name != t.Name { + // ensure that the target name is clear + collision, collides, fErr := r.find(db, "name = ? and id != ?", t.Name, target.ID) + if fErr != nil { + err = fErr + return + } + if collides { + err = r.rename(db, collision) + if err != nil { + return + } + } + } else { + target, found, fErr = r.find(db, "name = ?", t.Name) + if fErr != nil { + err = fErr + return + } + if found && target.CreateUser != "" { + err = r.rename(db, target) + if err != nil { + return + } + found = false + } + if !found { + target = &model.Target{} + } + } + + f, fErr := file(db, t.Image()) + if fErr != nil { + err = liberr.Wrap(fErr) + return + } + labels, _ := json.Marshal(t.Labels) + + target.UUID = &t.UUID + target.Name = t.Name + target.Description = t.Description + target.Choice = t.Choice + target.ImageID = f.ID + target.Labels = labels + result := db.Save(&target) + if result.Error != nil { + err = liberr.Wrap(result.Error) + return + } + ids = append(ids, target.ID) + } + + value, _ := json.Marshal(ids) + uiOrder := model.Setting{Key: "ui.target.order", Value: value} + result := db.Where("key", "ui.target.order").Updates(uiOrder) + if result.Error != nil { + err = liberr.Wrap(err) + return + } + + return +} + +// +// Convenience method to find a Target. +func (r *Target) find(db *gorm.DB, conditions ...interface{}) (t *model.Target, found bool, err error) { + t = &model.Target{} + result := db.First(t, conditions...) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return + } + err = liberr.Wrap(result.Error) + return + } + found = true + return +} + +// +// Rename a Target by adding a suffix. +func (r *Target) rename(db *gorm.DB, t *model.Target) (err error) { + suffix := 0 + for { + suffix++ + newName := fmt.Sprintf("%s (%d)", t.Name, suffix) + _, found, fErr := r.find(db, "name = ?", newName) + if fErr != nil { + err = fErr + return + } + if !found { + t.Name = newName + result := db.Save(t) + if result.Error != nil { + err = liberr.Wrap(result.Error) + return + } + return + } + } +}