mirror of
https://github.com/lukaszraczylo/kubernetes-images-sync-operator.git
synced 2026-06-08 23:09:23 +00:00
More fixes, moving from python to golang worker.
This commit is contained in:
@@ -24,46 +24,6 @@ jobs:
|
||||
docker-enabled: true
|
||||
secrets: inherit
|
||||
|
||||
build-worker-image:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get release version
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0")
|
||||
VERSION=${VERSION#v}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push worker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./docker-image-worker
|
||||
file: ./docker-image-worker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/lukaszraczylo/kubernetes-images-sync-worker:${{ steps.version.outputs.version }}
|
||||
ghcr.io/lukaszraczylo/kubernetes-images-sync-worker:latest
|
||||
|
||||
publish-helm-chart:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+41
-1
@@ -34,6 +34,22 @@ builds:
|
||||
- amd64
|
||||
- arm64
|
||||
|
||||
- id: worker
|
||||
main: ./cmd/worker
|
||||
binary: worker
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X main.Version={{.Version}}
|
||||
goos:
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
|
||||
archives:
|
||||
- id: default
|
||||
formats:
|
||||
@@ -97,7 +113,8 @@ release:
|
||||
```
|
||||
|
||||
dockers_v2:
|
||||
- ids:
|
||||
- id: operator
|
||||
ids:
|
||||
- manager
|
||||
images:
|
||||
- "ghcr.io/lukaszraczylo/kubernetes-images-sync-operator"
|
||||
@@ -114,6 +131,29 @@ dockers_v2:
|
||||
"org.opencontainers.image.source": "https://github.com/lukaszraczylo/kubernetes-images-sync-operator"
|
||||
"org.opencontainers.image.description": "Kubernetes operator for backing up and syncing container images"
|
||||
|
||||
- id: worker
|
||||
ids:
|
||||
- worker
|
||||
images:
|
||||
- "ghcr.io/lukaszraczylo/kubernetes-images-sync-worker"
|
||||
tags:
|
||||
- "{{ .Version }}"
|
||||
- "latest"
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
dockerfile: Dockerfile.worker
|
||||
extra_files:
|
||||
- docker-image-worker/storage.conf
|
||||
- docker-image-worker/containers.conf
|
||||
- docker-image-worker/registries.conf
|
||||
- docker-image-worker/podman-preauth.sh
|
||||
labels:
|
||||
"org.opencontainers.image.title": "kubernetes-images-sync-worker"
|
||||
"org.opencontainers.image.version": "{{ .Version }}"
|
||||
"org.opencontainers.image.source": "https://github.com/lukaszraczylo/kubernetes-images-sync-operator"
|
||||
"org.opencontainers.image.description": "Worker image for backing up container images to S3 or local storage"
|
||||
|
||||
signs:
|
||||
- cmd: cosign
|
||||
signature: "${artifact}.sigstore.json"
|
||||
|
||||
@@ -7,7 +7,6 @@ ARG TARGETARCH
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
gnupg2 \
|
||||
python3-pip \
|
||||
sudo \
|
||||
jq \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -30,11 +29,18 @@ RUN adduser --disabled-password --gecos "" --uid 1001 runner \
|
||||
|
||||
WORKDIR /home/runner
|
||||
|
||||
COPY storage.conf containers.conf registries.conf /home/runner/.config/containers/
|
||||
COPY requirements.txt export.py cleanup.py s3_utils.py podman-preauth.sh ./
|
||||
# Copy container configuration files
|
||||
COPY docker-image-worker/storage.conf docker-image-worker/containers.conf docker-image-worker/registries.conf /home/runner/.config/containers/
|
||||
|
||||
# Copy the entrypoint script
|
||||
COPY docker-image-worker/podman-preauth.sh ./
|
||||
|
||||
# Copy the worker binary (from goreleaser build context)
|
||||
COPY $TARGETPLATFORM/worker ./
|
||||
|
||||
USER runner
|
||||
RUN sudo chown -R runner:runner /home/runner/.config \
|
||||
&& python3 -m pip install --no-cache-dir --only-binary=:all: -r requirements.txt \
|
||||
&& sudo chmod +x podman-preauth.sh
|
||||
&& sudo chmod +x podman-preauth.sh worker
|
||||
|
||||
ENTRYPOINT ["/home/runner/podman-preauth.sh"]
|
||||
CMD ["bash", "-c"]
|
||||
CMD ["bash", "-c"]
|
||||
@@ -24,12 +24,13 @@ helm install raczylo/kube-images-sync
|
||||
|
||||
Please remember that backups are triggered whenever the new object appears
|
||||
|
||||
```
|
||||
```yaml
|
||||
apiVersion: raczylo.com/v1
|
||||
kind: ClusterImageExport
|
||||
metadata:
|
||||
name: backup-20240901
|
||||
spec:
|
||||
name: backup-20240901
|
||||
jobAnnotations:
|
||||
my-fancy-export: 11-09-2024
|
||||
# Excludes will remove all images with listed wording from the backup list
|
||||
@@ -68,6 +69,88 @@ spec:
|
||||
maxConcurrentJobs: 1
|
||||
```
|
||||
|
||||
## Automatic Cleanup (TTL & Retention)
|
||||
|
||||
To prevent old exports from accumulating, you can configure automatic cleanup using TTL (time-based) or retention policies (count-based).
|
||||
|
||||
> **WARNING**: When a ClusterImageExport is deleted, the actual backed up images in storage are also deleted. Make sure your retention settings align with your backup requirements.
|
||||
|
||||
### TTL-based cleanup
|
||||
|
||||
Delete exports after a specified number of days:
|
||||
|
||||
```yaml
|
||||
apiVersion: raczylo.com/v1
|
||||
kind: ClusterImageExport
|
||||
metadata:
|
||||
name: daily-backup-2024-12-18
|
||||
spec:
|
||||
name: daily-backup
|
||||
basePath: /backups/daily
|
||||
storage:
|
||||
target: S3
|
||||
s3:
|
||||
bucket: my-backup-bucket
|
||||
region: eu-west-1
|
||||
useRole: true
|
||||
maxConcurrentJobs: 5
|
||||
# Delete this backup 30 days after completion
|
||||
ttlDaysAfterFinished: 30
|
||||
```
|
||||
|
||||
### Retention-based cleanup
|
||||
|
||||
Keep only the last N successful/failed exports per base path:
|
||||
|
||||
```yaml
|
||||
apiVersion: raczylo.com/v1
|
||||
kind: ClusterImageExport
|
||||
metadata:
|
||||
name: weekly-backup-2024-w51
|
||||
spec:
|
||||
name: weekly-backup
|
||||
basePath: /backups/weekly
|
||||
storage:
|
||||
target: S3
|
||||
s3:
|
||||
bucket: my-backup-bucket
|
||||
region: eu-west-1
|
||||
useRole: true
|
||||
maxConcurrentJobs: 5
|
||||
# Keep the last 12 successful backups (3 months of weekly backups)
|
||||
# Keep only the last 2 failed backups for debugging
|
||||
retention:
|
||||
maxSuccessful: 12
|
||||
maxFailed: 2
|
||||
```
|
||||
|
||||
### Combined TTL + Retention
|
||||
|
||||
You can use both policies together. The export will be deleted when either condition is met:
|
||||
|
||||
```yaml
|
||||
apiVersion: raczylo.com/v1
|
||||
kind: ClusterImageExport
|
||||
metadata:
|
||||
name: monthly-backup-2024-12
|
||||
spec:
|
||||
name: monthly-backup
|
||||
basePath: /backups/monthly
|
||||
storage:
|
||||
target: S3
|
||||
s3:
|
||||
bucket: my-backup-bucket
|
||||
region: eu-west-1
|
||||
useRole: true
|
||||
maxConcurrentJobs: 10
|
||||
# Keep backups for up to 1 year
|
||||
ttlDaysAfterFinished: 365
|
||||
# But also limit to last 12 monthly backups
|
||||
retention:
|
||||
maxSuccessful: 12
|
||||
maxFailed: 1
|
||||
```
|
||||
|
||||
## Worth knowing
|
||||
|
||||
* If you provide roleARN, you also need to set the useRole to true.
|
||||
|
||||
@@ -21,6 +21,18 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// RetentionPolicy defines how many completed ClusterImageExport resources to keep
|
||||
type RetentionPolicy struct {
|
||||
// Maximum number of successful exports to keep
|
||||
// +kubebuilder:validation:Minimum=0
|
||||
// +kubebuilder:default=3
|
||||
MaxSuccessful *int32 `json:"maxSuccessful,omitempty"`
|
||||
// Maximum number of failed exports to keep
|
||||
// +kubebuilder:validation:Minimum=0
|
||||
// +kubebuilder:default=1
|
||||
MaxFailed *int32 `json:"maxFailed,omitempty"`
|
||||
}
|
||||
|
||||
type ClusterImageStorageS3 struct {
|
||||
// Bucket name
|
||||
Bucket string `json:"bucket"`
|
||||
@@ -72,6 +84,17 @@ type ClusterImageExportSpec struct {
|
||||
// +kubebuilder:default=5
|
||||
MaxConcurrentJobs int `json:"maxConcurrentJobs"`
|
||||
AdditionalImages []string `json:"additionalImages,omitempty"`
|
||||
// TTLDaysAfterFinished specifies how many days to keep completed exports.
|
||||
// If set, the export (and its backed up images) will be deleted after this many days.
|
||||
// WARNING: Deletion removes both the CRD and the actual backed up images from storage.
|
||||
// +kubebuilder:validation:Minimum=1
|
||||
// +kubebuilder:validation:Optional
|
||||
TTLDaysAfterFinished *int32 `json:"ttlDaysAfterFinished,omitempty"`
|
||||
// Retention specifies how many completed exports to keep per base path.
|
||||
// Oldest exports beyond this limit will be deleted (including their backed up images).
|
||||
// WARNING: Deletion removes both the CRD and the actual backed up images from storage.
|
||||
// +kubebuilder:validation:Optional
|
||||
Retention *RetentionPolicy `json:"retention,omitempty"`
|
||||
}
|
||||
|
||||
// ClusterImageExportStatus defines the observed state of ClusterImageExport
|
||||
@@ -81,6 +104,8 @@ type ClusterImageExportStatus struct {
|
||||
TotalImages int `json:"totalImages,omitempty"`
|
||||
// Number of images that have completed export
|
||||
CompletedImages int `json:"completedImages,omitempty"`
|
||||
// CompletedAt is the timestamp when the export completed (SUCCESS or FAILED)
|
||||
CompletedAt *metav1.Time `json:"completedAt,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
|
||||
"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"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
// Global flags
|
||||
useRole bool
|
||||
useCurrentRole bool
|
||||
roleName string
|
||||
awsAccessKeyID string
|
||||
awsSecretKey string
|
||||
endpointURL string
|
||||
region string
|
||||
maxRetries int
|
||||
retryDelay time.Duration
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "worker",
|
||||
Short: "Kubernetes Images Sync Worker",
|
||||
Long: "Worker for backing up container images to S3 or local storage",
|
||||
}
|
||||
|
||||
// Add global flags
|
||||
rootCmd.PersistentFlags().BoolVar(&useRole, "use_role", false, "Use IAM role for authentication")
|
||||
rootCmd.PersistentFlags().BoolVar(&useCurrentRole, "use_current_role", false, "Use current AWS role (e.g., from Kubernetes service account)")
|
||||
rootCmd.PersistentFlags().StringVar(&roleName, "role_name", "", "The name of the IAM role to assume (only when --use_role is set)")
|
||||
rootCmd.PersistentFlags().StringVar(&awsAccessKeyID, "aws_access_key_id", "", "AWS access key ID")
|
||||
rootCmd.PersistentFlags().StringVar(&awsSecretKey, "aws_secret_access_key", "", "AWS secret access key")
|
||||
rootCmd.PersistentFlags().StringVar(&endpointURL, "endpoint_url", "", "S3-compatible endpoint URL")
|
||||
rootCmd.PersistentFlags().StringVar(®ion, "region", "", "AWS region")
|
||||
rootCmd.PersistentFlags().IntVar(&maxRetries, "max_retries", 5, "Maximum number of retries")
|
||||
rootCmd.PersistentFlags().DurationVar(&retryDelay, "retry_delay", 5*time.Second, "Delay between retries")
|
||||
|
||||
// Add commands
|
||||
rootCmd.AddCommand(exportCmd())
|
||||
rootCmd.AddCommand(cleanupCmd())
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func exportCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "export <source> <destination>",
|
||||
Short: "Export a file to S3 or local destination",
|
||||
Long: "Transfer a file from a local source to either a local destination or an S3 bucket",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
source := args[0]
|
||||
destination := args[1]
|
||||
return runExport(source, destination)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "cleanup <destination>",
|
||||
Short: "Remove a directory from S3 or local filesystem",
|
||||
Long: "Remove a directory recursively, either local or in an S3 bucket",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
destination := args[0]
|
||||
return runCleanup(destination)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runExport(source, destination string) error {
|
||||
// Check if source file exists
|
||||
if _, err := os.Stat(source); os.IsNotExist(err) {
|
||||
return fmt.Errorf("source file '%s' does not exist", source)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 1 {
|
||||
fmt.Printf("Retry attempt %d/%d after %v\n", attempt, maxRetries, retryDelay)
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
|
||||
var err error
|
||||
if strings.HasPrefix(destination, "s3://") {
|
||||
err = uploadToS3(source, destination)
|
||||
} else {
|
||||
err = copyLocal(source, destination)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
fmt.Printf("Transfer completed successfully: %s -> %s\n", source, destination)
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
fmt.Printf("Attempt %d failed: %v\n", attempt, err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("transfer failed after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
func runCleanup(destination string) error {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 1 {
|
||||
fmt.Printf("Retry attempt %d/%d after %v\n", attempt, maxRetries, retryDelay)
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
|
||||
var err error
|
||||
if strings.HasPrefix(destination, "s3://") {
|
||||
err = deleteFromS3(destination)
|
||||
} else {
|
||||
err = deleteLocal(destination)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
fmt.Printf("Cleanup completed successfully: %s\n", destination)
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
fmt.Printf("Attempt %d failed: %v\n", attempt, err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("cleanup failed after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
func getS3Client(ctx context.Context) (*s3.Client, error) {
|
||||
var cfg aws.Config
|
||||
var err error
|
||||
|
||||
// Determine region
|
||||
awsRegion := region
|
||||
if awsRegion == "" {
|
||||
awsRegion = os.Getenv("AWS_REGION")
|
||||
}
|
||||
if awsRegion == "" {
|
||||
awsRegion = os.Getenv("AWS_DEFAULT_REGION")
|
||||
}
|
||||
|
||||
// Build config options
|
||||
optFns := []func(*config.LoadOptions) error{}
|
||||
|
||||
if awsRegion != "" {
|
||||
optFns = append(optFns, config.WithRegion(awsRegion))
|
||||
}
|
||||
|
||||
// Load base config
|
||||
cfg, err = config.LoadDefaultConfig(ctx, optFns...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load AWS config: %w", err)
|
||||
}
|
||||
|
||||
// Handle authentication methods
|
||||
if awsAccessKeyID != "" && awsSecretKey != "" {
|
||||
// Use explicit credentials
|
||||
fmt.Println("Using explicit AWS credentials")
|
||||
cfg.Credentials = credentials.NewStaticCredentialsProvider(awsAccessKeyID, awsSecretKey, "")
|
||||
} else if useRole && roleName != "" {
|
||||
// Assume specific role
|
||||
fmt.Printf("Attempting to assume role: %s\n", roleName)
|
||||
stsClient := sts.NewFromConfig(cfg)
|
||||
|
||||
// Get account ID for role ARN
|
||||
identity, err := stsClient.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get caller identity: %w", err)
|
||||
}
|
||||
|
||||
roleARN := fmt.Sprintf("arn:aws:iam::%s:role/%s", *identity.Account, roleName)
|
||||
cfg.Credentials = stscreds.NewAssumeRoleProvider(stsClient, roleARN)
|
||||
} else if useCurrentRole {
|
||||
// Use current role (default credential chain handles this)
|
||||
fmt.Println("Using current role from environment")
|
||||
// The default config already uses the credential chain which includes
|
||||
// web identity token if AWS_WEB_IDENTITY_TOKEN_FILE is set
|
||||
} else {
|
||||
fmt.Println("Using default credential provider chain")
|
||||
}
|
||||
|
||||
// Create S3 client options
|
||||
s3Opts := []func(*s3.Options){}
|
||||
if endpointURL != "" {
|
||||
s3Opts = append(s3Opts, func(o *s3.Options) {
|
||||
o.BaseEndpoint = aws.String(endpointURL)
|
||||
o.UsePathStyle = true // Required for most S3-compatible services
|
||||
})
|
||||
}
|
||||
|
||||
return s3.NewFromConfig(cfg, s3Opts...), nil
|
||||
}
|
||||
|
||||
func parseS3Path(s3Path string) (bucket, key string) {
|
||||
path := strings.TrimPrefix(s3Path, "s3://")
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
bucket = parts[0]
|
||||
if len(parts) > 1 {
|
||||
key = parts[1]
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func uploadToS3(source, destination string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getS3Client(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create S3 client: %w", err)
|
||||
}
|
||||
|
||||
bucket, key := parseS3Path(destination)
|
||||
|
||||
file, err := os.Open(source) // #nosec G304 -- source path is provided by operator via CLI args
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open source file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fmt.Printf("Uploading %s to s3://%s/%s\n", source, bucket, key)
|
||||
|
||||
_, err = client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
Body: file,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload to S3: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyLocal(source, destination string) error {
|
||||
// Create destination directory if it doesn't exist
|
||||
destDir := filepath.Dir(destination)
|
||||
if err := os.MkdirAll(destDir, 0750); err != nil { // #nosec G301 -- restricted permissions for backup directory
|
||||
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||
}
|
||||
|
||||
// Open source file
|
||||
srcFile, err := os.Open(source) // #nosec G304 -- source path is provided by operator via CLI args
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open source file: %w", err)
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
// Get source file info for permissions
|
||||
srcInfo, err := srcFile.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat source file: %w", err)
|
||||
}
|
||||
|
||||
// Create destination file
|
||||
dstFile, err := os.OpenFile(destination, os.O_RDWR|os.O_CREATE|os.O_TRUNC, srcInfo.Mode()) // #nosec G304 -- destination path is provided by operator via CLI args
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create destination file: %w", err)
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
// Copy content
|
||||
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
||||
return fmt.Errorf("failed to copy file content: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Copied %s to %s\n", source, destination)
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteFromS3(destination string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getS3Client(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create S3 client: %w", err)
|
||||
}
|
||||
|
||||
bucket, prefix := parseS3Path(destination)
|
||||
|
||||
fmt.Printf("Deleting objects from s3://%s/%s\n", bucket, prefix)
|
||||
|
||||
// List and delete objects
|
||||
paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucket),
|
||||
Prefix: aws.String(prefix),
|
||||
})
|
||||
|
||||
totalDeleted := 0
|
||||
for paginator.HasMorePages() {
|
||||
page, err := paginator.NextPage(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list objects: %w", err)
|
||||
}
|
||||
|
||||
if len(page.Contents) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build list of objects to delete
|
||||
var objectsToDelete []string
|
||||
for _, obj := range page.Contents {
|
||||
objectsToDelete = append(objectsToDelete, *obj.Key)
|
||||
}
|
||||
|
||||
// Delete objects in batches of 1000 (S3 limit)
|
||||
for i := 0; i < len(objectsToDelete); i += 1000 {
|
||||
end := i + 1000
|
||||
if end > len(objectsToDelete) {
|
||||
end = len(objectsToDelete)
|
||||
}
|
||||
|
||||
batch := objectsToDelete[i:end]
|
||||
deleteObjects := make([]types.ObjectIdentifier, len(batch))
|
||||
for j, key := range batch {
|
||||
deleteObjects[j] = types.ObjectIdentifier{Key: aws.String(key)}
|
||||
}
|
||||
|
||||
_, err := client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Delete: &types.Delete{
|
||||
Objects: deleteObjects,
|
||||
Quiet: aws.Bool(true),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete objects: %w", err)
|
||||
}
|
||||
|
||||
totalDeleted += len(batch)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Deleted %d objects from s3://%s/%s\n", totalDeleted, bucket, prefix)
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteLocal(destination string) error {
|
||||
// Check if path exists
|
||||
if _, err := os.Stat(destination); os.IsNotExist(err) {
|
||||
fmt.Printf("Directory %s does not exist, nothing to delete\n", destination)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove directory recursively
|
||||
if err := os.RemoveAll(destination); err != nil {
|
||||
return fmt.Errorf("failed to remove directory: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Deleted directory %s\n", destination)
|
||||
return nil
|
||||
}
|
||||
@@ -118,6 +118,25 @@ spec:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
retention:
|
||||
description: |-
|
||||
Retention specifies how many completed exports to keep per base path.
|
||||
Oldest exports beyond this limit will be deleted (including their backed up images).
|
||||
WARNING: Deletion removes both the CRD and the actual backed up images from storage.
|
||||
properties:
|
||||
maxFailed:
|
||||
default: 1
|
||||
description: Maximum number of failed exports to keep
|
||||
format: int32
|
||||
minimum: 0
|
||||
type: integer
|
||||
maxSuccessful:
|
||||
default: 3
|
||||
description: Maximum number of successful exports to keep
|
||||
format: int32
|
||||
minimum: 0
|
||||
type: integer
|
||||
type: object
|
||||
storage:
|
||||
description: ClusterImageStorageSpec defines the desired state of
|
||||
ClusterImageStorage
|
||||
@@ -160,6 +179,14 @@ spec:
|
||||
required:
|
||||
- target
|
||||
type: object
|
||||
ttlDaysAfterFinished:
|
||||
description: |-
|
||||
TTLDaysAfterFinished specifies how many days to keep completed exports.
|
||||
If set, the export (and its backed up images) will be deleted after this many days.
|
||||
WARNING: Deletion removes both the CRD and the actual backed up images from storage.
|
||||
format: int32
|
||||
minimum: 1
|
||||
type: integer
|
||||
required:
|
||||
- basePath
|
||||
- maxConcurrentJobs
|
||||
@@ -169,6 +196,11 @@ spec:
|
||||
status:
|
||||
description: ClusterImageExportStatus defines the observed state of ClusterImageExport
|
||||
properties:
|
||||
completedAt:
|
||||
description: CompletedAt is the timestamp when the export completed
|
||||
(SUCCESS or FAILED)
|
||||
format: date-time
|
||||
type: string
|
||||
completedImages:
|
||||
description: Number of images that have completed export
|
||||
type: integer
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
from botocore.exceptions import ClientError
|
||||
from tenacity import retry, stop_after_attempt, wait_fixed
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from s3_utils import get_s3_client, parse_s3_path, add_common_arguments, validate_args
|
||||
|
||||
@retry(stop=stop_after_attempt(5), wait=wait_fixed(5))
|
||||
def remove_directory(destination, use_role=False, role_name=None, aws_access_key_id=None, aws_secret_access_key=None, endpoint_url=None, region=None):
|
||||
"""
|
||||
Remove a directory recursively, either local or in an S3 bucket
|
||||
"""
|
||||
if destination.startswith('s3://'):
|
||||
# Removing from S3
|
||||
s3_client = get_s3_client(use_role, role_name, aws_access_key_id, aws_secret_access_key, endpoint_url, region)
|
||||
bucket, prefix = parse_s3_path(destination)
|
||||
try:
|
||||
paginator = s3_client.get_paginator('list_objects_v2')
|
||||
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
|
||||
if 'Contents' in page:
|
||||
objects_to_delete = [{'Key': obj['Key']} for obj in page['Contents']]
|
||||
s3_client.delete_objects(Bucket=bucket, Delete={'Objects': objects_to_delete})
|
||||
print(f"Directory {destination} removed successfully from S3")
|
||||
except ClientError as e:
|
||||
print(f"Error removing directory from S3: {str(e)}")
|
||||
return False
|
||||
else:
|
||||
# Removing local directory
|
||||
try:
|
||||
import shutil
|
||||
if os.path.exists(destination):
|
||||
shutil.rmtree(destination)
|
||||
print(f"Directory {destination} removed successfully")
|
||||
else:
|
||||
print(f"Directory {destination} does not exist")
|
||||
except IOError as e:
|
||||
print(f"Error removing directory: {str(e)}")
|
||||
return False
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Remove a directory recursively, either local or in an S3 bucket.")
|
||||
parser.add_argument("destination", help="The directory path (local) or S3 path (e.g., 's3://bucket/prefix') to remove")
|
||||
add_common_arguments(parser)
|
||||
|
||||
args = parser.parse_args()
|
||||
validate_args(args, parser)
|
||||
|
||||
success = remove_directory(
|
||||
args.destination,
|
||||
args.use_role,
|
||||
args.role_name,
|
||||
args.aws_access_key_id,
|
||||
args.aws_secret_access_key,
|
||||
args.endpoint_url,
|
||||
args.region
|
||||
)
|
||||
|
||||
if success:
|
||||
print("Cleanup completed successfully.")
|
||||
else:
|
||||
print("Cleanup failed.")
|
||||
exit(1)
|
||||
@@ -1,106 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import logging
|
||||
from botocore.exceptions import ClientError, BotoCoreError
|
||||
from tenacity import retry, stop_after_attempt, wait_fixed
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
from s3_utils import get_s3_client, parse_s3_path, add_common_arguments, validate_args
|
||||
|
||||
def log_error_details(e):
|
||||
"""Log detailed error information from AWS exceptions"""
|
||||
if hasattr(e, 'response'):
|
||||
error_code = e.response.get('Error', {}).get('Code', 'Unknown')
|
||||
error_message = e.response.get('Error', {}).get('Message', str(e))
|
||||
request_id = e.response.get('ResponseMetadata', {}).get('RequestId', 'Unknown')
|
||||
logger.error(f"AWS Error Details:")
|
||||
logger.error(f"- Error Code: {error_code}")
|
||||
logger.error(f"- Error Message: {error_message}")
|
||||
logger.error(f"- Request ID: {request_id}")
|
||||
logger.error(f"- Full Response: {e.response}")
|
||||
else:
|
||||
logger.error(f"Non-AWS Error: {str(e)}")
|
||||
|
||||
@retry(stop=stop_after_attempt(5), wait=wait_fixed(5))
|
||||
def transfer_file(source, destination, use_role=False, role_name=None, use_current_role=False, aws_access_key_id=None, aws_secret_access_key=None, endpoint_url=None, region=None):
|
||||
"""
|
||||
Transfer a file from a local source to either a local destination or an S3 bucket
|
||||
"""
|
||||
if not os.path.isfile(source):
|
||||
logger.error(f"Error: Source file '{source}' does not exist or is not a file.")
|
||||
return False
|
||||
|
||||
if destination.startswith('s3://'):
|
||||
# Uploading to S3
|
||||
try:
|
||||
logger.info(f"Attempting to upload {source} to {destination}")
|
||||
s3_client = get_s3_client(use_role, role_name, use_current_role, aws_access_key_id, aws_secret_access_key, endpoint_url, region)
|
||||
bucket, s3_key = parse_s3_path(destination)
|
||||
|
||||
try:
|
||||
s3_client.upload_file(source, bucket, s3_key)
|
||||
logger.info(f"File {source} uploaded successfully to {destination}")
|
||||
except ClientError as e:
|
||||
log_error_details(e)
|
||||
if "AccessDenied" in str(e):
|
||||
logger.error("Access denied. Please check:")
|
||||
logger.error("1. IAM role/user permissions")
|
||||
logger.error("2. S3 bucket permissions")
|
||||
logger.error("3. Web identity token configuration")
|
||||
return False
|
||||
except BotoCoreError as e:
|
||||
logger.error(f"Boto3 error during upload: {str(e)}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during S3 client creation or upload: {str(e)}")
|
||||
return False
|
||||
else:
|
||||
# Copying to local destination
|
||||
try:
|
||||
import shutil
|
||||
logger.info(f"Attempting to copy {source} to local destination {destination}")
|
||||
# Create destination directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(destination), exist_ok=True)
|
||||
shutil.copy2(source, destination)
|
||||
logger.info(f"File {source} copied successfully to {destination}")
|
||||
except IOError as e:
|
||||
logger.error(f"Error copying file: {str(e)}")
|
||||
return False
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Transfer a file from a local source to either a local destination or an S3 bucket.")
|
||||
parser.add_argument("source", help="The local source file path")
|
||||
parser.add_argument("destination", help="The destination file path (local) or S3 path (e.g., 's3://bucket/key')")
|
||||
add_common_arguments(parser)
|
||||
|
||||
args = parser.parse_args()
|
||||
validate_args(args, parser)
|
||||
|
||||
success = transfer_file(
|
||||
args.source,
|
||||
args.destination,
|
||||
args.use_role,
|
||||
args.role_name,
|
||||
args.use_current_role,
|
||||
args.aws_access_key_id,
|
||||
args.aws_secret_access_key,
|
||||
args.endpoint_url,
|
||||
args.region
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info("Transfer completed successfully.")
|
||||
else:
|
||||
logger.error("Transfer failed.")
|
||||
exit(1)
|
||||
@@ -1,4 +0,0 @@
|
||||
boto3
|
||||
botocore
|
||||
jmespath
|
||||
tenacity
|
||||
@@ -1,228 +0,0 @@
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
import os
|
||||
import logging
|
||||
|
||||
def get_s3_client(use_role=False, role_name=None, use_current_role=False, aws_access_key_id=None, aws_secret_access_key=None, endpoint_url=None, region=None):
|
||||
"""
|
||||
Create and return an S3 client based on the provided authentication method, endpoint, and region.
|
||||
"""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
client_kwargs = {}
|
||||
|
||||
# Log authentication method being attempted
|
||||
logger.info("Attempting S3 client creation with:")
|
||||
logger.info(f"- Region: {region if region else 'default'}")
|
||||
logger.info(f"- Endpoint URL: {endpoint_url if endpoint_url else 'default'}")
|
||||
|
||||
if endpoint_url:
|
||||
client_kwargs['endpoint_url'] = endpoint_url
|
||||
if region:
|
||||
client_kwargs['region_name'] = region
|
||||
|
||||
# Check for AWS Web Identity token
|
||||
token_file = os.environ.get('AWS_WEB_IDENTITY_TOKEN_FILE')
|
||||
role_arn = os.environ.get('AWS_ROLE_ARN')
|
||||
if token_file or role_arn:
|
||||
logger.info("AWS Web Identity configuration detected:")
|
||||
logger.info(f"- Token file path: {token_file}")
|
||||
logger.info(f"- Role ARN: {role_arn}")
|
||||
logger.info(f"- Session name: {os.environ.get('AWS_ROLE_SESSION_NAME', 'default')}")
|
||||
|
||||
if aws_access_key_id and aws_secret_access_key:
|
||||
logger.info("Using explicit AWS credentials")
|
||||
# Use explicit credentials if provided
|
||||
client_kwargs['aws_access_key_id'] = aws_access_key_id
|
||||
client_kwargs['aws_secret_access_key'] = aws_secret_access_key
|
||||
return boto3.client('s3', **client_kwargs)
|
||||
elif use_role and role_name:
|
||||
# Assume specific role if requested
|
||||
logger.info(f"Attempting to assume role: {role_name}")
|
||||
try:
|
||||
sts_client = boto3.client('sts')
|
||||
# Get current identity for logging
|
||||
identity = sts_client.get_caller_identity()
|
||||
logger.info(f"Current identity: {identity['Arn']}")
|
||||
|
||||
assumed_role_object = sts_client.assume_role(
|
||||
RoleArn=f"arn:aws:iam::{boto3.client('sts').get_caller_identity()['Account']}:role/{role_name}",
|
||||
RoleSessionName="AssumeRoleSession"
|
||||
)
|
||||
credentials = assumed_role_object['Credentials']
|
||||
client_kwargs['aws_access_key_id'] = credentials['AccessKeyId']
|
||||
client_kwargs['aws_secret_access_key'] = credentials['SecretAccessKey']
|
||||
client_kwargs['aws_session_token'] = credentials['SessionToken']
|
||||
return boto3.client('s3', **client_kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to assume role {role_name}: {str(e)}")
|
||||
raise
|
||||
elif use_current_role:
|
||||
# Use the current role (e.g., from Kubernetes service account)
|
||||
logger.info("Using current role from environment")
|
||||
try:
|
||||
# Log environment for debugging
|
||||
for key, value in sorted(os.environ.items()):
|
||||
if any(k in key.lower() for k in ['aws', 'role', 'auth', 'token', 'credential']):
|
||||
logger.info(f"Environment: {key}={value}")
|
||||
|
||||
# Get the AWS region from environment or parameter
|
||||
aws_region = os.environ.get('AWS_REGION') or os.environ.get('AWS_DEFAULT_REGION')
|
||||
if not aws_region and not region:
|
||||
raise ValueError("AWS region must be specified either through region parameter or AWS_REGION environment variable")
|
||||
|
||||
# Use region from parameter only if not set in environment
|
||||
if not aws_region:
|
||||
aws_region = region
|
||||
# Set it in environment for other AWS clients
|
||||
os.environ['AWS_REGION'] = region
|
||||
|
||||
logger.info(f"Using AWS region: {aws_region}")
|
||||
|
||||
# Create an STS client in the correct region
|
||||
sts_kwargs = {'endpoint_url': f'https://sts.{aws_region}.amazonaws.com'}
|
||||
if not os.environ.get('AWS_REGION') and not os.environ.get('AWS_DEFAULT_REGION'):
|
||||
sts_kwargs['region_name'] = aws_region
|
||||
sts = boto3.client('sts', **sts_kwargs)
|
||||
|
||||
# Read the web identity token
|
||||
token_file = os.environ.get('AWS_WEB_IDENTITY_TOKEN_FILE')
|
||||
role_arn = os.environ.get('AWS_ROLE_ARN')
|
||||
|
||||
if not token_file or not role_arn:
|
||||
raise ValueError("AWS_WEB_IDENTITY_TOKEN_FILE and AWS_ROLE_ARN must be set")
|
||||
|
||||
with open(token_file, 'r') as f:
|
||||
token = f.read().strip()
|
||||
|
||||
logger.info("Successfully read web identity token")
|
||||
logger.info(f"Using role ARN: {role_arn}")
|
||||
|
||||
# Assume role with web identity using regional endpoint
|
||||
try:
|
||||
response = sts.assume_role_with_web_identity(
|
||||
RoleArn=role_arn,
|
||||
RoleSessionName=os.environ.get('AWS_ROLE_SESSION_NAME', 'WebIdentitySession'),
|
||||
WebIdentityToken=token
|
||||
)
|
||||
|
||||
# Get the temporary credentials
|
||||
credentials = response['Credentials']
|
||||
|
||||
# Create the S3 client with the temporary credentials
|
||||
s3_kwargs = {
|
||||
'aws_access_key_id': credentials['AccessKeyId'],
|
||||
'aws_secret_access_key': credentials['SecretAccessKey'],
|
||||
'aws_session_token': credentials['SessionToken']
|
||||
}
|
||||
# Only set region_name if not already in environment
|
||||
if not os.environ.get('AWS_REGION') and not os.environ.get('AWS_DEFAULT_REGION'):
|
||||
s3_kwargs['region_name'] = aws_region
|
||||
# Add any additional kwargs
|
||||
s3_kwargs.update(client_kwargs)
|
||||
client = boto3.client('s3', **s3_kwargs)
|
||||
|
||||
logger.info(f"Successfully assumed role with web identity: {response['AssumedRoleUser']['Arn']}")
|
||||
|
||||
# Test the credentials
|
||||
try:
|
||||
# Try to get caller identity first
|
||||
sts_test = boto3.client(
|
||||
'sts',
|
||||
region_name=aws_region,
|
||||
aws_access_key_id=credentials['AccessKeyId'],
|
||||
aws_secret_access_key=credentials['SecretAccessKey'],
|
||||
aws_session_token=credentials['SessionToken']
|
||||
)
|
||||
identity = sts_test.get_caller_identity()
|
||||
logger.info(f"Successfully verified credentials as: {identity['Arn']}")
|
||||
|
||||
# Then try S3 access
|
||||
bucket_name = os.environ.get('BUCKET_NAME', 'default-bucket')
|
||||
try:
|
||||
client.head_bucket(Bucket=bucket_name)
|
||||
logger.info(f"Successfully verified S3 access to bucket: {bucket_name}")
|
||||
except ClientError as e:
|
||||
error_code = e.response['Error']['Code']
|
||||
if error_code == '404':
|
||||
logger.warning(f"Bucket {bucket_name} does not exist, but credentials work")
|
||||
else:
|
||||
logger.warning(f"S3 access check failed: {error_code} - {e.response['Error']['Message']}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not verify credentials: {str(e)}")
|
||||
|
||||
return client
|
||||
|
||||
except ClientError as e:
|
||||
error_code = e.response['Error']['Code']
|
||||
error_message = e.response['Error']['Message']
|
||||
logger.error("Failed to assume role with web identity:")
|
||||
logger.error(f"Error Code: {error_code}")
|
||||
logger.error(f"Error Message: {error_message}")
|
||||
logger.error("Trust policy might need to be updated to allow sts:AssumeRoleWithWebIdentity")
|
||||
logger.error("Current role ARN: " + role_arn)
|
||||
logger.error("Token file path: " + token_file)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to use current role: {str(e)}")
|
||||
logger.error("Current environment:")
|
||||
for key, value in sorted(os.environ.items()):
|
||||
if any(k in key.lower() for k in ['aws', 'role', 'auth', 'token', 'credential']):
|
||||
logger.error(f" {key}: {value}")
|
||||
raise
|
||||
else:
|
||||
# Use default credentials (environment, instance profile, or pod service account)
|
||||
logger.info("Using default credential provider chain")
|
||||
try:
|
||||
client = boto3.client('s3', **client_kwargs)
|
||||
# Try to get caller identity to verify credentials
|
||||
sts = boto3.client('sts')
|
||||
identity = sts.get_caller_identity()
|
||||
logger.info(f"Successfully authenticated as: {identity['Arn']}")
|
||||
return client
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create S3 client: {str(e)}")
|
||||
raise
|
||||
|
||||
def parse_s3_path(s3_path):
|
||||
"""
|
||||
Parse an S3 path into bucket and key
|
||||
"""
|
||||
parts = s3_path.replace('s3://', '').split('/', 1)
|
||||
bucket = parts[0]
|
||||
key = parts[1] if len(parts) > 1 else ''
|
||||
return bucket, key
|
||||
|
||||
def add_common_arguments(parser):
|
||||
"""
|
||||
Add common command-line arguments to an ArgumentParser object
|
||||
"""
|
||||
auth_group = parser.add_mutually_exclusive_group()
|
||||
auth_group.add_argument("--use_role", action="store_true", help="Use IAM role for authentication")
|
||||
auth_group.add_argument("--use_current_role", action="store_true", help="Use current AWS role (e.g. from Kubernetes service account)")
|
||||
parser.add_argument("--role_name", help="The name of the IAM role to assume (only when --use_role is set)")
|
||||
parser.add_argument("--aws_access_key_id", help="AWS access key ID")
|
||||
parser.add_argument("--aws_secret_access_key", help="AWS secret access key")
|
||||
parser.add_argument("--endpoint_url", help="S3-compatible endpoint URL")
|
||||
parser.add_argument("--region", help="AWS region (ignored if endpoint_url is specified)")
|
||||
|
||||
def validate_args(args, parser):
|
||||
"""
|
||||
Validate command-line arguments
|
||||
"""
|
||||
if args.destination.startswith('s3://'):
|
||||
# Check for conflicting auth methods
|
||||
if args.use_role and not args.role_name:
|
||||
parser.error("--role_name is required when using --use_role")
|
||||
|
||||
if args.role_name and not args.use_role:
|
||||
parser.error("--role_name can only be used with --use_role")
|
||||
|
||||
if args.use_current_role and (args.aws_access_key_id or args.aws_secret_access_key):
|
||||
parser.error("When using current role (--use_current_role), access key and secret should not be specified")
|
||||
|
||||
# If using explicit credentials, require both key and secret
|
||||
if (args.aws_access_key_id or args.aws_secret_access_key) and not (args.aws_access_key_id and args.aws_secret_access_key):
|
||||
parser.error("Both --aws_access_key_id and --aws_secret_access_key must be provided when using access key authentication")
|
||||
@@ -3,9 +3,16 @@ module github.com/lukaszraczylo/kubernetes-images-sync-operator
|
||||
go 1.24.9
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.6
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5
|
||||
github.com/go-logr/logr v1.4.3
|
||||
github.com/onsi/ginkgo/v2 v2.27.3
|
||||
github.com/onsi/gomega v1.38.2
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
k8s.io/api v0.34.3
|
||||
k8s.io/apimachinery v0.34.3
|
||||
k8s.io/client-go v0.34.3
|
||||
@@ -17,6 +24,20 @@ require (
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
|
||||
github.com/aws/smithy-go v1.24.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/blang/semver/v4 v4.0.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
@@ -62,7 +83,6 @@ require (
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.4 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/stoewer/go-strcase v1.3.1 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
@@ -96,6 +116,7 @@ require (
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
k8s.io/apiextensions-apiserver v0.34.3 // indirect
|
||||
k8s.io/apiserver v0.34.3 // indirect
|
||||
k8s.io/component-base v0.34.3 // indirect
|
||||
|
||||
@@ -4,6 +4,44 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0 h1:SWTxh/EcUCDVqi/0s26V6pVUq0BBG7kx0tDTmF/hCgA=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.94.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
|
||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
|
||||
|
||||
@@ -360,12 +360,12 @@ func (r *ClusterImageReconciler) createBackupJob(ctx context.Context, clusterIma
|
||||
if clusterImage.Spec.Storage == shared.STORAGE_S3 {
|
||||
s3Params := shared.SetupS3Params(clusterImageExport.Spec.Storage.S3)
|
||||
additionalCommands := []string{
|
||||
"./export.py " + strings.Join(s3Params, " ") + " '/tmp/" + normalisedImageName + ".tar' " + "'s3://" + clusterImageExport.Spec.Storage.S3.Bucket + clusterImage.Spec.ExportPath + "/" + clusterImage.Spec.ExportName + "/" + normalisedImageName + ".tar'",
|
||||
"./worker export " + strings.Join(s3Params, " ") + " '/tmp/" + normalisedImageName + ".tar' " + "'s3://" + clusterImageExport.Spec.Storage.S3.Bucket + clusterImage.Spec.ExportPath + "/" + clusterImage.Spec.ExportName + "/" + normalisedImageName + ".tar'",
|
||||
}
|
||||
defaultCommands = append(defaultCommands, additionalCommands...)
|
||||
} else if clusterImage.Spec.Storage == shared.STORAGE_FILE {
|
||||
additionalCommands := []string{
|
||||
"./export.py /tmp/" + normalisedImageName + ".tar" + " " + clusterImage.Spec.ExportPath + "/" + clusterImage.Spec.ExportName + "/" + normalisedImageName + ".tar",
|
||||
"./worker export '/tmp/" + normalisedImageName + ".tar' '" + clusterImage.Spec.ExportPath + "/" + clusterImage.Spec.ExportName + "/" + normalisedImageName + ".tar'",
|
||||
}
|
||||
defaultCommands = append(defaultCommands, additionalCommands...)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/md5" // #nosec G501 - MD5 used for non-cryptographic unique identifiers only
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-logr/logr"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
@@ -61,6 +62,19 @@ func (r *ClusterImageExportReconciler) Reconcile(ctx context.Context, req ctrl.R
|
||||
return r.handleDeletion(ctx, clusterImageExport)
|
||||
}
|
||||
|
||||
// Check if this export should be deleted by TTL
|
||||
if r.shouldDeleteByTTL(clusterImageExport) {
|
||||
l.Info("Deleting export due to TTL expiration",
|
||||
"export", clusterImageExport.Name,
|
||||
"ttlDays", *clusterImageExport.Spec.TTLDaysAfterFinished,
|
||||
"completedAt", clusterImageExport.Status.CompletedAt)
|
||||
if err := r.Delete(ctx, clusterImageExport); err != nil && !errors.IsNotFound(err) {
|
||||
l.Error(err, "Failed to delete export by TTL")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// Add finalizer and creation timestamp annotation if they don't exist
|
||||
needsUpdate := false
|
||||
if !controllerutil.ContainsFinalizer(clusterImageExport, clusterImageExportFinalizer) {
|
||||
@@ -214,6 +228,11 @@ func (r *ClusterImageExportReconciler) Reconcile(ctx context.Context, req ctrl.R
|
||||
} else {
|
||||
export.Status.Progress = shared.STATUS_SUCCESS
|
||||
}
|
||||
// Set CompletedAt timestamp when export completes
|
||||
if export.Status.CompletedAt == nil {
|
||||
now := metav1.Now()
|
||||
export.Status.CompletedAt = &now
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -221,6 +240,15 @@ func (r *ClusterImageExportReconciler) Reconcile(ctx context.Context, req ctrl.R
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
// If export is complete, run retention cleanup
|
||||
if clusterImageExport.Status.Progress == shared.STATUS_SUCCESS ||
|
||||
clusterImageExport.Status.Progress == shared.STATUS_FAILED {
|
||||
if err := r.cleanupByRetention(ctx, clusterImageExport); err != nil {
|
||||
l.Error(err, "Failed to cleanup by retention policy")
|
||||
// Don't return error - this is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
// If there are still pending images, requeue
|
||||
if pendingCount > 0 {
|
||||
return ctrl.Result{Requeue: true}, nil
|
||||
@@ -384,12 +412,12 @@ func (r *ClusterImageExportReconciler) runCleanupJob(ctx context.Context, cluste
|
||||
if clusterImageExport.Spec.Storage.StorageTarget == shared.STORAGE_S3 {
|
||||
s3Params := shared.SetupS3Params(clusterImageExport.Spec.Storage.S3)
|
||||
additionalCommands := []string{
|
||||
"./cleanup.py " + strings.Join(s3Params, " ") + " 's3://" + clusterImageExport.Spec.Storage.S3.Bucket + clusterImageExport.Spec.BasePath + "/" + clusterImageExport.ObjectMeta.Name + "/'",
|
||||
"./worker cleanup " + strings.Join(s3Params, " ") + " 's3://" + clusterImageExport.Spec.Storage.S3.Bucket + clusterImageExport.Spec.BasePath + "/" + clusterImageExport.ObjectMeta.Name + "/'",
|
||||
}
|
||||
defaultCommands = append(defaultCommands, additionalCommands...)
|
||||
} else if clusterImageExport.Spec.Storage.StorageTarget == shared.STORAGE_FILE {
|
||||
additionalCommands := []string{
|
||||
"./cleanup.py" + "'" + clusterImageExport.Spec.BasePath + "/" + clusterImageExport.ObjectMeta.Name + "/'",
|
||||
"./worker cleanup '" + clusterImageExport.Spec.BasePath + "/" + clusterImageExport.ObjectMeta.Name + "/'",
|
||||
}
|
||||
defaultCommands = append(defaultCommands, additionalCommands...)
|
||||
}
|
||||
@@ -452,3 +480,110 @@ func (r *ClusterImageExportReconciler) runCleanupJob(ctx context.Context, cluste
|
||||
l.Info("Created cleanup job with retry limit and TTL")
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldDeleteByTTL checks if the export should be deleted based on TTL (in days)
|
||||
func (r *ClusterImageExportReconciler) shouldDeleteByTTL(clusterImageExport *raczylocomv1.ClusterImageExport) bool {
|
||||
// Only apply TTL to completed exports
|
||||
if clusterImageExport.Status.Progress != shared.STATUS_SUCCESS &&
|
||||
clusterImageExport.Status.Progress != shared.STATUS_FAILED {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if TTL is configured
|
||||
if clusterImageExport.Spec.TTLDaysAfterFinished == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if CompletedAt is set
|
||||
if clusterImageExport.Status.CompletedAt == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Convert days to duration (24 hours per day)
|
||||
ttlDuration := time.Duration(*clusterImageExport.Spec.TTLDaysAfterFinished) * 24 * time.Hour
|
||||
expirationTime := clusterImageExport.Status.CompletedAt.Add(ttlDuration)
|
||||
|
||||
return time.Now().After(expirationTime)
|
||||
}
|
||||
|
||||
// cleanupByRetention enforces the retention policy for completed exports
|
||||
func (r *ClusterImageExportReconciler) cleanupByRetention(ctx context.Context, clusterImageExport *raczylocomv1.ClusterImageExport) error {
|
||||
l := log.FromContext(ctx)
|
||||
|
||||
// Check if retention policy is configured
|
||||
if clusterImageExport.Spec.Retention == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// List all ClusterImageExports in the same namespace
|
||||
exportList := &raczylocomv1.ClusterImageExportList{}
|
||||
if err := r.List(ctx, exportList, client.InNamespace(clusterImageExport.Namespace)); err != nil {
|
||||
return fmt.Errorf("failed to list ClusterImageExports: %w", err)
|
||||
}
|
||||
|
||||
// Separate successful and failed exports, sorted by completion time
|
||||
var successfulExports, failedExports []*raczylocomv1.ClusterImageExport
|
||||
for i := range exportList.Items {
|
||||
export := &exportList.Items[i]
|
||||
// Skip exports that don't have the same base path (different backup sets)
|
||||
if export.Spec.BasePath != clusterImageExport.Spec.BasePath {
|
||||
continue
|
||||
}
|
||||
// Skip exports that are still running
|
||||
if export.Status.Progress != shared.STATUS_SUCCESS &&
|
||||
export.Status.Progress != shared.STATUS_FAILED {
|
||||
continue
|
||||
}
|
||||
if export.Status.Progress == shared.STATUS_SUCCESS {
|
||||
successfulExports = append(successfulExports, export)
|
||||
} else if export.Status.Progress == shared.STATUS_FAILED {
|
||||
failedExports = append(failedExports, export)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by CompletedAt (newest first)
|
||||
sortByCompletionTime := func(exports []*raczylocomv1.ClusterImageExport) {
|
||||
for i := 0; i < len(exports); i++ {
|
||||
for j := i + 1; j < len(exports); j++ {
|
||||
iTime := exports[i].Status.CompletedAt
|
||||
jTime := exports[j].Status.CompletedAt
|
||||
if iTime == nil || (jTime != nil && jTime.After(iTime.Time)) {
|
||||
exports[i], exports[j] = exports[j], exports[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sortByCompletionTime(successfulExports)
|
||||
sortByCompletionTime(failedExports)
|
||||
|
||||
// Delete excess successful exports
|
||||
if clusterImageExport.Spec.Retention.MaxSuccessful != nil {
|
||||
maxSuccessful := int(*clusterImageExport.Spec.Retention.MaxSuccessful)
|
||||
if len(successfulExports) > maxSuccessful {
|
||||
for _, export := range successfulExports[maxSuccessful:] {
|
||||
l.Info("Deleting export due to retention policy (maxSuccessful exceeded)",
|
||||
"export", export.Name, "maxSuccessful", maxSuccessful)
|
||||
if err := r.Delete(ctx, export); err != nil && !errors.IsNotFound(err) {
|
||||
l.Error(err, "Failed to delete export for retention", "export", export.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete excess failed exports
|
||||
if clusterImageExport.Spec.Retention.MaxFailed != nil {
|
||||
maxFailed := int(*clusterImageExport.Spec.Retention.MaxFailed)
|
||||
if len(failedExports) > maxFailed {
|
||||
for _, export := range failedExports[maxFailed:] {
|
||||
l.Info("Deleting export due to retention policy (maxFailed exceeded)",
|
||||
"export", export.Name, "maxFailed", maxFailed)
|
||||
if err := r.Delete(ctx, export); err != nil && !errors.IsNotFound(err) {
|
||||
l.Error(err, "Failed to delete export for retention", "export", export.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,643 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
// TestScenario represents a test scenario classification
|
||||
type TestScenario string
|
||||
|
||||
const (
|
||||
ScenarioGood TestScenario = "good"
|
||||
ScenarioNotGood TestScenario = "not_good"
|
||||
ScenarioReallyBad TestScenario = "really_bad"
|
||||
)
|
||||
|
||||
// DefinitionsTestSuite tests the definitions and utility functions
|
||||
type DefinitionsTestSuite struct {
|
||||
suite.Suite
|
||||
}
|
||||
|
||||
func TestDefinitionsTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(DefinitionsTestSuite))
|
||||
}
|
||||
|
||||
// TestNormalizeImageName tests the NormalizeImageName function with matrix strategy
|
||||
func (s *DefinitionsTestSuite) TestNormalizeImageName() {
|
||||
testCases := []struct {
|
||||
name string
|
||||
scenario TestScenario
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
// Good scenarios - standard image names
|
||||
{
|
||||
name: "simple image name",
|
||||
scenario: ScenarioGood,
|
||||
input: "nginx",
|
||||
expected: "nginx",
|
||||
},
|
||||
{
|
||||
name: "image with tag",
|
||||
scenario: ScenarioGood,
|
||||
input: "nginx:latest",
|
||||
expected: "nginx-latest",
|
||||
},
|
||||
{
|
||||
name: "image with version tag",
|
||||
scenario: ScenarioGood,
|
||||
input: "nginx:1.21.0",
|
||||
expected: "nginx-1.21.0",
|
||||
},
|
||||
{
|
||||
name: "full registry path",
|
||||
scenario: ScenarioGood,
|
||||
input: "quay.io/cilium/cilium:v1.18.4",
|
||||
expected: "quay.io-cilium-cilium-v1.18.4",
|
||||
},
|
||||
{
|
||||
name: "ghcr registry",
|
||||
scenario: ScenarioGood,
|
||||
input: "ghcr.io/owner/repo:v1.0.0",
|
||||
expected: "ghcr.io-owner-repo-v1.0.0",
|
||||
},
|
||||
|
||||
// Not good scenarios - unusual but valid formats
|
||||
{
|
||||
name: "image with SHA digest",
|
||||
scenario: ScenarioNotGood,
|
||||
input: "nginx@sha256:abc123def456",
|
||||
expected: "nginx-sha256-abc123def456",
|
||||
},
|
||||
{
|
||||
name: "image with tag and SHA",
|
||||
scenario: ScenarioNotGood,
|
||||
input: "quay.io/cilium/cilium:v1.18.4@sha256:49d87af187eeeb9e9e3ec2bc6bd372261a0b5cb2d845659463ba7cc10fe9e45f",
|
||||
expected: "quay.io-cilium-cilium-v1.18.4-sha256-49d87af187eeeb9e9e3ec2bc6bd372261a0b5cb2d845659463ba7cc10fe9e45f",
|
||||
},
|
||||
{
|
||||
name: "multiple colons in path",
|
||||
scenario: ScenarioNotGood,
|
||||
input: "registry:5000/image:tag",
|
||||
expected: "registry-5000-image-tag",
|
||||
},
|
||||
|
||||
// Really bad scenarios - edge cases and potential problems
|
||||
{
|
||||
name: "empty string",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "only special characters",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: ":///@",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "multiple consecutive special chars",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: "image:::tag",
|
||||
expected: "image-tag",
|
||||
},
|
||||
{
|
||||
name: "leading special characters",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: "//image:tag",
|
||||
expected: "image-tag",
|
||||
},
|
||||
{
|
||||
name: "trailing special characters",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: "image:tag//",
|
||||
expected: "image-tag",
|
||||
},
|
||||
{
|
||||
name: "spaces in name",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: "image name:tag",
|
||||
expected: "image-name-tag",
|
||||
},
|
||||
{
|
||||
name: "unicode characters",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: "image:tag-日本語",
|
||||
expected: "image-tag-日本語",
|
||||
},
|
||||
{
|
||||
name: "very long image name",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: "registry.example.com/very/long/path/to/image:tag@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
||||
expected: "registry.example.com-very-long-path-to-image-tag-sha256-abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
||||
},
|
||||
{
|
||||
name: "query string characters",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: "image?foo=bar&baz=qux",
|
||||
expected: "image-foo-bar-baz-qux",
|
||||
},
|
||||
{
|
||||
name: "brackets and special chars",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: "image[tag]{version}",
|
||||
expected: "image-tag-version",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
s.Run(tc.name, func() {
|
||||
result := NormalizeImageName(tc.input)
|
||||
assert.Equal(s.T(), tc.expected, result, "Scenario: %s", tc.scenario)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveDuplicates tests duplicate removal functionality
|
||||
func (s *DefinitionsTestSuite) TestRemoveDuplicates() {
|
||||
testCases := []struct {
|
||||
name string
|
||||
scenario TestScenario
|
||||
input ContainersList
|
||||
expected int
|
||||
}{
|
||||
// Good scenarios
|
||||
{
|
||||
name: "no duplicates",
|
||||
scenario: ScenarioGood,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", Tag: "latest", FullName: "nginx:latest"},
|
||||
{Image: "redis", Tag: "6", FullName: "redis:6"},
|
||||
},
|
||||
},
|
||||
expected: 2,
|
||||
},
|
||||
{
|
||||
name: "with duplicates",
|
||||
scenario: ScenarioGood,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", Tag: "latest", FullName: "nginx:latest"},
|
||||
{Image: "nginx", Tag: "latest", FullName: "nginx:latest"},
|
||||
{Image: "redis", Tag: "6", FullName: "redis:6"},
|
||||
},
|
||||
},
|
||||
expected: 2,
|
||||
},
|
||||
|
||||
// Not good scenarios
|
||||
{
|
||||
name: "same image different tags",
|
||||
scenario: ScenarioNotGood,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", Tag: "latest", FullName: "nginx:latest"},
|
||||
{Image: "nginx", Tag: "1.21", FullName: "nginx:1.21"},
|
||||
},
|
||||
},
|
||||
expected: 2, // Should keep both
|
||||
},
|
||||
{
|
||||
name: "same image with and without SHA",
|
||||
scenario: ScenarioNotGood,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", Tag: "latest", FullName: "nginx:latest"},
|
||||
{Image: "nginx", Tag: "latest", Sha: "sha256:abc", FullName: "nginx:latest@sha256:abc"},
|
||||
},
|
||||
},
|
||||
expected: 2, // Different because of SHA
|
||||
},
|
||||
|
||||
// Really bad scenarios
|
||||
{
|
||||
name: "empty list",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: ContainersList{Containers: []Container{}},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "all duplicates",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", Tag: "latest", FullName: "nginx:latest"},
|
||||
{Image: "nginx", Tag: "latest", FullName: "nginx:latest"},
|
||||
{Image: "nginx", Tag: "latest", FullName: "nginx:latest"},
|
||||
},
|
||||
},
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "nil containers",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: ContainersList{Containers: nil},
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
s.Run(tc.name, func() {
|
||||
result := RemoveDuplicates(tc.input)
|
||||
assert.Len(s.T(), result.Containers, tc.expected, "Scenario: %s", tc.scenario)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveExcludedImages tests image exclusion functionality
|
||||
func (s *DefinitionsTestSuite) TestRemoveExcludedImages() {
|
||||
testCases := []struct {
|
||||
name string
|
||||
scenario TestScenario
|
||||
input ContainersList
|
||||
excludes []string
|
||||
expected int
|
||||
}{
|
||||
// Good scenarios
|
||||
{
|
||||
name: "no exclusions",
|
||||
scenario: ScenarioGood,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", Tag: "latest"},
|
||||
{Image: "redis", Tag: "6"},
|
||||
},
|
||||
},
|
||||
excludes: []string{},
|
||||
expected: 2,
|
||||
},
|
||||
{
|
||||
name: "exclude one image",
|
||||
scenario: ScenarioGood,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", Tag: "latest"},
|
||||
{Image: "redis", Tag: "6"},
|
||||
},
|
||||
},
|
||||
excludes: []string{"nginx"},
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "exclude by registry",
|
||||
scenario: ScenarioGood,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "gcr.io/google/nginx", Tag: "latest"},
|
||||
{Image: "docker.io/library/redis", Tag: "6"},
|
||||
},
|
||||
},
|
||||
excludes: []string{"gcr.io"},
|
||||
expected: 1,
|
||||
},
|
||||
|
||||
// Not good scenarios
|
||||
{
|
||||
name: "case insensitive exclusion",
|
||||
scenario: ScenarioNotGood,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "NGINX", Tag: "latest"},
|
||||
{Image: "Redis", Tag: "6"},
|
||||
},
|
||||
},
|
||||
excludes: []string{"nginx"},
|
||||
expected: 1, // Should exclude NGINX
|
||||
},
|
||||
{
|
||||
name: "partial match exclusion",
|
||||
scenario: ScenarioNotGood,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "my-nginx-custom", Tag: "latest"},
|
||||
{Image: "redis", Tag: "6"},
|
||||
},
|
||||
},
|
||||
excludes: []string{"nginx"},
|
||||
expected: 1, // Should exclude my-nginx-custom
|
||||
},
|
||||
|
||||
// Really bad scenarios
|
||||
{
|
||||
name: "exclude all images",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", Tag: "latest"},
|
||||
{Image: "redis", Tag: "6"},
|
||||
},
|
||||
},
|
||||
excludes: []string{"nginx", "redis"},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "empty exclude list on empty containers",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: ContainersList{Containers: []Container{}},
|
||||
excludes: []string{},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "exclude with empty string",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", Tag: "latest"},
|
||||
},
|
||||
},
|
||||
excludes: []string{""},
|
||||
expected: 0, // Empty string matches all
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
s.Run(tc.name, func() {
|
||||
result := RemoveExcludedImages(tc.input, tc.excludes)
|
||||
assert.Len(s.T(), result.Containers, tc.expected, "Scenario: %s", tc.scenario)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIncludeOnlyImages tests image inclusion filtering
|
||||
func (s *DefinitionsTestSuite) TestIncludeOnlyImages() {
|
||||
testCases := []struct {
|
||||
name string
|
||||
scenario TestScenario
|
||||
input ContainersList
|
||||
includes []string
|
||||
expected int
|
||||
}{
|
||||
// Good scenarios
|
||||
{
|
||||
name: "include specific image",
|
||||
scenario: ScenarioGood,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", Tag: "latest"},
|
||||
{Image: "redis", Tag: "6"},
|
||||
{Image: "postgres", Tag: "14"},
|
||||
},
|
||||
},
|
||||
includes: []string{"nginx"},
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "include multiple images",
|
||||
scenario: ScenarioGood,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", Tag: "latest"},
|
||||
{Image: "redis", Tag: "6"},
|
||||
{Image: "postgres", Tag: "14"},
|
||||
},
|
||||
},
|
||||
includes: []string{"nginx", "redis"},
|
||||
expected: 2,
|
||||
},
|
||||
|
||||
// Not good scenarios
|
||||
{
|
||||
name: "include by partial match",
|
||||
scenario: ScenarioNotGood,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "my-nginx-app", Tag: "latest"},
|
||||
{Image: "nginx-proxy", Tag: "v1"},
|
||||
{Image: "redis", Tag: "6"},
|
||||
},
|
||||
},
|
||||
includes: []string{"nginx"},
|
||||
expected: 2,
|
||||
},
|
||||
|
||||
// Really bad scenarios
|
||||
{
|
||||
name: "include non-existent image",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", Tag: "latest"},
|
||||
},
|
||||
},
|
||||
includes: []string{"nonexistent"},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "empty includes list",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", Tag: "latest"},
|
||||
},
|
||||
},
|
||||
includes: []string{},
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
s.Run(tc.name, func() {
|
||||
result := IncludeOnlyImages(tc.input, tc.includes)
|
||||
assert.Len(s.T(), result.Containers, tc.expected, "Scenario: %s", tc.scenario)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterOnlyFromNamespaces tests namespace filtering
|
||||
func (s *DefinitionsTestSuite) TestFilterOnlyFromNamespaces() {
|
||||
testCases := []struct {
|
||||
name string
|
||||
scenario TestScenario
|
||||
input ContainersList
|
||||
namespaces []string
|
||||
expected int
|
||||
}{
|
||||
// Good scenarios
|
||||
{
|
||||
name: "filter single namespace",
|
||||
scenario: ScenarioGood,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", ImageNamespace: "default"},
|
||||
{Image: "redis", ImageNamespace: "kube-system"},
|
||||
},
|
||||
},
|
||||
namespaces: []string{"default"},
|
||||
expected: 1,
|
||||
},
|
||||
|
||||
// Not good scenarios
|
||||
{
|
||||
name: "filter multiple namespaces",
|
||||
scenario: ScenarioNotGood,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", ImageNamespace: "default"},
|
||||
{Image: "redis", ImageNamespace: "kube-system"},
|
||||
{Image: "postgres", ImageNamespace: "database"},
|
||||
},
|
||||
},
|
||||
namespaces: []string{"default", "database"},
|
||||
expected: 2,
|
||||
},
|
||||
|
||||
// Really bad scenarios
|
||||
{
|
||||
name: "filter non-existent namespace",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", ImageNamespace: "default"},
|
||||
},
|
||||
},
|
||||
namespaces: []string{"nonexistent"},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "empty namespace filter",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", ImageNamespace: "default"},
|
||||
},
|
||||
},
|
||||
namespaces: []string{},
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
s.Run(tc.name, func() {
|
||||
result := FilterOnlyFromNamespaces(tc.input, tc.namespaces)
|
||||
assert.Len(s.T(), result.Containers, tc.expected, "Scenario: %s", tc.scenario)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterOutWholeNamespaces tests namespace exclusion
|
||||
func (s *DefinitionsTestSuite) TestFilterOutWholeNamespaces() {
|
||||
testCases := []struct {
|
||||
name string
|
||||
scenario TestScenario
|
||||
input ContainersList
|
||||
namespaces []string
|
||||
expected int
|
||||
}{
|
||||
// Good scenarios
|
||||
{
|
||||
name: "exclude kube-system",
|
||||
scenario: ScenarioGood,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", ImageNamespace: "default"},
|
||||
{Image: "coredns", ImageNamespace: "kube-system"},
|
||||
{Image: "redis", ImageNamespace: "apps"},
|
||||
},
|
||||
},
|
||||
namespaces: []string{"kube-system"},
|
||||
expected: 2,
|
||||
},
|
||||
|
||||
// Not good scenarios
|
||||
{
|
||||
name: "exclude multiple system namespaces",
|
||||
scenario: ScenarioNotGood,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", ImageNamespace: "default"},
|
||||
{Image: "coredns", ImageNamespace: "kube-system"},
|
||||
{Image: "cilium", ImageNamespace: "kube-system"},
|
||||
{Image: "local-path", ImageNamespace: "local-path-storage"},
|
||||
},
|
||||
},
|
||||
namespaces: []string{"kube-system", "local-path-storage"},
|
||||
expected: 1,
|
||||
},
|
||||
|
||||
// Really bad scenarios
|
||||
{
|
||||
name: "exclude all namespaces",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", ImageNamespace: "default"},
|
||||
{Image: "redis", ImageNamespace: "apps"},
|
||||
},
|
||||
},
|
||||
namespaces: []string{"default", "apps"},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "empty exclusion list",
|
||||
scenario: ScenarioReallyBad,
|
||||
input: ContainersList{
|
||||
Containers: []Container{
|
||||
{Image: "nginx", ImageNamespace: "default"},
|
||||
},
|
||||
},
|
||||
namespaces: []string{},
|
||||
expected: 1, // No exclusions = keep all
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
s.Run(tc.name, func() {
|
||||
result := FilterOutWholeNamespaces(tc.input, tc.namespaces)
|
||||
assert.Len(s.T(), result.Containers, tc.expected, "Scenario: %s", tc.scenario)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestContainerStruct tests Container struct behavior
|
||||
func (s *DefinitionsTestSuite) TestContainerStruct() {
|
||||
s.Run("full container with all fields", func() {
|
||||
c := Container{
|
||||
Image: "quay.io/cilium/cilium",
|
||||
Tag: "v1.18.4",
|
||||
Sha: "sha256:49d87af187eeeb9e9e3ec2bc6bd372261a0b5cb2d845659463ba7cc10fe9e45f",
|
||||
FullName: "quay.io/cilium/cilium:v1.18.4@sha256:49d87af187eeeb9e9e3ec2bc6bd372261a0b5cb2d845659463ba7cc10fe9e45f",
|
||||
ImageNamespace: "kube-system",
|
||||
}
|
||||
|
||||
assert.Equal(s.T(), "quay.io/cilium/cilium", c.Image)
|
||||
assert.Equal(s.T(), "v1.18.4", c.Tag)
|
||||
assert.Contains(s.T(), c.Sha, "sha256:")
|
||||
assert.Contains(s.T(), c.FullName, "@")
|
||||
})
|
||||
|
||||
s.Run("container equality for deduplication", func() {
|
||||
c1 := Container{Image: "nginx", Tag: "latest", FullName: "nginx:latest"}
|
||||
c2 := Container{Image: "nginx", Tag: "latest", FullName: "nginx:latest"}
|
||||
c3 := Container{Image: "nginx", Tag: "1.21", FullName: "nginx:1.21"}
|
||||
|
||||
assert.Equal(s.T(), c1, c2)
|
||||
assert.NotEqual(s.T(), c1, c3)
|
||||
})
|
||||
}
|
||||
|
||||
// TestConstants tests that constants are defined correctly
|
||||
func (s *DefinitionsTestSuite) TestConstants() {
|
||||
// Status constants
|
||||
assert.Equal(s.T(), "PENDING", STATUS_PENDING)
|
||||
assert.Equal(s.T(), "STARTING", STATUS_STARTING)
|
||||
assert.Equal(s.T(), "RETRYING", STATUS_RETRYING)
|
||||
assert.Equal(s.T(), "RUNNING", STATUS_RUNNING)
|
||||
assert.Equal(s.T(), "FAILED", STATUS_FAILED)
|
||||
assert.Equal(s.T(), "COMPLETED", STATUS_SUCCESS)
|
||||
assert.Equal(s.T(), "PRESENT", STATUS_PRESENT)
|
||||
|
||||
// Storage constants
|
||||
assert.Equal(s.T(), "S3", STORAGE_S3)
|
||||
assert.Equal(s.T(), "FILE", STORAGE_FILE)
|
||||
}
|
||||
|
||||
// TestBackupJobImage tests the BACKUP_JOB_IMAGE initialization
|
||||
func (s *DefinitionsTestSuite) TestBackupJobImage() {
|
||||
require.NotEmpty(s.T(), BACKUP_JOB_IMAGE)
|
||||
assert.Contains(s.T(), BACKUP_JOB_IMAGE, "kubernetes-images-sync-worker")
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
raczylocomv1 "github.com/lukaszraczylo/kubernetes-images-sync-operator/api/raczylo.com/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// JobsTestSuite tests job creation and related functionality
|
||||
type JobsTestSuite struct {
|
||||
suite.Suite
|
||||
}
|
||||
|
||||
func TestJobsTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(JobsTestSuite))
|
||||
}
|
||||
|
||||
// TestCreateJob tests the CreateJob function with various scenarios
|
||||
func (s *JobsTestSuite) TestCreateJob() {
|
||||
testCases := []struct {
|
||||
name string
|
||||
scenario TestScenario
|
||||
params JobParams
|
||||
expectJobName string
|
||||
expectNamespace string
|
||||
expectImage string
|
||||
expectAnnotations map[string]string
|
||||
expectSecrets int
|
||||
expectEnvVars int
|
||||
}{
|
||||
// Good scenarios
|
||||
{
|
||||
name: "basic job creation",
|
||||
scenario: ScenarioGood,
|
||||
params: JobParams{
|
||||
Name: "test-job",
|
||||
Namespace: "default",
|
||||
Image: "worker:latest",
|
||||
Commands: []string{"echo hello"},
|
||||
},
|
||||
expectJobName: "test-job",
|
||||
expectNamespace: "default",
|
||||
expectImage: "worker:latest",
|
||||
expectSecrets: 0,
|
||||
expectEnvVars: 0,
|
||||
},
|
||||
{
|
||||
name: "job with annotations",
|
||||
scenario: ScenarioGood,
|
||||
params: JobParams{
|
||||
Name: "annotated-job",
|
||||
Namespace: "default",
|
||||
Image: "worker:latest",
|
||||
Commands: []string{"echo hello"},
|
||||
Annotations: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
},
|
||||
},
|
||||
expectJobName: "annotated-job",
|
||||
expectNamespace: "default",
|
||||
expectImage: "worker:latest",
|
||||
expectAnnotations: map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
},
|
||||
expectSecrets: 0,
|
||||
expectEnvVars: 0,
|
||||
},
|
||||
{
|
||||
name: "job with image pull secrets",
|
||||
scenario: ScenarioGood,
|
||||
params: JobParams{
|
||||
Name: "secret-job",
|
||||
Namespace: "default",
|
||||
Image: "private-registry/image:latest",
|
||||
Commands: []string{"echo hello"},
|
||||
ImagePullSecrets: []corev1.LocalObjectReference{
|
||||
{Name: "my-registry-secret"},
|
||||
},
|
||||
},
|
||||
expectJobName: "secret-job",
|
||||
expectNamespace: "default",
|
||||
expectImage: "private-registry/image:latest",
|
||||
expectSecrets: 1,
|
||||
expectEnvVars: 0,
|
||||
},
|
||||
{
|
||||
name: "job with multiple secrets",
|
||||
scenario: ScenarioGood,
|
||||
params: JobParams{
|
||||
Name: "multi-secret-job",
|
||||
Namespace: "default",
|
||||
Image: "private-registry/image:latest",
|
||||
Commands: []string{"echo hello"},
|
||||
ImagePullSecrets: []corev1.LocalObjectReference{
|
||||
{Name: "secret1"},
|
||||
{Name: "secret2"},
|
||||
{Name: "secret3"},
|
||||
},
|
||||
},
|
||||
expectJobName: "multi-secret-job",
|
||||
expectNamespace: "default",
|
||||
expectImage: "private-registry/image:latest",
|
||||
expectSecrets: 3,
|
||||
expectEnvVars: 0,
|
||||
},
|
||||
{
|
||||
name: "job with environment variables",
|
||||
scenario: ScenarioGood,
|
||||
params: JobParams{
|
||||
Name: "env-job",
|
||||
Namespace: "default",
|
||||
Image: "worker:latest",
|
||||
Commands: []string{"echo $MY_VAR"},
|
||||
EnvVars: []corev1.EnvVar{
|
||||
{Name: "MY_VAR", Value: "my-value"},
|
||||
{Name: "AWS_REGION", Value: "us-east-1"},
|
||||
},
|
||||
},
|
||||
expectJobName: "env-job",
|
||||
expectNamespace: "default",
|
||||
expectImage: "worker:latest",
|
||||
expectSecrets: 0,
|
||||
expectEnvVars: 2,
|
||||
},
|
||||
|
||||
// Not good scenarios
|
||||
{
|
||||
name: "job with backoff limit",
|
||||
scenario: ScenarioNotGood,
|
||||
params: JobParams{
|
||||
Name: "retry-job",
|
||||
Namespace: "default",
|
||||
Image: "worker:latest",
|
||||
Commands: []string{"might-fail"},
|
||||
BackoffLimit: int32Ptr(3),
|
||||
},
|
||||
expectJobName: "retry-job",
|
||||
expectNamespace: "default",
|
||||
expectImage: "worker:latest",
|
||||
expectSecrets: 0,
|
||||
expectEnvVars: 0,
|
||||
},
|
||||
{
|
||||
name: "job with TTL after finished",
|
||||
scenario: ScenarioNotGood,
|
||||
params: JobParams{
|
||||
Name: "ttl-job",
|
||||
Namespace: "default",
|
||||
Image: "worker:latest",
|
||||
Commands: []string{"echo done"},
|
||||
TTLSecondsAfterFinished: int32Ptr(300),
|
||||
},
|
||||
expectJobName: "ttl-job",
|
||||
expectNamespace: "default",
|
||||
expectImage: "worker:latest",
|
||||
expectSecrets: 0,
|
||||
expectEnvVars: 0,
|
||||
},
|
||||
|
||||
// Really bad scenarios - edge cases
|
||||
{
|
||||
name: "job with empty commands",
|
||||
scenario: ScenarioReallyBad,
|
||||
params: JobParams{
|
||||
Name: "empty-cmd-job",
|
||||
Namespace: "default",
|
||||
Image: "worker:latest",
|
||||
Commands: []string{},
|
||||
},
|
||||
expectJobName: "empty-cmd-job",
|
||||
expectNamespace: "default",
|
||||
expectImage: "worker:latest",
|
||||
expectSecrets: 0,
|
||||
expectEnvVars: 0,
|
||||
},
|
||||
{
|
||||
name: "job with very long name",
|
||||
scenario: ScenarioReallyBad,
|
||||
params: JobParams{
|
||||
Name: "this-is-a-very-long-job-name-that-might-cause-issues-in-kubernetes-because-names-have-limits",
|
||||
Namespace: "default",
|
||||
Image: "worker:latest",
|
||||
Commands: []string{"echo hello"},
|
||||
},
|
||||
expectJobName: "this-is-a-very-long-job-name-that-might-cause-issues-in-kubernetes-because-names-have-limits",
|
||||
expectNamespace: "default",
|
||||
expectImage: "worker:latest",
|
||||
expectSecrets: 0,
|
||||
expectEnvVars: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
s.Run(tc.name, func() {
|
||||
job := CreateJob(tc.params, func(raczylocomv1.ClusterImageExport) []string { return nil })
|
||||
|
||||
require.NotNil(s.T(), job, "Job should not be nil")
|
||||
|
||||
// Verify job metadata
|
||||
assert.Equal(s.T(), tc.expectJobName, job.ObjectMeta.Name)
|
||||
assert.Equal(s.T(), tc.expectNamespace, job.ObjectMeta.Namespace)
|
||||
|
||||
// Verify labels
|
||||
assert.Equal(s.T(), "image-export", job.ObjectMeta.Labels["app"])
|
||||
assert.Equal(s.T(), "image-export", job.Spec.Template.ObjectMeta.Labels["app"])
|
||||
|
||||
// Verify annotations if expected
|
||||
if tc.expectAnnotations != nil {
|
||||
for k, v := range tc.expectAnnotations {
|
||||
assert.Equal(s.T(), v, job.ObjectMeta.Annotations[k])
|
||||
assert.Equal(s.T(), v, job.Spec.Template.ObjectMeta.Annotations[k])
|
||||
}
|
||||
}
|
||||
|
||||
// Verify pod template
|
||||
podSpec := job.Spec.Template.Spec
|
||||
require.Len(s.T(), podSpec.Containers, 1, "Should have exactly one container")
|
||||
|
||||
container := podSpec.Containers[0]
|
||||
assert.Equal(s.T(), "exporter", container.Name)
|
||||
assert.Equal(s.T(), tc.expectImage, container.Image)
|
||||
assert.True(s.T(), container.TTY)
|
||||
|
||||
// Verify restart policy
|
||||
assert.Equal(s.T(), corev1.RestartPolicyOnFailure, podSpec.RestartPolicy)
|
||||
|
||||
// Verify secrets
|
||||
assert.Len(s.T(), podSpec.ImagePullSecrets, tc.expectSecrets)
|
||||
assert.Len(s.T(), podSpec.Volumes, tc.expectSecrets)
|
||||
assert.Len(s.T(), container.VolumeMounts, tc.expectSecrets)
|
||||
|
||||
// Verify environment variables
|
||||
assert.Len(s.T(), container.Env, tc.expectEnvVars)
|
||||
|
||||
// Verify security context (privileged for podman)
|
||||
require.NotNil(s.T(), container.SecurityContext)
|
||||
require.NotNil(s.T(), container.SecurityContext.Privileged)
|
||||
assert.True(s.T(), *container.SecurityContext.Privileged)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateJobWithOwnerReferences tests owner reference handling
|
||||
func (s *JobsTestSuite) TestCreateJobWithOwnerReferences() {
|
||||
ownerRefs := []metav1.OwnerReference{
|
||||
{
|
||||
APIVersion: "raczylo.com/v1",
|
||||
Kind: "ClusterImage",
|
||||
Name: "test-image",
|
||||
UID: "test-uid-12345",
|
||||
},
|
||||
}
|
||||
|
||||
params := JobParams{
|
||||
Name: "owned-job",
|
||||
Namespace: "default",
|
||||
Image: "worker:latest",
|
||||
Commands: []string{"echo hello"},
|
||||
OwnerReferences: ownerRefs,
|
||||
}
|
||||
|
||||
job := CreateJob(params, func(raczylocomv1.ClusterImageExport) []string { return nil })
|
||||
|
||||
require.Len(s.T(), job.ObjectMeta.OwnerReferences, 1)
|
||||
assert.Equal(s.T(), "ClusterImage", job.ObjectMeta.OwnerReferences[0].Kind)
|
||||
assert.Equal(s.T(), "test-image", job.ObjectMeta.OwnerReferences[0].Name)
|
||||
}
|
||||
|
||||
// TestCreateJobCommands tests command concatenation
|
||||
func (s *JobsTestSuite) TestCreateJobCommands() {
|
||||
testCases := []struct {
|
||||
name string
|
||||
commands []string
|
||||
expectedArgsLen int
|
||||
expectedJoined string
|
||||
}{
|
||||
{
|
||||
name: "single command",
|
||||
commands: []string{"echo hello"},
|
||||
expectedArgsLen: 3, // /bin/bash, -c, "command"
|
||||
expectedJoined: "echo hello",
|
||||
},
|
||||
{
|
||||
name: "multiple commands",
|
||||
commands: []string{"echo hello", "echo world"},
|
||||
expectedArgsLen: 3,
|
||||
expectedJoined: "echo hello && echo world",
|
||||
},
|
||||
{
|
||||
name: "complex podman commands",
|
||||
commands: []string{
|
||||
"podman pull nginx:latest",
|
||||
"podman save --quiet -o /tmp/nginx.tar nginx:latest",
|
||||
"./worker export /tmp/nginx.tar s3://bucket/path",
|
||||
},
|
||||
expectedArgsLen: 3,
|
||||
expectedJoined: "podman pull nginx:latest && podman save --quiet -o /tmp/nginx.tar nginx:latest && ./worker export /tmp/nginx.tar s3://bucket/path",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
s.Run(tc.name, func() {
|
||||
params := JobParams{
|
||||
Name: "cmd-test",
|
||||
Namespace: "default",
|
||||
Image: "worker:latest",
|
||||
Commands: tc.commands,
|
||||
}
|
||||
|
||||
job := CreateJob(params, func(raczylocomv1.ClusterImageExport) []string { return nil })
|
||||
|
||||
container := job.Spec.Template.Spec.Containers[0]
|
||||
assert.Len(s.T(), container.Args, tc.expectedArgsLen)
|
||||
assert.Equal(s.T(), "/bin/bash", container.Args[0])
|
||||
assert.Equal(s.T(), "-c", container.Args[1])
|
||||
assert.Equal(s.T(), tc.expectedJoined, container.Args[2])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateJobBackoffAndTTL tests backoff limit and TTL settings
|
||||
func (s *JobsTestSuite) TestCreateJobBackoffAndTTL() {
|
||||
backoff := int32(5)
|
||||
ttl := int32(600)
|
||||
|
||||
params := JobParams{
|
||||
Name: "backoff-ttl-job",
|
||||
Namespace: "default",
|
||||
Image: "worker:latest",
|
||||
Commands: []string{"echo hello"},
|
||||
BackoffLimit: &backoff,
|
||||
TTLSecondsAfterFinished: &ttl,
|
||||
}
|
||||
|
||||
job := CreateJob(params, func(raczylocomv1.ClusterImageExport) []string { return nil })
|
||||
|
||||
require.NotNil(s.T(), job.Spec.BackoffLimit)
|
||||
assert.Equal(s.T(), int32(5), *job.Spec.BackoffLimit)
|
||||
|
||||
require.NotNil(s.T(), job.Spec.TTLSecondsAfterFinished)
|
||||
assert.Equal(s.T(), int32(600), *job.Spec.TTLSecondsAfterFinished)
|
||||
}
|
||||
|
||||
// TestSetupS3Params tests S3 parameter generation
|
||||
func (s *JobsTestSuite) TestSetupS3Params() {
|
||||
testCases := []struct {
|
||||
name string
|
||||
scenario TestScenario
|
||||
config raczylocomv1.ClusterImageStorageS3
|
||||
expectContains []string
|
||||
expectNotIn []string
|
||||
}{
|
||||
// Good scenarios
|
||||
{
|
||||
name: "basic credentials",
|
||||
scenario: ScenarioGood,
|
||||
config: raczylocomv1.ClusterImageStorageS3{
|
||||
Bucket: "my-bucket",
|
||||
Region: "us-east-1",
|
||||
AccessKey: "AKIAIOSFODNN7EXAMPLE",
|
||||
SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
},
|
||||
expectContains: []string{
|
||||
"--aws_access_key_id='AKIAIOSFODNN7EXAMPLE'",
|
||||
"--aws_secret_access_key='wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'",
|
||||
},
|
||||
expectNotIn: []string{"--use_role", "--use_current_role"},
|
||||
},
|
||||
{
|
||||
name: "use current role (IRSA)",
|
||||
scenario: ScenarioGood,
|
||||
config: raczylocomv1.ClusterImageStorageS3{
|
||||
Bucket: "my-bucket",
|
||||
Region: "us-east-1",
|
||||
UseRole: true,
|
||||
},
|
||||
expectContains: []string{"--use_current_role"},
|
||||
expectNotIn: []string{"--aws_access_key_id", "--aws_secret_access_key"},
|
||||
},
|
||||
{
|
||||
name: "use specific role ARN",
|
||||
scenario: ScenarioGood,
|
||||
config: raczylocomv1.ClusterImageStorageS3{
|
||||
Bucket: "my-bucket",
|
||||
Region: "us-east-1",
|
||||
UseRole: true,
|
||||
RoleARN: "arn:aws:iam::123456789:role/MyRole",
|
||||
},
|
||||
expectContains: []string{
|
||||
"--use_role",
|
||||
"--role_name='arn:aws:iam::123456789:role/MyRole'",
|
||||
},
|
||||
expectNotIn: []string{"--aws_access_key_id", "--use_current_role"},
|
||||
},
|
||||
|
||||
// Not good scenarios
|
||||
{
|
||||
name: "with custom endpoint (MinIO)",
|
||||
scenario: ScenarioNotGood,
|
||||
config: raczylocomv1.ClusterImageStorageS3{
|
||||
Bucket: "my-bucket",
|
||||
Region: "us-east-1",
|
||||
AccessKey: "minioadmin",
|
||||
SecretKey: "minioadmin",
|
||||
Endpoint: "http://minio.local:9000",
|
||||
},
|
||||
expectContains: []string{
|
||||
"--endpoint_url='http://minio.local:9000'",
|
||||
"--aws_access_key_id='minioadmin'",
|
||||
},
|
||||
},
|
||||
|
||||
// Really bad scenarios
|
||||
{
|
||||
name: "empty credentials with UseRole false",
|
||||
scenario: ScenarioReallyBad,
|
||||
config: raczylocomv1.ClusterImageStorageS3{
|
||||
Bucket: "my-bucket",
|
||||
Region: "us-east-1",
|
||||
},
|
||||
expectContains: []string{
|
||||
"--aws_access_key_id=''",
|
||||
"--aws_secret_access_key=''",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
s.Run(tc.name, func() {
|
||||
params := SetupS3Params(tc.config)
|
||||
|
||||
// Check expected parameters are present
|
||||
for _, expected := range tc.expectContains {
|
||||
found := false
|
||||
for _, param := range params {
|
||||
if param == expected {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(s.T(), found, "Expected parameter not found: %s", expected)
|
||||
}
|
||||
|
||||
// Check unexpected parameters are not present
|
||||
for _, notExpected := range tc.expectNotIn {
|
||||
for _, param := range params {
|
||||
assert.NotContains(s.T(), param, notExpected, "Unexpected parameter found: %s", notExpected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestJobParamsDefaults tests JobParams default handling
|
||||
func (s *JobsTestSuite) TestJobParamsDefaults() {
|
||||
s.Run("nil backoff limit", func() {
|
||||
params := JobParams{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
Image: "worker:latest",
|
||||
Commands: []string{"echo"},
|
||||
}
|
||||
|
||||
job := CreateJob(params, func(raczylocomv1.ClusterImageExport) []string { return nil })
|
||||
assert.Nil(s.T(), job.Spec.BackoffLimit)
|
||||
})
|
||||
|
||||
s.Run("nil TTL", func() {
|
||||
params := JobParams{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
Image: "worker:latest",
|
||||
Commands: []string{"echo"},
|
||||
}
|
||||
|
||||
job := CreateJob(params, func(raczylocomv1.ClusterImageExport) []string { return nil })
|
||||
assert.Nil(s.T(), job.Spec.TTLSecondsAfterFinished)
|
||||
})
|
||||
|
||||
s.Run("empty service account uses env var", func() {
|
||||
// This test verifies that when ServiceAccount is empty,
|
||||
// the job will use POD_SERVICE_ACCOUNT env var
|
||||
params := JobParams{
|
||||
Name: "test",
|
||||
Namespace: "default",
|
||||
Image: "worker:latest",
|
||||
Commands: []string{"echo"},
|
||||
ServiceAccount: "", // Empty
|
||||
}
|
||||
|
||||
job := CreateJob(params, func(raczylocomv1.ClusterImageExport) []string { return nil })
|
||||
// Service account will be set from environment variable POD_SERVICE_ACCOUNT
|
||||
// In tests, this will be empty, so we just verify the job is created
|
||||
require.NotNil(s.T(), job)
|
||||
})
|
||||
}
|
||||
|
||||
// TestSecretVolumeMounting tests that secrets are properly mounted
|
||||
func (s *JobsTestSuite) TestSecretVolumeMounting() {
|
||||
secrets := []corev1.LocalObjectReference{
|
||||
{Name: "docker-registry"},
|
||||
{Name: "gcr-json-key"},
|
||||
{Name: "ecr-credentials"},
|
||||
}
|
||||
|
||||
params := JobParams{
|
||||
Name: "secret-mount-test",
|
||||
Namespace: "default",
|
||||
Image: "worker:latest",
|
||||
Commands: []string{"echo"},
|
||||
ImagePullSecrets: secrets,
|
||||
}
|
||||
|
||||
job := CreateJob(params, func(raczylocomv1.ClusterImageExport) []string { return nil })
|
||||
podSpec := job.Spec.Template.Spec
|
||||
container := podSpec.Containers[0]
|
||||
|
||||
// Verify volumes are created
|
||||
require.Len(s.T(), podSpec.Volumes, 3)
|
||||
for i, vol := range podSpec.Volumes {
|
||||
assert.Equal(s.T(), secrets[i].Name, vol.VolumeSource.Secret.SecretName)
|
||||
assert.Contains(s.T(), vol.Name, "secret-")
|
||||
}
|
||||
|
||||
// Verify volume mounts
|
||||
require.Len(s.T(), container.VolumeMounts, 3)
|
||||
for i, mount := range container.VolumeMounts {
|
||||
assert.Contains(s.T(), mount.MountPath, ".docker-secret-")
|
||||
assert.True(s.T(), mount.ReadOnly)
|
||||
assert.Contains(s.T(), mount.Name, "secret-")
|
||||
// Verify index is correct
|
||||
expectedIndex := i
|
||||
assert.Equal(s.T(), "/home/runner/.docker-secret-"+string(rune('0'+expectedIndex)), mount.MountPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function
|
||||
func int32Ptr(i int32) *int32 {
|
||||
return &i
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user