tests: Add SCST component tests with Ginkgo

Add component tests for the scstadmin management interface, including
state preservation after rejected commands. Use scst_local and
vdisk_nullio to cover devices, targets, LUN mappings, initiator groups,
ALUA and configuration save/restore without physical storage.

Provide a shared runner with explicit configuration, host locking,
bounded command logs and JSON/JUnit reports. Select specs through labels
and focus before preparing the host, and reject empty selections. Parse
scstadmin output, including session attributes and LUN mappings, through
common helpers covered by framework tests.

Load checkout modules in suite setup, optionally build them first, and
unload them after successful cleanup. Add common Make targets for
framework checks, test selection and live runs, with setup documentation.
This commit is contained in:
Gleb Chesnokov
2026-10-13 12:42:36 +03:00
parent 9041f8518d
commit feab01b85b
30 changed files with 3193 additions and 1 deletions
+3 -1
View File
@@ -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.
+17
View File
@@ -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 \
+3
View File
@@ -0,0 +1,3 @@
!go.mod
/config.local.json
/results/
+28
View File
@@ -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.
+36
View File
@@ -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
+85
View File
@@ -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.
+28
View File
@@ -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)
}
}
+84
View File
@@ -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
}
+114
View File
@@ -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
}
+14
View File
@@ -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
}
+23
View File
@@ -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
)
+69
View File
@@ -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=
+154
View File
@@ -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
}
+194
View File
@@ -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")
}
}
+372
View File
@@ -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] == "<not set>" {
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
}
+211
View File
@@ -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 <not set> Yes No
trace_level mgmt Yes No
pid Yes No
Possible trace levels:
(use trace_level="add <level>", 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] != "<not set>" {
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 <not set> 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)
}
}
}
+138
View File
@@ -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")
}
+52
View File
@@ -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)
}
})
}
}
+114
View File
@@ -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
}
@@ -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")
}
})
}
}
+223
View File
@@ -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
}
@@ -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)
}
})
}
}
+109
View File
@@ -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))
}
@@ -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
}
@@ -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)
}
}
+47
View File
@@ -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())
})
})
+84
View File
@@ -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)
})
})
+180
View File
@@ -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
}
+185
View File
@@ -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))
})
})
+75
View File
@@ -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
}