Files
pocket-id/backend/internal/bootstrap/actors_client_bootstrap.go
Alessandro (Ale) Segala 7ee7caae8f feat: support JWT bootstrap and CLI access to a standalone Francis runtime
Pocket ID can now authenticate to a standalone runtime with a JWT as well
as a pre-shared key: FRANCIS_HOST_JWT carries the token inline, while
FRANCIS_HOST_JWT_FILE keeps a path that Francis re-reads on every
connection, so a rotated token (a Kubernetes projected service account
token, say) is picked up without a restart. Exactly one bootstrap method
must be configured, which the config validates up front.

The one-time-access-token command works against a standalone runtime
again. It has no actor host of its own, so it joins the cluster as a
client-only host for just long enough to write the token: that hosts no
actor, so the runtime never places one on it, and it disconnects before
the command returns.
2026-08-31 05:26:59 +00:00

79 lines
2.9 KiB
Go

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 bounds 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 runErr := <-runErrCh:
return fmt.Errorf("failed to connect to the Francis runtime: %w", runErr)
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()
runErr := <-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 runErr != nil && !errors.Is(runErr, context.Canceled):
return fmt.Errorf("error disconnecting from the Francis runtime: %w", runErr)
default:
return nil
}
}