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), 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
This commit is contained in:
ItalyPaleAle
2026-09-15 16:02:05 +00:00
co-authored by Claude Opus 5
parent 81cb290bed
commit cea1267f37
35 changed files with 1158 additions and 151 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, provErr := bootstrap.ActorsProviderOptions(db, pg)
if provErr != nil {
return provErr
}
provider, provErr := bootstrap.NewActorsBackupProvider(ctx, providerOpts)
if provErr != nil {
return fmt.Errorf("failed to initialize the actor host's data provider: %w", provErr)
}
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)
+33
View File
@@ -0,0 +1,33 @@
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.
That data covers the app configuration, signup and one-time access tokens, device
login requests, LDAP sync state, and the schedule of the background jobs.
To cover it, %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 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 {
@@ -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, 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)
@@ -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, 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
}
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 storeErr error
token, _, storeErr = onetimeaccess.StoreToken(tokenCtx, client, userID, time.Hour, false)
return storeErr
})
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)
}