Skip to content

Commit

Permalink
Add installation status API endpoint (#424)
Browse files Browse the repository at this point in the history
The new endpoint returns the complete status of all non-deleted
installations managed by the provisioner. This can be used by other
cloud services to easily determine the amount of installation
processing happening at any moment so that they can make intelligent
decisions on when to request additional installation updates.
  • Loading branch information
gabrieljackson authored Mar 2, 2021
1 parent 69a1864 commit 6f7051c
Show file tree
Hide file tree
Showing 12 changed files with 269 additions and 29 deletions.
27 changes: 27 additions & 0 deletions cmd/cloud/installation.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ func init() {
installationCmd.AddCommand(installationListCmd)
installationCmd.AddCommand(installationShowStateReport)
installationCmd.AddCommand(installationAnnotationCmd)
installationCmd.AddCommand(installationsGetStatuses)
}

var installationCmd = &cobra.Command{
Expand Down Expand Up @@ -383,6 +384,32 @@ var installationListCmd = &cobra.Command{
},
}

var installationsGetStatuses = &cobra.Command{
Use: "status",
Short: "Get status information for all installations.",
RunE: func(command *cobra.Command, args []string) error {
command.SilenceUsage = true

serverAddress, _ := command.Flags().GetString("server")
client := model.NewClient(serverAddress)

installationsStatus, err := client.GetInstallationsStatus()
if err != nil {
return errors.Wrap(err, "failed to query installation status")
}
if installationsStatus == nil {
return nil
}

err = printJSON(installationsStatus)
if err != nil {
return err
}

return nil
},
}

// TODO:
// Instead of showing the state data from the model of the CLI binary, add a new
// API endpoint to return the server's state model.
Expand Down
4 changes: 2 additions & 2 deletions internal/api/cluster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1146,8 +1146,8 @@ func TestGetAllUtilityMetadata(t *testing.T) {
Provider: model.ProviderAWS,
Zones: []string{"zone"},
DesiredUtilityVersions: map[string]*model.HelmUtilityVersion{
"prometheus-operator": &model.HelmUtilityVersion{Chart: "9.4.4"},
"nginx": &model.HelmUtilityVersion{Chart: "stable"},
"prometheus-operator": {Chart: "9.4.4"},
"nginx": {Chart: "stable"},
},
})

Expand Down
3 changes: 2 additions & 1 deletion internal/api/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ type Store interface {
GetInstallationDTO(installationID string, includeGroupConfig, includeGroupConfigOverrides bool) (*model.InstallationDTO, error)
GetInstallations(filter *model.InstallationFilter, includeGroupConfig, includeGroupConfigOverrides bool) ([]*model.Installation, error)
GetInstallationDTOs(filter *model.InstallationFilter, includeGroupConfig, includeGroupConfigOverrides bool) ([]*model.InstallationDTO, error)
GetInstallationsCount(includeDeleted bool) (int, error)
GetInstallationsCount(includeDeleted bool) (int64, error)
GetInstallationsStatus() (*model.InstallationsStatus, error)
UpdateInstallation(installation *model.Installation) error
LockInstallation(installationID, lockerID string) (bool, error)
UnlockInstallation(installationID, lockerID string, force bool) (bool, error)
Expand Down
10 changes: 5 additions & 5 deletions internal/api/group_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,7 @@ func TestGroupsStatus(t *testing.T) {
require.NoError(t, err)

t.Run("empty groups", func(t *testing.T) {
expectedStatus := &[]model.GroupsStatus{
expectedStatus := []*model.GroupsStatus{
{
ID: group.ID,
Status: model.GroupStatus{
Expand Down Expand Up @@ -701,12 +701,12 @@ func TestGroupsStatus(t *testing.T) {
groupsStatus, err := client.GetGroupsStatus()
require.NoError(t, err)
require.NotNil(t, groupsStatus)
assert.Len(t, *groupsStatus, 2)
for _, gs := range *groupsStatus {
assert.Len(t, groupsStatus, 2)
for _, gs := range groupsStatus {
if gs.ID == group.ID {
assert.Equal(t, expectedStatusGroup1, &gs)
assert.Equal(t, expectedStatusGroup1, gs)
} else if gs.ID == group2.ID {
assert.Equal(t, expectedStatusGroup2, &gs)
assert.Equal(t, expectedStatusGroup2, gs)
}
}
})
Expand Down
19 changes: 18 additions & 1 deletion internal/api/installation.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ func initInstallation(apiRouter *mux.Router, context *Context) {

installationsRouter := apiRouter.PathPrefix("/installations").Subrouter()
installationsRouter.Handle("", addContext(handleGetInstallations)).Methods("GET")
installationsRouter.Handle("/count", addContext(handleGetNumberOfInstallations)).Methods("GET")
installationsRouter.Handle("", addContext(handleCreateInstallation)).Methods("POST")
installationsRouter.Handle("/count", addContext(handleGetNumberOfInstallations)).Methods("GET")
installationsRouter.Handle("/status", addContext(handleGetInstallationsStatus)).Methods("GET")

installationRouter := apiRouter.PathPrefix("/installation/{installation:[A-Za-z0-9]{26}}").Subrouter()
installationRouter.Handle("", addContext(handleGetInstallation)).Methods("GET")
Expand Down Expand Up @@ -131,11 +132,27 @@ func handleGetNumberOfInstallations(c *Context, w http.ResponseWriter, r *http.R
return
}
result := model.InstallationsCount{Count: installationsCount}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
outputJSON(c, w, result)
}

// handleGetInstallationsStatus responds to GET /api/installations/status,
// returning the status of all non-deleted installations
func handleGetInstallationsStatus(c *Context, w http.ResponseWriter, r *http.Request) {
installationsStatus, err := c.Store.GetInstallationsStatus()
if err != nil {
c.Logger.WithError(err).Error("failed to query for installations status")
w.WriteHeader(http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
outputJSON(c, w, installationsStatus)
}

// handleCreateInstallation responds to POST /api/installations, beginning the process of creating
// a new installation.
func handleCreateInstallation(c *Context, w http.ResponseWriter, r *http.Request) {
Expand Down
11 changes: 10 additions & 1 deletion internal/api/installation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ func TestGetInstallations(t *testing.T) {
testCases := []struct {
Description string
IncludeDeleted bool
Expected int
Expected int64
}{
{
"count without deleted",
Expand All @@ -333,6 +333,15 @@ func TestGetInstallations(t *testing.T) {
})
}
})

t.Run("check installations status", func(t *testing.T) {
status, err := client.GetInstallationsStatus()
require.NoError(t, err)
assert.Equal(t, int64(3), status.InstallationsTotal)
assert.Equal(t, int64(0), status.InstallationsStable)
assert.Equal(t, int64(0), status.InstallationsHibernating)
assert.Equal(t, int64(3), status.InstallationsUpdating)
})
})
}

Expand Down
72 changes: 60 additions & 12 deletions internal/store/installation.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,23 +163,71 @@ func (sqlStore *SQLStore) applyInstallationFilter(builder sq.SelectBuilder, filt
return builder
}

// GetInstallationsCount returns the number of installations filtered by the deletedat
// field
func (sqlStore *SQLStore) GetInstallationsCount(includeDeleted bool) (int, error) {
builder := sq.Select("COUNT(*) as InstallationsCount").From("Installation")
if !includeDeleted {
builder = builder.Where("DeleteAt = 0")
// GetInstallationsCount returns the number of installations filtered by the
// deleteAt field.
func (sqlStore *SQLStore) GetInstallationsCount(includeDeleted bool) (int64, error) {
stateCounts, err := sqlStore.getInstallationCountsByState(includeDeleted)
if err != nil {
return 0, errors.Wrap(err, "failed to query installation state counts")
}
var numberOfInstallations int
query, _, err := builder.ToSql()
var totalCount int64
for _, count := range stateCounts {
totalCount += count
}

return totalCount, nil
}

// GetInstallationsStatus returns status of all installations which aren't
// deleted.
func (sqlStore *SQLStore) GetInstallationsStatus() (*model.InstallationsStatus, error) {
stateCounts, err := sqlStore.getInstallationCountsByState(false)
if err != nil {
return 0, errors.Wrap(err, "failed to parse query for installations count")
return nil, errors.Wrap(err, "failed to query installation state counts")
}

var totalCount int64
for _, count := range stateCounts {
totalCount += count
}
stableCount := stateCounts[model.InstallationStateStable]
hibernatingCount := stateCounts[model.InstallationStateHibernating]

return &model.InstallationsStatus{
InstallationsTotal: totalCount,
InstallationsStable: stableCount,
InstallationsHibernating: hibernatingCount,
InstallationsUpdating: totalCount - stableCount - hibernatingCount,
}, nil
}

// getInstallationCountsByState returns the number of installations in each
// state.
func (sqlStore *SQLStore) getInstallationCountsByState(includeDeleted bool) (map[string]int64, error) {
type Count struct {
Count int64
State string
}
var counts []Count

installationBuilder := sq.
Select("Count (*) as Count, State").
From("Installation").
GroupBy("State")
if !includeDeleted {
installationBuilder = installationBuilder.Where("DeleteAt = 0")
}
err = sqlStore.get(sqlStore.db, &numberOfInstallations, query)
err := sqlStore.selectBuilder(sqlStore.db, &counts, installationBuilder)
if err != nil {
return 0, errors.Wrap(err, "failed to query for installations count")
return nil, errors.Wrap(err, "failed to query for installations by state")
}
return numberOfInstallations, nil

result := make(map[string]int64)
for _, count := range counts {
result[count.State] = count.Count
}

return result, nil
}

// GetUnlockedInstallationsPendingWork returns an unlocked installation in a pending state.
Expand Down
88 changes: 88 additions & 0 deletions internal/store/installation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,94 @@ func TestUpdateInstallationState(t *testing.T) {
assert.NotEqual(t, storedInstallation.Version, installation1.Version)
}

func TestGetInstallationsStatus(t *testing.T) {
logger := testlib.MakeLogger(t)
sqlStore := MakeTestSQLStore(t, logger)
defer CloseConnection(t, sqlStore)

installation1 := &model.Installation{
OwnerID: model.NewID(),
Version: "version",
DNS: "dns1.example.com",
License: "this-is-a-license",
Database: model.InstallationDatabaseMysqlOperator,
Filestore: model.InstallationFilestoreMinioOperator,
Size: mmv1alpha1.Size100String,
Affinity: model.InstallationAffinityIsolated,
State: model.InstallationStateCreationRequested,
}

err := sqlStore.CreateInstallation(installation1, nil)
require.NoError(t, err)

status, err := sqlStore.GetInstallationsStatus()
require.NoError(t, err)
assert.Equal(t, int64(1), status.InstallationsTotal)
assert.Equal(t, int64(0), status.InstallationsStable)
assert.Equal(t, int64(0), status.InstallationsHibernating)
assert.Equal(t, int64(1), status.InstallationsUpdating)

time.Sleep(1 * time.Millisecond)

installation2 := &model.Installation{
OwnerID: model.NewID(),
Version: "version",
DNS: "dns2.example.com",
License: "this-is-a-license",
Database: model.InstallationDatabaseMysqlOperator,
Filestore: model.InstallationFilestoreMinioOperator,
Size: mmv1alpha1.Size100String,
Affinity: model.InstallationAffinityIsolated,
State: model.ClusterInstallationStateStable,
}

err = sqlStore.CreateInstallation(installation2, nil)
require.NoError(t, err)

status, err = sqlStore.GetInstallationsStatus()
require.NoError(t, err)
assert.Equal(t, int64(2), status.InstallationsTotal)
assert.Equal(t, int64(1), status.InstallationsStable)
assert.Equal(t, int64(0), status.InstallationsHibernating)
assert.Equal(t, int64(1), status.InstallationsUpdating)

time.Sleep(1 * time.Millisecond)

installation3 := &model.Installation{
OwnerID: model.NewID(),
Version: "version",
DNS: "dns3.example.com",
License: "this-is-a-license",
Database: model.InstallationDatabaseMysqlOperator,
Filestore: model.InstallationFilestoreMinioOperator,
Size: mmv1alpha1.Size100String,
Affinity: model.InstallationAffinityIsolated,
State: model.InstallationStateHibernating,
}

err = sqlStore.CreateInstallation(installation3, nil)
require.NoError(t, err)

status, err = sqlStore.GetInstallationsStatus()
require.NoError(t, err)
assert.Equal(t, int64(3), status.InstallationsTotal)
assert.Equal(t, int64(1), status.InstallationsStable)
assert.Equal(t, int64(1), status.InstallationsHibernating)
assert.Equal(t, int64(1), status.InstallationsUpdating)

time.Sleep(1 * time.Millisecond)

err = sqlStore.DeleteInstallation(installation1.ID)
require.NoError(t, err)

status, err = sqlStore.GetInstallationsStatus()
require.NoError(t, err)
assert.Equal(t, int64(2), status.InstallationsTotal)
assert.Equal(t, int64(1), status.InstallationsStable)
assert.Equal(t, int64(1), status.InstallationsHibernating)
assert.Equal(t, int64(0), status.InstallationsUpdating)
}

func TestUpdateInstallationCRVersion(t *testing.T) {
logger := testlib.MakeLogger(t)
sqlStore := MakeTestSQLStore(t, logger)
Expand Down
24 changes: 22 additions & 2 deletions model/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ func (c *Client) GetInstallations(request *GetInstallationsRequest) ([]*Installa
}

// GetInstallationsCount returns then number of installations filtered by deleted field
func (c *Client) GetInstallationsCount(includeDeleted bool) (int, error) {
func (c *Client) GetInstallationsCount(includeDeleted bool) (int64, error) {
u, err := url.Parse(c.buildURL("/api/installations/count"))
if err != nil {
return 0, err
Expand Down Expand Up @@ -515,6 +515,26 @@ func (c *Client) WakeupInstallation(installationID string) (*InstallationDTO, er
}
}

// GetInstallationsStatus fetches the status for all installations.
func (c *Client) GetInstallationsStatus() (*InstallationsStatus, error) {
resp, err := c.doGet(c.buildURL("/api/installations/status"))
if err != nil {
return nil, err
}
defer closeBody(resp)

switch resp.StatusCode {
case http.StatusOK:
return InstallationsStatusFromReader(resp.Body)

case http.StatusNotFound:
return nil, nil

default:
return nil, errors.Errorf("failed with status code %d", resp.StatusCode)
}
}

// DeleteInstallation deletes the given installation and all resources contained therein.
func (c *Client) DeleteInstallation(installationID string) error {
resp, err := c.doDelete(c.buildURL("/api/installation/%s", installationID))
Expand Down Expand Up @@ -803,7 +823,7 @@ func (c *Client) GetGroupStatus(groupID string) (*GroupStatus, error) {
}

// GetGroupsStatus fetches the status for all groups.
func (c *Client) GetGroupsStatus() (*[]GroupsStatus, error) {
func (c *Client) GetGroupsStatus() ([]*GroupsStatus, error) {
resp, err := c.doGet(c.buildURL("/api/groups/status"))
if err != nil {
return nil, err
Expand Down
6 changes: 3 additions & 3 deletions model/group_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,13 @@ func GroupStatusFromReader(reader io.Reader) (*GroupStatus, error) {
}

// GroupsStatusFromReader decodes a json-encoded groups status from the given io.Reader.
func GroupsStatusFromReader(reader io.Reader) (*[]GroupsStatus, error) {
groupsStatus := []GroupsStatus{}
func GroupsStatusFromReader(reader io.Reader) ([]*GroupsStatus, error) {
groupsStatus := []*GroupsStatus{}
decoder := json.NewDecoder(reader)
err := decoder.Decode(&groupsStatus)
if err != nil && err != io.EOF {
return nil, err
}

return &groupsStatus, nil
return groupsStatus, nil
}
Loading

0 comments on commit 6f7051c

Please sign in to comment.