Files

239 lines
7.5 KiB
Go

package cmds
import (
"archive/zip"
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/italypaleale/francis/clusteradmin"
"github.com/italypaleale/francis/components"
"github.com/spf13/cobra"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type importFlags struct {
Path string
Yes bool
ForcefullyAcquireLock bool
}
func init() {
var flags importFlags
importCmd := &cobra.Command{
Use: "import",
Short: "Imports all data of Pocket ID from a ZIP file",
RunE: func(cmd *cobra.Command, args []string) error {
return runImport(cmd.Context(), flags)
},
}
importCmd.Flags().StringVarP(&flags.Path, "path", "p", "pocket-id-export.zip", "Path to the ZIP file to import the data from, or '-' to read from stdin")
importCmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, "Skip confirmation prompts")
importCmd.Flags().BoolVarP(&flags.ForcefullyAcquireLock, "forcefully-acquire-lock", "", false, "Forcefully acquire exclusive access by terminating any running Pocket ID instance")
rootCmd.AddCommand(importCmd)
}
// runImport handles the high-level orchestration of the import process
func runImport(ctx context.Context, flags importFlags) error {
if !flags.Yes {
ok, err := askForConfirmation()
if err != nil {
return fmt.Errorf("failed to get confirmation: %w", err)
}
if !ok {
fmt.Println("Aborted")
os.Exit(1)
}
}
var (
zipReader *zip.ReadCloser
cleanup func()
err error
)
if flags.Path == "-" {
zipReader, cleanup, err = readZipFromStdin()
defer cleanup()
} else {
zipReader, err = zip.OpenReader(flags.Path)
}
if err != nil {
return fmt.Errorf("failed to open zip: %w", err)
}
defer zipReader.Close()
// Connect to the database without running migrations: the import re-creates the Pocket ID schema itself
db, pg, err := bootstrap.ConnectDatabase(ctx)
if err != nil {
return err
}
// The cluster admin talks to the same database as the actor host, so build its provider options the same way the host does
providerOpts, err := bootstrap.ActorsProviderOptions(db, pg)
if err != nil {
return err
}
// Take exclusive access to the cluster so no Pocket ID replica is running while we overwrite the database
release, lost, err := acquireExclusiveAccess(ctx, providerOpts, flags.ForcefullyAcquireLock)
if err != nil {
return err
}
defer release()
// Abort the import if exclusive access is lost partway through (for example if the lease can no longer be renewed)
importCtx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
select {
case <-lost:
cancel()
case <-importCtx.Done():
}
}()
// Init the storage provider
storage, err := bootstrap.InitStorage(importCtx, db)
if err != nil {
return fmt.Errorf("failed to initialize storage: %w", err)
}
// Close filesystem storage handles before the command exits
defer func() {
_ = storage.Close()
}()
// The actor host's data lives outside of the Pocket ID schema, so it's restored through Francis
// Restoring requires exclusive access to the cluster, which was acquired above
actorsProvider, err := bootstrap.NewActorsBackupProvider(importCtx, providerOpts)
if err != nil {
return fmt.Errorf("failed to initialize the actor host's data provider: %w", err)
}
defer func() {
_ = actorsProvider.Close()
}()
// Create the import service
importService := service.NewImportService(db, storage, actorsProvider)
// Load from ZIP
err = importService.ImportFromZip(importCtx, &zipReader.Reader)
if err != nil {
return fmt.Errorf("failed to import data from zip: %w", err)
}
fmt.Println("Import completed successfully.")
return nil
}
// acquireExclusiveAccess takes an exclusive-access lease on the cluster so the import can safely overwrite the database.
//
// It returns a release function that must be called once the import is done, and a channel that is closed if the lease is lost while it is held.
func acquireExclusiveAccess(ctx context.Context, providerOpts components.ProviderOptions, force bool) (release func(), lost <-chan struct{}, err error) {
// New initializes the provider, applying the actor host's schema migrations, so this also works against a brand-new (empty) database
admin, err := clusteradmin.New(ctx, providerOpts, clusteradmin.Options{
// Match the actor host so the admin waits the right amount of time for hosts to drain
HostHealthCheckDeadline: bootstrap.ActorsHostHealthCheckDeadline(common.EnvConfig.HAEnabled),
Logger: slog.Default(),
})
if err != nil {
return nil, nil, fmt.Errorf("failed to create cluster admin: %w", err)
}
lost, err = admin.AcquireExclusive(ctx, clusteradmin.AcquireOptions{Force: force})
if err != nil {
_ = admin.Close()
switch {
case errors.Is(err, components.ErrHostsConnected):
//nolint:staticcheck
return nil, nil, errors.New("Pocket ID must be stopped before importing data - please stop the running instance or run with --forcefully-acquire-lock to terminate the other instance")
case errors.Is(err, components.ErrExclusiveHeld):
return nil, nil, errors.New("another exclusive operation, such as another import, is already in progress; please wait for it to complete and try again")
default:
return nil, nil, fmt.Errorf("failed to acquire exclusive access: %w", err)
}
}
release = func() {
// The import preserves the actor host's "francis_" tables, including the lease row, so the lease must be released explicitly
// Detach from ctx so the release still runs even if the import was canceled
releaseCtx, cancelRelease := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancelRelease()
rErr := admin.ReleaseExclusive(releaseCtx)
if rErr != nil {
slog.WarnContext(ctx, "Failed to release exclusive access", slog.Any("error", rErr))
}
_ = admin.Close()
}
return release, lost, nil
}
func askForConfirmation() (bool, error) {
fmt.Println("WARNING: This feature is experimental and may not work correctly. Please create a backup before proceeding and report any issues you encounter.")
fmt.Println()
fmt.Println("WARNING: Import will erase all existing data at the following locations:")
fmt.Printf("Database: %s\n", absolutePathOrOriginal(common.EnvConfig.DbConnectionString))
fmt.Printf("Uploads Path: %s\n", absolutePathOrOriginal(common.EnvConfig.UploadPath))
ok, err := utils.PromptForConfirmation("Do you want to continue?")
if err != nil {
return false, err
}
return ok, nil
}
// absolutePathOrOriginal returns the absolute path of the given path, or the original if it fails
func absolutePathOrOriginal(path string) string {
abs, err := filepath.Abs(path)
if err != nil {
return path
}
return abs
}
func readZipFromStdin() (*zip.ReadCloser, func(), error) {
tmpFile, err := os.CreateTemp("", "pocket-id-import-*.zip")
if err != nil {
return nil, nil, fmt.Errorf("failed to create temporary file: %w", err)
}
cleanup := func() {
_ = os.Remove(tmpFile.Name())
}
_, err = io.Copy(tmpFile, os.Stdin)
if err != nil {
tmpFile.Close()
cleanup()
return nil, nil, fmt.Errorf("failed to read data from stdin: %w", err)
}
err = tmpFile.Close()
if err != nil {
cleanup()
return nil, nil, fmt.Errorf("failed to close temporary file: %w", err)
}
r, err := zip.OpenReader(tmpFile.Name())
if err != nil {
cleanup()
return nil, nil, err
}
return r, cleanup, nil
}