diff --git a/SPEC.md b/SPEC.md index c5a8183..824333e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -35,13 +35,13 @@ If `--name` is not provided, the sandbox name comes from the profile's `name` fi Reconnect to a running sandbox via `openshell sandbox connect`. -### `harness deploy [--local|--remote]` +### `harness deploy --local|--remote [--kubeconfig PATH]` -Deploy or verify the gateway without creating a sandbox. +Deploy or verify the gateway without creating a sandbox. Requires `--local` or `--remote`. -**Local:** Find a gateway with endpoint `127.0.0.1`, select it, verify it responds. +**Local:** Check podman installed, find a gateway with endpoint `127.0.0.1`, select it, verify it responds. -**Remote:** Create namespace, install CRDs, grant SCCs, deploy Helm chart from OCI registry (`oci://ghcr.io/nvidia/openshell/helm-chart`), create TLS passthrough route, register CLI gateway with mTLS certs from the cluster. +**Remote:** Create namespace, install CRDs, grant SCCs, deploy Helm chart from OCI registry (`oci://ghcr.io/nvidia/openshell/helm-chart`), create TLS passthrough route, register CLI gateway with mTLS certs from the cluster. `--kubeconfig` sets the kubeconfig path (or set `KUBECONFIG` env var). ### `harness teardown [--sandboxes] [--providers] [--k8s]` @@ -51,13 +51,29 @@ Tear down resources. Default (no flags) tears down everything applicable. - `--providers` — delete all providers and inference config (requires no running sandboxes) - `--k8s` — Helm uninstall, delete CRDs, SCCs, secrets, namespace, and gateway config -### `harness preflight` +### `harness preflight [--strict]` Read-only environment check. Validates all inputs defined in `providers.toml` for each enabled provider in `openshell.toml`. Reports per-input status with `✓`/`✗` prefixes. -### `harness providers` +With `--strict`, exits non-zero if any `required` provider has missing inputs. -Register credential providers with the gateway. Reads provider types and credentials from environment variables. Supports `--force` to delete and recreate all providers. +Subcommands: +- `harness preflight available` — print space-separated names of openshell-type providers where all inputs pass +- `harness preflight names` — print space-separated names of all enabled openshell-type providers + +### `harness providers [--force]` + +Register credential providers with the gateway: + +1. Enables providers v2 via `openshell settings set` +2. Imports custom provider profiles from `sandbox/profiles/` +3. Registers each provider (github, vertex-local, atlassian) if the required env vars are set: + - `github` — requires `GITHUB_TOKEN` + - `vertex-local` — requires ADC file + project ID (`ANTHROPIC_VERTEX_PROJECT_ID` or fallback from ADC's `quota_project_id`). Sets inference model from `OPENSHELL_MODEL` (default: `claude-sonnet-4-6`). + - `atlassian` — requires `JIRA_API_TOKEN` +4. Skips providers that already exist + +With `--force`: deletes existing providers and custom profiles before recreating. Requires no running sandboxes. ### `harness test [podman|ocp|all] [--full]` @@ -230,11 +246,22 @@ The launcher connects to the gateway at `https://openshell.openshell.svc.cluster --- +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `OPENSHELL_CLI` | `openshell` | Path or name of the openshell CLI binary | +| `OPENSHELL_MODEL` | `claude-sonnet-4-6` | Inference model for `harness providers` | +| `HARNESS_DIR` | auto-detected | Root directory of the harness project | +| `OPENSHELL_NAMESPACE` | `openshell` | Kubernetes namespace for OCP deployments | + +--- + ## Testing -### Unit Tests (`test/preflight.bats`) +### Bats Tests (`test/preflight.bats`) -29 bats tests covering the preflight check engine (`lib/providers.py`). Uses stubbed CLI, isolated temp dirs, and no gateway dependency. Tests: +29 bats tests covering the preflight check engine. Runs against both the Python (`lib/providers.py`) and Go (`harness preflight`) implementations via `USE_GO=true`. Uses stubbed CLI, isolated temp dirs, and no gateway dependency. Tests: - env inputs (set/missing/secret/masked) - file inputs (exists/missing/metadata extraction) - check inputs (pass/fail/env expansion) @@ -243,10 +270,18 @@ The launcher connects to the gateway at `https://openshell.openshell.svc.cluster - CLI detection (present/missing) - Gateway detection (podman/k8s) +### Go Unit Tests + +- `internal/gateway/cli_test.go` — stub-based tests for CLI output parsing and argument building +- `internal/profile/profile_test.go` — TOML parsing, env generation, provider validation with mock gateway +- `cmd/new_test.go` — orchestration tests: no gateway, missing providers, retry logic, create opts +- `sandbox/launcher/main_test.go` — launcher config parsing and file staging + ### Integration Tests (`test/test-flow.sh`) -End-to-end validation requiring a live gateway: +End-to-end validation requiring a live gateway. Supports `--go` flag to test the Go binary instead of bash scripts: - Quick mode: deploy → providers → gateway check → teardown - Full mode: + sandbox create → verify env/GWS/MCP/Claude → delete → teardown +- Error scenarios: bad profile, teardown idempotency, missing providers - Targets: `podman`, `ocp`, `all` -- Strips ANSI codes from CLI output for reliable parsing +- Test matrix: `{bash, go}` × `{podman, ocp}` via `make validate` diff --git a/cmd/deploy.go b/cmd/deploy.go index 750e454..d093346 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -13,8 +13,9 @@ import ( func NewDeployCmd(harnessDir, cli string) *cobra.Command { var ( - local bool - remote bool + local bool + remote bool + kubeconfig string ) cmd := &cobra.Command{ @@ -22,7 +23,11 @@ func NewDeployCmd(harnessDir, cli string) *cobra.Command { Short: "Deploy or verify the gateway", RunE: func(cmd *cobra.Command, args []string) error { if remote { - return runner.RunScript(harnessDir, "deploy.sh", "--remote") + scriptArgs := []string{"--remote"} + if kubeconfig != "" { + scriptArgs = append(scriptArgs, "--kubeconfig", kubeconfig) + } + return runner.RunScript(harnessDir, "deploy.sh", scriptArgs...) } if local { gw := gateway.NewCLI(cli) @@ -34,6 +39,7 @@ func NewDeployCmd(harnessDir, cli string) *cobra.Command { cmd.Flags().BoolVar(&local, "local", false, "Verify local podman gateway") cmd.Flags().BoolVar(&remote, "remote", false, "Deploy to OpenShift cluster") + cmd.Flags().StringVar(&kubeconfig, "kubeconfig", "", "Path to kubeconfig (remote only)") return cmd } @@ -79,7 +85,9 @@ func deployLocal(gw gateway.Gateway) error { return fmt.Errorf("no local gateway") } - gw.GatewaySelect(localGW) + if err := gw.GatewaySelect(localGW); err != nil { + return fmt.Errorf("selecting gateway %s: %w", localGW, err) + } if gw.InferenceGet() == nil { status.OKf("%s (active, reachable)", localGW) diff --git a/cmd/new_test.go b/cmd/new_test.go index d858b22..a22f730 100644 --- a/cmd/new_test.go +++ b/cmd/new_test.go @@ -50,6 +50,7 @@ func (m *mockGW) ActiveGateway() string 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) ProviderProfileDelete(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 } diff --git a/cmd/providers.go b/cmd/providers.go index 168cc88..704cc72 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -1,10 +1,12 @@ package cmd import ( + "bufio" "encoding/json" "fmt" "os" "path/filepath" + "strings" "github.com/robbycochran/harness-openshell/internal/gateway" "github.com/robbycochran/harness-openshell/internal/status" @@ -43,6 +45,7 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool) error for _, name := range []string{"github", "vertex-local", "atlassian"} { gw.ProviderDelete(name) } + deleteCustomProfiles(harnessDir, gw) fmt.Println("Deleted existing providers.") } @@ -137,16 +140,55 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool) error // Show results status.Section("Providers") - gw.ProviderList() + names, _ := gw.ProviderList() + for _, n := range names { + status.OK(n) + } status.Section("Inference") - gw.InferenceGet() + m := gw.InferenceModel() + if m != "" { + status.OKf("Model: %s", m) + } fmt.Println() - fmt.Println("Done.") + fmt.Println("Done. Launch a sandbox with: harness new --local") return nil } +func deleteCustomProfiles(harnessDir string, gw gateway.Gateway) { + profilesDir := filepath.Join(harnessDir, "sandbox", "profiles") + entries, err := os.ReadDir(profilesDir) + if err != nil { + return + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { + continue + } + id := extractYAMLID(filepath.Join(profilesDir, e.Name())) + if id != "" { + gw.ProviderProfileDelete(id) + } + } +} + +func extractYAMLID(path string) string { + f, err := os.Open(path) + if err != nil { + return "" + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "id:") { + return strings.TrimSpace(strings.TrimPrefix(line, "id:")) + } + } + return "" +} + func readADCProject(path string) string { data, err := os.ReadFile(path) if err != nil { diff --git a/cmd/teardown.go b/cmd/teardown.go index 66c25f1..5d251d3 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -69,13 +69,20 @@ func teardownSandboxes(gw gateway.Gateway, activeGW string) { return } - names, _ := gw.SandboxList() + names, err := gw.SandboxList() + if err != nil { + status.Fail(fmt.Sprintf("could not list sandboxes: %v", err)) + fmt.Println() + return + } if len(names) == 0 { status.Info("None running") } else { for _, name := range names { fmt.Printf(" Deleting %s\n", name) - gw.SandboxDelete(name) + if err := gw.SandboxDelete(name); err != nil { + status.Failf("failed to delete %s: %v", name, err) + } } } fmt.Println() @@ -89,18 +96,26 @@ func teardownProviders(gw gateway.Gateway, activeGW string) error { return nil } - remaining, _ := gw.SandboxList() + remaining, err := gw.SandboxList() + if err != nil { + return fmt.Errorf("could not check for running sandboxes: %w", err) + } if len(remaining) > 0 { return fmt.Errorf("cannot delete providers with running sandboxes — run: harness teardown --sandboxes") } - names, _ := gw.ProviderList() + names, err := gw.ProviderList() + if err != nil { + return fmt.Errorf("could not list providers: %w", err) + } if len(names) == 0 { status.Info("None registered") } else { for _, name := range names { fmt.Printf(" Deleting %s\n", name) - gw.ProviderDelete(name) + if err := gw.ProviderDelete(name); err != nil { + status.Failf("failed to delete %s: %v", name, err) + } } } diff --git a/internal/gateway/cli.go b/internal/gateway/cli.go index 5f865e8..a267fce 100644 --- a/internal/gateway/cli.go +++ b/internal/gateway/cli.go @@ -97,6 +97,10 @@ func (c *CLI) ProviderProfileImport(dir string) error { return c.silent("provider", "profile", "import", "--from", dir) } +func (c *CLI) ProviderProfileDelete(id string) error { + return c.silent("provider", "profile", "delete", id) +} + func (c *CLI) ProviderList() ([]string, error) { out, err := c.output("provider", "list") if err != nil { @@ -107,7 +111,8 @@ func (c *CLI) ProviderList() ([]string, error) { if i == 0 || strings.TrimSpace(line) == "" { continue // skip header } - fields := strings.Fields(line) + cleaned := ansiRE.ReplaceAllString(line, "") + fields := strings.Fields(cleaned) if len(fields) > 0 { names = append(names, fields[0]) } diff --git a/internal/gateway/cli_test.go b/internal/gateway/cli_test.go index 42a3fed..480f3dd 100644 --- a/internal/gateway/cli_test.go +++ b/internal/gateway/cli_test.go @@ -183,3 +183,128 @@ exit 1 t.Error("SandboxDelete = nil, want error") } } + +func TestGatewayList_ParsesActiveAndInactive(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +printf "NAME\tENDPOINT\tTYPE\tAUTH\n" +printf " openshell\thttps://127.0.0.1:17670\tlocal\tmtls\n" +printf "* openshell-remote-ocp\thttps://gw.example.com:443\tlocal\tmtls\n" +`) + gw := NewCLI(bin) + gateways, err := gw.GatewayList() + if err != nil { + t.Fatalf("GatewayList: %v", err) + } + if len(gateways) != 2 { + t.Fatalf("got %d gateways, want 2", len(gateways)) + } + if gateways[0].Active { + t.Error("first gateway should not be active") + } + if !gateways[1].Active { + t.Error("second gateway should be active") + } + if !strings.Contains(gateways[0].Endpoint, "127.0.0.1") { + t.Errorf("first endpoint = %q, want 127.0.0.1", gateways[0].Endpoint) + } +} + +func TestSandboxList_ParsesWithANSI(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +printf "NAME\tPHASE\n" +printf "\033[32magent\033[0m\tReady\n" +printf "\033[32mtest-agent\033[0m\tReady\n" +`) + gw := NewCLI(bin) + names, err := gw.SandboxList() + if err != nil { + t.Fatalf("SandboxList: %v", err) + } + if len(names) != 2 { + t.Fatalf("got %d sandboxes, want 2: %v", len(names), names) + } + if names[0] != "agent" || names[1] != "test-agent" { + t.Errorf("names = %v", names) + } +} + +func TestSandboxList_Empty(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +printf "NAME\tPHASE\n" +`) + gw := NewCLI(bin) + names, err := gw.SandboxList() + if err != nil { + t.Fatalf("SandboxList: %v", err) + } + if len(names) != 0 { + t.Errorf("got %d, want 0", len(names)) + } +} + +func TestActiveGateway_WithStar(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +printf "NAME\tENDPOINT\n" +printf " local\thttps://127.0.0.1:17670\n" +printf "* remote\thttps://gw.example.com\n" +`) + gw := NewCLI(bin) + active := gw.ActiveGateway() + if active != "remote" { + t.Errorf("ActiveGateway = %q, want remote", active) + } +} + +func TestActiveGateway_None(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +printf "NAME\tENDPOINT\n" +printf " local\thttps://127.0.0.1:17670\n" +`) + gw := NewCLI(bin) + active := gw.ActiveGateway() + if active != "" { + t.Errorf("ActiveGateway = %q, want empty", active) + } +} + +func TestInferenceModel_ParsesModel(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +echo "Provider: vertex-local" +echo "Model: claude-sonnet-4-6" +`) + gw := NewCLI(bin) + model := gw.InferenceModel() + if model != "claude-sonnet-4-6" { + t.Errorf("InferenceModel = %q, want claude-sonnet-4-6", model) + } +} + +func TestCLIVersion(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +echo "openshell v0.0.55" +`) + gw := NewCLI(bin) + ver := gw.CLIVersion() + if ver != "openshell v0.0.55" { + t.Errorf("CLIVersion = %q", ver) + } +} + +func TestCLIPath(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +exit 0 +`) + gw := NewCLI(bin) + path := gw.CLIPath() + if path == "" { + t.Error("CLIPath = empty, want non-empty") + } +} + +func TestCLIPath_NotFound(t *testing.T) { + gw := NewCLI("/nonexistent/openshell") + path := gw.CLIPath() + if path != "" { + t.Errorf("CLIPath = %q, want empty", path) + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index fae81e3..11618a1 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -29,6 +29,7 @@ type Gateway interface { ProviderCreate(name, providerType string, opts ProviderCreateOpts) error ProviderDelete(name string) error ProviderProfileImport(dir string) error + ProviderProfileDelete(id string) error // Inference config InferenceSet(provider, model string) error diff --git a/internal/preflight/check.go b/internal/preflight/check.go index 8df5507..186f3d1 100644 --- a/internal/preflight/check.go +++ b/internal/preflight/check.go @@ -11,7 +11,7 @@ import ( "github.com/robbycochran/harness-openshell/internal/status" ) -func RunCheck(harnessDir string, gw gateway.Gateway, strict bool) error { +func loadEnabledProviders(harnessDir string) ([]Provider, error) { providersPath := os.Getenv("PROVIDERS_TOML") if providersPath == "" { providersPath = filepath.Join(harnessDir, "providers.toml") @@ -20,16 +20,19 @@ func RunCheck(harnessDir string, gw gateway.Gateway, strict bool) error { if configPath == "" { configPath = filepath.Join(harnessDir, "openshell.toml") } - - allProviders, err := LoadProviders(providersPath) + all, err := LoadProviders(providersPath) if err != nil { - return err + return nil, err } - config, err := LoadConfig(configPath) + config, _ := LoadConfig(configPath) + return EnabledProviders(all, config), nil +} + +func RunCheck(harnessDir string, gw gateway.Gateway, strict bool) error { + providers, err := loadEnabledProviders(harnessDir) if err != nil { return err } - providers := EnabledProviders(allProviders, config) hasFailures := false @@ -162,27 +165,16 @@ func RunCheck(harnessDir string, gw gateway.Gateway, strict bool) error { // Summary status.Summary(!hasFailures) if hasFailures && strict { - os.Exit(1) + return fmt.Errorf("preflight: required checks failed") } 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) + providers, err := loadEnabledProviders(harnessDir) if err != nil { return err } - config, _ := LoadConfig(configPath) - providers := EnabledProviders(all, config) var available []string for _, p := range providers { @@ -199,21 +191,10 @@ func RunAvailable(harnessDir string) error { } 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) + providers, err := loadEnabledProviders(harnessDir) if err != nil { return err } - config, _ := LoadConfig(configPath) - providers := EnabledProviders(all, config) var names []string for _, p := range providers { diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go index f8db8fc..0677286 100644 --- a/internal/profile/profile_test.go +++ b/internal/profile/profile_test.go @@ -32,6 +32,7 @@ func (m *mockGateway) ActiveGateway() string 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) ProviderProfileDelete(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 } diff --git a/main.go b/main.go index 7ddac19..50b0d9b 100644 --- a/main.go +++ b/main.go @@ -13,8 +13,10 @@ func main() { harnessDir := detectHarnessDir() root := &cobra.Command{ - Use: "harness", - Short: "OpenShell Harness — deploy and manage AI agent sandboxes", + Use: "harness", + Short: "OpenShell Harness — deploy and manage AI agent sandboxes", + SilenceErrors: true, + SilenceUsage: true, } cli := os.Getenv("OPENSHELL_CLI") diff --git a/test/test-flow.sh b/test/test-flow.sh index 685ae77..b6df9d9 100755 --- a/test/test-flow.sh +++ b/test/test-flow.sh @@ -9,8 +9,9 @@ # ./test-flow.sh podman --full # full bash: + sandbox + verify integrations # ./test-flow.sh podman --go # quick Go: same flow via Go binary # ./test-flow.sh podman --full --go # full Go -# ./test-flow.sh ocp [--full] [--go] # OCP variants -# ./test-flow.sh all [--full] [--go] # both platforms +# ./test-flow.sh ocp [--full] [--go] # OCP variants +# ./test-flow.sh ocp --full --reuse-gateway # skip deploy/teardown-k8s (~50s vs ~130s) +# ./test-flow.sh all [--full] [--go] # both platforms set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" @@ -21,18 +22,20 @@ CLI="${OPENSHELL_CLI:-openshell}" TARGET="" FULL=false USE_GO=false +REUSE_GATEWAY=false for arg in "$@"; do case "$arg" in - --full) FULL=true ;; - --go) USE_GO=true ;; - -*) ;; - *) [[ -z "$TARGET" ]] && TARGET="$arg" ;; + --full) FULL=true ;; + --go) USE_GO=true ;; + --reuse-gateway) REUSE_GATEWAY=true ;; + -*) ;; + *) [[ -z "$TARGET" ]] && TARGET="$arg" ;; esac done if [[ -z "$TARGET" ]]; then - echo "Usage: $0 [--full] [--go]" + echo "Usage: $0 [--full] [--go] [--reuse-gateway]" exit 1 fi @@ -150,9 +153,14 @@ test_errors() { # Bad profile step_fail "nonexistent profile" "$HARNESS" new --local --profile nonexistent --no-tty - # Teardown idempotency - step "teardown (first)" "$HARNESS" teardown - step "teardown (second)" "$HARNESS" teardown + # Teardown idempotency (skip k8s teardown when reusing gateway) + if $REUSE_GATEWAY; then + step "teardown (first)" "$HARNESS" teardown --sandboxes --providers + step "teardown (second)" "$HARNESS" teardown --sandboxes --providers + else + step "teardown (first)" "$HARNESS" teardown + step "teardown (second)" "$HARNESS" teardown + fi echo "" } @@ -192,10 +200,22 @@ test_podman() { test_ocp() { local mode="quick" $FULL && mode="full" + $REUSE_GATEWAY && mode="$mode, reuse-gateway" echo "=== test-flow: ocp ($mode) [$IMPL] ===" - step "teardown" "$HARNESS" teardown - step "deploy" "$HARNESS" deploy --remote + if $REUSE_GATEWAY; then + step "teardown sandboxes+providers" "$HARNESS" teardown --sandboxes --providers + # Deploy only if gateway is not reachable + if ! "$CLI" inference get &>/dev/null; then + step "deploy" "$HARNESS" deploy --remote + else + step "gateway reachable" "$CLI" inference get + fi + else + step "teardown" "$HARNESS" teardown + step "deploy" "$HARNESS" deploy --remote + fi + step "setup creds" "$SCRIPT_DIR/bin/scripts/creds.sh" step "setup providers" "$HARNESS" providers step "gateway reachable" "$CLI" inference get @@ -214,7 +234,11 @@ test_ocp() { step "sandbox delete" "$CLI" sandbox delete "$sandbox_name" fi - step "teardown (clean)" "$HARNESS" teardown + if $REUSE_GATEWAY; then + step "teardown (sandboxes+providers)" "$HARNESS" teardown --sandboxes --providers + else + step "teardown (clean)" "$HARNESS" teardown + fi } # ── Main ─────────────────────────────────────────────────────────────