mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-17 08:29:04 +02:00
FRANCIS_HOST decides where the Francis actor runtime lives. When it is empty or set to "embedded" (the default), Pocket ID starts the runtime inside its own process, backed by its own database. Any other value is the address, or a comma-separated list of addresses, of a standalone Francis runtime. Pocket ID then connects to it as a remote actor host and starts no embedded runtime. Note: connecting to a standalone runtime also needs FRANCIS_HOST_PSK or FRANCIS_HOST_JWT, and optionally (but recommended) FRANCIS_CA. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DMgoTZtznSjP4SHTaHbRen
268 lines
8.9 KiB
Go
268 lines
8.9 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 {
|
|
// A standalone Francis runtime owns the actor data, so this import only covers what lives in Pocket ID's own database
|
|
// Nothing here can fence the replicas either, since they are hosts of the runtime's cluster rather than of a cluster in this database
|
|
embeddedRuntime := common.EnvConfig.HasEmbeddedFrancisRuntime()
|
|
if !embeddedRuntime {
|
|
printRemoteActorDataNotice(
|
|
"The actor data will NOT be restored, and Pocket ID replicas will NOT be stopped for you",
|
|
"stop every replica first, then restore the runtime with: francis runtime restore -f actors.bin",
|
|
)
|
|
}
|
|
|
|
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()
|
|
|
|
// An archive carrying the actor data was taken from a deployment with an embedded runtime, and there is nowhere to put that data here
|
|
// Restoring only the Pocket ID half of it would leave the runtime holding actor state from a different deployment, so refuse rather than half-restore
|
|
if !embeddedRuntime {
|
|
err = ensureNoActorsBackup(&zipReader.Reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
importCtx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
// Take exclusive access to the cluster so no Pocket ID replica is running while we overwrite the database
|
|
// The lease lives in the actor host's own tables, so it only exists when the runtime is embedded: with a standalone runtime the operator was told to stop the replicas instead
|
|
var providerOpts components.ProviderOptions
|
|
if embeddedRuntime {
|
|
// 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
|
|
}
|
|
|
|
release, lost, acquireErr := acquireExclusiveAccess(ctx, providerOpts, flags.ForcefullyAcquireLock)
|
|
if acquireErr != nil {
|
|
return acquireErr
|
|
}
|
|
defer release()
|
|
|
|
// Abort the import if exclusive access is lost partway through (for example if the lease can no longer be renewed)
|
|
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 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, and the import service skips the actor data entirely when no provider is passed
|
|
var actorsProvider service.ActorsBackupProvider
|
|
if embeddedRuntime {
|
|
provider, provErr := bootstrap.NewActorsBackupProvider(importCtx, providerOpts)
|
|
if provErr != nil {
|
|
return fmt.Errorf("failed to initialize the actor host's data provider: %w", provErr)
|
|
}
|
|
defer func() {
|
|
_ = provider.Close()
|
|
}()
|
|
|
|
actorsProvider = provider
|
|
}
|
|
|
|
// 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
|
|
}
|