wip: guided standalone installation and workspace checks

This commit is contained in:
Codex
2026-09-26 16:41:15 +02:00
parent 0d2e573e0d
commit 67ee52624c
15 changed files with 902 additions and 560 deletions
+62 -2
View File
@@ -40,9 +40,9 @@ When --installation is omitted, tht uses THOTHII_INSTALLATION or discovers one v
descriptor in the current project tree.
Commands:
setup [--configure-only] [--installation-id ID] [--profile local|server]
setup [--complete|--configure-only] [--installation-id ID] [--profile local|server]
[--shell-mode full|embedded] [--shell-default-locale BCP47-TAG] [--shell-adapter omics-portal]
Create or validate the local non-secret installation configuration.
Create, validate, and optionally complete the local installation.
installation migrate --output PATH --session-default PROVIDER/MODEL
--embedding-id PROVIDER/MODEL --embedding-dimensions N
Create a review-only schema-v2 candidate from all three legacy model sources.
@@ -86,6 +86,10 @@ Commands:
Verify a terminal installation, remove stale lifecycle files, and clear maintenance.
pi logs Show the latest 200 sanitized core log lines (bounded; no follow mode).
workspace inspect --workspace ID [--json]
workspace pull [--json]
Pull and activate the configured workspace repository.
workspace test [--json]
Test configured database, Evidence, Qdrant, and embedding connectivity.
workspace evidence consolidate --workspace ID [--json]
workspace evidence refresh --workspace ID [--json]
workspace evidence decide --workspace ID --source-id SHA --revision SHA --decision keep|replace [--json]
@@ -400,6 +404,11 @@ func parseSetupArgs(args []string) (setup.Request, error) {
flag := args[0]
args = args[1:]
switch flag {
case "--complete":
if request.Complete {
return setup.Request{}, errors.New("--complete may be supplied once")
}
request.Complete = true
case "--configure-only":
if request.ConfigureOnly {
return setup.Request{}, errors.New("--configure-only may be supplied once")
@@ -484,6 +493,9 @@ func parseSetupArgs(args []string) (setup.Request, error) {
*target = value
}
}
if request.Complete && request.ConfigureOnly {
return setup.Request{}, errors.New("--complete and --configure-only cannot be combined")
}
return request, nil
}
@@ -499,6 +511,9 @@ func writeRemovalTargets(outputWriter io.Writer, project string, targets []serve
}
func workspaceCommand(ctx context.Context, installation config.Installation, runner compose.Runner, args []string, secretValues []string, stdout, stderr io.Writer) int {
if len(args) > 0 && (args[0] == "pull" || args[0] == "test") {
return workspaceOperatorCommand(ctx, installation, runner, args, secretValues, stdout, stderr)
}
request, err := workspaceops.Parse(args)
if err != nil {
return commandUsageError(stderr, err.Error())
@@ -527,6 +542,51 @@ func workspaceCommand(ctx context.Context, installation config.Installation, run
}
}
func workspaceOperatorCommand(ctx context.Context, installation config.Installation, runner compose.Runner, args []string, secretValues []string, stdout, stderr io.Writer) int {
action := "workspace-" + args[0]
jsonMode := false
for _, arg := range args[1:] {
if arg != "--json" || jsonMode {
return commandUsageError(stderr, "workspace pull/test accepts only --json")
}
jsonMode = true
}
result, err := runner.Run(ctx, installation.ComposeArgs("exec", "-T", "core", "node", "dist/operator-command.js", action), nil)
if err != nil {
return writeResult(result, err, secretValues, stdout, stderr)
}
var payload struct {
Ready bool `json:"ready"`
Status string `json:"status"`
}
if err := json.Unmarshal([]byte(result.Stdout), &payload); err != nil {
fmt.Fprintln(stderr, "tht: workspace operator returned invalid JSON")
return 1
}
if jsonMode {
fmt.Fprintln(stdout, output.Sanitize(result.Stdout, secretValues))
} else {
fmt.Fprintf(stdout, "workspace %s: %s\n", args[0], output.Sanitize(workspaceOperatorSummary(payload), secretValues))
}
if !payload.Ready {
return 1
}
return 0
}
func workspaceOperatorSummary(payload struct {
Ready bool `json:"ready"`
Status string `json:"status"`
}) string {
if payload.Status != "" {
return payload.Status
}
if payload.Ready {
return "ready"
}
return "failed"
}
func workspaceFailure(stderr io.Writer, err error, secretValues []string) int {
message := output.Sanitize(err.Error(), secretValues)
var operationErr *workspaceops.OperationError
+157 -8
View File
@@ -3,6 +3,8 @@ package setup
import (
"bufio"
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
@@ -13,6 +15,7 @@ import (
"sort"
"strconv"
"strings"
"unicode"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
@@ -26,6 +29,7 @@ const (
)
var installationIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`)
var secretBundleKeyPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,127}$`)
// atomicWriteNewFile is a seam for failure testing. Its implementation never replaces an existing
// file and leaves no final target until all content is synced.
@@ -44,6 +48,7 @@ type answers struct {
secretsFile, piAuthFile string
gitCredentialsFile, gitCAFile string
gitSSHKeyFile, gitKnownHostsFile string
complete bool
createSecretTemplates bool
}
@@ -116,6 +121,11 @@ func EnsureFiles(request Request, input io.Reader, output io.Writer) (FilesResul
if err := validateOrCreateSecretFiles(values, output); err != nil {
return FilesResult{}, err
}
if values.complete {
if err := validateCompleteProtectedFiles(values); err != nil {
return FilesResult{}, err
}
}
created := make([]string, 0, 2)
cleanup := func() {
@@ -163,6 +173,27 @@ func collectAnswers(request Request, input io.Reader, output io.Writer, root str
value := answersFromRequest(request)
value.installationID = firstNonEmpty(request.InstallationID, os.Getenv("THT_SETUP_INSTALLATION_ID"), "local")
value.profile = firstNonEmpty(request.Profile, os.Getenv("THT_SETUP_PROFILE"), "local")
value.complete = request.Complete
if request.Complete {
// The complete path has one predictable protected directory. The user only fills the
// bundle and any repository credential that is genuinely required; catalog passwords
// are generated below and never appear in the questionnaire.
directory := filepath.Join(root, "deploy", value.installationID, "secrets")
value.workspaceBranch = firstNonEmpty(value.workspaceBranch, "main")
value.secretsFile = firstNonEmpty(value.secretsFile, filepath.Join(directory, "thothii.secrets"))
value.piAuthFile = firstNonEmpty(value.piAuthFile, filepath.Join(directory, "pi-auth.json"))
if request.NonInteractive {
value.workspaceAccess = firstNonEmpty(value.workspaceAccess, accessForRemote(value.workspaceRemote))
if value.workspaceAccess == "ssh" {
value.gitSSHKeyFile = firstNonEmpty(value.gitSSHKeyFile, filepath.Join(directory, "workspace-git-key"))
value.gitKnownHostsFile = firstNonEmpty(value.gitKnownHostsFile, filepath.Join(directory, "workspace-git-known-hosts"))
} else {
value.gitCredentialsFile = firstNonEmpty(value.gitCredentialsFile, filepath.Join(directory, "workspace-git-credentials"))
value.gitCAFile = firstNonEmpty(value.gitCAFile, filepath.Join(directory, "workspace-git-ca.pem"))
}
}
value.createSecretTemplates = true
}
if request.NonInteractive {
return requireNonInteractiveAnswers(value)
}
@@ -190,6 +221,18 @@ func collectAnswers(request Request, input io.Reader, output io.Writer, root str
return answers{}, err
}
directory := filepath.Join(root, "deploy", value.installationID, "secrets")
if request.Complete {
value.secretsFile = firstNonEmpty(value.secretsFile, filepath.Join(directory, "thothii.secrets"))
value.piAuthFile = firstNonEmpty(value.piAuthFile, filepath.Join(directory, "pi-auth.json"))
if value.workspaceAccess == "ssh" {
value.gitSSHKeyFile = firstNonEmpty(value.gitSSHKeyFile, filepath.Join(directory, "workspace-git-key"))
value.gitKnownHostsFile = firstNonEmpty(value.gitKnownHostsFile, filepath.Join(directory, "workspace-git-known-hosts"))
} else {
value.gitCredentialsFile = firstNonEmpty(value.gitCredentialsFile, filepath.Join(directory, "workspace-git-credentials"))
value.gitCAFile = firstNonEmpty(value.gitCAFile, filepath.Join(directory, "workspace-git-ca.pem"))
}
return value, nil
}
if value.secretsFile, err = prompt(scanner, output, "Secret file location", firstNonEmpty(value.secretsFile, filepath.Join(directory, "thothii.secrets"))); err != nil {
return answers{}, err
}
@@ -216,11 +259,15 @@ func collectAnswers(request Request, input io.Reader, output io.Writer, root str
return answers{}, missingErr
}
if len(missing) > 0 {
answer, promptErr := prompt(scanner, output, "Create blank secret-file templates for the missing locations? Type yes to confirm", "no")
if promptErr != nil {
return answers{}, promptErr
if request.Complete {
value.createSecretTemplates = true
} else {
answer, promptErr := prompt(scanner, output, "Create blank secret-file templates for the missing locations? Type yes to confirm", "no")
if promptErr != nil {
return answers{}, promptErr
}
value.createSecretTemplates = strings.EqualFold(answer, "yes")
}
value.createSecretTemplates = strings.EqualFold(answer, "yes")
}
return value, nil
}
@@ -379,6 +426,13 @@ func render(root, descriptorPath string, value answers) ([]byte, []byte, error)
if value.llmURL != "" {
lines = append(lines, "THT_LLM_URL="+dotenvValue(value.llmURL))
}
if value.complete {
passwordDirectory := filepath.Dir(value.secretsFile)
lines = append(lines,
"THT_CATALOG_RUNTIME_PASSWORD_SOURCE="+dotenvValue(filepath.Join(passwordDirectory, "catalog-runtime-password")),
"THT_CATALOG_MIGRATOR_PASSWORD_SOURCE="+dotenvValue(filepath.Join(passwordDirectory, "catalog-migrator-password")),
)
}
if value.profile == "server" {
installationDirectory := filepath.Dir(descriptorPath)
lines = append(lines,
@@ -474,10 +528,7 @@ func validateOrCreateSecretFiles(value answers, output io.Writer) error {
if err != nil {
return err
}
if len(missing) == 0 {
return nil
}
if !value.createSecretTemplates {
if len(missing) > 0 && !value.createSecretTemplates {
return fmt.Errorf("secret files are missing: %s; create them yourself or explicitly confirm blank secret-file templates", strings.Join(missing, ", "))
}
for _, path := range missing {
@@ -489,6 +540,104 @@ func validateOrCreateSecretFiles(value answers, output io.Writer) error {
}
fmt.Fprintf(output, "Created blank secret-file template: %s\n", path)
}
if value.complete {
for _, path := range catalogPasswordPaths(value) {
exists, err := inspectExistingSecretFile(path)
if err != nil {
return err
}
if exists {
continue
}
contents, err := generatedCatalogPassword()
if err != nil {
return fmt.Errorf("generate catalog password: %w", err)
}
if err := atomicWriteNewFile(path, contents, 0o600); err != nil {
return fmt.Errorf("create catalog password %s: %w", path, err)
}
fmt.Fprintf(output, "Created generated catalog password file: %s\n", path)
}
}
return nil
}
func catalogPasswordPaths(value answers) []string {
directory := filepath.Dir(value.secretsFile)
return []string{
filepath.Join(directory, "catalog-runtime-password"),
filepath.Join(directory, "catalog-migrator-password"),
}
}
func generatedCatalogPassword() ([]byte, error) {
value := make([]byte, 32)
if _, err := rand.Read(value); err != nil {
return nil, errors.New("secure random source is unavailable")
}
return []byte(hex.EncodeToString(value) + "\n"), nil
}
func validateCompleteProtectedFiles(value answers) error {
if err := validateSecretBundle(value.secretsFile); err != nil {
return err
}
// The generated catalog deliberately uses Pi's built-in provider. A syntactically empty
// auth store would let Docker start only to fail at the first provider check, so catch it
// before any image is built. Other model providers can be selected later in the descriptor.
contents, err := safeio.ReadCanonicalRegular(value.piAuthFile, maxSecretBytes)
if err != nil || strings.TrimSpace(string(contents)) == "" || strings.TrimSpace(string(contents)) == "{}" {
return fmt.Errorf("complete setup requires usable Pi credentials in %s", value.piAuthFile)
}
if value.workspaceAccess == "ssh" {
for name, path := range map[string]string{
"workspace Git SSH key": value.gitSSHKeyFile,
"workspace Git known-hosts": value.gitKnownHostsFile,
} {
contents, readErr := safeio.ReadCanonicalRegular(path, maxSecretBytes)
if readErr != nil || strings.TrimSpace(string(contents)) == "" {
return fmt.Errorf("complete setup requires usable %s in %s", name, path)
}
}
} else {
for name, path := range map[string]string{
"workspace Git credentials": value.gitCredentialsFile,
"workspace Git CA": value.gitCAFile,
} {
contents, readErr := safeio.ReadCanonicalRegular(path, maxSecretBytes)
if readErr != nil || strings.TrimSpace(string(contents)) == "" {
return fmt.Errorf("complete setup requires usable %s in %s", name, path)
}
}
}
return nil
}
func validateSecretBundle(path string) error {
contents, err := safeio.ReadCanonicalRegular(path, maxSecretBytes)
if err != nil {
return fmt.Errorf("complete setup cannot read the secret bundle %s", path)
}
seen := make(map[string]struct{})
for lineNumber, raw := range strings.Split(string(contents), "\n") {
line := strings.TrimSuffix(raw, "\r")
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
key, secret, found := strings.Cut(line, "=")
invalid := !found || !secretBundleKeyPattern.MatchString(key) || strings.TrimSpace(key) != key ||
secret == "" || strings.TrimSpace(secret) != secret ||
strings.Contains(strings.ToLower(secret), "replace-me") ||
strings.IndexFunc(secret, unicode.IsSpace) >= 0
if invalid {
return fmt.Errorf("complete setup found an invalid secret bundle entry at line %d", lineNumber+1)
}
if _, duplicate := seen[key]; duplicate {
return fmt.Errorf("complete setup found a duplicate secret bundle key %s", key)
}
seen[key] = struct{}{}
}
return nil
}
+44
View File
@@ -207,6 +207,50 @@ func TestEnsureFilesRequiresExplicitNonInteractiveAnswers(t *testing.T) {
}
}
func TestCompleteSetupCreatesProtectedPlaceholdersThenRequiresUsableCredentials(t *testing.T) {
root := newProject(t, "complete setup")
for name, value := range map[string]string{
"THT_SETUP_WORKSPACE_REMOTE": "git@git.example.invalid:team/workspaces.git",
"THT_SETUP_WORKSPACE_BRANCH": "main",
"THT_SETUP_WORKSPACE_ACCESS": "ssh",
} {
t.Setenv(name, value)
}
request := Request{ProjectRoot: root, InstallationID: "local", Profile: "local", Complete: true, NonInteractive: true}
if _, err := EnsureFiles(request, strings.NewReader(""), ioDiscard{}); err == nil || !strings.Contains(err.Error(), "usable Pi credentials") {
t.Fatalf("first complete setup error = %v, want the placeholder guidance", err)
}
secretRoot := filepath.Join(root, "deploy", "local", "secrets")
for path, contents := range map[string]string{
filepath.Join(secretRoot, "pi-auth.json"): "{\"deepseek\":{\"apiKey\":\"configured\"}}\n",
filepath.Join(secretRoot, "workspace-git-key"): "private-key\n",
filepath.Join(secretRoot, "workspace-git-known-hosts"): "git.example.invalid ssh-ed25519 AAAA\n",
} {
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
result, err := EnsureFiles(request, strings.NewReader(""), ioDiscard{})
if err != nil {
t.Fatal(err)
}
environment, err := os.ReadFile(result.EnvironmentPath)
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"THT_CATALOG_RUNTIME_PASSWORD_SOURCE", "THT_CATALOG_MIGRATOR_PASSWORD_SOURCE"} {
if !strings.Contains(string(environment), name+"=") {
t.Fatalf("complete environment misses %s: %s", name, environment)
}
}
for _, name := range []string{"catalog-runtime-password", "catalog-migrator-password"} {
contents, readErr := os.ReadFile(filepath.Join(secretRoot, name))
if readErr != nil || len(strings.TrimSpace(string(contents))) < 32 {
t.Fatalf("generated catalog password %s is unavailable or too short", name)
}
}
}
func TestEnsureFilesIncludesServerStorageLocations(t *testing.T) {
requireProjectedServerTestHost(t)
root := newProject(t, "server profile")
+5 -2
View File
@@ -1,12 +1,15 @@
// Package setup creates the local, non-secret configuration selected by tht setup.
package setup
// Request contains the stable setup-file inputs. Task 5 will use ConfigureOnly when it adds
// Compose validation and lifecycle orchestration.
// Request contains the stable setup-file inputs.
type Request struct {
ProjectRoot string
InstallationID string
Profile string
// Complete runs the installation-only steps that are safe to automate: catalog migration,
// stack startup, and the initial workspace pull. It intentionally does not invent database
// bindings or credentials that belong to the installation operator.
Complete bool
ConfigureOnly bool
NonInteractive bool
Answers Answers
+46 -2
View File
@@ -3,6 +3,7 @@ package setup
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -35,7 +36,8 @@ type Result struct {
}
// Run validates the host, creates or validates non-secret configuration, and by default builds,
// starts, and verifies the current checkout. ConfigureOnly stops after Compose rendering.
// starts, and verifies the current checkout. Complete additionally migrates the Catalog and
// imports the configured workspace repository. ConfigureOnly stops after Compose rendering.
func Run(ctx context.Context, runner compose.Runner, request Request, input io.Reader, output io.Writer) (Result, error) {
if runner == nil {
return Result{}, errors.New("setup requires a Docker command runner")
@@ -71,13 +73,33 @@ func Run(ctx context.Context, runner compose.Runner, request Request, input io.R
fmt.Fprintf(output, "Configuration is ready: %s\n", result.DescriptorPath)
return result, nil
}
if err := service.Start(ctx, installation, runner, true); err != nil {
if request.Complete {
if err := runCompose(ctx, runner, installation, "build"); err != nil {
return Result{}, fmt.Errorf("setup image build: %w", err)
}
if err := runCompose(ctx, runner, installation, "up", "--detach", "catalog-db"); err != nil {
return Result{}, fmt.Errorf("setup Catalog database start: %w", err)
}
if err := runCompose(ctx, runner, installation,
"--profile", "catalog-maintenance", "run", "--rm", "catalog-migrate"); err != nil {
return Result{}, fmt.Errorf("setup Catalog migration: %w", err)
}
}
if err := service.Start(ctx, installation, runner, !request.Complete); err != nil {
if strings.Contains(err.Error(), "image build") {
return Result{}, fmt.Errorf("setup %w", err)
}
return Result{}, withStartupRecovery(fmt.Errorf("setup %w", err), recoveryService(err))
}
result.Built, result.Started, result.Healthy = true, true, true
if request.Complete {
if err := runOperator(ctx, runner, installation, "workspace-pull"); err != nil {
return Result{}, withStartupRecovery(fmt.Errorf("setup workspace import: %w", err), "core")
}
if err := runOperator(ctx, runner, installation, "pi-test"); err != nil {
return Result{}, withStartupRecovery(fmt.Errorf("setup LLM credential test: %w", err), "core")
}
}
report, err := doctor.Run(ctx, installation, runner)
if err != nil {
return Result{}, withStartupRecovery(fmt.Errorf("setup doctor: %w", err), "core")
@@ -85,6 +107,9 @@ func Run(ctx context.Context, runner compose.Runner, request Request, input io.R
if !report.OK {
return Result{}, withStartupRecovery(errors.New("setup doctor reported failed checks"), "core")
}
if request.Complete {
fmt.Fprintln(output, "Workspace repository pulled and activated; run 'tht workspace test' after configuring each workspace database.")
}
fmt.Fprintf(output, "ThothII is ready at %s\nInstallation descriptor: %s\nNext: tht status\n", frontendURL(installation), result.DescriptorPath)
return result, nil
}
@@ -219,6 +244,25 @@ func runCompose(ctx context.Context, runner compose.Runner, installation config.
return nil
}
func runOperator(ctx context.Context, runner compose.Runner, installation config.Installation, action string) error {
result, err := runner.Run(ctx, installation.ComposeArgs(
"exec", "-T", "core", "node", "dist/operator-command.js", action,
), nil)
if err != nil {
if result.ExitCode != 0 {
return fmt.Errorf("Docker exited with status %d", result.ExitCode)
}
return err
}
var payload struct {
Ready *bool `json:"ready"`
}
if err := json.Unmarshal([]byte(result.Stdout), &payload); err != nil || payload.Ready == nil || !*payload.Ready {
return fmt.Errorf("operator action %s reported failure", action)
}
return nil
}
func composeFailure(result compose.Result, cause error) error {
if result.ExitCode != 0 {
return fmt.Errorf("Docker exited with status %d", result.ExitCode)
+34
View File
@@ -68,6 +68,34 @@ func TestRunConfigureOnlyStopsAfterRenderedConfiguration(t *testing.T) {
}
}
func TestRunCompleteMigratesCatalogPullsWorkspaceAndTestsLLM(t *testing.T) {
root, request := setupRunFixture(t, false)
request.Complete = true
secretRoot := filepath.Join(root, "deploy", "ci", "secrets")
for path, contents := range map[string]string{
filepath.Join(secretRoot, "pi-auth.json"): "{\"deepseek\":{\"apiKey\":\"configured\"}}\n",
filepath.Join(secretRoot, "workspace-git-key"): "private-key\n",
filepath.Join(secretRoot, "workspace-git-known-hosts"): "git.example.invalid ssh-ed25519 AAAA\n",
} {
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
runner := &setupRunner{health: []string{healthyServicesJSON}}
if _, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard); err != nil {
t.Fatal(err)
}
want := []string{
"docker engine", "docker compose", "architecture", "compose config", "compose build",
"catalog db", "catalog migrate", "compose up", "health", "workspace pull", "pi doctor",
"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("complete setup stages = %v, want %v", got, want)
}
}
func TestRunConfiguresAndStaticallyValidatesLocalAuthBeforeComposeRender(t *testing.T) {
projectRoot, request := setupRunFixture(t, true)
passwordFile := filepath.Join(projectRoot, "initial-admin-password")
@@ -421,6 +449,10 @@ func setupStage(args []string) (string, compose.Result) {
return "architecture", compose.Result{Stdout: "arm64\n"}
case strings.HasSuffix(joined, " config --quiet"):
return "compose config", compose.Result{}
case strings.HasSuffix(joined, " up --detach catalog-db"):
return "catalog db", compose.Result{}
case strings.HasSuffix(joined, " --profile catalog-maintenance run --rm catalog-migrate"):
return "catalog migrate", compose.Result{}
case strings.HasSuffix(joined, " build"):
return "compose build", compose.Result{}
case strings.HasSuffix(joined, " up --detach --remove-orphans"):
@@ -433,6 +465,8 @@ func setupStage(args []string) (string, compose.Result) {
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 node dist/operator-command.js workflow-doctor"):
return "workflow doctor", compose.Result{Stdout: `{"ready":true,"workspaces":1}`}
case strings.Contains(joined, "operator-command.js workspace-pull"):
return "workspace pull", compose.Result{Stdout: `{"ready":true,"status":"succeeded"}`}
case strings.Contains(joined, "exec -T core curl -fsS --max-time 5 http://127.0.0.1:8787/health"):
return "core HTTP", compose.Result{}
case strings.Contains(joined, "exec -T frontend wget -q -T 5 -O /dev/null http://127.0.0.1:8080/"):