tests/image/ami: test ami images on AWS

Prior this commit the ami images were tested locally using qemu. This does
not reflect at all how they're used in practice. This commit introduces
the support for running them in the actual AWS. Yay!

The structure of code reflects that we want to switch to osbuild-composer
to build the images soon.
This commit is contained in:
Ondřej Budai 2020-04-02 15:17:10 +02:00 committed by Tom Gundersen
parent 92e69dcb85
commit 7dd09443cf
7 changed files with 362 additions and 3 deletions

View file

@ -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)
}

View file

@ -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)
})
}

View file

@ -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
}

View file

@ -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!")
}

View file

@ -1,6 +1,6 @@
{
"boot": {
"type": "qemu-extract"
"type": "aws"
},
"compose-request": {
"distro": "fedora-30",

View file

@ -1,6 +1,6 @@
{
"boot": {
"type": "qemu-extract"
"type": "aws"
},
"compose-request": {
"distro": "fedora-31",

View file

@ -1,7 +1,7 @@
{
"ami": {
"boot": {
"type": "qemu-extract"
"type": "aws"
},
"compose-request": {
"distro": "",