Skip to content

Commit

Permalink
feature/managed-instances (#111)
Browse files Browse the repository at this point in the history
* Add managed instances handler and ssm file for underlying repo logic.

* Added updated method for GetManagedInstance that will also look for it by ComputerName

---------

Co-authored-by: bt353 <[email protected]>
  • Loading branch information
btassone and bt353 authored Jan 15, 2025
1 parent 5df0eb1 commit 12c6853
Show file tree
Hide file tree
Showing 4 changed files with 263 additions and 1 deletion.
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,8 @@ vendor
.vscode
debug
.idea/
*.exe
*.exe
control.json
deco.json
decofile-prod.json
docker-compose.yml
112 changes: 112 additions & 0 deletions api/handlers_managedinstances.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package api

import (
"fmt"
"net/http"
"strconv"

"github.com/YaleSpinup/apierror"
"github.com/YaleSpinup/ec2-api/ssm"
"github.com/aws/aws-sdk-go/aws"
"github.com/gorilla/mux"
log "github.com/sirupsen/logrus"
)

// ListManagedInstancesHandler lists all hybrid (managed) instances in an account
func (s *server) ListManagedInstancesHandler(w http.ResponseWriter, r *http.Request) {
w = LogWriter{w}
vars := mux.Vars(r)
account := s.mapAccountNumber(vars["account"])

log.Infof("listing managed instances in account: %s", account)

role := fmt.Sprintf("arn:aws:iam::%s:role/%s", account, s.session.RoleName)
session, err := s.assumeRole(
r.Context(),
s.session.ExternalID,
role,
"",
"arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess",
)
if err != nil {
msg := fmt.Sprintf("failed to assume role in account: %s", account)
handleError(w, apierror.New(apierror.ErrForbidden, msg, err))
return
}

service := ssm.New(
ssm.WithSession(session.Session),
)

perPage := int64(10) // default value
var pageToken *string
for name, values := range r.URL.Query() {
if name == "next" {
pageToken = aws.String(values[0])
}

if name == "limit" {
limit, err := strconv.ParseInt(values[0], 10, 64)
if err != nil {
handleError(w, apierror.New(apierror.ErrBadRequest, "invalid value for limit parameter", err))
return
}
perPage = limit
}
}

instances, next, err := service.ListManagedInstances(r.Context(), perPage, pageToken)
if err != nil {
handleError(w, err)
return
}

w.Header().Set("X-Items", strconv.Itoa(len(instances)))
if next != nil {
w.Header().Set("X-Per-Page", strconv.FormatInt(perPage, 10))
w.Header().Set("X-Next-Token", aws.StringValue(next))
}

handleResponseOk(w, instances)
}

// GetManagedInstanceHandler gets details about a specific hybrid (managed) instance
func (s *server) GetManagedInstanceHandler(w http.ResponseWriter, r *http.Request) {
w = LogWriter{w}
vars := mux.Vars(r)
account := s.mapAccountNumber(vars["account"])
identifier := vars["id"]

log.Infof("getting managed instance with identifier %s in account: %s", identifier, account)

if identifier == "" {
handleError(w, apierror.New(apierror.ErrBadRequest, "identifier (instance_id or computer_name) is required", nil))
return
}

role := fmt.Sprintf("arn:aws:iam::%s:role/%s", account, s.session.RoleName)
session, err := s.assumeRole(
r.Context(),
s.session.ExternalID,
role,
"",
"arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess",
)
if err != nil {
msg := fmt.Sprintf("failed to assume role in account: %s", account)
handleError(w, apierror.New(apierror.ErrForbidden, msg, err))
return
}

service := ssm.New(
ssm.WithSession(session.Session),
)

instance, err := service.GetManagedInstance(r.Context(), identifier)
if err != nil {
handleError(w, err)
return
}

handleResponseOk(w, instance)
}
2 changes: 2 additions & 0 deletions api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ func (s *server) routes() {

api.HandleFunc("/{account}/instances/{id}/ssm/command", s.InstanceGetCommandHandler).Methods(http.MethodGet).Queries("command_id", "{cid}")
api.HandleFunc("/{account}/instances/{id}/ssm/association", s.DescribeAssociationHandler).Methods(http.MethodGet).Queries("document", "{doc}")
api.HandleFunc("/{account}/ssm/managed-instances", s.ListManagedInstancesHandler).Methods(http.MethodGet)
api.HandleFunc("/{account}/ssm/managed-instances/{id}", s.GetManagedInstanceHandler).Methods(http.MethodGet)

api.HandleFunc("/{account}/sgs", s.SecurityGroupListHandler).Methods(http.MethodGet)
api.HandleFunc("/{account}/sgs/{id}", s.SecurityGroupGetHandler).Methods(http.MethodGet)
Expand Down
144 changes: 144 additions & 0 deletions ssm/managedinstance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package ssm

import (
"context"
"regexp"

"github.com/YaleSpinup/apierror"
"github.com/YaleSpinup/ec2-api/common"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ssm"
log "github.com/sirupsen/logrus"
)

// ManagedInstance represents the response structure for managed instances
type ManagedInstance struct {
InstanceId *string `json:"instanceId"`
Name *string `json:"name"`
PingStatus *string `json:"pingStatus"`
PlatformType *string `json:"platformType"`
ResourceType *string `json:"resourceType"`
ComputerName *string `json:"computerName"`
IPAddress *string `json:"ipAddress"`
AgentVersion *string `json:"agentVersion"`
}

// managedInstanceFilter creates a StringFilter for managed instances
func managedInstanceFilter() *ssm.InstanceInformationStringFilter {
return &ssm.InstanceInformationStringFilter{
Key: aws.String("ResourceType"),
Values: []*string{
aws.String("ManagedInstance"),
},
}
}

// ListManagedInstances lists hybrid instances from SSM Fleet Manager
func (s *SSM) ListManagedInstances(ctx context.Context, per int64, next *string) ([]*ManagedInstance, *string, error) {
if per < 1 || per > 50 {
return nil, nil, apierror.New(apierror.ErrBadRequest, "per page must be between 1 and 50", nil)
}

log.Info("listing managed instances from SSM")

input := &ssm.DescribeInstanceInformationInput{
MaxResults: aws.Int64(per),
NextToken: next,
Filters: []*ssm.InstanceInformationStringFilter{
managedInstanceFilter(),
},
}

out, err := s.Service.DescribeInstanceInformationWithContext(ctx, input)
if err != nil {
return nil, nil, common.ErrCode("listing managed instances", err)
}

log.Debugf("got output from managed instance list: %+v", out)

instances := make([]*ManagedInstance, 0, len(out.InstanceInformationList))
for _, info := range out.InstanceInformationList {
instances = append(instances, convertToManagedInstance(info))
}

return instances, out.NextToken, nil
}

// GetManagedInstance gets details about a specific managed instance by ID or computer name
func (s *SSM) GetManagedInstance(ctx context.Context, identifier string) (*ManagedInstance, error) {
if identifier == "" {
return nil, apierror.New(apierror.ErrBadRequest, "identifier (instance id or computer name) is required", nil)
}

log.Infof("getting managed instance details for identifier: %s", identifier)

// Check if the identifier matches managed instance ID pattern (mi-xxxxxxxxxxxxxxxxx)
if matched, _ := regexp.MatchString(`^mi-\w{17}$`, identifier); matched {
// If it's an instance ID, use direct filter
filters := []*ssm.InstanceInformationStringFilter{
managedInstanceFilter(),
{
Key: aws.String("InstanceId"),
Values: []*string{
aws.String(identifier),
},
},
}

input := &ssm.DescribeInstanceInformationInput{
Filters: filters,
}

out, err := s.Service.DescribeInstanceInformationWithContext(ctx, input)
if err != nil {
return nil, common.ErrCode("getting managed instance", err)
}

if len(out.InstanceInformationList) == 0 {
return nil, apierror.New(apierror.ErrNotFound, "managed instance not found", nil)
}

if len(out.InstanceInformationList) > 1 {
return nil, apierror.New(apierror.ErrBadRequest, "multiple instances found", nil)
}

return convertToManagedInstance(out.InstanceInformationList[0]), nil
}

// If not an instance ID, assume it's a computer name and list all instances
instances, _, err := s.ListManagedInstances(ctx, 50, nil)
if err != nil {
return nil, err
}

var matches []*ManagedInstance
for _, instance := range instances {
if aws.StringValue(instance.ComputerName) == identifier {
matches = append(matches, instance)
}
}

if len(matches) == 0 {
return nil, apierror.New(apierror.ErrNotFound, "managed instance not found", nil)
}

if len(matches) > 1 {
return nil, apierror.New(apierror.ErrBadRequest, "multiple instances found with same computer name", nil)
}

return matches[0], nil
}

// Helper function to convert SSM instance info to our ManagedInstance type
func convertToManagedInstance(info *ssm.InstanceInformation) *ManagedInstance {
return &ManagedInstance{
InstanceId: info.InstanceId,
Name: info.Name,
PingStatus: info.PingStatus,
PlatformType: info.PlatformType,
ResourceType: info.ResourceType,
ComputerName: info.ComputerName,
IPAddress: info.IPAddress,
AgentVersion: info.AgentVersion,
}
}

0 comments on commit 12c6853

Please sign in to comment.