feat(auth): include authentication in workspace and tht diagnostics

This commit is contained in:
2026-08-17 15:51:16 +02:00
parent cb0e873ed7
commit 3ed00ff086
19 changed files with 1050 additions and 34 deletions
+1
View File
@@ -1493,6 +1493,7 @@ case " $* " in
*"/settings "*) printf '%s\n' '{"provider":"provider","model":"model","thinking":"medium"}' ;;
*"/pi-management/test "*) printf '%s\n' '{"ready":true}' ;;
*" tht doctor --json"*) printf '%s\n' '{"ok":true}' ;;
*"dist/auth/diagnostic-command.js --json"*) printf '%s\n' '{"ready":true,"mode":"oidc","checks":[{"level":"info","code":"auth_ready","message":"Authentication is ready."}]}' ;;
*"/sessions?scope="*) printf '%s\n' '[]' ;;
*"/internal/maintenance/activate "*) printf '%s\n' '{"active":true,"admissions":0}' ;;
*"/internal/maintenance/deactivate "*) printf '%s\n' '{"active":false,"admissions":0}' ;;
+216 -2
View File
@@ -15,8 +15,11 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/output"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"golang.org/x/term"
"gopkg.in/yaml.v3"
@@ -24,6 +27,12 @@ import (
const maxPasswordFileBytes int64 = 1025
const authCheckTimeout = 45 * time.Second
const interactiveAuthCheckTimeout = 12 * time.Minute
const maxAuthDiagnosticOutputBytes = 64 * 1024
var errCommandRefused = errors.New("authentication command refused")
var writeNewAuthFile = safeio.WriteCanonicalNewFile
@@ -32,7 +41,12 @@ var removeAuthFile = safeio.RemoveCanonicalPrivateRegular
// Run implements the host-only authentication operator surface. It accepts password bytes only
// from an echo-free terminal or a bounded private file, and never writes them to either stream.
func Run(ctx context.Context, installation config.Installation, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
_ = ctx
return RunWithRunner(ctx, installation, args, stdin, stdout, stderr, compose.NewRunner(""))
}
// RunWithRunner retains the operator grammar while providing the shared shell-free runner seam
// used by the aggregate doctor and command tests.
func RunWithRunner(ctx context.Context, installation config.Installation, args []string, stdin io.Reader, stdout, stderr io.Writer, runner compose.Runner) int {
if len(args) == 0 {
return authFailure(stderr, "auth requires a subcommand")
}
@@ -54,12 +68,212 @@ func Run(ctx context.Context, installation config.Installation, args []string, s
}
return 0
case "check":
return authFailure(stderr, "auth check is not available in this release")
return checkCommand(ctx, installation, args[1:], stdout, stderr, runner)
default:
return authFailure(stderr, "unknown auth subcommand")
}
}
// AuthDiagnostic is the closed JSON contract emitted by the backend diagnostic command.
type AuthDiagnostic struct {
Level string `json:"level"`
Code string `json:"code"`
Message string `json:"message"`
Field *string `json:"field,omitempty"`
}
// AuthDiagnostics is intentionally isomorphic to backend AuthDiagnostics.
type AuthDiagnostics struct {
Ready bool `json:"ready"`
Mode string `json:"mode"`
Checks []AuthDiagnostic `json:"checks"`
}
func parseCheckArgs(args []string) (jsonMode, interactive bool, err error) {
for _, arg := range args {
switch arg {
case "--json":
if jsonMode {
return false, false, errCommandRefused
}
jsonMode = true
case "--interactive":
if interactive {
return false, false, errCommandRefused
}
interactive = true
default:
return false, false, errCommandRefused
}
}
return jsonMode, interactive, nil
}
func checkCommand(ctx context.Context, installation config.Installation, args []string, stdout, stderr io.Writer, runner compose.Runner) int {
jsonMode, interactive, err := parseCheckArgs(args)
if err != nil {
return authFailure(stderr, authMessage(err))
}
report, prompt, err := runCheck(ctx, installation, runner, interactive, false)
if err != nil {
fmt.Fprintln(stderr, "tht: authentication diagnostics could not be completed")
return 1
}
if interactive && prompt != "" {
fmt.Fprintln(stderr, prompt)
}
if jsonMode {
if err := json.NewEncoder(stdout).Encode(report); err != nil {
fmt.Fprintln(stderr, "tht: authentication diagnostic report could not be written")
return 1
}
} else {
status := "failed"
if report.Ready {
status = "passed"
}
fmt.Fprintf(stdout, "authentication: %s\n", status)
for _, check := range report.Checks {
if check.Level == "error" {
fmt.Fprintf(stdout, "%s: %s\n", check.Code, check.Message)
}
}
}
if report.Ready {
return 0
}
return 1
}
var authDiagnosticCodes = map[string]struct{}{
"auth_ready": {}, "auth_config_incomplete": {}, "auth_config_invalid": {}, "auth_session_store_invalid": {},
"local_user_registry_invalid": {}, "local_admin_missing": {}, "oidc_secret_missing": {},
"oidc_discovery_unreachable": {}, "oidc_issuer_mismatch": {}, "oidc_jwks_unreachable": {},
"oidc_group_catalog_unreachable": {}, "oidc_group_catalog_unauthorized": {}, "oidc_mapped_group_missing": {},
"oidc_mapped_group_ambiguous": {}, "oidc_groups_claim_invalid": {}, "oidc_device_flow_unavailable": {},
}
func validAuthDiagnostics(report AuthDiagnostics) bool {
if report.Mode != "local" && report.Mode != "oidc" && report.Mode != "upstream" && report.Mode != "none" && report.Mode != "mock" {
return false
}
if len(report.Checks) == 0 || len(report.Checks) > 129 {
return false
}
for _, check := range report.Checks {
if (check.Level != "error" && check.Level != "info") || check.Message == "" || len(check.Message) > 512 {
return false
}
if _, ok := authDiagnosticCodes[check.Code]; !ok {
return false
}
if check.Field != nil && (*check.Field == "" || len(*check.Field) > 512) {
return false
}
}
return true
}
func authenticationSecretValues(installation config.Installation) []string {
files, err := installation.SecretFiles()
if err != nil {
return nil
}
values, err := output.SecretValuesFromFiles(files)
if err != nil {
return nil
}
return values
}
func sanitizeAuthDiagnostics(report AuthDiagnostics, secrets []string) AuthDiagnostics {
for index := range report.Checks {
report.Checks[index].Message = output.Sanitize(report.Checks[index].Message, secrets)
if report.Checks[index].Field != nil {
field := output.Sanitize(*report.Checks[index].Field, secrets)
report.Checks[index].Field = &field
}
}
return report
}
func devicePrompt(stderr string, secrets []string) string {
if len(stderr) > maxAuthDiagnosticOutputBytes {
return ""
}
for _, line := range strings.Split(output.Sanitize(stderr, secrets), "\n") {
const prefix = "Open "
const separator = " and enter code "
if !strings.HasPrefix(line, prefix) {
continue
}
parts := strings.Split(strings.TrimPrefix(line, prefix), separator)
if len(parts) != 2 || !validDeviceVerificationURI(parts[0]) || !validDeviceUserCode(parts[1]) {
continue
}
return prefix + parts[0] + separator + parts[1]
}
return ""
}
func validDeviceVerificationURI(value string) bool {
parsed, err := url.Parse(value)
return err == nil && parsed.Scheme == "https" && parsed.Host != "" && parsed.User == nil && parsed.Fragment == ""
}
func validDeviceUserCode(value string) bool {
if len(value) < 4 || len(value) > 256 {
return false
}
for _, character := range value {
letter := (character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z')
digit := character >= '0' && character <= '9'
punctuation := character == '-' || character == '.' || character == '_' || character == '~'
if !letter && !digit && !punctuation {
return false
}
}
return true
}
// Check invokes the exact backend diagnostic command. Normal host checks always use one-shot
// Compose execution; aggregate doctor passes useRunningCore=true only after confirming core runs.
func Check(ctx context.Context, installation config.Installation, runner compose.Runner, interactive, useRunningCore bool) (AuthDiagnostics, error) {
report, _, err := runCheck(ctx, installation, runner, interactive, useRunningCore)
return report, err
}
func runCheck(ctx context.Context, installation config.Installation, runner compose.Runner, interactive, useRunningCore bool) (AuthDiagnostics, string, error) {
if runner == nil {
return AuthDiagnostics{}, "", errors.New("authentication diagnostic runner is unavailable")
}
timeout := authCheckTimeout
if interactive {
timeout = interactiveAuthCheckTimeout
}
bounded, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
command := []string{"run", "--rm", "--no-deps", "--no-TTY", "core", "node", "dist/auth/diagnostic-command.js", "--json"}
if useRunningCore {
command = []string{"exec", "-T", "core", "node", "dist/auth/diagnostic-command.js", "--json"}
}
if interactive {
command = append(command, "--interactive")
}
result, err := runner.Run(bounded, installation.ComposeArgs(command...), nil)
secrets := authenticationSecretValues(installation)
if err != nil || result.ExitCode != 0 || len(result.Stdout) > maxAuthDiagnosticOutputBytes || len(result.Stderr) > maxAuthDiagnosticOutputBytes {
return AuthDiagnostics{}, "", errors.New("authentication diagnostic command failed")
}
decoder := json.NewDecoder(strings.NewReader(result.Stdout))
decoder.DisallowUnknownFields()
var report AuthDiagnostics
if err := decoder.Decode(&report); err != nil || decoder.Decode(&struct{}{}) != io.EOF || !validAuthDiagnostics(report) {
return AuthDiagnostics{}, "", errors.New("authentication diagnostic report is invalid")
}
return sanitizeAuthDiagnostics(report, secrets), devicePrompt(result.Stderr, secrets), nil
}
func authFailure(stderr io.Writer, message string) int {
fmt.Fprintf(stderr, "tht: %s\n", message)
return 2
@@ -5,15 +5,126 @@ import (
"context"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
)
func TestAuthCheckRunsOneShotCoreDiagnosticWithPristineJSON(t *testing.T) {
installation := authInstallation(newAuthDirectory(t))
var calls [][]string
runner := runnerFunc(func(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
calls = append(calls, append([]string(nil), args...))
return compose.Result{Stdout: `{"ready":true,"mode":"oidc","checks":[{"level":"info","code":"auth_ready","message":"Authentication is ready."}]}` + "\n"}, nil
})
var stdout, stderr bytes.Buffer
code := RunWithRunner(context.Background(), installation, []string{"check", "--json"}, strings.NewReader(""), &stdout, &stderr, runner)
if code != 0 {
t.Fatalf("auth check = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
var report AuthDiagnostics
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
t.Fatalf("auth check stdout is not pristine JSON: %q: %v", stdout.String(), err)
}
if !report.Ready || report.Mode != "oidc" || len(report.Checks) != 1 || report.Checks[0].Code != "auth_ready" {
t.Fatalf("auth check report = %#v", report)
}
if stderr.Len() != 0 {
t.Fatalf("auth check stderr = %q", stderr.String())
}
if len(calls) != 1 {
t.Fatalf("Docker calls = %#v, want one", calls)
}
want := installation.ComposeArgs("run", "--rm", "--no-deps", "--no-TTY", "core", "node", "dist/auth/diagnostic-command.js", "--json")
if strings.Join(calls[0], "\x00") != strings.Join(want, "\x00") {
t.Fatalf("auth check Docker call = %#v, want %#v", calls[0], want)
}
}
func TestAuthCheckInteractiveForwardsOnlyTheValidatedDevicePrompt(t *testing.T) {
installation := authInstallation(newAuthDirectory(t))
var calls [][]string
runner := runnerFunc(func(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
calls = append(calls, append([]string(nil), args...))
return compose.Result{
Stdout: `{"ready":true,"mode":"oidc","checks":[{"level":"info","code":"auth_ready","message":"Authentication is ready."}]}` + "\n",
Stderr: "Open https://issuer.example.test/device and enter code ABCD-EFGH\nnot-a-device-prompt secret-sentinel\n",
}, nil
})
var stdout, stderr bytes.Buffer
code := RunWithRunner(context.Background(), installation, []string{"check", "--interactive"}, strings.NewReader(""), &stdout, &stderr, runner)
if code != 0 {
t.Fatalf("interactive auth check = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if stdout.String() != "authentication: passed\n" {
t.Fatalf("interactive auth stdout = %q", stdout.String())
}
if stderr.String() != "Open https://issuer.example.test/device and enter code ABCD-EFGH\n" {
t.Fatalf("interactive auth stderr = %q", stderr.String())
}
if strings.Contains(stdout.String()+stderr.String(), "secret-sentinel") {
t.Fatalf("interactive auth output leaked untrusted stderr: stdout=%q stderr=%q", stdout.String(), stderr.String())
}
if len(calls) != 1 {
t.Fatalf("Docker calls = %#v, want one", calls)
}
want := installation.ComposeArgs("run", "--rm", "--no-deps", "--no-TTY", "core", "node", "dist/auth/diagnostic-command.js", "--json", "--interactive")
if strings.Join(calls[0], "\x00") != strings.Join(want, "\x00") {
t.Fatalf("interactive auth command = %#v, want %#v", calls[0], want)
}
}
func TestAuthCheckRedactsFailedCoreOutputAndRejectsMalformedReports(t *testing.T) {
installation := authInstallation(newAuthDirectory(t))
secretPath := filepath.Join(t.TempDir(), "auth-check-secret")
if err := os.WriteFile(secretPath, []byte("auth-check-secret"), 0o600); err != nil {
t.Fatal(err)
}
installation.EnvFile = filepath.Join(t.TempDir(), "operator.env")
if err := os.WriteFile(installation.EnvFile, []byte("AUTH_CHECK_TOKEN_FILE="+secretPath+"\n"), 0o600); err != nil {
t.Fatal(err)
}
runner := runnerFunc(func(_ context.Context, _ []string, _ io.Reader) (compose.Result, error) {
return compose.Result{
Stdout: "not-json auth-check-secret token-sentinel /private/sentinel $argon2id$hash-sentinel",
Stderr: "auth-check-secret cookie-sentinel /private/sentinel",
ExitCode: 23,
}, errors.New("core failed")
})
var stdout, stderr bytes.Buffer
code := RunWithRunner(context.Background(), installation, []string{"check", "--json"}, strings.NewReader(""), &stdout, &stderr, runner)
if code != 1 {
t.Fatalf("auth check = %d, want 1; stderr=%q", code, stderr.String())
}
combined := stdout.String() + stderr.String()
leaked := strings.Contains(combined, "auth-check-secret") ||
strings.Contains(combined, "token-sentinel") ||
strings.Contains(combined, "cookie-sentinel") ||
strings.Contains(combined, "/private/sentinel") ||
strings.Contains(combined, "$argon2id$hash-sentinel")
if stdout.Len() != 0 || leaked {
t.Fatalf("auth check leaked a core failure: stdout=%q stderr=%q", stdout.String(), stderr.String())
}
}
type runnerFunc func(context.Context, []string, io.Reader) (compose.Result, error)
func (run runnerFunc) Run(ctx context.Context, args []string, input io.Reader) (compose.Result, error) {
return run(ctx, args, input)
}
func TestRunConfiguresLocalRegistryAndRedactsStatusJSON(t *testing.T) {
directory := newAuthDirectory(t)
passwordFile := writePasswordFile(t, "this is a local test password\n")
+35 -9
View File
@@ -12,6 +12,7 @@ import (
"strings"
"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/output"
@@ -98,7 +99,7 @@ func RunWithProbe(ctx context.Context, installation config.Installation, runner
return Report{}, errors.New("doctor requires an HTTP probe")
}
secretValues := secretValues(installation)
report := Report{Checks: make([]Check, 0, 11)}
report := Report{Checks: make([]Check, 0, 12)}
add := func(name, status, detail string) {
report.Checks = append(report.Checks, Check{Name: name, Status: status, Detail: output.SanitizeDetail(detail, secretValues)})
}
@@ -117,6 +118,7 @@ func RunWithProbe(ctx context.Context, installation config.Installation, runner
if !commandCheck(ctx, runner, []string{"version", "--format", "{{.Client.Version}}"}, secretValues, add, "docker", "Docker Engine") {
add("compose", StatusSkipped, "Docker Engine is unavailable")
add("configuration", StatusSkipped, "Docker Engine is unavailable")
add("authentication", StatusSkipped, "core is unavailable")
add("services", StatusSkipped, "Docker Engine is unavailable")
add("core-http", StatusSkipped, "core is unavailable")
add("frontend-http", StatusSkipped, "frontend is unavailable")
@@ -127,6 +129,7 @@ func RunWithProbe(ctx context.Context, installation config.Installation, runner
}
if !commandCheck(ctx, runner, []string{"compose", "version", "--short"}, secretValues, add, "compose", "Docker Compose") {
add("configuration", StatusSkipped, "Docker Compose is unavailable")
add("authentication", StatusSkipped, "core is unavailable")
add("services", StatusSkipped, "Docker Compose is unavailable")
add("core-http", StatusSkipped, "core is unavailable")
add("frontend-http", StatusSkipped, "frontend is unavailable")
@@ -150,7 +153,7 @@ func RunWithProbe(ctx context.Context, installation config.Installation, runner
add("configuration", StatusPassed, "Compose configuration and required volumes are valid")
}
status, statusAvailable := serviceStatus(ctx, installation, runner, secretValues, add)
status, statusAvailable, servicesCheck := serviceStatus(ctx, installation, runner, secretValues)
coreRunning := false
if statusAvailable {
var err error
@@ -159,6 +162,14 @@ func RunWithProbe(ctx context.Context, installation config.Installation, runner
coreRunning = false
}
}
if !configReady || !coreRunning {
add("authentication", StatusSkipped, "core is unavailable")
} else if authenticationCheck(ctx, installation, runner, secretValues) {
add("authentication", StatusPassed, "container-local authentication diagnostics passed")
} else {
add("authentication", StatusFailed, "container-local authentication diagnostics failed")
}
add(servicesCheck.Name, servicesCheck.Status, servicesCheck.Detail)
if !coreRunning {
add("core-http", StatusSkipped, "core is not running")
add("frontend-http", StatusSkipped, "core is not running")
@@ -226,18 +237,33 @@ func commandCheck(ctx context.Context, runner Runner, args []string, secrets []s
return true
}
func serviceStatus(ctx context.Context, installation config.Installation, runner Runner, secrets []string, add func(string, string, string)) (string, bool) {
func serviceStatus(ctx context.Context, installation config.Installation, runner Runner, secrets []string) (string, bool, Check) {
result, err := runner.Run(ctx, installation.ComposeArgs("ps", "--all", "--format", "json"), nil)
if err != nil {
add("services", StatusFailed, commandDetail("Compose service status", result, err, secrets))
return "", false
return "", false, Check{Name: "services", Status: StatusFailed, Detail: commandDetail("Compose service status", result, err, secrets)}
}
if err := service.Healthy(result.Stdout); err != nil {
add("services", StatusFailed, err.Error())
return result.Stdout, true
return result.Stdout, true, Check{Name: "services", Status: StatusFailed, Detail: err.Error()}
}
add("services", StatusPassed, "required services are running and reachable through Docker health checks")
return result.Stdout, true
return result.Stdout, true, Check{Name: "services", Status: StatusPassed, Detail: "required services are running and reachable through Docker health checks"}
}
func authenticationCheck(ctx context.Context, installation config.Installation, runner Runner, secrets []string) bool {
report, err := authconfig.Check(ctx, installation, runner, false, true)
if err != nil {
return false
}
if !report.Ready {
return false
}
// Check performs stream redaction before decoding; retain this sanitization call as a boundary
// if future report fields are added to the backend machine contract.
for _, check := range report.Checks {
if output.Sanitize(check.Message, secrets) != check.Message {
return false
}
}
return true
}
func workflowCheck(ctx context.Context, installation config.Installation, runner Runner, secrets []string, add func(string, string, string)) {
+8 -3
View File
@@ -52,7 +52,7 @@ func TestRunSkipsContainerDiagnosticsWhenCoreIsStopped(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if report.OK || checkStatus(report, "workflow") != "skipped" || checkStatus(report, "pi") != "skipped" {
if report.OK || checkStatus(report, "authentication") != "skipped" || checkStatus(report, "workflow") != "skipped" || checkStatus(report, "pi") != "skipped" {
t.Fatalf("Run() report = %#v, want stopped-core skips", report)
}
if strings.Contains(strings.Join(runner.calls, "\n"), " exec -T core ") {
@@ -69,11 +69,14 @@ func TestRunUsesOnlyContainerLocalWorkflowAndPiDiagnosticsWhenCoreRuns(t *testin
if err != nil {
t.Fatal(err)
}
if !report.OK || checkStatus(report, "workflow") != "passed" || checkStatus(report, "pi") != "passed" {
if !report.OK || checkStatus(report, "authentication") != "passed" || checkStatus(report, "workflow") != "passed" || checkStatus(report, "pi") != "passed" {
t.Fatalf("Run() report = %#v, want successful container diagnostics", report)
}
assertChecklist(t, report, []string{"descriptor", "files", "docker", "compose", "configuration", "services", "core-http", "frontend-http", "workspace-registry", "workflow", "pi"})
assertChecklist(t, report, []string{"descriptor", "files", "docker", "compose", "configuration", "authentication", "services", "core-http", "frontend-http", "workspace-registry", "workflow", "pi"})
calls := strings.Join(runner.calls, "\n")
if !strings.Contains(calls, "exec -T core node dist/auth/diagnostic-command.js --json") {
t.Fatalf("Run() calls = %s, want core-local authentication diagnostic", calls)
}
if !strings.Contains(calls, "exec -T core tht doctor --json") {
t.Fatalf("Run() calls = %s, want core-local workflow doctor", calls)
}
@@ -203,6 +206,8 @@ func (r *doctorRunner) Run(_ context.Context, args []string, _ io.Reader) (compo
return compose.Result{Stderr: r.workflowFailure, ExitCode: 23}, errors.New("workflow failed")
}
return compose.Result{Stdout: `{"ok":true,"checks":[]}`}, nil
case strings.Contains(call, "dist/auth/diagnostic-command.js --json"):
return compose.Result{Stdout: `{"ready":true,"mode":"oidc","checks":[{"level":"info","code":"auth_ready","message":"Authentication is ready."}]}`}, nil
case strings.Contains(call, "workspace-registry/state/active.json"):
if r.registryInvalid {
return compose.Result{ExitCode: 23, Stderr: "invalid workspace registry"}, errors.New("registry invalid")
+4 -2
View File
@@ -36,7 +36,7 @@ func TestRunBuildsStartsAndVerifiesInOrder(t *testing.T) {
want := []string{
"docker engine", "docker compose", "architecture", "compose config", "compose build",
"compose up", "health", "health", "doctor docker", "doctor compose", "compose config", "doctor config", "health", "core HTTP", "frontend HTTP", "workspace registry", "workflow doctor", "pi doctor",
"compose up", "health", "health", "doctor docker", "doctor compose", "compose config", "doctor config", "health", "authentication", "core HTTP", "frontend HTTP", "workspace registry", "workflow doctor", "pi doctor",
}
if got := collapseStages(runner.stages); strings.Join(got, " | ") != strings.Join(want, " | ") {
t.Fatalf("runner stages = %v, want %v", got, want)
@@ -133,7 +133,7 @@ func TestRunPiDoctorFailurePreservesCauseAndOffersRecovery(t *testing.T) {
_, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard)
assertRecoveryFailure(t, err, "setup doctor reported failed checks", "core")
assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config", "compose build", "compose up", "health", "doctor docker", "doctor compose", "compose config", "doctor config", "health", "core HTTP", "frontend HTTP", "workspace registry", "workflow doctor", "pi doctor")
assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config", "compose build", "compose up", "health", "doctor docker", "doctor compose", "compose config", "doctor config", "health", "authentication", "core HTTP", "frontend HTTP", "workspace registry", "workflow doctor", "pi doctor")
}
func TestRequireVolumesRequiresEveryInstallationVolume(t *testing.T) {
@@ -278,6 +278,8 @@ func setupStage(args []string) (string, compose.Result) {
return "health", compose.Result{}
case strings.HasSuffix(joined, " config --format json"):
return "doctor config", compose.Result{Stdout: renderedSetupConfig}
case strings.Contains(joined, "exec -T core node dist/auth/diagnostic-command.js --json"):
return "authentication", compose.Result{Stdout: `{"ready":true,"mode":"oidc","checks":[{"level":"info","code":"auth_ready","message":"Authentication is ready."}]}`}
case strings.Contains(joined, "exec -T core tht doctor --json"):
return "workflow doctor", compose.Result{Stdout: `{"ok":true,"components":{}}`}
case strings.Contains(joined, "exec -T core curl -fsS --max-time 5 http://127.0.0.1:8787/health"):