From 75a47d604a5c23b634bf91c37dc6752400c86f0e Mon Sep 17 00:00:00 2001 From: Carlos Panato Date: Sun, 26 Sep 2021 15:10:00 +0200 Subject: [PATCH] feat: introduce aws to minectl Signed-off-by: Carlos Panato --- go.mod | 6 +- go.sum | 7 + pkg/cloud/aws/aws.go | 430 ++++++++++++++++++ pkg/cloud/cloud.go | 1 + pkg/model/model.go | 2 +- pkg/provisioner/provisioner.go | 38 +- .../cloud-init/cloud-config.yaml.tmpl | 5 +- 7 files changed, 465 insertions(+), 24 deletions(-) create mode 100644 pkg/cloud/aws/aws.go diff --git a/go.mod b/go.mod index 94b0db84..3338564a 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/Azure/go-autorest/autorest/validation v0.3.1 // indirect github.com/Masterminds/sprig/v3 v3.2.2 github.com/Tnze/go-mc v1.17.0 + github.com/aws/aws-sdk-go v1.40.15 github.com/blang/semver/v4 v4.0.0 github.com/c-bata/go-prompt v0.2.6 github.com/civo/civogo v0.2.53 @@ -18,7 +19,8 @@ require ( github.com/fatih/color v1.13.0 github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/go-github v17.0.0+incompatible // indirect - github.com/google/uuid v1.3.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/uuid v1.3.0 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.0 github.com/hashicorp/go-version v1.3.0 // indirect @@ -70,9 +72,9 @@ require ( github.com/form3tech-oss/jwt-go v3.2.2+incompatible // indirect github.com/go-resty/resty/v2 v2.1.1-0.20191201195748-d7b97669fe48 // indirect github.com/golang/protobuf v1.5.2 // indirect - github.com/google/go-querystring v1.1.0 // indirect github.com/googleapis/gax-go/v2 v2.1.0 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/mattn/go-colorable v0.1.9 // indirect github.com/mattn/go-tty v0.0.3 // indirect diff --git a/go.sum b/go.sum index f8aaa04a..e59122d4 100644 --- a/go.sum +++ b/go.sum @@ -92,6 +92,8 @@ github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kd github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/aws/aws-sdk-go v1.40.15 h1:aqQCwW8meVzLCacWX8NEPg8bBkL0ZlcMSbhwrsg6eNE= +github.com/aws/aws-sdk-go v1.40.15/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -291,6 +293,10 @@ github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -575,6 +581,7 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985 h1:4CSI6oo7cOjJKajidEljs9h+uP0rRZBPPPhcCbj5mw8= golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= diff --git a/pkg/cloud/aws/aws.go b/pkg/cloud/aws/aws.go new file mode 100644 index 00000000..4c6a546f --- /dev/null +++ b/pkg/cloud/aws/aws.go @@ -0,0 +1,430 @@ +package aws + +import ( + "context" + _ "embed" + "encoding/base64" + "fmt" + "io/ioutil" + "path/filepath" + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ec2" + "github.com/google/uuid" + "github.com/minectl/pkg/automation" + "github.com/minectl/pkg/common" + minctlTemplate "github.com/minectl/pkg/template" + "github.com/minectl/pkg/update" + "github.com/pkg/errors" +) + +type Aws struct { + client *ec2.EC2 + tmpl *minctlTemplate.Template + region string +} + +// NewAWS creates an Aws and initialises an EC2 client +func NewAWS(region, accessKey, secretKey, token string) (*Aws, error) { + sess, err := session.NewSession(&aws.Config{ + Region: aws.String(region), + Credentials: credentials.NewStaticCredentials(accessKey, secretKey, token), + }) + if err != nil { + return nil, err + } + + ec2Svc := ec2.New(sess) + + tmpl, err := minctlTemplate.NewTemplateCloudConfig() + if err != nil { + return nil, err + } + + return &Aws{ + client: ec2Svc, + region: region, + tmpl: tmpl, + }, err +} + +func (a *Aws) ListServer() ([]automation.RessourceResults, error) { + var result []automation.RessourceResults + var nextToken *string + + for { + input := &ec2.DescribeInstancesInput{ + Filters: []*ec2.Filter{ + { + Name: aws.String(fmt.Sprintf("tag:%s", common.InstanceTag)), + Values: []*string{aws.String("true")}, + }, + }, + NextToken: nextToken, + } + + instances, err := a.client.DescribeInstances(input) + if err != nil { + return nil, err + } + + for _, r := range instances.Reservations { + for _, i := range r.Instances { + if *i.State.Name != ec2.InstanceStateNameTerminated { + arr := automation.RessourceResults{ + ID: *i.InstanceId, + Region: a.region, + } + + if i.PublicIpAddress != nil { + arr.PublicIP = *i.PublicIpAddress + } + + var tags []string + var instanceName string + for _, v := range i.Tags { + tags = append(tags, fmt.Sprintf("%s=%s", *v.Key, *v.Value)) + + if *v.Key == "Name" { + instanceName = *v.Value + } + } + + arr.Tags = strings.Join(tags, ",") + arr.Name = instanceName + + result = append(result, arr) + } + } + } + + nextToken = instances.NextToken + if nextToken == nil { + break + } + } + + return result, nil +} + +func (a *Aws) CreateServer(args automation.ServerArgs) (*automation.RessourceResults, error) { + image, err := a.lookupAMI("ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-20210621") + if err != nil { + return nil, err + } + + pubKeyFile, err := ioutil.ReadFile(fmt.Sprintf("%s.pub", args.MinecraftResource.GetSSH())) + if err != nil { + return nil, err + } + + key, err := a.client.ImportKeyPair(&ec2.ImportKeyPairInput{ + KeyName: aws.String(fmt.Sprintf("%s-ssh", args.MinecraftResource.GetName())), + PublicKeyMaterial: pubKeyFile, + }) + if err != nil { + return nil, err + } + + instanceInput := &ec2.RunInstancesInput{ + ImageId: image, + KeyName: key.KeyName, + InstanceType: aws.String(args.MinecraftResource.GetSize()), + MinCount: aws.Int64(1), + MaxCount: aws.Int64(1), + TagSpecifications: []*ec2.TagSpecification{ + { + ResourceType: aws.String("instance"), + Tags: []*ec2.Tag{ + { + Key: aws.String("Name"), + Value: aws.String(args.MinecraftResource.GetName()), + }, + { + Key: aws.String(common.InstanceTag), + Value: aws.String("true"), + }, + }, + }, + }, + } + + if args.MinecraftResource.GetVolumeSize() > 0 { + instanceInput.BlockDeviceMappings = []*ec2.BlockDeviceMapping{ + { + DeviceName: aws.String("/dev/sda1"), + Ebs: &ec2.EbsBlockDevice{ + VolumeSize: aws.Int64(int64(args.MinecraftResource.Spec.Server.VolumeSize)), + }, + }, + } + } + + userData, err := a.tmpl.GetTemplate(args.MinecraftResource, "", minctlTemplate.GetTemplateCloudConfigName(args.MinecraftResource.IsProxyServer())) + if err != nil { + return nil, err + } + instanceInput.UserData = aws.String(base64.StdEncoding.EncodeToString([]byte(userData))) + + groupID, _, err := a.createEC2SecurityGroup("", args.MinecraftResource.GetPort()) + if err != nil { + return nil, err + } + + var networkSpec = ec2.InstanceNetworkInterfaceSpecification{ + DeviceIndex: aws.Int64(int64(0)), + AssociatePublicIpAddress: aws.Bool(true), + DeleteOnTermination: aws.Bool(true), + Groups: []*string{groupID}, + } + + instanceInput.NetworkInterfaces = []*ec2.InstanceNetworkInterfaceSpecification{ + &networkSpec, + } + + result, err := a.client.RunInstances(instanceInput) + if err != nil { + if aerr, ok := err.(awserr.Error); ok { + switch aerr.Code() { + default: + return nil, aerr + } + } else { + return nil, err + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + for { + select { + case <-ctx.Done(): + return nil, errors.New("timed out while creating the aws instance") + case <-time.After(10 * time.Second): + instanceStatus, err := a.client.DescribeInstanceStatus(&ec2.DescribeInstanceStatusInput{ + InstanceIds: aws.StringSlice([]string{*result.Instances[0].InstanceId}), + }) + if err != nil { + return nil, err + } + if *instanceStatus.InstanceStatuses[0].InstanceState.Name == "running" { + i, err := a.client.DescribeInstances(&ec2.DescribeInstancesInput{ + InstanceIds: aws.StringSlice([]string{*result.Instances[0].InstanceId}), + }) + if err != nil { + return nil, err + } + var tags []string + var instanceName string + for _, v := range i.Reservations[0].Instances[0].Tags { + tags = append(tags, fmt.Sprintf("%s=%s", *v.Key, *v.Value)) + + if *v.Key == "Name" { + instanceName = *v.Value + } + } + + return &automation.RessourceResults{ + ID: *i.Reservations[0].Instances[0].InstanceId, + Name: instanceName, + Region: *a.client.Config.Region, + PublicIP: *i.Reservations[0].Instances[0].PublicIpAddress, + Tags: strings.Join(tags, ","), + }, nil + } + } + } +} + +func (a *Aws) UpdateServer(id string, args automation.ServerArgs) error { + i, err := a.client.DescribeInstances(&ec2.DescribeInstancesInput{ + InstanceIds: aws.StringSlice([]string{id}), + }) + if err != nil { + return err + } + + remoteCommand := update.NewRemoteServer(args.MinecraftResource.GetSSH(), *i.Reservations[0].Instances[0].PublicIpAddress, "ubuntu") + err = remoteCommand.UpdateServer(args.MinecraftResource) + if err != nil { + return err + } + + return nil +} + +func (a *Aws) DeleteServer(id string, args automation.ServerArgs) error { + keys, err := a.client.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{ + KeyNames: aws.StringSlice([]string{fmt.Sprintf("%s-ssh", args.MinecraftResource.GetName())}), + }) + if err != nil { + return err + } + + _, err = a.client.DeleteKeyPair(&ec2.DeleteKeyPairInput{ + KeyName: aws.String(*keys.KeyPairs[0].KeyName), + }) + if err != nil { + return err + } + + results, err := a.ListServer() + if err != nil { + return err + } + + for _, result := range results { + i, err := a.client.DescribeInstances(&ec2.DescribeInstancesInput{ + InstanceIds: aws.StringSlice([]string{result.ID}), + }) + if err != nil { + return err + } + groups := i.Reservations[0].Instances[0].SecurityGroups + + _, err = a.client.TerminateInstances(&ec2.TerminateInstancesInput{ + InstanceIds: aws.StringSlice([]string{result.ID}), + }) + if err != nil { + return err + } + + err = a.client.WaitUntilInstanceTerminated(&ec2.DescribeInstancesInput{ + InstanceIds: aws.StringSlice([]string{result.ID}), + }) + if err != nil { + return err + } + + for _, group := range groups { + _, err := a.client.DeleteSecurityGroup(&ec2.DeleteSecurityGroupInput{ + GroupId: group.GroupId, + }) + if err != nil { + return err + } + } + } + + return nil +} + +func (a *Aws) UploadPlugin(id string, args automation.ServerArgs, plugin, destination string) error { + i, err := a.client.DescribeInstances(&ec2.DescribeInstancesInput{ + InstanceIds: aws.StringSlice([]string{id}), + }) + if err != nil { + return err + } + + remoteCommand := update.NewRemoteServer(args.MinecraftResource.GetSSH(), *i.Reservations[0].Instances[0].PublicIpAddress, "ubuntu") + + err = remoteCommand.TransferFile(plugin, filepath.Join(destination, filepath.Base(plugin))) + if err != nil { + return err + } + + _, err = remoteCommand.ExecuteCommand("systemctl restart minecraft.service") + if err != nil { + return err + } + + return nil +} + +func (a *Aws) GetServer(id string, _ automation.ServerArgs) (*automation.RessourceResults, error) { + i, err := a.client.DescribeInstances(&ec2.DescribeInstancesInput{ + InstanceIds: aws.StringSlice([]string{id}), + }) + if err != nil { + return nil, err + } + + var tags []string + var instanceName string + for _, v := range i.Reservations[0].Instances[0].Tags { + tags = append(tags, fmt.Sprintf("%s=%s", *v.Key, *v.Value)) + + if *v.Key == "Name" { + instanceName = *v.Value + } + } + + return &automation.RessourceResults{ + ID: *i.Reservations[0].Instances[0].InstanceId, + Name: instanceName, + Region: *a.client.Config.Region, + PublicIP: *i.Reservations[0].Instances[0].PublicIpAddress, + Tags: strings.Join(tags, ","), + }, err +} + +func (a *Aws) createEC2SecurityGroup(vpcID string, controlPort int) (*string, *string, error) { + ports := []int{80, 443, 22, controlPort} + groupName := "minecraft-" + uuid.New().String() + var input = &ec2.CreateSecurityGroupInput{ + Description: aws.String("minecraft security group"), + GroupName: aws.String(groupName), + } + + if len(vpcID) > 0 { + input.VpcId = aws.String(vpcID) + } + + group, err := a.client.CreateSecurityGroup(input) + if err != nil { + return nil, nil, err + } + + for _, port := range ports { + err = a.createEC2SecurityGroupRule(*group.GroupId, port, port) + if err != nil { + return group.GroupId, &groupName, err + } + } + + return group.GroupId, &groupName, nil +} + +func (a *Aws) createEC2SecurityGroupRule(groupID string, fromPort, toPort int) error { + _, err := a.client.AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{ + CidrIp: aws.String("0.0.0.0/0"), + FromPort: aws.Int64(int64(fromPort)), + IpProtocol: aws.String("tcp"), + ToPort: aws.Int64(int64(toPort)), + GroupId: aws.String(groupID), + }) + if err != nil { + return err + } + + return nil +} + +// lookupAMI gets the AMI ID that the exit node will use +func (a *Aws) lookupAMI(name string) (*string, error) { + images, err := a.client.DescribeImages(&ec2.DescribeImagesInput{ + Filters: []*ec2.Filter{ + { + Name: aws.String("name"), + Values: []*string{ + aws.String(name), + }, + }, + }, + }) + if err != nil { + return nil, err + } + + if len(images.Images) == 0 { + return nil, fmt.Errorf("image not found") + } + + return images.Images[0].ImageId, nil +} diff --git a/pkg/cloud/cloud.go b/pkg/cloud/cloud.go index e24be711..f6ef1246 100644 --- a/pkg/cloud/cloud.go +++ b/pkg/cloud/cloud.go @@ -13,6 +13,7 @@ var cloudProvider = map[string]string{ "vultr": "vultr", "azure": "Azure", "oci": "Oracle Cloud Infrastructure", + "aws": "Amazon WebServices", } func GetCloudProviderFullName(cloud string) string { diff --git a/pkg/model/model.go b/pkg/model/model.go index 3d424b6e..d3445e1b 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -25,11 +25,11 @@ type Monitoring struct { // Server type Server struct { Size string `yaml:"size"` - VolumeSize int `yaml:"volumeSize"` Ssh string `yaml:"ssh"` Cloud string `yaml:"cloud"` Region string `yaml:"region"` Port int `yaml:"port"` + VolumeSize int `yaml:"volumeSize"` } // Minecraft diff --git a/pkg/provisioner/provisioner.go b/pkg/provisioner/provisioner.go index eb41cc6a..e786691d 100644 --- a/pkg/provisioner/provisioner.go +++ b/pkg/provisioner/provisioner.go @@ -6,35 +6,27 @@ import ( "os" "time" - "github.com/minectl/pkg/cloud/oci" - - "github.com/minectl/pkg/progress" - - "github.com/minectl/pkg/logging" - - "github.com/minectl/pkg/cloud/azure" - - "github.com/minectl/pkg/rcon" - - "github.com/minectl/pkg/cloud/vultr" - - "github.com/minectl/pkg/cloud/gce" - - "github.com/minectl/pkg/cloud/equinix" - - "github.com/minectl/pkg/cloud/ovh" - - "github.com/minectl/pkg/cloud/linode" + "github.com/pkg/errors" "github.com/minectl/pkg/automation" "github.com/minectl/pkg/cloud" + "github.com/minectl/pkg/cloud/aws" + "github.com/minectl/pkg/cloud/azure" "github.com/minectl/pkg/cloud/civo" "github.com/minectl/pkg/cloud/do" + "github.com/minectl/pkg/cloud/equinix" + "github.com/minectl/pkg/cloud/gce" "github.com/minectl/pkg/cloud/hetzner" + "github.com/minectl/pkg/cloud/linode" + "github.com/minectl/pkg/cloud/oci" + "github.com/minectl/pkg/cloud/ovh" "github.com/minectl/pkg/cloud/scaleway" + "github.com/minectl/pkg/cloud/vultr" "github.com/minectl/pkg/common" + "github.com/minectl/pkg/logging" "github.com/minectl/pkg/manifest" - "github.com/pkg/errors" + "github.com/minectl/pkg/progress" + "github.com/minectl/pkg/rcon" ) type MinectlProvisionerOpts struct { @@ -243,6 +235,12 @@ func getProvisioner(provider, region string) (automation.Automation, error) { return nil, err } return cloudProvider, nil + case "aws": + cloudProvider, err := aws.NewAWS(region, os.Getenv("AWS_ACCESS_KEY_ID"), os.Getenv("AWS_SECRET_ACCESS_KEY"), os.Getenv("AWS_SESSION_TOKEN")) + if err != nil { + return nil, err + } + return cloudProvider, nil default: return nil, errors.Errorf("Could not find provider %s", provider) } diff --git a/pkg/template/templates/cloud-init/cloud-config.yaml.tmpl b/pkg/template/templates/cloud-init/cloud-config.yaml.tmpl index e79c0bf2..8c102d16 100644 --- a/pkg/template/templates/cloud-init/cloud-config.yaml.tmpl +++ b/pkg/template/templates/cloud-init/cloud-config.yaml.tmpl @@ -55,6 +55,8 @@ write_files: [Unit] Description=Minecraft Server Documentation=https://www.minecraft.net/en-us/download/server + DefaultDependencies=no + After=network.target [Service] WorkingDirectory=/minecraft Type=simple @@ -74,7 +76,7 @@ runcmd: {{- template "monitoring-binaries" . }} {{- end }} {{- if not .Mount }} - - mkdir /minecraft + - mkdir -p /minecraft {{- end }} - ufw allow ssh - ufw allow 5201 @@ -107,6 +109,7 @@ runcmd: {{- end }} - echo "eula={{ .Spec.Minecraft.Eula }}" > /minecraft/eula.txt - mv /tmp/server.properties /minecraft/server.properties + - chmod a+rwx /minecraft - systemctl restart minecraft.service - systemctl enable minecraft.service {{- end -}} \ No newline at end of file