mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-07 02:09:04 +02:00
feat: add FRANCIS_HOST to connect to a standalone Francis runtime
FRANCIS_HOST decides where the Francis actor runtime lives. When set to "embedded" (the default), Pocket ID starts the runtime inside its own process. 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. Because when using a remote runtime, it's likewise not possible to enforce a single instance of Pocket ID is running at once, the env vars currently have the `EXPERIMENTAL_` prefix, are **undocumented**, and show a warning if used. Notes: - Connecting to a standalone runtime also needs FRANCIS_HOST_PSK or FRANCIS_HOST_JWT_FILE, and optionally (but recommended) FRANCIS_CA. - When connecting to a remote runtime, exporting Pocket ID data does not include the actor state, which will need to be backed up and restored separately
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -49,18 +50,27 @@ func runExport(ctx context.Context, flags exportFlags) error {
|
||||
_ = 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
|
||||
// The actor data lives outside of the Pocket ID schema, so it's exported through Francis
|
||||
// A standalone runtime keeps it in its own store, out of reach from here, and the export service leaves the entry out of the archive when no provider is passed
|
||||
var actorsProvider service.ActorsBackupProvider
|
||||
if common.EnvConfig.HasEmbeddedFrancisRuntime() {
|
||||
providerOpts, err := bootstrap.ActorsProviderOptions(db, pg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
provider, err := bootstrap.NewActorsBackupProvider(ctx, providerOpts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize the actor host's data provider: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = provider.Close()
|
||||
}()
|
||||
|
||||
actorsProvider = provider
|
||||
} else {
|
||||
printRemoteActorDataNotice("The actor data is NOT included in this export", "back it up separately with: francis runtime backup -f actors.bin")
|
||||
}
|
||||
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)
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package cmds
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
// printRemoteActorDataNotice warns that the command does not cover the actor data, which a standalone Francis runtime owns rather than Pocket ID
|
||||
// It goes to stderr so it stays visible when the archive itself is streamed to stdout
|
||||
func printRemoteActorDataNotice(consequence string, remedy string) {
|
||||
fmt.Fprintf(os.Stderr, `WARNING: FRANCIS_HOST points to a standalone Francis runtime.
|
||||
%s.
|
||||
To cover all data, %s.
|
||||
|
||||
`, consequence, remedy)
|
||||
}
|
||||
|
||||
// ensureNoActorsBackup rejects an archive that carries the actor data when a standalone Francis runtime owns it
|
||||
// Such an archive comes from a deployment with an embedded runtime, and restoring only its Pocket ID half would leave the runtime holding actor state belonging to a different deployment
|
||||
func ensureNoActorsBackup(zipReader *zip.Reader) error {
|
||||
for _, f := range zipReader.File {
|
||||
if f.Name == service.ActorsBackupFileName {
|
||||
return fmt.Errorf("this archive contains the actor data (%s) but FRANCIS_HOST points to a standalone Francis runtime, which owns that data instead: restore it into a deployment with an embedded runtime, or load the actor data into the runtime with 'francis runtime restore'", service.ActorsBackupFileName)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package cmds
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
func TestEnsureNoActorsBackup(t *testing.T) {
|
||||
buildZip := func(t *testing.T, names ...string) *zip.Reader {
|
||||
t.Helper()
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
zw := zip.NewWriter(buf)
|
||||
for _, name := range names {
|
||||
w, err := zw.Create(name)
|
||||
require.NoError(t, err)
|
||||
_, err = w.Write([]byte("payload"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, zw.Close())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||
require.NoError(t, err)
|
||||
|
||||
return zr
|
||||
}
|
||||
|
||||
t.Run("accepts an archive without the actor data", func(t *testing.T) {
|
||||
err := ensureNoActorsBackup(buildZip(t, "database.json", "uploads/logo.png"))
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("rejects an archive carrying the actor data", func(t *testing.T) {
|
||||
err := ensureNoActorsBackup(buildZip(t, "database.json", service.ActorsBackupFileName))
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, service.ActorsBackupFileName)
|
||||
})
|
||||
}
|
||||
@@ -47,6 +47,16 @@ func init() {
|
||||
|
||||
// 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 limit 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 {
|
||||
@@ -75,35 +85,49 @@ func runImport(ctx context.Context, flags importFlags) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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():
|
||||
|
||||
// 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, 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)
|
||||
go func() {
|
||||
select {
|
||||
case <-lost:
|
||||
cancel()
|
||||
case <-importCtx.Done():
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Init the storage provider
|
||||
storage, err := bootstrap.InitStorage(importCtx, db)
|
||||
@@ -116,15 +140,20 @@ func runImport(ctx context.Context, flags importFlags) error {
|
||||
_ = 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)
|
||||
// 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, err := bootstrap.NewActorsBackupProvider(importCtx, providerOpts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize the actor host's data provider: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = provider.Close()
|
||||
}()
|
||||
|
||||
actorsProvider = provider
|
||||
}
|
||||
defer func() {
|
||||
_ = actorsProvider.Close()
|
||||
}()
|
||||
|
||||
// Create the import service
|
||||
importService := service.NewImportService(db, storage, actorsProvider)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -53,22 +55,9 @@ var oneTimeAccessTokenCmd = &cobra.Command{
|
||||
return err
|
||||
}
|
||||
|
||||
// One-time access tokens are stored in the actor state store
|
||||
// The CLI doesn't run the full actor host, so it uses a minimal state store to persist the token directly
|
||||
actorStore, err := bootstrap.NewActorStateStore(bootstrap.NewActorsOpts{
|
||||
DB: db,
|
||||
Postgres: pg,
|
||||
EnvConfig: &common.EnvConfig,
|
||||
InstanceID: instanceID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize the actor state store: %w", err)
|
||||
}
|
||||
|
||||
// Create a new access token that expires in 1 hour
|
||||
tokenCtx, tokenCancel := context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||
defer tokenCancel()
|
||||
token, _, err := onetimeaccess.StoreToken(tokenCtx, actorStore, user.ID, time.Hour, false)
|
||||
// One-time access tokens live in the actor state store, which is reached differently depending on where the actor runtime runs
|
||||
// The CLI never runs the full actor host: with an embedded runtime it writes to Pocket ID's database directly, and with a standalone one it joins the cluster as a client for just long enough to write the token
|
||||
token, err := storeOneTimeAccessToken(cmd.Context(), db, pg, instanceID, user.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create access token: %w", err)
|
||||
}
|
||||
@@ -81,6 +70,48 @@ var oneTimeAccessTokenCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
// storeOneTimeAccessToken persists a one-time access token valid for one hour, through whichever actor runtime this deployment uses, and returns the token
|
||||
func storeOneTimeAccessToken(ctx context.Context, db *gorm.DB, pg *pgxpool.Pool, instanceID string, userID string) (string, error) {
|
||||
// A standalone Francis runtime owns the actor state, so the token is written through a short-lived client connection to it
|
||||
if !common.EnvConfig.HasEmbeddedFrancisRuntime() {
|
||||
var token string
|
||||
err := bootstrap.WithActorClient(ctx, &common.EnvConfig, func(clientCtx context.Context, client francishost.Host) error {
|
||||
tokenCtx, tokenCancel := context.WithTimeout(clientCtx, 10*time.Second)
|
||||
defer tokenCancel()
|
||||
|
||||
var rErr error
|
||||
token, _, rErr = onetimeaccess.StoreToken(tokenCtx, client, userID, time.Hour, false)
|
||||
return rErr
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// With the embedded runtime the actor state lives in Pocket ID's own database, which a minimal state store writes to without running an actor host
|
||||
actorStore, err := bootstrap.NewActorStateStore(bootstrap.NewActorsOpts{
|
||||
DB: db,
|
||||
Postgres: pg,
|
||||
EnvConfig: &common.EnvConfig,
|
||||
InstanceID: instanceID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to initialize the actor state store: %w", err)
|
||||
}
|
||||
|
||||
tokenCtx, tokenCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer tokenCancel()
|
||||
|
||||
token, _, err := onetimeaccess.StoreToken(tokenCtx, actorStore, userID, time.Hour, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(oneTimeAccessTokenCmd)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user