feat(auth): integrate authentication with installation lifecycle

This commit is contained in:
2026-08-17 20:21:42 +02:00
parent 0a2c667231
commit 9558eaa508
30 changed files with 756 additions and 120 deletions
+99 -32
View File
@@ -27,18 +27,19 @@ type RestoreResult struct {
Verified bool
}
type restoreLock interface { Release() error }
type restoreLock interface{ Release() error }
type restoreVerify func(context.Context, config.Installation, archiveRunner) error
type restoreDependencies struct {
preflight func(context.Context, config.Installation, PreflightRequest) (PreflightResult, error)
checkpoint func(context.Context, config.Installation, CreateRequest) (Result, error)
acquireLock func(config.Installation) (restoreLock, error)
runner archiveRunner
sleep func(duration time.Duration)
restoreFile func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error
restoreVolume func(context.Context, config.Installation, VolumeMetadata, io.Reader) error
verify map[string]restoreVerify
preflight func(context.Context, config.Installation, PreflightRequest) (PreflightResult, error)
checkpoint func(context.Context, config.Installation, CreateRequest) (Result, error)
acquireLock func(config.Installation) (restoreLock, error)
runner archiveRunner
sleep func(duration time.Duration)
restoreFile func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error
restoreVolume func(context.Context, config.Installation, VolumeMetadata, io.Reader) error
resetAuthenticationState func(context.Context, config.Installation, archiveRunner) error
verify map[string]restoreVerify
}
// Restore runs the host transaction. Concrete host dependencies are intentionally kept outside
@@ -48,60 +49,126 @@ func Restore(ctx context.Context, installation config.Installation, request Rest
}
func restoreWithDependencies(ctx context.Context, installation config.Installation, request RestoreRequest, deps restoreDependencies) (result RestoreResult, resultErr error) {
if !request.Confirm { return RestoreResult{}, ErrRestoreConfirmationRequired }
if request.Archive == "" { return RestoreResult{}, errors.New("restore archive is required") }
if deps.preflight == nil || deps.checkpoint == nil || deps.acquireLock == nil || deps.runner == nil || deps.restoreFile == nil || deps.restoreVolume == nil || deps.verify == nil {
if !request.Confirm {
return RestoreResult{}, ErrRestoreConfirmationRequired
}
if request.Archive == "" {
return RestoreResult{}, errors.New("restore archive is required")
}
if deps.preflight == nil || deps.checkpoint == nil || deps.acquireLock == nil || deps.runner == nil || deps.restoreFile == nil || deps.restoreVolume == nil || deps.resetAuthenticationState == nil || deps.verify == nil {
return RestoreResult{}, errors.New("restore dependencies are incomplete")
}
preflight, err := deps.preflight(ctx, installation, PreflightRequest{Archive: request.Archive, Confirm: true, AllowExternalSecrets: true})
if err != nil { return RestoreResult{}, err }
if err != nil {
return RestoreResult{}, err
}
defer preflight.CloseArchive()
archive, err := preflight.RevalidateArchive()
if err != nil { return RestoreResult{}, err }
if err != nil {
return RestoreResult{}, err
}
checkpoint, err := deps.checkpoint(ctx, installation, CreateRequest{})
if err != nil { return RestoreResult{}, fmt.Errorf("create recovery checkpoint: %w", err) }
if err != nil {
return RestoreResult{}, fmt.Errorf("create recovery checkpoint: %w", err)
}
result.Checkpoint = checkpoint.Path
lock, err := deps.acquireLock(installation)
if err != nil { return result, err }
defer func() { if releaseErr := lock.Release(); releaseErr != nil && resultErr == nil { resultErr = releaseErr } }()
if err != nil {
return result, err
}
defer func() {
if releaseErr := lock.Release(); releaseErr != nil && resultErr == nil {
resultErr = releaseErr
}
}()
wasRunning, err := installationRunning(ctx, installation, deps.runner)
if err != nil { return result, err }
if err != nil {
return result, err
}
mutated := false
defer func() {
if resultErr != nil && mutated { _ = runCompose(context.Background(), installation, deps.runner, "stop") }
if resultErr != nil && mutated {
_ = runCompose(context.Background(), installation, deps.runner, "stop")
}
}()
if wasRunning {
if err := maintenance(ctx, installation, deps.runner, true); err != nil { return result, err }
if err := waitForNoActiveSessions(ctx, installation, deps.runner, request.Drain, deps.sleep); err != nil { return result, err }
if err := runCompose(ctx, installation, deps.runner, "stop"); err != nil { return result, err }
if err := maintenance(ctx, installation, deps.runner, true); err != nil {
return result, err
}
if err := waitForNoActiveSessions(ctx, installation, deps.runner, request.Drain, deps.sleep); err != nil {
return result, err
}
if err := runCompose(ctx, installation, deps.runner, "stop"); err != nil {
return result, err
}
}
reader, err := zip.NewReader(archive, preflight.ArchiveSize)
if err != nil { return result, fmt.Errorf("read verified restore archive: %w", err) }
if err != nil {
return result, fmt.Errorf("read verified restore archive: %w", err)
}
members := make(map[string]*zip.File, len(reader.File))
for _, member := range reader.File { members[member.Name] = member }
for _, member := range reader.File {
members[member.Name] = member
}
for _, entry := range preflight.Entries {
if entry.Kind == EntryVolume { continue }
if entry.Kind == EntryVolume {
continue
}
member := members[entry.Path]
if member == nil { return result, fmt.Errorf("verified archive is missing %q", entry.Path) }
if member == nil {
return result, fmt.Errorf("verified archive is missing %q", entry.Path)
}
stream, openErr := member.Open()
if openErr != nil { return result, fmt.Errorf("open verified archive member %q: %w", entry.Path, openErr) }
if openErr != nil {
return result, fmt.Errorf("open verified archive member %q: %w", entry.Path, openErr)
}
mutated = true
restoreErr := deps.restoreFile(ctx, installation, entry, stream)
closeErr := stream.Close()
if restoreErr != nil { return result, restoreErr }
if closeErr != nil { return result, closeErr }
if restoreErr != nil {
return result, restoreErr
}
if closeErr != nil {
return result, closeErr
}
}
if err := deps.resetAuthenticationState(ctx, installation, deps.runner); err != nil {
return result, fmt.Errorf("reset authentication state: %w", err)
}
if wasRunning {
if err := composeStartAndVerify(ctx, installation, deps.runner); err != nil { return result, err }
if err := composeStartAndVerify(ctx, installation, deps.runner); err != nil {
return result, err
}
result.Restarted = true
}
for _, name := range []string{"health", "doctor", "pi", "workspace"} {
check := deps.verify[name]
if check == nil { return result, fmt.Errorf("restore verification %q is unavailable", name) }
if err := check(ctx, installation, deps.runner); err != nil { return result, fmt.Errorf("restore verification %s: %w", name, err) }
if check == nil {
return result, fmt.Errorf("restore verification %q is unavailable", name)
}
if err := check(ctx, installation, deps.runner); err != nil {
return result, fmt.Errorf("restore verification %s: %w", name, err)
}
}
result.Verified = true
return result, nil
}
// resetAuthenticationState clears browser sessions and pending OIDC transactions without touching
// installation-global auth.yaml or users.yaml. The command runs as the unprivileged core user so
// the recreated state root is private to the service on both the local volume and server /data bind.
func resetAuthenticationState(ctx context.Context, installation config.Installation, runner archiveRunner) error {
result, err := runner.Run(ctx, installation.ComposeArgs(
"run", "--rm", "--no-deps", "--no-TTY", "--entrypoint", "sh", "core", "-ceu",
"rm -rf /data/auth && install -d -m 0700 /data/auth /data/auth/sessions /data/auth/oidc",
), nil)
if err != nil {
return dockerError("reset authentication state", result, err)
}
if result.ExitCode != 0 {
return dockerError("reset authentication state", result, errors.New("Compose returned a nonzero exit status"))
}
return nil
}