feat(auth): include authentication in workspace and tht diagnostics
This commit is contained in:
@@ -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}' ;;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user