tests: Add two-host iSCSI component tests

Run Ginkgo on the target and drive a separate Open-iSCSI initiator over
SSH. Keep host locks, bounded command logs and failure latches on both
sides. Load checkout modules, own the foreground target daemon, and
remove sessions and node records before tearing down target resources.

Add discovery, login/logout, repeated login, portal filtering and CHAP
scenarios. Offer each CHAP algorithm separately and verify recovery after
an authentication rejection. Use dummy LUNs and per-run initiator IQNs
without changing the initiator's global configuration.

Register the iSCSI group with the shared runner and validate every
selected host before starting the first suite. Finish local teardown
before starting iSCSI tests and stop after any suite failure. Extend the
configuration examples and guide with initiator setup and cleanup limits.
This commit is contained in:
Gleb Chesnokov
2026-10-13 12:42:36 +03:00
parent 40c3ead2db
commit 41e6caeebc
25 changed files with 1378 additions and 48 deletions
+1
View File
@@ -142,6 +142,7 @@ help:
@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 " GINKGO_LABEL selects test groups: local, iscsi, basic, stress"
@echo ""
@echo "Build variables:"
@echo " WERROR=y|n : treat warnings as errors (default: n)"
+1
View File
@@ -1,3 +1,4 @@
!go.mod
/config.local.json
/config.*.local.json
/results/
+12 -9
View File
@@ -1,28 +1,31 @@
# 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.
`suite/` owns local tests, `iscsi/` owns target/SSH-initiator tests, and
`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.
- Initiator management uses `iscsiadm` over SSH.
- 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, backing files,
sessions and modules. Restore changed global settings; global SCST clearing
belongs only to explicit clear-config tests.
sessions, node/iface records, the daemon and modules. Restore changed global
settings; never use global initiator logout, deletion or service restarts
as cleanup. 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.
Uncertain initiator state must prevent target teardown. Preserve artifacts
and residual resources for inspection instead of forcing cleanup.
- Prepare only selected transport groups; complete local teardown before
iSCSI setup. 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;
check selection and configuration without SCST or remote-host access.
Live targets load or replace modules and mutate storage on selected hosts;
the root authorization rules apply. Dry runs do not validate SCST behavior.
Keep generated `results/` and local profiles out of commits.
+2 -2
View File
@@ -14,12 +14,12 @@ help:
@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"
@echo "GINKGO_LABEL selects groups, e.g. local, iscsi, or basic"
check:
$(GO) test -count=1 ./internal/... ./cmd/...
$(GO) vet ./...
$(GO) test -run='^$$' ./suite
$(GO) test -run='^$$' ./suite ./iscsi
list:
$(GO) run ./cmd/run -list
+35 -13
View File
@@ -1,7 +1,7 @@
# SCST component tests
Ginkgo/Gomega tests exercise SCST management through `scstadmin` on
`scst_local`.
`scst_local` and TCP iSCSI with a separate initiator controlled over SSH.
## Setup and run
@@ -43,22 +43,23 @@ runtime directories under `/var/lib/scst` remain.
## Select tests
The default is `GINKGO_LABEL='local && basic'`. Use the listing below to
select other labels.
The default is `GINKGO_LABEL='local && basic'`. Combine `local` or `iscsi`
with `basic` (management) or other labels from the listing below.
`GINKGO_FOCUS` additionally filters spec names by regexp.
```sh
# List every spec and its labels without accessing SCST:
# List every spec and its labels without accessing either host:
make ginkgo-list GINKGO_LABEL=''
# All except the known-crash case:
# All except the known-crash case; prepare both hosts first:
make ginkgo-test-build GINKGO_CONFIG=tests/ginkgo/config.local.json GINKGO_LABEL='!known-crash'
```
An empty label filter selects all specs. The `known-crash` case (sync on
inactive fileio) also requires `allow_known_crash=true` and can crash affected
kernels. `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.
Only selected transport groups access hosts. Local teardown finishes before
iSCSI setup; an empty selection or suite failure stops the run. Parallel runs
are unsupported: use the same `lock_file` and runner user on each host.
## Configuration
@@ -67,7 +68,7 @@ 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.
are ignored by Git; the initiator lock path must be absolute.
`backing_dir` selects an existing directory for temporary fileio images;
empty uses the artifact directory. Tests never adopt existing backing files.
@@ -76,18 +77,39 @@ paths. Set `fileio_thin_provisioning=false` if the filesystem cannot support
the TP-enabled case. `stress_iterations` and `scale_luns` control lifecycle
repetition and many-LUN coverage.
## Two-host iSCSI
Run Ginkgo on the target. Add `iscsi` from
[config.iscsi.example.json](config.iscsi.example.json), replace its addresses
and set both `disposable_host` flags to `true`.
- Target: OpenSSH client, `pgrep`, no running `iscsi-scstd`, and a local IPv4
address with a free TCP port reachable from the initiator. SHA256/SHA3-256
CHAP requires kernel AF_ALG hash support.
- Initiator: OpenSSH server, Python 3, `pgrep`, Open-iSCSI with SHA3-256
`node.session.auth.chap_algs` support, running `iscsid`, loaded `iscsi_tcp`
and no existing sessions. SCST is not needed here.
- Set up noninteractive root SSH from target to initiator with a verified host
key. `identity_file` is on the target; empty uses normal SSH key selection.
`iscsi.login_timeout` must be whole seconds below `command_timeout`.
The suite owns its target daemon and initiator sessions/node/iface records.
It preserves existing records and leaves initiator services and modules loaded.
## 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
it does not access SCST or remote hosts. 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, deleting
backing files and unloading modules. A failure or interrupted command stops
the run and may retain modules and files. Inspect and recover the host before
retrying; never remove a lock file to bypass a held lock.
the run and may retain modules, files and the daemon. Inspect and recover the
affected hosts 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.
command logs, JSON/JUnit reports and scenario inputs. iSCSI adds SSH, initiator
and daemon logs, which may contain
test CHAP credentials. Kernel warnings are not collected: retain kernel logs
when investigating failures.
+12 -10
View File
@@ -32,7 +32,7 @@ func TestMain(m *testing.M) {
os.Exit(2)
}
for _, arg := range os.Args[1:] {
if arg == "./suite" {
if arg == "./suite" || arg == "./iscsi" {
if _, err := file.WriteString(arg + "\n"); err != nil {
os.Exit(2)
}
@@ -51,16 +51,18 @@ func TestMain(m *testing.M) {
func TestSelectedPackagesRunInOrder(t *testing.T) {
for _, tc := range []struct {
name string
local int
local, iscsi 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: "iscsi", iscsi: 8, want: "./iscsi\n"},
{name: "both", local: 31, iscsi: 7, want: "./suite\n./iscsi\n"},
{name: "failed cleanup stops next group", local: 31, iscsi: 7, 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)
calls := stub(t, tc.local, tc.iscsi)
if tc.failLocal {
t.Setenv("SCST_STUB_FAIL_LOCAL", "1")
}
@@ -81,22 +83,22 @@ func TestSelectedPackagesRunInOrder(t *testing.T) {
}
func TestConfigurationRejectedBeforeAnySuiteRuns(t *testing.T) {
calls := stub(t, 31)
calls := stub(t, 31, 7)
config := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(config, []byte(`{"disposable_host":false}`), 0600); err != nil {
if err := os.WriteFile(config, []byte(`{"disposable_host":true}`), 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 == nil || !strings.Contains(err.Error(), "iscsi section") {
t.Fatalf("missing iSCSI 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 {
func stub(t *testing.T, local, iscsi int) string {
t.Helper()
binary, err := os.Executable()
if err != nil {
@@ -106,7 +108,7 @@ func stub(t *testing.T, local int) string {
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}})
data, _ := json.Marshal([]testrun.Selection{{Group: "local", Specs: local}, {Group: "iscsi", Specs: iscsi}})
t.Setenv("SCST_STUB_SELECTION", string(data))
calls := filepath.Join(t.TempDir(), "calls")
t.Setenv("SCST_STUB_CALLS", calls)
+20
View File
@@ -0,0 +1,20 @@
{
"admin_command": ["scstadmin"],
"disposable_host": false,
"report_dir": "results",
"command_timeout": "45s",
"cleanup_timeout": "3m",
"iscsi": {
"target_address": "192.0.2.10",
"port": 3260,
"login_timeout": "15s",
"initiator": {
"host": "192.0.2.20",
"user": "root",
"port": 22,
"identity_file": "/root/.ssh/id_ed25519",
"lock_file": "/tmp/scst-ginkgo.lock",
"disposable_host": false
}
}
}
+93
View File
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: GPL-2.0-only
package initiator
import (
"fmt"
"net"
"strconv"
"strings"
)
// Portal is the address plus port, excluding the target portal group tag.
type Session struct{ ID, Portal, Target string }
func splitPortal(value string) (string, error) {
portal, tag, ok := strings.Cut(value, ",")
if !ok {
return "", fmt.Errorf("missing portal group tag: %q", value)
}
if _, err := strconv.Atoi(tag); err != nil {
return "", err
}
_, port, err := net.SplitHostPort(portal)
if err != nil {
return "", err
}
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
return "", fmt.Errorf("invalid portal port: %q", value)
}
return portal, nil
}
// Sessions parses iscsiadm -m session's default output. Unexpected rows fail
// closed; they must never be interpreted as evidence that cleanup completed.
func Sessions(output string) ([]Session, error) {
var result []Session
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) != 5 || fields[0] != "tcp:" || !strings.HasPrefix(fields[1], "[") || !strings.HasSuffix(fields[1], "]") || fields[4] != "(non-flash)" {
return nil, fmt.Errorf("unexpected initiator session row: %q", line)
}
id := strings.TrimSuffix(strings.TrimPrefix(fields[1], "["), "]")
if n, err := strconv.Atoi(id); err != nil || n < 1 {
return nil, fmt.Errorf("invalid session ID: %q", id)
}
portal, err := splitPortal(fields[2])
if err != nil {
return nil, err
}
result = append(result, Session{id, portal, fields[3]})
}
return result, nil
}
func Discovery(output string) (map[string]string, error) {
result := map[string]string{}
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) != 2 {
return nil, fmt.Errorf("unexpected discovery row: %q", line)
}
portal, err := splitPortal(fields[0])
if err != nil {
return nil, err
}
if _, exists := result[fields[1]]; exists {
return nil, fmt.Errorf("duplicate discovered target %q", fields[1])
}
result[fields[1]] = portal
}
return result, nil
}
func Ifaces(output string) ([]string, error) {
var names []string
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) != 2 || !strings.Contains(fields[1], ",") {
return nil, fmt.Errorf("unexpected iface row: %q", line)
}
names = append(names, fields[0])
}
return names, nil
}
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: GPL-2.0-only
package initiator
import "testing"
func TestParsers(t *testing.T) {
sessions, err := Sessions("tcp: [12] 192.0.2.10:3260,1 iqn.2026-10.test.scst:one (non-flash)\n")
if err != nil || len(sessions) != 1 || sessions[0].ID != "12" || sessions[0].Portal != "192.0.2.10:3260" || sessions[0].Target != "iqn.2026-10.test.scst:one" {
t.Fatalf("sessions: %+v %v", sessions, err)
}
for _, bad := range []string{"No matching sessions", "tcp: [x] 192.0.2.10:3260,1 iqn.test (non-flash)", "tcp: [1] 192.0.2.10,1 iqn.test (non-flash)"} {
if _, err := Sessions(bad); err == nil {
t.Fatalf("accepted %q", bad)
}
}
discovered, err := Discovery("192.0.2.10:3260,1 iqn.test\n")
if err != nil || discovered["iqn.test"] != "192.0.2.10:3260" {
t.Fatalf("discovery: %+v %v", discovered, err)
}
if _, err := Discovery("iscsiadm: discovery failed"); err == nil {
t.Fatal("accepted diagnostics as discovery")
}
if _, err := Discovery("192.0.2.10:3260,1 iqn.test\n192.0.2.10:3261,2 iqn.test"); err == nil {
t.Fatal("accepted duplicate target")
}
ifaces, err := Ifaces("default tcp,<empty>,<empty>,<empty>,<empty>\nscst_run tcp,<empty>,<empty>,<empty>,iqn.test\n")
if err != nil || len(ifaces) != 2 || ifaces[1] != "scst_run" {
t.Fatalf("ifaces: %v %v", ifaces, err)
}
if _, err := Ifaces("some error"); err == nil {
t.Fatal("accepted invalid iface listing")
}
}
+202
View File
@@ -0,0 +1,202 @@
// SPDX-License-Identifier: GPL-2.0-only
package remote
import (
"bufio"
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
"scst-component-tests/internal/admin"
"scst-component-tests/internal/testcfg"
)
//go:embed worker.py
var worker string
type response struct {
ExitCode *int `json:"exit_code"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Failure string `json:"execution_error"`
UID int `json:"uid"`
BootID string `json:"boot_id"`
}
type message struct {
payload response
err error
}
// Session retains one SSH connection and one remote lock for the entire suite.
// Commands travel as JSON argv arrays, not interpolated remote shell commands.
// A lost response is never retried: its remote effects may already have occurred.
type Session struct {
mu sync.Mutex
cmd *exec.Cmd
input io.WriteCloser
messages chan message
done chan struct{}
waitErr error
log io.Writer
blocked error
}
func quote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" }
func Connect(ctx context.Context, cfg testcfg.Initiator, log, stderr io.Writer) (*Session, error) {
args := []string{"-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes",
"-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2",
"-p", strconv.Itoa(cfg.Port), "-l", cfg.User}
if cfg.IdentityFile != "" {
args = append(args, "-o", "IdentitiesOnly=yes", "-i", cfg.IdentityFile)
}
args = append(args, "--", cfg.Host, "exec python3 -u -c "+quote(worker)+" "+quote(cfg.LockFile))
s, hello, err := start(ctx, exec.Command("ssh", args...), log, stderr)
if err != nil {
return nil, err
}
localID, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
if err == nil && (hello.UID != 0 || hello.BootID == "" || hello.BootID == strings.TrimSpace(string(localID))) {
err = fmt.Errorf("initiator must be root on a different Linux host")
}
if err != nil {
s.Close()
return nil, err
}
return s, nil
}
func start(ctx context.Context, cmd *exec.Cmd, log, stderr io.Writer) (*Session, response, error) {
s := &Session{cmd: cmd, log: log, messages: make(chan message, 1), done: make(chan struct{})}
var err error
s.input, err = cmd.StdinPipe()
if err != nil {
return nil, response{}, err
}
out, err := cmd.StdoutPipe()
if err != nil {
s.input.Close()
return nil, response{}, err
}
cmd.Stderr = stderr
if err := cmd.Start(); err != nil {
s.input.Close()
out.Close()
return nil, response{}, err
}
go func() {
defer close(s.messages)
scanner := bufio.NewScanner(out)
scanner.Buffer(make([]byte, 4096), 16*1024*1024)
for scanner.Scan() {
var r response
err := json.Unmarshal(scanner.Bytes(), &r)
s.messages <- message{r, err}
if err != nil {
break
}
}
if err := scanner.Err(); err != nil {
s.messages <- message{err: err}
}
s.waitErr = cmd.Wait()
close(s.done)
}()
r, err := s.receive(ctx)
if err == nil && r.Failure != "" {
err = errors.New(r.Failure)
}
if err != nil {
s.Close()
return nil, r, fmt.Errorf("SSH initiator setup: %w", err)
}
return s, r, nil
}
func (s *Session) receive(ctx context.Context) (response, error) {
select {
case m, ok := <-s.messages:
if !ok {
return response{}, fmt.Errorf("SSH connection ended; inspect ssh.log and remote state")
}
return m.payload, m.err
case <-ctx.Done():
return response{}, ctx.Err()
}
}
func (s *Session) Blocked() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.blocked
}
func (s *Session) Run(ctx context.Context, timeout time.Duration, argv ...string) (admin.Result, error) {
s.mu.Lock()
defer s.mu.Unlock()
r := admin.Result{Args: argv, Started: time.Now().UTC(), ExitCode: -1}
if s.blocked != nil {
return r, fmt.Errorf("remote commands disabled: %w", s.blocked)
}
if deadline, ok := ctx.Deadline(); ok && time.Until(deadline) < timeout {
timeout = time.Until(deadline)
}
request := struct {
Args []string `json:"argv"`
Timeout float64 `json:"timeout"`
}{argv, timeout.Seconds()}
err := ctx.Err()
if err == nil {
err = json.NewEncoder(s.input).Encode(request)
}
if err == nil {
// The worker gets the first opportunity to kill and reap a timed-out command.
waitCtx, cancel := context.WithTimeout(ctx, timeout+3*time.Second)
var reply response
reply, err = s.receive(waitCtx)
cancel()
r.Stdout, r.Stderr = reply.Stdout, reply.Stderr
if reply.ExitCode != nil {
r.ExitCode = *reply.ExitCode
} else if err == nil {
err = fmt.Errorf("remote response lacks exit_code")
}
if err == nil && reply.Failure != "" {
err = errors.New(reply.Failure)
}
}
r.Duration = time.Since(r.Started)
if err != nil {
r.Failure = err.Error()
s.blocked = err
}
if s.log != nil {
if logErr := json.NewEncoder(s.log).Encode(r); logErr != nil {
s.blocked = errors.Join(err, logErr)
err = s.blocked
}
}
return r, err
}
// Close releases the connection, never performs implicit iSCSI cleanup.
func (s *Session) Close() error {
s.input.Close()
select {
case <-s.done:
return s.waitErr
case <-time.After(2 * time.Second):
s.cmd.Process.Kill()
return fmt.Errorf("SSH worker did not exit; remote state may require inspection")
}
}
+138
View File
@@ -0,0 +1,138 @@
// SPDX-License-Identifier: GPL-2.0-only
package remote
import (
"bytes"
"context"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func localWorker(t *testing.T, lock string) *Session {
t.Helper()
python, err := exec.LookPath("python3")
if err != nil {
t.Skip("python3 is needed to exercise the SSH worker protocol locally")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
s, _, err := start(ctx, exec.Command(python, "-u", "-c", worker, lock), io.Discard, io.Discard)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
return s
}
func TestWorkerArgvAndOrdinaryFailure(t *testing.T) {
s := localWorker(t, filepath.Join(t.TempDir(), "host.lock"))
var log bytes.Buffer
s.log = &log
input := "a 'quote'\n$(do-not-execute); `literal`"
r, err := s.Run(context.Background(), time.Second, "python3", "-c", "import sys; print(sys.argv[1]); sys.exit(24)", input)
if err != nil || r.ExitCode != 24 || r.Stdout != input+"\n" || s.Blocked() != nil {
t.Fatalf("ordinary result lost: %+v %v", r, err)
}
r, err = s.Run(context.Background(), time.Second, "true")
if err != nil || r.ExitCode != 0 || strings.Count(log.String(), "\n") != 2 {
t.Fatalf("follow-up/log: %+v %v", r, err)
}
}
func TestWorkerLockAndSymlink(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "host.lock")
s := localWorker(t, path)
for _, lock := range []string{path, filepath.Join(dir, "link")} {
if lock != path {
if err := os.Symlink(path, lock); err != nil {
t.Fatal(err)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
other, _, err := start(ctx, exec.Command("python3", "-u", "-c", worker, lock), io.Discard, io.Discard)
cancel()
if err == nil {
other.Close()
t.Fatal("accepted locked/symlink lock file")
}
}
if err := s.Close(); err != nil {
t.Fatal(err)
}
// The lock inode remains, but the lock itself is released on connection close.
localWorker(t, path)
}
func TestWorkerUncertainResultsBlockFurtherCommands(t *testing.T) {
for name, argv := range map[string][]string{
"timeout": {"python3", "-c", "import time; time.sleep(5)"},
"signal": {"python3", "-c", "import os,signal; os.kill(os.getpid(),signal.SIGKILL)"},
"output limit": {"python3", "-c", "print('x' * (2 * 1024 * 1024))"},
"missing program": {"/no/such/scst-test-program"},
} {
t.Run(name, func(t *testing.T) {
s := localWorker(t, filepath.Join(t.TempDir(), "host.lock"))
_, err := s.Run(context.Background(), 100*time.Millisecond, argv...)
if err == nil || s.Blocked() == nil {
t.Fatal("uncertain result accepted")
}
marker := filepath.Join(t.TempDir(), "must-not-exist")
if _, err := s.Run(context.Background(), time.Second, "touch", marker); err == nil {
t.Fatal("follow-up allowed")
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatal("follow-up executed")
}
})
}
}
func TestLostConnectionAndBadReply(t *testing.T) {
for name, code := range map[string]string{
"EOF": "import json; print(json.dumps({'uid':0,'boot_id':'test'}),flush=True)",
"bad reply": "import json,sys; print(json.dumps({'uid':0,'boot_id':'test'}),flush=True); sys.stdin.readline(); print('{}',flush=True)",
} {
t.Run(name, func(t *testing.T) {
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 unavailable")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
s, _, err := start(ctx, exec.Command("python3", "-u", "-c", code), io.Discard, io.Discard)
if err != nil {
t.Fatal(err)
}
defer s.Close()
if _, err := s.Run(ctx, time.Second, "true"); err == nil || s.Blocked() == nil {
t.Fatal("lost/malformed response accepted")
}
})
}
}
func TestSSHCommandQuoting(t *testing.T) {
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 unavailable")
}
lock := filepath.Join(t.TempDir(), "lock ' with $ and ` and\nnewline")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
s, _, err := start(ctx, exec.Command("sh", "-c", "exec python3 -u -c "+quote(worker)+" "+quote(lock)), io.Discard, io.Discard)
if err != nil {
t.Fatal(err)
}
defer s.Close()
if _, err := os.Stat(lock); err != nil {
t.Fatal("remote shell quoting changed the lock path:", err)
}
r, err := s.Run(ctx, time.Second, "true")
if err != nil || r.ExitCode != 0 {
t.Fatalf("worker command: %+v %v", r, err)
}
}
+98
View File
@@ -0,0 +1,98 @@
# SPDX-License-Identifier: GPL-2.0-only
"""One SSH connection owns a host lock and executes bounded argv requests."""
import fcntl
import json
import os
import selectors
import signal
import subprocess
import sys
import time
LIMIT = 1024 * 1024
def reply(value):
print(json.dumps(value), flush=True)
def execute(request):
result = {"exit_code": -1, "stdout": "", "stderr": ""}
process = None
try:
argv = request["argv"]
timeout = request["timeout"]
if not argv or not all(isinstance(arg, str) for arg in argv):
raise ValueError("invalid argv")
if not isinstance(timeout, (int, float)) or not 0 < timeout <= 3600:
raise ValueError("invalid timeout")
process = subprocess.Popen(
argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, start_new_session=True,
env=dict(os.environ, LC_ALL="C"))
streams = {"stdout": bytearray(), "stderr": bytearray()}
deadline = time.monotonic() + timeout
with selectors.DefaultSelector() as selector:
selector.register(process.stdout, selectors.EVENT_READ, "stdout")
selector.register(process.stderr, selectors.EVENT_READ, "stderr")
while selector.get_map() or process.poll() is None:
if time.monotonic() >= deadline:
raise TimeoutError("remote command timed out; state is uncertain")
for key, _ in selector.select(min(0.1, deadline - time.monotonic())):
data = os.read(key.fileobj.fileno(), 65536)
if not data:
selector.unregister(key.fileobj)
continue
stream = streams[key.data]
room = LIMIT - len(stream)
stream.extend(data[:room])
if len(data) > room:
raise ValueError("remote output limit exceeded")
result["exit_code"] = process.wait(timeout=1)
if result["exit_code"] < 0:
raise RuntimeError("remote command terminated by signal")
except Exception as error:
result["execution_error"] = str(error)
if process is not None:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
try:
process.wait(timeout=1)
except subprocess.TimeoutExpired:
pass
finally:
if process is not None:
for name, data in streams.items():
result[name] = data.decode("utf-8", errors="replace")
process.stdout.close()
process.stderr.close()
return result
def main():
try:
# Keep the inode: removing a lock file would let another run bypass it.
lock = os.open(sys.argv[1], os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
with open("/proc/sys/kernel/random/boot_id", encoding="ascii") as source:
boot_id = source.read().strip()
reply({"uid": os.geteuid(), "boot_id": boot_id})
except Exception as error:
reply({"execution_error": str(error)})
return
blocked = None
for line in sys.stdin:
try:
result = ({"execution_error": blocked} if blocked else
execute(json.loads(line)))
except Exception as error:
result = {"execution_error": str(error)}
blocked = result.get("execution_error")
reply(result)
os.close(lock)
if __name__ == "__main__":
main()
+46
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"os"
"path/filepath"
"regexp"
@@ -32,6 +33,25 @@ type Config struct {
StressIterations int `json:"stress_iterations"`
ScaleLUNs int `json:"scale_luns"`
AllowKnownCrash bool `json:"allow_known_crash"`
ISCSI *ISCSI `json:"iscsi,omitempty"`
}
// ISCSI selects a local TCP target and a separate SSH initiator. Credentials
// for test CHAP accounts are generated by the suite, never supplied here.
type ISCSI struct {
TargetAddress string `json:"target_address"`
Port int `json:"port"`
LoginTimeout string `json:"login_timeout"`
Initiator Initiator `json:"initiator"`
}
type Initiator struct {
Host string `json:"host"`
User string `json:"user"`
Port int `json:"port"`
IdentityFile string `json:"identity_file"`
LockFile string `json:"lock_file"`
DisposableHost bool `json:"disposable_host"`
}
func Load(path string) (Config, error) {
@@ -78,6 +98,9 @@ func Load(path string) (Config, error) {
if strings.ContainsRune(c.AdminCommand[0], '/') && !filepath.IsAbs(c.AdminCommand[0]) {
c.AdminCommand[0] = filepath.Join(base, c.AdminCommand[0])
}
if c.ISCSI != nil && c.ISCSI.Initiator.IdentityFile != "" && !filepath.IsAbs(c.ISCSI.Initiator.IdentityFile) {
c.ISCSI.Initiator.IdentityFile = filepath.Join(base, c.ISCSI.Initiator.IdentityFile)
}
return c, nil
}
@@ -119,6 +142,29 @@ func (c Config) Validate() error {
if c.ScaleLUNs < 2 || c.ScaleLUNs > 2048 {
return fmt.Errorf("scale_luns must be between 2 and 2048")
}
if c.ISCSI != nil {
i := c.ISCSI
if Duration(c.CommandTimeout) > time.Hour {
return fmt.Errorf("iSCSI command_timeout must not exceed 1h")
}
ip := net.ParseIP(i.TargetAddress)
if ip == nil || ip.To4() == nil || strings.Contains(i.TargetAddress, ":") || ip.IsUnspecified() || ip.IsLoopback() || ip.IsMulticast() {
return fmt.Errorf("iscsi.target_address must be a unicast, non-loopback IPv4 address")
}
if i.Port < 1 || i.Port > 65535 || i.Initiator.Port < 1 || i.Initiator.Port > 65535 {
return fmt.Errorf("iSCSI and SSH ports must be between 1 and 65535")
}
if !regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`).MatchString(i.Initiator.Host) || i.Initiator.User != "root" {
return fmt.Errorf("iscsi.initiator requires a host name or IPv4 address and user root")
}
if !filepath.IsAbs(i.Initiator.LockFile) || strings.ContainsRune(i.Initiator.LockFile+i.Initiator.IdentityFile, '\x00') {
return fmt.Errorf("initiator lock_file must be absolute and SSH paths must not contain NUL")
}
d, err := time.ParseDuration(i.LoginTimeout)
if err != nil || d < time.Second || d%time.Second != 0 || d >= Duration(c.CommandTimeout) {
return fmt.Errorf("iscsi.login_timeout must be whole seconds, positive and below command_timeout")
}
}
return nil
}
@@ -74,3 +74,42 @@ func TestScenarioOverrides(t *testing.T) {
t.Fatalf("scenario overrides lost: %+v", c)
}
}
func TestISCSIConfiguration(t *testing.T) {
input := `{"iscsi":{"target_address":"192.0.2.10","port":3260,"login_timeout":"15s","initiator":{"host":"192.0.2.20","user":"root","port":22,"identity_file":"id_test","lock_file":"/tmp/scst-test.lock","disposable_host":false}}}`
path := configFile(t, input)
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.ISCSI.Initiator.DisposableHost || cfg.ISCSI.Initiator.IdentityFile != filepath.Join(filepath.Dir(path), "id_test") {
t.Fatalf("unsafe defaults or bad path: %+v", cfg.ISCSI)
}
for name, change := range map[string]func(*ISCSI){
"wildcard target": func(i *ISCSI) { i.TargetAddress = "0.0.0.0" },
"localhost": func(i *ISCSI) { i.TargetAddress = "127.0.0.1" },
"IPv6": func(i *ISCSI) { i.TargetAddress = "2001:db8::1" },
"target DNS": func(i *ISCSI) { i.TargetAddress = "target" },
"bad port": func(i *ISCSI) { i.Port = 65536 },
"bad SSH port": func(i *ISCSI) { i.Initiator.Port = 0 },
"SSH option": func(i *ISCSI) { i.Initiator.Host = "-oProxyCommand=bad" },
"SSH injection": func(i *ISCSI) { i.Initiator.Host = "host; command" },
"nonroot": func(i *ISCSI) { i.Initiator.User = "tester" },
"relative remote lock": func(i *ISCSI) { i.Initiator.LockFile = "host.lock" },
"long login": func(i *ISCSI) { i.LoginTimeout = "31s" },
"fractional login": func(i *ISCSI) { i.LoginTimeout = "1.5s" },
} {
t.Run(name, func(t *testing.T) {
invalid := cfg
value := *cfg.ISCSI
invalid.ISCSI = &value
change(invalid.ISCSI)
if invalid.Validate() == nil {
t.Fatal("accepted invalid iSCSI configuration")
}
})
}
if _, err := Load(configFile(t, `{"iscsi":{"unknown":true}}`)); err == nil {
t.Fatal("accepted unknown nested field")
}
}
+10 -2
View File
@@ -19,6 +19,7 @@ type Options struct {
SourceDir string
KernelDir string
Build bool
ISCSI bool
Jobs int
BuildTimeout time.Duration
CommandTimeout time.Duration
@@ -70,8 +71,12 @@ func (m *Modules) Prepare(ctx context.Context, opts Options, run RunProgram, che
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"} {
// Target drivers consume the core's Module.symvers, so build in order.
driver := "scst_local"
if opts.ISCSI {
driver = "iscsi"
}
for _, target := range []string{"scst", driver, "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 {
@@ -84,6 +89,9 @@ func (m *Modules) Prepare(ctx context.Context, opts Options, run RunProgram, che
{"scst_vdisk", "scst/src/dev_handlers/scst_vdisk.ko"},
{"scst_local", "scst_local/scst_local.ko"},
}
if opts.ISCSI {
modules[2].name, modules[2].path = "iscsi_scst", "iscsi-scst/kernel/iscsi-scst.ko"
}
dependencies := make([][]string, len(modules))
for i, m := range modules {
path := filepath.Join(opts.SourceDir, m.path)
+32 -1
View File
@@ -87,7 +87,7 @@ func (h *host) run(_ context.Context, limit time.Duration, args ...string) (admi
return r, fmt.Errorf("unexpected modinfo arguments: %q", args)
}
case "insmod":
h.modules[strings.TrimSuffix(filepath.Base(args[1]), ".ko")] = true
h.modules[strings.ReplaceAll(strings.TrimSuffix(filepath.Base(args[1]), ".ko"), "-", "_")] = true
case "rmmod":
if args[1] != h.keep {
delete(h.modules, args[1])
@@ -384,3 +384,34 @@ func TestUnloadStopsOnFailure(t *testing.T) {
})
}
}
func TestISCSIProfile(t *testing.T) {
h := new(host)
opts := options()
opts.ISCSI = true
m := new(Modules)
if err := m.Prepare(context.Background(), opts, h.run, h.empty, io.Discard); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(m.loaded, []string{"scst", "scst_vdisk", "iscsi_scst"}) {
t.Fatalf("wrong modules: %v", m.loaded)
}
var builds []string
for _, args := range h.calls {
if strings.Contains(strings.Join(args, " "), "scst_local") {
t.Fatalf("local driver in iSCSI profile: %v", args)
}
if args[0] == "make" {
builds = append(builds, args[len(args)-1])
}
}
if !reflect.DeepEqual(builds, []string{"scst", "iscsi", "scstadm"}) {
t.Fatalf("wrong build order: %v", builds)
}
if err := m.Unload(context.Background(), opts.CommandTimeout, h.run, h.empty, io.Discard); err != nil {
t.Fatal(err)
}
if h.modules["iscsi_scst"] || h.modules["scst"] {
t.Fatal("iSCSI modules remain loaded")
}
}
+64
View File
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: GPL-2.0-only
package testhost
import (
"context"
"errors"
"fmt"
"io"
"os/exec"
"syscall"
)
// Process owns a foreground daemon. A failed spec deliberately leaves it alive
// if session cleanup cannot establish a safe teardown order.
type Process struct {
cmd *exec.Cmd
done chan struct{}
err error
}
func StartProcess(log io.Writer, argv ...string) (*Process, error) {
if len(argv) == 0 {
return nil, fmt.Errorf("missing daemon command")
}
p := &Process{cmd: exec.Command(argv[0], argv[1:]...), done: make(chan struct{})}
p.cmd.Stdout, p.cmd.Stderr = log, log
p.cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := p.cmd.Start(); err != nil {
return nil, err
}
go func() { p.err = p.cmd.Wait(); close(p.done) }()
return p, nil
}
func (p *Process) Running() error {
select {
case <-p.done:
return fmt.Errorf("target daemon exited unexpectedly: %v", p.err)
default:
return nil
}
}
func (p *Process) PID() int { return p.cmd.Process.Pid }
func (p *Process) Stop(ctx context.Context) error {
if err := p.Running(); err != nil {
return err
}
if err := syscall.Kill(-p.PID(), syscall.SIGTERM); err != nil {
return err
}
select {
case <-p.done:
var exit *exec.ExitError
if p.err == nil || errors.As(p.err, &exit) && exit.Sys().(syscall.WaitStatus).Signal() == syscall.SIGTERM {
return nil
}
return fmt.Errorf("target daemon stop: %w", p.err)
case <-ctx.Done():
// Do not escalate to SIGKILL or unload modules beneath a stuck daemon.
return fmt.Errorf("target daemon did not stop: %w", ctx.Err())
}
}
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-2.0-only
package testhost
import (
"context"
"io"
"testing"
"time"
)
func TestForegroundProcess(t *testing.T) {
p, err := StartProcess(io.Discard, "sleep", "30")
if err != nil {
t.Fatal(err)
}
defer p.cmd.Process.Kill()
if err := p.Running(); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := p.Stop(ctx); err != nil {
t.Fatal(err)
}
if p.Running() == nil {
t.Fatal("stopped daemon appears running")
}
}
func TestEarlyDaemonExit(t *testing.T) {
p, err := StartProcess(io.Discard, "false")
if err != nil {
t.Fatal(err)
}
select {
case <-p.done:
case <-time.After(time.Second):
t.Fatal("child did not exit")
}
if p.Running() == nil || p.Stop(context.Background()) == nil {
t.Fatal("early exit accepted as normal teardown")
}
}
+4 -1
View File
@@ -23,7 +23,7 @@ type Environment struct {
ArtifactDir, RunID string
}
func Run(t *testing.T, name string, assign func(Environment)) {
func Run(t *testing.T, name string, iscsi bool, assign func(Environment)) {
var cfg testcfg.Config
var artifactDir, runID string
var cli *admin.Client
@@ -33,6 +33,9 @@ func Run(t *testing.T, name string, assign func(Environment)) {
t.Fatal("SCST component tests share host state; parallel execution is unsupported")
}
group := "local"
if iscsi {
group = "iscsi"
}
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.
+13 -1
View File
@@ -13,6 +13,7 @@ import (
// Groups run in order, with a complete teardown between transports.
var Groups = []struct{ Name, Package string }{
{"local", "./suite"},
{"iscsi", "./iscsi"},
}
type Selection struct {
@@ -44,10 +45,21 @@ func ReadSelection(directory string) ([]Selection, error) {
return selected, nil
}
// Validate selected groups before starting the first live suite.
// Validate every selected group before starting the first live suite. An
// optional iSCSI section is harmless when only local specs are selected.
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")
}
for _, group := range selected {
if group.Group == "iscsi" {
if cfg.ISCSI == nil {
return fmt.Errorf("selected iSCSI specs require an iscsi section in GINKGO_CONFIG")
}
if !cfg.ISCSI.Initiator.DisposableHost {
return fmt.Errorf("iscsi.initiator.disposable_host must be true")
}
}
}
return nil
}
@@ -12,14 +12,14 @@ import (
func TestReadSelection(t *testing.T) {
for _, tc := range []struct {
name string
local, want int
name string
local, iscsi, want int
}{
{"local", 31, 1}, {"empty", 0, 0},
{"local", 31, 0, 1}, {"iscsi", 0, 8, 1}, {"both", 31, 7, 2}, {"empty", 0, 0, 0},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
for _, s := range []Selection{{"local", tc.local}} {
for _, s := range []Selection{{"local", tc.local}, {"iscsi", tc.iscsi}} {
data, _ := json.Marshal(s)
if err := os.WriteFile(filepath.Join(dir, s.Group+".json"), data, 0600); err != nil {
t.Fatal(err)
@@ -35,6 +35,9 @@ func TestReadSelection(t *testing.T) {
if err != nil || len(got) != tc.want {
t.Fatalf("selection=%v err=%v", got, err)
}
if tc.want == 2 && (got[0].Group != "local" || got[1].Group != "iscsi") {
t.Fatal("unstable transport order")
}
})
}
if _, err := ReadSelection(t.TempDir()); err == nil {
@@ -43,11 +46,23 @@ func TestReadSelection(t *testing.T) {
}
func TestValidateSelectedGroups(t *testing.T) {
local := []Selection{{"local", 31}}
if ValidateSelection(testcfg.Config{}, local) == nil {
t.Fatal("selected host lacks disposable_host")
local, both := []Selection{{"local", 31}}, []Selection{{"local", 31}, {"iscsi", 7}}
cfg := testcfg.Config{DisposableHost: true}
if err := ValidateSelection(cfg, local); err != nil {
t.Fatal(err)
}
if err := ValidateSelection(testcfg.Config{DisposableHost: true}, local); err != nil {
if ValidateSelection(cfg, both) == nil {
t.Fatal("iSCSI selection accepted without its configuration")
}
cfg.ISCSI = &testcfg.ISCSI{}
if err := ValidateSelection(cfg, local); err != nil {
t.Fatal("unselected initiator required approval:", err)
}
if ValidateSelection(cfg, both) == nil {
t.Fatal("selected initiator lacks disposable_host")
}
cfg.ISCSI.Initiator.DisposableHost = true
if err := ValidateSelection(cfg, both); err != nil {
t.Fatal(err)
}
}
+201
View File
@@ -0,0 +1,201 @@
// SPDX-License-Identifier: GPL-2.0-only
package iscsi
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"scst-component-tests/internal/admin"
"scst-component-tests/internal/initiator"
"scst-component-tests/internal/testcfg"
)
type fixture struct {
Target, Initiator, Iface, Device string
ifaceOwned, nodeOwned, targetOwned, deviceOwned bool
}
var sequence int
func newFixture(ctx context.Context) *fixture {
GinkgoHelper()
if pending.Load() != 0 {
AbortSuite("previous cleanup failed; inspect both hosts")
}
if err := peer.Blocked(); err != nil {
AbortSuite(err.Error())
}
if err := cli.Blocked(); err != nil {
AbortSuite(err.Error())
}
Expect(daemon.Running()).To(Succeed())
Expect(empty(ctx)).To(Succeed())
expectNoSessions(ctx)
sequence++
f := &fixture{
Target: fmt.Sprintf("iqn.2026-10.test.scst:%s.%s.%d", strings.ReplaceAll(cfg.NamePrefix, "_", "-"), runID, sequence),
Initiator: fmt.Sprintf("iqn.2026-10.test.scst:init.%s.%d", runID, sequence),
Iface: fmt.Sprintf("scst_%s_%d", runID, sequence),
Device: fmt.Sprintf("%s_%s_%d", cfg.NamePrefix, runID, sequence),
}
data := must(json.MarshalIndent(f, "", " "))
Expect(os.WriteFile(filepath.Join(artifactDir, f.Device+".json"), data, 0600)).To(Succeed())
pending.Add(1)
DeferCleanup(func(ctx SpecContext) {
f.cleanup(ctx)
if ctx.Err() == nil {
pending.Add(-1)
}
}, NodeTimeout(testcfg.Duration(cfg.CleanupTimeout)))
// Check names before claiming ownership, then record before each mutation.
Expect(ifaces(ctx)).NotTo(ContainElement(f.Iface))
f.ifaceOwned = true
remoteOK(ctx, "iscsiadm", "-m", "iface", "-I", f.Iface, "-o", "new")
for _, setting := range [][2]string{{"iface.transport_name", "tcp"}, {"iface.initiatorname", f.Initiator}} {
remoteOK(ctx, "iscsiadm", "-m", "iface", "-I", f.Iface, "-o", "update", "-n", setting[0], "-v", setting[1])
}
Expect(f.nodeExists(ctx)).To(BeFalse())
f.nodeOwned = true
f.nodeOK(ctx, "-o", "new")
for _, setting := range [][2]string{
{"node.startup", "manual"}, {"node.conn[0].startup", "manual"},
{"node.session.auth.authmethod", "None"},
{"node.session.auth.username_in", ""}, {"node.session.auth.password_in", ""},
{"node.session.initial_login_retry_max", "1"},
{"node.conn[0].timeo.login_timeout", strconv.Itoa(int(testcfg.Duration(cfg.ISCSI.LoginTimeout).Seconds()))},
{"node.session.timeo.replacement_timeout", "5"},
} {
f.update(ctx, setting[0], setting[1])
}
f.deviceOwned = true
do(ctx, "-open_dev", f.Device, "-handler", "vdisk_nullio", "-attributes", "dummy=1,size_mb=8")
f.targetOwned = true
do(ctx, "-add_target", f.Target, "-driver", "iscsi")
do(ctx, "-set_tgt_attr", f.Target, "-driver", "iscsi", "-attributes", "per_portal_acl=0")
f.at(ctx, "", "-add_group", "access")
f.at(ctx, "access", "-add_lun", "0", "-device", f.Device)
f.at(ctx, "access", "-add_init", f.Initiator)
do(ctx, "-enable_target", f.Target, "-driver", "iscsi")
return f
}
func ifaces(ctx context.Context) []string {
GinkgoHelper()
return must(initiator.Ifaces(remoteOK(ctx, "iscsiadm", "-m", "iface").Stdout))
}
func (f *fixture) at(ctx context.Context, group string, args ...string) {
GinkgoHelper()
args = append(args, "-driver", "iscsi", "-target", f.Target)
if group != "" {
args = append(args, "-group", group)
}
do(ctx, args...)
}
func (f *fixture) node(ctx context.Context, args ...string) admin.Result {
GinkgoHelper()
return remoteRun(ctx, append([]string{"iscsiadm", "-m", "node", "-T", f.Target, "-p", portal(), "-I", f.Iface}, args...)...)
}
func (f *fixture) nodeOK(ctx context.Context, args ...string) admin.Result {
GinkgoHelper()
r := f.node(ctx, args...)
Expect(r.ExitCode).To(BeZero(), r.String())
return r
}
func (f *fixture) nodeExists(ctx context.Context) bool {
GinkgoHelper()
r := f.node(ctx)
Expect(r.ExitCode).To(BeElementOf(0, 21), r.String())
return r.ExitCode == 0
}
func (f *fixture) update(ctx context.Context, name, value string) {
GinkgoHelper()
f.nodeOK(ctx, "-o", "update", "-n", name, "-v", value)
}
func (f *fixture) discover(ctx context.Context) map[string]string {
GinkgoHelper()
r := remoteRun(ctx, "iscsiadm", "-m", "discovery", "-t", "sendtargets", "-p", portal(), "-I", f.Iface, "-o", "nonpersistent")
Expect(r.ExitCode).To(BeElementOf(0, 21), r.String())
entries := must(initiator.Discovery(r.Stdout))
if r.ExitCode == 21 {
Expect(entries).To(BeEmpty())
}
return entries
}
func (f *fixture) login(ctx context.Context) {
GinkgoHelper()
f.nodeOK(ctx, "--login")
r := remoteOK(ctx, "iscsiadm", "-m", "session")
sessions := must(initiator.Sessions(r.Stdout))
Expect(sessions).To(HaveLen(1))
Expect(sessions[0].Target).To(Equal(f.Target))
Expect(sessions[0].Portal).To(Equal(portal()))
Eventually(func(g Gomega) {
sessions, err := cli.SessionDetails(ctx)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(sessions["iscsi/"+f.Target]).To(HaveLen(1))
// The public CLI shows the group's LUN map; this is not a data I/O check.
for _, s := range sessions["iscsi/"+f.Target] {
g.Expect(s.LUNs).To(Equal(map[int]string{0: f.Device}))
}
}, testcfg.Duration(cfg.SettleTimeout), testcfg.Duration(cfg.PollInterval)).WithContext(ctx).Should(Succeed())
}
func (f *fixture) noTargetSession(ctx context.Context) {
GinkgoHelper()
Eventually(func() []string { return must(cli.Sessions(ctx))["iscsi/"+f.Target] },
testcfg.Duration(cfg.SettleTimeout), testcfg.Duration(cfg.PollInterval)).WithContext(ctx).Should(BeEmpty())
}
func (f *fixture) logout(ctx context.Context) {
GinkgoHelper()
f.nodeOK(ctx, "--logout")
expectNoSessions(ctx)
f.noTargetSession(ctx)
}
func (f *fixture) cleanup(ctx context.Context) {
GinkgoHelper()
Expect(peer.Blocked()).NotTo(HaveOccurred(), "preserving target while initiator state is uncertain")
Expect(cli.Blocked()).NotTo(HaveOccurred())
Expect(daemon.Running()).To(Succeed())
if f.nodeOwned && f.nodeExists(ctx) {
// Attempt logout even when a failed login returned an ordinary nonzero code.
r := f.node(ctx, "--logout")
Expect(r.ExitCode).To(BeElementOf(0, 21), r.String())
expectNoSessions(ctx)
f.noTargetSession(ctx)
f.nodeOK(ctx, "-o", "delete")
Expect(f.nodeExists(ctx)).To(BeFalse())
}
if f.ifaceOwned {
for _, name := range ifaces(ctx) {
if name == f.Iface {
remoteOK(ctx, "iscsiadm", "-m", "iface", "-I", f.Iface, "-o", "delete")
}
}
Expect(ifaces(ctx)).NotTo(ContainElement(f.Iface))
}
expectNoSessions(ctx)
if f.targetOwned {
if _, present := must(cli.DriverTargets(ctx, "iscsi"))[f.Target]; present {
f.noTargetSession(ctx)
do(ctx, "-rem_target", f.Target, "-driver", "iscsi")
}
}
if f.deviceOwned {
if handler, present := must(cli.Devices(ctx))[f.Device]; present {
Expect(handler).To(Equal("vdisk_nullio"))
do(ctx, "-close_dev", f.Device, "-handler", handler)
}
}
Expect(empty(ctx)).To(Succeed())
}
+75
View File
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: GPL-2.0-only
package iscsi
import (
"crypto/rand"
"encoding/hex"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = It("discovers a target, logs in with its own IQN and logs out", Label("basic", "iscsi", "sessions"), func(ctx SpecContext) {
f := newFixture(ctx)
Expect(f.discover(ctx)).To(Equal(map[string]string{f.Target: portal()}))
f.login(ctx)
f.logout(ctx)
})
var _ = It("can log in again after a complete logout", Label("basic", "iscsi", "sessions"), func(ctx SpecContext) {
f := newFixture(ctx)
for i := 0; i < 2; i++ {
f.login(ctx)
f.logout(ctx)
}
})
var _ = It("repeatedly logs in and out without leaking sessions", Label("stress", "iscsi", "sessions"), func(ctx SpecContext) {
f := newFixture(ctx)
for i := 0; i < cfg.StressIterations; i++ {
f.login(ctx)
f.logout(ctx)
}
})
var _ = DescribeTable("CHAP authentication", Label("basic", "iscsi", "chap", "negative", "issue-318"),
func(ctx SpecContext, algorithm string) {
f := newFixture(ctx)
secretBytes := make([]byte, 8)
_, err := rand.Read(secretBytes)
Expect(err).NotTo(HaveOccurred())
secret := hex.EncodeToString(secretBytes)
do(ctx, "-add_tgt_attr", f.Target, "-driver", "iscsi", "-attributes", "IncomingUser=tester "+secret)
f.update(ctx, "node.session.auth.authmethod", "CHAP")
// Offer exactly one algorithm. A fallback would hide SHA256/SHA3 failures.
f.update(ctx, "node.session.auth.chap_algs", algorithm)
f.update(ctx, "node.session.auth.username", "tester")
f.update(ctx, "node.session.auth.password", "incorrect-secret")
before := must(cli.DriverAssignments(ctx, "iscsi", f.Target))
r := f.node(ctx, "--login")
Expect(r.ExitCode).To(Equal(24), "expected authentication failure, not transport/setup failure: %s", r.String())
expectNoSessions(ctx)
f.noTargetSession(ctx)
Expect(must(cli.DriverAssignments(ctx, "iscsi", f.Target))).To(Equal(before))
f.update(ctx, "node.session.auth.password", secret)
f.login(ctx)
f.logout(ctx)
},
Entry("MD5", "MD5"),
Entry("SHA1", "SHA1"),
Entry("SHA256", "SHA256"),
Entry("SHA3-256", "SHA3-256"),
)
var _ = It("filters discovery by the target portal address and permits the matching portal", Label("basic", "iscsi", "discovery", "issue-103", "issue-126"), func(ctx SpecContext) {
f := newFixture(ctx)
Expect(f.discover(ctx)).To(HaveKeyWithValue(f.Target, portal()))
// The daemon listens only on target_address. Loopback cannot match it.
do(ctx, "-add_tgt_attr", f.Target, "-driver", "iscsi", "-attributes", "allowed_portal=127.0.0.1")
Expect(f.discover(ctx)).To(BeEmpty())
do(ctx, "-rem_tgt_attr", f.Target, "-driver", "iscsi", "-attributes", "allowed_portal=127.0.0.1")
do(ctx, "-add_tgt_attr", f.Target, "-driver", "iscsi", "-attributes", "allowed_portal="+cfg.ISCSI.TargetAddress)
Expect(f.discover(ctx)).To(HaveKeyWithValue(f.Target, portal()))
f.login(ctx)
f.logout(ctx)
})
+180
View File
@@ -0,0 +1,180 @@
// SPDX-License-Identifier: GPL-2.0-only
package iscsi
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"sync/atomic"
"testing"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"scst-component-tests/internal/admin"
"scst-component-tests/internal/remote"
"scst-component-tests/internal/testcfg"
"scst-component-tests/internal/testhost"
"scst-component-tests/internal/testrun"
)
var cfg testcfg.Config
var cli *admin.Client
var peer *remote.Session
var daemon *testhost.Process
var artifactDir, runID string
var pending atomic.Int64
func TestISCSI(t *testing.T) {
testrun.Run(t, "SCST iSCSI over SSH", true, func(env testrun.Environment) {
cfg, cli, artifactDir, runID = env.Config, env.Client, env.ArtifactDir, env.RunID
})
}
func must[T any](value T, err error) T {
GinkgoHelper()
Expect(err).NotTo(HaveOccurred())
return value
}
func journal(name string) *os.File {
GinkgoHelper()
f := must(os.OpenFile(filepath.Join(artifactDir, name), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600))
DeferCleanup(func() { Expect(f.Close()).To(Succeed()) })
return f
}
func empty(ctx context.Context) error { return cli.RequireEmptyDriver(ctx, "iscsi") }
func portal() string { return net.JoinHostPort(cfg.ISCSI.TargetAddress, strconv.Itoa(cfg.ISCSI.Port)) }
func beforeReplace(ctx context.Context) error {
if err := empty(ctx); err != nil {
return err
}
drivers, err := cli.Names(ctx, "Driver", "-list_driver")
if err != nil {
return err
}
for _, driver := range drivers {
if driver == "iscsi" {
state, err := cli.Scalar(ctx, "open_state", "-list_drv_attr", "iscsi")
if err != nil {
return err
}
if state != "closed" {
return fmt.Errorf("existing iSCSI control daemon is still attached: %s", state)
}
}
}
return nil
}
var _ = BeforeSuite(func(ctx SpecContext) {
// Binding a temporary socket verifies address ownership and port availability.
listener := must(net.Listen("tcp4", portal()))
Expect(listener.Close()).To(Succeed())
r, err := cli.RunProgram(ctx, testcfg.Duration(cfg.CommandTimeout), "pgrep", "-x", "iscsi-scstd")
Expect(err).NotTo(HaveOccurred())
Expect(r.ExitCode).To(Equal(1), "stop the existing target daemon before this suite: %s", r.String())
sshLog, commandLog := journal("ssh.log"), journal("initiator.jsonl")
connectCtx, cancel := context.WithTimeout(ctx, testcfg.Duration(cfg.CommandTimeout))
peer, err = remote.Connect(connectCtx, cfg.ISCSI.Initiator, commandLog, sshLog)
cancel()
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() { Expect(peer.Close()).To(Succeed()) })
r = remoteRun(ctx, "iscsiadm", "--version")
Expect(r.ExitCode).To(BeZero(), r.String())
r = remoteRun(ctx, "pgrep", "-x", "iscsid")
Expect(r.ExitCode).To(BeZero(), "start iscsid on the initiator before running: %s", r.String())
// Use a disposable initiator with no sessions; existing node/iface records
// are preserved. Kernel TCP initiator support must already be loaded.
expectNoSessions(ctx)
r = remoteRun(ctx, "lsmod")
Expect(r.ExitCode).To(BeZero(), r.String())
Expect(r.Stdout).To(MatchRegexp(`(?m)^iscsi_tcp\s`), "load iscsi_tcp on the initiator before running")
modules := new(testhost.Modules)
source := os.Getenv("SCST_TEST_SOURCE_DIR")
if source == "" {
source = must(filepath.Abs("../../.."))
}
err = modules.Prepare(ctx, testhost.Options{
SourceDir: source, KernelDir: os.Getenv("SCST_TEST_KDIR"), ISCSI: true,
Build: os.Getenv("SCST_TEST_BUILD") == "1", Jobs: cfg.BuildJobs,
BuildTimeout: testcfg.Duration(cfg.BuildTimeout), CommandTimeout: testcfg.Duration(cfg.CommandTimeout),
}, cli.RunProgram, beforeReplace, GinkgoWriter)
DeferCleanup(func(ctx SpecContext) {
Expect(pending.Load()).To(BeZero(), "spec cleanup incomplete; preserving target stack")
Expect(peer.Blocked()).NotTo(HaveOccurred(), "initiator state uncertain; preserving target stack")
Expect(cli.Blocked()).NotTo(HaveOccurred(), "target state uncertain; preserving target stack")
expectNoSessions(ctx)
if daemon != nil {
Expect(empty(ctx)).To(Succeed())
fmt.Fprintf(GinkgoWriter, "Stopping owned iscsi-scstd (PID %d)\n", daemon.PID())
Expect(daemon.Stop(ctx)).To(Succeed())
Eventually(func() string { return must(cli.Scalar(ctx, "open_state", "-list_drv_attr", "iscsi")) },
testcfg.Duration(cfg.SettleTimeout), testcfg.Duration(cfg.PollInterval)).WithContext(ctx).Should(Equal("closed"))
}
Expect(modules.Unload(ctx, testcfg.Duration(cfg.CommandTimeout), cli.RunProgram, empty, GinkgoWriter)).To(Succeed())
}, NodeTimeout(testcfg.Duration(cfg.CleanupTimeout)))
Expect(err).To(Succeed())
Expect(empty(ctx)).To(Succeed())
config := filepath.Join(artifactDir, "iscsi-scstd.conf")
Expect(os.WriteFile(config, nil, 0600)).To(Succeed())
log := journal("iscsi-scstd.log")
fmt.Fprintf(GinkgoWriter, "Starting checkout iscsi-scstd on %s\n", portal())
daemon, err = testhost.StartProcess(log, filepath.Join(source, "iscsi-scst/usr/iscsi-scstd"),
"-f", "-d", "1", "-c", config, "-a", cfg.ISCSI.TargetAddress, "-p", strconv.Itoa(cfg.ISCSI.Port))
Expect(err).NotTo(HaveOccurred())
Expect(os.WriteFile(filepath.Join(artifactDir, "iscsi-scstd.pid"), []byte(fmt.Sprintln(daemon.PID())), 0600)).To(Succeed())
Eventually(func() bool {
Expect(daemon.Running()).To(Succeed())
attrs := must(cli.Attributes(ctx, "-list_drv_attr", "iscsi"))
return len(attrs["enabled"].Values) == 1
}, testcfg.Duration(cfg.SettleTimeout), testcfg.Duration(cfg.PollInterval)).WithContext(ctx).Should(BeTrue())
do(ctx, "-set_drv_attr", "iscsi", "-attributes", "enabled=1")
// Opening the listener can follow attribute registration in the daemon.
Eventually(func() error {
if err := daemon.Running(); err != nil {
return err
}
c, err := net.DialTimeout("tcp4", portal(), time.Second)
if err == nil {
c.Close()
}
return err
}, testcfg.Duration(cfg.SettleTimeout), testcfg.Duration(cfg.PollInterval)).WithContext(ctx).Should(Succeed())
})
func 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 remoteRun(ctx context.Context, args ...string) admin.Result {
GinkgoHelper()
r, err := peer.Run(ctx, testcfg.Duration(cfg.CommandTimeout), args...)
Expect(err).NotTo(HaveOccurred(), "remote execution failure is not an expected rejection: %s", r.String())
return r
}
func remoteOK(ctx context.Context, args ...string) admin.Result {
GinkgoHelper()
r := remoteRun(ctx, args...)
Expect(r.ExitCode).To(BeZero(), r.String())
return r
}
func expectNoSessions(ctx context.Context) {
GinkgoHelper()
r := remoteRun(ctx, "iscsiadm", "-m", "session")
Expect(r.ExitCode).To(Equal(21), "expected no initiator sessions: %s", r.String())
Expect(r.Stdout).To(BeEmpty(), r.String())
}
+1 -1
View File
@@ -22,7 +22,7 @@ var artifactDir, runID string
var pendingCleanups atomic.Int64
func TestSCST(t *testing.T) {
testrun.Run(t, "SCST through scstadmin", func(env testrun.Environment) {
testrun.Run(t, "SCST through scstadmin", false, func(env testrun.Environment) {
cfg, cli, artifactDir, runID = env.Config, env.Client, env.ArtifactDir, env.RunID
})
}