diff --git a/cmd/ctrlc/root/sync/aws/ec2/ec2.go b/cmd/ctrlc/root/sync/aws/ec2/ec2.go new file mode 100644 index 0000000..d5c3221 --- /dev/null +++ b/cmd/ctrlc/root/sync/aws/ec2/ec2.go @@ -0,0 +1,273 @@ +package ec2 + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/charmbracelet/log" + "github.com/ctrlplanedev/cli/internal/api" + "github.com/ctrlplanedev/cli/internal/cliutil" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type ConnectionMethod struct { + Type string `json:"type"` + Region string `json:"region"` + InstanceID string `json:"instanceId"` + AccountID string `json:"accountId"` +} + +type EC2Instance struct { + ID string `json:"id"` + Name string `json:"name"` + ConnectionMethod ConnectionMethod `json:"connectionMethod"` +} + +func (t *EC2Instance) Struct() map[string]interface{} { + b, _ := json.Marshal(t) + var m map[string]interface{} + json.Unmarshal(b, &m) + return m +} + +func NewSyncEC2Cmd() *cobra.Command { + var region string + var name string + cmd := &cobra.Command{ + Use: "aws-ec2", + Short: "Sync AWS EC2 instances into Ctrlplane", + Example: heredoc.Doc(` + # Make sure AWS credentials are configured via environment variables or ~/.aws/credentials + + # Sync all EC2 instances from a region + $ ctrlc sync ec2 --region us-west-2 --workspace 2a7c5560-75c9-4dbe-be74-04ee33bf8188 + `), + PreRunE: func(cmd *cobra.Command, args []string) error { + if region == "" { + return fmt.Errorf("region is required") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + log.Info("Syncing EC2 instances into Ctrlplane", "config-region", region) + + ctx := context.Background() + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region)) + if err != nil { + return fmt.Errorf("failed to load AWS config: %w", err) + } + + credentials, err := cfg.Credentials.Retrieve(ctx) + if err != nil { + return fmt.Errorf("failed to retrieve AWS credentials: %w", err) + } + + log.Info("AWS credentials loaded successfully", + "provider", credentials.Source, + "region", region, + "access_key_id", credentials.AccessKeyID[:4]+"****", + "expiration", credentials.Expires, + "type", credentials.Source, + "profile", os.Getenv("AWS_PROFILE"), + ) + + // Create EC2 client with retry options + ec2Client := ec2.NewFromConfig(cfg, func(o *ec2.Options) { + o.RetryMaxAttempts = 3 + o.RetryMode = aws.RetryModeStandard + }) + + apiURL := viper.GetString("url") + apiKey := viper.GetString("api-key") + workspaceId := viper.GetString("workspace") + + // Get EC2 instances + result, err := ec2Client.DescribeInstances(ctx, &ec2.DescribeInstancesInput{}) + if err != nil { + return fmt.Errorf("failed to describe instances: %w", err) + } + + resources := []api.AgentResource{} + for _, reservation := range result.Reservations { + accountId := *reservation.OwnerId + for _, instance := range reservation.Instances { + tags := make(map[string]string) + + for _, tag := range instance.Tags { + tags[*tag.Key] = *tag.Value + } + + // Get instance name from tags + name := tags["Name"] + if name == "" { + name = *instance.InstanceId + } + + // Get EC2 region from instance availability zone + region := "" + if instance.Placement != nil && instance.Placement.AvailabilityZone != nil { + // Region is AZ without the last character + region = (*instance.Placement.AvailabilityZone)[:len(*instance.Placement.AvailabilityZone)-1] + } + + instanceData := EC2Instance{ + ID: *instance.InstanceId, + Name: name, + ConnectionMethod: ConnectionMethod{ + Type: "aws", + Region: region, + InstanceID: *instance.InstanceId, + AccountID: accountId, + }, + } + + // Add AWS Console URL for the instance + consoleUrl := fmt.Sprintf("https://%s.console.aws.amazon.com/ec2/home?region=%s#InstanceDetails:instanceId=%s", + region, + region, + *instance.InstanceId) + + metadata := make(map[string]string) + for _, tag := range instance.Tags { + if tag.Key != nil && tag.Value != nil { + metadata[*tag.Key] = *tag.Value + metadata["compute/tag/"+*tag.Key] = *tag.Value + metadata["aws/tag/"+*tag.Key] = *tag.Value + } + } + + metadata["compute/machine-type"] = string(instance.InstanceType) + metadata["compute/region"] = region + metadata["compute/type"] = "standard" + metadata["compute/architecture"] = strings.ReplaceAll(string(instance.Architecture), "_mac", "") + metadata["compute/boot-mode"] = string(instance.BootMode) + + if instance.PlatformDetails != nil { + metadata["compute/platform"] = *instance.PlatformDetails + } + + if instance.CpuOptions != nil && instance.CpuOptions.CoreCount != nil { + metadata["compute/cpu-cores"] = strconv.Itoa(int(*instance.CpuOptions.CoreCount)) + if instance.CpuOptions.ThreadsPerCore != nil { + metadata["compute/cpu-threads-per-core"] = strconv.Itoa(int(*instance.CpuOptions.ThreadsPerCore)) + metadata["compute/cpu-threads"] = strconv.Itoa(int(*instance.CpuOptions.ThreadsPerCore) * int(*instance.CpuOptions.CoreCount)) + } + } + metadata["compute/hypervisor"] = string(instance.Hypervisor) + + if instance.State != nil { + metadata["compute/state"] = string(instance.State.Name) + } + + if instance.LaunchTime != nil { + metadata["compute/launch-time"] = instance.LaunchTime.Format(time.RFC3339) + } + + if instance.PrivateIpAddress != nil { + metadata["network/private-ip"] = *instance.PrivateIpAddress + } + + if instance.PublicIpAddress != nil { + metadata["network/public-ip"] = *instance.PublicIpAddress + } + + if instance.PrivateDnsName != nil { + metadata["network/private-dns"] = *instance.PrivateDnsName + } + + if instance.PublicDnsName != nil { + metadata["network/public-dns"] = *instance.PublicDnsName + } + + metadata["aws/account-id"] = accountId + metadata["aws/region"] = region + + if instance.VpcId != nil { + metadata["aws/vpc-id"] = *instance.VpcId + } + if instance.PlatformDetails != nil { + metadata["aws/platform-details"] = string(*instance.PlatformDetails) + } + if instance.InstanceId != nil { + metadata["aws/instance-id"] = *instance.InstanceId + } + + if instance.ImageId != nil { + metadata["aws/ami-id"] = *instance.ImageId + } + if instance.AmiLaunchIndex != nil { + metadata["aws/ami-launch-index"] = strconv.Itoa(int(*instance.AmiLaunchIndex)) + } + + if instance.KeyName != nil { + metadata["aws/key-name"] = *instance.KeyName + } + + if instance.EbsOptimized != nil { + metadata["aws/ebs-optimized"] = strconv.FormatBool(*instance.EbsOptimized) + } + + if instance.EnaSupport != nil { + metadata["aws/ena-support"] = strconv.FormatBool(*instance.EnaSupport) + } + + if instance.SubnetId != nil { + metadata["aws/subnet-id"] = *instance.SubnetId + } + + metadata["ctrlplane/links"] = fmt.Sprintf("{ \"AWS Console\": \"%s\" }", consoleUrl) + + // Get ARN for the instance + arn := fmt.Sprintf("arn:aws:ec2:%s:%s:instance/%s", region, accountId, *instance.InstanceId) + resources = append(resources, api.AgentResource{ + Version: "compute/v1", + Kind: "Instance", + Name: name, + Identifier: arn, + Config: instanceData.Struct(), + Metadata: metadata, + }) + } + } + + // Create or update resource provider + if name == "" { + name = fmt.Sprintf("aws-ec2-region-%s", region) + } + + ctrlplaneClient, err := api.NewAPIKeyClientWithResponses(apiURL, apiKey) + if err != nil { + return fmt.Errorf("failed to create API client: %w", err) + } + + rp, err := api.NewResourceProvider(ctrlplaneClient, workspaceId, name) + if err != nil { + return fmt.Errorf("failed to create resource provider: %w", err) + } + + upsertResp, err := rp.UpsertResource(ctx, resources) + log.Info("Response from upserting resources", "status", upsertResp.Status) + if err != nil { + return fmt.Errorf("failed to upsert resources: %w", err) + } + + return cliutil.HandleResponseOutput(cmd, upsertResp) + }, + } + + cmd.Flags().StringVarP(&name, "provider", "p", "", "Name of the resource provider") + cmd.Flags().StringVarP(®ion, "region", "c", "", "AWS Region") + cmd.MarkFlagRequired("region") + + return cmd +} diff --git a/cmd/ctrlc/root/sync/sync.go b/cmd/ctrlc/root/sync/sync.go index 6934485..b776883 100644 --- a/cmd/ctrlc/root/sync/sync.go +++ b/cmd/ctrlc/root/sync/sync.go @@ -2,6 +2,7 @@ package sync import ( "github.com/MakeNowJust/heredoc/v2" + "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/aws/ec2" "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/clickhouse" "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/tailscale" "github.com/ctrlplanedev/cli/cmd/ctrlc/root/sync/terraform" @@ -27,6 +28,7 @@ func NewSyncCmd() *cobra.Command { cmd.AddCommand(cliutil.AddIntervalSupport(terraform.NewSyncTerraformCmd(), "")) cmd.AddCommand(cliutil.AddIntervalSupport(tailscale.NewSyncTailscaleCmd(), "")) cmd.AddCommand(cliutil.AddIntervalSupport(clickhouse.NewSyncClickhouseCmd(), "")) + cmd.AddCommand(cliutil.AddIntervalSupport(ec2.NewSyncEC2Cmd(), "")) return cmd } diff --git a/go.mod b/go.mod index ac56b8e..288595f 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,20 @@ require ( require ( github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect + github.com/aws/aws-sdk-go-v2/config v1.29.14 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ec2 v1.211.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect + github.com/aws/smithy-go v1.22.2 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/lipgloss v0.10.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect diff --git a/go.sum b/go.sum index 3fdb145..abfe8c1 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,34 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7D github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= +github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= +github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= +github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= +github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= +github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= +github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.211.3 h1:4dPHqFVVvFG+ntkVUXrMrY55+E5dzFfEpjFWdkdSxnc= +github.com/aws/aws-sdk-go-v2/service/ec2 v1.211.3/go.mod h1:ouvGEfHbLaIlWwpDpOVWPWR+YwO0HDv3vm5tYLq8ImY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= +github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= +github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=