package main import ( "context" "crypto/rand" "fmt" "io" "log/slog" "os" "path/filepath" "time" "github.com/google/uuid" "github.com/spf13/cobra" "git.tswf.io/infra/go-synapse-backupper/pkg/adapters/config" "git.tswf.io/infra/go-synapse-backupper/pkg/adapters/crypto/composite" "git.tswf.io/infra/go-synapse-backupper/pkg/adapters/crypto/keymanager" "git.tswf.io/infra/go-synapse-backupper/pkg/adapters/crypto/mlkem768" "git.tswf.io/infra/go-synapse-backupper/pkg/adapters/crypto/x25519" pgdumpadapter "git.tswf.io/infra/go-synapse-backupper/pkg/adapters/pgdump" "git.tswf.io/infra/go-synapse-backupper/pkg/adapters/pipeline" "git.tswf.io/infra/go-synapse-backupper/pkg/adapters/retention" "git.tswf.io/infra/go-synapse-backupper/pkg/adapters/storage/local" "git.tswf.io/infra/go-synapse-backupper/pkg/domain" "git.tswf.io/infra/go-synapse-backupper/pkg/domain/backup" "git.tswf.io/infra/go-synapse-backupper/pkg/domain/crypto" "git.tswf.io/infra/go-synapse-backupper/pkg/domain/pgdump" ) var ( newKeyManager = keymanager.NewKeyManager newLocalSink = local.NewLocalSink newRunner = defaultNewRunner outputWriter io.Writer = os.Stderr ) func defaultNewRunner(options ...pipeline.Option) pipeline.Runner { return pipeline.NewRunner(options...) } func init() { config.RegisterFlags(backupCmd) } var backupCmd = &cobra.Command{ Use: "backup", Short: "Run a one-off backup", RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() if ctx == nil { ctx = context.Background() } startTime := time.Now().UTC() runID, err := uuid.NewRandom() if err != nil { return fmt.Errorf("generate run-id: %w", err) } logger := slog.New(slog.NewTextHandler(outputWriter, nil)) logger.Info( "backup started", slog.String("run_id", runID.String()), slog.Time("start_time", startTime), ) cfg, err := config.Load(cmd) if err != nil { return fmt.Errorf("load config: %w", err) } keyManager := newKeyManager(crypto.NewRegistry()) pqPub, err := keyManager.LoadPub(cfg.PQPublicKeyPath, cfg.PQScheme) if err != nil { return fmt.Errorf("load PQ public key: %w", err) } classicalPub, err := keyManager.LoadPub(cfg.ClassicalPublicKeyPath, cfg.ClassicalScheme) if err != nil { return fmt.Errorf("load classical public key: %w", err) } recipients := make([]crypto.RecipientPub, 0, 2) recipients = append(recipients, pqPub, classicalPub) pgDumpOpts := pgdump.Options{ Host: cfg.PG.Host, Port: cfg.PG.Port, Database: cfg.PG.Database, User: cfg.PG.User, Password: cfg.PG.Password, Key: backup.ArtifactKey(startTime), ExcludeTables: cfg.PG.ExcludeTables, } finalPath := filepath.Join(cfg.Backup.Dir, pgDumpOpts.Key) logger.Info( "backup destination", slog.String("backup_dir", cfg.Backup.Dir), slog.String("output_path", finalPath), ) var sink domain.Sink = newLocalSink(cfg.Backup.Dir) registry := crypto.NewRegistry() _ = registry.Register(0x0006, func() crypto.KEM { return mlkem768.New() }) _ = registry.Register(0x0007, func() crypto.KEM { return x25519.New() }) runner := newRunner( pipeline.WithDumper(pgdumpadapter.New()), pipeline.WithEncryptor(composite.NewEncryptor(registry)), ) err = runner.Run(ctx, pgDumpOpts, recipients, sink, rand.Reader) endTime := time.Now().UTC() var byteCount int64 if info, statErr := os.Stat(finalPath); statErr == nil { byteCount = info.Size() } if err != nil { logger.Error( "backup failed", slog.String("run_id", runID.String()), slog.String("output_path", finalPath), slog.Time("start_time", startTime), slog.Time("end_time", endTime), slog.Int64("byte_count", byteCount), slog.String("error", err.Error()), ) return err } logger.Info( "backup completed", slog.String("run_id", runID.String()), slog.String("output_path", finalPath), slog.Time("start_time", startTime), slog.Time("end_time", endTime), slog.Int64("byte_count", byteCount), ) if _, pruneErr := retention.PruneByAge( ctx, cfg.Backup.Dir, cfg.Backup.RetentionDays, time.Now(), ); pruneErr != nil { logger.Error( "retention pruning failed", slog.String("error", pruneErr.Error()), ) return pruneErr } return nil }, }