mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-08-30 19:59:48 +02:00
- Update Next.js generated chunk hashes and build IDs reflecting latest dashboard build - Update CSS stylesheet references in workflow upload page metadata - Add comprehensive cloud setup E2E test suite (cloud_setup_test.go) with SSH password/key auth, post-command variable expansion, and Ansible integration - Fix API priority levels to include 'medium' priority in test coverage - Add agent-sdk test workflows (minimal, config, codex, multi-agent, session variants) - Update E2E test utilities with runCLIInBase helper for multi-step cloud config tests - Fix stderr/stdout capture in dependencies_target_types_test assertions
385 lines
11 KiB
Go
385 lines
11 KiB
Go
package cloud
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
|
"github.com/aws/aws-sdk-go-v2/service/ec2"
|
|
ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
|
|
"github.com/aws/aws-sdk-go-v2/service/sts"
|
|
"github.com/pulumi/pulumi-aws/sdk/v6/go/aws"
|
|
awsec2 "github.com/pulumi/pulumi-aws/sdk/v6/go/aws/ec2"
|
|
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
|
|
)
|
|
|
|
// AWSProvider implements the Provider interface for AWS
|
|
type AWSProvider struct {
|
|
accessKeyID string
|
|
secretAccessKey string
|
|
region string
|
|
instanceType string
|
|
ami string
|
|
amiFilter string
|
|
useSpot bool
|
|
}
|
|
|
|
// NewAWSProvider creates a new AWS provider
|
|
func NewAWSProvider(accessKeyID, secretAccessKey, region, instanceType, ami, amiFilter string, useSpot bool) (*AWSProvider, error) {
|
|
if accessKeyID == "" {
|
|
return nil, fmt.Errorf("AWS access key ID is required")
|
|
}
|
|
if secretAccessKey == "" {
|
|
return nil, fmt.Errorf("AWS secret access key is required")
|
|
}
|
|
|
|
if region == "" {
|
|
region = "us-east-1"
|
|
}
|
|
if instanceType == "" {
|
|
instanceType = "t3.medium"
|
|
}
|
|
|
|
if amiFilter == "" {
|
|
amiFilter = "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"
|
|
}
|
|
|
|
return &AWSProvider{
|
|
accessKeyID: accessKeyID,
|
|
secretAccessKey: secretAccessKey,
|
|
region: region,
|
|
instanceType: instanceType,
|
|
ami: ami,
|
|
amiFilter: amiFilter,
|
|
useSpot: useSpot,
|
|
}, nil
|
|
}
|
|
|
|
// Validate checks if the provider configuration is valid
|
|
func (p *AWSProvider) Validate(ctx context.Context) error {
|
|
cfg, err := awsconfig.LoadDefaultConfig(ctx,
|
|
awsconfig.WithRegion(p.region),
|
|
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(p.accessKeyID, p.secretAccessKey, "")),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load AWS config: %w", err)
|
|
}
|
|
|
|
_, err = sts.NewFromConfig(cfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to validate AWS credentials: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// EstimateCost estimates the cost for the given configuration
|
|
func (p *AWSProvider) EstimateCost(mode ExecutionMode, instanceCount int) (*CostEstimate, error) {
|
|
if mode != ModeVM {
|
|
return nil, fmt.Errorf("only VM mode is supported for AWS")
|
|
}
|
|
|
|
// Default pricing for common instance types (USD per hour)
|
|
pricing := map[string]float64{
|
|
"t3.micro": 0.0104,
|
|
"t3.small": 0.0208,
|
|
"t3.medium": 0.0416,
|
|
"t3.large": 0.0832,
|
|
"t3.xlarge": 0.1664,
|
|
"m5.large": 0.0960,
|
|
"m5.xlarge": 0.1920,
|
|
"c5.large": 0.0850,
|
|
"c5.xlarge": 0.1700,
|
|
}
|
|
|
|
hourlyRate, ok := pricing[p.instanceType]
|
|
if !ok {
|
|
// Default to t3.medium pricing if unknown
|
|
hourlyRate = 0.0416
|
|
}
|
|
|
|
notes := []string{
|
|
fmt.Sprintf("%d x %s instances @ $%.4f/hr each", instanceCount, p.instanceType, hourlyRate),
|
|
}
|
|
|
|
if p.useSpot {
|
|
hourlyRate *= 0.4 // 60% discount for spot instances
|
|
notes = append(notes, "Using spot instances (estimated 60% discount)")
|
|
}
|
|
|
|
totalHourlyRate := hourlyRate * float64(instanceCount)
|
|
|
|
return &CostEstimate{
|
|
HourlyCost: totalHourlyRate,
|
|
DailyCost: totalHourlyRate * 24,
|
|
Currency: "USD",
|
|
Breakdown: map[string]float64{
|
|
"compute": totalHourlyRate,
|
|
},
|
|
Notes: notes,
|
|
}, nil
|
|
}
|
|
|
|
// CreateInfrastructure provisions AWS EC2 instances
|
|
func (p *AWSProvider) CreateInfrastructure(ctx context.Context, opts *CreateOptions) (*Infrastructure, error) {
|
|
infraID := fmt.Sprintf("cloud-aws-%d", time.Now().Unix())
|
|
statePath := opts.StatePath
|
|
|
|
pm, err := NewPulumiManager("osmedeus-cloud", infraID, statePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create Pulumi manager: %w", err)
|
|
}
|
|
|
|
// Set provider credentials
|
|
if err := pm.SetConfig(ctx, "aws:region", p.region, false); err != nil {
|
|
return nil, fmt.Errorf("failed to set AWS region: %w", err)
|
|
}
|
|
if err := pm.SetConfig(ctx, "aws:accessKey", p.accessKeyID, true); err != nil {
|
|
return nil, fmt.Errorf("failed to set AWS access key: %w", err)
|
|
}
|
|
if err := pm.SetConfig(ctx, "aws:secretKey", p.secretAccessKey, true); err != nil {
|
|
return nil, fmt.Errorf("failed to set AWS secret key: %w", err)
|
|
}
|
|
|
|
// Run Pulumi program
|
|
if err := pm.Up(ctx, p.createInstanceProgram(infraID, opts)); err != nil {
|
|
return nil, fmt.Errorf("failed to provision EC2 instances: %w", err)
|
|
}
|
|
|
|
// Extract outputs
|
|
outputs, err := pm.GetOutputs(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get outputs: %w", err)
|
|
}
|
|
|
|
infra := &Infrastructure{
|
|
ID: infraID,
|
|
Provider: ProviderAWS,
|
|
Mode: opts.Mode,
|
|
CreatedAt: time.Now(),
|
|
PulumiStackID: pm.GetStackName(),
|
|
StatePath: statePath,
|
|
Resources: buildResourcesFromOutputs(infraID, opts.InstanceCount, outputs),
|
|
Metadata: map[string]interface{}{
|
|
"region": p.region,
|
|
"instance_type": p.instanceType,
|
|
"ssh_user": "ubuntu",
|
|
},
|
|
}
|
|
|
|
return infra, nil
|
|
}
|
|
|
|
// DestroyInfrastructure tears down AWS resources
|
|
func (p *AWSProvider) DestroyInfrastructure(ctx context.Context, infra *Infrastructure) error {
|
|
statePath := infra.StatePath
|
|
|
|
pm, err := NewPulumiManager("osmedeus-cloud", infra.PulumiStackID, statePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create Pulumi manager: %w", err)
|
|
}
|
|
|
|
if err := pm.SetConfig(ctx, "aws:region", p.region, false); err != nil {
|
|
return fmt.Errorf("failed to set AWS region: %w", err)
|
|
}
|
|
if err := pm.SetConfig(ctx, "aws:accessKey", p.accessKeyID, true); err != nil {
|
|
return fmt.Errorf("failed to set AWS access key: %w", err)
|
|
}
|
|
if err := pm.SetConfig(ctx, "aws:secretKey", p.secretAccessKey, true); err != nil {
|
|
return fmt.Errorf("failed to set AWS secret key: %w", err)
|
|
}
|
|
|
|
return pm.Destroy(ctx)
|
|
}
|
|
|
|
// GetStatus retrieves the current status of infrastructure
|
|
func (p *AWSProvider) GetStatus(ctx context.Context, infra *Infrastructure) (*InfraStatus, error) {
|
|
cfg, err := awsconfig.LoadDefaultConfig(ctx,
|
|
awsconfig.WithRegion(p.region),
|
|
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(p.accessKeyID, p.secretAccessKey, "")),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load AWS config: %w", err)
|
|
}
|
|
|
|
ec2Client := ec2.NewFromConfig(cfg)
|
|
|
|
status := &InfraStatus{
|
|
Status: "running",
|
|
TotalCount: len(infra.Resources),
|
|
Details: make([]ResourceStatus, 0, len(infra.Resources)),
|
|
}
|
|
|
|
// Collect instance IDs
|
|
instanceIDs := make([]string, 0, len(infra.Resources))
|
|
for _, res := range infra.Resources {
|
|
if res.ID != "" {
|
|
instanceIDs = append(instanceIDs, res.ID)
|
|
}
|
|
}
|
|
|
|
if len(instanceIDs) == 0 {
|
|
return status, nil
|
|
}
|
|
|
|
// Describe instances
|
|
describeOutput, err := ec2Client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{
|
|
InstanceIds: instanceIDs,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to describe instances: %w", err)
|
|
}
|
|
|
|
// Build a map of instance ID -> state
|
|
instanceStates := make(map[string]ec2types.InstanceStateName)
|
|
for _, reservation := range describeOutput.Reservations {
|
|
for _, instance := range reservation.Instances {
|
|
if instance.InstanceId != nil && instance.State != nil {
|
|
instanceStates[*instance.InstanceId] = instance.State.Name
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, res := range infra.Resources {
|
|
rs := ResourceStatus{
|
|
ResourceID: res.ID,
|
|
WorkerRegistered: res.WorkerID != "",
|
|
}
|
|
|
|
if res.WorkerID != "" {
|
|
status.WorkersRegistered++
|
|
}
|
|
|
|
if state, ok := instanceStates[res.ID]; ok {
|
|
rs.Status = string(state)
|
|
if state == ec2types.InstanceStateNameRunning {
|
|
status.ReadyCount++
|
|
}
|
|
} else {
|
|
rs.Status = "unknown"
|
|
rs.Message = "instance not found"
|
|
}
|
|
|
|
status.Details = append(status.Details, rs)
|
|
}
|
|
|
|
return status, nil
|
|
}
|
|
|
|
// Type returns the provider type
|
|
func (p *AWSProvider) Type() ProviderType {
|
|
return ProviderAWS
|
|
}
|
|
|
|
// createInstanceProgram creates a Pulumi program for AWS EC2 instances
|
|
func (p *AWSProvider) createInstanceProgram(infraID string, opts *CreateOptions) pulumi.RunFunc {
|
|
suffix := infraSuffix(infraID)
|
|
|
|
return func(ctx *pulumi.Context) error {
|
|
|
|
userData := GenerateCloudInit(opts.RedisURL, opts.SSHPublicKey, opts.SetupCommands)
|
|
|
|
// Determine AMI
|
|
ami := p.ami
|
|
if opts.ImageID != "" {
|
|
ami = opts.ImageID
|
|
}
|
|
if ami == "" {
|
|
// Look up AMI using the configured filter
|
|
mostRecent := true
|
|
amiLookup, err := awsec2.LookupAmi(ctx, &awsec2.LookupAmiArgs{
|
|
Filters: []awsec2.GetAmiFilter{
|
|
{
|
|
Name: "name",
|
|
Values: []string{p.amiFilter},
|
|
},
|
|
{
|
|
Name: "virtualization-type",
|
|
Values: []string{"hvm"},
|
|
},
|
|
},
|
|
Owners: []string{"099720109477"}, // Canonical
|
|
MostRecent: &mostRecent,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to look up Ubuntu AMI: %w", err)
|
|
}
|
|
ami = amiLookup.Id
|
|
}
|
|
|
|
// Use aws.GetRegion to reference the aws provider package
|
|
regionResult, err := aws.GetRegion(ctx, &aws.GetRegionArgs{})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get AWS region: %w", err)
|
|
}
|
|
|
|
// Create security group allowing SSH inbound and all outbound
|
|
sg, err := awsec2.NewSecurityGroup(ctx, "osmedeus-sg", &awsec2.SecurityGroupArgs{
|
|
Description: pulumi.String("Osmedeus worker security group"),
|
|
Ingress: awsec2.SecurityGroupIngressArray{
|
|
&awsec2.SecurityGroupIngressArgs{
|
|
Protocol: pulumi.String("tcp"),
|
|
FromPort: pulumi.Int(22),
|
|
ToPort: pulumi.Int(22),
|
|
CidrBlocks: pulumi.StringArray{pulumi.String("0.0.0.0/0")},
|
|
},
|
|
},
|
|
Egress: awsec2.SecurityGroupEgressArray{
|
|
&awsec2.SecurityGroupEgressArgs{
|
|
Protocol: pulumi.String("-1"),
|
|
FromPort: pulumi.Int(0),
|
|
ToPort: pulumi.Int(0),
|
|
CidrBlocks: pulumi.StringArray{pulumi.String("0.0.0.0/0")},
|
|
},
|
|
},
|
|
Tags: pulumi.StringMap{
|
|
"Name": pulumi.String(fmt.Sprintf("osmedeus-workers-%s", suffix)),
|
|
"Region": pulumi.String(regionResult.Name),
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create security group: %w", err)
|
|
}
|
|
|
|
// Create key pair
|
|
keyPair, err := awsec2.NewKeyPair(ctx, "osmedeus-key", &awsec2.KeyPairArgs{
|
|
KeyName: pulumi.String(fmt.Sprintf("osmedeus-key-%s", suffix)),
|
|
PublicKey: pulumi.String(opts.SSHPublicKey),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create key pair: %w", err)
|
|
}
|
|
|
|
// Create instances
|
|
for i := 0; i < opts.InstanceCount; i++ {
|
|
instanceName := fmt.Sprintf("osmw-%s-%d", suffix, i)
|
|
|
|
instance, err := awsec2.NewInstance(ctx, instanceName, &awsec2.InstanceArgs{
|
|
Ami: pulumi.String(ami),
|
|
InstanceType: pulumi.String(p.instanceType),
|
|
KeyName: keyPair.KeyName,
|
|
VpcSecurityGroupIds: pulumi.StringArray{sg.ID()},
|
|
UserData: pulumi.String(userData),
|
|
AssociatePublicIpAddress: pulumi.Bool(true),
|
|
Tags: pulumi.StringMap{
|
|
"Name": pulumi.String(instanceName),
|
|
"Project": pulumi.String("osmedeus"),
|
|
"Role": pulumi.String("worker"),
|
|
"Index": pulumi.String(strconv.Itoa(i)),
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create instance %s: %w", instanceName, err)
|
|
}
|
|
|
|
ctx.Export(fmt.Sprintf("worker-%d-ip", i), instance.PublicIp)
|
|
ctx.Export(fmt.Sprintf("worker-%d-id", i), instance.ID())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|