feat(auth): integrate authentication with installation lifecycle
This commit is contained in:
@@ -364,6 +364,26 @@ func parseSetupArgs(args []string) (setup.Request, error) {
|
||||
target = &request.Answers.GitSSHKeyFile
|
||||
case "--git-known-hosts-file":
|
||||
target = &request.Answers.GitKnownHostsFile
|
||||
case "--auth-mode":
|
||||
target = &request.Answers.AuthMode
|
||||
case "--auth-public-url":
|
||||
target = &request.Answers.AuthPublicURL
|
||||
case "--auth-admin-user":
|
||||
target = &request.Answers.AuthAdminUser
|
||||
case "--auth-admin-display-name":
|
||||
target = &request.Answers.AuthAdminDisplayName
|
||||
case "--auth-password-file":
|
||||
target = &request.Answers.AuthPasswordFile
|
||||
case "--auth-issuer":
|
||||
target = &request.Answers.AuthIssuer
|
||||
case "--auth-client-id":
|
||||
target = &request.Answers.AuthClientID
|
||||
case "--auth-authentik-base-url":
|
||||
target = &request.Answers.AuthAuthentikBaseURL
|
||||
case "--auth-user-group":
|
||||
target = &request.Answers.AuthUserGroup
|
||||
case "--auth-admin-group":
|
||||
target = &request.Answers.AuthAdminGroup
|
||||
default:
|
||||
return setup.Request{}, fmt.Errorf("unknown setup option %q", flag)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,22 @@ func TestBackupCommandParsesSafeTransactionalOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupArgumentsRequireCompleteNonInteractiveAuthenticationInputs(t *testing.T) {
|
||||
request, err := parseSetupArgs([]string{
|
||||
"--non-interactive", "--auth-mode", "local", "--auth-public-url", "http://127.0.0.1:8080",
|
||||
"--auth-admin-user", "admin", "--auth-admin-display-name", "Initial Admin", "--auth-password-file", "/protected/password",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request.Answers.AuthMode != "local" || request.Answers.AuthPasswordFile != "/protected/password" {
|
||||
t.Fatalf("setup request = %#v", request)
|
||||
}
|
||||
if _, err := parseSetupArgs([]string{"--auth-mode", "local", "--auth-mode", "oidc"}); err == nil {
|
||||
t.Fatal("duplicate --auth-mode was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupCommandDispatchesWithoutDockerAndPrintsCustodyWarning(t *testing.T) {
|
||||
installation := config.Installation{Path: "/tmp/thothii-installation.yaml"}
|
||||
var received backup.CreateRequest
|
||||
@@ -1469,7 +1485,7 @@ case " $* " in
|
||||
if [ -n "${THT_FAKE_CONFIG:-}" ]; then
|
||||
printf '%s\n' "$THT_FAKE_CONFIG"
|
||||
else
|
||||
printf '%s\n' '{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}'
|
||||
printf '%s\n' '{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{},"auth-state":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}'
|
||||
fi ;;
|
||||
*" run --rm --no-deps --no-TTY session-migrate "*)
|
||||
if [ "${THT_FAKE_MIGRATION_EXIT:-0}" -ne 0 ]; then
|
||||
|
||||
@@ -21,7 +21,9 @@ import (
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/lifecycle"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/service"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -130,6 +132,10 @@ func createWithDependencies(ctx context.Context, installation config.Installatio
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("installation external secret references could not be read: %w", err)
|
||||
}
|
||||
authenticationPaths, err := authenticationConfigFiles(installation)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
revision, err := dependencies.revision(ctx, installation.ProjectDirectory)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
@@ -208,12 +214,12 @@ func createWithDependencies(ctx context.Context, installation config.Installatio
|
||||
InstallationID: installationID,
|
||||
CreatedAt: dependencies.now().UTC(),
|
||||
SourceRevision: revision,
|
||||
IncludesSecrets: request.IncludeSecrets && len(secretPaths) > 0,
|
||||
IncludesSecrets: request.IncludeSecrets && len(secretPaths)+len(authenticationPaths) > 0,
|
||||
ComposeProject: installation.ProjectName(),
|
||||
Images: images,
|
||||
Volumes: volumes,
|
||||
}
|
||||
if err := writeArchive(ctx, output, reservation, installation, request, secretPaths, manifest, volumes, dependencies); err != nil {
|
||||
if err := writeArchive(ctx, output, reservation, installation, request, secretPaths, authenticationPaths, manifest, volumes, dependencies); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
published = true
|
||||
@@ -228,8 +234,8 @@ func createWithDependencies(ctx context.Context, installation config.Installatio
|
||||
maintenanceActive = false
|
||||
}
|
||||
result = Result{Path: output}
|
||||
if request.IncludeSecrets {
|
||||
result.Warning = "The archive contains external secret files. Protect its custody and access."
|
||||
if manifest.IncludesSecrets {
|
||||
result.Warning = "The archive contains external secret files, including authentication configuration. Protect its custody and access."
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -631,7 +637,7 @@ func runCompose(ctx context.Context, installation config.Installation, runner ar
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeArchive(ctx context.Context, output string, reservation *archiveReservation, installation config.Installation, request CreateRequest, secretPaths []string, manifest Manifest, volumes []VolumeMetadata, dependencies dependencies) (resultErr error) {
|
||||
func writeArchive(ctx context.Context, output string, reservation *archiveReservation, installation config.Installation, request CreateRequest, secretPaths, authenticationPaths []string, manifest Manifest, volumes []VolumeMetadata, dependencies dependencies) (resultErr error) {
|
||||
directory := filepath.Dir(output)
|
||||
temporary, err := os.CreateTemp(directory, ".tht-backup-*.tmp")
|
||||
if err != nil {
|
||||
@@ -732,6 +738,23 @@ func writeArchive(ctx context.Context, output string, reservation *archiveReserv
|
||||
SourcePath: source, SHA256: "sha256:" + hex.EncodeToString(hash.Sum(nil)), Size: size, Sensitive: true,
|
||||
})
|
||||
}
|
||||
for index, source := range authenticationPaths {
|
||||
name := fmt.Sprintf("authentication-secrets/%03d-%s", index, filepath.Base(source))
|
||||
if request.IncludeSecrets {
|
||||
if err := addFile(name, "authentication-configuration", true, source); err != nil {
|
||||
return err
|
||||
}
|
||||
entry := &manifest.Entries[len(manifest.Entries)-1]
|
||||
entry.Kind, entry.SourcePath, entry.Sensitive = EntryExternalSecret, source, true
|
||||
continue
|
||||
}
|
||||
if filepath.Base(source) != "auth.yaml" {
|
||||
continue
|
||||
}
|
||||
if err := addAuthenticationReference(&manifest, name, source); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, volume := range volumes {
|
||||
headerName := "volumes/" + volume.LogicalName + ".tar"
|
||||
header := &zip.FileHeader{Name: headerName, Method: zip.Deflate}
|
||||
@@ -804,6 +827,51 @@ func configurationInputs(installation config.Installation) []configurationInput
|
||||
return inputs
|
||||
}
|
||||
|
||||
const maxAuthenticationConfigurationBytes = 1 << 20
|
||||
|
||||
func authenticationConfigFiles(installation config.Installation) ([]string, error) {
|
||||
directory := installation.AuthenticationDirectory()
|
||||
if directory == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if err := safeio.ValidatePrivateDirectory(directory); err != nil {
|
||||
return nil, errors.New("authentication configuration directory is unavailable or unsafe")
|
||||
}
|
||||
authPath := filepath.Join(directory, "auth.yaml")
|
||||
contents, err := safeio.ReadCanonicalRegular(authPath, maxAuthenticationConfigurationBytes)
|
||||
if err != nil {
|
||||
return nil, errors.New("authentication configuration is unavailable or unsafe")
|
||||
}
|
||||
var configuration struct {
|
||||
Mode string `yaml:"mode"`
|
||||
}
|
||||
if err := yaml.Unmarshal(contents, &configuration); err != nil || (configuration.Mode != "local" && configuration.Mode != "oidc") {
|
||||
return nil, errors.New("authentication configuration is unavailable or invalid")
|
||||
}
|
||||
paths := []string{authPath}
|
||||
if configuration.Mode == "local" {
|
||||
usersPath := filepath.Join(directory, "users.yaml")
|
||||
if _, err := safeio.ReadCanonicalRegular(usersPath, maxAuthenticationConfigurationBytes); err != nil {
|
||||
return nil, errors.New("authentication user registry is unavailable or unsafe")
|
||||
}
|
||||
paths = append(paths, usersPath)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func addAuthenticationReference(manifest *Manifest, name, source string) error {
|
||||
contents, err := safeio.ReadCanonicalRegular(source, maxAuthenticationConfigurationBytes)
|
||||
if err != nil {
|
||||
return errors.New("authentication configuration is unavailable or unsafe")
|
||||
}
|
||||
digest := sha256.Sum256(contents)
|
||||
manifest.Entries = append(manifest.Entries, Entry{
|
||||
Path: name, Kind: EntrySecretReference, Owner: "authentication-configuration", SourcePath: source,
|
||||
SHA256: "sha256:" + hex.EncodeToString(digest[:]), Size: int64(len(contents)), Sensitive: true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func volumeArchiveCommand(volume string) []string {
|
||||
return []string{"run", "--rm", "--network", "none", "--mount", "type=volume,src=" + volume + ",dst=/source,readonly", helperImage, "tar", "--numeric-owner", "-C", "/source", "-cf", "-", "."}
|
||||
}
|
||||
@@ -837,6 +905,10 @@ func archivePreservationRoots(installation config.Installation, secretPaths []st
|
||||
if err != nil || !info.IsDir() {
|
||||
return errors.New("server preservation root is unavailable")
|
||||
}
|
||||
authStateRoot := ""
|
||||
if variable == "THT_DATA_ROOT" {
|
||||
authStateRoot = filepath.Join(root, "auth")
|
||||
}
|
||||
err = filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
@@ -847,6 +919,12 @@ func archivePreservationRoots(installation config.Installation, secretPaths []st
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if authStateRoot != "" && path == authStateRoot {
|
||||
if entry.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return errors.New("server preservation root contains a symlink")
|
||||
}
|
||||
|
||||
@@ -70,6 +70,75 @@ func TestCreateWritesManifestLastWithConfigurationMetadataAndSevenVolumes(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateReferencesAuthFilesByDefaultAndArchivesThemOnlyWithSecretCustody(t *testing.T) {
|
||||
fixture := newBackupFixture(t, "local")
|
||||
authDirectory := filepath.Join(filepath.Dir(fixture.installation.Path), "auth")
|
||||
if err := os.Mkdir(authDirectory, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authPath := filepath.Join(authDirectory, "auth.yaml")
|
||||
usersPath := filepath.Join(authDirectory, "users.yaml")
|
||||
if err := os.WriteFile(authPath, []byte("version: 1\nmode: local\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(usersPath, []byte("users:\n - passwordHash: must-not-be-archived-by-default\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture.installation.Authentication.ConfigDirectory = authDirectory
|
||||
|
||||
defaultOutput := filepath.Join(t.TempDir(), "default.zip")
|
||||
defaultResult, err := createWithDependencies(context.Background(), fixture.installation, CreateRequest{Output: defaultOutput}, testDependencies(t, newBackupRunner(fixture.installation, false)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if defaultResult.Warning != "" {
|
||||
t.Fatalf("default backup warning = %q, want no custody warning", defaultResult.Warning)
|
||||
}
|
||||
defaultArchive := readFixtureArchive(t, defaultOutput)
|
||||
defaultBytes := bytes.Join(mapValues(defaultArchive.files), nil)
|
||||
for _, value := range []string{"mode: local", "must-not-be-archived-by-default"} {
|
||||
if bytes.Contains(defaultBytes, []byte(value)) {
|
||||
t.Fatalf("default backup contains authentication content %q", value)
|
||||
}
|
||||
}
|
||||
if !manifestHasReference(defaultArchive.manifest, authPath) || manifestHasReference(defaultArchive.manifest, usersPath) {
|
||||
t.Fatalf("default backup did not record only the auth.yaml configuration path: %#v", defaultArchive.manifest.Entries)
|
||||
}
|
||||
|
||||
secretOutput := filepath.Join(t.TempDir(), "with-auth-secrets.zip")
|
||||
secretResult, err := createWithDependencies(context.Background(), fixture.installation, CreateRequest{Output: secretOutput, IncludeSecrets: true, Confirm: true}, testDependencies(t, newBackupRunner(fixture.installation, false)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(secretResult.Warning, "custody") {
|
||||
t.Fatalf("secret backup warning = %q, want custody guidance", secretResult.Warning)
|
||||
}
|
||||
secretArchive := readFixtureArchive(t, secretOutput)
|
||||
for _, path := range []string{authPath, usersPath} {
|
||||
if !manifestHasArchivedSecret(secretArchive.manifest, path) {
|
||||
t.Fatalf("secret backup did not archive authentication file %q", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func manifestHasReference(manifest Manifest, sourcePath string) bool {
|
||||
for _, entry := range manifest.Entries {
|
||||
if entry.Kind == EntrySecretReference && entry.SourcePath == sourcePath && !entry.Archived {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func manifestHasArchivedSecret(manifest Manifest, sourcePath string) bool {
|
||||
for _, entry := range manifest.Entries {
|
||||
if entry.Kind == EntryExternalSecret && entry.SourcePath == sourcePath && entry.Archived && entry.Sensitive {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestCreateRestartsAndVerifiesAnInstallationThatWasRunning(t *testing.T) {
|
||||
fixture := newBackupFixture(t, "local")
|
||||
runner := newBackupRunner(fixture.installation, true)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -59,6 +59,58 @@ func TestRestoreStoppedInstallationRunsCheckpointRestoreAndVerification(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreResetsAuthenticationStateBeforeRestart(t *testing.T) {
|
||||
installation := preflightTestInstallation(t)
|
||||
archive := restoreArchive(t)
|
||||
runner := newBackupRunner(installation, true)
|
||||
deps := restoreTestDependencies(t, runner)
|
||||
var events []string
|
||||
deps.restoreFile = func(_ context.Context, _ config.Installation, entry ArchiveEntryMetadata, _ io.Reader) error {
|
||||
events = append(events, "file:"+entry.Path)
|
||||
return nil
|
||||
}
|
||||
deps.resetAuthenticationState = func(context.Context, config.Installation, archiveRunner) error {
|
||||
events = append(events, "reset-auth-state")
|
||||
return nil
|
||||
}
|
||||
for _, name := range []string{"health", "doctor", "pi", "workspace"} {
|
||||
name := name
|
||||
deps.verify[name] = func(context.Context, config.Installation, archiveRunner) error {
|
||||
events = append(events, name)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
result, err := restoreWithDependencies(context.Background(), installation, RestoreRequest{Archive: archive, Confirm: true}, deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Restarted || !result.Verified {
|
||||
t.Fatalf("restore result = %#v", result)
|
||||
}
|
||||
if got, want := events, []string{"file:configuration/operator.env", "reset-auth-state", "health", "doctor", "pi", "workspace"}; !equalStrings(got, want) {
|
||||
t.Fatalf("restore events = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetAuthenticationStateCreatesOnlyPrivateEmptyStateDirectories(t *testing.T) {
|
||||
installation := preflightTestInstallation(t)
|
||||
runner := &authenticationStateResetRunner{}
|
||||
|
||||
if err := resetAuthenticationState(context.Background(), installation, runner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := strings.Join(runner.args, "\x00")
|
||||
for _, required := range []string{
|
||||
"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",
|
||||
} {
|
||||
if !strings.Contains(joined, required) {
|
||||
t.Fatalf("authentication state reset command omits %q: %#v", required, runner.args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestorePreflightFailureDoesNotMutateTarget(t *testing.T) {
|
||||
installation := preflightTestInstallation(t)
|
||||
runner := newBackupRunner(installation, true)
|
||||
@@ -213,6 +265,19 @@ type fakeRestoreLock struct {
|
||||
release func()
|
||||
}
|
||||
|
||||
type authenticationStateResetRunner struct{ args []string }
|
||||
|
||||
func (runner *authenticationStateResetRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
|
||||
runner.args = append([]string(nil), args...)
|
||||
return compose.Result{}, nil
|
||||
}
|
||||
|
||||
func (runner *authenticationStateResetRunner) Stream(context.Context, []string, io.Reader, io.Writer) (compose.Result, error) {
|
||||
return compose.Result{}, errors.New("authentication state reset must not stream a volume archive")
|
||||
}
|
||||
|
||||
func (*authenticationStateResetRunner) SessionInventoryScope() string { return "mine" }
|
||||
|
||||
func (lock fakeRestoreLock) Release() error {
|
||||
if lock.release != nil {
|
||||
lock.release()
|
||||
@@ -248,6 +313,9 @@ func restoreTestDependencies(t *testing.T, runner archiveRunner) restoreDependen
|
||||
restoreVolume: func(context.Context, config.Installation, VolumeMetadata, io.Reader) error {
|
||||
return nil
|
||||
},
|
||||
resetAuthenticationState: func(context.Context, config.Installation, archiveRunner) error {
|
||||
return nil
|
||||
},
|
||||
verify: map[string]restoreVerify{
|
||||
"health": func(context.Context, config.Installation, archiveRunner) error { return nil },
|
||||
"doctor": func(context.Context, config.Installation, archiveRunner) error { return nil },
|
||||
|
||||
@@ -353,7 +353,7 @@ func filePermissions(installation config.Installation) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateVolumes checks the seven persistent volumes required by a ThothII installation.
|
||||
// ValidateVolumes checks the eight persistent volumes required by a local ThothII installation.
|
||||
func ValidateVolumes(rendered string) error {
|
||||
var document struct {
|
||||
Volumes map[string]json.RawMessage `json:"volumes"`
|
||||
@@ -361,7 +361,7 @@ func ValidateVolumes(rendered string) error {
|
||||
if err := json.Unmarshal([]byte(rendered), &document); err != nil {
|
||||
return errors.New("Compose returned invalid rendered configuration")
|
||||
}
|
||||
for _, name := range []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models"} {
|
||||
for _, name := range []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models", "auth-state"} {
|
||||
if _, exists := document.Volumes[name]; !exists {
|
||||
return fmt.Errorf("rendered Compose configuration is missing required volume %s", name)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,17 @@ func TestRunReportsUnavailableDockerWithoutReturningAnExecutionError(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateVolumesRequiresAuthState(t *testing.T) {
|
||||
legacy := `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}}}`
|
||||
if err := ValidateVolumes(legacy); err == nil || !strings.Contains(err.Error(), "auth-state") {
|
||||
t.Fatalf("ValidateVolumes() error = %v, want missing auth-state", err)
|
||||
}
|
||||
withAuthState := `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{},"auth-state":{}}}`
|
||||
if err := ValidateVolumes(withAuthState); err != nil {
|
||||
t.Fatalf("ValidateVolumes() error = %v, want complete volume set", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Catches Docker availability short-circuiting a host file-permission failure.
|
||||
func TestRunChecksUnsafeFilesEvenWhenDockerIsUnavailable(t *testing.T) {
|
||||
installation := doctorInstallation(t, "")
|
||||
@@ -327,7 +338,7 @@ func assertChecklist(t *testing.T, report Report, want []string) {
|
||||
}
|
||||
}
|
||||
|
||||
const renderedConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}`
|
||||
const renderedConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{},"auth-state":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}`
|
||||
|
||||
const healthyServices = `[
|
||||
{"Service":"core","State":"running","Health":"healthy"},
|
||||
|
||||
@@ -65,6 +65,9 @@ func TestEnsureFilesCreatesDiscoverableConfigurationInProjectWithSpaces(t *testi
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(environment), "THT_AUTH_CONFIG_ROOT=") {
|
||||
t.Fatalf("generated environment does not declare the authentication config root: %s", environment)
|
||||
}
|
||||
for _, secretValue := range []string{"super-secret-value", "pi-secret-value", "private-key-value"} {
|
||||
if bytes.Contains(descriptor, []byte(secretValue)) || bytes.Contains(environment, []byte(secretValue)) {
|
||||
t.Fatalf("generated configuration contains a secret value %q", secretValue)
|
||||
|
||||
@@ -26,6 +26,16 @@ type Answers struct {
|
||||
GitCAFile string
|
||||
GitSSHKeyFile string
|
||||
GitKnownHostsFile string
|
||||
AuthMode string
|
||||
AuthPublicURL string
|
||||
AuthAdminUser string
|
||||
AuthAdminDisplayName string
|
||||
AuthPasswordFile string
|
||||
AuthIssuer string
|
||||
AuthClientID string
|
||||
AuthAuthentikBaseURL string
|
||||
AuthUserGroup string
|
||||
AuthAdminGroup string
|
||||
CreateSecretTemplates bool
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/authconfig"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/doctor"
|
||||
@@ -53,6 +54,9 @@ func Run(ctx context.Context, runner compose.Runner, request Request, input io.R
|
||||
return Result{}, fmt.Errorf("setup generated configuration is invalid: %w", err)
|
||||
}
|
||||
result := Result{DescriptorPath: files.DescriptorPath, ProjectName: installation.ProjectName(), Configured: true}
|
||||
if err := configureAuthentication(ctx, installation, request, input, output); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := runCompose(ctx, runner, installation, "config", "--quiet"); err != nil {
|
||||
return Result{}, fmt.Errorf("setup Compose configuration: %w", err)
|
||||
}
|
||||
@@ -78,6 +82,76 @@ func Run(ctx context.Context, runner compose.Runner, request Request, input io.R
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func configureAuthentication(ctx context.Context, installation config.Installation, request Request, input io.Reader, output io.Writer) error {
|
||||
directory := installation.AuthenticationDirectory()
|
||||
if _, _, err := authconfig.Load(directory); err == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(directory, "auth.yaml")); !errors.Is(err, os.ErrNotExist) {
|
||||
return errors.New("setup authentication configuration is invalid")
|
||||
}
|
||||
args, err := authenticationConfigureArgs(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exitCode := authconfig.Run(ctx, installation, args, input, io.Discard, output); exitCode != 0 {
|
||||
return errors.New("setup authentication configuration failed")
|
||||
}
|
||||
if _, _, err := authconfig.Load(directory); err != nil {
|
||||
return errors.New("setup authentication configuration is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func authenticationConfigureArgs(request Request) ([]string, error) {
|
||||
answers := request.Answers
|
||||
mode := answers.AuthMode
|
||||
if mode == "" && !request.NonInteractive {
|
||||
mode = "local"
|
||||
}
|
||||
if mode != "local" && mode != "oidc" {
|
||||
return nil, errors.New("setup requires --auth-mode local or oidc")
|
||||
}
|
||||
if mode == "local" && request.NonInteractive && (answers.AuthAdminUser == "" || answers.AuthAdminDisplayName == "" || answers.AuthPasswordFile == "") {
|
||||
return nil, errors.New("non-interactive local authentication requires --auth-admin-user, --auth-admin-display-name, and --auth-password-file")
|
||||
}
|
||||
publicURL := answers.AuthPublicURL
|
||||
if publicURL == "" && !request.NonInteractive && mode == "local" {
|
||||
publicURL = "http://127.0.0.1:8080"
|
||||
}
|
||||
if publicURL == "" {
|
||||
return nil, errors.New("setup requires --auth-public-url")
|
||||
}
|
||||
args := []string{"configure", "--mode", mode, "--public-url", publicURL}
|
||||
if mode == "local" {
|
||||
if answers.AuthAdminUser != "" {
|
||||
args = append(args, "--admin-user", answers.AuthAdminUser)
|
||||
}
|
||||
if answers.AuthAdminDisplayName != "" {
|
||||
args = append(args, "--admin-display-name", answers.AuthAdminDisplayName)
|
||||
}
|
||||
if answers.AuthPasswordFile != "" {
|
||||
args = append(args, "--password-file", answers.AuthPasswordFile)
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
for _, option := range []struct {
|
||||
name, value string
|
||||
}{
|
||||
{"--issuer", answers.AuthIssuer},
|
||||
{"--client-id", answers.AuthClientID},
|
||||
{"--authentik-base-url", answers.AuthAuthentikBaseURL},
|
||||
{"--user-group", answers.AuthUserGroup},
|
||||
{"--admin-group", answers.AuthAdminGroup},
|
||||
} {
|
||||
if option.value == "" {
|
||||
return nil, errors.New("OIDC authentication requires complete provider and group options")
|
||||
}
|
||||
args = append(args, option.name, option.value)
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func checkHost(ctx context.Context, runner compose.Runner, root string) error {
|
||||
checks := []struct {
|
||||
name string
|
||||
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/authconfig"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/doctor"
|
||||
)
|
||||
|
||||
@@ -65,6 +67,55 @@ func TestRunConfigureOnlyStopsAfterRenderedConfiguration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfiguresAndStaticallyValidatesLocalAuthBeforeComposeRender(t *testing.T) {
|
||||
projectRoot, request := setupRunFixture(t, true)
|
||||
passwordFile := filepath.Join(projectRoot, "initial-admin-password")
|
||||
if err := os.WriteFile(passwordFile, []byte("correct horse battery staple"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.NonInteractive = true
|
||||
request.Answers.AuthMode = "local"
|
||||
request.Answers.AuthPublicURL = "http://127.0.0.1:8080"
|
||||
request.Answers.AuthAdminUser = "admin"
|
||||
request.Answers.AuthAdminDisplayName = "Initial Admin"
|
||||
request.Answers.AuthPasswordFile = passwordFile
|
||||
runner := &setupRunner{}
|
||||
|
||||
if _, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installationPath := filepath.Join(projectRoot, "deploy", "ci", "thothii-installation.yaml")
|
||||
installation, err := config.Load(installationPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configuration, registry, err := authconfig.Load(installation.AuthenticationDirectory())
|
||||
if err != nil {
|
||||
t.Fatalf("setup did not create a statically valid authentication configuration: %v", err)
|
||||
}
|
||||
if configuration.Mode != "local" || len(registry.Users) != 1 || registry.Users[0].Username != "admin" {
|
||||
t.Fatalf("authentication configuration = %#v registry = %#v", configuration, registry)
|
||||
}
|
||||
assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config")
|
||||
}
|
||||
|
||||
func TestRunRejectsIncompleteNonInteractiveLocalAuthenticationBeforeComposeRender(t *testing.T) {
|
||||
_, request := setupRunFixture(t, true)
|
||||
request.NonInteractive = true
|
||||
request.Answers.AuthMode = "local"
|
||||
request.Answers.AuthPublicURL = ""
|
||||
request.Answers.AuthAdminUser = ""
|
||||
request.Answers.AuthAdminDisplayName = ""
|
||||
request.Answers.AuthPasswordFile = ""
|
||||
runner := &setupRunner{}
|
||||
|
||||
_, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "non-interactive local authentication") {
|
||||
t.Fatalf("Run() error = %v, want non-interactive local authentication guidance", err)
|
||||
}
|
||||
assertSetupStages(t, runner, "docker engine", "docker compose", "architecture")
|
||||
}
|
||||
|
||||
func TestRunPropagatesPreflightFailureBeforeWritingConfiguration(t *testing.T) {
|
||||
projectRoot, request := setupRunFixture(t, false)
|
||||
runner := &setupRunner{failureAt: "docker engine"}
|
||||
@@ -137,7 +188,7 @@ func TestRunPiDoctorFailurePreservesCauseAndOffersRecovery(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequireVolumesRequiresEveryInstallationVolume(t *testing.T) {
|
||||
all := []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models"}
|
||||
all := []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models", "auth-state"}
|
||||
for _, missing := range all {
|
||||
t.Run("missing "+missing, func(t *testing.T) {
|
||||
volumes := make([]string, 0, len(all)-1)
|
||||
@@ -301,7 +352,7 @@ func setupStage(args []string) (string, compose.Result) {
|
||||
}
|
||||
}
|
||||
|
||||
const renderedSetupConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}`
|
||||
const renderedSetupConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{},"auth-state":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}`
|
||||
|
||||
func renderedConfigForVolumes(volumes ...string) string {
|
||||
entries := make([]string, 0, len(volumes))
|
||||
@@ -338,12 +389,18 @@ func setupRunFixture(t *testing.T, configureOnly bool) (string, Request) {
|
||||
if err := os.MkdirAll(secrets, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
passwordFile := filepath.Join(secrets, "initial-admin-password")
|
||||
if err := os.WriteFile(passwordFile, []byte("fixture authentication password"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root, Request{
|
||||
ProjectRoot: root, InstallationID: "ci", Profile: "local", ConfigureOnly: configureOnly, NonInteractive: true,
|
||||
Answers: Answers{
|
||||
WorkspaceRemote: "https://git.example.invalid/thothii-workspaces.git", WorkspaceBranch: "main", WorkspaceAccess: "https",
|
||||
SecretsFile: filepath.Join(secrets, "thothii.secrets"), PiAuthFile: filepath.Join(secrets, "pi-auth.json"),
|
||||
GitCredentialsFile: filepath.Join(secrets, "git-credentials"), GitCAFile: filepath.Join(secrets, "git-ca.pem"),
|
||||
AuthMode: "local", AuthPublicURL: "http://127.0.0.1:8080", AuthAdminUser: "admin",
|
||||
AuthAdminDisplayName: "Initial Admin", AuthPasswordFile: passwordFile,
|
||||
CreateSecretTemplates: true,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user