Files
pocket-id/backend/internal/cmds/export.go
Alessandro (Ale) Segala 600ca3f31f feat: add FRANCIS_HOST to connect to a standalone Francis runtime
FRANCIS_HOST decides where the Francis actor runtime lives. When it is
empty or set to "embedded" (the default) nothing changes: 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.

Connecting to a standalone runtime also needs FRANCIS_HOST_PSK, the host
bootstrap pre-shared key the runtime is configured with, and optionally
FRANCIS_CA, the PEM-encoded cluster CA to pin before the first connection.
Without a pinned CA Francis trusts the certificate it is served on first
use, and warns about it.

The actor host is now held as the topology-agnostic francis host.Host
interface, since the concrete type depends on the configuration. The
commands that reach the actor data through Pocket ID's own database
(export, import, and one-time-access-token) fail with an explicit error
when a standalone runtime owns that data instead, rather than silently
operating on the wrong store.
2026-08-31 05:26:59 +00:00

98 lines
2.6 KiB
Go

package cmds
import (
"context"
"errors"
"fmt"
"io"
"os"
"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"
)
type exportFlags struct {
Path string
}
func init() {
var flags exportFlags
exportCmd := &cobra.Command{
Use: "export",
Short: "Exports all data of Pocket ID into a ZIP file",
RunE: func(cmd *cobra.Command, args []string) error {
return runExport(cmd.Context(), flags)
},
}
exportCmd.Flags().StringVarP(&flags.Path, "path", "p", "pocket-id-export.zip", "Path to the ZIP file to export the data to, or '-' to write to stdout")
rootCmd.AddCommand(exportCmd)
}
// runExport orchestrates the export flow
func runExport(ctx context.Context, flags exportFlags) error {
// The export includes the actor data, which a standalone Francis runtime keeps in its own store rather than in Pocket ID's database
// Exporting anyway would silently produce an archive missing that data, so refuse instead
if !common.EnvConfig.HasEmbeddedFrancisRuntime() {
return errors.New("exporting is not supported when FRANCIS_HOST points to a standalone Francis runtime: export Pocket ID's data and the runtime's data separately, using the runtime's own backup command for the latter")
}
db, pg, err := bootstrap.NewDatabase(ctx)
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
storage, err := bootstrap.InitStorage(ctx, 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 exported through Francis
providerOpts, err := bootstrap.ActorsProviderOptions(db, pg)
if err != nil {
return err
}
actorsProvider, err := bootstrap.NewActorsBackupProvider(ctx, providerOpts)
if err != nil {
return fmt.Errorf("failed to initialize the actor host's data provider: %w", err)
}
defer func() {
_ = actorsProvider.Close()
}()
exportService := service.NewExportService(db, storage, actorsProvider)
var w io.Writer
if flags.Path == "-" {
w = os.Stdout
} else {
file, err := os.Create(flags.Path)
if err != nil {
return fmt.Errorf("failed to create export file: %w", err)
}
defer file.Close()
w = file
}
if err := exportService.ExportToZip(ctx, w); err != nil {
return fmt.Errorf("failed to export data: %w", err)
}
if flags.Path != "-" {
fmt.Printf("Exported data to %s\n", flags.Path)
}
return nil
}