mirror of
https://github.com/SCST-project/scst.git
synced 2026-10-22 14:12:33 +00:00
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:
@@ -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,3 +1,4 @@
|
||||
!go.mod
|
||||
/config.local.json
|
||||
/config.*.local.json
|
||||
/results/
|
||||
|
||||
+12
-9
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user