diff --git a/cmd/osbuild-image-tests/aws.go b/cmd/osbuild-image-tests/aws.go new file mode 100644 index 000000000..b000bf70d --- /dev/null +++ b/cmd/osbuild-image-tests/aws.go @@ -0,0 +1,281 @@ +// +build integration + +package main + +import ( + "encoding/base64" + "errors" + "fmt" + "io/ioutil" + "os" + + "github.com/aws/aws-sdk-go/aws" + "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/osbuild/osbuild-composer/internal/upload/awsupload" +) + +type awsCredentials struct { + AccessKeyId string + SecretAccessKey string + Region string + Bucket string +} + +// getAWSCredentialsFromEnv gets the credentials from environment variables +// If none of the environment variables is set, it returns nil. +// If some but not all environment variables are set, it returns an error. +func getAWSCredentialsFromEnv() (*awsCredentials, error) { + accessKeyId, akExists := os.LookupEnv("AWS_ACCESS_KEY_ID") + secretAccessKey, sakExists := os.LookupEnv("AWS_SECRET_ACCESS_KEY") + region, regionExists := os.LookupEnv("AWS_REGION") + bucket, bucketExists := os.LookupEnv("AWS_BUCKET") + + // Workaround Travis security feature. If non of the variables is set, just ignore the test + if !akExists && !sakExists && !bucketExists && !regionExists { + return nil, nil + } + // If only one/two of them are not set, then fail + if !akExists || !sakExists || !bucketExists || !regionExists { + return nil, errors.New("not all required env variables were set") + } + + return &awsCredentials{ + AccessKeyId: accessKeyId, + SecretAccessKey: secretAccessKey, + Region: region, + Bucket: bucket, + }, nil +} + +// encodeBase64 encodes string to base64-encoded string +func encodeBase64(input string) string { + return base64.StdEncoding.EncodeToString([]byte(input)) +} + +// createUserData creates cloud-init's user-data that contains user redhat with +// the specified public key +func createUserData(publicKeyFile string) (string, error) { + publicKey, err := ioutil.ReadFile(publicKeyFile) + if err != nil { + return "", fmt.Errorf("cannot read the public key: %#v", err) + } + + userData := fmt.Sprintf(`#cloud-config +user: redhat +ssh_authorized_keys: + - %s +`, string(publicKey)) + + return userData, nil +} + +// wrapErrorf returns error constructed using fmt.Errorf from format and any +// other args. If innerError != nil, it's appended at the end of the new +// error. +func wrapErrorf(innerError error, format string, a ...interface{}) error { + if innerError != nil { + a = append(a, innerError) + return fmt.Errorf(format+"\n\ninner error: %#s", a...) + } + + return fmt.Errorf(format, a...) +} + +// uploadImageToAWS mimics the upload feature of osbuild-composer. +// It takes an image and an image name and creates an ec2 instance from them. +// The s3 key is never returned - the same thing is done in osbuild-composer, +// the user has no way of getting the s3 key. +func uploadImageToAWS(c *awsCredentials, imagePath string, imageName string) error { + uploader, err := awsupload.New(c.Region, c.AccessKeyId, c.SecretAccessKey) + if err != nil { + return fmt.Errorf("cannot create aws uploader: %#v", err) + } + + _, err = uploader.Upload(imagePath, c.Bucket, imageName) + if err != nil { + return fmt.Errorf("cannot upload the image: %#v", err) + } + _, err = uploader.Register(imageName, c.Bucket, imageName) + if err != nil { + return fmt.Errorf("cannot register the image: %#v", err) + } + + return nil +} + +// newEC2 creates EC2 struct from given credentials +func newEC2(c *awsCredentials) (*ec2.EC2, error) { + creds := credentials.NewStaticCredentials(c.AccessKeyId, c.SecretAccessKey, "") + sess, err := session.NewSession(&aws.Config{ + Credentials: creds, + Region: aws.String(c.Region), + }) + if err != nil { + return nil, fmt.Errorf("cannot create aws session: %#v", err) + } + + return ec2.New(sess), nil +} + +type imageDescription struct { + Id *string + SnapshotId *string + // this doesn't support multiple snapshots per one image, + // because this feature is not supported in composer +} + +// describeEC2Image searches for EC2 image by its name and returns +// its id and snapshot id +func describeEC2Image(e *ec2.EC2, imageName string) (*imageDescription, error) { + imageDescriptions, err := e.DescribeImages(&ec2.DescribeImagesInput{ + Filters: []*ec2.Filter{ + { + Name: aws.String("name"), + Values: []*string{ + aws.String(imageName), + }, + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("cannot describe the image: %#v", err) + } + imageId := imageDescriptions.Images[0].ImageId + snapshotId := imageDescriptions.Images[0].BlockDeviceMappings[0].Ebs.SnapshotId + + return &imageDescription{ + Id: imageId, + SnapshotId: snapshotId, + }, nil +} + +// deleteEC2Image deletes the specified image and its associated snapshot +func deleteEC2Image(e *ec2.EC2, imageDesc *imageDescription) error { + var retErr error + + // firstly, deregister the image + _, err := e.DeregisterImage(&ec2.DeregisterImageInput{ + ImageId: imageDesc.Id, + }) + + if err != nil { + retErr = wrapErrorf(retErr, "cannot deregister the image: %#v", err) + } + + // now it's possible to delete the snapshot + _, err = e.DeleteSnapshot(&ec2.DeleteSnapshotInput{ + SnapshotId: imageDesc.SnapshotId, + }) + + if err != nil { + retErr = wrapErrorf(retErr, "cannot delete the snapshot: %#v", err) + } + + return retErr +} + +// withBootedImageInEC2 runs the function f in the context of booted +// image in AWS EC2 +func withBootedImageInEC2(e *ec2.EC2, imageDesc *imageDescription, publicKey string, f func(address string) error) (retErr error) { + // generate user data with given public key + userData, err := createUserData(publicKey) + if err != nil { + return err + } + + // Security group must be now generated, because by default + // all traffic to EC2 instance is filtered. + + securityGroupName, err := generateRandomString("osbuild-image-tests-security-group-") + if err != nil { + return fmt.Errorf("cannot generate a random name for the image: %#v", err) + } + + // Firstly create a security group + securityGroup, err := e.CreateSecurityGroup(&ec2.CreateSecurityGroupInput{ + GroupName: aws.String(securityGroupName), + Description: aws.String("image-tests-security-group"), + }) + if err != nil { + return fmt.Errorf("cannot create a new security group: %#v", err) + } + + defer func() { + _, err = e.DeleteSecurityGroup(&ec2.DeleteSecurityGroupInput{ + GroupId: securityGroup.GroupId, + }) + + if err != nil { + retErr = wrapErrorf(retErr, "cannot delete the security group: %#v", err) + } + }() + + // Authorize incoming SSH connections. + _, err = e.AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{ + CidrIp: aws.String("0.0.0.0/0"), + GroupId: securityGroup.GroupId, + FromPort: aws.Int64(22), + ToPort: aws.Int64(22), + IpProtocol: aws.String("tcp"), + }) + if err != nil { + return fmt.Errorf("canot add a rule to the security group: %#v", err) + } + + // Finally, run the instance from the given image and with the created security group + res, err := e.RunInstances(&ec2.RunInstancesInput{ + MaxCount: aws.Int64(1), + MinCount: aws.Int64(1), + ImageId: imageDesc.Id, + InstanceType: aws.String("t3.micro"), + SecurityGroupIds: []*string{securityGroup.GroupId}, + UserData: aws.String(encodeBase64(userData)), + }) + if err != nil { + return fmt.Errorf("cannot create a new instance: %#v", err) + } + + describeInstanceInput := &ec2.DescribeInstancesInput{ + InstanceIds: []*string{ + res.Instances[0].InstanceId, + }, + } + + defer func() { + // We need to terminate the instance now and wait until the termination is done. + // Otherwise, it wouldn't be possible to delete the image. + _, err = e.TerminateInstances(&ec2.TerminateInstancesInput{ + InstanceIds: []*string{ + res.Instances[0].InstanceId, + }, + }) + if err != nil { + retErr = wrapErrorf(retErr, "cannot terminate the instance: %#v", err) + return + } + + err = e.WaitUntilInstanceTerminated(describeInstanceInput) + if err != nil { + retErr = wrapErrorf(retErr, "waiting for the instance termination failed: %#v", err) + } + }() + + // The instance has no IP address yet. It's assigned when the instance + // is in the state "EXISTS". However, in this state the instance is not + // much usable, therefore wait until "RUNNING" state, in which the instance + // actually can do something useful for us. + err = e.WaitUntilInstanceRunning(describeInstanceInput) + if err != nil { + return fmt.Errorf("waiting for the instance to be running failed: %#v", err) + } + + // By describing the instance, we can get the ip address. + out, err := e.DescribeInstances(describeInstanceInput) + if err != nil { + return fmt.Errorf("cannot describe the instance: %#v", err) + } + + return f(*out.Reservations[0].Instances[0].PublicIpAddress) +} diff --git a/cmd/osbuild-image-tests/context-managers.go b/cmd/osbuild-image-tests/context-managers.go index 6c7c39065..89974169f 100644 --- a/cmd/osbuild-image-tests/context-managers.go +++ b/cmd/osbuild-image-tests/context-managers.go @@ -206,3 +206,24 @@ func withExtractedTarArchive(archive string, f func(dir string) error) error { return f(dir) }) } + +// withSSHKeyPair runs the function f with a newly generated +// ssh key-pair, they key-pair is deleted immediately after +// the function f returns +func withSSHKeyPair(f func(privateKey, publicKey string) error) error { + return withTempDir("", "keys", func(dir string) error { + privateKey := dir + "/id_rsa" + publicKey := dir + "/id_rsa.pub" + cmd := exec.Command("ssh-keygen", + "-N", "", + "-f", privateKey, + ) + + err := cmd.Run() + if err != nil { + return fmt.Errorf("ssh-keygen failed: %#v", err) + } + + return f(privateKey, publicKey) + }) +} diff --git a/cmd/osbuild-image-tests/helpers.go b/cmd/osbuild-image-tests/helpers.go index 75f6da2b9..d67954913 100644 --- a/cmd/osbuild-image-tests/helpers.go +++ b/cmd/osbuild-image-tests/helpers.go @@ -7,6 +7,8 @@ import ( "os" "syscall" "time" + + "github.com/google/uuid" ) // durationMin returns the smaller of two given durations @@ -50,3 +52,14 @@ func killProcessCleanly(process *os.Process, timeout time.Duration) error { return process.Kill() } + +// generateRandomString generates a new random string with specified prefix. +// The random part is based on UUID. +func generateRandomString(prefix string) (string, error) { + id, err := uuid.NewRandom() + if err != nil { + return "", err + } + + return prefix + id.String(), nil +} diff --git a/cmd/osbuild-image-tests/main_test.go b/cmd/osbuild-image-tests/main_test.go index 2b8d0e08e..6652fea41 100644 --- a/cmd/osbuild-image-tests/main_test.go +++ b/cmd/osbuild-image-tests/main_test.go @@ -232,6 +232,47 @@ func testBootUsingNspawnDirectory(t *testing.T, imagePath string, outputID strin require.NoError(t, err) } +func testBootUsingAWS(t *testing.T, imagePath string) { + creds, err := getAWSCredentialsFromEnv() + require.NoError(t, err) + + // if no credentials are given, fall back to qemu + if creds == nil { + log.Print("no AWS credentials given, falling back to booting using qemu") + testBootUsingQemu(t, imagePath) + return + + } + + imageName, err := generateRandomString("osbuild-image-tests-image-") + require.NoError(t, err) + + e, err := newEC2(creds) + require.NoError(t, err) + + // the following line should be done by osbuild-composer at some point + err = uploadImageToAWS(creds, imagePath, imageName) + require.NoErrorf(t, err, "upload to amazon failed, resources could have been leaked") + + imageDesc, err := describeEC2Image(e, imageName) + require.NoErrorf(t, err, "cannot describe the ec2 image") + + // delete the image after the test is over + defer func() { + err = deleteEC2Image(e, imageDesc) + require.NoErrorf(t, err, "cannot delete the ec2 image, resources could have been leaked") + }() + + // boot the uploaded image and try to connect to it + err = withSSHKeyPair(func(privateKey, publicKey string) error { + return withBootedImageInEC2(e, imageDesc, publicKey, func(address string) error { + testSSH(t, address, privateKey, nil) + return nil + }) + }) + require.NoError(t, err) +} + // testBoot tests if the image is able to successfully boot // Before the test it boots the image respecting the specified bootType. // The test passes if the function is able to connect to the image via ssh @@ -250,6 +291,9 @@ func testBoot(t *testing.T, imagePath string, bootType string, outputID string) case "nspawn-extract": testBootUsingNspawnDirectory(t, imagePath, outputID) + case "aws": + testBootUsingAWS(t, imagePath) + default: panic("unknown boot type!") } diff --git a/test/cases/f30-x86_64-ami-boot.json b/test/cases/f30-x86_64-ami-boot.json index ba125b2b6..19da682b2 100644 --- a/test/cases/f30-x86_64-ami-boot.json +++ b/test/cases/f30-x86_64-ami-boot.json @@ -1,6 +1,6 @@ { "boot": { - "type": "qemu-extract" + "type": "aws" }, "compose-request": { "distro": "fedora-30", diff --git a/test/cases/f31-x86_64-ami-boot.json b/test/cases/f31-x86_64-ami-boot.json index 18e0551b9..c26203d51 100644 --- a/test/cases/f31-x86_64-ami-boot.json +++ b/test/cases/f31-x86_64-ami-boot.json @@ -1,6 +1,6 @@ { "boot": { - "type": "qemu-extract" + "type": "aws" }, "compose-request": { "distro": "fedora-31", diff --git a/tools/test-case-generators/format-request-map.json b/tools/test-case-generators/format-request-map.json index a62993d88..712f0ed89 100644 --- a/tools/test-case-generators/format-request-map.json +++ b/tools/test-case-generators/format-request-map.json @@ -1,7 +1,7 @@ { "ami": { "boot": { - "type": "qemu-extract" + "type": "aws" }, "compose-request": { "distro": "",