mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-09-26 21: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:
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Actors *local.Host
|
||||
Actors francishost.Host
|
||||
StaticApiKey string
|
||||
AppConfig appconfig.AppConfigResolver
|
||||
EmailSender APIKeyExpiryEmailSender
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/italypaleale/francis/actor"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
@@ -26,7 +26,7 @@ type AppConfigService struct {
|
||||
envConfig *AppConfigModel
|
||||
}
|
||||
|
||||
func NewService(ctx context.Context, actors *local.Host, db *gorm.DB) (service *AppConfigService, err error) {
|
||||
func NewService(ctx context.Context, actors francishost.Host, db *gorm.DB) (service *AppConfigService, err error) {
|
||||
service = &AppConfigService{}
|
||||
|
||||
// If the UI config is disabled, we do not need to init the config actor
|
||||
@@ -56,8 +56,8 @@ func NewService(ctx context.Context, actors *local.Host, db *gorm.DB) (service *
|
||||
}
|
||||
err = actors.RegisterSingletonActor(
|
||||
AppConfigActorType, NewAppConfigActor,
|
||||
local.WithBootstrapData(bootstrapData),
|
||||
local.WithIdleTimeout(-1), // Disable idle timeout for this actor
|
||||
francishost.WithBootstrapData(bootstrapData),
|
||||
francishost.WithIdleTimeout(-1), // Disable idle timeout for this actor
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error registering the %s actor: %w", AppConfigActorType, err)
|
||||
|
||||
@@ -5,13 +5,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Actors *local.Host
|
||||
Actors francishost.Host
|
||||
|
||||
// RetentionDays is how long audit logs are kept before the cleanup job deletes them
|
||||
RetentionDays int
|
||||
|
||||
@@ -13,7 +13,9 @@ import (
|
||||
"github.com/italypaleale/francis/components"
|
||||
"github.com/italypaleale/francis/components/postgres"
|
||||
"github.com/italypaleale/francis/components/sqlite"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"github.com/italypaleale/francis/host/remote"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -24,6 +26,14 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
|
||||
)
|
||||
|
||||
// ErrRemoteFrancisRuntime is returned by the helpers that reach the actor data through Pocket ID's own database when FRANCIS_HOST points to a standalone Francis runtime
|
||||
// That runtime owns the actor data instead, so it can only be reached through the runtime itself
|
||||
var ErrRemoteFrancisRuntime = errors.New("the actor data is owned by the standalone Francis runtime configured in FRANCIS_HOST, and is not stored in Pocket ID's database")
|
||||
|
||||
// ErrEmbeddedFrancisRuntime is returned by the helpers that reach the actor data through a standalone Francis runtime when Pocket ID runs an embedded one
|
||||
// There is no runtime to connect to in that case, and the actor data is in Pocket ID's own database
|
||||
var ErrEmbeddedFrancisRuntime = errors.New("the actor runtime is embedded in Pocket ID, so there is no standalone Francis runtime to connect to")
|
||||
|
||||
type NewActorsOpts struct {
|
||||
Postgres *pgxpool.Pool
|
||||
|
||||
@@ -34,14 +44,50 @@ type NewActorsOpts struct {
|
||||
FileStorage storage.FileStorage
|
||||
}
|
||||
|
||||
func NewActors(o NewActorsOpts) (*local.Host, map[string]*ratelimit.RateLimitService, error) {
|
||||
log := slog.Default()
|
||||
func NewActors(o NewActorsOpts) (h francishost.Host, rateLimitServices map[string]*ratelimit.RateLimitService, err error) {
|
||||
log := slog.Default().With("scope", "actor-host")
|
||||
|
||||
// Create the actor host for the configured topology
|
||||
// The embedded runtime keeps the actor data in Pocket ID's own database, while a standalone Francis runtime owns it instead and coordinates every host that connects to it
|
||||
if o.EnvConfig.HasEmbeddedFrancisRuntime() {
|
||||
log.Debug("Starting the embedded Francis runtime")
|
||||
h, err = o.newEmbeddedHost(log)
|
||||
} else {
|
||||
log.Info("Connecting to a standalone Francis runtime", slog.Any("addresses", o.EnvConfig.FrancisAddresses))
|
||||
h, err = o.newRemoteHost(log)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Add all cron jobs
|
||||
err = o.registerCronJobs(h)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Add the rate limiters
|
||||
rateLimiters, err := o.registerRateLimiters(h)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Bind a service for each rate limiter so the middleware can invoke them
|
||||
rateLimitServices = make(map[string]*ratelimit.RateLimitService, len(rateLimiters))
|
||||
for name, rl := range rateLimiters {
|
||||
rateLimitServices[name] = rl.Service(h.Service())
|
||||
}
|
||||
|
||||
return h, rateLimitServices, nil
|
||||
}
|
||||
|
||||
// newEmbeddedHost creates the actor host that runs the Francis runtime inside the Pocket ID process
|
||||
func (o *NewActorsOpts) newEmbeddedHost(log *slog.Logger) (*local.Host, error) {
|
||||
// Derive a PSK from the global encryption key
|
||||
// The runtime PSK derives the cluster CA used for host-to-host mTLS
|
||||
psk, err := o.getPSK()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to derive PSK: %w", err)
|
||||
return nil, fmt.Errorf("failed to derive PSK: %w", err)
|
||||
}
|
||||
|
||||
// Derive the cluster host limit from the HA setting
|
||||
@@ -55,7 +101,7 @@ func NewActors(o NewActorsOpts) (*local.Host, map[string]*ratelimit.RateLimitSer
|
||||
// Options for the host
|
||||
opts := []local.HostOption{
|
||||
local.WithAddress(net.JoinHostPort(o.EnvConfig.ActorsHost, o.EnvConfig.ActorsPort)),
|
||||
local.WithLogger(log.With("scope", "actor-host")),
|
||||
local.WithLogger(log),
|
||||
local.WithRuntimePSKs(psk),
|
||||
local.WithShutdownGracePeriod(10 * time.Second),
|
||||
local.WithMaxHosts(maxHosts),
|
||||
@@ -76,35 +122,59 @@ func NewActors(o NewActorsOpts) (*local.Host, map[string]*ratelimit.RateLimitSer
|
||||
// Add the database connection
|
||||
providerOpt, err := o.getProviderOption()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, err
|
||||
}
|
||||
opts = append(opts, providerOpt)
|
||||
|
||||
// Create a new actor host
|
||||
h, err := local.NewHost(opts...)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create actor host: %w", err)
|
||||
return nil, fmt.Errorf("failed to create actor host: %w", err)
|
||||
}
|
||||
|
||||
// Add all cron jobs
|
||||
err = o.registerCronJobs(h)
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// newRemoteHost creates the actor host that connects to a standalone Francis runtime
|
||||
func (o *NewActorsOpts) newRemoteHost(log *slog.Logger) (*remote.Host, error) {
|
||||
opts := append(
|
||||
remoteConnectionOptions(o.EnvConfig, log),
|
||||
remote.WithAddress(net.JoinHostPort(o.EnvConfig.ActorsHost, o.EnvConfig.ActorsPort)),
|
||||
remote.WithShutdownGracePeriod(10*time.Second),
|
||||
)
|
||||
|
||||
h, err := remote.NewHost(opts...)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, fmt.Errorf("failed to create remote actor host: %w", err)
|
||||
}
|
||||
|
||||
// Add the rate limiters
|
||||
rateLimiters, err := o.registerRateLimiters(h)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// remoteConnectionOptions builds the options that address and authenticate Pocket ID to a standalone Francis runtime
|
||||
// Both the actor host and the short-lived client the CLI commands use go through here, so they always present the same identity to the same cluster
|
||||
func remoteConnectionOptions(envConfig *common.EnvConfigSchema, log *slog.Logger) []remote.HostOption {
|
||||
opts := []remote.HostOption{
|
||||
remote.WithLogger(log),
|
||||
remote.WithRuntimeAddresses(envConfig.FrancisAddresses()...),
|
||||
}
|
||||
|
||||
// Bind a service for each rate limiter so the middleware can invoke them
|
||||
rateLimitServices := make(map[string]*ratelimit.RateLimitService, len(rateLimiters))
|
||||
for name, rl := range rateLimiters {
|
||||
rateLimitServices[name] = rl.Service(h.Service())
|
||||
// The configuration is validated to carry exactly one bootstrap method, so the first match is the one the operator chose
|
||||
switch {
|
||||
case len(envConfig.FrancisHostPSK) > 0:
|
||||
opts = append(opts, remote.WithHostBootstrapPSK(envConfig.FrancisHostPSK))
|
||||
case envConfig.FrancisHostJWTFile != "":
|
||||
// Francis re-reads the file on every connection, so a rotated token is picked up without restarting Pocket ID
|
||||
opts = append(opts, remote.WithHostBootstrapJWTFile(envConfig.FrancisHostJWTFile))
|
||||
}
|
||||
|
||||
return h, rateLimitServices, nil
|
||||
// Pinning the cluster CA lets Pocket ID verify the runtime on its very first connection
|
||||
if len(envConfig.FrancisCA) > 0 {
|
||||
opts = append(opts, remote.WithPinnedCA(envConfig.FrancisCA))
|
||||
} else {
|
||||
opts = append(opts, remote.WithUnsafeNoPinnedCA())
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
||||
|
||||
// Derive a PSK from the global encryption key
|
||||
@@ -117,7 +187,12 @@ func (o *NewActorsOpts) getPSK() ([]byte, error) {
|
||||
// NewActorStateStore creates a minimal actor host that can read and write actor state directly, without joining the cluster or binding a network port.
|
||||
// It's meant for short-lived contexts such as CLI commands that need to persist actor state (for example, one-time access tokens) without running the full actor host.
|
||||
// The returned host must NOT be Run(): only direct state operations (Get/Set/Delete on state) are supported, and they require the actor state tables to already exist, which is the case whenever the server has run at least once against this database.
|
||||
// It only works with the embedded runtime, since the actor state then lives in Pocket ID's own database: with a standalone Francis runtime it returns ErrRemoteFrancisRuntime.
|
||||
func NewActorStateStore(o NewActorsOpts) (*local.Host, error) {
|
||||
if !o.EnvConfig.HasEmbeddedFrancisRuntime() {
|
||||
return nil, ErrRemoteFrancisRuntime
|
||||
}
|
||||
|
||||
providerOpt, err := o.getProviderOption()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -234,7 +309,7 @@ func (o *NewActorsOpts) getProviderOption() (local.HostOption, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (o *NewActorsOpts) registerCronJobs(host *local.Host) (err error) {
|
||||
func (o *NewActorsOpts) registerCronJobs(host francishost.Host) (err error) {
|
||||
// In test mode, we do not register anything
|
||||
if common.EnvConfig.AppEnv == "test" {
|
||||
return nil
|
||||
@@ -271,7 +346,7 @@ func (o *NewActorsOpts) registerCronJobs(host *local.Host) (err error) {
|
||||
|
||||
// registerRateLimiters creates a built-in rate-limit actor for each middleware policy and returns both the created actors (keyed by policy name) and the host options to register them
|
||||
// Unlike cron jobs, rate limiters keep no durable state, so they are registered in every environment
|
||||
func (o *NewActorsOpts) registerRateLimiters(host *local.Host) (actors map[string]*ratelimit.RateLimit, err error) {
|
||||
func (o *NewActorsOpts) registerRateLimiters(host francishost.Host) (actors map[string]*ratelimit.RateLimit, err error) {
|
||||
policies := middleware.RateLimitPolicies()
|
||||
actors = make(map[string]*ratelimit.RateLimit, len(policies))
|
||||
for _, p := range policies {
|
||||
|
||||
@@ -2,10 +2,23 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"github.com/italypaleale/francis/host/remote"
|
||||
"github.com/libtnb/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
@@ -31,6 +44,150 @@ func TestNewActorsOptsGetPSKUsesStableValue(t *testing.T) {
|
||||
require.Equalf(t, expected, actual, "actual result: %s", actual)
|
||||
}
|
||||
|
||||
// TestNewActorsSelectsTopology covers the branch that FRANCIS_HOST drives: with no standalone runtime configured Pocket ID starts an embedded one, and otherwise it connects to the addresses it was given.
|
||||
func TestNewActorsSelectsTopology(t *testing.T) {
|
||||
// The actor host is created but never run, so the database only has to exist
|
||||
newDB := func(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "pocket-id.db")
|
||||
db, err := gorm.Open(sqlite.Open("file:"+dbPath+"?_pragma=foreign_keys(1)"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
sqlDB, dbErr := db.DB()
|
||||
if dbErr == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// registerCronJobs reads the app environment off the global config and skips every job in test mode, which keeps each case down to the host itself and the rate limiters
|
||||
baseConfig := func(t *testing.T) *common.EnvConfigSchema {
|
||||
t.Helper()
|
||||
|
||||
originalAppEnv := common.EnvConfig.AppEnv
|
||||
common.EnvConfig.AppEnv = common.AppEnvTest
|
||||
t.Cleanup(func() {
|
||||
common.EnvConfig.AppEnv = originalAppEnv
|
||||
})
|
||||
|
||||
return &common.EnvConfigSchema{
|
||||
AppEnv: common.AppEnvTest,
|
||||
EncryptionKey: []byte("test-encryption-key"),
|
||||
ActorsHost: "127.0.0.1",
|
||||
ActorsPort: "1414",
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("embedded runtime by default", func(t *testing.T) {
|
||||
cfg := baseConfig(t)
|
||||
|
||||
h, rateLimitServices, err := NewActors(NewActorsOpts{
|
||||
EnvConfig: cfg,
|
||||
InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876",
|
||||
DB: newDB(t),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.IsType(t, &local.Host{}, h)
|
||||
require.NotEmpty(t, rateLimitServices)
|
||||
})
|
||||
|
||||
t.Run("remote runtime when addresses are configured", func(t *testing.T) {
|
||||
cfg := baseConfig(t)
|
||||
cfg.SetFrancisAddresses([]string{"runtime-1.example.com:8443", "runtime-2.example.com:8443"})
|
||||
cfg.FrancisHostPSK = []byte("bootstrap-psk-that-is-long-enough")
|
||||
|
||||
// No database is passed, since a standalone runtime owns the actor data and the remote host must not reach for Pocket ID's own database
|
||||
h, rateLimitServices, err := NewActors(NewActorsOpts{
|
||||
EnvConfig: cfg,
|
||||
InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.IsType(t, &remote.Host{}, h)
|
||||
require.NotEmpty(t, rateLimitServices)
|
||||
})
|
||||
|
||||
// Each bootstrap method has to produce a host Francis accepts, which is the only part of the remote wiring that can be checked without a runtime to connect to
|
||||
t.Run("every bootstrap method builds a valid remote host", func(t *testing.T) {
|
||||
jwtFile := filepath.Join(t.TempDir(), "token")
|
||||
require.NoError(t, os.WriteFile(jwtFile, []byte("header.payload.signature"), 0600))
|
||||
|
||||
for name, apply := range map[string]func(cfg *common.EnvConfigSchema){
|
||||
"PSK": func(cfg *common.EnvConfigSchema) { cfg.FrancisHostPSK = []byte("bootstrap-psk-that-is-long-enough") },
|
||||
"JWT file": func(cfg *common.EnvConfigSchema) { cfg.FrancisHostJWTFile = jwtFile },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := baseConfig(t)
|
||||
cfg.SetFrancisAddresses([]string{"runtime-1.example.com:8443"})
|
||||
apply(cfg)
|
||||
|
||||
opts := NewActorsOpts{EnvConfig: cfg, InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876"}
|
||||
h, err := opts.newRemoteHost(slog.New(slog.DiscardHandler))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, h)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// The E2E suite runs the remote variant without a pinned CA, so this is the only place the pinning branch is exercised
|
||||
t.Run("pinning the cluster CA builds a valid remote host", func(t *testing.T) {
|
||||
cfg := baseConfig(t)
|
||||
cfg.SetFrancisAddresses([]string{"runtime-1.example.com:8443"})
|
||||
cfg.FrancisHostPSK = []byte("bootstrap-psk-that-is-long-enough")
|
||||
cfg.FrancisCA = testCAPEM(t)
|
||||
|
||||
opts := NewActorsOpts{EnvConfig: cfg, InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876"}
|
||||
h, err := opts.newRemoteHost(slog.New(slog.DiscardHandler))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, h)
|
||||
})
|
||||
|
||||
t.Run("an unparsable cluster CA is rejected", func(t *testing.T) {
|
||||
cfg := baseConfig(t)
|
||||
cfg.SetFrancisAddresses([]string{"runtime-1.example.com:8443"})
|
||||
cfg.FrancisHostPSK = []byte("bootstrap-psk-that-is-long-enough")
|
||||
cfg.FrancisCA = []byte("-----BEGIN CERTIFICATE-----\nnot a certificate\n-----END CERTIFICATE-----")
|
||||
|
||||
opts := NewActorsOpts{EnvConfig: cfg, InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876"}
|
||||
_, err := opts.newRemoteHost(slog.New(slog.DiscardHandler))
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("no bootstrap method is rejected by Francis", func(t *testing.T) {
|
||||
cfg := baseConfig(t)
|
||||
cfg.SetFrancisAddresses([]string{"runtime-1.example.com:8443"})
|
||||
|
||||
opts := NewActorsOpts{EnvConfig: cfg, InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876"}
|
||||
_, err := opts.newRemoteHost(slog.New(slog.DiscardHandler))
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("the actor client requires a standalone runtime", func(t *testing.T) {
|
||||
cfg := baseConfig(t)
|
||||
|
||||
err := WithActorClient(t.Context(), cfg, func(context.Context, francishost.Host) error {
|
||||
t.Fatal("the callback must not run without a standalone runtime")
|
||||
return nil
|
||||
})
|
||||
require.ErrorIs(t, err, ErrEmbeddedFrancisRuntime)
|
||||
})
|
||||
|
||||
t.Run("state store is unavailable with a remote runtime", func(t *testing.T) {
|
||||
cfg := baseConfig(t)
|
||||
cfg.SetFrancisAddresses([]string{"runtime-1.example.com:8443"})
|
||||
cfg.FrancisHostPSK = []byte("bootstrap-psk-that-is-long-enough")
|
||||
|
||||
_, err := NewActorStateStore(NewActorsOpts{
|
||||
EnvConfig: cfg,
|
||||
InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876",
|
||||
DB: newDB(t),
|
||||
})
|
||||
require.ErrorIs(t, err, ErrRemoteFrancisRuntime)
|
||||
})
|
||||
}
|
||||
|
||||
// TestNewActorsBackupProvider covers the provider the export and import use to back up and restore the actor host's data.
|
||||
// It builds the provider from the same options the actor host uses, so a mismatch between those options and the concrete provider would otherwise only surface at runtime, when an export or import is attempted.
|
||||
func TestNewActorsBackupProvider(t *testing.T) {
|
||||
@@ -70,3 +227,26 @@ func TestNewActorsBackupProvider(t *testing.T) {
|
||||
err = provider.Restore(t.Context(), bytes.NewReader(buf.Bytes()))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// testCAPEM returns a self-signed CA certificate in PEM form, standing in for the cluster CA an operator would pin with FRANCIS_CA
|
||||
func testCAPEM(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "test-cluster-ca"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, pub, priv)
|
||||
require.NoError(t, err)
|
||||
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"github.com/italypaleale/francis/host/remote"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
// actorClientConnectTimeout is the timeout for how long a CLI command waits to join the cluster
|
||||
// Francis reconnects to the runtime indefinitely, which is right for the server but would leave a command hanging against an unreachable runtime
|
||||
const actorClientConnectTimeout = 30 * time.Second
|
||||
|
||||
// WithActorClient connects to the standalone Francis runtime, calls fn once the connection is live, and disconnects before returning.
|
||||
// It's meant for CLI commands, which have no actor host of their own: the client joins the cluster only for the duration of fn, and hosts no actor while connected, so the runtime never places an actor on it.
|
||||
// It requires FRANCIS_HOST to point to a standalone runtime, and returns ErrEmbeddedFrancisRuntime otherwise, since an embedded runtime is reached through the database instead.
|
||||
func WithActorClient(parentCtx context.Context, envConfig *common.EnvConfigSchema, fn func(ctx context.Context, client francishost.Host) error) error {
|
||||
if envConfig.HasEmbeddedFrancisRuntime() {
|
||||
return ErrEmbeddedFrancisRuntime
|
||||
}
|
||||
|
||||
log := slog.Default().With("scope", "actor-client")
|
||||
|
||||
// The client hosts no actor, so it advertises no address of its own and binds nothing
|
||||
// The short grace period keeps a command from lingering on the way out, since there are no actors to drain
|
||||
client, err := remote.NewHost(append(
|
||||
remoteConnectionOptions(envConfig, log),
|
||||
remote.WithClientOnly(),
|
||||
remote.WithShutdownGracePeriod(2*time.Second),
|
||||
)...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create the actor client: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(parentCtx)
|
||||
defer cancel()
|
||||
|
||||
runErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
runErrCh <- client.Run(ctx)
|
||||
}()
|
||||
|
||||
// Every operation travels on the runtime session, so nothing can run before the client has joined the cluster
|
||||
connectCtx, connectCancel := context.WithTimeout(ctx, actorClientConnectTimeout)
|
||||
defer connectCancel()
|
||||
|
||||
select {
|
||||
case <-client.Ready():
|
||||
case err = <-runErrCh:
|
||||
return fmt.Errorf("failed to connect to the Francis runtime: %w", err)
|
||||
case <-connectCtx.Done():
|
||||
cancel()
|
||||
<-runErrCh
|
||||
return fmt.Errorf("timed out connecting to the Francis runtime after %v", actorClientConnectTimeout)
|
||||
}
|
||||
|
||||
fnErr := fn(ctx, client)
|
||||
|
||||
// Leave the cluster before returning, so the runtime drops the registration instead of waiting for the health check to lapse
|
||||
cancel()
|
||||
err = <-runErrCh
|
||||
|
||||
// The error from fn is the one the caller asked for, and a canceled run is just the disconnect we asked for
|
||||
switch {
|
||||
case fnErr != nil:
|
||||
return fnErr
|
||||
case err != nil && !errors.Is(err, context.Canceled):
|
||||
return fmt.Errorf("error disconnecting from the Francis runtime: %w", err)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
|
||||
"github.com/italypaleale/francis/components"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"github.com/italypaleale/go-kit/servicerunner"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -137,7 +137,7 @@ func Bootstrap(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// actorsRunServiceFn wraps the actor host's Run method in a background service and returns a "ready" signal that other services can wait on
|
||||
func actorsRunServiceFn(actors *local.Host) (servicerunner.Service, *servicerunner.Ready) {
|
||||
func actorsRunServiceFn(actors francishost.Host) (servicerunner.Service, *servicerunner.Ready) {
|
||||
actorsReady := servicerunner.NewReady()
|
||||
fn := func(ctx context.Context) error {
|
||||
runErrCh := make(chan error, 1)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/api"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
@@ -52,7 +52,7 @@ type services struct {
|
||||
emailVerificationModule *emailverification.Module
|
||||
apiModule *api.Module
|
||||
environmentModule *environment.Module
|
||||
actors *local.Host
|
||||
actors francishost.Host
|
||||
}
|
||||
|
||||
// Initializes all services
|
||||
@@ -60,7 +60,7 @@ func initServices(
|
||||
ctx context.Context,
|
||||
db *gorm.DB,
|
||||
instanceID string,
|
||||
actors *local.Host,
|
||||
actors francishost.Host,
|
||||
httpClient *http.Client,
|
||||
imageExtensions map[string]string,
|
||||
fileStorage storage.FileStorage,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -42,6 +43,11 @@ const (
|
||||
defaultSqliteConnString string = "data/pocket-id.db"
|
||||
defaultFsUploadPath string = "data/uploads"
|
||||
AppUrl string = "http://localhost:1411"
|
||||
|
||||
// FrancisHostEmbedded is the FRANCIS_HOST value that keeps the Francis actor runtime embedded in the Pocket ID process
|
||||
FrancisHostEmbedded string = "embedded"
|
||||
// francisHostPSKMinLength is the min length for the host bootstrap pre-shared key for Francis
|
||||
francisHostPSKMinLength int = 16
|
||||
)
|
||||
|
||||
type EnvConfigSchema struct {
|
||||
@@ -93,6 +99,22 @@ type EnvConfigSchema struct {
|
||||
ActorsPort string `env:"ACTORS_PORT"`
|
||||
ActorsHost string `env:"ACTORS_HOST" options:"toLower"`
|
||||
|
||||
// FrancisHost selects where the Francis actor runtime lives
|
||||
// When set to "embedded" (the default), Pocket ID runs the runtime inside its own process
|
||||
// Any other value is the address (or a comma-separated list of addresses) of a standalone Francis runtime to connect to
|
||||
FrancisHost string `env:"EXPERIMENTAL_FRANCIS_HOST" options:"toLower"`
|
||||
// FrancisHostPSK is the pre-shared key Pocket ID presents to a standalone Francis runtime when joining the cluster
|
||||
// It must match the "bootstrap.hostPSK" value in the runtime's own configuration
|
||||
// One and only one of FrancisHostPSK and FrancisHostJWTFile must be set when FrancisHost is pointing at a standalone runtime
|
||||
FrancisHostPSK []byte `env:"EXPERIMENTAL_FRANCIS_HOST_PSK" options:"file"`
|
||||
// FrancisHostJWTFile is the path to a file holding the bearer token Pocket ID presents to a standalone Francis runtime
|
||||
// One and only one of FrancisHostPSK and FrancisHostJWTFile must be set when FrancisHost is pointing at a standalone runtime
|
||||
// Note: Unlike the other "_FILE" variables this one keeps the path rather than the contents: the file is re-read on every connection to the runtime, so a rotated token (such as a Kubernetes projected service account token) is picked up without restarting Pocket ID
|
||||
FrancisHostJWTFile string `env:"EXPERIMENTAL_FRANCIS_HOST_JWT_FILE"`
|
||||
// FrancisCA is the PEM-encoded cluster CA of a standalone Francis runtime, which Pocket ID pins before its first connection
|
||||
// Leaving it empty makes Pocket ID trust the certificate the runtime presents on the first connection, which is vulnerable to an attacker intercepting that connection
|
||||
FrancisCA []byte `env:"EXPERIMENTAL_FRANCIS_CA" options:"file"`
|
||||
|
||||
// HAEnabled turns on high-availability mode, allowing more than one replica of Pocket ID to run against the same database at once
|
||||
// It is intentionally not bound to an environment variable while HA support is still being completed
|
||||
// TODO: Add env var when HA mode is ready
|
||||
@@ -108,6 +130,12 @@ type EnvConfigSchema struct {
|
||||
// This is true when DISMISS_SQLITE_STORAGE_WARNING is the exact confirmation phrase set in the constant above
|
||||
// Note: this is omitted from the general list of environment variables in the docs, and documented only in the SQLite-specific section
|
||||
DismissSQLiteStorageWarning DismissSQLiteStorageWarningConfig `env:"DISMISS_SQLITE_STORAGE_WARNING"`
|
||||
|
||||
/*** Internal properties ***/
|
||||
|
||||
// francisAddresses contains the runtime addresses parsed out of FrancisHost, and is empty when the actor runtime is embedded
|
||||
// It is automatically derived from FrancisHost
|
||||
francisAddresses []string
|
||||
}
|
||||
|
||||
var EnvConfig = defaultConfig()
|
||||
@@ -133,6 +161,7 @@ func defaultConfig() EnvConfigSchema {
|
||||
Host: "0.0.0.0",
|
||||
ActorsPort: "1414",
|
||||
ActorsHost: "0.0.0.0",
|
||||
FrancisHost: FrancisHostEmbedded,
|
||||
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
||||
}
|
||||
@@ -195,6 +224,18 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
|
||||
// Prepare the DB config
|
||||
prepareDbConfig(config)
|
||||
|
||||
// Resolve where the Francis actor runtime lives, which decides whether Pocket ID starts an embedded one
|
||||
err = prepareFrancisConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Show a warning if using a standalone Francis runtime as it's currently experimental and meant for development only
|
||||
// TODO: Remove when HA mode is ready
|
||||
if !config.HasEmbeddedFrancisRuntime() {
|
||||
slog.Warn("🚨🚨🚨 CONNECTING TO A STANDALONE FRANCIS RUNTIME IS EXPERIMENTAL AND MEANT FOR DEVELOPMENT ONLY 🚨🚨🚨")
|
||||
}
|
||||
|
||||
// Validate other required options
|
||||
err = validateAppURLs(config)
|
||||
if err != nil {
|
||||
@@ -231,6 +272,97 @@ func prepareDbConfig(config *EnvConfigSchema) {
|
||||
}
|
||||
}
|
||||
|
||||
// prepareFrancisConfig resolves FRANCIS_HOST into the list of standalone runtime addresses Pocket ID connects to
|
||||
// An empty value or the "embedded" constant keeps the actor runtime inside the Pocket ID process, and leaves the address list empty
|
||||
func prepareFrancisConfig(config *EnvConfigSchema) error {
|
||||
config.francisAddresses = nil
|
||||
|
||||
value := strings.TrimSpace(config.FrancisHost)
|
||||
if value == "" || value == FrancisHostEmbedded {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Any other value is one address, or a comma-separated list of addresses, of the standalone runtime replicas
|
||||
// Pocket ID dials them directly rather than resolving a service record, so each one must carry an explicit port
|
||||
parts := strings.Split(value, ",")
|
||||
addresses := make([]string, 0, len(parts))
|
||||
for _, address := range parts {
|
||||
address = strings.TrimSpace(address)
|
||||
if address == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
_, port, err := net.SplitHostPort(address)
|
||||
if err != nil || port == "" {
|
||||
return fmt.Errorf("invalid address '%s' in FRANCIS_HOST: addresses must be in the 'host:port' format", address)
|
||||
}
|
||||
|
||||
addresses = append(addresses, address)
|
||||
}
|
||||
|
||||
if len(addresses) == 0 {
|
||||
return errors.New("FRANCIS_HOST does not contain any address")
|
||||
}
|
||||
|
||||
// Credentials have to match the runtime's byte-for-byte, and reading one from a file (including a container secret) usually leaves a trailing newline behind, so surrounding whitespace is never meaningful here
|
||||
config.FrancisHostPSK = bytes.TrimSpace(config.FrancisHostPSK)
|
||||
|
||||
err := validateFrancisBootstrap(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config.francisAddresses = addresses
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateFrancisBootstrap checks the credential Pocket ID presents when joining a standalone Francis runtime
|
||||
// The runtime admits a host through exactly one bootstrap method, so configuring none or more than one is a configuration error rather than something to resolve by picking a winner
|
||||
func validateFrancisBootstrap(config *EnvConfigSchema) error {
|
||||
configured := make([]string, 0, 2)
|
||||
if len(config.FrancisHostPSK) > 0 {
|
||||
configured = append(configured, "FRANCIS_HOST_PSK")
|
||||
}
|
||||
if config.FrancisHostJWTFile != "" {
|
||||
configured = append(configured, "FRANCIS_HOST_JWT_FILE")
|
||||
}
|
||||
|
||||
switch len(configured) {
|
||||
case 1:
|
||||
// Exactly one method, which is what the runtime expects
|
||||
case 0:
|
||||
return errors.New("one of FRANCIS_HOST_PSK or FRANCIS_HOST_JWT_FILE is required when FRANCIS_HOST points to a standalone Francis runtime")
|
||||
default:
|
||||
return fmt.Errorf("only one host bootstrap method may be configured, but %s are both set", strings.Join(configured, " and "))
|
||||
}
|
||||
|
||||
// Francis rejects a shorter key, so checking the length here turns that into a configuration error at startup
|
||||
if len(config.FrancisHostPSK) > 0 && len(config.FrancisHostPSK) < francisHostPSKMinLength {
|
||||
return fmt.Errorf("FRANCIS_HOST_PSK must be at least %d bytes long", francisHostPSKMinLength)
|
||||
}
|
||||
|
||||
// A token read on every connection is useless if the file is not there when Pocket ID starts
|
||||
if config.FrancisHostJWTFile != "" {
|
||||
_, err := os.Stat(config.FrancisHostJWTFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("FRANCIS_HOST_JWT_FILE not found: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasEmbeddedFrancisRuntime returns true when Pocket ID runs the Francis actor runtime inside its own process, which is the case unless FRANCIS_HOST points to a standalone runtime
|
||||
func (c *EnvConfigSchema) HasEmbeddedFrancisRuntime() bool {
|
||||
return len(c.francisAddresses) == 0
|
||||
}
|
||||
|
||||
// FrancisAddresses returns the value of francisAddresses
|
||||
func (c *EnvConfigSchema) FrancisAddresses() []string {
|
||||
return c.francisAddresses
|
||||
}
|
||||
|
||||
func validateAppURLs(config *EnvConfigSchema) error {
|
||||
if err := validateURLWithoutPath(config.AppURL, "APP_URL"); err != nil {
|
||||
return err
|
||||
|
||||
@@ -396,6 +396,175 @@ func TestParseEnvConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestFrancisHostConfig(t *testing.T) {
|
||||
originalConfig := EnvConfig
|
||||
t.Cleanup(func() {
|
||||
EnvConfig = originalConfig
|
||||
})
|
||||
|
||||
// setBaseEnv sets the variables every valid configuration needs, so each subtest only sets what it is about
|
||||
setBaseEnv := func(t *testing.T) {
|
||||
t.Helper()
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
}
|
||||
|
||||
t.Run("should default to the embedded runtime", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, FrancisHostEmbedded, EnvConfig.FrancisHost)
|
||||
assert.Empty(t, EnvConfig.francisAddresses)
|
||||
assert.True(t, EnvConfig.HasEmbeddedFrancisRuntime())
|
||||
})
|
||||
|
||||
t.Run("should keep the embedded runtime when FRANCIS_HOST is empty", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST", "")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, EnvConfig.francisAddresses)
|
||||
assert.True(t, EnvConfig.HasEmbeddedFrancisRuntime())
|
||||
})
|
||||
|
||||
t.Run("should keep the embedded runtime when FRANCIS_HOST is 'embedded'", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST", "EMBEDDED")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, FrancisHostEmbedded, EnvConfig.FrancisHost) // lowercased
|
||||
assert.Empty(t, EnvConfig.francisAddresses)
|
||||
assert.True(t, EnvConfig.HasEmbeddedFrancisRuntime())
|
||||
})
|
||||
|
||||
t.Run("should parse a single remote runtime address", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST", "francis.example.com:8443")
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST_PSK", "bootstrap-psk-that-is-long-enough")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"francis.example.com:8443"}, EnvConfig.francisAddresses)
|
||||
assert.False(t, EnvConfig.HasEmbeddedFrancisRuntime())
|
||||
})
|
||||
|
||||
t.Run("should parse a comma-separated list of remote runtime addresses", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST", "one.example.com:8443, two.example.com:8443 ,[2001:db8::1]:8443")
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST_PSK", "bootstrap-psk-that-is-long-enough")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"one.example.com:8443", "two.example.com:8443", "[2001:db8::1]:8443"}, EnvConfig.francisAddresses)
|
||||
assert.False(t, EnvConfig.HasEmbeddedFrancisRuntime())
|
||||
})
|
||||
|
||||
t.Run("should fail when a remote runtime address has no port", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST", "francis.example.com")
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST_PSK", "bootstrap-psk-that-is-long-enough")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "invalid address 'francis.example.com' in FRANCIS_HOST")
|
||||
})
|
||||
|
||||
t.Run("should fail when FRANCIS_HOST only contains separators", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST", " , ")
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST_PSK", "bootstrap-psk-that-is-long-enough")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "FRANCIS_HOST does not contain any address")
|
||||
})
|
||||
|
||||
t.Run("should fail when no bootstrap method is configured", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST", "francis.example.com:8443")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "one of FRANCIS_HOST_PSK or FRANCIS_HOST_JWT_FILE is required")
|
||||
})
|
||||
|
||||
t.Run("should accept a bootstrap JWT file and keep its path", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
|
||||
// The path is what gets stored, not the contents: Francis re-reads the file on every connection so a rotated token is picked up
|
||||
jwtFile := t.TempDir() + "/token"
|
||||
require.NoError(t, os.WriteFile(jwtFile, []byte("header.payload.signature"), 0600))
|
||||
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST", "francis.example.com:8443")
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST_JWT_FILE", jwtFile)
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, jwtFile, EnvConfig.FrancisHostJWTFile)
|
||||
assert.False(t, EnvConfig.HasEmbeddedFrancisRuntime())
|
||||
})
|
||||
|
||||
t.Run("should fail when the bootstrap JWT file does not exist", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST", "francis.example.com:8443")
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST_JWT_FILE", "/nonexistent/token")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "FRANCIS_HOST_JWT_FILE not found")
|
||||
})
|
||||
|
||||
t.Run("should fail when more than one bootstrap method is configured", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
|
||||
jwtFile := t.TempDir() + "/token"
|
||||
require.NoError(t, os.WriteFile(jwtFile, []byte("header.payload.signature"), 0600))
|
||||
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST", "francis.example.com:8443")
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST_PSK", "bootstrap-psk-that-is-long-enough")
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST_JWT_FILE", jwtFile)
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "only one host bootstrap method may be configured, but FRANCIS_HOST_PSK and FRANCIS_HOST_JWT_FILE are both set")
|
||||
})
|
||||
|
||||
t.Run("should fail when the bootstrap PSK is too short", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST", "francis.example.com:8443")
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST_PSK", "too-short")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "FRANCIS_HOST_PSK must be at least 16 bytes long")
|
||||
})
|
||||
|
||||
t.Run("should read the bootstrap PSK and the CA from files", func(t *testing.T) {
|
||||
setBaseEnv(t)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
pskFile := tempDir + "/psk"
|
||||
caFile := tempDir + "/ca.pem"
|
||||
caContent := "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"
|
||||
// The trailing newline is what writing a secret to a file usually leaves behind, and it must not become part of the key
|
||||
require.NoError(t, os.WriteFile(pskFile, []byte("bootstrap-psk-that-is-long-enough\n"), 0600))
|
||||
require.NoError(t, os.WriteFile(caFile, []byte(caContent), 0600))
|
||||
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST", "francis.example.com:8443")
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_HOST_PSK_FILE", pskFile)
|
||||
t.Setenv("EXPERIMENTAL_FRANCIS_CA_FILE", caFile)
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte("bootstrap-psk-that-is-long-enough"), EnvConfig.FrancisHostPSK)
|
||||
assert.Equal(t, []byte(caContent), EnvConfig.FrancisCA)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build unit
|
||||
|
||||
// This file contains utils for unit tests and it's only built when the "unit" tag is set
|
||||
|
||||
package common
|
||||
|
||||
// SetFrancisAddresses sets a value for francisAddresses
|
||||
func (c *EnvConfigSchema) SetFrancisAddresses(v []string) {
|
||||
c.francisAddresses = v
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
@@ -33,7 +33,7 @@ type IPLocationResolver interface {
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Actors *local.Host
|
||||
Actors francishost.Host
|
||||
BaseURL string
|
||||
|
||||
Signer TokenService
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Actors *local.Host
|
||||
Actors francishost.Host
|
||||
|
||||
Users UserProvider
|
||||
EmailSender EmailSender
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
@@ -43,7 +43,7 @@ type ScimSyncScheduler interface {
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Actors *local.Host
|
||||
Actors francishost.Host
|
||||
HTTPClient *http.Client
|
||||
FileStorage storage.FileStorage
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"github.com/lestrrat-go/jwx/v4/jwa"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
@@ -42,7 +42,7 @@ type AuditLogger interface {
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Actors *local.Host
|
||||
Actors francishost.Host
|
||||
Config Config
|
||||
HTTPClient *http.Client
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
@@ -33,7 +33,7 @@ type UserProvider interface {
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Actors *local.Host
|
||||
Actors francishost.Host
|
||||
|
||||
Signer TokenService
|
||||
AuditLog AuditLogger
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/actor"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Actors *local.Host
|
||||
Actors francishost.Host
|
||||
HTTPClient *http.Client
|
||||
|
||||
// ScheduleDisabled keeps automatic synchronizations from being armed
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"io"
|
||||
)
|
||||
|
||||
// actorsBackupFileName is the name of the entry in the export ZIP that contains the actor host's data
|
||||
// ActorsBackupFileName is the name of the entry in the export ZIP that contains the actor host's data
|
||||
// The payload is Francis' own backup stream, which is a binary, versioned, provider-neutral format
|
||||
const actorsBackupFileName = "francis.bin"
|
||||
const ActorsBackupFileName = "francis.bin"
|
||||
|
||||
// ActorsBackupProvider backs up and restores the actor host's data: actor state, alarms, and dead-lettered jobs.
|
||||
type ActorsBackupProvider interface {
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestExportActorsBackup(t *testing.T) {
|
||||
|
||||
files := writeActorsBackupZip(t, NewExportService(nil, nil, actors))
|
||||
|
||||
require.Equal(t, map[string][]byte{actorsBackupFileName: actors.backupData}, files)
|
||||
require.Equal(t, map[string][]byte{ActorsBackupFileName: actors.backupData}, files)
|
||||
})
|
||||
|
||||
t.Run("adds nothing without a provider", func(t *testing.T) {
|
||||
@@ -69,7 +69,7 @@ func TestImportActorsBackup(t *testing.T) {
|
||||
actors := &stubActorsBackupProvider{}
|
||||
files := readZip(t, buildZip(t, map[string][]byte{
|
||||
"database.json": []byte("{}"),
|
||||
actorsBackupFileName: []byte("francis-backup-payload"),
|
||||
ActorsBackupFileName: []byte("francis-backup-payload"),
|
||||
}))
|
||||
|
||||
err := NewImportService(nil, nil, actors).importActorsBackup(t.Context(), files)
|
||||
@@ -92,7 +92,7 @@ func TestImportActorsBackup(t *testing.T) {
|
||||
|
||||
t.Run("surfaces restore errors", func(t *testing.T) {
|
||||
actors := &stubActorsBackupProvider{restoreErr: errors.New("a host is still connected")}
|
||||
files := readZip(t, buildZip(t, map[string][]byte{actorsBackupFileName: []byte("francis-backup-payload")}))
|
||||
files := readZip(t, buildZip(t, map[string][]byte{ActorsBackupFileName: []byte("francis-backup-payload")}))
|
||||
|
||||
err := NewImportService(nil, nil, actors).importActorsBackup(t.Context(), files)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/italypaleale/francis/actor"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"github.com/lestrrat-go/jwx/v4/jwa"
|
||||
"github.com/lestrrat-go/jwx/v4/jwk"
|
||||
"github.com/lestrrat-go/jwx/v4/jwt"
|
||||
@@ -51,7 +51,7 @@ type LdapSyncer interface {
|
||||
|
||||
type TestService struct {
|
||||
db *gorm.DB
|
||||
actors *local.Host
|
||||
actors francishost.Host
|
||||
jwtService *JwtService
|
||||
appConfigService *appconfig.AppConfigService
|
||||
ldapSyncer LdapSyncer
|
||||
@@ -69,7 +69,7 @@ const (
|
||||
e2eEmailVerificationToken = "2FZFSoupBdHyqIL65bWTsgCgHIhxlXup"
|
||||
)
|
||||
|
||||
func NewTestService(db *gorm.DB, actors *local.Host, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapSyncer LdapSyncer, fileStorage storage.FileStorage) (*TestService, error) {
|
||||
func NewTestService(db *gorm.DB, actors francishost.Host, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapSyncer LdapSyncer, fileStorage storage.FileStorage) (*TestService, error) {
|
||||
s := &TestService{
|
||||
db: db,
|
||||
actors: actors,
|
||||
|
||||
@@ -248,9 +248,9 @@ func (s *ExportService) addActorsBackupToZip(ctx context.Context, zipWriter *zip
|
||||
return nil
|
||||
}
|
||||
|
||||
w, err := zipWriter.Create(actorsBackupFileName)
|
||||
w, err := zipWriter.Create(ActorsBackupFileName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create %s in zip: %w", actorsBackupFileName, err)
|
||||
return fmt.Errorf("failed to create %s in zip: %w", ActorsBackupFileName, err)
|
||||
}
|
||||
|
||||
err = s.actors.Backup(ctx, w)
|
||||
|
||||
@@ -81,20 +81,20 @@ func (s *ImportService) importActorsBackup(ctx context.Context, files []*zip.Fil
|
||||
|
||||
var backupFile *zip.File
|
||||
for _, f := range files {
|
||||
if f.Name == actorsBackupFileName {
|
||||
if f.Name == ActorsBackupFileName {
|
||||
backupFile = f
|
||||
break
|
||||
}
|
||||
}
|
||||
if backupFile == nil {
|
||||
// Archives exported before Pocket ID included the actor host's data don't have that entry, in which case the existing data is left untouched
|
||||
slog.WarnContext(ctx, "The archive does not contain the actor host's data, which will be left unchanged", slog.String("file", actorsBackupFileName))
|
||||
slog.WarnContext(ctx, "The archive does not contain the actor host's data, which will be left unchanged", slog.String("file", ActorsBackupFileName))
|
||||
return nil
|
||||
}
|
||||
|
||||
rc, err := backupFile.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open %s: %w", actorsBackupFileName, err)
|
||||
return fmt.Errorf("failed to open %s: %w", ActorsBackupFileName, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
@@ -34,7 +34,7 @@ type ScimSyncScheduler interface {
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Actors *local.Host
|
||||
Actors francishost.Host
|
||||
|
||||
Signer TokenService
|
||||
AuditLog AuditLogger
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
francishost "github.com/italypaleale/francis/host"
|
||||
"github.com/lestrrat-go/jwx/v4/jwt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -29,7 +29,7 @@ type AuditLogger interface {
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
Actors *local.Host
|
||||
Actors francishost.Host
|
||||
AppURL string
|
||||
|
||||
Signer TokenService
|
||||
|
||||
Reference in New Issue
Block a user