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:
ItalyPaleAle
2026-09-19 23:38:18 -07:00
parent 4ba28992ff
commit fadb1a5552
35 changed files with 1125 additions and 130 deletions
+21 -11
View File
@@ -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)
+31
View File
@@ -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)
})
}
+57 -28
View File
@@ -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)
+47 -16
View File
@@ -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)
}