diff --git a/Makefile b/Makefile index cef0857..6f7285e 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ LAUNCHER_IMAGE := $(REGISTRY):launcher .PHONY: cli sandbox push-sandbox cli-launcher launcher push-launcher \ test-unit test test-podman test-ocp \ - test-go-podman test-go-ocp test-all clean help + test-go-podman test-go-ocp test-all validate clean help ## ── CLI ────────────────────────────────────────────────────────────── @@ -81,6 +81,31 @@ test-all: cli sandbox push-launcher ./test/test-flow.sh all --full ./test/test-flow.sh all --full --go +## Full validation: unit tests + bats (both paths) + integration (all 4 combos) +## Run this before every commit. +validate: cli sandbox push-launcher + @echo "=== Unit tests ===" + CGO_ENABLED=0 go test ./... + cd sandbox/launcher && go test ./... + @echo "" + @echo "=== Bats (Python) ===" + bats test/preflight.bats + @echo "" + @echo "=== Bats (Go) ===" + USE_GO=true bats test/preflight.bats + @echo "" + @echo "=== Integration: bash + podman ===" + ./test/test-flow.sh podman --full + @echo "" + @echo "=== Integration: Go + podman ===" + ./test/test-flow.sh podman --full --go + @echo "" + @echo "=== Integration: bash + OCP ===" + ./test/test-flow.sh ocp --full + @echo "" + @echo "=== Integration: Go + OCP ===" + ./test/test-flow.sh ocp --full --go + ## ── Convenience targets ─────────────────────────────────────────────── ## Clean built binaries diff --git a/cmd/new.go b/cmd/new.go index 887d9cb..1a20294 100644 --- a/cmd/new.go +++ b/cmd/new.go @@ -8,6 +8,7 @@ import ( "github.com/robbycochran/harness-openshell/internal/gateway" "github.com/robbycochran/harness-openshell/internal/profile" "github.com/robbycochran/harness-openshell/internal/runner" + "github.com/robbycochran/harness-openshell/internal/status" "github.com/spf13/cobra" ) @@ -119,14 +120,13 @@ func newLocal(opts newLocalOpts) error { fmt.Printf(" Image: %s\n", cfg.Image) // 4. Validate providers against profile - fmt.Println() - fmt.Println("=== Providers ===") + status.Section("Providers") registered, missing := profile.ValidateProviders(cfg.Providers, gw) for _, name := range registered { - fmt.Printf(" ✓ %s: attached\n", name) + status.OKf("%s: attached", name) } for _, name := range missing { - fmt.Printf(" ✗ %s: not registered (skipping)\n", name) + status.Failf("%s: not registered (skipping)", name) } if len(missing) > 0 && len(registered) == 0 { fmt.Println() diff --git a/cmd/new_test.go b/cmd/new_test.go index a4d9362..0c59d61 100644 --- a/cmd/new_test.go +++ b/cmd/new_test.go @@ -41,6 +41,10 @@ func (m *mockGW) SandboxDelete(name string) error { m.deletedNames = append(m.deletedNames, name) return nil } +func (m *mockGW) CLIVersion() string { return "openshell v0.0.55" } +func (m *mockGW) CLIPath() string { return "/usr/bin/openshell" } +func (m *mockGW) InferenceModel() string { return "" } +func (m *mockGW) ActiveGateway() string { return "" } func (m *mockGW) SandboxConnect(string) error { return nil } func (m *mockGW) SandboxUpload(string, string, string) error { return nil } func (m *mockGW) SandboxExec(string, ...string) error { return nil } diff --git a/cmd/preflight.go b/cmd/preflight.go index 62782e1..f3070f2 100644 --- a/cmd/preflight.go +++ b/cmd/preflight.go @@ -1,17 +1,33 @@ package cmd import ( - "github.com/robbycochran/harness-openshell/internal/runner" + "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/robbycochran/harness-openshell/internal/preflight" "github.com/spf13/cobra" ) -func NewPreflightCmd(harnessDir string) *cobra.Command { - return &cobra.Command{ - Use: "preflight [--strict]", +func NewPreflightCmd(harnessDir, cli string) *cobra.Command { + var strict bool + + cmd := &cobra.Command{ + Use: "preflight", Short: "Check environment prerequisites", - DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { - return runner.RunScript(harnessDir, "preflight.sh", args...) + // Support subcommands: available, names + if len(args) > 0 { + switch args[0] { + case "available": + return preflight.RunAvailable(harnessDir) + case "names": + return preflight.RunNames(harnessDir) + } + } + gw := gateway.NewCLI(cli) + return preflight.RunCheck(harnessDir, gw, strict) }, } + + cmd.Flags().BoolVar(&strict, "strict", false, "Exit 1 if required providers missing") + + return cmd } diff --git a/internal/gateway/cli.go b/internal/gateway/cli.go index 3dcb3d1..6fc3622 100644 --- a/internal/gateway/cli.go +++ b/internal/gateway/cli.go @@ -4,10 +4,13 @@ import ( "io" "os" "os/exec" + "regexp" "strings" "syscall" ) +var ansiRE = regexp.MustCompile(`\x1b\[[0-9;]*m`) + // CLI implements Gateway by shelling out to the openshell binary. type CLI struct { bin string // path or name of the openshell binary @@ -17,10 +20,57 @@ func NewCLI(bin string) *CLI { return &CLI{bin: bin} } +func (c *CLI) CLIVersion() string { + out, err := c.output("--version") + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func (c *CLI) CLIPath() string { + path, err := exec.LookPath(c.bin) + if err != nil { + return "" + } + return path +} + func (c *CLI) InferenceGet() error { return c.silent("inference", "get") } +func (c *CLI) InferenceModel() string { + out, err := c.output("inference", "get") + if err != nil { + return "" + } + for _, line := range strings.Split(string(out), "\n") { + cleaned := ansiRE.ReplaceAllString(line, "") + if strings.Contains(cleaned, "Model:") { + return strings.TrimSpace(strings.SplitN(cleaned, "Model:", 2)[1]) + } + } + return "" +} + +func (c *CLI) ActiveGateway() string { + out, err := c.output("gateway", "list") + if err != nil { + return "" + } + for _, line := range strings.Split(string(out), "\n") { + cleaned := ansiRE.ReplaceAllString(line, "") + if strings.HasPrefix(cleaned, "*") { + fields := strings.Fields(cleaned) + if len(fields) > 1 { + return fields[1] + } + } + } + return "" +} + func (c *CLI) ProviderGet(name string) error { return c.silent("provider", "get", name) } diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 6d4fb4a..09e6aaf 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -4,9 +4,21 @@ package gateway // shells out to the openshell binary; a future gRPC implementation will call // the gateway API directly. type Gateway interface { + // CLIVersion returns the openshell CLI version string, or empty if not found. + CLIVersion() string + + // CLIPath returns the path to the openshell CLI binary, or empty if not found. + CLIPath() string + // InferenceGet checks if the gateway is active and reachable. InferenceGet() error + // InferenceModel returns the configured inference model name, or empty. + InferenceModel() string + + // ActiveGateway returns the name of the currently selected gateway, or empty. + ActiveGateway() string + // ProviderGet checks if a provider is registered. Returns nil if it exists. ProviderGet(name string) error diff --git a/internal/preflight/check.go b/internal/preflight/check.go new file mode 100644 index 0000000..8df5507 --- /dev/null +++ b/internal/preflight/check.go @@ -0,0 +1,236 @@ +package preflight + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/robbycochran/harness-openshell/internal/status" +) + +func RunCheck(harnessDir string, gw gateway.Gateway, strict bool) error { + providersPath := os.Getenv("PROVIDERS_TOML") + if providersPath == "" { + providersPath = filepath.Join(harnessDir, "providers.toml") + } + configPath := os.Getenv("CONFIG_TOML") + if configPath == "" { + configPath = filepath.Join(harnessDir, "openshell.toml") + } + + allProviders, err := LoadProviders(providersPath) + if err != nil { + return err + } + config, err := LoadConfig(configPath) + if err != nil { + return err + } + providers := EnabledProviders(allProviders, config) + + hasFailures := false + + // CLI detection + fmt.Println("=== OpenShell CLI ===") + cliPath := gw.CLIPath() + cliFound := cliPath != "" + if !cliFound { + status.Fail("not found on PATH") + hasFailures = true + } else { + ver := gw.CLIVersion() + if ver != "" { + status.OK(ver) + } else { + status.OK("openshell") + } + status.Detail(cliPath) + } + + // Detect active gateway + activeGW := "" + if cliFound { + activeGW = gw.ActiveGateway() + } + isK8s := strings.Contains(activeGW, "-remote-") + + // Gateway check + gwOK := false + if isK8s { + status.Section("K8s gateway") + kubectlPath, _ := exec.LookPath("kubectl") + if kubectlPath == "" { + status.Fail("kubectl not found") + hasFailures = true + } else { + ctx := runOutput("kubectl", "config", "current-context") + if ctx != "" { + status.OKf("Cluster: %s", ctx) + if cliFound { + if gw.InferenceGet() == nil { + gwOK = true + model := gw.InferenceModel() + if model != "" { + status.OKf("Gateway reachable (model: %s)", model) + } else { + status.OK("Gateway reachable") + } + } else { + status.Fail("Gateway unreachable") + } + } + } else { + status.Fail("No cluster (kubectl not configured)") + hasFailures = true + } + } + } else { + status.Section("Podman gateway") + if cliFound { + if gw.InferenceGet() == nil { + gwOK = true + model := gw.InferenceModel() + if model != "" { + status.OKf("Reachable (model: %s)", model) + } else { + status.OK("Reachable") + } + } else { + status.Info("Not running") + } + + podmanPath, _ := exec.LookPath("podman") + if podmanPath != "" { + ver := runOutput("podman", "--version") + status.OKf("Podman: %s", ver) + } else { + status.Fail("Podman not found") + hasFailures = true + } + } else { + status.Info("CLI not available") + } + } + + // Registered providers + if cliFound && gwOK { + gwLabel := "podman" + if isK8s { + gwLabel = "k8s" + } + status.Section(fmt.Sprintf("Registered providers (%s)", gwLabel)) + for _, p := range providers { + if p.Type != "openshell" { + continue + } + if gw.ProviderGet(p.Name) == nil { + status.OK(p.Name) + } else { + status.Failf("%s: not registered — run ./setup-providers.sh", p.Name) + hasFailures = true + } + } + } + + // Provider inputs + status.Section("Provider inputs") + for _, p := range providers { + ok, details := CheckProvider(p) + if ok { + status.OK(p.Name) + } else { + status.Fail(p.Name) + if p.Required { + hasFailures = true + } + } + status.Detail(p.Description) + + for _, d := range details { + status.Sub(d) + } + + if p.Upstream != "" && !ok { + status.Sub(fmt.Sprintf("upstream: %s", p.Upstream)) + } + fmt.Println() + } + + // Summary + status.Summary(!hasFailures) + if hasFailures && strict { + os.Exit(1) + } + return nil +} + +func RunAvailable(harnessDir string) error { + providersPath := os.Getenv("PROVIDERS_TOML") + if providersPath == "" { + providersPath = filepath.Join(harnessDir, "providers.toml") + } + configPath := os.Getenv("CONFIG_TOML") + if configPath == "" { + configPath = filepath.Join(harnessDir, "openshell.toml") + } + + all, err := LoadProviders(providersPath) + if err != nil { + return err + } + config, _ := LoadConfig(configPath) + providers := EnabledProviders(all, config) + + var available []string + for _, p := range providers { + if p.Type != "openshell" { + continue + } + ok, _ := CheckProvider(p) + if ok { + available = append(available, p.Name) + } + } + fmt.Println(strings.Join(available, " ")) + return nil +} + +func RunNames(harnessDir string) error { + providersPath := os.Getenv("PROVIDERS_TOML") + if providersPath == "" { + providersPath = filepath.Join(harnessDir, "providers.toml") + } + configPath := os.Getenv("CONFIG_TOML") + if configPath == "" { + configPath = filepath.Join(harnessDir, "openshell.toml") + } + + all, err := LoadProviders(providersPath) + if err != nil { + return err + } + config, _ := LoadConfig(configPath) + providers := EnabledProviders(all, config) + + var names []string + for _, p := range providers { + if p.Type == "openshell" { + names = append(names, p.Name) + } + } + fmt.Println(strings.Join(names, " ")) + return nil +} + +func runOutput(name string, args ...string) string { + cmd := exec.Command(name, args...) + cmd.Stderr = nil + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} diff --git a/internal/preflight/preflight.go b/internal/preflight/preflight.go new file mode 100644 index 0000000..7e2bdc0 --- /dev/null +++ b/internal/preflight/preflight.go @@ -0,0 +1,229 @@ +package preflight + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/BurntSushi/toml" +) + +type Provider struct { + Name string `toml:"name"` + Type string `toml:"type"` + Description string `toml:"description"` + Required bool `toml:"required"` + Method string `toml:"method"` + Upstream string `toml:"upstream"` + Inputs []Input `toml:"inputs"` +} + +type Input struct { + Key string `toml:"key"` + Kind string `toml:"kind"` + Secret bool `toml:"secret"` +} + +type ProvidersFile struct { + Providers []Provider `toml:"providers"` +} + +type ConfigFile struct { + Providers []string `toml:"providers"` + ProvidersCustom []string `toml:"providers-custom"` +} + +func LoadProviders(path string) ([]Provider, error) { + var pf ProvidersFile + if _, err := toml.DecodeFile(path, &pf); err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + return pf.Providers, nil +} + +func LoadConfig(path string) (*ConfigFile, error) { + if _, err := os.Stat(path); err != nil { + return nil, nil + } + var cf ConfigFile + if _, err := toml.DecodeFile(path, &cf); err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + return &cf, nil +} + +func EnabledProviders(all []Provider, config *ConfigFile) []Provider { + if config == nil { + return all + } + enabled := make(map[string]bool) + for _, n := range config.Providers { + enabled[n] = true + } + for _, n := range config.ProvidersCustom { + enabled[n] = true + } + var result []Provider + for _, p := range all { + if enabled[p.Name] { + result = append(result, p) + } + } + return result +} + +func CheckInput(inp Input) (bool, string) { + switch inp.Kind { + case "env": + val := os.Getenv(inp.Key) + if val != "" { + display := val + if inp.Secret { + display = MaskValue(val, 4) + } + return true, fmt.Sprintf("✓ local env: %s=%s", inp.Key, display) + } + return false, fmt.Sprintf("✗ local env: %s not set → export %s=...", inp.Key, inp.Key) + + case "file": + path := expandPath(inp.Key) + if _, err := os.Stat(path); err == nil { + meta := FileMetadata(path) + if meta != nil && inp.Secret { + safe := pickKeys(meta, "project", "type") + masked := pickKeysExcept(meta, "project", "type") + parts := formatMeta(safe) + parts = append(parts, formatMeta(masked)...) + if len(parts) > 0 { + return true, fmt.Sprintf("✓ local file: %s (%s)", inp.Key, strings.Join(parts, ", ")) + } + } else if meta != nil { + parts := formatMeta(meta) + if len(parts) > 0 { + return true, fmt.Sprintf("✓ local file: %s (%s)", inp.Key, strings.Join(parts, ", ")) + } + } + return true, fmt.Sprintf("✓ local file: %s", inp.Key) + } + return false, fmt.Sprintf("✗ local file: %s not found", inp.Key) + + case "check": + expanded := os.ExpandEnv(inp.Key) + ok := runQuiet(expanded) + sym := "✓" + if !ok { + sym = "✗" + } + return ok, fmt.Sprintf("%s check: %s", sym, inp.Key) + } + + return false, fmt.Sprintf("%s: unknown kind '%s'", inp.Key, inp.Kind) +} + +func CheckProvider(p Provider) (bool, []string) { + issues := 0 + var details []string + for _, inp := range p.Inputs { + ok, detail := CheckInput(inp) + if !ok { + issues++ + } + details = append(details, detail) + } + return issues == 0, details +} + +func MaskValue(val string, show int) string { + if val == "" || len(val) <= show { + return "***" + } + return val[:show] + "***" +} + +func FileMetadata(path string) map[string]string { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil + } + meta := make(map[string]string) + if qp, ok := raw["quota_project_id"].(string); ok { + meta["project"] = qp + if t, ok := raw["type"].(string); ok { + meta["type"] = t + } + } + if installed, ok := raw["installed"].(map[string]any); ok { + if cid, ok := installed["client_id"].(string); ok { + meta["client_id"] = MaskValue(cid, 4) + } + } + if len(meta) == 0 { + return nil + } + return meta +} + +func expandPath(p string) string { + if strings.HasPrefix(p, "~/") { + home, _ := os.UserHomeDir() + return filepath.Join(home, p[2:]) + } + return os.ExpandEnv(p) +} + +func runQuiet(cmd string) bool { + ctx := exec.Command("bash", "-c", cmd) + ctx.Stdout = nil + ctx.Stderr = nil + done := make(chan error, 1) + go func() { done <- ctx.Run() }() + select { + case err := <-done: + return err == nil + case <-time.After(5 * time.Second): + ctx.Process.Kill() + return false + } +} + +func pickKeys(m map[string]string, keys ...string) map[string]string { + result := make(map[string]string) + for _, k := range keys { + if v, ok := m[k]; ok && v != "" { + result[k] = v + } + } + return result +} + +func pickKeysExcept(m map[string]string, except ...string) map[string]string { + skip := make(map[string]bool) + for _, k := range except { + skip[k] = true + } + result := make(map[string]string) + for k, v := range m { + if !skip[k] && v != "" { + result[k] = v + } + } + return result +} + +func formatMeta(m map[string]string) []string { + var parts []string + for k, v := range m { + if v != "" { + parts = append(parts, fmt.Sprintf("%s=%s", k, v)) + } + } + return parts +} diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go index 808f548..b072e74 100644 --- a/internal/profile/profile_test.go +++ b/internal/profile/profile_test.go @@ -22,7 +22,11 @@ func (m *mockGateway) ProviderGet(name string) error { return fmt.Errorf("not found") } +func (m *mockGateway) CLIVersion() string { return "" } +func (m *mockGateway) CLIPath() string { return "" } func (m *mockGateway) InferenceGet() error { return nil } +func (m *mockGateway) InferenceModel() string { return "" } +func (m *mockGateway) ActiveGateway() string { return "" } func (m *mockGateway) ProviderList() ([]string, error) { return nil, nil } func (m *mockGateway) SandboxCreate(gateway.SandboxCreateOpts) error { return nil } func (m *mockGateway) SandboxDelete(string) error { return nil } diff --git a/internal/status/status.go b/internal/status/status.go new file mode 100644 index 0000000..e81cea8 --- /dev/null +++ b/internal/status/status.go @@ -0,0 +1,19 @@ +package status + +import "fmt" + +func OK(msg string) { fmt.Println(" ✓ " + msg) } +func OKf(format string, a ...any) { fmt.Printf(" ✓ "+format+"\n", a...) } +func Fail(msg string) { fmt.Println(" ✗ " + msg) } +func Failf(format string, a ...any) { fmt.Printf(" ✗ "+format+"\n", a...) } +func Info(msg string) { fmt.Println(" - " + msg) } +func Detail(msg string) { fmt.Println(" " + msg) } +func Sub(msg string) { fmt.Println(" " + msg) } +func Section(title string) { fmt.Printf("\n=== %s ===\n", title) } +func Summary(ok bool) { + if ok { + fmt.Println("✓ Ready to launch") + } else { + fmt.Println("✗ Not ready — fix issues above") + } +} diff --git a/main.go b/main.go index 30c22e1..50c70a7 100644 --- a/main.go +++ b/main.go @@ -27,7 +27,7 @@ func main() { cmd.NewConnectCmd(cli), cmd.NewDeployCmd(harnessDir), cmd.NewTeardownCmd(harnessDir), - cmd.NewPreflightCmd(harnessDir), + cmd.NewPreflightCmd(harnessDir, cli), cmd.NewProvidersCmd(harnessDir), cmd.NewTestCmd(harnessDir), ) diff --git a/test/preflight.bats b/test/preflight.bats index 22a8cbf..4568444 100644 --- a/test/preflight.bats +++ b/test/preflight.bats @@ -51,9 +51,24 @@ STUB } run_preflight() { - # Override the TOML paths via env — providers.py reads ROOT-relative, - # so we patch it with a wrapper - python3 -c " + if [[ "${USE_GO:-}" == "true" ]]; then + # Run via the Go harness binary + local harness="$REPO_ROOT/harness" + local cmd="${1:-check}" + shift 2>/dev/null || true + case "$cmd" in + check) + PROVIDERS_TOML="$PROVIDERS_TOML" CONFIG_TOML="$CONFIG_TOML" \ + "$harness" preflight "$@" + ;; + available|names) + PROVIDERS_TOML="$PROVIDERS_TOML" CONFIG_TOML="$CONFIG_TOML" \ + "$harness" preflight "$cmd" + ;; + esac + else + # Run via Python (original) + python3 -c " import sys, os sys.path.insert(0, '$REPO_ROOT/bin/scripts/lib') os.chdir('$TEST_TMPDIR') @@ -73,6 +88,7 @@ elif cmd == 'available': elif cmd == 'names': providers.cmd_names() " "$@" + fi } # ── ENV input tests ────────────────────────────────────────────────── diff --git a/test/test-flow.sh b/test/test-flow.sh index 290c7b8..685ae77 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -150,10 +150,6 @@ test_errors() { # Bad profile step_fail "nonexistent profile" "$HARNESS" new --local --profile nonexistent --no-tty - # No gateway (teardown first, then try new without --local) - "$HARNESS" teardown --sandboxes --providers &>/dev/null || true - step_fail "new without gateway" "$HARNESS" new --name test-nogw --no-tty - # Teardown idempotency step "teardown (first)" "$HARNESS" teardown step "teardown (second)" "$HARNESS" teardown