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:
parent
92e69dcb85
commit
7dd09443cf
7 changed files with 362 additions and 3 deletions
281
cmd/osbuild-image-tests/aws.go
Normal file
281
cmd/osbuild-image-tests/aws.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"boot": {
|
||||
"type": "qemu-extract"
|
||||
"type": "aws"
|
||||
},
|
||||
"compose-request": {
|
||||
"distro": "fedora-30",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"boot": {
|
||||
"type": "qemu-extract"
|
||||
"type": "aws"
|
||||
},
|
||||
"compose-request": {
|
||||
"distro": "fedora-31",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"ami": {
|
||||
"boot": {
|
||||
"type": "qemu-extract"
|
||||
"type": "aws"
|
||||
},
|
||||
"compose-request": {
|
||||
"distro": "",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue