Skip to content

Commit

Permalink
status: show new status page
Browse files Browse the repository at this point in the history
  • Loading branch information
jessicatoscani committed Aug 4, 2023
1 parent 918242c commit b719c35
Show file tree
Hide file tree
Showing 3 changed files with 240 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
124 changes: 48 additions & 76 deletions cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,121 +2,93 @@ 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 impacted services by zone (incidents and maintenances)
incidents, maintenances, err := status.GetIncidents()
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()
// Show maintenances currently taking place
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
}
190 changes: 190 additions & 0 deletions pkg/status/status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package status

import (
"encoding/json"
"fmt"
"net/http"
"sort"
"time"
)

// https://www.statuspal.io/api-docs
const (
statusPalURL = "https://statuspal.eu/api/v1/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
type StatusPalStatus struct {
// Services: all the (parent) services of the StatusPage with the current incident type
Services Services `json:"services"`

// Maintenances only contains the future scheduled maintenances
// Ongoing maintenances are in Incidents
// Incidents
Incidents []Incident `json:"incidents"`
}

// 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 _, svc := range s.Services {
state := svc.getIncidentType()
global = append(global, []string{*svc.Name, state})
}
return global, nil
}

func (s StatusPalStatus) GetIncidents() ([][]string, [][]string, error) {
var incidents IncidentsDetails
var maintenances IncidentsDetails

services := s.Services
// In Incidents, we have maintenances and incidents currently taking place
// We need to show them in different tables
for _, event := range s.Incidents {
// 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.Services {
if services.isParentService(*impacted.Id) {
continue
}
svcName, err := services.getServiceNamebyId(*impacted.Id)
if err != nil {
return nil, nil, err
}
started, err := time.Parse(dateLayout, *event.StartsAt)
if err != nil {
return nil, nil, err
}
startTimeUTC := started.Format(time.RFC822)
eventDetails := []string{svcName, *event.Title}
if *event.Type == IncidentTypeScheduled {
eventDetails = append(eventDetails, "scheduled at "+startTimeUTC)
maintenances = append(maintenances, eventDetails)
} else {

eventDetails = append(eventDetails, fmt.Sprint(*event.Type), "since "+startTimeUTC)
incidents = append(incidents, eventDetails)
}
}
}
// Sort by zones
sort.Sort(incidents)
sort.Sort(maintenances)
return incidents, maintenances, 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)
}
}

type Incident 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 (only id and name)
Services Services `json:"services"`
}

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 IncidentsDetails [][]string

// Implement Sort interface
func (t IncidentsDetails) Less(i, j int) bool {
return t[i][0] < t[j][0]
}
func (t IncidentsDetails) Len() int {
return len(t)
}
func (t IncidentsDetails) 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 + "/status"
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
}

0 comments on commit b719c35

Please sign in to comment.