feat(setup): build start and verify ThothII
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user