diff --git a/CHANGELOG.md b/CHANGELOG.md index b74e4d96..8577ca46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Changes - remove **runstatus** commands +- **status** command shows new status page + ## 1.71.2 diff --git a/cmd/status.go b/cmd/status.go index 8c9af188..8dbb476c 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -2,121 +2,97 @@ package cmd import ( "bytes" - "encoding/json" "fmt" - "net/http" "os" - "time" "github.com/spf13/cobra" + "github.com/exoscale/cli/pkg/status" "github.com/exoscale/cli/table" ) +// REF: https://www.statuspal.io/api-docs#tag/Status/operation/getStatusPageStatus const ( - statusURL = "https://exoscalestatus.com" - jsonStatusURL = statusURL + "/api.json" - statusContentPage = "application/json" - twitterURL = "https://twitter.com/exoscalestatus" + statusPageSubdomain = "exoscalestatus" ) -// ServiceStatus represents the state of a service -type ServiceStatus struct { - State string `json:"state"` -} +func init() { -// RunStatus represents a runstatus struct -type RunStatus struct { - URL string `json:"url"` - Incidents []struct { - Message string `json:"message"` - Status string `json:"status"` - Updated time.Time `json:"updated"` - Title string `json:"title"` - Created time.Time `json:"created"` - } `json:"incidents"` - UpcomingMaintenances []struct { - Description string `json:"description"` - Title string `json:"title"` - Date time.Time `json:"date"` - } `json:"upcoming_maintenances"` - Status map[string]ServiceStatus `json:"status"` -} + // Global flags have no effect here, hide them + statusCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + for _, flags := range []string{"quiet", "output-format", "output-template"} { -func init() { - RootCmd.AddCommand(&cobra.Command{ - Use: "status", - Short: "Exoscale status", - RunE: func(cmd *cobra.Command, args []string) error { - return statusShow() - }, + err := cmd.Flags().MarkHidden(flags) + if err != nil { + fmt.Print(err) + } + } + cmd.Parent().HelpFunc()(cmd, args) }) + RootCmd.AddCommand(statusCmd) +} + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Exoscale status", + RunE: func(cmd *cobra.Command, args []string) error { + return statusShow() + }, } func statusShow() error { - status, err := fetchRunStatus(jsonStatusURL) + + status, err := status.GetStatusPage(statusPageSubdomain) if err != nil { return err } + // First show the global status per zone + global, err := status.GetStatusByZone() + if err != nil { + return err + } t := table.NewTable(os.Stdout) t.SetHeader([]string{"Exoscale Status"}) buf := bytes.NewBuffer(nil) st := table.NewEmbeddedTable(buf) - for service, status := range status.Status { - st.Append([]string{service, status.State}) - } + st.Table.AppendBulk(global) st.Render() - t.Append([]string{"Services", buf.String()}) + buf.Reset() + + // Get the active incidents with impacted services by zone + incidents, err := status.Incidents.GetActiveEvents(status.Services) + if err != nil { + return err + } - buf = bytes.NewBuffer([]byte("n/a")) - if len(status.Incidents) > 0 { - buf.Reset() + // Show incidents currently taking place + if len(incidents) > 0 { it := table.NewEmbeddedTable(buf) - for _, i := range status.Incidents { - it.Append([]string{i.Title, i.Status, fmt.Sprint(i.Created), fmt.Sprint(i.Updated)}) - } + it.Table.AppendBulk(incidents) it.Render() + } else { + buf = bytes.NewBuffer([]byte("n/a")) } t.Append([]string{"Incidents", buf.String()}) + buf.Reset() - buf = bytes.NewBuffer([]byte("n/a")) - if len(status.UpcomingMaintenances) > 0 { - buf.Reset() + // Get the active maintenances with impacted services by zone + maintenances, err := status.Maintenances.GetActiveEvents(status.Services) + if err != nil { + return err + } + if len(maintenances) > 0 { mt := table.NewEmbeddedTable(buf) - for _, m := range status.UpcomingMaintenances { - mt.Append([]string{m.Title, m.Description, fmt.Sprint(m.Date)}) - } + mt.Table.AppendBulk(maintenances) mt.Render() + } else { + buf = bytes.NewBuffer([]byte("n/a")) } t.Append([]string{"Maintenances", buf.String()}) t.Render() - - fmt.Println("Updates available at", twitterURL) - return nil } - -func fetchRunStatus(url string) (*RunStatus, error) { - // XXX need gContext - r, err := http.Get(url) - if err != nil { - return nil, err - } - defer r.Body.Close() - - contentType := r.Header.Get("content-type") - if contentType != statusContentPage { - return nil, fmt.Errorf("status page content type expected %q, but got %q", statusContentPage, contentType) - } - - response := &RunStatus{} - if err := json.NewDecoder(r.Body).Decode(response); err != nil { - return nil, err - } - - return response, nil -} diff --git a/pkg/status/status.go b/pkg/status/status.go new file mode 100644 index 00000000..ab32970a --- /dev/null +++ b/pkg/status/status.go @@ -0,0 +1,193 @@ +package status + +import ( + "encoding/json" + "fmt" + "net/http" + "sort" + "time" +) + +// https://www.statuspal.io/api-docs/v2 +const ( + statusPalURL = "https://statuspal.eu/api/v2/status_pages/" + statusContentPage = "application/json; charset=utf-8" + dateLayout = "2006-01-02T15:04:05" + IncidentTypeScheduled = "scheduled" +) + +// https://www.statuspal.io/api-docs#tag/Status/operation/getStatusPageStatus + +// Exoscale Services: Parent / child services +// Parent services are +type StatusPalStatus struct { + // Services: all the services of the StatusPage with the current incident type + Services Services `json:"services"` + + // Active Incidents and Maintenances + Incidents Events `json:"incidents"` + Maintenances Events `json:"maintenances"` +} + +// Get the status of global services (status of global or zones, no details of impacted services) +func (s StatusPalStatus) GetStatusByZone() ([][]string, error) { + global := make([][]string, len(s.Services)) + for index, svc := range s.Services { + state := svc.getIncidentType() + global[index] = []string{*svc.Name, state} + } + return global, nil +} + +// A service can contains several child services +// In our case: +// - Parent services = Global and all the zones +// - Child services = products available in a zone or globally +type Service struct { + Id *int `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + + // The type of the current incident: + // * `major` - A minor incident is currently taking place. + // * `minor` - A major incident is currently taking place. + // * `scheduled` - A scheduled maintenance is currently taking place. + // * null - No incident is taking place. + IncidentType *string `json:"current_incident_type,omitempty"` + + // Each product available in the zone + Children Services `json:"children,omitempty"` +} + +func (s *Service) getIncidentType() string { + if s.IncidentType == nil { + return "operational" + } + switch *s.IncidentType { + case IncidentTypeScheduled: + return "scheduled maintenance" + default: + return fmt.Sprint(*s.IncidentType) + } +} + +// Active Maintenance or Incident +type Event struct { + Id *int `json:"id,omitempty"` + Title *string `json:"title,omitempty"` + // The time at which the incident/maintenance started(UTC). + StartsAt *string `json:"starts_at"` + // Type of current incident (major, minor, scheduled) + + Type *string `json:"type"` + + // Services impacted + ServiceIds []int `json:"service_ids"` +} + +type Events []Event + +// Get Active Incidents or Maintenances with the full service name (Zone+Product) +func (e Events) GetActiveEvents(services Services) ([][]string, error) { + var events EventsDetails + + for _, event := range e { + // Get all the services impacted by the incident (name and id) + // Child and parent are all mixed, we need to rebuild the dependency + for _, impacted := range event.ServiceIds { + if services.IsParentService(impacted) { + continue + } + svcName, err := services.GetServiceNamebyId(impacted) + if err != nil { + return nil, err + } + started, err := time.Parse(dateLayout, *event.StartsAt) + if err != nil { + return nil, err + } + startTimeUTC := started.Format(time.RFC822) + eventDetails := []string{svcName, *event.Title} + if *event.Type == IncidentTypeScheduled { + eventDetails = append(eventDetails, "scheduled at "+startTimeUTC) + events = append(events, eventDetails) + } else { + + eventDetails = append(eventDetails, *event.Type, "since "+startTimeUTC) + events = append(events, eventDetails) + } + } + } + // Sort by zones + sort.Sort(events) + return events, nil +} + +type Services []Service + +// We have 2 levels of services, check if a service is a parent +func (s Services) IsParentService(id int) bool { + for _, service := range s { + if service.Id != nil && *service.Id == id { + return true + } + } + return false + +} + +// Return the Zone and the impacted service = fullname (parent svc + child svc) +func (s Services) GetServiceNamebyId(id int) (string, error) { + // For all zones / global services + for _, parentService := range s { + // id provided is a parent service, return the name + if *parentService.Id == id { + return *parentService.Name, nil + } + // Try to find the Service Id in the child services + for _, childService := range parentService.Children { + // In this case, we returen the Parent and the Child names + if *childService.Id == id { + return *parentService.Name + " " + *childService.Name, nil + } + } + } + + return "", fmt.Errorf("Service ID %d not found", id) +} + +// Details of incident: zone, service, description, start time and type +type EventsDetails [][]string + +// Implement Sort interface +func (t EventsDetails) Less(i, j int) bool { + return t[i][0] < t[j][0] +} +func (t EventsDetails) Len() int { + return len(t) +} +func (t EventsDetails) Swap(i, j int) { + t[i], t[j] = t[j], t[i] +} + +// Get the status of a status page +func GetStatusPage(subdomain string) (*StatusPalStatus, error) { + url := statusPalURL + subdomain + "/summary" + resp, err := http.Get(url) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + contentType := resp.Header.Get("content-type") + if contentType != statusContentPage { + return nil, fmt.Errorf("status page content type expected %q, but got %q", statusContentPage, contentType) + } + + response := &StatusPalStatus{} + if err := json.NewDecoder(resp.Body).Decode(response); err != nil { + return nil, err + } + + return response, nil +} diff --git a/pkg/status/status_test.go b/pkg/status/status_test.go new file mode 100644 index 00000000..8d10e7c4 --- /dev/null +++ b/pkg/status/status_test.go @@ -0,0 +1,158 @@ +package status_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/exoscale/cli/pkg/status" +) + +// Status page and the services +var svc1Parent = "Parent" +var svc1Child1 = "API" +var svc1Child2 = "Web app" + +var svc2Parent = "NoChild" +var svc3Parent = "OtherParent" +var svc3Child1 = "API" + +// Produce JSON with services and with or without events +func getJSONStatus(events bool) string { + + incidentType := "null" + maintenanceType := "null" + incidents := `[]` + maintenances := `[]` + + // Add maintenance and incident + if events { + // Update incident type + incidentType = "\"minor\"" + maintenanceType = "\"scheduled\"" + + incidents = `[ + {"id":123, + "service_ids":[10,11,30,31], + "starts_at":"2023-07-28T07:57:37", + "title":"App partial outage", + "type":"minor" + }]` + maintenances = `[ + {"id":124, + "service_ids":[10,12], + "starts_at":"2023-07-28T11:00:00", + "title":"app maintenance", + "type":"scheduled" + } ]` + } + + // build Json + return fmt.Sprintf(` + { + "services": [ + {"name": "%v", + "id": 10, + "current_incident_type": %v, + "children": [ + {"name": "%v", + "id": 11, + "current_incident_type": %v}, + {"name": "%v", + "id": 12, + "current_incident_type": %v}]}, + {"name": "%v", + "id": 20, + "current_incident_type": null, + "children": null}, + {"name": "%v", + "id": 30, + "current_incident_type": %v, + "children": [ + {"name": "%v", + "id": 31, + "current_incident_type": %v}]} + ], + "incidents": %v, + "maintenances": %v + }`, svc1Parent, incidentType, svc1Child1, incidentType, svc1Child2, maintenanceType, + svc2Parent, svc3Parent, incidentType, svc3Child1, incidentType, + incidents, maintenances) +} + +// Test status page +func TestStatusPageNoEvent(t *testing.T) { + + var statusPageTest status.StatusPalStatus + + // Json without incidents or maintenances + jsonData := getJSONStatus(false) + err := json.Unmarshal([]byte(jsonData), &statusPageTest) + assert.NoError(t, err, err) + + // Validate the services returned by GetStatusByZone + output, err := statusPageTest.GetStatusByZone() + // assert.Equal(t, 3, len(output), jsonData) + // expected Services are only "parent" services + expectedServices := []string{svc1Parent, svc2Parent, svc3Parent} + assert.Equal(t, len(expectedServices), len(output), "Only parent services are expected") + assert.NoError(t, err) + + // check the parent service names + for i, o := range output { + assert.Equal(t, o[0], expectedServices[i], "Service name expected") + fmt.Print(o) + } + + i, err := statusPageTest.Incidents.GetActiveEvents(statusPageTest.Services) + assert.Nil(t, i, "No Incident expected") + assert.NoError(t, err) + m, err := statusPageTest.Maintenances.GetActiveEvents(statusPageTest.Services) + assert.Nil(t, m, "No Maintenance expected") + assert.NoError(t, err) + + // Check IsParentServce + assert.Equal(t, true, statusPageTest.Services.IsParentService(10)) + assert.Equal(t, false, statusPageTest.Services.IsParentService(11)) + + // Check ServiceName contains Parent + name, err := statusPageTest.Services.GetServiceNamebyId(10) + assert.NoError(t, err) + assert.Equal(t, svc1Parent, name) + name, err = statusPageTest.Services.GetServiceNamebyId(11) + assert.NoError(t, err) + assert.Equal(t, svc1Parent+" "+svc1Child1, name) + +} + +// Test status page with active incident and maintenance +func TestStatusPageEvents(t *testing.T) { + + var statusPageTest status.StatusPalStatus + + // Json without incidents or maintenances + jsonData := getJSONStatus(true) + err := json.Unmarshal([]byte(jsonData), &statusPageTest) + assert.NoError(t, err, err) + + i, err := statusPageTest.Incidents.GetActiveEvents(statusPageTest.Services) + assert.NotNil(t, i, "Incident expected") + assert.NoError(t, err) + assert.Equal(t, 2, len(i), "2 (child) services impacted by the incident") + assert.Equal(t, 4, len(i[0]), "4 fields expected: Name, title, type and start time") + assert.Equal(t, 4, len(i[1]), "4 fields expected: Name, title, type and start time") + // Name = full service name and result is sorted by Name + // it should be svc3 first + name, _ := statusPageTest.Services.GetServiceNamebyId(31) + assert.Equal(t, name, i[0][0], "Incidents should be sorted by name") + assert.Equal(t, "minor", i[0][2], "Incident Type minor") + + m, err := statusPageTest.Maintenances.GetActiveEvents(statusPageTest.Services) + assert.NotNil(t, m, "Maintenance expected") + assert.NoError(t, err) + assert.Equal(t, 1, len(m), "1 (child) service impacted by the maintenance") + assert.Equal(t, 3, len(m[0]), "3 fields expected: Name, title and start time") + +}