Skip to content

Commit a902e4d

Browse files
committed
test(e2e): stage AWS pull artifacts in S3
The kubetest2-kops deployer stages AWS build artifacts in an ephemeral S3 bucket to exercise the s3:// KOPS_BASE_URL path from #18661. The bucket name uses the full BUILD_ID so separate kubetest2 invocations derive the same name without sharing state. The deployer creates the bucket during --build and deletes it during --down or after Build or Up fails. It never manages buckets supplied through KOPS_STAGING_BUCKET. Without BUILD_ID, staging continues to use GCS. Names over S3's 63-character limit fail instead of being truncated, avoiding collisions between jobs. s3-publish-ci re-exports AWS_REGION because the Makefile unexports it and hack/upload needs it to resolve the AWS CLI region. Uploads use --private because the bucket policy grants public read while its public access block settings reject object ACLs. upgrade.sh delays acquiring version B artifacts until after kubetest2 --down because teardown deletes staged artifacts. It builds a local version B binary first so cleanup can run before that acquisition.
1 parent bc8e90b commit a902e4d

14 files changed

Lines changed: 569 additions & 88 deletions

File tree

Makefile

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,26 @@ gcs-publish-ci: gcloud version-dist-ci
249249
echo "${GCS_URL}/${VERSION}" > ${UPLOAD}/${LATEST_FILE}
250250
gcloud storage cp --cache-control="private, max-age=0, no-transform" ${UPLOAD}/${LATEST_FILE} ${GCS_LOCATION}
251251

252+
# s3-publish-ci is the entry point for AWS CI testing.
253+
.PHONY: s3-publish-ci
254+
ifneq ($(AWS_ACCESS_KEY_ID),)
255+
s3-publish-ci: export AWS_ACCESS_KEY_ID := $(AWS_ACCESS_KEY_ID)
256+
endif
257+
ifneq ($(AWS_SECRET_ACCESS_KEY),)
258+
s3-publish-ci: export AWS_SECRET_ACCESS_KEY := $(AWS_SECRET_ACCESS_KEY)
259+
endif
260+
ifneq ($(AWS_SESSION_TOKEN),)
261+
s3-publish-ci: export AWS_SESSION_TOKEN := $(AWS_SESSION_TOKEN)
262+
endif
263+
ifneq ($(AWS_REGION),)
264+
s3-publish-ci: export AWS_REGION := $(AWS_REGION)
265+
endif
266+
s3-publish-ci: version-dist-ci
267+
@echo "== Uploading kops =="
268+
${UPLOAD_CMD} ${UPLOAD}/kops/ ${UPLOAD_DEST}
269+
echo "VERSION: ${VERSION}"
270+
echo "$(patsubst %/,%,$(UPLOAD_DEST))/${VERSION}" > ${UPLOAD}/${LATEST_FILE}
271+
252272
.PHONY: gen-cli-docs
253273
gen-cli-docs: kops # Regenerate CLI docs
254274
KOPS_STATE_STORE= \

tests/e2e/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010
github.com/aws/aws-sdk-go-v2/service/ec2 v1.307.0
1111
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
1212
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5
13+
github.com/aws/smithy-go v1.27.3
1314
github.com/blang/semver/v4 v4.0.0
1415
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
1516
github.com/spf13/pflag v1.0.10
@@ -93,7 +94,6 @@ require (
9394
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
9495
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect
9596
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
96-
github.com/aws/smithy-go v1.27.3 // indirect
9797
github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.10.1 // indirect
9898
github.com/beorn7/perks v1.0.1 // indirect
9999
github.com/blang/semver v3.5.1+incompatible // indirect

tests/e2e/kubetest2-kops/aws/s3.go

Lines changed: 91 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"github.com/aws/aws-sdk-go-v2/service/s3"
3131
"github.com/aws/aws-sdk-go-v2/service/s3/types"
3232
"github.com/aws/aws-sdk-go-v2/service/sts"
33+
"github.com/aws/smithy-go"
3334
"k8s.io/klog/v2"
3435
)
3536

@@ -50,6 +51,7 @@ type BucketType string
5051
const (
5152
BucketTypeStateStore BucketType = "state"
5253
BucketTypeDiscoveryStore BucketType = "discovery"
54+
BucketTypeStagingStore BucketType = "staging"
5355
)
5456

5557
// NewAWSClient returns a new instance of awsClient configured to work in the default region (us-east-2).
@@ -68,17 +70,14 @@ func NewClient(ctx context.Context, region string) (*Client, error) {
6870

6971
// BucketName constructs an unique bucket name using the AWS account ID in the default region (us-east-2).
7072
func (c Client) BucketName(ctx context.Context, bucketType BucketType) (string, error) {
71-
// Construct the bucket name based on the ProwJob ID (if running in Prow) or AWS account ID (if running outside
72-
// Prow) and a timestamp. When BUILD_ID is set we use it in place of time.Now() so that multiple kubetest2-kops
73-
// invocations within the same CI job (e.g. upgrade tests) resolve to the same bucket name.
74-
var suffix string
75-
if jobID := os.Getenv("BUILD_ID"); jobID != "" {
76-
if len(jobID) > 14 {
77-
suffix = jobID[:14]
78-
} else {
79-
suffix = jobID
80-
}
81-
} else {
73+
// Construct the bucket name based on the ProwJob ID (if running in Prow) or AWS account ID (if
74+
// running outside Prow) and a timestamp. When BUILD_ID is set we use it in place of time.Now()
75+
// so that multiple kubetest2-kops invocations within the same CI job (e.g. upgrade tests)
76+
// resolve to the same bucket name. Use the full build ID because truncating its low-order
77+
// digits can make jobs started in the same millisecond collide, allowing one job to delete
78+
// another's bucket.
79+
suffix := os.Getenv("BUILD_ID")
80+
if suffix == "" {
8281
callerIdentity, err := c.stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
8382
if err != nil {
8483
return "", fmt.Errorf("building AWS STS presigned request: %w", err)
@@ -91,14 +90,12 @@ func (c Client) BucketName(ctx context.Context, bucketType BucketType) (string,
9190
// Only allow lowercase letters, numbers, and hyphens
9291
bucket = bucketNameRegex.ReplaceAllString(bucket, "")
9392

94-
if len(bucket) > 63 {
95-
bucket = bucket[:63] // Max length is 63
96-
}
97-
93+
// Names over the 63 character S3 limit fail bucket creation rather than being silently
94+
// truncated, which could make distinct jobs collide on one bucket.
9895
return bucket, nil
9996
}
10097

101-
// EnsureS3Bucket creates a new S3 bucket with the given name and public read permissions.
98+
// EnsureS3Bucket creates an S3 bucket, optionally with public read access.
10299
func (c Client) EnsureS3Bucket(ctx context.Context, region, bucketName string, publicRead bool) error {
103100
bucketName = strings.TrimPrefix(bucketName, "s3://")
104101
klog.Infof("Creating bucket %s in region %s", bucketName, region)
@@ -112,14 +109,11 @@ func (c Client) EnsureS3Bucket(ctx context.Context, region, bucketName string, p
112109
},
113110
)
114111
if err != nil {
115-
var exists *types.BucketAlreadyExists
116-
if errors.As(err, &exists) {
117-
klog.Infof("Bucket %s already exists\n", bucketName)
118-
} else {
119-
klog.Infof("Error creating bucket %s, err: %v\n", bucketName, err)
112+
var owned *types.BucketAlreadyOwnedByYou
113+
if !errors.As(err, &owned) {
114+
return fmt.Errorf("creating bucket %s: %w", bucketName, err)
120115
}
121-
122-
return fmt.Errorf("creating bucket %s: %w", bucketName, err)
116+
klog.Infof("Bucket %s already exists in this account", bucketName)
123117
}
124118

125119
// Wait for the bucket to be created
@@ -134,26 +128,21 @@ func (c Client) EnsureS3Bucket(ctx context.Context, region, bucketName string, p
134128
return fmt.Errorf("waiting for bucket %s to exist: %w", bucketName, err)
135129
}
136130

137-
klog.Infof("Bucket %s created successfully", bucketName)
131+
klog.Infof("Bucket %s is ready", bucketName)
138132

139133
if publicRead {
140-
err = c.setPublicAccessBlock(ctx, bucketName)
141-
if err != nil {
134+
if err := c.setPublicAccessBlock(ctx, bucketName); err != nil {
142135
klog.Errorf("Failed to disable public access block policies on bucket %s, err: %v", bucketName, err)
143-
144136
return fmt.Errorf("disabling public access block policies for bucket %s: %w", bucketName, err)
145137
}
146138

147139
// Wait for public access block settings to propagate before setting the policy
148140
time.Sleep(10 * time.Second)
149141

150-
err = c.setPublicReadPolicy(ctx, bucketName)
151-
if err != nil {
142+
if err := c.setPublicReadPolicy(ctx, bucketName); err != nil {
152143
klog.Errorf("Failed to set public read policy on bucket %s, err: %v", bucketName, err)
153-
154144
return fmt.Errorf("setting public read policy for bucket %s: %w", bucketName, err)
155145
}
156-
157146
klog.Infof("Public read policy set on bucket %s", bucketName)
158147
}
159148

@@ -162,28 +151,47 @@ func (c Client) EnsureS3Bucket(ctx context.Context, region, bucketName string, p
162151

163152
// DeleteS3Bucket deletes a S3 bucket with the given name.
164153
func (c Client) DeleteS3Bucket(ctx context.Context, bucketName string) error {
154+
return c.deleteS3Bucket(ctx, bucketName, false)
155+
}
156+
157+
// DeleteS3BucketAndContents deletes all objects from an S3 bucket before deleting the bucket.
158+
func (c Client) DeleteS3BucketAndContents(ctx context.Context, bucketName string) error {
159+
return c.deleteS3Bucket(ctx, bucketName, true)
160+
}
161+
162+
func (c Client) deleteS3Bucket(ctx context.Context, bucketName string, empty bool) error {
165163
bucketName = strings.TrimPrefix(bucketName, "s3://")
166164

167165
// Resolve the bucket's actual region to avoid 301 PermanentRedirect errors.
168166
// During teardown the deployer may derive the S3 client region from random
169167
// zones, which can differ from the region where the bucket was created.
170168
bucketRegion, err := c.getBucketRegion(ctx, bucketName)
171169
if err != nil {
170+
if isNoSuchBucket(err) {
171+
// Teardown may run without creating every bucket it knows about.
172+
return nil
173+
}
172174
klog.Infof("Could not determine region for bucket %s: %v", bucketName, err)
173175
}
174176

175-
regionOpt := func(o *s3.Options) {
176-
if bucketRegion != "" {
177-
o.Region = bucketRegion
177+
client := c.s3Client
178+
if bucketRegion != "" {
179+
options := c.s3Client.Options()
180+
options.Region = bucketRegion
181+
client = s3.New(options)
182+
}
183+
184+
if empty {
185+
if err := emptyS3Bucket(ctx, client, bucketName); err != nil {
186+
return err
178187
}
179188
}
180189

181-
_, err = c.s3Client.DeleteBucket(ctx, &s3.DeleteBucketInput{
190+
_, err = client.DeleteBucket(ctx, &s3.DeleteBucketInput{
182191
Bucket: aws.String(bucketName),
183-
}, regionOpt)
192+
})
184193
if err != nil {
185-
var noBucket *types.NoSuchBucket
186-
if errors.As(err, &noBucket) {
194+
if isNoSuchBucket(err) {
187195
klog.Infof("Bucket %s does not exist.", bucketName)
188196

189197
return nil
@@ -193,7 +201,7 @@ func (c Client) DeleteS3Bucket(ctx context.Context, bucketName string) error {
193201
return fmt.Errorf("deleting bucket %s: %w", bucketName, err)
194202
}
195203

196-
err = s3.NewBucketNotExistsWaiter(c.s3Client).Wait(
204+
err = s3.NewBucketNotExistsWaiter(client).Wait(
197205
ctx, &s3.HeadBucketInput{
198206
Bucket: aws.String(bucketName),
199207
},
@@ -205,10 +213,53 @@ func (c Client) DeleteS3Bucket(ctx context.Context, bucketName string) error {
205213
}
206214

207215
klog.Infof("Bucket %s deleted", bucketName)
208-
209216
return nil
210217
}
211218

219+
func emptyS3Bucket(ctx context.Context, client *s3.Client, bucketName string) error {
220+
for {
221+
result, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
222+
Bucket: aws.String(bucketName),
223+
})
224+
if err != nil {
225+
if isNoSuchBucket(err) {
226+
return nil
227+
}
228+
return fmt.Errorf("listing objects in bucket %s: %w", bucketName, err)
229+
}
230+
231+
if len(result.Contents) == 0 {
232+
return nil
233+
}
234+
235+
objects := make([]types.ObjectIdentifier, len(result.Contents))
236+
for i, object := range result.Contents {
237+
objects[i] = types.ObjectIdentifier{Key: object.Key}
238+
}
239+
deleteResult, err := client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
240+
Bucket: aws.String(bucketName),
241+
Delete: &types.Delete{Objects: objects, Quiet: aws.Bool(true)},
242+
})
243+
if err != nil {
244+
return fmt.Errorf("deleting objects from bucket %s: %w", bucketName, err)
245+
}
246+
if len(deleteResult.Errors) > 0 {
247+
objectError := deleteResult.Errors[0]
248+
return fmt.Errorf("deleting object %q from bucket %s: %s: %s",
249+
aws.ToString(objectError.Key), bucketName, aws.ToString(objectError.Code), aws.ToString(objectError.Message))
250+
}
251+
}
252+
}
253+
254+
func isNoSuchBucket(err error) bool {
255+
var noBucket *types.NoSuchBucket
256+
if errors.As(err, &noBucket) {
257+
return true
258+
}
259+
var apiErr smithy.APIError
260+
return errors.As(err, &apiErr) && (apiErr.ErrorCode() == "NoSuchBucket" || apiErr.ErrorCode() == "NotFound")
261+
}
262+
212263
// getBucketRegion resolves the AWS region where the bucket resides.
213264
// GetBucketLocation is region-agnostic and can locate buckets in any region
214265
// from any endpoint. We pin to defaultRegion for consistency.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
Copyright 2026 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package aws
18+
19+
import (
20+
"context"
21+
"errors"
22+
"fmt"
23+
"net/http"
24+
"net/http/httptest"
25+
"strings"
26+
"testing"
27+
28+
"github.com/aws/aws-sdk-go-v2/aws"
29+
"github.com/aws/aws-sdk-go-v2/service/s3"
30+
"github.com/aws/aws-sdk-go-v2/service/s3/types"
31+
"github.com/aws/smithy-go"
32+
)
33+
34+
func TestStagingBucketName(t *testing.T) {
35+
t.Setenv("BUILD_ID", "12345678901234567890")
36+
name, err := (Client{}).BucketName(context.Background(), BucketTypeStagingStore)
37+
if err != nil {
38+
t.Fatalf("building bucket name: %v", err)
39+
}
40+
if expected := "k8s-infra-kops-staging-12345678901234567890"; name != expected {
41+
t.Errorf("expected %q, got %q", expected, name)
42+
}
43+
if len(name) > 63 {
44+
t.Errorf("bucket name %q is longer than the 63 character limit", name)
45+
}
46+
}
47+
48+
// Teardown can run without --build, so a missing staging bucket must be a no-op.
49+
func TestDeleteMissingS3Bucket(t *testing.T) {
50+
var requests []string
51+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52+
requests = append(requests, r.Method+" "+r.URL.RequestURI())
53+
w.Header().Set("Content-Type", "application/xml")
54+
w.WriteHeader(http.StatusNotFound)
55+
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?><Error><Code>NoSuchBucket</Code><Message>The specified bucket does not exist</Message></Error>`)
56+
}))
57+
defer server.Close()
58+
59+
c := Client{s3Client: s3.New(s3.Options{
60+
Region: defaultRegion,
61+
BaseEndpoint: aws.String(server.URL),
62+
Credentials: aws.AnonymousCredentials{},
63+
UsePathStyle: true,
64+
RetryMaxAttempts: 1,
65+
})}
66+
67+
if err := c.deleteS3Bucket(context.Background(), "does-not-exist", true); err != nil {
68+
t.Errorf("deleting a bucket that does not exist: %v", err)
69+
}
70+
if len(requests) != 1 || !strings.Contains(requests[0], "location") {
71+
t.Errorf("expected the bucket location request only, got %v", requests)
72+
}
73+
}
74+
75+
func TestIsNoSuchBucket(t *testing.T) {
76+
for _, tc := range []struct {
77+
name string
78+
err error
79+
want bool
80+
}{
81+
{name: "typed", err: &types.NoSuchBucket{}, want: true},
82+
{name: "generic code", err: &smithy.GenericAPIError{Code: "NoSuchBucket"}, want: true},
83+
{name: "generic not found", err: &smithy.GenericAPIError{Code: "NotFound"}, want: true},
84+
{name: "other", err: errors.New("other")},
85+
} {
86+
t.Run(tc.name, func(t *testing.T) {
87+
if got := isNoSuchBucket(tc.err); got != tc.want {
88+
t.Errorf("isNoSuchBucket() = %v, expected %v", got, tc.want)
89+
}
90+
})
91+
}
92+
}

0 commit comments

Comments
 (0)