229 lines
8.2 KiB
Go
229 lines
8.2 KiB
Go
package setup
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
|
)
|
|
|
|
func TestRunBuildsStartsAndVerifiesInOrder(t *testing.T) {
|
|
projectRoot, request := setupRunFixture(t, false)
|
|
runner := &setupRunner{health: []string{
|
|
unhealthyServicesJSON,
|
|
healthyServicesJSON,
|
|
}}
|
|
var output bytes.Buffer
|
|
|
|
result, err := Run(context.Background(), runner, request, strings.NewReader(""), &output)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
if !result.Configured || !result.Built || !result.Started || !result.Healthy {
|
|
t.Fatalf("Run() result = %#v, want all lifecycle phases complete", result)
|
|
}
|
|
if result.ProjectName == "" || result.DescriptorPath != filepath.Join(projectRoot, "deploy", "ci", "thothii-installation.yaml") {
|
|
t.Fatalf("Run() result = %#v, want descriptor below the project deployment directory", result)
|
|
}
|
|
|
|
want := []string{
|
|
"docker engine", "docker compose", "architecture", "compose config", "compose build",
|
|
"compose up", "health", "health", "doctor", "pi doctor",
|
|
}
|
|
if got := collapseStages(runner.stages); strings.Join(got, " | ") != strings.Join(want, " | ") {
|
|
t.Fatalf("runner stages = %v, want %v", got, want)
|
|
}
|
|
for _, text := range []string{"ThothII is ready", "http://127.0.0.1:8080", result.DescriptorPath} {
|
|
if !strings.Contains(output.String(), text) {
|
|
t.Errorf("output = %q, want %q", output.String(), text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunConfigureOnlyStopsAfterRenderedConfiguration(t *testing.T) {
|
|
_, request := setupRunFixture(t, true)
|
|
runner := &setupRunner{}
|
|
|
|
result, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
if !result.Configured || result.Built || result.Started || result.Healthy {
|
|
t.Fatalf("Run() result = %#v, want only configuration", result)
|
|
}
|
|
want := []string{"docker engine", "docker compose", "architecture", "compose config"}
|
|
if got := runner.stages; strings.Join(got, " | ") != strings.Join(want, " | ") {
|
|
t.Fatalf("runner stages = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRunPropagatesPreflightFailureBeforeWritingConfiguration(t *testing.T) {
|
|
projectRoot, request := setupRunFixture(t, false)
|
|
runner := &setupRunner{failureAt: "docker engine"}
|
|
|
|
_, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard)
|
|
if err == nil || !strings.Contains(err.Error(), "Docker Engine") {
|
|
t.Fatalf("Run() error = %v, want Docker Engine failure", err)
|
|
}
|
|
if _, statErr := os.Stat(filepath.Join(projectRoot, "deploy", "ci", "thothii-installation.yaml")); !errors.Is(statErr, os.ErrNotExist) {
|
|
t.Fatalf("descriptor was written after preflight failure: %v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestRunTimesOutWithPartialStartupGuidance(t *testing.T) {
|
|
_, request := setupRunFixture(t, false)
|
|
runner := &setupRunner{health: []string{unhealthyServicesJSON}}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
|
|
defer cancel()
|
|
|
|
_, err := Run(ctx, runner, request, strings.NewReader(""), io.Discard)
|
|
if err == nil {
|
|
t.Fatal("Run() error = nil, want health timeout")
|
|
}
|
|
for _, text := range []string{"frontend", "tht logs frontend", "tht status", "left running"} {
|
|
if !strings.Contains(err.Error(), text) {
|
|
t.Errorf("Run() error = %q, want %q", err, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
const unhealthyServicesJSON = `[
|
|
{"Service":"core","State":"running","Health":"healthy"},
|
|
{"Service":"frontend","State":"running","Health":"starting"},
|
|
{"Service":"qdrant","State":"running","Health":"healthy"},
|
|
{"Service":"embedding","State":"running","Health":"healthy"},
|
|
{"Service":"embedding-model-init","State":"exited","ExitCode":0}
|
|
]`
|
|
|
|
const healthyServicesJSON = `[
|
|
{"Service":"core","State":"running","Health":"healthy"},
|
|
{"Service":"frontend","State":"running","Health":"healthy"},
|
|
{"Service":"qdrant","State":"running","Health":"healthy"},
|
|
{"Service":"embedding","State":"running","Health":"healthy"},
|
|
{"Service":"embedding-model-init","State":"exited","ExitCode":0}
|
|
]`
|
|
|
|
type setupRunner struct {
|
|
stages []string
|
|
health []string
|
|
failureAt string
|
|
}
|
|
|
|
func (r *setupRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
|
|
stage, result := setupStage(args)
|
|
if stage == "doctor" && containsStage(r.stages, "doctor") {
|
|
stage = "pi doctor"
|
|
}
|
|
r.stages = append(r.stages, stage)
|
|
if stage == r.failureAt {
|
|
return compose.Result{ExitCode: 41}, errors.New("fixture failure")
|
|
}
|
|
if stage == "health" {
|
|
if len(r.health) == 0 {
|
|
result.Stdout = healthyServicesJSON
|
|
} else {
|
|
result.Stdout, r.health = r.health[0], r.health[1:]
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func containsStage(stages []string, wanted string) bool {
|
|
for _, stage := range stages {
|
|
if stage == wanted {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func collapseStages(stages []string) []string {
|
|
collapsed := make([]string, 0, len(stages))
|
|
for _, stage := range stages {
|
|
if stage == "pi doctor" && len(collapsed) > 0 && collapsed[len(collapsed)-1] == stage {
|
|
continue
|
|
}
|
|
collapsed = append(collapsed, stage)
|
|
}
|
|
return collapsed
|
|
}
|
|
|
|
func setupStage(args []string) (string, compose.Result) {
|
|
joined := strings.Join(args, " ")
|
|
switch {
|
|
case joined == "version --format {{.Server.Version}}":
|
|
return "docker engine", compose.Result{Stdout: "26.0.0\n"}
|
|
case joined == "compose version --short":
|
|
return "docker compose", compose.Result{Stdout: "v2.30.0\n"}
|
|
case joined == "version --format {{.Server.Arch}}":
|
|
return "architecture", compose.Result{Stdout: "arm64\n"}
|
|
case strings.HasSuffix(joined, " config --quiet"):
|
|
return "compose config", compose.Result{}
|
|
case strings.HasSuffix(joined, " build"):
|
|
return "compose build", compose.Result{}
|
|
case strings.HasSuffix(joined, " up --detach --remove-orphans"):
|
|
return "compose up", compose.Result{}
|
|
case strings.HasSuffix(joined, " ps --all --format json"):
|
|
return "health", compose.Result{}
|
|
case strings.HasSuffix(joined, " config --format json"):
|
|
return "doctor", compose.Result{Stdout: renderedSetupConfig}
|
|
case strings.Contains(joined, " ps -q core"):
|
|
return "pi doctor", compose.Result{Stdout: "core-id\n"}
|
|
case strings.HasPrefix(joined, "inspect --format"):
|
|
return "pi doctor", compose.Result{Stdout: "0.80.3\n"}
|
|
case strings.Contains(joined, "pi --version") || strings.Contains(joined, "PI_VERSION") || strings.Contains(joined, "test -w") || strings.Contains(joined, "test -r") || strings.Contains(joined, "127.0.0.1:8787/health"):
|
|
return "pi doctor", compose.Result{Stdout: "0.80.3\n"}
|
|
case strings.Contains(joined, "/pi-management/test"):
|
|
return "pi doctor", compose.Result{Stdout: `{"ready":true}`}
|
|
default:
|
|
return "unexpected: " + joined, compose.Result{}
|
|
}
|
|
}
|
|
|
|
const renderedSetupConfig = `{"volumes":{"settings":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}`
|
|
|
|
func setupRunFixture(t *testing.T, configureOnly bool) (string, Request) {
|
|
t.Helper()
|
|
temporaryRoot, err := filepath.EvalSymlinks(os.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
root, err := os.MkdirTemp(temporaryRoot, "tht-setup-run-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(root) })
|
|
for _, directory := range []string{".git", "backend", "frontend", "harness", "tools", "deploy", "docker"} {
|
|
if err := os.MkdirAll(filepath.Join(root, directory), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, path := range []string{
|
|
"compose.yaml", "deploy/compose.local.yaml", "deploy/compose.server.yaml", "deploy/compose.git-https.yaml",
|
|
} {
|
|
if err := os.WriteFile(filepath.Join(root, path), []byte("services: {}\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
secrets := filepath.Join(root, "deploy", "ci", "secrets")
|
|
if err := os.MkdirAll(secrets, 0o700); 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"),
|
|
CreateSecretTemplates: true,
|
|
},
|
|
}
|
|
}
|