diff --git a/cmd/command.go b/cmd/command.go index 447759f..71c6f96 100644 --- a/cmd/command.go +++ b/cmd/command.go @@ -3,12 +3,17 @@ package cmd import ( "context" "fmt" + "net" "os" + "os/exec" + "strconv" + "time" "github.com/loft-sh/devpod-provider-aws/pkg/aws" "github.com/loft-sh/devpod/pkg/log" "github.com/loft-sh/devpod/pkg/provider" "github.com/loft-sh/devpod/pkg/ssh" + devssh "github.com/loft-sh/devpod/pkg/ssh" "github.com/spf13/cobra" ) @@ -69,6 +74,53 @@ func (cmd *CommandCmd) Run( return fmt.Errorf("instance %s doesn't exist", providerAws.Config.MachineID) } + if providerAws.Config.UseInstanceConnectEndpoint { + instanceID := *instance.Reservations[0].Instances[0].InstanceId + endpointID := providerAws.Config.InstanceConnectEndpointID + + var err error + port, err := findAvailablePort() + if err != nil { + return err + } + addr := "localhost:" + port + cancelCtx, cancel := context.WithCancel(ctx) + defer cancel() + connectArgs := []string{ + "ec2-instance-connect", + "open-tunnel", + "--instance-id", instanceID, + "--local-port", port, + } + if endpointID != "" { + connectArgs = append(connectArgs, "--instance-connect-endpoint-id", endpointID) + } + cmd := exec.CommandContext(cancelCtx, "aws", connectArgs...) + // open tunnel in background + if err = cmd.Start(); err != nil { + return fmt.Errorf("start tunnel: %w", err) + } + defer func() { + err = cmd.Process.Kill() + }() + + timeoutCtx, cancelFn := context.WithTimeout(ctx, 30*time.Second) + defer cancelFn() + waitForPort(timeoutCtx, addr) + + client, err := devssh.NewSSHClient("devpod", addr, privateKey) + if err != nil { + return err + } + + err = devssh.Run(ctx, client, command, os.Stdin, os.Stdout, os.Stderr) + if err != nil { + return err + } + + return err + } + // try public ip if instance.Reservations[0].Instances[0].PublicIpAddress != nil { ip := *instance.Reservations[0].Instances[0].PublicIpAddress @@ -104,3 +156,30 @@ func (cmd *CommandCmd) Run( providerAws.Config.MachineID, ) } + +func waitForPort(ctx context.Context, addr string) { + for { + select { + case <-ctx.Done(): + return + default: + l, err := net.Listen("tcp", addr) + if err != nil { + // port is taken + return + } + _ = l.Close() + time.Sleep(1 * time.Second) + } + } + +} +func findAvailablePort() (string, error) { + l, err := net.Listen("tcp", ":0") + if err != nil { + return "", err + } + defer l.Close() + + return strconv.Itoa(l.Addr().(*net.TCPAddr).Port), nil +} diff --git a/hack/build.sh b/hack/build.sh old mode 100644 new mode 100755 index 1674a43..a53675f --- a/hack/build.sh +++ b/hack/build.sh @@ -22,6 +22,16 @@ fi GO_BUILD_CMD="go build" GO_BUILD_LDFLAGS="-s -w" +BUILD_VERSION="prod" +for arg in "$@"; do + if [ "$arg" == "--dev" ]; then + BUILD_VERSION="dev" + break + fi +done + +echo "Building version: ${BUILD_VERSION}" + if [[ -z "${PROVIDER_BUILD_PLATFORMS}" ]]; then PROVIDER_BUILD_PLATFORMS="linux windows darwin" fi @@ -60,4 +70,4 @@ for OS in ${PROVIDER_BUILD_PLATFORMS[@]}; do done # generate provider.yaml -go run -mod vendor "${PROVIDER_ROOT}/hack/provider/main.go" ${RELEASE_VERSION} > "${PROVIDER_ROOT}/release/provider.yaml" +go run -mod vendor "${PROVIDER_ROOT}/hack/provider/main.go" ${RELEASE_VERSION} ${BUILD_VERSION} ${PROVIDER_ROOT} > "${PROVIDER_ROOT}/release/provider.yaml" diff --git a/hack/provider/main.go b/hack/provider/main.go index abf6194..b60ccf3 100644 --- a/hack/provider/main.go +++ b/hack/provider/main.go @@ -18,19 +18,27 @@ var checksumMap = map[string]string{ } func main() { - if len(os.Args) != 2 { + if len(os.Args) != 4 { fmt.Fprintln(os.Stderr, "Expected version as argument") os.Exit(1) return } - content, err := os.ReadFile("./hack/provider/provider.yaml") + releaseVersion := os.Args[1] + buildVersion := os.Args[2] + projectRoot := os.Args[3] + + content, err := os.ReadFile(providerConfigPath(buildVersion)) if err != nil { panic(err) } - replaced := strings.Replace(string(content), "##VERSION##", os.Args[1], -1) + replaced := strings.Replace(string(content), "##VERSION##", releaseVersion, -1) + + if buildVersion == "dev" { + replaced = strings.Replace(replaced, "##PROJECT_ROOT##", projectRoot, -1) + } for k, v := range checksumMap { checksum, err := File(k) @@ -53,7 +61,6 @@ func File(filePath string) (string, error) { defer file.Close() hash := sha256.New() - _, err = io.Copy(hash, file) if err != nil { return "", err @@ -61,3 +68,11 @@ func File(filePath string) (string, error) { return strings.ToLower(hex.EncodeToString(hash.Sum(nil))), nil } + +func providerConfigPath(buildVersion string) string { + if buildVersion == "prod" { + return "./hack/provider/provider.yaml" + } else { + return "./hack/provider/provider-dev.yaml" + } +} diff --git a/hack/provider/provider-dev.yaml b/hack/provider/provider-dev.yaml new file mode 100644 index 0000000..76a2089 --- /dev/null +++ b/hack/provider/provider-dev.yaml @@ -0,0 +1,299 @@ +name: aws +version: ##VERSION## +description: |- + DevPod on AWS Cloud +icon: https://devpod.sh/assets/aws.svg +iconDark: https://devpod.sh/assets/aws_dark.svg +optionGroups: + - options: + - AWS_ACCESS_KEY_ID + - AWS_SECRET_ACCESS_KEY + - AWS_PROFILE + - AWS_AMI + - AWS_DISK_SIZE + - AWS_ROOT_DEVICE + - AWS_INSTANCE_TYPE + - AWS_VPC_ID + - AWS_SUBNET_ID + - AWS_SECURITY_GROUP_ID + - AWS_INSTANCE_PROFILE_ARN + - AWS_INSTANCE_TAGS + - AWS_USE_INSTANCE_CONNECT_ENDPOINT + - AWS_INSTANCE_CONNECT_ENDPOINT_ID + name: "AWS options" + defaultVisible: false + - options: + - AGENT_PATH + - INACTIVITY_TIMEOUT + - INJECT_DOCKER_CREDENTIALS + - INJECT_GIT_CREDENTIALS + name: "Agent options" + defaultVisible: false +options: + AWS_REGION: + suggestions: + - ap-south-1 + - eu-north-1 + - eu-west-3 + - eu-west-2 + - eu-west-1 + - ap-northeast-3 + - ap-northeast-2 + - ap-northeast-1 + - ca-central-1 + - sa-east-1 + - ap-southeast-1 + - ap-southeast-2 + - eu-central-1 + - us-east-1 + - us-east-2 + - us-west-1 + - us-west-2 + description: The aws cloud region to create the VM in. E.g. us-west-1 + required: true + command: printf "%s" "${AWS_DEFAULT_REGION:-$(aws configure get region)}" || true + AWS_ACCESS_KEY_ID: + description: The aws access key id + required: false + command: printf "%s" "${AWS_ACCESS_KEY_ID:-}" + AWS_SECRET_ACCESS_KEY: + description: The aws secret access key + required: false + command: printf "%s" "${AWS_SECRET_ACCESS_KEY:-}" + AWS_PROFILE: + description: The aws profile name to use + required: false + command: printf "%s" "${AWS_PROFILE:-default}" + AWS_DISK_SIZE: + description: The disk size to use. + default: "40" + AWS_ROOT_DEVICE: + description: The root device of the disk image. + default: "" + AWS_VPC_ID: + description: The vpc id to use. + default: "" + AWS_SUBNET_ID: + description: The subnet id to use. + default: "" + AWS_SECURITY_GROUP_ID: + description: The security group id to use. Multiple can be specified by separating with a comma. + default: "" + AWS_AMI: + description: The disk image to use. + default: "" + AWS_INSTANCE_PROFILE_ARN: + description: The instance profile ARN to use + default: "" + AWS_INSTANCE_TAGS: + description: Additional flags to add to the instance in the form of "Name=XXX,Value=YYY Name=ZZZ,Value=WWW" + default: "" + AWS_INSTANCE_TYPE: + description: The machine type to use. + default: "c5.xlarge" + suggestions: + - t2.2xlarge + - t2.large + - t2.medium + - t2.nano + - t2.small + - t2.xlarge + - t3.2xlarge + - t3.large + - t3.medium + - t3.nano + - t3.small + - t3.xlarge + - t3a.2xlarge + - t3a.large + - t3a.medium + - t3a.nano + - t3a.small + - t3a.xlarge + - t4g.2xlarge + - t4g.large + - t4g.medium + - t4g.nano + - t4g.small + - t4g.xlarge + - c4.2xlarge + - c4.4xlarge + - c4.8xlarge + - c4.large + - c4.xlarge + - c5.12xlarge + - c5.18xlarge + - c5.24xlarge + - c5.2xlarge + - c5.4xlarge + - c5.9xlarge + - c5.large + - c5.xlarge + - c5a.12xlarge + - c5a.16xlarge + - c5a.24xlarge + - c5a.2xlarge + - c5a.4xlarge + - c5a.8xlarge + - c5a.large + - c5a.xlarge + - c6a.12xlarge + - c6a.16xlarge + - c6a.24xlarge + - c6a.2xlarge + - c6a.32xlarge + - c6a.48xlarge + - c6a.4xlarge + - c6a.8xlarge + - c6a.large + - c6a.xlarge + - c6g.12xlarge + - c6g.16xlarge + - c6g.2xlarge + - c6g.4xlarge + - c6g.8xlarge + - c6g.large + - c6g.medium + - c6g.xlarge + - c6i.12xlarge + - c6i.16xlarge + - c6i.24xlarge + - c6i.2xlarge + - c6i.32xlarge + - c6i.4xlarge + - c6i.8xlarge + - c6i.large + - c6i.xlarge + - c7g.12xlarge + - c7g.16xlarge + - c7g.2xlarge + - c7g.4xlarge + - c7g.8xlarge + - c7g.large + - c7g.medium + - c7g.xlarge + - cc2.8xlarge + - m4.10xlarge + - m4.16xlarge + - m4.2xlarge + - m4.4xlarge + - m4.large + - m4.xlarge + - m5.12xlarge + - m5.16xlarge + - m5.24xlarge + - m5.2xlarge + - m5.4xlarge + - m5.8xlarge + - m5.large + - m5.xlarge + - m5a.12xlarge + - m5a.16xlarge + - m5a.24xlarge + - m5a.2xlarge + - m5a.4xlarge + - m5a.8xlarge + - m5a.large + - m5a.xlarge + - m6a.12xlarge + - m6a.16xlarge + - m6a.24xlarge + - m6a.2xlarge + - m6a.32xlarge + - m6a.48xlarge + - m6a.4xlarge + - m6a.8xlarge + - m6a.large + - m6a.xlarge + - m6g.12xlarge + - m6g.16xlarge + - m6g.2xlarge + - m6g.4xlarge + - m6g.8xlarge + - m6g.large + - m6g.medium + - m6g.xlarge + - m6i.12xlarge + - m6i.16xlarge + - m6i.24xlarge + - m6i.2xlarge + - m6i.32xlarge + - m6i.4xlarge + - m6i.8xlarge + - m6i.large + - m6i.xlarge + - m7g.12xlarge + - m7g.16xlarge + - m7g.2xlarge + - m7g.4xlarge + - m7g.8xlarge + - m7g.large + - m7g.medium + - m7g.xlarge + AWS_USE_INSTANCE_CONNECT_ENDPOINT: + description: "If defined, will try to connect to the ec2 instance via the default instance connect endpoint for the current subnet" + type: boolean + default: false + AWS_INSTANCE_CONNECT_ENDPOINT_ID: + description: "Specify which instance connect endpoint to use. Only works with AWS_USE_INSTANCE_CONNECT_ENDPOINT enabled" + default: "" + INACTIVITY_TIMEOUT: + description: If defined, will automatically stop the VM after the inactivity period. + default: 10m + INJECT_GIT_CREDENTIALS: + description: "If DevPod should inject git credentials into the remote host." + default: "true" + INJECT_DOCKER_CREDENTIALS: + description: "If DevPod should inject docker credentials into the remote host." + default: "true" + AGENT_PATH: + description: The path where to inject the DevPod agent to. + default: /var/lib/toolbox/devpod +agent: + path: ${AGENT_PATH} + inactivityTimeout: ${INACTIVITY_TIMEOUT} + injectGitCredentials: ${INJECT_GIT_CREDENTIALS} + injectDockerCredentials: ${INJECT_DOCKER_CREDENTIALS} + binaries: + AWS_PROVIDER: + - os: linux + arch: amd64 + path: ##PROJECT_ROOT##/release/devpod-provider-aws-linux-amd64 + checksum: ##CHECKSUM_LINUX_AMD64## + - os: linux + arch: arm64 + path: ##PROJECT_ROOT##/release/devpod-provider-aws-linux-arm64 + checksum: ##CHECKSUM_LINUX_ARM64## + exec: + shutdown: |- + ${AWS_PROVIDER} stop || shutdown +binaries: + AWS_PROVIDER: + - os: linux + arch: amd64 + path: ##PROJECT_ROOT##/release/devpod-provider-aws-linux-amd64 + checksum: ##CHECKSUM_LINUX_AMD64## + - os: linux + arch: arm64 + path: ##PROJECT_ROOT##/release/devpod-provider-aws-linux-arm64 + checksum: ##CHECKSUM_LINUX_ARM64## + - os: darwin + arch: amd64 + path: ##PROJECT_ROOT##/releasedevpod-provider-aws-darwin-amd64 + checksum: ##CHECKSUM_DARWIN_AMD64## + - os: darwin + arch: arm64 + path: ##PROJECT_ROOT##/release/devpod-provider-aws-darwin-arm64 + checksum: ##CHECKSUM_DARWIN_ARM64## + - os: windows + arch: amd64 + path: ##PROJECT_ROOT##/release/devpod-provider-kubernetes-windows-amd64.exe + checksum: ##CHECKSUM_WINDOWS_AMD64## +exec: + init: ${AWS_PROVIDER} init + command: ${AWS_PROVIDER} command + create: ${AWS_PROVIDER} create + delete: ${AWS_PROVIDER} delete + start: ${AWS_PROVIDER} start + stop: ${AWS_PROVIDER} stop + status: ${AWS_PROVIDER} status diff --git a/hack/provider/provider.yaml b/hack/provider/provider.yaml index 8ed81f2..9601188 100644 --- a/hack/provider/provider.yaml +++ b/hack/provider/provider.yaml @@ -18,6 +18,8 @@ optionGroups: - AWS_SECURITY_GROUP_ID - AWS_INSTANCE_PROFILE_ARN - AWS_INSTANCE_TAGS + - AWS_USE_INSTANCE_CONNECT_ENDPOINT + - AWS_INSTANCE_CONNECT_ENDPOINT_ID name: "AWS options" defaultVisible: false - options: @@ -228,8 +230,13 @@ options: - m7g.large - m7g.medium - m7g.xlarge - - + AWS_USE_INSTANCE_CONNECT_ENDPOINT: + description: "If defined, will try to connect to the ec2 instance via the default instance connect endpoint for the current subnet" + type: boolean + default: false + AWS_INSTANCE_CONNECT_ENDPOINT_ID: + description: "Specify which instance connect endpoint to use. Only works with AWS_USE_INSTANCE_CONNECT_ENDPOINT enabled" + default: "" INACTIVITY_TIMEOUT: description: If defined, will automatically stop the VM after the inactivity period. default: 10m diff --git a/pkg/aws/aws.go b/pkg/aws/aws.go index 019289c..b62e31d 100644 --- a/pkg/aws/aws.go +++ b/pkg/aws/aws.go @@ -297,7 +297,8 @@ func CreateDevpodInstanceProfile(ctx context.Context, provider *AwsProvider) (st "Action": [ "ec2:DescribeInstances", "ec2:StopInstances", - "ec2:DescribeInstanceStatus" + "ec2:DescribeInstanceStatus", + "ec2:DescribeInstanceConnectEndpoints" ], "Resource": "*" } diff --git a/pkg/options/options.go b/pkg/options/options.go index 7db7d55..c518901 100644 --- a/pkg/options/options.go +++ b/pkg/options/options.go @@ -7,31 +7,35 @@ import ( ) var ( - AWS_AMI = "AWS_AMI" - AWS_DISK_SIZE = "AWS_DISK_SIZE" - AWS_ROOT_DEVICE = "AWS_ROOT_DEVICE" - AWS_INSTANCE_TYPE = "AWS_INSTANCE_TYPE" - AWS_REGION = "AWS_REGION" - AWS_SECURITY_GROUP_ID = "AWS_SECURITY_GROUP_ID" - AWS_SUBNET_ID = "AWS_SUBNET_ID" - AWS_VPC_ID = "AWS_VPC_ID" - AWS_INSTANCE_TAGS = "AWS_INSTANCE_TAGS" - AWS_INSTANCE_PROFILE_ARN = "AWS_INSTANCE_PROFILE_ARN" + AWS_AMI = "AWS_AMI" + AWS_DISK_SIZE = "AWS_DISK_SIZE" + AWS_ROOT_DEVICE = "AWS_ROOT_DEVICE" + AWS_INSTANCE_TYPE = "AWS_INSTANCE_TYPE" + AWS_REGION = "AWS_REGION" + AWS_SECURITY_GROUP_ID = "AWS_SECURITY_GROUP_ID" + AWS_SUBNET_ID = "AWS_SUBNET_ID" + AWS_VPC_ID = "AWS_VPC_ID" + AWS_INSTANCE_TAGS = "AWS_INSTANCE_TAGS" + AWS_INSTANCE_PROFILE_ARN = "AWS_INSTANCE_PROFILE_ARN" + AWS_USE_INSTANCE_CONNECT_ENDPOINT = "AWS_USE_INSTANCE_CONNECT_ENDPOINT" + AWS_INSTANCE_CONNECT_ENDPOINT_ID = "AWS_INSTANCE_CONNECT_ENDPOINT_ID" ) type Options struct { - DiskImage string - DiskSizeGB int - RootDevice string - MachineFolder string - MachineID string - MachineType string - VpcID string - SubnetID string - SecurityGroupID string - InstanceProfileArn string - InstanceTags string - Zone string + DiskImage string + DiskSizeGB int + RootDevice string + MachineFolder string + MachineID string + MachineType string + VpcID string + SubnetID string + SecurityGroupID string + InstanceProfileArn string + InstanceTags string + Zone string + UseInstanceConnectEndpoint bool + InstanceConnectEndpointID string } func FromEnv(init bool) (*Options, error) { @@ -62,6 +66,8 @@ func FromEnv(init bool) (*Options, error) { retOptions.InstanceTags = os.Getenv(AWS_INSTANCE_TAGS) retOptions.InstanceProfileArn = os.Getenv(AWS_INSTANCE_PROFILE_ARN) retOptions.Zone = os.Getenv(AWS_REGION) + retOptions.UseInstanceConnectEndpoint = os.Getenv(AWS_USE_INSTANCE_CONNECT_ENDPOINT) == "true" + retOptions.InstanceConnectEndpointID = os.Getenv(AWS_INSTANCE_CONNECT_ENDPOINT_ID) // Return eraly if we're just doing init if init {