debian-forge-composer/internal/boot/aws.go
2024-08-20 15:32:40 +02:00

278 lines
8.5 KiB
Go

//go:build integration
package boot
import (
"context"
"encoding/base64"
"errors"
"fmt"
"os"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/ec2"
ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
"github.com/osbuild/images/pkg/arch"
"github.com/osbuild/osbuild-composer/internal/cloud/awscloud"
)
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: %w", err)
}
_, err = uploader.Upload(imagePath, c.Bucket, imageName)
if err != nil {
return fmt.Errorf("cannot upload the image: %w", err)
}
_, err = uploader.Register(imageName, c.Bucket, imageName, nil, arch.Current().String(), nil)
if err != nil {
return fmt.Errorf("cannot register the image: %w", err)
}
return 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
Img *ec2types.Image
}
// DescribeEC2Image searches for EC2 image by its name and returns
// its id and snapshot id
func DescribeEC2Image(c *awsCredentials, imageName string) (*imageDescription, error) {
awscl, err := awscloud.New(c.Region, c.AccessKeyId, c.SecretAccessKey, c.sessionToken)
if err != nil {
return nil, fmt.Errorf("cannot create aws client: %w", err)
}
imageDescriptions, err := awscl.DescribeImagesByName(imageName)
if err != nil {
return nil, fmt.Errorf("cannot describe images: %w", err)
}
imageId := imageDescriptions.Images[0].ImageId
snapshotId := imageDescriptions.Images[0].BlockDeviceMappings[0].Ebs.SnapshotId
return &imageDescription{
Id: imageId,
SnapshotId: snapshotId,
Img: &imageDescriptions.Images[0],
}, nil
}
// DeleteEC2Image deletes the specified image and its associated snapshot
func DeleteEC2Image(c *awsCredentials, imageDesc *imageDescription) error {
awscl, err := awscloud.New(c.Region, c.AccessKeyId, c.SecretAccessKey, c.sessionToken)
if err != nil {
return fmt.Errorf("cannot create aws client: %w", err)
}
return awscl.RemoveSnapshotAndDeregisterImage(imageDesc.Img)
}
// WithBootedImageInEC2 runs the function f in the context of booted
// image in AWS EC2
func WithBootedImageInEC2(c *awsCredentials, securityGroupName string, imageDesc *imageDescription, publicKey string, instanceType string, f func(address string) error) (retErr error) {
awscl, err := awscloud.New(c.Region, c.AccessKeyId, c.SecretAccessKey, c.sessionToken)
if err != nil {
return fmt.Errorf("cannot create aws client: %w", err)
}
// 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 := awscl.EC2ForTestsOnly().CreateSecurityGroup(
context.Background(),
&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: %w", err)
}
defer func() {
_, err = awscl.EC2ForTestsOnly().DeleteSecurityGroup(
context.Background(),
&ec2.DeleteSecurityGroupInput{
GroupId: securityGroup.GroupId,
},
)
if err != nil {
retErr = wrapErrorf(retErr, "cannot delete the security group: %w", err)
}
}()
// Authorize incoming SSH connections.
_, err = awscl.EC2ForTestsOnly().AuthorizeSecurityGroupIngress(
context.Background(),
&ec2.AuthorizeSecurityGroupIngressInput{
CidrIp: aws.String("0.0.0.0/0"),
GroupId: securityGroup.GroupId,
FromPort: aws.Int32(22),
ToPort: aws.Int32(22),
IpProtocol: aws.String("tcp"),
},
)
if err != nil {
return fmt.Errorf("canot add a rule to the security group: %w", err)
}
// Finally, run the instance from the given image and with the created security group
res, err := awscl.EC2ForTestsOnly().RunInstances(
context.Background(),
&ec2.RunInstancesInput{
MaxCount: aws.Int32(1),
MinCount: aws.Int32(1),
ImageId: imageDesc.Id,
InstanceType: ec2types.InstanceType(instanceType),
SecurityGroupIds: []string{*securityGroup.GroupId},
UserData: aws.String(encodeBase64(userData)),
},
)
if err != nil {
return fmt.Errorf("cannot create a new instance: %w", 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 = awscl.EC2ForTestsOnly().TerminateInstances(
context.Background(),
&ec2.TerminateInstancesInput{
InstanceIds: []string{
*res.Instances[0].InstanceId,
},
},
)
if err != nil {
retErr = wrapErrorf(retErr, "cannot terminate the instance: %w", err)
return
}
instTermWaiter := ec2.NewInstanceTerminatedWaiter(awscl.EC2ForTestsOnly())
err = instTermWaiter.Wait(
context.Background(),
describeInstanceInput,
time.Hour,
)
if err != nil {
retErr = wrapErrorf(retErr, "cannot terminate the instance: %w", err)
return
}
}()
// 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.
instWaiter := ec2.NewInstanceRunningWaiter(awscl.EC2ForTestsOnly())
err = instWaiter.Wait(
context.Background(),
describeInstanceInput,
time.Hour,
)
if err != nil {
return fmt.Errorf("waiting for the instance to be running failed: %w", err)
}
// By describing the instance, we can get the ip address.
out, err := awscl.EC2ForTestsOnly().DescribeInstances(context.Background(), describeInstanceInput)
if err != nil {
return fmt.Errorf("cannot describe the instance: %w", err)
}
return f(*out.Reservations[0].Instances[0].PublicIpAddress)
}