Files
cozystack/internal/migrationcontroller/credentials_projector.go
Alexey Artamonov ce74fdeea9 docs(backups): describe restored credentials and pre-upgrade residue
Say that a restored copy converges its application passwords shortly
after recovery, that MariaDB backups leave out the grant table, and that
dropping the password field does not rewrite copies made before the
upgrade: old Backup snapshots and Helm revisions still hold the password
that stays live. The credentials projector comment counts only the
engines that still take a password from values.

Signed-off-by: Alexey Artamonov <aleksei.artamonov@aenix.io>
2026-09-24 18:11:07 +03:00

140 lines
4.7 KiB
Go

// SPDX-License-Identifier: Apache-2.0
package migrationcontroller
import (
"context"
"fmt"
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"
migrationv1alpha1 "github.com/cozystack/cozystack/api/migration/v1alpha1"
)
// ProjectionError is a typed error so the reconciler can tell a misconfiguration
// that needs a human (a Secret owned by someone else) from an API hiccup that
// will clear on its own.
type ProjectionError struct {
Reason string
Message string
}
func (e *ProjectionError) Error() string { return e.Message }
// projectCredentials writes a Source's credentials into the Secret shape
// Forklift's Provider consumes, and returns the Secret's name.
//
// Tenants cannot create Secrets in Cozystack (core.cozystack.io/tenantsecrets is
// read-only at every access level), so a credentialsSecretRef field would be
// unsatisfiable for the audience this API is for: the credentials arrive on the
// Source's spec and the controller materializes them. The exposure class is the
// same one the managed databases that still take a values-supplied credential
// carry (the postgres and mariadb apps take no `users[].password`; the other
// engines still do).
//
// The projected Secret is labelled as ours and the projector refuses to write a
// pre-existing Secret that is not, so a name collision can never clobber a
// tenant's own object — it surfaces as a terminal condition instead.
func projectCredentials(ctx context.Context, c client.Client, src *migrationv1alpha1.VMImportSource) (string, error) {
return projectSecret(ctx, c, src, credentialsSecretName(src.Name), src.Spec.Credentials)
}
// projectSecret materializes one credential set into a Secret of the given
// name. Shared by the provider connection and by each host override, which
// carries its own ESXi account for the same reason: the engine authenticates
// that connection on the host itself.
func projectSecret(
ctx context.Context,
c client.Client,
src *migrationv1alpha1.VMImportSource,
name string,
creds migrationv1alpha1.ProviderCredentials,
) (string, error) {
desired := map[string][]byte{
"user": []byte(creds.Username),
"password": []byte(creds.Password),
}
// The engine's vSphere validation wants `user`, `password`, and then either
// a `cacert` or `insecureSkipVerify` parseable as a boolean. Without one of
// the latter two it marks the provider's secret invalid and never attempts
// a connection — a state that looks like a credentials problem and is not.
// Verified against the vendored engine's own validation on a live cluster.
if creds.CACert != "" {
desired["cacert"] = []byte(creds.CACert)
}
if creds.InsecureSkipVerify {
desired["insecureSkipVerify"] = []byte("true")
}
existing := &corev1.Secret{}
err := c.Get(ctx, types.NamespacedName{Namespace: src.Namespace, Name: name}, existing)
switch {
case apierrors.IsNotFound(err):
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: src.Namespace,
Labels: map[string]string{
migrationv1alpha1.ManagedByLabel: migrationv1alpha1.ManagedByValue,
},
// Forklift matches a provider's Secret by this label when it
// re-reads credentials after a Provider edit.
Annotations: map[string]string{
"createdForProviderType": string(src.Spec.Type),
},
OwnerReferences: []metav1.OwnerReference{
ownerRef(migrationv1alpha1.GroupVersion.WithKind("VMImportSource"), src.Name, src.UID),
},
},
Type: corev1.SecretTypeOpaque,
Data: desired,
}
if err := c.Create(ctx, secret); err != nil {
if apierrors.IsAlreadyExists(err) {
// Lost a race with another reconcile; the next pass adopts it.
return name, &ProjectionError{Reason: migrationv1alpha1.ReasonCredentialsMissing, Message: "credentials Secret is being created"}
}
return "", err
}
return name, nil
case err != nil:
return "", err
}
if existing.Labels[migrationv1alpha1.ManagedByLabel] != migrationv1alpha1.ManagedByValue {
return "", &ProjectionError{
Reason: migrationv1alpha1.ReasonCredentialsMissing,
Message: fmt.Sprintf(
"Secret %q already exists in this namespace and is not managed by the migration controller; "+
"rename the VMImportSource or remove the Secret", name),
}
}
if secretDataEqual(existing.Data, desired) {
return name, nil
}
existing.Data = desired
if err := c.Update(ctx, existing); err != nil {
return "", err
}
return name, nil
}
func secretDataEqual(a, b map[string][]byte) bool {
if len(a) != len(b) {
return false
}
for k, av := range a {
bv, ok := b[k]
if !ok || string(av) != string(bv) {
return false
}
}
return true
}