Skip to content

Commit

Permalink
status: show new status page (#527)
Browse files Browse the repository at this point in the history
With Incidents and maintenances
```
go run  . status
┼─────────────────┼───────────────────────────────────────────────────────────────────────────────────────────┼
│ EXOSCALE STATUS │                                                                                           │
┼─────────────────┼───────────────────────────────────────────────────────────────────────────────────────────┼
│ Services        │   Global     operational                                                                  │
│                 │   CH-GVA-2   operational                                                                  │
│                 │   CH-DK-2    minor                                                                        │
│                 │   DE-FRA-1   minor                                                                        │
│                 │   DE-MUC-1   operational                                                                  │
│                 │   AT-VIE-1   operational                                                                  │
│                 │   AT-VIE-2   operational                                                                  │
│                 │   BG-SOF-1   scheduled maintenance                                                        │
│                 │                                                                                           │
│ Incidents       │   CH-DK-2 Managed Kubernetes SKS      Network issue   minor   since 28 Jul 23 07:57 UTC   │
│                 │   CH-DK-2 Network Load Balancer NLB   Network issue   minor   since 28 Jul 23 07:57 UTC   │
│                 │   DE-FRA-1 Managed Kubernetes SKS     Network issue   minor   since 28 Jul 23 07:57 UTC   │
│                 │                                                                                           │
│ Maintenances    │   BG-SOF-1 Object Storage SOS   Urgent maintenance   scheduled at 28 Jul 23 08:40 UTC     │
│                 │                                                                                           │
┼─────────────────┼───────────────────────────────────────────────────────────────────────────────────────────┼
```


```
 go run  . status
┼─────────────────┼────────────────────────────┼
│ EXOSCALE STATUS │                            │
┼─────────────────┼────────────────────────────┼
│ Services        │   Global     operational   │
│                 │   CH-GVA-2   operational   │
│                 │   CH-DK-2    operational   │
│                 │   DE-FRA-1   operational   │
│                 │   DE-MUC-1   operational   │
│                 │   AT-VIE-1   operational   │
│                 │   AT-VIE-2   operational   │
│                 │   BG-SOF-1   operational   │
│                 │                            │
│ Incidents       │ n/a                        │
│ Maintenances    │ n/a                        │
┼─────────────────┼────────────────────────────┼
```
  • Loading branch information
jessicatoscani authored Aug 9, 2023
1 parent 918242c commit e82f18f
Show file tree
Hide file tree
Showing 4 changed files with 405 additions and 76 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
### Changes

- remove **runstatus** commands
- **status** command shows new status page



## 1.71.2
Expand Down
128 changes: 52 additions & 76 deletions cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
193 changes: 193 additions & 0 deletions pkg/status/status.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit e82f18f

Please sign in to comment.