diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..a3b97ba --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,24 @@ +name: Build + +on: + pull_request: {} + push: + branches: + - main + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Clone the code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build binary + run: make build diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..3781dc4 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,45 @@ +name: E2E Tests + +on: + pull_request: {} + push: + branches: + - main + +jobs: + e2e: + name: Run on Ubuntu + runs-on: runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/extras=s3-cache + steps: + - uses: runs-on/action@v2 + + - name: Clone the code + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 + + - name: Setup Go + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6 + with: + go-version-file: go.mod + cache: true # Enable Go module caching for faster dependency downloads + + - name: Install the latest version of kind + run: | + curl -Lo ./kind https://kind.sigs.k8s.io/dl/latest/kind-linux-$(go env GOARCH) + chmod +x ./kind + sudo mv ./kind /usr/local/bin/kind + + - name: Verify kind installation + run: kind version + + - name: Create Kind cluster + run: kind create cluster --name kind --wait 5m + + - name: Verify Kind cluster + run: | + kubectl cluster-info --context kind-kind + kubectl get nodes + + - name: Running Test e2e + run: | + go mod tidy + make test-e2e diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..1117164 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,25 @@ +name: Lint + +on: + pull_request: {} + +jobs: + lint: + name: Lint + runs-on: runs-on=${{ github.run_id }}/runner=2cpu-linux-x64/extras=s3-cache + steps: + - uses: runs-on/action@v2 + + - name: Clone the code + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 + + - name: Setup Go + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6 + with: + go-version-file: go.mod + cache: true # Enable Go module caching for faster dependency downloads + + - name: Run linter + uses: golangci/golangci-lint-action@e7fa5ac41e1cf5b7d48e45e42232ce7ada589601 # v9 + with: + version: v2.5.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ea1ac20 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: Tests + +on: + pull_request: {} + +jobs: + test: + name: Test + runs-on: runs-on=${{ github.run_id }}/runner=2cpu-linux-x64/extras=s3-cache + steps: + - uses: runs-on/action@v2 + + - name: Clone the code + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 + + - name: Setup Go + uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6 + with: + go-version-file: go.mod + cache: true # Enable Go module caching for faster dependency downloads + + - name: Running Tests + run: | + go mod tidy + make test + + - name: Report coverage + run: make cover diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70084ca --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/* +dist/ +Dockerfile.cross + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +cover*.out +cover*.html + +# Go workspace file +go.work + +# Kubernetes Generated files - skip generated files, except for vendored files +!vendor/**/zz_generated.* + +# editor and IDE paraphernalia +.idea +.vscode +*.swp +*.swo +*~ + +# Kubeconfig might contain secrets +*.kubeconfig + +# Allow local config.yaml for testing +config.yaml + +# Helm chart artifacts +*.tgz +.deploy/ +charts/karve/Chart.lock +charts/karve/charts/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..dfe92e6 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,53 @@ +version: "2" +run: + allow-parallel-runners: true + timeout: 5m +linters: + default: none + enable: + - copyloopvar + - dupl + - errcheck + - ginkgolinter + - goconst + - gocyclo + - govet + - ineffassign + - lll + - misspell + - nakedret + - prealloc + - revive + - staticcheck + - unconvert + - unparam + - unused + settings: + revive: + rules: + - name: comment-spacings + - name: import-shadowing + exclusions: + generated: lax + rules: + - linters: + - lll + path: api/* + - linters: + - dupl + - lll + path: internal/* + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - gofmt + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6ecbe88 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/.DS_Store": true, + "**/Thumbs.db": true, + "**/node_modules": true + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..89f6a68 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,223 @@ +# Claude Code Instructions for Karve + +This file contains project-specific instructions for Claude Code when working on the Karve repository. + +## Project Context + +This repository is currently in active internal development but will be released as a public open-source project. All code and documentation must be written with this future in mind. + +Karve is a Kubernetes controller that optimizes Karpenter provisioning decisions by managing NodeOverlay resources based on real-time AWS Reserved Instance and Savings Plans data from Lumina. + +## Code Quality Standards + +### 1. Open Source Readiness + +**CRITICAL**: This project will be open-sourced. All code, comments, documentation, and configuration must: +- NOT contain Nextdoor-specific internal references, URLs, or domain names +- NOT include internal service names, hostnames, or infrastructure details +- NOT reference internal tools, systems, or processes specific to Nextdoor +- Use generic examples and placeholder values instead of real internal data +- Be written as if the code is already public + +Before committing any code: +1. Review all comments for internal references +2. Check configuration files for hardcoded internal values +3. Verify examples and documentation use generic/placeholder data +4. Ensure error messages don't leak internal information + +### 2. Code Coverage Requirements + +**100% code coverage is mandatory** for all code in this repository. + +Requirements: +- All packages must maintain 100% test coverage +- Use `// coverage:ignore` comments ONLY when 100% coverage is genuinely not reasonable +- Every `// coverage:ignore` must have a clear comment explaining why coverage is not possible +- CI/CD must fail if coverage drops below 100% +- When adding new code, tests must be included in the same commit/PR + +Valid reasons for `// coverage:ignore`: +- Pure data structures with no logic (e.g., type definitions) +- Unreachable error conditions in generated code +- Defensive programming checks that cannot be triggered in tests +- Platform-specific code that cannot be tested in CI environment + +Invalid reasons: +- "Hard to test" - refactor the code to make it testable +- "Takes too long" - optimize the test or use appropriate mocking +- "Edge case" - edge cases must be tested + +**Do NOT write tests for pure data structures**: Testing that struct field assignment works (e.g., `config.Field = "value"`) provides zero value. These types are covered through their usage in real tests. + +### 3. Testing Strategy + +**Integration tests are a primary focus** of this project. + +Testing requirements: +- **Unit tests**: Test individual functions and methods in isolation +- **Integration tests**: Test component interactions and real workflows + - Integration tests should test actual behavior, not mocked behavior + - Should cover realistic end-to-end scenarios (Prometheus queries, NodeOverlay management) + - Should validate error handling and edge cases +- **Table-driven tests**: Use Go's table-driven test pattern for multiple scenarios +- **Test organization**: + - Unit tests in `*_test.go` files alongside source + - Integration tests in `integration_test.go` or separate `integration/` directory +- **Test naming**: Use descriptive test names that explain what is being tested + +Integration test priorities: +1. Core functionality (Prometheus queries, cost comparison logic, NodeOverlay CRUD) +2. Error conditions and failure modes +3. Boundary conditions and edge cases +4. Concurrent access patterns (if applicable) +5. Performance characteristics (where relevant) + +## Development Workflow + +### Pull Requests + +- Follow conventional commit format for PR titles: `type(component): description` +- Open PRs in draft mode initially +- Include comprehensive descriptions explaining changes +- Reference related issues or tickets (especially RFC-0003 phases) +- Ensure all CI checks pass (including coverage) before requesting review + +### Commit Messages + +- Use conventional commits format +- Always include a component value: `feat(prometheus): add query client` +- Valid types: feat, fix, docs, test, refactor, chore, ci +- Be specific about what changed and why + +### Pre-Commit Checklist + +**MANDATORY**: Before every commit, run these commands in order: + +```bash +# 1. Run the linter to catch style issues +make lint + +# 2. Run all tests with race detection +go test -race ./... + +# 3. If both pass, stage and commit +git add +git commit -m "your message" +``` + +If either the linter or tests fail: +- Fix the issues +- Re-run both checks +- Only commit when both pass + +**Never skip these checks**. CI will fail if linting or tests fail, and you'll need to amend your commit anyway. + +## Code Review Checklist + +Before submitting code for review: +- [ ] No Nextdoor-specific references or internal data +- [ ] 100% code coverage (or justified coverage:ignore comments) +- [ ] Integration tests included for new functionality +- [ ] All tests pass locally +- [ ] Code follows Go best practices and project conventions +- [ ] Documentation updated (if applicable) +- [ ] Error messages are generic and don't leak internal info + +## When Adding New Features + +1. Write integration tests first (TDD approach encouraged) +2. Implement the feature with unit tests +3. Verify 100% coverage +4. Run full test suite including integration tests +5. Check for any internal references that need to be genericized +6. Update documentation + +## CI/CD Expectations + +The CI pipeline must enforce: +- Code coverage at 100% (fail if below) +- All tests pass (unit + integration) +- Linting passes +- No hardcoded internal references (future enhancement) +- Build succeeds + +## Documentation Style + +### README Files + +README files should be **concise and information-dense**: + +- **Be terse**: Get to the point quickly, avoid fluff and repetition +- **Minimal examples**: 1-2 short code snippets maximum +- **Focus on "what" and "why"**: Not extensive "how-to" tutorials +- **Bullet points over paragraphs**: Easy to scan +- **No dozens of code examples**: Link to godoc or tests for detailed usage + +Bad README: +```markdown +## How to Configure Karve + +First, you'll need to create a configuration file. Here's how... +[10 paragraphs of explanation] +[5 different code examples showing every possible option] +``` + +Good README: +```markdown +## Quick Start + +Create `config.yaml` with your Prometheus URL, deploy to cluster. + +See [config.example.yaml](config.example.yaml) for all options. +``` + +### Code Comments + +In contrast to READMEs, **code comments should be verbose and explain intent**: + +```go +// Good: Explains WHY, not just WHAT +// We query Prometheus every 5 minutes to match Lumina's EC2 reconciliation interval. +// This ensures we always have fresh RI/SP capacity data for our NodeOverlay decisions. +// More frequent queries waste resources; less frequent queries risk stale cost data. +interval := 5 * time.Minute + +// Bad: Just repeats the code +// Set interval to 5 minutes +interval := 5 * time.Minute +``` + +Key principles for code comments: +- Explain **intent and reasoning**, not mechanics +- Document **why decisions were made** +- Call out **non-obvious implications** +- Explain **edge cases and gotchas** +- Reference **RFC-0003 sections or external docs** when relevant + +## Karve-Specific Guidelines + +### NodeOverlay Management + +- Always use label `managed-by: karve` on all NodeOverlays we create +- Use naming convention: `cost-aware-{instance-family}` +- Set weight to 10 (higher than default 0) for cost-aware overlays +- Document decision logic clearly (why we created/updated/deleted overlay) + +### Prometheus Queries + +- Use PromQL queries that match Lumina's metric names +- Handle metric staleness gracefully (data freshness checks) +- Document which Lumina metrics we depend on + +### Cost Decision Logic + +- Document all threshold values (10% cost difference, 20% capacity buffer) +- Explain why thresholds were chosen (reference RFC-0003) +- Comment on edge cases and failure modes + +## Questions or Exceptions + +If you need to deviate from these guidelines, always: +1. Ask the user first +2. Document the reason in code comments +3. Create a TODO/FIXME if it needs to be addressed before open-sourcing diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7262e63 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Build the manager binary +FROM golang:1.24.6 as builder +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /workspace +# Copy the Go Modules manifests +COPY go.mod go.mod +COPY go.sum go.sum +# cache deps before building and copying source so that we don't need to re-download as much +# and so that source changes don't invalidate our downloaded layer +RUN go mod download + +# Copy the go source +COPY cmd/ cmd/ +COPY internal/ internal/ +COPY pkg/ pkg/ + +# Build +# the GOARCH has not a default value to allow the binary be built according to the host where the command +# was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO +# the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, +# by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go + +# Use distroless as minimal base image to package the manager binary +# Refer to https://github.com/GoogleContainerTools/distroless for more details +FROM gcr.io/distroless/static:nonroot +WORKDIR / +COPY --from=builder /workspace/manager . +USER 65532:65532 + +ENTRYPOINT ["/manager"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e503d4d --- /dev/null +++ b/Makefile @@ -0,0 +1,76 @@ +# Image URL to use all building/pushing image targets +IMG ?= karve:latest + +# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) +ifeq (,$(shell go env GOBIN)) +GOBIN=$(shell go env GOPATH)/bin +else +GOBIN=$(shell go env GOBIN) +endif + +# CONTAINER_TOOL defines the container tool to be used for building images. +CONTAINER_TOOL ?= docker + +# Setting SHELL to bash allows bash commands to be executed by recipes. +SHELL = /usr/bin/env bash -o pipefail +.SHELLFLAGS = -ec + +.PHONY: all +all: build + +##@ General + +.PHONY: help +help: ## Display this help. + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +##@ Development + +.PHONY: fmt +fmt: ## Run go fmt against code. + go fmt ./... + +.PHONY: vet +vet: ## Run go vet against code. + go vet ./... + +.PHONY: test +test: fmt vet ## Run unit tests. + go test ./... -coverprofile cover.out -covermode=atomic + +.PHONY: test-e2e +test-e2e: ## Run E2E tests (requires Kind cluster). + go test -v -tags=e2e -timeout=20m ./test/e2e/... + +.PHONY: cover +cover: ## Display test coverage report + go tool cover -func cover.out + +.PHONY: coverhtml +coverhtml: ## Generate and open HTML coverage report + go tool cover -html cover.out + +##@ Build + +.PHONY: build +build: fmt vet ## Build manager binary. + go build -o bin/manager cmd/main.go + +.PHONY: run +run: fmt vet ## Run a controller from your host. + go run ./cmd/main.go + +##@ Build Dependencies + +## Location to install dependencies to +LOCALBIN ?= $(shell pwd)/bin +$(LOCALBIN): + mkdir -p $(LOCALBIN) + +.PHONY: docker-build +docker-build: ## Build docker image with the manager. + $(CONTAINER_TOOL) build -t ${IMG} . + +.PHONY: docker-push +docker-push: ## Push docker image with the manager. + $(CONTAINER_TOOL) push ${IMG} diff --git a/README.md b/README.md index 90ecde4..34b5eb3 100644 --- a/README.md +++ b/README.md @@ -1 +1,69 @@ -# karve +# Karve + +> Cost-aware Karpenter provisioning via NodeOverlay management + +Karve is a Kubernetes controller that optimizes Karpenter provisioning decisions by managing NodeOverlay resources based on real-time AWS Reserved Instance and Savings Plans data from [Lumina](https://github.com/Nextdoor/lumina). + +## Status + +**Phase 1: In Development** - Controller skeleton and Prometheus integration in progress. + +See [RFC-0003](https://github.com/Nextdoor/cloudeng/blob/main/rfcs/RFC-0003-karpenter-cost-aware-provisioning.md) for full design. + +## Overview + +Karve watches Lumina metrics and creates/updates/deletes Karpenter NodeOverlay CRs to: +- Prefer RI/SP-covered on-demand instances when cost-effective +- Fall back to spot when RI/SP capacity exhausted +- Avoid provisioning thrashing with smart debouncing + +## Architecture + +``` +Lumina (RFC-0002) → Exposes SP/RI metrics to Prometheus + ↓ +Karve (RFC-0003) → Queries metrics, manages NodeOverlays + ↓ +Karpenter → Uses adjusted pricing for provisioning decisions +``` + +## Development + +```bash +# Build +make build + +# Run tests +make test + +# Run locally (requires kubeconfig) +make run +``` + +## Configuration + +Create `config.yaml`: + +```yaml +prometheusUrl: "http://prometheus:9090" +logLevel: "info" +``` + +See [config.example.yaml](config.example.yaml) for all options. + +## Contributing + +This project will be open-sourced. See [CLAUDE.md](CLAUDE.md) for development guidelines. + +**Requirements:** +- 100% code coverage +- Integration tests for all functionality +- No internal references + +## License + +Apache 2.0 (to be confirmed) + +## Credits + +Built by the Platform Engineering team as a companion to [Lumina](https://github.com/Nextdoor/lumina). diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..987c46b --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,152 @@ +/* +Copyright 2025 Karve Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Main entrypoint for the Karve controller manager. +// This file is scaffolded from kubebuilder and will be tested through E2E tests, not unit tests. +// +// Coverage: Excluded - main entrypoints are tested via E2E tests + +package main + +import ( + "flag" + "os" + + // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) + _ "k8s.io/client-go/plugin/pkg/client/auth" + + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + "github.com/nextdoor/karve/pkg/config" + "github.com/nextdoor/karve/pkg/prometheus" + "github.com/nextdoor/karve/pkg/reconciler" + // +kubebuilder:scaffold:imports +) + +var ( + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + + // +kubebuilder:scaffold:scheme +} + +func main() { + var metricsAddr string + var enableLeaderElection bool + var probeAddr string + var configFile string + + flag.StringVar(&configFile, "config", "/etc/karve/config.yaml", + "Path to the controller configuration file. Can be overridden with KARVE_CONFIG_PATH environment variable.") + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", + "The address the metrics endpoint binds to.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", + "The address the probe endpoint binds to.") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, + "Enable leader election for controller manager. "+ + "Enabling this will ensure there is only one active controller manager.") + + opts := zap.Options{ + Development: true, + } + opts.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + + // Allow environment variable to override config file path + if envConfigPath := os.Getenv("KARVE_CONFIG_PATH"); envConfigPath != "" { + configFile = envConfigPath + } + + // Load controller configuration + cfg, err := config.Load(configFile) + if err != nil { + if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) { + setupLog.Info("config file not found, using defaults", "config-file", configFile) + cfg = &config.Config{} + } else { + setupLog.Error(err, "failed to load configuration", "config-file", configFile) + os.Exit(1) + } + } else { + setupLog.Info("loaded configuration", + "prometheus-url", cfg.PrometheusURL, + "log-level", cfg.LogLevel) + } + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: metricsAddr, + }, + HealthProbeBindAddress: probeAddr, + LeaderElection: enableLeaderElection, + LeaderElectionID: "karve.nextdoor.com", + }) + if err != nil { + setupLog.Error(err, "unable to start manager") + os.Exit(1) + } + + // +kubebuilder:scaffold:builder + + // Create Prometheus client for querying Lumina metrics + promClient, err := prometheus.NewClient(cfg.PrometheusURL) + if err != nil { + setupLog.Error(err, "unable to create Prometheus client", "url", cfg.PrometheusURL) + os.Exit(1) + } + + // Create and start metrics reconciler + metricsReconciler := &reconciler.MetricsReconciler{ + PrometheusClient: promClient, + Logger: ctrl.Log.WithName("metrics-reconciler"), + // Use default 5 minute interval + } + + // Add metrics reconciler as a runnable + if err := mgr.Add(metricsReconciler); err != nil { + setupLog.Error(err, "unable to add metrics reconciler to manager") + os.Exit(1) + } + + // Setup health checks + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up health check") + os.Exit(1) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up ready check") + os.Exit(1) + } + + setupLog.Info("starting manager") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + setupLog.Error(err, "problem running manager") + os.Exit(1) + } +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..6615b7e --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,23 @@ +# Karve Controller Configuration Example +# +# This file shows all available configuration options with their default values. +# Copy this to config.yaml and modify as needed. + +# Prometheus URL for querying Lumina metrics +# Can be overridden with KARVE_PROMETHEUS_URL environment variable +prometheusUrl: "http://prometheus:9090" + +# Log level: debug, info, warn, or error +# Default: info +# Can be overridden with KARVE_LOG_LEVEL environment variable +logLevel: "info" + +# Metrics endpoint bind address +# Default: :8080 +# Can be overridden with KARVE_METRICS_BIND_ADDRESS environment variable +metricsBindAddress: ":8080" + +# Health probe endpoint bind address +# Default: :8081 +# Can be overridden with KARVE_HEALTH_PROBE_BIND_ADDRESS environment variable +healthProbeBindAddress: ":8081" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d68f0f2 --- /dev/null +++ b/go.mod @@ -0,0 +1,83 @@ +module github.com/nextdoor/karve + +go 1.24.4 + +require ( + github.com/go-logr/logr v1.4.3 + github.com/onsi/ginkgo/v2 v2.27.2 + github.com/onsi/gomega v1.38.2 + github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/common v0.66.1 + github.com/spf13/viper v1.21.0 + k8s.io/api v0.34.2 + k8s.io/apimachinery v0.34.2 + k8s.io/client-go v0.34.2 + sigs.k8s.io/controller-runtime v0.22.4 +) + +require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/term v0.34.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/time v0.9.0 // indirect + golang.org/x/tools v0.36.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.34.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7d1f132 --- /dev/null +++ b/go.sum @@ -0,0 +1,246 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +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-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +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/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +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-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/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/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +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/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-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +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.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +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= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/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/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +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/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.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +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.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +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= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +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.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY= +k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw= +k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= +k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= +k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= +k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M= +k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= +sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/testutil/README.md b/internal/testutil/README.md new file mode 100644 index 0000000..4b0121c --- /dev/null +++ b/internal/testutil/README.md @@ -0,0 +1,177 @@ +# Test Utilities + +This package provides testing utilities for Karve, including mock Prometheus servers and test fixtures for Lumina metrics. + +## Mock Prometheus Server + +The `MockPrometheusServer` allows testing Karve's Prometheus client without running actual Lumina or Prometheus instances. + +### Basic Usage + +```go +package mypackage + +import ( + "testing" + "github.com/nextdoor/karve/internal/testutil" +) + +func TestMyFunction(t *testing.T) { + // Create mock server + server := testutil.NewMockPrometheusServer() + defer server.Close() + + // Load metrics fixtures + server.SetMetrics(testutil.LuminaMetricsWithSPCapacity()) + + // Use server.URL with your Prometheus client + client := prometheus.NewClient(server.URL) + + // Query metrics as normal + result, err := client.Query("savings_plan_remaining_capacity{instance_family=\"m5\"}") + // ... assertions ... +} +``` + +### Available Fixtures + +| Fixture | Description | Use Case | +|---------|-------------|----------| +| `LuminaMetricsWithSPCapacity()` | SP capacity available ($50/hr m5, $30/hr c5) | Test "prefer RI/SP" path | +| `LuminaMetricsWithNoCapacity()` | SP capacity exhausted (all 0) | Test "prefer spot" path | +| `LuminaMetricsEmpty()` | No metrics available | Test error handling | +| `LuminaMetricsWithSpotPrices()` | Spot pricing data | Test cost comparison | + +### Multiple Fixtures + +Load multiple fixtures to simulate complex scenarios: + +```go +server.SetMetrics( + testutil.LuminaMetricsWithSPCapacity(), + testutil.LuminaMetricsWithSpotPrices(), +) +``` + +### Custom Fixtures + +Create custom fixtures for specific test scenarios: + +```go +customMetrics := testutil.MetricFixture{ + `my_custom_query`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": {"label": "value"}, + "value": [1640000000, "123.45"] + } + ] + } + }`, +} + +server.SetMetrics(customMetrics) +``` + +### Clearing Metrics + +Reset server state between tests: + +```go +server.ClearMetrics() +server.SetMetrics(testutil.LuminaMetricsWithNoCapacity()) +``` + +## Metric Formats + +All fixtures return data in Prometheus HTTP API format: + +```json +{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "type": "ec2_instance", + "instance_family": "m5", + "account_id": "123456789012" + }, + "value": [1640000000, "50.00"] + } + ] + } +} +``` + +## Integration Testing Example + +Complete example showing how to test cost decision logic: + +```go +func TestCostDecision_PreferRISP(t *testing.T) { + // Setup mock Prometheus with available capacity + server := testutil.NewMockPrometheusServer() + defer server.Close() + + server.SetMetrics( + testutil.LuminaMetricsWithSPCapacity(), + testutil.LuminaMetricsWithSpotPrices(), + ) + + // Create controller with mock Prometheus URL + cfg := &config.Config{ + PrometheusURL: server.URL, + } + + controller := NewCostController(cfg) + + // Execute decision logic + decision, err := controller.MakeDecision("m5") + if err != nil { + t.Fatalf("MakeDecision failed: %v", err) + } + + // Verify decision prefers RI/SP due to available capacity + if decision != PreferRISP { + t.Errorf("expected PreferRISP, got %v", decision) + } +} +``` + +## E2E Testing + +For E2E tests, use the same mock server but run in a test cluster: + +```go +func TestE2E_NodeOverlayCreation(t *testing.T) { + // Create mock server + server := testutil.NewMockPrometheusServer() + defer server.Close() + server.SetMetrics(testutil.LuminaMetricsWithSPCapacity()) + + // Deploy Karve with mock Prometheus URL + helmArgs := fmt.Sprintf( + "--set prometheusUrl=%s", + server.URL, + ) + + // ... deploy to test cluster and verify NodeOverlay creation ... +} +``` + +## Design Rationale + +This approach was chosen over config-based test data because: + +1. **Tests actual code paths**: The Prometheus client code is exercised in tests +2. **No production pollution**: Test data doesn't leak into production config +3. **Flexible scenarios**: Easy to create complex multi-query scenarios +4. **Realistic**: Simulates actual Prometheus HTTP API responses +5. **Reusable**: Same fixtures work for unit, integration, and E2E tests + +Compare to Lumina's approach where test data goes in config YAML - that works for AWS APIs but not well for HTTP-based metric queries. diff --git a/internal/testutil/prometheus.go b/internal/testutil/prometheus.go new file mode 100644 index 0000000..a637329 --- /dev/null +++ b/internal/testutil/prometheus.go @@ -0,0 +1,427 @@ +/* +Copyright 2025 Karve Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package testutil provides testing utilities for Karve, including mock Prometheus servers +// and test fixtures for Lumina metrics. +// +// This package is designed to simulate Lumina's output without requiring a running Lumina +// instance or real Prometheus server during tests. +package testutil + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" +) + +// MockPrometheusServer creates an in-memory HTTP server that responds to Prometheus API queries +// with predefined metric data. This allows testing Karve's Prometheus client without running +// actual Lumina or Prometheus instances. +// +// The server supports: +// - /api/v1/query - Instant queries +// - /api/v1/query_range - Range queries (returns same data as instant for simplicity) +// +// Usage: +// +// server := testutil.NewMockPrometheusServer() +// server.SetMetrics(testutil.LuminaMetricsWithSPCapacity()) +// defer server.Close() +// +// // Use server.URL in your Prometheus client +// client := prometheus.NewClient(server.URL) +type MockPrometheusServer struct { + *httptest.Server + metrics map[string]string // query -> response JSON +} + +// NewMockPrometheusServer creates a new mock Prometheus server with no metrics loaded. +// Use SetMetrics() to load test data before making queries. +func NewMockPrometheusServer() *MockPrometheusServer { + mock := &MockPrometheusServer{ + metrics: make(map[string]string), + } + + // Create HTTP server with handler + mock.Server = httptest.NewServer(http.HandlerFunc(mock.handler)) + + return mock +} + +// SetMetrics loads metric fixtures into the mock server. +// The fixtures should be created using the MetricFixture functions below. +// +// Example: +// +// server.SetMetrics( +// testutil.LuminaMetricsWithSPCapacity(), +// testutil.LuminaMetricsWithNoCapacity(), +// ) +func (m *MockPrometheusServer) SetMetrics(fixtures ...MetricFixture) { + for _, fixture := range fixtures { + for query, response := range fixture { + m.metrics[query] = response + } + } +} + +// ClearMetrics removes all loaded metrics from the server. +// Useful for resetting state between tests. +func (m *MockPrometheusServer) ClearMetrics() { + m.metrics = make(map[string]string) +} + +// handler processes Prometheus API requests and returns mocked responses. +// Supports both instant queries (/api/v1/query) and range queries (/api/v1/query_range). +func (m *MockPrometheusServer) handler(w http.ResponseWriter, r *http.Request) { + // Parse query parameter - handle both GET (query param) and POST (form body) + var query string + if r.Method == http.MethodPost { + // For POST requests, the Prometheus client sends form-encoded data + if err := r.ParseForm(); err != nil { + http.Error(w, `{"status":"error","errorType":"bad_data","error":"failed to parse form"}`, http.StatusBadRequest) + return + } + query = r.FormValue("query") + } else { + // For GET requests, query is in URL parameter + query = r.URL.Query().Get("query") + } + if query == "" { + http.Error(w, `{"status":"error","errorType":"bad_data","error":"query missing"}`, http.StatusBadRequest) + return + } + + // Normalize query (remove extra whitespace for matching) + query = strings.TrimSpace(query) + + // Look up response + response, ok := m.metrics[query] + if !ok { + // Return empty result set if query not found (not an error) + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"status":"success","data":{"resultType":"vector","result":[]}}`) + return + } + + // Return mocked response + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, response) +} + +// MetricFixture represents a set of Prometheus queries and their responses. +// Keys are PromQL queries, values are JSON responses in Prometheus API format. +type MetricFixture map[string]string + +// LuminaMetricsWithSPCapacity returns metrics showing available Savings Plans capacity. +// Scenario: m5 family has $50/hour remaining capacity, c5 has $30/hour remaining. +// +// Use this fixture when testing the "prefer RI/SP" decision path. +func LuminaMetricsWithSPCapacity() MetricFixture { + return MetricFixture{ + // Query with instance_family selector + `savings_plan_remaining_capacity{instance_family="m5"}`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "type": "ec2_instance", + "instance_family": "m5", + "savings_plan_arn": "arn:aws:savingsplans::123456789012:savingsplan/sp-12345", + "account_id": "123456789012" + }, + "value": [1640000000, "50.00"] + } + ] + } + }`, + + // Query without selector (all families) + `savings_plan_remaining_capacity`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "type": "ec2_instance", + "instance_family": "m5", + "savings_plan_arn": "arn:aws:savingsplans::123456789012:savingsplan/sp-12345", + "account_id": "123456789012" + }, + "value": [1640000000, "50.00"] + }, + { + "metric": { + "type": "compute", + "instance_family": "c5", + "savings_plan_arn": "arn:aws:savingsplans::123456789012:savingsplan/sp-67890", + "account_id": "123456789012" + }, + "value": [1640000000, "30.00"] + } + ] + } + }`, + + // Old query format (kept for backwards compat) + `savings_plan_remaining_capacity{type="ec2_instance",instance_family="m5"}`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "type": "ec2_instance", + "instance_family": "m5", + "savings_plan_arn": "arn:aws:savingsplans::123456789012:savingsplan/sp-12345", + "account_id": "123456789012" + }, + "value": [1640000000, "50.00"] + } + ] + } + }`, + + // Query: savings_plan_remaining_capacity{type="compute"} + `savings_plan_remaining_capacity{type="compute"}`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "type": "compute", + "instance_family": "c5", + "savings_plan_arn": "arn:aws:savingsplans::123456789012:savingsplan/sp-67890", + "account_id": "123456789012" + }, + "value": [1640000000, "30.00"] + } + ] + } + }`, + + // Query: ec2_reserved_instance (all instance types) + `ec2_reserved_instance`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "account_id": "123456789012", + "region": "us-west-2", + "instance_type": "m5.xlarge", + "availability_zone": "us-west-2a" + }, + "value": [1640000000, "1"] + } + ] + } + }`, + + // Query: ec2_reserved_instance{instance_type="m5.xlarge"} + `ec2_reserved_instance{instance_type="m5.xlarge"}`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "account_id": "123456789012", + "region": "us-west-2", + "instance_type": "m5.xlarge", + "availability_zone": "us-west-2a" + }, + "value": [1640000000, "1"] + } + ] + } + }`, + } +} + +// LuminaMetricsWithNoCapacity returns metrics showing exhausted Savings Plans capacity. +// Scenario: All SP capacity is fully utilized (0 remaining). +// +// Use this fixture when testing the "prefer spot" decision path. +func LuminaMetricsWithNoCapacity() MetricFixture { + return MetricFixture{ + // Query with instance_family selector + `savings_plan_remaining_capacity{instance_family="m5"}`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "type": "ec2_instance", + "instance_family": "m5", + "savings_plan_arn": "arn:aws:savingsplans::123456789012:savingsplan/sp-12345", + "account_id": "123456789012" + }, + "value": [1640000000, "0.00"] + } + ] + } + }`, + + // Old format + `savings_plan_remaining_capacity{type="ec2_instance",instance_family="m5"}`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "type": "ec2_instance", + "instance_family": "m5", + "savings_plan_arn": "arn:aws:savingsplans::123456789012:savingsplan/sp-12345", + "account_id": "123456789012" + }, + "value": [1640000000, "0.00"] + } + ] + } + }`, + + `ec2_reserved_instance`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [] + } + }`, + } +} + +// LuminaMetricsEmpty returns empty metric results. +// Scenario: Lumina is running but has no data yet (initial startup). +// +// Use this fixture when testing error handling and edge cases. +func LuminaMetricsEmpty() MetricFixture { + return MetricFixture{ + // With selector + `savings_plan_remaining_capacity{instance_family="m5"}`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [] + } + }`, + + // Old format + `savings_plan_remaining_capacity{type="ec2_instance",instance_family="m5"}`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [] + } + }`, + + `ec2_reserved_instance`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [] + } + }`, + } +} + +// LuminaMetricsWithSpotPrices returns metrics including spot pricing data. +// Scenario: Spot prices available for m5 family instances. +// +// Use this fixture when testing cost comparison logic (spot vs RI/SP). +func LuminaMetricsWithSpotPrices() MetricFixture { + return MetricFixture{ + // Spot pricing for m5.xlarge with selector + `ec2_spot_price{instance_type="m5.xlarge"}`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "instance_type": "m5.xlarge", + "region": "us-west-2", + "availability_zone": "us-west-2a" + }, + "value": [1640000000, "0.12"] + } + ] + } + }`, + + // Spot pricing (old format) + `ec2_spot_price{instance_type="m5.xlarge",region="us-west-2"}`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "instance_type": "m5.xlarge", + "region": "us-west-2", + "availability_zone": "us-west-2a" + }, + "value": [1640000000, "0.12"] + } + ] + } + }`, + + // On-demand pricing with selector + `ec2_ondemand_price{instance_type="m5.xlarge"}`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "instance_type": "m5.xlarge", + "region": "us-west-2", + "operating_system": "Linux" + }, + "value": [1640000000, "0.192"] + } + ] + } + }`, + + // On-demand pricing (old format) + `ec2_ondemand_price{instance_type="m5.xlarge",region="us-west-2"}`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": { + "instance_type": "m5.xlarge", + "region": "us-west-2", + "operating_system": "Linux" + }, + "value": [1640000000, "0.192"] + } + ] + } + }`, + } +} diff --git a/internal/testutil/prometheus_test.go b/internal/testutil/prometheus_test.go new file mode 100644 index 0000000..228f4f5 --- /dev/null +++ b/internal/testutil/prometheus_test.go @@ -0,0 +1,224 @@ +/* +Copyright 2025 Karve Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package testutil + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "testing" +) + +func TestMockPrometheusServer(t *testing.T) { + server := NewMockPrometheusServer() + defer server.Close() + + // Load test metrics + server.SetMetrics(LuminaMetricsWithSPCapacity()) + + // Test querying loaded metric + query := `savings_plan_remaining_capacity{type="ec2_instance",instance_family="m5"}` + resp, err := http.Get(fmt.Sprintf("%s/api/v1/query?query=%s", server.URL, url.QueryEscape(query))) + if err != nil { + t.Fatalf("failed to query server: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Errorf("unexpected status code: got %d, want %d", resp.StatusCode, http.StatusOK) + } + + // Verify response is valid JSON + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + // Check response structure + status, ok := result["status"].(string) + if !ok || status != "success" { + t.Errorf("unexpected status: got %v, want 'success'", result["status"]) + } + + data, ok := result["data"].(map[string]interface{}) + if !ok { + t.Fatal("response missing 'data' field") + } + + results, ok := data["result"].([]interface{}) + if !ok { + t.Fatal("response data missing 'result' array") + } + + if len(results) != 1 { + t.Errorf("unexpected result count: got %d, want 1", len(results)) + } +} + +func TestMockPrometheusServer_UnknownQuery(t *testing.T) { + server := NewMockPrometheusServer() + defer server.Close() + + server.SetMetrics(LuminaMetricsWithSPCapacity()) + + // Query that wasn't loaded + query := "nonexistent_metric" + resp, err := http.Get(fmt.Sprintf("%s/api/v1/query?query=%s", server.URL, url.QueryEscape(query))) + if err != nil { + t.Fatalf("failed to query server: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Errorf("unexpected status code: got %d, want %d", resp.StatusCode, http.StatusOK) + } + + // Should return empty result set (not an error) + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + status, ok := result["status"].(string) + if !ok || status != "success" { + t.Errorf("unexpected status: got %v, want 'success'", result["status"]) + } + + data := result["data"].(map[string]interface{}) + results := data["result"].([]interface{}) + if len(results) != 0 { + t.Errorf("expected empty results for unknown query, got %d results", len(results)) + } +} + +func TestMockPrometheusServer_MissingQuery(t *testing.T) { + server := NewMockPrometheusServer() + defer server.Close() + + // No query parameter + resp, err := http.Get(fmt.Sprintf("%s/api/v1/query", server.URL)) + if err != nil { + t.Fatalf("failed to query server: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("unexpected status code: got %d, want %d", resp.StatusCode, http.StatusBadRequest) + } +} + +func TestMockPrometheusServer_ClearMetrics(t *testing.T) { + server := NewMockPrometheusServer() + defer server.Close() + + // Load metrics + server.SetMetrics(LuminaMetricsWithSPCapacity()) + + // Verify metric exists + query := `savings_plan_remaining_capacity{type="ec2_instance",instance_family="m5"}` + resp, _ := http.Get(fmt.Sprintf("%s/api/v1/query?query=%s", server.URL, url.QueryEscape(query))) + var result1 map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result1); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + _ = resp.Body.Close() + + data1 := result1["data"].(map[string]interface{}) + results1 := data1["result"].([]interface{}) + if len(results1) != 1 { + t.Errorf("expected 1 result before clear, got %d", len(results1)) + } + + // Clear metrics + server.ClearMetrics() + + // Verify metric is gone + resp2, _ := http.Get(fmt.Sprintf("%s/api/v1/query?query=%s", server.URL, url.QueryEscape(query))) + var result2 map[string]interface{} + if err := json.NewDecoder(resp2.Body).Decode(&result2); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + _ = resp2.Body.Close() + + data2 := result2["data"].(map[string]interface{}) + results2 := data2["result"].([]interface{}) + if len(results2) != 0 { + t.Errorf("expected 0 results after clear, got %d", len(results2)) + } +} + +func TestLuminaMetricsFixtures(t *testing.T) { + tests := []struct { + name string + fixture MetricFixture + query string + wantLen int // expected number of results + }{ + { + name: "with SP capacity", + fixture: LuminaMetricsWithSPCapacity(), + query: `savings_plan_remaining_capacity{type="ec2_instance",instance_family="m5"}`, + wantLen: 1, + }, + { + name: "no capacity", + fixture: LuminaMetricsWithNoCapacity(), + query: `savings_plan_remaining_capacity{type="ec2_instance",instance_family="m5"}`, + wantLen: 1, // Returns result with value "0.00" + }, + { + name: "empty metrics", + fixture: LuminaMetricsEmpty(), + query: `savings_plan_remaining_capacity{type="ec2_instance",instance_family="m5"}`, + wantLen: 0, // Empty result array + }, + { + name: "spot prices", + fixture: LuminaMetricsWithSpotPrices(), + query: `ec2_spot_price{instance_type="m5.xlarge",region="us-west-2"}`, + wantLen: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := NewMockPrometheusServer() + defer server.Close() + + server.SetMetrics(tt.fixture) + + resp, err := http.Get(fmt.Sprintf("%s/api/v1/query?query=%s", server.URL, url.QueryEscape(tt.query))) + if err != nil { + t.Fatalf("failed to query server: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + data := result["data"].(map[string]interface{}) + results := data["result"].([]interface{}) + + if len(results) != tt.wantLen { + t.Errorf("unexpected result count: got %d, want %d", len(results), tt.wantLen) + } + }) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..72b6401 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,133 @@ +/* +Copyright 2025 Karve Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package config provides configuration management for the Karve controller. +// +// Configuration can be loaded from YAML files or environment variables. +// Uses Viper for robust configuration management with automatic env binding. +package config + +import ( + "fmt" + + "github.com/spf13/viper" +) + +// Configuration key constants for viper SetDefault and BindEnv calls. +const ( + KeyPrometheusURL = "prometheusUrl" + KeyLogLevel = "logLevel" + KeyMetricsBindAddress = "metricsBindAddress" + KeyHealthProbeBindAddress = "healthProbeBindAddress" +) + +// Environment variable name constants. +const ( + EnvPrometheusURL = "KARVE_PROMETHEUS_URL" + EnvLogLevel = "KARVE_LOG_LEVEL" + EnvMetricsBindAddress = "KARVE_METRICS_BIND_ADDRESS" + EnvHealthProbeBindAddress = "KARVE_HEALTH_PROBE_BIND_ADDRESS" + EnvPrefix = "KARVE" +) + +// Default configuration values. +const ( + DefaultPrometheusURL = "http://prometheus:9090" + DefaultLogLevel = "info" + DefaultMetricsBindAddress = ":8080" + DefaultHealthProbeBindAddress = ":8081" +) + +// Config represents the complete controller configuration. +type Config struct { + // PrometheusURL is the URL of the Prometheus server to query for Lumina metrics. + PrometheusURL string `yaml:"prometheusUrl,omitempty"` + + // LogLevel controls the verbosity of logs. + // Valid values: debug, info, warn, error + LogLevel string `yaml:"logLevel,omitempty"` + + // MetricsBindAddress is the address the metrics endpoint binds to. + MetricsBindAddress string `yaml:"metricsBindAddress,omitempty"` + + // HealthProbeBindAddress is the address the health probe endpoint binds to. + HealthProbeBindAddress string `yaml:"healthProbeBindAddress,omitempty"` +} + +// Load loads configuration from a YAML file and validates it. +// +// Configuration precedence (highest to lowest): +// 1. Environment variables (KARVE_* prefix) +// 2. Configuration file values +// 3. Default values +func Load(path string) (*Config, error) { + v := viper.New() + + // Set configuration file + v.SetConfigFile(path) + + // Set default values + v.SetDefault(KeyPrometheusURL, DefaultPrometheusURL) + v.SetDefault(KeyLogLevel, DefaultLogLevel) + v.SetDefault(KeyMetricsBindAddress, DefaultMetricsBindAddress) + v.SetDefault(KeyHealthProbeBindAddress, DefaultHealthProbeBindAddress) + + // Enable environment variable overrides with KARVE_ prefix + v.SetEnvPrefix(EnvPrefix) + _ = v.BindEnv(KeyPrometheusURL, EnvPrometheusURL) + _ = v.BindEnv(KeyLogLevel, EnvLogLevel) + _ = v.BindEnv(KeyMetricsBindAddress, EnvMetricsBindAddress) + _ = v.BindEnv(KeyHealthProbeBindAddress, EnvHealthProbeBindAddress) + + // Read configuration file + if err := v.ReadInConfig(); err != nil { + return nil, fmt.Errorf("failed to read config file %s: %w", path, err) + } + + // Unmarshal into Config struct + var cfg Config + if err := v.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("failed to parse config file %s: %w", path, err) + } + + // Validate configuration + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) + } + + return &cfg, nil +} + +// Validate checks that the configuration is valid and returns an error if not. +func (c *Config) Validate() error { + // Validate Prometheus URL + if c.PrometheusURL == "" { + return fmt.Errorf("prometheus URL is required") + } + + // Validate log level + validLogLevels := map[string]bool{ + "debug": true, + "info": true, + "warn": true, + "error": true, + } + if c.LogLevel != "" && !validLogLevels[c.LogLevel] { + return fmt.Errorf("invalid log level %q, must be one of: debug, info, warn, error", c.LogLevel) + } + + return nil +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..3bf14c7 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,194 @@ +/* +Copyright 2025 Karve Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad(t *testing.T) { + tests := []struct { + name string + configYAML string + wantErr bool + validate func(*testing.T, *Config) + }{ + { + name: "valid config with all fields", + configYAML: ` +prometheusUrl: "http://prometheus:9090" +logLevel: "debug" +metricsBindAddress: ":8080" +healthProbeBindAddress: ":8081" +`, + wantErr: false, + validate: func(t *testing.T, c *Config) { + if c.PrometheusURL != "http://prometheus:9090" { + t.Errorf("PrometheusURL = %q, want %q", c.PrometheusURL, "http://prometheus:9090") + } + if c.LogLevel != "debug" { + t.Errorf("LogLevel = %q, want %q", c.LogLevel, "debug") + } + }, + }, + { + name: "minimal valid config", + configYAML: ` +prometheusUrl: "http://prom:9090" +`, + wantErr: false, + validate: func(t *testing.T, c *Config) { + if c.PrometheusURL != "http://prom:9090" { + t.Errorf("PrometheusURL = %q, want %q", c.PrometheusURL, "http://prom:9090") + } + // Check defaults + if c.LogLevel != DefaultLogLevel { + t.Errorf("LogLevel = %q, want default %q", c.LogLevel, DefaultLogLevel) + } + }, + }, + { + name: "invalid log level", + configYAML: ` +prometheusUrl: "http://prom:9090" +logLevel: "invalid" +`, + wantErr: true, + }, + { + name: "missing prometheus URL uses default", + configYAML: ` +logLevel: "info" +`, + wantErr: false, + validate: func(t *testing.T, c *Config) { + if c.PrometheusURL != DefaultPrometheusURL { + t.Errorf("PrometheusURL = %q, want default %q", c.PrometheusURL, DefaultPrometheusURL) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary config file + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + if err := os.WriteFile(configPath, []byte(tt.configYAML), 0644); err != nil { + t.Fatalf("failed to write temp config: %v", err) + } + + // Load config + cfg, err := Load(configPath) + if (err != nil) != tt.wantErr { + t.Errorf("Load() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr && tt.validate != nil { + tt.validate(t, cfg) + } + }) + } +} + +func TestValidate(t *testing.T) { + tests := []struct { + name string + config Config + wantErr bool + }{ + { + name: "valid config", + config: Config{ + PrometheusURL: "http://prometheus:9090", + LogLevel: "info", + }, + wantErr: false, + }, + { + name: "invalid log level", + config: Config{ + PrometheusURL: "http://prometheus:9090", + LogLevel: "trace", + }, + wantErr: true, + }, + { + name: "all valid log levels", + config: Config{ + PrometheusURL: "http://prometheus:9090", + LogLevel: "debug", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestLoadNonexistentFile(t *testing.T) { + _, err := Load("/nonexistent/config.yaml") + if err == nil { + t.Error("Load() expected error for nonexistent file, got nil") + } +} + +func TestValidateEmptyPrometheusURL(t *testing.T) { + config := Config{ + PrometheusURL: "", + LogLevel: "info", + } + err := config.Validate() + if err == nil { + t.Error("Validate() expected error for empty PrometheusURL, got nil") + } +} + +func TestEnvironmentVariableOverrides(t *testing.T) { + // Create a temporary config file + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + configYAML := `prometheusUrl: "http://default:9090"` + if err := os.WriteFile(configPath, []byte(configYAML), 0644); err != nil { + t.Fatalf("failed to write temp config: %v", err) + } + + // Set environment variable + _ = os.Setenv("KARVE_PROMETHEUS_URL", "http://override:9090") + defer func() { _ = os.Unsetenv("KARVE_PROMETHEUS_URL") }() + + // Load config + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + // Environment variable should override file value + if cfg.PrometheusURL != "http://override:9090" { + t.Errorf("PrometheusURL = %q, want %q (env var override)", cfg.PrometheusURL, "http://override:9090") + } +} diff --git a/pkg/prometheus/client.go b/pkg/prometheus/client.go new file mode 100644 index 0000000..957d4bd --- /dev/null +++ b/pkg/prometheus/client.go @@ -0,0 +1,367 @@ +/* +Copyright 2025 Karve Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package prometheus provides a client for querying Prometheus to retrieve Lumina metrics. +// +// The client abstracts the Prometheus HTTP API and provides typed methods for querying +// specific Lumina metrics needed for cost optimization decisions. +package prometheus + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/prometheus/client_golang/api" + v1 "github.com/prometheus/client_golang/api/prometheus/v1" + "github.com/prometheus/common/model" +) + +// Client is a Prometheus client for querying Lumina metrics. +// It wraps the official Prometheus Go client and provides typed methods +// for the specific metrics Karve needs. +type Client struct { + api v1.API +} + +// NewClient creates a new Prometheus client. +// The url parameter should be the base URL of the Prometheus server +// (e.g., "http://prometheus:9090"). +func NewClient(url string) (*Client, error) { + promClient, err := api.NewClient(api.Config{ + Address: url, + }) + if err != nil { + return nil, fmt.Errorf("failed to create Prometheus client: %w", err) + } + + return &Client{ + api: v1.NewAPI(promClient), + }, nil +} + +// SavingsPlanCapacity represents remaining Savings Plan capacity for an instance family. +type SavingsPlanCapacity struct { + // Type is the Savings Plan type ("ec2_instance" or "compute") + Type string + + // InstanceFamily is the EC2 instance family (e.g., "m5", "c5") + InstanceFamily string + + // SavingsPlanARN is the ARN of the Savings Plan + SavingsPlanARN string + + // AccountID is the AWS account ID + AccountID string + + // RemainingCapacity is the remaining capacity in $/hour + RemainingCapacity float64 + + // Timestamp is when this metric was recorded + Timestamp time.Time +} + +// ReservedInstance represents a Reserved Instance from Lumina metrics. +type ReservedInstance struct { + // AccountID is the AWS account ID + AccountID string + + // Region is the AWS region + Region string + + // InstanceType is the EC2 instance type (e.g., "m5.xlarge") + InstanceType string + + // AvailabilityZone is the AZ where the RI is available + AvailabilityZone string + + // Count is the number of RIs (typically 1 per metric) + Count int + + // Timestamp is when this metric was recorded + Timestamp time.Time +} + +// QuerySavingsPlanCapacity queries Prometheus for Savings Plan remaining capacity. +// The instanceFamily parameter filters results (e.g., "m5", "c5"). +// Pass empty string to get all instance families. +// +// This queries: savings_plan_remaining_capacity{instance_family="$family"} +func (c *Client) QuerySavingsPlanCapacity(ctx context.Context, instanceFamily string) ([]SavingsPlanCapacity, error) { + // Build query + var query string + if instanceFamily != "" { + query = fmt.Sprintf(`savings_plan_remaining_capacity{instance_family="%s"}`, instanceFamily) + } else { + query = `savings_plan_remaining_capacity` + } + + // Execute query + result, warnings, err := c.api.Query(ctx, query, time.Now()) + if err != nil { + return nil, fmt.Errorf("prometheus query failed: %w", err) + } + + // Log warnings if any + if len(warnings) > 0 { + // In production code, use a proper logger here + // For now, warnings are silently ignored + _ = warnings + } + + // Parse results + vector, ok := result.(model.Vector) + if !ok { + return nil, fmt.Errorf("unexpected result type: %T", result) + } + + capacities := make([]SavingsPlanCapacity, 0, len(vector)) + for _, sample := range vector { + capacity := SavingsPlanCapacity{ + Type: string(sample.Metric["type"]), + InstanceFamily: string(sample.Metric["instance_family"]), + SavingsPlanARN: string(sample.Metric["savings_plan_arn"]), + AccountID: string(sample.Metric["account_id"]), + RemainingCapacity: float64(sample.Value), + Timestamp: sample.Timestamp.Time(), + } + capacities = append(capacities, capacity) + } + + return capacities, nil +} + +// QueryReservedInstances queries Prometheus for Reserved Instances. +// The instanceType parameter filters results (e.g., "m5.xlarge"). +// Pass empty string to get all instance types. +// +// This queries: ec2_reserved_instance{instance_type="$type"} +func (c *Client) QueryReservedInstances(ctx context.Context, instanceType string) ([]ReservedInstance, error) { + // Build query + var query string + if instanceType != "" { + query = fmt.Sprintf(`ec2_reserved_instance{instance_type="%s"}`, instanceType) + } else { + query = `ec2_reserved_instance` + } + + // Execute query + result, warnings, err := c.api.Query(ctx, query, time.Now()) + if err != nil { + return nil, fmt.Errorf("prometheus query failed: %w", err) + } + + // Log warnings if any + if len(warnings) > 0 { + _ = warnings + } + + // Parse results + vector, ok := result.(model.Vector) + if !ok { + return nil, fmt.Errorf("unexpected result type: %T", result) + } + + ris := make([]ReservedInstance, 0, len(vector)) + for _, sample := range vector { + // Parse count (the metric value represents the count) + count := int(sample.Value) + + ri := ReservedInstance{ + AccountID: string(sample.Metric["account_id"]), + Region: string(sample.Metric["region"]), + InstanceType: string(sample.Metric["instance_type"]), + AvailabilityZone: string(sample.Metric["availability_zone"]), + Count: count, + Timestamp: sample.Timestamp.Time(), + } + ris = append(ris, ri) + } + + return ris, nil +} + +// buildInstanceTypeQuery builds a Prometheus query with optional instance_type filter. +func buildInstanceTypeQuery(metricName, instanceType string) string { + if instanceType != "" { + return fmt.Sprintf(`%s{instance_type="%s"}`, metricName, instanceType) + } + return metricName +} + +// executeQuery executes a Prometheus query and returns the vector result. +func (c *Client) executeQuery(ctx context.Context, query string) (model.Vector, error) { + result, warnings, err := c.api.Query(ctx, query, time.Now()) + if err != nil { + return nil, fmt.Errorf("prometheus query failed: %w", err) + } + + if len(warnings) > 0 { + _ = warnings + } + + vector, ok := result.(model.Vector) + if !ok { + return nil, fmt.Errorf("unexpected result type: %T", result) + } + + return vector, nil +} + +// SpotPrice represents current spot pricing from Lumina metrics. +type SpotPrice struct { + // InstanceType is the EC2 instance type + InstanceType string + + // Region is the AWS region + Region string + + // AvailabilityZone is the specific AZ + AvailabilityZone string + + // Price is the current spot price in $/hour + Price float64 + + // Timestamp is when this metric was recorded + Timestamp time.Time +} + +// QuerySpotPrice queries Prometheus for current spot prices. +// The instanceType parameter filters results (e.g., "m5.xlarge"). +// Pass empty string to get all instance types. +// +// This queries: ec2_spot_price{instance_type="$type"} +func (c *Client) QuerySpotPrice(ctx context.Context, instanceType string) ([]SpotPrice, error) { + query := buildInstanceTypeQuery("ec2_spot_price", instanceType) + vector, err := c.executeQuery(ctx, query) + if err != nil { + return nil, err + } + + prices := make([]SpotPrice, 0, len(vector)) + for _, sample := range vector { + price := SpotPrice{ + InstanceType: string(sample.Metric["instance_type"]), + Region: string(sample.Metric["region"]), + AvailabilityZone: string(sample.Metric["availability_zone"]), + Price: float64(sample.Value), + Timestamp: sample.Timestamp.Time(), + } + prices = append(prices, price) + } + + return prices, nil +} + +// OnDemandPrice represents on-demand pricing from Lumina metrics. +type OnDemandPrice struct { + // InstanceType is the EC2 instance type + InstanceType string + + // Region is the AWS region + Region string + + // OperatingSystem is the OS (e.g., "Linux", "Windows") + OperatingSystem string + + // Price is the on-demand price in $/hour + Price float64 + + // Timestamp is when this metric was recorded + Timestamp time.Time +} + +// QueryOnDemandPrice queries Prometheus for on-demand prices. +// The instanceType parameter filters results (e.g., "m5.xlarge"). +// Pass empty string to get all instance types. +// +// This queries: ec2_ondemand_price{instance_type="$type"} +func (c *Client) QueryOnDemandPrice(ctx context.Context, instanceType string) ([]OnDemandPrice, error) { + query := buildInstanceTypeQuery("ec2_ondemand_price", instanceType) + vector, err := c.executeQuery(ctx, query) + if err != nil { + return nil, err + } + + prices := make([]OnDemandPrice, 0, len(vector)) + for _, sample := range vector { + price := OnDemandPrice{ + InstanceType: string(sample.Metric["instance_type"]), + Region: string(sample.Metric["region"]), + OperatingSystem: string(sample.Metric["operating_system"]), + Price: float64(sample.Value), + Timestamp: sample.Timestamp.Time(), + } + prices = append(prices, price) + } + + return prices, nil +} + +// DataFreshness queries the lumina_data_freshness_seconds metric to check how old +// Lumina's data is. Returns the age in seconds, or an error if the metric is not available. +// +// This is useful for determining if Lumina's data is stale and cost decisions +// should be delayed until fresh data is available. +func (c *Client) DataFreshness(ctx context.Context) (float64, error) { + query := `lumina_data_freshness_seconds` + + result, warnings, err := c.api.Query(ctx, query, time.Now()) + if err != nil { + return 0, fmt.Errorf("prometheus query failed: %w", err) + } + + if len(warnings) > 0 { + _ = warnings + } + + vector, ok := result.(model.Vector) + if !ok { + return 0, fmt.Errorf("unexpected result type: %T", result) + } + + if len(vector) == 0 { + return 0, fmt.Errorf("no data freshness metric available") + } + + // Return the first sample (should only be one) + return float64(vector[0].Value), nil +} + +// QueryRaw executes a raw PromQL query and returns the result as a string. +// This is useful for debugging or custom queries not covered by typed methods. +// +// The result is formatted as: metric_name{labels} value +func (c *Client) QueryRaw(ctx context.Context, query string) (string, error) { + result, warnings, err := c.api.Query(ctx, query, time.Now()) + if err != nil { + return "", fmt.Errorf("prometheus query failed: %w", err) + } + + if len(warnings) > 0 { + _ = warnings + } + + // Format result as string + return result.String(), nil +} + +// ParseFloat64 is a helper to safely parse Prometheus metric values. +// Prometheus returns values as model.SampleValue which is a float64 alias. +func ParseFloat64(s string) (float64, error) { + return strconv.ParseFloat(s, 64) +} diff --git a/pkg/prometheus/client_test.go b/pkg/prometheus/client_test.go new file mode 100644 index 0000000..843718c --- /dev/null +++ b/pkg/prometheus/client_test.go @@ -0,0 +1,424 @@ +/* +Copyright 2025 Karve Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package prometheus + +import ( + "context" + "testing" + + "github.com/nextdoor/karve/internal/testutil" +) + +func TestNewClient(t *testing.T) { + server := testutil.NewMockPrometheusServer() + defer server.Close() + + client, err := NewClient(server.URL) + if err != nil { + t.Fatalf("NewClient() failed: %v", err) + } + + if client == nil { + t.Error("NewClient() returned nil client") + } +} + +func TestNewClient_InvalidURL(t *testing.T) { + // Invalid URL scheme should still succeed (Prometheus client accepts it) + _, err := NewClient("not-a-url") + if err != nil { + t.Errorf("NewClient() with invalid URL failed: %v", err) + } +} + +func TestQuerySavingsPlanCapacity(t *testing.T) { + server := testutil.NewMockPrometheusServer() + defer server.Close() + + server.SetMetrics(testutil.LuminaMetricsWithSPCapacity()) + + client, err := NewClient(server.URL) + if err != nil { + t.Fatalf("NewClient() failed: %v", err) + } + + ctx := context.Background() + + tests := []struct { + name string + instanceFamily string + wantCount int + wantCapacity float64 + }{ + { + name: "m5 family", + instanceFamily: "m5", + wantCount: 1, + wantCapacity: 50.00, + }, + { + name: "all families", + instanceFamily: "", + wantCount: 2, // m5 + c5 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + capacities, err := client.QuerySavingsPlanCapacity(ctx, tt.instanceFamily) + if err != nil { + t.Fatalf("QuerySavingsPlanCapacity() error = %v", err) + } + + if len(capacities) != tt.wantCount { + t.Errorf("got %d capacities, want %d", len(capacities), tt.wantCount) + } + + if tt.wantCount > 0 && tt.wantCapacity > 0 { + if capacities[0].RemainingCapacity != tt.wantCapacity { + t.Errorf("got capacity %f, want %f", capacities[0].RemainingCapacity, tt.wantCapacity) + } + } + }) + } +} + +func TestQuerySavingsPlanCapacity_NoCapacity(t *testing.T) { + server := testutil.NewMockPrometheusServer() + defer server.Close() + + server.SetMetrics(testutil.LuminaMetricsWithNoCapacity()) + + client, _ := NewClient(server.URL) + ctx := context.Background() + + capacities, err := client.QuerySavingsPlanCapacity(ctx, "m5") + if err != nil { + t.Fatalf("QuerySavingsPlanCapacity() error = %v", err) + } + + if len(capacities) != 1 { + t.Fatalf("expected 1 result, got %d", len(capacities)) + } + + // Should have 0 capacity + if capacities[0].RemainingCapacity != 0.0 { + t.Errorf("expected 0 capacity, got %f", capacities[0].RemainingCapacity) + } +} + +func TestQuerySavingsPlanCapacity_Empty(t *testing.T) { + server := testutil.NewMockPrometheusServer() + defer server.Close() + + server.SetMetrics(testutil.LuminaMetricsEmpty()) + + client, _ := NewClient(server.URL) + ctx := context.Background() + + capacities, err := client.QuerySavingsPlanCapacity(ctx, "m5") + if err != nil { + t.Fatalf("QuerySavingsPlanCapacity() error = %v", err) + } + + if len(capacities) != 0 { + t.Errorf("expected 0 results for empty metrics, got %d", len(capacities)) + } +} + +func TestQueryReservedInstances(t *testing.T) { + server := testutil.NewMockPrometheusServer() + defer server.Close() + + server.SetMetrics(testutil.LuminaMetricsWithSPCapacity()) + + client, _ := NewClient(server.URL) + ctx := context.Background() + + tests := []struct { + name string + instanceType string + wantCount int + }{ + { + name: "specific instance type", + instanceType: "m5.xlarge", + wantCount: 1, + }, + { + name: "all instance types", + instanceType: "", + wantCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ris, err := client.QueryReservedInstances(ctx, tt.instanceType) + if err != nil { + t.Fatalf("QueryReservedInstances() error = %v", err) + } + + if len(ris) != tt.wantCount { + t.Errorf("got %d RIs, want %d", len(ris), tt.wantCount) + } + + if len(ris) > 0 { + if ris[0].InstanceType == "" { + t.Error("RI missing instance type") + } + if ris[0].Region == "" { + t.Error("RI missing region") + } + } + }) + } +} + +func TestQueryReservedInstances_Empty(t *testing.T) { + server := testutil.NewMockPrometheusServer() + defer server.Close() + + server.SetMetrics(testutil.LuminaMetricsWithNoCapacity()) + + client, _ := NewClient(server.URL) + ctx := context.Background() + + ris, err := client.QueryReservedInstances(ctx, "m5.xlarge") + if err != nil { + t.Fatalf("QueryReservedInstances() error = %v", err) + } + + if len(ris) != 0 { + t.Errorf("expected 0 RIs when capacity exhausted, got %d", len(ris)) + } +} + +func TestQuerySpotPrice(t *testing.T) { + server := testutil.NewMockPrometheusServer() + defer server.Close() + + server.SetMetrics(testutil.LuminaMetricsWithSpotPrices()) + + client, _ := NewClient(server.URL) + ctx := context.Background() + + prices, err := client.QuerySpotPrice(ctx, "m5.xlarge") + if err != nil { + t.Fatalf("QuerySpotPrice() error = %v", err) + } + + if len(prices) != 1 { + t.Fatalf("expected 1 price, got %d", len(prices)) + } + + if prices[0].Price != 0.12 { + t.Errorf("expected price 0.12, got %f", prices[0].Price) + } + + if prices[0].InstanceType != "m5.xlarge" { + t.Errorf("expected instance type m5.xlarge, got %s", prices[0].InstanceType) + } +} + +func TestQueryOnDemandPrice(t *testing.T) { + server := testutil.NewMockPrometheusServer() + defer server.Close() + + server.SetMetrics(testutil.LuminaMetricsWithSpotPrices()) + + client, _ := NewClient(server.URL) + ctx := context.Background() + + prices, err := client.QueryOnDemandPrice(ctx, "m5.xlarge") + if err != nil { + t.Fatalf("QueryOnDemandPrice() error = %v", err) + } + + if len(prices) != 1 { + t.Fatalf("expected 1 price, got %d", len(prices)) + } + + if prices[0].Price != 0.192 { + t.Errorf("expected price 0.192, got %f", prices[0].Price) + } + + if prices[0].OperatingSystem != "Linux" { + t.Errorf("expected OS Linux, got %s", prices[0].OperatingSystem) + } +} + +func TestDataFreshness(t *testing.T) { + server := testutil.NewMockPrometheusServer() + defer server.Close() + + // Add data freshness metric + server.SetMetrics(testutil.MetricFixture{ + `lumina_data_freshness_seconds`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [ + { + "metric": {}, + "value": [1640000000, "45.5"] + } + ] + } + }`, + }) + + client, _ := NewClient(server.URL) + ctx := context.Background() + + freshness, err := client.DataFreshness(ctx) + if err != nil { + t.Fatalf("DataFreshness() error = %v", err) + } + + if freshness != 45.5 { + t.Errorf("expected freshness 45.5, got %f", freshness) + } +} + +func TestDataFreshness_NoMetric(t *testing.T) { + server := testutil.NewMockPrometheusServer() + defer server.Close() + + server.SetMetrics(testutil.LuminaMetricsEmpty()) + + client, _ := NewClient(server.URL) + ctx := context.Background() + + _, err := client.DataFreshness(ctx) + if err == nil { + t.Error("DataFreshness() expected error when metric missing, got nil") + } +} + +func TestQueryRaw(t *testing.T) { + server := testutil.NewMockPrometheusServer() + defer server.Close() + + server.SetMetrics(testutil.LuminaMetricsWithSPCapacity()) + + client, _ := NewClient(server.URL) + ctx := context.Background() + + result, err := client.QueryRaw(ctx, `savings_plan_remaining_capacity{type="ec2_instance",instance_family="m5"}`) + if err != nil { + t.Fatalf("QueryRaw() error = %v", err) + } + + if result == "" { + t.Error("QueryRaw() returned empty result") + } +} + +func TestParseFloat64(t *testing.T) { + tests := []struct { + name string + input string + want float64 + wantErr bool + }{ + { + name: "valid float", + input: "123.45", + want: 123.45, + wantErr: false, + }, + { + name: "integer", + input: "100", + want: 100.0, + wantErr: false, + }, + { + name: "invalid", + input: "not-a-number", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseFloat64(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseFloat64() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && got != tt.want { + t.Errorf("ParseFloat64() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestQueryWithPrometheusWarnings(t *testing.T) { + server := testutil.NewMockPrometheusServer() + defer server.Close() + + // Add metric that will trigger warnings path (though we can't easily mock warnings) + server.SetMetrics(testutil.LuminaMetricsWithSPCapacity()) + + client, _ := NewClient(server.URL) + ctx := context.Background() + + // These queries should succeed even with warnings (which are logged and ignored) + _, err := client.QuerySavingsPlanCapacity(ctx, "m5") + if err != nil { + t.Errorf("QuerySavingsPlanCapacity() with warnings failed: %v", err) + } +} + +func TestQueryServerUnavailable(t *testing.T) { + // Use invalid server URL to trigger connection error + client, _ := NewClient("http://localhost:1") + ctx := context.Background() + + // All query methods should handle connection errors gracefully + _, err := client.QuerySavingsPlanCapacity(ctx, "m5") + if err == nil { + t.Error("QuerySavingsPlanCapacity() expected error with unavailable server") + } + + _, err = client.QueryReservedInstances(ctx, "m5.xlarge") + if err == nil { + t.Error("QueryReservedInstances() expected error with unavailable server") + } + + _, err = client.QuerySpotPrice(ctx, "m5.xlarge") + if err == nil { + t.Error("QuerySpotPrice() expected error with unavailable server") + } + + _, err = client.QueryOnDemandPrice(ctx, "m5.xlarge") + if err == nil { + t.Error("QueryOnDemandPrice() expected error with unavailable server") + } + + _, err = client.DataFreshness(ctx) + if err == nil { + t.Error("DataFreshness() expected error with unavailable server") + } + + _, err = client.QueryRaw(ctx, "test_metric") + if err == nil { + t.Error("QueryRaw() expected error with unavailable server") + } +} diff --git a/pkg/reconciler/metrics.go b/pkg/reconciler/metrics.go new file mode 100644 index 0000000..12a6454 --- /dev/null +++ b/pkg/reconciler/metrics.go @@ -0,0 +1,132 @@ +/* +Copyright 2025 Karve Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package reconciler provides Kubernetes controllers for managing cost-aware provisioning. +// +// The metrics reconciler periodically queries Prometheus for Lumina metrics and logs +// the current state of Savings Plans and Reserved Instances capacity. +package reconciler + +import ( + "context" + "fmt" + "time" + + "github.com/go-logr/logr" + "github.com/nextdoor/karve/pkg/prometheus" +) + +// MetricsReconciler periodically queries Prometheus for Lumina metrics. +// It logs the current state of Savings Plans and Reserved Instances capacity +// to provide visibility into cost optimization data. +type MetricsReconciler struct { + // PrometheusClient is the client for querying Lumina metrics + PrometheusClient *prometheus.Client + + // Logger is the structured logger for this reconciler + Logger logr.Logger + + // Interval is how often to query metrics (default: 5 minutes) + Interval time.Duration +} + +// Start begins the metrics reconciliation loop. +// It runs until the context is cancelled. +func (r *MetricsReconciler) Start(ctx context.Context) error { + r.Logger.Info("Starting metrics reconciler", "interval", r.Interval) + + // Use default interval if not set + if r.Interval == 0 { + r.Interval = 5 * time.Minute + } + + ticker := time.NewTicker(r.Interval) + defer ticker.Stop() + + // Run once immediately on startup + if err := r.reconcile(ctx); err != nil { + r.Logger.Error(err, "Failed to reconcile metrics on startup") + // Don't fail startup on first reconcile error + } + + // Then run on the ticker interval + for { + select { + case <-ctx.Done(): + r.Logger.Info("Metrics reconciler stopped") + return nil + case <-ticker.C: + if err := r.reconcile(ctx); err != nil { + r.Logger.Error(err, "Failed to reconcile metrics") + // Continue running even on error + } + } + } +} + +// reconcile queries Prometheus and logs current metrics state. +func (r *MetricsReconciler) reconcile(ctx context.Context) error { + r.Logger.V(1).Info("Reconciling metrics") + + // Check data freshness + freshness, err := r.PrometheusClient.DataFreshness(ctx) + if err != nil { + return fmt.Errorf("failed to query data freshness: %w", err) + } + r.Logger.Info("Lumina data freshness", "age_seconds", freshness) + + // Query Savings Plans capacity (all families) + spCapacities, err := r.PrometheusClient.QuerySavingsPlanCapacity(ctx, "") + if err != nil { + return fmt.Errorf("failed to query Savings Plans capacity: %w", err) + } + + // Log SP capacity by instance family + for _, sp := range spCapacities { + r.Logger.Info("Savings Plan capacity", + "instance_family", sp.InstanceFamily, + "type", sp.Type, + "remaining_capacity_dollars_per_hour", sp.RemainingCapacity, + "savings_plan_arn", sp.SavingsPlanARN, + ) + } + + // Query Reserved Instances (all types) + ris, err := r.PrometheusClient.QueryReservedInstances(ctx, "") + if err != nil { + return fmt.Errorf("failed to query Reserved Instances: %w", err) + } + + // Log RI count by instance type + riCounts := make(map[string]int) + for _, ri := range ris { + riCounts[ri.InstanceType] += ri.Count + } + + for instanceType, count := range riCounts { + r.Logger.Info("Reserved Instance availability", + "instance_type", instanceType, + "count", count, + ) + } + + r.Logger.V(1).Info("Metrics reconciliation complete", + "savings_plans_count", len(spCapacities), + "reserved_instances_count", len(ris), + ) + + return nil +} diff --git a/pkg/reconciler/metrics_test.go b/pkg/reconciler/metrics_test.go new file mode 100644 index 0000000..056f3ca --- /dev/null +++ b/pkg/reconciler/metrics_test.go @@ -0,0 +1,250 @@ +/* +Copyright 2025 Karve Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package reconciler + +import ( + "context" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/nextdoor/karve/internal/testutil" + "github.com/nextdoor/karve/pkg/prometheus" +) + +func TestMetricsReconciler_Start(t *testing.T) { + server := testutil.NewMockPrometheusServer() + defer server.Close() + + // Set up metrics with SP capacity and data freshness + server.SetMetrics(testutil.LuminaMetricsWithSPCapacity()) + server.SetMetrics(testutil.MetricFixture{ + `lumina_data_freshness_seconds`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [{"metric": {}, "value": [1640000000, "30"]}] + } + }`, + }) + + client, err := prometheus.NewClient(server.URL) + if err != nil { + t.Fatalf("Failed to create Prometheus client: %v", err) + } + + reconciler := &MetricsReconciler{ + PrometheusClient: client, + Logger: logr.Discard(), + Interval: 100 * time.Millisecond, // Fast interval for testing + } + + // Start reconciler in background + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + + err = reconciler.Start(ctx) + if err != nil { + t.Errorf("Start() returned unexpected error: %v", err) + } + + // Start should run at least twice (once immediately, once on ticker) + // If we got here without error, the reconciler ran successfully +} + +func TestMetricsReconciler_StartWithCancel(t *testing.T) { + server := testutil.NewMockPrometheusServer() + defer server.Close() + + server.SetMetrics(testutil.LuminaMetricsWithSPCapacity()) + server.SetMetrics(testutil.MetricFixture{ + `lumina_data_freshness_seconds`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [{"metric": {}, "value": [1640000000, "30"]}] + } + }`, + }) + + client, _ := prometheus.NewClient(server.URL) + + reconciler := &MetricsReconciler{ + PrometheusClient: client, + Logger: logr.Discard(), + Interval: 1 * time.Second, + } + + // Start and immediately cancel + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + err := reconciler.Start(ctx) + if err != nil { + t.Errorf("Start() with cancelled context returned unexpected error: %v", err) + } +} + +func TestMetricsReconciler_Reconcile(t *testing.T) { + tests := []struct { + name string + fixtures []testutil.MetricFixture + wantErr bool + errContains string + }{ + { + name: "successful reconcile with SP capacity", + fixtures: []testutil.MetricFixture{ + testutil.LuminaMetricsWithSPCapacity(), + { + `lumina_data_freshness_seconds`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [{"metric": {}, "value": [1640000000, "30"]}] + } + }`, + }, + }, + wantErr: false, + }, + { + name: "successful reconcile with no capacity", + fixtures: []testutil.MetricFixture{ + testutil.LuminaMetricsWithNoCapacity(), + { + `lumina_data_freshness_seconds`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [{"metric": {}, "value": [1640000000, "45"]}] + } + }`, + }, + }, + wantErr: false, + }, + { + name: "successful reconcile with empty metrics", + fixtures: []testutil.MetricFixture{ + testutil.LuminaMetricsEmpty(), + { + `lumina_data_freshness_seconds`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [{"metric": {}, "value": [1640000000, "60"]}] + } + }`, + }, + }, + wantErr: false, + }, + { + name: "error when data freshness missing", + fixtures: []testutil.MetricFixture{ + testutil.LuminaMetricsWithSPCapacity(), + }, + wantErr: true, + errContains: "data freshness", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := testutil.NewMockPrometheusServer() + defer server.Close() + + for _, fixture := range tt.fixtures { + server.SetMetrics(fixture) + } + + client, _ := prometheus.NewClient(server.URL) + + reconciler := &MetricsReconciler{ + PrometheusClient: client, + Logger: logr.Discard(), + } + + ctx := context.Background() + err := reconciler.reconcile(ctx) + + if (err != nil) != tt.wantErr { + t.Errorf("reconcile() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr && tt.errContains != "" { + if err == nil || len(err.Error()) == 0 { + t.Errorf("expected error containing %q, got nil", tt.errContains) + } + } + }) + } +} + +func TestMetricsReconciler_ReconcileWithServerError(t *testing.T) { + // Use unavailable server to trigger connection errors + client, _ := prometheus.NewClient("http://localhost:1") + + reconciler := &MetricsReconciler{ + PrometheusClient: client, + Logger: logr.Discard(), + } + + ctx := context.Background() + err := reconciler.reconcile(ctx) + + if err == nil { + t.Error("reconcile() expected error with unavailable server, got nil") + } +} + +func TestMetricsReconciler_DefaultInterval(t *testing.T) { + server := testutil.NewMockPrometheusServer() + defer server.Close() + + server.SetMetrics(testutil.LuminaMetricsWithSPCapacity()) + server.SetMetrics(testutil.MetricFixture{ + `lumina_data_freshness_seconds`: `{ + "status": "success", + "data": { + "resultType": "vector", + "result": [{"metric": {}, "value": [1640000000, "30"]}] + } + }`, + }) + + client, _ := prometheus.NewClient(server.URL) + + reconciler := &MetricsReconciler{ + PrometheusClient: client, + Logger: logr.Discard(), + // Don't set Interval - should use default + } + + // Start with short timeout to verify default interval is set + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _ = reconciler.Start(ctx) + + // Verify default was set + if reconciler.Interval != 5*time.Minute { + t.Errorf("Expected default interval 5m, got %v", reconciler.Interval) + } +} diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go new file mode 100644 index 0000000..263a575 --- /dev/null +++ b/test/e2e/e2e_suite_test.go @@ -0,0 +1,341 @@ +//go:build e2e +// +build e2e + +/* +Copyright 2025 Karve Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "context" + "fmt" + "os/exec" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/nextdoor/karve/test/utils" +) + +const ( + namespace = "karve-system" +) + +var ( + // projectImage is the name of the image which will be built and loaded + // with the code source changes to be tested. + projectImage = "example.com/karve:v0.0.1" +) + +// TestE2E runs the end-to-end (e2e) test suite for the project. +// These tests execute in an isolated, temporary environment to validate +// project changes with the purpose of being used in CI jobs. +func TestE2E(t *testing.T) { + RegisterFailHandler(Fail) + _, _ = fmt.Fprintf(GinkgoWriter, "Starting Karve integration test suite\n") + RunSpecs(t, "e2e suite") +} + +var _ = BeforeSuite(func() { + ctx := context.Background() + + By("building the manager(Operator) image") + cmd := exec.Command("docker", "build", "-t", projectImage, ".") + cmd.Dir = "../.." // Set working directory to project root + _, err := utils.Run(cmd) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to build Docker image") + + By("loading the manager(Operator) image on Kind") + err = utils.LoadImageToKindClusterWithName(projectImage) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to load image to Kind cluster") + + By("building the mock Lumina exporter image") + cmd = exec.Command("docker", "build", "-t", "mock-lumina-exporter:test", "-f", "test/e2e/mock-exporter/Dockerfile", "test/e2e/mock-exporter") + cmd.Dir = "../.." + _, err = utils.Run(cmd) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to build mock exporter image") + + By("loading the mock exporter image on Kind") + err = utils.LoadImageToKindClusterWithName("mock-lumina-exporter:test") + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to load mock exporter image to Kind cluster") + + By("creating manager namespace") + client, err := NewResourceClient("") + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to create resource client") + + err = client.CreateNamespace(ctx, namespace) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to create namespace") + + // Update client to use the new namespace + client.namespace = namespace + + By("deploying mock Lumina exporter") + replicas := int32(1) + mockExporterDeployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mock-lumina-exporter", + Namespace: namespace, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "mock-lumina-exporter"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "mock-lumina-exporter"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "exporter", + Image: "mock-lumina-exporter:test", + ImagePullPolicy: corev1.PullIfNotPresent, + Ports: []corev1.ContainerPort{ + {ContainerPort: 8080}, + }, + }, + }, + }, + }, + }, + } + err = client.CreateDeployment(ctx, mockExporterDeployment) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to create mock exporter deployment") + + mockExporterService := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mock-lumina-exporter", + Namespace: namespace, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": "mock-lumina-exporter"}, + Ports: []corev1.ServicePort{ + { + Port: 8080, + TargetPort: intstr.FromInt(8080), + }, + }, + }, + } + err = client.CreateService(ctx, mockExporterService) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to create mock exporter service") + + By("deploying Prometheus server") + // Create Prometheus ConfigMap + prometheusConfig := map[string]string{ + "prometheus.yml": `global: + scrape_interval: 5s + evaluation_interval: 5s + +scrape_configs: + - job_name: 'lumina' + static_configs: + - targets: ['mock-lumina-exporter:8080'] +`, + } + err = client.CreateConfigMapFromYAML(ctx, "prometheus-config", prometheusConfig) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to create Prometheus ConfigMap") + + prometheusDeployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "prometheus", + Namespace: namespace, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "prometheus"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "prometheus"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "prometheus", + Image: "prom/prometheus:latest", + Args: []string{ + "--config.file=/etc/prometheus/prometheus.yml", + "--storage.tsdb.path=/prometheus", + "--web.console.libraries=/usr/share/prometheus/console_libraries", + "--web.console.templates=/usr/share/prometheus/consoles", + }, + Ports: []corev1.ContainerPort{ + {ContainerPort: 9090}, + }, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "config", + MountPath: "/etc/prometheus", + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "prometheus-config", + }, + }, + }, + }, + }, + }, + }, + }, + } + err = client.CreateDeployment(ctx, prometheusDeployment) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to create Prometheus deployment") + + prometheusService := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "prometheus", + Namespace: namespace, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": "prometheus"}, + Ports: []corev1.ServicePort{ + { + Port: 9090, + TargetPort: intstr.FromInt(9090), + }, + }, + }, + } + err = client.CreateService(ctx, prometheusService) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to create Prometheus service") + + By("waiting for mock exporter deployment to be ready") + Eventually(func(g Gomega) { + err := client.WaitForDeploymentReady(ctx, "mock-lumina-exporter") + g.Expect(err).NotTo(HaveOccurred()) + }, 60*time.Second, 2*time.Second).Should(Succeed()) + + By("waiting for Prometheus deployment to be ready") + Eventually(func(g Gomega) { + err := client.WaitForDeploymentReady(ctx, "prometheus") + g.Expect(err).NotTo(HaveOccurred()) + }, 60*time.Second, 2*time.Second).Should(Succeed()) + + By("waiting for Prometheus to scrape metrics from mock exporter") + // Give Prometheus time to scrape the mock exporter (scrape interval is 5s, wait 15s to be safe) + time.Sleep(15 * time.Second) + + By("creating Karve ConfigMap") + karveConfig := map[string]string{ + "config.yaml": fmt.Sprintf("prometheusURL: \"http://prometheus.%s.svc.cluster.local:9090\"\nlogLevel: \"debug\"\n", namespace), + } + err = client.CreateConfigMapFromYAML(ctx, "karve-config", karveConfig) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to create Karve ConfigMap") + + By("deploying the Karve controller-manager") + karveDeployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "karve-controller-manager", + Namespace: namespace, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"control-plane": "controller-manager"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"control-plane": "controller-manager"}, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: "default", + Containers: []corev1.Container{ + { + Name: "manager", + Image: projectImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Args: []string{"--config=/etc/karve/config.yaml", "--leader-elect=false"}, + VolumeMounts: []corev1.VolumeMount{ + { + Name: "config", + MountPath: "/etc/karve", + }, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: "karve-config", + }, + }, + }, + }, + }, + }, + }, + }, + } + err = client.CreateDeployment(ctx, karveDeployment) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to create Karve controller-manager deployment") + + By("waiting for controller-manager deployment to be ready") + Eventually(func(g Gomega) { + err := client.WaitForDeploymentReady(ctx, "karve-controller-manager") + g.Expect(err).NotTo(HaveOccurred()) + }, 60*time.Second, 2*time.Second).Should(Succeed()) +}) + +var _ = AfterSuite(func() { + ctx := context.Background() + + By("fetching controller logs before teardown") + logsClient, err := NewLogsClient(namespace) + if err == nil { + logs, err := logsClient.GetPodLogsByLabel(ctx, "control-plane=controller-manager", nil) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "\n========== Controller Manager Logs ==========\n%s\n========================================\n", logs) + } + } + + By("undeploying resources") + client, err := NewResourceClient(namespace) + if err == nil { + _ = client.DeleteDeployment(ctx, "karve-controller-manager") + _ = client.DeleteDeployment(ctx, "prometheus") + _ = client.DeleteDeployment(ctx, "mock-lumina-exporter") + _ = client.DeleteService(ctx, "prometheus") + _ = client.DeleteService(ctx, "mock-lumina-exporter") + _ = client.DeleteConfigMap(ctx, "karve-config") + _ = client.DeleteConfigMap(ctx, "prometheus-config") + } + + By("removing manager namespace") + if err == nil { + _ = client.DeleteNamespace(ctx, namespace) + } +}) diff --git a/test/e2e/helpers.go b/test/e2e/helpers.go new file mode 100644 index 0000000..2602c9f --- /dev/null +++ b/test/e2e/helpers.go @@ -0,0 +1,267 @@ +//go:build e2e +// +build e2e + +/* +Copyright 2025 Karve Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "bytes" + "context" + "fmt" + "io" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +// LogsClient provides a clean interface to fetch logs from pods +// using the native Kubernetes client instead of kubectl commands. +type LogsClient struct { + namespace string + clientset *kubernetes.Clientset + restConfig *rest.Config +} + +// NewLogsClient creates a new logs client. +func NewLogsClient(namespace string) (*LogsClient, error) { + // Load the kubeconfig from the default location or KUBECONFIG env var + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + configOverrides := &clientcmd.ConfigOverrides{} + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) + + config, err := kubeConfig.ClientConfig() + if err != nil { + return nil, fmt.Errorf("failed to load kubeconfig: %w", err) + } + + // Create the clientset + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create clientset: %w", err) + } + + return &LogsClient{ + namespace: namespace, + clientset: clientset, + restConfig: config, + }, nil +} + +// GetPodLogs retrieves logs from a specific pod by name. +// +// Options: +// - tailLines: Number of lines from the end of the logs (nil = all logs) +// - container: Specific container name (empty = default container) +func (l *LogsClient) GetPodLogs(ctx context.Context, podName string, tailLines *int64, container string) (string, error) { + opts := &corev1.PodLogOptions{ + TailLines: tailLines, + } + if container != "" { + opts.Container = container + } + + req := l.clientset.CoreV1().Pods(l.namespace).GetLogs(podName, opts) + podLogs, err := req.Stream(ctx) + if err != nil { + return "", fmt.Errorf("failed to stream logs: %w", err) + } + defer podLogs.Close() + + buf := new(bytes.Buffer) + _, err = io.Copy(buf, podLogs) + if err != nil { + return "", fmt.Errorf("failed to copy logs: %w", err) + } + + return buf.String(), nil +} + +// GetPodLogsByLabel retrieves logs from pods matching a label selector. +// Returns logs from all matching pods concatenated together. +// +// Options: +// - labelSelector: Kubernetes label selector (e.g., "control-plane=controller-manager") +// - tailLines: Number of lines from the end of the logs (nil = all logs) +func (l *LogsClient) GetPodLogsByLabel(ctx context.Context, labelSelector string, tailLines *int64) (string, error) { + // List pods matching the label selector + podList, err := l.clientset.CoreV1().Pods(l.namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + }) + if err != nil { + return "", fmt.Errorf("failed to list pods: %w", err) + } + + if len(podList.Items) == 0 { + return "", fmt.Errorf("no pods found matching label selector: %s", labelSelector) + } + + // Get logs from all matching pods + var allLogs bytes.Buffer + for i, pod := range podList.Items { + if i > 0 { + allLogs.WriteString("\n") // Separate logs from different pods + } + + logs, err := l.GetPodLogs(ctx, pod.Name, tailLines, "") + if err != nil { + // Continue to next pod if one fails + allLogs.WriteString(fmt.Sprintf("# Failed to get logs from pod %s: %v\n", pod.Name, err)) + continue + } + + allLogs.WriteString(logs) + } + + return allLogs.String(), nil +} + +// ResourceClient provides a clean interface to fetch Kubernetes resources +// using the native Kubernetes client instead of kubectl commands. +type ResourceClient struct { + namespace string + clientset *kubernetes.Clientset + restConfig *rest.Config +} + +// NewResourceClient creates a new resource client. +func NewResourceClient(namespace string) (*ResourceClient, error) { + // Load the kubeconfig from the default location or KUBECONFIG env var + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + configOverrides := &clientcmd.ConfigOverrides{} + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) + + config, err := kubeConfig.ClientConfig() + if err != nil { + return nil, fmt.Errorf("failed to load kubeconfig: %w", err) + } + + // Create the clientset + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create clientset: %w", err) + } + + return &ResourceClient{ + namespace: namespace, + clientset: clientset, + restConfig: config, + }, nil +} + +// GetPodsByLabel retrieves pods matching a label selector. +func (r *ResourceClient) GetPodsByLabel(ctx context.Context, labelSelector string) (*corev1.PodList, error) { + return r.clientset.CoreV1().Pods(r.namespace).List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + }) +} + +// GetPod retrieves a specific pod by name. +func (r *ResourceClient) GetPod(ctx context.Context, name string) (*corev1.Pod, error) { + return r.clientset.CoreV1().Pods(r.namespace).Get(ctx, name, metav1.GetOptions{}) +} + +// IsPodReady checks if a pod is in Ready state. +func (r *ResourceClient) IsPodReady(ctx context.Context, podName string) (bool, error) { + pod, err := r.GetPod(ctx, podName) + if err != nil { + return false, err + } + + for _, condition := range pod.Status.Conditions { + if condition.Type == corev1.PodReady { + return condition.Status == corev1.ConditionTrue, nil + } + } + + return false, nil +} + +// CreateNamespace creates a namespace using the Kubernetes API. +func (r *ResourceClient) CreateNamespace(ctx context.Context, name string) error { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } + _, err := r.clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + return err +} + +// DeleteNamespace deletes a namespace using the Kubernetes API. +func (r *ResourceClient) DeleteNamespace(ctx context.Context, name string) error { + return r.clientset.CoreV1().Namespaces().Delete(ctx, name, metav1.DeleteOptions{}) +} + +// CreateConfigMapFromYAML creates a ConfigMap from YAML data. +func (r *ResourceClient) CreateConfigMapFromYAML(ctx context.Context, name string, data map[string]string) error { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: r.namespace, + }, + Data: data, + } + _, err := r.clientset.CoreV1().ConfigMaps(r.namespace).Create(ctx, cm, metav1.CreateOptions{}) + return err +} + +// DeleteConfigMap deletes a ConfigMap. +func (r *ResourceClient) DeleteConfigMap(ctx context.Context, name string) error { + return r.clientset.CoreV1().ConfigMaps(r.namespace).Delete(ctx, name, metav1.DeleteOptions{}) +} + +// CreateDeployment creates a Deployment. +func (r *ResourceClient) CreateDeployment(ctx context.Context, deployment *appsv1.Deployment) error { + _, err := r.clientset.AppsV1().Deployments(r.namespace).Create(ctx, deployment, metav1.CreateOptions{}) + return err +} + +// DeleteDeployment deletes a Deployment. +func (r *ResourceClient) DeleteDeployment(ctx context.Context, name string) error { + return r.clientset.AppsV1().Deployments(r.namespace).Delete(ctx, name, metav1.DeleteOptions{}) +} + +// CreateService creates a Service. +func (r *ResourceClient) CreateService(ctx context.Context, service *corev1.Service) error { + _, err := r.clientset.CoreV1().Services(r.namespace).Create(ctx, service, metav1.CreateOptions{}) + return err +} + +// DeleteService deletes a Service. +func (r *ResourceClient) DeleteService(ctx context.Context, name string) error { + return r.clientset.CoreV1().Services(r.namespace).Delete(ctx, name, metav1.DeleteOptions{}) +} + +// WaitForDeploymentReady waits for a deployment to become ready. +func (r *ResourceClient) WaitForDeploymentReady(ctx context.Context, name string) error { + // This is a simplified version - in production you'd want to use a Watch + deployment, err := r.clientset.AppsV1().Deployments(r.namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + + if deployment.Status.ReadyReplicas < *deployment.Spec.Replicas { + return fmt.Errorf("deployment %s not ready: %d/%d replicas", name, deployment.Status.ReadyReplicas, *deployment.Spec.Replicas) + } + + return nil +} diff --git a/test/e2e/metrics_reconciler_test.go b/test/e2e/metrics_reconciler_test.go new file mode 100644 index 0000000..96e0b80 --- /dev/null +++ b/test/e2e/metrics_reconciler_test.go @@ -0,0 +1,211 @@ +//go:build e2e +// +build e2e + +/* +Copyright 2025 Karve Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "context" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Metrics Reconciler", Ordered, func() { + var controllerPodName string + + // Get controller pod name before running tests + BeforeAll(func() { + By("getting controller pod name for metrics tests") + Eventually(func(g Gomega) { + client, err := NewResourceClient(namespace) + g.Expect(err).NotTo(HaveOccurred(), "Failed to create resource client") + + ctx := context.Background() + podList, err := client.GetPodsByLabel(ctx, "control-plane=controller-manager") + g.Expect(err).NotTo(HaveOccurred()) + + // Filter out pods that are being deleted + var runningPods []string + for _, pod := range podList.Items { + if pod.DeletionTimestamp == nil { + runningPods = append(runningPods, pod.Name) + } + } + + g.Expect(runningPods).To(HaveLen(1), "expected 1 controller pod running") + controllerPodName = runningPods[0] + }, 20*time.Second, 2*time.Second).Should(Succeed()) + }) + + Context("Controller Startup", func() { + It("should start successfully", func() { + By("verifying controller pod is running") + Eventually(func(g Gomega) { + client, err := NewResourceClient(namespace) + g.Expect(err).NotTo(HaveOccurred()) + + ctx := context.Background() + ready, err := client.IsPodReady(ctx, controllerPodName) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(ready).To(BeTrue(), "Controller pod should be ready") + }, 30*time.Second, 2*time.Second).Should(Succeed()) + + By("verifying controller started manager") + Eventually(func(g Gomega) { + logsClient, err := NewLogsClient(namespace) + g.Expect(err).NotTo(HaveOccurred()) + + ctx := context.Background() + logs, err := logsClient.GetPodLogs(ctx, controllerPodName, nil, "") + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(logs).To(ContainSubstring("starting manager"), + "Controller should log 'starting manager'") + }, 30*time.Second, 2*time.Second).Should(Succeed()) + + By("verifying Prometheus client was created") + Eventually(func(g Gomega) { + logsClient, err := NewLogsClient(namespace) + g.Expect(err).NotTo(HaveOccurred()) + + ctx := context.Background() + logs, err := logsClient.GetPodLogs(ctx, controllerPodName, nil, "") + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(logs).To(Or( + ContainSubstring("Prometheus client"), + ContainSubstring("loaded configuration"), + ), "Controller should create Prometheus client") + }, 30*time.Second, 2*time.Second).Should(Succeed()) + }) + }) + + Context("Metrics Reconciliation", func() { + It("should start metrics reconciler", func() { + By("verifying metrics reconciler started") + Eventually(func(g Gomega) { + logsClient, err := NewLogsClient(namespace) + g.Expect(err).NotTo(HaveOccurred()) + + ctx := context.Background() + logs, err := logsClient.GetPodLogs(ctx, controllerPodName, nil, "") + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(logs).To(ContainSubstring("Starting metrics reconciler"), + "Metrics reconciler should have started") + }, 30*time.Second, 2*time.Second).Should(Succeed()) + }) + + It("should query Lumina metrics successfully", func() { + By("waiting for first reconciliation cycle") + // The reconciler runs immediately on startup, then every 5 minutes + // We should see logs from the first run quickly + Eventually(func(g Gomega) { + logsClient, err := NewLogsClient(namespace) + g.Expect(err).NotTo(HaveOccurred()) + + ctx := context.Background() + logs, err := logsClient.GetPodLogs(ctx, controllerPodName, nil, "") + g.Expect(err).NotTo(HaveOccurred()) + + // The reconciler should log either: + // - "Reconciling metrics" (debug level) + // - "Lumina data freshness" (info level) + // - Any capacity/RI logs + g.Expect(logs).To(Or( + ContainSubstring("Lumina data freshness"), + ContainSubstring("Savings Plan capacity"), + ContainSubstring("Reserved Instance"), + ), "Metrics reconciler should have queried Lumina") + }, 45*time.Second, 3*time.Second).Should(Succeed()) + }) + + It("should log data freshness", func() { + By("verifying data freshness is logged") + Eventually(func(g Gomega) { + logsClient, err := NewLogsClient(namespace) + g.Expect(err).NotTo(HaveOccurred()) + + ctx := context.Background() + logs, err := logsClient.GetPodLogs(ctx, controllerPodName, nil, "") + g.Expect(err).NotTo(HaveOccurred()) + + // Should see log with data freshness (age in seconds) + g.Expect(logs).To(ContainSubstring("Lumina data freshness"), + "Should log Lumina data freshness") + + // Verify the log includes age_seconds field + g.Expect(logs).To(ContainSubstring("age_seconds"), + "Data freshness log should include age_seconds") + }, 45*time.Second, 3*time.Second).Should(Succeed()) + }) + + It("should handle empty metrics gracefully", func() { + By("verifying no errors for empty SP/RI data") + // With our mock Prometheus, we likely have no SP/RI data + // The reconciler should handle this gracefully (not crash) + Consistently(func(g Gomega) { + logsClient, err := NewLogsClient(namespace) + g.Expect(err).NotTo(HaveOccurred()) + + ctx := context.Background() + logs, err := logsClient.GetPodLogs(ctx, controllerPodName, nil, "") + g.Expect(err).NotTo(HaveOccurred()) + + // Should NOT contain fatal errors + g.Expect(strings.ToLower(logs)).NotTo(ContainSubstring("panic")) + g.Expect(strings.ToLower(logs)).NotTo(ContainSubstring("fatal")) + }, 10*time.Second, 2*time.Second).Should(Succeed()) + }) + + It("should continue reconciling periodically", func() { + By("verifying reconciler continues running") + // The reconciler should keep running in the background + // We can verify this by checking that the controller stays healthy + Consistently(func(g Gomega) { + client, err := NewResourceClient(namespace) + g.Expect(err).NotTo(HaveOccurred()) + + ctx := context.Background() + ready, err := client.IsPodReady(ctx, controllerPodName) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(ready).To(BeTrue(), "Controller should remain ready") + }, 15*time.Second, 3*time.Second).Should(Succeed()) + }) + }) + + Context("Health Checks", func() { + It("should have working health endpoints", func() { + By("verifying healthz endpoint responds") + // TODO: This requires exposing the health port via a Service + // For now, we can verify the controller logs show healthy startup + Eventually(func(g Gomega) { + logsClient, err := NewLogsClient(namespace) + g.Expect(err).NotTo(HaveOccurred()) + + ctx := context.Background() + logs, err := logsClient.GetPodLogs(ctx, controllerPodName, nil, "") + g.Expect(err).NotTo(HaveOccurred()) + + // Controller should have started successfully without health check errors + g.Expect(logs).NotTo(ContainSubstring("unable to set up health check")) + g.Expect(logs).NotTo(ContainSubstring("unable to set up ready check")) + }, 30*time.Second, 2*time.Second).Should(Succeed()) + }) + }) +}) diff --git a/test/e2e/mock-exporter/Dockerfile b/test/e2e/mock-exporter/Dockerfile new file mode 100644 index 0000000..81c1005 --- /dev/null +++ b/test/e2e/mock-exporter/Dockerfile @@ -0,0 +1,10 @@ +FROM golang:1.24.6-alpine AS builder +WORKDIR /app +COPY main.go . +RUN go mod init mock-exporter && \ + CGO_ENABLED=0 go build -o mock-exporter main.go + +FROM alpine:latest +COPY --from=builder /app/mock-exporter /mock-exporter +EXPOSE 8080 +ENTRYPOINT ["/mock-exporter"] diff --git a/test/e2e/mock-exporter/main.go b/test/e2e/mock-exporter/main.go new file mode 100644 index 0000000..8d35e88 --- /dev/null +++ b/test/e2e/mock-exporter/main.go @@ -0,0 +1,52 @@ +// Mock Lumina metrics exporter for E2E tests +package main + +import ( + "fmt" + "log" + "net/http" +) + +func metricsHandler(w http.ResponseWriter, r *http.Request) { + // Mock Lumina metrics in Prometheus format + metrics := `# HELP lumina_data_freshness_seconds Time since last successful AWS API refresh +# TYPE lumina_data_freshness_seconds gauge +lumina_data_freshness_seconds 30 + +# HELP lumina_savings_plan_capacity_hours Remaining Savings Plan capacity in normalized hours +# TYPE lumina_savings_plan_capacity_hours gauge +lumina_savings_plan_capacity_hours{instance_family="m5",plan_type="Compute"} 100.5 +lumina_savings_plan_capacity_hours{instance_family="c5",plan_type="Compute"} 50.25 +lumina_savings_plan_capacity_hours{instance_family="r5",plan_type="EC2Instance"} 75.0 + +# HELP lumina_reserved_instance_count Number of active Reserved Instances +# TYPE lumina_reserved_instance_count gauge +lumina_reserved_instance_count{instance_type="m5.large",availability_zone="us-west-2a"} 10 +lumina_reserved_instance_count{instance_type="m5.xlarge",availability_zone="us-west-2b"} 5 +lumina_reserved_instance_count{instance_type="c5.2xlarge",availability_zone="us-west-2a"} 8 + +# HELP lumina_spot_price_usd Current Spot price in USD per hour +# TYPE lumina_spot_price_usd gauge +lumina_spot_price_usd{instance_type="m5.large",availability_zone="us-west-2a"} 0.045 +lumina_spot_price_usd{instance_type="m5.xlarge",availability_zone="us-west-2b"} 0.089 +lumina_spot_price_usd{instance_type="c5.2xlarge",availability_zone="us-west-2a"} 0.125 + +# HELP lumina_ondemand_price_usd On-Demand price in USD per hour +# TYPE lumina_ondemand_price_usd gauge +lumina_ondemand_price_usd{instance_type="m5.large"} 0.096 +lumina_ondemand_price_usd{instance_type="m5.xlarge"} 0.192 +lumina_ondemand_price_usd{instance_type="c5.2xlarge"} 0.34 +` + + w.Header().Set("Content-Type", "text/plain; version=0.0.4") + _, _ = fmt.Fprint(w, metrics) +} + +func main() { + http.HandleFunc("/metrics", metricsHandler) + + log.Println("Mock Lumina exporter listening on :8080") + if err := http.ListenAndServe(":8080", nil); err != nil { + log.Fatal(err) + } +} diff --git a/test/utils/utils.go b/test/utils/utils.go new file mode 100644 index 0000000..4f50f5b --- /dev/null +++ b/test/utils/utils.go @@ -0,0 +1,50 @@ +/* +Copyright 2025 Karve Contributors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package utils + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// Run executes the provided command within this context +func Run(cmd *exec.Cmd) (string, error) { + fmt.Printf("Running: %s\n", strings.Join(cmd.Args, " ")) + output, err := cmd.CombinedOutput() + if err != nil { + cmdStr := strings.Join(cmd.Args, " ") + return string(output), fmt.Errorf( + "failed to run command %s: %w\nOutput: %s", + cmdStr, err, string(output), + ) + } + return string(output), nil +} + +// LoadImageToKindClusterWithName loads a local docker image into the kind cluster +func LoadImageToKindClusterWithName(name string) error { + cluster := "kind" + if v, ok := os.LookupEnv("KIND_CLUSTER"); ok { + cluster = v + } + kindOptions := []string{"load", "docker-image", name, "--name", cluster} + cmd := exec.Command("kind", kindOptions...) + _, err := Run(cmd) + return err +}