diff --git a/AGENTS.md b/AGENTS.md index c1cdf59..67b9a18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,11 +28,12 @@ Read the [README](README.md) for an overview of the project and its features. - `cmd/flux-mirror/` - the `main` package. One file per cobra command or command concern (`sync.go`, `login.go`, `create_secret.go`, `keygen.go`, `version.go`, `completion.go`, `progress.go`, `logging.go`). `main.VERSION` is overridden at build time by the Makefile. - `cmd/flux-mirror/main_test.go` - hosts `TestMain`, shared `executeCommand(...)` helpers, and `resetCmdArgs()`, which restores global cobra flag state between tests. New commands or flags must update this reset path so tests do not leak state across subtests. -- `api/v1beta1/` - the `mirror.plugin.fluxcd.io/v1beta1` API types: the `Config` wire model (`hosts`, `artifacts`, `charts`, selector, verification) and the sync `Report` envelope, annotated with kubebuilder markers. `zz_generated.deepcopy.go` and the published JSON Schemas are generated from these types; the package stays dependency-light (apimachinery + stdlib) and carries only the wire structs, enums, consts, and `Effective*` helpers — no semver/OCI/SPIFFE deps. -- `internal/config/` - decoding (`Decode`), semantic validation (`Validate`, `ValidateNoEntriesOK`), and path resolution (`ResolvePaths`) for `api/v1beta1.Config` (`apiVersion: mirror.plugin.fluxcd.io/v1beta1`, `kind: Config`). Validation is free functions over the API types, not methods. -- `tools/schema-gen/` - generator that turns controller-gen CRD output into a standalone draft 2020-12 JSON Schema. Run via `make generate`; do not edit `docs/{config,report}/*-v1beta1.json` by hand. +- `api/v1beta2/` - the `mirror.plugin.fluxcd.io/v1beta2` API types: the `Config` wire model (`hosts`, `artifacts`, `charts`, selector, verification) and the sync `Report` envelope, annotated with kubebuilder markers. `zz_generated.deepcopy.go` and the published JSON Schemas are generated from these types; the package stays dependency-light (apimachinery + stdlib) and carries only the wire structs, enums, consts, and `Effective*` helpers — no semver/OCI/SPIFFE deps. `convert.go` maps a deprecated `api/v1beta1.Config` onto the current shape. +- `api/v1beta1/` - the deprecated `mirror.plugin.fluxcd.io/v1beta1` wire types, kept so old configs still decode. Frozen: no new fields or behavior, only the conversion in `api/v1beta2/convert.go` reads it. +- `internal/config/` - decoding (`Decode`), semantic validation (`Validate`, `ValidateNoEntriesOK`), and path resolution (`ResolvePaths`) for `api/v1beta2.Config` (`apiVersion: mirror.plugin.fluxcd.io/v1beta2`, `kind: Config`). A v1beta1 document is decoded with `api/v1beta1` and converted to v1beta2 in memory, emitting a migration warning (`DeprecationWarning`); validation requires v1beta2. Validation is free functions over the API types, not methods. +- `tools/schema-gen/` - generator that turns controller-gen CRD output into a standalone draft 2020-12 JSON Schema. Run via `make generate`; do not edit `docs/{config,report}/*-v1beta2.json` by hand. - `internal/selector/` - tag selection pipeline for OCI artifacts: regex prefilter, semver filter, sort strategy (`semver`, `alphabetical`, `numerical`), then top-N limit. -- `internal/sync/` - sync runner, retry/timeout behavior, per-entry execution, summaries, outcomes, and exit-code aggregation. Report assembly (`NewReport`, `RenderReport`) builds the `api/v1beta1.Report` envelope; the wire report types live in `api/v1beta1`. +- `internal/sync/` - sync runner, retry/timeout behavior, per-entry execution, summaries, outcomes, and exit-code aggregation. Report assembly (`NewReport`, `RenderReport`) builds the `api/v1beta2.Report` envelope; the wire report types live in `api/v1beta2`. - `internal/artifacts/` - OCI artifact mirroring from source repository tags to destination repository tags, including drift handling, dry-run outcomes, referrers, verification, and concurrency fan-out. - `internal/charts/` - Helm chart mirroring to OCI destinations, including version selection, deterministic Helm-OCI publication, drift handling, and dry-run outcomes. - `internal/oci/` - OCI client wrapper, auth/keychain setup, transport customization, digest checks, blob copy, Helm artifact helpers, referrers, and cosign verification. @@ -49,7 +50,7 @@ All development goes through the Makefile - do not invoke `go build` directly, b - `make build` - build `./bin/flux-mirror` with VERSION stamped from git - `make test` - runs `tidy`, `fmt`, `generate`, `vet`, then `go test ./... -coverprofile cover.out` - Single test pattern: `make test GO_TEST_ARGS="-run TestVersionCmd"` -- `make generate` - download `controller-gen` if needed, regenerate `api/v1beta1/zz_generated.deepcopy.go`, and regenerate the config/report JSON Schemas under `docs/`. Run after changing any `api/v1beta1` type or marker; CI fails if the tree is left dirty. +- `make generate` - download `controller-gen` if needed, regenerate `api/v1beta2/zz_generated.deepcopy.go`, and regenerate the config/report JSON Schemas under `docs/`. Run after changing any `api/v1beta2` type or marker; CI fails if the tree is left dirty. - `make lint` - runs golangci-lint with revive, staticcheck, goimports, errcheck, misspell, and related checks - `make run GO_RUN_ARGS="version -o json"` - build then run the CLI with args - `make docker-build` - build the container image @@ -67,7 +68,7 @@ CI (`.github/workflows/test.yaml`) runs `make test`, `make lint`, `make build`, - Cobra commands keep flag state in package-level `*Args` variables. When adding a command or flag, update `resetCmdArgs()` in `cmd/flux-mirror/main_test.go`. - Command output should go through cobra command streams (`cmd.Printf`, `cmd.PrintErrf`, `cmd.OutOrStdout()`, `cmd.ErrOrStderr()`) rather than writing to process-wide stdout/stderr directly. Tests rely on command-local streams. - Error handling should wrap context with `%w` and preserve the distinct sync exit-code behavior (`0` clean, `1` failed jobs, `2` drift-only by default). -- Config and report wire types live in `api/v1beta1`; semantic config validation lives in `internal/config`. Do not defer schema or semantic validation to mirror execution if it can be rejected before network operations. After changing an `api/v1beta1` type or kubebuilder marker, run `make generate` and commit the regenerated deepcopy and JSON Schemas. +- Config and report wire types live in `api/v1beta2`; semantic config validation lives in `internal/config`. Do not defer schema or semantic validation to mirror execution if it can be rejected before network operations. After changing an `api/v1beta2` type or kubebuilder marker, run `make generate` and commit the regenerated deepcopy and JSON Schemas. - Tests use Gomega (`. "github.com/onsi/gomega"` dot-import is accepted - staticcheck ST1001 is disabled project-wide). Table-driven tests are the norm. ## Writing Documentation @@ -75,8 +76,8 @@ CI (`.github/workflows/test.yaml`) runs `make test`, `make lint`, `make build`, User-facing changes (flags, commands, config fields, report shape, GitHub Action inputs) must be reflected in the docs. The tree is: - `README.md` - features list, install, quickstart, CI usage, GitHub Action example, and doc links. -- `docs/config.md` + `docs/config-v1beta1.json` - the single config doc: YAML specification (hosts, artifacts, charts, selectors, overwrite behavior, signature verification, defaults), envelope reference, and generated JSON Schema. -- `docs/report.md` + `docs/report-v1beta1.json` - sync report envelope reference and its generated JSON Schema. +- `docs/config.md` + `docs/config-v1beta2.json` - the single config doc: YAML specification (hosts, artifacts, charts, selectors, overwrite behavior, signature verification, defaults), envelope reference, and generated JSON Schema. The frozen `docs/config-v1beta1.json` remains published for existing configs. +- `docs/report.md` + `docs/report-v1beta2.json` - sync report envelope reference and its generated JSON Schema. The frozen `docs/report-v1beta1.json` remains published for existing reports. - `docs/sync.md` - `sync` command reference, config source resolution, authentication, flags, output, outcomes, exit codes, and dry-run behavior. - `docs/login.md`, `docs/secret.md`, `docs/keygen.md` - auth login, Kubernetes Secret generation, and JWK key generation command references. - `actions/setup/README.md` + `actions/setup/action.yaml` - GitHub Action inputs and example workflows. @@ -85,8 +86,8 @@ User-facing changes (flags, commands, config fields, report shape, GitHub Action Apply these rules: - New or changed CLI flag: update the flag table in `docs/sync.md` and any relevant examples in `README.md`. -- New or changed config field: add it to the `api/v1beta1.Config` types (with kubebuilder markers), run `make generate`, then update `internal/config` validation/tests, `docs/config.md`, and examples when appropriate. -- New or changed report field: add it to the `api/v1beta1` report types, run `make generate`, then update `internal/sync` assembly/tests and `docs/report.md`. +- New or changed config field: add it to the `api/v1beta2.Config` types (with kubebuilder markers), run `make generate`, then update `internal/config` validation/tests, `docs/config.md`, and examples when appropriate. +- New or changed report field: add it to the `api/v1beta2` report types, run `make generate`, then update `internal/sync` assembly/tests and `docs/report.md`. - New command: add or update README command guidance and create a dedicated reference doc under `docs/` if the command has meaningful flags or output. - Auth command changes: update `docs/login.md`, `docs/secret.md`, or `docs/keygen.md` as applicable. - Output or outcome shape change: update `docs/sync.md`, README examples, and tests that assert text/YAML/JSON output. diff --git a/Makefile b/Makefile index d811d7c..750072a 100644 --- a/Makefile +++ b/Makefile @@ -80,6 +80,25 @@ generate-json-schemas: generate-api ## Generate the config and report JSON Schem -schema-field \ -id "https://raw.githubusercontent.com/fluxcd/flux-mirror/main/docs/report-v1beta1.json" \ -out ./docs/report-v1beta1.json + go run ./tools/schema-gen \ + -controller-gen "$(CONTROLLER_GEN)" \ + -group "mirror.plugin.fluxcd.io" \ + -version "v1beta2" \ + -kind "Config" \ + -type "github.com/fluxcd/flux-mirror/api/v1beta2.Config" \ + -title "Config is the flux-mirror configuration file." \ + -id "https://raw.githubusercontent.com/fluxcd/flux-mirror/main/docs/config-v1beta2.json" \ + -out ./docs/config-v1beta2.json + go run ./tools/schema-gen \ + -controller-gen "$(CONTROLLER_GEN)" \ + -group "mirror.plugin.fluxcd.io" \ + -version "v1beta2" \ + -kind "Report" \ + -type "github.com/fluxcd/flux-mirror/api/v1beta2.ReportSpec" \ + -field "report" \ + -schema-field \ + -id "https://raw.githubusercontent.com/fluxcd/flux-mirror/main/docs/report-v1beta2.json" \ + -out ./docs/report-v1beta2.json .PHONY: package-schemas package-schemas: ## Package JSON Schemas for release assets. @@ -87,6 +106,8 @@ package-schemas: ## Package JSON Schemas for release assets. mkdir -p $(RELEASE_DIR)/schemas cp ./docs/config-v1beta1.json $(RELEASE_DIR)/schemas/ cp ./docs/report-v1beta1.json $(RELEASE_DIR)/schemas/ + cp ./docs/config-v1beta2.json $(RELEASE_DIR)/schemas/ + cp ./docs/report-v1beta2.json $(RELEASE_DIR)/schemas/ tar -czf $(RELEASE_DIR)/schemas.tar.gz -C $(RELEASE_DIR) schemas .PHONY: docker-build diff --git a/README.md b/README.md index 402984d..4989673 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ on upstream chart repositories. - **Registry auth** — OCI auth supports Docker config and credential helpers, cloud workload identity for ECR/ACR/GAR, and per-host bearer credentials from GitHub/Forgejo OIDC, GCP, Azure, AWS STS, SPIFFE JWT-SVID, env vars, files, or - JWK-signed JWTs. Per-host TLS/mTLS (custom CA, client cert, or SPIFFE X.509-SVID) + JWK-signed JWTs. Per-host TLS/mTLS (a certificate directory, inline PEMs, or SPIFFE X.509-SVID) is also supported. Helm HTTP/S credentials come from Helm's repositories config. - **Structured output** — `text` and `yaml`/`json` for downstream tooling, plus a verbose mode that streams every blob and manifest digest @@ -72,7 +72,7 @@ Write a config file describing what to mirror: ```yaml # flux-mirror.yaml -apiVersion: mirror.plugin.fluxcd.io/v1beta1 +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config charts: - name: external-dns @@ -217,9 +217,9 @@ Run `flux mirror --help` for the full flag list. exit codes, and example invocations. - [Config specification](docs/config.md) — YAML schema for `hosts`, `artifacts`, and `charts` entries, selector pipeline, overwrite semantics, - defaults, and the published [JSON Schema](docs/config-v1beta1.json). + defaults, and the published [JSON Schema](docs/config-v1beta2.json). - [Sync report reference](docs/report.md) — report envelope and its - published [JSON Schema](docs/report-v1beta1.json) for `-o json` / `-o yaml`. + published [JSON Schema](docs/report-v1beta2.json) for `-o json` / `-o yaml`. - [Keygen command reference](docs/keygen.md) — generate EdDSA JWK pairs for JWK-based registry auth. - [Login command reference](docs/login.md) — store configured credentials in diff --git a/api/v1beta2/config_types.go b/api/v1beta2/config_types.go new file mode 100644 index 0000000..b96af7b --- /dev/null +++ b/api/v1beta2/config_types.go @@ -0,0 +1,466 @@ +// Copyright 2026 The Flux Authors +// SPDX-License-Identifier: Apache-2.0 + +package v1beta2 + +import ( + "strings" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + // ConfigKind is the kind used by flux-mirror config files. + ConfigKind = "Config" + + // SortBySemver sorts selected tags by semantic version (Selector.SortBy). + SortBySemver = "semver" + // SortByAlphabetical sorts selected tags lexicographically (Selector.SortBy). + SortByAlphabetical = "alphabetical" + // SortByNumerical sorts selected tags as numbers (Selector.SortBy). + SortByNumerical = "numerical" + + // VerifyProviderCosign selects cosign keyless signature verification + // (ArtifactVerification.Provider). + VerifyProviderCosign = "cosign" + + // JWTProviderGitHub mints a GitHub Actions OIDC ID token for the audience. + // It and JWTProviderForgejo currently mint tokens the same way (the + // GitHub/Forgejo Actions endpoint), but they are kept as distinct values so + // the config can adapt to each platform's breaking changes. + JWTProviderGitHub = "github" + // JWTProviderForgejo mints a Forgejo Actions OIDC ID token for the audience. + // See JWTProviderGitHub for why the two providers are kept distinct. + JWTProviderForgejo = "forgejo" + // JWTProviderGCP mints a Google ID token for the audience via Application + // Default Credentials (GKE/GCE metadata server, service account key file, + // workload identity federation, ...). + JWTProviderGCP = "gcp" + // JWTProviderAzure mints a Microsoft Entra ID access token for the audience + // via the default Azure credential chain (AKS/managed identity, workload + // identity federation, environment credentials, ...). + JWTProviderAzure = "azure" + // JWTProviderAWS proves the caller's AWS identity to the registry. AWS mints + // no JWT, so instead of an OIDC token this signs an sts:GetCallerIdentity + // request with the ambient role credentials (IMDS, env, ...) and wraps it in + // a JWT-shaped envelope; the registry replays the signed request to STS to + // verify it and read the caller's account/ARN. Each audience pins a target + // registry via a signed header, not an OIDC audience claim. + JWTProviderAWS = "aws" + // JWTProviderSPIFFE fetches a JWT-SVID from the SPIFFE Workload API + // (ambient SPIFFE_ENDPOINT_SOCKET) for the audiences (defaulting to the host) + // and sends it as the registry credential. This is the HTTP-layer counterpart + // to the transport-layer tls.spiffe provider (X.509-SVID mTLS); the two are + // independent. + JWTProviderSPIFFE = "spiffe" + + // CredentialTypeJWT selects JSON Web Token material for a credential + // (credential.type). It is the only type currently supported; other + // material kinds (e.g. X.509) are reserved for a future release. + CredentialTypeJWT = "jwt" + + // RegistryProviderGeneric is the default registry provider for a host + // (hosts[].provider): the host is an ordinary registry whose transport and + // credential flux-mirror configures directly. It composes with credential and + // tls, unlike the cloud providers below. + RegistryProviderGeneric = "generic" + // RegistryProviderECR selects AWS ECR workload-identity credentials for a + // host (hosts[].provider). It maps to AWS and is mutually exclusive with the + // per-host credential. + RegistryProviderECR = "ecr" + // RegistryProviderACR selects Azure ACR workload-identity credentials for a + // host (hosts[].provider). It maps to Azure and is mutually exclusive with + // the per-host credential. + RegistryProviderACR = "acr" + // RegistryProviderGAR selects Google GAR workload-identity credentials for a + // host (hosts[].provider). It maps to GCP and is mutually exclusive with the + // per-host credential. + RegistryProviderGAR = "gar" + + // defaultChartVersion is the chart version constraint applied when unset. + defaultChartVersion = "*" + // defaultLimit is the chart/selector limit applied when unset. + defaultLimit = 1 + + // defaultJWKExp is the jwkPath/jwkValue JWT lifetime when expiration is unset. + // It matches cijwt's per-request signing lifetime, keeping the default behavior + // of short-lived, freshly signed tokens. + defaultJWKExp = 60 * time.Second +) + +// Config is the flux-mirror configuration file. +// +// It lists the registry hosts to authenticate and the OCI artifacts and Helm +// charts to mirror. +// +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster +type Config struct { + // TypeMeta identifies the config API version and kind. + metav1.TypeMeta `json:",inline"` + + // Hosts configures per-host authentication for OCI registry requests, both + // source pulls and destination pushes. + // +optional + Hosts []RegistryHost `json:"hosts,omitempty"` + + // Charts lists Helm charts to mirror from an HTTP/S repository into an OCI + // registry. Mirroring a chart that already lives in an OCI registry is an + // OCI-to-OCI copy; configure it under Artifacts instead. + // +optional + Charts []ChartEntry `json:"charts,omitempty"` + + // Artifacts lists OCI artifacts to mirror. + // +optional + Artifacts []ArtifactEntry `json:"artifacts,omitempty"` +} + +// RegistryHost binds an authentication method to a registry host. Credential and +// Provider configure the HTTP-layer registry credential (mutually exclusive for a +// cloud provider). TLS configures the transport-layer TLS/mTLS settings: it +// composes with Credential and with the default (generic) Provider, but is +// mutually exclusive with a cloud Provider — a cloud registry provider is a +// managed registry whose transport flux-mirror does not customize. At least one +// of Credential, a cloud Provider, TLS, or MaxChunkSize must be set. +type RegistryHost struct { + // Host is the registry host (and optional port) the auth applies to. + Host string `json:"host"` + + // Credential configures the HTTP-layer registry credential for the host. + // +optional + Credential *RegistryCredential `json:"credential,omitempty"` + + // Provider selects a cloud registry provider's workload-identity credentials + // for the host: RegistryProviderECR, RegistryProviderACR, or + // RegistryProviderGAR. Omitted (or RegistryProviderGeneric) means an ordinary + // registry, for which flux-mirror configures the credential and/or TLS + // directly. A cloud provider is mutually exclusive with Credential and TLS. + // +optional + // +kubebuilder:validation:Enum=generic;ecr;acr;gar + Provider string `json:"provider,omitempty"` + + // TLS configures transport-layer TLS for the host: a PEM directory or inline + // PEM fields (custom CA and/or client certificate), or SPIFFE mutual TLS. + // +optional + TLS *TLS `json:"tls,omitempty"` + + // MaxChunkSize is the maximum size in KiB (1024 bytes) for an OCI blob + // upload PATCH to this host; larger blobs are split into chunked PATCH + // uploads. 0 (the default) disables chunking, sending a single monolithic + // PATCH per blob. Useful for registries or proxies that cap request bodies. + // +optional + // +kubebuilder:validation:Minimum=0 + MaxChunkSize int `json:"maxChunkSize,omitempty"` +} + +// IsCloudProvider reports whether the host selects a cloud registry provider +// (RegistryProviderECR, RegistryProviderACR, or RegistryProviderGAR) rather than +// the default (RegistryProviderGeneric) provider. A cloud provider authenticates +// the host itself and is mutually exclusive with Credential and TLS. The value is +// matched as written. +func (h RegistryHost) IsCloudProvider() bool { + switch h.Provider { + case RegistryProviderECR, RegistryProviderACR, RegistryProviderGAR: + return true + default: + return false + } +} + +// RegistryCredential configures a per-host credential. Type selects the kind of +// material (CredentialTypeJWT) and is required. Exactly one of Provider, Value, +// FromPath, JWKPath, or JWKValue selects how the credential is obtained: +// +// - Provider mints a per-request credential for Audiences (an OIDC token for +// the OIDC providers, a JWT-SVID for spiffe, or a signed +// sts:GetCallerIdentity envelope for aws; see JWTProviderGitHub, +// JWTProviderForgejo, JWTProviderGCP, JWTProviderAzure, JWTProviderAWS, +// JWTProviderSPIFFE). +// - Value sends a static JWT read as-is from the config. Environment +// substitution can fill this from an environment variable. +// - FromPath sends a static JWT read from the file at the path, with leading +// and trailing whitespace trimmed. +// - JWKPath signs a fresh JWT with the private JSON Web Key at the path. +// - JWKValue signs a fresh JWT with the private JSON Web Key read from the config. +// +// Issuer and Subject are required for, and may only be set with, JWKPath or +// JWKValue. Audiences is optional and may only be set with JWKPath, JWKValue, or +// Provider; it defaults to Host. Every source accepts multiple audiences except +// the gcp and azure providers, whose token carries a single aud. Expiration sets +// the JWT lifetime and may only be set with JWKPath or JWKValue, the sources +// whose lifetime flux-mirror controls; it defaults to a short 60s. Every other +// source's lifetime is fixed by an external issuer or is an opaque static token, +// so Expiration is rejected for them. +// +// Username presents the credential as a username/password pair. When unset, the +// credential is used as a bearer token. +// +// Envelope is an optional transform applied to the resolved credential on top of +// whichever source is selected; see its field comment. +type RegistryCredential struct { + // Type is the kind of credential material (CredentialTypeJWT). + // +kubebuilder:validation:Enum=jwt + Type string `json:"type"` + + // Provider mints a per-request credential for the audiences, one of + // JWTProviderGitHub, JWTProviderForgejo, JWTProviderGCP, JWTProviderAzure, + // JWTProviderAWS, or JWTProviderSPIFFE. + // +optional + // +kubebuilder:validation:Enum=github;forgejo;gcp;azure;aws;spiffe + Provider string `json:"provider,omitempty"` + + // Value sends a static JWT read from the config. + // +optional + Value string `json:"value,omitempty"` + + // FromPath sends a static JWT read from the file at the path. + // +optional + FromPath string `json:"fromPath,omitempty"` + + // JWKPath signs a fresh JWT with the private JSON Web Key at the path. + // +optional + JWKPath string `json:"jwkPath,omitempty"` + + // JWKValue signs a fresh JWT with the private JSON Web Key read from the config. + // +optional + JWKValue string `json:"jwkValue,omitempty"` + + // Issuer is the issuer claim for jwkPath/jwkValue-signed tokens. + // +optional + Issuer string `json:"issuer,omitempty"` + + // Subject is the subject claim for jwkPath/jwkValue-signed tokens. + // +optional + Subject string `json:"subject,omitempty"` + + // Audiences is the audience claim. Defaults to Host. + // +optional + Audiences []string `json:"audiences,omitempty"` + + // Expiration is the jwkPath/jwkValue JWT lifetime. Defaults to 60s. + // +optional + Expiration *metav1.Duration `json:"expiration,omitempty"` + + // Username presents the credential as the password of a username/password + // pair. When unset, the credential is used as a bearer token. + // +optional + Username string `json:"username,omitempty"` + + // Envelope transforms the resolved credential before it is sent to the + // registry. It is a Go template whose data is a single Token field holding + // the credential, with two functions available: base64 (standard base64) + // and hex (lowercase hexadecimal). For example, "token-{{ hex .Token }}" + // sends the hex-encoded credential with a token- prefix. An unset envelope + // sends the credential unchanged. + // +optional + Envelope string `json:"envelope,omitempty"` +} + +// EffectiveExpiration returns the jwkPath/jwkValue JWT lifetime with the +// documented default (60s) applied. Only meaningful for jwkPath/jwkValue +// credentials. +func (c RegistryCredential) EffectiveExpiration() time.Duration { + if c.Expiration != nil { + return c.Expiration.Duration + } + return defaultJWKExp +} + +// EffectiveAudiences returns the audiences with the documented default (the +// host) applied. +func (h RegistryHost) EffectiveAudiences() []string { + if h.Credential == nil || len(h.Credential.Audiences) == 0 { + return []string{h.Host} + } + out := make([]string, 0, len(h.Credential.Audiences)) + for _, raw := range h.Credential.Audiences { + if aud := strings.TrimSpace(raw); aud != "" { + out = append(out, aud) + } + } + return out +} + +// TLS configures transport-layer TLS for a host. Exactly one of Path, an inline +// certificate field (CACert, TLSCert, TLSKey), or SPIFFE must be set. +type TLS struct { + // Path is a directory of PEM files, laid out like a mounted Flux + // certSecretRef: ca.crt verifies the registry's server certificate and/or + // tls.crt with tls.key presents a client certificate for mTLS. At least one + // of ca.crt or the tls.crt/tls.key pair must be present. + // +optional + Path string `json:"path,omitempty"` + + // CACert is the PEM-encoded CA bundle used to verify the registry's server + // certificate. It is the inline counterpart of ca.crt in Path. + // +optional + CACert string `json:"ca.crt,omitempty"` + + // TLSCert is the PEM-encoded client certificate chain. It is the inline + // counterpart of tls.crt in Path and must be set with TLSKey. + // +optional + TLSCert string `json:"tls.crt,omitempty"` + + // TLSKey is the PEM-encoded client private key. It is the inline + // counterpart of tls.key in Path and must be set with TLSCert. + // +optional + TLSKey string `json:"tls.key,omitempty"` + + // SPIFFE configures SPIFFE mutual TLS for the host. + // +optional + SPIFFE *SPIFFETLS `json:"spiffe,omitempty"` +} + +// SPIFFETLS configures SPIFFE mutual TLS. The X.509-SVID and trust bundle come +// from the ambient Workload API socket (SPIFFE_ENDPOINT_SOCKET). ServerIDs +// authorizes the registry's SVID; at least one entry is required. +type SPIFFETLS struct { + // ServerIDs authorizes the registry's X.509-SVID. Each entry accepts the + // RFC-0014 forms parsed by the Flux crypto API: an exact SPIFFE ID, a SPIFFE + // trust domain, a SPIFFE ID prefix ending in "/", or the "spiffe://self" + // forms for the client's own trust domain. + // +kubebuilder:validation:MinItems=1 + ServerIDs []string `json:"serverIDs"` +} + +// ChartEntry mirrors a Helm chart from an HTTP/S repository to an OCI +// destination. To mirror a chart that is already in an OCI registry, use an +// ArtifactEntry instead (an OCI Helm chart is mirrored as a plain OCI artifact). +type ChartEntry struct { + // Source is the HTTP/S repository URL the chart is mirrored from. + Source string `json:"source"` + + // Destination is the OCI repository URL the chart is mirrored to. + Destination string `json:"destination"` + + // Name is the chart name. + Name string `json:"name"` + + // Version is the semver constraint selecting which chart versions to mirror. + // +optional + Version string `json:"version,omitempty"` + + // Limit caps how many matching versions are mirrored. 0 means unlimited. + // +optional + // +kubebuilder:validation:Minimum=0 + Limit *int `json:"limit,omitempty"` + + // Overwrite re-mirrors versions that already exist at the destination. + // +optional + Overwrite bool `json:"overwrite,omitempty"` +} + +// EffectiveVersion returns the version constraint with the documented default applied. +func (c ChartEntry) EffectiveVersion() string { + if strings.TrimSpace(c.Version) == "" { + return defaultChartVersion + } + return c.Version +} + +// EffectiveLimit returns the limit with the documented default applied. +func (c ChartEntry) EffectiveLimit() int { + if c.Limit == nil { + return defaultLimit + } + return *c.Limit +} + +// ArtifactEntry mirrors an OCI artifact (image, OCI chart, signed manifest, etc.). +type ArtifactEntry struct { + // Source is the OCI repository the artifact is mirrored from. + Source string `json:"source"` + + // Destination is the OCI repository the artifact is mirrored to. + Destination string `json:"destination"` + + // Selector selects which tags to mirror. + Selector Selector `json:"selector"` + + // Overwrite re-mirrors tags that already exist at the destination. + // +optional + Overwrite bool `json:"overwrite,omitempty"` + + // IncludeReferrers also mirrors the artifact's referrers (signatures, SBOMs). + // +optional + IncludeReferrers bool `json:"includeReferrers,omitempty"` + + // Verify configures signature verification for source artifacts. + // +optional + Verify *ArtifactVerification `json:"verify,omitempty"` +} + +// ArtifactVerification configures signature verification for source artifacts. +type ArtifactVerification struct { + // Provider is the verification provider (VerifyProviderCosign). + // +kubebuilder:validation:Enum=cosign + Provider string `json:"provider"` + + // MatchOIDCIdentity lists the Fulcio certificate identities to accept. + // +optional + MatchOIDCIdentity []OIDCIdentity `json:"matchOIDCIdentity,omitempty"` + + // MinAge requires a signature to be at least this old before mirroring. + // +optional + MinAge *metav1.Duration `json:"minAge,omitempty"` +} + +// OIDCIdentity matches a Fulcio certificate identity. +type OIDCIdentity struct { + // Issuer is the OIDC issuer URL of the signing identity. + Issuer string `json:"issuer"` + + // Subject is a regexp matched against the certificate subject. + Subject string `json:"subject"` +} + +// Selector is the four-step tag selection pipeline: +// regex → semver → sort → top-N. +type Selector struct { + // Regex applies a Go regexp prefilter to tags. + // +optional + Regex *RegexFilter `json:"regex,omitempty"` + + // Semver is a semver constraint applied after the regex prefilter. + // +optional + Semver string `json:"semver,omitempty"` + + // SortBy selects the sort strategy: semver, alphabetical, or numerical. + // +optional + // +kubebuilder:validation:Enum=semver;alphabetical;numerical + SortBy string `json:"sortBy,omitempty"` + + // Limit caps how many tags are mirrored. 0 means unlimited. + // +optional + // +kubebuilder:validation:Minimum=0 + Limit *int `json:"limit,omitempty"` +} + +// EffectiveSortBy returns the sort strategy with the documented default applied. +func (s Selector) EffectiveSortBy() string { + if strings.TrimSpace(s.SortBy) == "" { + return SortBySemver + } + return s.SortBy +} + +// EffectiveLimit returns the cap with the documented default applied. +// Returns 0 to mean "no cap" (unlimited). +func (s Selector) EffectiveLimit() int { + if s.Limit == nil { + return defaultLimit + } + return *s.Limit +} + +// RegexFilter applies a Go regexp prefilter to tags, optionally extracting a +// substring via a named capture group for use as the sort/semver value. +type RegexFilter struct { + // Pattern is the Go regexp applied to tags. + Pattern string `json:"pattern"` + + // Extract is the named capture group used as the sort/semver value. + // +optional + Extract string `json:"extract,omitempty"` +} diff --git a/api/v1beta2/convert.go b/api/v1beta2/convert.go new file mode 100644 index 0000000..dea0255 --- /dev/null +++ b/api/v1beta2/convert.go @@ -0,0 +1,236 @@ +// Copyright 2026 The Flux Authors +// SPDX-License-Identifier: Apache-2.0 + +package v1beta2 + +import ( + "fmt" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + apiv1beta1 "github.com/fluxcd/flux-mirror/api/v1beta1" +) + +// FromV1Beta1 converts a v1beta1 Config to the current v1beta2 shape. It +// renames the credential claims (aud->audiences, iss->issuer, sub->subject, +// exp->expiration), moves hosts[].username under credential, renames the SPIFFE +// credential provider to spiffe, and converts the v1beta1 tls shapes that map +// exactly: inline certificate values and SPIFFE mTLS with an X.509-SVID client +// certificate. Per-file TLS paths, mixed inline/SPIFFE halves, one-sided +// SPIFFE, and authorizeAny have no v1beta2 equivalent and return an error. +// Callers gate this behind a deprecation warning. The declared Kind is carried +// over unchanged so validation still rejects a v1beta1 document that names the +// wrong kind. +func FromV1Beta1(in *apiv1beta1.Config) (*Config, error) { + if in == nil { + return nil, nil + } + out := &Config{ + // Preserve the declared kind so Validate still rejects a mismatched one; + // Decode returns the source apiVersion separately for the warning. + TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: in.Kind}, + } + for i, h := range in.Hosts { + converted, err := registryHostFromV1Beta1(h) + if err != nil { + return nil, fmt.Errorf("hosts[%d]: %w", i, err) + } + out.Hosts = append(out.Hosts, converted) + } + for _, c := range in.Charts { + out.Charts = append(out.Charts, ChartEntry{ + Source: c.Source, + Destination: c.Destination, + Name: c.Name, + Version: c.Version, + Limit: c.Limit, + Overwrite: c.Overwrite, + }) + } + for _, a := range in.Artifacts { + out.Artifacts = append(out.Artifacts, ArtifactEntry{ + Source: a.Source, + Destination: a.Destination, + Selector: selectorFromV1Beta1(a.Selector), + Overwrite: a.Overwrite, + IncludeReferrers: a.IncludeReferrers, + Verify: verificationFromV1Beta1(a.Verify), + }) + } + return out, nil +} + +func registryHostFromV1Beta1(in apiv1beta1.RegistryHost) (RegistryHost, error) { + if strings.TrimSpace(in.Username) != "" && in.Credential == nil { + // v1beta1 validation rejected this; do not silently drop the username. + return RegistryHost{}, fmt.Errorf("username requires credential") + } + tls, err := tlsFromV1Beta1(in.TLS) + if err != nil { + return RegistryHost{}, err + } + out := RegistryHost{ + Host: in.Host, + Provider: in.Provider, + TLS: tls, + MaxChunkSize: in.MaxChunkSize, + } + if in.Credential != nil { + out.Credential = credentialFromV1Beta1(*in.Credential, in.Username) + } + return out, nil +} + +func credentialFromV1Beta1(in apiv1beta1.RegistryCredential, username string) *RegistryCredential { + out := &RegistryCredential{ + Type: CredentialTypeJWT, + Provider: providerFromV1Beta1(in.Provider), + Value: in.Value, + FromPath: in.FromPath, + JWKPath: in.JWKPath, + JWKValue: in.JWKValue, + Issuer: in.Iss, + Subject: in.Sub, + Expiration: in.Exp, + Username: username, + Envelope: in.Envelope, + } + if strings.TrimSpace(in.Aud) != "" { + out.Audiences = []string{in.Aud} + } + return out +} + +// providerFromV1Beta1 maps the v1beta1 credential provider jwt-svid to v1beta2's +// spiffe; every other value is carried over unchanged. +func providerFromV1Beta1(provider string) string { + if provider == apiv1beta1.JWTProviderJWTSVID { + return JWTProviderSPIFFE + } + return provider +} + +// tlsFromV1Beta1 converts the v1beta1 tls shapes that map exactly onto +// v1beta2: inline PEM values and SPIFFE mutual TLS with an X.509-SVID client +// certificate. Per-file paths, mixed inline/SPIFFE halves, one-sided SPIFFE, +// and authorizeAny cannot be represented and return migration guidance. +func tlsFromV1Beta1(in *apiv1beta1.TLS) (*TLS, error) { + if in == nil { + return nil, nil + } + + if in.ServerAuth != nil && in.ServerAuth.SPIFFE != nil { + if strings.TrimSpace(in.ServerAuth.FromPath) != "" || strings.TrimSpace(in.ServerAuth.Value) != "" { + return nil, fmt.Errorf("serverAuth.spiffe cannot be combined with fromPath or value") + } + provider := "" + if in.ClientAuth != nil { + provider = strings.TrimSpace(in.ClientAuth.Provider) + } + if provider != apiv1beta1.TLSClientProviderX509SVID { + return nil, fmt.Errorf("SPIFFE serverAuth requires clientAuth.provider x509-svid; " + + "use tls.spiffe.serverIDs") + } + if in.ClientAuth.Certificate != nil || in.ClientAuth.Key != nil { + return nil, fmt.Errorf("static clientAuth cannot be combined with SPIFFE serverAuth") + } + serverIDs, err := serverIDsFromV1Beta1(in.ServerAuth.SPIFFE) + if err != nil { + return nil, err + } + return &TLS{SPIFFE: &SPIFFETLS{ServerIDs: serverIDs}}, nil + } + + if in.ServerAuth != nil && strings.TrimSpace(in.ServerAuth.FromPath) != "" { + return nil, fmt.Errorf("serverAuth.fromPath cannot be converted; use tls.path") + } + if in.ClientAuth != nil { + if provider := strings.TrimSpace(in.ClientAuth.Provider); provider != "" { + return nil, fmt.Errorf("clientAuth.provider %q cannot be converted without serverAuth.spiffe", provider) + } + if in.ClientAuth.Certificate != nil && strings.TrimSpace(in.ClientAuth.Certificate.FromPath) != "" { + return nil, fmt.Errorf("clientAuth.certificate.fromPath cannot be converted; use tls.path") + } + if in.ClientAuth.Key != nil && strings.TrimSpace(in.ClientAuth.Key.FromPath) != "" { + return nil, fmt.Errorf("clientAuth.key.fromPath cannot be converted; use tls.path") + } + } + + out := &TLS{} + if in.ServerAuth != nil { + out.CACert = strings.TrimSpace(in.ServerAuth.Value) + } + if in.ClientAuth != nil { + if in.ClientAuth.Certificate != nil { + out.TLSCert = strings.TrimSpace(in.ClientAuth.Certificate.Value) + } + if in.ClientAuth.Key != nil { + out.TLSKey = strings.TrimSpace(in.ClientAuth.Key.Value) + } + } + if out.CACert == "" && out.TLSCert == "" && out.TLSKey == "" { + return nil, fmt.Errorf("v1beta1 tls has no inline values to convert; use tls.path or tls.spiffe") + } + return out, nil +} + +// serverIDsFromV1Beta1 maps the v1beta1 single-authorizer SPIFFE block to the +// v1beta2 serverIDs list accepted by the Flux crypto API. +func serverIDsFromV1Beta1(in *apiv1beta1.SPIFFETLS) ([]string, error) { + serverID := strings.TrimSpace(in.ServerID) + trustDomain := strings.TrimSpace(in.TrustDomain) + + n := 0 + if serverID != "" { + n++ + } + if trustDomain != "" { + n++ + } + if in.AuthorizeAny { + n++ + } + if n != 1 { + return nil, fmt.Errorf("serverAuth.spiffe must set exactly one of serverID, trustDomain, or authorizeAny") + } + if in.AuthorizeAny { + return nil, fmt.Errorf("authorizeAny cannot be converted; set serverID or trustDomain") + } + if serverID != "" { + return []string{serverID}, nil + } + if trustDomain == apiv1beta1.TrustDomainSelf { + return []string{"spiffe://self"}, nil + } + return []string{"spiffe://" + trustDomain}, nil +} + +func selectorFromV1Beta1(in apiv1beta1.Selector) Selector { + out := Selector{ + Semver: in.Semver, + SortBy: in.SortBy, + Limit: in.Limit, + } + if in.Regex != nil { + out.Regex = &RegexFilter{Pattern: in.Regex.Pattern, Extract: in.Regex.Extract} + } + return out +} + +func verificationFromV1Beta1(in *apiv1beta1.ArtifactVerification) *ArtifactVerification { + if in == nil { + return nil + } + out := &ArtifactVerification{ + Provider: in.Provider, + MinAge: in.MinAge, + } + for _, id := range in.MatchOIDCIdentity { + out.MatchOIDCIdentity = append(out.MatchOIDCIdentity, OIDCIdentity{ + Issuer: id.Issuer, + Subject: id.Subject, + }) + } + return out +} diff --git a/api/v1beta2/convert_test.go b/api/v1beta2/convert_test.go new file mode 100644 index 0000000..684bcbb --- /dev/null +++ b/api/v1beta2/convert_test.go @@ -0,0 +1,252 @@ +// Copyright 2026 The Flux Authors +// SPDX-License-Identifier: Apache-2.0 + +package v1beta2 + +import ( + "testing" + "time" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + apiv1beta1 "github.com/fluxcd/flux-mirror/api/v1beta1" +) + +func TestFromV1Beta1(t *testing.T) { + g := NewWithT(t) + + in := &apiv1beta1.Config{ + TypeMeta: metav1.TypeMeta{ + APIVersion: apiv1beta1.GroupVersion.String(), + Kind: apiv1beta1.ConfigKind, + }, + Hosts: []apiv1beta1.RegistryHost{{ + Host: "registry.example.com", + Username: "robot", + Credential: &apiv1beta1.RegistryCredential{ + Provider: apiv1beta1.JWTProviderJWTSVID, + Iss: "https://issuer.example", + Sub: "client-id", + Aud: "custom-aud", + Exp: &metav1.Duration{Duration: time.Hour}, + }, + MaxChunkSize: 1024, + }}, + Charts: []apiv1beta1.ChartEntry{{ + Source: "https://charts.example.com", + Destination: "oci://ghcr.io/x", + Name: "foo", + Version: ">=1.0.0", + Limit: new(5), + Overwrite: true, + }}, + Artifacts: []apiv1beta1.ArtifactEntry{{ + Source: "ghcr.io/a/b", + Destination: "ghcr.io/c/d", + Selector: apiv1beta1.Selector{ + Regex: &apiv1beta1.RegexFilter{Pattern: ".*", Extract: "v"}, + SortBy: apiv1beta1.SortByNumerical, + }, + Overwrite: true, + IncludeReferrers: true, + Verify: &apiv1beta1.ArtifactVerification{ + Provider: apiv1beta1.VerifyProviderCosign, + MinAge: &metav1.Duration{Duration: time.Hour}, + MatchOIDCIdentity: []apiv1beta1.OIDCIdentity{{ + Issuer: "https://issuer.example", + Subject: "subject", + }}, + }, + }}, + } + + out, err := FromV1Beta1(in) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(out.APIVersion).To(Equal(GroupVersion.String())) + g.Expect(out.Kind).To(Equal(ConfigKind)) + + g.Expect(out.Hosts).To(HaveLen(1)) + h := out.Hosts[0] + g.Expect(h.Host).To(Equal("registry.example.com")) + g.Expect(h.MaxChunkSize).To(Equal(1024)) + g.Expect(h.Credential.Type).To(Equal(CredentialTypeJWT)) + g.Expect(h.Credential.Provider).To(Equal(JWTProviderSPIFFE)) + g.Expect(h.Credential.Username).To(Equal("robot")) + g.Expect(h.Credential.Issuer).To(Equal("https://issuer.example")) + g.Expect(h.Credential.Subject).To(Equal("client-id")) + g.Expect(h.Credential.Audiences).To(Equal([]string{"custom-aud"})) + g.Expect(h.Credential.Expiration.Duration).To(Equal(time.Hour)) + g.Expect(h.TLS).To(BeNil()) + + g.Expect(out.Charts).To(HaveLen(1)) + g.Expect(out.Charts[0].Name).To(Equal("foo")) + g.Expect(*out.Charts[0].Limit).To(Equal(5)) + g.Expect(out.Charts[0].Overwrite).To(BeTrue()) + + g.Expect(out.Artifacts).To(HaveLen(1)) + g.Expect(out.Artifacts[0].IncludeReferrers).To(BeTrue()) + g.Expect(out.Artifacts[0].Selector.Regex.Pattern).To(Equal(".*")) + g.Expect(out.Artifacts[0].Selector.SortBy).To(Equal(SortByNumerical)) + g.Expect(out.Artifacts[0].Verify.Provider).To(Equal(VerifyProviderCosign)) + g.Expect(out.Artifacts[0].Verify.MinAge.Duration).To(Equal(time.Hour)) + g.Expect(out.Artifacts[0].Verify.MatchOIDCIdentity).To(Equal([]OIDCIdentity{{ + Issuer: "https://issuer.example", + Subject: "subject", + }})) +} + +func TestFromV1Beta1_Nil(t *testing.T) { + g := NewWithT(t) + out, err := FromV1Beta1(nil) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(out).To(BeNil()) +} + +// TestFromV1Beta1_TLSInline proves v1beta1 inline certificate values map onto +// the v1beta2 inline TLS fields. +func TestFromV1Beta1_TLSInline(t *testing.T) { + g := NewWithT(t) + out, err := FromV1Beta1(&apiv1beta1.Config{ + Hosts: []apiv1beta1.RegistryHost{{ + Host: "registry.example.com", + TLS: &apiv1beta1.TLS{ + ServerAuth: &apiv1beta1.TLSServerAuth{Value: "CA"}, + ClientAuth: &apiv1beta1.TLSClientAuth{ + Certificate: &apiv1beta1.TLSData{Value: "CERT"}, + Key: &apiv1beta1.TLSKey{Value: "KEY"}, + }, + }, + }}, + }) + g.Expect(err).ToNot(HaveOccurred()) + tls := out.Hosts[0].TLS + g.Expect(tls.Path).To(BeEmpty()) + g.Expect(tls.SPIFFE).To(BeNil()) + g.Expect(tls.CACert).To(Equal("CA")) + g.Expect(tls.TLSCert).To(Equal("CERT")) + g.Expect(tls.TLSKey).To(Equal("KEY")) +} + +// TestFromV1Beta1_TLSSPIFFE proves the v1beta1 SPIFFE server authorizer maps to +// tls.spiffe.serverIDs. +func TestFromV1Beta1_TLSSPIFFE(t *testing.T) { + for _, tc := range []struct { + name string + in *apiv1beta1.SPIFFETLS + want []string + }{ + { + "serverID", + &apiv1beta1.SPIFFETLS{ServerID: "spiffe://example.org/registry"}, + []string{"spiffe://example.org/registry"}, + }, + {"trustDomain", &apiv1beta1.SPIFFETLS{TrustDomain: "example.org"}, []string{"spiffe://example.org"}}, + {"self", &apiv1beta1.SPIFFETLS{TrustDomain: apiv1beta1.TrustDomainSelf}, []string{"spiffe://self"}}, + } { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + out, err := FromV1Beta1(&apiv1beta1.Config{ + Hosts: []apiv1beta1.RegistryHost{{ + Host: "registry.example.com", + TLS: &apiv1beta1.TLS{ + ServerAuth: &apiv1beta1.TLSServerAuth{SPIFFE: tc.in}, + ClientAuth: &apiv1beta1.TLSClientAuth{Provider: apiv1beta1.TLSClientProviderX509SVID}, + }, + }}, + }) + g.Expect(err).ToNot(HaveOccurred()) + tls := out.Hosts[0].TLS + g.Expect(tls.Path).To(BeEmpty()) + g.Expect(tls.CACert).To(BeEmpty()) + g.Expect(tls.SPIFFE.ServerIDs).To(Equal(tc.want)) + }) + } +} + +// TestFromV1Beta1_RejectsUnmappableTLS covers the v1beta1 TLS shapes with no +// v1beta2 equivalent. +func TestFromV1Beta1_RejectsUnmappableTLS(t *testing.T) { + for _, tc := range []struct { + name string + tls *apiv1beta1.TLS + errText string + }{ + { + name: "gives up on per-file server CA", + tls: &apiv1beta1.TLS{ServerAuth: &apiv1beta1.TLSServerAuth{FromPath: "/ca.crt"}}, + errText: "serverAuth.fromPath cannot be converted", + }, + { + name: "gives up on per-file client cert", + tls: &apiv1beta1.TLS{ClientAuth: &apiv1beta1.TLSClientAuth{ + Certificate: &apiv1beta1.TLSData{FromPath: "/tls.crt"}, + Key: &apiv1beta1.TLSKey{Value: "KEY"}, + }}, + errText: "clientAuth.certificate.fromPath cannot be converted", + }, + { + name: "gives up on one-sided SPIFFE", + tls: &apiv1beta1.TLS{ServerAuth: &apiv1beta1.TLSServerAuth{ + SPIFFE: &apiv1beta1.SPIFFETLS{TrustDomain: apiv1beta1.TrustDomainSelf}, + }}, + errText: "requires clientAuth.provider x509-svid", + }, + { + name: "gives up on authorizeAny", + tls: &apiv1beta1.TLS{ + ServerAuth: &apiv1beta1.TLSServerAuth{SPIFFE: &apiv1beta1.SPIFFETLS{AuthorizeAny: true}}, + ClientAuth: &apiv1beta1.TLSClientAuth{Provider: apiv1beta1.TLSClientProviderX509SVID}, + }, + errText: "authorizeAny cannot be converted", + }, + { + name: "gives up on mixed SPIFFE and value", + tls: &apiv1beta1.TLS{ + ServerAuth: &apiv1beta1.TLSServerAuth{ + Value: "CA", + SPIFFE: &apiv1beta1.SPIFFETLS{TrustDomain: apiv1beta1.TrustDomainSelf}, + }, + ClientAuth: &apiv1beta1.TLSClientAuth{Provider: apiv1beta1.TLSClientProviderX509SVID}, + }, + errText: "cannot be combined", + }, + } { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + _, err := FromV1Beta1(&apiv1beta1.Config{ + Hosts: []apiv1beta1.RegistryHost{{Host: "registry.example.com", TLS: tc.tls}}, + }) + g.Expect(err).To(MatchError(ContainSubstring(tc.errText))) + }) + } +} + +// TestFromV1Beta1_RejectsUsernameWithoutCredential proves conversion does not +// silently drop a host-level username that v1beta1 validation would have +// rejected. +func TestFromV1Beta1_RejectsUsernameWithoutCredential(t *testing.T) { + g := NewWithT(t) + _, err := FromV1Beta1(&apiv1beta1.Config{ + Hosts: []apiv1beta1.RegistryHost{{ + Host: "registry.example.com", + Username: "robot", + TLS: &apiv1beta1.TLS{}, + }}, + }) + g.Expect(err).To(MatchError(ContainSubstring("username requires credential"))) +} + +// TestFromV1Beta1_PreservesKind proves the conversion carries the declared kind +// over unchanged, so Validate can reject a mismatched one. +func TestFromV1Beta1_PreservesKind(t *testing.T) { + g := NewWithT(t) + out, err := FromV1Beta1(&apiv1beta1.Config{ + TypeMeta: metav1.TypeMeta{ + APIVersion: apiv1beta1.GroupVersion.String(), + Kind: "NotConfig", + }, + }) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(out.Kind).To(Equal("NotConfig")) +} diff --git a/api/v1beta2/groupversion_info.go b/api/v1beta2/groupversion_info.go new file mode 100644 index 0000000..c34a15d --- /dev/null +++ b/api/v1beta2/groupversion_info.go @@ -0,0 +1,14 @@ +// Copyright 2026 The Flux Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package v1beta2 contains the API Schema definitions for the flux-mirror +// config and report types. +// +kubebuilder:object:generate=true +// +groupName=mirror.plugin.fluxcd.io +// +versionName=v1beta2 +package v1beta2 + +import "k8s.io/apimachinery/pkg/runtime/schema" + +// GroupVersion identifies the flux-mirror API group and version. +var GroupVersion = schema.GroupVersion{Group: "mirror.plugin.fluxcd.io", Version: "v1beta2"} diff --git a/api/v1beta2/report_types.go b/api/v1beta2/report_types.go new file mode 100644 index 0000000..decd835 --- /dev/null +++ b/api/v1beta2/report_types.go @@ -0,0 +1,268 @@ +// Copyright 2026 The Flux Authors +// SPDX-License-Identifier: Apache-2.0 + +package v1beta2 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +const ( + // ReportKind is the kind used by sync report envelopes. + ReportKind = "Report" + + // ReportSchema is the canonical URL of the JSON Schema describing the Report shape. + ReportSchema = "https://raw.githubusercontent.com/fluxcd/flux-mirror/main/docs/report-v1beta2.json" +) + +// Report is the sync report envelope. +// +// +kubebuilder:object:root=true +type Report struct { + // TypeMeta identifies the report API version and kind. + metav1.TypeMeta `json:",inline"` + + // Schema identifies the JSON Schema for this report. + // +optional + Schema string `json:"$schema,omitempty"` + + // Report contains the sync report data. + Report ReportSpec `json:"report"` +} + +// ReportSpec holds the sync run metadata and results. +type ReportSpec struct { + // Reporter identifies the software that produced the report. + // +kubebuilder:validation:Pattern=`^flux-mirror/` + Reporter string `json:"reporter"` + + // Timestamp is the report creation time in RFC 3339 format. + // +kubebuilder:validation:Format=date-time + Timestamp string `json:"timestamp"` + + // DurationMs is the total wall time of the run in milliseconds. + // +kubebuilder:validation:Minimum=0 + DurationMs int64 `json:"durationMs"` + + // Summary contains aggregate per-outcome tag counts. + Summary ReportSummary `json:"summary"` + + // Results contains one result per mirror entry. + Results []EntryResult `json:"results"` +} + +// ReportSummary aggregates per-outcome tag counts across all entries. Every +// field is always present (zero when unused) so consumers can rely on a stable +// key set. ExitCode is the semantic sync exit code (0 clean, 1 failures, +// 2 drift) and is not affected by the --drift-exit-code override. +type ReportSummary struct { + // Entries is the number of mirror entries processed. + // +kubebuilder:validation:Minimum=0 + Entries int `json:"entries"` + + // Copied is the number of tags copied. + // +kubebuilder:validation:Minimum=0 + Copied int `json:"copied"` + + // Overwritten is the number of tags overwritten. + // +kubebuilder:validation:Minimum=0 + Overwritten int `json:"overwritten"` + + // Skipped is the number of tags skipped. + // +kubebuilder:validation:Minimum=0 + Skipped int `json:"skipped"` + + // Drifted is the number of tags that drifted. + // +kubebuilder:validation:Minimum=0 + Drifted int `json:"drifted"` + + // WouldCopy is the number of tags that would be copied (dry-run). + // +kubebuilder:validation:Minimum=0 + WouldCopy int `json:"wouldCopy"` + + // WouldOverwrite is the number of tags that would be overwritten (dry-run). + // +kubebuilder:validation:Minimum=0 + WouldOverwrite int `json:"wouldOverwrite"` + + // Failed is the number of failed tags and plan-failed entries. + // +kubebuilder:validation:Minimum=0 + Failed int `json:"failed"` + + // ExitCode is the semantic sync exit code (0 clean, 1 failures, 2 drift). + // +kubebuilder:validation:Minimum=0 + ExitCode int `json:"exitCode"` +} + +// EntryStatus is the entry-level outcome. It answers "was the entry planned +// and run, or did it abort at plan time" — distinct from the per-tag Status. +// +// +kubebuilder:validation:Enum=completed;failed +type EntryStatus string + +const ( + // EntryCompleted means the entry produced a plan and ran. Its Tags may be + // empty (nothing matched the selector) or contain StatusFailed rows + // (per-tag problems) — neither makes the entry itself a failure. + EntryCompleted EntryStatus = "completed" + // EntryFailed means the entry could not be planned (e.g. ListTags + // rejected). Error carries the plan-time message and Tags is empty. + EntryFailed EntryStatus = "failed" +) + +// EntryResult is the per-entry slice of a report. Each tag job becomes a +// TagResult row carrying its own status, digest, skip reason, error, and +// referrers — so verification and per-tag detail are expressible without a +// side channel. Status disambiguate an empty Tags array (nothing matched vs +// plan failed). +type EntryResult struct { + // Source identifies the entry's source. + Source string `json:"source"` + + // Destination is the entry's destination repository. + Destination string `json:"destination"` + + // Status is the entry-level outcome. + Status EntryStatus `json:"status"` + + // Error carries the plan-time failure message when Status is failed. + // +optional + Error string `json:"error,omitempty"` + + // Tags lists the per-tag results. + Tags []TagResult `json:"tags"` +} + +// TagResult is one tag's row in the report. Optional fields follow the wire +// contract: digest when resolved; reason only on skipped; error only on +// failed; referrers only when includeReferrers surfaced any; verification only +// when a signature was confirmed. +type TagResult struct { + // Tag is the tag name. + Tag string `json:"tag"` + + // Status is the per-tag outcome. + Status Status `json:"status"` + + // Digest is the resolved source artifact digest. + // +optional + Digest string `json:"digest,omitempty"` + + // Reason explains a skip (up-to-date or signature-too-new). + // +optional + Reason string `json:"reason,omitempty"` + + // Error carries the failure message when Status is failed. + // +optional + Error string `json:"error,omitempty"` + + // Referrers lists the mirrored referrers (signatures, SBOMs, attestations). + // +optional + Referrers []ReferrerResult `json:"referrers,omitempty"` + + // Verification carries the signature verification metadata, when confirmed. + // +optional + Verification *Verification `json:"verification,omitempty"` +} + +// ReferrerResult is one mirrored referrer (cosign signature bundle, SBOM, +// attestation) of a tag. It reuses the per-tag Status enum; Reason is set to +// up-to-date on a skipped referrer (same always-on-skip rule as tags). +type ReferrerResult struct { + // Digest is the referrer digest. + Digest string `json:"digest"` + + // ArtifactType is the referrer's OCI artifact type. + // +optional + ArtifactType string `json:"artifactType,omitempty"` + + // Status is the referrer outcome. + Status Status `json:"status"` + + // Reason explains a skip. + // +optional + Reason string `json:"reason,omitempty"` +} + +// Verification is the signature verification metadata recorded on a tag row. +// Only Provider is guaranteed; everything else is best-effort and omitted when +// empty: Issuer/Identity are set when the keyless cert identity is recovered; +// IntegratedTime only when the bundle carries a verified transparency-log +// timestamp; Age/MinAge only on the signature-too-new skip. +type Verification struct { + // Provider is the verification provider (VerifyProviderCosign). + Provider string `json:"provider"` + + // Issuer is the recovered keyless certificate issuer. + // +optional + Issuer string `json:"issuer,omitempty"` + + // Identity is the recovered keyless certificate identity. + // +optional + Identity string `json:"identity,omitempty"` + + // IntegratedTime is the verified transparency-log timestamp. + // +optional + IntegratedTime string `json:"integratedTime,omitempty"` + + // Age is the signature age on the signature-too-new skip. + // +optional + Age string `json:"age,omitempty"` + + // MinAge is the configured minimum age on the signature-too-new skip. + // +optional + MinAge string `json:"minAge,omitempty"` +} + +// Status is the result of one mirror job, or runner-assigned on failure. +// +// +kubebuilder:validation:Enum=copied;overwritten;skipped;drifted;would-copy;would-overwrite;failed +type Status string + +const ( + // StatusCopied means the destination did not have the tag and it was copied. + StatusCopied Status = "copied" + // StatusOverwritten means the destination had a different digest and it was + // replaced (overwrite enabled). + StatusOverwritten Status = "overwritten" + // StatusSkipped means nothing was copied; the Reason explains why. + StatusSkipped Status = "skipped" + // StatusDrifted means the destination had a different digest and was left + // alone (overwrite disabled). + StatusDrifted Status = "drifted" + // StatusWouldCopy is the dry-run forecast for a tag that would be copied. + StatusWouldCopy Status = "would-copy" + // StatusWouldOverwrite is the dry-run forecast for a tag that would be + // overwritten. + StatusWouldOverwrite Status = "would-overwrite" + // StatusFailed marks a failed mirror. The runner assigns it as a job's own + // status when the job errors (or carries a Job.PlanError); a job never + // returns it as a result status, so it is excluded from validStatuses. + // Nested ReferrerResult rows may carry it directly when a referrer fails. + StatusFailed Status = "failed" +) + +const ( + // ReasonUpToDate means the destination already has the same digest. Always + // set on a StatusSkipped result, absent otherwise. + ReasonUpToDate = "up-to-date" + // ReasonSignatureTooNew means a valid signature was deferred because it is + // younger than the configured verify.minAge. Always set on a StatusSkipped + // result, absent otherwise. + ReasonSignatureTooNew = "signature-too-new" +) + +var validStatuses = map[Status]struct{}{ + StatusCopied: {}, + StatusOverwritten: {}, + StatusSkipped: {}, + StatusDrifted: {}, + StatusWouldCopy: {}, + StatusWouldOverwrite: {}, +} + +// Valid reports whether s is one of the documented job-returnable statuses. +// Catches typos in entry implementations (a `return "coppied"` would otherwise +// compile and produce an unmatched status that's silently ignored by Render). +// StatusFailed is excluded by design: it is runner-assigned, not returned. +func (s Status) Valid() bool { + _, ok := validStatuses[s] + return ok +} diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go new file mode 100644 index 0000000..6090491 --- /dev/null +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -0,0 +1,409 @@ +//go:build !ignore_autogenerated + +// Copyright 2026 The Flux Authors +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by controller-gen. DO NOT EDIT. + +package v1beta2 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ArtifactEntry) DeepCopyInto(out *ArtifactEntry) { + *out = *in + in.Selector.DeepCopyInto(&out.Selector) + if in.Verify != nil { + in, out := &in.Verify, &out.Verify + *out = new(ArtifactVerification) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ArtifactEntry. +func (in *ArtifactEntry) DeepCopy() *ArtifactEntry { + if in == nil { + return nil + } + out := new(ArtifactEntry) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ArtifactVerification) DeepCopyInto(out *ArtifactVerification) { + *out = *in + if in.MatchOIDCIdentity != nil { + in, out := &in.MatchOIDCIdentity, &out.MatchOIDCIdentity + *out = make([]OIDCIdentity, len(*in)) + copy(*out, *in) + } + if in.MinAge != nil { + in, out := &in.MinAge, &out.MinAge + *out = new(v1.Duration) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ArtifactVerification. +func (in *ArtifactVerification) DeepCopy() *ArtifactVerification { + if in == nil { + return nil + } + out := new(ArtifactVerification) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ChartEntry) DeepCopyInto(out *ChartEntry) { + *out = *in + if in.Limit != nil { + in, out := &in.Limit, &out.Limit + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ChartEntry. +func (in *ChartEntry) DeepCopy() *ChartEntry { + if in == nil { + return nil + } + out := new(ChartEntry) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Config) DeepCopyInto(out *Config) { + *out = *in + out.TypeMeta = in.TypeMeta + if in.Hosts != nil { + in, out := &in.Hosts, &out.Hosts + *out = make([]RegistryHost, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Charts != nil { + in, out := &in.Charts, &out.Charts + *out = make([]ChartEntry, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Artifacts != nil { + in, out := &in.Artifacts, &out.Artifacts + *out = make([]ArtifactEntry, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Config. +func (in *Config) DeepCopy() *Config { + if in == nil { + return nil + } + out := new(Config) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Config) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EntryResult) DeepCopyInto(out *EntryResult) { + *out = *in + if in.Tags != nil { + in, out := &in.Tags, &out.Tags + *out = make([]TagResult, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EntryResult. +func (in *EntryResult) DeepCopy() *EntryResult { + if in == nil { + return nil + } + out := new(EntryResult) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OIDCIdentity) DeepCopyInto(out *OIDCIdentity) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OIDCIdentity. +func (in *OIDCIdentity) DeepCopy() *OIDCIdentity { + if in == nil { + return nil + } + out := new(OIDCIdentity) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReferrerResult) DeepCopyInto(out *ReferrerResult) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReferrerResult. +func (in *ReferrerResult) DeepCopy() *ReferrerResult { + if in == nil { + return nil + } + out := new(ReferrerResult) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RegexFilter) DeepCopyInto(out *RegexFilter) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegexFilter. +func (in *RegexFilter) DeepCopy() *RegexFilter { + if in == nil { + return nil + } + out := new(RegexFilter) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RegistryCredential) DeepCopyInto(out *RegistryCredential) { + *out = *in + if in.Audiences != nil { + in, out := &in.Audiences, &out.Audiences + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Expiration != nil { + in, out := &in.Expiration, &out.Expiration + *out = new(v1.Duration) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegistryCredential. +func (in *RegistryCredential) DeepCopy() *RegistryCredential { + if in == nil { + return nil + } + out := new(RegistryCredential) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RegistryHost) DeepCopyInto(out *RegistryHost) { + *out = *in + if in.Credential != nil { + in, out := &in.Credential, &out.Credential + *out = new(RegistryCredential) + (*in).DeepCopyInto(*out) + } + if in.TLS != nil { + in, out := &in.TLS, &out.TLS + *out = new(TLS) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegistryHost. +func (in *RegistryHost) DeepCopy() *RegistryHost { + if in == nil { + return nil + } + out := new(RegistryHost) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Report) DeepCopyInto(out *Report) { + *out = *in + out.TypeMeta = in.TypeMeta + in.Report.DeepCopyInto(&out.Report) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Report. +func (in *Report) DeepCopy() *Report { + if in == nil { + return nil + } + out := new(Report) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Report) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReportSpec) DeepCopyInto(out *ReportSpec) { + *out = *in + out.Summary = in.Summary + if in.Results != nil { + in, out := &in.Results, &out.Results + *out = make([]EntryResult, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReportSpec. +func (in *ReportSpec) DeepCopy() *ReportSpec { + if in == nil { + return nil + } + out := new(ReportSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ReportSummary) DeepCopyInto(out *ReportSummary) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ReportSummary. +func (in *ReportSummary) DeepCopy() *ReportSummary { + if in == nil { + return nil + } + out := new(ReportSummary) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SPIFFETLS) DeepCopyInto(out *SPIFFETLS) { + *out = *in + if in.ServerIDs != nil { + in, out := &in.ServerIDs, &out.ServerIDs + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SPIFFETLS. +func (in *SPIFFETLS) DeepCopy() *SPIFFETLS { + if in == nil { + return nil + } + out := new(SPIFFETLS) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Selector) DeepCopyInto(out *Selector) { + *out = *in + if in.Regex != nil { + in, out := &in.Regex, &out.Regex + *out = new(RegexFilter) + **out = **in + } + if in.Limit != nil { + in, out := &in.Limit, &out.Limit + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Selector. +func (in *Selector) DeepCopy() *Selector { + if in == nil { + return nil + } + out := new(Selector) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TLS) DeepCopyInto(out *TLS) { + *out = *in + if in.SPIFFE != nil { + in, out := &in.SPIFFE, &out.SPIFFE + *out = new(SPIFFETLS) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLS. +func (in *TLS) DeepCopy() *TLS { + if in == nil { + return nil + } + out := new(TLS) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TagResult) DeepCopyInto(out *TagResult) { + *out = *in + if in.Referrers != nil { + in, out := &in.Referrers, &out.Referrers + *out = make([]ReferrerResult, len(*in)) + copy(*out, *in) + } + if in.Verification != nil { + in, out := &in.Verification, &out.Verification + *out = new(Verification) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TagResult. +func (in *TagResult) DeepCopy() *TagResult { + if in == nil { + return nil + } + out := new(TagResult) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Verification) DeepCopyInto(out *Verification) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Verification. +func (in *Verification) DeepCopy() *Verification { + if in == nil { + return nil + } + out := new(Verification) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/flux-mirror/login_test.go b/cmd/flux-mirror/login_test.go index 699071c..084c7c9 100644 --- a/cmd/flux-mirror/login_test.go +++ b/cmd/flux-mirror/login_test.go @@ -15,7 +15,7 @@ import ( "testing" "time" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" "github.com/fluxcd/flux-mirror/internal/registryauth" gojwt "github.com/golang-jwt/jwt/v5" . "github.com/onsi/gomega" @@ -29,24 +29,7 @@ func writeLoginConfig(t *testing.T) string { func writeLoginConfigWithUsername(t *testing.T, username, credBlock string) string { t.Helper() - dir := t.TempDir() - g := NewWithT(t) - src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 -kind: Config -artifacts: - - source: ghcr.io/a/b - destination: ghcr.io/c/d - selector: - semver: ">=1.0.0" - limit: 1 -hosts: - - host: registry.example.com - username: ` + username + ` - credential: -` + credBlock - path := filepath.Join(dir, "config.yaml") - g.Expect(os.WriteFile(path, []byte(src), 0o600)).To(Succeed()) - return path + return writeLoginConfigIn(t, t.TempDir(), " username: "+username+"\n"+credBlock) } // writeLoginConfigIn writes the config into dir, so path fields in credBlock @@ -54,7 +37,7 @@ hosts: func writeLoginConfigIn(t *testing.T, dir, credBlock string) string { t.Helper() g := NewWithT(t) - src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config artifacts: - source: ghcr.io/a/b @@ -65,6 +48,7 @@ artifacts: hosts: - host: registry.example.com credential: + type: jwt ` + credBlock path := filepath.Join(dir, "config.yaml") g.Expect(os.WriteFile(path, []byte(src), 0o600)).To(Succeed()) @@ -89,8 +73,8 @@ func jwkLoginConfig(t *testing.T, extra string) string { jwk := writeJWKIn(t, dir) return writeLoginConfigIn(t, dir, " jwkPath: "+jwk+"\n"+ - " iss: https://issuer.example\n"+ - " sub: client-id\n"+extra) + " issuer: https://issuer.example\n"+ + " subject: client-id\n"+extra) } func parseUnverified(t *testing.T, token string) gojwt.MapClaims { @@ -185,7 +169,7 @@ func TestLogin_FromPath(t *testing.T) { func TestLogin_JWKPath(t *testing.T) { g := NewWithT(t) - cfg := jwkLoginConfig(t, " aud: custom-aud\n") + cfg := jwkLoginConfig(t, " audiences:\n - custom-aud\n") cred, err := loginStore(t, cfg, "") g.Expect(err).ToNot(HaveOccurred()) @@ -208,7 +192,7 @@ func TestLogin_JWKPathExp(t *testing.T) { g.Expect(time.Until(defExp.Time)).To(BeNumerically("<", 5*time.Minute)) // Explicit exp (1h): exp claim is far in the future. - expCred, err := loginStore(t, jwkLoginConfig(t, " exp: 1h\n"), "") + expCred, err := loginStore(t, jwkLoginConfig(t, " expiration: 1h\n"), "") g.Expect(err).ToNot(HaveOccurred()) longExp, err := parseUnverified(t, expCred).GetExpirationTime() g.Expect(err).ToNot(HaveOccurred()) @@ -224,14 +208,53 @@ func TestLogin_AudDefaultsToHost(t *testing.T) { g.Expect(claims["aud"]).To(ContainElement("registry.example.com")) } +func TestLogin_DeprecatedV1Beta1Config(t *testing.T) { + g := NewWithT(t) + + // A hosts-only v1beta1 config still logs in: it is migrated in memory and a + // warning is printed. The host-level username and v1beta1 credential claim + // names are honored. + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 +kind: Config +hosts: + - host: registry.example.com + username: robot + credential: + value: static-token-value +` + path := filepath.Join(t.TempDir(), "config.yaml") + g.Expect(os.WriteFile(path, []byte(src), 0o600)).To(Succeed()) + + dir := t.TempDir() + out, err := executeCommand([]string{ + "login", "--host", "registry.example.com", "--config", path, + "--docker-config", dir, "--plaintext", + }) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(out).To(ContainSubstring("mirror.plugin.fluxcd.io/v1beta1\" is deprecated")) + + data, err := os.ReadFile(filepath.Join(dir, "config.json")) + g.Expect(err).ToNot(HaveOccurred()) + var parsed struct { + Auths map[string]struct { + Auth string `json:"auth"` + } `json:"auths"` + } + g.Expect(json.Unmarshal(data, &parsed)).To(Succeed()) + dec, err := base64.StdEncoding.DecodeString(parsed.Auths["registry.example.com"].Auth) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(string(dec)).To(Equal("robot:static-token-value")) +} + func TestLogin_ConfigFromStdin(t *testing.T) { g := NewWithT(t) t.Setenv("MY_LOGIN_TOKEN", "stdin-token") - src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config hosts: - host: registry.example.com credential: + type: jwt value: ${MY_LOGIN_TOKEN} ` cred, err := loginStore(t, "-", src) @@ -243,11 +266,12 @@ func TestLogin_AuthOnlyConfig(t *testing.T) { g := NewWithT(t) t.Setenv("MY_LOGIN_TOKEN", "auth-only-token") // No charts or artifacts — valid for login, rejected by sync. - src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config hosts: - host: registry.example.com credential: + type: jwt value: ${MY_LOGIN_TOKEN} ` path := filepath.Join(t.TempDir(), "auth-only.yaml") @@ -326,14 +350,16 @@ func TestLogin_AllHostsByDefault(t *testing.T) { g := NewWithT(t) t.Setenv("TOKEN_A", "cred-a") t.Setenv("TOKEN_B", "cred-b") - src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config hosts: - host: a.example.com credential: + type: jwt value: ${TOKEN_A} - host: b.example.com credential: + type: jwt value: ${TOKEN_B} ` cfgPath := filepath.Join(t.TempDir(), "config.yaml") @@ -367,7 +393,7 @@ func TestLogin_HostNotFound(t *testing.T) { func TestLogin_NoHosts(t *testing.T) { g := NewWithT(t) - src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config artifacts: - source: ghcr.io/a/b @@ -396,13 +422,12 @@ func TestLogin_SkipsTLSOnlyHost(t *testing.T) { g := NewWithT(t) // A TLS-only host has no credential to store: login skips it without error // (and without panicking). - src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config hosts: - host: tls.example.com tls: - serverAuth: - value: dummy + path: /certs ` path := filepath.Join(t.TempDir(), "config.yaml") g.Expect(os.WriteFile(path, []byte(src), 0o600)).To(Succeed()) diff --git a/cmd/flux-mirror/progress.go b/cmd/flux-mirror/progress.go index d3e4775..f2a51ae 100644 --- a/cmd/flux-mirror/progress.go +++ b/cmd/flux-mirror/progress.go @@ -11,7 +11,7 @@ import ( "github.com/briandowns/spinner" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" ) // progress drives a single global spinner (on stderr) plus per-job diff --git a/cmd/flux-mirror/sync.go b/cmd/flux-mirror/sync.go index ebfdaae..a243b51 100644 --- a/cmd/flux-mirror/sync.go +++ b/cmd/flux-mirror/sync.go @@ -17,7 +17,7 @@ import ( craneLogs "github.com/google/go-containerregistry/pkg/logs" "github.com/spf13/cobra" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" "github.com/fluxcd/flux-mirror/internal/artifacts" "github.com/fluxcd/flux-mirror/internal/charts" "github.com/fluxcd/flux-mirror/internal/config" @@ -38,7 +38,7 @@ var syncCmd = &cobra.Command{ Use: "sync [CONFIG|-]", Short: "Mirror Helm charts and OCI artifacts to a destination registry", Long: `Mirror Helm charts and OCI artifacts between registries based on a -declarative YAML config (apiVersion: mirror.plugin.fluxcd.io/v1beta1, kind: Config). +declarative YAML config (apiVersion: mirror.plugin.fluxcd.io/v1beta2, kind: Config). OCI registry auth is read from the ambient Docker config (~/.docker/config.json, $DOCKER_CONFIG, and configured credential helpers), or, for hosts listed in the config's 'hosts' section, from a per-host JWT. Helm HTTP/S repository auth is read @@ -366,10 +366,16 @@ func resolveConfigPath(args []string) (string, error) { // fields are resolved (via SecureJoin) within the config file's directory, or the // process working directory when the config is read from stdin. func loadConfig(cmd *cobra.Command, path string, requireEntries bool) (*apiv1.Config, error) { - cfg, err := decodeConfig(cmd, path) + cfg, declaredAPIVersion, err := decodeConfig(cmd, path) if err != nil { + if guide := config.MigrationGuide(declaredAPIVersion); guide != "" { + cmd.PrintErrf("warning: v1beta1 config cannot be migrated:\n%s\n", guide) + } return nil, err } + if warn := config.DeprecationWarning(declaredAPIVersion); warn != "" { + cmd.PrintErrf("warning: %s\n", warn) + } if requireEntries { err = config.Validate(cfg) } else { @@ -405,13 +411,13 @@ func configBaseDir(path string) (string, error) { return filepath.Dir(abs), nil } -func decodeConfig(cmd *cobra.Command, path string) (*apiv1.Config, error) { +func decodeConfig(cmd *cobra.Command, path string) (*apiv1.Config, string, error) { if path == "-" { return config.DecodeWithEnvSubst(cmd.InOrStdin(), !rootArgs.noEnvsubst) } f, err := os.Open(path) if err != nil { - return nil, fmt.Errorf("open config: %w", err) + return nil, "", fmt.Errorf("open config: %w", err) } defer f.Close() return config.DecodeWithEnvSubst(f, !rootArgs.noEnvsubst) diff --git a/cmd/flux-mirror/sync_test.go b/cmd/flux-mirror/sync_test.go index e94020e..6e5fd3f 100644 --- a/cmd/flux-mirror/sync_test.go +++ b/cmd/flux-mirror/sync_test.go @@ -22,7 +22,7 @@ import ( "github.com/fluxcd/pkg/auth/utils/cijwt" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" "github.com/fluxcd/flux-mirror/internal/registryauth" "github.com/fluxcd/flux-mirror/internal/testregistry" ) @@ -72,7 +72,7 @@ func TestJWTTransportOptions(t *testing.T) { g := NewWithT(t) hosts := []apiv1.RegistryHost{{ Host: "mint.example", - Credential: &apiv1.RegistryCredential{Provider: apiv1.JWTProviderForgejo}, + Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, Provider: apiv1.JWTProviderForgejo}, }} opts, err := registryauth.JWTTransportOptions(http.DefaultTransport, hosts) g.Expect(err).ToNot(HaveOccurred()) @@ -85,7 +85,7 @@ func TestJWTTransportOptions(t *testing.T) { g := NewWithT(t) hosts := []apiv1.RegistryHost{{ Host: "static.example", - Credential: &apiv1.RegistryCredential{Value: "env-token"}, + Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, Value: "env-token"}, }} opts, err := registryauth.JWTTransportOptions(http.DefaultTransport, hosts) g.Expect(err).ToNot(HaveOccurred()) @@ -97,7 +97,7 @@ func TestJWTTransportOptions(t *testing.T) { g := NewWithT(t) hosts := []apiv1.RegistryHost{{ Host: "static.example", - Credential: &apiv1.RegistryCredential{FromPath: "/run/secrets/token"}, + Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, FromPath: "/run/secrets/token"}, }} opts, err := registryauth.JWTTransportOptions(http.DefaultTransport, hosts) g.Expect(err).ToNot(HaveOccurred()) @@ -109,11 +109,11 @@ func TestJWTTransportOptions(t *testing.T) { g := NewWithT(t) hosts := []apiv1.RegistryHost{{ Host: "registry.example", - Credential: &apiv1.RegistryCredential{ - JWKPath: writeJWK(t), - Iss: "https://issuer.example", - Sub: "client-id", - Aud: "registry.example", + Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, + JWKPath: writeJWK(t), + Issuer: "https://issuer.example", + Subject: "client-id", + Audiences: []string{"registry.example"}, }, }} opts, err := registryauth.JWTTransportOptions(http.DefaultTransport, hosts) @@ -126,10 +126,10 @@ func TestJWTTransportOptions(t *testing.T) { g := NewWithT(t) hosts := []apiv1.RegistryHost{{ Host: "registry.example", - Credential: &apiv1.RegistryCredential{ + Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, JWKPath: filepath.Join(t.TempDir(), "missing.json"), - Iss: "https://issuer.example", - Sub: "client-id", + Issuer: "https://issuer.example", + Subject: "client-id", }, }} _, err := registryauth.JWTTransportOptions(http.DefaultTransport, hosts) @@ -140,10 +140,10 @@ func TestJWTTransportOptions(t *testing.T) { g := NewWithT(t) hosts := []apiv1.RegistryHost{{ Host: "registry.example", - Credential: &apiv1.RegistryCredential{ + Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, JWKPath: writeJWKS(t, 1), - Iss: "https://issuer.example", - Sub: "client-id", + Issuer: "https://issuer.example", + Subject: "client-id", }, }} opts, err := registryauth.JWTTransportOptions(http.DefaultTransport, hosts) @@ -156,10 +156,10 @@ func TestJWTTransportOptions(t *testing.T) { g := NewWithT(t) hosts := []apiv1.RegistryHost{{ Host: "registry.example", - Credential: &apiv1.RegistryCredential{ + Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, JWKPath: writeJWKS(t, 2), - Iss: "https://issuer.example", - Sub: "client-id", + Issuer: "https://issuer.example", + Subject: "client-id", }, }} _, err := registryauth.JWTTransportOptions(http.DefaultTransport, hosts) @@ -171,10 +171,10 @@ func TestJWTTransportOptions(t *testing.T) { path := writeJWKFile(t, []byte(`{"keys":[]}`)) hosts := []apiv1.RegistryHost{{ Host: "registry.example", - Credential: &apiv1.RegistryCredential{ + Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, JWKPath: path, - Iss: "https://issuer.example", - Sub: "client-id", + Issuer: "https://issuer.example", + Subject: "client-id", }, }} _, err := registryauth.JWTTransportOptions(http.DefaultTransport, hosts) @@ -186,10 +186,10 @@ func TestJWTTransportOptions(t *testing.T) { path := writeJWKFile(t, []byte("not json")) hosts := []apiv1.RegistryHost{{ Host: "registry.example", - Credential: &apiv1.RegistryCredential{ + Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, JWKPath: path, - Iss: "https://issuer.example", - Sub: "client-id", + Issuer: "https://issuer.example", + Subject: "client-id", }, }} _, err := registryauth.JWTTransportOptions(http.DefaultTransport, hosts) @@ -200,11 +200,11 @@ func TestJWTTransportOptions(t *testing.T) { g := NewWithT(t) hosts := []apiv1.RegistryHost{{ Host: "registry.example", - Credential: &apiv1.RegistryCredential{ - JWKValue: string(mustMarshalJWK(t)), - Iss: "https://issuer.example", - Sub: "client-id", - Aud: "registry.example", + Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, + JWKValue: string(mustMarshalJWK(t)), + Issuer: "https://issuer.example", + Subject: "client-id", + Audiences: []string{"registry.example"}, }, }} opts, err := registryauth.JWTTransportOptions(http.DefaultTransport, hosts) @@ -213,15 +213,15 @@ func TestJWTTransportOptions(t *testing.T) { g.Expect(err).ToNot(HaveOccurred()) }) - t.Run("jwkValue with custom exp signs from the inline value", func(t *testing.T) { + t.Run("jwkValue with custom expiration signs from the inline value", func(t *testing.T) { g := NewWithT(t) hosts := []apiv1.RegistryHost{{ Host: "registry.example", - Credential: &apiv1.RegistryCredential{ - JWKValue: string(mustMarshalJWK(t)), - Iss: "https://issuer.example", - Sub: "client-id", - Exp: &metav1.Duration{Duration: time.Hour}, + Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, + JWKValue: string(mustMarshalJWK(t)), + Issuer: "https://issuer.example", + Subject: "client-id", + Expiration: &metav1.Duration{Duration: time.Hour}, }, }} opts, err := registryauth.JWTTransportOptions(http.DefaultTransport, hosts) @@ -234,10 +234,10 @@ func TestJWTTransportOptions(t *testing.T) { g := NewWithT(t) hosts := []apiv1.RegistryHost{{ Host: "registry.example", - Credential: &apiv1.RegistryCredential{ + Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, JWKValue: "not json", - Iss: "https://issuer.example", - Sub: "client-id", + Issuer: "https://issuer.example", + Subject: "client-id", }, }} _, err := registryauth.JWTTransportOptions(http.DefaultTransport, hosts) @@ -248,10 +248,10 @@ func TestJWTTransportOptions(t *testing.T) { g := NewWithT(t) t.Setenv("MY_CI_TOKEN", "env-token") hosts := []apiv1.RegistryHost{ - {Host: "static.example", Credential: &apiv1.RegistryCredential{Value: "env-token"}}, - {Host: "mint.example", Credential: &apiv1.RegistryCredential{Provider: apiv1.JWTProviderGitHub}}, - {Host: "registry.example", Credential: &apiv1.RegistryCredential{ - JWKPath: writeJWK(t), Iss: "https://issuer.example", Sub: "client-id", + {Host: "static.example", Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, Value: "env-token"}}, + {Host: "mint.example", Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, Provider: apiv1.JWTProviderGitHub}}, + {Host: "registry.example", Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, + JWKPath: writeJWK(t), Issuer: "https://issuer.example", Subject: "client-id", }}, } opts, err := registryauth.JWTTransportOptions(http.DefaultTransport, hosts) @@ -287,7 +287,7 @@ func writeConfig(t *testing.T, src, dst string) string { } func configBody(src, dst string) string { - return fmt.Sprintf(`apiVersion: mirror.plugin.fluxcd.io/v1beta1 + return fmt.Sprintf(`apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config artifacts: - source: %s @@ -307,6 +307,66 @@ func TestSync_NoConfigError(t *testing.T) { g.Expect(err.Error()).To(ContainSubstring("config required")) } +func TestSync_DeprecatedAPIVersionWarns(t *testing.T) { + g := NewWithT(t) + + // A v1beta1 config still syncs; the only change is a migration warning on + // stderr. Point both endpoints at the local registry so the run succeeds. + ensureRegistry(t) + src := dockerReg + "/dep-src-" + testregistry.RandSuffix() + dst := dockerReg + "/dep-dst-" + testregistry.RandSuffix() + testregistry.PushImage(t, src+":1.0.0") + + body := fmt.Sprintf(`apiVersion: mirror.plugin.fluxcd.io/v1beta1 +kind: Config +hosts: + - host: %s + username: robot + credential: + value: token +artifacts: + - source: %s + destination: %s + selector: + semver: ">=0.0.0" +`, dockerReg, src, dst) + path := filepath.Join(t.TempDir(), "config.yaml") + g.Expect(os.WriteFile(path, []byte(body), 0o600)).To(Succeed()) + + out, err := executeCommand([]string{"sync", path, "--insecure", "-o", "json"}) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(out).To(ContainSubstring("mirror.plugin.fluxcd.io/v1beta1\" is deprecated")) + g.Expect(out).To(ContainSubstring(`"status": "copied"`)) +} + +func TestSync_DeprecatedV1Beta1ConversionFailureShowsGuide(t *testing.T) { + g := NewWithT(t) + + // A v1beta1 config that cannot be converted fails, but the user still gets + // the migration guide when they need it most. + body := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 +kind: Config +hosts: + - host: registry.example.com + tls: + serverAuth: + fromPath: /etc/ssl/ca.crt +artifacts: + - source: ghcr.io/a/b + destination: ghcr.io/c/d + selector: + semver: ">=0.0.0" +` + path := filepath.Join(t.TempDir(), "config.yaml") + g.Expect(os.WriteFile(path, []byte(body), 0o600)).To(Succeed()) + + out, err := executeCommand([]string{"sync", path}) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("serverAuth.fromPath cannot be converted")) + g.Expect(out).To(ContainSubstring("v1beta1 config cannot be migrated")) + g.Expect(out).To(ContainSubstring("v1beta2 is a breaking change")) +} + func TestSync_ConfigViaEnv(t *testing.T) { g := NewWithT(t) ensureRegistry(t) diff --git a/docs/config-v1beta2.json b/docs/config-v1beta2.json new file mode 100644 index 0000000..5278186 --- /dev/null +++ b/docs/config-v1beta2.json @@ -0,0 +1,328 @@ +{ + "$id": "https://raw.githubusercontent.com/fluxcd/flux-mirror/main/docs/config-v1beta2.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Config is the flux-mirror configuration file.", + "properties": { + "apiVersion": { + "const": "mirror.plugin.fluxcd.io/v1beta2", + "type": "string" + }, + "artifacts": { + "description": "Artifacts lists OCI artifacts to mirror.", + "items": { + "additionalProperties": false, + "description": "ArtifactEntry mirrors an OCI artifact (image, OCI chart, signed manifest, etc.).", + "properties": { + "destination": { + "description": "Destination is the OCI repository the artifact is mirrored to.", + "type": "string" + }, + "includeReferrers": { + "description": "IncludeReferrers also mirrors the artifact's referrers (signatures, SBOMs).", + "type": "boolean" + }, + "overwrite": { + "description": "Overwrite re-mirrors tags that already exist at the destination.", + "type": "boolean" + }, + "selector": { + "additionalProperties": false, + "description": "Selector selects which tags to mirror.", + "properties": { + "limit": { + "description": "Limit caps how many tags are mirrored. 0 means unlimited.", + "minimum": 0, + "type": "integer" + }, + "regex": { + "additionalProperties": false, + "description": "Regex applies a Go regexp prefilter to tags.", + "properties": { + "extract": { + "description": "Extract is the named capture group used as the sort/semver value.", + "type": "string" + }, + "pattern": { + "description": "Pattern is the Go regexp applied to tags.", + "type": "string" + } + }, + "required": [ + "pattern" + ], + "type": "object" + }, + "semver": { + "description": "Semver is a semver constraint applied after the regex prefilter.", + "type": "string" + }, + "sortBy": { + "description": "SortBy selects the sort strategy: semver, alphabetical, or numerical.", + "enum": [ + "semver", + "alphabetical", + "numerical" + ], + "type": "string" + } + }, + "type": "object" + }, + "source": { + "description": "Source is the OCI repository the artifact is mirrored from.", + "type": "string" + }, + "verify": { + "additionalProperties": false, + "description": "Verify configures signature verification for source artifacts.", + "properties": { + "matchOIDCIdentity": { + "description": "MatchOIDCIdentity lists the Fulcio certificate identities to accept.", + "items": { + "additionalProperties": false, + "description": "OIDCIdentity matches a Fulcio certificate identity.", + "properties": { + "issuer": { + "description": "Issuer is the OIDC issuer URL of the signing identity.", + "type": "string" + }, + "subject": { + "description": "Subject is a regexp matched against the certificate subject.", + "type": "string" + } + }, + "required": [ + "issuer", + "subject" + ], + "type": "object" + }, + "type": "array" + }, + "minAge": { + "description": "MinAge requires a signature to be at least this old before mirroring.", + "type": "string" + }, + "provider": { + "description": "Provider is the verification provider (VerifyProviderCosign).", + "enum": [ + "cosign" + ], + "type": "string" + } + }, + "required": [ + "provider" + ], + "type": "object" + } + }, + "required": [ + "destination", + "selector", + "source" + ], + "type": "object" + }, + "type": "array" + }, + "charts": { + "description": "Charts lists Helm charts to mirror from an HTTP/S repository into an OCI\nregistry. Mirroring a chart that already lives in an OCI registry is an\nOCI-to-OCI copy; configure it under Artifacts instead.", + "items": { + "additionalProperties": false, + "description": "ChartEntry mirrors a Helm chart from an HTTP/S repository to an OCI\ndestination. To mirror a chart that is already in an OCI registry, use an\nArtifactEntry instead (an OCI Helm chart is mirrored as a plain OCI artifact).", + "properties": { + "destination": { + "description": "Destination is the OCI repository URL the chart is mirrored to.", + "type": "string" + }, + "limit": { + "description": "Limit caps how many matching versions are mirrored. 0 means unlimited.", + "minimum": 0, + "type": "integer" + }, + "name": { + "description": "Name is the chart name.", + "type": "string" + }, + "overwrite": { + "description": "Overwrite re-mirrors versions that already exist at the destination.", + "type": "boolean" + }, + "source": { + "description": "Source is the HTTP/S repository URL the chart is mirrored from.", + "type": "string" + }, + "version": { + "description": "Version is the semver constraint selecting which chart versions to mirror.", + "type": "string" + } + }, + "required": [ + "destination", + "name", + "source" + ], + "type": "object" + }, + "type": "array" + }, + "hosts": { + "description": "Hosts configures per-host authentication for OCI registry requests, both\nsource pulls and destination pushes.", + "items": { + "additionalProperties": false, + "description": "RegistryHost binds an authentication method to a registry host. Credential and\nProvider configure the HTTP-layer registry credential (mutually exclusive for a\ncloud provider). TLS configures the transport-layer TLS/mTLS settings: it\ncomposes with Credential and with the default (generic) Provider, but is\nmutually exclusive with a cloud Provider — a cloud registry provider is a\nmanaged registry whose transport flux-mirror does not customize. At least one\nof Credential, a cloud Provider, TLS, or MaxChunkSize must be set.", + "properties": { + "credential": { + "additionalProperties": false, + "description": "Credential configures the HTTP-layer registry credential for the host.", + "properties": { + "audiences": { + "description": "Audiences is the audience claim. Defaults to Host.", + "items": { + "type": "string" + }, + "type": "array" + }, + "envelope": { + "description": "Envelope transforms the resolved credential before it is sent to the\nregistry. It is a Go template whose data is a single Token field holding\nthe credential, with two functions available: base64 (standard base64)\nand hex (lowercase hexadecimal). For example, \"token-{{ hex .Token }}\"\nsends the hex-encoded credential with a token- prefix. An unset envelope\nsends the credential unchanged.", + "type": "string" + }, + "expiration": { + "description": "Expiration is the jwkPath/jwkValue JWT lifetime. Defaults to 60s.", + "type": "string" + }, + "fromPath": { + "description": "FromPath sends a static JWT read from the file at the path.", + "type": "string" + }, + "issuer": { + "description": "Issuer is the issuer claim for jwkPath/jwkValue-signed tokens.", + "type": "string" + }, + "jwkPath": { + "description": "JWKPath signs a fresh JWT with the private JSON Web Key at the path.", + "type": "string" + }, + "jwkValue": { + "description": "JWKValue signs a fresh JWT with the private JSON Web Key read from the config.", + "type": "string" + }, + "provider": { + "description": "Provider mints a per-request credential for the audiences, one of\nJWTProviderGitHub, JWTProviderForgejo, JWTProviderGCP, JWTProviderAzure,\nJWTProviderAWS, or JWTProviderSPIFFE.", + "enum": [ + "github", + "forgejo", + "gcp", + "azure", + "aws", + "spiffe" + ], + "type": "string" + }, + "subject": { + "description": "Subject is the subject claim for jwkPath/jwkValue-signed tokens.", + "type": "string" + }, + "type": { + "description": "Type is the kind of credential material (CredentialTypeJWT).", + "enum": [ + "jwt" + ], + "type": "string" + }, + "username": { + "description": "Username presents the credential as the password of a username/password\npair. When unset, the credential is used as a bearer token.", + "type": "string" + }, + "value": { + "description": "Value sends a static JWT read from the config.", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "host": { + "description": "Host is the registry host (and optional port) the auth applies to.", + "type": "string" + }, + "maxChunkSize": { + "description": "MaxChunkSize is the maximum size in KiB (1024 bytes) for an OCI blob\nupload PATCH to this host; larger blobs are split into chunked PATCH\nuploads. 0 (the default) disables chunking, sending a single monolithic\nPATCH per blob. Useful for registries or proxies that cap request bodies.", + "minimum": 0, + "type": "integer" + }, + "provider": { + "description": "Provider selects a cloud registry provider's workload-identity credentials\nfor the host: RegistryProviderECR, RegistryProviderACR, or\nRegistryProviderGAR. Omitted (or RegistryProviderGeneric) means an ordinary\nregistry, for which flux-mirror configures the credential and/or TLS\ndirectly. A cloud provider is mutually exclusive with Credential and TLS.", + "enum": [ + "generic", + "ecr", + "acr", + "gar" + ], + "type": "string" + }, + "tls": { + "additionalProperties": false, + "description": "TLS configures transport-layer TLS for the host: a PEM directory or inline\nPEM fields (custom CA and/or client certificate), or SPIFFE mutual TLS.", + "properties": { + "ca.crt": { + "description": "CACert is the PEM-encoded CA bundle used to verify the registry's server\ncertificate. It is the inline counterpart of ca.crt in Path.", + "type": "string" + }, + "path": { + "description": "Path is a directory of PEM files, laid out like a mounted Flux\ncertSecretRef: ca.crt verifies the registry's server certificate and/or\ntls.crt with tls.key presents a client certificate for mTLS. At least one\nof ca.crt or the tls.crt/tls.key pair must be present.", + "type": "string" + }, + "spiffe": { + "additionalProperties": false, + "description": "SPIFFE configures SPIFFE mutual TLS for the host.", + "properties": { + "serverIDs": { + "description": "ServerIDs authorizes the registry's X.509-SVID. Each entry accepts the\nRFC-0014 forms parsed by the Flux crypto API: an exact SPIFFE ID, a SPIFFE\ntrust domain, a SPIFFE ID prefix ending in \"/\", or the \"spiffe://self\"\nforms for the client's own trust domain.", + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "serverIDs" + ], + "type": "object" + }, + "tls.crt": { + "description": "TLSCert is the PEM-encoded client certificate chain. It is the inline\ncounterpart of tls.crt in Path and must be set with TLSKey.", + "type": "string" + }, + "tls.key": { + "description": "TLSKey is the PEM-encoded client private key. It is the inline\ncounterpart of tls.key in Path and must be set with TLSCert.", + "type": "string" + } + }, + "type": "object" + } + }, + "required": [ + "host" + ], + "type": "object" + }, + "type": "array" + }, + "kind": { + "const": "Config", + "type": "string" + } + }, + "required": [ + "apiVersion", + "kind" + ], + "title": "Config is the flux-mirror configuration file.", + "type": "object" +} diff --git a/docs/config.md b/docs/config.md index 99354a9..41d137a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -17,7 +17,9 @@ Sources can be OCI registries or HTTP/S Helm repositories; destinations are always OCI registries. The config shape is published as a JSON Schema in -[`config-v1beta1.json`](config-v1beta1.json) and is consumed by +[`config-v1beta2.json`](config-v1beta2.json); the deprecated +[`config-v1beta1.json`](config-v1beta1.json) remains published for existing +configs. It is consumed by [`flux mirror sync`](sync.md), [`flux mirror login`](login.md), and [`flux mirror secret`](secret.md). @@ -27,7 +29,7 @@ The following config mirrors a container image and a Helm chart, and authenticates to the destination registry with a per-host credential: ```yaml -apiVersion: mirror.plugin.fluxcd.io/v1beta1 +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config artifacts: - source: docker.io/stefanprodan/podinfo @@ -50,8 +52,9 @@ charts: limit: 2 hosts: - host: quay.io - username: 'my-org+robot-user' credential: + type: jwt + username: 'my-org+robot-user' value: ${QUAY_TOKEN} ``` @@ -67,7 +70,7 @@ In the above example: `quay.io/my-org/charts/podinfo`. The two highest `6.x` versions are mirrored. - Requests to `quay.io` authenticate with the Quay robot token read from the `QUAY_TOKEN` environment variable, presented as a username/password pair - because `username` is set. + because `credential.username` is set. You can run this with: @@ -77,7 +80,7 @@ flux mirror sync ./config.yaml ## Writing a Config spec -A config is a single YAML document with `apiVersion: mirror.plugin.fluxcd.io/v1beta1`, +A config is a single YAML document with `apiVersion: mirror.plugin.fluxcd.io/v1beta2`, `kind: Config`, and any of the `artifacts`, `charts`, and `hosts` lists. At least one `artifacts` or `charts` entry is required, except for @@ -85,6 +88,23 @@ At least one `artifacts` or `charts` entry is required, except for [`flux mirror secret`](secret.md), which read only the `hosts` section and accept a `hosts`-only config. +### API version + +`mirror.plugin.fluxcd.io/v1beta2` is the current `apiVersion`. The previous +`mirror.plugin.fluxcd.io/v1beta1` is deprecated: a config that still declares it +still loads (it is converted to v1beta2 in memory), but prints a migration +warning. v1beta2 is a breaking change that makes `credential.type` required, +renames the credential claims (`aud`→`audiences`, `iss`→`issuer`, +`sub`→`subject`, `exp`→`expiration`), moves `hosts[].username` under +`credential`, renames the SPIFFE credential provider (`jwt-svid`→`spiffe`), and +replaces the `.tls` serverAuth/clientAuth blocks with `.tls.path` (a directory), +inline `.ca.crt`, `.tls.crt`, and `.tls.key` values, or `.tls.spiffe.serverIDs` +(SPIFFE mutual TLS). Update the `apiVersion` and the +renamed fields to silence the warning. Inline v1beta1 `.tls` values and SPIFFE +`serverAuth` with `clientAuth: x509-svid` convert automatically; per-file paths, +one-sided SPIFFE, and `authorizeAny` fail with a migration error and must be +rewritten as `.tls.path`, inline certificate fields, or `.tls.spiffe`. + ### Artifacts `.artifacts` is an optional field that lists OCI artifacts to mirror between OCI @@ -273,20 +293,21 @@ are not listed fall back to the Docker config and its credential helpers Each entry has one required field, `.host`, the registry host (with optional port) the entry applies to (e.g. `registry.example.com` or `localhost:5000`), -unique across `hosts`. Beyond that, an entry configures **either** a -[`.credential`](#per-host-credential) **or** a -[`.provider`](#cloud-registry-providers) — the two are mutually exclusive. A -`credential` host (or a host with neither) may also set a -[`.tls`](#transport-tls) block; `tls` is not allowed with `provider`, because a -cloud registry is managed and its transport is not customized. -[`.maxChunkSize`](#blob-upload-chunking) tunes blob uploads independently of auth. -At least one of `credential`, `provider`, `tls`, or `maxChunkSize` must be set. +unique across `hosts`. Beyond that, an entry configures a +[`.credential`](#per-host-credential) and/or a [`.tls`](#transport-tls) block, or +a cloud [`.provider`](#cloud-registry-providers). A cloud `provider` is mutually +exclusive with `credential` and `tls`, because a cloud registry is managed and +its transport is not customized; the default `generic` provider is an ordinary +registry and composes with both. [`.maxChunkSize`](#blob-upload-chunking) tunes +blob uploads independently of auth. At least one of `credential`, a cloud +`provider`, `tls`, or `maxChunkSize` must be set. ```yaml hosts: # A per-host credential (HTTP-layer token). - host: registry.example.com credential: + type: jwt provider: github # A cloud registry provider, via ambient workload identity. - host: 123456789012.dkr.ecr.us-east-1.amazonaws.com @@ -296,11 +317,13 @@ hosts: #### Per-host credential `.hosts[].credential` configures an HTTP-layer registry credential for the host. -Exactly one **token source** subfield selects how the credential is obtained: +`.type` is required and selects the credential material; only `jwt` is supported +today. Exactly one **token source** subfield selects how the credential is +obtained: -- `.provider`, mints a fresh, per-request credential for the audience using a - cloud or CI identity, then caches and refreshes it on demand. One of `github`, - `forgejo`, `gcp`, `azure`, `aws`, or `jwt-svid` — see +- `.provider`, mints a fresh, per-request credential for the audiences using a + cloud, CI, or SPIFFE identity, then caches and refreshes it on demand. One of + `github`, `forgejo`, `gcp`, `azure`, `aws`, or `spiffe` — see [Token providers](#token-providers) for what each obtains and what the registry must accept. - `.value`, sends the JSON Web Token configured inline as-is (e.g. a GitLab CI/CD `id_token`). Use `${VAR}` to substitute it from the environment while loading the config. @@ -315,35 +338,40 @@ Exactly one **token source** subfield selects how the credential is obtained: The signed and minted sources take additional claim subfields: -- `.iss` and `.sub`, the issuer and subject claims. Both are **required** with - `.jwkPath` or `.jwkValue`, and not allowed with the other sources. -- `.aud`, the audience claim. Allowed with `.jwkPath`, `.jwkValue`, or `.provider`, - and defaults to the host. The audience pins the credential to a specific - registry, so it must match what the registry (or the cloud identity provider) - expects. -- `.exp`, the signed JWT lifetime, as a +- `.issuer` and `.subject`, the issuer and subject claims. Both are **required** + with `.jwkPath` or `.jwkValue`, and not allowed with the other sources. +- `.audiences`, the audience claim as a list. Allowed with `.jwkPath`, + `.jwkValue`, or `.provider`, and defaults to the host. The audience pins the + credential to a specific registry, so it must match what the registry (or the + cloud identity provider) expects. Every source accepts multiple values except + the `gcp` and `azure` providers, which mint a token with a single `aud` and + therefore reject a list longer than one. +- `.expiration`, the signed JWT lifetime, as a [duration](https://pkg.go.dev/time#ParseDuration). Allowed only with `.jwkPath` or `.jwkValue` — the sources whose lifetime `flux mirror` controls — and defaults to `60s`. A longer-lived token is cached and re-minted at half its lifetime. Every other source's lifetime is fixed by its issuer. + +Two subfields apply to every token source: + - `.envelope`, an optional Go template applied to the resolved credential just before it is sent to the registry, for registries that expect the token in a specific serialized form. The template data is a single `.Token` field holding the credential, and two functions are available: `base64` (standard base64) and `hex` (lowercase hexadecimal). For example, `envelope: "token-{{ hex .Token }}"` sends the hex-encoded credential with a - `token-` prefix. The envelope applies to every token source and to both + `token-` prefix. The envelope applies to both [transport styles](#bearer-token-vs-usernamepassword): it transforms the bearer - token, or the password when `.hosts[].username` is set. An unset envelope + token, or the password when `.credential.username` is set. An unset envelope sends the credential unchanged. -- `.hosts[].username`, controls how the resolved credential is transported, and therefore +- `.username`, controls how the resolved credential is transported, and therefore what the registry must accept — see [Bearer token vs. username/password](#bearer-token-vs-usernamepassword). ##### Token providers When `.credential.provider` is set, the credential is obtained from the -provider's ambient identity for the audience (defaulting to the host). This is +provider's ambient identity for the audiences (defaulting to the host). This is what the registry on the other side has to accept: - `github` and `forgejo` request an OIDC ID token from the CI Actions OIDC @@ -351,47 +379,53 @@ what the registry on the other side has to accept: The registry must trust the GitHub/Forgejo Actions issuer and match the token's audience and subject (e.g. `repo:my-org/my-repo:ref:refs/heads/main`). The two mint tokens the same way today but are kept distinct so each platform can - diverge. + diverge. Each audience is sent as a repeated `audience` query parameter; how + the endpoint folds them into the token is up to the provider. - `gcp` obtains a Google ID token for the audience via Application Default Credentials (the GKE/GCE metadata server, a service account key, or workload identity federation). The registry must trust Google's OIDC issuer and the configured audience. User credentials (`authorized_user`, e.g. from `gcloud auth application-default login`) cannot mint a token for a custom - audience: with `.aud` set the run fails, and with `.aud` unset it falls back - to the default-audience ID token carried in the ADC response, whose audience - is the gcloud OAuth client ID and whose identity is the signed-in user. + audience: with `.audiences` set the run fails, and with `.audiences` unset it + falls back to the default-audience ID token carried in the ADC response, whose + audience is the gcloud OAuth client ID and whose identity is the signed-in + user. - `azure` obtains a Microsoft Entra ID access token via the default Azure credential chain (AKS/managed identity, workload identity federation, environment credentials). The audience is requested as the `/.default` - scope, so `.aud` must be the application ID URI (or client ID) of a registered - Entra application the registry validates tokens against. + scope, so `.audiences` must contain a single application ID URI (or client ID) + of a registered Entra application the registry validates tokens against. - `aws` is not an OIDC token. AWS mints no JWT, so `flux mirror` signs an `sts:GetCallerIdentity` request with the ambient role credentials (IRSA, EC2 instance role, environment, ...) and wraps it in a JWT-shaped envelope whose - header is `{"alg":"none","typ":"aws-sigv4-getcalleridentity"}`. The audience is - carried as a signed `X-Audience` header that pins the target registry, not as - an OIDC audience claim. The registry verifies the caller by replaying the - signed request to AWS STS and reading the returned account/ARN, so the - destination **must** understand this scheme — a generic OIDC registry will not. -- `jwt-svid` fetches a JWT-SVID for the audience from the SPIFFE Workload API - (`SPIFFE_ENDPOINT_SOCKET`) and sends it as the credential. The registry must - trust the SPIFFE trust domain's JWT bundle. This is the HTTP-layer counterpart - to the transport-layer SPIFFE X.509-SVID mTLS configured under + header is `{"alg":"none","typ":"aws-sigv4-getcalleridentity"}`. The audiences + are carried as signed `X-Audience` header values, not as an OIDC audience + claim: the registry accepts the request only if the signed list contains an + identity it recognizes, so a single audience ties the request to that one + registry while several authorize each registry listed. The registry verifies + the caller by replaying the signed request to AWS STS and reading the returned + account/ARN, so the destination **must** understand this scheme — a plain OIDC + registry will not. +- `spiffe` fetches a JWT-SVID from the SPIFFE Workload API + (`SPIFFE_ENDPOINT_SOCKET`) for the audiences and sends it as the credential. + The extra audiences are carried as additional JWT-SVID audiences. The registry + must trust the SPIFFE trust domain's JWT bundle. This is the HTTP-layer + counterpart to the transport-layer SPIFFE X.509-SVID mTLS configured under [`.tls`](#transport-tls); the two are independent. ##### Resolving file paths -`.fromPath`, `.jwkPath`, and the `.tls` path fields are resolved relative to the -config file's directory, and a config may only reference files under its own +`.fromPath`, `.jwkPath`, and the `.tls.path` directory are resolved relative to +the config file's directory, and a config may only reference files under its own directory tree. When the config is read from stdin (`-f -`), the process working directory is used as the confinement root instead. ##### Bearer token vs. username/password -`.hosts[].username` controls how the resolved credential is +`.credential.username` controls how the resolved credential is transported, and therefore what the registry on the other side must accept: -- **`username` unset (default)** — the credential is treated as a **bearer +- **`credential.username` unset (default)** — the credential is treated as a **bearer token**. `sync` sends it as an HTTP Bearer credential on every request, with no auth challenge, and `login`/`secret` write it to the Docker config's `registrytoken` field. This suits registries that natively validate an OIDC @@ -401,7 +435,7 @@ transported, and therefore what the registry on the other side must accept: image pulls. Because credential helpers only store username/secret pairs, a `registrytoken` is always written to the config file, never to a keychain helper. -- **`username` set** — the credential becomes the **password** of a +- **`credential.username` set** — the credential becomes the **password** of a username/password pair. `sync` goes through the standard registry auth challenge (credentials are exchanged at the token endpoint, like the cloud providers), and `login`/`secret` write `username`/`password`/`auth`. Use this @@ -409,27 +443,32 @@ transported, and therefore what the registry on the other side must accept: username/password login even when the username is a placeholder the registry ignores. -Choose `username` deliberately: set it when the registry expects a +Choose `credential.username` deliberately: set it when the registry expects a username/password login (or the credential will be consumed by `kubelet`), and leave it unset when the registry validates a self-contained bearer token. #### Cloud registry providers -`.hosts[].provider` authenticates the host with a cloud registry provider's -ambient workload identity — the same mechanism the `flux push artifact` family -uses — and obtains the registry's native credentials directly (no -`credential`/JWT involved). It is mutually exclusive with `credential`. The -supported values are: - +`.hosts[].provider` selects how the host's registry credentials are obtained. It +defaults to `generic`, an ordinary registry for which `credential` and/or `tls` +configure the connection directly. The other values authenticate the host with a +cloud registry provider's ambient workload identity — the same mechanism the +`flux push artifact` family uses — and obtain the registry's native credentials +directly (no `credential`/JWT involved). A cloud provider is mutually exclusive +with `credential` and `tls`. The supported values are: + +- `generic` (default), an ordinary registry whose authentication is configured + through `credential` and/or `tls`. It is the implicit value when `provider` is + omitted, and composes with both. - `ecr`, for Amazon ECR. It uses the AWS credential chain (IRSA, EC2 instance role, environment, SSO), reading the region from the host. - `acr`, for Azure ACR. It uses the default Azure credential chain (managed identity, workload identity, environment, ...). - `gar`, for Google GAR. It uses Google Application Default Credentials. -The resolved credentials are presented to the registry as a username/password -pair through the standard registry auth challenge, and written as -`username`/`password`/`auth` by [`login`](login.md) and +The cloud providers' resolved credentials are presented to the registry as a +username/password pair through the standard registry auth challenge, and written +as `username`/`password`/`auth` by [`login`](login.md) and [`secret`](secret.md). #### Transport TLS @@ -437,60 +476,66 @@ pair through the standard registry auth challenge, and written as `.hosts[].tls` configures transport-layer TLS for the host's registry requests, separately from the HTTP-layer `credential`. It is applied by `sync` (which connects to the registry); `login` and `secret` do not open registry connections -and ignore it. It is not allowed on a `provider` host. The field has two -independent halves, of which at least one must be set: - -- `.serverAuth`, how the registry's server certificate is verified. Set exactly - one of `.fromPath` / `.value` to supply a custom CA bundle - (one or more concatenated PEM certificates), or `.spiffe` to verify the - server's X.509-SVID against the SPIFFE trust bundle. When `.serverAuth` is - omitted entirely, the system trust pool is used. -- `.clientAuth`, the client certificate for mTLS. Set exactly one of `.provider: - x509-svid` (present a SPIFFE X.509-SVID from the Workload API) or the static - `.certificate` + `.key` pair. The `.certificate` is one of - `.fromPath`/`.value`; the `.key` is one of `.fromPath`/`.value`. - -Each half chooses SPIFFE or non-SPIFFE independently, so SPIFFE can authenticate -the client while a normal or custom CA verifies the server, or vice versa. Under -`.serverAuth.spiffe`, set exactly one of `.serverID` (authorize one exact SPIFFE -ID), `.trustDomain` (authorize any SVID in that trust domain; the value `self` -means the client's own trust domain, read from its X.509-SVID), or -`.authorizeAny: true` (accept any SVID the bundle can validate — discouraged). -With SPIFFE on either side, the client SVID and trust bundle come from the -ambient Workload API socket (`SPIFFE_ENDPOINT_SOCKET`) and rotate automatically. +and ignore it. It is not allowed on a cloud `provider` host. Exactly one of the +following must be set: + +- `.path`, a directory laid out like a mounted Flux `certSecretRef` Secret: + - `ca.crt` supplies the CA bundle used to verify the registry's server + certificate (one or more concatenated PEM certificates). + - `tls.crt` and `tls.key` supply a client certificate and key for mTLS. + + At least one of `ca.crt` or the `tls.crt`/`tls.key` pair must be present, and + the two files of the pair must be set together. Legacy Flux key names + (`caFile`, `certFile`, `keyFile`) are accepted as fallbacks, with the standard + names taking precedence. The directory is read with the same key names and + validation as the Flux controllers; relative paths are resolved against the + config file's directory and confined within it, exactly like the credential + file paths. +- inline PEM values, set directly as `.ca.crt`, `.tls.crt`, and `.tls.key`. + Use these when the PEM material is already available to the config, for + example through `${VAR}` substitution, and cannot be mounted as a directory. + `tls.crt` and `tls.key` must be set together, and at least one of `ca.crt` or + the pair must be present. +- `.spiffe`, SPIFFE mutual TLS. `.spiffe.serverIDs` is required and lists the + registry server identities to authorize. The client X.509-SVID and trust + bundle come from the ambient Workload API socket (`SPIFFE_ENDPOINT_SOCKET`) and + rotate automatically. + +SPIFFE is always mutual TLS: when `.spiffe` is set, flux-mirror presents its own +X.509-SVID as the client certificate as part of the handshake. The independent +server/client halves of earlier revisions are gone, as is client-only SPIFFE. + +Each `.spiffe.serverIDs` entry accepts the +[SPIFFE ID formats defined by Flux RFC-0014](/flux/integrations/spiffe-mtls/). ```yaml hosts: # Custom CA for server verification and a static client cert for mTLS. - host: registry.example.com tls: - serverAuth: - fromPath: ./certs/ca.crt - clientAuth: - certificate: - fromPath: ./certs/client.crt - key: - fromPath: ./certs/client.key - - # Full SPIFFE: X.509-SVID client cert and SPIFFE server verification. - - host: spiffe.example.com + path: ./certs # contains ca.crt, tls.crt, and tls.key + + # CA-only: verify the server with a custom CA, no client certificate. + - host: ca-only.example.com + tls: + path: ./certs/ca-only # contains ca.crt + + # Inline PEM values, e.g. injected from CI secrets. + - host: inline.example.com tls: - clientAuth: - provider: x509-svid - serverAuth: - spiffe: - # Pick exactly one server authorization rule: - serverID: spiffe://example.org/registry # exact SPIFFE ID - # trustDomain: example.org # any SVID in this trust domain - # trustDomain: self # any SVID in our own trust domain - # authorizeAny: true # any SVID the bundle validates (discouraged) - - # Client-only SPIFFE: SPIFFE client cert, server verified by a public/custom CA. - - host: public.example.com + ca.crt: ${REGISTRY_CA_PEM} + tls.crt: ${REGISTRY_CLIENT_CERT_PEM} + tls.key: ${REGISTRY_CLIENT_KEY_PEM} + + # SPIFFE mutual TLS: X.509-SVID client cert and SPIFFE server verification. + - host: spiffe.example.com tls: - clientAuth: - provider: x509-svid - # serverAuth omitted → system trust pool verifies the server. + spiffe: + serverIDs: + - spiffe://example.org/registry # exact SPIFFE ID + # - spiffe://example.org/registry/ # any ID under this prefix + # - spiffe://example.org # any SVID in this trust domain + # - spiffe://self # any SVID in our own trust domain ``` #### Blob upload chunking @@ -546,8 +591,9 @@ first mirrored. Referrers that exist with a different digest are skipped | `charts[].version` | `*` | | `charts[].limit` | `1` | | `charts[].overwrite` | `false` | -| `hosts[].credential.aud` | `host` | -| `hosts[].credential.exp` | `60s` | +| `hosts[].provider` | `generic` | +| `hosts[].credential.audiences` | `host` | +| `hosts[].credential.expiration` | `60s` | | `hosts[].credential.envelope` | unset | | `hosts[].maxChunkSize` | `0` | diff --git a/docs/keygen.md b/docs/keygen.md index f2768c9..8c9d564 100644 --- a/docs/keygen.md +++ b/docs/keygen.md @@ -61,7 +61,7 @@ Output: Unlike `provider`/`value` credentials — whose lifetime is fixed by an external issuer — a `jwkPath` credential is signed locally, so you control its lifetime -through [`exp`](config.md#per-host-credential). This makes a key pair plus +through [`expiration`](config.md#per-host-credential). This makes a key pair plus `login` a convenient way to mint a single, long-lived bearer token (for example, a year-long token for a CI system or an air-gapped agent). @@ -71,23 +71,25 @@ a year-long token for a CI system or an air-gapped agent). flux mirror keygen -o ./keys/registry ``` -**2. Reference the private key from a host credential, with a long `exp`.** Set -`iss`/`sub` to the identity the registry expects, and `exp` to the desired -lifetime (here ~1 year). With no `username`, the signed JWT is stored as a bearer -`registrytoken`: +**2. Reference the private key from a host credential, with a long `expiration`.** +Set `issuer`/`subject` to the identity the registry expects, and `expiration` to +the desired lifetime (here ~1 year). With no `credential.username`, the signed +JWT is stored as a bearer `registrytoken`: ```yaml # config.yaml -apiVersion: mirror.plugin.fluxcd.io/v1beta1 +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config hosts: - host: registry.example.com credential: + type: jwt jwkPath: ./keys/registry/privkey.json - iss: https://my-issuer.example - sub: ci-pusher - # aud: registry.example.com # optional, defaults to the host - exp: 8760h # ~1 year + issuer: https://my-issuer.example + subject: ci-pusher + # audiences: # optional, defaults to the host + # - registry.example.com + expiration: 8760h # ~1 year ``` **3. Log in once to mint and store the token.** @@ -96,13 +98,14 @@ hosts: flux mirror login -f ./config.yaml ``` -`login` signs one JWT valid for `exp` and writes it to the Docker config, so -tools like `flux push artifact` authenticate as that identity until it expires. +`login` signs one JWT valid for the configured `expiration` and writes it to the +Docker config, so tools like `flux push artifact` authenticate as that identity +until it expires. **4. Grant access on the registry side.** Share `pubkey.json` with the registry operator, or publish it at an HTTPS URL the registry can fetch as JWKS, so the registry can verify tokens signed by the matching private key (matched by `kid`). > Re-run `login` to mint a fresh token before the current one expires. Treat -> `privkey.json` as a secret: anyone holding it can mint tokens for `sub` until -> the public key is rotated out. +> `privkey.json` as a secret: anyone holding it can mint tokens for the +> configured `subject` until the public key is rotated out. diff --git a/docs/login.md b/docs/login.md index fe667a4..e89d668 100644 --- a/docs/login.md +++ b/docs/login.md @@ -55,10 +55,11 @@ exactly like `docker login`: What gets written depends on the host: - A cloud [`provider`](config.md#cloud-registry-providers) host, or a - [`credential`](config.md#per-host-credential) host with `username` set, - writes `username`/`password`/`auth`. -- A `credential` host without `username` writes the bearer `registrytoken` field - instead. Because credential helpers only store username/secret pairs, a + [`credential`](config.md#per-host-credential) host with `credential.username` + set, writes `username`/`password`/`auth`. +- A `credential` host without `credential.username` writes the bearer + `registrytoken` field instead. Because credential helpers only store + username/secret pairs, a `registrytoken` always goes to the config file (never a keychain helper). See [Bearer token vs. username/password](config.md#bearer-token-vs-usernamepassword). @@ -66,8 +67,8 @@ Pass `--plaintext` to force the base64 `config.json` entry and bypass any configured or auto-detected helper (this applies to the username/password case; `registrytoken` is always file-based). -> **Note:** a TLS-only host (only `tls`, no `credential`/`provider`) has nothing -> to store and is skipped with a `• skipping ` message. +> **Note:** a TLS-only host (only `tls`, no `credential` and no cloud `provider`) +> has nothing to store and is skipped with a `• skipping ` message. ## Examples @@ -84,7 +85,7 @@ A hosts-only config — switch clouds by changing the host and `provider`: ```yaml # hosts.yaml -apiVersion: mirror.plugin.fluxcd.io/v1beta1 +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config hosts: - host: 123456789012.dkr.ecr.us-east-1.amazonaws.com @@ -133,17 +134,19 @@ jobs: ### Log in to GHCR from GitHub Actions GHCR authorizes by the token and ignores the username value, but it still expects -a username/password login — so set `username` to any value (for example a -`github.*` context key) and pass `GITHUB_TOKEN` as the password via `value`: +a username/password login — so set `credential.username` to any value (for +example a `github.*` context key) and pass `GITHUB_TOKEN` as the password via +`value`: ```yaml # hosts.yaml -apiVersion: mirror.plugin.fluxcd.io/v1beta1 +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config hosts: - host: ghcr.io - username: ${GITHUB_REPOSITORY_OWNER} # ignored by GHCR; any value works credential: + type: jwt + username: ${GITHUB_REPOSITORY_OWNER} # ignored by GHCR; any value works value: ${GH_TOKEN} ``` @@ -168,17 +171,18 @@ jobs: ### Log in to Docker Hub from GitHub Actions -Docker Hub uses a standard username/password login. Set `username` to the Docker -Hub account and pass an access token as the password via `value`: +Docker Hub uses a standard username/password login. Set `credential.username` to +the Docker Hub account and pass an access token as the password via `value`: ```yaml # hosts.yaml -apiVersion: mirror.plugin.fluxcd.io/v1beta1 +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config hosts: - host: docker.io - username: ${DOCKERHUB_USERNAME} credential: + type: jwt + username: ${DOCKERHUB_USERNAME} value: ${DOCKERHUB_TOKEN} ``` @@ -228,7 +232,7 @@ flux mirror login --plaintext or whatever `value`/`fromPath` holds). Re-run `login` before it expires; for `provider` sources the registry re-validates each request, so a stored credential stops working once it lapses. To mint a longer-lived login token, - use a `jwkPath`/`jwkValue` credential with a longer `exp` — see + use a `jwkPath`/`jwkValue` credential with a longer `expiration` — see [keygen](keygen.md). - For `aws`, the credential is a JWT-shaped envelope wrapping a signed `sts:GetCallerIdentity` request, not an OIDC token. The destination registry diff --git a/docs/report-v1beta2.json b/docs/report-v1beta2.json new file mode 100644 index 0000000..81f10b0 --- /dev/null +++ b/docs/report-v1beta2.json @@ -0,0 +1,274 @@ +{ + "$id": "https://raw.githubusercontent.com/fluxcd/flux-mirror/main/docs/report-v1beta2.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "$schema": { + "format": "uri", + "type": "string" + }, + "apiVersion": { + "const": "mirror.plugin.fluxcd.io/v1beta2", + "type": "string" + }, + "kind": { + "const": "Report", + "type": "string" + }, + "report": { + "additionalProperties": false, + "description": "ReportSpec holds the sync run metadata and results.", + "properties": { + "durationMs": { + "description": "DurationMs is the total wall time of the run in milliseconds.", + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "reporter": { + "description": "Reporter identifies the software that produced the report.", + "pattern": "^flux-mirror/", + "type": "string" + }, + "results": { + "description": "Results contains one result per mirror entry.", + "items": { + "additionalProperties": false, + "description": "EntryResult is the per-entry slice of a report. Each tag job becomes a\nTagResult row carrying its own status, digest, skip reason, error, and\nreferrers — so verification and per-tag detail are expressible without a\nside channel. Status disambiguate an empty Tags array (nothing matched vs\nplan failed).", + "properties": { + "destination": { + "description": "Destination is the entry's destination repository.", + "type": "string" + }, + "error": { + "description": "Error carries the plan-time failure message when Status is failed.", + "type": "string" + }, + "source": { + "description": "Source identifies the entry's source.", + "type": "string" + }, + "status": { + "description": "Status is the entry-level outcome.", + "enum": [ + "completed", + "failed" + ], + "type": "string" + }, + "tags": { + "description": "Tags lists the per-tag results.", + "items": { + "additionalProperties": false, + "description": "TagResult is one tag's row in the report. Optional fields follow the wire\ncontract: digest when resolved; reason only on skipped; error only on\nfailed; referrers only when includeReferrers surfaced any; verification only\nwhen a signature was confirmed.", + "properties": { + "digest": { + "description": "Digest is the resolved source artifact digest.", + "type": "string" + }, + "error": { + "description": "Error carries the failure message when Status is failed.", + "type": "string" + }, + "reason": { + "description": "Reason explains a skip (up-to-date or signature-too-new).", + "type": "string" + }, + "referrers": { + "description": "Referrers lists the mirrored referrers (signatures, SBOMs, attestations).", + "items": { + "additionalProperties": false, + "description": "ReferrerResult is one mirrored referrer (cosign signature bundle, SBOM,\nattestation) of a tag. It reuses the per-tag Status enum; Reason is set to\nup-to-date on a skipped referrer (same always-on-skip rule as tags).", + "properties": { + "artifactType": { + "description": "ArtifactType is the referrer's OCI artifact type.", + "type": "string" + }, + "digest": { + "description": "Digest is the referrer digest.", + "type": "string" + }, + "reason": { + "description": "Reason explains a skip.", + "type": "string" + }, + "status": { + "description": "Status is the referrer outcome.", + "enum": [ + "copied", + "overwritten", + "skipped", + "drifted", + "would-copy", + "would-overwrite", + "failed" + ], + "type": "string" + } + }, + "required": [ + "digest", + "status" + ], + "type": "object" + }, + "type": "array" + }, + "status": { + "description": "Status is the per-tag outcome.", + "enum": [ + "copied", + "overwritten", + "skipped", + "drifted", + "would-copy", + "would-overwrite", + "failed" + ], + "type": "string" + }, + "tag": { + "description": "Tag is the tag name.", + "type": "string" + }, + "verification": { + "additionalProperties": false, + "description": "Verification carries the signature verification metadata, when confirmed.", + "properties": { + "age": { + "description": "Age is the signature age on the signature-too-new skip.", + "type": "string" + }, + "identity": { + "description": "Identity is the recovered keyless certificate identity.", + "type": "string" + }, + "integratedTime": { + "description": "IntegratedTime is the verified transparency-log timestamp.", + "type": "string" + }, + "issuer": { + "description": "Issuer is the recovered keyless certificate issuer.", + "type": "string" + }, + "minAge": { + "description": "MinAge is the configured minimum age on the signature-too-new skip.", + "type": "string" + }, + "provider": { + "description": "Provider is the verification provider (VerifyProviderCosign).", + "type": "string" + } + }, + "required": [ + "provider" + ], + "type": "object" + } + }, + "required": [ + "status", + "tag" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "destination", + "source", + "status", + "tags" + ], + "type": "object" + }, + "type": "array" + }, + "summary": { + "additionalProperties": false, + "description": "Summary contains aggregate per-outcome tag counts.", + "properties": { + "copied": { + "description": "Copied is the number of tags copied.", + "minimum": 0, + "type": "integer" + }, + "drifted": { + "description": "Drifted is the number of tags that drifted.", + "minimum": 0, + "type": "integer" + }, + "entries": { + "description": "Entries is the number of mirror entries processed.", + "minimum": 0, + "type": "integer" + }, + "exitCode": { + "description": "ExitCode is the semantic sync exit code (0 clean, 1 failures, 2 drift).", + "minimum": 0, + "type": "integer" + }, + "failed": { + "description": "Failed is the number of failed tags and plan-failed entries.", + "minimum": 0, + "type": "integer" + }, + "overwritten": { + "description": "Overwritten is the number of tags overwritten.", + "minimum": 0, + "type": "integer" + }, + "skipped": { + "description": "Skipped is the number of tags skipped.", + "minimum": 0, + "type": "integer" + }, + "wouldCopy": { + "description": "WouldCopy is the number of tags that would be copied (dry-run).", + "minimum": 0, + "type": "integer" + }, + "wouldOverwrite": { + "description": "WouldOverwrite is the number of tags that would be overwritten (dry-run).", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "copied", + "drifted", + "entries", + "exitCode", + "failed", + "overwritten", + "skipped", + "wouldCopy", + "wouldOverwrite" + ], + "type": "object" + }, + "timestamp": { + "description": "Timestamp is the report creation time in RFC 3339 format.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "durationMs", + "reporter", + "results", + "summary", + "timestamp" + ], + "type": "object" + } + }, + "required": [ + "apiVersion", + "kind", + "report" + ], + "title": "ReportSpec holds the sync run metadata and results.", + "type": "object" +} diff --git a/docs/report.md b/docs/report.md index 2a5234a..3b894f6 100644 --- a/docs/report.md +++ b/docs/report.md @@ -7,7 +7,9 @@ weight: 60 The `flux mirror sync` command can emit a structured report of the mirror results by setting `--output` to `json` or `yaml`. The envelope shape is versioned and documented by the JSON Schema in -[`report-v1beta1.json`](report-v1beta1.json). +[`report-v1beta2.json`](report-v1beta2.json); the deprecated +[`report-v1beta1.json`](report-v1beta1.json) remains published for existing +reports. ## Usage @@ -25,7 +27,7 @@ Every report is wrapped in a top-level envelope: | Key | Description | |---------------------|--------------------------------------------------------------| -| `apiVersion` | Report API version. Currently `mirror.plugin.fluxcd.io/v1beta1`. | +| `apiVersion` | Report API version. Currently `mirror.plugin.fluxcd.io/v1beta2`. | | `kind` | Report API kind. Currently `Report`. | | `$schema` | URL of the JSON Schema describing the envelope. JSON only. | | `report.reporter` | Identity of the producer, e.g. `flux-mirror/v0.1.0`. | @@ -163,9 +165,9 @@ second entry failed at plan time because the source repository does not exist. ```json { - "apiVersion": "mirror.plugin.fluxcd.io/v1beta1", + "apiVersion": "mirror.plugin.fluxcd.io/v1beta2", "kind": "Report", - "$schema": "https://raw.githubusercontent.com/fluxcd/flux-mirror/main/docs/report-v1beta1.json", + "$schema": "https://raw.githubusercontent.com/fluxcd/flux-mirror/main/docs/report-v1beta2.json", "report": { "reporter": "flux-mirror/v0.1.0", "timestamp": "2026-06-06T20:12:25Z", diff --git a/docs/secret.md b/docs/secret.md index 68bcd77..c83384c 100644 --- a/docs/secret.md +++ b/docs/secret.md @@ -65,13 +65,13 @@ for `provider` sources involves a network call to mint the token. The global present in the config is an error. - The Secret's `.dockerconfigjson` holds one `auths` entry per host. A cloud [`provider`](config.md#cloud-registry-providers) host, and a - [`credential`](config.md#per-host-credential) host with `username` set, - write `username`/`password`/`auth` (understood by `kubelet` and Flux). A - `credential` host without `username` writes the bearer `registrytoken` field + [`credential`](config.md#per-host-credential) host with `credential.username` + set, write `username`/`password`/`auth` (understood by `kubelet` and Flux). A + `credential` host without `credential.username` writes the bearer `registrytoken` field (understood by go-containerregistry and Flux, **not** by `kubelet`). See [Bearer token vs. username/password](config.md#bearer-token-vs-usernamepassword). -- A TLS-only host (only `tls`, no `credential`/`provider`) has nothing to put in - the Secret and is skipped with a `• skipping ` message. +- A TLS-only host (only `tls`, no `credential` and no cloud `provider`) has + nothing to put in the Secret and is skipped with a `• skipping ` message. - Credentials are short-lived (provider tokens, freshly signed JWTs). Re-run the command to refresh the Secret before they expire; by default it replaces the existing one in place. @@ -151,7 +151,7 @@ A hosts-only config selecting the cloud registry (switch clouds via the host and ```yaml # mirror.yaml -apiVersion: mirror.plugin.fluxcd.io/v1beta1 +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config hosts: - host: 123456789012.dkr.ecr.us-east-1.amazonaws.com @@ -206,7 +206,7 @@ both as a pod `imagePullSecret` and as a Flux `OCIRepository`/`HelmRepository` When the target registry validates the cluster's ServiceAccount OIDC tokens directly, project a ServiceAccount token whose audience is the registry host and feed it to a [`credential`](config.md#per-host-credential) host via -`fromPath`. Setting `username` writes the token as the password of a +`fromPath`. Setting `credential.username` writes the token as the password of a username/password pair (`username`/`password`/`auth`), so the resulting Secret works as a pod `imagePullSecret` (which `kubelet` can use) as well as a Flux `OCIRepository`/`HelmRepository` `.spec.secretRef`. The username value is @@ -222,12 +222,13 @@ are mounted together: ```yaml # mirror.yaml -apiVersion: mirror.plugin.fluxcd.io/v1beta1 +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config hosts: - host: registry.example.com - username: sa-oidc # value the registry expects; often ignored credential: + type: jwt + username: sa-oidc # value the registry expects; often ignored fromPath: registry-token # the audience is set by the projected volume ``` @@ -271,11 +272,12 @@ spec: ### Rotate credentials using a SPIFFE JWT-SVID When the registry accepts SPIFFE JWT-SVIDs, use the -[`jwt-svid` credential provider](config.md#token-providers): it fetches -a JWT-SVID for the audience (the registry host) from the SPIFFE Workload API. -Setting `username` writes the JWT-SVID as the password of a username/password -pair, so the resulting Secret works as a pod `imagePullSecret` (which `kubelet` -can use) as well as a Flux `OCIRepository`/`HelmRepository` `.spec.secretRef`. +[`spiffe` credential provider](config.md#token-providers): it fetches +a JWT-SVID for the audiences (defaulting to the registry host) from the SPIFFE +Workload API. Setting `credential.username` writes the JWT-SVID as the password +of a username/password pair, so the resulting Secret works as a pod +`imagePullSecret` (which `kubelet` can use) as well as a Flux +`OCIRepository`/`HelmRepository` `.spec.secretRef`. The Workload API is exposed to the pod by the SPIFFE CSI driver, and go-spiffe locates it through the `SPIFFE_ENDPOINT_SOCKET` environment variable. @@ -284,14 +286,15 @@ cloud annotations are needed; the registry trusts the SPIFFE trust domain). ```yaml # mirror.yaml -apiVersion: mirror.plugin.fluxcd.io/v1beta1 +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config hosts: - host: registry.example.com - username: spiffe # value the registry expects; often ignored credential: - provider: jwt-svid - # aud defaults to the host (registry.example.com) + type: jwt + provider: spiffe + username: spiffe # value the registry expects; often ignored + # audiences default to the host (registry.example.com) ``` ```yaml diff --git a/docs/sync.md b/docs/sync.md index 6a88c49..299f9aa 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -43,14 +43,16 @@ FLUX_MIRROR_CONFIG=./flux-mirror.yaml flux mirror sync registry's native credentials from the cloud provider's workload identity. - [`credential`](config.md#per-host-credential) resolves a per-host token from a cloud/CI identity, a JWK signature, or an environment variable - or file. With no `username` it is sent as an HTTP Bearer credential; with a - `username` it is the password in the standard registry auth challenge. + or file. With no `credential.username` it is sent as an HTTP Bearer + credential; with `credential.username` it is the password in the standard + registry auth challenge. 2. Hosts not listed under `hosts` use the ambient Docker config and credential helpers (`~/.docker/config.json`, or `$DOCKER_CONFIG` when set). -A non-`provider` host may also set [`tls`](config.md#transport-tls) to -configure transport-layer TLS for its registry connections: a custom CA, a client -certificate (mTLS), or SPIFFE X.509-SVID mTLS. +A host whose `provider` is unset or `generic` may also set +[`tls`](config.md#transport-tls) to configure transport-layer TLS for its registry +connections: a directory or inline PEM values with a custom CA and/or client +certificate, or SPIFFE mutual TLS. HTTP/S Helm repository authentication (for `charts` sources) is separate from OCI auth and always comes from the ambient Helm repositories config: @@ -191,7 +193,7 @@ This config mirrors a few such charts into GitHub Container Registry (GHCR): ```yaml # flux-mirror.yaml -apiVersion: mirror.plugin.fluxcd.io/v1beta1 +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config charts: - name: ingress-nginx @@ -216,11 +218,12 @@ charts: limit: 3 hosts: # GHCR is the push destination. It expects a username/password login (the - # username is any non-empty value; GHCR authorizes by the token), so `username` - # is set and GITHUB_TOKEN is the password. + # username is any non-empty value; GHCR authorizes by the token), so + # `credential.username` is set and GITHUB_TOKEN is the password. - host: ghcr.io - username: my-org credential: + type: jwt + username: my-org value: ${GH_TOKEN} ``` @@ -256,7 +259,7 @@ the only way to mirror an OCI Helm chart between OCI repositories — the [`charts`](config.md#charts) section is exclusively for HTTP/S sources. ```yaml -apiVersion: mirror.plugin.fluxcd.io/v1beta1 +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config artifacts: - source: ghcr.io/stefanprodan/charts/podinfo @@ -272,11 +275,11 @@ This mirrors `ghcr.io/fluxcd` controller images into a private registry from GitHub Actions. The destination authenticates with a username and password from GitHub Actions secrets. The GHCR source is authenticated with `GITHUB_TOKEN` (rather than pulled anonymously) to avoid anonymous pull rate limits — GHCR -expects a username/password login, so `username` is set there too. +expects a username/password login, so `credential.username` is set there too. ```yaml # flux-mirror.yaml -apiVersion: mirror.plugin.fluxcd.io/v1beta1 +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config artifacts: - source: ghcr.io/fluxcd/source-controller @@ -294,15 +297,17 @@ artifacts: hosts: # Source: authenticate to GHCR to avoid anonymous rate limits. - host: ghcr.io - username: my-org credential: + type: jwt + username: my-org value: ${GH_TOKEN} # Destination: a private registry expecting a username/password login. The # username and password are substituted from the environment while loading the # config. - host: registry.internal.example.com - username: ${REGISTRY_USERNAME} credential: + type: jwt + username: ${REGISTRY_USERNAME} value: ${REGISTRY_PASSWORD} ``` @@ -344,7 +349,7 @@ The config is the same for all three clouds except for the destination host and ```yaml # flux-mirror.yaml — Amazon ECR destination -apiVersion: mirror.plugin.fluxcd.io/v1beta1 +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config artifacts: - source: ghcr.io/fluxcd/source-controller # container image @@ -359,8 +364,9 @@ artifacts: selector: { regex: { pattern: "^latest$" }, sortBy: alphabetical } hosts: - host: ghcr.io - username: my-org credential: + type: jwt + username: my-org value: ${GH_TOKEN} - host: 123456789012.dkr.ecr.us-east-1.amazonaws.com provider: ecr @@ -425,7 +431,7 @@ identity. The destination cloud registry is authenticated with cloud credential chain — IRSA, AKS Workload Identity, or GKE Workload Identity). The source registry here accepts the cluster's ServiceAccount OIDC tokens, so it is authenticated with a projected ServiceAccount token sent as an HTTP Bearer -credential (no `username`). +credential (no `credential.username`). First, the ServiceAccount, annotated for each provider's workload identity. The projected token's audience must be the **source** registry host: @@ -470,7 +476,7 @@ are mounted together (below) so a relative `fromPath` resolves: ```yaml # mirror.yaml -apiVersion: mirror.plugin.fluxcd.io/v1beta1 +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config artifacts: - source: source-registry.example.com/library/app @@ -478,10 +484,11 @@ artifacts: selector: { semver: ">=1.0.0", limit: 5 } hosts: # Source: a registry that validates the cluster's ServiceAccount OIDC token. - # No username → the token is sent as an HTTP Bearer credential. Its audience - # (the source host) is set by the projected-token volume, not here. + # No credential.username → the token is sent as an HTTP Bearer credential. Its + # audience (the source host) is set by the projected-token volume, not here. - host: source-registry.example.com credential: + type: jwt fromPath: registry-token # Destination: ECR via the pod's IRSA identity (use acr/gar to switch clouds). - host: 123456789012.dkr.ecr.us-east-1.amazonaws.com @@ -540,9 +547,9 @@ X.509-SVID mTLS. The source host uses the [`aws` credential provider](config.md#token-providers), which signs an `sts:GetCallerIdentity` request the source registry replays to AWS STS, so the pod needs AWS credentials via IRSA. The destination host uses -[`tls`](config.md#transport-tls) with a SPIFFE client certificate and -SPIFFE server verification, and no HTTP credential — the registry authorizes the -client by its X.509-SVID. The SPIFFE SVIDs and trust bundle come from the +[`tls.spiffe`](config.md#transport-tls) and no HTTP credential — the registry +authorizes the client by its X.509-SVID and the server is verified by its SPIFFE +identity. The SPIFFE SVIDs and trust bundle come from the Workload API, which the SPIFFE CSI driver exposes to the pod; go-spiffe locates the socket through `SPIFFE_ENDPOINT_SOCKET`. @@ -561,28 +568,28 @@ metadata: ```yaml # mirror.yaml -apiVersion: mirror.plugin.fluxcd.io/v1beta1 +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config artifacts: - source: source-registry.example.com/library/app destination: registry.example.org/library/app selector: { semver: ">=1.0.0", limit: 5 } hosts: - # Source: a registry that validates the caller's AWS identity. No username → - # the signed sts:GetCallerIdentity envelope is sent as an HTTP Bearer credential. + # Source: a registry that validates the caller's AWS identity. + # No credential.username → the signed sts:GetCallerIdentity envelope is sent + # as an HTTP Bearer credential. - host: source-registry.example.com credential: + type: jwt provider: aws # Destination: SPIFFE mTLS in both directions, no HTTP credential. Our client # X.509-SVID authenticates us; the server's SVID is verified against our own # trust domain. - host: registry.example.org tls: - clientAuth: - provider: x509-svid - serverAuth: - spiffe: - trustDomain: self + spiffe: + serverIDs: + - spiffe://self ``` ```yaml diff --git a/examples/artifacts.yaml b/examples/artifacts.yaml index 02fa043..48e11f7 100644 --- a/examples/artifacts.yaml +++ b/examples/artifacts.yaml @@ -1,4 +1,4 @@ -apiVersion: mirror.plugin.fluxcd.io/v1beta1 +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config artifacts: - source: ghcr.io/stefanprodan/charts/podinfo diff --git a/examples/charts.yaml b/examples/charts.yaml index 405e1dc..216d7a2 100644 --- a/examples/charts.yaml +++ b/examples/charts.yaml @@ -1,4 +1,4 @@ -apiVersion: mirror.plugin.fluxcd.io/v1beta1 +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config charts: - name: metrics-server diff --git a/examples/cronjob.yaml b/examples/cronjob.yaml index 4af2723..2fc4779 100644 --- a/examples/cronjob.yaml +++ b/examples/cronjob.yaml @@ -18,7 +18,7 @@ metadata: namespace: flux-system data: mirror.yaml: | - apiVersion: mirror.plugin.fluxcd.io/v1beta1 + apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config charts: - name: podinfo diff --git a/go.mod b/go.mod index 59b0c85..bc8b3af 100644 --- a/go.mod +++ b/go.mod @@ -12,9 +12,12 @@ require ( github.com/cyphar/filepath-securejoin v0.7.0 github.com/distribution/distribution/v3 v3.1.2 github.com/docker/cli v29.9.2+incompatible + github.com/fluxcd/pkg/apis/crypto v0.5.0 github.com/fluxcd/pkg/auth v0.66.0 github.com/fluxcd/pkg/envsubst v1.9.0 github.com/fluxcd/pkg/helmtestserver v0.45.0 + github.com/fluxcd/pkg/runtime v0.122.0 + github.com/fluxcd/pkg/spiffe v0.2.0 github.com/go-jose/go-jose/v4 v4.1.6 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-containerregistry v0.22.1 @@ -96,10 +99,8 @@ require ( github.com/fatih/color v1.19.0 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/fluxcd/cli-utils v1.4.0 // indirect - github.com/fluxcd/pkg/apis/crypto v0.5.0 // indirect github.com/fluxcd/pkg/apis/meta v1.38.0 // indirect github.com/fluxcd/pkg/cache v0.19.0 // indirect - github.com/fluxcd/pkg/spiffe v0.2.0 // indirect github.com/fluxcd/pkg/testserver v0.15.0 // indirect github.com/fxamacker/cbor/v2 v2.9.3 // indirect github.com/go-errors/errors v1.5.1 // indirect diff --git a/go.sum b/go.sum index 542ddc5..8ea8f5f 100644 --- a/go.sum +++ b/go.sum @@ -19,8 +19,8 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU= filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e/go.mod h1:32qQ5yj3R24Eu03iWFWchdC3OB653wPvoepWejkefbY= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.3 h1:qgnWTiWs1G7AqcKZegFvkJD1bmXJcaDU3uC0ELSgZ5U= @@ -200,6 +200,8 @@ github.com/fluxcd/pkg/envsubst v1.9.0 h1:QTagwV4WLmqF+JsD1rGkcKFmmj4HV64BnqNM1T3 github.com/fluxcd/pkg/envsubst v1.9.0/go.mod h1:9VgSWepmQj/H3icObqh65DNodrUz8P88QnM3EaSE5mA= github.com/fluxcd/pkg/helmtestserver v0.45.0 h1:erSSj767Z3+LX1oq8Fuu1ybO1da0alg4PiIKW43yc3I= github.com/fluxcd/pkg/helmtestserver v0.45.0/go.mod h1:Rc+VEXsTqfmcluj2IIVkujsOFaHxZ49leVBvr46/Rso= +github.com/fluxcd/pkg/runtime v0.122.0 h1:5nT+3PqbRvnzIMBNW6g/lIqsg6B715zaF12J3KnvjJY= +github.com/fluxcd/pkg/runtime v0.122.0/go.mod h1:46y8/GVMFotQkQxzzePecv7cn09DLbQhq6cl2VQ5l9A= github.com/fluxcd/pkg/spiffe v0.2.0 h1:UM5USXRkl92nZZov3R5gAfPxnRpudowsCprqPG/GNaA= github.com/fluxcd/pkg/spiffe v0.2.0/go.mod h1:zLRMyp/vOwQEGWLhe04qKOBorUuTQvwBKBgpmWwkKQE= github.com/fluxcd/pkg/testserver v0.15.0 h1:6Pao9oK1Cu5ssh9c7QoDnnyfPUtxHbw7gJ+5tSOmkWc= @@ -208,6 +210,8 @@ github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZ github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= 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.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q= github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= @@ -664,6 +668,8 @@ golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= +gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.308.0 h1:iGb+4qT3nVb3uuIzwor3yP2nyu+rMZr31PvLFiRNJQk= diff --git a/internal/artifacts/mirror.go b/internal/artifacts/mirror.go index af1d88a..49b86b5 100644 --- a/internal/artifacts/mirror.go +++ b/internal/artifacts/mirror.go @@ -10,7 +10,7 @@ import ( "log/slog" "time" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" "github.com/fluxcd/flux-mirror/internal/oci" "github.com/fluxcd/flux-mirror/internal/selector" "github.com/fluxcd/flux-mirror/internal/sync" diff --git a/internal/artifacts/mirror_test.go b/internal/artifacts/mirror_test.go index 3dad848..2043202 100644 --- a/internal/artifacts/mirror_test.go +++ b/internal/artifacts/mirror_test.go @@ -22,7 +22,7 @@ import ( "github.com/google/go-containerregistry/pkg/v1/types" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" "github.com/fluxcd/flux-mirror/internal/oci" "github.com/fluxcd/flux-mirror/internal/sync" "github.com/fluxcd/flux-mirror/internal/testregistry" diff --git a/internal/charts/mirror.go b/internal/charts/mirror.go index a3f59fe..edc8ca9 100644 --- a/internal/charts/mirror.go +++ b/internal/charts/mirror.go @@ -24,7 +24,7 @@ import ( "helm.sh/helm/v4/pkg/chart/v2/loader" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" "github.com/fluxcd/flux-mirror/internal/helmrepo" "github.com/fluxcd/flux-mirror/internal/oci" "github.com/fluxcd/flux-mirror/internal/selector" diff --git a/internal/charts/mirror_test.go b/internal/charts/mirror_test.go index 1a9e9c0..d5e58dd 100644 --- a/internal/charts/mirror_test.go +++ b/internal/charts/mirror_test.go @@ -14,7 +14,7 @@ import ( . "github.com/onsi/gomega" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" "github.com/fluxcd/flux-mirror/internal/oci" "github.com/fluxcd/flux-mirror/internal/sync" "github.com/fluxcd/flux-mirror/internal/testregistry" diff --git a/internal/config/config.go b/internal/config/config.go index cda861a..1eaab71 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,43 +15,147 @@ import ( "github.com/Masterminds/semver/v3" securejoin "github.com/cyphar/filepath-securejoin" "github.com/google/go-containerregistry/pkg/name" - "github.com/spiffe/go-spiffe/v2/spiffeid" "sigs.k8s.io/yaml" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + cryptov1 "github.com/fluxcd/pkg/apis/crypto/v1" + + apiv1beta1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" "github.com/fluxcd/flux-mirror/internal/envelope" ) -// Decode reads YAML from r into a Config without validating it. -func Decode(r io.Reader) (*apiv1.Config, error) { +// DeprecatedV1Beta1APIVersion is the deprecated v1beta1 apiVersion. A config +// that declares it still loads, but is converted to v1beta2 and a migration +// warning is printed. +var DeprecatedV1Beta1APIVersion = apiv1beta1.GroupVersion.String() + +// V1Beta1MigrationWarning describes how a v1beta1 config maps onto v1beta2. It +// is printed as a warning when a v1beta1 config is loaded, and doubles as the +// migration guide for the breaking rename. +const V1Beta1MigrationWarning = `apiVersion "mirror.plugin.fluxcd.io/v1beta1" is deprecated and will be removed in a future release; the config is being migrated to "mirror.plugin.fluxcd.io/v1beta2" in memory.` + +// V1Beta1Migration describes how to move a v1beta1 config to v1beta2. +const V1Beta1Migration = `v1beta2 is a breaking change: + - credential.type is required (set type: jwt) + - credential.aud -> credential.audiences + - credential.iss -> credential.issuer + - credential.sub -> credential.subject + - credential.exp -> credential.expiration + - hosts[].username -> hosts[].credential.username + - credential provider jwt-svid -> spiffe + - tls is now tls.path (a directory), inline ca.crt/tls.crt/tls.key, or + tls.spiffe.serverIDs (SPIFFE mutual TLS) + - inline v1beta1 tls values and spiffe serverAuth with clientAuth + x509-svid convert automatically; per-file paths, one-sided SPIFFE, and + authorizeAny must be migrated manually +See docs/config.md for the full specification.` + +// DeprecationWarning returns the migration warning to print for a decoded +// config, naming the apiVersion declared by the source document. It is empty +// for the current version. Decode already performs the v1beta1 -> v1beta2 +// conversion, so callers only surface the warning; they do not act on the +// version. +func DeprecationWarning(declaredAPIVersion string) string { + guide := MigrationGuide(declaredAPIVersion) + if guide == "" { + return "" + } + return V1Beta1MigrationWarning + "\n" + guide +} + +// MigrationGuide returns the v1beta1 -> v1beta2 migration guide for the given +// declared apiVersion, or "" for the current version. +func MigrationGuide(declaredAPIVersion string) string { + if declaredAPIVersion != DeprecatedV1Beta1APIVersion { + return "" + } + return V1Beta1Migration +} + +// Decode reads YAML from r into a Config without validating it. It returns the +// config, which is always in the current v1beta2 shape, and the apiVersion +// declared by the source document. A v1beta1 document is converted to v1beta2 +// in memory; pass the declared version to DeprecationWarning to surface the +// migration warning. +func Decode(r io.Reader) (*apiv1.Config, string, error) { return DecodeWithEnvSubst(r, true) } -// DecodeWithEnvSubst reads YAML from r into a Config, optionally applying -// strict environment variable substitution before parsing. -func DecodeWithEnvSubst(r io.Reader, substitute bool) (*apiv1.Config, error) { +// DecodeWithEnvSubst is like Decode, with optional strict environment variable +// substitution before parsing. +func DecodeWithEnvSubst(r io.Reader, substitute bool) (*apiv1.Config, string, error) { data, err := io.ReadAll(r) if err != nil { - return nil, fmt.Errorf("read config: %w", err) + return nil, "", fmt.Errorf("read config: %w", err) } rendered := data if substitute { out, err := envsubst.EvalEnv(string(data), true) if err != nil { - return nil, fmt.Errorf("substitute environment variables: %w", err) + return nil, "", fmt.Errorf("substitute environment variables: %w", err) } rendered = []byte(out) } + // Peek at apiVersion leniently to branch the right wire struct. A v1beta1 + // document uses the old field names, which the strict v1beta2 decoder would + // reject as unknown fields before apiVersion is ever checked. A malformed + // document yields no peeked version and falls through to the strict decoder, + // which reports the parse error. + declaredAPIVersion, _ := peekAPIVersion(rendered) + switch declaredAPIVersion { + case DeprecatedV1Beta1APIVersion: + var old apiv1beta1.Config + // v1beta1 is frozen: decode it leniently, like the release that + // introduced it, so unknown fields do not stop an old config loading. + if err := yaml.Unmarshal(rendered, &old); err != nil { + return nil, declaredAPIVersion, fmt.Errorf("parse config: %w", err) + } + cfg, err := apiv1.FromV1Beta1(&old) + if err != nil { + return nil, declaredAPIVersion, fmt.Errorf("convert v1beta1 config: %w", err) + } + return cfg, declaredAPIVersion, nil + case apiv1.GroupVersion.String(): + // Fall through to the strict current-version decode below. + default: + // Any other declared version (including an unknown one) is not a v1beta1 + // migration, so let the strict decoder report the unknown fields; a + // version mismatch is reported by Validate. + } var cfg apiv1.Config - if err := yaml.Unmarshal(rendered, &cfg); err != nil { - return nil, fmt.Errorf("parse config: %w", err) + // Strict decoding rejects unknown fields (e.g. a removed or misspelled + // key), so a config written for an older schema fails loudly instead of + // silently dropping the field. + if err := yaml.UnmarshalStrict(rendered, &cfg); err != nil { + return nil, declaredAPIVersion, fmt.Errorf("parse config: %w", err) + } + return &cfg, declaredAPIVersion, nil +} + +// peekAPIVersion reads only the apiVersion field, ignoring unknown fields so a +// v1beta1 document is not rejected by the strict current-version decoder. +func peekAPIVersion(rendered []byte) (string, error) { + var meta struct { + APIVersion string `json:"apiVersion"` + } + if err := yaml.Unmarshal(rendered, &meta); err != nil { + return "", err + } + return meta.APIVersion, nil +} + +// checkAPIVersion requires a known apiVersion, turning an unknown one into an +// actionable error rather than a generic mismatch. +func checkAPIVersion(v string) error { + if v != apiv1.GroupVersion.String() { + return fmt.Errorf("apiVersion must be %q, got %q", apiv1.GroupVersion.String(), v) } - return &cfg, nil + return nil } // ResolvePaths resolves every file-path field (credential fromPath and jwkPath, -// and the tls path fields) against baseDir — the directory of the config file — +// and the tls path) against baseDir — the directory of the config file — // using SecureJoin, so the result is always confined within baseDir: relative // paths, absolute paths, "../" segments, and symlinks cannot escape it. Empty // values are left empty. A baseDir of "" is a no-op (paths stay as written). @@ -83,17 +187,7 @@ func hostPathFields(h *apiv1.RegistryHost) []*string { ps = append(ps, &h.Credential.FromPath, &h.Credential.JWKPath) } if h.TLS != nil { - if h.TLS.ServerAuth != nil { - ps = append(ps, &h.TLS.ServerAuth.FromPath) - } - if h.TLS.ClientAuth != nil { - if h.TLS.ClientAuth.Certificate != nil { - ps = append(ps, &h.TLS.ClientAuth.Certificate.FromPath) - } - if h.TLS.ClientAuth.Key != nil { - ps = append(ps, &h.TLS.ClientAuth.Key.FromPath) - } - } + ps = append(ps, &h.TLS.Path) } return ps } @@ -114,8 +208,8 @@ func ValidateNoEntriesOK(c *apiv1.Config) error { } func validate(c *apiv1.Config, requireEntries bool) error { - if c.APIVersion != apiv1.GroupVersion.String() { - return fmt.Errorf("apiVersion must be %q, got %q", apiv1.GroupVersion.String(), c.APIVersion) + if err := checkAPIVersion(c.APIVersion); err != nil { + return err } if c.Kind != apiv1.ConfigKind { return fmt.Errorf("kind must be %q, got %q", apiv1.ConfigKind, c.Kind) @@ -150,28 +244,26 @@ func validateHost(h apiv1.RegistryHost) error { if strings.TrimSpace(h.Host) == "" { return fmt.Errorf("host is required") } - provider := strings.TrimSpace(h.Provider) - if strings.TrimSpace(h.Username) != "" && h.Credential == nil { - return fmt.Errorf("username requires credential") + h.Provider = strings.TrimSpace(h.Provider) + // A generic provider is the default and behaves like an unset provider: it + // composes with credential and tls, unlike a cloud provider. + cloud := h.IsCloudProvider() + if h.Credential != nil && cloud { + return fmt.Errorf("credential and cloud provider are mutually exclusive") } - if h.Credential != nil && provider != "" { - return fmt.Errorf("credential and provider are mutually exclusive") - } - if provider != "" && h.TLS != nil { - return fmt.Errorf("provider and tls are mutually exclusive") + if cloud && h.TLS != nil { + return fmt.Errorf("cloud provider and tls are mutually exclusive") } if h.Credential != nil { if err := validateCredential(*h.Credential); err != nil { return fmt.Errorf("credential: %w", err) } } - if provider != "" { - switch provider { - case apiv1.RegistryProviderECR, apiv1.RegistryProviderACR, apiv1.RegistryProviderGAR: - default: - return fmt.Errorf("provider %q must be one of: %s, %s, %s", - provider, apiv1.RegistryProviderECR, apiv1.RegistryProviderACR, apiv1.RegistryProviderGAR) - } + switch h.Provider { + case "", apiv1.RegistryProviderGeneric, apiv1.RegistryProviderECR, apiv1.RegistryProviderACR, apiv1.RegistryProviderGAR: + default: + return fmt.Errorf("provider %q must be one of: %s, %s, %s, %s", + h.Provider, apiv1.RegistryProviderGeneric, apiv1.RegistryProviderECR, apiv1.RegistryProviderACR, apiv1.RegistryProviderGAR) } if h.TLS != nil { if err := validateTLS(*h.TLS); err != nil { @@ -181,8 +273,8 @@ func validateHost(h apiv1.RegistryHost) error { if h.MaxChunkSize < 0 { return fmt.Errorf("maxChunkSize must be >= 0 (0 disables chunking)") } - if h.Credential == nil && provider == "" && h.TLS == nil && h.MaxChunkSize == 0 { - return fmt.Errorf("one of credential, provider, tls, or maxChunkSize is required") + if h.Credential == nil && !cloud && h.TLS == nil && h.MaxChunkSize == 0 { + return fmt.Errorf("one of credential, a cloud provider, tls, or maxChunkSize is required") } return nil } @@ -199,116 +291,71 @@ func countTrue(conds ...bool) int { return n } -// validateTLS checks the TLS settings: at least one of serverAuth or clientAuth -// must be set, and each (if set) must itself be valid. +// validateTLS checks the TLS settings: exactly one of path, inline certificate +// fields, or spiffe must be set. Inline data is validated here; a path directory +// is checked when the transport is built, because paths are resolved after +// validation. A spiffe block must authorize at least one parseable server +// identity. func validateTLS(t apiv1.TLS) error { - if t.ServerAuth == nil && t.ClientAuth == nil { - return fmt.Errorf("one of serverAuth or clientAuth is required") + inline := t.CACert != "" || t.TLSCert != "" || t.TLSKey != "" + if countTrue(strings.TrimSpace(t.Path) != "", inline, t.SPIFFE != nil) != 1 { + return fmt.Errorf("exactly one of path, inline certificates, or spiffe must be set") } - if t.ServerAuth != nil { - if err := validateTLSServerAuth(*t.ServerAuth); err != nil { - return fmt.Errorf("serverAuth: %w", err) - } + if inline { + return validateTLSInline(t) } - if t.ClientAuth != nil { - if err := validateTLSClientAuth(*t.ClientAuth); err != nil { - return fmt.Errorf("clientAuth: %w", err) - } + if t.SPIFFE == nil { + return nil } - return nil -} - -// validateTLSServerAuth checks that exactly one of the CA-bundle sources or -// spiffe is set. -func validateTLSServerAuth(s apiv1.TLSServerAuth) error { - if countTrue( - strings.TrimSpace(s.FromPath) != "", - strings.TrimSpace(s.Value) != "", - s.SPIFFE != nil, - ) != 1 { - return fmt.Errorf("exactly one of fromPath, value, or spiffe must be set") + if len(t.SPIFFE.ServerIDs) == 0 { + return fmt.Errorf("spiffe.serverIDs is required") } - if s.SPIFFE != nil { - if err := validateSPIFFE(*s.SPIFFE); err != nil { - return fmt.Errorf("spiffe: %w", err) + for i, raw := range t.SPIFFE.ServerIDs { + id := strings.TrimSpace(raw) + if id == "" { + return fmt.Errorf("spiffe.serverIDs[%d] must not be empty", i) + } + if _, err := cryptov1.ParseSPIFFEID(id); err != nil { + return fmt.Errorf("spiffe.serverIDs[%d]: %w", i, err) } } return nil } -// validateTLSData checks that exactly one source is set. -func validateTLSData(d apiv1.TLSData) error { - if countTrue( - strings.TrimSpace(d.FromPath) != "", - strings.TrimSpace(d.Value) != "", - ) != 1 { - return fmt.Errorf("exactly one of fromPath or value must be set") - } - return nil -} - -// validateTLSKey checks that exactly one source is set. -func validateTLSKey(k apiv1.TLSKey) error { - if countTrue( - strings.TrimSpace(k.FromPath) != "", - strings.TrimSpace(k.Value) != "", - ) != 1 { - return fmt.Errorf("exactly one of fromPath or value must be set") - } - return nil -} - -// validateTLSClientAuth checks the client cert source: either provider -// (x509-svid) or a static certificate/key pair, mutually exclusive. -func validateTLSClientAuth(c apiv1.TLSClientAuth) error { - provider := strings.TrimSpace(c.Provider) - hasStatic := c.Certificate != nil || c.Key != nil - if provider != "" && hasStatic { - return fmt.Errorf("provider is mutually exclusive with certificate and key") +// validateTLSInline checks the inline PEM fields: tls.crt and tls.key must be +// set together, at least one of ca.crt or the pair must be present, and no +// inline value may be blank. +func validateTLSInline(t apiv1.TLS) error { + hasCACert := strings.TrimSpace(t.CACert) != "" + hasCert := strings.TrimSpace(t.TLSCert) != "" + hasKey := strings.TrimSpace(t.TLSKey) != "" + if t.CACert != "" && !hasCACert { + return fmt.Errorf("inline ca.crt must not be empty") } - if provider != "" { - if provider != apiv1.TLSClientProviderX509SVID { - return fmt.Errorf("provider %q must be %q", provider, apiv1.TLSClientProviderX509SVID) - } - return nil + if t.TLSCert != "" && !hasCert { + return fmt.Errorf("inline tls.crt must not be empty") } - if c.Certificate == nil { - return fmt.Errorf("certificate is required") + if t.TLSKey != "" && !hasKey { + return fmt.Errorf("inline tls.key must not be empty") } - if err := validateTLSData(*c.Certificate); err != nil { - return fmt.Errorf("certificate: %w", err) + if hasCert != hasKey { + return fmt.Errorf("inline tls.crt and tls.key must be set together") } - if c.Key == nil { - return fmt.Errorf("key is required") - } - if err := validateTLSKey(*c.Key); err != nil { - return fmt.Errorf("key: %w", err) + if !hasCert && !hasCACert { + return fmt.Errorf("inline must contain ca.crt or both tls.crt and tls.key") } return nil } -// validateSPIFFE checks that exactly one authorizer is set and parses. -func validateSPIFFE(s apiv1.SPIFFETLS) error { - serverID := strings.TrimSpace(s.ServerID) - trustDomain := strings.TrimSpace(s.TrustDomain) - - if countTrue(serverID != "", trustDomain != "", s.AuthorizeAny) != 1 { - return fmt.Errorf("exactly one of serverID, trustDomain, or authorizeAny must be set") - } - if serverID != "" { - if _, err := spiffeid.FromString(serverID); err != nil { - return fmt.Errorf("serverID %q is not a valid SPIFFE ID: %w", serverID, err) - } +func validateCredential(j apiv1.RegistryCredential) error { + credentialType := strings.TrimSpace(j.Type) + if credentialType == "" { + return fmt.Errorf("type is required") } - if trustDomain != "" && trustDomain != apiv1.TrustDomainSelf { - if _, err := spiffeid.TrustDomainFromString(trustDomain); err != nil { - return fmt.Errorf("trustDomain %q is not valid: %w", trustDomain, err) - } + if credentialType != apiv1.CredentialTypeJWT { + return fmt.Errorf("type %q must be %q", credentialType, apiv1.CredentialTypeJWT) } - return nil -} -func validateCredential(j apiv1.RegistryCredential) error { provider := strings.TrimSpace(j.Provider) value := strings.TrimSpace(j.Value) fromPath := strings.TrimSpace(j.FromPath) @@ -323,45 +370,60 @@ func validateCredential(j apiv1.RegistryCredential) error { return err } - hasIss := strings.TrimSpace(j.Iss) != "" - hasSub := strings.TrimSpace(j.Sub) != "" - hasAud := strings.TrimSpace(j.Aud) != "" - hasExp := j.Exp != nil + hasIssuer := strings.TrimSpace(j.Issuer) != "" + hasSubject := strings.TrimSpace(j.Subject) != "" + hasAudiences := len(j.Audiences) > 0 + hasExpiration := j.Expiration != nil switch { case jwkPath != "", jwkValue != "": - if !hasIss { - return fmt.Errorf("iss is required with jwkPath or jwkValue") + if !hasIssuer { + return fmt.Errorf("issuer is required with jwkPath or jwkValue") } - if !hasSub { - return fmt.Errorf("sub is required with jwkPath or jwkValue") + if !hasSubject { + return fmt.Errorf("subject is required with jwkPath or jwkValue") } - if hasExp && j.Exp.Duration <= 0 { - return fmt.Errorf("exp must be a positive duration") + if hasExpiration && j.Expiration.Duration <= 0 { + return fmt.Errorf("expiration must be a positive duration") } case provider != "": switch provider { - case apiv1.JWTProviderGitHub, apiv1.JWTProviderForgejo, apiv1.JWTProviderGCP, apiv1.JWTProviderAzure, apiv1.JWTProviderAWS, apiv1.JWTProviderJWTSVID: + case apiv1.JWTProviderGitHub, apiv1.JWTProviderForgejo, apiv1.JWTProviderGCP, apiv1.JWTProviderAzure, apiv1.JWTProviderAWS, apiv1.JWTProviderSPIFFE: default: return fmt.Errorf("provider %q must be one of: %s, %s, %s, %s, %s, %s", - provider, apiv1.JWTProviderGitHub, apiv1.JWTProviderForgejo, apiv1.JWTProviderGCP, apiv1.JWTProviderAzure, apiv1.JWTProviderAWS, apiv1.JWTProviderJWTSVID) + provider, apiv1.JWTProviderGitHub, apiv1.JWTProviderForgejo, apiv1.JWTProviderGCP, apiv1.JWTProviderAzure, apiv1.JWTProviderAWS, apiv1.JWTProviderSPIFFE) } - if hasIss || hasSub { - return fmt.Errorf("iss and sub can only be set with jwkPath or jwkValue") + if len(j.Audiences) > 1 && (provider == apiv1.JWTProviderGCP || provider == apiv1.JWTProviderAzure) { + return fmt.Errorf("provider %q accepts at most one audience", provider) } - if hasExp { - return fmt.Errorf("exp can only be set with jwkPath or jwkValue") + if hasIssuer || hasSubject { + return fmt.Errorf("issuer and subject can only be set with jwkPath or jwkValue") + } + if hasExpiration { + return fmt.Errorf("expiration can only be set with jwkPath or jwkValue") } case value != "", fromPath != "": - if hasIss || hasSub { - return fmt.Errorf("iss and sub can only be set with jwkPath or jwkValue") + if hasIssuer || hasSubject { + return fmt.Errorf("issuer and subject can only be set with jwkPath or jwkValue") + } + if hasAudiences { + return fmt.Errorf("audiences can only be set with jwkPath, jwkValue, or provider") } - if hasAud { - return fmt.Errorf("aud can only be set with jwkPath, jwkValue, or provider") + if hasExpiration { + return fmt.Errorf("expiration can only be set with jwkPath or jwkValue") + } + } + + seen := make(map[string]bool, len(j.Audiences)) + for i, raw := range j.Audiences { + a := strings.TrimSpace(raw) + if a == "" { + return fmt.Errorf("audiences[%d] must not be empty", i) } - if hasExp { - return fmt.Errorf("exp can only be set with jwkPath or jwkValue") + if seen[a] { + return fmt.Errorf("audiences[%d] duplicates %q", i, a) } + seen[a] = true } return nil } @@ -404,7 +466,7 @@ func validateArtifactEntry(a apiv1.ArtifactEntry) error { } func validateVerification(v apiv1.ArtifactVerification) error { - switch strings.TrimSpace(v.Provider) { + switch provider := strings.TrimSpace(v.Provider); provider { case apiv1.VerifyProviderCosign: default: return fmt.Errorf("provider %q must be %q", v.Provider, apiv1.VerifyProviderCosign) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 832ee80..fc39cb0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -12,14 +12,14 @@ import ( . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - . "github.com/fluxcd/flux-mirror/api/v1beta1" + . "github.com/fluxcd/flux-mirror/api/v1beta2" ) func TestDecode_FullExample(t *testing.T) { g := NewWithT(t) src := ` -apiVersion: mirror.plugin.fluxcd.io/v1beta1 +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config charts: - source: https://charts.dexidp.io @@ -49,7 +49,7 @@ artifacts: sortBy: numerical limit: 5 ` - cfg, err := Decode(strings.NewReader(src)) + cfg, _, err := Decode(strings.NewReader(src)) g.Expect(err).ToNot(HaveOccurred()) g.Expect(Validate(cfg)).To(Succeed()) @@ -77,14 +77,14 @@ func TestDecode_EnvSubstitution(t *testing.T) { t.Setenv("CREDENTIAL_KEY", "value") t.Setenv("REGISTRY_TOKEN", "env-token") - src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config hosts: - host: registry.example.com credential: ${CREDENTIAL_KEY}: ${REGISTRY_TOKEN} ` - cfg, err := Decode(strings.NewReader(src)) + cfg, _, err := Decode(strings.NewReader(src)) g.Expect(err).ToNot(HaveOccurred()) g.Expect(cfg.Hosts).To(HaveLen(1)) g.Expect(cfg.Hosts[0].Credential.Value).To(Equal("env-token")) @@ -93,7 +93,7 @@ hosts: func TestDecode_EnvSubstitutionIgnoresSingleDollar(t *testing.T) { g := NewWithT(t) - src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config artifacts: - source: ghcr.io/a/b @@ -103,7 +103,7 @@ artifacts: pattern: .* extract: "$patch" ` - cfg, err := Decode(strings.NewReader(src)) + cfg, _, err := Decode(strings.NewReader(src)) g.Expect(err).ToNot(HaveOccurred()) g.Expect(cfg.Artifacts[0].Selector.Regex.Extract).To(Equal("$patch")) } @@ -112,14 +112,14 @@ func TestDecodeWithEnvSubstDisabled(t *testing.T) { g := NewWithT(t) t.Setenv("REGISTRY_TOKEN", "env-token") - src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config hosts: - host: registry.example.com credential: value: ${REGISTRY_TOKEN} ` - cfg, err := DecodeWithEnvSubst(strings.NewReader(src), false) + cfg, _, err := DecodeWithEnvSubst(strings.NewReader(src), false) g.Expect(err).ToNot(HaveOccurred()) g.Expect(cfg.Hosts[0].Credential.Value).To(Equal("${REGISTRY_TOKEN}")) } @@ -135,14 +135,14 @@ func TestDecode_EnvSubstitutionStrict(t *testing.T) { } }) - src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config hosts: - host: registry.example.com credential: value: ${FLUX_MIRROR_TEST_MISSING_TOKEN_STRICT} ` - _, err := Decode(strings.NewReader(src)) + _, _, err := Decode(strings.NewReader(src)) g.Expect(err).To(MatchError(ContainSubstring("substitute environment variables"))) } @@ -150,18 +150,314 @@ func TestDecode_EnvSubstitutionStrictAllowsEmpty(t *testing.T) { g := NewWithT(t) t.Setenv("EMPTY_TOKEN", "") - src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config hosts: - host: registry.example.com credential: value: ${EMPTY_TOKEN} ` - cfg, err := Decode(strings.NewReader(src)) + cfg, _, err := Decode(strings.NewReader(src)) g.Expect(err).ToNot(HaveOccurred()) g.Expect(cfg.Hosts[0].Credential.Value).To(BeEmpty()) } +func TestDecode_RejectsUnknownFields(t *testing.T) { + // Removed or renamed fields must fail to decode rather than be silently + // dropped, since dropping them changes auth behavior: a host-level username + // would fall back to bearer auth, and a dropped aud/iss/sub/exp would send + // the credential to the wrong identity. + cases := []struct { + name string + field string + hostLine string + credLine string + }{ + {name: "removed host-level username", field: "username", hostLine: " username: legacy-user"}, + {name: "renamed credential aud", field: "aud", credLine: " aud: custom-aud"}, + {name: "renamed credential iss", field: "iss", credLine: " iss: https://issuer.example"}, + {name: "renamed credential sub", field: "sub", credLine: " sub: client-id"}, + {name: "renamed credential exp", field: "exp", credLine: " exp: 1h"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + src := "apiVersion: mirror.plugin.fluxcd.io/v1beta2\n" + + "kind: Config\n" + + "hosts:\n" + + " - host: registry.example.com\n" + if tc.hostLine != "" { + src += tc.hostLine + "\n" + } + src += " credential:\n" + + " type: jwt\n" + + " value: token\n" + if tc.credLine != "" { + src += tc.credLine + "\n" + } + + _, _, err := Decode(strings.NewReader(src)) + g.Expect(err).To(MatchError(ContainSubstring("unknown field"))) + g.Expect(err).To(MatchError(ContainSubstring(tc.field))) + }) + } +} + +func TestDecode_RemovedTLSFields(t *testing.T) { + // The v1beta2 TLS API only has path and spiffe: every v1beta1 half, including + // authorizeAny, is an unknown field and must be rejected rather than + // silently dropped. + for _, field := range []string{"serverAuth", "clientAuth"} { + t.Run("v1beta2 rejects "+field, func(t *testing.T) { + g := NewWithT(t) + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta2 +kind: Config +hosts: + - host: registry.example.com + tls: + ` + field + `: {} +` + _, _, err := Decode(strings.NewReader(src)) + g.Expect(err).To(MatchError(ContainSubstring("unknown field"))) + g.Expect(err).To(MatchError(ContainSubstring(field))) + }) + } + + // v1beta1 authorizeAny has no v1beta2 equivalent; conversion fails instead + // of silently dropping the authorizer. + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 +kind: Config +hosts: + - host: registry.example.com + tls: + serverAuth: + spiffe: + authorizeAny: true + clientAuth: + provider: x509-svid +artifacts: + - source: ghcr.io/a/b + destination: ghcr.io/c/d +` + _, _, err := Decode(strings.NewReader(src)) + g := NewWithT(t) + g.Expect(err).To(MatchError(ContainSubstring("authorizeAny cannot be converted"))) +} + +func TestDecode_ConvertsDeprecatedV1Beta1(t *testing.T) { + g := NewWithT(t) + + // A v1beta1 document still loads: it is decoded with the v1beta1 wire types + // and converted to v1beta2 in memory. The returned config is v1beta2; the + // declared version is returned separately so callers can surface the + // migration warning. The old field names (host-level username, aud/iss/sub) + // must not be treated as unknown fields. + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 +kind: Config +hosts: + - host: registry.example.com + username: legacy-user + credential: + jwkPath: /run/jwk.json + iss: https://issuer.example + sub: client-id + aud: custom-aud +artifacts: + - source: ghcr.io/a/b + destination: ghcr.io/c/d + selector: + semver: ">=1.0.0" +` + cfg, declaredAPIVersion, err := Decode(strings.NewReader(src)) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(declaredAPIVersion).To(Equal(DeprecatedV1Beta1APIVersion)) + g.Expect(cfg.APIVersion).To(Equal(GroupVersion.String())) + g.Expect(Validate(cfg)).To(Succeed()) + + g.Expect(cfg.Hosts).To(HaveLen(1)) + h := cfg.Hosts[0] + g.Expect(h.Credential.Type).To(Equal(CredentialTypeJWT)) + g.Expect(h.Credential.JWKPath).To(Equal("/run/jwk.json")) + g.Expect(h.Credential.Username).To(Equal("legacy-user")) + g.Expect(h.Credential.Issuer).To(Equal("https://issuer.example")) + g.Expect(h.Credential.Subject).To(Equal("client-id")) + g.Expect(h.Credential.Audiences).To(Equal([]string{"custom-aud"})) + + // The migration warning is actionable and names the new apiVersion. + warn := DeprecationWarning(declaredAPIVersion) + g.Expect(warn).To(ContainSubstring("mirror.plugin.fluxcd.io/v1beta1")) + g.Expect(warn).To(ContainSubstring("mirror.plugin.fluxcd.io/v1beta2")) + g.Expect(warn).To(ContainSubstring("credential.aud -> credential.audiences")) + g.Expect(DeprecationWarning(GroupVersion.String())).To(BeEmpty()) +} + +func TestDecode_ConvertsV1Beta1TLS(t *testing.T) { + t.Run("inline values", func(t *testing.T) { + g := NewWithT(t) + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 +kind: Config +hosts: + - host: registry.example.com + tls: + serverAuth: + value: CA + clientAuth: + certificate: + value: CERT + key: + value: KEY +artifacts: + - source: ghcr.io/a/b + destination: ghcr.io/c/d +` + cfg, _, err := Decode(strings.NewReader(src)) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(Validate(cfg)).To(Succeed()) + tls := cfg.Hosts[0].TLS + g.Expect(tls.Path).To(BeEmpty()) + g.Expect(tls.SPIFFE).To(BeNil()) + g.Expect(tls.CACert).To(Equal("CA")) + g.Expect(tls.TLSCert).To(Equal("CERT")) + g.Expect(tls.TLSKey).To(Equal("KEY")) + }) + + t.Run("spiffe mutual tls", func(t *testing.T) { + g := NewWithT(t) + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 +kind: Config +hosts: + - host: registry.example.com + tls: + serverAuth: + spiffe: + trustDomain: self + clientAuth: + provider: x509-svid +artifacts: + - source: ghcr.io/a/b + destination: ghcr.io/c/d +` + cfg, _, err := Decode(strings.NewReader(src)) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(Validate(cfg)).To(Succeed()) + tls := cfg.Hosts[0].TLS + g.Expect(tls.Path).To(BeEmpty()) + g.Expect(tls.CACert).To(BeEmpty()) + g.Expect(tls.SPIFFE.ServerIDs).To(Equal([]string{"spiffe://self"})) + }) +} + +func TestDecode_RejectsUnmappableV1Beta1TLS(t *testing.T) { + for _, tc := range []struct { + name string + tls string + errText string + }{ + { + name: "per-file server CA", + tls: ` serverAuth: + fromPath: /etc/ssl/ca.crt`, + errText: "serverAuth.fromPath cannot be converted", + }, + { + name: "one-sided SPIFFE", + tls: ` serverAuth: + spiffe: + trustDomain: self`, + errText: "requires clientAuth.provider x509-svid", + }, + { + name: "authorizeAny", + tls: ` serverAuth: + spiffe: + authorizeAny: true + clientAuth: + provider: x509-svid`, + errText: "authorizeAny cannot be converted", + }, + { + name: "mixed SPIFFE and value", + tls: ` serverAuth: + value: CA + spiffe: + trustDomain: self + clientAuth: + provider: x509-svid`, + errText: "cannot be combined", + }, + } { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 +kind: Config +hosts: + - host: registry.example.com + tls: +` + tc.tls + ` +artifacts: + - source: ghcr.io/a/b + destination: ghcr.io/c/d +` + _, _, err := Decode(strings.NewReader(src)) + g.Expect(err).To(MatchError(ContainSubstring(tc.errText))) + }) + } +} + +func TestDecode_V1Beta1Lenient(t *testing.T) { + // v1beta1 was decoded leniently before v1beta2. Unknown fields must not + // stop an old config from loading and being migrated. + g := NewWithT(t) + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 +kind: Config +typo: ignored +artifacts: + - source: ghcr.io/a/b + destination: ghcr.io/c/d +` + cfg, declared, err := Decode(strings.NewReader(src)) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(declared).To(Equal(DeprecatedV1Beta1APIVersion)) + g.Expect(Validate(cfg)).To(Succeed()) +} + +func TestValidate_RejectsDeprecatedV1Beta1(t *testing.T) { + // Decode always returns a v1beta2-shaped Config with the current + // apiVersion; a Config labeled v1beta1 is stale or hand-built and must not + // validate. + g := NewWithT(t) + + cfg := Config{ + TypeMeta: metav1.TypeMeta{APIVersion: DeprecatedV1Beta1APIVersion, Kind: ConfigKind}, + Artifacts: validArtifact(), + } + g.Expect(Validate(&cfg)).To(MatchError(ContainSubstring(`apiVersion must be "mirror.plugin.fluxcd.io/v1beta2"`))) +} + +// TestDecode_V1Beta1WrongKindRejected proves the declared kind survives the +// v1beta1 conversion, so Validate still rejects a document that names the +// wrong kind (rather than the converter silently stamping "Config"). +func TestDecode_V1Beta1WrongKindRejected(t *testing.T) { + g := NewWithT(t) + + src := `apiVersion: mirror.plugin.fluxcd.io/v1beta1 +kind: NotConfig +hosts: + - host: h.example + credential: + value: TOKEN +artifacts: + - source: ghcr.io/a/b + destination: ghcr.io/c/d + selector: + semver: ">=1.0.0" +` + cfg, _, err := Decode(strings.NewReader(src)) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(Validate(cfg)).To(MatchError(ContainSubstring(`kind must be "Config"`))) +} + func TestDefaults(t *testing.T) { g := NewWithT(t) @@ -175,6 +471,34 @@ func TestDefaults(t *testing.T) { s.Limit = new(0) g.Expect(s.EffectiveLimit()).To(Equal(0)) // 0 = unlimited (selector enforces this) + + h := RegistryHost{Host: "registry.example.com"} + g.Expect(h.EffectiveAudiences()).To(Equal([]string{"registry.example.com"})) + + h.Credential = &RegistryCredential{} + g.Expect(h.EffectiveAudiences()).To(Equal([]string{"registry.example.com"})) + + h.Credential.Audiences = []string{"a.example", "b.example"} + g.Expect(h.EffectiveAudiences()).To(Equal([]string{"a.example", "b.example"})) + + h.Credential.Audiences = []string{" a.example ", "b.example"} + g.Expect(h.EffectiveAudiences()).To(Equal([]string{"a.example", "b.example"})) +} + +func TestIsCloudProvider(t *testing.T) { + g := NewWithT(t) + + for provider, want := range map[string]bool{ + "": false, + "generic": false, + "ecr": true, + "acr": true, + "gar": true, + " ecr ": false, + "dockerhub": false, + } { + g.Expect(RegistryHost{Provider: provider}.IsCloudProvider()).To(Equal(want), "provider %q", provider) + } } func TestResolvePaths(t *testing.T) { @@ -183,17 +507,11 @@ func TestResolvePaths(t *testing.T) { mkCfg := func() *Config { return &Config{Hosts: []RegistryHost{{ Host: "h.example", - Credential: &RegistryCredential{ + Credential: &RegistryCredential{Type: CredentialTypeJWT, FromPath: "tokens/jwt", JWKPath: "/abs/keys/jwk.json", // absolute: clamped within baseDir }, - TLS: &TLS{ - ServerAuth: &TLSServerAuth{FromPath: "../../etc/shadow"}, // traversal: clamped - ClientAuth: &TLSClientAuth{ - Certificate: &TLSData{FromPath: "certs/client.crt"}, - Key: &TLSKey{Value: "CLIENT_KEY"}, // not a path: untouched - }, - }, + TLS: &TLS{Path: "../../etc/shadow"}, // traversal: clamped }}} } @@ -204,15 +522,13 @@ func TestResolvePaths(t *testing.T) { h := cfg.Hosts[0] g.Expect(h.Credential.FromPath).To(Equal("/etc/flux-mirror/tokens/jwt")) g.Expect(h.Credential.JWKPath).To(Equal("/etc/flux-mirror/abs/keys/jwk.json")) - g.Expect(h.TLS.ServerAuth.FromPath).To(Equal("/etc/flux-mirror/etc/shadow")) // escape clamped - g.Expect(h.TLS.ClientAuth.Certificate.FromPath).To(Equal("/etc/flux-mirror/certs/client.crt")) - g.Expect(h.TLS.ClientAuth.Key.Value).To(Equal("CLIENT_KEY")) + g.Expect(h.TLS.Path).To(Equal("/etc/flux-mirror/etc/shadow")) // escape clamped // Empty baseDir is a no-op: paths stay as written. cfg = mkCfg() g.Expect(ResolvePaths(cfg, "")).To(Succeed()) g.Expect(cfg.Hosts[0].Credential.FromPath).To(Equal("tokens/jwt")) - g.Expect(cfg.Hosts[0].TLS.ServerAuth.FromPath).To(Equal("../../etc/shadow")) + g.Expect(cfg.Hosts[0].TLS.Path).To(Equal("../../etc/shadow")) } func TestValidate_Table(t *testing.T) { @@ -409,50 +725,50 @@ func TestValidate_Table(t *testing.T) { name: "auth valid provider", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{ - Host: "mint.example", Credential: &RegistryCredential{Provider: JWTProviderGitHub, Aud: "custom"}, + Host: "mint.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, Provider: JWTProviderGitHub, Audiences: []string{"custom"}}, }}}, }, { name: "auth valid provider gcp", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{ - Host: "us-docker.pkg.dev", Credential: &RegistryCredential{Provider: JWTProviderGCP, Aud: "custom"}, + Host: "us-docker.pkg.dev", Credential: &RegistryCredential{Type: CredentialTypeJWT, Provider: JWTProviderGCP, Audiences: []string{"custom"}}, }}}, }, { name: "auth valid provider azure", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{ - Host: "myregistry.azurecr.io", Credential: &RegistryCredential{Provider: JWTProviderAzure, Aud: "custom"}, + Host: "myregistry.azurecr.io", Credential: &RegistryCredential{Type: CredentialTypeJWT, Provider: JWTProviderAzure, Audiences: []string{"custom"}}, }}}, }, { name: "auth valid provider aws", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{ - Host: "registry.example.com", Credential: &RegistryCredential{Provider: JWTProviderAWS, Aud: "custom"}, + Host: "registry.example.com", Credential: &RegistryCredential{Type: CredentialTypeJWT, Provider: JWTProviderAWS, Audiences: []string{"custom"}}, }}}, }, { name: "auth valid value", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{ - Host: "static.example", Credential: &RegistryCredential{Value: "TOKEN"}, + Host: "static.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, Value: "TOKEN"}, }}}, }, { name: "auth valid fromPath", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{ - Host: "static.example", Credential: &RegistryCredential{FromPath: "/run/secrets/token"}, + Host: "static.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, FromPath: "/run/secrets/token"}, }}}, }, { name: "auth valid jwkPath", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{ - Host: "registry.example", Credential: &RegistryCredential{ - JWKPath: "/path/jwk.json", Iss: "https://issuer", Sub: "client", Aud: "registry.example", + Host: "registry.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + JWKPath: "/path/jwk.json", Issuer: "https://issuer", Subject: "client", Audiences: []string{"registry.example"}, }, }}}, }, @@ -460,9 +776,9 @@ func TestValidate_Table(t *testing.T) { name: "auth valid jwkPath with exp", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{ - Host: "registry.example", Credential: &RegistryCredential{ - JWKPath: "/path/jwk.json", Iss: "https://issuer", Sub: "client", - Exp: &metav1.Duration{Duration: time.Hour}, + Host: "registry.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + JWKPath: "/path/jwk.json", Issuer: "https://issuer", Subject: "client", + Expiration: &metav1.Duration{Duration: time.Hour}, }, }}}, }, @@ -470,9 +786,9 @@ func TestValidate_Table(t *testing.T) { name: "auth valid jwkValue", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{ - Host: "registry.example", Credential: &RegistryCredential{ - JWKValue: "REGISTRY_JWK", Iss: "https://issuer", Sub: "client", Aud: "registry.example", - Exp: &metav1.Duration{Duration: time.Hour}, + Host: "registry.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + JWKValue: "REGISTRY_JWK", Issuer: "https://issuer", Subject: "client", Audiences: []string{"registry.example"}, + Expiration: &metav1.Duration{Duration: time.Hour}, }, }}}, }, @@ -480,18 +796,18 @@ func TestValidate_Table(t *testing.T) { name: "auth jwkValue missing iss", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{ - Host: "registry.example", Credential: &RegistryCredential{ - JWKValue: "REGISTRY_JWK", Sub: "client", + Host: "registry.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + JWKValue: "REGISTRY_JWK", Subject: "client", }, }}}, - errMsg: "iss is required with jwkPath or jwkValue", + errMsg: "issuer is required with jwkPath or jwkValue", }, { name: "auth jwkPath and jwkValue both set", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{ - Host: "registry.example", Credential: &RegistryCredential{ - JWKPath: "/path/jwk.json", JWKValue: "REGISTRY_JWK", Iss: "https://issuer", Sub: "client", + Host: "registry.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + JWKPath: "/path/jwk.json", JWKValue: "REGISTRY_JWK", Issuer: "https://issuer", Subject: "client", }, }}}, errMsg: "exactly one of provider, value, fromPath, jwkPath, or jwkValue", @@ -500,50 +816,56 @@ func TestValidate_Table(t *testing.T) { name: "auth jwkPath exp non-positive", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{ - Host: "registry.example", Credential: &RegistryCredential{ - JWKPath: "/path/jwk.json", Iss: "https://issuer", Sub: "client", - Exp: &metav1.Duration{Duration: 0}, + Host: "registry.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + JWKPath: "/path/jwk.json", Issuer: "https://issuer", Subject: "client", + Expiration: &metav1.Duration{Duration: 0}, }, }}}, - errMsg: "exp must be a positive duration", + errMsg: "expiration must be a positive duration", }, { name: "auth exp rejected with provider", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{ - Host: "registry.example", Credential: &RegistryCredential{ - Provider: JWTProviderGitHub, Exp: &metav1.Duration{Duration: time.Hour}, + Host: "registry.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + Provider: JWTProviderGitHub, Expiration: &metav1.Duration{Duration: time.Hour}, }, }}}, - errMsg: "exp can only be set with jwkPath", + errMsg: "expiration can only be set with jwkPath", }, { name: "auth exp rejected with value", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{ - Host: "registry.example", Credential: &RegistryCredential{ - Value: "TOKEN", Exp: &metav1.Duration{Duration: time.Hour}, + Host: "registry.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + Value: "TOKEN", Expiration: &metav1.Duration{Duration: time.Hour}, }, }}}, - errMsg: "exp can only be set with jwkPath", + errMsg: "expiration can only be set with jwkPath", }, { name: "auth missing host", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Credential: &RegistryCredential{Value: "TOKEN"}}}}, + Hosts: []RegistryHost{{Credential: &RegistryCredential{Type: CredentialTypeJWT, Value: "TOKEN"}}}}, + errMsg: "host is required", + }, + { + name: "auth whitespace host", + cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), + Hosts: []RegistryHost{{Host: " ", Credential: &RegistryCredential{Type: CredentialTypeJWT, Value: "TOKEN"}}}}, errMsg: "host is required", }, { name: "auth valid envelope", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{ + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, Value: "TOKEN", Envelope: "token-{{ hex .Token }}", }}}}, }, { name: "auth invalid envelope function", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{ + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, Value: "TOKEN", Envelope: "{{ nope .Token }}", }}}}, errMsg: "parse envelope", @@ -551,7 +873,7 @@ func TestValidate_Table(t *testing.T) { { name: "auth invalid envelope field", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{ + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, Value: "TOKEN", Envelope: "{{ .Nope }}", }}}}, errMsg: "render envelope", @@ -560,7 +882,7 @@ func TestValidate_Table(t *testing.T) { name: "auth missing credential, provider, tls and maxChunkSize", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{Host: "h.example"}}}, - errMsg: "one of credential, provider, tls, or maxChunkSize is required", + errMsg: "one of credential, a cloud provider, tls, or maxChunkSize is required", }, { name: "auth maxChunkSize only is valid", @@ -580,62 +902,87 @@ func TestValidate_Table(t *testing.T) { Host: "123.dkr.ecr.us-east-1.amazonaws.com", Provider: RegistryProviderECR, }}}, }, + { + name: "auth generic provider with credential is valid", + cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), + Hosts: []RegistryHost{{ + Host: "h.example", Provider: RegistryProviderGeneric, + Credential: &RegistryCredential{Type: CredentialTypeJWT, Value: "TOKEN"}, + }}}, + }, + { + name: "auth generic provider with tls is valid", + cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), + Hosts: []RegistryHost{{ + Host: "h.example", Provider: RegistryProviderGeneric, + TLS: &TLS{Path: "/certs"}, + }}}, + }, + { + name: "auth generic provider alone is not enough", + cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), + Hosts: []RegistryHost{{Host: "h.example", Provider: RegistryProviderGeneric}}}, + errMsg: "one of credential, a cloud provider, tls, or maxChunkSize is required", + }, { name: "auth invalid provider", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{Host: "h.example", Provider: "dockerhub"}}}, - errMsg: "provider \"dockerhub\" must be one of: ecr, acr, gar", + errMsg: "provider \"dockerhub\" must be one of: generic, ecr, acr, gar", }, { name: "auth credential and provider mutually exclusive", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{ Host: "h.example", Provider: RegistryProviderGAR, - Credential: &RegistryCredential{Value: "TOKEN"}, + Credential: &RegistryCredential{Type: CredentialTypeJWT, Value: "TOKEN"}, }}}, - errMsg: "credential and provider are mutually exclusive", + errMsg: "credential and cloud provider are mutually exclusive", }, { name: "auth username with credential is valid", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{ - Host: "h.example", Username: "bob", - Credential: &RegistryCredential{Value: "TOKEN"}, + Host: "h.example", + Credential: &RegistryCredential{Type: CredentialTypeJWT, Value: "TOKEN", + Username: "bob"}, }}}, }, { - name: "auth username without credential", + name: "auth type is required", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", Username: "bob", MaxChunkSize: 1024}}}, - errMsg: "username requires credential", + Hosts: []RegistryHost{{ + Host: "h.example", Credential: &RegistryCredential{Value: "TOKEN"}, + }}}, + errMsg: "type is required", }, { - name: "auth username with tls but no credential", + name: "auth unknown type", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", Username: "bob", - TLS: &TLS{ServerAuth: &TLSServerAuth{FromPath: "/ca.crt"}}, + Hosts: []RegistryHost{{ + Host: "h.example", Credential: &RegistryCredential{Type: "x509", Value: "TOKEN"}, }}}, - errMsg: "username requires credential", + errMsg: `type "x509" must be "jwt"`, }, { name: "auth duplicate host", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{ - {Host: "dup.example", Credential: &RegistryCredential{Value: "A"}}, - {Host: "dup.example", Credential: &RegistryCredential{Value: "B"}}, + {Host: "dup.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, Value: "A"}}, + {Host: "dup.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, Value: "B"}}, }}, errMsg: "configured more than once", }, { name: "auth no source set", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{}}}}, + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT}}}}, errMsg: "exactly one of provider, value, fromPath, jwkPath, or jwkValue", }, { name: "auth two sources set", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{ + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, Provider: JWTProviderGitHub, Value: "TOKEN", }}}}, errMsg: "exactly one of provider, value, fromPath, jwkPath, or jwkValue", @@ -643,189 +990,287 @@ func TestValidate_Table(t *testing.T) { { name: "auth invalid provider", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{ + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, Provider: "gitlab", }}}}, - errMsg: "must be one of: github, forgejo, gcp, azure, aws", + errMsg: "must be one of: github, forgejo, gcp, azure, aws, spiffe", }, { name: "auth jwkPath missing iss", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{ - JWKPath: "/k.json", Sub: "client", + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + JWKPath: "/k.json", Subject: "client", }}}}, - errMsg: "iss is required with jwkPath", + errMsg: "issuer is required with jwkPath", }, { name: "auth jwkPath missing sub", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{ - JWKPath: "/k.json", Iss: "https://issuer", + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + JWKPath: "/k.json", Issuer: "https://issuer", }}}}, - errMsg: "sub is required with jwkPath", + errMsg: "subject is required with jwkPath", }, { name: "auth iss set without jwkPath", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{ - Provider: JWTProviderGitHub, Iss: "https://issuer", + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + Provider: JWTProviderGitHub, Issuer: "https://issuer", }}}}, - errMsg: "iss and sub can only be set with jwkPath", + errMsg: "issuer and subject can only be set with jwkPath", }, { name: "auth aud set with value", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{ - Value: "TOKEN", Aud: "nope", + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + Value: "TOKEN", Audiences: []string{"nope"}, }}}}, - errMsg: "aud can only be set with jwkPath, jwkValue, or provider", + errMsg: "audiences can only be set with jwkPath, jwkValue, or provider", }, { name: "auth aud set with fromPath", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{ - FromPath: "/path/token", Aud: "nope", + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + FromPath: "/path/token", Audiences: []string{"nope"}, }}}}, - errMsg: "aud can only be set with jwkPath, jwkValue, or provider", + errMsg: "audiences can only be set with jwkPath, jwkValue, or provider", }, { name: "auth iss set with fromPath", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{ - FromPath: "/path/token", Iss: "https://issuer", + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + FromPath: "/path/token", Issuer: "https://issuer", }}}}, - errMsg: "iss and sub can only be set with jwkPath", + errMsg: "issuer and subject can only be set with jwkPath", }, { name: "auth fromPath and value set", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{ + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, Value: "TOKEN", FromPath: "/path/token", }}}}, errMsg: "exactly one of provider, value, fromPath, jwkPath, or jwkValue", }, { - name: "credential provider jwt-svid is valid", + name: "credential provider spiffe is valid", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{ - Provider: JWTProviderJWTSVID, Aud: "h.example", + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + Provider: JWTProviderSPIFFE, Audiences: []string{"h.example"}, }}}}, }, { - name: "tls serverAuth only host is valid", + name: "credential provider spiffe accepts multiple audiences", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{ - ServerAuth: &TLSServerAuth{FromPath: "/ca.crt"}, + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + Provider: JWTProviderSPIFFE, Audiences: []string{"a.example", "b.example"}, }}}}, }, { - name: "tls and provider mutually exclusive", + name: "multiple audiences accepted for github provider", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", Provider: RegistryProviderECR, - TLS: &TLS{ServerAuth: &TLSServerAuth{FromPath: "/ca.crt"}}, - }}}, - errMsg: "provider and tls are mutually exclusive", + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + Provider: JWTProviderGitHub, Audiences: []string{"a.example", "b.example"}, + }}}}, }, { - name: "tls credential and tls together is valid", + name: "multiple audiences accepted for aws provider", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", - Credential: &RegistryCredential{Value: "TOKEN"}, - TLS: &TLS{ClientAuth: &TLSClientAuth{Certificate: &TLSData{FromPath: "/c.crt"}, Key: &TLSKey{FromPath: "/c.key"}}}, - }}}, + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + Provider: JWTProviderAWS, Audiences: []string{"a.example", "b.example"}, + }}}}, }, { - name: "tls client-only spiffe with custom CA server is valid", + name: "multiple audiences rejected for gcp provider", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{ - ServerAuth: &TLSServerAuth{FromPath: "/ca.crt"}, - ClientAuth: &TLSClientAuth{Provider: TLSClientProviderX509SVID}, + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + Provider: JWTProviderGCP, Audiences: []string{"a.example", "b.example"}, }}}}, + errMsg: "provider \"gcp\" accepts at most one audience", }, { - name: "tls full spiffe (client svid + server spiffe) is valid", + name: "multiple audiences rejected for azure provider", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{ - ServerAuth: &TLSServerAuth{SPIFFE: &SPIFFETLS{TrustDomain: TrustDomainSelf}}, - ClientAuth: &TLSClientAuth{Provider: TLSClientProviderX509SVID}, + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + Provider: JWTProviderAzure, Audiences: []string{"a.example", "b.example"}, }}}}, + errMsg: "provider \"azure\" accepts at most one audience", }, { - name: "tls empty is rejected", + name: "multiple audiences accepted for jwk credential", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{}}}}, - errMsg: "one of serverAuth or clientAuth is required", + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + JWKPath: "/k.json", Issuer: "https://issuer", Subject: "client", + Audiences: []string{"a.example", "b.example"}, + }}}}, }, { - name: "tls serverAuth two sources", + name: "empty audience rejected", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), - Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{ - ServerAuth: &TLSServerAuth{FromPath: "/ca.crt", Value: "CA"}, + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + Provider: JWTProviderSPIFFE, Audiences: []string{""}, + }}}}, + errMsg: "audiences[0] must not be empty", + }, + { + name: "whitespace audience rejected", + cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + Provider: JWTProviderSPIFFE, Audiences: []string{" "}, + }}}}, + errMsg: "audiences[0] must not be empty", + }, + { + name: "duplicate audience rejected", + cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + Provider: JWTProviderSPIFFE, Audiences: []string{"a.example", " a.example "}, + }}}}, + errMsg: `audiences[1] duplicates "a.example"`, + }, + { + name: "padded audience accepted", + cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), + Hosts: []RegistryHost{{Host: "h.example", Credential: &RegistryCredential{Type: CredentialTypeJWT, + Provider: JWTProviderSPIFFE, Audiences: []string{" a.example "}, }}}}, - errMsg: "serverAuth: exactly one of fromPath, value, or spiffe", }, { - name: "tls serverAuth ca and spiffe together", + name: "tls path host is valid", + cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), + Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{Path: "/certs"}}}}, + }, + { + name: "tls inline CA is valid", + cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), + Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{CACert: "CA"}}}}, + }, + { + name: "tls inline client pair is valid", + cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), + Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{TLSCert: "CERT", TLSKey: "KEY"}}}}, + }, + { + name: "tls inline CA and client pair is valid", + cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), + Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{CACert: "CA", TLSCert: "CERT", TLSKey: "KEY"}}}}, + }, + { + name: "tls spiffe host is valid", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{ - ServerAuth: &TLSServerAuth{FromPath: "/ca.crt", SPIFFE: &SPIFFETLS{AuthorizeAny: true}}, + SPIFFE: &SPIFFETLS{ServerIDs: []string{"spiffe://example.org/registry"}}, }}}}, - errMsg: "serverAuth: exactly one of fromPath, value, or spiffe", }, { - name: "tls clientAuth missing key", + name: "tls spiffe accepts all RFC forms", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{ - ClientAuth: &TLSClientAuth{Certificate: &TLSData{FromPath: "/c.crt"}}, + SPIFFE: &SPIFFETLS{ServerIDs: []string{ + "spiffe://example.org", + "spiffe://example.org/registry", + "spiffe://example.org/registry/", + "spiffe://self", + "spiffe://self/registry", + "spiffe://self/registry/", + }}, }}}}, - errMsg: "clientAuth: key is required", }, { - name: "tls clientAuth provider and static mutually exclusive", + name: "tls and provider mutually exclusive", + cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), + Hosts: []RegistryHost{{Host: "h.example", Provider: RegistryProviderECR, + TLS: &TLS{Path: "/certs"}, + }}}, + errMsg: "cloud provider and tls are mutually exclusive", + }, + { + name: "tls credential and tls together is valid", + cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), + Hosts: []RegistryHost{{Host: "h.example", + Credential: &RegistryCredential{Type: CredentialTypeJWT, Value: "TOKEN"}, + TLS: &TLS{Path: "/certs"}, + }}}, + }, + { + name: "tls empty is rejected", + cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), + Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{}}}}, + errMsg: "exactly one of path, inline certificates, or spiffe must be set", + }, + { + name: "tls path and spiffe together", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{ - ClientAuth: &TLSClientAuth{Provider: TLSClientProviderX509SVID, Key: &TLSKey{FromPath: "/c.key"}}, + Path: "/certs", + SPIFFE: &SPIFFETLS{ServerIDs: []string{"spiffe://example.org"}}, }}}}, - errMsg: "provider is mutually exclusive with certificate and key", + errMsg: "exactly one of path, inline certificates, or spiffe must be set", }, { - name: "tls clientAuth invalid provider", + name: "tls path and inline together", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{ - ClientAuth: &TLSClientAuth{Provider: "jwt-svid"}, + Path: "/certs", + CACert: "CA", }}}}, - errMsg: `provider "jwt-svid" must be "x509-svid"`, + errMsg: "exactly one of path, inline certificates, or spiffe must be set", }, { - name: "tls serverAuth spiffe serverID valid", + name: "tls inline and spiffe together", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{ - ServerAuth: &TLSServerAuth{SPIFFE: &SPIFFETLS{ServerID: "spiffe://example.org/registry"}}, + CACert: "CA", + SPIFFE: &SPIFFETLS{ServerIDs: []string{"spiffe://example.org"}}, }}}}, + errMsg: "exactly one of path, inline certificates, or spiffe must be set", + }, + { + name: "tls inline cert without key", + cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), + Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{TLSCert: "CERT"}}}}, + errMsg: "inline tls.crt and tls.key must be set together", + }, + { + name: "tls inline key without cert", + cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), + Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{TLSKey: "KEY"}}}}, + errMsg: "inline tls.crt and tls.key must be set together", + }, + { + name: "tls inline empty ca.crt", + cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), + Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{CACert: " "}}}}, + errMsg: "inline ca.crt must not be empty", + }, + { + name: "tls spiffe without serverIDs", + cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), + Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{SPIFFE: &SPIFFETLS{}}}}}, + errMsg: "spiffe.serverIDs is required", }, { - name: "tls serverAuth spiffe no authorizer", + name: "tls spiffe empty serverID", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{ - ServerAuth: &TLSServerAuth{SPIFFE: &SPIFFETLS{}}, + SPIFFE: &SPIFFETLS{ServerIDs: []string{" "}}, }}}}, - errMsg: "exactly one of serverID, trustDomain, or authorizeAny must be set", + errMsg: "spiffe.serverIDs[0] must not be empty", }, { - name: "tls serverAuth spiffe two authorizers", + name: "tls spiffe invalid serverID", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{ - ServerAuth: &TLSServerAuth{SPIFFE: &SPIFFETLS{TrustDomain: TrustDomainSelf, AuthorizeAny: true}}, + SPIFFE: &SPIFFETLS{ServerIDs: []string{"not-a-spiffe-id"}}, }}}}, - errMsg: "exactly one of serverID, trustDomain, or authorizeAny must be set", + errMsg: "invalid SPIFFE ID", }, { - name: "tls serverAuth spiffe invalid serverID", + name: "tls spiffe prefix without path", cfg: Config{TypeMeta: metav1.TypeMeta{APIVersion: GroupVersion.String(), Kind: ConfigKind}, Artifacts: validArtifact(), Hosts: []RegistryHost{{Host: "h.example", TLS: &TLS{ - ServerAuth: &TLSServerAuth{SPIFFE: &SPIFFETLS{ServerID: "not-a-spiffe-id"}}, + SPIFFE: &SPIFFETLS{ServerIDs: []string{"spiffe://example.org/"}}, }}}}, - errMsg: "is not a valid SPIFFE ID", + errMsg: "prefix must include a path", }, { name: "verify negative minAge", @@ -866,15 +1311,15 @@ func validArtifact() []ArtifactEntry { func TestDecode_BadYAML(t *testing.T) { g := NewWithT(t) - _, err := Decode(strings.NewReader("apiVersion: [oops")) + _, _, err := Decode(strings.NewReader("apiVersion: [oops")) g.Expect(err).To(HaveOccurred()) g.Expect(err.Error()).To(ContainSubstring("parse config")) } func TestDecode_BadDuration(t *testing.T) { g := NewWithT(t) - _, err := Decode(strings.NewReader(` -apiVersion: mirror.plugin.fluxcd.io/v1beta1 + _, _, err := Decode(strings.NewReader(` +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config artifacts: - source: ghcr.io/a/b diff --git a/internal/config/schema_test.go b/internal/config/schema_test.go index 10185f4..c3aa62b 100644 --- a/internal/config/schema_test.go +++ b/internal/config/schema_test.go @@ -22,7 +22,7 @@ func validateConfigSchema(t *testing.T, raw string) { var doc any g.Expect(k8syaml.Unmarshal([]byte(raw), &doc)).To(Succeed()) - abs, err := filepath.Abs(filepath.Join("..", "..", "docs", "config-v1beta1.json")) + abs, err := filepath.Abs(filepath.Join("..", "..", "docs", "config-v1beta2.json")) g.Expect(err).ToNot(HaveOccurred()) compiler := jsonschema.NewCompiler() @@ -33,18 +33,24 @@ func validateConfigSchema(t *testing.T, raw string) { } func TestConfig_Schema(t *testing.T) { - validateConfigSchema(t, `apiVersion: mirror.plugin.fluxcd.io/v1beta1 + validateConfigSchema(t, `apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config hosts: - host: ghcr.io credential: provider: github + type: jwt - host: registry.example.com tls: - serverAuth: - fromPath: /etc/ssl/ca.crt - clientAuth: - provider: x509-svid + path: /etc/ssl + - host: inline.example.com + tls: + ca.crt: dummy + - host: spiffe.example.com + tls: + spiffe: + serverIDs: + - spiffe://example.org/registry - host: 123456789.dkr.ecr.us-east-1.amazonaws.com provider: ecr charts: diff --git a/internal/envelope/envelope.go b/internal/envelope/envelope.go index 34e2ab7..bad17d7 100644 --- a/internal/envelope/envelope.go +++ b/internal/envelope/envelope.go @@ -30,7 +30,7 @@ var funcs = template.FuncMap{ // Parse compiles an envelope template. An empty template is valid and returns a // nil template, which Render treats as "send the credential unchanged". func Parse(text string) (*template.Template, error) { - if strings.TrimSpace(text) == "" { + if text == "" { return nil, nil } t, err := template.New("envelope").Funcs(funcs).Parse(text) diff --git a/internal/envelope/envelope_test.go b/internal/envelope/envelope_test.go index b726f6b..f0f6333 100644 --- a/internal/envelope/envelope_test.go +++ b/internal/envelope/envelope_test.go @@ -22,7 +22,6 @@ func TestApply(t *testing.T) { {name: "literal only", tmpl: "static", token: "secret", want: "static"}, {name: "prefix and hex", tmpl: "token-{{ hex .Token }}", token: "abc", want: "token-" + hex.EncodeToString([]byte("abc"))}, {name: "base64", tmpl: "{{ base64 .Token }}", token: "abc", want: base64.StdEncoding.EncodeToString([]byte("abc"))}, - {name: "trimmed whitespace only is a no-op", tmpl: " ", token: "secret", want: "secret"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/flags/output.go b/internal/flags/output.go index c173235..d6522c5 100644 --- a/internal/flags/output.go +++ b/internal/flags/output.go @@ -18,7 +18,7 @@ func (o *Output) String() string { } func (o *Output) Set(str string) error { - if strings.TrimSpace(str) == "" { + if str == "" { return fmt.Errorf("no output format given, must be one of: %s", strings.Join(supportedOutputs, ", ")) } diff --git a/internal/helmrepo/http_test.go b/internal/helmrepo/http_test.go index b4c9065..a94a713 100644 --- a/internal/helmrepo/http_test.go +++ b/internal/helmrepo/http_test.go @@ -220,3 +220,10 @@ func writeRepositoriesFile(t *testing.T, entries ...*repo.Entry) string { } return path } + +func TestNormalizeRepositoryURL_TrimsWhitespace(t *testing.T) { + g := NewWithT(t) + got, err := normalizeRepositoryURL(" https://charts.example.com/ ") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got).To(Equal("https://charts.example.com")) +} diff --git a/internal/oci/verify.go b/internal/oci/verify.go index 9fbd153..066022e 100644 --- a/internal/oci/verify.go +++ b/internal/oci/verify.go @@ -21,7 +21,7 @@ import ( "github.com/sigstore/sigstore-go/pkg/tuf" "github.com/sigstore/sigstore-go/pkg/verify" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" ) const ( diff --git a/internal/registryauth/credential.go b/internal/registryauth/credential.go index ad8628f..e35efff 100644 --- a/internal/registryauth/credential.go +++ b/internal/registryauth/credential.go @@ -33,7 +33,7 @@ import ( "github.com/fluxcd/pkg/auth/jwt" "github.com/fluxcd/pkg/auth/utils/cijwt" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" "github.com/fluxcd/flux-mirror/internal/envelope" ) @@ -43,14 +43,14 @@ import ( // source). The host's Envelope, if any, is applied to the resolved credential. func resolveCredential(ctx context.Context, h apiv1.RegistryHost) (string, error) { c := h.Credential - aud := h.EffectiveAud() + audiences := h.EffectiveAudiences() var ( token string err error ) switch { case c.Provider != "": - fn, ferr := providerTokenFunc(c.Provider, aud, strings.TrimSpace(c.Aud) != "") + fn, ferr := providerTokenFunc(c.Provider, audiences, len(c.Audiences) > 0) if ferr != nil { return "", ferr } @@ -68,7 +68,7 @@ func resolveCredential(ctx context.Context, h apiv1.RegistryHost) (string, error if rerr != nil { return "", rerr } - fn, ferr := jwkTokenFunc(raw, c.Iss, c.Sub, aud, c.EffectiveExp()) + fn, ferr := jwkTokenFunc(raw, c.Issuer, c.Subject, audiences, c.EffectiveExpiration()) if ferr != nil { return "", fmt.Errorf("parse JWK: %w", ferr) } @@ -84,15 +84,15 @@ func resolveCredential(ctx context.Context, h apiv1.RegistryHost) (string, error // jwkTokenFunc parses a private JWK once and returns a cijwt.TokenFunc that // signs a fresh JWT with the given claims and lifetime on each call. Used for -// jwkPath/jwkValue credentials that set a custom exp; the default 60s lifetime is -// served by cijwt.WithHostJWK instead. -func jwkTokenFunc(jwk, iss, sub, aud string, ttl time.Duration) (cijwt.TokenFunc, error) { +// jwkPath/jwkValue credentials that set a custom expiration; the default 60s +// lifetime is served by cijwt.WithHostJWK instead. +func jwkTokenFunc(jwk, iss, sub string, auds []string, ttl time.Duration) (cijwt.TokenFunc, error) { key, err := jwt.ParseJWK(jwk) if err != nil { return nil, err } return func(context.Context) (string, error) { - return key.Issue(iss, sub, []string{aud}, ttl) + return key.Issue(iss, sub, auds, ttl) }, nil } @@ -121,19 +121,28 @@ func gcpUserIDToken(ctx context.Context) (string, error) { } // providerTokenFunc returns a cijwt.TokenFunc that mints a per-request bearer -// credential for aud using the given provider: an OIDC ID/access token for the -// OIDC providers, or a signed sts:GetCallerIdentity envelope for aws. audSet -// reports whether aud was explicitly configured (rather than defaulted to the -// host), so a provider branch that cannot honor a requested audience fails fast -// instead of silently minting a token for a different one. cijwt parses each -// returned token's exp claim and caches it for the first 50% of its lifetime, so -// the closure runs only on a cache miss and any ctx-scoped setup happens lazily -// under the request's own context. -func providerTokenFunc(provider, aud string, audSet bool) (cijwt.TokenFunc, error) { +// credential for audiences using the given provider: an OIDC ID/access token for +// the OIDC providers, a JWT-SVID for spiffe, or a signed sts:GetCallerIdentity +// envelope for aws. audiences has at least one entry (the host is the default). +// gcp and azure accept a single audience, enforced both here and by the config +// validation; the rest carry all of them. audiencesSet reports whether any +// audience was explicitly configured, so a provider branch that cannot honor a +// requested audience fails fast instead of silently minting a token for a +// different one. cijwt parses each returned token's exp claim and caches it for +// the first 50% of its lifetime, so the closure runs only on a cache miss and +// any ctx-scoped setup happens lazily under the request's own context. +func providerTokenFunc(provider string, audiences []string, audiencesSet bool) (cijwt.TokenFunc, error) { + if len(audiences) == 0 { + return nil, fmt.Errorf("provider %q requires at least one audience", provider) + } + if len(audiences) > 1 && (provider == apiv1.JWTProviderGCP || provider == apiv1.JWTProviderAzure) { + return nil, fmt.Errorf("provider %q accepts at most one audience", provider) + } + aud := audiences[0] switch provider { case apiv1.JWTProviderGitHub, apiv1.JWTProviderForgejo: return func(ctx context.Context) (string, error) { - return actionsoidc.FetchToken(ctx, []string{aud}) + return actionsoidc.FetchToken(ctx, audiences) }, nil case apiv1.JWTProviderGCP: return func(ctx context.Context) (string, error) { @@ -148,8 +157,8 @@ func providerTokenFunc(provider, aud string, audSet bool) (cijwt.TokenFunc, erro // ignore it, so reject rather than mint a token for the wrong // audience. if strings.Contains(err.Error(), "unsupported credentials type") { - if audSet { - return "", fmt.Errorf("aud %q cannot be honored with GCP user credentials (authorized_user Application Default Credentials); unset aud or authenticate with a service account", aud) + if audiencesSet { + return "", fmt.Errorf("audience %q cannot be honored with GCP user credentials (authorized_user Application Default Credentials); unset audiences or authenticate with a service account", aud) } return gcpUserIDToken(ctx) } @@ -189,11 +198,12 @@ func providerTokenFunc(provider, aud string, audSet bool) (cijwt.TokenFunc, erro if region == "" { region = "us-east-1" } - return mintAWSSTSToken(ctx, cfg.Credentials, signer, region, aud) + return mintAWSSTSToken(ctx, cfg.Credentials, signer, region, audiences) }, nil - case apiv1.JWTProviderJWTSVID: + case apiv1.JWTProviderSPIFFE: + extra := audiences[1:] return func(ctx context.Context) (string, error) { - // Fetch a JWT-SVID for the audience from the ambient Workload API + // Fetch a JWT-SVID for the audiences from the ambient Workload API // (SPIFFE_ENDPOINT_SOCKET). The source is opened per cache-miss call // and closed immediately; cijwt caches the returned token's exp, so // this does not dial the socket on every request. @@ -202,7 +212,7 @@ func providerTokenFunc(provider, aud string, audSet bool) (cijwt.TokenFunc, erro return "", fmt.Errorf("create SPIFFE JWT source: %w", err) } defer src.Close() - svid, err := src.FetchJWTSVID(ctx, jwtsvid.Params{Audience: aud}) + svid, err := src.FetchJWTSVID(ctx, jwtsvid.Params{Audience: aud, ExtraAudiences: extra}) if err != nil { return "", fmt.Errorf("fetch JWT-SVID: %w", err) } @@ -219,10 +229,13 @@ func providerTokenFunc(provider, aud string, audSet bool) (cijwt.TokenFunc, erro // window AWS STS accepts a SigV4 request's timestamp. const awsSTSTokenTTL = 2 * time.Minute -// awsAudienceHeader carries aud, signed into the GetCallerIdentity request to -// pin the intended registry, so a request minted for one host cannot be -// replayed against another. The registry must require it and match it to its -// own identity. It plays the role Vault's X-Vault-AWS-IAM-Server-ID guard does. +// awsAudienceHeader carries the audiences, signed into the GetCallerIdentity +// request so the registry can match the request against its own identity. Each +// audience is sent as its own signed header value; the registry must require the +// header and accept the request only if it lists an identity the registry +// recognizes. A single audience therefore ties the request to that one registry, +// while several audiences deliberately authorize every registry listed. It plays +// the role Vault's X-Vault-AWS-IAM-Server-ID guard does. const awsAudienceHeader = "X-Audience" // awsSTSTokenType is the JOSE "typ" header the registry uses to tell this @@ -240,7 +253,7 @@ const awsSTSTokenType = "aws-sigv4-getcalleridentity" // not a real JWT — it carries the signed request under the "sts" claim and an // unverified exp that only tells the cijwt transport when to re-mint; nothing // checks its (empty) signature segment. -func mintAWSSTSToken(ctx context.Context, creds aws.CredentialsProvider, signer *v4.Signer, region, serverID string) (string, error) { +func mintAWSSTSToken(ctx context.Context, creds aws.CredentialsProvider, signer *v4.Signer, region string, audiences []string) (string, error) { c, err := creds.Retrieve(ctx) if err != nil { return "", fmt.Errorf("retrieve AWS credentials: %w", err) @@ -253,7 +266,7 @@ func mintAWSSTSToken(ctx context.Context, creds aws.CredentialsProvider, signer return "", err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set(awsAudienceHeader, serverID) + req.Header[awsAudienceHeader] = audiences sum := sha256.Sum256([]byte(body)) now := time.Now() @@ -272,7 +285,7 @@ func mintAWSSTSToken(ctx context.Context, creds aws.CredentialsProvider, signer } claims, err := json.Marshal(map[string]any{ "exp": now.Add(awsSTSTokenTTL).Unix(), - "aud": serverID, + "aud": audiences, "sts": json.RawMessage(sts), }) if err != nil { diff --git a/internal/registryauth/registryauth.go b/internal/registryauth/registryauth.go index f906709..fca2726 100644 --- a/internal/registryauth/registryauth.go +++ b/internal/registryauth/registryauth.go @@ -17,7 +17,7 @@ import ( "github.com/fluxcd/pkg/auth/utils" "github.com/fluxcd/pkg/auth/utils/cijwt" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" "github.com/fluxcd/flux-mirror/internal/envelope" "github.com/fluxcd/flux-mirror/internal/jwkio" ) @@ -59,7 +59,7 @@ func SelectAuthHosts(cfg *apiv1.Config, filter []string) ([]apiv1.RegistryHost, // sets a provider or a credential. A TLS-only host (only tls configured) does // not, so the login/secret commands skip it. func HasCredential(h apiv1.RegistryHost) bool { - return h.Provider != "" || h.Credential != nil + return h.IsCloudProvider() || h.Credential != nil } // ResolveHostAuth resolves the credential for any auth host: @@ -75,7 +75,7 @@ func ResolveHostAuth(ctx context.Context, h apiv1.RegistryHost) (HostAuth, error if !HasCredential(h) { return HostAuth{}, fmt.Errorf("host %q has no credential or provider configured", h.Host) } - if h.Provider != "" { + if h.IsCloudProvider() { a, err := providerAuthenticator(ctx, h) if err != nil { return HostAuth{}, err @@ -90,8 +90,8 @@ func ResolveHostAuth(ctx context.Context, h apiv1.RegistryHost) (HostAuth, error if err != nil { return HostAuth{}, err } - if h.Username != "" { - return HostAuth{Username: h.Username, Password: cred}, nil + if h.Credential.Username != "" { + return HostAuth{Username: h.Credential.Username, Password: cred}, nil } return HostAuth{RegistryToken: cred}, nil } @@ -165,7 +165,7 @@ func (m mintingAuthenticator) AuthorizationContext(ctx context.Context) (*authn. if err != nil { return nil, err } - return &authn.AuthConfig{Username: m.host.Username, Password: cred}, nil + return &authn.AuthConfig{Username: m.host.Credential.Username, Password: cred}, nil } // BuildKeychain returns a keychain that serves the hosts which authenticate @@ -177,13 +177,13 @@ func BuildKeychain(ctx context.Context, hosts []apiv1.RegistryHost) (authn.Keych auths := make(map[string]authn.Authenticator) for _, h := range hosts { switch { - case h.Provider != "": + case h.IsCloudProvider(): a, err := providerAuthenticator(ctx, h) if err != nil { return nil, err } auths[h.Host] = a - case h.Credential != nil && h.Username != "": + case h.Credential != nil && h.Credential.Username != "": auths[h.Host] = mintingAuthenticator{host: h} } } @@ -193,13 +193,21 @@ func BuildKeychain(ctx context.Context, hosts []apiv1.RegistryHost) (authn.Keych return providerKeychain{auths: auths, inner: authn.DefaultKeychain}, nil } +// usesCredentialTransport reports whether the cijwt bearer-stamp transport +// manages this host: a credential host without a username (its credential is +// stamped directly as a Bearer token). Cloud provider hosts and username +// credentials authenticate through the keychain instead. +func usesCredentialTransport(h apiv1.RegistryHost) bool { + return h.Credential != nil && h.Credential.Username == "" +} + // NeedsCredentialTransport reports whether the cijwt bearer-stamp transport is // needed: true when any credential host has no username (a bearer token stamped // directly). Credential hosts with a username and provider hosts go through the // keychain instead. func NeedsCredentialTransport(hosts []apiv1.RegistryHost) bool { for _, h := range hosts { - if h.Credential != nil && h.Username == "" { + if usesCredentialTransport(h) { return true } } @@ -226,9 +234,10 @@ func NewCredentialTransport(inner http.RoundTripper, hosts []apiv1.RegistryHost) // cijwt transport into the host's configured envelope. It sits between the // cijwt transport and the base transport, so cijwt sees the raw token — and can // still parse its exp claim to schedule re-minting — while the registry -// receives the enveloped string. Requests without an Authorization bearer -// header (e.g. a username/password host, which authenticates through the -// keychain) pass through untouched. +// receives the enveloped string. It is built only for hosts without a username +// (the cijwt-managed bearer hosts); a username/password host's Bearer registry +// token, and any request without an Authorization bearer header, pass through +// untouched. type envelopeTransport struct { inner http.RoundTripper envelopes map[string]*template.Template @@ -236,11 +245,16 @@ type envelopeTransport struct { // newEnvelopeTransport builds an envelope transport for the credential hosts // that configure an envelope. It parses each template up front, so a malformed -// envelope fails the run before any request. +// envelope fails the run before any request. Only hosts without a username are +// included: they are the ones the cijwt transport stamps a Bearer token for. A +// username credential host authenticates through the keychain, and after the +// auth challenge it also carries a Bearer registry token — transforming that +// token would corrupt it. Its envelope is applied to the password instead, by +// resolveCredential. func newEnvelopeTransport(inner http.RoundTripper, hosts []apiv1.RegistryHost) (*envelopeTransport, error) { envelopes := make(map[string]*template.Template) for _, h := range hosts { - if h.Credential == nil || h.Credential.Envelope == "" { + if !usesCredentialTransport(h) || h.Credential.Envelope == "" { continue } t, err := envelope.Parse(h.Credential.Envelope) @@ -297,16 +311,16 @@ func JWTTransportOptions(inner http.RoundTripper, hosts []apiv1.RegistryHost) ([ opts := []cijwt.Option{cijwt.WithInner(inner)} for _, h := range hosts { - if h.Credential == nil || h.Username != "" { - // Provider hosts and username credentials authenticate via the + if !usesCredentialTransport(h) { + // Cloud provider hosts and username credentials authenticate via the // keychain (standard challenge), not the cijwt bearer-stamp. continue } j := h.Credential - aud := h.EffectiveAud() + audiences := h.EffectiveAudiences() switch { case j.Provider != "": - fn, err := providerTokenFunc(j.Provider, aud, strings.TrimSpace(j.Aud) != "") + fn, err := providerTokenFunc(j.Provider, audiences, len(j.Audiences) > 0) if err != nil { return nil, fmt.Errorf("auth host %q: %w", h.Host, err) } @@ -320,13 +334,13 @@ func JWTTransportOptions(inner http.RoundTripper, hosts []apiv1.RegistryHost) ([ if err != nil { return nil, fmt.Errorf("auth host %q: %w", h.Host, err) } - if j.Exp == nil { + if j.Expiration == nil { // Default: cijwt signs a fresh 60s token per request. - opts = append(opts, cijwt.WithHostJWK(h.Host, jwk, j.Iss, []string{aud}, j.Sub)) + opts = append(opts, cijwt.WithHostJWK(h.Host, jwk, j.Issuer, audiences, j.Subject)) break } - // Custom exp: sign it ourselves and let cijwt cache per its exp. - fn, err := jwkTokenFunc(jwk, j.Iss, j.Sub, aud, j.Exp.Duration) + // Custom expiration: sign it ourselves and let cijwt cache per its exp. + fn, err := jwkTokenFunc(jwk, j.Issuer, j.Subject, audiences, j.Expiration.Duration) if err != nil { return nil, fmt.Errorf("auth host %q: parse JWK: %w", h.Host, err) } diff --git a/internal/registryauth/registryauth_test.go b/internal/registryauth/registryauth_test.go index 69c903a..1a34ed9 100644 --- a/internal/registryauth/registryauth_test.go +++ b/internal/registryauth/registryauth_test.go @@ -7,6 +7,9 @@ import ( "context" "crypto/ed25519" "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" "encoding/json" "net/http" "os" @@ -15,12 +18,15 @@ import ( "testing" "time" + "github.com/aws/aws-sdk-go-v2/aws" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" "github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4/jwt" + gojwt "github.com/golang-jwt/jwt/v5" . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" ) func authHostsConfig(hosts ...string) *apiv1.Config { @@ -28,7 +34,7 @@ func authHostsConfig(hosts ...string) *apiv1.Config { for _, h := range hosts { cfg.Hosts = append(cfg.Hosts, apiv1.RegistryHost{ Host: h, - Credential: &apiv1.RegistryCredential{Value: "X"}, + Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, Value: "X"}, }) } return cfg @@ -67,10 +73,10 @@ func TestUsernameSwitch(t *testing.T) { g := NewWithT(t) bearer := []apiv1.RegistryHost{{ - Host: "bearer.example", Credential: &apiv1.RegistryCredential{Value: "X"}, + Host: "bearer.example", Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, Value: "X"}, }} userpass := []apiv1.RegistryHost{{ - Host: "userpass.example", Username: "robot", Credential: &apiv1.RegistryCredential{Value: "X"}, + Host: "userpass.example", Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, Value: "X", Username: "robot"}, }} // cijwt (bearer-stamp) transport is needed only when a credential host has @@ -99,7 +105,7 @@ func TestProviderTokenFunc_GCPUserCredentialRejectsAud(t *testing.T) { t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", writeAuthorizedUserCreds(t)) t.Setenv("GOOGLE_API_GO_EXPERIMENTAL_ENABLE_NEW_AUTH_LIB", "false") - fn, err := providerTokenFunc(apiv1.JWTProviderGCP, "registry.example.com", true) + fn, err := providerTokenFunc(apiv1.JWTProviderGCP, []string{"registry.example.com"}, true) g.Expect(err).ToNot(HaveOccurred()) _, err = fn(context.Background()) g.Expect(err).To(MatchError(ContainSubstring("cannot be honored with GCP user credentials"))) @@ -135,15 +141,20 @@ func TestPkgAuthProviderName(t *testing.T) { func TestHasCredentialAndTLSOnly(t *testing.T) { g := NewWithT(t) - tlsOnly := apiv1.RegistryHost{Host: "tls.example", TLS: &apiv1.TLS{ - ServerAuth: &apiv1.TLSServerAuth{Value: "x"}, - }} - withCred := apiv1.RegistryHost{Host: "c.example", Credential: &apiv1.RegistryCredential{Value: "X"}} + tlsOnly := apiv1.RegistryHost{Host: "tls.example", TLS: &apiv1.TLS{Path: "/certs"}} + withCred := apiv1.RegistryHost{Host: "c.example", Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, Value: "X"}} withProvider := apiv1.RegistryHost{Host: "p.example", Provider: apiv1.RegistryProviderECR} + // The generic provider is not a credential source: it behaves like an unset + // provider, so a generic host needs a credential to yield one. + withGenericOnly := apiv1.RegistryHost{Host: "g.example", Provider: apiv1.RegistryProviderGeneric} + withGenericCred := apiv1.RegistryHost{Host: "gc.example", Provider: apiv1.RegistryProviderGeneric, + Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, Value: "X"}} g.Expect(HasCredential(tlsOnly)).To(BeFalse()) g.Expect(HasCredential(withCred)).To(BeTrue()) g.Expect(HasCredential(withProvider)).To(BeTrue()) + g.Expect(HasCredential(withGenericOnly)).To(BeFalse()) + g.Expect(HasCredential(withGenericCred)).To(BeTrue()) // A TLS-only host returns a clear error instead of panicking. _, err := ResolveHostAuth(context.Background(), tlsOnly) @@ -152,7 +163,7 @@ func TestHasCredentialAndTLSOnly(t *testing.T) { func TestResolveCredential_Envelope(t *testing.T) { g := NewWithT(t) - h := apiv1.RegistryHost{Host: "h.example", Credential: &apiv1.RegistryCredential{ + h := apiv1.RegistryHost{Host: "h.example", Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, Value: "abc", Envelope: "token-{{ hex .Token }}", }} @@ -169,10 +180,10 @@ func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { retu func TestEnvelopeTransport(t *testing.T) { g := NewWithT(t) hosts := []apiv1.RegistryHost{ - {Host: "h.example", Credential: &apiv1.RegistryCredential{ + {Host: "h.example", Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, Value: "abc", Envelope: "token-{{ hex .Token }}", }}, - {Host: "plain.example", Credential: &apiv1.RegistryCredential{Value: "abc"}}, + {Host: "plain.example", Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, Value: "abc"}}, } var got http.Header @@ -207,7 +218,7 @@ func TestEnvelopeTransport(t *testing.T) { // A malformed envelope fails when the transport is built. _, err = newEnvelopeTransport(inner, []apiv1.RegistryHost{{ - Host: "bad.example", Credential: &apiv1.RegistryCredential{ + Host: "bad.example", Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, Provider: apiv1.JWTProviderGitHub, Envelope: "{{ nope .Token }}", }, }}) @@ -223,7 +234,7 @@ func TestNewCredentialTransport_EnvelopeStatic(t *testing.T) { }) tr, err := NewCredentialTransport(inner, []apiv1.RegistryHost{{ Host: "h.example", - Credential: &apiv1.RegistryCredential{Value: "abc", Envelope: "token-{{ hex .Token }}"}, + Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, Value: "abc", Envelope: "token-{{ hex .Token }}"}, }}) g.Expect(err).ToNot(HaveOccurred()) @@ -251,12 +262,12 @@ func TestNewCredentialTransport_EnvelopePreservesExpParsing(t *testing.T) { }) tr, err := NewCredentialTransport(inner, []apiv1.RegistryHost{{ Host: "h.example", - Credential: &apiv1.RegistryCredential{ - JWKValue: string(jwk), - Iss: "https://issuer.example", - Sub: "client-id", - Exp: &metav1.Duration{Duration: time.Hour}, - Envelope: "token-{{ .Token }}", + Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, + JWKValue: string(jwk), + Issuer: "https://issuer.example", + Subject: "client-id", + Expiration: &metav1.Duration{Duration: time.Hour}, + Envelope: "token-{{ .Token }}", }, }}) g.Expect(err).ToNot(HaveOccurred()) @@ -271,3 +282,233 @@ func TestNewCredentialTransport_EnvelopePreservesExpParsing(t *testing.T) { _, err = jwt.ParseSigned(raw, []jose.SignatureAlgorithm{jose.EdDSA}) g.Expect(err).ToNot(HaveOccurred()) } + +// TestEnvelopeTransport_SkipsUsernameHosts proves the envelope is not applied to +// the Bearer token a username/password host obtains after the registry auth +// challenge: that token is issued by the registry and transforming it would +// corrupt it. The host's envelope is applied to its password by +// resolveCredential instead. +func TestEnvelopeTransport_SkipsUsernameHosts(t *testing.T) { + g := NewWithT(t) + host := apiv1.RegistryHost{ + Host: "userpass.example", + Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, + Value: "pw", Username: "robot", Envelope: "token-{{ hex .Token }}", + }, + } + + var got http.Header + inner := roundTripFunc(func(r *http.Request) (*http.Response, error) { + got = r.Header.Clone() + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil + }) + et, err := newEnvelopeTransport(inner, []apiv1.RegistryHost{host}) + g.Expect(err).ToNot(HaveOccurred()) + + req, err := http.NewRequest(http.MethodGet, "https://userpass.example/v2/", nil) + g.Expect(err).ToNot(HaveOccurred()) + req.Header.Set("Authorization", "Bearer registry-issued-token") + _, err = et.RoundTrip(req) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got.Get("Authorization")).To(Equal("Bearer registry-issued-token")) + + // The password still carries the envelope. + pw, err := resolveCredential(context.Background(), host) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(pw).To(Equal("token-" + hex.EncodeToString([]byte("pw")))) +} + +// TestJWKTokenFunc_MultipleAudiences proves a jwk-signed JWT carries every +// configured audience as a JSON array (RFC 7519). +func TestJWKTokenFunc_MultipleAudiences(t *testing.T) { + g := NewWithT(t) + _, priv, err := ed25519.GenerateKey(rand.Reader) + g.Expect(err).ToNot(HaveOccurred()) + jwk, err := json.Marshal(jose.JSONWebKey{Key: priv, KeyID: "k", Algorithm: "EdDSA"}) + g.Expect(err).ToNot(HaveOccurred()) + + fn, err := jwkTokenFunc(string(jwk), "https://issuer.example", "client-id", + []string{"a.example", "b.example"}, time.Minute) + g.Expect(err).ToNot(HaveOccurred()) + token, err := fn(context.Background()) + g.Expect(err).ToNot(HaveOccurred()) + + claims := gojwt.MapClaims{} + _, _, err = gojwt.NewParser().ParseUnverified(token, claims) + g.Expect(err).ToNot(HaveOccurred()) + aud, err := claims.GetAudience() + g.Expect(err).ToNot(HaveOccurred()) + g.Expect([]string(aud)).To(Equal([]string{"a.example", "b.example"})) +} + +// staticCredentialsProvider is an aws.CredentialsProvider that returns fixed +// credentials, so signing works without contacting IMDS. +type staticCredentialsProvider struct{ creds aws.Credentials } + +func (p staticCredentialsProvider) Retrieve(context.Context) (aws.Credentials, error) { + return p.creds, nil +} + +// TestMintAWSSTSToken_Audiences proves the envelope carries every configured +// audience both as repeated X-Audience header values covered by the SigV4 +// signature and as an aud array — including the single-audience case, where aud +// is still an array rather than a bare string. +func TestMintAWSSTSToken_Audiences(t *testing.T) { + creds := staticCredentialsProvider{creds: aws.Credentials{ + AccessKeyID: "AKIDEXAMPLE", + SecretAccessKey: "secret", + }} + + for _, tc := range []struct { + name string + audiences []string + }{ + {name: "single audience", audiences: []string{"a.example"}}, + {name: "multiple audiences", audiences: []string{"a.example", "b.example"}}, + } { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + + token, err := mintAWSSTSToken(context.Background(), creds, v4.NewSigner(), "us-east-1", tc.audiences) + g.Expect(err).ToNot(HaveOccurred()) + + parts := strings.Split(token, ".") + g.Expect(parts).To(HaveLen(3)) + claimsRaw, err := base64.RawURLEncoding.DecodeString(parts[1]) + g.Expect(err).ToNot(HaveOccurred()) + var claims struct { + Aud []string `json:"aud"` + STS struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string][]string `json:"headers"` + Body string `json:"body"` + } `json:"sts"` + } + g.Expect(json.Unmarshal(claimsRaw, &claims)).To(Succeed()) + g.Expect(claims.Aud).To(Equal(tc.audiences)) + g.Expect(claims.STS.Headers["X-Audience"]).To(Equal(tc.audiences)) + + // The X-Audience header must be covered by the SigV4 signature; + // otherwise the replay pinning it provides could be stripped. + authz := claims.STS.Headers["Authorization"] + g.Expect(authz).To(HaveLen(1)) + var signedHeaders []string + for _, part := range strings.Split(authz[0], ",") { + if v, ok := strings.CutPrefix(strings.TrimSpace(part), "SignedHeaders="); ok { + signedHeaders = strings.Split(v, ";") + } + } + g.Expect(signedHeaders).To(ContainElement("x-audience")) + + // Re-sign the request reconstructed from the envelope with the same + // credentials and signing time. The signature must match, proving + // every X-Audience value (not just the header name) is part of the + // canonical request. + req, err := http.NewRequest(claims.STS.Method, claims.STS.URL, strings.NewReader(claims.STS.Body)) + g.Expect(err).ToNot(HaveOccurred()) + for k, vs := range claims.STS.Headers { + req.Header[k] = vs + } + signedAt, err := time.Parse("20060102T150405Z", req.Header.Get("X-Amz-Date")) + g.Expect(err).ToNot(HaveOccurred()) + sum := sha256.Sum256([]byte(claims.STS.Body)) + g.Expect(v4.NewSigner().SignHTTP(context.Background(), creds.creds, req, + hex.EncodeToString(sum[:]), "sts", "us-east-1", signedAt)).To(Succeed()) + g.Expect(req.Header.Get("Authorization")).To(Equal(authz[0])) + }) + } +} + +// TestProviderTokenFunc_AudienceGuards proves providerTokenFunc enforces its +// audience contract independently of config validation: at least one audience +// for every provider, and at most one for the single-audience providers. +func TestProviderTokenFunc_AudienceGuards(t *testing.T) { + g := NewWithT(t) + + _, err := providerTokenFunc(apiv1.JWTProviderGitHub, nil, false) + g.Expect(err).To(MatchError(ContainSubstring("requires at least one audience"))) + + for _, provider := range []string{apiv1.JWTProviderGCP, apiv1.JWTProviderAzure} { + _, err := providerTokenFunc(provider, []string{"a.example", "b.example"}, true) + g.Expect(err).To(MatchError(ContainSubstring(`provider "` + provider + `" accepts at most one audience`))) + } +} + +// TestProviderTokenFunc_ExactProvider proves provider values are matched as +// written: an unpadded value dispatches, a padded one does not. +func TestProviderTokenFunc_ExactProvider(t *testing.T) { + g := NewWithT(t) + + fn, err := providerTokenFunc(apiv1.JWTProviderGitHub, []string{"a.example"}, false) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(fn).ToNot(BeNil()) + + _, err = providerTokenFunc(" "+apiv1.JWTProviderGitHub+" ", []string{"a.example"}, false) + g.Expect(err).To(MatchError(ContainSubstring("unknown provider"))) +} + +// TestPkgAuthProviderName_ExactProvider proves the registry provider translation +// matches the value as written. +func TestPkgAuthProviderName_ExactProvider(t *testing.T) { + g := NewWithT(t) + + for raw, want := range map[string]string{ + "ecr": "aws", + "acr": "azure", + "gar": "gcp", + } { + got, err := pkgAuthProviderName(raw) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got).To(Equal(want)) + } + + for _, raw := range []string{"s3", " ecr "} { + _, err := pkgAuthProviderName(raw) + g.Expect(err).To(MatchError(ContainSubstring("unknown registry provider"))) + } +} + +// TestGenericProviderHostResolution proves a generic (default) provider host is +// not a cloud provider and resolves its credential through the credential path: +// as username/password when credential.username is set, else as a bearer token. +func TestGenericProviderHostResolution(t *testing.T) { + g := NewWithT(t) + + userpass := apiv1.RegistryHost{ + Host: "h.example", + Provider: apiv1.RegistryProviderGeneric, + Credential: &apiv1.RegistryCredential{ + Type: apiv1.CredentialTypeJWT, + Value: "X", + Username: "robot", + }, + } + g.Expect(userpass.IsCloudProvider()).To(BeFalse()) + g.Expect(HasCredential(userpass)).To(BeTrue()) + ha, err := ResolveHostAuth(context.Background(), userpass) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(ha.Username).To(Equal("robot")) + g.Expect(ha.Password).To(Equal("X")) + g.Expect(ha.RegistryToken).To(BeEmpty()) + + bearer := apiv1.RegistryHost{ + Host: "h.example", + Provider: apiv1.RegistryProviderGeneric, + Credential: &apiv1.RegistryCredential{ + Type: apiv1.CredentialTypeJWT, + Value: "X", + }, + } + g.Expect(HasCredential(bearer)).To(BeTrue()) + ha, err = ResolveHostAuth(context.Background(), bearer) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(ha.RegistryToken).To(Equal("X")) + g.Expect(ha.Username).To(BeEmpty()) + + // The generic provider alone yields no credential, like an unset provider. + genericOnly := apiv1.RegistryHost{Host: "h.example", Provider: apiv1.RegistryProviderGeneric} + g.Expect(HasCredential(genericOnly)).To(BeFalse()) + _, err = ResolveHostAuth(context.Background(), genericOnly) + g.Expect(err).To(MatchError(ContainSubstring("has no credential or provider"))) +} diff --git a/internal/registryauth/spiffe_test.go b/internal/registryauth/spiffe_test.go index d8cdfe9..2ed90a3 100644 --- a/internal/registryauth/spiffe_test.go +++ b/internal/registryauth/spiffe_test.go @@ -25,7 +25,7 @@ import ( "github.com/spiffe/go-spiffe/v2/proto/spiffe/workload" "google.golang.org/grpc" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" ) const ( @@ -128,8 +128,8 @@ func startFakeWorkloadAPI(t *testing.T, ca *testCA) { // TestSPIFFE_MTLSEndToEnd drives a real X.509-SVID mTLS handshake: a SPIFFE // server SVID on the server side, flux-mirror's tls.spiffe transport (fed by the -// fake Workload API) on the client side, exercising trustDomain: self, an exact -// serverID, and a serverID mismatch. +// fake Workload API) on the client side, exercising the RFC-0014 serverIDs +// forms: self trust domain, exact ID, ID prefix, trust domain, and mismatches. func TestSPIFFE_MTLSEndToEnd(t *testing.T) { g := NewWithT(t) ca := newTestCA(t) @@ -154,21 +154,24 @@ func TestSPIFFE_MTLSEndToEnd(t *testing.T) { host := mustHost(t, srv.URL) cases := []struct { - name string - spiffe *apiv1.SPIFFETLS - wantOK bool + name string + serverIDs []string + wantOK bool }{ - {"trustDomain self", &apiv1.SPIFFETLS{TrustDomain: apiv1.TrustDomainSelf}, true}, - {"exact serverID", &apiv1.SPIFFETLS{ServerID: testServerID}, true}, - {"serverID mismatch", &apiv1.SPIFFETLS{ServerID: "spiffe://example.org/wrong"}, false}, + {"self trust domain", []string{"spiffe://self"}, true}, + {"trust domain", []string{"spiffe://example.org"}, true}, + {"exact serverID", []string{testServerID}, true}, + {"serverID mismatch", []string{"spiffe://example.org/wrong"}, false}, + {"serverID prefix", []string{"spiffe://example.org/server/"}, true}, + {"serverID prefix mismatch", []string{"spiffe://example.org/other/"}, false}, + {"any of several", []string{"spiffe://example.org/wrong", testServerID}, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { g := NewWithT(t) rt, closeFn, err := NewTLSTransport(context.Background(), http.DefaultTransport, []apiv1.RegistryHost{{Host: host, TLS: &apiv1.TLS{ - ClientAuth: &apiv1.TLSClientAuth{Provider: apiv1.TLSClientProviderX509SVID}, - ServerAuth: &apiv1.TLSServerAuth{SPIFFE: tc.spiffe}, + SPIFFE: &apiv1.SPIFFETLS{ServerIDs: tc.serverIDs}, }}}) g.Expect(err).ToNot(HaveOccurred()) defer closeFn() @@ -184,58 +187,40 @@ func TestSPIFFE_MTLSEndToEnd(t *testing.T) { } } -// TestSPIFFE_ClientOnly drives mTLS where the client presents a SPIFFE X.509-SVID -// but the server is verified with a normal CA bundle (not SPIFFE) — the -// client-only case enabled by splitting serverAuth and clientAuth. -func TestSPIFFE_ClientOnly(t *testing.T) { +// TestSPIFFE_JWTSVIDCredential resolves a credential with provider spiffe +// against the fake Workload API and checks the returned JWT carries the host as +// audience and the SPIFFE ID as subject. +func TestSPIFFE_JWTSVIDCredential(t *testing.T) { g := NewWithT(t) ca := newTestCA(t) startFakeWorkloadAPI(t, ca) - // Normal (non-SPIFFE) server cert with an IP SAN for hostname verification. - serverCertPEM, serverKeyPEM := ca.issue(t, "server", []net.IP{net.IPv4(127, 0, 0, 1)}, nil) - serverCert, err := tls.X509KeyPair(serverCertPEM, serverKeyPEM) - g.Expect(err).ToNot(HaveOccurred()) - caPool := x509.NewCertPool() - g.Expect(caPool.AppendCertsFromPEM(ca.certPEM)).To(BeTrue()) - - srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - })) - srv.TLS = &tls.Config{ - Certificates: []tls.Certificate{serverCert}, - ClientCAs: caPool, - ClientAuth: tls.RequireAndVerifyClientCert, - } - srv.StartTLS() - defer srv.Close() - host := mustHost(t, srv.URL) - - rt, closeFn, err := NewTLSTransport(context.Background(), http.DefaultTransport, []apiv1.RegistryHost{{ - Host: host, - TLS: &apiv1.TLS{ - ServerAuth: &apiv1.TLSServerAuth{Value: string(ca.certPEM)}, - ClientAuth: &apiv1.TLSClientAuth{Provider: apiv1.TLSClientProviderX509SVID}, - }, - }}) + h := apiv1.RegistryHost{Host: "registry.example.com", Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, + Provider: apiv1.JWTProviderSPIFFE, + }} + cred, err := resolveCredential(context.Background(), h) g.Expect(err).ToNot(HaveOccurred()) - defer closeFn() - resp, err := rt.RoundTrip(mustGet(t, srv.URL)) + tok, err := jwt.ParseSigned(cred, []jose.SignatureAlgorithm{jose.ES256}) g.Expect(err).ToNot(HaveOccurred()) - g.Expect(resp.StatusCode).To(Equal(http.StatusNoContent)) + var claims jwt.Claims + g.Expect(tok.UnsafeClaimsWithoutVerification(&claims)).To(Succeed()) + g.Expect(claims.Subject).To(Equal(testClientID)) + g.Expect(claims.Audience).To(ContainElement("registry.example.com")) } -// TestSPIFFE_JWTSVIDCredential resolves a credential with provider jwt-svid -// against the fake Workload API and checks the returned JWT carries the host as -// audience and the SPIFFE ID as subject. -func TestSPIFFE_JWTSVIDCredential(t *testing.T) { +// TestSPIFFE_JWTSVIDCredential_MultipleAudiences resolves a credential with +// provider spiffe and several audiences, and checks the extra audiences are +// carried as additional JWT-SVID audiences. +func TestSPIFFE_JWTSVIDCredential_MultipleAudiences(t *testing.T) { g := NewWithT(t) ca := newTestCA(t) startFakeWorkloadAPI(t, ca) - h := apiv1.RegistryHost{Host: "registry.example.com", Credential: &apiv1.RegistryCredential{ - Provider: apiv1.JWTProviderJWTSVID, + audiences := []string{"registry.example.com", "extra.example.com"} + h := apiv1.RegistryHost{Host: "registry.example.com", Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, + Provider: apiv1.JWTProviderSPIFFE, + Audiences: audiences, }} cred, err := resolveCredential(context.Background(), h) g.Expect(err).ToNot(HaveOccurred()) @@ -245,7 +230,7 @@ func TestSPIFFE_JWTSVIDCredential(t *testing.T) { var claims jwt.Claims g.Expect(tok.UnsafeClaimsWithoutVerification(&claims)).To(Succeed()) g.Expect(claims.Subject).To(Equal(testClientID)) - g.Expect(claims.Audience).To(ContainElement("registry.example.com")) + g.Expect(claims.Audience).To(ConsistOf(audiences)) } func mustURL(t *testing.T, raw string) *url.URL { diff --git a/internal/registryauth/tls.go b/internal/registryauth/tls.go index 99bc393..73b0830 100644 --- a/internal/registryauth/tls.go +++ b/internal/registryauth/tls.go @@ -6,16 +6,21 @@ package registryauth import ( "context" "crypto/tls" - "crypto/x509" + "errors" "fmt" + "io/fs" "net/http" "os" + "path/filepath" + "strings" - "github.com/spiffe/go-spiffe/v2/spiffeid" - "github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig" + corev1 "k8s.io/api/core/v1" + + "github.com/fluxcd/pkg/runtime/secrets" + fluxspiffe "github.com/fluxcd/pkg/spiffe" "github.com/spiffe/go-spiffe/v2/workloadapi" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" ) // NeedsTLS reports whether any host configures transport-layer TLS. @@ -47,9 +52,10 @@ func (t tlsDispatchTransport) RoundTrip(req *http.Request) (*http.Response, erro } // NewTLSTransport wraps inner with a transport that applies each host's -// configured TLS settings (custom CA, client certificate, or SPIFFE X.509-SVID -// mTLS) to requests for that host. Hosts without TLS fall through to inner -// unchanged. Returns inner and a no-op closer when no host configures TLS. +// configured TLS settings to requests for that host: a PEM directory (custom CA +// and/or client certificate) or SPIFFE mutual TLS. Hosts without TLS fall +// through to inner unchanged. Returns inner and a no-op closer when no host +// configures TLS. // // The returned closer must be called when the transport is no longer needed; it // closes the SPIFFE Workload API source, if one was opened. @@ -75,14 +81,27 @@ func NewTLSTransport(ctx context.Context, inner http.RoundTripper, hosts []apiv1 if h.TLS == nil { continue } - if spiffeSrc == nil && tlsNeedsSPIFFE(h.TLS) { - s, err := workloadapi.NewX509Source(ctx) - if err != nil { - return nil, nil, fmt.Errorf("host %q: tls: spiffe: create X.509 source: %w", h.Host, err) + inline := h.TLS.CACert != "" || h.TLS.TLSCert != "" || h.TLS.TLSKey != "" + if h.TLS.SPIFFE == nil && h.TLS.Path == "" && !inline { + _ = closeSource() + return nil, nil, fmt.Errorf("host %q: tls: one of path, inline certificates, or spiffe is required", h.Host) + } + var tlsCfg *tls.Config + var err error + switch { + case h.TLS.SPIFFE != nil: + if spiffeSrc == nil { + spiffeSrc, err = workloadapi.NewX509Source(ctx) + if err != nil { + return nil, nil, fmt.Errorf("host %q: tls: spiffe: create X.509 source: %w", h.Host, err) + } } - spiffeSrc = s + tlsCfg, err = fluxspiffe.BuildTLSConfig(spiffeSrc, trimAll(h.TLS.SPIFFE.ServerIDs)...) + case inline: + tlsCfg, err = tlsConfigFromInline(ctx, *h.TLS) + default: + tlsCfg, err = tlsConfigFromPath(ctx, h.TLS.Path) } - tlsCfg, err := buildTLSConfig(h.TLS, spiffeSrc) if err != nil { _ = closeSource() return nil, nil, fmt.Errorf("host %q: tls: %w", h.Host, err) @@ -95,115 +114,73 @@ func NewTLSTransport(ctx context.Context, inner http.RoundTripper, hosts []apiv1 return tlsDispatchTransport{byHost: byHost, inner: inner}, closeSource, nil } -// tlsNeedsSPIFFE reports whether either half of the TLS config uses SPIFFE and so -// requires a Workload API source. -func tlsNeedsSPIFFE(t *apiv1.TLS) bool { - clientSPIFFE := t.ClientAuth != nil && t.ClientAuth.Provider == apiv1.TLSClientProviderX509SVID - serverSPIFFE := t.ServerAuth != nil && t.ServerAuth.SPIFFE != nil - return clientSPIFFE || serverSPIFFE -} - -// buildTLSConfig builds a *tls.Config for a host from its independent server and -// client settings, each of which may be SPIFFE or static. When either side uses -// SPIFFE it draws on src, the shared Workload API source owned by the caller -// (which must be non-nil whenever the config uses SPIFFE). -func buildTLSConfig(t *apiv1.TLS, src *workloadapi.X509Source) (*tls.Config, error) { - clientSPIFFE := t.ClientAuth != nil && t.ClientAuth.Provider == apiv1.TLSClientProviderX509SVID - serverSPIFFE := t.ServerAuth != nil && t.ServerAuth.SPIFFE != nil - - cfg := &tls.Config{MinVersion: tls.VersionTLS12} - - // Server verification: SPIFFE, a custom CA bundle, or (unset) system roots. - switch { - case serverSPIFFE: - authorizer, err := spiffeAuthorizer(src, t.ServerAuth.SPIFFE) - if err != nil { - return nil, fmt.Errorf("serverAuth: spiffe: %w", err) - } - tlsconfig.HookTLSClientConfig(cfg, src, authorizer) - case t.ServerAuth != nil: - pemBytes, err := readPEMSource(t.ServerAuth.FromPath, t.ServerAuth.Value) - if err != nil { - return nil, fmt.Errorf("serverAuth: %w", err) +// tlsConfigFromPath builds a *tls.Config from a directory laid out like a +// mounted Flux certSecretRef: ca.crt supplies the server CA bundle, and/or +// tls.crt with tls.key presents a client certificate. The Flux secrets package +// performs the same key lookup and validation as the Flux API, so error messages +// and accepted layouts stay consistent with the controllers. +func tlsConfigFromPath(ctx context.Context, dir string) (*tls.Config, error) { + data := make(map[string][]byte, 6) + // Read both the standard certificate keys and the legacy Flux keys; the + // secrets package gives the standard keys precedence and warns about legacy + // ones, matching how Flux loads a certSecretRef. + for _, key := range []string{ + secrets.KeyCACert, secrets.KeyTLSCert, secrets.KeyTLSPrivateKey, + secrets.LegacyKeyCACert, secrets.LegacyKeyTLSCert, secrets.LegacyKeyTLSPrivateKey, + } { + b, err := os.ReadFile(filepath.Join(dir, key)) + switch { + case err == nil: + data[key] = b + case errors.Is(err, fs.ErrNotExist): + // The file is optional; the secrets package validates that the + // combination that is present is usable. + default: + return nil, fmt.Errorf("read %s: %w", filepath.Join(dir, key), err) } - pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM(pemBytes) { - return nil, fmt.Errorf("serverAuth: no valid certificates in CA bundle") - } - cfg.RootCAs = pool } - // Client certificate: SPIFFE X.509-SVID or a static cert/key pair. - switch { - case clientSPIFFE: - cfg.GetClientCertificate = tlsconfig.GetClientCertificate(src) - case t.ClientAuth != nil: - certPEM, err := readTLSData(t.ClientAuth.Certificate) - if err != nil { - return nil, fmt.Errorf("clientAuth: certificate: %w", err) - } - keyPEM, err := readPEMSource(t.ClientAuth.Key.FromPath, t.ClientAuth.Key.Value) - if err != nil { - return nil, fmt.Errorf("clientAuth: key: %w", err) - } - cert, err := tls.X509KeyPair(certPEM, keyPEM) - if err != nil { - return nil, fmt.Errorf("clientAuth: load key pair: %w", err) - } - cfg.Certificates = []tls.Certificate{cert} - } - return cfg, nil + return tlsConfigFromData(ctx, dir, data) } -// spiffeAuthorizer turns the configured authorization rule into a tlsconfig -// Authorizer. trustDomain "self" resolves to the client's own trust domain. -func spiffeAuthorizer(src *workloadapi.X509Source, s *apiv1.SPIFFETLS) (tlsconfig.Authorizer, error) { - switch { - case s.AuthorizeAny: - return tlsconfig.AuthorizeAny(), nil - case s.ServerID != "": - id, err := spiffeid.FromString(s.ServerID) - if err != nil { - return nil, fmt.Errorf("serverID: %w", err) +// tlsConfigFromInline builds a *tls.Config from the inline PEM fields, using the +// same key names as a certSecretRef Secret. +func tlsConfigFromInline(ctx context.Context, t apiv1.TLS) (*tls.Config, error) { + data := make(map[string][]byte, 3) + for key, value := range map[string]string{ + secrets.KeyCACert: t.CACert, + secrets.KeyTLSCert: t.TLSCert, + secrets.KeyTLSPrivateKey: t.TLSKey, + } { + if value != "" { + data[key] = []byte(value) } - return tlsconfig.AuthorizeID(id), nil - case s.TrustDomain != "": - var td spiffeid.TrustDomain - if s.TrustDomain == apiv1.TrustDomainSelf { - svid, err := src.GetX509SVID() - if err != nil { - return nil, fmt.Errorf("read own SVID for trustDomain self: %w", err) - } - td = svid.ID.TrustDomain() - } else { - var err error - if td, err = spiffeid.TrustDomainFromString(s.TrustDomain); err != nil { - return nil, fmt.Errorf("trustDomain: %w", err) - } - } - return tlsconfig.AuthorizeMemberOf(td), nil - default: - return nil, fmt.Errorf("no authorizer configured") } + return tlsConfigFromData(ctx, "tls (inline)", data) } -// readTLSData reads a single PEM value from the one configured source. -func readTLSData(d *apiv1.TLSData) ([]byte, error) { - return readPEMSource(d.FromPath, d.Value) +// tlsConfigFromData reuses the Flux validation and TLS assembly by presenting +// the PEM data as an in-memory Secret. name identifies the source in validation +// errors. +func tlsConfigFromData(ctx context.Context, name string, data map[string][]byte) (*tls.Config, error) { + secret := &corev1.Secret{Data: data} + secret.Name = name + cfg, err := secrets.TLSConfigFromSecret(ctx, secret) + if err != nil { + return nil, err + } + cfg.MinVersion = tls.VersionTLS12 + return cfg, nil } -// readPEMSource reads PEM bytes from exactly one of a file path or an inline value. -func readPEMSource(fromPath, value string) ([]byte, error) { - switch { - case fromPath != "": - b, err := os.ReadFile(fromPath) - if err != nil { - return nil, fmt.Errorf("read fromPath: %w", err) +// trimAll trims surrounding whitespace from each entry, matching how Flux +// parses the RFC-0014 annotation lists, and drops entries that become empty. +func trimAll(values []string) []string { + out := make([]string, 0, len(values)) + for _, v := range values { + if v = strings.TrimSpace(v); v != "" { + out = append(out, v) } - return b, nil - case value != "": - return []byte(value), nil - default: - return nil, fmt.Errorf("no source set") } + return out } diff --git a/internal/registryauth/tls_test.go b/internal/registryauth/tls_test.go index eea8f99..e44e81c 100644 --- a/internal/registryauth/tls_test.go +++ b/internal/registryauth/tls_test.go @@ -24,7 +24,7 @@ import ( . "github.com/onsi/gomega" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" ) // testCA is a self-signed CA used to issue server and client certificates for @@ -85,11 +85,22 @@ func (ca *testCA) issue(t *testing.T, cn string, ips []net.IP, uris []*url.URL) pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) } +// certDir writes the given files into a fresh directory and returns its path. +func certDir(t *testing.T, files map[string][]byte) string { + t.Helper() + g := NewWithT(t) + dir := t.TempDir() + for name, data := range files { + g.Expect(os.WriteFile(filepath.Join(dir, name), data, 0o600)).To(Succeed()) + } + return dir +} + // TestNewTLSTransport_MTLSEndToEnd drives a real mTLS handshake through the // dispatching transport: a CA-backed server that requires a client certificate, -// reached with serverAuth (custom CA) + clientAuth (client cert) configured for -// its host. It also checks that the wrong CA and a missing client cert fail, and -// that an unconfigured host falls through to the inner transport. +// reached with a tls.path directory containing ca.crt, tls.crt, and tls.key. It +// also checks that a CA-only directory fails the client-cert requirement, and +// that the wrong CA fails server verification. func TestNewTLSTransport_MTLSEndToEnd(t *testing.T) { g := NewWithT(t) ca := newTestCA(t) @@ -114,16 +125,33 @@ func TestNewTLSTransport_MTLSEndToEnd(t *testing.T) { host := mustHost(t, srv.URL) // e.g. "127.0.0.1:NNNNN" (with port) - t.Run("serverAuth + clientAuth succeeds", func(t *testing.T) { + t.Run("CA and client certificate succeed", func(t *testing.T) { + g := NewWithT(t) + dir := certDir(t, map[string][]byte{ + "ca.crt": ca.certPEM, + "tls.crt": clientCertPEM, + "tls.key": clientKeyPEM, + }) + rt, closeFn, err := NewTLSTransport(context.Background(), http.DefaultTransport, []apiv1.RegistryHost{{ + Host: host, + TLS: &apiv1.TLS{Path: dir}, + }}) + g.Expect(err).ToNot(HaveOccurred()) + defer closeFn() + + resp, err := rt.RoundTrip(mustGet(t, srv.URL)) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(resp.StatusCode).To(Equal(http.StatusNoContent)) + }) + + t.Run("inline CA and client certificate succeed", func(t *testing.T) { g := NewWithT(t) rt, closeFn, err := NewTLSTransport(context.Background(), http.DefaultTransport, []apiv1.RegistryHost{{ Host: host, TLS: &apiv1.TLS{ - ServerAuth: &apiv1.TLSServerAuth{Value: string(ca.certPEM)}, - ClientAuth: &apiv1.TLSClientAuth{ - Certificate: &apiv1.TLSData{Value: string(clientCertPEM)}, - Key: &apiv1.TLSKey{FromPath: writeTemp(t, clientKeyPEM)}, - }, + CACert: string(ca.certPEM), + TLSCert: string(clientCertPEM), + TLSKey: string(clientKeyPEM), }, }}) g.Expect(err).ToNot(HaveOccurred()) @@ -136,9 +164,10 @@ func TestNewTLSTransport_MTLSEndToEnd(t *testing.T) { t.Run("missing client cert is rejected by the server", func(t *testing.T) { g := NewWithT(t) + dir := certDir(t, map[string][]byte{"ca.crt": ca.certPEM}) rt, closeFn, err := NewTLSTransport(context.Background(), http.DefaultTransport, []apiv1.RegistryHost{{ Host: host, - TLS: &apiv1.TLS{ServerAuth: &apiv1.TLSServerAuth{Value: string(ca.certPEM)}}, + TLS: &apiv1.TLS{Path: dir}, }}) g.Expect(err).ToNot(HaveOccurred()) defer closeFn() @@ -150,15 +179,14 @@ func TestNewTLSTransport_MTLSEndToEnd(t *testing.T) { t.Run("wrong CA fails server verification", func(t *testing.T) { g := NewWithT(t) otherCA := newTestCA(t) + dir := certDir(t, map[string][]byte{ + "ca.crt": otherCA.certPEM, + "tls.crt": clientCertPEM, + "tls.key": clientKeyPEM, + }) rt, closeFn, err := NewTLSTransport(context.Background(), http.DefaultTransport, []apiv1.RegistryHost{{ Host: host, - TLS: &apiv1.TLS{ - ServerAuth: &apiv1.TLSServerAuth{Value: string(otherCA.certPEM)}, - ClientAuth: &apiv1.TLSClientAuth{ - Certificate: &apiv1.TLSData{Value: string(clientCertPEM)}, - Key: &apiv1.TLSKey{FromPath: writeTemp(t, clientKeyPEM)}, - }, - }, + TLS: &apiv1.TLS{Path: dir}, }}) g.Expect(err).ToNot(HaveOccurred()) defer closeFn() @@ -183,15 +211,6 @@ func mustGet(t *testing.T, rawURL string) *http.Request { return req } -// writeTemp writes data to a temp file and returns its path. -func writeTemp(t *testing.T, data []byte) string { - t.Helper() - g := NewWithT(t) - p := filepath.Join(t.TempDir(), "data.pem") - g.Expect(os.WriteFile(p, data, 0o600)).To(Succeed()) - return p -} - // makeCertPEM returns a self-signed certificate and its private key, PEM-encoded. func makeCertPEM(t *testing.T) (certPEM, keyPEM []byte) { t.Helper() @@ -215,15 +234,15 @@ func makeCertPEM(t *testing.T) (certPEM, keyPEM []byte) { func TestNeedsTLS(t *testing.T) { g := NewWithT(t) g.Expect(NeedsTLS(nil)).To(BeFalse()) - g.Expect(NeedsTLS([]apiv1.RegistryHost{{Host: "a", Credential: &apiv1.RegistryCredential{Value: "X"}}})).To(BeFalse()) - g.Expect(NeedsTLS([]apiv1.RegistryHost{{Host: "a", TLS: &apiv1.TLS{ServerAuth: &apiv1.TLSServerAuth{Value: "x"}}}})).To(BeTrue()) + g.Expect(NeedsTLS([]apiv1.RegistryHost{{Host: "a", Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, Value: "X"}}})).To(BeFalse()) + g.Expect(NeedsTLS([]apiv1.RegistryHost{{Host: "a", TLS: &apiv1.TLS{Path: "/certs"}}})).To(BeTrue()) } func TestNewTLSTransport_NoTLSReturnsInner(t *testing.T) { g := NewWithT(t) inner := http.DefaultTransport rt, closeFn, err := NewTLSTransport(context.Background(), inner, []apiv1.RegistryHost{ - {Host: "a", Credential: &apiv1.RegistryCredential{Value: "X"}}, + {Host: "a", Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, Value: "X"}}, }) g.Expect(err).ToNot(HaveOccurred()) g.Expect(rt).To(BeIdenticalTo(inner)) @@ -235,13 +254,14 @@ func TestNewTLSTransport_StaticDispatch(t *testing.T) { caPEM, _ := makeCertPEM(t) certPEM, keyPEM := makeCertPEM(t) + caDir := certDir(t, map[string][]byte{"ca.crt": caPEM}) + mtlsDir := certDir(t, map[string][]byte{"tls.crt": certPEM, "tls.key": keyPEM}) + hosts := []apiv1.RegistryHost{ - {Host: "ca.example", TLS: &apiv1.TLS{ServerAuth: &apiv1.TLSServerAuth{Value: string(caPEM)}}}, - {Host: "mtls.example", TLS: &apiv1.TLS{ClientAuth: &apiv1.TLSClientAuth{ - Certificate: &apiv1.TLSData{Value: string(certPEM)}, - Key: &apiv1.TLSKey{FromPath: writeTemp(t, keyPEM)}, - }}}, - {Host: "plain.example", Credential: &apiv1.RegistryCredential{Value: "X"}}, + {Host: "ca.example", TLS: &apiv1.TLS{Path: caDir}}, + {Host: "mtls.example", TLS: &apiv1.TLS{Path: mtlsDir}}, + {Host: "inline.example", TLS: &apiv1.TLS{CACert: string(caPEM)}}, + {Host: "plain.example", Credential: &apiv1.RegistryCredential{Type: apiv1.CredentialTypeJWT, Value: "X"}}, } rt, closeFn, err := NewTLSTransport(context.Background(), http.DefaultTransport, hosts) g.Expect(err).ToNot(HaveOccurred()) @@ -251,54 +271,100 @@ func TestNewTLSTransport_StaticDispatch(t *testing.T) { g.Expect(ok).To(BeTrue()) g.Expect(disp.byHost).To(HaveKey("ca.example")) g.Expect(disp.byHost).To(HaveKey("mtls.example")) + g.Expect(disp.byHost).To(HaveKey("inline.example")) g.Expect(disp.byHost).ToNot(HaveKey("plain.example")) // no TLS → falls through to inner caRT := disp.byHost["ca.example"].(*http.Transport) g.Expect(caRT.TLSClientConfig.RootCAs).ToNot(BeNil()) mtlsRT := disp.byHost["mtls.example"].(*http.Transport) g.Expect(mtlsRT.TLSClientConfig.Certificates).To(HaveLen(1)) + inlineRT := disp.byHost["inline.example"].(*http.Transport) + g.Expect(inlineRT.TLSClientConfig.RootCAs).ToNot(BeNil()) } -func TestNewTLSTransport_BadCABundle(t *testing.T) { - g := NewWithT(t) - _, _, err := NewTLSTransport(context.Background(), http.DefaultTransport, []apiv1.RegistryHost{ - {Host: "ca.example", TLS: &apiv1.TLS{ServerAuth: &apiv1.TLSServerAuth{Value: "not a pem"}}}, - }) - g.Expect(err).To(MatchError(ContainSubstring("no valid certificates in CA bundle"))) -} - -func TestReadTLSData(t *testing.T) { +// TestNewTLSTransport_LegacyKeys proves the directory loader accepts the legacy +// Flux certificate key names as fallbacks, like a certSecretRef Secret. +func TestNewTLSTransport_LegacyKeys(t *testing.T) { g := NewWithT(t) + caPEM, _ := makeCertPEM(t) + dir := certDir(t, map[string][]byte{"caFile": caPEM}) - // value - b, err := readTLSData(&apiv1.TLSData{Value: "inline"}) + rt, closeFn, err := NewTLSTransport(context.Background(), http.DefaultTransport, []apiv1.RegistryHost{ + {Host: "ca.example", TLS: &apiv1.TLS{Path: dir}}, + }) g.Expect(err).ToNot(HaveOccurred()) - g.Expect(string(b)).To(Equal("inline")) + defer closeFn() - // fromPath - p := filepath.Join(t.TempDir(), "f") - g.Expect(os.WriteFile(p, []byte("filedata"), 0o600)).To(Succeed()) - b, err = readTLSData(&apiv1.TLSData{FromPath: p}) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(string(b)).To(Equal("filedata")) + disp, ok := rt.(tlsDispatchTransport) + g.Expect(ok).To(BeTrue()) + caRT := disp.byHost["ca.example"].(*http.Transport) + g.Expect(caRT.TLSClientConfig.RootCAs).ToNot(BeNil()) } -func TestSpiffeAuthorizer(t *testing.T) { +// TestNewTLSTransport_MissingTLSConfig guards unvalidated callers: a TLS block +// without path, inline certificates, or spiffe must not fall back to reading the working +// directory. +func TestNewTLSTransport_MissingTLSConfig(t *testing.T) { g := NewWithT(t) + _, _, err := NewTLSTransport(context.Background(), http.DefaultTransport, []apiv1.RegistryHost{ + {Host: "empty.example", TLS: &apiv1.TLS{}}, + }) + g.Expect(err).To(MatchError(ContainSubstring("one of path, inline certificates, or spiffe is required"))) +} - // AuthorizeAny and ServerID and a concrete trustDomain do not need a source. - a, err := spiffeAuthorizer(nil, &apiv1.SPIFFETLS{AuthorizeAny: true}) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(a).ToNot(BeNil()) +func TestNewTLSTransport_BadCABundle(t *testing.T) { + g := NewWithT(t) + dir := certDir(t, map[string][]byte{"ca.crt": []byte("not a pem")}) + _, _, err := NewTLSTransport(context.Background(), http.DefaultTransport, []apiv1.RegistryHost{ + {Host: "ca.example", TLS: &apiv1.TLS{Path: dir}}, + }) + g.Expect(err).To(MatchError(ContainSubstring("failed to parse CA certificate"))) +} - a, err = spiffeAuthorizer(nil, &apiv1.SPIFFETLS{ServerID: "spiffe://example.org/registry"}) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(a).ToNot(BeNil()) +func TestNewTLSTransport_BadInlineData(t *testing.T) { + g := NewWithT(t) + _, _, err := NewTLSTransport(context.Background(), http.DefaultTransport, []apiv1.RegistryHost{ + {Host: "ca.example", TLS: &apiv1.TLS{CACert: "not a pem"}}, + }) + g.Expect(err).To(MatchError(ContainSubstring("failed to parse CA certificate"))) +} - a, err = spiffeAuthorizer(nil, &apiv1.SPIFFETLS{TrustDomain: "example.org"}) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(a).ToNot(BeNil()) +func TestNewTLSTransport_PathValidation(t *testing.T) { + certPEM, keyPEM := makeCertPEM(t) - _, err = spiffeAuthorizer(nil, &apiv1.SPIFFETLS{ServerID: "not-a-spiffe-id"}) - g.Expect(err).To(MatchError(ContainSubstring("serverID"))) + for _, tc := range []struct { + name string + files map[string][]byte + errText string + }{ + { + name: "empty directory", + files: map[string][]byte{}, + errText: "must contain either 'ca.crt' or both 'tls.crt' and 'tls.key'", + }, + { + name: "cert without key", + files: map[string][]byte{"tls.crt": certPEM}, + errText: "contains 'tls.crt' but missing 'tls.key'", + }, + { + name: "key without cert", + files: map[string][]byte{"tls.key": keyPEM}, + errText: "contains 'tls.key' but missing 'tls.crt'", + }, + { + name: "invalid key pair", + files: map[string][]byte{"tls.crt": certPEM, "tls.key": []byte("not a key")}, + errText: "failed to parse TLS certificate and key", + }, + } { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + dir := certDir(t, tc.files) + _, _, err := NewTLSTransport(context.Background(), http.DefaultTransport, []apiv1.RegistryHost{ + {Host: "ca.example", TLS: &apiv1.TLS{Path: dir}}, + }) + g.Expect(err).To(MatchError(ContainSubstring(tc.errText))) + }) + } } diff --git a/internal/selector/selector.go b/internal/selector/selector.go index 6135161..5978253 100644 --- a/internal/selector/selector.go +++ b/internal/selector/selector.go @@ -11,7 +11,7 @@ import ( "github.com/Masterminds/semver/v3" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" ) // Pipeline: diff --git a/internal/selector/selector_test.go b/internal/selector/selector_test.go index ed94cb2..e963fdf 100644 --- a/internal/selector/selector_test.go +++ b/internal/selector/selector_test.go @@ -8,7 +8,7 @@ import ( . "github.com/onsi/gomega" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" ) func TestSelect_SemverDefault(t *testing.T) { diff --git a/internal/sync/entry.go b/internal/sync/entry.go index e7494aa..8f8d01a 100644 --- a/internal/sync/entry.go +++ b/internal/sync/entry.go @@ -6,7 +6,7 @@ package sync import ( "context" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" ) // EntryMirror is the consumer-side interface the Runner uses to drive any diff --git a/internal/sync/report.go b/internal/sync/report.go index c903c50..ef20dca 100644 --- a/internal/sync/report.go +++ b/internal/sync/report.go @@ -12,7 +12,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" ) // NewReport assembles a Report from a Result. The caller owns the reporter diff --git a/internal/sync/runner.go b/internal/sync/runner.go index 5f5de1f..af925a2 100644 --- a/internal/sync/runner.go +++ b/internal/sync/runner.go @@ -4,7 +4,7 @@ package sync import ( - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" "context" "errors" diff --git a/internal/sync/runner_test.go b/internal/sync/runner_test.go index dd4c81f..ac26c1a 100644 --- a/internal/sync/runner_test.go +++ b/internal/sync/runner_test.go @@ -14,7 +14,7 @@ import ( "testing" "time" - . "github.com/fluxcd/flux-mirror/api/v1beta1" + . "github.com/fluxcd/flux-mirror/api/v1beta2" . "github.com/onsi/gomega" ) diff --git a/internal/sync/schema_test.go b/internal/sync/schema_test.go index d59371a..a0e07aa 100644 --- a/internal/sync/schema_test.go +++ b/internal/sync/schema_test.go @@ -13,7 +13,7 @@ import ( . "github.com/onsi/gomega" "github.com/santhosh-tekuri/jsonschema/v6" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" ) // validateReportSchema compiles the generated report JSON Schema and validates @@ -26,7 +26,7 @@ func validateReportSchema(t *testing.T, raw string) { var doc any g.Expect(json.Unmarshal([]byte(raw), &doc)).To(Succeed()) - abs, err := filepath.Abs(filepath.Join("..", "..", "docs", "report-v1beta1.json")) + abs, err := filepath.Abs(filepath.Join("..", "..", "docs", "report-v1beta2.json")) g.Expect(err).ToNot(HaveOccurred()) compiler := jsonschema.NewCompiler() diff --git a/internal/sync/summary.go b/internal/sync/summary.go index 1e63079..b963559 100644 --- a/internal/sync/summary.go +++ b/internal/sync/summary.go @@ -10,7 +10,7 @@ import ( "strings" "time" - apiv1 "github.com/fluxcd/flux-mirror/api/v1beta1" + apiv1 "github.com/fluxcd/flux-mirror/api/v1beta2" ) // Result is the aggregate output of Runner.Run. Duration is the total wall diff --git a/test/podinfo.yaml b/test/podinfo.yaml index c83081e..8b27288 100644 --- a/test/podinfo.yaml +++ b/test/podinfo.yaml @@ -1,4 +1,4 @@ -apiVersion: mirror.plugin.fluxcd.io/v1beta1 +apiVersion: mirror.plugin.fluxcd.io/v1beta2 kind: Config charts: - name: podinfo