From feab01b85b13a955f16b2207efe572d5e90d43ea Mon Sep 17 00:00:00 2001 From: Gleb Chesnokov Date: Mon, 12 Oct 2026 15:49:46 +0300 Subject: [PATCH] tests: Add SCST component tests with Ginkgo Add component tests for the scstadmin management interface, including state preservation after rejected commands. Use scst_local and vdisk_nullio to cover devices, targets, LUN mappings, initiator groups, ALUA and configuration save/restore without physical storage. Provide a shared runner with explicit configuration, host locking, bounded command logs and JSON/JUnit reports. Select specs through labels and focus before preparing the host, and reject empty selections. Parse scstadmin output, including session attributes and LUN mappings, through common helpers covered by framework tests. Load checkout modules in suite setup, optionally build them first, and unload them after successful cleanup. Add common Make targets for framework checks, test selection and live runs, with setup documentation. --- AGENTS.md | 4 +- Makefile | 17 + tests/ginkgo/.gitignore | 3 + tests/ginkgo/AGENTS.md | 28 ++ tests/ginkgo/Makefile | 36 ++ tests/ginkgo/README.md | 85 ++++ tests/ginkgo/cmd/config-check/main.go | 28 ++ tests/ginkgo/cmd/run/main.go | 84 ++++ tests/ginkgo/cmd/run/main_test.go | 114 ++++++ tests/ginkgo/config.example.json | 14 + tests/ginkgo/go.mod | 23 ++ tests/ginkgo/go.sum | 69 ++++ tests/ginkgo/internal/admin/command.go | 154 +++++++ tests/ginkgo/internal/admin/command_test.go | 194 +++++++++ tests/ginkgo/internal/admin/parse.go | 372 +++++++++++++++++ tests/ginkgo/internal/admin/parse_test.go | 211 ++++++++++ tests/ginkgo/internal/admin/read.go | 138 +++++++ tests/ginkgo/internal/admin/read_test.go | 52 +++ tests/ginkgo/internal/testcfg/config.go | 114 ++++++ tests/ginkgo/internal/testcfg/config_test.go | 59 +++ tests/ginkgo/internal/testhost/prepare.go | 223 ++++++++++ .../ginkgo/internal/testhost/prepare_test.go | 386 ++++++++++++++++++ tests/ginkgo/internal/testrun/run.go | 109 +++++ tests/ginkgo/internal/testrun/selection.go | 53 +++ .../ginkgo/internal/testrun/selection_test.go | 53 +++ tests/ginkgo/suite/alua_test.go | 47 +++ tests/ginkgo/suite/config_test.go | 84 ++++ tests/ginkgo/suite/fixture_test.go | 180 ++++++++ tests/ginkgo/suite/management_test.go | 185 +++++++++ tests/ginkgo/suite/suite_test.go | 75 ++++ 30 files changed, 3193 insertions(+), 1 deletion(-) create mode 100644 tests/ginkgo/.gitignore create mode 100644 tests/ginkgo/AGENTS.md create mode 100644 tests/ginkgo/Makefile create mode 100644 tests/ginkgo/README.md create mode 100644 tests/ginkgo/cmd/config-check/main.go create mode 100644 tests/ginkgo/cmd/run/main.go create mode 100644 tests/ginkgo/cmd/run/main_test.go create mode 100644 tests/ginkgo/config.example.json create mode 100644 tests/ginkgo/go.mod create mode 100644 tests/ginkgo/go.sum create mode 100644 tests/ginkgo/internal/admin/command.go create mode 100644 tests/ginkgo/internal/admin/command_test.go create mode 100644 tests/ginkgo/internal/admin/parse.go create mode 100644 tests/ginkgo/internal/admin/parse_test.go create mode 100644 tests/ginkgo/internal/admin/read.go create mode 100644 tests/ginkgo/internal/admin/read_test.go create mode 100644 tests/ginkgo/internal/testcfg/config.go create mode 100644 tests/ginkgo/internal/testcfg/config_test.go create mode 100644 tests/ginkgo/internal/testhost/prepare.go create mode 100644 tests/ginkgo/internal/testhost/prepare_test.go create mode 100644 tests/ginkgo/internal/testrun/run.go create mode 100644 tests/ginkgo/internal/testrun/selection.go create mode 100644 tests/ginkgo/internal/testrun/selection_test.go create mode 100644 tests/ginkgo/suite/alua_test.go create mode 100644 tests/ginkgo/suite/config_test.go create mode 100644 tests/ginkgo/suite/fixture_test.go create mode 100644 tests/ginkgo/suite/management_test.go create mode 100644 tests/ginkgo/suite/suite_test.go diff --git a/AGENTS.md b/AGENTS.md index 92c1cae8b..a62f2a425 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,8 @@ user-space handlers and daemons. ALUA state-change daemon, and event example. - `scstadmin/` owns the configuration grammar, sysfs discovery and mutation, service integration, and configuration application policy. +- `tests/ginkgo/` owns SCST management component tests through `scstadmin`; + follow its component guidance for validation and live-host boundaries. - `scripts/` owns source, patch, release, checkpatch, regression, and performance helpers. `doc/` contains design and ABI references; `www/` contains website material. `debian/`, spec templates, `nightly/`, and @@ -139,7 +141,7 @@ the public certificate is an enrollment input. smatch workflow is `scripts/run-regression-tests` as configured by `.github/workflows/run_regression_tests.yaml`; it is heavyweight, downloads or prepares kernel trees, and is not a quick unit test. -- There is no repository-wide lightweight unit-test target. Read a local +- `make ginkgo-check` checks the component-test framework only. Read a local `AGENTS.md` before using a component `test` target. For instruction-only or documentation-only changes, inspect the full diff and run `git diff --check`; do not build merely to validate prose. diff --git a/Makefile b/Makefile index d81740cbd..3afef7f68 100644 --- a/Makefile +++ b/Makefile @@ -137,6 +137,12 @@ SCST_SOURCE_FILES = $(shell if [ -e scripts/list-source-files ]; then \ fi) help: + @echo "Component tests (see tests/ginkgo/README.md):" + @echo " ginkgo-check / ginkgo-list: checks / dry run without SCST access" + @echo " ginkgo-config: validate GINKGO_CONFIG (JSON file)" + @echo " ginkgo-test: load built SCST modules, then run tests (root)" + @echo " ginkgo-test-build: build and load SCST, then run tests (root)" + @echo "" @echo "Build variables:" @echo " WERROR=y|n : treat warnings as errors (default: n)" @echo "" @@ -226,6 +232,16 @@ help: @echo " Note:" @echo " - install and uninstall may need root privileges" +# Resolve the config relative to the caller, before entering tests/ginkgo. +export GINKGO_CONFIG + +ginkgo-test-build: export SCST_TEST_KDIR := $(abspath $(KDIR)) + +ginkgo-check ginkgo-list ginkgo-config ginkgo-test ginkgo-test-build: + @case "$$GINKGO_CONFIG" in ""|/*) ;; \ + *) GINKGO_CONFIG="$$PWD/$$GINKGO_CONFIG" ;; esac; \ + $(MAKE) -C tests/ginkgo $(patsubst ginkgo-%,%,$@) GINKGO_CONFIG="$$GINKGO_CONFIG" + tags: find . -type f -name "*.[ch]" | ctags --c-kinds=+p --fields=+iaS --extra=+q -e -L- @@ -598,6 +614,7 @@ multiple-release-archives: cd $(SCST_DIR) && $(MAKE) $@ .PHONY: help tags cov-build shellcheck all clean extraclean install uninstall \ + ginkgo-check ginkgo-list ginkgo-config ginkgo-test ginkgo-test-build \ scst scst_clean scst_extraclean scst_install scst_uninstall \ docs docs_clean docs_extraclean \ scstadm scstadm_clean scstadm_extraclean scstadm_install scstadm_uninstall \ diff --git a/tests/ginkgo/.gitignore b/tests/ginkgo/.gitignore new file mode 100644 index 000000000..2d1a2f5ce --- /dev/null +++ b/tests/ginkgo/.gitignore @@ -0,0 +1,3 @@ +!go.mod +/config.local.json +/results/ diff --git a/tests/ginkgo/AGENTS.md b/tests/ginkgo/AGENTS.md new file mode 100644 index 000000000..9041c7797 --- /dev/null +++ b/tests/ginkgo/AGENTS.md @@ -0,0 +1,28 @@ +# SCST Component Tests + +Use [README.md](README.md) for setup, configuration, commands and recovery. +`suite/` owns local tests; `internal/` owns shared configuration, execution +and fixtures. + +## Interfaces and lifecycle + +- Management operations and assertions use `scstadmin`, never direct sysfs or + Perl-library calls. A rejection covers the whole management chain, not + necessarily a kernel check. Check operation errors even with exit status 0. +- Use shared command runners with contexts, timeouts, logs and failure handling. + Execution failures must not satisfy assertions for expected rejections. +- Keep ownership and cleanup explicit for configuration, sessions and modules. + Restore changed global settings; global SCST clearing belongs only to + explicit clear-config tests. +- Failed cleanup or uncertain command completion must stop subsequent specs. + Preserve artifacts and residual resources for inspection instead of forcing + cleanup. +- Module loading and unloading belong to the suite lifecycle. + +## Validation + +`make ginkgo-check` checks the framework; `ginkgo-list` and `ginkgo-config` +check selection and configuration without SCST access. +Live targets load or replace modules and mutate storage on the test host; +the root authorization rules apply. Dry runs do not validate SCST behavior. +Keep generated `results/` and local profiles out of commits. diff --git a/tests/ginkgo/Makefile b/tests/ginkgo/Makefile new file mode 100644 index 000000000..853c6c2b2 --- /dev/null +++ b/tests/ginkgo/Makefile @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: GPL-2.0-only +GO ?= go +GINKGO_LABEL ?= local && basic +GINKGO_FOCUS ?= + +# Export values instead of interpolating them into shell source. In particular, +# label expressions and focus regexps can contain shell metacharacters. +export GO GINKGO_LABEL GINKGO_FOCUS GINKGO_CONFIG +export SCST_TEST_SOURCE_DIR := $(abspath ../..) + +help: + @echo "check : unit tests, vet and component-suite compilation; no SCST access" + @echo "list : Ginkgo dry run; no SCST access" + @echo "config : validate and print GINKGO_CONFIG; no SCST access" + @echo "test : load built SCST modules from this checkout, then run tests (root)" + @echo "test-build: build and load SCST from this checkout, then run tests (root)" + @echo "GINKGO_LABEL selects groups, e.g. local or basic" + +check: + $(GO) test -count=1 ./internal/... ./cmd/... + $(GO) vet ./... + $(GO) test -run='^$$' ./suite + +list: + $(GO) run ./cmd/run -list + +config: + @test -n "$$GINKGO_CONFIG" || { echo "Set GINKGO_CONFIG to a JSON file" >&2; exit 2; } + $(GO) run ./cmd/config-check "$$GINKGO_CONFIG" + +test test-build: + @test -n "$$GINKGO_CONFIG" || { echo "Set GINKGO_CONFIG to a disposable-host JSON file" >&2; exit 2; } + SCST_TEST_BUILD=$(if $(filter test-build,$@),1,0) SCST_TEST_CONFIG="$$GINKGO_CONFIG" \ + SCST_TEST_KDIR="$${SCST_TEST_KDIR:-$${KDIR:-}}" $(GO) run ./cmd/run + +.PHONY: help check list config test test-build diff --git a/tests/ginkgo/README.md b/tests/ginkgo/README.md new file mode 100644 index 000000000..7e71ed94d --- /dev/null +++ b/tests/ginkgo/README.md @@ -0,0 +1,85 @@ +# SCST component tests + +Ginkgo/Gomega tests exercise SCST management through `scstadmin` on +`scst_local`. + +## Setup and run + +Use a disposable Linux target with root access, an empty SCST configuration, +Go as specified in [go.mod](go.mod), Perl and kmod utilities. Building requires +SCST build dependencies, Perl `ExtUtils::MakeMaker` and running-kernel headers. +Install dependencies separately; do not run another SCST manager concurrently. + +From the repository root: + +```sh +cp tests/ginkgo/config.example.json tests/ginkgo/config.local.json +``` + +Set `disposable_host=true` and choose `admin_command`. `["scstadmin"]` uses the +installed utility, including with `ginkgo-test-build`. To test this checkout: + +```json +"admin_command": [ + "/usr/bin/perl", + "-I/absolute/checkout/scstadmin/scstadmin.sysfs/scst-1.0.0/lib", + "/absolute/checkout/scstadmin/scstadmin.sysfs/scstadmin" +] +``` + +Validate the config, then run as root: + +```sh +make ginkgo-config GINKGO_CONFIG=tests/ginkgo/config.local.json +make ginkgo-test-build GINKGO_CONFIG=tests/ginkgo/config.local.json +# Reuse modules already built for the running kernel: +make ginkgo-test GINKGO_CONFIG=tests/ginkgo/config.local.json +``` + +Use `KDIR=/absolute/kernel/build` for headers elsewhere. Both live targets +load checkout modules, replacing existing SCST modules only if unconfigured. +Successful cleanup unloads SCST modules; standard kernel dependencies and +runtime directories under `/var/lib/scst` remain. + +## Select tests + +The default is `GINKGO_LABEL='local && basic'`. Use the listing below to +select other labels. +`GINKGO_FOCUS` additionally filters spec names by regexp. + +```sh +# List every spec and its labels without accessing SCST: +make ginkgo-list GINKGO_LABEL='' +# Select rejection cases: +make ginkgo-test-build GINKGO_CONFIG=tests/ginkgo/config.local.json GINKGO_LABEL=negative +``` + +An empty label filter selects all specs. `clear-config` tests invoke global +`scstadmin -clear_config -force`. +An empty selection or suite failure stops the run. Parallel runs are +unsupported: use the same `lock_file` and runner user on the host. + +## Configuration + +[config.example.json](config.example.json) lists defaults; `ginkgo-config` +validates and prints the effective config. The named JSON file overrides +defaults without implicit user overrides. `GINKGO_CONFIG` is relative to the +Make invocation directory; relative config paths resolve against the JSON +file, except interpreter arguments, which need absolute paths. Local profiles +are ignored by Git. + +## Checks and recovery + +`make ginkgo-check` runs framework unit tests, vet and suite compilation; +it does not access SCST. Neither it nor `ginkgo-list` proves +live behavior. Use the Make targets for live runs: plain `go test` skips specs +without `SCST_TEST_CONFIG` and needs `-count=1 -timeout=0` when running them. + +Cleanup removes owned sessions/configuration before closing devices and +unloading modules. A failure or interrupted command stops the run and may +retain modules. Inspect and recover the host before +retrying; never remove a lock file to bypass a held lock. + +The printed artifact directory under `report_dir` contains effective config, +command logs, JSON/JUnit reports and scenario inputs. Kernel warnings are not +collected: retain kernel logs when investigating failures. diff --git a/tests/ginkgo/cmd/config-check/main.go b/tests/ginkgo/cmd/config-check/main.go new file mode 100644 index 000000000..7d7480072 --- /dev/null +++ b/tests/ginkgo/cmd/config-check/main.go @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-2.0-only +package main + +import ( + "encoding/json" + "fmt" + "os" + + "scst-component-tests/internal/testcfg" +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: config-check CONFIG.json") + os.Exit(2) + } + cfg, err := testcfg.Load(os.Args[1]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + e := json.NewEncoder(os.Stdout) + e.SetIndent("", " ") + if err := e.Encode(cfg); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/tests/ginkgo/cmd/run/main.go b/tests/ginkgo/cmd/run/main.go new file mode 100644 index 000000000..1ce252311 --- /dev/null +++ b/tests/ginkgo/cmd/run/main.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-2.0-only +package main + +import ( + "flag" + "fmt" + "os" + "os/exec" + + "scst-component-tests/internal/testcfg" + "scst-component-tests/internal/testrun" +) + +func main() { + list := flag.Bool("list", false, "list selected specs without host access") + flag.Parse() + if err := run(*list); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(list bool) error { + goTool := os.Getenv("GO") + if goTool == "" { + goTool = "go" + } + directory, err := os.MkdirTemp("", "scst-ginkgo-selection-") + if err != nil { + return err + } + defer os.RemoveAll(directory) + + filters := []string{"-ginkgo.label-filter=" + os.Getenv("GINKGO_LABEL"), + "-ginkgo.focus=" + os.Getenv("GINKGO_FOCUS")} + args := []string{"test", "-count=1", "-timeout=0", "-p=1"} + for _, group := range testrun.Groups { + args = append(args, group.Package) + } + args = append(append(args, "-args"), filters...) + plan := exec.Command(goTool, args...) + plan.Env = append(os.Environ(), "SCST_TEST_PLAN_DIR="+directory) + if output, err := plan.CombinedOutput(); err != nil { + return fmt.Errorf("discover test selection: %w\n%s", err, output) + } + selected, err := testrun.ReadSelection(directory) + if err != nil { + return err + } + if !list { + path := os.Getenv("SCST_TEST_CONFIG") + if path == "" { + return fmt.Errorf("set GINKGO_CONFIG to a disposable-host JSON file") + } + cfg, err := testcfg.Load(path) + if err != nil { + return err + } + if err := testrun.ValidateSelection(cfg, selected); err != nil { + return err + } + } + for _, selection := range selected { + for _, group := range testrun.Groups { + if group.Name != selection.Group { + continue + } + fmt.Printf("Selected %s: %d specs\n", group.Name, selection.Specs) + args := []string{"test", "-count=1", "-timeout=0", "-v", group.Package, + "-args", "-ginkgo.v", "-ginkgo.fail-on-empty"} + if list { + args = append(args, "-ginkgo.dry-run") + } + command := exec.Command(goTool, append(args, filters...)...) + command.Env = append(os.Environ(), "SCST_TEST_PLAN_DIR=") + command.Stdout, command.Stderr = os.Stdout, os.Stderr + if err := command.Run(); err != nil { + // In particular, never start the next transport after failed cleanup. + return fmt.Errorf("%s suite failed: %w", group.Name, err) + } + } + } + return nil +} diff --git a/tests/ginkgo/cmd/run/main_test.go b/tests/ginkgo/cmd/run/main_test.go new file mode 100644 index 000000000..f60fad505 --- /dev/null +++ b/tests/ginkgo/cmd/run/main_test.go @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: GPL-2.0-only +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "scst-component-tests/internal/testrun" +) + +// Stand in for the Go tool so these tests never launch a live component suite. +func TestMain(m *testing.M) { + if os.Getenv("SCST_LAUNCHER_STUB") == "1" { + if dir := os.Getenv("SCST_TEST_PLAN_DIR"); dir != "" { + var selections []testrun.Selection + if json.Unmarshal([]byte(os.Getenv("SCST_STUB_SELECTION")), &selections) != nil { + os.Exit(2) + } + for _, s := range selections { + data, _ := json.Marshal(s) + if os.WriteFile(filepath.Join(dir, s.Group+".json"), data, 0600) != nil { + os.Exit(2) + } + } + os.Exit(0) + } + file, err := os.OpenFile(os.Getenv("SCST_STUB_CALLS"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + os.Exit(2) + } + for _, arg := range os.Args[1:] { + if arg == "./suite" { + if _, err := file.WriteString(arg + "\n"); err != nil { + os.Exit(2) + } + file.Close() + if arg == "./suite" && os.Getenv("SCST_STUB_FAIL_LOCAL") == "1" { + os.Exit(1) + } + os.Exit(0) + } + } + os.Exit(2) + } + os.Exit(m.Run()) +} + +func TestSelectedPackagesRunInOrder(t *testing.T) { + for _, tc := range []struct { + name string + local int + failLocal, wantError bool + want string + }{ + {name: "local", local: 31, want: "./suite\n"}, + {name: "suite failure", local: 31, failLocal: true, wantError: true, want: "./suite\n"}, + {name: "empty filter", wantError: true}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := stub(t, tc.local) + if tc.failLocal { + t.Setenv("SCST_STUB_FAIL_LOCAL", "1") + } + // Listing skips host config but follows the same package selection/order. + err := run(true) + if (err != nil) != tc.wantError { + t.Fatalf("unexpected result: %v", err) + } + got, err := os.ReadFile(calls) + if err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + if string(got) != tc.want { + t.Fatalf("calls=%q, want %q", got, tc.want) + } + }) + } +} + +func TestConfigurationRejectedBeforeAnySuiteRuns(t *testing.T) { + calls := stub(t, 31) + config := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(config, []byte(`{"disposable_host":false}`), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("SCST_TEST_CONFIG", config) + err := run(false) + if err == nil || !strings.Contains(err.Error(), "disposable_host") { + t.Fatalf("missing host preflight: %v", err) + } + if _, err := os.Stat(calls); !os.IsNotExist(err) { + t.Fatal("launched a suite before validating all selected groups") + } +} + +func stub(t *testing.T, local int) string { + t.Helper() + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + t.Setenv("GO", binary) + t.Setenv("SCST_LAUNCHER_STUB", "1") + t.Setenv("SCST_STUB_FAIL_LOCAL", "0") + t.Setenv("SCST_TEST_CONFIG", "/must/not/read/in/list/mode") + data, _ := json.Marshal([]testrun.Selection{{Group: "local", Specs: local}}) + t.Setenv("SCST_STUB_SELECTION", string(data)) + calls := filepath.Join(t.TempDir(), "calls") + t.Setenv("SCST_STUB_CALLS", calls) + return calls +} diff --git a/tests/ginkgo/config.example.json b/tests/ginkgo/config.example.json new file mode 100644 index 000000000..64c1b69aa --- /dev/null +++ b/tests/ginkgo/config.example.json @@ -0,0 +1,14 @@ +{ + "admin_command": ["scstadmin"], + "disposable_host": false, + "name_prefix": "scsttest", + "report_dir": "results", + "lock_file": "/tmp/scst-ginkgo.lock", + "command_timeout": "30s", + "cleanup_timeout": "2m", + "settle_timeout": "10s", + "poll_interval": "100ms", + "suite_timeout": "15m", + "build_timeout": "10m", + "build_jobs": 2 +} diff --git a/tests/ginkgo/go.mod b/tests/ginkgo/go.mod new file mode 100644 index 000000000..eb229b824 --- /dev/null +++ b/tests/ginkgo/go.mod @@ -0,0 +1,23 @@ +module scst-component-tests + +go 1.25.0 + +require ( + github.com/onsi/ginkgo/v2 v2.29.0 + github.com/onsi/gomega v1.41.0 +) + +require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.44.0 // indirect +) diff --git a/tests/ginkgo/go.sum b/tests/ginkgo/go.sum new file mode 100644 index 000000000..8e1e80b9e --- /dev/null +++ b/tests/ginkgo/go.sum @@ -0,0 +1,69 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag= +github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA= +github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tests/ginkgo/internal/admin/command.go b/tests/ginkgo/internal/admin/command.go new file mode 100644 index 000000000..92db14e72 --- /dev/null +++ b/tests/ginkgo/internal/admin/command.go @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: GPL-2.0-only +package admin + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os/exec" + "strings" + "sync" + "syscall" + "time" +) + +type Result struct { + Args []string `json:"argv"` + Started time.Time `json:"started"` + Duration time.Duration `json:"duration_ns"` + ExitCode int `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Failure string `json:"execution_error,omitempty"` +} + +func (r Result) String() string { + return fmt.Sprintf("%q: exit %d\nstdout:\n%s\nstderr:\n%s\nexecution error: %s", + r.Args, r.ExitCode, r.Stdout, r.Stderr, r.Failure) +} + +// ReportedError recognizes scstadmin's operation-error banners. In particular, +// issueWarning can report a failed attribute write while the process exits 0. +// Configuration advisories (such as a missing LUN 0) are not these errors. +func (r Result) ReportedError() bool { + for _, stream := range []string{r.Stdout, r.Stderr} { + for _, line := range strings.Split(stream, "\n") { + switch strings.TrimSpace(line) { + case "WARNING: Received the following error:", "FATAL: Received the following error:": + return true + } + } + } + return false +} + +// Client invokes the public CLI without a shell. Execute separates ordinary +// command results from failures to execute or supervise the process. Callers +// must check both ExitCode and ReportedError for scstadmin operations. +type Client struct { + Command []string + Timeout time.Duration + Log io.Writer + mu sync.Mutex + blocked error +} + +func (c *Client) Blocked() error { + c.mu.Lock() + defer c.mu.Unlock() + return c.blocked +} + +const outputLimit = 1024 * 1024 + +type limitedOutput struct { + buffer bytes.Buffer + truncated bool +} + +func (b *limitedOutput) String() string { return b.buffer.String() } + +func (b *limitedOutput) Write(p []byte) (int, error) { + n := len(p) + if len(p) > outputLimit-b.buffer.Len() { + p = p[:outputLimit-b.buffer.Len()] + b.truncated = true + } + _, _ = b.buffer.Write(p) + return n, nil +} + +func (c *Client) Execute(ctx context.Context, args ...string) (Result, error) { + if len(c.Command) == 0 { + return Result{ExitCode: -1}, fmt.Errorf("missing scstadmin command") + } + argv := append(append([]string{}, c.Command...), "-noprompt", "-no_lip") + return c.RunProgram(ctx, c.Timeout, append(argv, args...)...) +} + +// RunProgram is used for host setup and teardown. It shares the command journal +// and failure latch with Execute, but does not add scstadmin arguments. +func (c *Client) RunProgram(ctx context.Context, timeout time.Duration, argv ...string) (Result, error) { + c.mu.Lock() + defer c.mu.Unlock() + r := Result{ExitCode: -1, Started: time.Now().UTC()} + if c.blocked != nil { + return r, fmt.Errorf("further commands disabled: %w", c.blocked) + } + if len(argv) == 0 { + return r, fmt.Errorf("missing program") + } + r.Args = append([]string{}, argv...) + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + cmd := exec.CommandContext(ctx, r.Args[0], r.Args[1:]...) + // Terminate the command's process group on cancellation. Privilege wrappers + // may prevent this; a timeout always blocks subsequent management operations. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } + cmd.WaitDelay = time.Second + var out, stderr limitedOutput + cmd.Stdout, cmd.Stderr = &out, &stderr + err := cmd.Run() + r.Duration, r.Stdout, r.Stderr = time.Since(r.Started), out.String(), stderr.String() + if cmd.ProcessState != nil { + r.ExitCode = cmd.ProcessState.ExitCode() + } + var executionError error + var exitError *exec.ExitError + switch { + case ctx.Err() != nil: + executionError = fmt.Errorf("command interrupted: %w; kernel work may still be running", ctx.Err()) + c.blocked = executionError + case out.truncated || stderr.truncated: + executionError = fmt.Errorf("command output exceeded %d bytes", outputLimit) + c.blocked = executionError + case err != nil && (!errors.As(err, &exitError) || r.ExitCode < 0): + executionError = err + c.blocked = executionError + } + if executionError != nil { + r.Failure = executionError.Error() + } + if c.Log != nil { + if err := json.NewEncoder(c.Log).Encode(r); err != nil { + c.blocked = fmt.Errorf("cannot record command: %w", err) + return r, errors.Join(executionError, c.blocked) + } + } + return r, executionError +} + +func (c *Client) Query(ctx context.Context, args ...string) (string, error) { + r, err := c.Execute(ctx, args...) + if err != nil { + return "", fmt.Errorf("scstadmin query could not complete: %w\n%s", err, r.String()) + } + if r.ExitCode != 0 || r.ReportedError() { + return "", fmt.Errorf("scstadmin query failed: %s", r.String()) + } + return r.Stdout, nil +} diff --git a/tests/ginkgo/internal/admin/command_test.go b/tests/ginkgo/internal/admin/command_test.go new file mode 100644 index 000000000..53f05da1f --- /dev/null +++ b/tests/ginkgo/internal/admin/command_test.go @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: GPL-2.0-only +package admin + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "reflect" + "strings" + "syscall" + "testing" + "time" +) + +// A subprocess exercises the actual exec path without invoking scstadmin. +func TestCommandProcess(t *testing.T) { + if os.Getenv("SCST_COMMAND_TEST_HELPER") != "1" { + return + } + i := 0 + for i < len(os.Args) && os.Args[i] != "--" { + i++ + } + mode := os.Args[i+1] + switch mode { + case "reject": + _ = json.NewEncoder(os.Stdout).Encode(os.Args[i+2:]) + fmt.Fprint(os.Stderr, "expected rejection") + os.Exit(23) + case "wait": + time.Sleep(time.Minute) + case "signal": + _ = syscall.Kill(os.Getpid(), syscall.SIGKILL) + case "overflow": + fmt.Print(strings.Repeat("x", outputLimit+1)) + case "warning": + fmt.Print(attributeWriteError) + fmt.Fprint(os.Stderr, "(EINVAL) ") + case "query": + var outputs map[string]string + if err := json.Unmarshal([]byte(os.Getenv("SCST_QUERY_TEST_OUTPUTS")), &outputs); err != nil { + os.Exit(2) + } + for _, arg := range os.Args[i+2:] { + if out, found := outputs[arg]; found { + fmt.Print(out) + os.Exit(0) + } + } + os.Exit(2) + default: + os.Exit(2) + } + os.Exit(0) +} + +// A failed attribute write: the CLI says "done" and exits zero, but +// issueWarning reports the operation error. State checks remain in the specs. +const attributeWriteError = ` +Collecting current configuration: done. + +-> Making requested changes. + -> Setting SCST attribute 'measure_latency' to value '.': done. + +WARNING: Received the following error: + + Failed to set a SCST attribute. See "dmesg" for more information. + + -> Done, 0 change(s) made. + +All done. +` + +func TestReportedError(t *testing.T) { + for _, tc := range []struct { + name, output string + want bool + }{ + {"failed attribute write", attributeWriteError, true}, + {"fatal error", "FATAL: Received the following error:\nA fatal error occurred.\n", true}, + {"indented error", "\tWARNING: Received the following error:\r\n", true}, + {"success", "-> Done, 1 change(s) made.\nAll done.\n", false}, + {"idempotent success", "-> Attribute 'measure_latency' already set to value '0', ignoring.\n-> Done, 0 change(s) made.\n", false}, + {"configuration advisory", "-> WARNING: No LUN 0 defined for driver/target/group 'local/t/g'.\n-> Done, 1 warnings found.\n", false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := (Result{Stdout: tc.output}).ReportedError(); got != tc.want { + t.Fatalf("ReportedError() = %t, want %t", got, tc.want) + } + }) + } +} + +func TestWarningWithZeroExit(t *testing.T) { + c := helperClient(t, "warning") + r, err := c.Execute(context.Background(), "-set_scst_attr", "-attributes", "measure_latency=.") + if err != nil || r.ExitCode != 0 || !r.ReportedError() || c.Blocked() != nil { + t.Fatalf("lost operation rejection or treated it as execution failure: %v / %s", err, r) + } + if _, err := c.Query(context.Background(), "-list_scst_attr"); err == nil { + t.Fatal("query accepted explicit error with zero exit") + } +} + +func helperClient(t *testing.T, mode string) *Client { + t.Helper() + t.Setenv("SCST_COMMAND_TEST_HELPER", "1") + return &Client{Command: []string{os.Args[0], "-test.run=^TestCommandProcess$", "--", mode}, Timeout: 5 * time.Second} +} + +func TestRejectionAndLiteralArguments(t *testing.T) { + c := helperClient(t, "reject") + var log bytes.Buffer + c.Log = &log + args := []string{"-device", "value with spaces; $(not-a-command)"} + r, err := c.Execute(context.Background(), args...) + if err != nil || r.ExitCode != 23 || c.Blocked() != nil { + t.Fatalf("ordinary rejection treated as execution failure: %v / %s", err, r) + } + var actual []string + if err := json.Unmarshal([]byte(r.Stdout), &actual); err != nil { + t.Fatal(err) + } + want := append([]string{"-noprompt", "-no_lip"}, args...) + if !reflect.DeepEqual(actual, want) { + t.Fatalf("argv got %q, want %q", actual, want) + } + var recorded Result + if err := json.Unmarshal(log.Bytes(), &recorded); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(recorded.Args, r.Args) || recorded.Stderr != "expected rejection" { + t.Fatalf("incomplete command log: %+v", recorded) + } + if _, err := c.Query(context.Background(), "-list_device"); err == nil { + t.Fatal("query accepted nonzero exit") + } +} + +func TestExecutionFailuresBlockFurtherCommands(t *testing.T) { + for _, mode := range []string{"wait", "signal", "overflow", "missing"} { + t.Run(mode, func(t *testing.T) { + c := helperClient(t, mode) + if mode == "wait" { + c.Timeout = 50 * time.Millisecond + } + if mode == "missing" { + c.Command = []string{t.TempDir() + "/missing"} + } + _, err := c.Execute(context.Background()) + if err == nil || c.Blocked() == nil { + t.Fatalf("%s did not block: %v", mode, err) + } + c.Command = []string{os.Args[0]} + r, err := c.Execute(context.Background()) + if err == nil || len(r.Args) != 0 { + t.Fatalf("executed another command after %s", mode) + } + }) + } +} + +func TestHostProgramSharesJournalAndFailureLatch(t *testing.T) { + c := helperClient(t, "reject") + var log bytes.Buffer + c.Log = &log + args := append(append([]string{}, c.Command...), "path with spaces") + r, err := c.RunProgram(context.Background(), time.Second, args...) + if err != nil || r.ExitCode != 23 { + t.Fatalf("host program: %v / %s", err, r) + } + var actual []string + if err := json.Unmarshal([]byte(r.Stdout), &actual); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(actual, []string{"path with spaces"}) { + t.Fatalf("host program received CLI flags or split arguments: %q", actual) + } + var recorded Result + if err := json.Unmarshal(log.Bytes(), &recorded); err != nil || !reflect.DeepEqual(recorded.Args, args) { + t.Fatalf("host program missing from journal: %+v, %v", recorded, err) + } + // A failed preparation command must also prevent subsequent SCST access. + _, err = c.RunProgram(context.Background(), time.Second, t.TempDir()+"/missing") + if err == nil || c.Blocked() == nil { + t.Fatalf("missing host program did not block: %v", err) + } + r, err = c.Execute(context.Background(), "-list_device") + if err == nil || len(r.Args) != 0 { + t.Fatal("SCST command executed after host preparation failure") + } +} diff --git a/tests/ginkgo/internal/admin/parse.go b/tests/ginkgo/internal/admin/parse.go new file mode 100644 index 000000000..8c7987e81 --- /dev/null +++ b/tests/ginkgo/internal/admin/parse.go @@ -0,0 +1,372 @@ +// SPDX-License-Identifier: GPL-2.0-only +package admin + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +// The CLI has human-readable tables, not JSON. These parsers recognize their +// structure and reject unknown output instead of turning parse failures into +// an empty object list. Ordering and column padding are not significant. +func body(output string) ([]string, error) { + var lines []string + finished := false + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if finished { + return nil, fmt.Errorf("unexpected output after CLI footer: %q", line) + } + if line == "All done." { + finished = true + continue + } + if line == "Collecting current configuration: done." { + continue + } + if strings.Trim(line, "-") == "" { + continue + } + lines = append(lines, line) + } + if !finished { + return nil, fmt.Errorf("missing scstadmin completion footer") + } + return lines, nil +} + +func NamedSections(output string, headings ...string) (map[string][]string, error) { + lines, err := body(output) + if err != nil { + return nil, err + } + result := make(map[string][]string) + current := "" + for _, line := range lines { + isHeading := false + for _, heading := range headings { + if line == heading { + if _, exists := result[heading]; exists { + return nil, fmt.Errorf("duplicate heading %q", heading) + } + result[heading] = []string{} + current, isHeading = heading, true + break + } + } + if isHeading { + continue + } + if current == "" || len(strings.Fields(line)) != 1 { + return nil, fmt.Errorf("unexpected list row: %q", line) + } + result[current] = append(result[current], line) + } + for _, heading := range headings { + if _, exists := result[heading]; !exists { + return nil, fmt.Errorf("missing heading %q", heading) + } + } + return result, nil +} + +// Pairs decodes the Handler/Device and Driver/Target tables. A blank first +// column repeats the previous owner. Empty handler rows use a '-' placeholder. +func Pairs(output, first, second string) (map[string]string, error) { + lines, err := body(output) + if err != nil { + return nil, err + } + if len(lines) == 0 || strings.Join(strings.Fields(lines[0]), " ") != first+" "+second { + return nil, fmt.Errorf("missing %s/%s table", first, second) + } + result := make(map[string]string) + owner := "" + for _, line := range lines[1:] { + fields := strings.Fields(line) + if len(fields) == 2 { + owner = fields[0] + } else if len(fields) != 1 || owner == "" { + return nil, fmt.Errorf("invalid %s/%s row: %q", first, second, line) + } + name := fields[len(fields)-1] + if name == "-" { + continue + } + if _, exists := result[name]; exists { + return nil, fmt.Errorf("duplicate %s %q", second, name) + } + result[name] = owner + } + return result, nil +} + +type Attribute struct { + Values []string + Writable bool + Key bool +} + +var columns = regexp.MustCompile(`[\t ]{2,}`) +var attributeName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +func Attributes(output string) (map[string]Attribute, error) { + lines, err := body(output) + if err != nil { + return nil, err + } + if len(lines) == 0 || strings.Join(strings.Fields(lines[0]), " ") != "Attribute Value Writable KEY" { + return nil, fmt.Errorf("missing attribute table") + } + result := make(map[string]Attribute) + previous := "" + appendix := "" + appendixRows := 0 + seenAppendices := make(map[string]bool) + for _, line := range lines[1:] { + switch line { + case "LUN CREATE attributes available", "Initiator CREATE attributes available", + "Device CREATE attributes available", "Target CREATE attributes available:", + "Dynamic attributes available": + if (appendix != "" && appendixRows == 0) || seenAppendices[line] { + return nil, fmt.Errorf("empty or duplicate attribute appendix: %q", line) + } + appendix, appendixRows = line, 0 + seenAppendices[line] = true + continue + } + // Target/group listings append names of supported create parameters. + // These are capability descriptions, not current attribute values. + if appendix != "" { + if !attributeName.MatchString(line) { + return nil, fmt.Errorf("invalid row in %s: %q", appendix, line) + } + appendixRows++ + continue + } + // The remaining lines document accepted trace_level values; they are + // not attributes. This optional appendix follows the complete table. + if line == "Possible trace levels:" { + if _, ok := result["trace_level"]; !ok { + return nil, fmt.Errorf("trace-level help without an attribute") + } + break + } + if line == "(none)" && len(lines) == 2 { + return result, nil + } + fields := columns.Split(line, -1) + if len(fields) == 3 && previous != "" { + fields = append([]string{previous}, fields...) + } else if len(fields) != 4 { + return nil, fmt.Errorf("invalid attribute row: %q", line) + } + if (fields[2] != "Yes" && fields[2] != "No") || (fields[3] != "Yes" && fields[3] != "No") { + return nil, fmt.Errorf("invalid attribute flags: %q", line) + } + a := result[fields[0]] + a.Values = append(a.Values, fields[1]) + a.Writable, a.Key = fields[2] == "Yes", fields[3] == "Yes" + result[fields[0]], previous = a, fields[0] + } + if appendix != "" && appendixRows == 0 { + return nil, fmt.Errorf("empty attribute appendix: %q", appendix) + } + return result, nil +} + +type Group struct { + LUNs map[int]string + Initiators []string +} + +type Assignments struct { + Driver string + Target string + LUNs map[int]string + Groups map[string]*Group +} + +func ParseAssignments(output string) (Assignments, error) { + r := Assignments{LUNs: map[int]string{}, Groups: map[string]*Group{}} + lines, err := body(output) + if err != nil { + return r, err + } + luns := r.LUNs + var group *Group + section := "" + for _, line := range lines { + switch { + case strings.HasPrefix(line, "Driver: "): + if r.Driver != "" { + return r, fmt.Errorf("expected exactly one target") + } + r.Driver = strings.TrimPrefix(line, "Driver: ") + case strings.HasPrefix(line, "Target: "): + if r.Target != "" { + return r, fmt.Errorf("expected exactly one target") + } + r.Target = strings.TrimPrefix(line, "Target: ") + case strings.HasPrefix(line, "Group: "): + name := strings.TrimPrefix(line, "Group: ") + if _, exists := r.Groups[name]; exists { + return r, fmt.Errorf("duplicate group %q", name) + } + group = &Group{LUNs: map[int]string{}, Initiators: []string{}} + r.Groups[name], luns, section = group, group.LUNs, "" + case line == "Assigned LUNs:": + section = "luns" + case line == "Assigned Initiators:": + section = "initiators" + case line == "(none)" || strings.Join(strings.Fields(line), " ") == "LUN Device" || line == "Initiator": + if section == "" { + return r, fmt.Errorf("table data without a section: %q", line) + } + case line == fmt.Sprintf("Driver/target '%s/%s' has no associated LUNs.", r.Driver, r.Target): + section = "" + case strings.HasPrefix(line, "Group '") && strings.HasSuffix(line, "' has no associated LUNs or initiators."): + name := strings.TrimSuffix(strings.TrimPrefix(line, "Group '"), "' has no associated LUNs or initiators.") + if r.Groups[name] != group || group == nil { + return r, fmt.Errorf("unexpected empty group: %q", line) + } + section = "" + default: + fields := strings.Fields(line) + if section == "luns" && len(fields) == 2 { + n, err := strconv.Atoi(fields[0]) + if err != nil || n < 0 { + return r, fmt.Errorf("invalid LUN: %q", line) + } + if _, exists := luns[n]; exists { + return r, fmt.Errorf("duplicate LUN: %q", line) + } + luns[n] = fields[1] + } else if section == "initiators" && group != nil && len(fields) == 1 { + group.Initiators = append(group.Initiators, line) + } else { + return r, fmt.Errorf("unexpected assignment row: %q", line) + } + } + } + if r.Driver == "" || r.Target == "" { + return r, fmt.Errorf("missing driver/target in assignment listing") + } + return r, nil +} + +// Session includes only what scstadmin exposes. LUN rows describe the selected +// initiator group's map; the CLI does not expose per-LUN kernel objects or the +// default group's session map. +type Session struct { + Attributes map[string]Attribute + LUNs map[int]string +} + +func ParseSessions(output string) (map[string]map[string]Session, error) { + lines, err := body(output) + if err != nil { + return nil, err + } + r := make(map[string]map[string]Session) + owner, name := "", "" + var table []string + flush := func() error { + if name == "" { + return nil + } + attrs, err := Attributes(strings.Join(table, "\n") + "\nAll done.\n") + if err != nil { + return fmt.Errorf("session %s/%s: %w", owner, name, err) + } + session := Session{Attributes: attrs, LUNs: map[int]string{}} + for key, attr := range attrs { + if !strings.HasPrefix(key, "LUN ") { + continue + } + n, err := strconv.Atoi(strings.TrimPrefix(key, "LUN ")) + if err != nil || n < 0 || len(attr.Values) != 1 || attr.Values[0] == "" { + return fmt.Errorf("invalid session LUN %q: %+v", key, attr) + } + if _, exists := session.LUNs[n]; exists { + return fmt.Errorf("duplicate session LUN %d", n) + } + session.LUNs[n] = attr.Values[0] + } + r[owner][name] = session + name, table = "", nil + return nil + } + empty := false + for _, line := range lines { + switch { + case strings.HasPrefix(line, "Driver/Target: "): + if err := flush(); err != nil { + return nil, err + } + if owner != "" && len(r[owner]) == 0 && !empty { + return nil, fmt.Errorf("missing sessions for %s", owner) + } + owner = strings.TrimPrefix(line, "Driver/Target: ") + parts := strings.Split(owner, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return nil, fmt.Errorf("invalid session owner %q", owner) + } + if _, exists := r[owner]; exists { + return nil, fmt.Errorf("duplicate session owner %q", owner) + } + r[owner], empty = map[string]Session{}, false + case strings.HasPrefix(line, "Session: "): + if err := flush(); err != nil { + return nil, err + } + if owner == "" || empty { + return nil, fmt.Errorf("unexpected session %q", line) + } + name = strings.TrimPrefix(line, "Session: ") + if name == "" { + return nil, fmt.Errorf("empty session name") + } + if _, exists := r[owner][name]; exists { + return nil, fmt.Errorf("duplicate session %q", name) + } + case line == "(no sessions)": + if owner == "" || name != "" || len(r[owner]) != 0 || empty { + return nil, fmt.Errorf("unexpected empty session list") + } + empty = true + default: + if name == "" { + return nil, fmt.Errorf("unexpected session row %q", line) + } + table = append(table, line) + } + } + if err := flush(); err != nil { + return nil, err + } + if owner != "" && len(r[owner]) == 0 && !empty { + return nil, fmt.Errorf("missing sessions for %s", owner) + } + return r, nil +} + +func SessionNames(output string) (map[string][]string, error) { + sessions, err := ParseSessions(output) + if err != nil { + return nil, err + } + names := make(map[string][]string) + for owner, entries := range sessions { + names[owner] = []string{} + for name := range entries { + names[owner] = append(names[owner], name) + } + } + return names, nil +} diff --git a/tests/ginkgo/internal/admin/parse_test.go b/tests/ginkgo/internal/admin/parse_test.go new file mode 100644 index 000000000..0675ff003 --- /dev/null +++ b/tests/ginkgo/internal/admin/parse_test.go @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: GPL-2.0-only +package admin + +import ( + "reflect" + "testing" +) + +func output(table string) string { + return "Collecting current configuration: done.\n\n" + table + "\nAll done.\n" +} + +func TestDeviceTable(t *testing.T) { + r, err := Pairs(output("Handler Device\n---------------------\nvdisk_fileio -\nvdisk_nullio first\n second"), "Handler", "Device") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(r, map[string]string{"first": "vdisk_nullio", "second": "vdisk_nullio"}) { + t.Fatalf("wrong device ownership: %v", r) + } + empty, err := Pairs(output("Driver Target\n-------------"), "Driver", "Target") + if err != nil || len(empty) != 0 { + t.Fatalf("empty target table: %v %v", empty, err) + } + for _, table := range []string{"Driver Target\n orphan", "Driver Target\nlocal one\nlocal one", "No such driver 'local' exists."} { + if _, err := Pairs(output(table), "Driver", "Target"); err == nil { + t.Fatalf("accepted malformed table %q", table) + } + } +} + +func TestAttributes(t *testing.T) { + r, err := Attributes(output(`Attribute Value Writable KEY +---------------------------------------------------------- +measure_latency 1 Yes Yes +optional Yes No +trace_level mgmt Yes No + pid Yes No + +Possible trace levels: + (use trace_level="add ", none, all or default to set): + mgmt, pid,`)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(r["trace_level"].Values, []string{"mgmt", "pid"}) || !r["measure_latency"].Key || r["optional"].Values[0] != "" { + t.Fatalf("incorrect attributes: %+v", r) + } + for _, table := range []string{"Attribute Value Writable KEY\nx 1 perhaps No", "Attribute Value Writable KEY\nPossible trace levels:", "device not found"} { + if _, err := Attributes(output(table)); err == nil { + t.Fatalf("accepted malformed attributes: %q", table) + } + } +} + +func TestAttributeCapabilityAppendices(t *testing.T) { + table := `Attribute Value Writable KEY +rel_tgt_id 60000 Yes Yes +read_only 0 Yes No` + for _, appendix := range []string{ + "LUN CREATE attributes available\n-------------------------------\nread_only", + "Dynamic attributes available\n----------------------------\nportal\nLUN CREATE attributes available\nread_only", + "LUN CREATE attributes available\nread_only\nInitiator CREATE attributes available\nactive", + "Device CREATE attributes available\nfilename\nread_only", + "Target CREATE attributes available:\nsession_name", + } { + t.Run(appendix, func(t *testing.T) { + attrs, err := Attributes(output(table + "\n" + appendix)) + if err != nil { + t.Fatal(err) + } + want := map[string]Attribute{ + "rel_tgt_id": {Values: []string{"60000"}, Writable: true, Key: true}, + "read_only": {Values: []string{"0"}, Writable: true}, + } + if !reflect.DeepEqual(attrs, want) { + t.Fatalf("capability names changed current attributes: %+v", attrs) + } + }) + } + for _, appendix := range []string{ + "LUN CREATE attributes available", + "LUN CREATE attributes available\nInitiator CREATE attributes available\nactive", + "LUN CREATE attributes available\nread_only\nLUN CREATE attributes available\nread_only", + "LUN CREATE attributes available\nread_only\nunexpected output", + "LUN CREATE attributes available\nread_only\nrel_tgt_id 1 Yes Yes", + "Unknown attributes available\nread_only", + } { + if _, err := Attributes(output(table + "\n" + appendix)); err == nil { + t.Fatalf("accepted malformed appendix %q", appendix) + } + } +} + +func TestAssignmentScopes(t *testing.T) { + r, err := ParseAssignments(output(`Driver: scst_local +Target: local +Assigned LUNs: + LUN Device + 0 default_disk +Group: group_a +Assigned LUNs: + LUN Device + 0 first_disk +Assigned Initiators: + Initiator + ini1 + ini2 +Group: group_b +Group 'group_b' has no associated LUNs or initiators.`)) + if err != nil { + t.Fatal(err) + } + if r.LUNs[0] != "default_disk" || r.Groups["group_a"].LUNs[0] != "first_disk" || len(r.Groups["group_a"].Initiators) != 2 || len(r.Groups["group_b"].LUNs) != 0 { + t.Fatalf("scope lost: %+v", r) + } + empty, err := ParseAssignments(output("Driver: scst_local\nTarget: local\nDriver/target 'scst_local/local' has no associated LUNs.")) + if err != nil || len(empty.LUNs) != 0 || len(empty.Groups) != 0 { + t.Fatalf("empty assignments: %v %v", empty, err) + } + if _, err := ParseAssignments(output("Driver/target 'scst_local/missing' not found")); err == nil { + t.Fatal("missing target treated as empty") + } +} + +func TestNamedSections(t *testing.T) { + r, err := NamedSections(output("Devices\n-------\ndisk\n\nTarget Groups\n------------\nports"), "Devices", "Target Groups") + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(r, map[string][]string{"Devices": {"disk"}, "Target Groups": {"ports"}}) { + t.Fatal(r) + } + for _, table := range []string{"Devices", "Devices\nDevices\nTarget Groups", "Devices\nUnexpected error\nTarget Groups"} { + if _, err := NamedSections(output(table), "Devices", "Target Groups"); err == nil { + t.Fatalf("accepted %q", table) + } + } + if _, err := NamedSections("Devices\n", "Devices"); err == nil { + t.Fatal("accepted truncated CLI output") + } + if _, err := NamedSections(output("Devices")+"late error", "Devices"); err == nil { + t.Fatal("accepted output after completion") + } +} + +func TestSessions(t *testing.T) { + r, err := SessionNames(output(`Driver/Target: scst_local/first + Session: initiator + Attribute Value Writable KEY + commands 5 No No +Driver/Target: scst_local/second + (no sessions)`)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(r, map[string][]string{"scst_local/first": {"initiator"}, "scst_local/second": {}}) { + t.Fatal(r) + } + if _, err := SessionNames(output("Session: orphan")); err == nil { + t.Fatal("accepted orphan session") + } +} + +func TestSessionLUNsAndScopes(t *testing.T) { + r, err := ParseSessions(output(`Driver/Target: scst_local/first +Session: initiator +Attribute Value Writable KEY +LUN 0 disk_a Yes No +LUN 12 disk_b Yes No +commands 17 Yes No +Session: second +Attribute Value Writable KEY +LUN 0 disk_c Yes No +Driver/Target: scst_local/second +(no sessions) +Driver/Target: copy_manager/copy_manager_tgt +Session: copy_manager_sess +Attribute Value Writable KEY +commands 42 Yes No`)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(r["scst_local/first"]["initiator"].LUNs, map[int]string{0: "disk_a", 12: "disk_b"}) || + r["scst_local/first"]["second"].LUNs[0] != "disk_c" || len(r["scst_local/second"]) != 0 || + len(r["copy_manager/copy_manager_tgt"]["copy_manager_sess"].LUNs) != 0 { + t.Fatalf("session scope lost: %+v", r) + } +} + +func TestRejectMalformedSessions(t *testing.T) { + header := "Driver/Target: scst_local/first\nSession: initiator\nAttribute Value Writable KEY\n" + for _, table := range []string{ + "Driver/Target: /first\n(no sessions)", + "Driver/Target: scst_local/first", + "Driver/Target: scst_local/first\n(no sessions)\nSession: initiator", + "Driver/Target: scst_local/first\n(no sessions)\nDriver/Target: scst_local/first\n(no sessions)", + "Driver/Target: scst_local/first\nSession: initiator", + header + "LUN -1 disk Yes No", + header + "LUN bad disk Yes No", + header + "LUN 0 disk Yes No\nLUN 0 disk2 Yes No", + header + "LUN 0 disk Yes No\nLUN 00 disk2 Yes No", + header + "LUN 0 Yes No", + header + "commands 1 Yes No\nSession: initiator\nAttribute Value Writable KEY\n(none)", + header + "unrecognized row", + } { + if _, err := ParseSessions(output(table)); err == nil { + t.Fatalf("accepted malformed sessions %q", table) + } + } +} diff --git a/tests/ginkgo/internal/admin/read.go b/tests/ginkgo/internal/admin/read.go new file mode 100644 index 000000000..3e7b29be0 --- /dev/null +++ b/tests/ginkgo/internal/admin/read.go @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-2.0-only +package admin + +import ( + "context" + "fmt" +) + +// RequireEmpty checks whether replacing the loaded SCST stack is safe for this +// test profile. It also accepts a core without scst_local on an initial run. +func (c *Client) RequireEmpty(ctx context.Context) error { + return c.RequireEmptyDriver(ctx, "scst_local") +} + +func (c *Client) RequireEmptyDriver(ctx context.Context, allowed string) error { + drivers, err := c.Names(ctx, "Driver", "-list_driver") + if err != nil { + return err + } + for _, driver := range drivers { + if driver != allowed && driver != "copy_manager" { + return fmt.Errorf("unexpected target driver %q", driver) + } + if driver == allowed { + targets, err := c.DriverTargets(ctx, driver) + if err != nil { + return err + } + if len(targets) != 0 { + return fmt.Errorf("%s targets still exist: %v", driver, targets) + } + } + } + devices, err := c.Devices(ctx) + if err != nil { + return err + } + if len(devices) != 0 { + return fmt.Errorf("SCST devices still exist: %v", devices) + } + groups, err := c.Names(ctx, "Device Group", "-list_dgrp") + if err != nil { + return err + } + if len(groups) != 0 { + return fmt.Errorf("ALUA device groups still exist: %v", groups) + } + return nil +} + +func (c *Client) Devices(ctx context.Context) (map[string]string, error) { + out, err := c.Query(ctx, "-list_device") + if err != nil { + return nil, err + } + return Pairs(out, "Handler", "Device") +} + +func (c *Client) Targets(ctx context.Context) (map[string]string, error) { + return c.DriverTargets(ctx, "scst_local") +} + +func (c *Client) DriverTargets(ctx context.Context, driver string) (map[string]string, error) { + out, err := c.Query(ctx, "-list_target", "-driver", driver) + if err != nil { + return nil, err + } + return Pairs(out, "Driver", "Target") +} + +func (c *Client) Names(ctx context.Context, heading string, args ...string) ([]string, error) { + out, err := c.Query(ctx, args...) + if err != nil { + return nil, err + } + sections, err := NamedSections(out, heading) + return sections[heading], err +} + +func (c *Client) Assignments(ctx context.Context, target string) (Assignments, error) { + return c.DriverAssignments(ctx, "scst_local", target) +} + +func (c *Client) DriverAssignments(ctx context.Context, driver, target string) (Assignments, error) { + out, err := c.Query(ctx, "-list_group", "-driver", driver, "-target", target) + if err != nil { + return Assignments{}, err + } + a, err := ParseAssignments(out) + if err == nil && (a.Driver != driver || a.Target != target) { + err = fmt.Errorf("unexpected assignment owner %s/%s", a.Driver, a.Target) + } + return a, err +} + +func (c *Client) SessionDetails(ctx context.Context) (map[string]map[string]Session, error) { + out, err := c.Query(ctx, "-list_sessions") + if err != nil { + return nil, err + } + return ParseSessions(out) +} + +func (c *Client) Attributes(ctx context.Context, args ...string) (map[string]Attribute, error) { + out, err := c.Query(ctx, append(args, "-nonkey")...) + if err != nil { + return nil, err + } + return Attributes(out) +} + +func (c *Client) Scalar(ctx context.Context, name string, args ...string) (string, error) { + attrs, err := c.Attributes(ctx, args...) + if err != nil { + return "", err + } + a, exists := attrs[name] + if !exists || len(a.Values) != 1 { + return "", fmt.Errorf("expected one value for attribute %q, got %+v", name, a) + } + return a.Values[0], nil +} + +func (c *Client) Sessions(ctx context.Context) (map[string][]string, error) { + out, err := c.Query(ctx, "-list_sessions") + if err != nil { + return nil, err + } + return SessionNames(out) +} + +func (c *Client) DeviceGroup(ctx context.Context, name string) (map[string][]string, error) { + out, err := c.Query(ctx, "-list_dgrp", name) + if err != nil { + return nil, err + } + return NamedSections(out, "Devices", "Target Groups") +} diff --git a/tests/ginkgo/internal/admin/read_test.go b/tests/ginkgo/internal/admin/read_test.go new file mode 100644 index 000000000..7ccc4e1c0 --- /dev/null +++ b/tests/ginkgo/internal/admin/read_test.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-2.0-only +package admin + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestRequireEmpty(t *testing.T) { + for _, tc := range []struct { + name, query, table, errorText string + }{ + {name: "empty stack"}, + {"core only", "-list_driver", "Driver\n------\ncopy_manager", ""}, + {"foreign driver", "-list_driver", "Driver\n------\niscsi", "unexpected target driver"}, + {"target", "-list_target", "Driver Target\n-------------\nscst_local existing", "targets still exist"}, + {"device", "-list_device", "Handler Device\n--------------\nvdisk_nullio existing", "devices still exist"}, + {"ALUA", "-list_dgrp", "Device Group\n------------\nexisting", "device groups still exist"}, + {"bad output", "-list_device", "unknown output", "missing Handler/Device table"}, + } { + t.Run(tc.name, func(t *testing.T) { + outputs := map[string]string{ + "-list_driver": output("Driver\n------\nscst_local\ncopy_manager"), + "-list_target": output("Driver Target\n-------------"), + "-list_device": output("Handler Device\n--------------\nvdisk_nullio -"), + "-list_dgrp": output("Device Group\n------------"), + } + if tc.query != "" { + outputs[tc.query] = output(tc.table) + } + if tc.name == "core only" { + delete(outputs, "-list_target") + } + data, err := json.Marshal(outputs) + if err != nil { + t.Fatal(err) + } + t.Setenv("SCST_QUERY_TEST_OUTPUTS", string(data)) + c := helperClient(t, "query") + err = c.RequireEmpty(context.Background()) + if tc.errorText == "" { + if err != nil { + t.Fatal(err) + } + } else if err == nil || !strings.Contains(err.Error(), tc.errorText) { + t.Fatalf("expected %q, got %v", tc.errorText, err) + } + }) + } +} diff --git a/tests/ginkgo/internal/testcfg/config.go b/tests/ginkgo/internal/testcfg/config.go new file mode 100644 index 000000000..8c9eeb14f --- /dev/null +++ b/tests/ginkgo/internal/testcfg/config.go @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: GPL-2.0-only +package testcfg + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// Config describes one local, disposable SCST host. There are no implicit +// per-user overrides: the named file completely determines a run. +type Config struct { + AdminCommand []string `json:"admin_command"` + DisposableHost bool `json:"disposable_host"` + NamePrefix string `json:"name_prefix"` + ReportDir string `json:"report_dir"` + LockFile string `json:"lock_file"` + CommandTimeout string `json:"command_timeout"` + CleanupTimeout string `json:"cleanup_timeout"` + SettleTimeout string `json:"settle_timeout"` + PollInterval string `json:"poll_interval"` + SuiteTimeout string `json:"suite_timeout"` + BuildTimeout string `json:"build_timeout"` + BuildJobs int `json:"build_jobs"` +} + +func Load(path string) (Config, error) { + c := Config{ + AdminCommand: []string{"scstadmin"}, NamePrefix: "scsttest", + ReportDir: "results", LockFile: "/tmp/scst-ginkgo.lock", + CommandTimeout: "30s", CleanupTimeout: "2m", SettleTimeout: "10s", + PollInterval: "100ms", SuiteTimeout: "15m", + BuildTimeout: "10m", BuildJobs: 2, + } + f, err := os.Open(path) + if err != nil { + return c, err + } + defer f.Close() + d := json.NewDecoder(f) + d.DisallowUnknownFields() + decoded := &c + if err := d.Decode(&decoded); err != nil { + return c, fmt.Errorf("test configuration: %w", err) + } + if decoded == nil { + return c, fmt.Errorf("test configuration must be a JSON object") + } + if err := d.Decode(new(any)); err != io.EOF { + return c, fmt.Errorf("test configuration must contain exactly one JSON object") + } + if err := c.Validate(); err != nil { + return c, err + } + base, err := filepath.Abs(filepath.Dir(path)) + if err != nil { + return c, err + } + for _, p := range []*string{&c.ReportDir, &c.LockFile} { + if !filepath.IsAbs(*p) { + *p = filepath.Join(base, *p) + } + } + if strings.ContainsRune(c.AdminCommand[0], '/') && !filepath.IsAbs(c.AdminCommand[0]) { + c.AdminCommand[0] = filepath.Join(base, c.AdminCommand[0]) + } + return c, nil +} + +func (c Config) Validate() error { + if len(c.AdminCommand) == 0 { + return fmt.Errorf("admin_command must contain an executable") + } + for _, arg := range c.AdminCommand { + if strings.TrimSpace(arg) == "" || strings.ContainsRune(arg, '\x00') { + return fmt.Errorf("admin_command contains an empty or invalid argument") + } + } + if !regexp.MustCompile(`^[a-z][a-z0-9_]{0,15}$`).MatchString(c.NamePrefix) { + return fmt.Errorf("name_prefix must contain 1-16 lowercase letters, digits or underscores and start with a letter") + } + if c.ReportDir == "" || c.LockFile == "" { + return fmt.Errorf("report_dir and lock_file must be nonempty") + } + for name, value := range map[string]string{ + "command_timeout": c.CommandTimeout, "cleanup_timeout": c.CleanupTimeout, + "settle_timeout": c.SettleTimeout, "poll_interval": c.PollInterval, + "suite_timeout": c.SuiteTimeout, + "build_timeout": c.BuildTimeout, + } { + v, err := time.ParseDuration(value) + if err != nil || v <= 0 { + return fmt.Errorf("%s must be a positive Go duration", name) + } + } + if Duration(c.PollInterval) > Duration(c.SettleTimeout) { + return fmt.Errorf("poll_interval exceeds settle_timeout") + } + if c.BuildJobs < 1 { + return fmt.Errorf("build_jobs must be positive") + } + return nil +} + +// Duration is used only after Validate has checked all duration fields. +func Duration(s string) time.Duration { + d, _ := time.ParseDuration(s) + return d +} diff --git a/tests/ginkgo/internal/testcfg/config_test.go b/tests/ginkgo/internal/testcfg/config_test.go new file mode 100644 index 000000000..9975b8de5 --- /dev/null +++ b/tests/ginkgo/internal/testcfg/config_test.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-2.0-only +package testcfg + +import ( + "os" + "path/filepath" + "testing" +) + +func configFile(t *testing.T, input string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(input), 0600); err != nil { + t.Fatal(err) + } + return path +} + +func TestLoadDefaultsAndPaths(t *testing.T) { + path := configFile(t, `{"admin_command":["./bin/admin","literal argument"],"report_dir":"out","lock_file":"host.lock"}`) + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + base := filepath.Dir(path) + if cfg.AdminCommand[0] != filepath.Join(base, "bin/admin") || cfg.AdminCommand[1] != "literal argument" || cfg.ReportDir != filepath.Join(base, "out") || cfg.LockFile != filepath.Join(base, "host.lock") { + t.Fatalf("paths not resolved against config location: %+v", cfg) + } + if cfg.DisposableHost || cfg.CommandTimeout != "30s" || cfg.BuildJobs != 2 || cfg.BuildTimeout != "10m" { + t.Fatalf("unsafe or missing defaults: %+v", cfg) + } +} + +func TestRejectMalformedConfiguration(t *testing.T) { + for name, input := range map[string]string{ + "unknown key": `{"commmand_timeout":"5s"}`, + "trailing object": `{} {}`, + "trailing text": `{} garbage`, + "null": `null`, + "array": `[]`, + "empty command": `{"admin_command":[]}`, + "empty argument": `{"admin_command":["sudo",""]}`, + "zero timeout": `{"command_timeout":"0s"}`, + "negative timeout": `{"cleanup_timeout":"-1s"}`, + "missing unit": `{"suite_timeout":"123"}`, + "invalid prefix": `{"name_prefix":"../../scst"}`, + "poll exceeds settle": `{"poll_interval":"20s","settle_timeout":"2s"}`, + "empty reports": `{"report_dir":""}`, + "zero build jobs": `{"build_jobs":0}`, + "negative build jobs": `{"build_jobs":-2}`, + "invalid build limit": `{"build_timeout":"never"}`, + } { + t.Run(name, func(t *testing.T) { + if _, err := Load(configFile(t, input)); err == nil { + t.Fatal("accepted invalid config") + } + }) + } +} diff --git a/tests/ginkgo/internal/testhost/prepare.go b/tests/ginkgo/internal/testhost/prepare.go new file mode 100644 index 000000000..57ce6135e --- /dev/null +++ b/tests/ginkgo/internal/testhost/prepare.go @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: GPL-2.0-only +package testhost + +import ( + "context" + "fmt" + "io" + "path/filepath" + "regexp" + "strings" + "time" + + "scst-component-tests/internal/admin" +) + +type RunProgram func(context.Context, time.Duration, ...string) (admin.Result, error) + +type Options struct { + SourceDir string + KernelDir string + Build bool + Jobs int + BuildTimeout time.Duration + CommandTimeout time.Duration +} + +var moduleName = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_-]*$`) + +// Modules tracks only the checkout modules successfully loaded by Prepare. +type Modules struct { + loaded []string +} + +// Prepare loads checkout modules under the suite's host lock and root checks. +// Build controls compilation only; module preparation is always performed. +// CheckEmpty must inspect SCST through scstadmin and reject nonempty state. +// Existing modules remain loaded on build failure. No forced unload is used. +func (m *Modules) Prepare(ctx context.Context, opts Options, run RunProgram, checkEmpty func(context.Context) error, progress io.Writer) error { + if len(m.loaded) != 0 { + return fmt.Errorf("checkout modules have not been unloaded") + } + if !filepath.IsAbs(opts.SourceDir) || opts.CommandTimeout <= 0 || + opts.Build && (opts.Jobs < 1 || opts.BuildTimeout <= 0) { + return fmt.Errorf("invalid module preparation options") + } + command := func(timeout time.Duration, args ...string) (string, error) { + return runCommand(ctx, timeout, run, args...) + } + before, err := loadedModules(ctx, opts.CommandTimeout, run) + if err != nil { + return err + } + if before["scst"] { + if err := checkEmpty(ctx); err != nil { + return fmt.Errorf("refusing to replace configured SCST: %w", err) + } + } + release, err := command(opts.CommandTimeout, "uname", "-r") + if err != nil { + return err + } + if release == "" || strings.ContainsAny(release, "/\n\r\t ") { + return fmt.Errorf("invalid running kernel release %q", release) + } + if opts.Build { + kdir := opts.KernelDir + if kdir == "" { + kdir = filepath.Join("/lib/modules", release, "build") + } + if !filepath.IsAbs(kdir) { + return fmt.Errorf("KDIR must be absolute") + } + // scst_local consumes the core's Module.symvers, so build in order. + for _, target := range []string{"scst", "scst_local", "scstadm"} { + fmt.Fprintf(progress, "Building %s for %s\n", target, release) + if _, err := command(opts.BuildTimeout, "make", "-C", opts.SourceDir, + fmt.Sprintf("-j%d", opts.Jobs), "KVER="+release, "KDIR="+kdir, target); err != nil { + return err + } + } + } + modules := []struct{ name, path string }{ + {"scst", "scst/src/scst.ko"}, + {"scst_vdisk", "scst/src/dev_handlers/scst_vdisk.ko"}, + {"scst_local", "scst_local/scst_local.ko"}, + } + dependencies := make([][]string, len(modules)) + for i, m := range modules { + path := filepath.Join(opts.SourceDir, m.path) + vermagic, err := command(opts.CommandTimeout, "modinfo", "-F", "vermagic", path) + if err != nil { + if !opts.Build { + return fmt.Errorf("cannot inspect %s; use ginkgo-test-build if modules have not been built: %w", path, err) + } + return err + } + fields := strings.Fields(vermagic) + if len(fields) == 0 || fields[0] != release { + return fmt.Errorf("%s was not built for running kernel %s: vermagic=%q", path, release, vermagic) + } + deps, err := command(opts.CommandTimeout, "modinfo", "-F", "depends", path) + if err != nil { + return err + } + if deps == "" { + continue + } + for _, dep := range strings.Split(deps, ",") { + if !moduleName.MatchString(dep) { + return fmt.Errorf("invalid dependency %q for %s", dep, path) + } + if dep == "scst" { + // Supplied by this checkout below. + continue + } + if strings.HasPrefix(dep, "scst_") { + return fmt.Errorf("unexpected SCST dependency %q", dep) + } + dependencies[i] = append(dependencies[i], dep) + } + } + // Recheck before unload: a different manager may have changed the host. + before, err = loadedModules(ctx, opts.CommandTimeout, run) + if err != nil { + return err + } + if before["scst"] { + if err := checkEmpty(ctx); err != nil { + return fmt.Errorf("refusing to replace configured SCST: %w", err) + } + } + // Direct insmod skips the runtime directories created by scst/src/Makefile + // during installation. The PR directory is needed even for dummy devices. + fmt.Fprintln(progress, "Preparing SCST runtime directories") + if _, err := command(opts.CommandTimeout, "mkdir", "-p", "--", + "/var/lib/scst/pr", "/var/lib/scst/dif_tags", "/var/lib/scst/vdev_mode_pages"); err != nil { + return err + } + for i := len(modules) - 1; i >= 0; i-- { + if before[modules[i].name] { + fmt.Fprintf(progress, "Unloading %s\n", modules[i].name) + if _, err := command(opts.CommandTimeout, "rmmod", modules[i].name); err != nil { + return err + } + } + } + for i, module := range modules { + for _, dep := range dependencies[i] { + if _, err := command(opts.CommandTimeout, "modprobe", "--", dep); err != nil { + return err + } + } + fmt.Fprintf(progress, "Loading %s from checkout\n", module.name) + args := []string{"insmod", filepath.Join(opts.SourceDir, module.path)} + if module.name == "scst_local" { + args = append(args, "add_default_tgt=0") + } + if _, err := command(opts.CommandTimeout, args...); err != nil { + return err + } + m.loaded = append(m.loaded, module.name) + } + return nil +} + +// Unload runs after spec cleanup, while the suite still owns its host lock. +// It also handles a partially completed Prepare, but leaves existing modules +// alone if Prepare failed before loading anything. Stop on the first failure. +func (m *Modules) Unload(ctx context.Context, timeout time.Duration, run RunProgram, checkEmpty func(context.Context) error, progress io.Writer) error { + if len(m.loaded) == 0 { + return nil + } + if err := checkEmpty(ctx); err != nil { + return fmt.Errorf("refusing to unload SCST: %w", err) + } + for len(m.loaded) != 0 { + name := m.loaded[len(m.loaded)-1] + fmt.Fprintf(progress, "Unloading %s\n", name) + if _, err := runCommand(ctx, timeout, run, "rmmod", name); err != nil { + return err + } + loaded, err := loadedModules(ctx, timeout, run) + if err != nil { + return err + } + if loaded[name] { + return fmt.Errorf("%s is still loaded after rmmod", name) + } + m.loaded = m.loaded[:len(m.loaded)-1] + } + return nil +} + +func runCommand(ctx context.Context, timeout time.Duration, run RunProgram, args ...string) (string, error) { + r, err := run(ctx, timeout, args...) + if err != nil { + return "", fmt.Errorf("host command: %w\n%s", err, r.String()) + } + if r.ExitCode != 0 { + return "", fmt.Errorf("host command: %s", r.String()) + } + return strings.TrimSpace(r.Stdout), nil +} + +func loadedModules(ctx context.Context, timeout time.Duration, run RunProgram) (map[string]bool, error) { + out, err := runCommand(ctx, timeout, run, "lsmod") + if err != nil { + return nil, err + } + lines := strings.Split(out, "\n") + if len(lines) == 0 || strings.Join(strings.Fields(lines[0]), " ") != "Module Size Used by" { + return nil, fmt.Errorf("unexpected lsmod header") + } + modules := make(map[string]bool) + for _, line := range lines[1:] { + fields := strings.Fields(line) + if len(fields) < 3 || !moduleName.MatchString(fields[0]) { + return nil, fmt.Errorf("invalid lsmod row: %q", line) + } + modules[fields[0]] = true + } + return modules, nil +} diff --git a/tests/ginkgo/internal/testhost/prepare_test.go b/tests/ginkgo/internal/testhost/prepare_test.go new file mode 100644 index 000000000..f0077d36f --- /dev/null +++ b/tests/ginkgo/internal/testhost/prepare_test.go @@ -0,0 +1,386 @@ +// SPDX-License-Identifier: GPL-2.0-only +package testhost + +import ( + "context" + "fmt" + "io" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + "time" + + "scst-component-tests/internal/admin" +) + +// The fake observes command ordering without building or accessing modules. +type host struct { + loaded bool + dirtyAt int + checks int + fail string + vermagic string + deps string + calls [][]string + limits []time.Duration + modules map[string]bool + keep string + execFail bool + listing string +} + +func (h *host) run(_ context.Context, limit time.Duration, args ...string) (admin.Result, error) { + h.calls = append(h.calls, append([]string{}, args...)) + h.limits = append(h.limits, limit) + r := admin.Result{Args: args} + if h.modules == nil { + h.modules = map[string]bool{"unrelated": true} + if h.loaded { + for _, name := range []string{"scst", "scst_vdisk", "scst_local"} { + h.modules[name] = true + } + } + } + command := strings.Join(args, " ") + if h.fail != "" && (command == h.fail || strings.HasPrefix(command, h.fail+" ")) { + r.ExitCode, r.Stderr = 1, "injected failure" + if h.execFail { + return r, context.DeadlineExceeded + } + return r, nil + } + switch args[0] { + case "lsmod": + r.Stdout = "Module Size Used by\n" + names := make([]string, 0, len(h.modules)) + for name := range h.modules { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + r.Stdout += name + " 123 0\n" + } + if h.listing != "" { + r.Stdout = h.listing + } + case "uname": + r.Stdout = "6.12-test\n" + case "modinfo": + switch args[2] { + case "vermagic": + r.Stdout = "6.12-test SMP mod_unload\n" + if h.vermagic != "" { + r.Stdout = h.vermagic + } + case "depends": + if strings.HasSuffix(args[3], "/scst.ko") { + r.Stdout = "crc32c_generic\n" + } else { + r.Stdout = "scst\n" + } + if h.deps != "" { + r.Stdout = h.deps + } + default: + return r, fmt.Errorf("unexpected modinfo arguments: %q", args) + } + case "insmod": + h.modules[strings.TrimSuffix(filepath.Base(args[1]), ".ko")] = true + case "rmmod": + if args[1] != h.keep { + delete(h.modules, args[1]) + } + case "modprobe": + h.modules[args[2]] = true + case "make", "mkdir": + default: + return r, fmt.Errorf("unexpected host program: %q", args) + } + return r, nil +} + +func (h *host) empty(context.Context) error { + h.checks++ + if h.checks == h.dirtyAt { + return fmt.Errorf("configured objects remain") + } + return nil +} + +func options() Options { + return Options{SourceDir: "/checkout with spaces", Build: true, Jobs: 2, + BuildTimeout: 10 * time.Minute, CommandTimeout: 30 * time.Second} +} + +func TestBuildAndLoadFromCheckout(t *testing.T) { + for _, build := range []bool{false, true} { + for _, loaded := range []bool{false, true} { + t.Run(fmt.Sprintf("build=%t/already-loaded=%t", build, loaded), func(t *testing.T) { + h := &host{loaded: loaded} + opts := options() + opts.Build = build + wantBuildCount := 0 + if build { + wantBuildCount = 3 + } else { + // Loading existing modules must not need any build settings. + opts.Jobs, opts.BuildTimeout = 0, 0 + } + if err := new(Modules).Prepare(context.Background(), opts, h.run, h.empty, io.Discard); err != nil { + t.Fatal(err) + } + wantChecks := 0 + if loaded { + wantChecks = 2 + } + if h.checks != wantChecks { + t.Fatalf("empty-host checks: got %d, want %d", h.checks, wantChecks) + } + var builds, changes [][]string + metadata := 0 + for i, args := range h.calls { + limit := opts.CommandTimeout + switch args[0] { + case "make": + builds = append(builds, args) + limit = opts.BuildTimeout + case "modinfo": + metadata++ + case "mkdir", "rmmod", "modprobe", "insmod": + if metadata != 6 || len(builds) != wantBuildCount { + t.Fatal("changed modules before all builds and metadata checks completed") + } + changes = append(changes, args) + } + if h.limits[i] != limit { + t.Fatalf("wrong timeout for %q", args) + } + } + var wantBuilds [][]string + if build { + for _, target := range []string{"scst", "scst_local", "scstadm"} { + wantBuilds = append(wantBuilds, []string{"make", "-C", opts.SourceDir, "-j2", "KVER=6.12-test", "KDIR=/lib/modules/6.12-test/build", target}) + } + } + if !reflect.DeepEqual(builds, wantBuilds) { + t.Fatalf("build order/arguments: %q", builds) + } + wantChanges := [][]string{{"mkdir", "-p", "--", "/var/lib/scst/pr", "/var/lib/scst/dif_tags", "/var/lib/scst/vdev_mode_pages"}} + if loaded { + wantChanges = append(wantChanges, []string{"rmmod", "scst_local"}, []string{"rmmod", "scst_vdisk"}, []string{"rmmod", "scst"}) + } + wantChanges = append(wantChanges, + []string{"modprobe", "--", "crc32c_generic"}, + []string{"insmod", opts.SourceDir + "/scst/src/scst.ko"}, + []string{"insmod", opts.SourceDir + "/scst/src/dev_handlers/scst_vdisk.ko"}, + []string{"insmod", opts.SourceDir + "/scst_local/scst_local.ko", "add_default_tgt=0"}) + if !reflect.DeepEqual(changes, wantChanges) { + t.Fatalf("module order/arguments: %q", changes) + } + }) + } + } +} + +func TestRefuseConfiguredHostBeforeUnload(t *testing.T) { + for _, build := range []bool{false, true} { + for _, dirtyAt := range []int{1, 2} { + t.Run(fmt.Sprintf("build=%t/check-%d", build, dirtyAt), func(t *testing.T) { + h := &host{loaded: true, dirtyAt: dirtyAt} + opts := options() + opts.Build = build + err := new(Modules).Prepare(context.Background(), opts, h.run, h.empty, io.Discard) + if err == nil || !strings.Contains(err.Error(), "configured objects") { + t.Fatalf("dirty host accepted: %v", err) + } + for _, args := range h.calls { + if args[0] == "mkdir" || args[0] == "rmmod" || args[0] == "insmod" || args[0] == "modprobe" || dirtyAt == 1 && args[0] == "make" { + t.Fatalf("unexpected operation on dirty host: %q", args) + } + } + }) + } + } +} + +func TestMissingModulesWithoutBuild(t *testing.T) { + h := &host{loaded: true, fail: "modinfo"} + opts := options() + opts.Build = false + err := new(Modules).Prepare(context.Background(), opts, h.run, h.empty, io.Discard) + if err == nil || !strings.Contains(err.Error(), "ginkgo-test-build") { + t.Fatalf("missing build instruction: %v", err) + } + for _, args := range h.calls { + if args[0] == "make" || args[0] == "mkdir" || args[0] == "rmmod" || args[0] == "insmod" || args[0] == "modprobe" { + t.Fatalf("changed host instead of reporting missing modules: %q", args) + } + } +} + +func TestBuildOrMetadataFailurePreservesLoadedModules(t *testing.T) { + for name, h := range map[string]*host{ + "build": {fail: "make"}, + "metadata": {fail: "modinfo"}, + "wrong kernel": {vermagic: "6.13-other SMP"}, + "unexpected dependency": {deps: "scst_other"}, + } { + t.Run(name, func(t *testing.T) { + h.loaded = true + if err := new(Modules).Prepare(context.Background(), options(), h.run, h.empty, io.Discard); err == nil { + t.Fatal("accepted failure") + } + for _, args := range h.calls { + if args[0] == "mkdir" || args[0] == "rmmod" || args[0] == "insmod" || args[0] == "modprobe" { + t.Fatalf("changed loaded modules: %q", args) + } + } + }) + } +} + +func TestStopAfterModuleCommandFailure(t *testing.T) { + for _, fail := range []string{"mkdir", "rmmod scst_local", "rmmod scst", "modprobe", "insmod"} { + t.Run(fail, func(t *testing.T) { + h := &host{loaded: true, fail: fail} + err := new(Modules).Prepare(context.Background(), options(), h.run, h.empty, io.Discard) + if err == nil || !strings.Contains(err.Error(), "injected failure") { + t.Fatalf("missing failure: %v", err) + } + last := strings.Join(h.calls[len(h.calls)-1], " ") + if last != fail && !strings.HasPrefix(last, fail+" ") { + t.Fatalf("continued after failure with %s", last) + } + }) + } +} + +func TestKernelDirectoryOverride(t *testing.T) { + h := &host{} + opts := options() + opts.KernelDir = "/custom kernel/build" + if err := new(Modules).Prepare(context.Background(), opts, h.run, h.empty, io.Discard); err != nil { + t.Fatal(err) + } + for _, args := range h.calls { + if args[0] == "make" && args[5] != "KDIR="+opts.KernelDir { + t.Fatalf("lost kernel directory override: %q", args) + } + } +} + +func TestUnloadOwnedModules(t *testing.T) { + for _, tc := range []struct { + name, fail string + loaded bool + unload []string + remaining []string + }{ + {name: "complete", unload: []string{"scst_local", "scst_vdisk", "scst"}}, + {name: "replaced", loaded: true, unload: []string{"scst_local", "scst_vdisk", "scst"}}, + {name: "core only", fail: "insmod /checkout with spaces/scst/src/dev_handlers/scst_vdisk.ko", unload: []string{"scst"}}, + {name: "no local driver", fail: "insmod /checkout with spaces/scst_local/scst_local.ko", unload: []string{"scst_vdisk", "scst"}}, + {name: "build failure", fail: "make", loaded: true, remaining: []string{"scst", "scst_vdisk", "scst_local"}}, + {name: "replacement failure", fail: "rmmod scst_vdisk", loaded: true, remaining: []string{"scst", "scst_vdisk"}}, + {name: "core load failure", fail: "insmod /checkout with spaces/scst/src/scst.ko"}, + } { + t.Run(tc.name, func(t *testing.T) { + h := &host{loaded: tc.loaded, fail: tc.fail} + m := new(Modules) + opts := options() + err := m.Prepare(context.Background(), opts, h.run, h.empty, io.Discard) + if (err != nil) != (tc.fail != "") { + t.Fatalf("preparation: %v", err) + } + h.fail, h.calls, h.limits = "", nil, nil + checked := false + checkEmpty := func(ctx context.Context) error { + checked = true + return h.empty(ctx) + } + run := func(ctx context.Context, timeout time.Duration, args ...string) (admin.Result, error) { + if !checked { + t.Fatal("module command before empty-state check") + } + return h.run(ctx, timeout, args...) + } + if err := m.Unload(context.Background(), opts.CommandTimeout, run, checkEmpty, io.Discard); err != nil { + t.Fatal(err) + } + if checked != (len(tc.unload) != 0) { + t.Fatalf("checked host without owned modules: %t", checked) + } + var want [][]string + for _, name := range tc.unload { + want = append(want, []string{"rmmod", name}, []string{"lsmod"}) + } + if !reflect.DeepEqual(h.calls, want) { + t.Fatalf("unload order/verification: got %q, want %q", h.calls, want) + } + for _, limit := range h.limits { + if limit != opts.CommandTimeout { + t.Fatalf("wrong unload command timeout: %s", limit) + } + } + for _, name := range []string{"scst", "scst_vdisk", "scst_local"} { + want := false + for _, remaining := range tc.remaining { + want = want || name == remaining + } + if h.modules[name] != want { + t.Fatalf("unexpected remaining module %s: %t", name, h.modules[name]) + } + } + if !h.modules["unrelated"] || len(tc.unload) != 0 && !h.modules["crc32c_generic"] { + t.Fatal("unloaded modules outside suite ownership") + } + calls, checks := len(h.calls), h.checks + if err := m.Unload(context.Background(), opts.CommandTimeout, h.run, h.empty, io.Discard); err != nil { + t.Fatal(err) + } + if len(h.calls) != calls || h.checks != checks { + t.Fatal("repeated cleanup accessed host") + } + }) + } +} + +func TestUnloadStopsOnFailure(t *testing.T) { + for _, tc := range []struct { + name, fail, keep, listing, last string + dirty, execFail bool + }{ + {name: "configured host", dirty: true}, + {name: "busy local driver", fail: "rmmod scst_local", last: "rmmod scst_local"}, + {name: "busy handler", fail: "rmmod scst_vdisk", last: "rmmod scst_vdisk"}, + {name: "busy core", fail: "rmmod scst", last: "rmmod scst"}, + {name: "interrupted command", fail: "rmmod scst_local", execFail: true, last: "rmmod scst_local"}, + {name: "still loaded", keep: "scst_local", last: "lsmod"}, + {name: "verification failed", fail: "lsmod", last: "lsmod"}, + {name: "invalid module list", listing: "invalid output", last: "lsmod"}, + } { + t.Run(tc.name, func(t *testing.T) { + h := &host{} + m := new(Modules) + if err := m.Prepare(context.Background(), options(), h.run, h.empty, io.Discard); err != nil { + t.Fatal(err) + } + h.calls, h.fail, h.keep, h.listing, h.execFail = nil, tc.fail, tc.keep, tc.listing, tc.execFail + if tc.dirty { + h.dirtyAt = h.checks + 1 + } + if err := m.Unload(context.Background(), options().CommandTimeout, h.run, h.empty, io.Discard); err == nil { + t.Fatal("unload failure accepted") + } + if tc.dirty { + if len(h.calls) != 0 { + t.Fatalf("accessed modules on configured host: %q", h.calls) + } + } else if last := strings.Join(h.calls[len(h.calls)-1], " "); last != tc.last { + t.Fatalf("continued after failure: %s", last) + } + }) + } +} diff --git a/tests/ginkgo/internal/testrun/run.go b/tests/ginkgo/internal/testrun/run.go new file mode 100644 index 000000000..3a5ecc221 --- /dev/null +++ b/tests/ginkgo/internal/testrun/run.go @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-2.0-only +package testrun + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "syscall" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "scst-component-tests/internal/admin" + "scst-component-tests/internal/testcfg" +) + +type Environment struct { + Config testcfg.Config + Client *admin.Client + ArtifactDir, RunID string +} + +func Run(t *testing.T, name string, assign func(Environment)) { + var cfg testcfg.Config + var artifactDir, runID string + var cli *admin.Client + RegisterFailHandler(Fail) + suiteConfig, reporterConfig := GinkgoConfiguration() + if suiteConfig.ParallelTotal != 1 { + t.Fatal("SCST component tests share host state; parallel execution is unsupported") + } + group := "local" + if directory := os.Getenv("SCST_TEST_PLAN_DIR"); directory != "" { + // Discover filters in a separate process, before config or host access. + // Do not reuse the previewed spec tree for the actual run. + preview := PreviewSpecs(name, suiteConfig, Label(group)) + data, err := json.Marshal(Selection{Group: group, Specs: preview.PreRunStats.SpecsThatWillRun}) + if err == nil { + err = os.WriteFile(filepath.Join(directory, group+".json"), data, 0600) + } + if err != nil { + t.Fatal(err) + } + return + } + if !suiteConfig.DryRun { + path := os.Getenv("SCST_TEST_CONFIG") + if path == "" { + t.Skip("set SCST_TEST_CONFIG to an explicit disposable-host configuration") + } + var err error + cfg, err = testcfg.Load(path) + if err != nil { + t.Fatal(err) + } + if err := ValidateSelection(cfg, []Selection{{Group: group, Specs: 1}}); err != nil { + t.Fatal(err) + } + if os.Geteuid() != 0 { + t.Fatal("SCST component tests load modules and must run as root") + } + // Keep the same lock inode across runs. Removing it would allow another + // process to lock a new inode while this process still owns the old one. + fd, err := syscall.Open(cfg.LockFile, syscall.O_CREAT|syscall.O_RDWR|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0600) + if err != nil { + t.Fatal(err) + } + lock := os.NewFile(uintptr(fd), cfg.LockFile) + defer lock.Close() + if err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + t.Fatalf("cannot acquire host lock %s: %v", cfg.LockFile, err) + } + if err := os.MkdirAll(cfg.ReportDir, 0700); err != nil { + t.Fatal(err) + } + artifactDir, err = os.MkdirTemp(cfg.ReportDir, "run-") + if err != nil { + t.Fatal(err) + } + runID = fmt.Sprintf("%x", time.Now().UnixNano()) + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(artifactDir, "config.json"), append(data, '\n'), 0600); err != nil { + t.Fatal(err) + } + log, err := os.OpenFile(filepath.Join(artifactDir, "commands.jsonl"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + defer log.Close() + cli = &admin.Client{Command: cfg.AdminCommand, Timeout: testcfg.Duration(cfg.CommandTimeout), Log: log} + reporterConfig.JSONReport = filepath.Join(artifactDir, "report.json") + reporterConfig.JUnitReport = filepath.Join(artifactDir, "junit.xml") + suiteConfig.Timeout = testcfg.Duration(cfg.SuiteTimeout) + if os.Getenv("SCST_TEST_BUILD") == "1" { + // Each of the three existing build targets has its own timeout. + suiteConfig.Timeout += 3 * testcfg.Duration(cfg.BuildTimeout) + } + fmt.Fprintf(GinkgoWriter, "Artifacts: %s\n", artifactDir) + t.Logf("Artifacts: %s", artifactDir) + } + assign(Environment{Config: cfg, Client: cli, ArtifactDir: artifactDir, RunID: runID}) + RunSpecs(t, name, suiteConfig, reporterConfig, Label(group)) +} diff --git a/tests/ginkgo/internal/testrun/selection.go b/tests/ginkgo/internal/testrun/selection.go new file mode 100644 index 000000000..f2abe68a7 --- /dev/null +++ b/tests/ginkgo/internal/testrun/selection.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-2.0-only +package testrun + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "scst-component-tests/internal/testcfg" +) + +// Groups run in order, with a complete teardown between transports. +var Groups = []struct{ Name, Package string }{ + {"local", "./suite"}, +} + +type Selection struct { + Group string + Specs int +} + +func ReadSelection(directory string) ([]Selection, error) { + var selected []Selection + for _, group := range Groups { + data, err := os.ReadFile(filepath.Join(directory, group.Name+".json")) + if err != nil { + return nil, err + } + var entry Selection + if err := json.Unmarshal(data, &entry); err != nil { + return nil, err + } + if entry.Group != group.Name || entry.Specs < 0 { + return nil, fmt.Errorf("invalid selection for %s", group.Name) + } + if entry.Specs > 0 { + selected = append(selected, entry) + } + } + if len(selected) == 0 { + return nil, fmt.Errorf("no specs match GINKGO_LABEL and GINKGO_FOCUS") + } + return selected, nil +} + +// Validate selected groups before starting the first live suite. +func ValidateSelection(cfg testcfg.Config, selected []Selection) error { + if !cfg.DisposableHost { + return fmt.Errorf("disposable_host must be true: tests create exports and change SCST configuration") + } + return nil +} diff --git a/tests/ginkgo/internal/testrun/selection_test.go b/tests/ginkgo/internal/testrun/selection_test.go new file mode 100644 index 000000000..733f64988 --- /dev/null +++ b/tests/ginkgo/internal/testrun/selection_test.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-2.0-only +package testrun + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "scst-component-tests/internal/testcfg" +) + +func TestReadSelection(t *testing.T) { + for _, tc := range []struct { + name string + local, want int + }{ + {"local", 31, 1}, {"empty", 0, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + for _, s := range []Selection{{"local", tc.local}} { + data, _ := json.Marshal(s) + if err := os.WriteFile(filepath.Join(dir, s.Group+".json"), data, 0600); err != nil { + t.Fatal(err) + } + } + got, err := ReadSelection(dir) + if tc.want == 0 { + if err == nil { + t.Fatal("empty selection succeeded") + } + return + } + if err != nil || len(got) != tc.want { + t.Fatalf("selection=%v err=%v", got, err) + } + }) + } + if _, err := ReadSelection(t.TempDir()); err == nil { + t.Fatal("missing discovery reports succeeded") + } +} + +func TestValidateSelectedGroups(t *testing.T) { + local := []Selection{{"local", 31}} + if ValidateSelection(testcfg.Config{}, local) == nil { + t.Fatal("selected host lacks disposable_host") + } + if err := ValidateSelection(testcfg.Config{DisposableHost: true}, local); err != nil { + t.Fatal(err) + } +} diff --git a/tests/ginkgo/suite/alua_test.go b/tests/ginkgo/suite/alua_test.go new file mode 100644 index 000000000..c0bb218a9 --- /dev/null +++ b/tests/ginkgo/suite/alua_test.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-2.0-only +package suite + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ALUA configuration", Label("basic", "alua"), func() { + var f *fixture + BeforeEach(func() { f = newFixture() }) + + It("manages device groups, target groups and remote target entries", func(ctx SpecContext) { + d, dg := f.device(ctx, "disk"), f.deviceGroup(ctx, "devices") + tg, entry := f.name("ports"), f.name("remote") + f.do(ctx, "-add_dgrp_dev", d, "-dev_group", dg) + f.do(ctx, "-add_tgrp", tg, "-dev_group", dg) + f.do(ctx, "-set_tgrp_attr", tg, "-dev_group", dg, "-attributes", "group_id=7") + f.do(ctx, "-add_tgrp_tgt", entry, "-dev_group", dg, "-tgt_group", tg) + f.do(ctx, "-set_ttgt_attr", entry, "-dev_group", dg, "-tgt_group", tg, "-attributes", "rel_tgt_id=8") + members := must(cli.DeviceGroup(ctx, dg)) + Expect(members["Devices"]).To(ConsistOf(d)) + Expect(members["Target Groups"]).To(ConsistOf(tg)) + Expect(must(cli.Names(ctx, "Targets", "-list_tgrp", tg, "-dev_group", dg))).To(ConsistOf(entry)) + Expect(must(cli.Scalar(ctx, "group_id", "-list_tgrp_attr", tg, "-dev_group", dg))).To(Equal("7")) + Expect(must(cli.Scalar(ctx, "rel_tgt_id", "-list_ttgt_attr", entry, "-dev_group", dg, "-tgt_group", tg))).To(Equal("8")) + f.do(ctx, "-rem_tgrp_tgt", entry, "-dev_group", dg, "-tgt_group", tg) + Expect(must(cli.Names(ctx, "Targets", "-list_tgrp", tg, "-dev_group", dg))).To(BeEmpty()) + f.do(ctx, "-rem_tgrp", tg, "-dev_group", dg) + Expect(must(cli.DeviceGroup(ctx, dg))["Target Groups"]).To(BeEmpty()) + f.do(ctx, "-rem_dgrp_dev", d, "-dev_group", dg) + Expect(must(cli.DeviceGroup(ctx, dg))["Devices"]).To(BeEmpty()) + f.do(ctx, "-rem_dgrp", dg) + Expect(must(cli.Names(ctx, "Device Group", "-list_dgrp"))).NotTo(ContainElement(dg)) + Expect(must(cli.Devices(ctx))).To(HaveKey(d)) + }) + + It("rejects duplicate and conflicting device membership", Label("negative"), func(ctx SpecContext) { + d, first, second := f.device(ctx, "disk"), f.deviceGroup(ctx, "first"), f.deviceGroup(ctx, "second") + f.do(ctx, "-add_dgrp_dev", d, "-dev_group", first) + f.reject(ctx, "-add_dgrp_dev", d, "-dev_group", first) + Expect(must(cli.DeviceGroup(ctx, first))["Devices"]).To(ConsistOf(d)) + f.reject(ctx, "-add_dgrp_dev", d, "-dev_group", second) + Expect(must(cli.DeviceGroup(ctx, first))["Devices"]).To(ConsistOf(d)) + Expect(must(cli.DeviceGroup(ctx, second))["Devices"]).To(BeEmpty()) + }) +}) diff --git a/tests/ginkgo/suite/config_test.go b/tests/ginkgo/suite/config_test.go new file mode 100644 index 000000000..47d586168 --- /dev/null +++ b/tests/ginkgo/suite/config_test.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-2.0-only +package suite + +import ( + "context" + "fmt" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// A small input configuration, written independently for this suite. Output +// configurations are consumed only by scstadmin, never by a test-side parser. +func (f *fixture) configuration() string { + GinkgoHelper() + d, t, g, init := f.name("disk"), f.name("target"), f.name("group"), f.name("initiator") + f.devices = append(f.devices, d) + f.targets = append(f.targets, t) + path := f.file("input.conf") + text := fmt.Sprintf(`HANDLER vdisk_nullio { + DEVICE %s { + dummy 1 + size_mb 8 + } +} +TARGET_DRIVER scst_local { + TARGET %s { + LUN 0 %s + GROUP %s { + LUN 1 %s { + read_only 1 + } + INITIATOR %s + } + } +} +`, d, t, d, g, d, init) + Expect(os.WriteFile(path, []byte(text), 0600)).To(Succeed()) + return path +} + +func (f *fixture) expectConfiguration(ctx context.Context) { + GinkgoHelper() + d, t, g := f.name("disk"), f.name("target"), f.name("group") + Expect(must(cli.Devices(ctx))).To(Equal(map[string]string{d: "vdisk_nullio"})) + Expect(must(cli.Targets(ctx))).To(Equal(map[string]string{t: "scst_local"})) + Expect(must(cli.Scalar(ctx, "dummy", "-list_device", d))).To(Equal("1")) + Expect(must(cli.Scalar(ctx, "size_mb", "-list_device", d))).To(Equal("8")) + state := f.state(ctx, t) + Expect(state.LUNs).To(Equal(map[int]string{0: d})) + Expect(state.Groups).To(HaveLen(1)) + Expect(state.Groups).To(HaveKey(g)) + Expect(state.Groups[g].LUNs).To(Equal(map[int]string{1: d})) + Expect(state.Groups[g].Initiators).To(ConsistOf(f.name("initiator"))) + Expect(f.lunReadOnly(ctx, t, "", "0")).To(Equal("0")) + Expect(f.lunReadOnly(ctx, t, g, "1")).To(Equal("1")) +} + +var _ = Describe("Configuration files", Label("basic", "config"), func() { + var f *fixture + BeforeEach(func() { f = newFixture() }) + + It("applies the same configuration twice without changing its graph", func(ctx SpecContext) { + path := f.configuration() + f.do(ctx, "-config", path) + f.expectConfiguration(ctx) + f.do(ctx, "-config", path) + f.expectConfiguration(ctx) + }) + + It("saves, clears and restores the configuration through scstadmin", Label("clear-config"), func(ctx SpecContext) { + f.do(ctx, "-config", f.configuration()) + f.expectConfiguration(ctx) + saved := f.file("saved.conf") + f.do(ctx, "-write_config", saved) + f.do(ctx, "-clear_config", "-force") + Expect(must(cli.Devices(ctx))).To(BeEmpty()) + Expect(must(cli.Targets(ctx))).To(BeEmpty()) + Expect(must(cli.Names(ctx, "Device Group", "-list_dgrp"))).To(BeEmpty()) + f.do(ctx, "-config", saved) + f.expectConfiguration(ctx) + }) +}) diff --git a/tests/ginkgo/suite/fixture_test.go b/tests/ginkgo/suite/fixture_test.go new file mode 100644 index 000000000..100570f35 --- /dev/null +++ b/tests/ginkgo/suite/fixture_test.go @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: GPL-2.0-only +package suite + +import ( + "context" + "errors" + "fmt" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "scst-component-tests/internal/admin" +) + +type fixture struct { + prefix string + devices, targets, deviceGroups []string +} + +var fixtureNumber int + +func newFixture() *fixture { + GinkgoHelper() + if err := cli.Blocked(); err != nil { + AbortSuite(err.Error()) + } + fixtureNumber++ + f := &fixture{prefix: fmt.Sprintf("%s_%s_%d", cfg.NamePrefix, runID, fixtureNumber)} + deferSpecCleanup(func(ctx SpecContext) { + if err := f.cleanup(ctx); err != nil { + AbortSuite("cleanup failed; inspect this disposable host before another run: " + err.Error()) + } + }) + return f +} + +func (f *fixture) name(suffix string) string { return f.prefix + "_" + suffix } +func (f *fixture) file(suffix string) string { return filepath.Join(artifactDir, f.name(suffix)) } + +func (f *fixture) do(ctx context.Context, args ...string) { + GinkgoHelper() + r, err := cli.Execute(ctx, args...) + Expect(err).NotTo(HaveOccurred(), r.String()) + Expect(r.ExitCode).To(BeZero(), r.String()) + Expect(r.ReportedError()).To(BeFalse(), r.String()) +} + +func (f *fixture) reject(ctx context.Context, args ...string) { + GinkgoHelper() + r, err := cli.Execute(ctx, args...) + Expect(err).NotTo(HaveOccurred(), "execution failure is not an expected rejection: %s", r.String()) + Expect(r.ExitCode != 0 || r.ReportedError()).To(BeTrue(), r.String()) +} + +func (f *fixture) device(ctx context.Context, suffix string) string { + GinkgoHelper() + d := f.name(suffix) + // Record ownership before invoking a mutation, including partial failures. + f.devices = append(f.devices, d) + f.do(ctx, "-open_dev", d, "-handler", "vdisk_nullio", "-attributes", "dummy=1,size_mb=8") + return d +} + +func (f *fixture) target(ctx context.Context, suffix string, attributes ...string) string { + GinkgoHelper() + t := f.name(suffix) + f.targets = append(f.targets, t) + args := []string{"-add_target", t, "-driver", "scst_local"} + if len(attributes) != 0 { + args = append(args, "-attributes", attributes[0]) + } + f.do(ctx, args...) + return t +} + +func (f *fixture) deviceGroup(ctx context.Context, suffix string) string { + GinkgoHelper() + dg := f.name(suffix) + f.deviceGroups = append(f.deviceGroups, dg) + f.do(ctx, "-add_dgrp", dg) + return dg +} + +func scope(target, group string) []string { + args := []string{"-driver", "scst_local", "-target", target} + if group != "" { + args = append(args, "-group", group) + } + return args +} + +func (f *fixture) at(ctx context.Context, target, group string, args ...string) { + GinkgoHelper() + f.do(ctx, append(args, scope(target, group)...)...) +} + +func (f *fixture) state(ctx context.Context, target string) admin.Assignments { + GinkgoHelper() + return must(cli.Assignments(ctx, target)) +} + +func (f *fixture) luns(ctx context.Context, target, group string) map[int]string { + GinkgoHelper() + a := f.state(ctx, target) + if group == "" { + return a.LUNs + } + Expect(a.Groups).To(HaveKey(group)) + return a.Groups[group].LUNs +} + +func (f *fixture) lunReadOnly(ctx context.Context, target, group string, lun string) string { + GinkgoHelper() + return must(cli.Scalar(ctx, "read_only", append([]string{"-list_lun_attr", lun}, scope(target, group)...)...)) +} + +func (f *fixture) cleanup(ctx context.Context) error { + if err := cli.Blocked(); err != nil { + return err + } + var problems []error + remove := func(args ...string) { + r, err := cli.Execute(ctx, args...) + if err != nil || r.ExitCode != 0 || r.ReportedError() { + problems = append(problems, fmt.Errorf("%s", r.String())) + } + } + // Remove dependencies before devices; deleting a target removes its groups + // and LUN assignments. Never clear unrelated configuration for cleanup. + dgs, err := cli.Names(ctx, "Device Group", "-list_dgrp") + if err != nil { + return err + } + for _, owned := range f.deviceGroups { + for _, present := range dgs { + if owned == present { + remove("-rem_dgrp", owned, "-force") + } + } + } + targets, err := cli.Targets(ctx) + if err != nil { + return errors.Join(append(problems, err)...) + } + for _, t := range f.targets { + if _, ok := targets[t]; ok { + remove("-rem_target", t, "-driver", "scst_local") + } + } + devices, err := cli.Devices(ctx) + if err != nil { + return errors.Join(append(problems, err)...) + } + for _, d := range f.devices { + if _, ok := devices[d]; ok { + remove("-close_dev", d, "-handler", "vdisk_nullio") + } + } + if len(problems) != 0 { + return errors.Join(problems...) + } + // A zero exit alone is not evidence that teardown completed. + devices, err = cli.Devices(ctx) + if err != nil { + return err + } + targets, err = cli.Targets(ctx) + if err != nil { + return err + } + dgs, err = cli.Names(ctx, "Device Group", "-list_dgrp") + if err != nil { + return err + } + if len(devices) != 0 || len(targets) != 0 || len(dgs) != 0 { + return fmt.Errorf("host is no longer empty: devices=%v targets=%v device_groups=%v", devices, targets, dgs) + } + return nil +} diff --git a/tests/ginkgo/suite/management_test.go b/tests/ginkgo/suite/management_test.go new file mode 100644 index 000000000..bc185deb3 --- /dev/null +++ b/tests/ginkgo/suite/management_test.go @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: GPL-2.0-only +package suite + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "scst-component-tests/internal/testcfg" +) + +var _ = Describe("Device management", Label("basic", "devices"), func() { + var f *fixture + BeforeEach(func() { f = newFixture() }) + + It("opens and closes a device without removing its neighbor", func(ctx SpecContext) { + d, neighbor := f.device(ctx, "disk"), f.device(ctx, "neighbor") + Expect(must(cli.Devices(ctx))).To(Equal(map[string]string{d: "vdisk_nullio", neighbor: "vdisk_nullio"})) + Expect(must(cli.Scalar(ctx, "dummy", "-list_device", d))).To(Equal("1")) + f.do(ctx, "-close_dev", d, "-handler", "vdisk_nullio") + Expect(must(cli.Devices(ctx))).To(Equal(map[string]string{neighbor: "vdisk_nullio"})) + }) + + It("rejects a duplicate name and preserves the existing device", Label("negative"), func(ctx SpecContext) { + d := f.device(ctx, "disk") + before := must(cli.Scalar(ctx, "size_mb", "-list_device", d)) + f.reject(ctx, "-open_dev", d, "-handler", "vdisk_nullio", "-attributes", "dummy=1,size_mb=16") + Expect(must(cli.Devices(ctx))).To(Equal(map[string]string{d: "vdisk_nullio"})) + Expect(must(cli.Scalar(ctx, "size_mb", "-list_device", d))).To(Equal(before)) + }) + + DescribeTable("refuses to close an assigned device until it is unmapped", Label("negative"), + func(ctx SpecContext, inGroup bool) { + d, t := f.device(ctx, "disk"), f.target(ctx, "target") + g := "" + if inGroup { + g = f.name("group") + f.at(ctx, t, "", "-add_group", g) + } + f.at(ctx, t, g, "-add_lun", "0", "-device", d) + f.reject(ctx, "-close_dev", d, "-handler", "vdisk_nullio") + Expect(must(cli.Devices(ctx))).To(HaveKey(d)) + Expect(f.luns(ctx, t, g)).To(Equal(map[int]string{0: d})) + f.at(ctx, t, g, "-rem_lun", "0") + Expect(f.luns(ctx, t, g)).To(BeEmpty()) + f.do(ctx, "-close_dev", d, "-handler", "vdisk_nullio") + Expect(must(cli.Devices(ctx))).NotTo(HaveKey(d)) + }, Entry("in the default LUN map", false), Entry("in an initiator group", true)) +}) + +var _ = Describe("Local targets", Label("basic", "targets"), func() { + var f *fixture + BeforeEach(func() { f = newFixture() }) + + It("creates a target with a session and removes only that target", func(ctx SpecContext) { + session := f.name("session") + t, neighbor := f.target(ctx, "target", "session_name="+session), f.target(ctx, "neighbor") + Expect(must(cli.Targets(ctx))).To(Equal(map[string]string{t: "scst_local", neighbor: "scst_local"})) + Eventually(func(g Gomega) { + sessions, err := cli.Sessions(ctx) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(sessions["scst_local/"+t]).To(ConsistOf(session)) + }, testcfg.Duration(cfg.SettleTimeout), testcfg.Duration(cfg.PollInterval)).WithContext(ctx).Should(Succeed()) + f.do(ctx, "-rem_target", t, "-driver", "scst_local") + Expect(must(cli.Targets(ctx))).To(Equal(map[string]string{neighbor: "scst_local"})) + Expect(must(cli.Sessions(ctx))).NotTo(HaveKey("scst_local/" + t)) + }) + + It("rejects a duplicate target and preserves its mappings", Label("negative"), func(ctx SpecContext) { + t, d := f.target(ctx, "target"), f.device(ctx, "disk") + f.at(ctx, t, "", "-add_lun", "0", "-device", d) + f.reject(ctx, "-add_target", t, "-driver", "scst_local") + Expect(must(cli.Targets(ctx))).To(Equal(map[string]string{t: "scst_local"})) + Expect(f.state(ctx, t).LUNs).To(Equal(map[int]string{0: d})) + }) +}) + +var _ = Describe("LUN mappings", Label("basic", "luns"), func() { + var f *fixture + BeforeEach(func() { f = newFixture() }) + + It("adds and removes a read-only LUN without changing its neighbor", func(ctx SpecContext) { + t, d, neighbor := f.target(ctx, "target"), f.device(ctx, "disk"), f.device(ctx, "neighbor") + f.at(ctx, t, "", "-add_lun", "0", "-device", d, "-attributes", "read_only=1") + f.at(ctx, t, "", "-add_lun", "1", "-device", neighbor) + Expect(f.state(ctx, t).LUNs).To(Equal(map[int]string{0: d, 1: neighbor})) + Expect(f.lunReadOnly(ctx, t, "", "0")).To(Equal("1")) + Expect(f.lunReadOnly(ctx, t, "", "1")).To(Equal("0")) + f.at(ctx, t, "", "-rem_lun", "0") + Expect(f.state(ctx, t).LUNs).To(Equal(map[int]string{1: neighbor})) + }) + + It("replaces a LUN's device without changing another LUN", func(ctx SpecContext) { + t, a, b := f.target(ctx, "target"), f.device(ctx, "first"), f.device(ctx, "second") + f.at(ctx, t, "", "-add_lun", "0", "-device", a) + f.at(ctx, t, "", "-add_lun", "1", "-device", a) + f.at(ctx, t, "", "-replace_lun", "0", "-device", b) + Expect(f.state(ctx, t).LUNs).To(Equal(map[int]string{0: b, 1: a})) + }) + + It("rejects a duplicate LUN and preserves its original device", Label("negative"), func(ctx SpecContext) { + t, a, b := f.target(ctx, "target"), f.device(ctx, "first"), f.device(ctx, "second") + f.at(ctx, t, "", "-add_lun", "0", "-device", a) + f.reject(ctx, append([]string{"-add_lun", "0", "-device", b}, scope(t, "")...)...) + Expect(f.state(ctx, t).LUNs).To(Equal(map[int]string{0: a})) + }) + + It("rejects a missing device without adding a LUN", Label("negative"), func(ctx SpecContext) { + t, d := f.target(ctx, "target"), f.device(ctx, "disk") + f.at(ctx, t, "", "-add_lun", "0", "-device", d) + f.reject(ctx, append([]string{"-add_lun", "1", "-device", f.name("missing")}, scope(t, "")...)...) + Expect(f.state(ctx, t).LUNs).To(Equal(map[int]string{0: d})) + }) +}) + +var _ = Describe("Initiator groups", Label("basic", "groups"), func() { + var f *fixture + BeforeEach(func() { f = newFixture() }) + + It("creates groups, moves an initiator and removes empty groups", func(ctx SpecContext) { + t, a, b, init := f.target(ctx, "target"), f.name("group_a"), f.name("group_b"), f.name("initiator") + f.at(ctx, t, "", "-add_group", a) + f.at(ctx, t, "", "-add_group", b) + f.at(ctx, t, a, "-add_init", init) + Expect(f.state(ctx, t).Groups[a].Initiators).To(ConsistOf(init)) + f.at(ctx, t, a, "-move_init", init, "-to", b) + state := f.state(ctx, t) + Expect(state.Groups[a].Initiators).To(BeEmpty()) + Expect(state.Groups[b].Initiators).To(ConsistOf(init)) + f.at(ctx, t, b, "-rem_init", init) + Expect(f.state(ctx, t).Groups[b].Initiators).To(BeEmpty()) + f.at(ctx, t, "", "-rem_group", a) + Expect(f.state(ctx, t).Groups).To(HaveLen(1)) + Expect(f.state(ctx, t).Groups).To(HaveKey(b)) + f.at(ctx, t, "", "-rem_group", b) + Expect(f.state(ctx, t).Groups).To(BeEmpty()) + }) + + It("clears one group's LUNs while preserving default and sibling mappings", func(ctx SpecContext) { + t, a, b := f.target(ctx, "target"), f.name("group_a"), f.name("group_b") + d, da, db := f.device(ctx, "default"), f.device(ctx, "first"), f.device(ctx, "second") + f.at(ctx, t, "", "-add_group", a) + f.at(ctx, t, "", "-add_group", b) + f.at(ctx, t, "", "-add_lun", "0", "-device", d) + f.at(ctx, t, a, "-add_lun", "0", "-device", da) + f.at(ctx, t, b, "-add_lun", "0", "-device", db) + state := f.state(ctx, t) + Expect(state.LUNs).To(Equal(map[int]string{0: d})) + Expect(state.Groups[a].LUNs).To(Equal(map[int]string{0: da})) + Expect(state.Groups[b].LUNs).To(Equal(map[int]string{0: db})) + f.at(ctx, t, a, "-clear_luns") + state = f.state(ctx, t) + Expect(state.LUNs).To(Equal(map[int]string{0: d})) + Expect(state.Groups[a].LUNs).To(BeEmpty()) + Expect(state.Groups[b].LUNs).To(Equal(map[int]string{0: db})) + }) +}) + +var _ = Describe("Global attributes", Label("basic", "attributes"), func() { + It("sets and reads measure_latency, rejecting an invalid value without changing it", Label("negative"), func(ctx SpecContext) { + f := newFixture() + original := must(cli.Scalar(ctx, "measure_latency", "-list_scst_attr")) + Expect(original).To(BeElementOf("0", "1")) + deferSpecCleanup(func(ctx SpecContext) { + if err := cli.Blocked(); err != nil { + AbortSuite("cannot restore measure_latency: " + err.Error()) + } + r, err := cli.Execute(ctx, "-set_scst_attr", "-attributes", "measure_latency="+original) + if err != nil || r.ExitCode != 0 || r.ReportedError() { + AbortSuite("cannot restore measure_latency: " + r.String()) + } + actual, err := cli.Scalar(ctx, "measure_latency", "-list_scst_attr") + if err != nil || actual != original { + AbortSuite("measure_latency restoration could not be verified") + } + }) + value := "1" + if original == value { + value = "0" + } + f.do(ctx, "-set_scst_attr", "-attributes", "measure_latency="+value) + Expect(must(cli.Scalar(ctx, "measure_latency", "-list_scst_attr"))).To(Equal(value)) + f.reject(ctx, "-set_scst_attr", "-attributes", "measure_latency=.") + Expect(must(cli.Scalar(ctx, "measure_latency", "-list_scst_attr"))).To(Equal(value)) + }) +}) diff --git a/tests/ginkgo/suite/suite_test.go b/tests/ginkgo/suite/suite_test.go new file mode 100644 index 000000000..c01e22ffd --- /dev/null +++ b/tests/ginkgo/suite/suite_test.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-2.0-only +package suite + +import ( + "os" + "path/filepath" + "sync/atomic" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "scst-component-tests/internal/admin" + "scst-component-tests/internal/testcfg" + "scst-component-tests/internal/testhost" + "scst-component-tests/internal/testrun" +) + +var cfg testcfg.Config +var cli *admin.Client +var artifactDir, runID string +var pendingCleanups atomic.Int64 + +func TestSCST(t *testing.T) { + testrun.Run(t, "SCST through scstadmin", func(env testrun.Environment) { + cfg, cli, artifactDir, runID = env.Config, env.Client, env.ArtifactDir, env.RunID + }) +} + +var _ = BeforeSuite(func(ctx SpecContext) { + modules := new(testhost.Modules) + sourceDir := os.Getenv("SCST_TEST_SOURCE_DIR") + if sourceDir == "" { + // Direct go test invocation runs in tests/ginkgo/suite. + sourceDir = must(filepath.Abs("../../..")) + } + err := modules.Prepare(ctx, testhost.Options{ + SourceDir: sourceDir, KernelDir: os.Getenv("SCST_TEST_KDIR"), + Build: os.Getenv("SCST_TEST_BUILD") == "1", + Jobs: cfg.BuildJobs, BuildTimeout: testcfg.Duration(cfg.BuildTimeout), + CommandTimeout: testcfg.Duration(cfg.CommandTimeout), + }, cli.RunProgram, cli.RequireEmpty, GinkgoWriter) + // Register after Prepare returns, including partial failure, so unloading + // cannot race with module loading if Ginkgo interrupts suite setup. + DeferCleanup(func(ctx SpecContext) { + Expect(pendingCleanups.Load()).To(BeZero(), "refusing to unload modules: spec cleanup did not complete") + Expect(cli.Blocked()).NotTo(HaveOccurred(), "refusing to unload modules after an execution failure") + Expect(modules.Unload(ctx, testcfg.Duration(cfg.CommandTimeout), + cli.RunProgram, cli.RequireEmpty, GinkgoWriter)).To(Succeed()) + }, NodeTimeout(testcfg.Duration(cfg.CleanupTimeout))) + Expect(err).To(Succeed()) + drivers, err := cli.Names(ctx, "Driver", "-list_driver") + Expect(err).NotTo(HaveOccurred(), "scstadmin must be able to query the modules loaded by the suite") + Expect(drivers).To(ContainElement("scst_local"), "prepare scst_local with add_default_tgt=0") + Expect(must(cli.Names(ctx, "Handler", "-list_handler"))).To(ContainElement("vdisk_nullio")) + Expect(cli.RequireEmpty(ctx)).To(Succeed()) +}) + +// Keep failed, interrupted, or unfinished spec cleanup visible to suite cleanup. +func deferSpecCleanup(body func(SpecContext)) { + GinkgoHelper() + pendingCleanups.Add(1) + DeferCleanup(func(ctx SpecContext) { + body(ctx) + if ctx.Err() == nil { + pendingCleanups.Add(-1) + } + }, NodeTimeout(testcfg.Duration(cfg.CleanupTimeout))) +} + +func must[T any](value T, err error) T { + GinkgoHelper() + Expect(err).NotTo(HaveOccurred()) + return value +}