From 889c6a149ecfe34f49e2f090836f39f6e4e05aba Mon Sep 17 00:00:00 2001 From: Ilia Bakhterev <39965096+bakhterets@users.noreply.github.com> Date: Mon, 9 Dec 2024 09:18:59 +0100 Subject: [PATCH] Implement Components Availability API Endpoint (#22) --- internal/api/routes.go | 3 +- internal/api/v1/v1_test.go | 2 +- internal/api/v2/v2.go | 205 ++++++++++++++++++++++++++++++++++++ internal/api/v2/v2_test.go | 208 ++++++++++++++++++++++++++++++++----- internal/db/db.go | 1 + openapi.yaml | 54 ++++++++++ 6 files changed, 446 insertions(+), 27 deletions(-) diff --git a/internal/api/routes.go b/internal/api/routes.go index 994c134..5db69a4 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -30,11 +30,10 @@ func (a *API) InitRoutes() { v2Api.POST("incidents", a.ValidateComponentsMW(), v2.PostIncidentHandler(a.db, a.log)) v2Api.GET("incidents/:id", v2.GetIncidentHandler(a.db, a.log)) v2Api.PATCH("incidents/:id", a.ValidateComponentsMW(), v2.PatchIncidentHandler(a.db, a.log)) - + v2Api.GET("availability", v2.GetComponentsAvailabilityHandler(a.db, a.log)) //nolint:gocritic //v2Api.GET("rss") //v2Api.GET("history") - //v2Api.GET("availability") //v2Api.GET("/separate//") - > investigate it!!! // //v2Api.GET("/login/:name") diff --git a/internal/api/v1/v1_test.go b/internal/api/v1/v1_test.go index becb8f8..c96f1f5 100644 --- a/internal/api/v1/v1_test.go +++ b/internal/api/v1/v1_test.go @@ -22,7 +22,7 @@ func TestCustomTimeFormat(t *testing.T) { data, err := json.Marshal(inc) require.NoError(t, err) - assert.Equal(t, "{\"id\":0,\"text\":\"\",\"impact\":null,\"start_date\":\"2024-09-01 11:45\",\"end_date\":null,\"updates\":null}", string(data)) + assert.JSONEq(t, "{\"id\":0,\"text\":\"\",\"impact\":null,\"start_date\":\"2024-09-01 11:45\",\"end_date\":null,\"updates\":null}", string(data)) inc = &Incident{} err = json.Unmarshal(data, &inc) diff --git a/internal/api/v2/v2.go b/internal/api/v2/v2.go index 1869f12..bcf43c7 100644 --- a/internal/api/v2/v2.go +++ b/internal/api/v2/v2.go @@ -2,7 +2,9 @@ package v2 import ( "errors" + "fmt" "net/http" + "sort" "time" "github.com/gin-gonic/gin" @@ -245,6 +247,13 @@ type Component struct { Name string `json:"name"` } +type ComponentAvailability struct { + ComponentID + Name string `json:"name"` + Availability []MonthlyAvailability `json:"availability"` + Region string `json:"region"` +} + type ComponentID struct { ID int `json:"id" uri:"id" binding:"required,gte=0"` } @@ -266,6 +275,12 @@ var availableAttrs = map[string]struct{}{ //nolint:gochecknoglobals "category": {}, } +type MonthlyAvailability struct { + Year int `json:"year"` + Month int `json:"month"` // Number of the month (1 - 12) + Percentage float64 `json:"percentage"` // Percent (0 - 100 / example: 95.23478) +} + func GetComponentsHandler(dbInst *db.DB, logger *zap.Logger) gin.HandlerFunc { return func(c *gin.Context) { logger.Debug("retrieve components") @@ -370,3 +385,193 @@ func checkComponentAttrs(attrs []ComponentAttribute) error { return nil } + +func GetComponentsAvailabilityHandler(dbInst *db.DB, logger *zap.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + logger.Debug("retrieve availability of components") + + components, err := dbInst.GetComponentsWithIncidents() + if err != nil { + apiErrors.RaiseInternalErr(c, err) + return + } + + availability := make([]*ComponentAvailability, len(components)) + for index, comp := range components { + attrs := make([]ComponentAttribute, len(comp.Attrs)) + for i, attr := range comp.Attrs { + attrs[i] = ComponentAttribute{ + Name: attr.Name, + Value: attr.Value, + } + } + regionValue := "" + for _, attr := range attrs { + if attr.Name == "region" { + regionValue = attr.Value + break + } + } + + incidents := make([]*Incident, len(comp.Incidents)) + for i, inc := range comp.Incidents { + newInc := &Incident{ + IncidentID: IncidentID{int(inc.ID)}, + IncidentData: IncidentData{ + Title: *inc.Text, + Impact: inc.Impact, + StartDate: *inc.StartDate, + EndDate: inc.EndDate, + Updates: nil, + }, + } + incidents[i] = newInc + } + + compAvailability, calcErr := calculateAvailability(&comp) + if calcErr != nil { + apiErrors.RaiseInternalErr(c, calcErr) + return + } + + sortComponentAvailability(compAvailability) + // sort.Slice(compAvailability, func(i, j int) bool { + // if compAvailability[i].Year == compAvailability[j].Year { + // return compAvailability[i].Month > compAvailability[j].Month + // } + // return compAvailability[i].Year > compAvailability[j].Year + // }) + + availability[index] = &ComponentAvailability{ + ComponentID: ComponentID{int(comp.ID)}, + Region: regionValue, + Name: comp.Name, + Availability: compAvailability, + } + } + + c.JSON(http.StatusOK, gin.H{"data": availability}) + } +} + +func sortComponentAvailability(availabilities []MonthlyAvailability) { + sort.Slice(availabilities, func(i, j int) bool { + if availabilities[i].Year == availabilities[j].Year { + return availabilities[i].Month > availabilities[j].Month + } + return availabilities[i].Year > availabilities[j].Year + }) +} + +// TODO: add filters for GET request +func calculateAvailability(component *db.Component) ([]MonthlyAvailability, error) { + const ( + monthsInYear = 12 + precisionFactor = 100000 + fullPercentage = 100 + availabilityMonths = 11 + roundFactor = 0.5 + ) + + if component == nil { + return nil, fmt.Errorf("component is nil") + } + + if len(component.Incidents) == 0 { + return nil, nil + } + + periodEndDate := time.Now() + // Get the current date and starting point (12 months ago) + periodStartDate := periodEndDate.AddDate(0, -availabilityMonths, 0) // a year ago, including current the month + monthlyDowntime := make([]float64, monthsInYear) // 12 months + + for _, inc := range component.Incidents { + if inc.EndDate == nil || *inc.Impact != 3 { + continue + } + + // here we skip all incidents that are not correspond to our period + // if the incident started before availability period + // (as example the incident was started at 01:00 31/12 and finished at 02:00 01/01), + // we cut the beginning to the period start date, and do the same for the period ending + + incidentStart, incidentEnd, valid := adjustIncidentPeriod( + *inc.StartDate, + *inc.EndDate, + periodStartDate, + periodEndDate, + ) + if !valid { + continue + } + + current := incidentStart + for current.Before(incidentEnd) { + monthStart := time.Date(current.Year(), current.Month(), 1, 0, 0, 0, 0, time.UTC) + monthEnd := monthStart.AddDate(0, 1, 0) + + downtimeStart := maxTime(incidentStart, monthStart) + downtimeEnd := minTime(incidentEnd, monthEnd) + downtime := downtimeEnd.Sub(downtimeStart).Hours() + + monthIndex := (downtimeStart.Year()-periodStartDate.Year())*monthsInYear + + int(downtimeStart.Month()-periodStartDate.Month()) + if monthIndex >= 0 && monthIndex < len(monthlyDowntime) { + monthlyDowntime[monthIndex] += downtime + } + + current = monthEnd + } + } + + monthlyAvailability := make([]MonthlyAvailability, 0, monthsInYear) + for i := range [monthsInYear]int{} { + monthDate := periodStartDate.AddDate(0, i, 0) + totalHours := hoursInMonth(monthDate.Year(), int(monthDate.Month())) + availability := fullPercentage - (monthlyDowntime[i] / totalHours * fullPercentage) + availability = float64(int(availability*precisionFactor+roundFactor)) / precisionFactor + + monthlyAvailability = append(monthlyAvailability, MonthlyAvailability{ + Year: monthDate.Year(), + Month: int(monthDate.Month()), + Percentage: availability, + }) + } + + return monthlyAvailability, nil +} + +func adjustIncidentPeriod(incidentStart, incidentEnd, periodStart, periodEnd time.Time) (time.Time, time.Time, bool) { + if incidentEnd.Before(periodStart) || incidentStart.After(periodEnd) { + return time.Time{}, time.Time{}, false + } + if incidentStart.Before(periodStart) { + incidentStart = periodStart + } + if incidentEnd.After(periodEnd) { + incidentEnd = periodEnd + } + return incidentStart, incidentEnd, true +} + +func minTime(start, end time.Time) time.Time { + if start.Before(end) { + return start + } + return end +} + +func maxTime(start, end time.Time) time.Time { + if start.After(end) { + return start + } + return end +} + +func hoursInMonth(year int, month int) float64 { + firstDay := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC) + nextMonth := firstDay.AddDate(0, 1, 0) + + return float64(nextMonth.Sub(firstDay).Hours()) +} diff --git a/internal/api/v2/v2_test.go b/internal/api/v2/v2_test.go index 439b170..7884739 100644 --- a/internal/api/v2/v2_test.go +++ b/internal/api/v2/v2_test.go @@ -21,14 +21,15 @@ import ( func TestGetIncidentsHandler(t *testing.T) { r, m := initTests(t) - str := "2024-09-01T11:45:26.371Z" + startDate := "2024-09-01T11:45:26.371Z" + endDate := "2024-09-04T11:45:26.371Z" - testTime, err := time.Parse(time.RFC3339, str) + testTime, err := time.Parse(time.RFC3339, startDate) require.NoError(t, err) - prepareDB(t, m, testTime) + prepareIncident(t, m, testTime) - var response = `{"data":[{"id":1,"title":"Incident title","impact":0,"components":[150],"start_date":"%s","system":false,"updates":[{"id":1,"status":"resolved","text":"Issue solved.","timestamp":"%s"}]}]}` + var response = `{"data":[{"id":1,"title":"Incident title A","impact":0,"components":[150],"start_date":"%s","end_date":"%s","system":false,"updates":[{"id":1,"status":"resolved","text":"Issue solved.","timestamp":"%s"}]},{"id":2,"title":"Incident title B","impact":3,"components":[151],"start_date":"%s","end_date":"%s","system":false,"updates":[{"id":2,"status":"resolved","text":"Issue solved.","timestamp":"%s"}]}]}` w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, "/v2/incidents", nil) @@ -36,7 +37,7 @@ func TestGetIncidentsHandler(t *testing.T) { r.ServeHTTP(w, req) assert.Equal(t, 200, w.Code) - assert.Equal(t, fmt.Sprintf(response, str, str), w.Body.String()) + assert.Equal(t, fmt.Sprintf(response, startDate, endDate, endDate, startDate, endDate, endDate), w.Body.String()) } func TestReturn404Handler(t *testing.T) { @@ -46,7 +47,131 @@ func TestReturn404Handler(t *testing.T) { r.ServeHTTP(w, req) assert.Equal(t, 404, w.Code) - assert.Equal(t, `{"errMsg":"page not found"}`, w.Body.String()) + assert.JSONEq(t, `{"errMsg":"page not found"}`, w.Body.String()) +} + +func TestGetComponentsAvailabilityHandler(t *testing.T) { + r, m := initTests(t) + // Mocking data for testing + + currentTime := time.Now().UTC() + year, month, _ := currentTime.Date() + + firstDayOfLastMonth := time.Date(year, month-1, 1, 0, 0, 0, 0, time.UTC) + testTime := firstDayOfLastMonth + prepareAvailability(t, m, testTime) + + getYearAndMonth := func(year, month, offset int) (int, int) { + newMonth := month - offset + for newMonth <= 0 { + year-- + newMonth += 12 + } + return year, newMonth + } + + expectedAvailability := "" + for i := range [12]int{} { + availYear, availMonth := getYearAndMonth(year, int(month), i) + percentage := 100 + // For the second month (current month in test setup), set percentage to 0 + if i == 1 { + percentage = 0 + } + expectedAvailability += fmt.Sprintf(`{"year":%d,"month":%d,"percentage":%d},`, availYear, availMonth, percentage) + } + // Remove trailing comma + expectedAvailability = expectedAvailability[:len(expectedAvailability)-1] + + response := fmt.Sprintf(`{"data":[{"id":151,"name":"Component B","availability":[%s],"region":"B"}]}`, expectedAvailability) + + // Sending GET request to get availability of components + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v2/availability", nil) + r.ServeHTTP(w, req) + // Checking status code of response and format + assert.Equal(t, 200, w.Code) + assert.Equal(t, response, w.Body.String()) + // unmarshal data to golang struct +} + +func TestCalculateAvailability(t *testing.T) { + type testCase struct { + description string + Component *db.Component + Result []*MonthlyAvailability + } + + impact := 3 + + comp := db.Component{ + ID: 150, + Name: "DataArts", + Incidents: []*db.Incident{}, + } + + compForSept := comp + stDate := time.Date(2024, 9, 21, 0, 0, 0, 0, time.UTC) + endDate := time.Date(2024, 10, 2, 20, 0, 0, 0, time.UTC) + compForSept.Incidents = append(compForSept.Incidents, &db.Incident{ + ID: 1, + StartDate: &stDate, + EndDate: &endDate, + Impact: &impact, + }) + + testCases := []testCase{ + { + description: "Test case: September (66.66667%)- October (94.08602%)", + Component: &compForSept, + Result: func() []*MonthlyAvailability { + results := make([]*MonthlyAvailability, 12) + + for i := range [12]int{} { + year, month := getYearAndMonth(time.Now().Year(), int(time.Now().Month()), 12-i-1) + results[i] = &MonthlyAvailability{ + Year: year, + Month: month, + Percentage: 100, + } + if month == 9 { + results[i] = &MonthlyAvailability{ + Month: month, + Percentage: 66.66667, + } + } + if month == 10 { + results[i] = &MonthlyAvailability{ + Month: month, + Percentage: 94.08602, + } + } + } + return results + }(), + }, + } + + for _, tc := range testCases { + result, err := calculateAvailability(tc.Component) + require.NoError(t, err) + + t.Logf("Test '%s': Calculated availability: %+v", tc.description, result) + + assert.Len(t, result, 12) + for i, r := range result { + assert.InEpsilon(t, tc.Result[i].Percentage, r.Percentage, 0.0001) + } + } +} + +func getYearAndMonth(year, month, offset int) (int, int) { + newMonth := month - offset + for newMonth <= 0 { + year-- + newMonth += 12 + } + return year, newMonth } func initTests(t *testing.T) (*gin.Engine, sqlmock.Sqlmock) { @@ -80,42 +205,77 @@ func initRoutes(t *testing.T, c *gin.Engine, dbInst *db.DB, log *zap.Logger) { v2Api.POST("incidents", PostIncidentHandler(dbInst, log)) v2Api.GET("incidents/:id", GetIncidentHandler(dbInst, log)) v2Api.PATCH("incidents/:id", PatchIncidentHandler(dbInst, log)) + + v2Api.GET("availability", GetComponentsAvailabilityHandler(dbInst, log)) } } -func prepareDB(t *testing.T, mock sqlmock.Sqlmock, testTime time.Time) { +func prepareIncident(t *testing.T, mock sqlmock.Sqlmock, testTime time.Time) { t.Helper() - rows := sqlmock.NewRows([]string{"id", "text", "start_date", "end_date", "impact", "system"}). - AddRow(1, "Incident title", testTime, nil, 0, false) - mock.ExpectQuery("^SELECT (.+) FROM \"incident\"$").WillReturnRows(rows) + rowsInc := sqlmock.NewRows([]string{"id", "text", "start_date", "end_date", "impact", "system"}). + AddRow(1, "Incident title A", testTime, testTime.Add(time.Hour*72), 0, false). + AddRow(2, "Incident title B", testTime, testTime.Add(time.Hour*72), 3, false) + mock.ExpectQuery("^SELECT (.+) FROM \"incident\"$").WillReturnRows(rowsInc) rowsIncComp := sqlmock.NewRows([]string{"incident_id", "component_id"}). - AddRow(1, 150) + AddRow(1, 150). + AddRow(2, 151) mock.ExpectQuery("^SELECT (.+) FROM \"incident_component_relation\"(.+)").WillReturnRows(rowsIncComp) rowsComp := sqlmock.NewRows([]string{"id", "name"}). - AddRow(150, "Cloud Container Engine") + AddRow(150, "Component A"). + AddRow(151, "Component B") mock.ExpectQuery("^SELECT (.+) FROM \"component\"(.+)").WillReturnRows(rowsComp) rowsStatus := sqlmock.NewRows([]string{"id", "incident_id", "timestamp", "text", "status"}). - AddRow(1, 1, testTime, "Issue solved.", "resolved") + AddRow(1, 1, testTime.Add(time.Hour*72), "Issue solved.", "resolved"). + AddRow(2, 2, testTime.Add(time.Hour*72), "Issue solved.", "resolved") mock.ExpectQuery("^SELECT (.+) FROM \"incident_status\"").WillReturnRows(rowsStatus) rowsCompAttr := sqlmock.NewRows([]string{"id", "component_id", "name", "value"}). AddRows([][]driver.Value{ - { - 859, 150, "category", "Container", - }, - { - 860, 150, "region", "EU-DE", - }, - { - 861, 150, "type", "cce", - }, - }..., - ) + {859, 150, "category", "A"}, + {860, 150, "region", "A"}, + {861, 150, "type", "b"}, + {862, 151, "category", "B"}, + {863, 151, "region", "B"}, + {864, 151, "type", "a"}, + }...) mock.ExpectQuery("^SELECT (.+) FROM \"component_attribute\"").WillReturnRows(rowsCompAttr) mock.NewRowsWithColumnDefinition() } + +func prepareAvailability(t *testing.T, mock sqlmock.Sqlmock, testTime time.Time) { + t.Helper() + + rowsComp := sqlmock.NewRows([]string{"id", "name"}). + AddRow(151, "Component B") + mock.ExpectQuery("^SELECT (.+) FROM \"component\"$").WillReturnRows(rowsComp) + + rowsCompAttr := sqlmock.NewRows([]string{"id", "component_id", "name", "value"}). + AddRows([][]driver.Value{ + {862, 151, "category", "B"}, + {863, 151, "region", "B"}, + {864, 151, "type", "a"}, + }...) + mock.ExpectQuery("^SELECT (.+) FROM \"component_attribute\"").WillReturnRows(rowsCompAttr) + + rowsIncComp := sqlmock.NewRows([]string{"incident_id", "component_id"}). + AddRow(2, 151) + mock.ExpectQuery("^SELECT (.+) FROM \"incident_component_relation\"(.+)").WillReturnRows(rowsIncComp) + + startOfMonth := time.Date(testTime.Year(), testTime.Month(), 1, 0, 0, 0, 0, time.UTC) + startOfNextMonth := startOfMonth.AddDate(0, 1, 0) + + rowsInc := sqlmock.NewRows([]string{"id", "text", "start_date", "end_date", "impact", "system"}). + AddRow(2, "Incident title B", startOfMonth, startOfNextMonth, 3, false) + mock.ExpectQuery("^SELECT (.+) FROM \"incident\" WHERE \"incident\".\"id\" = \\$1$").WillReturnRows(rowsInc) + + rowsStatus := sqlmock.NewRows([]string{"id", "incident_id", "timestamp", "text", "status"}). + AddRow(2, 2, testTime.Add(time.Hour*72), "Issue solved.", "resolved") + mock.ExpectQuery("^SELECT (.+) FROM \"incident_status\"").WillReturnRows(rowsStatus) + + mock.NewRowsWithColumnDefinition() +} diff --git a/internal/db/db.go b/internal/db/db.go index 0164f8d..6bb0b98 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -99,6 +99,7 @@ func (db *DB) SaveIncident(inc *Incident) (uint, error) { return inc.ID, nil } +// TODO: check this function for patching incident func (db *DB) ModifyIncident(inc *Incident) error { r := db.g.Updates(inc) diff --git a/openapi.yaml b/openapi.yaml index 78f3f5e..73897de 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -90,6 +90,23 @@ paths: application/json: schema: $ref: '#/components/schemas/InternalServerError' + /v2/availability: + get: + summary: Get availability. + tags: + - availability + responses: + '200': + description: Successful operation. + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ComponentAvailability' /v2/incidents: get: summary: Get all incidents. @@ -254,6 +271,43 @@ components: errMsg: type: string example: internal server error + ComponentAvailability: + type: object + required: + - id + - name + - region + - availability + properties: + id: + type: integer + format: int64 + example: 218 + name: + type: string + example: "Auto Scaling" + region: + type: string + example: "EU-DE" + availability: + type: array + items: + type: object + required: + - year + - month + - percentage + properties: + year: + type: integer + example: 2024 + month: + type: integer + example: 5 + percentage: + type: number + format: float + example: 99.999666 Incidents: type: object properties: