debian-forge-composer/internal/boot/aws.go
Tomáš Hozza e13f0a1ae2 AWS: allow specifying the AMI boot mode when registering the image
When the AMI is being registered from a snapshot, the caller can
optionally specify the boot mode of the AMI. If no boot mode is
specified, then the default behavior is to use the boot type of the
instance that is launched from the AMI.

The default behavior (no boot type specified) is preserved after this
change.

Signed-off-by: Tomáš Hozza <thozza@redhat.com>
2023-05-19 13:24:39 +02:00

277 lines
8.3 KiB
Go

//go:build integration
package boot
import (
"encoding/base64"
"errors"
"fmt"
"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/cloud/awscloud"
"github.com/osbuild/osbuild-composer/internal/common"
)
type awsCredentials struct {
AccessKeyId string
SecretAccessKey string
sessionToken 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("V2_AWS_ACCESS_KEY_ID")
secretAccessKey, sakExists := os.LookupEnv("V2_AWS_SECRET_ACCESS_KEY")
region, regionExists := os.LookupEnv("AWS_REGION")
bucket, bucketExists := os.LookupEnv("AWS_BUCKET")
// 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 := os.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 := awscloud.New(c.Region, c.AccessKeyId, c.SecretAccessKey, c.sessionToken)
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, nil, common.CurrentArch(), nil)
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, securityGroupName string, imageDesc *imageDescription, publicKey string, instanceType 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.
// 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(instanceType),
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)
}