readysite / pkg / platform / providers / aws / server.go
4.7 KB
server.go
package aws

import (
	"crypto/md5"
	"encoding/hex"
	"fmt"
	"time"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/service/ec2"
	"github.com/aws/aws-sdk-go-v2/service/ec2/types"
	"github.com/readysite/readysite/pkg/platform"
)

// CreateServer creates an EC2 instance
func (b *backend) CreateServer(opts platform.ServerOptions) (*platform.Server, error) {
	// Translate size
	instanceType, ok := sizes[opts.Size]
	if !ok {
		return nil, fmt.Errorf("%w: %s", platform.ErrUnsupportedSize, opts.Size)
	}

	// Handle SSH key
	var keyName *string
	if opts.SSHKey != "" {
		name, err := b.getOrCreateSSHKey(opts.SSHKey)
		if err != nil {
			return nil, fmt.Errorf("ssh key: %w", err)
		}
		keyName = &name
	}

	// Build tags
	tags := []types.Tag{
		{Key: aws.String("Name"), Value: aws.String(opts.Name)},
	}
	for _, t := range opts.Tags {
		parts := splitTag(t)
		if len(parts) == 2 {
			tags = append(tags, types.Tag{Key: aws.String(parts[0]), Value: aws.String(parts[1])})
		}
	}

	input := &ec2.RunInstancesInput{
		ImageId:      aws.String(opts.Image),
		InstanceType: types.InstanceType(instanceType),
		MinCount:     aws.Int32(1),
		MaxCount:     aws.Int32(1),
		KeyName:      keyName,
		TagSpecifications: []types.TagSpecification{
			{
				ResourceType: types.ResourceTypeInstance,
				Tags:         tags,
			},
		},
	}

	result, err := b.ec2.RunInstances(b.ctx, input)
	if err != nil {
		return nil, fmt.Errorf("run instances: %w", err)
	}

	if len(result.Instances) == 0 {
		return nil, fmt.Errorf("no instances created")
	}

	instanceID := *result.Instances[0].InstanceId

	// Wait for instance to be running
	server, err := b.waitForInstance(instanceID)
	if err != nil {
		return nil, err
	}

	return server, nil
}

// GetServer retrieves an EC2 instance by name
func (b *backend) GetServer(name string) (*platform.Server, error) {
	input := &ec2.DescribeInstancesInput{
		Filters: []types.Filter{
			{Name: aws.String("tag:Name"), Values: []string{name}},
			{Name: aws.String("instance-state-name"), Values: []string{"pending", "running", "stopping", "stopped"}},
		},
	}

	result, err := b.ec2.DescribeInstances(b.ctx, input)
	if err != nil {
		return nil, fmt.Errorf("describe instances: %w", err)
	}

	for _, reservation := range result.Reservations {
		for _, instance := range reservation.Instances {
			return b.instanceToServer(&instance), nil
		}
	}

	return nil, platform.ErrNotFound
}

// DeleteServer terminates an EC2 instance
func (b *backend) DeleteServer(id string) error {
	input := &ec2.TerminateInstancesInput{
		InstanceIds: []string{id},
	}

	_, err := b.ec2.TerminateInstances(b.ctx, input)
	if err != nil {
		return fmt.Errorf("terminate instance: %w", err)
	}
	return nil
}

// Helper methods

func (b *backend) waitForInstance(instanceID string) (*platform.Server, error) {
	timeout := time.After(5 * time.Minute)
	ticker := time.NewTicker(5 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-timeout:
			return nil, platform.ErrTimeout
		case <-ticker.C:
			input := &ec2.DescribeInstancesInput{
				InstanceIds: []string{instanceID},
			}
			result, err := b.ec2.DescribeInstances(b.ctx, input)
			if err != nil {
				continue
			}
			for _, reservation := range result.Reservations {
				for _, instance := range reservation.Instances {
					if instance.State.Name == types.InstanceStateNameRunning {
						return b.instanceToServer(&instance), nil
					}
				}
			}
		}
	}
}

func (b *backend) instanceToServer(i *types.Instance) *platform.Server {
	name := ""
	for _, tag := range i.Tags {
		if *tag.Key == "Name" {
			name = *tag.Value
			break
		}
	}

	ip := ""
	if i.PublicIpAddress != nil {
		ip = *i.PublicIpAddress
	}

	region := ""
	if i.Placement != nil && i.Placement.AvailabilityZone != nil {
		region = *i.Placement.AvailabilityZone
	}

	return &platform.Server{
		ID:     *i.InstanceId,
		Name:   name,
		IP:     ip,
		Size:   string(i.InstanceType),
		Region: region,
		Status: string(i.State.Name),
	}
}

func (b *backend) getOrCreateSSHKey(publicKey string) (string, error) {
	// Generate a deterministic name from the key
	hash := md5.Sum([]byte(publicKey))
	keyName := "readysite-" + hex.EncodeToString(hash[:8])

	// Check if key exists
	describeInput := &ec2.DescribeKeyPairsInput{
		KeyNames: []string{keyName},
	}
	_, err := b.ec2.DescribeKeyPairs(b.ctx, describeInput)
	if err == nil {
		return keyName, nil
	}

	// Import key
	importInput := &ec2.ImportKeyPairInput{
		KeyName:           aws.String(keyName),
		PublicKeyMaterial: []byte(publicKey),
	}
	_, err = b.ec2.ImportKeyPair(b.ctx, importInput)
	if err != nil {
		return "", fmt.Errorf("import key pair: %w", err)
	}

	return keyName, nil
}

func splitTag(t string) []string {
	for i := 0; i < len(t); i++ {
		if t[i] == '=' {
			return []string{t[:i], t[i+1:]}
		}
	}
	return nil
}
← Back