Files
cozystack/internal/backupcontroller/credentials_projector.go
2026-05-28 13:49:59 +04:00

443 lines
18 KiB
Go

package backupcontroller
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)
// CredentialsProjectionRequeue is the backoff between reconcile attempts
// when the source credentials Secret hasn't propagated yet. Short enough
// that the first BackupJob after a fresh install does not stall, long
// enough that a permanently mis-configured source does not flood the
// controller. Shared between BackupJob and RestoreJob reconcilers so a
// change in one half cannot drift from the other.
const CredentialsProjectionRequeue = 30 * time.Second
// managedByLabel marks Secrets the credentials projector owns. The
// projector refuses to update any pre-existing Secret that lacks this
// label so a tenant cannot accidentally have a manually-created or
// restored cozy-backups-creds clobbered on the next BackupJob reconcile.
const (
managedByLabel = "apps.cozystack.io/managed-by"
managedByValue = "cozystack-backups"
)
// ProjectionError is a typed error so callers (reconcilers) can decide
// whether projection failure is transient (source not yet populated by
// the bucket controller) or terminal (target Secret owned by someone
// else and refuses to be overwritten).
type ProjectionError struct {
Reason string
Message string
}
func (e *ProjectionError) Error() string { return e.Message }
// Reason identifiers carried on ProjectionError. Callers compare against
// these to drive Condition reasons / metrics labels.
const (
ReasonSourceMissing = "SourceSecretMissing"
ReasonSourceMalformed = "SourceSecretMalformed"
ReasonTargetNotOwned = "TargetSecretNotOwned"
ReasonAPIError = "APIError"
)
// IsTransient reports whether a projection error is expected to clear on
// its own (source Secret not yet propagated, apiserver hiccup) or whether
// it surfaces an operator-visible misconfiguration that needs a human.
// Callers use this to decide between requeue and terminal Failed.
func IsTransient(err error) bool {
var perr *ProjectionError
if !errors.As(err, &perr) {
return false
}
switch perr.Reason {
case ReasonSourceMissing, ReasonAPIError:
return true
}
return false
}
// BackupCredentialsConfig describes the platform-managed source Secret
// produced by the cozy-backups Bucket, the per-tenant projection target
// Secret name, and the bucket coordinates (endpoint used to format the
// FDB blob_credentials account-host key, region carried verbatim for
// drivers like clickhouse-backup that wire it through env, forcePathStyle
// projected as a flat key so the chart-emitted ClickHouse sidecar can read
// a platform value it cannot see directly). Values are wired from
// environment variables in main() and the deployment chart populates them
// from .Values.backupStorage.
type BackupCredentialsConfig struct {
SourceNamespace string
SourceSecretName string
TargetSecretName string
Endpoint string
Region string
// ForcePathStyle is the string "true"/"false" from
// backupStorage.forcePathStyle, projected into the credentials Secret
// under the forcePathStyle key. The app charts cannot read
// backupStorage.* directly, so the ClickHouse sidecar consumes it via
// an optional secretKeyRef as S3_FORCE_PATH_STYLE.
ForcePathStyle string
}
// IsEnabled reports whether credentials projection is configured. When
// disabled (any required field empty), reconcilers skip projection —
// strategies that need cozy-backups-creds then fail validation downstream,
// which is the intended behaviour on clusters still on the legacy
// chart-managed flow.
func (c BackupCredentialsConfig) IsEnabled() bool {
return c.SourceNamespace != "" && c.SourceSecretName != "" && c.TargetSecretName != ""
}
// ProjectBackupCredentials copies the platform-managed S3 credentials Secret
// from the configured source namespace to a tenant namespace, formatting
// the data so every default Strategy CR can consume the same Secret:
//
// - AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY — CNPG, MariaDB, Etcd.
// - accessKey / secretKey + bucketName / endpoint / region — ClickHouse
// sidecar.
// - cloud — Velero credentials file ([default] section).
// - blob_credentials.json — FoundationDB backup_agent.
//
// The source Secret is read in two formats:
//
// 1. Human-friendly flat keys (accessKey/secretKey/endpoint/bucketName),
// produced by packages/system/bucket/templates/user-credentials.yaml.
// 2. Raw COSI Secret with a single "BucketInfo" JSON blob, produced by
// the COSI driver before the user-credentials renderer runs (or when
// the cluster wires an external S3 Secret manually). Parsed best-
// effort; cluster admins can override by writing flat keys.
//
// The projection is idempotent and safe to invoke on every reconcile
// pass. Pre-existing target Secrets without the managed-by label are
// refused — see managedByLabel.
//
// SECURITY: the projected Secret deliberately omits the
// internal.cozystack.io/tenantresource label, so the lineage-controller
// webhook does NOT promote it to a TenantSecret view — tenants without
// core/v1.Secret verbs (default cozy:tenant:base) cannot read the keys.
// Pods of operators that consume the Secret mount it via kubelet, which
// bypasses tenant RBAC.
func ProjectBackupCredentials(ctx context.Context, c client.Client, cfg BackupCredentialsConfig, targetNamespace string) error {
if !cfg.IsEnabled() {
return nil
}
src := &corev1.Secret{}
if err := c.Get(ctx, types.NamespacedName{Namespace: cfg.SourceNamespace, Name: cfg.SourceSecretName}, src); err != nil {
if apierrors.IsNotFound(err) {
return &ProjectionError{
Reason: ReasonSourceMissing,
Message: fmt.Sprintf("source credentials Secret %s/%s not found", cfg.SourceNamespace, cfg.SourceSecretName),
}
}
return &ProjectionError{
Reason: ReasonAPIError,
Message: fmt.Sprintf("read source credentials Secret: %v", err),
}
}
creds, err := parseSourceSecret(src)
if err != nil {
return err
}
if creds.endpoint == "" {
// Scheme-strip the env-var fallback to match the source-Secret path
// (parseSourceSecret strips there). The projected `endpoint` key is
// consumed verbatim as S3_ENDPOINT by the ClickHouse sidecar, which
// expects a bare host:port — see docs/operations/backup-classes.md.
creds.endpoint = stripScheme(cfg.Endpoint)
}
if creds.endpoint == "" {
// Both source Secret and BACKUP_STORAGE_ENDPOINT are empty. Fail
// loud rather than projecting a half-broken Secret (Velero's BSL
// s3Url="" silently rejects Backups, FDB blob_credentials cannot
// be rendered without a host). Surfaced as terminal misconfig
// so the admin gets a clear signal at first BackupJob instead of
// debugging downstream "object not found" errors.
return &ProjectionError{
Reason: ReasonSourceMalformed,
Message: fmt.Sprintf("endpoint is empty in both source Secret %s/%s and BACKUP_STORAGE_ENDPOINT", cfg.SourceNamespace, cfg.SourceSecretName),
}
}
if creds.region == "" {
creds.region = cfg.Region
}
cloud := buildVeleroCredentialsFile(creds.accessKey, creds.secretKey)
blob, err := buildFDBBlobCredentials(creds.endpoint, creds.accessKey, creds.secretKey)
if err != nil {
return &ProjectionError{
Reason: ReasonSourceMalformed,
Message: fmt.Sprintf("build blob_credentials.json: %v", err),
}
}
// Ownership guard lives INSIDE the CreateOrUpdate mutator (rather
// than in a separate Get preflight) for fewer round trips: the
// mutator runs against the same object CreateOrUpdate just
// re-fetched, and returning an error from the closure short-circuits
// the Update. There is still a small window where a racing actor's
// Create-of-an-unlabelled-Secret can land between CreateOrUpdate's
// internal Get and the Create call — in that case the apiserver
// returns AlreadyExists, controller-runtime surfaces the error
// without re-invoking the mutator, the projection fails this round,
// and the next reconcile cycle sees the existing unlabelled Secret
// and hits the guard. Safe modulo one extra apiserver round trip on
// concurrent racing Create; no stomp.
//
// Surfaced error: when the closure returns a ProjectionError, the
// outer CreateOrUpdate wraps it as a plain error — IsTransient unwraps
// via errors.As, so callers still classify ReasonTargetNotOwned as
// terminal.
target := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: targetNamespace,
Name: cfg.TargetSecretName,
},
}
var ownershipErr *ProjectionError
if _, err := controllerutil.CreateOrUpdate(ctx, c, target, func() error {
// On Update path, target has been re-fetched from the cluster
// (ResourceVersion populated). On Create path, ResourceVersion is
// empty — there is no existing object to own.
isExisting := target.ResourceVersion != ""
if isExisting && target.Labels[managedByLabel] != managedByValue {
ownershipErr = &ProjectionError{
Reason: ReasonTargetNotOwned,
Message: fmt.Sprintf(
"target Secret %s/%s exists without label %s=%s; refusing to overwrite",
targetNamespace, cfg.TargetSecretName, managedByLabel, managedByValue),
}
return ownershipErr
}
if target.Labels == nil {
target.Labels = map[string]string{}
}
target.Labels[managedByLabel] = managedByValue
target.Type = corev1.SecretTypeOpaque
if target.Data == nil {
target.Data = map[string][]byte{}
}
target.Data["AWS_ACCESS_KEY_ID"] = []byte(creds.accessKey)
target.Data["AWS_SECRET_ACCESS_KEY"] = []byte(creds.secretKey)
// Mirror under the legacy bucket-controller key names so
// chart-emitted sidecars (clickhouse-backup) can consume the
// same Secret without a separate field map.
target.Data["accessKey"] = []byte(creds.accessKey)
target.Data["secretKey"] = []byte(creds.secretKey)
target.Data["cloud"] = []byte(cloud)
target.Data["blob_credentials.json"] = blob
// Always overwrite endpoint/bucket/region (delete if empty in
// source) so a re-projection cannot leave stale values lingering
// after the bucket-controller rotates the source. Consumers
// (ClickHouse sidecar via secretKeyRef) must see exactly what the
// source carries, not a half-stale Secret.
setOrDelete(target.Data, "endpoint", creds.endpoint)
setOrDelete(target.Data, "bucketName", creds.bucket)
setOrDelete(target.Data, "region", creds.region)
// forcePathStyle comes from the platform config (backupStorage.
// forcePathStyle), not the source Secret: the ClickHouse sidecar
// reads it as S3_FORCE_PATH_STYLE because the app chart cannot see
// backupStorage.* directly.
setOrDelete(target.Data, "forcePathStyle", cfg.ForcePathStyle)
return nil
}); err != nil {
// If the mutator refused to overwrite an unowned Secret, surface
// the typed ProjectionError directly (callers route on Reason).
if ownershipErr != nil {
return ownershipErr
}
return &ProjectionError{
Reason: ReasonAPIError,
Message: fmt.Sprintf("project credentials Secret to %s/%s: %v", targetNamespace, cfg.TargetSecretName, err),
}
}
return nil
}
// setOrDelete writes value under key when value is non-empty, otherwise
// deletes the key from the map. Used to keep the projected Secret
// strictly in sync with the source — a re-projection after the source
// loses a field must not leave the previous value behind.
func setOrDelete(m map[string][]byte, key, value string) {
if value == "" {
delete(m, key)
return
}
m[key] = []byte(value)
}
// parsedCreds is the in-memory shape both source formats are normalised
// into before the projector renders the per-driver representations.
type parsedCreds struct {
accessKey string
secretKey string
endpoint string
bucket string
region string
}
// parseSourceSecret accepts either the human-friendly flat-key Secret
// produced by packages/system/bucket/templates/user-credentials.yaml
// (accessKey/secretKey/endpoint/bucketName) or the raw COSI Secret
// containing a single BucketInfo JSON document, and returns the
// normalised credentials. Per-field fallback: each missing flat key
// falls back to the matching BucketInfo field when both are present,
// rather than gating the whole JSON parse on accessKey/secretKey being
// absent. The endpoint is stripped of any scheme prefix to match the
// host:port shape downstream callers expect.
func parseSourceSecret(src *corev1.Secret) (parsedCreds, error) {
out := parsedCreds{
accessKey: string(src.Data["accessKey"]),
secretKey: string(src.Data["secretKey"]),
endpoint: string(src.Data["endpoint"]),
bucket: string(src.Data["bucketName"]),
region: string(src.Data["region"]),
}
// COSI fallback: BucketInfo JSON blob. Always consulted (when
// present) so a flat-key Secret that omits bucket/endpoint/region
// can still pull those from the raw COSI document — the flat-key
// renderer in packages/system/bucket strips region for example, and
// callers should not have to know which fields are populated.
if raw, ok := src.Data["BucketInfo"]; ok && len(raw) > 0 {
var info struct {
Spec struct {
BucketName string `json:"bucketName"`
SecretS3 struct {
AccessKeyID string `json:"accessKeyID"`
AccessSecretKey string `json:"accessSecretKey"`
Endpoint string `json:"endpoint"`
Region string `json:"region"`
} `json:"secretS3"`
} `json:"spec"`
}
if err := json.Unmarshal(raw, &info); err != nil {
// BucketInfo is a best-effort fallback for fields the flat-key
// renderer doesn't emit (e.g. region). If the flat keys are
// already authoritative for access/secret, an unrelated stray
// BucketInfo blob (e.g. left over from a copy-paste of an old
// COSI Secret) should not terminally fail the projection.
// Hard-fail only when BOTH the flat keys are missing AND the
// JSON cannot be parsed — that's a Secret with no usable
// credentials material.
if out.accessKey == "" || out.secretKey == "" {
return parsedCreds{}, &ProjectionError{
Reason: ReasonSourceMalformed,
Message: fmt.Sprintf("parse BucketInfo: %v", err),
}
}
// Soft-skip: flat keys win, BucketInfo not consulted further.
return finaliseParsedCreds(out, src)
}
if out.accessKey == "" {
out.accessKey = info.Spec.SecretS3.AccessKeyID
}
if out.secretKey == "" {
out.secretKey = info.Spec.SecretS3.AccessSecretKey
}
if out.endpoint == "" {
out.endpoint = info.Spec.SecretS3.Endpoint
}
if out.region == "" {
out.region = info.Spec.SecretS3.Region
}
if out.bucket == "" {
out.bucket = info.Spec.BucketName
}
}
return finaliseParsedCreds(out, src)
}
// finaliseParsedCreds runs the post-parse validation + scheme stripping
// path used by both the happy path and the soft-skip-on-garbled-JSON
// path. Returns the typed ReasonSourceMalformed when access/secret keys
// did not land from either source.
func finaliseParsedCreds(out parsedCreds, src *corev1.Secret) (parsedCreds, error) {
if out.accessKey == "" {
return parsedCreds{}, &ProjectionError{
Reason: ReasonSourceMalformed,
Message: fmt.Sprintf("source credentials Secret %s/%s missing accessKey", src.Namespace, src.Name),
}
}
if out.secretKey == "" {
return parsedCreds{}, &ProjectionError{
Reason: ReasonSourceMalformed,
Message: fmt.Sprintf("source credentials Secret %s/%s missing secretKey", src.Namespace, src.Name),
}
}
out.endpoint = stripScheme(out.endpoint)
return out, nil
}
// buildVeleroCredentialsFile renders the AWS credentials file Velero
// expects when reading from a Secret-backed BackupStorageLocation. The
// format matches `aws configure`'s ~/.aws/credentials.
func buildVeleroCredentialsFile(accessKey, secretKey string) string {
return fmt.Sprintf("[default]\naws_access_key_id=%s\naws_secret_access_key=%s\n", accessKey, secretKey)
}
// buildFDBBlobCredentials renders the JSON file the FoundationDB
// backup_agent reads when invoked with --blob_credentials=<path>. The
// account key is the bare host:port (no scheme); the FoundationDB
// strategy CR's accountName field carries the same value so they line up.
//
// Returns a typed error when endpoint is empty so the caller can surface
// a clear reason rather than write an account keyed by "".
func buildFDBBlobCredentials(endpoint, accessKey, secretKey string) ([]byte, error) {
host := stripScheme(endpoint)
if host == "" {
return nil, fmt.Errorf("endpoint is empty; cannot build blob_credentials.json")
}
doc := map[string]any{
"accounts": map[string]any{
host: map[string]string{
"api_key": accessKey,
"secret": secretKey,
},
},
}
return json.Marshal(doc)
}
// stripScheme returns the endpoint stripped of any leading scheme prefix
// (http:// or https://) and preserves the path portion if present. The
// resulting form is what `host[:port][/path]` callers (MariaDB strategy,
// FDB blob_credentials.json account host) expect.
//
// Implementation note: url.Parse on a bare `host:port` mis-parses port
// as the URL scheme (no error, Scheme="host", Host=""). We only treat
// url.Parse output as authoritative when an explicit scheme prefix is
// present; otherwise the input is assumed to already be in host[:port]
// form and returned as-is.
func stripScheme(endpoint string) string {
if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
return endpoint
}
u, err := url.Parse(endpoint)
if err != nil || u.Host == "" {
// Fallback: trim manually. Preserve path component intact.
host := strings.TrimPrefix(endpoint, "http://")
host = strings.TrimPrefix(host, "https://")
return host
}
if u.Path != "" && u.Path != "/" {
return u.Host + u.Path
}
return u.Host
}