From dc56a18d24e3c96596ca5eaab3d6e5e221c68484 Mon Sep 17 00:00:00 2001 From: Marcelo Ariza Date: Mon, 11 May 2026 15:16:21 -0300 Subject: [PATCH 1/4] feat(cli): add volume setup for VAST host configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce 'lsh volume setup' as a one-time, idempotent per-host command that prepares a bare-metal server for NVMe-oF/TCP volume mounting: - /etc/modules-load.d/nvme-tcp.conf (kernel module persistence) - /etc/modprobe.d/nvme-core.conf (max_retries=5) - /etc/udev/rules.d/71-nvmf-vastdata.rules (VAST round-robin multipath I/O) - /etc/nvme/discovery.conf (seed gateway VIP) - initramfs rebuild (update-initramfs / dracut) - systemctl enable nvmf-autoconnect (reboot-resilient reconnect) Without this, volume mounts only survive the current session. After setup, the volume reconnects automatically after reboot and VAST returns up to 16 VIPs on discovery for native multipathing. Also improves 'lsh volume mount': - Loads 'nvme' alongside 'nvme-tcp' in checkPrerequisites - Replaces ICMP ping connectivity test with 'nvme discover' (VAST VIPs may not respond to ping; nvme discover validates the full NVMe-oF/TCP path) - Warns when host setup is missing and points the user at 'lsh volume setup'; mount still proceeds as a one-shot. This is API-independent — works against the existing v3 Ceph backend. A follow-up PR will wire mount to the new v4 VAST API response and switch to 'nvme connect-all' for VIP-pool fan-out. Co-Authored-By: Claude Opus 4.7 (1M context) --- cli/cli.go | 6 + cli/volume_mount_operation.go | 34 +++-- cli/volume_setup_operation.go | 243 ++++++++++++++++++++++++++++++++++ 3 files changed, 272 insertions(+), 11 deletions(-) create mode 100644 cli/volume_setup_operation.go diff --git a/cli/cli.go b/cli/cli.go index 499f8e9..ff31170 100755 --- a/cli/cli.go +++ b/cli/cli.go @@ -454,5 +454,11 @@ func makeOperationGroupVolumeCmd() (*cobra.Command, error) { } operationGroupVolumeCmd.AddCommand(operationVolumeDeleteCmd) + operationVolumeSetupCmd, err := makeOperationVolumeSetupCmd() + if err != nil { + return nil, err + } + operationGroupVolumeCmd.AddCommand(operationVolumeSetupCmd) + return operationGroupVolumeCmd, nil } diff --git a/cli/volume_mount_operation.go b/cli/volume_mount_operation.go index a4ae7b7..0443945 100644 --- a/cli/volume_mount_operation.go +++ b/cli/volume_mount_operation.go @@ -299,10 +299,14 @@ Please install manually: printStatus("āœ“ nvme-cli is installed") } - // Load NVMe TCP module - printStatus("Loading NVMe-oF TCP module...") - if _, err := runCommand("modprobe", "nvme_tcp"); err != nil { - printWarning("nvme_tcp module may already be loaded") + // Load NVMe modules. nvme-tcp depends on nvme-fabrics and nvme-core, which + // the kernel will auto-pull; we modprobe nvme + nvme-tcp explicitly so a + // missing module surfaces as an error rather than silently failing later. + printStatus("Loading NVMe-oF TCP modules...") + for _, mod := range []string{"nvme", "nvme-tcp"} { + if _, err := runCommand("modprobe", mod); err != nil { + printWarning(fmt.Sprintf("modprobe %s may already be loaded", mod)) + } } // Check multipath setting (informational) @@ -313,13 +317,15 @@ Please install manually: return nil } -// testConnectivity tests network connectivity to the gateway -func testConnectivity(gatewayIP string) error { - printStatus(fmt.Sprintf("Testing connectivity to %s...", gatewayIP)) +// testConnectivity validates the NVMe-oF/TCP path to the gateway by running +// `nvme discover`. VAST VIPs may not respond to ICMP, so a plain ping is an +// unreliable signal; a successful discover both proves L4 reachability and +// confirms the gateway is willing to expose subsystems to this host. +func testConnectivity(gatewayIP, gatewayPort string) error { + printStatus(fmt.Sprintf("Probing %s:%s with nvme discover...", gatewayIP, gatewayPort)) - cmd := exec.Command("ping", "-c", "2", "-W", "2", gatewayIP) - if err := cmd.Run(); err != nil { - return fmt.Errorf("cannot reach gateway at %s", gatewayIP) + if _, err := runCommand("nvme", "discover", "-t", "tcp", "-a", gatewayIP, "-s", gatewayPort); err != nil { + return fmt.Errorf("nvme discover to %s:%s failed: %w", gatewayIP, gatewayPort, err) } printStatus("Gateway is reachable") @@ -440,6 +446,12 @@ func (o *VolumeMountOperation) run(cmd *cobra.Command, args []string) error { return err } + if !hostSetupApplied() { + printWarning("Host is not production-configured (missing module persistence and/or VAST udev rule).") + printWarning("Run 'sudo lsh volume setup --gateway-ip ' for reboot-resilient mounts and VAST multipath I/O.") + printWarning("Continuing with one-shot mount...") + } + // Get the volume ID from flags volumeID, err := cmd.Flags().GetString("id") if err != nil { @@ -631,7 +643,7 @@ func (o *VolumeMountOperation) run(cmd *cobra.Command, args []string) error { return err } - if err := testConnectivity(gatewayIP); err != nil { + if err := testConnectivity(gatewayIP, gatewayPort); err != nil { printError(fmt.Sprintf("Connectivity test failed: %v", err)) return err } diff --git a/cli/volume_setup_operation.go b/cli/volume_setup_operation.go new file mode 100644 index 0000000..1ca2be1 --- /dev/null +++ b/cli/volume_setup_operation.go @@ -0,0 +1,243 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/latitudesh/lsh/cmd/lsh" + "github.com/latitudesh/lsh/internal/cmdflag" + "github.com/spf13/cobra" +) + +const ( + modulesLoadPath = "/etc/modules-load.d/nvme-tcp.conf" + modulesLoadContent = "nvme\nnvme-core\nnvme-tcp\nnvme-fabrics\n" + + modprobePath = "/etc/modprobe.d/nvme-core.conf" + modprobeContent = "options nvme_core max_retries=5\n" + + udevRulePath = "/etc/udev/rules.d/71-nvmf-vastdata.rules" + udevRuleContent = "ACTION==\"add|change\", SUBSYSTEM==\"nvme-subsystem\", ATTR{model}==\"VASTData\", ATTR{subsystype}==\"nvm\", ATTR{iopolicy}=\"round-robin\"\n" + + discoveryConfPath = "/etc/nvme/discovery.conf" + autoconnectService = "nvmf-autoconnect.service" + + defaultGatewayPort = "4420" +) + +func makeOperationVolumeSetupCmd() (*cobra.Command, error) { + operation := VolumeSetupOperation{} + return operation.Register() +} + +type VolumeSetupOperation struct { + OptionsFlags cmdflag.Flags +} + +func (o *VolumeSetupOperation) Register() (*cobra.Command, error) { + cmd := &cobra.Command{ + Use: "setup", + Short: "Configure the host for NVMe-oF/TCP volume mounting", + Long: `One-time, idempotent host configuration for VAST block storage. + +This command: + - Persists NVMe kernel modules across reboots (/etc/modules-load.d/nvme-tcp.conf) + - Tunes nvme_core max_retries (/etc/modprobe.d/nvme-core.conf) + - Installs the VAST multipath I/O udev rule (/etc/udev/rules.d/71-nvmf-vastdata.rules) + - Seeds /etc/nvme/discovery.conf with the gateway IP + - Rebuilds initramfs (Ubuntu: update-initramfs / RHEL: dracut) + - Enables the nvmf-autoconnect service for reboot-resilient mounts + +Run once per server. After this, "lsh volume mount --id " will reconnect +automatically after reboot and use VAST round-robin multipath I/O. + +This command must be run with sudo/root privileges. + +Example: + sudo lsh volume setup --gateway-ip 10.0.1.10`, + RunE: o.run, + PreRun: o.preRun, + } + o.registerFlags(cmd) + return cmd, nil +} + +func (o *VolumeSetupOperation) registerFlags(cmd *cobra.Command) { + o.OptionsFlags = cmdflag.Flags{FlagSet: cmd.Flags()} + + optionsSchema := &cmdflag.FlagsSchema{ + &cmdflag.String{ + Name: "gateway-ip", + Label: "Gateway IP", + Description: "The VAST gateway IP (VIP) to seed into /etc/nvme/discovery.conf", + Required: true, + }, + &cmdflag.String{ + Name: "gateway-port", + Label: "Gateway Port", + Description: fmt.Sprintf("NVMe-oF/TCP port (default: %s)", defaultGatewayPort), + Required: false, + }, + } + + o.OptionsFlags.Register(optionsSchema) +} + +func (o *VolumeSetupOperation) preRun(cmd *cobra.Command, args []string) { + o.OptionsFlags.PreRun(cmd, args) +} + +func (o *VolumeSetupOperation) run(cmd *cobra.Command, args []string) error { + if err := checkRoot(); err != nil { + printError(err.Error()) + return err + } + + gatewayIP, _ := cmd.Flags().GetString("gateway-ip") + gatewayPort, _ := cmd.Flags().GetString("gateway-port") + if gatewayPort == "" { + gatewayPort = defaultGatewayPort + } + + if lsh.DryRun { + lsh.LogDebugf("dry-run flag specified. Skip applying host changes.") + return nil + } + + fmt.Fprintf(os.Stdout, "\nšŸ”§ Configuring host for NVMe-oF/TCP volume mounting...\n\n") + + if err := checkPrerequisites(); err != nil { + printError(err.Error()) + return err + } + + steps := []func() error{ + writeModulesLoadConfig, + writeModprobeConfig, + writeUdevRule, + reloadUdev, + func() error { return writeDiscoveryConf(gatewayIP, gatewayPort) }, + rebuildInitramfs, + enableAutoconnect, + } + for _, step := range steps { + if err := step(); err != nil { + printError(err.Error()) + return err + } + } + + fmt.Fprintf(os.Stdout, "\nāœ… Host setup complete.\n") + fmt.Fprintf(os.Stdout, "\nNext step: sudo lsh volume mount --id \n") + return nil +} + +func writeModulesLoadConfig() error { + printStatus(fmt.Sprintf("Writing %s", modulesLoadPath)) + return writeFileIdempotent(modulesLoadPath, modulesLoadContent, 0644) +} + +func writeModprobeConfig() error { + printStatus(fmt.Sprintf("Writing %s", modprobePath)) + return writeFileIdempotent(modprobePath, modprobeContent, 0644) +} + +func writeUdevRule() error { + printStatus(fmt.Sprintf("Writing %s", udevRulePath)) + return writeFileIdempotent(udevRulePath, udevRuleContent, 0644) +} + +func reloadUdev() error { + printStatus("Reloading udev rules") + if _, err := runCommand("udevadm", "control", "--reload-rules"); err != nil { + return fmt.Errorf("udevadm control --reload-rules: %w", err) + } + if _, err := runCommand("udevadm", "trigger"); err != nil { + return fmt.Errorf("udevadm trigger: %w", err) + } + return nil +} + +func writeDiscoveryConf(gatewayIP, gatewayPort string) error { + printStatus(fmt.Sprintf("Seeding %s with %s:%s", discoveryConfPath, gatewayIP, gatewayPort)) + + if err := os.MkdirAll(filepath.Dir(discoveryConfPath), 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(discoveryConfPath), err) + } + + line := fmt.Sprintf("--transport=tcp --traddr=%s --trsvcid=%s", gatewayIP, gatewayPort) + + existing, err := os.ReadFile(discoveryConfPath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("read %s: %w", discoveryConfPath, err) + } + + for _, l := range strings.Split(string(existing), "\n") { + if strings.TrimSpace(l) == line { + printStatus(" āœ“ discovery entry already present") + return nil + } + } + + content := string(existing) + if len(content) > 0 && !strings.HasSuffix(content, "\n") { + content += "\n" + } + content += line + "\n" + + return os.WriteFile(discoveryConfPath, []byte(content), 0644) +} + +func rebuildInitramfs() error { + if _, err := exec.LookPath("update-initramfs"); err == nil { + printStatus("Rebuilding initramfs (update-initramfs -u)") + if _, err := runCommand("update-initramfs", "-u"); err != nil { + return fmt.Errorf("update-initramfs: %w", err) + } + return nil + } + if _, err := exec.LookPath("dracut"); err == nil { + printStatus("Rebuilding initramfs (dracut -f)") + if _, err := runCommand("dracut", "-f"); err != nil { + return fmt.Errorf("dracut: %w", err) + } + return nil + } + printWarning("Neither update-initramfs nor dracut found; skipping initramfs rebuild") + return nil +} + +func enableAutoconnect() error { + printStatus(fmt.Sprintf("Enabling %s", autoconnectService)) + if _, err := runCommand("systemctl", "enable", autoconnectService); err != nil { + return fmt.Errorf("systemctl enable %s: %w", autoconnectService, err) + } + return nil +} + +func writeFileIdempotent(path, content string, perm os.FileMode) error { + if existing, err := os.ReadFile(path); err == nil && string(existing) == content { + printStatus(fmt.Sprintf(" āœ“ %s already up to date", path)) + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(content), perm); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return nil +} + +func hostSetupApplied() bool { + if _, err := os.Stat(modulesLoadPath); os.IsNotExist(err) { + return false + } + if _, err := os.Stat(udevRulePath); os.IsNotExist(err) { + return false + } + return true +} From 73f037dc689573d6c3a0c3c4f3103625c0aec033 Mon Sep 17 00:00:00 2001 From: Marcelo Ariza Date: Mon, 11 May 2026 15:55:03 -0300 Subject: [PATCH 2/4] chore(cli): hide storage backend vendor name from user-visible strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrubs vendor references from CLI Short/Long descriptions, flag descriptions, runtime printWarning messages, and the public-repo godoc comments. Customers should see "block storage" and "NVMe-oF/TCP" — never the underlying vendor. - 'volume setup' Long description uses neutral "NVMe-oF/TCP block storage" and "round-robin multipath I/O" instead of naming the backend. - --gateway-ip flag description says "block storage gateway IP". - 'volume mount' warning when host setup is missing no longer names the vendor in the udev-rule reference. - Renamed the udev rule file path to a branded, neutral name: /etc/udev/rules.d/71-latitude-block-multipath.rules (was 71-nvmf-vastdata.rules) so a customer listing /etc/udev/ sees a Latitude.sh-branded file, not a vendor name. - testConnectivity godoc comment uses "block storage gateways" in place of the vendor name. Unavoidable: the udev rule *content* still has ATTR{model}=="VASTData" because that is the literal kernel-reported hardware identifier; udev matches against the string the kernel actually emits, not user copy. A code comment now documents this. Co-Authored-By: Claude Opus 4.7 (1M context) --- cli/volume_mount_operation.go | 11 ++++++----- cli/volume_setup_operation.go | 13 ++++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/cli/volume_mount_operation.go b/cli/volume_mount_operation.go index 0443945..3efc60f 100644 --- a/cli/volume_mount_operation.go +++ b/cli/volume_mount_operation.go @@ -318,9 +318,10 @@ Please install manually: } // testConnectivity validates the NVMe-oF/TCP path to the gateway by running -// `nvme discover`. VAST VIPs may not respond to ICMP, so a plain ping is an -// unreliable signal; a successful discover both proves L4 reachability and -// confirms the gateway is willing to expose subsystems to this host. +// `nvme discover`. Block storage gateways may not respond to ICMP, so a plain +// ping is an unreliable signal; a successful discover both proves L4 +// reachability and confirms the gateway is willing to expose subsystems to +// this host. func testConnectivity(gatewayIP, gatewayPort string) error { printStatus(fmt.Sprintf("Probing %s:%s with nvme discover...", gatewayIP, gatewayPort)) @@ -447,8 +448,8 @@ func (o *VolumeMountOperation) run(cmd *cobra.Command, args []string) error { } if !hostSetupApplied() { - printWarning("Host is not production-configured (missing module persistence and/or VAST udev rule).") - printWarning("Run 'sudo lsh volume setup --gateway-ip ' for reboot-resilient mounts and VAST multipath I/O.") + printWarning("Host is not production-configured (missing module persistence and/or multipath udev rule).") + printWarning("Run 'sudo lsh volume setup --gateway-ip ' for reboot-resilient mounts and round-robin multipath I/O.") printWarning("Continuing with one-shot mount...") } diff --git a/cli/volume_setup_operation.go b/cli/volume_setup_operation.go index 1ca2be1..082ff03 100644 --- a/cli/volume_setup_operation.go +++ b/cli/volume_setup_operation.go @@ -19,7 +19,10 @@ const ( modprobePath = "/etc/modprobe.d/nvme-core.conf" modprobeContent = "options nvme_core max_retries=5\n" - udevRulePath = "/etc/udev/rules.d/71-nvmf-vastdata.rules" + udevRulePath = "/etc/udev/rules.d/71-latitude-block-multipath.rules" + // The ATTR{model} match is the literal hardware vendor identifier as + // reported by the kernel; do not change it — udev matches against the + // hardware-reported string, not user-supplied text. udevRuleContent = "ACTION==\"add|change\", SUBSYSTEM==\"nvme-subsystem\", ATTR{model}==\"VASTData\", ATTR{subsystype}==\"nvm\", ATTR{iopolicy}=\"round-robin\"\n" discoveryConfPath = "/etc/nvme/discovery.conf" @@ -41,18 +44,18 @@ func (o *VolumeSetupOperation) Register() (*cobra.Command, error) { cmd := &cobra.Command{ Use: "setup", Short: "Configure the host for NVMe-oF/TCP volume mounting", - Long: `One-time, idempotent host configuration for VAST block storage. + Long: `One-time, idempotent host configuration for NVMe-oF/TCP block storage. This command: - Persists NVMe kernel modules across reboots (/etc/modules-load.d/nvme-tcp.conf) - Tunes nvme_core max_retries (/etc/modprobe.d/nvme-core.conf) - - Installs the VAST multipath I/O udev rule (/etc/udev/rules.d/71-nvmf-vastdata.rules) + - Installs the round-robin multipath I/O udev rule - Seeds /etc/nvme/discovery.conf with the gateway IP - Rebuilds initramfs (Ubuntu: update-initramfs / RHEL: dracut) - Enables the nvmf-autoconnect service for reboot-resilient mounts Run once per server. After this, "lsh volume mount --id " will reconnect -automatically after reboot and use VAST round-robin multipath I/O. +automatically after reboot with round-robin multipath I/O. This command must be run with sudo/root privileges. @@ -72,7 +75,7 @@ func (o *VolumeSetupOperation) registerFlags(cmd *cobra.Command) { &cmdflag.String{ Name: "gateway-ip", Label: "Gateway IP", - Description: "The VAST gateway IP (VIP) to seed into /etc/nvme/discovery.conf", + Description: "The block storage gateway IP to seed into /etc/nvme/discovery.conf", Required: true, }, &cmdflag.String{ From d542e0a678a9710107c51222c103ac4b151e4261 Mon Sep 17 00:00:00 2001 From: Marcelo Ariza Date: Mon, 11 May 2026 20:15:43 -0300 Subject: [PATCH 3/4] feat(cli): wire 'volume mount' to v4 API and use nvme connect-all Phase 3 of the block-storage CLI rewrite. The CLI now talks to the new v4 storage volumes API by sending an Api-Version header on every request, consumes the enriched mount response, and lets the kernel's nvme connect-all fan out to all gateway VIPs the API returns. What changed in 'volume mount': - New cli/storage_v4_client.go: small typed HTTP client that sends `Api-Version: 2026-05-11` and decodes the v4 envelope. Avoids waiting on a regenerated latitudesh-go-sdk; the file can be deleted once the SDK ships v4 bindings. - The mount flow no longer makes a separate GET /storage/volumes call to fish out connector_id. The v4 POST .../mount response carries everything the host needs: subsystem_nqn, namespace_id, gateway_vips (multipath VIP pool), discovery_endpoint, region. - The hardcoded gateway IP fallback (67.213.118.147) is gone. If the response is missing subsystem_nqn or gateway_vips the CLI errors out with a clear message. - Replaces explicit `nvme connect -t tcp -a -s -n ` with: iterate gateway_vips, append each to /etc/nvme/discovery.conf via the idempotent writeDiscoveryConf helper, then run `nvme connect-all`. The kernel handles the multipath fan-out (block-storage discovery returns up to 16 VIPs from any seed) and discovery.conf is what nvmf-autoconnect.service reads on reboot. - Removes the now-meaningless --gateway-ip, --gateway-port, and --subsystem-nqn flags from 'volume mount'. The new CLI is a breaking change relative to v1.0.0-ceph (intentional: legacy customers keep using v1.0.0-ceph against v3 routes). - Removes the connectNVMeoF helper (dead with connect-all in place) and the SDK + manual-JSON-parsing import block. What changed in 'volume setup': - --gateway-ip becomes optional. With it, setup pre-seeds /etc/nvme/discovery.conf as before. Without it, setup still installs modules + udev + autoconnect, and the first 'volume mount' call seeds discovery.conf from the API response. This means ops can prepare a host fully before the customer has chosen a volume. - Long help reflects the new "mount will seed discovery.conf" path so operators aren't surprised when reboot reconnection just works after a single mount. This PR depends on the API Phase 2 work (latitudesh/API#4995) for end-to-end exercise; locally `go build` and `go vet` pass, and the behavior against the still-stub orchestrators will be a clean "API call failed: ... 500 NotImplementedError" until those land. Co-Authored-By: Claude Opus 4.7 (1M context) --- cli/storage_v4_client.go | 137 +++++++++++++++++++++ cli/volume_mount_operation.go | 222 +++++++--------------------------- cli/volume_setup_operation.go | 27 +++-- 3 files changed, 198 insertions(+), 188 deletions(-) create mode 100644 cli/storage_v4_client.go diff --git a/cli/storage_v4_client.go b/cli/storage_v4_client.go new file mode 100644 index 0000000..9e8d338 --- /dev/null +++ b/cli/storage_v4_client.go @@ -0,0 +1,137 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" +) + +// v4APIVersion is sent as the `Api-Version` header on every v4 request. The +// API selects routes based on this header; v3 routes (legacy backend) keep +// serving clients that omit the header. +const ( + v4APIVersion = "2026-05-11" + v4DefaultBaseURL = "https://api.latitude.sh" + v4Timeout = 30 * time.Second +) + +// V4VolumeAttributes is the v4 mount/show response shape. Fields are pointers +// where they are absent on the v3 backend; the CLI requires the connection +// fields to be populated and errors out otherwise. +type V4VolumeAttributes struct { + Name string `json:"name"` + SizeInGB int `json:"size_in_gb"` + CreatedAt string `json:"created_at"` + Region string `json:"region,omitempty"` + SubsystemNQN string `json:"subsystem_nqn,omitempty"` + NamespaceID *int `json:"namespace_id,omitempty"` + GatewayVIPs []string `json:"gateway_vips,omitempty"` + DiscoveryEndpoint string `json:"discovery_endpoint,omitempty"` +} + +type V4Volume struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes V4VolumeAttributes `json:"attributes"` +} + +type v4Envelope struct { + Data V4Volume `json:"data"` +} + +type v4Client struct { + apiKey string + baseURL string + http *http.Client +} + +func newV4Client(apiKey string) *v4Client { + baseURL := os.Getenv("LSH_API_URL") + if baseURL == "" { + baseURL = v4DefaultBaseURL + } + return &v4Client{ + apiKey: apiKey, + baseURL: baseURL, + http: &http.Client{Timeout: v4Timeout}, + } +} + +func (c *v4Client) do(method, path string, body []byte) ([]byte, int, error) { + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + req, err := http.NewRequest(method, c.baseURL+path, reader) + if err != nil { + return nil, 0, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Api-Version", v4APIVersion) + req.Header.Set("Accept", "application/vnd.api+json") + if body != nil { + req.Header.Set("Content-Type", "application/vnd.api+json") + } + + resp, err := c.http.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("execute request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, fmt.Errorf("read response: %w", err) + } + return data, resp.StatusCode, nil +} + +func (c *v4Client) decodeVolume(data []byte) (*V4Volume, error) { + var env v4Envelope + if err := json.Unmarshal(data, &env); err != nil { + return nil, fmt.Errorf("decode volume response: %w", err) + } + return &env.Data, nil +} + +// GetVolume fetches one volume by ID via the v4 routes. +func (c *v4Client) GetVolume(id string) (*V4Volume, error) { + data, status, err := c.do("GET", "/storage/volumes/"+id, nil) + if err != nil { + return nil, err + } + if status >= 400 { + return nil, fmt.Errorf("GET /storage/volumes/%s returned %d: %s", id, status, string(data)) + } + return c.decodeVolume(data) +} + +// MountVolume authorizes the client's NQN on the storage subsystem and +// returns the connection info (subsystem NQN, namespace ID, gateway VIPs) +// that the host needs to establish the NVMe-oF/TCP path. +func (c *v4Client) MountVolume(id, clientNQN string) (*V4Volume, error) { + body, err := json.Marshal(map[string]any{ + "data": map[string]any{ + "type": "volumes", + "attributes": map[string]any{ + "nqn": clientNQN, + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("encode mount request: %w", err) + } + + data, status, err := c.do("POST", "/storage/volumes/"+id+"/mount", body) + if err != nil { + return nil, err + } + if status >= 400 { + return nil, fmt.Errorf("POST /storage/volumes/%s/mount returned %d: %s", id, status, string(data)) + } + return c.decodeVolume(data) +} diff --git a/cli/volume_mount_operation.go b/cli/volume_mount_operation.go index 3efc60f..cd2c039 100644 --- a/cli/volume_mount_operation.go +++ b/cli/volume_mount_operation.go @@ -1,20 +1,14 @@ package cli import ( - "context" - "encoding/json" "fmt" - "io" "os" "os/exec" "strings" "time" - latitudeshgosdk "github.com/latitudesh/latitudesh-go-sdk" - "github.com/latitudesh/latitudesh-go-sdk/models/operations" "github.com/latitudesh/lsh/cmd/lsh" "github.com/latitudesh/lsh/internal/cmdflag" - "github.com/latitudesh/lsh/internal/utils" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -46,18 +40,13 @@ func (o *VolumeMountOperation) Register() (*cobra.Command, error) { cmd := &cobra.Command{ Use: "mount", Short: "Mount a volume storage to a server", - Long: `Mount a volume storage to a server. This command will: - 1. Auto-fetch volume storage details (including connector_id) - 2. Auto-detect the server's NQN from /etc/nvme/hostnqn + Long: `Mount a block storage volume to a server. This command will: + 1. Auto-detect the server's NQN from /etc/nvme/hostnqn (or generate a new one if the file doesn't exist) - 3. Send the client NQN to the API to authorize access - 4. Execute all NVMe-oF connection steps automatically - -The mount process: -- Volume ID: Used to fetch connector_id (subsystem NQN) automatically -- Client NQN (--nqn or auto-detected): Sent to API to authorize this client -- Subsystem NQN: Auto-fetched from volume storage's connector_id -- Gateway: The NVMe-oF gateway IP and port (defaults to 67.213.118.147:4420) + 2. Send the client NQN to the API to authorize access + 3. Receive the subsystem NQN, namespace ID, and gateway VIPs back from the API + 4. Seed /etc/nvme/discovery.conf with the gateway VIPs + 5. Run "nvme connect-all" to attach the volume This command must be run with sudo/root privileges on the target server. @@ -92,24 +81,6 @@ func (o *VolumeMountOperation) registerFlags(cmd *cobra.Command) { Description: "NVMe Qualified Name of the server (will auto-detect if not provided)", Required: false, }, - &cmdflag.String{ - Name: "gateway-ip", - Label: "Gateway IP", - Description: "Override the gateway IP address (optional, default: 67.213.118.147)", - Required: false, - }, - &cmdflag.String{ - Name: "gateway-port", - Label: "Gateway Port", - Description: "Override the gateway port (optional, default: 4420)", - Required: false, - }, - &cmdflag.String{ - Name: "subsystem-nqn", - Label: "Subsystem NQN", - Description: "Override the subsystem NQN (optional, auto-fetched from volume storage's connector_id)", - Required: false, - }, } o.PathParamFlags.Register(pathParamsSchema) @@ -350,24 +321,6 @@ func disconnectExisting(subsystemNQN string) { } } -// connectNVMeoF connects to the NVMe-oF target -func connectNVMeoF(gatewayIP, gatewayPort, subsystemNQN string) error { - printStatus("Connecting to NVMe-oF target...") - printStatus(fmt.Sprintf(" Gateway: %s:%s", gatewayIP, gatewayPort)) - printStatus(fmt.Sprintf(" Subsystem: %s", subsystemNQN)) - - _, err := runCommand("nvme", "connect", "-t", "tcp", "-a", gatewayIP, "-s", gatewayPort, "-n", subsystemNQN) - if err != nil { - return fmt.Errorf(`connection failed. Please check: - 1. Gateway is accessible from this server - 2. Client NQN is authorized on the gateway - 3. Volume storage is properly configured`) - } - - printStatus("Successfully connected!") - return nil -} - // verifyConnection verifies the connection and shows available devices func verifyConnection(subsystemNQN string) error { printStatus("Verifying connection...") @@ -449,7 +402,7 @@ func (o *VolumeMountOperation) run(cmd *cobra.Command, args []string) error { if !hostSetupApplied() { printWarning("Host is not production-configured (missing module persistence and/or multipath udev rule).") - printWarning("Run 'sudo lsh volume setup --gateway-ip ' for reboot-resilient mounts and round-robin multipath I/O.") + printWarning("Run 'sudo lsh volume setup' for reboot-resilient mounts and round-robin multipath I/O.") printWarning("Continuing with one-shot mount...") } @@ -502,159 +455,69 @@ func (o *VolumeMountOperation) run(cmd *cobra.Command, args []string) error { return fmt.Errorf("API key not found. Please run 'lsh login ' first") } - // Initialize the new SDK client - ctx := context.Background() - client := latitudeshgosdk.New( - latitudeshgosdk.WithSecurity(apiKey), - ) - - // Step 1: Fetch volume storage details to get connector_id (subsystem NQN) - subsystemNQN, _ := cmd.Flags().GetString("subsystem-nqn") - - if subsystemNQN == "" { - // Auto-fetch connector_id from API - fmt.Fprintf(os.Stdout, "\nšŸ“‹ Fetching volume details...\n") - printStatus(fmt.Sprintf("Volume ID: %s", volumeID)) - - if lsh.Debug { - fmt.Fprintf(os.Stdout, "[DEBUG] Fetching volume storage details to get connector_id\n") - } - - volumesResponse, err := client.Storage.GetStorageVolumes(ctx, nil) - if err != nil { - printError(fmt.Sprintf("Failed to fetch volume storage details: %v", err)) - utils.PrintError(err) - return err - } - - // Parse response body manually to get volume data - if volumesResponse != nil && volumesResponse.HTTPMeta.Response != nil { - bodyBytes, err := io.ReadAll(volumesResponse.HTTPMeta.Response.Body) - if err != nil { - printError(fmt.Sprintf("Failed to read response body: %v", err)) - return err - } - - // Parse JSON response - var responseData struct { - Data []struct { - ID string `json:"id"` - Type string `json:"type"` - Attributes struct { - ConnectorID *string `json:"connector_id"` - } `json:"attributes"` - } `json:"data"` - } - - if err := json.Unmarshal(bodyBytes, &responseData); err != nil { - printError(fmt.Sprintf("Failed to parse response: %v", err)) - return err - } - - // Find the volume by ID - var found bool - for _, volume := range responseData.Data { - if volume.ID == volumeID { - if volume.Attributes.ConnectorID != nil && *volume.Attributes.ConnectorID != "" { - subsystemNQN = *volume.Attributes.ConnectorID - printStatus(fmt.Sprintf("āœ“ Retrieved connector_id (subsystem NQN): %s", subsystemNQN)) - found = true - break - } else { - printError("Volume storage does not have a connector_id configured") - printError("The volume storage must have a connector_id before mounting") - return fmt.Errorf("connector_id not found for volume storage %s", volumeID) - } - } - } - - if !found { - printError(fmt.Sprintf("Volume storage not found: %s", volumeID)) - return fmt.Errorf("volume storage %s not found", volumeID) - } - } else { - printError("No response from API") - return fmt.Errorf("failed to get response from API") - } - } else { - printStatus(fmt.Sprintf("Using provided subsystem NQN: %s", subsystemNQN)) - } - fmt.Fprintf(os.Stdout, "\nšŸ“¦ Authorizing client and mounting volume...\n") printStatus(fmt.Sprintf("Volume ID: %s", volumeID)) printStatus(fmt.Sprintf("Client NQN (for authorization): %s", nqn)) if lsh.Debug { - fmt.Fprintf(os.Stdout, "[DEBUG] API Request: POST /storage/volumes/%s/mount\n", volumeID) - fmt.Fprintf(os.Stdout, "[DEBUG] Request Body: {\"data\":{\"type\":\"volumes\",\"attributes\":{\"nqn\":\"%s\"}}}\n", nqn) - } - - // Call the API to authorize the client NQN and mount - // The NQN authorizes this client to access the storage - // The subsystem-nqn (connector_id) defines which storage subsystem to connect to - response, err := client.Storage.PostStorageVolumesMount(ctx, volumeID, operations.PostStorageVolumesMountRequestBody{ - Data: operations.PostStorageVolumesMountData{ - Type: operations.PostStorageVolumesMountTypeVolumes, - Attributes: operations.PostStorageVolumesMountAttributes{ - Nqn: nqn, // Send client NQN to authorize - }, - }, - }) + fmt.Fprintf(os.Stdout, "[DEBUG] POST /storage/volumes/%s/mount (Api-Version: %s)\n", volumeID, v4APIVersion) + fmt.Fprintf(os.Stdout, "[DEBUG] Request body NQN: %s\n", nqn) + } + + apiClient := newV4Client(apiKey) + volume, err := apiClient.MountVolume(volumeID, nqn) if err != nil { printError(fmt.Sprintf("API call failed: %v", err)) - utils.PrintError(err) return err } - if lsh.Debug { - fmt.Fprintf(os.Stdout, "[DEBUG] API Response Status: %d\n", response.HTTPMeta.Response.StatusCode) - } - - if response != nil && response.HTTPMeta.Response != nil { - if response.HTTPMeta.Response.StatusCode == 204 || response.HTTPMeta.Response.StatusCode == 200 { - printStatus("āœ“ Successfully authorized client and mounted volume!") - } else { - printWarning(fmt.Sprintf("Unexpected status code: %d", response.HTTPMeta.Response.StatusCode)) - } - } else { - printWarning("No response from API") + subsystemNQN := volume.Attributes.SubsystemNQN + if subsystemNQN == "" { + return fmt.Errorf("API response missing subsystem_nqn — the volume may not be on a current-generation backend") } - - // Get override values or use defaults - gatewayIP, _ := cmd.Flags().GetString("gateway-ip") - gatewayPort, _ := cmd.Flags().GetString("gateway-port") - - // Hardcoded gateway for now - if gatewayIP == "" { - gatewayIP = "67.213.118.147" // Hardcoded gateway IP - printStatus(fmt.Sprintf("Using default gateway IP: %s", gatewayIP)) + if len(volume.Attributes.GatewayVIPs) == 0 { + return fmt.Errorf("API response missing gateway_vips — the volume may not be on a current-generation backend") } - if gatewayPort == "" { - gatewayPort = "4420" // Default NVMe-oF port + printStatus(fmt.Sprintf("āœ“ Subsystem NQN: %s", subsystemNQN)) + printStatus(fmt.Sprintf("āœ“ Gateway VIPs: %s", strings.Join(volume.Attributes.GatewayVIPs, ", "))) + if volume.Attributes.NamespaceID != nil { + printStatus(fmt.Sprintf("āœ“ Namespace ID: %d", *volume.Attributes.NamespaceID)) } fmt.Fprintf(os.Stdout, "\nšŸ“” Connecting to NVMe-oF storage...\n\n") - printStatus(fmt.Sprintf("Gateway: %s:%s", gatewayIP, gatewayPort)) - printStatus(fmt.Sprintf("Subsystem NQN: %s", subsystemNQN)) - // Execute mount steps (prerequisites already checked) if err := ensureHostNQN(nqn); err != nil { printError(fmt.Sprintf("Failed to ensure host NQN: %v", err)) return err } - if err := testConnectivity(gatewayIP, gatewayPort); err != nil { + // Seed /etc/nvme/discovery.conf with every VIP the API returned. The + // helper is idempotent: if a line already exists (e.g. from a previous + // mount of another volume in the same pool, or from `lsh volume setup`), + // it is left in place. This also ensures nvmf-autoconnect.service has + // a complete discovery seed for reboot reconnection. + for _, vip := range volume.Attributes.GatewayVIPs { + if err := writeDiscoveryConf(vip, "4420"); err != nil { + printError(fmt.Sprintf("Failed to update %s: %v", discoveryConfPath, err)) + return err + } + } + + // Fast-fail check against the first VIP before attempting connect-all. + if err := testConnectivity(volume.Attributes.GatewayVIPs[0], "4420"); err != nil { printError(fmt.Sprintf("Connectivity test failed: %v", err)) return err } disconnectExisting(subsystemNQN) - if err := connectNVMeoF(gatewayIP, gatewayPort, subsystemNQN); err != nil { - printError(fmt.Sprintf("NVMe-oF connection failed: %v", err)) - return err + printStatus("Running `nvme connect-all` (multipath fan-out reads /etc/nvme/discovery.conf)...") + if _, err := runCommand("nvme", "connect-all"); err != nil { + printError(fmt.Sprintf("nvme connect-all failed: %v", err)) + return fmt.Errorf("nvme connect-all failed: %w", err) } + printStatus("āœ“ Connected") if err := verifyConnection(subsystemNQN); err != nil { printError(fmt.Sprintf("Connection verification failed: %v", err)) @@ -663,8 +526,9 @@ func (o *VolumeMountOperation) run(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stdout, "\nāœ… Volume mount complete!\n") fmt.Fprintf(os.Stdout, "\nConnection Summary:\n") - fmt.Fprintf(os.Stdout, " Client NQN: %s\n", nqn) + fmt.Fprintf(os.Stdout, " Client NQN: %s\n", nqn) fmt.Fprintf(os.Stdout, " Subsystem NQN: %s\n", subsystemNQN) + fmt.Fprintf(os.Stdout, " Gateway VIPs: %s\n", strings.Join(volume.Attributes.GatewayVIPs, ", ")) return nil } diff --git a/cli/volume_setup_operation.go b/cli/volume_setup_operation.go index 082ff03..230f08a 100644 --- a/cli/volume_setup_operation.go +++ b/cli/volume_setup_operation.go @@ -50,17 +50,20 @@ This command: - Persists NVMe kernel modules across reboots (/etc/modules-load.d/nvme-tcp.conf) - Tunes nvme_core max_retries (/etc/modprobe.d/nvme-core.conf) - Installs the round-robin multipath I/O udev rule - - Seeds /etc/nvme/discovery.conf with the gateway IP + - Optionally seeds /etc/nvme/discovery.conf with a gateway IP - Rebuilds initramfs (Ubuntu: update-initramfs / RHEL: dracut) - Enables the nvmf-autoconnect service for reboot-resilient mounts -Run once per server. After this, "lsh volume mount --id " will reconnect -automatically after reboot with round-robin multipath I/O. +Run once per server. After this, "lsh volume mount --id " populates +/etc/nvme/discovery.conf with the gateway VIPs returned by the API and +the volume reconnects automatically after reboot with round-robin +multipath I/O. This command must be run with sudo/root privileges. -Example: - sudo lsh volume setup --gateway-ip 10.0.1.10`, +Examples: + sudo lsh volume setup + sudo lsh volume setup --gateway-ip 10.0.1.10 # pre-seed discovery.conf`, RunE: o.run, PreRun: o.preRun, } @@ -75,8 +78,8 @@ func (o *VolumeSetupOperation) registerFlags(cmd *cobra.Command) { &cmdflag.String{ Name: "gateway-ip", Label: "Gateway IP", - Description: "The block storage gateway IP to seed into /etc/nvme/discovery.conf", - Required: true, + Description: "Optionally pre-seed /etc/nvme/discovery.conf with this gateway IP; otherwise `lsh volume mount` populates it from the API response on first mount", + Required: false, }, &cmdflag.String{ Name: "gateway-port", @@ -122,10 +125,16 @@ func (o *VolumeSetupOperation) run(cmd *cobra.Command, args []string) error { writeModprobeConfig, writeUdevRule, reloadUdev, - func() error { return writeDiscoveryConf(gatewayIP, gatewayPort) }, + } + // Pre-seed discovery.conf only if the operator passed --gateway-ip; + // otherwise `lsh volume mount` populates it from the API response. + if gatewayIP != "" { + steps = append(steps, func() error { return writeDiscoveryConf(gatewayIP, gatewayPort) }) + } + steps = append(steps, rebuildInitramfs, enableAutoconnect, - } + ) for _, step := range steps { if err := step(); err != nil { printError(err.Error()) From 1c16b30106b28cc46de18b56c77e721cdf2fd443 Mon Sep 17 00:00:00 2001 From: Marcelo Ariza Date: Thu, 14 May 2026 12:02:37 -0300 Subject: [PATCH 4/4] =?UTF-8?q?refactor(cli):=20rename=20volume=20mount=20?= =?UTF-8?q?=E2=86=92=20volume=20attach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns with the v4 API rename of /storage/volumes/:id/mount → /attach. "Attach" describes what the operation actually does — make the remote NVMe block device visible on the host — without colliding with the Linux filesystem-mount meaning of "mount". Changes: - `lsh volume mount` → `lsh volume attach` - file: cli/volume_mount_operation.go → cli/volume_attach_operation.go - struct: VolumeMountOperation → VolumeAttachOperation - factory: makeOperationVolumeMountCmd → makeOperationVolumeAttachCmd - v4 client: v4Client#MountVolume → AttachVolume; POST path /mount → /attach - all user-visible help text and runtime printStatus / printWarning strings updated to "attach" / "attached" / "attachments" Genuine references to filesystem mount(8) survive unchanged — e.g. the post-attach hint that shows the user how to `mkfs` + `mount` a filesystem on the new /dev/nvmeXn1 block device. Legacy `lsh volume mount` against v3 (Ceph) is unaffected because legacy customers stay on the v1.0.0-ceph pin. Co-Authored-By: Claude Opus 4.7 (1M context) --- cli/cli.go | 6 +-- cli/storage_v4_client.go | 12 ++--- ...peration.go => volume_attach_operation.go} | 51 ++++++++++--------- cli/volume_setup_operation.go | 14 ++--- 4 files changed, 44 insertions(+), 39 deletions(-) rename cli/{volume_mount_operation.go => volume_attach_operation.go} (90%) diff --git a/cli/cli.go b/cli/cli.go index ff31170..30baece 100755 --- a/cli/cli.go +++ b/cli/cli.go @@ -421,7 +421,7 @@ func makeOperationGroupVolumeCmd() (*cobra.Command, error) { operationGroupVolumeCmd := &cobra.Command{ Use: "volume", Short: "Manage volumes", - Long: `Commands to manage volume operations such as listing, mounting, creating, and deleting volumes`, + Long: `Commands to manage volume operations such as listing, attaching, creating, and deleting volumes`, } operationVolumeListCmd, err := makeOperationVolumeListCmd() @@ -436,11 +436,11 @@ func makeOperationGroupVolumeCmd() (*cobra.Command, error) { } operationGroupVolumeCmd.AddCommand(operationVolumeGetCmd) - operationVolumeMountCmd, err := makeOperationVolumeMountCmd() + operationVolumeAttachCmd, err := makeOperationVolumeAttachCmd() if err != nil { return nil, err } - operationGroupVolumeCmd.AddCommand(operationVolumeMountCmd) + operationGroupVolumeCmd.AddCommand(operationVolumeAttachCmd) operationVolumeCreateCmd, err := makeOperationVolumeCreateCmd() if err != nil { diff --git a/cli/storage_v4_client.go b/cli/storage_v4_client.go index 9e8d338..04a9ceb 100644 --- a/cli/storage_v4_client.go +++ b/cli/storage_v4_client.go @@ -19,7 +19,7 @@ const ( v4Timeout = 30 * time.Second ) -// V4VolumeAttributes is the v4 mount/show response shape. Fields are pointers +// V4VolumeAttributes is the v4 attach/show response shape. Fields are pointers // where they are absent on the v3 backend; the CLI requires the connection // fields to be populated and errors out otherwise. type V4VolumeAttributes struct { @@ -110,10 +110,10 @@ func (c *v4Client) GetVolume(id string) (*V4Volume, error) { return c.decodeVolume(data) } -// MountVolume authorizes the client's NQN on the storage subsystem and +// AttachVolume authorizes the client's NQN on the storage subsystem and // returns the connection info (subsystem NQN, namespace ID, gateway VIPs) // that the host needs to establish the NVMe-oF/TCP path. -func (c *v4Client) MountVolume(id, clientNQN string) (*V4Volume, error) { +func (c *v4Client) AttachVolume(id, clientNQN string) (*V4Volume, error) { body, err := json.Marshal(map[string]any{ "data": map[string]any{ "type": "volumes", @@ -123,15 +123,15 @@ func (c *v4Client) MountVolume(id, clientNQN string) (*V4Volume, error) { }, }) if err != nil { - return nil, fmt.Errorf("encode mount request: %w", err) + return nil, fmt.Errorf("encode attach request: %w", err) } - data, status, err := c.do("POST", "/storage/volumes/"+id+"/mount", body) + data, status, err := c.do("POST", "/storage/volumes/"+id+"/attach", body) if err != nil { return nil, err } if status >= 400 { - return nil, fmt.Errorf("POST /storage/volumes/%s/mount returned %d: %s", id, status, string(data)) + return nil, fmt.Errorf("POST /storage/volumes/%s/attach returned %d: %s", id, status, string(data)) } return c.decodeVolume(data) } diff --git a/cli/volume_mount_operation.go b/cli/volume_attach_operation.go similarity index 90% rename from cli/volume_mount_operation.go rename to cli/volume_attach_operation.go index cd2c039..4b637fa 100644 --- a/cli/volume_mount_operation.go +++ b/cli/volume_attach_operation.go @@ -20,8 +20,8 @@ const ( colorReset = "\033[0m" ) -func makeOperationVolumeMountCmd() (*cobra.Command, error) { - operation := VolumeMountOperation{} +func makeOperationVolumeAttachCmd() (*cobra.Command, error) { + operation := VolumeAttachOperation{} cmd, err := operation.Register() if err != nil { @@ -31,27 +31,32 @@ func makeOperationVolumeMountCmd() (*cobra.Command, error) { return cmd, nil } -type VolumeMountOperation struct { +type VolumeAttachOperation struct { PathParamFlags cmdflag.Flags OptionsFlags cmdflag.Flags } -func (o *VolumeMountOperation) Register() (*cobra.Command, error) { +func (o *VolumeAttachOperation) Register() (*cobra.Command, error) { cmd := &cobra.Command{ - Use: "mount", - Short: "Mount a volume storage to a server", - Long: `Mount a block storage volume to a server. This command will: + Use: "attach", + Short: "Attach a block storage volume to a server", + Long: `Attach a block storage volume to a server as an NVMe block device. +This command will: 1. Auto-detect the server's NQN from /etc/nvme/hostnqn (or generate a new one if the file doesn't exist) 2. Send the client NQN to the API to authorize access 3. Receive the subsystem NQN, namespace ID, and gateway VIPs back from the API 4. Seed /etc/nvme/discovery.conf with the gateway VIPs - 5. Run "nvme connect-all" to attach the volume + 5. Run "nvme connect-all" to bring the device online + +After this command succeeds, the volume appears as /dev/nvme*n1 on the host. +Formatting it (mkfs) and mounting a filesystem are separate, customer-driven +steps — this command only attaches the raw block device. This command must be run with sudo/root privileges on the target server. Example: - sudo lsh volume mount --id vol_abc123`, + sudo lsh volume attach --id vol_abc123`, RunE: o.run, PreRun: o.preRun, } @@ -61,7 +66,7 @@ Example: return cmd, nil } -func (o *VolumeMountOperation) registerFlags(cmd *cobra.Command) { +func (o *VolumeAttachOperation) registerFlags(cmd *cobra.Command) { o.PathParamFlags = cmdflag.Flags{FlagSet: cmd.Flags()} o.OptionsFlags = cmdflag.Flags{FlagSet: cmd.Flags()} @@ -69,7 +74,7 @@ func (o *VolumeMountOperation) registerFlags(cmd *cobra.Command) { &cmdflag.String{ Name: "id", Label: "Volume Storage ID", - Description: "The ID of the volume storage to mount", + Description: "The ID of the block storage volume to attach", Required: true, }, } @@ -87,7 +92,7 @@ func (o *VolumeMountOperation) registerFlags(cmd *cobra.Command) { o.OptionsFlags.Register(optionsSchema) } -func (o *VolumeMountOperation) preRun(cmd *cobra.Command, args []string) { +func (o *VolumeAttachOperation) preRun(cmd *cobra.Command, args []string) { o.PathParamFlags.PreRun(cmd, args) o.OptionsFlags.PreRun(cmd, args) } @@ -116,7 +121,7 @@ This command requires root privileges to: - Connect to NVMe-oF targets Usage: - sudo lsh volume mount --id + sudo lsh volume attach --id Note: Your API key will be automatically detected from your user config, so make sure you've logged in first: @@ -393,7 +398,7 @@ func verifyConnection(subsystemNQN string) error { return nil } -func (o *VolumeMountOperation) run(cmd *cobra.Command, args []string) error { +func (o *VolumeAttachOperation) run(cmd *cobra.Command, args []string) error { // Check if running as root if err := checkRoot(); err != nil { printError(err.Error()) @@ -402,8 +407,8 @@ func (o *VolumeMountOperation) run(cmd *cobra.Command, args []string) error { if !hostSetupApplied() { printWarning("Host is not production-configured (missing module persistence and/or multipath udev rule).") - printWarning("Run 'sudo lsh volume setup' for reboot-resilient mounts and round-robin multipath I/O.") - printWarning("Continuing with one-shot mount...") + printWarning("Run 'sudo lsh volume setup' for reboot-resilient attachments and round-robin multipath I/O.") + printWarning("Continuing with one-shot attach...") } // Get the volume ID from flags @@ -412,7 +417,7 @@ func (o *VolumeMountOperation) run(cmd *cobra.Command, args []string) error { return fmt.Errorf("error getting volume ID: %w", err) } - fmt.Fprintf(os.Stdout, "\nšŸ”§ Preparing server for volume mount...\n\n") + fmt.Fprintf(os.Stdout, "\nšŸ”§ Preparing server for volume attach...\n\n") // STEP 1: Install prerequisites (nvme-cli) BEFORE getting NQN if err := checkPrerequisites(); err != nil { @@ -434,7 +439,7 @@ func (o *VolumeMountOperation) run(cmd *cobra.Command, args []string) error { if err != nil { printError(fmt.Sprintf("Could not get or generate NQN: %v", err)) printError("\nOr provide NQN manually:") - printError(fmt.Sprintf(" sudo lsh volume mount --id %s --nqn nqn.2014-08.org.nvmexpress:uuid:YOUR-UUID", volumeID)) + printError(fmt.Sprintf(" sudo lsh volume attach --id %s --nqn nqn.2014-08.org.nvmexpress:uuid:YOUR-UUID", volumeID)) return fmt.Errorf("NQN is required but could not be obtained") } nqn = detectedNQN @@ -455,17 +460,17 @@ func (o *VolumeMountOperation) run(cmd *cobra.Command, args []string) error { return fmt.Errorf("API key not found. Please run 'lsh login ' first") } - fmt.Fprintf(os.Stdout, "\nšŸ“¦ Authorizing client and mounting volume...\n") + fmt.Fprintf(os.Stdout, "\nšŸ“¦ Authorizing client and attaching volume...\n") printStatus(fmt.Sprintf("Volume ID: %s", volumeID)) printStatus(fmt.Sprintf("Client NQN (for authorization): %s", nqn)) if lsh.Debug { - fmt.Fprintf(os.Stdout, "[DEBUG] POST /storage/volumes/%s/mount (Api-Version: %s)\n", volumeID, v4APIVersion) + fmt.Fprintf(os.Stdout, "[DEBUG] POST /storage/volumes/%s/attach (Api-Version: %s)\n", volumeID, v4APIVersion) fmt.Fprintf(os.Stdout, "[DEBUG] Request body NQN: %s\n", nqn) } apiClient := newV4Client(apiKey) - volume, err := apiClient.MountVolume(volumeID, nqn) + volume, err := apiClient.AttachVolume(volumeID, nqn) if err != nil { printError(fmt.Sprintf("API call failed: %v", err)) return err @@ -494,7 +499,7 @@ func (o *VolumeMountOperation) run(cmd *cobra.Command, args []string) error { // Seed /etc/nvme/discovery.conf with every VIP the API returned. The // helper is idempotent: if a line already exists (e.g. from a previous - // mount of another volume in the same pool, or from `lsh volume setup`), + // attach of another volume in the same pool, or from `lsh volume setup`), // it is left in place. This also ensures nvmf-autoconnect.service has // a complete discovery seed for reboot reconnection. for _, vip := range volume.Attributes.GatewayVIPs { @@ -524,7 +529,7 @@ func (o *VolumeMountOperation) run(cmd *cobra.Command, args []string) error { return err } - fmt.Fprintf(os.Stdout, "\nāœ… Volume mount complete!\n") + fmt.Fprintf(os.Stdout, "\nāœ… Volume attached!\n") fmt.Fprintf(os.Stdout, "\nConnection Summary:\n") fmt.Fprintf(os.Stdout, " Client NQN: %s\n", nqn) fmt.Fprintf(os.Stdout, " Subsystem NQN: %s\n", subsystemNQN) diff --git a/cli/volume_setup_operation.go b/cli/volume_setup_operation.go index 230f08a..20c688e 100644 --- a/cli/volume_setup_operation.go +++ b/cli/volume_setup_operation.go @@ -43,7 +43,7 @@ type VolumeSetupOperation struct { func (o *VolumeSetupOperation) Register() (*cobra.Command, error) { cmd := &cobra.Command{ Use: "setup", - Short: "Configure the host for NVMe-oF/TCP volume mounting", + Short: "Configure the host for NVMe-oF/TCP volume attachment", Long: `One-time, idempotent host configuration for NVMe-oF/TCP block storage. This command: @@ -52,9 +52,9 @@ This command: - Installs the round-robin multipath I/O udev rule - Optionally seeds /etc/nvme/discovery.conf with a gateway IP - Rebuilds initramfs (Ubuntu: update-initramfs / RHEL: dracut) - - Enables the nvmf-autoconnect service for reboot-resilient mounts + - Enables the nvmf-autoconnect service for reboot-resilient attachments -Run once per server. After this, "lsh volume mount --id " populates +Run once per server. After this, "lsh volume attach --id " populates /etc/nvme/discovery.conf with the gateway VIPs returned by the API and the volume reconnects automatically after reboot with round-robin multipath I/O. @@ -78,7 +78,7 @@ func (o *VolumeSetupOperation) registerFlags(cmd *cobra.Command) { &cmdflag.String{ Name: "gateway-ip", Label: "Gateway IP", - Description: "Optionally pre-seed /etc/nvme/discovery.conf with this gateway IP; otherwise `lsh volume mount` populates it from the API response on first mount", + Description: "Optionally pre-seed /etc/nvme/discovery.conf with this gateway IP; otherwise `lsh volume attach` populates it from the API response on first attach", Required: false, }, &cmdflag.String{ @@ -113,7 +113,7 @@ func (o *VolumeSetupOperation) run(cmd *cobra.Command, args []string) error { return nil } - fmt.Fprintf(os.Stdout, "\nšŸ”§ Configuring host for NVMe-oF/TCP volume mounting...\n\n") + fmt.Fprintf(os.Stdout, "\nšŸ”§ Configuring host for NVMe-oF/TCP volume attachment...\n\n") if err := checkPrerequisites(); err != nil { printError(err.Error()) @@ -127,7 +127,7 @@ func (o *VolumeSetupOperation) run(cmd *cobra.Command, args []string) error { reloadUdev, } // Pre-seed discovery.conf only if the operator passed --gateway-ip; - // otherwise `lsh volume mount` populates it from the API response. + // otherwise `lsh volume attach` populates it from the API response. if gatewayIP != "" { steps = append(steps, func() error { return writeDiscoveryConf(gatewayIP, gatewayPort) }) } @@ -143,7 +143,7 @@ func (o *VolumeSetupOperation) run(cmd *cobra.Command, args []string) error { } fmt.Fprintf(os.Stdout, "\nāœ… Host setup complete.\n") - fmt.Fprintf(os.Stdout, "\nNext step: sudo lsh volume mount --id \n") + fmt.Fprintf(os.Stdout, "\nNext step: sudo lsh volume attach --id \n") return nil }