1
0
mirror of https://github.com/quay/quay.git synced 2026-10-23 09:49:06 +03:00
Files
quay/internal/migrate/postgres_source.go
jbpratt e7b1c43dee PROJQUAY-13328: fix(migrate): roll back the source registry when a migration fails (#7760)
* PROJQUAY-13328: fix(migrate): roll back the source registry when a migration fails

On the SQLite path a failure after the source services stopped returned
with the old registry still down. Restart the stopped source services on
any failure after the stop, remove a target install only when this run
created it, and keep the migration marker so a rerun resumes the copy.
A marker-bearing target now counts as unfinished, and a successful
-skip-install run clears the marker.

QUAY_MIGRATE_FAIL_AT injects a failure at after-convert (PostgreSQL),
after-copy, after-schema or at-install; the e2e-migrate jobs use it to
check rollback and retry on both paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PROJQUAY-13328: fix(migrate): remove persisted key files on rollback

A root 0600 quay.pem left in the old OMR's config dir is unreadable to
quay-app, so its config validator drops every cert and the OMR exits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PROJQUAY-13328: test(migrate): re-capture source JWT key after rollback restarts

Each injected-failure rollback restarts the old OMR, which comes back with a
new registry JWT key, so the pre-loop fingerprint no longer matches the source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PROJQUAY-13328: fix(migrate): record config material path before persisting it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PROJQUAY-13328: fix(migrate): bound each rollback step with its own timeout

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PROJQUAY-13328: fix(migrate): remove recovered key files on pre-stop failure

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PROJQUAY-13328: fix(migrate): refuse to resume over a completed install

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* PROJQUAY-13328: fix(migrate): report a failed migration-marker removal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:52:50 -05:00

281 lines
8.4 KiB
Go

package migrate
import (
"context"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/quay/quay/internal/config"
"github.com/quay/quay/internal/dal/dbcore"
"github.com/quay/quay/internal/installer"
postgrescopy "github.com/quay/quay/internal/migrate/postgres"
)
const partialDatabaseName = "quay.db.partial"
func (m *Migrator) runPostgresMigration(ctx context.Context) (retErr error) {
if err := m.validatePostgresFlags(); err != nil {
return err
}
if err := m.validatePostgresBeforeStop(ctx); err != nil {
return errors.Join(fmt.Errorf("validate: %w", err), m.removePersistedConfigMaterial())
}
retrySafe := true
defer func() {
if retErr == nil {
return
}
if cleanupErr := m.removePostgresMigrationArtifacts(retrySafe); cleanupErr != nil {
retErr = errors.Join(retErr, fmt.Errorf("remove failed migration artifacts: %w", cleanupErr))
}
retErr = withSourceRollback(retErr, m.rollbackPostgresSource(context.Background()))
}()
if err := m.stopPostgresApp(ctx); err != nil {
return fmt.Errorf("stop old OMR app: %w", err)
}
if err := os.MkdirAll(m.DataDir, 0o750); err != nil {
return fmt.Errorf("create target directory: %w", err)
}
if err := os.WriteFile(filepath.Join(m.DataDir, markerFile), []byte("migration in progress\n"), 0o600); err != nil {
return fmt.Errorf("write migration marker: %w", err)
}
if err := m.runPostgresCopyInNamespace(ctx); err != nil {
return fmt.Errorf("convert PostgreSQL source: %w", err)
}
m.Source.DBPath = m.postgresPartialDBPath()
if err := m.validateSourceDatabase(ctx); err != nil {
return fmt.Errorf("validate converted source: %w", err)
}
if err := failAt("after-convert"); err != nil {
return err
}
// Once broad target publication begins, retain the marker so a later failure
// remains fail-closed rather than being mistaken for a clean retry.
retrySafe = false
if err := m.copyAndUpgrade(ctx); err != nil {
return err
}
if err := m.stopPostgresRemainingServices(ctx); err != nil {
return fmt.Errorf("stop old OMR at cutover: %w", err)
}
if err := m.install(ctx); err != nil {
return fmt.Errorf("install: %w", err)
}
return nil
}
func (m *Migrator) validatePostgresFlags() error {
var unsupported []string
if m.DryRun {
unsupported = append(unsupported, "-dry-run")
}
if m.SkipInstall {
unsupported = append(unsupported, "-skip-install")
}
if m.Cleanup {
unsupported = append(unsupported, "-cleanup")
}
if len(unsupported) > 0 {
return fmt.Errorf("PostgreSQL migration does not support %s", strings.Join(unsupported, ", "))
}
return nil
}
func (m *Migrator) validatePostgresBeforeStop(ctx context.Context) error {
hasInstallation, err := m.hasTargetInstallation()
if err != nil {
return fmt.Errorf("check target installation: %w", err)
}
if hasInstallation {
return fmt.Errorf("an existing target Quay installation must be removed before PostgreSQL migration")
}
if err := validatePostgresTargetDir(m.DataDir); err != nil {
return err
}
if _, err := m.validateSourceAuthConfig(); err != nil {
return fmt.Errorf("source authentication preflight: %w", err)
}
sourceCfg, err := config.Load(filepath.Join(m.Source.ConfigDir, runtimeConfigFile))
if err != nil {
return fmt.Errorf("load source config for registry JWT key capture: %w", err)
}
key, kid, err := loadRegistryJWTSigningKey(ctx, m.Source.ConfigDir, sourceCfg, m.Runner, &m.persistedConfigFiles)
if err != nil {
return fmt.Errorf("registry JWT key capture: %w", err)
}
m.sourceRegistryJWTKey = key
m.sourceRegistryJWTKID = kid
return m.validateCommonInputs()
}
func validatePostgresTargetDir(path string) error {
entries, err := os.ReadDir(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("read target directory %s: %w", path, err)
}
if len(entries) > 0 {
for _, e := range entries {
if e.Name() == markerFile {
return fmt.Errorf("target directory %s is not clean (found %s): PostgreSQL migration does not support resuming a previous migration — specify a clean directory or remove existing files", path, markerFile)
}
}
return fmt.Errorf("target directory %s is not clean (found %d existing file(s)) — specify a clean directory or remove existing files before PostgreSQL migration", path, len(entries))
}
return nil
}
func (m *Migrator) hasTargetInstallation() (bool, error) {
if m.checkTargetInstallation != nil {
return m.checkTargetInstallation()
}
inst, err := installer.New(m.Out)
if err != nil {
return false, err
}
return inst.HasInstallation(), nil
}
func (m *Migrator) inPostgresNetworkNamespace() (bool, error) {
current, err := os.Stat("/proc/self/ns/net")
if err != nil {
return false, err
}
target, err := os.Stat(m.Source.PodSandboxKey)
if err != nil {
return false, err
}
return os.SameFile(current, target), nil
}
func (m *Migrator) runPostgresCopyInNamespace(ctx context.Context) error {
if m.Runner == nil {
return fmt.Errorf("no command runner for PostgreSQL conversion")
}
executable, err := os.Executable()
if err != nil {
return fmt.Errorf("locate current executable: %w", err)
}
reexec := []string{"--net=" + m.Source.PodSandboxKey, executable, "migrate", "-data-dir=" + m.DataDir, "-source-certs=" + m.Source.ConfigDir}
if m.Source.SystemdScope == scopeUser {
return m.Runner.Run(ctx, "podman", append([]string{"unshare", "nsenter"}, reexec...)...)
}
return m.Runner.Run(ctx, "nsenter", reexec...)
}
func (m *Migrator) copyPostgresSource(ctx context.Context) (retErr error) {
sourceCfg, err := config.Load(filepath.Join(m.Source.ConfigDir, runtimeConfigFile))
if err != nil {
return fmt.Errorf("load source config: %w", err)
}
dsn, err := postgresLoopbackURI(sourceCfg.DBURI)
if err != nil {
return err
}
pg, err := pgx.Connect(ctx, dsn)
if err != nil {
return postgresConnectionError(err)
}
defer func() { _ = pg.Close(context.Background()) }()
targetPath := m.postgresPartialDBPath()
if _, err := os.Stat(targetPath); err == nil {
return fmt.Errorf("intermediate database already exists")
} else if !os.IsNotExist(err) {
return fmt.Errorf("stat intermediate database: %w", err)
}
defer func() {
if retErr != nil {
_ = os.Remove(targetPath)
_ = os.Remove(targetPath + "-wal")
_ = os.Remove(targetPath + "-shm")
}
}()
if err := createIntermediateDatabaseFile(targetPath); err != nil {
return err
}
db, err := dbcore.OpenSQLite(targetPath)
if err != nil {
return fmt.Errorf("open intermediate database: %w", err)
}
defer func() { _ = db.Close() }()
if err := dbcore.InitOMRSourceIntermediate(ctx, db); err != nil {
return fmt.Errorf("initialize intermediate database: %w", err)
}
if _, err := postgrescopy.CopyPostgresToSQLite(ctx, pg, db); err != nil {
return fmt.Errorf("copy PostgreSQL source: %w", err)
}
return nil
}
func postgresConnectionError(err error) error {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return fmt.Errorf("connect to source PostgreSQL: %w", err)
}
var connectErr *pgconn.ConnectError
if errors.As(err, &connectErr) {
if cause := errors.Unwrap(connectErr); cause != nil {
return fmt.Errorf("connect to source PostgreSQL: %w", cause)
}
}
return errors.New("connect to source PostgreSQL: connection failed")
}
func createIntermediateDatabaseFile(path string) error {
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) //nolint:gosec // path is the validated migration target
if err != nil {
return fmt.Errorf("create intermediate database: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("create intermediate database: %w", err)
}
return nil
}
func (m *Migrator) removePostgresMigrationArtifacts(removeMarker bool) error {
partialPath := m.postgresPartialDBPath()
paths := []string{
partialPath,
partialPath + "-wal",
partialPath + "-shm",
}
if removeMarker {
paths = append(paths, filepath.Join(m.DataDir, markerFile))
}
var errs []error
for _, path := range paths {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
errs = append(errs, fmt.Errorf("remove %s: %w", path, err))
}
}
return errors.Join(errs...)
}
func postgresLoopbackURI(raw string) (string, error) {
u, err := parsePostgresURI(raw)
if err != nil {
return "", err
}
if u.Port() == "" {
u.Host = "127.0.0.1"
} else {
u.Host = net.JoinHostPort("127.0.0.1", u.Port())
}
return u.String(), nil
}
func (m *Migrator) postgresPartialDBPath() string {
return filepath.Join(m.DataDir, partialDatabaseName)
}