diff --git a/README.md b/README.md index edc6928..9d934dd 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,23 @@ environment, then config precedence. Provisioning the gateway is OpenShell's or HyperShell's job, not the harness's (see [Install](#install)). When none is declared, apply uses the active OpenShell gateway registration. +### Target resolution + +Effective target resolution is: + +1. explicit flags (`--gateway`, `--workspace`) +2. environment (`OPENSHELL_GATEWAY`, `OPENSHELL_WORKSPACE`) +3. workflow config (`spec.target.gateway`, `spec.target.workspace`) +4. OpenShell active gateway selection (gateway only) + +When no flag, `OPENSHELL_GATEWAY`, or `spec.target.gateway` selects a named +gateway, direct SDK/OIDC targeting can come from +`OPENSHELL_GATEWAY_ENDPOINT` plus all three of `OPENSHELL_OIDC_ISSUER`, +`OPENSHELL_OIDC_CLIENT_ID`, and `OPENSHELL_OIDC_AUDIENCE`. +All direct-target fields are required; otherwise Harness falls back to the +CLI-managed gateway configuration. `OPENSHELL_OIDC_CLIENT_SECRET` remains +required at runtime and is never part of the workflow document. + ### One-shot tasks Run a task headlessly -- the agent executes in a sandbox and outputs results. @@ -117,6 +134,13 @@ Managed providers may be updated or explicitly adopted, but apply does not creat credentialed providers; platform bootstrap owns their creation. Relative payload and policy paths resolve from the workflow file's directory. +Workflow schema essentials: + +- `spec.providers` declares provider resources; `spec.sandbox.providers` attaches provider capabilities to the sandbox runtime. +- `management: referenced` requires an already-registered provider; managed providers can set `adopt: true` to take ownership of a pre-existing provider. +- `spec.inference.verify: true` enforces inference-route endpoint checks during inference route writes. +- `spec.source.repo` is cloned outside the sandbox and uploaded; `spec.payloads[*].source` and `spec.sandbox.policy.file` resolve relative to the workflow file. + Canonical workflows use the OpenShell SDK for sandbox creation, policy application, readiness, source and payload uploads, execution, and cleanup. Interactive workflows use the same path with host terminal resize and raw-mode @@ -148,6 +172,12 @@ openshell term # interactive policy terminal `openshell term` provides a live view of policy decisions -- which requests are allowed, denied, or pending review. This is how you audit and tune the deny-by-default L7 network policy while an agent is running. +## Prerequisites + +- OpenShell CLI and gateway service at the repo-pinned version (see `make openshell` and `.openshell-version`). +- An active OpenShell gateway registration (`openshell gateway add ...`, `openshell gateway select ...`). +- Provider credentials already reconciled on the gateway for any referenced providers. + ## Install ```bash @@ -156,8 +186,14 @@ openshell term # interactive policy terminal # managed gateway service (Homebrew/launchd on macOS, systemd on Linux). make openshell -# Download the harness binary -curl -L https://github.com/stackrox/harness-openshell/releases/latest/download/harness_darwin_arm64 -o harness +# Download the harness binary for your OS/arch +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ARCH=amd64 ;; + arm64|aarch64) ARCH=arm64 ;; +esac +curl -L "https://github.com/stackrox/harness-openshell/releases/latest/download/harness_${OS}_${ARCH}" -o harness chmod +x harness ``` @@ -175,7 +211,7 @@ openshell gateway select openshell If you need to restart the service later: `brew services restart openshell` (macOS) or `systemctl --user restart openshell-gateway` (Linux). -Or build the harness from source: `make cli` +Or build from source with `make cli` (uses your local Go toolchain). ### On a cluster @@ -209,11 +245,13 @@ removes the gateway. | `harness doctor` | Validate gateway reachability and referenced providers | | `harness apply -f FILE` | Deploy a sandbox from config | | `harness apply -f FILE --attach` | Interactive TTY mode | +| `harness apply -f FILE --setup-only` | Reconcile providers and inference only (skip sandbox run) | | `harness apply -f FILE --dry-run` | Render the v1alpha1 action plan without mutating | | `harness apply -f FILE -o yaml` | Output resolved config with interpolated and credential-bearing map values redacted | -| `harness get agents\|providers\|gateways` | List resources | +| `harness get gateways` | Show active gateway only (name, endpoint, status, version) | +| `harness get agents\|providers` | List resources | | `harness describe ` | Sandbox details | -| `harness delete [--all]` | Tear down | +| `harness delete [--all\|--sandboxes\|--providers]` | Delete targeted or bulk resources | | `harness plan -f FILE` | Read-only reconciliation plan (mutates nothing) | ### Credentials @@ -269,3 +307,4 @@ TTY, so it does not claim a live interactive proof. | [docs/](docs/) | Repo-facing docs index | | [docs/ci.md](docs/ci.md) | HyperShell CI bootstrap and repository contract | | [docs/compatibility.md](docs/compatibility.md) | Tested OpenShell, ACP, and Go versions | +| [profiles/README.md](profiles/README.md) | Profile layout and examples | diff --git a/cmd/apply.go b/cmd/apply.go index 3a61e4e..84530e4 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -1,8 +1,6 @@ package cmd import ( - "fmt" - "github.com/spf13/cobra" "github.com/stackrox/harness-openshell/internal/openshell" ) @@ -22,46 +20,20 @@ mutating anything, or -o yaml to output the resolved configuration with host-interpolated and credential-bearing map values redacted.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if file == "" { - return fmt.Errorf("flag -f/--file is required") - } if len(args) == 1 && sandboxName == "" { sandboxName = args[0] } - - workflow, err := loadWorkflow(file, *gatewayName, *workspace, applyOverrides{ - Name: sandboxName, AgentType: entrypoint, ForceTTY: attach, - }) - if err != nil { - return err - } - if output != "" && !dryRun { - return renderWorkflow(workflow, output) - } - - var client openshell.Client - // A non-dry-run apply always asks the SDK factory to resolve its target. - // An empty target means the active CLI-compatible gateway registration; - // dry-run remains fully offline when no target was requested. - if !dryRun || workflow.Target.Direct != nil || workflow.Target.Gateway != "" { - client, err = newClient(cmd.Context(), workflow.Target) - if err != nil { - desc := targetDescription(workflow.Target) - if !dryRun { - return fmt.Errorf("connecting to %s: %w", desc, err) - } - fmt.Fprintf(cmd.ErrOrStderr(), "warning: %s unreachable: %v (rendering desired config only)\n", desc, err) - } else { - defer client.Close() - } - } - planned, current, err := workflow.buildPlan(cmd.Context(), client) - if err != nil { - return err - } - return applyWorkflow(cmd.Context(), workflow, planned, current, client, applyOptions{ - SetupOnly: setupOnly, DryRun: dryRun, Output: output, - }) + return runApply(cmd.Context(), newClient, applyRequest{ + File: file, + Name: sandboxName, + Entrypoint: entrypoint, + Attach: attach, + DryRun: dryRun, + SetupOnly: setupOnly, + Output: output, + Gateway: *gatewayName, + Workspace: *workspace, + }, cmd.ErrOrStderr()) }, } diff --git a/cmd/apply_service.go b/cmd/apply_service.go new file mode 100644 index 0000000..e335562 --- /dev/null +++ b/cmd/apply_service.go @@ -0,0 +1,150 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/stackrox/harness-openshell/internal/openshell" + "github.com/stackrox/harness-openshell/internal/plan" + "github.com/stackrox/harness-openshell/internal/reconcile" + "github.com/stackrox/harness-openshell/internal/run" + "github.com/stackrox/harness-openshell/internal/status" +) + +type applyRequest struct { + File string + Name string + Entrypoint string + Attach bool + DryRun bool + SetupOnly bool + Output string + Gateway string + Workspace string +} + +type applyService struct { + newClient openshell.Factory + stderr io.Writer +} + +// runApply executes an apply request through the service layer. +func runApply(ctx context.Context, newClient openshell.Factory, req applyRequest, stderr io.Writer) error { + return applyService{newClient: newClient, stderr: stderr}.run(ctx, req) +} + +// run loads, resolves, plans, and executes one workflow request. +func (s applyService) run(ctx context.Context, req applyRequest) error { + if req.File == "" { + return fmt.Errorf("flag -f/--file is required") + } + + workflow, err := loadWorkflow(req.File, req.Gateway, req.Workspace, applyOverrides{ + Name: req.Name, AgentType: req.Entrypoint, ForceTTY: req.Attach, + }) + if err != nil { + return err + } + if req.Output != "" && !req.DryRun { + return renderWorkflow(workflow, req.Output) + } + + client, planned, current, err := s.connectAndPlan(ctx, workflow, req.DryRun) + if err != nil { + return err + } + if client != nil { + defer client.Close() + } + return executeResolvedWorkflow(ctx, workflow, planned, current, client, applyOptions{ + SetupOnly: req.SetupOnly, DryRun: req.DryRun, Output: req.Output, + }) +} + +// connectAndPlan connects to the selected target when needed and builds the +// plan used by the subsequent execution step. +func (s applyService) connectAndPlan(ctx context.Context, workflow *resolvedWorkflow, dryRun bool) (openshell.Client, *plan.Plan, plan.CurrentState, error) { + var ( + client openshell.Client + err error + ) + if !dryRun || workflow.Target.Direct != nil || workflow.Target.Gateway != "" { + client, err = s.newClient(ctx, workflow.Target) + if err != nil { + desc := targetDescription(workflow.Target) + if !dryRun { + return nil, nil, plan.CurrentState{}, fmt.Errorf("connecting to %s: %w", desc, err) + } + out := s.stderr + if out == nil { + out = io.Discard + } + fmt.Fprintf(out, "warning: %s unreachable: %v (rendering desired config only)\n", desc, err) + } + } + + planned, current, err := workflow.buildPlan(ctx, client) + if err != nil { + if client != nil { + _ = client.Close() + } + return nil, nil, plan.CurrentState{}, err + } + return client, planned, current, nil +} + +// executeResolvedWorkflow runs the fully resolved and planned workflow through +// preflight, reconcile, and optional sandbox execution. +func executeResolvedWorkflow(ctx context.Context, workflow *resolvedWorkflow, p *plan.Plan, current plan.CurrentState, client openshell.Client, opts applyOptions) error { + if opts.DryRun { + return renderPlan(p, opts.Output) + } + if client == nil || !current.Reachable { + return fmt.Errorf("%s is not reachable or authenticated", targetDescription(workflow.Target)) + } + if err := preflightPlan(workflow.Desired, p); err != nil { + return err + } + if err := verifySandboxProviders(ctx, client, workflow.Desired); err != nil { + return err + } + + var req run.SandboxRunRequest + if !opts.SetupOnly && runConfigured(workflow.Desired) { + var ( + cleanup func() + err error + ) + req, cleanup, err = buildRunRequest(workflow) + if err != nil { + return err + } + defer cleanup() + } + + if err := reconcileProviders(ctx, client, workflow.Desired.Spec.Providers); err != nil { + return err + } + if inferenceConfigured(workflow.Desired.Spec.Inference) { + result, err := reconcile.ReconcileInference(ctx, client, workflow.Desired.Spec.Inference) + if err != nil { + return fmt.Errorf("reconciling inference: %w", err) + } + status.OKf("inference: %s (model %s)", result.Action, workflow.Desired.Spec.Inference.Model) + } + if opts.SetupOnly { + status.OK("Setup complete (--setup-only): skipping sandbox creation") + return nil + } + if !runConfigured(workflow.Desired) { + status.OK("Reconciliation complete: workflow declares no sandbox run") + return nil + } + executor, ok := client.(openshell.SandboxExecutionClient) + if !ok { + return fmt.Errorf("configured OpenShell client does not support SDK sandbox execution") + } + return run.Run(ctx, executor, req, os.Stdin, os.Stdout, os.Stderr) +} diff --git a/cmd/describe.go b/cmd/describe.go index 6a1533f..b48d08b 100644 --- a/cmd/describe.go +++ b/cmd/describe.go @@ -9,6 +9,7 @@ import ( "github.com/stackrox/harness-openshell/internal/status" ) +// NewDescribeCmd constructs the sandbox detail command. func NewDescribeCmd(newClient openshell.Factory) *cobra.Command { var output string var gatewayName, workspace *string @@ -42,47 +43,31 @@ func NewDescribeCmd(newClient openshell.Factory) *cobra.Command { // Gateway context and providers are best-effort: a describe still // shows the sandbox even if gateway introspection or the provider // list fails (behavior-preserving with the former CLI path). - var gwName, gwEndpoint string + var gatewayInfo openshell.GatewayInfo if info, err := client.GatewayInfo(cmd.Context()); err == nil { - gwName = info.Name - gwEndpoint = info.Endpoint + gatewayInfo = info } - var providerNames []string - if providers, err := client.Providers(cmd.Context()); err == nil { - providerNames = make([]string, len(providers)) - for i, p := range providers { - providerNames[i] = p.Name - } + var providers []openshell.Provider + if listedProviders, err := client.Providers(cmd.Context()); err == nil { + providers = listedProviders } if format != formatTable { - type describeOut struct { - Name string `json:"name" yaml:"name"` - Phase string `json:"phase" yaml:"phase"` - Gateway string `json:"gateway,omitempty" yaml:"gateway,omitempty"` - Endpoint string `json:"endpoint,omitempty" yaml:"endpoint,omitempty"` - Providers []string `json:"providers,omitempty" yaml:"providers,omitempty"` - } - return printStructured(format, describeOut{ - Name: sandbox.Name, - Phase: sandbox.Phase, - Gateway: gwName, - Endpoint: gwEndpoint, - Providers: providerNames, - }) + return printStructured(format, describeRecord(sandbox, gatewayInfo, providers)) } status.Header(sandbox.Name) status.Infof("Phase: %s", sandbox.Phase) - if gwName != "" { - status.Infof("Gateway: %s (%s)", gwName, gwEndpoint) + if gatewayInfo.Name != "" { + status.Infof("Gateway: %s (%s)", gatewayInfo.Name, gatewayInfo.Endpoint) } - if len(providerNames) > 0 { - status.Infof("Providers: %d registered", len(providerNames)) - for _, p := range providerNames { + providerIDs := providerNames(providers) + if len(providerIDs) > 0 { + status.Infof("Providers: %d registered", len(providerIDs)) + for _, p := range providerIDs { fmt.Printf(" - %s\n", p) } } diff --git a/cmd/get.go b/cmd/get.go index 2185d8a..da0ddcb 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -7,6 +7,7 @@ import ( "github.com/stackrox/harness-openshell/internal/openshell" ) +// NewGetCmd constructs the resource listing command and its subcommands. func NewGetCmd(newClient openshell.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "get", @@ -23,6 +24,7 @@ func NewGetCmd(newClient openshell.Factory) *cobra.Command { return cmd } +// newGetAgentsCmd constructs the sandbox listing subcommand. func newGetAgentsCmd(newClient openshell.Factory) *cobra.Command { var output string var gatewayName, workspace *string @@ -58,15 +60,7 @@ func newGetAgentsCmd(newClient openshell.Factory) *cobra.Command { } if format != formatTable { - type sandboxOut struct { - Name string `json:"name" yaml:"name"` - Phase string `json:"phase" yaml:"phase"` - } - out := make([]sandboxOut, len(sandboxes)) - for i, s := range sandboxes { - out[i] = sandboxOut{Name: s.Name, Phase: s.Phase} - } - return printStructured(format, out) + return printStructured(format, sandboxOutputs(sandboxes)) } rows := make([][]string, len(sandboxes)) @@ -83,6 +77,7 @@ func newGetAgentsCmd(newClient openshell.Factory) *cobra.Command { return cmd } +// newGetProvidersCmd constructs the provider listing subcommand. func newGetProvidersCmd(newClient openshell.Factory) *cobra.Command { var output string var gatewayName, workspace *string @@ -118,14 +113,7 @@ func newGetProvidersCmd(newClient openshell.Factory) *cobra.Command { } if format != formatTable { - type providerOut struct { - Name string `json:"name" yaml:"name"` - } - out := make([]providerOut, len(providers)) - for i, p := range providers { - out[i] = providerOut{Name: p.Name} - } - return printStructured(format, out) + return printStructured(format, providerOutputs(providers)) } rows := make([][]string, len(providers)) @@ -142,6 +130,7 @@ func newGetProvidersCmd(newClient openshell.Factory) *cobra.Command { return cmd } +// newGetGatewaysCmd constructs the gateway listing subcommand. func newGetGatewaysCmd(newClient openshell.Factory) *cobra.Command { var output string var gatewayName, workspace *string @@ -173,18 +162,7 @@ registration.`, } if format != formatTable { - type gwOut struct { - Name string `json:"name" yaml:"name"` - Endpoint string `json:"endpoint" yaml:"endpoint"` - Status string `json:"status" yaml:"status"` - Version string `json:"version" yaml:"version"` - } - return printStructured(format, gwOut{ - Name: info.Name, - Endpoint: info.Endpoint, - Status: info.Status, - Version: info.Version, - }) + return printStructured(format, gatewayRecord(info)) } printTable( diff --git a/cmd/init_cmd.go b/cmd/init_cmd.go index 9cc4dae..e8d0ee8 100644 --- a/cmd/init_cmd.go +++ b/cmd/init_cmd.go @@ -5,13 +5,11 @@ import ( "fmt" "io" "os" - "os/exec" "strconv" "strings" "github.com/spf13/cobra" "github.com/stackrox/harness-openshell/internal/config" - "github.com/stackrox/harness-openshell/internal/status" "gopkg.in/yaml.v3" ) @@ -28,6 +26,7 @@ var defaultProviders = []availableProvider{ {ID: "google-workspace", DisplayName: "Google Workspace", Category: "knowledge"}, } +// NewInitCmd constructs the command that generates a starter workflow file. func NewInitCmd(defaultCfg []byte) *cobra.Command { var ( outputPath string @@ -54,6 +53,7 @@ Use --non-interactive to write the embedded default config without prompts.`, return cmd } +// initRun writes a starter workflow, optionally collecting interactive choices. func initRun(in io.Reader, out io.Writer, outputPath string, force, nonInteractive bool, defaultCfg []byte) error { if _, err := os.Stat(outputPath); err == nil && !force { return fmt.Errorf("%s already exists (use --force to overwrite)", outputPath) @@ -107,7 +107,7 @@ func promptEntrypoint(scanner *bufio.Scanner, out io.Writer) (string, error) { } func promptProviders(scanner *bufio.Scanner, out io.Writer) ([]config.Provider, error) { - available := discoverProviders() + available := defaultProviders fmt.Fprintln(out, "Available providers:") for i, p := range available { @@ -137,78 +137,6 @@ func promptProviders(scanner *bufio.Scanner, out io.Writer) ([]config.Provider, return buildProviders(available, indices), nil } -func discoverProviders() []availableProvider { - if providers := discoverFromOpenShell(); len(providers) > 0 { - return providers - } - return defaultProviders -} - -func discoverFromOpenShell() []availableProvider { - path, err := exec.LookPath("openshell") - if err != nil { - return nil - } - status.Cmd(path, "provider", "list-profiles") - out, err := exec.Command(path, "provider", "list-profiles").Output() - if err != nil { - return nil - } - return parseListProfiles(string(out)) -} - -func parseListProfiles(output string) []availableProvider { - var providers []availableProvider - var currentCategory string - - for _, line := range strings.Split(output, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - continue - } - - // Category headers and provider rows are indented. - if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") { - continue - } - - fields := strings.Fields(trimmed) - if len(fields) == 0 { - continue - } - - // Provider rows contain "endpoints:"; other indented lines are category - // headings, including multi-word headings such as "SOURCE CONTROL". - epIdx := -1 - for i, f := range fields { - if f == "endpoints:" { - epIdx = i - break - } - } - if epIdx < 0 { - currentCategory = strings.ToLower(strings.Join(fields, "-")) - continue - } - - id := fields[0] - displayName := strings.Join(fields[1:epIdx], " ") - - category := currentCategory - if epIdx+2 < len(fields) { - category = fields[epIdx+2] - } - - providers = append(providers, availableProvider{ - ID: id, - DisplayName: displayName, - Category: category, - }) - } - - return providers -} - func providerDefaults(available []availableProvider) string { var defaults []string for i, p := range available { diff --git a/cmd/init_cmd_test.go b/cmd/init_cmd_test.go index 5a0c2f1..bebf06f 100644 --- a/cmd/init_cmd_test.go +++ b/cmd/init_cmd_test.go @@ -247,38 +247,6 @@ func TestParseSelection_Invalid(t *testing.T) { } } -func TestParseListProfiles(t *testing.T) { - output := `Available Provider Profiles: - - INFERENCE - google-vertex-ai Google Vertex AI endpoints: 4 inference - - SOURCE CONTROL - github GitHub endpoints: 3 - - KNOWLEDGE - atlassian Atlassian (Jira + Confluence) endpoints: 3 - google-workspace Google Workspace endpoints: 8 -` - providers := parseListProfiles(output) - if len(providers) < 3 { - t.Fatalf("expected at least 3 providers, got %d: %+v", len(providers), providers) - } - - found := make(map[string]availableProvider) - for _, p := range providers { - found[p.ID] = p - } - for _, id := range []string{"google-vertex-ai", "github", "atlassian"} { - if _, ok := found[id]; !ok { - t.Errorf("missing provider %q in parsed output", id) - } - } - if got := found["github"].Category; got != "source-control" { - t.Errorf("github category = %q, want source-control", got) - } -} - func TestInitNoCredentialLeak(t *testing.T) { dir := t.TempDir() outPath := filepath.Join(dir, "harness.yaml") diff --git a/cmd/resource_output.go b/cmd/resource_output.go new file mode 100644 index 0000000..566f19a --- /dev/null +++ b/cmd/resource_output.go @@ -0,0 +1,75 @@ +package cmd + +import "github.com/stackrox/harness-openshell/internal/openshell" + +type sandboxOutput struct { + Name string `json:"name" yaml:"name"` + Phase string `json:"phase" yaml:"phase"` +} + +type providerOutput struct { + Name string `json:"name" yaml:"name"` +} + +type gatewayOutput struct { + Name string `json:"name" yaml:"name"` + Endpoint string `json:"endpoint" yaml:"endpoint"` + Status string `json:"status" yaml:"status"` + Version string `json:"version" yaml:"version"` +} + +type describeOutput struct { + Name string `json:"name" yaml:"name"` + Phase string `json:"phase" yaml:"phase"` + Gateway string `json:"gateway,omitempty" yaml:"gateway,omitempty"` + Endpoint string `json:"endpoint,omitempty" yaml:"endpoint,omitempty"` + Providers []string `json:"providers,omitempty" yaml:"providers,omitempty"` +} + +// sandboxOutputs converts internal sandbox records to structured CLI output. +func sandboxOutputs(sandboxes []openshell.Sandbox) []sandboxOutput { + out := make([]sandboxOutput, len(sandboxes)) + for i, sandbox := range sandboxes { + out[i] = sandboxOutput{Name: sandbox.Name, Phase: sandbox.Phase} + } + return out +} + +// providerOutputs converts internal provider records to redaction-safe output. +func providerOutputs(providers []openshell.Provider) []providerOutput { + out := make([]providerOutput, len(providers)) + for i, provider := range providers { + out[i] = providerOutput{Name: provider.Name} + } + return out +} + +// providerNames returns provider names for the compact table output. +func providerNames(providers []openshell.Provider) []string { + out := make([]string, len(providers)) + for i, provider := range providers { + out[i] = provider.Name + } + return out +} + +// gatewayRecord converts gateway connection and health facts to CLI output. +func gatewayRecord(info openshell.GatewayInfo) gatewayOutput { + return gatewayOutput{ + Name: info.Name, + Endpoint: info.Endpoint, + Status: info.Status, + Version: info.Version, + } +} + +// describeRecord combines sandbox, gateway, and provider facts for describe. +func describeRecord(sandbox openshell.Sandbox, info openshell.GatewayInfo, providers []openshell.Provider) describeOutput { + return describeOutput{ + Name: sandbox.Name, + Phase: sandbox.Phase, + Gateway: info.Name, + Endpoint: info.Endpoint, + Providers: providerNames(providers), + } +} diff --git a/cmd/workflow_apply.go b/cmd/workflow_apply.go index a164715..91c5752 100644 --- a/cmd/workflow_apply.go +++ b/cmd/workflow_apply.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "os" "path/filepath" "strings" @@ -24,59 +23,9 @@ type applyOptions struct { Output string } -// applyWorkflow executes a resolved v1alpha1 workflow. The caller supplies the -// same plan snapshot that it may render for --dry-run; writes are delegated to -// reconcilers that share plan's provider and inference action functions. +// applyWorkflow is a compatibility wrapper used by existing tests. func applyWorkflow(ctx context.Context, workflow *resolvedWorkflow, p *plan.Plan, current plan.CurrentState, client openshell.Client, opts applyOptions) error { - if opts.DryRun { - return renderPlan(p, opts.Output) - } - if client == nil || !current.Reachable { - return fmt.Errorf("%s is not reachable or authenticated", targetDescription(workflow.Target)) - } - if err := preflightPlan(workflow.Desired, p); err != nil { - return err - } - if err := verifySandboxProviders(ctx, client, workflow.Desired); err != nil { - return err - } - - var req run.SandboxRunRequest - if !opts.SetupOnly && runConfigured(workflow.Desired) { - var ( - cleanup func() - err error - ) - req, cleanup, err = buildRunRequest(workflow) - if err != nil { - return err - } - defer cleanup() - } - - if err := reconcileProviders(ctx, client, workflow.Desired.Spec.Providers); err != nil { - return err - } - if inferenceConfigured(workflow.Desired.Spec.Inference) { - result, err := reconcile.ReconcileInference(ctx, client, workflow.Desired.Spec.Inference) - if err != nil { - return fmt.Errorf("reconciling inference: %w", err) - } - status.OKf("inference: %s (model %s)", result.Action, workflow.Desired.Spec.Inference.Model) - } - if opts.SetupOnly { - status.OK("Setup complete (--setup-only): skipping sandbox creation") - return nil - } - if !runConfigured(workflow.Desired) { - status.OK("Reconciliation complete: workflow declares no sandbox run") - return nil - } - executor, ok := client.(openshell.SandboxExecutionClient) - if !ok { - return fmt.Errorf("configured OpenShell client does not support SDK sandbox execution") - } - return run.Run(ctx, executor, req, os.Stdin, os.Stdout, os.Stderr) + return executeResolvedWorkflow(ctx, workflow, p, current, client, opts) } // targetDescription names a target for error messages: direct registrations diff --git a/internal/openshell/sdkclient/client.go b/internal/openshell/sdkclient/client.go index 57e5530..46b133d 100644 --- a/internal/openshell/sdkclient/client.go +++ b/internal/openshell/sdkclient/client.go @@ -9,6 +9,7 @@ package sdkclient import ( "context" "fmt" + "os" v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" gateway "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" @@ -45,13 +46,16 @@ type client struct { gatewayEndpoint string } -// New constructs an openshell.Client for the given target: it loads the -// CLI-managed gateway config, resolves the dial plan via planConnection, and -// executes it via dial. For OIDC gateways, authenticate with the OpenShell CLI -// first so its audience-aware token is available to the SDK. +// New constructs an openshell.Client for the given target. +// +// Resolution order: +// - explicit Target.Direct connection metadata, +// - OPENSHELL_GATEWAY_ENDPOINT + complete OPENSHELL_OIDC_* direct metadata +// (when no gateway name is selected), +// - CLI-managed gateway registration/config via gateway.LoadConfig. func New(ctx context.Context, t openshell.Target) (openshell.Client, error) { - if t.Direct != nil { - return newDirect(ctx, t) + if target, ok := resolveDirectTarget(t, os.Getenv); ok { + return newDirect(ctx, target) } cfg, err := gateway.LoadConfig(t.Gateway) if err != nil { @@ -80,6 +84,37 @@ func New(ctx context.Context, t openshell.Target) (openshell.Client, error) { return c, nil } +// resolveDirectTarget normalizes target selection for New: +// - explicit target.Direct is authoritative; +// - when no gateway name is selected, OPENSHELL_GATEWAY_ENDPOINT plus all +// required OPENSHELL_OIDC_* values can define a direct SDK target; +// - otherwise New falls back to CLI-managed gateway config. +func resolveDirectTarget(target openshell.Target, getenv func(string) string) (openshell.Target, bool) { + if target.Direct != nil { + return target, true + } + if target.Gateway != "" { + return target, false + } + endpoint := getenv(openshell.EnvGatewayEndpoint) + issuer := getenv(openshell.EnvOIDCIssuer) + clientID := getenv(openshell.EnvOIDCClientID) + audience := getenv(openshell.EnvOIDCAudience) + if endpoint == "" || issuer == "" || clientID == "" || audience == "" { + return target, false + } + + target.Direct = &openshell.DirectConnection{ + Endpoint: endpoint, + OIDC: openshell.OIDCConnection{ + Issuer: issuer, + ClientID: clientID, + Audience: audience, + }, + } + return target, true +} + // NewFromClient wraps an existing SDK client (or the SDK fake) bound to a // workspace. It is the injection seam used by white-box tests and by // internal/testutil. Empty workspace defaults to defaultWorkspace. It leaves the diff --git a/internal/openshell/sdkclient/client_test.go b/internal/openshell/sdkclient/client_test.go index b2b3fae..16ac041 100644 --- a/internal/openshell/sdkclient/client_test.go +++ b/internal/openshell/sdkclient/client_test.go @@ -147,6 +147,88 @@ func TestNewFromClientDefaultsWorkspace(t *testing.T) { } } +func TestResolveDirectTarget(t *testing.T) { + tests := []struct { + name string + target openshell.Target + env map[string]string + want openshell.Target + wantUse bool + }{ + { + name: "explicit direct target wins", + target: openshell.Target{Gateway: "named", Direct: &openshell.DirectConnection{Endpoint: "https://direct.example", OIDC: openshell.OIDCConnection{Issuer: "https://issuer", ClientID: "id", Audience: "aud"}}}, + env: map[string]string{openshell.EnvGatewayEndpoint: "https://env.example"}, + want: openshell.Target{Gateway: "named", Direct: &openshell.DirectConnection{Endpoint: "https://direct.example", OIDC: openshell.OIDCConnection{Issuer: "https://issuer", ClientID: "id", Audience: "aud"}}}, + wantUse: true, + }, + { + name: "named gateway keeps CLI path", + target: openshell.Target{Gateway: "cli-gateway"}, + env: map[string]string{openshell.EnvGatewayEndpoint: "https://env.example"}, + want: openshell.Target{Gateway: "cli-gateway"}, + wantUse: false, + }, + { + name: "direct target from env", + env: map[string]string{ + openshell.EnvGatewayEndpoint: "https://env.example", + openshell.EnvOIDCIssuer: "https://issuer.example", + openshell.EnvOIDCClientID: "client", + openshell.EnvOIDCAudience: "gateway", + }, + want: openshell.Target{Direct: &openshell.DirectConnection{ + Endpoint: "https://env.example", + OIDC: openshell.OIDCConnection{ + Issuer: "https://issuer.example", + ClientID: "client", + Audience: "gateway", + }, + }}, + wantUse: true, + }, + { + name: "no direct target info", + target: openshell.Target{}, + env: map[string]string{}, + want: openshell.Target{}, + wantUse: false, + }, + { + name: "incomplete direct target falls back to CLI path", + env: map[string]string{ + openshell.EnvGatewayEndpoint: "https://env.example", + openshell.EnvOIDCIssuer: "https://issuer.example", + openshell.EnvOIDCClientID: "client", + }, + want: openshell.Target{}, + wantUse: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + getenv := func(key string) string { return tt.env[key] } + got, use := resolveDirectTarget(tt.target, getenv) + if use != tt.wantUse { + t.Fatalf("useDirect = %v, want %v", use, tt.wantUse) + } + if got.Gateway != tt.want.Gateway || got.Workspace != tt.want.Workspace { + t.Fatalf("target gateway/workspace = %+v, want %+v", got, tt.want) + } + switch { + case got.Direct == nil && tt.want.Direct == nil: + return + case got.Direct == nil || tt.want.Direct == nil: + t.Fatalf("direct = %+v, want %+v", got.Direct, tt.want.Direct) + } + if *got.Direct != *tt.want.Direct { + t.Fatalf("direct = %+v, want %+v", *got.Direct, *tt.want.Direct) + } + }) + } +} + func TestProvidersErrorTranslated(t *testing.T) { ctx := context.Background() fc := fake.NewClient() diff --git a/internal/openshell/target.go b/internal/openshell/target.go index 1514f48..7ddfa94 100644 --- a/internal/openshell/target.go +++ b/internal/openshell/target.go @@ -5,8 +5,12 @@ package openshell // default). Exported so cmd help text and tests can name them without // re-declaring the strings. const ( - EnvGateway = "OPENSHELL_GATEWAY" - EnvWorkspace = "OPENSHELL_WORKSPACE" + EnvGateway = "OPENSHELL_GATEWAY" + EnvWorkspace = "OPENSHELL_WORKSPACE" + EnvGatewayEndpoint = "OPENSHELL_GATEWAY_ENDPOINT" + EnvOIDCIssuer = "OPENSHELL_OIDC_ISSUER" + EnvOIDCClientID = "OPENSHELL_OIDC_CLIENT_ID" + EnvOIDCAudience = "OPENSHELL_OIDC_AUDIENCE" ) // ResolveTarget builds a Target from explicit flag values, environment variables,