diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7f3cfce..c766532 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,13 +8,22 @@ on: env: GO_VERSION: '1.24' + GO_LINT: 'v2.1.6' permissions: contents: read jobs: + check_linter_version: + name: check golangci-lint version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check golangci-lint version + run: make lint-check-version check: runs-on: ubuntu-latest + needs: check_linter_version steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -23,7 +32,7 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v7 with: - version: v2.1.5 + version: ${{ env.GO_LINT }} test: name: go-test diff --git a/Makefile b/Makefile index 0cc8d0a..31fc5ea 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,7 @@ default: test SHELL=/bin/bash SD_DB?="postgresql://pg:pass@localhost:5432/status_dashboard?sslmode=disable" +GOLANGCI_LINT_VERSION?="2.1.6" test: @echo running unit tests @@ -17,9 +18,15 @@ build: go build -o app cmd/main.go lint: + @echo check linter version + if [[ $$(golangci-lint --version |awk '{print $$4}') == $(GOLANGCI_LINT_VERSION) ]]; then echo "current installed version is actual to $(GOLANGCI_LINT_VERSION)"; else echo "current version is not actual, please use $(GOLANGCI_LINT_VERSION)"; exit 1; fi @echo running linter golangci-lint run -v +lint-check-version: + @echo check linter version + @if [[ $(GO_LINT) == v$(GOLANGCI_LINT_VERSION) ]]; then echo "current installed version is actual to $(GOLANGCI_LINT_VERSION)"; else echo "current version $(GO_LINT) is not actual, please use $(GOLANGCI_LINT_VERSION)"; exit 1; fi + migrate-up: @echo staring migrations migrate -database $(SD_DB) -path db/migrations up diff --git a/internal/api/common/common.go b/internal/api/common/common.go index 6fc6fe5..b4cb6df 100644 --- a/internal/api/common/common.go +++ b/internal/api/common/common.go @@ -15,7 +15,8 @@ func MoveIncidentToHigherImpact( if incWithHighImpact == nil { if len(incident.Components) > 1 { log.Info("no active incidents with requested impact, opening the new one") - return dbInst.ExtractComponentToNewIncident(storedComponent, incident, impact, text) + components := []db.Component{*storedComponent} + return dbInst.ExtractComponentsToNewIncident(components, incident, impact, text) } log.Info( "only one component in the incident, increase impact", diff --git a/internal/api/middleware.go b/internal/api/middleware.go index eeba6f7..1099e52 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -20,7 +20,7 @@ func ValidateComponentsMW(dbInst *db.DB, logger *zap.Logger) gin.HandlerFunc { return func(c *gin.Context) { logger.Info("start to validate given components") type Components struct { - Components []int `json:"components"` + Components []int `json:"components" binding:"required,min=1"` } var components Components diff --git a/internal/api/routes.go b/internal/api/routes.go index 425e094..2fae3e8 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -45,11 +45,12 @@ func (a *API) InitRoutes() { ) v2API.GET("incidents/:id", v2.GetIncidentHandler(a.db, a.log)) v2API.PATCH("incidents/:id", AuthenticationMW(a.oa2Prov, a.log), v2.PatchIncidentHandler(a.db, a.log)) + v2API.POST("incidents/:id/extract", + AuthenticationMW(a.oa2Prov, a.log), + ValidateComponentsMW(a.db, a.log), + v2.PostIncidentExtractHandler(a.db, a.log)) v2API.GET("availability", v2.GetComponentsAvailabilityHandler(a.db, a.log)) - - //nolint:gocritic - //v2API.GET("/separate//") - > investigate it!!! } rssFEED := a.r.Group("rss") diff --git a/internal/api/v2/statuses.go b/internal/api/v2/statuses.go index 0dd6866..da3624a 100644 --- a/internal/api/v2/statuses.go +++ b/internal/api/v2/statuses.go @@ -16,6 +16,7 @@ var maintenanceStatuses = map[string]struct{}{ // Incident actions for opened incidents. const ( + IncidentDetected = "detected" // not implemented yet IncidentAnalysing = "analysing" IncidentFixing = "fixing" IncidentImpactChanged = "impact changed" @@ -25,6 +26,7 @@ const ( //nolint:gochecknoglobals var incidentOpenStatuses = map[string]struct{}{ + IncidentDetected: {}, IncidentAnalysing: {}, IncidentFixing: {}, IncidentImpactChanged: {}, diff --git a/internal/api/v2/v2.go b/internal/api/v2/v2.go index 17f838d..27689df 100644 --- a/internal/api/v2/v2.go +++ b/internal/api/v2/v2.go @@ -440,6 +440,71 @@ func updateFields(income *PatchIncidentData, stored *db.Incident) { } } +type PostIncidentSeparateData struct { + Components []int `json:"components" binding:"required,min=1"` +} + +func PostIncidentExtractHandler(dbInst *db.DB, logger *zap.Logger) gin.HandlerFunc { //nolint:gocognit + return func(c *gin.Context) { + logger.Debug("start to extract components to the new incident") + + var incID IncidentID + if err := c.ShouldBindUri(&incID); err != nil { + apiErrors.RaiseBadRequestErr(c, err) + return + } + + var incData PostIncidentSeparateData + if err := c.ShouldBindBodyWithJSON(&incData); err != nil { + apiErrors.RaiseBadRequestErr(c, err) + return + } + + logger.Debug( + "extract components from the incident", + zap.Any("components", incData.Components), + zap.Int("incident_id", incID.ID), + ) + + storedInc, err := dbInst.GetIncident(incID.ID) + if err != nil { + apiErrors.RaiseInternalErr(c, err) + return + } + + var movedComponents []db.Component + var movedCounter int + for _, incCompID := range incData.Components { + present := false + for _, storedComp := range storedInc.Components { + if incCompID == int(storedComp.ID) { + present = true + movedComponents = append(movedComponents, storedComp) + movedCounter++ + break + } + } + if !present { + apiErrors.RaiseBadRequestErr(c, fmt.Errorf("component %d is not in the incident", incCompID)) + return + } + } + + if movedCounter == len(storedInc.Components) { + apiErrors.RaiseBadRequestErr(c, fmt.Errorf("can not move all components to the new incident, keep at least one")) + return + } + + inc, err := dbInst.ExtractComponentsToNewIncident(movedComponents, storedInc, *storedInc.Impact, *storedInc.Text) + if err != nil { + apiErrors.RaiseInternalErr(c, err) + return + } + + c.JSON(http.StatusOK, toAPIIncident(inc)) + } +} + type Component struct { ComponentID Attributes []ComponentAttribute `json:"attributes"` diff --git a/internal/db/db.go b/internal/db/db.go index 32c954b..35dbc49 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -76,8 +76,9 @@ func (db *DB) GetIncident(id int) (*Incident, error) { Where(inc). Preload("Statuses"). Preload("Components", func(db *gorm.DB) *gorm.DB { - return db.Select("ID") + return db.Select("ID, Name") }). + Preload("Components.Attrs"). First(&inc) if r.Error != nil { @@ -378,9 +379,13 @@ func (db *DB) MoveComponentFromOldToAnotherIncident( return incNew, nil } -func (db *DB) ExtractComponentToNewIncident( - comp *Component, incOld *Incident, impact int, text string, +func (db *DB) ExtractComponentsToNewIncident( + comp []Component, incOld *Incident, impact int, text string, ) (*Incident, error) { + if len(comp) == 0 { + return nil, fmt.Errorf("no components to extract") + } + timeNow := time.Now().UTC() inc := &Incident{ @@ -390,7 +395,7 @@ func (db *DB) ExtractComponentToNewIncident( Impact: &impact, Statuses: []IncidentStatus{}, System: false, - Components: []Component{*comp}, + Components: comp, } id, err := db.SaveIncident(inc) @@ -398,31 +403,35 @@ func (db *DB) ExtractComponentToNewIncident( return nil, err } - incText := fmt.Sprintf("%s moved from %s", comp.PrintAttrs(), incOld.Link()) - inc.Statuses = append(inc.Statuses, IncidentStatus{ - IncidentID: id, - Status: statusSYSTEM, - Text: incText, - Timestamp: timeNow, - }) + for _, c := range comp { + incText := fmt.Sprintf("%s moved from %s", c.PrintAttrs(), incOld.Link()) + inc.Statuses = append(inc.Statuses, IncidentStatus{ + IncidentID: id, + Status: statusSYSTEM, + Text: incText, + Timestamp: timeNow, + }) + } err = db.ModifyIncident(inc) if err != nil { return nil, err } - err = db.g.Model(incOld).Association("Components").Delete(comp) - if err != nil { - return nil, err - } + for _, c := range comp { + err = db.g.Model(incOld).Association("Components").Delete(c) + if err != nil { + return nil, err + } - incText = fmt.Sprintf("%s moved to %s", comp.PrintAttrs(), inc.Link()) - incOld.Statuses = append(incOld.Statuses, IncidentStatus{ - IncidentID: inc.ID, - Status: statusSYSTEM, - Text: incText, - Timestamp: timeNow, - }) + incText := fmt.Sprintf("%s moved to %s", c.PrintAttrs(), inc.Link()) + incOld.Statuses = append(incOld.Statuses, IncidentStatus{ + IncidentID: inc.ID, + Status: statusSYSTEM, + Text: incText, + Timestamp: timeNow, + }) + } err = db.ModifyIncident(incOld) if err != nil { diff --git a/openapi.yaml b/openapi.yaml index 759c974..5f4b184 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -313,6 +313,36 @@ paths: description: Invalid ID supplied '404': description: Incident not found. + /v2/incidents/{incident_id}/extract: + post: + summary: extract components to the new incident + tags: + - incidents + parameters: + - name: incident_id + in: path + description: ID of incident to return + required: true + schema: + type: integer + format: int64 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/IncidentPostExtract' + required: true + responses: + '200': + description: successful operation, return the new incident id + content: + application/json: + schema: + $ref: '#/components/schemas/Incident' + '400': + description: Invalid ID supplied + '404': + description: Incident not found. /v1/component_status: get: @@ -597,6 +627,16 @@ components: end_date: type: string format: date-time + IncidentPostExtract: + type: object + required: + - components + properties: + components: + type: array + items: + type: string + example: [ 218, 254 ] IncidentStatus: type: object allOf: diff --git a/tests/main_test.go b/tests/main_test.go index 0cecc71..ab4afd9 100644 --- a/tests/main_test.go +++ b/tests/main_test.go @@ -136,5 +136,7 @@ func initRoutesV2(t *testing.T, c *gin.Engine, dbInst *db.DB, logger *zap.Logger v2Api.POST("incidents", api.ValidateComponentsMW(dbInst, logger), v2.PostIncidentHandler(dbInst, logger)) v2Api.GET("incidents/:id", v2.GetIncidentHandler(dbInst, logger)) v2Api.PATCH("incidents/:id", v2.PatchIncidentHandler(dbInst, logger)) + v2Api.POST("incidents/:id/extract", v2.PostIncidentExtractHandler(dbInst, logger)) + v2Api.GET("availability", v2.GetComponentsAvailabilityHandler(dbInst, logger)) } diff --git a/tests/v2_test.go b/tests/v2_test.go index 2c25d6c..767d972 100644 --- a/tests/v2_test.go +++ b/tests/v2_test.go @@ -460,6 +460,72 @@ func TestV2PatchIncidentHandler(t *testing.T) { assert.NotNil(t, inc.EndDate) } +func TestV2PostIncidentExtractHandler(t *testing.T) { + t.Log("start to test incident creation for /v2/incidents") + r, _, _ := initTests(t) + + t.Log("create an incident") + + components := []int{1, 2} + impact := 1 + title := "Test incident for dcs" + startDate := time.Now().AddDate(0, 0, -1).UTC() + system := false + + incidentCreateData := v2.IncidentData{ + Title: title, + Impact: &impact, + Components: components, + StartDate: startDate, + System: &system, + } + + incidents := v2GetIncidents(t, r) + for _, inc := range incidents { + if inc.EndDate == nil { + endDate := inc.StartDate.Add(time.Hour * 1) + inc.EndDate = &endDate + v2PatchIncident(t, r, inc) + } + } + + result := v2CreateIncident(t, r, &incidentCreateData) + + t.Logf("create an incident with components: %v", components) + type IncidentData struct { + Components []int `json:"components"` + } + movedComponents := IncidentData{Components: []int{2}} + data, err := json.Marshal(movedComponents) + require.NoError(t, err) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, v2Incidents+fmt.Sprintf("/%d/extract", result.Result[0].IncidentID), bytes.NewReader(data)) + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + newInc := &v2.Incident{} + err = json.Unmarshal(w.Body.Bytes(), newInc) + require.NoError(t, err) + assert.Len(t, newInc.Components, 1) + assert.Equal(t, incidentCreateData.Impact, newInc.Impact) + assert.Equal(t, fmt.Sprintf("Cloud Container Engine (Container, EU-NL, cce) moved from Test incident for dcs", result.Result[0].IncidentID), newInc.Updates[0].Text) + + createdInc := v2GetIncident(t, r, result.Result[0].IncidentID) + assert.Equal(t, fmt.Sprintf("Cloud Container Engine (Container, EU-NL, cce) moved to Test incident for dcs", newInc.ID), createdInc.Updates[0].Text) + + // start negative case + movedComponents = IncidentData{Components: []int{1}} + data, err = json.Marshal(movedComponents) + require.NoError(t, err) + + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodPost, v2Incidents+fmt.Sprintf("/%d/extract", result.Result[0].IncidentID), bytes.NewReader(data)) + r.ServeHTTP(w, req) + require.Equal(t, http.StatusBadRequest, w.Code) + assert.JSONEq(t, `{"errMsg":"can not move all components to the new incident, keep at least one"}`, w.Body.String()) +} + func v2CreateIncident(t *testing.T, r *gin.Engine, inc *v2.IncidentData) *v2.PostIncidentResp { t.Helper()