Use ephemeral S3 buckets for E2E tests (#17157)

* Use ephemeral S3 buckets for E2E tests

Use S3 buckets created during the lifecycle of a test instead of a
static one and provide the capability to make them read-only public.

Signed-off-by: Arnaud Meukam <ameukam@gmail.com>

* Improve ephemeral S3 buckets implementation for tests

Signed-off-by: Marko Mudrinić <mudrinic.mare@gmail.com>
Signed-off-by: Arnaud Meukam <ameukam@gmail.com>

* Base S3 bucket name on ProwJob ID

Signed-off-by: Marko Mudrinić <mudrinic.mare@gmail.com>

---------

Signed-off-by: Arnaud Meukam <ameukam@gmail.com>
Signed-off-by: Marko Mudrinić <mudrinic.mare@gmail.com>
Co-authored-by: Marko Mudrinić <mudrinic.mare@gmail.com>
This commit is contained in:
Arnaud M. 2025-03-06 17:45:45 +01:00 committed by GitHub
parent 4539d80a52
commit 06250623d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 263 additions and 14 deletions

View File

@ -5,6 +5,10 @@ go 1.23.5
replace k8s.io/kops => ../../.
require (
github.com/aws/aws-sdk-go-v2 v1.31.0
github.com/aws/aws-sdk-go-v2/config v1.27.38
github.com/aws/aws-sdk-go-v2/service/s3 v1.63.2
github.com/aws/aws-sdk-go-v2/service/sts v1.31.2
github.com/blang/semver/v4 v4.0.0
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/octago/sflags v0.2.0
@ -66,9 +70,7 @@ require (
github.com/aliyun/credentials-go v1.2.3 // indirect
github.com/apparentlymart/go-cidr v1.1.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2 v1.31.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.5 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.38 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.36 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.18 // indirect
@ -82,10 +84,8 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.18 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.63.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.23.2 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.27.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.31.2 // indirect
github.com/aws/smithy-go v1.21.0 // indirect
github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20220228164355-396b2034c795 // indirect
github.com/beorn7/perks v1.0.1 // indirect

View File

@ -0,0 +1,194 @@
/*
Copyright 2024 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package aws
import (
"context"
"errors"
"fmt"
"os"
"regexp"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/aws-sdk-go-v2/service/sts"
"k8s.io/klog/v2"
)
// defaultRegion is the region to query the AWS APIs through, this can be any AWS region is required even if we are not
// running on AWS.
const defaultRegion = "us-east-2"
// Client contains S3 and STS clients that are used to perform bucket and object actions.
type Client struct {
s3Client *s3.Client
stsClient *sts.Client
}
// NewAWSClient returns a new instance of awsClient configured to work in the default region (us-east-2).
func NewClient(ctx context.Context) (*Client, error) {
cfg, err := awsconfig.LoadDefaultConfig(ctx,
awsconfig.WithRegion(defaultRegion))
if err != nil {
return nil, fmt.Errorf("loading AWS config: %w", err)
}
return &Client{
s3Client: s3.NewFromConfig(cfg),
stsClient: sts.NewFromConfig(cfg),
}, nil
}
// BucketName constructs an unique bucket name using the AWS account ID in the default region (us-east-2).
func (c Client) BucketName(ctx context.Context) (string, error) {
// Construct the bucket name based on the ProwJob ID (if running in Prow) or AWS account ID (if running outside
// Prow) and the current timestamp
var identifier string
if jobID := os.Getenv("PROW_JOB_ID"); len(jobID) >= 4 {
identifier = jobID[:4]
} else {
callerIdentity, err := c.stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
if err != nil {
return "", fmt.Errorf("building AWS STS presigned request: %w", err)
}
identifier = *callerIdentity.Account
}
timestamp := time.Now().Format("20060102150405")
bucket := fmt.Sprintf("k8s-infra-kops-%s-%s", identifier, timestamp)
bucket = strings.ToLower(bucket)
// Only allow lowercase letters, numbers, and hyphens
bucket = regexp.MustCompile("[^a-z0-9-]").ReplaceAllString(bucket, "")
if len(bucket) > 63 {
bucket = bucket[:63] // Max length is 63
}
return bucket, nil
}
// EnsureS3Bucket creates a new S3 bucket with the given name and public read permissions.
func (c Client) EnsureS3Bucket(ctx context.Context, bucketName string, publicRead bool) error {
bucketName = strings.TrimPrefix(bucketName, "s3://")
_, err := c.s3Client.CreateBucket(ctx, &s3.CreateBucketInput{
Bucket: aws.String(bucketName),
CreateBucketConfiguration: &types.CreateBucketConfiguration{
LocationConstraint: defaultRegion,
},
})
if err != nil {
var exists *types.BucketAlreadyExists
if errors.As(err, &exists) {
klog.Infof("Bucket %s already exists\n", bucketName)
} else {
klog.Infof("Error creating bucket %s, err: %v\n", bucketName, err)
}
return fmt.Errorf("creating bucket %s: %w", bucketName, err)
}
// Wait for the bucket to be created
err = s3.NewBucketExistsWaiter(c.s3Client).Wait(
ctx, &s3.HeadBucketInput{
Bucket: aws.String(bucketName),
},
time.Minute)
if err != nil {
klog.Infof("Failed attempt to wait for bucket %s to exist, err: %v", bucketName, err)
return fmt.Errorf("waiting for bucket %s to exist: %w", bucketName, err)
}
klog.Infof("Bucket %s created successfully", bucketName)
if publicRead {
err = c.setPublicReadPolicy(ctx, bucketName)
if err != nil {
klog.Errorf("Failed to set public read policy on bucket %s, err: %v", bucketName, err)
return fmt.Errorf("setting public read policy for bucket %s: %w", bucketName, err)
}
klog.Infof("Public read policy set on bucket %s", bucketName)
}
return nil
}
// DeleteS3Bucket deletes a S3 bucket with the given name.
func (c Client) DeleteS3Bucket(ctx context.Context, bucketName string) error {
bucketName = strings.TrimPrefix(bucketName, "s3://")
_, err := c.s3Client.DeleteBucket(ctx, &s3.DeleteBucketInput{
Bucket: aws.String(bucketName),
})
if err != nil {
var noBucket *types.NoSuchBucket
if errors.As(err, &noBucket) {
klog.Infof("Bucket %s does not exits.", bucketName)
return nil
} else {
klog.Infof("Couldn't delete bucket %s, err: %v", bucketName, err)
return fmt.Errorf("deleting bucket %s: %w", bucketName, err)
}
}
err = s3.NewBucketNotExistsWaiter(c.s3Client).Wait(
ctx, &s3.HeadBucketInput{
Bucket: aws.String(bucketName),
},
time.Minute)
if err != nil {
klog.Infof("Failed attempt to wait for bucket %s to be deleted, err: %v", bucketName, err)
return fmt.Errorf("waiting for bucket %s to be deleted, err: %w", bucketName, err)
}
klog.Infof("Bucket %s deleted", bucketName)
return nil
}
func (c Client) setPublicReadPolicy(ctx context.Context, bucketName string) error {
policy := fmt.Sprintf(`{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::%s/*"
}
]
}`, bucketName)
_, err := c.s3Client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{
Bucket: aws.String(bucketName),
Policy: aws.String(policy),
})
if err != nil {
return fmt.Errorf("putting bucket policy for %s: %w", bucketName, err)
}
return nil
}

View File

@ -17,6 +17,7 @@ limitations under the License.
package deployer
import (
"context"
"errors"
"fmt"
"os"
@ -24,6 +25,7 @@ import (
"strings"
"k8s.io/klog/v2"
"k8s.io/kops/tests/e2e/kubetest2-kops/aws"
"k8s.io/kops/tests/e2e/kubetest2-kops/gce"
"k8s.io/kops/tests/e2e/pkg/target"
"k8s.io/kops/tests/e2e/pkg/util"
@ -51,6 +53,12 @@ func (d *deployer) initialize() error {
switch d.CloudProvider {
case "aws":
client, err := aws.NewClient(context.Background())
if err != nil {
return fmt.Errorf("init failed to build AWS client: %w", err)
}
d.aws = client
if d.SSHPrivateKeyPath == "" {
d.SSHPrivateKeyPath = os.Getenv("AWS_SSH_PRIVATE_KEY_FILE")
}
@ -316,11 +324,21 @@ func defaultClusterName(cloudProvider string) (string, error) {
// stateStore returns the kops state store to use
// defaulting to values used in prow jobs
func (d *deployer) stateStore() string {
if d.stateStoreName != "" {
return d.stateStoreName
}
ss := os.Getenv("KOPS_STATE_STORE")
if ss == "" {
switch d.CloudProvider {
case "aws":
ss = "s3://k8s-kops-prow"
ctx := context.Background()
bucketName, err := d.aws.BucketName(ctx)
if err != nil {
klog.Fatalf("Failed to generate bucket name: %v", err)
return ""
}
d.createBucket = true
ss = "s3://" + bucketName
case "gce":
d.createBucket = true
ss = "gs://" + gce.GCSBucketName(d.GCPProject, "state")
@ -328,11 +346,16 @@ func (d *deployer) stateStore() string {
ss = "do://e2e-kops-space"
}
}
d.stateStoreName = ss
return ss
}
// discoveryStore returns the VFS path to use for public OIDC documents
func (d *deployer) discoveryStore() string {
if d.discoveryStoreName != "" {
return d.discoveryStoreName
}
discovery := os.Getenv("KOPS_DISCOVERY_STORE")
if discovery == "" {
switch d.CloudProvider {
@ -340,10 +363,14 @@ func (d *deployer) discoveryStore() string {
discovery = "s3://k8s-kops-ci-prow"
}
}
d.discoveryStoreName = discovery
return discovery
}
func (d *deployer) stagingStore() string {
if d.stagingStoreName != "" {
return d.stagingStoreName
}
sb := os.Getenv("KOPS_STAGING_BUCKET")
if sb == "" {
switch d.CloudProvider {
@ -352,6 +379,7 @@ func (d *deployer) stagingStore() string {
sb = "gs://" + gce.GCSBucketName(d.GCPProject, "staging")
}
}
d.stagingStoreName = sb
return sb
}

View File

@ -25,6 +25,7 @@ import (
"github.com/octago/sflags/gen/gpflag"
"github.com/spf13/pflag"
"k8s.io/klog/v2"
"k8s.io/kops/tests/e2e/kubetest2-kops/aws"
"k8s.io/kops/tests/e2e/kubetest2-kops/builder"
"k8s.io/kops/tests/e2e/pkg/target"
@ -57,7 +58,6 @@ type deployer struct {
CreateArgs string `flag:"create-args" desc:"Extra space-separated arguments passed to 'kops create cluster'"`
KopsBinaryPath string `flag:"kops-binary-path" desc:"The path to kops executable used for testing"`
KubernetesFeatureGates string `flag:"kubernetes-feature-gates" desc:"Feature Gates to enable on Kubernetes components"`
createBucket bool `flag:"-"`
// ControlPlaneCount specifies the number of VMs in the control-plane.
ControlPlaneCount int `flag:"control-plane-count" desc:"Number of control-plane instances"`
@ -90,6 +90,13 @@ type deployer struct {
manifestPath string
terraform *target.Terraform
aws *aws.Client
createBucket bool
stateStoreName string
discoveryStoreName string
stagingStoreName string
// boskos struct field will be non-nil when the deployer is
// using boskos to acquire a GCP project
boskos *client.Client
@ -106,8 +113,10 @@ type deployer struct {
var _ types.NewDeployer = New
// assert that deployer implements types.Deployer
var _ types.Deployer = &deployer{}
var _ types.DeployerWithPostTester = &deployer{}
var (
_ types.Deployer = &deployer{}
_ types.DeployerWithPostTester = &deployer{}
)
func (d *deployer) Provider() string {
return Name

View File

@ -17,6 +17,7 @@ limitations under the License.
package deployer
import (
"context"
"fmt"
"strings"
@ -72,9 +73,17 @@ func (d *deployer) Down() error {
return err
}
if d.CloudProvider == "gce" && d.createBucket {
gce.DeleteGCSBucket(d.stateStore(), d.GCPProject)
gce.DeleteGCSBucket(d.stagingStore(), d.GCPProject)
if d.createBucket {
switch d.CloudProvider {
case "aws":
ctx := context.Background()
if err := d.aws.DeleteS3Bucket(ctx, d.stateStore()); err != nil {
return err
}
case "gce":
gce.DeleteGCSBucket(d.stateStore(), d.GCPProject)
gce.DeleteGCSBucket(d.stagingStore(), d.GCPProject)
}
}
if d.boskos != nil {

View File

@ -17,6 +17,7 @@ limitations under the License.
package deployer
import (
"context"
"errors"
"fmt"
osexec "os/exec"
@ -61,9 +62,17 @@ func (d *deployer) Up() error {
_ = d.Down()
}
if d.CloudProvider == "gce" && d.createBucket {
if err := gce.EnsureGCSBucket(d.stateStore(), d.GCPProject, false); err != nil {
return err
if d.createBucket {
switch d.CloudProvider {
case "aws":
ctx := context.Background()
if err := d.aws.EnsureS3Bucket(ctx, d.stateStore(), false); err != nil {
return err
}
case "gce":
if err := gce.EnsureGCSBucket(d.stateStore(), d.GCPProject, false); err != nil {
return err
}
}
}