From f341ffdb0c990414e349a3596221dbdd8d4668e9 Mon Sep 17 00:00:00 2001 From: Hugo Bollon Date: Fri, 25 Jun 2021 15:00:18 +0200 Subject: [PATCH] feat(api): add plan submit/get endpoints (#175) * feat(api): add plan submit endpoint * feat(api): add get plan endpoint + models tweaks * fix: missing omitempty tag on PlanStateResource.SchemaVersion * fix: replace PlanOutputChange by generic Change (adapt json unmarshalling) * feat(api): allow new lineage insertion if not exists on plan submit --- api/api.go | 53 +++++++++++++++++++++++++++++++++++++++++ db/db.go | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++- main.go | 1 + types/db.go | 13 ++++------ types/json.go | 9 ++++--- 5 files changed, 129 insertions(+), 13 deletions(-) diff --git a/api/api.go b/api/api.go index 8af3e42e..650222ce 100644 --- a/api/api.go +++ b/api/api.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "io/ioutil" "net/http" "github.com/camptocamp/terraboard/auth" @@ -278,3 +279,55 @@ func GetUser(w http.ResponseWriter, r *http.Request) { log.Error(err.Error()) } } + +// SubmitPlan inserts a new Terraform plan in the database. +// /api/plans POST endpoint callback +func SubmitPlan(w http.ResponseWriter, r *http.Request, db *db.Database) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + log.Errorf("Failed to read body: %v", err) + JSONError(w, "Failed to read body during plan submit", err) + return + } + + if err = db.InsertPlan(body); err != nil { + log.Errorf("Failed to insert plan to db: %v", err) + JSONError(w, "Failed to insert plan to db", err) + return + } +} + +// GetPlans provides all Plan by lineage. +// Optional "&limit=X" parameter to limit requested quantity of plans. +// Sorted by most recent to oldest. +// /api/plans GET endpoint callback +func GetPlans(w http.ResponseWriter, r *http.Request, db *db.Database) { + w.Header().Set("Access-Control-Allow-Origin", "*") + lineage := r.URL.Query().Get("lineage") + limit := r.URL.Query().Get("limit") + plans := db.GetPlans(lineage, limit) + + j, err := json.Marshal(plans) + if err != nil { + log.Errorf("Failed to marshal plans: %v", err) + JSONError(w, "Failed to marshal plans", err) + return + } + if _, err := io.WriteString(w, string(j)); err != nil { + log.Error(err.Error()) + } +} + +// ManagePlans is used to route the request to the appropriated handler function +// on /api/plans request +func ManagePlans(w http.ResponseWriter, r *http.Request, db *db.Database) { + if r.Method == "GET" { + GetPlans(w, r, db) + } else if r.Method == "POST" { + SubmitPlan(w, r, db) + } else { + http.Error(w, "Invalid request method.", 405) + } +} diff --git a/db/db.go b/db/db.go index 0c2f86f3..0e6a8e2a 100644 --- a/db/db.go +++ b/db/db.go @@ -61,7 +61,6 @@ func Init(config config.DBConfig, debug bool) *Database { &types.PlanModel{}, &types.PlanModelVariable{}, &types.PlanOutput{}, - &types.PlanOutputChange{}, &types.PlanResourceChange{}, &types.PlanState{}, &types.PlanStateModule{}, @@ -566,6 +565,71 @@ func (db *Database) ListAttributeKeys(resourceType string) (results []string, er return } +// InsertPlan inserts a Terraform plan with associated information in the Database +func (db *Database) InsertPlan(plan []byte) error { + var lineage types.Lineage + if err := json.Unmarshal(plan, &lineage); err != nil { + return err + } + + // Recover lineage from db if it's already exists or insert it + res := db.FirstOrCreate(&lineage, lineage) + if res.Error != nil { + return fmt.Errorf("Error on lineage retrival during plan insertion: %v", res.Error) + } + + var p types.Plan + if err := json.Unmarshal(plan, &p); err != nil { + return err + } + if err := json.Unmarshal(p.PlanJSON, &p.ParsedPlan); err != nil { + return err + } + + p.LineageID = lineage.ID + return db.Create(&p).Error +} + +// GetPlans retrieves all Plan of a lineage from the database +func (db *Database) GetPlans(lineage, limitStr string) (plans []types.Plan) { + var limit int + if limitStr == "" { + limit = -1 + } else { + var err error + limit, err = strconv.Atoi(limitStr) + if err != nil { + log.Warnf("GetPlans limit ignored: %v", err) + limit = -1 + } + } + + db.Joins("JOIN lineages on plans.lineage_id=lineages.id"). + Preload("ParsedPlan"). + Preload("ParsedPlan.PlanStateValue"). + Preload("ParsedPlan.PlanStateValue.PlanStateOutputs"). + Preload("ParsedPlan.PlanStateValue.PlanStateModule"). + Preload("ParsedPlan.PlanStateValue.PlanStateModule.PlanStateResources"). + Preload("ParsedPlan.PlanStateValue.PlanStateModule.PlanStateResources.PlanStateResourceAttributes"). + Preload("ParsedPlan.PlanStateValue.PlanStateModule.PlanStateModules"). + Preload("ParsedPlan.Variables"). + Preload("ParsedPlan.PlanResourceChanges"). + Preload("ParsedPlan.PlanResourceChanges.Change"). + Preload("ParsedPlan.PlanOutputs"). + Preload("ParsedPlan.PlanOutputs.Change"). + Preload("ParsedPlan.PlanState"). + Preload("ParsedPlan.PlanState.PlanStateValue"). + Preload("ParsedPlan.PlanState.PlanStateValue.PlanStateOutputs"). + Preload("ParsedPlan.PlanState.PlanStateValue.PlanStateModule"). + Preload("ParsedPlan.PlanState.PlanStateValue.PlanStateModule.PlanStateResources"). + Preload("ParsedPlan.PlanState.PlanStateValue.PlanStateModule.PlanStateResources.PlanStateResourceAttributes"). + Preload("ParsedPlan.PlanState.PlanStateValue.PlanStateModule.PlanStateModules"). + Order("created_at desc"). + Limit(limit). + Find(&plans, "lineages.value = ?", lineage) + return +} + // DefaultVersion returns the detault VersionID for a given State path // Copied and adapted from github.com/hashicorp/terraform/command/jsonstate/state.go func (db *Database) DefaultVersion(path string) (version string, err error) { diff --git a/main.go b/main.go index 6019093d..1d10406b 100644 --- a/main.go +++ b/main.go @@ -196,6 +196,7 @@ func main() { http.HandleFunc(util.GetFullPath("api/resource/names"), handleWithDB(api.ListResourceNames, database)) http.HandleFunc(util.GetFullPath("api/attribute/keys"), handleWithDB(api.ListAttributeKeys, database)) http.HandleFunc(util.GetFullPath("api/tf_versions"), handleWithDB(api.ListTfVersions, database)) + http.HandleFunc(util.GetFullPath("api/plans"), handleWithDB(api.ManagePlans, database)) // Start server log.Debugf("Listening on port %d\n", c.Web.Port) diff --git a/types/db.go b/types/db.go index 397c785e..3235f438 100644 --- a/types/db.go +++ b/types/db.go @@ -80,6 +80,7 @@ type Attribute struct { type Plan struct { gorm.Model LineageID uint `gorm:"index" json:"-"` + Lineage Lineage `json:"lineage_data"` TFVersion string `gorm:"varchar(10)" json:"terraform_version"` GitRemote string `json:"git_remote"` GitCommit string `gorm:"varchar(50)" json:"git_commit"` @@ -214,7 +215,7 @@ type PlanStateResource struct { // The version of the resource type schema the "values" property // conforms to. - SchemaVersion uint `json:"schema_version,"` + SchemaVersion uint `json:"schema_version,omitempty"` // The JSON representation of the attribute values of the resource, // whose structure depends on the resource type schema. Any unknown @@ -289,14 +290,8 @@ type PlanResourceChange struct { type PlanOutput struct { gorm.Model - Name string `gorm:"index" json:"key"` - PlanModelID sql.NullInt64 `gorm:"index" json:"-"` - PlanOutputChange PlanOutputChange `json:"value"` - PlanOutputChangeID sql.NullInt64 `gorm:"index" json:"-"` -} - -type PlanOutputChange struct { - gorm.Model + Name string `gorm:"index" json:"name"` + PlanModelID sql.NullInt64 `gorm:"index" json:"-"` // The data describing the change that will be made to this object. Change Change `json:"change,omitempty"` ChangeID sql.NullInt64 `gorm:"index" json:"-"` diff --git a/types/json.go b/types/json.go index 86b165d4..28f5f594 100644 --- a/types/json.go +++ b/types/json.go @@ -16,7 +16,7 @@ type planStateResourceAttributeList []PlanStateResourceAttribute type rawJSON string func (p *planOutputList) UnmarshalJSON(b []byte) error { - tmp := map[string]PlanOutput{} + tmp := map[string]Change{} err := json.Unmarshal(b, &tmp) if err != nil { return err @@ -24,8 +24,11 @@ func (p *planOutputList) UnmarshalJSON(b []byte) error { var list planOutputList for key, value := range tmp { - value.Name = key - list = append(list, value) + output := PlanOutput{ + Name: key, + Change: value, + } + list = append(list, output) } *p = list