diff --git a/cmd/cmd/aws/aws.go b/cmd/cmd/aws/aws.go index 4479fa4e..df36b4d8 100644 --- a/cmd/cmd/aws/aws.go +++ b/cmd/cmd/aws/aws.go @@ -27,6 +27,7 @@ func GetCmd() *cobra.Command { hosts.GetMacCmd(), hosts.GetWindowsCmd(), hosts.GetRHELCmd(), - hosts.GetFedoraCmd()) + hosts.GetFedoraCmd(), + hosts.GetCustomCmd()) return c } diff --git a/cmd/cmd/aws/hosts/custom.go b/cmd/cmd/aws/hosts/custom.go new file mode 100644 index 00000000..2784b739 --- /dev/null +++ b/cmd/cmd/aws/hosts/custom.go @@ -0,0 +1,112 @@ +package hosts + +import ( + params "github.com/adrianriobo/qenvs/cmd/cmd/constants" + qenvsContext "github.com/adrianriobo/qenvs/pkg/manager/context" + "github.com/adrianriobo/qenvs/pkg/provider/aws/action/custom" + "github.com/adrianriobo/qenvs/pkg/provider/aws/action/fedora" + "github.com/adrianriobo/qenvs/pkg/util/logging" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +const ( + cmdCustom = "fedora" + cmdCustomDesc = "manage " + + amiID string = "ami" + amiIDDesc string = "ID for the custom ami" + instanceType string = "instance-type" + instanceTypeDesc string = "type of instance" + productDescription string = "product-description" + productDescriptionDesc string = "Product description for the custom AMI: (Linux/UNIX, Windows or Red Hat Enterprise Linux)" + productDescriptionDefault string = "Linux/UNIX" +) + +func GetCustomCmd() *cobra.Command { + c := &cobra.Command{ + Use: cmdCustom, + Short: cmdCustomDesc, + RunE: func(cmd *cobra.Command, args []string) error { + if err := viper.BindPFlags(cmd.Flags()); err != nil { + return err + } + return nil + }, + } + c.AddCommand(getCustomCreate(), getCustomDestroy()) + return c +} + +func getCustomCreate() *cobra.Command { + c := &cobra.Command{ + Use: params.CreateCmdName, + Short: params.CreateCmdName, + RunE: func(cmd *cobra.Command, args []string) error { + if err := viper.BindPFlags(cmd.Flags()); err != nil { + return err + } + + // Initialize context + qenvsContext.Init( + viper.GetString(params.ProjectName), + viper.GetString(params.BackedURL), + viper.GetString(params.ConnectionDetailsOutput), + viper.GetStringMapString(params.Tags)) + + // Run create + if err := custom.Create( + &custom.Request{ + Prefix: "main", + AMI: viper.GetString(amiID), + InstanceType: viper.GetString(instanceType), + ProductDescription: viper.GetString(productDescription), + Spot: viper.IsSet(spot), + Airgap: viper.IsSet(airgap)}); err != nil { + logging.Error(err) + } + return nil + }, + } + flagSet := pflag.NewFlagSet(params.CreateCmdName, pflag.ExitOnError) + flagSet.StringP(params.ConnectionDetailsOutput, "", "", params.ConnectionDetailsOutputDesc) + flagSet.StringToStringP(params.Tags, "", nil, params.TagsDesc) + flagSet.StringP(amiID, "", "", amiIDDesc) + flagSet.StringP(instanceType, "", "", instanceTypeDesc) + flagSet.StringP(productDescription, "", productDescriptionDefault, productDescriptionDesc) + flagSet.Bool(airgap, false, airgapDesc) + flagSet.Bool(spot, false, spotDesc) + c.PersistentFlags().AddFlagSet(flagSet) + err := c.MarkPersistentFlagRequired(amiID) + if err != nil { + logging.Error(err) + } + err = c.MarkPersistentFlagRequired(instanceType) + if err != nil { + logging.Error(err) + } + return c +} + +func getCustomDestroy() *cobra.Command { + c := &cobra.Command{ + Use: params.DestroyCmdName, + Short: params.DestroyCmdName, + RunE: func(cmd *cobra.Command, args []string) error { + if err := viper.BindPFlags(cmd.Flags()); err != nil { + return err + } + + qenvsContext.InitBase( + viper.GetString(params.ProjectName), + viper.GetString(params.BackedURL)) + + if err := fedora.Destroy(); err != nil { + logging.Error(err) + } + return nil + }, + } + return c +} diff --git a/pkg/provider/aws/action/custom/constants.go b/pkg/provider/aws/action/custom/constants.go new file mode 100644 index 00000000..be18d939 --- /dev/null +++ b/pkg/provider/aws/action/custom/constants.go @@ -0,0 +1,18 @@ +package custom + +var ( + stackName = "stackCustom" + awsCustomID = "ac" + + diskSize int = 200 + + // amiRegex = "Fedora-Cloud-Base-%s*" + // amiOwner = "125523088429" + // amiUserDefault = "fedora" + + // requiredInstanceTypes = []string{"c5.metal", "c5d.metal", "c5n.metal"} + + outputHost = "acdHost" + outputUsername = "acUsername" + outputUserPrivateKey = "acPrivatekey" +) diff --git a/pkg/provider/aws/action/custom/custom.go b/pkg/provider/aws/action/custom/custom.go new file mode 100644 index 00000000..9f955bf1 --- /dev/null +++ b/pkg/provider/aws/action/custom/custom.go @@ -0,0 +1,238 @@ +package custom + +import ( + "fmt" + + "github.com/adrianriobo/qenvs/pkg/manager" + qenvsContext "github.com/adrianriobo/qenvs/pkg/manager/context" + infra "github.com/adrianriobo/qenvs/pkg/provider" + "github.com/adrianriobo/qenvs/pkg/provider/aws" + "github.com/adrianriobo/qenvs/pkg/provider/aws/data" + "github.com/adrianriobo/qenvs/pkg/provider/aws/modules/bastion" + "github.com/adrianriobo/qenvs/pkg/provider/aws/modules/ec2/compute" + "github.com/adrianriobo/qenvs/pkg/provider/aws/modules/network" + "github.com/adrianriobo/qenvs/pkg/provider/aws/modules/spot" + amiSVC "github.com/adrianriobo/qenvs/pkg/provider/aws/services/ec2/ami" + "github.com/adrianriobo/qenvs/pkg/provider/aws/services/ec2/keypair" + securityGroup "github.com/adrianriobo/qenvs/pkg/provider/aws/services/ec2/security-group" + "github.com/adrianriobo/qenvs/pkg/provider/util/command" + "github.com/adrianriobo/qenvs/pkg/provider/util/output" + "github.com/adrianriobo/qenvs/pkg/util" + "github.com/adrianriobo/qenvs/pkg/util/logging" + resourcesUtil "github.com/adrianriobo/qenvs/pkg/util/resources" + "github.com/pulumi/pulumi-aws/sdk/v6/go/aws/ec2" + "github.com/pulumi/pulumi/sdk/v3/go/auto" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" +) + +type Request struct { + Prefix string + AMI string + InstanceType string + ProductDescription string + Spot bool + Airgap bool + // internal management + // For airgap scenario there is an orchestation of + // a phase with connectivity on the machine (allowing bootstraping) + // a pahase with connectivyt off where the subnet for the target lost the nat gateway + airgapPhaseConnectivity network.Connectivity + // location and price (if Spot is enable) + region string + az string + spotPrice float64 +} + +// Create orchestrate 2 stacks: +// If spot is enable it will run best spot option to get the best option to spin the machine +// Then it will run the stack for windows dedicated host +func Create(r *Request) error { + // if r.Spot { + // sr := spot.SpotOptionRequest{ + // Prefix: r.Prefix, + // ProductDescription: "Linux/UNIX", + // InstaceTypes: , + // AMIName: fmt.Sprintf(amiRegex, r.Version), + // AMIArch: "x86_64", + // } + // so, err := sr.Create() + // if err != nil { + // return err + // } + // r.region = so.Region + // r.az = so.AvailabilityZone + // r.spotPrice = so.MaxPrice + // } else { + // r.region = os.Getenv("AWS_DEFAULT_REGION") + // az, err := data.GetRandomAvailabilityZone(r.region, nil) + // if err != nil { + // return err + // } + // r.az = *az + // } + + // // if not only host the mac machine will be created + // if !r.Airgap { + // return r.createMachine() + // } + // // Airgap scneario requires orchestration + // return r.createAirgapMachine() + sit, err := data.GetSimilarInstaceTypes(r.InstanceType, "") + logging.Debugf("similar types %v", sit) + return err +} + +// Will destroy resources related to machine +func Destroy() (err error) { + if err := aws.DestroyStack( + aws.DestroyStackRequest{ + Stackname: stackName, + }); err != nil { + return err + } + if spot.Exist() { + return spot.Destroy() + } + return nil +} + +func (r *Request) createMachine() error { + cs := manager.Stack{ + StackName: qenvsContext.StackNameByProject(stackName), + ProjectName: qenvsContext.ProjectName(), + BackedURL: qenvsContext.BackedURL(), + ProviderCredentials: aws.GetClouProviderCredentials( + map[string]string{ + aws.CONFIG_AWS_REGION: r.region}), + DeployFunc: r.deploy, + } + + sr, _ := manager.UpStack(cs) + return r.manageResults(sr) +} + +// Abstract this with a stackAirgapHandle receives a fn (connectivty on / off) err executes +// first on then off +func (r *Request) createAirgapMachine() error { + r.airgapPhaseConnectivity = network.ON + err := r.createMachine() + if err != nil { + return nil + } + r.airgapPhaseConnectivity = network.OFF + return r.createMachine() +} + +// function wil all the logic to deploy resources required by windows +// * create AMI Copy if needed +// * networking +// * key +// * security group +// * compute +// * checks +func (r *Request) deploy(ctx *pulumi.Context) error { + // Get AMI + ami, err := amiSVC.GetAMIByName(ctx, + fmt.Sprintf(amiRegex, r.Version), + amiOwner, + map[string]string{ + "architecture": "x86_64"}) + if err != nil { + return err + } + // Networking + nr := network.NetworkRequest{ + Prefix: r.Prefix, + ID: awsFedoraDedicatedID, + Region: r.region, + AZ: r.az, + // LB is required if we use as which is used for spot feature + CreateLoadBalancer: &r.Spot, + Airgap: r.Airgap, + AirgapPhaseConnectivity: r.airgapPhaseConnectivity, + } + vpc, targetSubnet, _, bastion, lb, err := nr.Network(ctx) + if err != nil { + return err + } + // Create Keypair + kpr := keypair.KeyPairRequest{ + Name: resourcesUtil.GetResourceName( + r.Prefix, awsFedoraDedicatedID, "pk")} + keyResources, err := kpr.Create(ctx) + if err != nil { + return err + } + ctx.Export(fmt.Sprintf("%s-%s", r.Prefix, outputUserPrivateKey), + keyResources.PrivateKey.PrivateKeyPem) + // Security groups + securityGroups, err := r.securityGroups(ctx, vpc) + if err != nil { + return err + } + cr := compute.ComputeRequest{ + Prefix: r.Prefix, + ID: awsFedoraDedicatedID, + VPC: vpc, + Subnet: targetSubnet, + AMI: ami, + KeyResources: keyResources, + SecurityGroups: securityGroups, + InstaceTypes: requiredInstanceTypes, + DiskSize: &diskSize, + Airgap: r.Airgap, + LB: lb, + LBTargetGroups: []int{22}, + Spot: r.Spot} + c, err := cr.NewCompute(ctx) + if err != nil { + return err + } + ctx.Export(fmt.Sprintf("%s-%s", r.Prefix, outputUsername), + pulumi.String(amiUserDefault)) + ctx.Export(fmt.Sprintf("%s-%s", r.Prefix, outputHost), + c.GetHostIP(!r.Airgap)) + return c.Readiness(ctx, command.CommandPing, r.Prefix, awsFedoraDedicatedID, + keyResources.PrivateKey, amiUserDefault, bastion, []pulumi.Resource{}) +} + +// Write exported values in context to files o a selected target folder +func (r *Request) manageResults(stackResult auto.UpResult) error { + results := map[string]string{ + fmt.Sprintf("%s-%s", r.Prefix, outputUsername): "username", + fmt.Sprintf("%s-%s", r.Prefix, outputUserPrivateKey): "id_rsa", + fmt.Sprintf("%s-%s", r.Prefix, outputHost): "host", + } + if r.Airgap { + err := bastion.WriteOutputs(stackResult, r.Prefix, qenvsContext.GetResultsOutputPath()) + if err != nil { + return err + } + } + return output.Write(stackResult, qenvsContext.GetResultsOutputPath(), results) +} + +// security group for mac machine with ingress rules for ssh and vnc +func (r *Request) securityGroups(ctx *pulumi.Context, + vpc *ec2.Vpc) (pulumi.StringArray, error) { + // ingress for ssh access from 0.0.0.0 + sshIngressRule := securityGroup.SSH_TCP + sshIngressRule.CidrBlocks = infra.NETWORKING_CIDR_ANY_IPV4 + // Create SG with ingress rules + sg, err := securityGroup.SGRequest{ + Name: resourcesUtil.GetResourceName(r.Prefix, awsFedoraDedicatedID, "sg"), + VPC: vpc, + Description: fmt.Sprintf("sg for %s", awsFedoraDedicatedID), + IngressRules: []securityGroup.IngressRules{ + sshIngressRule}, + }.Create(ctx) + if err != nil { + return nil, err + } + // Convert to an array of IDs + sgs := util.ArrayConvert([]*ec2.SecurityGroup{sg.SG}, + func(sg *ec2.SecurityGroup) pulumi.StringInput { + return sg.ID() + }) + return pulumi.StringArray(sgs[:]), nil +} diff --git a/pkg/provider/aws/data/instance-type.go b/pkg/provider/aws/data/instance-type.go index a69665ca..c7a22b39 100644 --- a/pkg/provider/aws/data/instance-type.go +++ b/pkg/provider/aws/data/instance-type.go @@ -2,7 +2,9 @@ package data import ( "context" + "fmt" + "github.com/adrianriobo/qenvs/pkg/util" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/ec2" ec2Types "github.com/aws/aws-sdk-go-v2/service/ec2/types" @@ -16,13 +18,54 @@ var ( filternameInstanceType string = "instance-type" ) +func GetSimilarInstaceTypes(instanceType, region string) ([]string, error) { + var cfgOpts config.LoadOptionsFunc + if len(region) > 0 { + cfgOpts = config.WithRegion(region) + } + cfg, err := config.LoadDefaultConfig( + context.Background(), cfgOpts) + if err != nil { + return nil, err + } + client := ec2.NewFromConfig(cfg) + tit, err := client.DescribeInstanceTypes( + context.Background(), + &ec2.DescribeInstanceTypesInput{ + InstanceTypes: []ec2Types.InstanceType{ + ec2Types.InstanceType(instanceType)}, + }) + if err != nil { + return nil, err + } + if len(tit.InstanceTypes) != 1 { + return nil, fmt.Errorf("instance type %s not found on region %s", instanceType, region) + } + titi := tit.InstanceTypes[0] + ait, err := client.DescribeInstanceTypes( + context.Background(), + &ec2.DescribeInstanceTypesInput{}) + if err != nil { + return nil, err + } + sit := util.ArrayFilter(ait.InstanceTypes, + func(i ec2Types.InstanceTypeInfo) bool { + return i.MemoryInfo.SizeInMiB == titi.MemoryInfo.SizeInMiB && + i.VCpuInfo.DefaultCores == titi.VCpuInfo.DefaultCores && + i.GpuInfo.TotalGpuMemoryInMiB == titi.GpuInfo.TotalGpuMemoryInMiB && + *i.DedicatedHostsSupported == *titi.InstanceStorageSupported + }) + + return util.ArrayConvert(sit, func(i ec2Types.InstanceTypeInfo) string { return string(i.InstanceType) }), nil +} + // Get InstanceTypes offerings on current location func IsInstanceTypeOfferedByRegion(instanceType, region string) (bool, error) { var cfgOpts config.LoadOptionsFunc if len(region) > 0 { cfgOpts = config.WithRegion(region) } - cfg, err := config.LoadDefaultConfig(context.TODO(), cfgOpts) + cfg, err := config.LoadDefaultConfig(context.Background(), cfgOpts) if err != nil { return false, err }