From 56699ecb7a894997b24cc2a0abbebc6e6e9aaf21 Mon Sep 17 00:00:00 2001 From: Dylan Ross Date: Fri, 3 Apr 2026 13:39:37 -0500 Subject: [PATCH 1/2] test(e2e): add e2e stress test suite with minikube framework Implement end-to-end testing infrastructure including a Ginkgo-based test framework, workload scheduler, OCI image generator, and minikube setup scripts with Kustomize manifests for deploying barnacle, redis, and a local registry. Fixes bugs found in disk management and cache configuration during e2e validation. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 6 + Makefile | 63 ++- cmd/e2e-imagegen/main.go | 159 +++++++ go.mod | 33 +- go.sum | 88 +++- hack/e2e/config/barnacle.yaml | 28 ++ hack/e2e/manifests/barnacle/configmap.yaml | 38 ++ hack/e2e/manifests/barnacle/deployment.yaml | 73 +++ hack/e2e/manifests/barnacle/ingress.yaml | 28 ++ .../e2e/manifests/barnacle/kustomization.yaml | 10 + hack/e2e/manifests/barnacle/service.yaml | 17 + .../kube-ingress-dns-minikube.yaml | 39 ++ hack/e2e/manifests/kustomization.yaml | 17 + hack/e2e/manifests/namespace.yaml | 7 + hack/e2e/manifests/redis/deployment.yaml | 45 ++ hack/e2e/manifests/redis/kustomization.yaml | 8 + hack/e2e/manifests/redis/service.yaml | 16 + hack/e2e/manifests/registry/deployment.yaml | 53 +++ hack/e2e/manifests/registry/ingress.yaml | 28 ++ .../e2e/manifests/registry/kustomization.yaml | 9 + hack/e2e/manifests/registry/service.yaml | 17 + hack/e2e/minikube/setup.sh | 365 +++++++++++++++ internal/node/disk.go | 28 ++ internal/node/disk_test.go | 86 ++++ internal/node/registry.go | 41 +- pkg/configuration/cache.go | 28 ++ pkg/configuration/cache_test.go | 88 ++++ test/e2e/example_test.go | 17 - test/e2e/framework/barnacle.go | 185 ++++++++ test/e2e/framework/cluster.go | 203 +++++++++ test/e2e/framework/framework.go | 155 +++++++ test/e2e/framework/options.go | 124 ++++++ test/e2e/imagegen/generator.go | 421 ++++++++++++++++++ test/e2e/imagegen/layer.go | 172 +++++++ test/e2e/stress_test.go | 162 +++++++ test/e2e/suite_test.go | 116 +++++ test/e2e/workload/events.go | 155 +++++++ test/e2e/workload/reporter.go | 295 ++++++++++++ test/e2e/workload/scheduler.go | 186 ++++++++ test/e2e/workload/worker.go | 210 +++++++++ 40 files changed, 3776 insertions(+), 43 deletions(-) create mode 100644 cmd/e2e-imagegen/main.go create mode 100644 hack/e2e/config/barnacle.yaml create mode 100644 hack/e2e/manifests/barnacle/configmap.yaml create mode 100644 hack/e2e/manifests/barnacle/deployment.yaml create mode 100644 hack/e2e/manifests/barnacle/ingress.yaml create mode 100644 hack/e2e/manifests/barnacle/kustomization.yaml create mode 100644 hack/e2e/manifests/barnacle/service.yaml create mode 100644 hack/e2e/manifests/ingress-dns/kube-ingress-dns-minikube.yaml create mode 100644 hack/e2e/manifests/kustomization.yaml create mode 100644 hack/e2e/manifests/namespace.yaml create mode 100644 hack/e2e/manifests/redis/deployment.yaml create mode 100644 hack/e2e/manifests/redis/kustomization.yaml create mode 100644 hack/e2e/manifests/redis/service.yaml create mode 100644 hack/e2e/manifests/registry/deployment.yaml create mode 100644 hack/e2e/manifests/registry/ingress.yaml create mode 100644 hack/e2e/manifests/registry/kustomization.yaml create mode 100644 hack/e2e/manifests/registry/service.yaml create mode 100755 hack/e2e/minikube/setup.sh delete mode 100644 test/e2e/example_test.go create mode 100644 test/e2e/framework/barnacle.go create mode 100644 test/e2e/framework/cluster.go create mode 100644 test/e2e/framework/framework.go create mode 100644 test/e2e/framework/options.go create mode 100644 test/e2e/imagegen/generator.go create mode 100644 test/e2e/imagegen/layer.go create mode 100644 test/e2e/stress_test.go create mode 100644 test/e2e/suite_test.go create mode 100644 test/e2e/workload/events.go create mode 100644 test/e2e/workload/reporter.go create mode 100644 test/e2e/workload/scheduler.go create mode 100644 test/e2e/workload/worker.go diff --git a/.gitignore b/.gitignore index c0f6246..68b682c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,12 @@ *.so *.dylib dist/ +bin/ + +# E2E test artifacts +e2e-images.json +e2e-results-*.json +test/e2e/out # Test binary, built with `go test -c` *.test diff --git a/Makefile b/Makefile index 7c380d6..606a9f8 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all tools fmt swagger test e2e lint clean run local-up local-down open-local-redisinsight local-cluster-build local-cluster-up local-cluster-down local-cluster-clean local-cluster-healthcheck local-cluster-logs open-local-cluster-redisinsight +.PHONY: all tools fmt swagger test e2e lint clean run local-up local-down open-local-redisinsight local-cluster-build local-cluster-up local-cluster-down local-cluster-clean local-cluster-healthcheck local-cluster-logs open-local-cluster-redisinsight e2e-setup e2e-deploy e2e-generate e2e-test e2e-clean e2e-imagegen-build # Default target: format, generate swagger docs, and build all: fmt swagger build @@ -39,10 +39,67 @@ test: @echo "Running unit tests..." @go test -v $$(go list ./... | grep -v /test/e2e) -race -# Run end-to-end tests +# Run end-to-end tests (simple mode - requires manual setup) e2e: @echo "Running e2e tests..." - @go test -v ./test/e2e/... + @go test -v -tags=e2e ./test/e2e/... + +# E2E Variables +E2E_WORKERS ?= 10 +E2E_ITERATIONS ?= 10000 +E2E_VARIANTS ?= 100 +E2E_MINIKUBE_IP := $(shell minikube ip -p barnacle-e2e 2>/dev/null || echo "localhost") +E2E_REGISTRY ?= $(E2E_MINIKUBE_IP):30500 +E2E_MANIFEST ?= e2e-images.json +E2E_KUBE_QPS ?= 20000 +E2E_KUBE_BURST ?= 40000 +E2E_RESULTS ?= ./test/e2e/out + +# Create minikube cluster, build barnacle image, and deploy infrastructure +e2e-setup: + @echo "Setting up e2e environment..." + @./hack/e2e/minikube/setup.sh setup + +# Deploy barnacle + redis + local registry (assumes cluster exists) +e2e-deploy: + @echo "Deploying e2e infrastructure..." + @./hack/e2e/minikube/setup.sh deploy + +# Build the e2e image generator tool +e2e-imagegen-build: + @echo "Building e2e image generator..." + @go build -o bin/e2e-imagegen ./cmd/e2e-imagegen + +# Generate test images (standalone, can re-run) +# Usage: make e2e-generate +# make e2e-generate E2E_VARIANTS=50 E2E_REGISTRY=192.168.49.2:30500 +e2e-generate: e2e-imagegen-build + @echo "Generating $(E2E_VARIANTS) test images to $(E2E_REGISTRY)..." + @./bin/e2e-imagegen \ + -registry $(E2E_REGISTRY) \ + -variants $(E2E_VARIANTS) \ + -output $(E2E_MANIFEST) + +# Run e2e tests against existing images +# Usage: make e2e-test +# make e2e-test E2E_WORKERS=20 E2E_ITERATIONS=50000 +e2e-test: + @mkdir -p $(E2E_RESULTS) + @echo "Running e2e tests with $(E2E_WORKERS) workers, $(E2E_ITERATIONS) iterations..." + @go test -v -tags=e2e ./test/e2e/... \ + -workers=$(E2E_WORKERS) \ + -iterations=$(E2E_ITERATIONS) \ + -manifest=$(CURDIR)/$(E2E_MANIFEST) \ + -barnacle-node-addr=$(E2E_MINIKUBE_IP):30080 \ + -kube-qps=$(E2E_KUBE_QPS) \ + -kube-burst=$(E2E_KUBE_BURST) \ + -results $(CURDIR)/$(E2E_RESULTS) \ + -timeout 0 + +# Teardown the e2e cluster +e2e-clean: + @echo "Cleaning up e2e environment..." + @./hack/e2e/minikube/setup.sh teardown # Run linter lint: diff --git a/cmd/e2e-imagegen/main.go b/cmd/e2e-imagegen/main.go new file mode 100644 index 0000000..57889ab --- /dev/null +++ b/cmd/e2e-imagegen/main.go @@ -0,0 +1,159 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/pdylanross/barnacle/test/e2e/imagegen" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func main() { + // Parse command line flags + var ( + baseImage = flag.String("base", "alpine:latest", "Base image to use for generation") + registry = flag.String("registry", "localhost:5000", "Target registry to push images to") + numVariants = flag.Int("variants", 100, "Number of image variants to generate") + minLayers = flag.Int("min-layers", 1, "Minimum number of layers per image") + maxLayers = flag.Int("max-layers", 4, "Maximum number of layers per image") + sharingPercent = flag.Int("sharing", 50, "Percentage of images to base on previously generated images") + concurrency = flag.Int("concurrency", 4, "Number of concurrent image generations") + insecure = flag.Bool("insecure", true, "Allow pushing to insecure registries") + output = flag.String("output", "e2e-images.json", "Output manifest file path") + verbose = flag.Bool("verbose", false, "Enable verbose logging") + ) + + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "e2e-imagegen - Generate variant container images for e2e testing\n\n") + fmt.Fprintf(os.Stderr, "Usage: e2e-imagegen [options]\n\n") + fmt.Fprintf(os.Stderr, "Options:\n") + flag.PrintDefaults() + fmt.Fprintf(os.Stderr, "\nExamples:\n") + fmt.Fprintf(os.Stderr, " # Generate 100 images to local registry\n") + fmt.Fprintf(os.Stderr, " e2e-imagegen -registry localhost:5000 -variants 100\n\n") + fmt.Fprintf(os.Stderr, " # Generate images with custom layer settings\n") + fmt.Fprintf(os.Stderr, " e2e-imagegen -min-layers 2 -max-layers 5 -variants 50\n\n") + } + + flag.Parse() + + // Setup logger + var logConfig zap.Config + if *verbose { + logConfig = zap.NewDevelopmentConfig() + logConfig.Level = zap.NewAtomicLevelAt(zapcore.DebugLevel) + } else { + logConfig = zap.NewProductionConfig() + logConfig.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel) + } + + logger, err := logConfig.Build() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create logger: %v\n", err) + os.Exit(1) + } + defer logger.Sync() //nolint:errcheck + + // Build configuration + config := imagegen.Config{ + BaseImage: *baseImage, + TargetRegistry: *registry, + NumVariants: *numVariants, + MinLayers: *minLayers, + MaxLayers: *maxLayers, + LayerSharingPercent: *sharingPercent, + Concurrency: *concurrency, + Insecure: *insecure, + } + + // Validate configuration + if err := validateConfig(config); err != nil { + logger.Fatal("Invalid configuration", zap.Error(err)) + } + + logger.Info("Starting image generation", + zap.String("base_image", config.BaseImage), + zap.String("registry", config.TargetRegistry), + zap.Int("variants", config.NumVariants), + zap.Int("min_layers", config.MinLayers), + zap.Int("max_layers", config.MaxLayers), + zap.Int("sharing_percent", config.LayerSharingPercent), + zap.Int("concurrency", config.Concurrency), + ) + + // Create generator and run + generator := imagegen.NewGenerator(config, logger) + manifest, err := generator.Generate() + if err != nil { + logger.Fatal("Image generation failed", zap.Error(err)) + } + + // Write manifest + if err := manifest.WriteManifest(*output); err != nil { + logger.Fatal("Failed to write manifest", zap.Error(err)) + } + + logger.Info("Image generation complete", + zap.String("manifest", *output), + zap.Int("total_images", manifest.Statistics.TotalImages), + zap.Int64("total_size_bytes", manifest.Statistics.TotalSize), + zap.Int("images_with_sharing", manifest.Statistics.ImagesWithSharing), + ) + + // Print summary + fmt.Println() + fmt.Println("=== Generation Summary ===") + fmt.Printf("Total images: %d\n", manifest.Statistics.TotalImages) + fmt.Printf("Total size: %s\n", formatBytes(manifest.Statistics.TotalSize)) + fmt.Printf("Average image size: %s\n", formatBytes(manifest.Statistics.AverageSize)) + fmt.Printf("Total layers: %d\n", manifest.Statistics.TotalLayers) + fmt.Printf("Average layer size: %s\n", formatBytes(manifest.Statistics.AverageLayerSize)) + fmt.Printf("Images with sharing: %d\n", manifest.Statistics.ImagesWithSharing) + fmt.Printf("Manifest written to: %s\n", *output) +} + +func validateConfig(config imagegen.Config) error { + if config.NumVariants < 1 { + return fmt.Errorf("variants must be at least 1") + } + if config.MinLayers < 1 { + return fmt.Errorf("min-layers must be at least 1") + } + if config.MaxLayers < config.MinLayers { + return fmt.Errorf("max-layers must be >= min-layers") + } + if config.LayerSharingPercent < 0 || config.LayerSharingPercent > 100 { + return fmt.Errorf("sharing must be between 0 and 100") + } + if config.Concurrency < 1 { + return fmt.Errorf("concurrency must be at least 1") + } + if config.BaseImage == "" { + return fmt.Errorf("base image cannot be empty") + } + if config.TargetRegistry == "" { + return fmt.Errorf("registry cannot be empty") + } + return nil +} + +func formatBytes(bytes int64) string { + const ( + KB = 1024 + MB = KB * 1024 + GB = MB * 1024 + ) + + switch { + case bytes >= GB: + return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB)) + case bytes >= MB: + return fmt.Sprintf("%.2f MB", float64(bytes)/float64(MB)) + case bytes >= KB: + return fmt.Sprintf("%.2f KB", float64(bytes)/float64(KB)) + default: + return fmt.Sprintf("%d B", bytes) + } +} diff --git a/go.mod b/go.mod index c6e2145..e76321a 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/gin-contrib/zap v1.1.6 github.com/gin-gonic/gin v1.11.0 github.com/google/go-containerregistry v0.20.7 + github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/env v1.1.0 @@ -23,12 +24,14 @@ require ( go.uber.org/zap v1.27.1 golang.org/x/sync v0.19.0 golang.org/x/sys v0.40.0 + k8s.io/api v0.30.0 + k8s.io/apimachinery v0.30.0 + k8s.io/client-go v0.30.0 + k8s.io/klog/v2 v2.120.1 ) require ( github.com/KyleBanks/depth v1.2.1 // indirect - github.com/PuerkitoBio/purell v1.1.1 // indirect - github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.14.2 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect @@ -41,19 +44,25 @@ require ( github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect - github.com/go-openapi/jsonpointer v0.19.5 // indirect - github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/spec v0.20.4 // indirect - github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-openapi/swag v0.22.3 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/imdario/mergo v0.3.6 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -61,13 +70,14 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mailru/easyjson v0.7.6 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -87,9 +97,18 @@ require ( golang.org/x/crypto v0.47.0 // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect + golang.org/x/term v0.39.0 // indirect golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.3.0 // indirect golang.org/x/tools v0.40.0 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect ) diff --git a/go.sum b/go.sum index e2db8f9..0cd4751 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,6 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/alicebob/miniredis/v2 v2.36.1 h1:Dvc5oAnNOr7BIfPn7tF269U8DvRW1dBG2D5n0WrfYMI= github.com/alicebob/miniredis/v2 v2.36.1/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= @@ -43,6 +41,8 @@ github.com/drone/envsubst v1.0.3 h1:PCIBwNDYjs50AsLZPYdfhSATKaRg/FJmDc2D6+C2x8g= github.com/drone/envsubst v1.0.3/go.mod h1:N2jZmlMufstn1KEqvbHjw40h1KyTmnVzHcSc9bFiJ2g= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= @@ -53,16 +53,21 @@ github.com/gin-contrib/zap v1.1.6 h1:TZcXi1UR8QG6OO4rewIMth7SmweyZuCeuUyJohpRcXg github.com/gin-contrib/zap v1.1.6/go.mod h1:V/sSE4Rf6ptzsEW4vj1KpUUV8ptJSVdE1nqsX9HQ1II= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -71,20 +76,35 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I= github.com/google/go-containerregistry v0.20.7/go.mod h1:Lx5LCZQjLH1QBaMPeGwsME9biPeo1lPx6lbGj/UmzgM= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -93,6 +113,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -108,6 +130,7 @@ github.com/knadh/koanf/providers/rawbytes v1.0.0/go.mod h1:KxwYJf1uezTKy6PBtfE+m github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM= github.com/knadh/koanf/v2 v2.3.0/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -118,8 +141,9 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= @@ -133,7 +157,13 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/onsi/ginkgo/v2 v2.15.0 h1:79HwNRBAZHOEwrczrgSOPy+eFTTlIGELKy5as+ClttY= +github.com/onsi/ginkgo/v2 v2.15.0/go.mod h1:HlxMHtYF57y6Dpf+mc5529KKmSq9h2FpCF+/ZkwUxKM= +github.com/onsi/gomega v1.31.0 h1:54UJxxj6cPInHS3a35wm6BK/F9nHYueZ1NVujHDrnXE= +github.com/onsi/gomega v1.31.0/go.mod h1:DW9aCi7U6Yi40wNVAvT6kzFnEVEI5n3DloYBiKiT6zk= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -168,6 +198,7 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -184,6 +215,8 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= @@ -200,24 +233,37 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -231,6 +277,8 @@ golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -238,12 +286,19 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -251,7 +306,10 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -260,3 +318,21 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= +k8s.io/api v0.30.0 h1:siWhRq7cNjy2iHssOB9SCGNCl2spiF1dO3dABqZ8niA= +k8s.io/api v0.30.0/go.mod h1:OPlaYhoHs8EQ1ql0R/TsUgaRPhpKNxIMrKQfWUp8QSE= +k8s.io/apimachinery v0.30.0 h1:qxVPsyDM5XS96NIh9Oj6LavoVFYff/Pon9cZeDIkHHA= +k8s.io/apimachinery v0.30.0/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= +k8s.io/client-go v0.30.0 h1:sB1AGGlhY/o7KCyCEQ0bPWzYDL0pwOZO4vAtTSh/gJQ= +k8s.io/client-go v0.30.0/go.mod h1:g7li5O5256qe6TYdAMyX/otJqMhIiGgTapdLchhmOaY= +k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= +k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/hack/e2e/config/barnacle.yaml b/hack/e2e/config/barnacle.yaml new file mode 100644 index 0000000..bb5f363 --- /dev/null +++ b/hack/e2e/config/barnacle.yaml @@ -0,0 +1,28 @@ +server: + port: 8080 + +redis: + addr: redis.barnacle-e2e.svc.cluster.local:6379 + +cache: + memory: + tagLimit: 10000 + manifestMemoryLimitMi: 100 + tagTTL: 5m + disk: + tiers: + - tier: 0 + path: /var/cache/barnacle/hot + sizeLimit: 2Gi + - tier: 1 + path: /var/cache/barnacle/cold + sizeLimit: 5Gi + descriptorLimit: 10000 + +rebalance: + enabled: true + +upstreams: + local: + registry: registry.barnacle-e2e.svc.cluster.local:5000 + insecure: true diff --git a/hack/e2e/manifests/barnacle/configmap.yaml b/hack/e2e/manifests/barnacle/configmap.yaml new file mode 100644 index 0000000..4334cb7 --- /dev/null +++ b/hack/e2e/manifests/barnacle/configmap.yaml @@ -0,0 +1,38 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: barnacle-config + namespace: barnacle-e2e + labels: + app.kubernetes.io/name: barnacle + app.kubernetes.io/part-of: barnacle-e2e +data: + barnacle.yaml: | + server: + port: 8080 + + redis: + addr: redis.barnacle-e2e.svc.cluster.local:6379 + + cache: + memory: + tagLimit: 10000 + manifestMemoryLimitMi: 100 + tagTTL: 5m + disk: + tiers: + - tier: 0 + path: /var/cache/barnacle/hot + sizeLimit: 2Gi + - tier: 1 + path: /var/cache/barnacle/cold + sizeLimit: 5Gi + descriptorLimit: 10000 + + rebalance: + enabled: true + + upstreams: + local: + registry: registry.barnacle-e2e.svc.cluster.local:5000 + insecure: true diff --git a/hack/e2e/manifests/barnacle/deployment.yaml b/hack/e2e/manifests/barnacle/deployment.yaml new file mode 100644 index 0000000..499af1a --- /dev/null +++ b/hack/e2e/manifests/barnacle/deployment.yaml @@ -0,0 +1,73 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: barnacle + namespace: barnacle-e2e + labels: + app.kubernetes.io/name: barnacle + app.kubernetes.io/part-of: barnacle-e2e +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: barnacle + template: + metadata: + labels: + app.kubernetes.io/name: barnacle + app.kubernetes.io/part-of: barnacle-e2e + spec: + containers: + - name: barnacle + image: barnacle:e2e + imagePullPolicy: Always + args: + - serve + - --configDir + - /etc/barnacle + ports: + - containerPort: 8080 + name: http + env: + - name: BARNACLE_NODE_ID + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: DEVELOPMENT + value: "true" + resources: + requests: + memory: "256Mi" + cpu: "200m" + limits: + memory: "512Mi" + volumeMounts: + - name: config + mountPath: /etc/barnacle + readOnly: true + - name: cache-hot + mountPath: /var/cache/barnacle/hot + - name: cache-cold + mountPath: /var/cache/barnacle/cold + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + volumes: + - name: config + configMap: + name: barnacle-config + - name: cache-hot + emptyDir: + sizeLimit: 2Gi + - name: cache-cold + emptyDir: + sizeLimit: 5Gi diff --git a/hack/e2e/manifests/barnacle/ingress.yaml b/hack/e2e/manifests/barnacle/ingress.yaml new file mode 100644 index 0000000..235d428 --- /dev/null +++ b/hack/e2e/manifests/barnacle/ingress.yaml @@ -0,0 +1,28 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: barnacle + namespace: barnacle-e2e + labels: + app.kubernetes.io/name: barnacle + app.kubernetes.io/part-of: barnacle-e2e + annotations: + # Allow large image pulls (10GB max) + nginx.ingress.kubernetes.io/proxy-body-size: "10g" + # Increase timeouts for large image operations + nginx.ingress.kubernetes.io/proxy-read-timeout: "600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "600" + nginx.ingress.kubernetes.io/proxy-connect-timeout: "600" +spec: + ingressClassName: nginx + rules: + - host: barnacle.test + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: barnacle + port: + number: 8080 diff --git a/hack/e2e/manifests/barnacle/kustomization.yaml b/hack/e2e/manifests/barnacle/kustomization.yaml new file mode 100644 index 0000000..f1cc404 --- /dev/null +++ b/hack/e2e/manifests/barnacle/kustomization.yaml @@ -0,0 +1,10 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: barnacle-e2e + +resources: + - configmap.yaml + - deployment.yaml + - service.yaml + - ingress.yaml diff --git a/hack/e2e/manifests/barnacle/service.yaml b/hack/e2e/manifests/barnacle/service.yaml new file mode 100644 index 0000000..7c32cc9 --- /dev/null +++ b/hack/e2e/manifests/barnacle/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: barnacle + namespace: barnacle-e2e + labels: + app.kubernetes.io/name: barnacle + app.kubernetes.io/part-of: barnacle-e2e +spec: + selector: + app.kubernetes.io/name: barnacle + ports: + - port: 8080 + targetPort: 8080 + nodePort: 30080 + name: http + type: NodePort diff --git a/hack/e2e/manifests/ingress-dns/kube-ingress-dns-minikube.yaml b/hack/e2e/manifests/ingress-dns/kube-ingress-dns-minikube.yaml new file mode 100644 index 0000000..a1980d8 --- /dev/null +++ b/hack/e2e/manifests/ingress-dns/kube-ingress-dns-minikube.yaml @@ -0,0 +1,39 @@ +apiVersion: v1 +kind: Pod +metadata: + labels: + app: minikube-ingress-dns + app.kubernetes.io/part-of: kube-system + name: kube-ingress-dns-minikube + namespace: kube-system +spec: + containers: + - env: + - name: DNS_PORT + value: "53" + - name: POD_IP + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: status.podIP + image: docker.io/kicbase/minikube-ingress-dns:0.0.4@sha256:d7c3fd25a0ea8fa62d4096eda202b3fc69d994b01ed6ab431def629f16ba1a89 + imagePullPolicy: IfNotPresent + name: minikube-ingress-dns + ports: + - containerPort: 53 + hostPort: 53 + protocol: UDP + volumeMounts: + - mountPath: /config + name: minikube-ingress-dns-config-volume + dnsPolicy: ClusterFirst + enableServiceLinks: true + hostNetwork: true + serviceAccount: minikube-ingress-dns + nodeSelector: + minikube.k8s.io/primary: "true" + volumes: + - configMap: + defaultMode: 420 + name: minikube-ingress-dns + name: minikube-ingress-dns-config-volume diff --git a/hack/e2e/manifests/kustomization.yaml b/hack/e2e/manifests/kustomization.yaml new file mode 100644 index 0000000..70e1365 --- /dev/null +++ b/hack/e2e/manifests/kustomization.yaml @@ -0,0 +1,17 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: barnacle-e2e + +resources: +- namespace.yaml +- redis +- registry +- barnacle + +# Image configuration - newName will be set by kustomize edit +# Example: kustomize edit set image barnacle:e2e=192.168.49.2:30500/barnacle:e2e +images: +- name: barnacle + newName: 192.168.49.2:30500/barnacle + newTag: e2e diff --git a/hack/e2e/manifests/namespace.yaml b/hack/e2e/manifests/namespace.yaml new file mode 100644 index 0000000..8076f8e --- /dev/null +++ b/hack/e2e/manifests/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: barnacle-e2e + labels: + app.kubernetes.io/name: barnacle-e2e + app.kubernetes.io/part-of: barnacle diff --git a/hack/e2e/manifests/redis/deployment.yaml b/hack/e2e/manifests/redis/deployment.yaml new file mode 100644 index 0000000..b0436c6 --- /dev/null +++ b/hack/e2e/manifests/redis/deployment.yaml @@ -0,0 +1,45 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + namespace: barnacle-e2e + labels: + app.kubernetes.io/name: redis + app.kubernetes.io/part-of: barnacle-e2e +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: redis + template: + metadata: + labels: + app.kubernetes.io/name: redis + app.kubernetes.io/part-of: barnacle-e2e + spec: + containers: + - name: redis + image: redis:7-alpine + ports: + - containerPort: 6379 + name: redis + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + readinessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 10 + periodSeconds: 10 diff --git a/hack/e2e/manifests/redis/kustomization.yaml b/hack/e2e/manifests/redis/kustomization.yaml new file mode 100644 index 0000000..7bfb5c9 --- /dev/null +++ b/hack/e2e/manifests/redis/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: barnacle-e2e + +resources: + - deployment.yaml + - service.yaml diff --git a/hack/e2e/manifests/redis/service.yaml b/hack/e2e/manifests/redis/service.yaml new file mode 100644 index 0000000..38a8d96 --- /dev/null +++ b/hack/e2e/manifests/redis/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: barnacle-e2e + labels: + app.kubernetes.io/name: redis + app.kubernetes.io/part-of: barnacle-e2e +spec: + selector: + app.kubernetes.io/name: redis + ports: + - port: 6379 + targetPort: 6379 + name: redis + type: ClusterIP diff --git a/hack/e2e/manifests/registry/deployment.yaml b/hack/e2e/manifests/registry/deployment.yaml new file mode 100644 index 0000000..0f2ad76 --- /dev/null +++ b/hack/e2e/manifests/registry/deployment.yaml @@ -0,0 +1,53 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: registry + namespace: barnacle-e2e + labels: + app.kubernetes.io/name: registry + app.kubernetes.io/part-of: barnacle-e2e +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: registry + template: + metadata: + labels: + app.kubernetes.io/name: registry + app.kubernetes.io/part-of: barnacle-e2e + spec: + containers: + - name: registry + image: registry:2 + ports: + - containerPort: 5000 + name: registry + env: + - name: REGISTRY_STORAGE_DELETE_ENABLED + value: "true" + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + volumeMounts: + - name: registry-data + mountPath: /var/lib/registry + readinessProbe: + httpGet: + path: /v2/ + port: 5000 + initialDelaySeconds: 5 + periodSeconds: 5 + livenessProbe: + httpGet: + path: /v2/ + port: 5000 + initialDelaySeconds: 10 + periodSeconds: 10 + volumes: + - name: registry-data + emptyDir: + sizeLimit: 150Gi diff --git a/hack/e2e/manifests/registry/ingress.yaml b/hack/e2e/manifests/registry/ingress.yaml new file mode 100644 index 0000000..830a375 --- /dev/null +++ b/hack/e2e/manifests/registry/ingress.yaml @@ -0,0 +1,28 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: registry + namespace: barnacle-e2e + labels: + app.kubernetes.io/name: registry + app.kubernetes.io/part-of: barnacle-e2e + annotations: + # Allow large image uploads (10GB max) + nginx.ingress.kubernetes.io/proxy-body-size: "10g" + # Increase timeouts for large uploads + nginx.ingress.kubernetes.io/proxy-read-timeout: "600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "600" + nginx.ingress.kubernetes.io/proxy-connect-timeout: "600" +spec: + ingressClassName: nginx + rules: + - host: registry.test + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: registry + port: + number: 5000 diff --git a/hack/e2e/manifests/registry/kustomization.yaml b/hack/e2e/manifests/registry/kustomization.yaml new file mode 100644 index 0000000..7803bac --- /dev/null +++ b/hack/e2e/manifests/registry/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: barnacle-e2e + +resources: + - deployment.yaml + - service.yaml + - ingress.yaml diff --git a/hack/e2e/manifests/registry/service.yaml b/hack/e2e/manifests/registry/service.yaml new file mode 100644 index 0000000..16a3546 --- /dev/null +++ b/hack/e2e/manifests/registry/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: registry + namespace: barnacle-e2e + labels: + app.kubernetes.io/name: registry + app.kubernetes.io/part-of: barnacle-e2e +spec: + selector: + app.kubernetes.io/name: registry + ports: + - port: 5000 + targetPort: 5000 + nodePort: 30500 + name: registry + type: NodePort diff --git a/hack/e2e/minikube/setup.sh b/hack/e2e/minikube/setup.sh new file mode 100755 index 0000000..6c4ea36 --- /dev/null +++ b/hack/e2e/minikube/setup.sh @@ -0,0 +1,365 @@ +#!/bin/bash +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" +MANIFESTS_DIR="${SCRIPT_DIR}/../manifests" + +PROFILE_NAME="barnacle-e2e" +KUBERNETES_VERSION="v1.34.0" +CPUS=4 +MEMORY=16384 +DISK_SIZE="200g" +NODES=4 +REGISTRY_PORT=30500 + +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +check_prerequisites() { + log_info "Checking prerequisites..." + + if ! command -v minikube &> /dev/null; then + log_error "minikube is not installed. Please install minikube first." + exit 1 + fi + + if ! command -v kubectl &> /dev/null; then + log_error "kubectl is not installed. Please install kubectl first." + exit 1 + fi + + if ! command -v docker &> /dev/null; then + log_error "docker is not installed. Please install docker first." + exit 1 + fi + + if ! command -v kustomize &> /dev/null; then + log_warn "kustomize is not installed. Attempting to install..." + install_kustomize + fi + + log_info "All prerequisites satisfied." +} + +install_kustomize() { + log_info "Installing kustomize..." + + # Use the official kustomize installation script + KUSTOMIZE_VERSION="v5.4.1" + INSTALL_DIR="${HOME}/.local/bin" + mkdir -p "${INSTALL_DIR}" + + # Download and install + curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash -s -- "${KUSTOMIZE_VERSION#v}" "${INSTALL_DIR}" + + # Add to PATH for this session if needed + if [[ ":$PATH:" != *":${INSTALL_DIR}:"* ]]; then + export PATH="${INSTALL_DIR}:${PATH}" + fi + + if ! command -v kustomize &> /dev/null; then + log_error "Failed to install kustomize. Please install manually:" + log_error " curl -s 'https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh' | bash" + exit 1 + fi + + log_info "kustomize installed successfully." +} + +create_cluster() { + log_info "Creating minikube cluster with profile: ${PROFILE_NAME}" + + # Check if cluster already exists + if minikube status -p "${PROFILE_NAME}" &> /dev/null; then + log_warn "Cluster ${PROFILE_NAME} already exists. Skipping creation." + return 0 + fi + + minikube start \ + --profile="${PROFILE_NAME}" \ + --kubernetes-version="${KUBERNETES_VERSION}" \ + --cpus="${CPUS}" \ + --memory="${MEMORY}" \ + --disk-size="${DISK_SIZE}" \ + --driver=docker \ + --nodes="${NODES}" \ + --insecure-registry="192.168.0.0/16" + + log_info "Cluster created successfully." + + # Enable ingress addons + log_info "Enabling ingress addons..." + minikube addons enable ingress -p "${PROFILE_NAME}" + minikube addons enable ingress-dns -p "${PROFILE_NAME}" + log_info "Ingress addons enabled." + + # the ingress addon is actually bugged with multi-replica. We need to recreate the pod for now + minikube kubectl -p "${PROFILE_NAME}" -- delete pod -n kube-system kube-ingress-dns-minikube + minikube kubectl -p "${PROFILE_NAME}" -- apply -f "${MANIFESTS_DIR}/ingress-dns/kube-ingress-dns-minikube.yaml" + + # Wait for ingress controller to be ready + log_info "Waiting for ingress dns..." + minikube kubectl -p "${PROFILE_NAME}" -- wait --for=condition=ready --timeout=120s pod/kube-ingress-dns-minikube -n kube-system +} + +configure_ingress_dns() { + log_info "Configuring ingress DNS..." + + MINIKUBE_IP=$(minikube ip -p "${PROFILE_NAME}") + + # Check if systemd-resolved is in use + if systemctl is-active --quiet systemd-resolved; then + log_info "Configuring systemd-resolved for .test domain..." + + sudo mkdir -p /etc/systemd/resolved.conf.d + sudo tee /etc/systemd/resolved.conf.d/minikube.conf > /dev/null << EOF +[Resolve] +DNS=${MINIKUBE_IP} +Domains=~test +FallbackDNS=8.8.8.8 1.1.1.1 +DefaultRoute=false +EOF + sudo systemctl restart systemd-resolved + log_info "systemd-resolved configured." + + # Check if NetworkManager is in use + elif systemctl is-active --quiet NetworkManager; then + log_info "Configuring NetworkManager for .test domain..." + + sudo mkdir -p /etc/NetworkManager/dnsmasq.d/ + echo "server=/test/${MINIKUBE_IP}" | sudo tee /etc/NetworkManager/dnsmasq.d/minikube.conf > /dev/null + sudo systemctl restart NetworkManager + log_info "NetworkManager configured." + + else + log_warn "Could not detect DNS resolver. Please configure manually." + log_warn "Add the following to your DNS configuration:" + log_warn " DNS server: ${MINIKUBE_IP}" + log_warn " Domain: .test" + fi + + log_info "Ingress DNS configuration complete." + log_info "Registry will be available at: registry.test" +} + +deploy_registry() { + log_info "Deploying registry using kustomize..." + kubectl config use-context "${PROFILE_NAME}" + + # Apply namespace first + kubectl apply -f "${MANIFESTS_DIR}/namespace.yaml" + + # Deploy registry using kustomize + kubectl apply -k "${MANIFESTS_DIR}/registry/" + + # Wait for ingress controller to be ready + log_info "Waiting for ingress controller..." + kubectl wait --for=condition=available --timeout=120s deployment/ingress-nginx-controller -n ingress-nginx || true + + kubectl wait --for=condition=available --timeout=120s deployment/registry -n barnacle-e2e + log_info "Registry is ready." +} + +build_barnacle_image() { + log_info "Building and pushing barnacle image to in-cluster registry..." + + # Build the image and tag for localhost (Docker trusts localhost as insecure) + docker build -t "localhost:5000/barnacle:e2e" "${PROJECT_ROOT}" + + # Port-forward to the registry service (localhost is trusted by Docker) + kubectl port-forward -n barnacle-e2e svc/registry 5000:5000 & + PF_PID=$! + + # Wait for port-forward to establish + sleep 3 + + # Push via localhost + docker push "localhost:5000/barnacle:e2e" + + # Cleanup port-forward + kill $PF_PID 2>/dev/null || true + + log_info "Barnacle image pushed successfully." +} + +apply_manifests() { + log_info "Applying Kubernetes manifests using kustomize..." + + # Set kubectl context + kubectl config use-context "${PROFILE_NAME}" + + # Get registry URL for image reference + MINIKUBE_IP=$(minikube ip -p "${PROFILE_NAME}") + REGISTRY_URL="${MINIKUBE_IP}:${REGISTRY_PORT}" + + # Use kustomize to set the barnacle image with the registry URL + log_info "Setting barnacle image to: ${REGISTRY_URL}/barnacle:e2e" + pushd "${MANIFESTS_DIR}" > /dev/null + kustomize edit set image "barnacle=${REGISTRY_URL}/barnacle:e2e" + popd > /dev/null + + # Apply all manifests using kustomize + log_info "Applying manifests..." + kubectl apply -k "${MANIFESTS_DIR}" + + log_info "Manifests applied successfully." +} + +wait_for_deployments() { + log_info "Waiting for deployments to be ready..." + + kubectl config use-context "${PROFILE_NAME}" + + log_info "Waiting for redis..." + kubectl wait --for=condition=available --timeout=120s deployment/redis -n barnacle-e2e + + log_info "Waiting for barnacle..." + kubectl wait --for=condition=available --timeout=120s deployment/barnacle -n barnacle-e2e + + log_info "All deployments are ready." +} + +verify_health() { + log_info "Verifying barnacle health via ingress..." + + kubectl config use-context "${PROFILE_NAME}" + + # Wait for ingress to be ready + log_info "Waiting for barnacle ingress to be ready..." + sleep 5 + + # Check health endpoint via ingress + local retries=10 + local count=0 + while [ $count -lt $retries ]; do + if curl -sf http://barnacle.test/health > /dev/null 2>&1; then + log_info "Barnacle is healthy!" + log_info "Health verification complete." + return 0 + fi + count=$((count + 1)) + log_info "Waiting for barnacle to be reachable via ingress (attempt $count/$retries)..." + sleep 3 + done + + log_error "Barnacle health check failed after $retries attempts." + exit 1 +} + +print_status() { + log_info "Cluster status:" + echo "" + minikube status -p "${PROFILE_NAME}" + echo "" + + log_info "Pods in barnacle-e2e namespace:" + kubectl get pods -n barnacle-e2e + echo "" + + log_info "Services in barnacle-e2e namespace:" + kubectl get svc -n barnacle-e2e + echo "" + + log_info "Setup complete!" + echo "" + echo "To use this cluster:" + echo " kubectl config use-context ${PROFILE_NAME}" + echo "" + echo "Services available via ingress:" + echo " Barnacle: http://barnacle.test" + echo " Registry: http://registry.test" + echo "" + echo "Example usage:" + echo " # Check barnacle health" + echo " curl http://barnacle.test/health" + echo "" + echo " # Push image to registry" + echo " docker push registry.test/myimage:tag" + echo "" + echo " # Pull image through barnacle" + echo " docker pull barnacle.test/local/myimage:tag" +} + +teardown() { + log_info "Tearing down e2e cluster..." + + if minikube status -p "${PROFILE_NAME}" &> /dev/null; then + minikube delete -p "${PROFILE_NAME}" + log_info "Cluster deleted." + else + log_warn "Cluster ${PROFILE_NAME} does not exist." + fi +} + +usage() { + echo "Usage: $0 [command]" + echo "" + echo "Commands:" + echo " setup Create cluster, build image, and deploy (default)" + echo " build Build barnacle image only" + echo " deploy Apply manifests only" + echo " status Show cluster status" + echo " teardown Delete the cluster" + echo " help Show this help message" +} + +main() { + local command="${1:-setup}" + + case "${command}" in + setup) + check_prerequisites + create_cluster + configure_ingress_dns + deploy_registry + build_barnacle_image + apply_manifests + wait_for_deployments + verify_health + print_status + ;; + build) + check_prerequisites + build_barnacle_image + ;; + deploy) + check_prerequisites + apply_manifests + wait_for_deployments + verify_health + print_status + ;; + status) + print_status + ;; + teardown) + teardown + ;; + help|--help|-h) + usage + ;; + *) + log_error "Unknown command: ${command}" + usage + exit 1 + ;; + esac +} + +main "$@" diff --git a/internal/node/disk.go b/internal/node/disk.go index 7b1d5ae..3e22cf2 100644 --- a/internal/node/disk.go +++ b/internal/node/disk.go @@ -40,3 +40,31 @@ func GetDiskUsage(path string) (*DiskUsageStats, error) { UsedBytes: usedBytes, }, nil } + +// ApplySizeLimit adjusts DiskUsageStats to respect a configured size limit. +// If sizeLimit is 0, the stats are returned unchanged. +// If sizeLimit is set: +// - TotalBytes is set to min(actualTotal, sizeLimit) +// - UsedBytes remains unchanged (actual disk usage) +// - FreeBytes is calculated as: max(0, TotalBytes - UsedBytes) +func ApplySizeLimit(stats *DiskUsageStats, sizeLimit uint64) *DiskUsageStats { + if sizeLimit == 0 { + return stats + } + + result := *stats // Copy to avoid mutating original + + // Cap TotalBytes at the configured limit + if sizeLimit < result.TotalBytes { + result.TotalBytes = sizeLimit + } + + // Recalculate FreeBytes based on limited total + if result.UsedBytes >= result.TotalBytes { + result.FreeBytes = 0 + } else { + result.FreeBytes = result.TotalBytes - result.UsedBytes + } + + return &result +} diff --git a/internal/node/disk_test.go b/internal/node/disk_test.go index f574224..30cbda3 100644 --- a/internal/node/disk_test.go +++ b/internal/node/disk_test.go @@ -37,3 +37,89 @@ func TestGetDiskUsage(t *testing.T) { assert.NotZero(t, stats.TotalBytes) }) } + +func TestApplySizeLimit(t *testing.T) { + t.Run("zero limit returns unchanged stats", func(t *testing.T) { + stats := &node.DiskUsageStats{ + Path: "/test", + TotalBytes: 100 * 1024 * 1024 * 1024, // 100 GiB + FreeBytes: 60 * 1024 * 1024 * 1024, // 60 GiB + UsedBytes: 40 * 1024 * 1024 * 1024, // 40 GiB + } + + result := node.ApplySizeLimit(stats, 0) + + assert.Equal(t, stats.TotalBytes, result.TotalBytes) + assert.Equal(t, stats.FreeBytes, result.FreeBytes) + assert.Equal(t, stats.UsedBytes, result.UsedBytes) + }) + + t.Run("limit smaller than total caps TotalBytes and adjusts FreeBytes", func(t *testing.T) { + stats := &node.DiskUsageStats{ + Path: "/test", + TotalBytes: 100 * 1024 * 1024 * 1024, // 100 GiB + FreeBytes: 60 * 1024 * 1024 * 1024, // 60 GiB + UsedBytes: 40 * 1024 * 1024 * 1024, // 40 GiB + } + limit := uint64(50 * 1024 * 1024 * 1024) // 50 GiB + + result := node.ApplySizeLimit(stats, limit) + + assert.Equal(t, limit, result.TotalBytes) + assert.Equal(t, stats.UsedBytes, result.UsedBytes) // UsedBytes unchanged + // FreeBytes = TotalBytes - UsedBytes = 50 GiB - 40 GiB = 10 GiB + assert.Equal(t, uint64(10*1024*1024*1024), result.FreeBytes) + }) + + t.Run("limit larger than total leaves stats unchanged", func(t *testing.T) { + stats := &node.DiskUsageStats{ + Path: "/test", + TotalBytes: 100 * 1024 * 1024 * 1024, // 100 GiB + FreeBytes: 60 * 1024 * 1024 * 1024, // 60 GiB + UsedBytes: 40 * 1024 * 1024 * 1024, // 40 GiB + } + limit := uint64(200 * 1024 * 1024 * 1024) // 200 GiB (larger than actual) + + result := node.ApplySizeLimit(stats, limit) + + // TotalBytes should remain at the actual value, not increase + assert.Equal(t, stats.TotalBytes, result.TotalBytes) + assert.Equal(t, stats.UsedBytes, result.UsedBytes) + // FreeBytes = TotalBytes - UsedBytes = 100 GiB - 40 GiB = 60 GiB + assert.Equal(t, uint64(60*1024*1024*1024), result.FreeBytes) + }) + + t.Run("usage exceeds limit sets FreeBytes to 0", func(t *testing.T) { + stats := &node.DiskUsageStats{ + Path: "/test", + TotalBytes: 100 * 1024 * 1024 * 1024, // 100 GiB + FreeBytes: 60 * 1024 * 1024 * 1024, // 60 GiB + UsedBytes: 40 * 1024 * 1024 * 1024, // 40 GiB + } + limit := uint64(30 * 1024 * 1024 * 1024) // 30 GiB (less than used) + + result := node.ApplySizeLimit(stats, limit) + + assert.Equal(t, limit, result.TotalBytes) + assert.Equal(t, stats.UsedBytes, result.UsedBytes) + assert.Equal(t, uint64(0), result.FreeBytes) // No free space + }) + + t.Run("does not mutate original stats", func(t *testing.T) { + original := &node.DiskUsageStats{ + Path: "/test", + TotalBytes: 100 * 1024 * 1024 * 1024, + FreeBytes: 60 * 1024 * 1024 * 1024, + UsedBytes: 40 * 1024 * 1024 * 1024, + } + originalTotal := original.TotalBytes + originalFree := original.FreeBytes + + limit := uint64(50 * 1024 * 1024 * 1024) + node.ApplySizeLimit(original, limit) + + // Original stats should be unchanged + assert.Equal(t, originalTotal, original.TotalBytes) + assert.Equal(t, originalFree, original.FreeBytes) + }) +} diff --git a/internal/node/registry.go b/internal/node/registry.go index f77e92f..2e31ee7 100644 --- a/internal/node/registry.go +++ b/internal/node/registry.go @@ -53,10 +53,11 @@ type Info struct { // Registry manages node registration and health tracking. type Registry struct { - config *configuration.NodeHealthConfig - cacheConfig *configuration.CacheConfiguration - logger *zap.Logger - redisClient *redis.Client + config *configuration.NodeHealthConfig + cacheConfig *configuration.CacheConfiguration + tierSizeLimits []uint64 // Pre-parsed size limits per tier (0 = no limit) + logger *zap.Logger + redisClient *redis.Client nodeID string status Status @@ -81,13 +82,27 @@ func NewRegistry( nodeID = hostname } + // Pre-parse tier size limits to avoid repeated string parsing + var tierSizeLimits []uint64 + if cacheConfig != nil { + tierSizeLimits = make([]uint64, len(cacheConfig.Disk.Tiers)) + for i, tier := range cacheConfig.Disk.Tiers { + limit, err := tier.GetSizeLimitBytes() + if err != nil { + return nil, fmt.Errorf("tier %d: %w", tier.Tier, err) + } + tierSizeLimits[i] = limit + } + } + r := &Registry{ - config: config, - cacheConfig: cacheConfig, - logger: logger.Named("node-registry"), - redisClient: redisClient, - nodeID: nodeID, - status: StatusStarting, + config: config, + cacheConfig: cacheConfig, + tierSizeLimits: tierSizeLimits, + logger: logger.Named("node-registry"), + redisClient: redisClient, + nodeID: nodeID, + status: StatusStarting, } r.logger.Info("node registry initialized", @@ -140,6 +155,12 @@ func (r *Registry) collectStats() Stats { stats.TierDiskUsage[i] = DiskUsageStats{Path: tier.Path} continue } + + // Apply pre-parsed size limit if set + if i < len(r.tierSizeLimits) && r.tierSizeLimits[i] > 0 { + diskUsage = ApplySizeLimit(diskUsage, r.tierSizeLimits[i]) + } + stats.TierDiskUsage[i] = *diskUsage } diff --git a/pkg/configuration/cache.go b/pkg/configuration/cache.go index 48244ac..de268b7 100644 --- a/pkg/configuration/cache.go +++ b/pkg/configuration/cache.go @@ -3,6 +3,8 @@ package configuration import ( "fmt" "time" + + "k8s.io/apimachinery/pkg/api/resource" ) // Default disk cache settings. @@ -42,6 +44,12 @@ type DiskTierConfiguration struct { // during rebalancing. This prevents the rebalancer from filling the tier completely, // leaving headroom for new hot blobs. Defaults to 0.0 (no reservation). ReservePercent float64 `koanf:"reservePercent"` + + // SizeLimit is an optional maximum size for this tier's cache storage. + // Accepts Kubernetes-style quantities: "5Gi", "10G", "500Mi", "1Ti", etc. + // When set, capacity planning uses this value instead of the actual + // filesystem size. If empty, the actual filesystem capacity is used. + SizeLimit string `koanf:"sizeLimit"` } // GetReservePercent returns the reserve percent, using the default if not set. @@ -52,6 +60,20 @@ func (t *DiskTierConfiguration) GetReservePercent() float64 { return t.ReservePercent } +// GetSizeLimitBytes parses SizeLimit and returns the value in bytes. +// Returns 0 if SizeLimit is empty (meaning no limit). +// Returns an error if the format is invalid. +func (t *DiskTierConfiguration) GetSizeLimitBytes() (uint64, error) { + if t.SizeLimit == "" { + return 0, nil + } + q, err := resource.ParseQuantity(t.SizeLimit) + if err != nil { + return 0, fmt.Errorf("invalid sizeLimit %q: %w", t.SizeLimit, err) + } + return uint64(q.Value()), nil +} + // Validate checks that the disk tier configuration is valid. func (t *DiskTierConfiguration) Validate() error { if t.Tier < 0 { @@ -66,6 +88,12 @@ func (t *DiskTierConfiguration) Validate() error { return fmt.Errorf("%w: disk tier %d reservePercent must be >= 0 and < 1.0, got %f", ErrInvalidConfiguration, t.Tier, t.ReservePercent) } + if t.SizeLimit != "" { + if _, err := t.GetSizeLimitBytes(); err != nil { + return fmt.Errorf("%w: disk tier %d: %v", + ErrInvalidConfiguration, t.Tier, err) + } + } return nil } diff --git a/pkg/configuration/cache_test.go b/pkg/configuration/cache_test.go index 34c4769..e458bb2 100644 --- a/pkg/configuration/cache_test.go +++ b/pkg/configuration/cache_test.go @@ -37,6 +37,94 @@ func TestDiskTierConfiguration_Validate(t *testing.T) { require.Error(t, err) assert.ErrorIs(t, err, configuration.ErrInvalidConfiguration) }) + + t.Run("valid sizeLimit", func(t *testing.T) { + tier := configuration.DiskTierConfiguration{ + Tier: 0, + Path: "/var/cache/tier0", + SizeLimit: "5Gi", + } + err := tier.Validate() + assert.NoError(t, err) + }) + + t.Run("invalid sizeLimit", func(t *testing.T) { + tier := configuration.DiskTierConfiguration{ + Tier: 0, + Path: "/var/cache/tier0", + SizeLimit: "invalid", + } + err := tier.Validate() + require.Error(t, err) + assert.ErrorIs(t, err, configuration.ErrInvalidConfiguration) + }) +} + +func TestDiskTierConfiguration_GetSizeLimitBytes(t *testing.T) { + t.Run("empty sizeLimit returns 0", func(t *testing.T) { + tier := configuration.DiskTierConfiguration{ + Tier: 0, + Path: "/var/cache/tier0", + } + limit, err := tier.GetSizeLimitBytes() + require.NoError(t, err) + assert.Equal(t, uint64(0), limit) + }) + + t.Run("gibibytes", func(t *testing.T) { + tier := configuration.DiskTierConfiguration{ + Tier: 0, + Path: "/var/cache/tier0", + SizeLimit: "5Gi", + } + limit, err := tier.GetSizeLimitBytes() + require.NoError(t, err) + assert.Equal(t, uint64(5*1024*1024*1024), limit) + }) + + t.Run("mebibytes", func(t *testing.T) { + tier := configuration.DiskTierConfiguration{ + Tier: 0, + Path: "/var/cache/tier0", + SizeLimit: "500Mi", + } + limit, err := tier.GetSizeLimitBytes() + require.NoError(t, err) + assert.Equal(t, uint64(500*1024*1024), limit) + }) + + t.Run("gigabytes decimal", func(t *testing.T) { + tier := configuration.DiskTierConfiguration{ + Tier: 0, + Path: "/var/cache/tier0", + SizeLimit: "10G", + } + limit, err := tier.GetSizeLimitBytes() + require.NoError(t, err) + assert.Equal(t, uint64(10*1000*1000*1000), limit) + }) + + t.Run("plain bytes", func(t *testing.T) { + tier := configuration.DiskTierConfiguration{ + Tier: 0, + Path: "/var/cache/tier0", + SizeLimit: "1073741824", + } + limit, err := tier.GetSizeLimitBytes() + require.NoError(t, err) + assert.Equal(t, uint64(1073741824), limit) + }) + + t.Run("invalid format returns error", func(t *testing.T) { + tier := configuration.DiskTierConfiguration{ + Tier: 0, + Path: "/var/cache/tier0", + SizeLimit: "invalid", + } + _, err := tier.GetSizeLimitBytes() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid sizeLimit") + }) } func TestDiskCacheConfiguration_Validate(t *testing.T) { diff --git a/test/e2e/example_test.go b/test/e2e/example_test.go deleted file mode 100644 index 4becced..0000000 --- a/test/e2e/example_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package e2e_test - -import ( - "testing" -) - -// Example end-to-end test -// E2E tests should verify complete workflows and integrations. -func TestExample(t *testing.T) { - t.Skip("Example e2e test - implement your end-to-end tests here") - - // Example structure for e2e tests: - // 1. Setup test environment - // 2. Execute complete workflow - // 3. Verify expected outcomes - // 4. Cleanup -} diff --git a/test/e2e/framework/barnacle.go b/test/e2e/framework/barnacle.go new file mode 100644 index 0000000..80f27d4 --- /dev/null +++ b/test/e2e/framework/barnacle.go @@ -0,0 +1,185 @@ +package framework + +import ( + "context" + "fmt" + "io" + "net/http" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "go.uber.org/zap" +) + +// Barnacle provides helpers for interacting with the barnacle deployment. +type Barnacle struct { + cluster *Cluster + serviceName string + port int + ingressHost string + nodeAddress string + logger *zap.Logger +} + +// NewBarnacle creates a new barnacle helper. +func NewBarnacle(cluster *Cluster, serviceName string, logger *zap.Logger) *Barnacle { + return &Barnacle{ + cluster: cluster, + serviceName: serviceName, + port: 8080, + logger: logger, + } +} + +// SetIngressHost sets the ingress hostname for external access. +func (b *Barnacle) SetIngressHost(host string) { + b.ingressHost = host +} + +// SetNodeAddress sets the node address (ip:port) for kubelet access. +// This is required because kubelet cannot resolve Kubernetes service DNS names. +func (b *Barnacle) SetNodeAddress(addr string) { + b.nodeAddress = addr +} + +// IngressURL returns the external URL for barnacle via ingress. +// Returns empty string if ingress host is not configured. +func (b *Barnacle) IngressURL() string { + if b.ingressHost == "" { + return "" + } + return fmt.Sprintf("http://%s", b.ingressHost) +} + +// ServiceURL returns the in-cluster URL for the barnacle service. +func (b *Barnacle) ServiceURL() string { + return fmt.Sprintf("http://%s.%s.svc.cluster.local:%d", + b.serviceName, + b.cluster.Namespace(), + b.port, + ) +} + +// ImageURL returns the URL for pulling an image through barnacle. +// If nodeAddress is set, it uses that (required for kubelet access since +// service DNS only works inside pods). Otherwise falls back to service DNS. +func (b *Barnacle) ImageURL(upstream, imageName, tag string) string { + var host string + if b.nodeAddress != "" { + host = b.nodeAddress + } else { + host = b.serviceName + "." + b.cluster.Namespace() + ".svc.cluster.local:" + fmt.Sprint(b.port) + } + return fmt.Sprintf("%s/%s/%s:%s", host, upstream, imageName, tag) +} + +// WaitForReady waits for the barnacle deployment to be ready. +func (b *Barnacle) WaitForReady(ctx context.Context, timeout time.Duration) error { + b.logger.Info("Waiting for barnacle deployment to be ready", + zap.String("deployment", b.serviceName), + zap.Duration("timeout", timeout), + ) + + if err := b.cluster.WaitForDeploymentReady(ctx, b.serviceName, timeout); err != nil { + return fmt.Errorf("barnacle deployment not ready: %w", err) + } + + b.logger.Info("Barnacle deployment is ready") + return nil +} + +// CheckHealth performs a health check against barnacle. +// If an ingress host is configured, it uses the ingress URL. +// Otherwise, it falls back to the provided local port (for port-forward scenarios). +func (b *Barnacle) CheckHealth(ctx context.Context, localPort int) error { + var url string + if b.ingressHost != "" { + url = fmt.Sprintf("http://%s/healthz", b.ingressHost) + b.logger.Debug("Checking health via ingress", zap.String("url", url)) + } else { + url = fmt.Sprintf("http://localhost:%d/healthz", localPort) + b.logger.Debug("Checking health via localhost", zap.String("url", url)) + } + + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("health check failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("health check returned %d: %s", resp.StatusCode, string(body)) + } + + b.logger.Info("Barnacle health check passed", zap.String("url", url)) + return nil +} + +// CheckHealthViaIngress performs a health check against barnacle via the ingress. +// Returns an error if ingress host is not configured. +func (b *Barnacle) CheckHealthViaIngress(ctx context.Context) error { + if b.ingressHost == "" { + return fmt.Errorf("ingress host not configured") + } + + url := fmt.Sprintf("http://%s/healthz", b.ingressHost) + b.logger.Debug("Checking health via ingress", zap.String("url", url)) + + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("health check via ingress failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("health check returned %d: %s", resp.StatusCode, string(body)) + } + + b.logger.Info("Barnacle health check via ingress passed") + return nil +} + +// GetPods returns the pods for the barnacle deployment. +func (b *Barnacle) GetPods(ctx context.Context) ([]string, error) { + pods, err := b.cluster.ListPods(ctx, "app.kubernetes.io/name="+b.serviceName) + if err != nil { + return nil, fmt.Errorf("failed to list barnacle pods: %w", err) + } + + var names []string + for _, pod := range pods.Items { + names = append(names, pod.Name) + } + + return names, nil +} + +// GetReplicaCount returns the number of ready barnacle replicas. +func (b *Barnacle) GetReplicaCount(ctx context.Context) (int, error) { + deployment, err := b.cluster.Clientset().AppsV1().Deployments(b.cluster.Namespace()).Get( + ctx, b.serviceName, metav1.GetOptions{}) + if err != nil { + return 0, fmt.Errorf("failed to get deployment: %w", err) + } + + return int(deployment.Status.ReadyReplicas), nil +} + +// RegistryServiceURL returns the in-cluster URL for the local registry. +func (b *Barnacle) RegistryServiceURL() string { + return fmt.Sprintf("registry.%s.svc.cluster.local:5000", b.cluster.Namespace()) +} diff --git a/test/e2e/framework/cluster.go b/test/e2e/framework/cluster.go new file mode 100644 index 0000000..6a97e60 --- /dev/null +++ b/test/e2e/framework/cluster.go @@ -0,0 +1,203 @@ +package framework + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +// Cluster provides operations for interacting with the Kubernetes cluster. +type Cluster struct { + clientset *kubernetes.Clientset + config *rest.Config + namespace string +} + +// NewCluster creates a new cluster client. +func NewCluster(kubeContext, namespace string, kubeQPS float32, kubeBurst int) (*Cluster, error) { + config, err := buildConfig(kubeContext) + if err != nil { + return nil, fmt.Errorf("failed to build kubernetes config: %w", err) + } + + config.QPS = kubeQPS + config.Burst = kubeBurst + + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create kubernetes client: %w", err) + } + + return &Cluster{ + clientset: clientset, + config: config, + namespace: namespace, + }, nil +} + +func buildConfig(context string) (*rest.Config, error) { + // Try in-cluster config first + if config, err := rest.InClusterConfig(); err == nil { + return config, nil + } + + // Fall back to kubeconfig + kubeconfigPath := os.Getenv("KUBECONFIG") + if kubeconfigPath == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to get home directory: %w", err) + } + kubeconfigPath = filepath.Join(home, ".kube", "config") + } + + loadingRules := &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfigPath} + configOverrides := &clientcmd.ConfigOverrides{} + if context != "" { + configOverrides.CurrentContext = context + } + + return clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + loadingRules, + configOverrides, + ).ClientConfig() +} + +// Clientset returns the Kubernetes clientset. +func (c *Cluster) Clientset() *kubernetes.Clientset { + return c.clientset +} + +// Namespace returns the configured namespace. +func (c *Cluster) Namespace() string { + return c.namespace +} + +// CreatePod creates a pod in the cluster. +func (c *Cluster) CreatePod(ctx context.Context, pod *corev1.Pod) (*corev1.Pod, error) { + return c.clientset.CoreV1().Pods(c.namespace).Create(ctx, pod, metav1.CreateOptions{}) +} + +// GetPod retrieves a pod by name. +func (c *Cluster) GetPod(ctx context.Context, name string) (*corev1.Pod, error) { + return c.clientset.CoreV1().Pods(c.namespace).Get(ctx, name, metav1.GetOptions{}) +} + +// DeletePod deletes a pod by name. +func (c *Cluster) DeletePod(ctx context.Context, name string) error { + return c.clientset.CoreV1().Pods(c.namespace).Delete(ctx, name, metav1.DeleteOptions{}) +} + +// WaitForPodComplete waits for a pod to complete (either succeed or fail). +func (c *Cluster) WaitForPodComplete(ctx context.Context, name string, timeout time.Duration) (*corev1.Pod, error) { + var resultPod *corev1.Pod + + err := wait.PollUntilContextTimeout(ctx, time.Second, timeout, true, func(ctx context.Context) (bool, error) { + pod, err := c.GetPod(ctx, name) + if err != nil { + return false, err + } + + resultPod = pod + + switch pod.Status.Phase { + case corev1.PodSucceeded, corev1.PodFailed: + return true, nil + case corev1.PodPending, corev1.PodRunning: + return false, nil + default: + return false, fmt.Errorf("unexpected pod phase: %s", pod.Status.Phase) + } + }) + + if err != nil { + return resultPod, fmt.Errorf("waiting for pod %s: %w", name, err) + } + + return resultPod, nil +} + +// WaitForPodRunning waits for a pod to be in running state. +func (c *Cluster) WaitForPodRunning(ctx context.Context, name string, timeout time.Duration) (*corev1.Pod, error) { + var resultPod *corev1.Pod + + err := wait.PollUntilContextTimeout(ctx, time.Second, timeout, true, func(ctx context.Context) (bool, error) { + pod, err := c.GetPod(ctx, name) + if err != nil { + return false, err + } + + resultPod = pod + + switch pod.Status.Phase { + case corev1.PodRunning: + return true, nil + case corev1.PodSucceeded, corev1.PodFailed: + return false, fmt.Errorf("pod completed unexpectedly with phase: %s", pod.Status.Phase) + case corev1.PodPending: + return false, nil + default: + return false, fmt.Errorf("unexpected pod phase: %s", pod.Status.Phase) + } + }) + + if err != nil { + return resultPod, fmt.Errorf("waiting for pod %s: %w", name, err) + } + + return resultPod, nil +} + +// GetService retrieves a service by name. +func (c *Cluster) GetService(ctx context.Context, name string) (*corev1.Service, error) { + return c.clientset.CoreV1().Services(c.namespace).Get(ctx, name, metav1.GetOptions{}) +} + +// ListPods lists pods matching a label selector. +func (c *Cluster) ListPods(ctx context.Context, labelSelector string) (*corev1.PodList, error) { + return c.clientset.CoreV1().Pods(c.namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + }) +} + +// DeletePodsByLabel deletes all pods matching a label selector. +func (c *Cluster) DeletePodsByLabel(ctx context.Context, labelSelector string) error { + return c.clientset.CoreV1().Pods(c.namespace).DeleteCollection(ctx, + metav1.DeleteOptions{}, + metav1.ListOptions{LabelSelector: labelSelector}, + ) +} + +// WaitForDeploymentReady waits for a deployment to have all replicas ready. +func (c *Cluster) WaitForDeploymentReady(ctx context.Context, name string, timeout time.Duration) error { + return wait.PollUntilContextTimeout(ctx, time.Second, timeout, true, func(ctx context.Context) (bool, error) { + deployment, err := c.clientset.AppsV1().Deployments(c.namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, err + } + + if deployment.Status.ReadyReplicas == *deployment.Spec.Replicas { + return true, nil + } + + return false, nil + }) +} + +// CheckNamespaceExists checks if the namespace exists. +func (c *Cluster) CheckNamespaceExists(ctx context.Context) (bool, error) { + _, err := c.clientset.CoreV1().Namespaces().Get(ctx, c.namespace, metav1.GetOptions{}) + if err != nil { + return false, nil + } + return true, nil +} diff --git a/test/e2e/framework/framework.go b/test/e2e/framework/framework.go new file mode 100644 index 0000000..94abb62 --- /dev/null +++ b/test/e2e/framework/framework.go @@ -0,0 +1,155 @@ +package framework + +import ( + "context" + "fmt" + "time" + + "github.com/pdylanross/barnacle/test/e2e/imagegen" + "go.uber.org/zap" +) + +// Framework is the main orchestrator for e2e tests. +type Framework struct { + options Options + cluster *Cluster + barnacle *Barnacle + manifest *imagegen.ImageManifest + logger *zap.Logger +} + +// New creates a new test framework. +func New(options Options, logger *zap.Logger) (*Framework, error) { + if err := options.Validate(); err != nil { + return nil, fmt.Errorf("invalid options: %w", err) + } + + if logger == nil { + var err error + logger, err = zap.NewProduction() + if err != nil { + return nil, fmt.Errorf("failed to create logger: %w", err) + } + } + + return &Framework{ + options: options, + logger: logger, + }, nil +} + +// Setup initializes the framework by connecting to the cluster and verifying barnacle is ready. +func (f *Framework) Setup(ctx context.Context) error { + f.logger.Info("Setting up e2e test framework", + zap.String("context", f.options.KubeContext), + zap.String("namespace", f.options.Namespace), + ) + + // Connect to cluster + cluster, err := NewCluster(f.options.KubeContext, f.options.Namespace, f.options.KubeQPS, f.options.KubeBurst) + if err != nil { + return fmt.Errorf("failed to connect to cluster: %w", err) + } + f.cluster = cluster + + // Verify namespace exists + exists, err := cluster.CheckNamespaceExists(ctx) + if err != nil { + return fmt.Errorf("failed to check namespace: %w", err) + } + if !exists { + return fmt.Errorf("namespace %s does not exist", f.options.Namespace) + } + + // Create barnacle helper + f.barnacle = NewBarnacle(cluster, f.options.BarnacleService, f.logger) + if f.options.BarnacleIngressHost != "" { + f.barnacle.SetIngressHost(f.options.BarnacleIngressHost) + } + if f.options.BarnacleNodeAddress != "" { + f.barnacle.SetNodeAddress(f.options.BarnacleNodeAddress) + } + + // Wait for barnacle to be ready + if err := f.barnacle.WaitForReady(ctx, 2*time.Minute); err != nil { + return fmt.Errorf("barnacle not ready: %w", err) + } + + f.logger.Info("Framework setup complete") + return nil +} + +// LoadImageManifest loads the image manifest from the specified path. +func (f *Framework) LoadImageManifest() error { + f.logger.Info("Loading image manifest", zap.String("path", f.options.ManifestPath)) + + manifest, err := imagegen.LoadManifest(f.options.ManifestPath) + if err != nil { + return fmt.Errorf("failed to load manifest: %w", err) + } + + f.manifest = manifest + + f.logger.Info("Loaded image manifest", + zap.Int("total_images", manifest.Statistics.TotalImages), + zap.Int64("total_size", manifest.Statistics.TotalSize), + ) + + return nil +} + +// Manifest returns the loaded image manifest. +func (f *Framework) Manifest() *imagegen.ImageManifest { + return f.manifest +} + +// Cluster returns the cluster client. +func (f *Framework) Cluster() *Cluster { + return f.cluster +} + +// Barnacle returns the barnacle helper. +func (f *Framework) Barnacle() *Barnacle { + return f.barnacle +} + +// Options returns the framework options. +func (f *Framework) Options() Options { + return f.options +} + +// Logger returns the framework logger. +func (f *Framework) Logger() *zap.Logger { + return f.logger +} + +// Teardown cleans up resources created during the test. +func (f *Framework) Teardown(ctx context.Context) error { + f.logger.Info("Tearing down e2e test framework") + + if f.options.DeletePods && f.cluster != nil { + f.logger.Info("Cleaning up test pods") + if err := f.cluster.DeletePodsByLabel(ctx, "app.kubernetes.io/part-of=barnacle-e2e-test"); err != nil { + f.logger.Warn("Failed to delete test pods", zap.Error(err)) + } + } + + f.logger.Info("Framework teardown complete") + return nil +} + +// GetImageForIteration returns the image entry to use for a given iteration. +// Uses round-robin selection across all available images. +func (f *Framework) GetImageForIteration(iteration int) imagegen.ImageEntry { + if f.manifest == nil || len(f.manifest.Images) == 0 { + return imagegen.ImageEntry{} + } + + idx := iteration % len(f.manifest.Images) + return f.manifest.Images[idx] +} + +// BuildPodImageRef builds the full image reference for pulling through barnacle. +func (f *Framework) BuildPodImageRef(image imagegen.ImageEntry) string { + return f.barnacle.ImageURL(f.options.UpstreamName, image.Name, image.Tag) +} diff --git a/test/e2e/framework/options.go b/test/e2e/framework/options.go new file mode 100644 index 0000000..7b3fdc4 --- /dev/null +++ b/test/e2e/framework/options.go @@ -0,0 +1,124 @@ +package framework + +import ( + "time" +) + +// Options holds the configuration for e2e tests. +type Options struct { + // Workers is the number of concurrent workers pulling images + Workers int + + // Iterations is the total number of image pulls to perform + Iterations int + + // Timeout is the maximum time to wait for a single pod operation + Timeout time.Duration + + // KubeContext is the Kubernetes context to use + KubeContext string + + // Namespace is the Kubernetes namespace where barnacle is deployed + Namespace string + + // BarnacleService is the name of the barnacle service + BarnacleService string + + // ManifestPath is the path to the image manifest JSON file + ManifestPath string + + // ResultsPath is the directory path for test output (report.json, failed-pod-events.json) + ResultsPath string + + // PodImage is the container image used for worker pods + PodImage string + + // UpstreamName is the barnacle upstream name to use + UpstreamName string + + // KubeQPS is the maximum queries per second to the K8s API server + KubeQPS float32 + + // KubeBurst is the maximum burst for throttle to the K8s API server + KubeBurst int + + // DeletePods controls whether to delete pods after completion + DeletePods bool + + // Verbose enables verbose logging + Verbose bool + + // BarnacleIngressHost is the ingress hostname for barnacle (e.g., "barnacle.test") + // If set, external health checks will use this instead of port-forwarding + BarnacleIngressHost string + + // RegistryIngressHost is the ingress hostname for the registry (e.g., "registry.test") + // If set, external registry access will use this instead of port-forwarding + RegistryIngressHost string + + // BarnacleNodeAddress is the node address for barnacle (e.g., "192.168.49.2:30080") + // This is required for kubelet to pull images since service DNS only works inside pods. + // Use minikube ip to get the node IP, combined with the NodePort (30080). + BarnacleNodeAddress string +} + +// DefaultOptions returns the default options for e2e tests. +func DefaultOptions() Options { + return Options{ + Workers: 10, + Iterations: 10000, + Timeout: 5 * time.Minute, + KubeContext: "barnacle-e2e", + Namespace: "barnacle-e2e", + BarnacleService: "barnacle", + ManifestPath: "e2e-images.json", + ResultsPath: "", + PodImage: "busybox:latest", + UpstreamName: "local", + KubeQPS: 20000, + KubeBurst: 40000, + DeletePods: true, + Verbose: false, + BarnacleIngressHost: "barnacle.test", + RegistryIngressHost: "registry.test", + } +} + +// Validate validates the options. +func (o *Options) Validate() error { + if o.Workers < 1 { + return ErrInvalidWorkers + } + if o.Iterations < 1 { + return ErrInvalidIterations + } + if o.Timeout < time.Second { + return ErrInvalidTimeout + } + if o.Namespace == "" { + return ErrInvalidNamespace + } + if o.BarnacleService == "" { + return ErrInvalidService + } + if o.ManifestPath == "" { + return ErrInvalidManifestPath + } + return nil +} + +// Error types for validation. +var ( + ErrInvalidWorkers = validationError("workers must be at least 1") + ErrInvalidIterations = validationError("iterations must be at least 1") + ErrInvalidTimeout = validationError("timeout must be at least 1 second") + ErrInvalidNamespace = validationError("namespace cannot be empty") + ErrInvalidService = validationError("barnacle service cannot be empty") + ErrInvalidManifestPath = validationError("manifest path cannot be empty") +) + +type validationError string + +func (e validationError) Error() string { + return string(e) +} diff --git a/test/e2e/imagegen/generator.go b/test/e2e/imagegen/generator.go new file mode 100644 index 0000000..017d462 --- /dev/null +++ b/test/e2e/imagegen/generator.go @@ -0,0 +1,421 @@ +package imagegen + +import ( + "crypto/rand" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "os" + "sync" + "time" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "go.uber.org/zap" +) + +// Config holds the configuration for image generation. +type Config struct { + // BaseImage is the base image to use for generation (e.g., "alpine:latest") + BaseImage string + + // TargetRegistry is where generated images will be pushed + TargetRegistry string + + // NumVariants is the total number of image variants to generate + NumVariants int + + // MinLayers is the minimum number of layers to add per image + MinLayers int + + // MaxLayers is the maximum number of layers to add per image + MaxLayers int + + // LayerSharingPercent is the percentage of images that should be based on + // previously generated images instead of the base image (0-100) + LayerSharingPercent int + + // Concurrency is the number of concurrent image generations + Concurrency int + + // Insecure allows pushing to insecure registries + Insecure bool +} + +// DefaultConfig returns a default configuration. +func DefaultConfig() Config { + return Config{ + BaseImage: "alpine:latest", + TargetRegistry: "localhost:5000", + NumVariants: 100, + MinLayers: 1, + MaxLayers: 4, + LayerSharingPercent: 50, + Concurrency: 4, + Insecure: true, + } +} + +// ImageManifest represents the manifest of generated images. +type ImageManifest struct { + GeneratedAt time.Time `json:"generated_at"` + BaseImage string `json:"base_image"` + Images []ImageEntry `json:"images"` + Statistics ManifestStats `json:"statistics"` +} + +// ImageEntry represents a single generated image. +type ImageEntry struct { + Name string `json:"name"` + Tag string `json:"tag"` + Reference string `json:"reference"` + Digest string `json:"digest"` + NumLayers int `json:"num_layers"` + TotalSize int64 `json:"total_size"` + ParentRef string `json:"parent_ref,omitempty"` + LayerSizes []int64 `json:"layer_sizes"` +} + +// ManifestStats holds statistics about the generated images. +type ManifestStats struct { + TotalImages int `json:"total_images"` + TotalSize int64 `json:"total_size"` + AverageSize int64 `json:"average_size"` + ImagesWithSharing int `json:"images_with_sharing"` + TotalLayers int `json:"total_layers"` + AverageLayerSize int64 `json:"average_layer_size"` +} + +// Generator generates variant container images. +type Generator struct { + config Config + logger *zap.Logger + generated []ImageEntry + mu sync.Mutex +} + +// NewGenerator creates a new image generator. +func NewGenerator(config Config, logger *zap.Logger) *Generator { + if logger == nil { + logger, _ = zap.NewProduction() + } + return &Generator{ + config: config, + logger: logger, + generated: make([]ImageEntry, 0, config.NumVariants), + } +} + +// Generate generates all variant images and returns the manifest. +func (g *Generator) Generate() (*ImageManifest, error) { + startTime := time.Now() + + g.logger.Info("Starting image generation", + zap.String("base_image", g.config.BaseImage), + zap.Int("num_variants", g.config.NumVariants), + zap.String("target_registry", g.config.TargetRegistry), + ) + + // Pull base image + baseRef, err := name.ParseReference(g.config.BaseImage) + if err != nil { + return nil, fmt.Errorf("failed to parse base image reference: %w", err) + } + + baseImg, err := remote.Image(baseRef, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + if err != nil { + return nil, fmt.Errorf("failed to pull base image: %w", err) + } + + g.logger.Info("Pulled base image", zap.String("ref", baseRef.String())) + + // Create work channel and result channel + workChan := make(chan int, g.config.NumVariants) + resultChan := make(chan error, g.config.NumVariants) + + // Start workers + var wg sync.WaitGroup + for i := 0; i < g.config.Concurrency; i++ { + wg.Add(1) + go func(workerID int) { + defer wg.Done() + for idx := range workChan { + err := g.generateImage(idx, baseImg) + resultChan <- err + } + }(i) + } + + // Queue work + for i := 0; i < g.config.NumVariants; i++ { + workChan <- i + } + close(workChan) + + // Wait for completion + go func() { + wg.Wait() + close(resultChan) + }() + + // Collect results + successCount := 0 + errorCount := 0 + for err := range resultChan { + if err != nil { + g.logger.Error("Failed to generate image", zap.Error(err)) + errorCount++ + } else { + successCount++ + } + } + + if errorCount > 0 { + g.logger.Warn("Some images failed to generate", + zap.Int("success", successCount), + zap.Int("errors", errorCount), + ) + } + + // Build manifest + manifest := g.buildManifest(startTime) + + g.logger.Info("Image generation complete", + zap.Duration("duration", time.Since(startTime)), + zap.Int("total_images", manifest.Statistics.TotalImages), + zap.Int64("total_size", manifest.Statistics.TotalSize), + ) + + return manifest, nil +} + +func (g *Generator) generateImage(idx int, baseImg v1.Image) error { + imageName := fmt.Sprintf("e2e-test-%03d", idx) + tag := "v1" + + g.logger.Debug("Generating image", + zap.Int("index", idx), + zap.String("name", imageName), + ) + + // Determine if this image should be based on a previously generated image + var parentRef string + img := baseImg + + if idx > 0 && g.shouldShareLayers() { + parentEntry := g.getRandomPreviousImage() + if parentEntry != nil { + parentRef = parentEntry.Reference + ref, err := name.ParseReference(parentRef) + if err == nil { + opts := g.remoteOptions() + if parentImg, err := remote.Image(ref, opts...); err == nil { + img = parentImg + g.logger.Debug("Using parent image", + zap.String("parent", parentRef), + zap.String("child", imageName), + ) + } + } + } + } + + // Determine number of layers to add + numLayers := g.randomInt(g.config.MinLayers, g.config.MaxLayers) + + // Generate random layers + var layerSizes []int64 + var totalSize int64 + + currentImg := img + + for i := 0; i < numLayers; i++ { + size := WeightedRandomSize() + layerSizes = append(layerSizes, size) + totalSize += size + + layer, err := RandomLayer(size) + if err != nil { + return fmt.Errorf("failed to create layer %d: %w", i, err) + } + + currentImg, err = mutate.AppendLayers(currentImg, layer) + if err != nil { + return fmt.Errorf("failed to append layer %d: %w", i, err) + } + } + + // Push to registry + targetRef, err := name.ParseReference( + fmt.Sprintf("%s/%s:%s", g.config.TargetRegistry, imageName, tag), + name.Insecure, + ) + if err != nil { + return fmt.Errorf("failed to parse target reference: %w", err) + } + + opts := g.remoteOptions() + + if err := remote.Write(targetRef, currentImg, opts...); err != nil { + return fmt.Errorf("failed to push image: %w", err) + } + + // Get digest + digest, err := currentImg.Digest() + if err != nil { + return fmt.Errorf("failed to get digest: %w", err) + } + + // Record the generated image + entry := ImageEntry{ + Name: imageName, + Tag: tag, + Reference: targetRef.String(), + Digest: digest.String(), + NumLayers: numLayers, + TotalSize: totalSize, + ParentRef: parentRef, + LayerSizes: layerSizes, + } + + g.mu.Lock() + g.generated = append(g.generated, entry) + g.mu.Unlock() + + g.logger.Info("Generated image", + zap.String("ref", targetRef.String()), + zap.Int("layers", numLayers), + zap.Int64("size", totalSize), + ) + + return nil +} + +func (g *Generator) remoteOptions() []remote.Option { + opts := []remote.Option{remote.WithAuthFromKeychain(authn.DefaultKeychain)} + if g.config.Insecure { + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec + }, + } + opts = append(opts, remote.WithTransport(transport)) + } + return opts +} + +func (g *Generator) shouldShareLayers() bool { + // Generate random byte + randByte := make([]byte, 1) + rand.Read(randByte) //nolint:errcheck + + // Convert to percentage (0-100) + pct := int(randByte[0]) * 100 / 256 + return pct < g.config.LayerSharingPercent +} + +func (g *Generator) getRandomPreviousImage() *ImageEntry { + g.mu.Lock() + defer g.mu.Unlock() + + if len(g.generated) == 0 { + return nil + } + + // Get random index + randBytes := make([]byte, 4) + rand.Read(randBytes) //nolint:errcheck + idx := int(randBytes[0]) % len(g.generated) + + return &g.generated[idx] +} + +func (g *Generator) randomInt(min, max int) int { + if min >= max { + return min + } + + randBytes := make([]byte, 4) + rand.Read(randBytes) //nolint:errcheck + + rangeSize := max - min + 1 + val := int(randBytes[0]) | int(randBytes[1])<<8 | int(randBytes[2])<<16 | int(randBytes[3])<<24 + if val < 0 { + val = -val + } + + return min + (val % rangeSize) +} + +func (g *Generator) buildManifest(startTime time.Time) *ImageManifest { + g.mu.Lock() + defer g.mu.Unlock() + + var totalSize int64 + var totalLayers int + sharingCount := 0 + + for _, img := range g.generated { + totalSize += img.TotalSize + totalLayers += img.NumLayers + if img.ParentRef != "" { + sharingCount++ + } + } + + numImages := len(g.generated) + var avgSize, avgLayerSize int64 + if numImages > 0 { + avgSize = totalSize / int64(numImages) + } + if totalLayers > 0 { + avgLayerSize = totalSize / int64(totalLayers) + } + + return &ImageManifest{ + GeneratedAt: startTime, + BaseImage: g.config.BaseImage, + Images: g.generated, + Statistics: ManifestStats{ + TotalImages: numImages, + TotalSize: totalSize, + AverageSize: avgSize, + ImagesWithSharing: sharingCount, + TotalLayers: totalLayers, + AverageLayerSize: avgLayerSize, + }, + } +} + +// WriteManifest writes the manifest to a file. +func (m *ImageManifest) WriteManifest(path string) error { + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal manifest: %w", err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("failed to write manifest: %w", err) + } + + return nil +} + +// LoadManifest loads a manifest from a file. +func LoadManifest(path string) (*ImageManifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read manifest: %w", err) + } + + var manifest ImageManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, fmt.Errorf("failed to unmarshal manifest: %w", err) + } + + return &manifest, nil +} diff --git a/test/e2e/imagegen/layer.go b/test/e2e/imagegen/layer.go new file mode 100644 index 0000000..5483017 --- /dev/null +++ b/test/e2e/imagegen/layer.go @@ -0,0 +1,172 @@ +package imagegen + +import ( + "archive/tar" + "bytes" + "crypto/rand" + "fmt" + "io" + "time" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/stream" +) + +// RandomLayer creates a new layer with random binary content of the specified size. +// The layer contains a single file with random data at /data/.bin +func RandomLayer(size int64) (v1.Layer, error) { + buf := new(bytes.Buffer) + tw := tar.NewWriter(buf) + + // Generate random filename + randName := make([]byte, 8) + if _, err := rand.Read(randName); err != nil { + return nil, fmt.Errorf("failed to generate random name: %w", err) + } + filename := fmt.Sprintf("data/%x.bin", randName) + + // Create the directory entry + dirHeader := &tar.Header{ + Name: "data/", + Mode: 0755, + Typeflag: tar.TypeDir, + ModTime: time.Now(), + } + if err := tw.WriteHeader(dirHeader); err != nil { + return nil, fmt.Errorf("failed to write dir header: %w", err) + } + + // Create the file header + header := &tar.Header{ + Name: filename, + Mode: 0644, + Size: size, + ModTime: time.Now(), + } + if err := tw.WriteHeader(header); err != nil { + return nil, fmt.Errorf("failed to write file header: %w", err) + } + + // Write random content in chunks to avoid memory issues with large files + const chunkSize = 1024 * 1024 // 1MB chunks + remaining := size + chunk := make([]byte, chunkSize) + + for remaining > 0 { + toWrite := chunkSize + if remaining < int64(chunkSize) { + toWrite = int(remaining) + chunk = make([]byte, toWrite) + } + + if _, err := rand.Read(chunk[:toWrite]); err != nil { + return nil, fmt.Errorf("failed to generate random content: %w", err) + } + + if _, err := tw.Write(chunk[:toWrite]); err != nil { + return nil, fmt.Errorf("failed to write content: %w", err) + } + + remaining -= int64(toWrite) + } + + if err := tw.Close(); err != nil { + return nil, fmt.Errorf("failed to close tar writer: %w", err) + } + + // Create the layer from the tar archive using stream.NewLayer + // This avoids writing temporary files to disk + layer := stream.NewLayer(io.NopCloser(bytes.NewReader(buf.Bytes()))) + + return layer, nil +} + +// RandomLayerStream creates a layer from a streaming random reader to handle very large layers. +// This is more memory-efficient for layers > 100MB. +func RandomLayerStream(size int64) (v1.Layer, error) { + pr, pw := io.Pipe() + + go func() { + tw := tar.NewWriter(pw) + + // Generate random filename + randName := make([]byte, 8) + rand.Read(randName) //nolint:errcheck + filename := fmt.Sprintf("data/%x.bin", randName) + + // Create the directory entry + tw.WriteHeader(&tar.Header{ //nolint:errcheck + Name: "data/", + Mode: 0755, + Typeflag: tar.TypeDir, + ModTime: time.Now(), + }) + + // Create the file header + tw.WriteHeader(&tar.Header{ //nolint:errcheck + Name: filename, + Mode: 0644, + Size: size, + ModTime: time.Now(), + }) + + // Write random content in chunks + const chunkSize = 1024 * 1024 // 1MB chunks + remaining := size + chunk := make([]byte, chunkSize) + + for remaining > 0 { + toWrite := int64(chunkSize) + if remaining < toWrite { + toWrite = remaining + } + + rand.Read(chunk[:toWrite]) //nolint:errcheck + tw.Write(chunk[:toWrite]) //nolint:errcheck + + remaining -= toWrite + } + + tw.Close() + pw.Close() + }() + + // Use stream.NewLayer to avoid writing temporary files to disk + return stream.NewLayer(pr), nil +} + +// WeightedRandomSize returns a random layer size following the weighted distribution: +// 80% of layers are 1-100Mi, 20% are up to 1Gi +func WeightedRandomSize() int64 { + // Generate random byte for weighting decision + weightByte := make([]byte, 1) + rand.Read(weightByte) //nolint:errcheck + + // Use the random byte to determine weight (0-255) + // 80% = 204/256, so if < 204, use small size + if weightByte[0] < 204 { + // Small layer: 1MB to 100MB + return randomInt64(1*1024*1024, 100*1024*1024) + } + + // Large layer: 100MB to 1GB + return randomInt64(100*1024*1024, 1024*1024*1024) +} + +// randomInt64 returns a random int64 between min and max (inclusive) +func randomInt64(min, max int64) int64 { + if min >= max { + return min + } + + rangeSize := max - min + 1 + randBytes := make([]byte, 8) + rand.Read(randBytes) //nolint:errcheck + + // Convert to uint64 and scale to range + randVal := uint64(randBytes[0]) | uint64(randBytes[1])<<8 | uint64(randBytes[2])<<16 | + uint64(randBytes[3])<<24 | uint64(randBytes[4])<<32 | uint64(randBytes[5])<<40 | + uint64(randBytes[6])<<48 | uint64(randBytes[7])<<56 + + return min + int64(randVal%uint64(rangeSize)) +} diff --git a/test/e2e/stress_test.go b/test/e2e/stress_test.go new file mode 100644 index 0000000..95c72c7 --- /dev/null +++ b/test/e2e/stress_test.go @@ -0,0 +1,162 @@ +//go:build e2e + +package e2e_test + +import ( + "context" + "testing" + "time" + + "github.com/pdylanross/barnacle/test/e2e/workload" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// TestStressImagePulls runs the main stress test that pulls images through barnacle. +func TestStressImagePulls(t *testing.T) { + require.NotNil(t, testFramework, "test framework not initialized") + require.NotNil(t, testFramework.Manifest(), "image manifest not loaded") + + opts := testFramework.Options() + t.Logf("Running stress test with %d workers, %d iterations", opts.Workers, opts.Iterations) + t.Logf("Using %d images from manifest", len(testFramework.Manifest().Images)) + + // Create scheduler + scheduler := workload.NewScheduler(testFramework, testLogger) + + // Create context with overall timeout + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour) + defer cancel() + + // Run workload with progress reporting + lastProgress := 0 + results, err := scheduler.RunWithProgress(ctx, func(completed, total int) { + pct := (completed * 100) / total + if pct >= lastProgress+10 { + t.Logf("Progress: %d/%d (%d%%)", completed, total, pct) + lastProgress = pct + } + }) + require.NoError(t, err, "workload execution failed") + + // Generate report + reporter := workload.NewReporter(results, opts.ResultsPath != "") + report := reporter.Generate() + + // Print summary + report.PrintSummary() + + // Write results to output directory if path specified + if opts.ResultsPath != "" { + err := reporter.WriteOutputDir(opts.ResultsPath) + if err != nil { + t.Logf("Warning: failed to write results to %s: %v", opts.ResultsPath, err) + } else { + t.Logf("Results written to %s", opts.ResultsPath) + } + } + + // Assert success rate > 99% + assert.GreaterOrEqual(t, report.Summary.SuccessRate, 99.0, + "success rate should be >= 99%%, got %.2f%%", report.Summary.SuccessRate) + + // Log additional metrics + testLogger.Info("Stress test completed", + zap.Int("total", report.Summary.TotalIterations), + zap.Int("success", report.Summary.SuccessCount), + zap.Int("failure", report.Summary.FailureCount), + zap.Float64("success_rate", report.Summary.SuccessRate), + zap.Float64("p50_s", report.Latencies.P50), + zap.Float64("p95_s", report.Latencies.P95), + zap.Float64("p99_s", report.Latencies.P99), + ) +} + +// TestBarnacleHealth verifies barnacle is healthy before running stress tests. +func TestBarnacleHealth(t *testing.T) { + require.NotNil(t, testFramework, "test framework not initialized") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Get replica count + replicas, err := testFramework.Barnacle().GetReplicaCount(ctx) + require.NoError(t, err, "failed to get replica count") + assert.GreaterOrEqual(t, replicas, 2, "should have at least 2 replicas") + + t.Logf("Barnacle has %d ready replicas", replicas) + + // Get pod names + pods, err := testFramework.Barnacle().GetPods(ctx) + require.NoError(t, err, "failed to get pods") + + for _, pod := range pods { + t.Logf("Barnacle pod: %s", pod) + } +} + +// TestImageManifest verifies the image manifest is loaded correctly. +func TestImageManifest(t *testing.T) { + require.NotNil(t, testFramework, "test framework not initialized") + require.NotNil(t, testFramework.Manifest(), "image manifest not loaded") + + manifest := testFramework.Manifest() + + t.Logf("Image manifest stats:") + t.Logf(" Total images: %d", manifest.Statistics.TotalImages) + t.Logf(" Total size: %d bytes", manifest.Statistics.TotalSize) + t.Logf(" Average size: %d bytes", manifest.Statistics.AverageSize) + t.Logf(" Total layers: %d", manifest.Statistics.TotalLayers) + t.Logf(" Images with sharing: %d", manifest.Statistics.ImagesWithSharing) + + assert.Greater(t, manifest.Statistics.TotalImages, 0, "should have at least 1 image") + assert.Greater(t, len(manifest.Images), 0, "should have image entries") +} + +// TestSingleImagePull tests pulling a single image as a sanity check. +func TestSingleImagePull(t *testing.T) { + require.NotNil(t, testFramework, "test framework not initialized") + require.NotNil(t, testFramework.Manifest(), "image manifest not loaded") + require.Greater(t, len(testFramework.Manifest().Images), 0, "need at least 1 image") + + // Get first image + image := testFramework.Manifest().Images[0] + imageRef := testFramework.BuildPodImageRef(image) + + t.Logf("Testing single image pull: %s", imageRef) + + // Create work channels + workChan := make(chan workload.WorkItem, 1) + resultChan := make(chan workload.WorkResult, 1) + + // Create worker + worker := workload.NewWorker(0, testFramework, workChan, resultChan) + + // Send work item + workChan <- workload.WorkItem{ + Iteration: 0, + Image: image, + ImageRef: imageRef, + } + close(workChan) + + // Run worker + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + go worker.Run(ctx) + + // Get result + result := <-resultChan + + t.Logf("Pull result:") + t.Logf(" Success: %v", result.Success) + t.Logf(" Duration: %.3fs", result.Duration) + t.Logf(" Pull time: %.3fs", result.PullTime) + if result.Error != "" { + t.Logf(" Error: %s", result.Error) + } + + assert.True(t, result.Success, "single image pull should succeed: %s", result.Error) +} diff --git a/test/e2e/suite_test.go b/test/e2e/suite_test.go new file mode 100644 index 0000000..f11a906 --- /dev/null +++ b/test/e2e/suite_test.go @@ -0,0 +1,116 @@ +//go:build e2e + +package e2e_test + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "testing" + "time" + + "github.com/pdylanross/barnacle/test/e2e/framework" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "k8s.io/klog/v2" +) + +var ( + // Command line flags + workers = flag.Int("workers", 10, "Number of concurrent workers") + iterations = flag.Int("iterations", 10000, "Total number of iterations") + timeout = flag.Duration("timeout", 5*time.Minute, "Timeout per operation") + kubeContext = flag.String("kube-context", "barnacle-e2e", "Kubernetes context") + namespace = flag.String("namespace", "barnacle-e2e", "Kubernetes namespace") + manifestPath = flag.String("manifest", "e2e-images.json", "Path to image manifest") + resultsPath = flag.String("results", "", "Output directory for report and event data (optional)") + verbose = flag.Bool("verbose", false, "Enable verbose logging") + barnacleIngressHost = flag.String("barnacle-ingress", "barnacle.test", "Barnacle ingress hostname") + registryIngressHost = flag.String("registry-ingress", "registry.test", "Registry ingress hostname") + barnacleNodeAddress = flag.String("barnacle-node-addr", "", "Barnacle node address (ip:port) for kubelet access, e.g., $(minikube ip):30080") + kubeQPS = flag.Float64("kube-qps", 20000, "Max queries per second to the K8s API server") + kubeBurst = flag.Int("kube-burst", 40000, "Max burst for throttle to the K8s API server") + + // Global test framework + testFramework *framework.Framework + testLogger *zap.Logger +) + +func TestMain(m *testing.M) { + // Suppress noisy client-go klog messages (e.g., client-side throttling warnings). + klog.InitFlags(nil) + flag.Parse() + klog.SetOutput(io.Discard) + + // Setup logger + var logConfig zap.Config + if *verbose { + logConfig = zap.NewDevelopmentConfig() + logConfig.Level = zap.NewAtomicLevelAt(zapcore.DebugLevel) + } else { + logConfig = zap.NewProductionConfig() + logConfig.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel) + } + + var err error + testLogger, err = logConfig.Build() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create logger: %v\n", err) + os.Exit(1) + } + defer testLogger.Sync() //nolint:errcheck + + // Build options + opts := framework.Options{ + Workers: *workers, + Iterations: *iterations, + Timeout: *timeout, + KubeContext: *kubeContext, + Namespace: *namespace, + BarnacleService: "barnacle", + ManifestPath: *manifestPath, + ResultsPath: *resultsPath, + PodImage: "busybox:latest", + UpstreamName: "local", + KubeQPS: float32(*kubeQPS), + KubeBurst: *kubeBurst, + DeletePods: true, + Verbose: *verbose, + BarnacleIngressHost: *barnacleIngressHost, + RegistryIngressHost: *registryIngressHost, + BarnacleNodeAddress: *barnacleNodeAddress, + } + + // Create framework + testFramework, err = framework.New(opts, testLogger) + if err != nil { + testLogger.Fatal("Failed to create test framework", zap.Error(err)) + } + + // Setup + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + if err := testFramework.Setup(ctx); err != nil { + cancel() + testLogger.Fatal("Failed to setup test framework", zap.Error(err)) + } + cancel() + + // Load image manifest + if err := testFramework.LoadImageManifest(); err != nil { + testLogger.Fatal("Failed to load image manifest", zap.Error(err)) + } + + // Run tests + code := m.Run() + + // Teardown + ctx, cancel = context.WithTimeout(context.Background(), 2*time.Minute) + if err := testFramework.Teardown(ctx); err != nil { + testLogger.Warn("Failed to teardown test framework", zap.Error(err)) + } + cancel() + + os.Exit(code) +} diff --git a/test/e2e/workload/events.go b/test/e2e/workload/events.go new file mode 100644 index 0000000..b7ff3bd --- /dev/null +++ b/test/e2e/workload/events.go @@ -0,0 +1,155 @@ +package workload + +import ( + "context" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes" +) + +// PodEventRecord holds K8s events for a failed pod. +type PodEventRecord struct { + PodName string `json:"pod_name"` + Namespace string `json:"namespace"` + ContainerImage string `json:"container_image"` + Labels map[string]string `json:"labels"` + StartTime *metav1.Time `json:"start_time,omitempty"` + FailureReason string `json:"failure_reason"` + Events []PodEvent `json:"events"` +} + +// PodEvent represents a single K8s event associated with a pod. +type PodEvent struct { + Type string `json:"type"` + Reason string `json:"reason"` + Message string `json:"message"` + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` + Count int32 `json:"count"` + Source string `json:"source"` +} + +// PodEventWatcher watches K8s Event resources for a specific pod. +type PodEventWatcher struct { + clientset kubernetes.Interface + namespace string +} + +// NewPodEventWatcher creates a new PodEventWatcher. +func NewPodEventWatcher(clientset kubernetes.Interface, namespace string) *PodEventWatcher { + return &PodEventWatcher{ + clientset: clientset, + namespace: namespace, + } +} + +// PullWatchResult holds the outcome of watching pull-related events on a pod. +type PullWatchResult struct { + Duration time.Duration + SawPullError bool +} + +// WatchPullTime watches for kubelet Pulling/Pulled events on a pod and returns +// the duration between them. Returns zero duration if the events aren't observed +// (e.g., image already cached on the node). SawPullError is set when transient +// image-pull errors (Failed, BackOff) are observed before the pull succeeds. +func (w *PodEventWatcher) WatchPullTime(ctx context.Context, podName string) (PullWatchResult, error) { + selector := fields.AndSelectors( + fields.OneTermEqualSelector("involvedObject.name", podName), + fields.OneTermEqualSelector("involvedObject.kind", "Pod"), + ) + + watcher, err := w.clientset.CoreV1().Events(w.namespace).Watch(ctx, metav1.ListOptions{ + FieldSelector: selector.String(), + }) + if err != nil { + return PullWatchResult{}, fmt.Errorf("failed to watch events for pod %s: %w", podName, err) + } + defer watcher.Stop() + + // We timestamp events at receipt rather than relying on K8s event timestamps, + // which only have second-level precision in the core/v1 Events API. + var pullingReceived time.Time + var sawPullError bool + + for { + select { + case <-ctx.Done(): + return PullWatchResult{SawPullError: sawPullError}, nil + case evt, ok := <-watcher.ResultChan(): + if !ok { + return PullWatchResult{SawPullError: sawPullError}, nil + } + if evt.Type == watch.Error { + continue + } + event, ok := evt.Object.(*corev1.Event) + if !ok { + continue + } + switch event.Reason { + case "Pulling": + pullingReceived = time.Now() + case "Pulled": + result := PullWatchResult{SawPullError: sawPullError} + if !pullingReceived.IsZero() { + result.Duration = time.Since(pullingReceived) + } + return result, nil + case "Failed", "BackOff": + sawPullError = true + } + } + } +} + +// FetchPodEvents lists all events for a pod and returns a structured PodEventRecord. +func (w *PodEventWatcher) FetchPodEvents(ctx context.Context, podName, containerImage, failureReason string) (*PodEventRecord, error) { + selector := fields.AndSelectors( + fields.OneTermEqualSelector("involvedObject.name", podName), + fields.OneTermEqualSelector("involvedObject.kind", "Pod"), + ) + + eventList, err := w.clientset.CoreV1().Events(w.namespace).List(ctx, metav1.ListOptions{ + FieldSelector: selector.String(), + }) + if err != nil { + return nil, fmt.Errorf("failed to list events for pod %s: %w", podName, err) + } + + record := &PodEventRecord{ + PodName: podName, + Namespace: w.namespace, + ContainerImage: containerImage, + FailureReason: failureReason, + Events: make([]PodEvent, 0, len(eventList.Items)), + } + + for _, e := range eventList.Items { + firstSeen := e.EventTime.Time + if firstSeen.IsZero() { + firstSeen = e.FirstTimestamp.Time + } + lastSeen := e.LastTimestamp.Time + if lastSeen.IsZero() { + lastSeen = firstSeen + } + + record.Events = append(record.Events, PodEvent{ + Type: e.Type, + Reason: e.Reason, + Message: e.Message, + FirstSeen: firstSeen, + LastSeen: lastSeen, + Count: e.Count, + Source: e.Source.Component, + }) + } + + return record, nil +} diff --git a/test/e2e/workload/reporter.go b/test/e2e/workload/reporter.go new file mode 100644 index 0000000..8ff5cad --- /dev/null +++ b/test/e2e/workload/reporter.go @@ -0,0 +1,295 @@ +package workload + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" +) + +// Report contains aggregated results from a workload run. +type Report struct { + Summary Summary `json:"summary"` + Latencies Latencies `json:"latencies"` + ByImage map[string]ImageStats `json:"by_image"` + Results []WorkResult `json:"results,omitempty"` + GeneratedAt time.Time `json:"generated_at"` +} + +// Summary contains high-level statistics. +type Summary struct { + TotalIterations int `json:"total_iterations"` + SuccessCount int `json:"success_count"` + FailureCount int `json:"failure_count"` + SuccessRate float64 `json:"success_rate"` + EventualSuccessCount int `json:"eventual_success_count"` + TotalDuration float64 `json:"total_duration_s"` +} + +// Latencies contains latency percentiles in seconds. +type Latencies struct { + Min float64 `json:"min_s"` + Max float64 `json:"max_s"` + Mean float64 `json:"mean_s"` + Median float64 `json:"median_s"` + P50 float64 `json:"p50_s"` + P90 float64 `json:"p90_s"` + P95 float64 `json:"p95_s"` + P99 float64 `json:"p99_s"` +} + +// ImageStats contains per-image statistics. +type ImageStats struct { + ImageName string `json:"image_name"` + PullCount int `json:"pull_count"` + SuccessCount int `json:"success_count"` + FailureCount int `json:"failure_count"` + MeanLatency float64 `json:"mean_latency_s"` + MinLatency float64 `json:"min_latency_s"` + MaxLatency float64 `json:"max_latency_s"` +} + +// Reporter aggregates and reports workload results. +type Reporter struct { + results []WorkResult + includeRaw bool +} + +// NewReporter creates a new reporter. +func NewReporter(results []WorkResult, includeRaw bool) *Reporter { + return &Reporter{ + results: results, + includeRaw: includeRaw, + } +} + +// Generate generates a report from the results. +func (r *Reporter) Generate() *Report { + report := &Report{ + GeneratedAt: time.Now(), + ByImage: make(map[string]ImageStats), + } + + if r.includeRaw { + report.Results = r.results + } + + if len(r.results) == 0 { + return report + } + + // Calculate summary + var totalDuration float64 + successCount := 0 + failureCount := 0 + eventualSuccessCount := 0 + + for _, result := range r.results { + totalDuration += result.Duration + if result.Success { + successCount++ + } else { + failureCount++ + } + if result.EventualSuccessEvents != nil { + eventualSuccessCount++ + } + } + + report.Summary = Summary{ + TotalIterations: len(r.results), + SuccessCount: successCount, + FailureCount: failureCount, + SuccessRate: float64(successCount) / float64(len(r.results)) * 100, + EventualSuccessCount: eventualSuccessCount, + TotalDuration: totalDuration, + } + + // Calculate latencies + report.Latencies = r.calculateLatencies() + + // Calculate per-image stats + imageResults := make(map[string][]WorkResult) + for _, result := range r.results { + imageResults[result.ImageName] = append(imageResults[result.ImageName], result) + } + + for imageName, results := range imageResults { + stats := r.calculateImageStats(imageName, results) + report.ByImage[imageName] = stats + } + + return report +} + +func (r *Reporter) calculateLatencies() Latencies { + // Filter out zero-duration results where the watch didn't capture timing + var durations []float64 + for _, result := range r.results { + if result.PullTime > 0 { + durations = append(durations, result.PullTime) + } + } + + if len(durations) == 0 { + return Latencies{} + } + + sort.Float64s(durations) + + // Calculate stats + var total float64 + for _, d := range durations { + total += d + } + + n := len(durations) + mean := total / float64(n) + + return Latencies{ + Min: durations[0], + Max: durations[n-1], + Mean: mean, + Median: durations[n/2], + P50: percentile(durations, 50), + P90: percentile(durations, 90), + P95: percentile(durations, 95), + P99: percentile(durations, 99), + } +} + +func percentile(sorted []float64, p int) float64 { + if len(sorted) == 0 { + return 0 + } + idx := (p * len(sorted)) / 100 + if idx >= len(sorted) { + idx = len(sorted) - 1 + } + return sorted[idx] +} + +func (r *Reporter) calculateImageStats(imageName string, results []WorkResult) ImageStats { + stats := ImageStats{ + ImageName: imageName, + PullCount: len(results), + } + + if len(results) == 0 { + return stats + } + + var totalLatency float64 + stats.MinLatency = results[0].PullTime + stats.MaxLatency = results[0].PullTime + + for _, result := range results { + if result.Success { + stats.SuccessCount++ + } else { + stats.FailureCount++ + } + totalLatency += result.PullTime + if result.PullTime < stats.MinLatency { + stats.MinLatency = result.PullTime + } + if result.PullTime > stats.MaxLatency { + stats.MaxLatency = result.PullTime + } + } + + stats.MeanLatency = totalLatency / float64(len(results)) + return stats +} + +// WriteJSON writes the report to a JSON file. +func (r *Report) WriteJSON(path string) error { + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal report: %w", err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("failed to write report: %w", err) + } + + return nil +} + +// WriteOutputDir writes the report and failed pod events to an output directory. +func (r *Reporter) WriteOutputDir(dir string) error { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + report := r.Generate() + + // Write report.json + if err := report.WriteJSON(filepath.Join(dir, "report.json")); err != nil { + return err + } + + // Collect failed pod events from results + var failedEvents []PodEventRecord + for _, result := range r.results { + if result.FailedPodEvents != nil { + failedEvents = append(failedEvents, *result.FailedPodEvents) + } + } + + // Write failed-pod-events.json + eventsData, err := json.MarshalIndent(failedEvents, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal failed pod events: %w", err) + } + + eventsPath := filepath.Join(dir, "failed-pod-events.json") + if err := os.WriteFile(eventsPath, eventsData, 0644); err != nil { + return fmt.Errorf("failed to write failed pod events: %w", err) + } + + // Collect eventual success events from results + var eventualSuccessEvents []PodEventRecord + for _, result := range r.results { + if result.EventualSuccessEvents != nil { + eventualSuccessEvents = append(eventualSuccessEvents, *result.EventualSuccessEvents) + } + } + + // Write eventual-success-events.json + esData, err := json.MarshalIndent(eventualSuccessEvents, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal eventual success events: %w", err) + } + + esPath := filepath.Join(dir, "eventual-success-events.json") + if err := os.WriteFile(esPath, esData, 0644); err != nil { + return fmt.Errorf("failed to write eventual success events: %w", err) + } + + return nil +} + +// PrintSummary prints a human-readable summary to stdout. +func (r *Report) PrintSummary() { + fmt.Println() + fmt.Println("=== E2E Test Results ===") + fmt.Println() + fmt.Printf("Total Iterations: %d\n", r.Summary.TotalIterations) + fmt.Printf("Success Count: %d\n", r.Summary.SuccessCount) + fmt.Printf("Failure Count: %d\n", r.Summary.FailureCount) + fmt.Printf("Eventual Successes: %d\n", r.Summary.EventualSuccessCount) + fmt.Printf("Success Rate: %.2f%%\n", r.Summary.SuccessRate) + fmt.Println() + fmt.Println("Latency Percentiles (Pull Time):") + fmt.Printf(" Min: %.3fs\n", r.Latencies.Min) + fmt.Printf(" P50: %.3fs\n", r.Latencies.P50) + fmt.Printf(" P90: %.3fs\n", r.Latencies.P90) + fmt.Printf(" P95: %.3fs\n", r.Latencies.P95) + fmt.Printf(" P99: %.3fs\n", r.Latencies.P99) + fmt.Printf(" Max: %.3fs\n", r.Latencies.Max) + fmt.Printf(" Mean: %.3fs\n", r.Latencies.Mean) + fmt.Println() +} diff --git a/test/e2e/workload/scheduler.go b/test/e2e/workload/scheduler.go new file mode 100644 index 0000000..1e5415d --- /dev/null +++ b/test/e2e/workload/scheduler.go @@ -0,0 +1,186 @@ +package workload + +import ( + "context" + "sync" + + "github.com/pdylanross/barnacle/test/e2e/framework" + "go.uber.org/zap" +) + +// Scheduler distributes work across multiple workers. +type Scheduler struct { + framework *framework.Framework + logger *zap.Logger + workChan chan WorkItem + resultChan chan WorkResult + workers []*Worker +} + +// NewScheduler creates a new scheduler. +func NewScheduler(fw *framework.Framework, logger *zap.Logger) *Scheduler { + numWorkers := fw.Options().Workers + bufferSize := numWorkers * 10 + + return &Scheduler{ + framework: fw, + logger: logger, + workChan: make(chan WorkItem, bufferSize), + resultChan: make(chan WorkResult, bufferSize), + workers: make([]*Worker, numWorkers), + } +} + +// Run executes the workload and returns all results. +func (s *Scheduler) Run(ctx context.Context) ([]WorkResult, error) { + opts := s.framework.Options() + + s.logger.Info("Starting workload scheduler", + zap.Int("workers", opts.Workers), + zap.Int("iterations", opts.Iterations), + ) + + // Start workers + var wg sync.WaitGroup + for i := 0; i < opts.Workers; i++ { + s.workers[i] = NewWorker(i, s.framework, s.workChan, s.resultChan) + wg.Add(1) + go func(w *Worker) { + defer wg.Done() + w.Run(ctx) + }(s.workers[i]) + } + + // Queue work items + go func() { + defer close(s.workChan) + for i := 0; i < opts.Iterations; i++ { + select { + case <-ctx.Done(): + return + default: + image := s.framework.GetImageForIteration(i) + imageRef := s.framework.BuildPodImageRef(image) + + item := WorkItem{ + Iteration: i, + Image: image, + ImageRef: imageRef, + } + + select { + case s.workChan <- item: + case <-ctx.Done(): + return + } + } + } + }() + + // Collect results + results := make([]WorkResult, 0, opts.Iterations) + resultsDone := make(chan struct{}) + + go func() { + defer close(resultsDone) + for result := range s.resultChan { + results = append(results, result) + + // Log progress periodically + if len(results)%100 == 0 { + s.logger.Info("Progress", + zap.Int("completed", len(results)), + zap.Int("total", opts.Iterations), + ) + } + } + }() + + // Wait for workers to finish + wg.Wait() + close(s.resultChan) + + // Wait for results collection to finish + <-resultsDone + + s.logger.Info("Workload completed", + zap.Int("total_results", len(results)), + ) + + return results, nil +} + +// RunWithProgress executes the workload and calls the progress callback periodically. +func (s *Scheduler) RunWithProgress(ctx context.Context, progressFn func(completed, total int)) ([]WorkResult, error) { + opts := s.framework.Options() + + s.logger.Info("Starting workload scheduler with progress", + zap.Int("workers", opts.Workers), + zap.Int("iterations", opts.Iterations), + ) + + // Start workers + var wg sync.WaitGroup + for i := 0; i < opts.Workers; i++ { + s.workers[i] = NewWorker(i, s.framework, s.workChan, s.resultChan) + wg.Add(1) + go func(w *Worker) { + defer wg.Done() + w.Run(ctx) + }(s.workers[i]) + } + + // Queue work items + go func() { + defer close(s.workChan) + for i := 0; i < opts.Iterations; i++ { + select { + case <-ctx.Done(): + return + default: + image := s.framework.GetImageForIteration(i) + imageRef := s.framework.BuildPodImageRef(image) + + item := WorkItem{ + Iteration: i, + Image: image, + ImageRef: imageRef, + } + + select { + case s.workChan <- item: + case <-ctx.Done(): + return + } + } + } + }() + + // Collect results + results := make([]WorkResult, 0, opts.Iterations) + resultsDone := make(chan struct{}) + + go func() { + defer close(resultsDone) + for result := range s.resultChan { + results = append(results, result) + + if progressFn != nil { + progressFn(len(results), opts.Iterations) + } + } + }() + + // Wait for workers to finish + wg.Wait() + close(s.resultChan) + + // Wait for results collection to finish + <-resultsDone + + s.logger.Info("Workload completed", + zap.Int("total_results", len(results)), + ) + + return results, nil +} diff --git a/test/e2e/workload/worker.go b/test/e2e/workload/worker.go new file mode 100644 index 0000000..386ff7f --- /dev/null +++ b/test/e2e/workload/worker.go @@ -0,0 +1,210 @@ +package workload + +import ( + "context" + "fmt" + "time" + + "github.com/pdylanross/barnacle/test/e2e/framework" + "github.com/pdylanross/barnacle/test/e2e/imagegen" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// WorkItem represents a single unit of work for a worker. +type WorkItem struct { + Iteration int + Image imagegen.ImageEntry + ImageRef string +} + +// WorkResult represents the result of a single work item. +type WorkResult struct { + Iteration int `json:"iteration"` + ImageName string `json:"image_name"` + ImageRef string `json:"image_ref"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + Duration float64 `json:"duration_s"` + PullTime float64 `json:"pull_time_s"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + PodName string `json:"pod_name"` + WorkerID int `json:"worker_id"` + FailedPodEvents *PodEventRecord `json:"failed_pod_events,omitempty"` + EventualSuccessEvents *PodEventRecord `json:"eventual_success_events,omitempty"` +} + +// Worker executes work items by creating pods that pull images through barnacle. +type Worker struct { + id int + framework *framework.Framework + workChan <-chan WorkItem + resultChan chan<- WorkResult + eventWatcher *PodEventWatcher +} + +// NewWorker creates a new worker. +func NewWorker(id int, fw *framework.Framework, workChan <-chan WorkItem, resultChan chan<- WorkResult) *Worker { + return &Worker{ + id: id, + framework: fw, + workChan: workChan, + resultChan: resultChan, + eventWatcher: NewPodEventWatcher(fw.Cluster().Clientset(), fw.Options().Namespace), + } +} + +// Run starts the worker and processes work items until the work channel is closed. +func (w *Worker) Run(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case item, ok := <-w.workChan: + if !ok { + return + } + result := w.processItem(ctx, item) + select { + case w.resultChan <- result: + case <-ctx.Done(): + return + } + } + } +} + +func (w *Worker) processItem(ctx context.Context, item WorkItem) WorkResult { + result := WorkResult{ + Iteration: item.Iteration, + ImageName: item.Image.Name, + ImageRef: item.ImageRef, + StartTime: time.Now(), + WorkerID: w.id, + } + + podName := fmt.Sprintf("e2e-pull-%d-%d", item.Iteration, time.Now().UnixNano()%10000) + result.PodName = podName + + // Create the pod + pod := w.buildPod(podName, item.ImageRef) + + createdPod, err := w.framework.Cluster().CreatePod(ctx, pod) + if err != nil { + result.Error = fmt.Sprintf("failed to create pod: %v", err) + result.EndTime = time.Now() + result.Duration = result.EndTime.Sub(result.StartTime).Seconds() + return result + } + + // Start watching for pull timing events in a goroutine + type pullResult struct { + watchResult PullWatchResult + err error + } + pullCh := make(chan pullResult, 1) + go func() { + wr, watchErr := w.eventWatcher.WatchPullTime(ctx, podName) + pullCh <- pullResult{watchResult: wr, err: watchErr} + }() + + // Wait for pod to complete + completedPod, err := w.framework.Cluster().WaitForPodComplete(ctx, podName, w.framework.Options().Timeout) + + // Collect pull time from the event watcher + var sawPullError bool + select { + case pr := <-pullCh: + if pr.err == nil { + result.PullTime = pr.watchResult.Duration.Seconds() + sawPullError = pr.watchResult.SawPullError + } + case <-time.After(5 * time.Second): + // Timed out waiting for watcher result; leave PullTime as zero + } + + if err != nil { + result.Error = fmt.Sprintf("pod did not complete: %v", err) + // Fetch events for the failed pod + eventRecord, fetchErr := w.eventWatcher.FetchPodEvents(ctx, podName, item.ImageRef, result.Error) + if fetchErr == nil { + result.FailedPodEvents = eventRecord + } + w.cleanupPod(ctx, podName) + result.EndTime = time.Now() + result.Duration = result.EndTime.Sub(result.StartTime).Seconds() + return result + } + + // Check pod status + if completedPod.Status.Phase == corev1.PodSucceeded { + result.Success = true + if sawPullError { + eventRecord, fetchErr := w.eventWatcher.FetchPodEvents(ctx, podName, item.ImageRef, "transient image pull error") + if fetchErr == nil { + result.EventualSuccessEvents = eventRecord + } + } + } else { + result.Error = fmt.Sprintf("pod failed with phase: %s", completedPod.Status.Phase) + if len(completedPod.Status.ContainerStatuses) > 0 { + cs := completedPod.Status.ContainerStatuses[0] + if cs.State.Terminated != nil && cs.State.Terminated.Message != "" { + result.Error = fmt.Sprintf("%s: %s", result.Error, cs.State.Terminated.Message) + } + } + // Fetch events for the failed pod + eventRecord, fetchErr := w.eventWatcher.FetchPodEvents(ctx, podName, item.ImageRef, result.Error) + if fetchErr == nil { + result.FailedPodEvents = eventRecord + } + } + + // Cleanup pod + w.cleanupPod(ctx, podName) + + // Record timing for pod events if available + if createdPod != nil { + result.PodName = createdPod.Name + } + + result.EndTime = time.Now() + result.Duration = result.EndTime.Sub(result.StartTime).Seconds() + + return result +} + +func (w *Worker) buildPod(name, imageRef string) *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: w.framework.Options().Namespace, + Labels: map[string]string{ + "app.kubernetes.io/name": "e2e-pull-test", + "app.kubernetes.io/part-of": "barnacle-e2e-test", + }, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "test", + Image: imageRef, + ImagePullPolicy: corev1.PullAlways, + Command: []string{"echo", "hello world"}, + }, + }, + }, + } +} + +func (w *Worker) cleanupPod(ctx context.Context, name string) { + if w.framework.Options().DeletePods { + // Use a separate context for cleanup to ensure it completes + cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + _ = w.framework.Cluster().DeletePod(cleanupCtx, name) + } +} From 3f715cec9ad03289c88dfcaf3e705b9a995c1c15 Mon Sep 17 00:00:00 2001 From: Dylan Ross Date: Fri, 3 Apr 2026 14:13:15 -0500 Subject: [PATCH 2/2] doc: consolidate claude skills into single code-standards skill and add gh cli workflow Replaces individual skill files with a unified code-standards skill containing all references. Adds comprehensive GitHub CLI documentation to the development workflow covering PR creation, CI checks, squash merging, and auto-merge. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/code-standards/SKILL.md | 23 +++ .../references/api-swagger-support.md} | 11 +- .../references/context-standards.md} | 7 +- .../references/dependency-injection.md} | 7 +- .../references/development-workflow.md | 169 ++++++++++++++++++ .../references/environment-dependencies.md} | 21 +-- .../references/error-handling.md} | 11 +- .../references/internal-api-standards.md} | 23 +-- .../swagger-annotation-patterns.md} | 0 .../swagger-model-documentation.md} | 0 .../references/unit-testing-patterns.md} | 9 +- .claude/skills/development-workflow/SKILL.md | 49 ----- Claude.md => CLAUDE.md | 58 +++--- 13 files changed, 250 insertions(+), 138 deletions(-) create mode 100644 .claude/skills/code-standards/SKILL.md rename .claude/skills/{api-swagger-support/SKILL.md => code-standards/references/api-swagger-support.md} (95%) rename .claude/skills/{context-standards/SKILL.md => code-standards/references/context-standards.md} (84%) rename .claude/skills/{dependency-injection/SKILL.md => code-standards/references/dependency-injection.md} (98%) create mode 100644 .claude/skills/code-standards/references/development-workflow.md rename .claude/skills/{environment-dependencies/SKILL.md => code-standards/references/environment-dependencies.md} (77%) rename .claude/skills/{error-handling/SKILL.md => code-standards/references/error-handling.md} (95%) rename .claude/skills/{internal-api-standards/SKILL.md => code-standards/references/internal-api-standards.md} (91%) rename .claude/skills/{api-swagger-support/references/annotation-patterns.md => code-standards/references/swagger-annotation-patterns.md} (100%) rename .claude/skills/{api-swagger-support/references/model-documentation.md => code-standards/references/swagger-model-documentation.md} (100%) rename .claude/skills/{unit-testing-patterns/SKILL.md => code-standards/references/unit-testing-patterns.md} (88%) delete mode 100644 .claude/skills/development-workflow/SKILL.md rename Claude.md => CLAUDE.md (67%) diff --git a/.claude/skills/code-standards/SKILL.md b/.claude/skills/code-standards/SKILL.md new file mode 100644 index 0000000..7b5d35b --- /dev/null +++ b/.claude/skills/code-standards/SKILL.md @@ -0,0 +1,23 @@ +--- +name: code-standards +description: Project code standards covering context usage, DI, workflow, errors, testing, API design, and Swagger docs. Always load when working with code in this repo. +--- + +# Barnacle Code Standards + +This project follows consistent patterns for Go development. Load the relevant reference documents below based on the area you're working in. + +## References + +| Reference | Load when... | +|---|---| +| [context-standards](references/context-standards.md) | Working with `context.Context` parameters or propagation | +| [dependency-injection](references/dependency-injection.md) | Adding, modifying, or wiring dependencies via `internal/dependencies` | +| [development-workflow](references/development-workflow.md) | Creating branches, commits, or PRs | +| [environment-dependencies](references/environment-dependencies.md) | Adding dev tools or updating `make tools` | +| [error-handling](references/error-handling.md) | Handling errors, defining sentinel errors, or returning HTTP errors | +| [api-swagger-support](references/api-swagger-support.md) | Adding or updating Swagger/OpenAPI annotations | +| [swagger-annotation-patterns](references/swagger-annotation-patterns.md) | Writing handler-level swagger comment blocks | +| [swagger-model-documentation](references/swagger-model-documentation.md) | Documenting DTOs with struct tags, enums, or generics for swagger | +| [internal-api-standards](references/internal-api-standards.md) | Building internal management API endpoints or DTOs | +| [unit-testing-patterns](references/unit-testing-patterns.md) | Writing or modifying unit tests | \ No newline at end of file diff --git a/.claude/skills/api-swagger-support/SKILL.md b/.claude/skills/code-standards/references/api-swagger-support.md similarity index 95% rename from .claude/skills/api-swagger-support/SKILL.md rename to .claude/skills/code-standards/references/api-swagger-support.md index 1c0553b..6a0d8ac 100644 --- a/.claude/skills/api-swagger-support/SKILL.md +++ b/.claude/skills/code-standards/references/api-swagger-support.md @@ -1,8 +1,3 @@ ---- -name: api-swagger-support -description: Guide for generating Swagger/OpenAPI documentation from Go code using swag and serving it via gin-swagger. Use when adding API documentation annotations to handlers, creating or updating Swagger comments, configuring swagger UI routes, running swag init/fmt, or troubleshooting documentation generation. ---- - # API Swagger Support (swag + gin-swagger) This project uses [swag](https://github.com/swaggo/swag) to generate Swagger 2.0 documentation from Go annotations, and [gin-swagger](https://github.com/swaggo/gin-swagger) to serve the interactive Swagger UI. @@ -237,7 +232,7 @@ type Event struct { // time.Time auto-maps, but you can be explicit: CreatedAt time.Time `json:"createdAt" swaggertype:"string" format:"date-time"` - // Custom type → primitive + // Custom type -> primitive Status AppStatus `json:"status" swaggertype:"string" enums:"running,stopped"` // Map types @@ -359,8 +354,8 @@ When adding swagger annotations to this codebase: ## Detailed References -- See [references/annotation-patterns.md](references/annotation-patterns.md) for complete annotation examples matching this project's patterns -- See [references/model-documentation.md](references/model-documentation.md) for struct tag patterns, enums, composition, and generics +- See [swagger-annotation-patterns.md](swagger-annotation-patterns.md) for complete annotation examples matching this project's patterns +- See [swagger-model-documentation.md](swagger-model-documentation.md) for struct tag patterns, enums, composition, and generics ## External Resources diff --git a/.claude/skills/context-standards/SKILL.md b/.claude/skills/code-standards/references/context-standards.md similarity index 84% rename from .claude/skills/context-standards/SKILL.md rename to .claude/skills/code-standards/references/context-standards.md index 5aef8e8..e7ced01 100644 --- a/.claude/skills/context-standards/SKILL.md +++ b/.claude/skills/code-standards/references/context-standards.md @@ -1,7 +1,4 @@ ---- -name: context-standards -description: Code standards whenever working with golang's `context.Context` type ---- +# Context Standards When working with golang's `context.Context` type, there are a few code standards that should be followed. * Always check for cancellation, especially before doing expensive work. @@ -11,5 +8,5 @@ When working with golang's `context.Context` type, there are a few code standard * NEVER pass nil context. * context.WithValue should only be used for carrying request-scoped, immutable data across API boundaries and between processes (e.g., security credentials, tracing IDs, or an authenticated user's details). * To avoid key collisions when using WithValue, define a custom, unexported type for your context keys, often an empty struct, rather than using basic types like string or int. -* Do not use Context to pass optional function parameters; use regular function arguments for operational data. +* Do not use Context to pass optional function parameters; use regular function arguments for operational data. * Avoid storing large data in the context. Use dedicated function parameters or structs for large data payload \ No newline at end of file diff --git a/.claude/skills/dependency-injection/SKILL.md b/.claude/skills/code-standards/references/dependency-injection.md similarity index 98% rename from .claude/skills/dependency-injection/SKILL.md rename to .claude/skills/code-standards/references/dependency-injection.md index 9d383fc..bf56bdb 100644 --- a/.claude/skills/dependency-injection/SKILL.md +++ b/.claude/skills/code-standards/references/dependency-injection.md @@ -1,7 +1,4 @@ ---- -name: dependency-injection -description: Dependency injection patterns and practices for this project ---- +# Dependency Injection ## Overview @@ -221,4 +218,4 @@ if err != nil { return err } deps.Logger().Info("starting") -``` \ No newline at end of file +``` diff --git a/.claude/skills/code-standards/references/development-workflow.md b/.claude/skills/code-standards/references/development-workflow.md new file mode 100644 index 0000000..cd2d3fb --- /dev/null +++ b/.claude/skills/code-standards/references/development-workflow.md @@ -0,0 +1,169 @@ +# Development Workflow + +## Branch Naming Conventions + +When starting work, create a branch from `main` using the appropriate prefix: + +* `feat/` - For new features (e.g., `feat/blob-replication`) +* `bug/` - For bug fixes (e.g., `bug/redis-connection-timeout`) +* `doc/` - For documentation changes (e.g., `doc/api-examples`) + +## Development Workflow + +1. **Create branch** from `main` with the appropriate prefix +2. **Do the work** - implement the feature, fix, or documentation +3. **Commit and push** - commit changes with descriptive messages, push to remote +4. **Create PR** - open a pull request targeting `main` +5. **Wait for CI** - ensure all pipelines pass +6. **Get review** - wait for code review approval +7. **Squash merge** - merge to `main` using squash merge + +## Commit Message Style + +Follow conventional commits where appropriate: +* `feat:` - New feature +* `fix:` - Bug fix +* `doc:` or `docs:` - Documentation +* `refactor:` - Code refactoring +* `test:` - Adding or updating tests +* `chore:` - Maintenance tasks + +## GitHub CLI (`gh`) Reference + +This project uses the `gh` CLI for all GitHub interactions. Authentication is handled via `gh auth login` (already configured for this repo). + +### Creating a Branch and Pushing + +```bash +# Start from an up-to-date main +git checkout main +git pull origin main +git checkout -b feat/ + +# Stage and commit changes +git add +git commit -m "feat: description of changes" + +# Push and set upstream tracking (-u only needed on first push) +git push -u origin feat/ +``` + +### Creating a Pull Request + +Use `gh pr create` to open a PR from the current branch: + +```bash +# Basic PR creation (will prompt for title and body if omitted) +gh pr create --base main --title "feat: description" --body "Summary of changes" + +# Use commit messages to auto-fill title and body +gh pr create --base main --fill + +# Use first commit for title, all commits for body +gh pr create --base main --fill-first --fill-verbose + +# Create a draft PR +gh pr create --base main --title "feat: WIP description" --draft + +# Use a heredoc for multi-line body +gh pr create --base main --title "feat: description" --body "$(cat <<'EOF' +## Summary +- Change 1 +- Change 2 + +## Test Plan +- [ ] Unit tests pass +- [ ] E2E tests pass +EOF +)" +``` + +### Checking PR Status + +```bash +# View the current branch's PR details +gh pr view + +# Check CI status for the current branch's PR +gh pr checks + +# Watch CI checks until they complete (polls every 10s) +gh pr checks --watch + +# Watch with a custom interval (seconds) +gh pr checks --watch --interval 30 + +# Only show required checks +gh pr checks --required + +# List your open PRs +gh pr list --author "@me" +``` + +### Merging a Pull Request + +Always use **squash merge** for this project: + +```bash +# Squash merge the current branch's PR and delete the branch +gh pr merge --squash --delete-branch + +# Squash merge with a custom commit message +gh pr merge --squash --delete-branch \ + --subject "feat: description (#123)" \ + --body "Detailed description of the squashed changes" + +# Enable auto-merge (merges automatically once CI passes and review is approved) +gh pr merge --squash --delete-branch --auto + +# Merge a specific PR by number +gh pr merge 123 --squash --delete-branch +``` + +### Full Workflow Example + +```bash +# 1. Create branch +git checkout main && git pull origin main +git checkout -b feat/blob-ttl + +# 2. Do the work, commit +git add internal/registry/cache/disk/cache.go +git commit -m "feat: add TTL support for disk blob cache" + +# 3. Push +git push -u origin feat/blob-ttl + +# 4. Create PR +gh pr create --base main \ + --title "feat: add TTL support for disk blob cache" \ + --body "Adds configurable TTL eviction to the disk-based blob cache." + +# 5. Wait for CI (watch checks in terminal) +gh pr checks --watch + +# 6. After approval, squash merge and clean up +gh pr merge --squash --delete-branch + +# 7. Return to main +git checkout main && git pull origin main +``` + +### Auto-Merge Workflow + +When CI is still running or review is pending, enable auto-merge so the PR merges as soon as all requirements are met: + +```bash +# Create PR and immediately enable auto-merge +gh pr create --base main \ + --title "feat: description" \ + --body "Summary of changes" + +gh pr merge --squash --delete-branch --auto +``` + +To disable auto-merge if plans change: + +```bash +gh pr merge --disable-auto +``` \ No newline at end of file diff --git a/.claude/skills/environment-dependencies/SKILL.md b/.claude/skills/code-standards/references/environment-dependencies.md similarity index 77% rename from .claude/skills/environment-dependencies/SKILL.md rename to .claude/skills/code-standards/references/environment-dependencies.md index 5ba3ffa..08437fd 100644 --- a/.claude/skills/environment-dependencies/SKILL.md +++ b/.claude/skills/code-standards/references/environment-dependencies.md @@ -1,13 +1,8 @@ ---- -name: environment-dependencies -description: Standards for managing development environment tools and keeping the Makefile tools recipe in sync ---- +# Environment Dependencies -## Environment Dependencies +This covers the management of external development tools required to build, test, and develop the Barnacle project. All required tools must be documented here and installed via `make tools`. -This skill covers the management of external development tools required to build, test, and develop the Barnacle project. All required tools must be documented here and installed via `make tools`. - -### Required Tools +## Required Tools The following tools are required for development and are installed by `make tools`: @@ -17,7 +12,7 @@ The following tools are required for development and are installed by `make tool | `goimports` | Code formatting with import organization | `go install golang.org/x/tools/cmd/goimports@latest` | | `golangci-lint` | Linting and static analysis (v2 required) | `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest` | -### Adding a New Tool +## Adding a New Tool When adding a new development tool dependency: @@ -45,9 +40,9 @@ tools: @echo "All tools installed successfully" ``` -Then update this skill document to include mockgen in the Required Tools table. +Then update this document to include mockgen in the Required Tools table. -### First-Time Setup +## First-Time Setup New developers should run: @@ -57,7 +52,7 @@ make tools This installs all required development tools. Run this command again if tools are updated or new tools are added. -### Version Pinning +## Version Pinning Currently, tools are installed at `@latest`. If version pinning becomes necessary for reproducibility, update the install commands to use specific versions: @@ -65,4 +60,4 @@ Currently, tools are installed at `@latest`. If version pinning becomes necessar go install github.com/swaggo/swag/cmd/swag@v1.16.6 ``` -Document any pinned versions and the reason for pinning in this file. \ No newline at end of file +Document any pinned versions and the reason for pinning in this file. diff --git a/.claude/skills/error-handling/SKILL.md b/.claude/skills/code-standards/references/error-handling.md similarity index 95% rename from .claude/skills/error-handling/SKILL.md rename to .claude/skills/code-standards/references/error-handling.md index 7d52cb1..e59e83f 100644 --- a/.claude/skills/error-handling/SKILL.md +++ b/.claude/skills/code-standards/references/error-handling.md @@ -1,9 +1,6 @@ ---- -name: error-handling -description: Skill for handling errors in code ---- +# Error Handling -## Error Handling +## Sentinel Errors - **NEVER use inline `fmt.Errorf()` calls in function returns** - Always use global error variables - Define global error variables at package level using `errors.New()`: ```go @@ -22,7 +19,7 @@ description: Skill for handling errors in code - Always wrap underlying errors with `%w` to preserve the error chain - Include relevant context (filenames, paths, etc.) between the sentinel error and wrapped error -### HTTP API Errors +## HTTP API Errors - **ALWAYS use `internal/tk/httptk` error factories for HTTP API errors** - These conform to the OCI distribution specification - Use pre-defined factory methods instead of constructing errors manually: ```go @@ -50,4 +47,4 @@ description: Skill for handling errors in code // BAD: c.Error(httptk.ErrManifestUnknown(nil)) // Linter will warn about unchecked error - ``` \ No newline at end of file + ``` diff --git a/.claude/skills/internal-api-standards/SKILL.md b/.claude/skills/code-standards/references/internal-api-standards.md similarity index 91% rename from .claude/skills/internal-api-standards/SKILL.md rename to .claude/skills/code-standards/references/internal-api-standards.md index 62090ce..0a285ad 100644 --- a/.claude/skills/internal-api-standards/SKILL.md +++ b/.claude/skills/code-standards/references/internal-api-standards.md @@ -1,13 +1,8 @@ ---- -name: internal-api-standards -description: Standards for internal management API endpoints, including DTO patterns and response type organization ---- - -## Internal API Standards +# Internal API Standards Internal APIs are the management endpoints served under `/api/`. These are distinct from the OCI Distribution API (`/v2/`) and have their own conventions for request/response types. -### DTO Requirement +## DTO Requirement **ALWAYS define dedicated DTOs (Data Transfer Objects) for API request and response types.** Never return types from `internal/` packages directly in API responses. @@ -17,7 +12,7 @@ Internal APIs are the management endpoints served under `/api/`. These are disti **Why?** Internal types carry implementation details (unexported fields, validation methods, internal tags) that should not leak into the API contract. DTOs provide a stable, versioned API surface decoupled from internal refactoring. -### DTO Package Organization +## DTO Package Organization DTOs live in `pkg/api/` and mirror the route structure under `internal/routes/apiroutes/`: @@ -34,7 +29,7 @@ pkg/api/ Each DTO file corresponds to a controller and version. Name the file to match the version (e.g., `v1.go` for v1 endpoints). -### Defining DTOs +## Defining DTOs DTOs are plain structs with JSON tags. They do not have `Validate()` methods or `koanf` tags - those belong to configuration and domain types. @@ -57,7 +52,7 @@ Naming conventions: - Response types: `Response` (e.g., `ListUpstreamsResponse`, `GetUpstreamResponse`) - Request types: `Request` (e.g., `CreateUpstreamRequest`, `UpdateUpstreamRequest`) -### Mapping in Controllers +## Mapping in Controllers Controllers are responsible for mapping between internal types and DTOs: @@ -96,14 +91,14 @@ func (c *controller) Create(ctx *gin.Context) { } ``` -### What Counts as an Internal Type +## What Counts as an Internal Type Never return any of the following directly in an API response: - Types from `internal/` packages (e.g., `registry.UpstreamRegistry`, `node.Info`) - Types from `pkg/configuration/` (e.g., `configuration.UpstreamConfiguration`) - Domain/model types that carry methods, validation, or non-JSON tags -### Swagger Annotation Requirement +## Swagger Annotation Requirement All internal API handler functions **must** have swagger annotations (`@Summary`, `@Tags`, `@Param`, `@Success`, `@Failure`, `@Router`). This is required for every handler that serves an internal API endpoint. The `/v2/` OCI distribution routes are exempt — they are intentionally hidden from swagger documentation. @@ -119,9 +114,9 @@ type GetUpstreamResponse struct { Regenerate docs after adding/changing annotations: `go generate ./cmd/barnacle/...` -### Versioning +## Versioning DTO packages are versioned alongside their routes. When a new API version is introduced: - Create a new DTO file (e.g., `v2.go`) in the same package, or a new subpackage if the surface area is large - Old DTOs remain for backwards compatibility -- Mapping functions may need to handle version differences \ No newline at end of file +- Mapping functions may need to handle version differences diff --git a/.claude/skills/api-swagger-support/references/annotation-patterns.md b/.claude/skills/code-standards/references/swagger-annotation-patterns.md similarity index 100% rename from .claude/skills/api-swagger-support/references/annotation-patterns.md rename to .claude/skills/code-standards/references/swagger-annotation-patterns.md diff --git a/.claude/skills/api-swagger-support/references/model-documentation.md b/.claude/skills/code-standards/references/swagger-model-documentation.md similarity index 100% rename from .claude/skills/api-swagger-support/references/model-documentation.md rename to .claude/skills/code-standards/references/swagger-model-documentation.md diff --git a/.claude/skills/unit-testing-patterns/SKILL.md b/.claude/skills/code-standards/references/unit-testing-patterns.md similarity index 88% rename from .claude/skills/unit-testing-patterns/SKILL.md rename to .claude/skills/code-standards/references/unit-testing-patterns.md index 34c170b..a1f38e6 100644 --- a/.claude/skills/unit-testing-patterns/SKILL.md +++ b/.claude/skills/code-standards/references/unit-testing-patterns.md @@ -1,7 +1,4 @@ ---- -name: unit-testing-patterns -description: Unit testing patterns for this project ---- +# Unit Testing Patterns When writing unit tests for this project, ensure to always follow the following patterns: * Always include a test function for each exported function or method @@ -19,7 +16,7 @@ When writing unit tests for this project, ensure to always follow the following * Use `test.CreateTestLogger(t)` to create loggers in tests (integrates with Go testing framework, follows zap best practices) * Never use `zap.NewNop()` or create loggers manually - always use test utilities * Write table-driven tests with clear input/output expectations -* Use package `_test` suffix (e.g., `configloader_test`) for external testing perspective +* Use package `_test` suffix (e.g., `configloader_test`) for external testing perspective * Include detailed error messages (expected vs. actual) -* Test every exported function and error case +* Test every exported function and error case * For external test packages, explicitly declare types when using imported packages to avoid "imported and not used" errors diff --git a/.claude/skills/development-workflow/SKILL.md b/.claude/skills/development-workflow/SKILL.md deleted file mode 100644 index 3d34e25..0000000 --- a/.claude/skills/development-workflow/SKILL.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -name: development-workflow -description: Branch naming conventions, PR workflow, and development process for this repository ---- - -## Branch Naming Conventions - -When starting work, create a branch from `main` using the appropriate prefix: - -* `feat/` - For new features (e.g., `feat/blob-replication`) -* `bug/` - For bug fixes (e.g., `bug/redis-connection-timeout`) -* `doc/` - For documentation changes (e.g., `doc/api-examples`) - -## Development Workflow - -1. **Create branch** from `main` with the appropriate prefix -2. **Do the work** - implement the feature, fix, or documentation -3. **Commit and push** - commit changes with descriptive messages, push to remote -4. **Create PR** - open a pull request targeting `main` -5. **Wait for CI** - ensure all pipelines pass -6. **Get review** - wait for code review approval -7. **Squash merge** - merge to `main` using squash merge - -## Git Commands Reference - -```bash -# Start a new feature -git checkout main -git pull origin main -git checkout -b feat/ - -# After work is complete -git add -git commit -m "feat: description of changes" -git push -u origin feat/ - -# Create PR via GitHub CLI -gh pr create --base main --title "feat: description" --body "..." -``` - -## Commit Message Style - -Follow conventional commits where appropriate: -* `feat:` - New feature -* `fix:` - Bug fix -* `doc:` or `docs:` - Documentation -* `refactor:` - Code refactoring -* `test:` - Adding or updating tests -* `chore:` - Maintenance tasks \ No newline at end of file diff --git a/Claude.md b/CLAUDE.md similarity index 67% rename from Claude.md rename to CLAUDE.md index 83452f6..eb28f62 100644 --- a/Claude.md +++ b/CLAUDE.md @@ -1,3 +1,11 @@ +# Barnacle - Claude Code Instructions + +## Skills + +- **Always** load the `code-standards` skill when working with code in this repo. It covers context usage, dependency injection, development workflow, environment tools, error handling, API swagger support, internal API standards, and unit testing patterns. +- Load the `gin-gonic` skill when working with HTTP handlers, routes, middleware, or Gin-related components. +- Load the `oci-distribution-spec` skill when working with registry protocol, OCI endpoints, or container distribution code. + ## Build Commands - `make` - Format and build project - `make fmt` - Format code using goimports @@ -13,8 +21,8 @@ - Use `make run serve` to start the server with default config - Use `make run -- ` when passing flags (the `--` separator is required before flags) - Examples: - - `make run serve` - Run with default configuration - - `make run -- serve --configDir /custom/path` - Run with custom config directory + - `make run serve` - Run with default configuration + - `make run -- serve --configDir /custom/path` - Run with custom config directory ## Test Commands - **ALWAYS use `make test` to run unit tests** - This is the standard way to run tests in this project @@ -25,8 +33,10 @@ ## Project Structure ### Core Application -- `cmd/barnacle` - CLI entrypoint and commands +- `cmd/barnacle` - CLI entrypoint and commands (serve, root) +- `cmd/e2e-imagegen` - E2E test image generator tool - `pkg/configuration` - Public configuration structs (importable by external packages) +- `pkg/api` - Public API DTOs (blobsapi, nodesapi, rebalanceapi, upstreamsapi) - `internal/configloader` - Config loading logic (YAML + envsubst + koanf with BARNACLE_ env prefix) - `internal/dependencies` - Dependency injection container with eager initialization - `internal/logsetup` - Logger initialization and configuration @@ -37,9 +47,15 @@ - `internal/routes` - Route registration and health endpoints - `internal/routes/apiroutes` - API route group (management endpoints) - `internal/routes/apiroutes/upstreamsapi` - Upstream registry management API (v1) +- `internal/routes/apiroutes/nodesapi` - Node lifecycle and health API (v1) +- `internal/routes/apiroutes/blobsapi` - Blob cache inspection API (v1) +- `internal/routes/apiroutes/rebalanceapi` - Rebalance operations API (v1) - `internal/routes/distributionroutes` - OCI Distribution API routes - `internal/routes/distributionroutes/registry` - Registry controller for OCI endpoints +### Node Management +- `internal/node` - Node management (disk usage, registry coordination) + ### Registry & Caching - `internal/registry` - Upstream registry management and factory - `internal/registry/upstream` - Upstream interface and caching wrapper @@ -49,6 +65,7 @@ - `internal/registry/cache/memory` - In-memory manifest cache with TTL - `internal/registry/cache/disk` - Disk-based blob cache with descriptor persistence - `internal/registry/cache/coordinator` - Redis-coordinated distributed blob cache +- `internal/registry/cache/coordinator/rebalance` - Blob rebalance planner, transfer, and worker ### Infrastructure - `internal/tasks` - Task runner system for managing long-running concurrent tasks @@ -57,45 +74,24 @@ ### Testing - `test/` - Test utilities shared across all tests -- `test/mocks` - Shared mock implementations (BlobCache, Task) +- `test/mocks` - Shared mock implementations (BlobCache, NodeRegistry, Task) - `test/e2e` - End-to-end tests - `hack/local` - Local development configuration (docker-compose for Redis) +- `hack/local-clustered` - Multi-node clustered local dev environment +- `hack/e2e` - E2E test infrastructure (minikube setup, manifests, config) ## Task System - `internal/tasks` provides a concurrent task runner with graceful shutdown - Tasks start immediately when added via `AddTask()` and receive a named logger - Use `Wait()` to block until all tasks complete or a signal is received +- `OneShot` task wrapper executes a function once with context cancellation support - `Repeating` task wrapper executes functions at fixed intervals ## Toolkit (tk) - `internal/tk` provides common utility functions - `HandleDeferError(fn, logger, description)` - handles defer statements that return errors - - Logs errors with context if the deferred function fails - - Example: `defer tk.HandleDeferError(file.Close, logger, "closing file")` + - Logs errors with context if the deferred function fails + - Example: `defer tk.HandleDeferError(file.Close, logger, "closing file")` - `IgnoreDeferError(fn)` - silently ignores errors from deferred functions - - Example: `defer tk.IgnoreDeferError(logger.Sync)` + - Example: `defer tk.IgnoreDeferError(logger.Sync)` - `internal/tk/httptk` provides HTTP utilities for OCI-compliant error handling - -## Code Style -- ALWAYS run `make lint` after making code changes to ensure code quality -- ALWAYS fix all linter issues before committing - zero linter warnings/errors are required -- Use `goimports` for formatting (run via `make`) -- Follow standard Go formatting conventions -- Group imports: standard library first, then third-party -- Use PascalCase for exported types/methods, camelCase for variables -- Add comments for public API and complex logic -- Place related functionality in logically named files -- Rename unused parameters to `_` to avoid linter warnings -- Use `t.Setenv()` instead of `os.Setenv()` in tests for automatic cleanup -- Use bracketed links `[errors.Is]` instead of `errors.Is()` in documentation comments -- Use `errors.Is()` and `errors.As()` for error checking -- Replace `interface{}` with `any` type alias -- Replace type assertions with type switches where appropriate -- Use generics for type-safe operations -- Add proper docstring comments for exported functions and types -- NEVER use plain integers for HTTP status codes - Always use `net/http` constants - -## Dependencies -- Minimum Go version: 1.23.0 -- External dependencies managed through go modules -