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 +}