diff --git a/cmd/deploy.go b/cmd/deploy.go index 82f1735..750e454 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -1,17 +1,98 @@ package cmd import ( + "fmt" + "os/exec" + "strings" + + "github.com/robbycochran/harness-openshell/internal/gateway" "github.com/robbycochran/harness-openshell/internal/runner" + "github.com/robbycochran/harness-openshell/internal/status" "github.com/spf13/cobra" ) -func NewDeployCmd(harnessDir string) *cobra.Command { - return &cobra.Command{ +func NewDeployCmd(harnessDir, cli string) *cobra.Command { + var ( + local bool + remote bool + ) + + cmd := &cobra.Command{ Use: "deploy [--local|--remote]", Short: "Deploy or verify the gateway", - DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { - return runner.RunScript(harnessDir, "deploy.sh", args...) + if remote { + return runner.RunScript(harnessDir, "deploy.sh", "--remote") + } + if local { + gw := gateway.NewCLI(cli) + return deployLocal(gw) + } + return fmt.Errorf("specify --local or --remote") }, } + + cmd.Flags().BoolVar(&local, "local", false, "Verify local podman gateway") + cmd.Flags().BoolVar(&remote, "remote", false, "Deploy to OpenShift cluster") + + return cmd +} + +func deployLocal(gw gateway.Gateway) error { + // CLI check + cliPath := gw.CLIPath() + if cliPath == "" { + return fmt.Errorf("openshell CLI not found. Install it first:\n curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh") + } + + // Podman check + status.Section("Container Runtime") + podmanPath, _ := exec.LookPath("podman") + if podmanPath == "" { + status.Fail("Podman not found") + return fmt.Errorf("podman is required") + } + cmd := exec.Command("podman", "--version") + out, _ := cmd.Output() + status.OKf("Podman: %s", strings.TrimSpace(string(out))) + + // Find local gateway + status.Section("Gateway") + gateways, err := gw.GatewayList() + if err != nil { + return fmt.Errorf("listing gateways: %w", err) + } + + var localGW string + for _, g := range gateways { + if strings.Contains(g.Endpoint, "127.0.0.1") { + localGW = g.Name + break + } + } + + if localGW == "" { + status.Fail("No local gateway found") + fmt.Println() + fmt.Println(" Install OpenShell (auto-registers the gateway):") + fmt.Println(" curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh") + return fmt.Errorf("no local gateway") + } + + gw.GatewaySelect(localGW) + + if gw.InferenceGet() == nil { + status.OKf("%s (active, reachable)", localGW) + } else { + status.Failf("%s (not responding)", localGW) + fmt.Println() + fmt.Println(" Start the gateway:") + fmt.Println(" macOS: brew services start openshell") + fmt.Println(" Linux: systemctl --user start openshell") + return fmt.Errorf("gateway not responding") + } + + fmt.Println() + fmt.Println("Done.") + return nil } diff --git a/cmd/new_test.go b/cmd/new_test.go index 0c59d61..d858b22 100644 --- a/cmd/new_test.go +++ b/cmd/new_test.go @@ -41,13 +41,22 @@ 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 } +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) InferenceSet(string, string) error { return nil } +func (m *mockGW) InferenceRemove() error { return nil } +func (m *mockGW) ActiveGateway() string { return "" } +func (m *mockGW) ProviderCreate(string, string, gateway.ProviderCreateOpts) error { return nil } +func (m *mockGW) ProviderDelete(string) error { return nil } +func (m *mockGW) ProviderProfileImport(string) error { return nil } +func (m *mockGW) SettingsSet(string, string) error { return nil } +func (m *mockGW) SandboxList() ([]string, error) { return nil, nil } +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 } +func (m *mockGW) GatewayList() ([]gateway.GatewayInfo, error) { return nil, nil } +func (m *mockGW) GatewaySelect(string) error { return nil } func setupTestProfile(t *testing.T) string { t.Helper() diff --git a/cmd/providers.go b/cmd/providers.go index 843f164..168cc88 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -1,17 +1,167 @@ package cmd import ( - "github.com/robbycochran/harness-openshell/internal/runner" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/robbycochran/harness-openshell/internal/status" "github.com/spf13/cobra" ) -func NewProvidersCmd(harnessDir string) *cobra.Command { - return &cobra.Command{ - Use: "providers [--force]", +func NewProvidersCmd(harnessDir, cli string) *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "providers", Short: "Register providers with the gateway", - DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { - return runner.RunScript(harnessDir, "providers.sh", args...) + gw := gateway.NewCLI(cli) + return registerProviders(harnessDir, gw, force) }, } + + cmd.Flags().BoolVar(&force, "force", false, "Delete and recreate all providers") + + return cmd +} + +func registerProviders(harnessDir string, gw gateway.Gateway, force bool) error { + model := os.Getenv("OPENSHELL_MODEL") + if model == "" { + model = "claude-sonnet-4-6" + } + + // Force mode: require no running sandboxes + if force { + sandboxes, _ := gw.SandboxList() + if len(sandboxes) > 0 { + return fmt.Errorf("cannot --force with running sandboxes — delete them first") + } + for _, name := range []string{"github", "vertex-local", "atlassian"} { + gw.ProviderDelete(name) + } + fmt.Println("Deleted existing providers.") + } + + // Enable providers v2 + status.Section("Enabling providers v2") + if err := gw.SettingsSet("providers_v2_enabled", "true"); err != nil { + return fmt.Errorf("enabling providers v2: %w", err) + } + + // Import custom profiles + status.Section("Importing custom profiles") + profilesDir := filepath.Join(harnessDir, "sandbox", "profiles") + if err := gw.ProviderProfileImport(profilesDir); err != nil { + fmt.Println(" (already imported)") + } + + // Register providers + status.Section("Registering providers") + + // GitHub + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + if gw.ProviderGet("github") != nil { + if err := gw.ProviderCreate("github", "github", gateway.ProviderCreateOpts{ + Credentials: []string{"GITHUB_TOKEN=" + token}, + }); err != nil { + return fmt.Errorf("creating github provider: %w", err) + } + fmt.Println(" github — registered") + } else { + fmt.Println(" github — exists (use --force to recreate)") + } + } else { + fmt.Println(" github — skipped (GITHUB_TOKEN not set)") + } + + // Vertex AI + home, _ := os.UserHomeDir() + adcPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") + if adcPath == "" { + adcPath = filepath.Join(home, ".config", "gcloud", "application_default_credentials.json") + } + project := os.Getenv("ANTHROPIC_VERTEX_PROJECT_ID") + region := os.Getenv("CLOUD_ML_REGION") + if region == "" { + region = "global" + } + + if project == "" { + project = readADCProject(adcPath) + } + + if fileExists(adcPath) && project != "" { + if gw.ProviderGet("vertex-local") != nil { + if err := gw.ProviderCreate("vertex-local", "google-vertex-ai", gateway.ProviderCreateOpts{ + FromADC: true, + Configs: []string{ + "VERTEX_AI_PROJECT_ID=" + project, + "VERTEX_AI_REGION=" + region, + }, + }); err != nil { + return fmt.Errorf("creating vertex-local provider: %w", err) + } + fmt.Printf(" vertex-local — registered (project: %s, region: %s)\n", project, region) + } else { + fmt.Println(" vertex-local — exists (use --force to recreate)") + } + if err := gw.InferenceSet("vertex-local", model); err != nil { + return fmt.Errorf("setting inference: %w", err) + } + fmt.Printf(" inference — model: %s\n", model) + } else if !fileExists(adcPath) { + fmt.Printf(" vertex-local — skipped (no ADC file at %s)\n", adcPath) + } else { + fmt.Println(" vertex-local — skipped (no project ID — set ANTHROPIC_VERTEX_PROJECT_ID or run gcloud auth application-default login)") + } + + // Atlassian + if token := os.Getenv("JIRA_API_TOKEN"); token != "" { + if gw.ProviderGet("atlassian") != nil { + if err := gw.ProviderCreate("atlassian", "atlassian", gateway.ProviderCreateOpts{ + Credentials: []string{"JIRA_API_TOKEN=" + token}, + }); err != nil { + return fmt.Errorf("creating atlassian provider: %w", err) + } + fmt.Println(" atlassian — registered") + } else { + fmt.Println(" atlassian — exists (use --force to recreate)") + } + } else { + fmt.Println(" atlassian — skipped (JIRA_API_TOKEN not set)") + } + + // Show results + status.Section("Providers") + gw.ProviderList() + + status.Section("Inference") + gw.InferenceGet() + + fmt.Println() + fmt.Println("Done.") + return nil +} + +func readADCProject(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + var adc struct { + QuotaProjectID string `json:"quota_project_id"` + } + if json.Unmarshal(data, &adc) != nil { + return "" + } + return adc.QuotaProjectID +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil } diff --git a/cmd/teardown.go b/cmd/teardown.go index 6cb2e6f..66c25f1 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -1,17 +1,119 @@ package cmd import ( + "fmt" + + "github.com/robbycochran/harness-openshell/internal/gateway" "github.com/robbycochran/harness-openshell/internal/runner" + "github.com/robbycochran/harness-openshell/internal/status" "github.com/spf13/cobra" ) -func NewTeardownCmd(harnessDir string) *cobra.Command { - return &cobra.Command{ +func NewTeardownCmd(harnessDir, cli string) *cobra.Command { + var ( + sandboxes bool + providers bool + k8s bool + ) + + cmd := &cobra.Command{ Use: "teardown [--sandboxes] [--providers] [--k8s]", Short: "Tear down sandboxes, providers, or k8s resources", - DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { - return runner.RunScript(harnessDir, "teardown.sh", args...) + // Default: all flags true + if !sandboxes && !providers && !k8s { + sandboxes = true + providers = true + k8s = true + } + + gw := gateway.NewCLI(cli) + activeGW := gw.ActiveGateway() + + if activeGW != "" { + fmt.Printf("Active gateway: %s\n", activeGW) + } else { + fmt.Println("Active gateway: none") + } + fmt.Println() + + if sandboxes { + teardownSandboxes(gw, activeGW) + } + if providers { + if err := teardownProviders(gw, activeGW); err != nil { + return err + } + } + if k8s { + return teardownK8s(harnessDir) + } + + fmt.Println("Done.") + return nil }, } + + cmd.Flags().BoolVar(&sandboxes, "sandboxes", false, "Delete all sandboxes") + cmd.Flags().BoolVar(&providers, "providers", false, "Delete all providers") + cmd.Flags().BoolVar(&k8s, "k8s", false, "Delete k8s resources") + + return cmd +} + +func teardownSandboxes(gw gateway.Gateway, activeGW string) { + status.Section("Sandboxes") + if activeGW == "" { + status.Info("No active gateway, skipping") + fmt.Println() + return + } + + names, _ := gw.SandboxList() + if len(names) == 0 { + status.Info("None running") + } else { + for _, name := range names { + fmt.Printf(" Deleting %s\n", name) + gw.SandboxDelete(name) + } + } + fmt.Println() +} + +func teardownProviders(gw gateway.Gateway, activeGW string) error { + status.Section("Providers") + if activeGW == "" { + status.Info("No active gateway, skipping") + fmt.Println() + return nil + } + + remaining, _ := gw.SandboxList() + if len(remaining) > 0 { + return fmt.Errorf("cannot delete providers with running sandboxes — run: harness teardown --sandboxes") + } + + names, _ := gw.ProviderList() + if len(names) == 0 { + status.Info("None registered") + } else { + for _, name := range names { + fmt.Printf(" Deleting %s\n", name) + gw.ProviderDelete(name) + } + } + + status.Section("Inference") + if gw.InferenceRemove() == nil { + status.Info("Cleared") + } else { + status.Info("Already cleared") + } + fmt.Println() + return nil +} + +func teardownK8s(harnessDir string) error { + return runner.RunScript(harnessDir, "teardown.sh", "--k8s") } diff --git a/internal/gateway/cli.go b/internal/gateway/cli.go index 6fc3622..5f865e8 100644 --- a/internal/gateway/cli.go +++ b/internal/gateway/cli.go @@ -75,6 +75,28 @@ func (c *CLI) ProviderGet(name string) error { return c.silent("provider", "get", name) } +func (c *CLI) ProviderCreate(name, providerType string, opts ProviderCreateOpts) error { + args := []string{"provider", "create", "--name", name, "--type", providerType} + if opts.FromADC { + args = append(args, "--from-gcloud-adc") + } + for _, cred := range opts.Credentials { + args = append(args, "--credential", cred) + } + for _, cfg := range opts.Configs { + args = append(args, "--config", cfg) + } + return c.passthrough(args...) +} + +func (c *CLI) ProviderDelete(name string) error { + return c.silent("provider", "delete", name) +} + +func (c *CLI) ProviderProfileImport(dir string) error { + return c.silent("provider", "profile", "import", "--from", dir) +} + func (c *CLI) ProviderList() ([]string, error) { out, err := c.output("provider", "list") if err != nil { @@ -93,6 +115,73 @@ func (c *CLI) ProviderList() ([]string, error) { return names, nil } +func (c *CLI) InferenceRemove() error { + return c.silent("inference", "remove") +} + +func (c *CLI) InferenceSet(provider, model string) error { + return c.passthrough("inference", "set", "--provider", provider, "--model", model, "--no-verify") +} + +func (c *CLI) SettingsSet(key, value string) error { + return c.passthrough("settings", "set", "--global", "--key", key, "--value", value, "--yes") +} + +func (c *CLI) SandboxList() ([]string, error) { + out, err := c.output("sandbox", "list") + if err != nil { + return nil, err + } + var names []string + for i, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if i == 0 || strings.TrimSpace(line) == "" { + continue + } + cleaned := ansiRE.ReplaceAllString(line, "") + fields := strings.Fields(cleaned) + if len(fields) > 0 { + names = append(names, fields[0]) + } + } + return names, nil +} + +func (c *CLI) GatewayList() ([]GatewayInfo, error) { + out, err := c.output("gateway", "list") + if err != nil { + return nil, err + } + var gateways []GatewayInfo + for i, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if i == 0 || strings.TrimSpace(line) == "" { + continue + } + cleaned := ansiRE.ReplaceAllString(line, "") + active := strings.HasPrefix(cleaned, "*") + fields := strings.Fields(cleaned) + if len(fields) >= 2 { + name := fields[0] + if name == "*" && len(fields) >= 3 { + name = fields[1] + } + endpoint := fields[1] + if active && len(fields) >= 3 { + endpoint = fields[2] + } + gateways = append(gateways, GatewayInfo{ + Name: name, + Endpoint: endpoint, + Active: active, + }) + } + } + return gateways, nil +} + +func (c *CLI) GatewaySelect(name string) error { + return c.silent("gateway", "select", name) +} + func (c *CLI) SandboxCreate(opts SandboxCreateOpts) error { args := []string{"sandbox", "create", "--name", opts.Name} if opts.TTY { diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 09e6aaf..fae81e3 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -25,23 +25,41 @@ type Gateway interface { // ProviderList returns the names of all registered providers. ProviderList() ([]string, error) - // SandboxCreate creates a new sandbox. Stdin/stdout/stderr are connected - // for TTY mode. - SandboxCreate(opts SandboxCreateOpts) error + // Provider lifecycle + ProviderCreate(name, providerType string, opts ProviderCreateOpts) error + ProviderDelete(name string) error + ProviderProfileImport(dir string) error - // SandboxDelete deletes a sandbox by name. Ignores "not found" errors. - SandboxDelete(name string) error + // Inference config + InferenceSet(provider, model string) error + InferenceRemove() error - // SandboxConnect opens an interactive session to a running sandbox. - // In CLI mode this replaces the process (exec syscall). In gRPC mode - // this would use bidirectional streaming. - SandboxConnect(name string) error + // Settings + SettingsSet(key, value string) error - // SandboxUpload copies local files into a running sandbox. + // Sandbox lifecycle + SandboxList() ([]string, error) + SandboxCreate(opts SandboxCreateOpts) error + SandboxDelete(name string) error + SandboxConnect(name string) error SandboxUpload(name, localDir, remotePath string) error - - // SandboxExec runs a command inside a sandbox and waits for it to finish. SandboxExec(name string, command ...string) error + + // Gateway management + GatewayList() ([]GatewayInfo, error) + GatewaySelect(name string) error +} + +type ProviderCreateOpts struct { + Credentials []string // "KEY=VALUE" pairs + Configs []string // "KEY=VALUE" pairs + FromADC bool // --from-gcloud-adc +} + +type GatewayInfo struct { + Name string + Endpoint string + Active bool } type SandboxCreateOpts struct { @@ -50,7 +68,7 @@ type SandboxCreateOpts struct { Providers []string TTY bool Keep bool - UploadSrc string // local path (empty = no upload) - UploadDst string // remote path + UploadSrc string + UploadDst string Command []string } diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go index b072e74..f8db8fc 100644 --- a/internal/profile/profile_test.go +++ b/internal/profile/profile_test.go @@ -22,17 +22,26 @@ 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 } -func (m *mockGateway) SandboxConnect(string) error { return nil } -func (m *mockGateway) SandboxUpload(string, string, string) error { return nil } -func (m *mockGateway) SandboxExec(string, ...string) error { return nil } +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) InferenceSet(string, string) error { return nil } +func (m *mockGateway) InferenceRemove() error { return nil } +func (m *mockGateway) ActiveGateway() string { return "" } +func (m *mockGateway) ProviderCreate(string, string, gateway.ProviderCreateOpts) error { return nil } +func (m *mockGateway) ProviderDelete(string) error { return nil } +func (m *mockGateway) ProviderProfileImport(string) error { return nil } +func (m *mockGateway) ProviderList() ([]string, error) { return nil, nil } +func (m *mockGateway) SettingsSet(string, string) error { return nil } +func (m *mockGateway) SandboxList() ([]string, error) { return nil, nil } +func (m *mockGateway) SandboxCreate(gateway.SandboxCreateOpts) error { return nil } +func (m *mockGateway) SandboxDelete(string) error { return nil } +func (m *mockGateway) SandboxConnect(string) error { return nil } +func (m *mockGateway) SandboxUpload(string, string, string) error { return nil } +func (m *mockGateway) SandboxExec(string, ...string) error { return nil } +func (m *mockGateway) GatewayList() ([]gateway.GatewayInfo, error) { return nil, nil } +func (m *mockGateway) GatewaySelect(string) error { return nil } func TestParseFile_Full(t *testing.T) { dir := t.TempDir() diff --git a/main.go b/main.go index 50c70a7..7ddac19 100644 --- a/main.go +++ b/main.go @@ -25,10 +25,10 @@ func main() { root.AddCommand( cmd.NewNewCmd(harnessDir, cli), cmd.NewConnectCmd(cli), - cmd.NewDeployCmd(harnessDir), - cmd.NewTeardownCmd(harnessDir), + cmd.NewDeployCmd(harnessDir, cli), + cmd.NewTeardownCmd(harnessDir, cli), cmd.NewPreflightCmd(harnessDir, cli), - cmd.NewProvidersCmd(harnessDir), + cmd.NewProvidersCmd(harnessDir, cli), cmd.NewTestCmd(harnessDir), )