├── debian ├── compat ├── source │ └── format ├── install ├── control ├── rules └── copyright ├── client ├── nfs_test.go ├── status.go ├── options.go ├── nfs.go ├── iscsi_test.go ├── iscsi.go ├── nvmeof.go ├── client_test.go └── client.go ├── integration-tests ├── tests │ ├── gatewaytest.py │ ├── healthcheck.py │ ├── create-same-name.py │ ├── nfs-create.py │ ├── create-same-ip.py │ ├── iscsi-create-scst.py │ ├── nvme-create.py │ └── iscsi-create-lio-t.py ├── virter │ ├── roles │ │ ├── linstor-gateway │ │ │ ├── defaults │ │ │ │ └── main.yml │ │ │ └── tasks │ │ │ │ └── main.yml │ │ ├── linstor-cluster │ │ │ ├── defaults │ │ │ │ └── main.yml │ │ │ ├── templates │ │ │ │ └── linstor-client.conf.j2 │ │ │ ├── files │ │ │ │ ├── lvm.conf │ │ │ │ └── linstor_satellite.toml │ │ │ ├── handlers │ │ │ │ └── main.yml │ │ │ └── tasks │ │ │ │ └── main.yml │ │ ├── linstor-storage-pool │ │ │ ├── defaults │ │ │ │ └── main.yml │ │ │ └── tasks │ │ │ │ └── main.yml │ │ ├── drbd-reactor │ │ │ ├── handlers │ │ │ │ └── main.yml │ │ │ └── tasks │ │ │ │ └── main.yml │ │ └── register-linstor-satellite │ │ │ └── tasks │ │ │ └── main.yml │ ├── vms.toml │ ├── tests.toml │ ├── provision-test.toml │ ├── provision-playbook.yml │ ├── provision-base.toml │ ├── run.toml │ └── inventory │ │ └── virter-dyn-inventory ├── Dockerfile ├── entry.sh └── Makefile ├── docs ├── Linstor-Logo.png ├── migrating.md ├── md │ ├── linstor-gateway_nvme_list.md │ ├── linstor-gateway_nvme_stop.md │ ├── linstor-gateway_nvme_start.md │ ├── linstor-gateway_version.md │ ├── linstor-gateway_nvme_delete.md │ ├── linstor-gateway_docs.md │ ├── linstor-gateway_nvme_delete-volume.md │ ├── linstor-gateway_nvme_add-volume.md │ ├── linstor-gateway_iscsi_add-volume.md │ ├── linstor-gateway_iscsi_delete-volume.md │ ├── linstor-gateway_iscsi_start.md │ ├── linstor-gateway_nfs_list.md │ ├── linstor-gateway_iscsi_stop.md │ ├── linstor-gateway_iscsi_list.md │ ├── linstor-gateway_completion.md │ ├── linstor-gateway_nfs_delete.md │ ├── linstor-gateway_nfs_upgrade.md │ ├── linstor-gateway_nvme_upgrade.md │ ├── linstor-gateway_iscsi_upgrade.md │ ├── linstor-gateway_iscsi_delete.md │ ├── linstor-gateway_nvme_create.md │ ├── linstor-gateway_server.md │ ├── linstor-gateway.md │ ├── linstor-gateway_nfs.md │ ├── linstor-gateway_nvme.md │ ├── linstor-gateway_iscsi.md │ ├── linstor-gateway_iscsi_create.md │ ├── linstor-gateway_check-health.md │ └── linstor-gateway_nfs_create.md └── config.md ├── main.go ├── .gitlab └── dependabot.yml ├── .gitignore ├── linstor-gateway.service ├── pkg ├── common │ ├── validation.go │ ├── net.go │ ├── resource_config.go │ └── resource.go ├── version │ └── version.go ├── reactor │ ├── service.go │ ├── service_test.go │ ├── reactor_test.go │ ├── resourceagent.go │ └── resourceagent_test.go ├── upgrade │ ├── common.go │ ├── iscsi.go │ ├── nvmeof.go │ ├── drbd.go │ ├── upgrade.go │ └── nfs.go ├── rest │ ├── api_status.go │ ├── nfs_list.go │ ├── iscsi_list.go │ ├── nvmeof_list.go │ ├── nfs_stop.go │ ├── nfs_start.go │ ├── iscsi_start.go │ ├── iscsi_stop.go │ ├── nvmeof_create.go │ ├── nvmeof_stop.go │ ├── nvmeof_start.go │ ├── nfs_create.go │ ├── iscsi_create.go │ ├── nfs_delete.go │ ├── nfs_get.go │ ├── iscsi_delete.go │ ├── iscsi_get.go │ ├── nvmeof_get.go │ ├── nvmeof_delete.go │ ├── iscsi_add_volume.go │ ├── nvmeof_add_volume.go │ ├── server.go │ └── routes.go ├── prompt │ └── confirm.go ├── healthcheck │ ├── client.go │ ├── reactor.go │ ├── linstor.go │ └── healthcheck.go ├── nvmeof │ ├── nqn.go │ └── nvmeof_test.go ├── iscsi │ ├── iqn_test.go │ └── iqn.go └── linstorcontrol │ └── linstorcontrol_test.go ├── linstor-gateway.xml ├── cmd ├── version.go ├── completion.go ├── colors.go ├── docs.go ├── server.go ├── check_health.go └── root.go ├── scripts └── release.sh ├── .github └── workflows │ └── go.yml ├── go.mod ├── Makefile ├── README.md └── linstor-gateway.spec /debian/compat: -------------------------------------------------------------------------------- 1 | 9 2 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /client/nfs_test.go: -------------------------------------------------------------------------------- 1 | package client_test 2 | -------------------------------------------------------------------------------- /integration-tests/tests/gatewaytest.py: -------------------------------------------------------------------------------- 1 | ../gatewaytest.py -------------------------------------------------------------------------------- /debian/install: -------------------------------------------------------------------------------- 1 | linstor-gateway usr/sbin/ 2 | linstor-gateway.service usr/lib/systemd/system/ -------------------------------------------------------------------------------- /integration-tests/virter/roles/linstor-gateway/defaults/main.yml: -------------------------------------------------------------------------------- 1 | linstor_gateway_version: "*" 2 | -------------------------------------------------------------------------------- /docs/Linstor-Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LINBIT/linstor-gateway/HEAD/docs/Linstor-Logo.png -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/LINBIT/linstor-gateway/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /integration-tests/virter/roles/linstor-cluster/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | linstor_controller_hosts: "{{ groups['linstor_controller'] }}" 3 | -------------------------------------------------------------------------------- /.gitlab/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" -------------------------------------------------------------------------------- /integration-tests/virter/roles/linstor-cluster/templates/linstor-client.conf.j2: -------------------------------------------------------------------------------- 1 | [global] 2 | controllers={{ linstor_controller_hosts | join(',') }} 3 | -------------------------------------------------------------------------------- /integration-tests/virter/roles/linstor-cluster/files/lvm.conf: -------------------------------------------------------------------------------- 1 | devices { 2 | # Disable scanning drbd devices 3 | global_filter = [ "r|^/dev/drbd.*|" ] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /linstor-gateway 2 | /linstor-iscsi 3 | /linstor-nfs 4 | *.tar.gz 5 | /tests-out/ 6 | *.rpm 7 | *.deb 8 | /vendor/ 9 | /.idea/ 10 | version.env 11 | /sbom/ 12 | -------------------------------------------------------------------------------- /integration-tests/virter/roles/linstor-cluster/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Restart satellite 4 | service: 5 | name: linstor-satellite 6 | state: restarted 7 | -------------------------------------------------------------------------------- /integration-tests/virter/vms.toml: -------------------------------------------------------------------------------- 1 | name = "t" 2 | provision_file = "provision-test.toml" 3 | 4 | [[vms]] 5 | base_image = "linstor-gateway-tests--base-alma-9" 6 | vcpus = 2 7 | memory = "4G" 8 | -------------------------------------------------------------------------------- /integration-tests/virter/roles/linstor-cluster/files/linstor_satellite.toml: -------------------------------------------------------------------------------- 1 | [files] 2 | allowExtFiles = ["/etc/systemd/system", "/etc/systemd/system/linstor-satellite.service.d", "/etc/drbd-reactor.d"] -------------------------------------------------------------------------------- /integration-tests/virter/roles/linstor-storage-pool/defaults/main.yml: -------------------------------------------------------------------------------- 1 | pool_name: default 2 | pool_type: lvm 3 | thin_size: "100%VG" 4 | vg_name: "linstor_{{ pool_name }}" 5 | thinpool_name: "{{ pool_name }}" 6 | -------------------------------------------------------------------------------- /linstor-gateway.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=LINSTOR Gateway 3 | After=network.target 4 | 5 | [Service] 6 | ExecStart=/usr/sbin/linstor-gateway server 7 | 8 | [Install] 9 | WantedBy=multi-user.target 10 | -------------------------------------------------------------------------------- /pkg/common/validation.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "fmt" 4 | 5 | type ValidationError string 6 | 7 | func (v ValidationError) Error() string { 8 | return fmt.Sprintf("invalid config: %s", string(v)) 9 | } 10 | -------------------------------------------------------------------------------- /integration-tests/tests/healthcheck.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | import gatewaytest 4 | 5 | nodes = gatewaytest.setup() 6 | 7 | first = nodes[0] 8 | first.start_server() 9 | first.run(['linstor-gateway', 'check-health']) 10 | 11 | nodes.cleanup() 12 | -------------------------------------------------------------------------------- /linstor-gateway.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | LINSTOR Gateway 4 | A utility to expose LINSTOR storage via iSCSI, NFS, and NVMe 5 | 6 | 7 | -------------------------------------------------------------------------------- /integration-tests/virter/roles/drbd-reactor/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Reload drbd-reactor 3 | systemd: 4 | service: drbd-reactor.service 5 | state: restarted 6 | 7 | - name: Reload drbd-reactor reloader 8 | systemd: 9 | service: drbd-reactor-reload.path 10 | state: restarted 11 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import "fmt" 4 | 5 | // (potentially) set by makefile 6 | var ( 7 | Version = "unknown" 8 | GitCommit = "unknown" 9 | BuildDate = "unknown" 10 | ) 11 | 12 | func UserAgent() string { 13 | return fmt.Sprintf("linstor-gateway/%s-g%s", Version, GitCommit) 14 | } 15 | -------------------------------------------------------------------------------- /integration-tests/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine 2 | 3 | RUN apk update && \ 4 | apk add --no-cache openssh bash python3 py3-pip py3-requests py3-setuptools 5 | 6 | RUN pip install --break-system-packages lbpytest python-linstor 7 | 8 | COPY entry.sh gatewaytest.py / 9 | COPY tests /tests 10 | 11 | WORKDIR / 12 | 13 | ENTRYPOINT /entry.sh 14 | -------------------------------------------------------------------------------- /client/status.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type StatusService struct { 8 | client *Client 9 | } 10 | 11 | type Status struct { 12 | Status string `json:"status"` 13 | } 14 | 15 | func (s *StatusService) Get(ctx context.Context) (*Status, error) { 16 | var status *Status 17 | _, err := s.client.doGET(ctx, "/api/v2/status", &status) 18 | return status, err 19 | } 20 | -------------------------------------------------------------------------------- /integration-tests/entry.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ssh-keygen -f /root/.ssh/id_rsa -y >/root/.ssh/id_rsa.pub 4 | 5 | sorted_targets=$(echo "$TARGETS" | tr ',' '\n' | sort) 6 | nodes=() 7 | for target in $sorted_targets; do 8 | host=$(ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null $target hostname) 9 | nodes+=("$host") 10 | done 11 | 12 | tests/${TEST_NAME}.py --logdir /log ${nodes[@]} 13 | -------------------------------------------------------------------------------- /integration-tests/virter/tests.toml: -------------------------------------------------------------------------------- 1 | test_suite_file = "run.toml" 2 | 3 | [tests] 4 | [tests.iscsi-create-lio-t] 5 | vms = [3] 6 | 7 | [tests.iscsi-create-scst] 8 | vms = [3] 9 | 10 | [tests.nfs-create] 11 | vms = [3] 12 | 13 | [tests.nvme-create] 14 | vms = [3] 15 | 16 | [tests.healthcheck] 17 | vms = [1] 18 | 19 | [tests.create-same-name] 20 | vms = [3] 21 | 22 | [tests.create-same-ip] 23 | vms = [3] 24 | -------------------------------------------------------------------------------- /integration-tests/virter/roles/register-linstor-satellite/tasks/main.yml: -------------------------------------------------------------------------------- 1 | - name: Check satellite registration 2 | command: "linstor --machine --output-version v1 node list --nodes {{ inventory_hostname }}" 3 | register: node_list_output 4 | changed_when: no 5 | tags: 6 | - run 7 | 8 | - name: Register satellite 9 | command: "linstor node create {{ inventory_hostname }}" 10 | when: 'not (node_list_output.stdout | from_json)[0]' 11 | tags: 12 | - run 13 | -------------------------------------------------------------------------------- /docs/migrating.md: -------------------------------------------------------------------------------- 1 | # Migrating LINSTOR Gateway 2 | 3 | ## From v1 to v2 4 | 5 | In version 2.x, the default REST API port was changed from `8080` to `8337`. 6 | 7 | If you are upgrading from LINSTOR Gateway v1.x to v2.x, please ensure that you update any configurations, 8 | firewall rules, or scripts that reference the old port `8080` to the new default port `8337`. 9 | 10 | The CLI and Go client have been updated to use the new default port, so no action is necessary if you 11 | don't use the API directly. -------------------------------------------------------------------------------- /pkg/reactor/service.go: -------------------------------------------------------------------------------- 1 | package reactor 2 | 3 | // SystemdService is an entry within a drbd-reactor config that describes a 4 | // systemd service. 5 | // It is very simple: the whole text is just the systemd service name. 6 | type SystemdService struct { 7 | Name string 8 | } 9 | 10 | func (s *SystemdService) MarshalText() (text []byte, err error) { 11 | return []byte(s.Name), nil 12 | } 13 | 14 | func (s *SystemdService) UnmarshalText(text []byte) error { 15 | s.Name = string(text) 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /integration-tests/virter/provision-test.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [values] 4 | LinstorGatewayVersion = "*" 5 | 6 | [[steps]] 7 | [steps.container] 8 | image = "quay.io/ansible/ansible-runner:stable-2.9-devel" 9 | command = [ 10 | "ansible-playbook", 11 | "--inventory", "/virter/workspace/virter/inventory", 12 | "/virter/workspace/virter/provision-playbook.yml", 13 | "--extra-vars", "linstor_gateway_version={{ .LinstorGatewayVersion }}", 14 | "--tags", "testimage" 15 | ] 16 | [steps.container.env] 17 | ANSIBLE_PIPELINING = "yes" 18 | -------------------------------------------------------------------------------- /integration-tests/virter/provision-playbook.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Setup LINSTOR cluster 3 | hosts: all 4 | roles: 5 | - role: linstor-cluster 6 | - role: register-linstor-satellite 7 | - role: linstor-storage-pool 8 | pool_name: thinpool 9 | vg_name: linstor_vg 10 | devices: 11 | - "{{ linstor_device_path }}" 12 | pool_type: lvmthin 13 | thin_size: "100%FREE" 14 | thinpool_name: thinpool 15 | - role: linstor-gateway 16 | - role: drbd-reactor 17 | configure_autoreload: yes 18 | -------------------------------------------------------------------------------- /pkg/upgrade/common.go: -------------------------------------------------------------------------------- 1 | package upgrade 2 | 3 | import "github.com/LINBIT/linstor-gateway/pkg/reactor" 4 | 5 | // removeID unsets the ID field of the promoter config. 6 | // Starting with drbd-reactor v1.2.0, configs no longer require an ID. It 7 | // prints a deprecation warning when it is used, so remove the field. 8 | func removeID(cfg *reactor.PromoterConfig) error { 9 | cfg.ID = "" 10 | return nil 11 | } 12 | 13 | func firstResourceId(cfg *reactor.PromoterConfig) string { 14 | for k := range cfg.Resources { 15 | return k 16 | } 17 | return "" 18 | } 19 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/LINBIT/linstor-gateway/pkg/version" 9 | ) 10 | 11 | func versionCommand() *cobra.Command { 12 | return &cobra.Command{ 13 | Use: "version", 14 | Short: "Print version information of LINSTOR Gateway", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | fmt.Printf("LINSTOR Gateway version %s\n", version.Version) 17 | fmt.Printf("Built at %s\n", version.BuildDate) 18 | fmt.Printf("Version control hash: %s\n", version.GitCommit) 19 | }, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /pkg/rest/api_status.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // HealthCheck used for checking HTTP APIs are available or not 11 | func (s *server) APIStatus() http.HandlerFunc { 12 | return func(w http.ResponseWriter, r *http.Request) { 13 | status := map[string]string{ 14 | "status": "ok", 15 | } 16 | 17 | w.WriteHeader(http.StatusOK) 18 | enc := json.NewEncoder(w) 19 | 20 | err := enc.Encode(status) 21 | if err != nil { 22 | log.WithError(err).Warn("failed to write response") 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pkg/rest/nfs_list.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | func (s *server) NFSList() http.HandlerFunc { 11 | return func(w http.ResponseWriter, r *http.Request) { 12 | targets, err := s.nfs.List(r.Context()) 13 | if err != nil { 14 | MustError(http.StatusInternalServerError, w, "Could not list exports: %v", err) 15 | return 16 | } 17 | 18 | w.WriteHeader(http.StatusOK) 19 | enc := json.NewEncoder(w) 20 | 21 | err = enc.Encode(targets) 22 | if err != nil { 23 | log.WithError(err).Warn("failed to write response") 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pkg/prompt/confirm.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "github.com/sirupsen/logrus" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | // Confirm displays a prompt `s` to the user and returns true if the user confirmed, 12 | // false if not. 13 | // If the lower cased, trimmed input is equal to 'y', that is considered to be 14 | // a confirmation. Any other input value will return false. 15 | func Confirm(s string) bool { 16 | r := bufio.NewReader(os.Stdin) 17 | 18 | fmt.Printf("%s [y/N]: ", s) 19 | 20 | res, err := r.ReadString('\n') 21 | if err != nil { 22 | logrus.Error(err) 23 | return false 24 | } 25 | 26 | return strings.ToLower(strings.TrimSpace(res)) == "y" 27 | } 28 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: linstor-gateway 2 | Section: admin 3 | Priority: optional 4 | Maintainer: Christoph Böhmwalder 5 | Build-Depends: debhelper (>= 9) 6 | Standards-Version: 3.9.8 7 | Homepage: https://github.com/LINBIT/linstor-gateway 8 | Vcs-Git: https://github.com/LINBIT/linstor-gateway.git 9 | Vcs-Browser: https://github.com/LINBIT/linstor-gateway 10 | 11 | Package: linstor-gateway 12 | Architecture: amd64 13 | Depends: ${shlibs:Depends}, ${misc:Depends} 14 | Description: Exposes highly available LINSTOR storage via iSCSI, NFS, or NVMe-OF 15 | LINSTOR Gateway manages highly available iSCSI targets, NFS exports, and 16 | NVMe-oF targets by leveraging LINSTOR and drbd-reactor. 17 | -------------------------------------------------------------------------------- /docs/md/linstor-gateway_nvme_list.md: -------------------------------------------------------------------------------- 1 | ## linstor-gateway nvme list 2 | 3 | list configured NVMe-oF targets 4 | 5 | ``` 6 | linstor-gateway nvme list [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for list 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --config string Config file to load (default "/etc/linstor-gateway/linstor-gateway.toml") 19 | -c, --connect string LINSTOR Gateway server to connect to (default "http://localhost:8337") 20 | --loglevel string Set the log level (as defined by logrus) (default "info") 21 | ``` 22 | 23 | ### SEE ALSO 24 | 25 | * [linstor-gateway nvme](linstor-gateway_nvme.md) - Manages Highly-Available NVME targets 26 | 27 | -------------------------------------------------------------------------------- /docs/md/linstor-gateway_nvme_stop.md: -------------------------------------------------------------------------------- 1 | ## linstor-gateway nvme stop 2 | 3 | Stop a started NVMe-oF target 4 | 5 | ``` 6 | linstor-gateway nvme stop NQN... [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for stop 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --config string Config file to load (default "/etc/linstor-gateway/linstor-gateway.toml") 19 | -c, --connect string LINSTOR Gateway server to connect to (default "http://localhost:8337") 20 | --loglevel string Set the log level (as defined by logrus) (default "info") 21 | ``` 22 | 23 | ### SEE ALSO 24 | 25 | * [linstor-gateway nvme](linstor-gateway_nvme.md) - Manages Highly-Available NVME targets 26 | 27 | -------------------------------------------------------------------------------- /docs/md/linstor-gateway_nvme_start.md: -------------------------------------------------------------------------------- 1 | ## linstor-gateway nvme start 2 | 3 | Start a stopped NVMe-oF target 4 | 5 | ``` 6 | linstor-gateway nvme start NQN... [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for start 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --config string Config file to load (default "/etc/linstor-gateway/linstor-gateway.toml") 19 | -c, --connect string LINSTOR Gateway server to connect to (default "http://localhost:8337") 20 | --loglevel string Set the log level (as defined by logrus) (default "info") 21 | ``` 22 | 23 | ### SEE ALSO 24 | 25 | * [linstor-gateway nvme](linstor-gateway_nvme.md) - Manages Highly-Available NVME targets 26 | 27 | -------------------------------------------------------------------------------- /cmd/completion.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | func completionCommand(dst *cobra.Command) *cobra.Command { 10 | var completionCmd = &cobra.Command{ 11 | Use: "completion", 12 | Short: "Generates bash completion script", 13 | Long: `To load completion run 14 | 15 | . <(linstor-gateway completion) 16 | 17 | To configure your bash shell to load completions for each session add to your bashrc 18 | 19 | # ~/.bashrc or ~/.profile 20 | . <(linstor-gateway completion)`, 21 | Run: func(cmd *cobra.Command, args []string) { 22 | dst.GenBashCompletion(os.Stdout) 23 | }, 24 | } 25 | 26 | completionCmd.ResetCommands() 27 | completionCmd.DisableAutoGenTag = true 28 | 29 | return completionCmd 30 | } 31 | -------------------------------------------------------------------------------- /pkg/rest/iscsi_list.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | func (s *server) ISCSIList() http.HandlerFunc { 11 | return func(w http.ResponseWriter, r *http.Request) { 12 | targets, err := s.iscsi.List(r.Context()) 13 | if err != nil { 14 | MustError(http.StatusInternalServerError, w, "Could not list targets: %v", err) 15 | return 16 | } 17 | 18 | for i := range targets { 19 | targets[i].Username = "" 20 | targets[i].Password = "" 21 | } 22 | 23 | w.WriteHeader(http.StatusOK) 24 | enc := json.NewEncoder(w) 25 | 26 | err = enc.Encode(targets) 27 | if err != nil { 28 | log.WithError(err).Warn("failed to write response") 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | # See debhelper(7) (uncomment to enable) 3 | # output every command that modifies files on the build system. 4 | #export DH_VERBOSE = 1 5 | 6 | 7 | # see FEATURE AREAS in dpkg-buildflags(1) 8 | #export DEB_BUILD_MAINT_OPTIONS = hardening=+all 9 | 10 | # see ENVIRONMENT in dpkg-buildflags(1) 11 | # package maintainers to append CFLAGS 12 | #export DEB_CFLAGS_MAINT_APPEND = -Wall -pedantic 13 | # package maintainers to append LDFLAGS 14 | #export DEB_LDFLAGS_MAINT_APPEND = -Wl,--as-needed 15 | 16 | 17 | %: 18 | dh $@ 19 | 20 | 21 | # dh_make generated override targets 22 | # This is example for Cmake (See https://bugs.debian.org/641051 ) 23 | #override_dh_auto_configure: 24 | # dh_auto_configure -- # -DCMAKE_LIBRARY_PATH=$(DEB_HOST_MULTIARCH) 25 | 26 | -------------------------------------------------------------------------------- /docs/md/linstor-gateway_version.md: -------------------------------------------------------------------------------- 1 | ## linstor-gateway version 2 | 3 | Print version information of LINSTOR Gateway 4 | 5 | ``` 6 | linstor-gateway version [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for version 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --config string Config file to load (default "/etc/linstor-gateway/linstor-gateway.toml") 19 | -c, --connect string LINSTOR Gateway server to connect to (default "http://localhost:8337") 20 | --loglevel string Set the log level (as defined by logrus) (default "info") 21 | ``` 22 | 23 | ### SEE ALSO 24 | 25 | * [linstor-gateway](linstor-gateway.md) - Manage linstor-gateway targets and exports 26 | 27 | ###### Auto generated by spf13/cobra on 4-Dec-2025 28 | -------------------------------------------------------------------------------- /integration-tests/virter/provision-base.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [values] 4 | KernelVersion = "5.14.0-503.11.1.el9_5.x86_64" 5 | RhelMajorVersion = "9" 6 | LinbitRepoBaseURL = "" 7 | CIRepoBaseURL = "" 8 | 9 | [[steps]] 10 | [steps.container] 11 | image = "quay.io/ansible/ansible-runner:stable-2.9-devel" 12 | command = [ 13 | "ansible-playbook", 14 | "--inventory", "/virter/workspace/virter/inventory", 15 | "/virter/workspace/virter/provision-playbook.yml", 16 | "--extra-vars", "kernel_version={{ .KernelVersion }}", 17 | "--extra-vars", "rhel_major_version={{ .RhelMajorVersion }}", 18 | "--extra-vars", "linbit_repo_baseurl={{ .LinbitRepoBaseURL }}", 19 | "--extra-vars", "ci_repo_baseurl={{ .CIRepoBaseURL }}", 20 | "--tags", "preload" 21 | ] 22 | [steps.container.env] 23 | ANSIBLE_PIPELINING = "yes" 24 | -------------------------------------------------------------------------------- /integration-tests/tests/create-same-name.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from subprocess import CalledProcessError 3 | 4 | import gatewaytest 5 | 6 | nodes = gatewaytest.setup() 7 | 8 | first = nodes[0] 9 | first.start_server() 10 | service_ip = nodes.get_service_ip() 11 | other_service_ip = nodes.get_service_ip() 12 | 13 | first.run([ 14 | 'linstor-gateway', 'iscsi', 'create', '--implementation=scst', 'iqn.2019-08.com.linbit:target1', 15 | service_ip, '1G' 16 | ]) 17 | 18 | try: 19 | first.run([ 20 | 'linstor-gateway', 'nvme', 'create', 'nqn.2021-08.com.linbit:nvme:target1', 21 | other_service_ip, '1G' 22 | ]) 23 | except CalledProcessError: 24 | print("command threw CalledProcessError, that was expected") 25 | except BaseException: 26 | raise 27 | 28 | nodes.cleanup() 29 | -------------------------------------------------------------------------------- /docs/md/linstor-gateway_nvme_delete.md: -------------------------------------------------------------------------------- 1 | ## linstor-gateway nvme delete 2 | 3 | Delete existing NVMe-oF targets 4 | 5 | ``` 6 | linstor-gateway nvme delete NQN... [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -f, --force Delete without prompting for confirmation 13 | -h, --help help for delete 14 | ``` 15 | 16 | ### Options inherited from parent commands 17 | 18 | ``` 19 | --config string Config file to load (default "/etc/linstor-gateway/linstor-gateway.toml") 20 | -c, --connect string LINSTOR Gateway server to connect to (default "http://localhost:8337") 21 | --loglevel string Set the log level (as defined by logrus) (default "info") 22 | ``` 23 | 24 | ### SEE ALSO 25 | 26 | * [linstor-gateway nvme](linstor-gateway_nvme.md) - Manages Highly-Available NVME targets 27 | 28 | -------------------------------------------------------------------------------- /docs/md/linstor-gateway_docs.md: -------------------------------------------------------------------------------- 1 | ## linstor-gateway docs 2 | 3 | Generate linstor-gateway documentation 4 | 5 | ``` 6 | linstor-gateway docs [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | --format strings Generate documentation in the given format (md,man) (default [md]) 13 | -h, --help help for docs 14 | ``` 15 | 16 | ### Options inherited from parent commands 17 | 18 | ``` 19 | --config string Config file to load (default "/etc/linstor-gateway/linstor-gateway.toml") 20 | -c, --connect string LINSTOR Gateway server to connect to (default "http://localhost:8337") 21 | --loglevel string Set the log level (as defined by logrus) (default "info") 22 | ``` 23 | 24 | ### SEE ALSO 25 | 26 | * [linstor-gateway](linstor-gateway.md) - Manage linstor-gateway targets and exports 27 | 28 | -------------------------------------------------------------------------------- /integration-tests/virter/run.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [values] 4 | LinstorGatewayVersion = "*" 5 | LinstorDevicePath = "/dev/sda" 6 | TestSuiteImage = "linstor-gateway-e2e" 7 | OutDir = "" 8 | 9 | [[steps]] 10 | [steps.container] 11 | image = "quay.io/ansible/ansible-runner:stable-2.9-devel" 12 | command = [ 13 | "ansible-playbook", 14 | "--inventory", "/virter/workspace/virter/inventory", 15 | "/virter/workspace/virter/provision-playbook.yml", 16 | "--extra-vars", "linstor_gateway_version={{ .LinstorGatewayVersion }}", 17 | "--extra-vars", "linstor_device_path={{ .LinstorDevicePath }}", 18 | "--tags", "run", 19 | ] 20 | [steps.container.env] 21 | ANSIBLE_PIPELINING = "yes" 22 | 23 | [[steps]] 24 | [steps.container] 25 | image = "{{.TestSuiteImage}}" 26 | [steps.container.copy] 27 | source = "/log" 28 | dest = "{{.OutDir}}" 29 | -------------------------------------------------------------------------------- /pkg/rest/nvmeof_list.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | func (s *server) NVMeoFList() func(http.ResponseWriter, *http.Request) { 11 | return func(writer http.ResponseWriter, request *http.Request) { 12 | ctx := request.Context() 13 | 14 | cfgs, err := s.nvmeof.List(ctx) 15 | if err != nil { 16 | _, err := Errorf(http.StatusInternalServerError, writer, "nvmeof list failed: %v", err) 17 | if err != nil { 18 | log.WithError(err).Warn("failed to write error response") 19 | } 20 | return 21 | } 22 | 23 | writer.WriteHeader(http.StatusOK) 24 | enc := json.NewEncoder(writer) 25 | 26 | err = enc.Encode(cfgs) 27 | if err != nil { 28 | log.WithError(err).Warn("failed to write response") 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /integration-tests/tests/nfs-create.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | import gatewaytest 4 | 5 | nodes = gatewaytest.setup() 6 | 7 | first = nodes[0] 8 | first.start_server() 9 | service_ip = nodes.get_service_ip() 10 | 11 | first.run(['linstor-gateway', 'nfs', 'create', 'nfs1', service_ip, '1G']) 12 | first.assert_resource_exists('nfs', 'nfs1') 13 | 14 | ls = gatewaytest.LinstorConnection(first) 15 | 16 | active_node = ls.wait_for_resource_active('nfs1') 17 | gatewaytest.log('Resource nfs1 active on node {}'.format(active_node)) 18 | ls.wait_inuse_stable('nfs1', active_node) 19 | gatewaytest.log('Resource nfs1 stably in use on node {}'.format(active_node)) 20 | 21 | first.run(['linstor-gateway', 'nfs', 'delete', '--force', 'nfs1']) 22 | first.assert_resource_not_exists('nfs', 'nfs1') 23 | 24 | assert not ls.resource_exists('nfs1') 25 | 26 | ls.disconnect() 27 | nodes.cleanup() 28 | -------------------------------------------------------------------------------- /integration-tests/tests/create-same-ip.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # create-same-ip 3 | # - Create an iSCSI resource 4 | # - Try to create an NVMe resource with the same IP address (should fail) 5 | from subprocess import CalledProcessError 6 | 7 | import gatewaytest 8 | 9 | nodes = gatewaytest.setup() 10 | 11 | first = nodes[0] 12 | first.start_server() 13 | service_ip = nodes.get_service_ip() 14 | 15 | first.run([ 16 | 'linstor-gateway', 'iscsi', 'create', '--implementation=scst', 'iqn.2019-08.com.linbit:iscsi1', 17 | service_ip, '1G' 18 | ]) 19 | 20 | try: 21 | first.run([ 22 | 'linstor-gateway', 'nvme', 'create', 'nqn.2021-08.com.linbit:nvme:nvme1', 23 | service_ip, '1G' 24 | ]) 25 | except CalledProcessError: 26 | print("command threw CalledProcessError, that was expected") 27 | except BaseException: 28 | raise 29 | 30 | nodes.cleanup() 31 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | die() { 6 | echo >&2 "$1" 7 | exit 1 8 | } 9 | 10 | version=$1 11 | [ -z "$version" ] && die "Usage: $0 " 12 | 13 | export EMAIL="$(git config --get user.email)" 14 | export NAME="$(git config --get user.name)" 15 | 16 | version_and_release="${version}-1" 17 | 18 | rpmdev-bumpspec -n "$version_and_release" \ 19 | -c "New upstream release" \ 20 | -u "$NAME <$EMAIL>" \ 21 | linstor-gateway.spec 22 | 23 | dch -v "$version_and_release" \ 24 | -u "medium" \ 25 | "New upstream release" \ 26 | && dch -r "" 27 | 28 | date=$(date -u +"%Y-%m-%d") 29 | sed -i '9r /dev/stdin' CHANGELOG.md <:nvme:. 8 | 9 | ``` 10 | linstor-gateway nvme create NQN SERVICE_IP VOLUME_SIZE [VOLUME_SIZE]... [flags] 11 | ``` 12 | 13 | ### Examples 14 | 15 | ``` 16 | linstor-gateway nvme create linbit:nvme:example 17 | ``` 18 | 19 | ### Options 20 | 21 | ``` 22 | --gross Make all size options specify gross size, i.e. the actual space used on disk 23 | -h, --help help for create 24 | -r, --resource-group string resource group to use. (default "DfltRscGrp") 25 | ``` 26 | 27 | ### Options inherited from parent commands 28 | 29 | ``` 30 | --config string Config file to load (default "/etc/linstor-gateway/linstor-gateway.toml") 31 | -c, --connect string LINSTOR Gateway server to connect to (default "http://localhost:8337") 32 | --loglevel string Set the log level (as defined by logrus) (default "info") 33 | ``` 34 | 35 | ### SEE ALSO 36 | 37 | * [linstor-gateway nvme](linstor-gateway_nvme.md) - Manages Highly-Available NVME targets 38 | 39 | -------------------------------------------------------------------------------- /cmd/docs.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "path" 6 | 7 | log "github.com/sirupsen/logrus" 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/cobra/doc" 10 | ) 11 | 12 | func docsCommand(dst *cobra.Command) *cobra.Command { 13 | var format []string 14 | 15 | var docsCmd = &cobra.Command{ 16 | Use: "docs", 17 | Short: "Generate linstor-gateway documentation", 18 | Run: func(cmd *cobra.Command, args []string) { 19 | 20 | for _, f := range format { 21 | dir := path.Join("./docs", f) 22 | _ = os.Mkdir(dir, 0755) // we don't care, if this fails, the next one fails 23 | switch f { 24 | case "man": 25 | header := &doc.GenManHeader{ 26 | Title: "linstor-gateway", 27 | Section: "3", 28 | } 29 | if err := doc.GenManTree(dst, header, dir); err != nil { 30 | log.Fatal(err) 31 | } 32 | case "md": 33 | if err := doc.GenMarkdownTree(dst, dir); err != nil { 34 | log.Fatal(err) 35 | } 36 | } 37 | } 38 | }, 39 | } 40 | 41 | docsCmd.ResetCommands() 42 | docsCmd.Flags().StringSliceVar(&format, "format", []string{"md"}, "Generate documentation in the given format (md,man)") 43 | docsCmd.DisableAutoGenTag = true 44 | 45 | return docsCmd 46 | } 47 | -------------------------------------------------------------------------------- /pkg/healthcheck/client.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/LINBIT/linstor-gateway/client" 7 | "github.com/fatih/color" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | type checkGatewayServerConnection struct { 13 | cli *client.Client 14 | } 15 | 16 | func (c *checkGatewayServerConnection) check(bool) error { 17 | ctx, done := context.WithTimeout(context.Background(), 5*time.Second) 18 | defer done() 19 | status, err := c.cli.Status.Get(ctx) 20 | if err != nil { 21 | return fmt.Errorf("failed to connect to server: %w", err) 22 | } 23 | if status == nil { 24 | return fmt.Errorf("received nil status from server") 25 | } 26 | if status.Status != "ok" { 27 | return fmt.Errorf("received invalid status from server: %q", status.Status) 28 | } 29 | return nil 30 | } 31 | 32 | func (c *checkGatewayServerConnection) format(err error) string { 33 | var b strings.Builder 34 | fmt.Fprintf(&b, " %s The LINSTOR Gateway server cannot be reached from this node\n", color.RedString("✗")) 35 | fmt.Fprintf(&b, " %s\n\n", err.Error()) 36 | fmt.Fprintf(&b, " Make sure the %s command line option points to a running LINSTOR Gateway server.\n", bold("--connect")) 37 | return b.String() 38 | } 39 | -------------------------------------------------------------------------------- /docs/md/linstor-gateway_server.md: -------------------------------------------------------------------------------- 1 | ## linstor-gateway server 2 | 3 | Starts a web server serving a REST API 4 | 5 | ### Synopsis 6 | 7 | Starts a web server serving a REST API 8 | An up to date version of the REST-API documentation can be found here: 9 | https://app.swaggerhub.com/apis-docs/Linstor/linstor-gateway 10 | 11 | For example: 12 | linstor-gateway server --addr=":8337" 13 | 14 | ``` 15 | linstor-gateway server [flags] 16 | ``` 17 | 18 | ### Options 19 | 20 | ``` 21 | --addr string Host and port as defined by http.ListenAndServe() (default ":8337") 22 | --controllers strings List of LINSTOR controllers to try to connect to (default from $LS_CONTROLLERS, or localhost:3370) 23 | -h, --help help for server 24 | ``` 25 | 26 | ### Options inherited from parent commands 27 | 28 | ``` 29 | --config string Config file to load (default "/etc/linstor-gateway/linstor-gateway.toml") 30 | -c, --connect string LINSTOR Gateway server to connect to (default "http://localhost:8337") 31 | --loglevel string Set the log level (as defined by logrus) (default "info") 32 | ``` 33 | 34 | ### SEE ALSO 35 | 36 | * [linstor-gateway](linstor-gateway.md) - Manage linstor-gateway targets and exports 37 | 38 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: linstor-gateway 3 | Source: https://github.com/LINBIT/linstor-gateway 4 | 5 | Files: * 6 | Copyright: 2019 Robert Altnoeder 7 | 2019 Roland Kammerer 8 | 2019 Christoph Boehmwalder 9 | License: GPL-3.0+ 10 | 11 | Files: debian/* 12 | Copyright: 2019 Roland Kammerer 13 | License: GPL-3.0+ 14 | 15 | License: GPL-3.0+ 16 | This package is free software; you can redistribute it and/or modify 17 | it under the terms of the GNU General Public License as published by 18 | the Free Software Foundation; either version 3 of the License, or 19 | (at your option) any later version. 20 | . 21 | This package is distributed in the hope that it will be useful, 22 | but WITHOUT ANY WARRANTY; without even the implied warranty of 23 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 24 | GNU General Public License for more details. 25 | . 26 | You should have received a copy of the GNU General Public License 27 | along with this program. If not, see 28 | . 29 | On Debian systems, the complete text of the GNU General 30 | Public License version 2 can be found in "/usr/share/common-licenses/GPL-3". 31 | -------------------------------------------------------------------------------- /integration-tests/virter/roles/drbd-reactor/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Create snippets dir 3 | file: 4 | path: /etc/drbd-reactor.d 5 | state: directory 6 | tags: 7 | - preload 8 | 9 | - name: Install drbd-reactor 10 | yum: 11 | pkg: 12 | - drbd-reactor 13 | state: present 14 | notify: 15 | - Reload drbd-reactor 16 | tags: 17 | - preload 18 | 19 | - name: Enable drbd-reactor service 20 | service: 21 | name: drbd-reactor.service 22 | state: started 23 | enabled: yes 24 | tags: 25 | - preload 26 | 27 | - name: Configure reloading service 28 | copy: 29 | src: "{{ item.src }}" 30 | dest: "{{ item.dest }}" 31 | remote_src: yes 32 | with_items: 33 | - src: /usr/share/doc/drbd-reactor/drbd-reactor-reload.service 34 | dest: /etc/systemd/system/drbd-reactor-reload.service 35 | - src: /usr/share/doc/drbd-reactor/drbd-reactor-reload.path 36 | dest: /etc/systemd/system/drbd-reactor-reload.path 37 | register: drbd_reactor_reloader 38 | notify: 39 | - Reload drbd-reactor reloader 40 | when: configure_autoreload 41 | tags: 42 | - preload 43 | 44 | - name: Enable drbd-reactor reloader 45 | service: 46 | name: drbd-reactor-reload.path 47 | state: started 48 | enabled: yes 49 | when: configure_autoreload and drbd_reactor_reloader.changed 50 | tags: 51 | - preload 52 | -------------------------------------------------------------------------------- /docs/md/linstor-gateway.md: -------------------------------------------------------------------------------- 1 | ## linstor-gateway 2 | 3 | Manage linstor-gateway targets and exports 4 | 5 | ### Options 6 | 7 | ``` 8 | --config string Config file to load (default "/etc/linstor-gateway/linstor-gateway.toml") 9 | -c, --connect string LINSTOR Gateway server to connect to (default "http://localhost:8337") 10 | -h, --help help for linstor-gateway 11 | --loglevel string Set the log level (as defined by logrus) (default "info") 12 | ``` 13 | 14 | ### SEE ALSO 15 | 16 | * [linstor-gateway check-health](linstor-gateway_check-health.md) - Check if all requirements and dependencies are met on the current system 17 | * [linstor-gateway completion](linstor-gateway_completion.md) - Generates bash completion script 18 | * [linstor-gateway docs](linstor-gateway_docs.md) - Generate linstor-gateway documentation 19 | * [linstor-gateway iscsi](linstor-gateway_iscsi.md) - Manages Highly-Available iSCSI targets 20 | * [linstor-gateway nfs](linstor-gateway_nfs.md) - Manages Highly-Available NFS exports 21 | * [linstor-gateway nvme](linstor-gateway_nvme.md) - Manages Highly-Available NVME targets 22 | * [linstor-gateway server](linstor-gateway_server.md) - Starts a web server serving a REST API 23 | * [linstor-gateway version](linstor-gateway_version.md) - Print version information of LINSTOR Gateway 24 | 25 | ###### Auto generated by spf13/cobra on 4-Dec-2025 26 | -------------------------------------------------------------------------------- /cmd/server.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/LINBIT/linstor-gateway/client" 7 | "github.com/LINBIT/linstor-gateway/pkg/rest" 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | func serverCommand() *cobra.Command { 13 | var addr string 14 | 15 | var serverCmd = &cobra.Command{ 16 | Use: "server", 17 | Short: "Starts a web server serving a REST API", 18 | Long: `Starts a web server serving a REST API 19 | An up to date version of the REST-API documentation can be found here: 20 | https://app.swaggerhub.com/apis-docs/Linstor/linstor-gateway 21 | 22 | For example: 23 | linstor-gateway server --addr=":8337"`, 24 | Args: cobra.NoArgs, 25 | Run: func(cmd *cobra.Command, args []string) { 26 | controllers := viper.GetStringSlice("linstor.controllers") 27 | rest.ListenAndServe(addr, controllers) 28 | }, 29 | } 30 | 31 | serverCmd.ResetCommands() 32 | defaultAddr := fmt.Sprintf(":%d", client.DefaultPort) 33 | serverCmd.Flags().StringVar(&addr, "addr", defaultAddr, "Host and port as defined by http.ListenAndServe()") 34 | serverCmd.Flags().StringSlice("controllers", nil, "List of LINSTOR controllers to try to connect to (default from $LS_CONTROLLERS, or localhost:3370)") 35 | viper.BindPFlag("linstor.controllers", serverCmd.Flags().Lookup("controllers")) 36 | serverCmd.DisableAutoGenTag = true 37 | 38 | return serverCmd 39 | } 40 | -------------------------------------------------------------------------------- /docs/md/linstor-gateway_nfs.md: -------------------------------------------------------------------------------- 1 | ## linstor-gateway nfs 2 | 3 | Manages Highly-Available NFS exports 4 | 5 | ### Synopsis 6 | 7 | linstor-gateway nfs manages highly available NFS exports by leveraging LINSTOR 8 | and drbd-reactor. A running LINSTOR cluster including storage pools and resource groups 9 | is a prerequisite to use this tool. 10 | 11 | NOTE that only one NFS resource can exist in a cluster. 12 | See "help nfs create" for more information 13 | 14 | ### Options 15 | 16 | ``` 17 | -h, --help help for nfs 18 | ``` 19 | 20 | ### Options inherited from parent commands 21 | 22 | ``` 23 | --config string Config file to load (default "/etc/linstor-gateway/linstor-gateway.toml") 24 | -c, --connect string LINSTOR Gateway server to connect to (default "http://localhost:8337") 25 | --loglevel string Set the log level (as defined by logrus) (default "info") 26 | ``` 27 | 28 | ### SEE ALSO 29 | 30 | * [linstor-gateway](linstor-gateway.md) - Manage linstor-gateway targets and exports 31 | * [linstor-gateway nfs create](linstor-gateway_nfs_create.md) - Creates an NFS export 32 | * [linstor-gateway nfs delete](linstor-gateway_nfs_delete.md) - Deletes an NFS export 33 | * [linstor-gateway nfs list](linstor-gateway_nfs_list.md) - Lists NFS resources 34 | * [linstor-gateway nfs upgrade](linstor-gateway_nfs_upgrade.md) - Check existing resources and upgrade their configuration if necessary 35 | 36 | -------------------------------------------------------------------------------- /pkg/rest/nfs_delete.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/gorilla/mux" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | // NFSDelete deletes a highly-available NFS export via the REST-API 13 | func (s *server) NFSDelete(all bool) http.HandlerFunc { 14 | return func(writer http.ResponseWriter, request *http.Request) { 15 | ctx := request.Context() 16 | 17 | resource := mux.Vars(request)["resource"] 18 | 19 | if all { 20 | err := s.nfs.Delete(ctx, resource) 21 | if err != nil { 22 | MustError(http.StatusInternalServerError, writer, "delete failed: %v", err) 23 | return 24 | } 25 | } else { 26 | id, err := strconv.Atoi(mux.Vars(request)["id"]) 27 | if err != nil { 28 | MustError(http.StatusInternalServerError, writer, "invalid volume: %v", err) 29 | return 30 | } 31 | 32 | oldCfg, err := s.nfs.DeleteVolume(ctx, resource, id) 33 | if err != nil { 34 | MustError(http.StatusInternalServerError, writer, "error deleting volume: %v", err) 35 | return 36 | } 37 | 38 | if oldCfg == nil { 39 | MustError(http.StatusNotFound, writer, "no resource found") 40 | return 41 | } 42 | } 43 | 44 | writer.WriteHeader(http.StatusOK) 45 | enc := json.NewEncoder(writer) 46 | 47 | err := enc.Encode(struct{}{}) 48 | if err != nil { 49 | log.WithError(err).Warn("failed to write response") 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /pkg/nvmeof/nqn.go: -------------------------------------------------------------------------------- 1 | package nvmeof 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | ) 8 | 9 | // Nqn represents a conventional nvme qualified name 10 | type Nqn [2]string 11 | 12 | func NewNqn(s string) (Nqn, error) { 13 | n := Nqn{} 14 | err := n.UnmarshalText([]byte(s)) 15 | if err != nil { 16 | return Nqn{}, err 17 | } 18 | 19 | return n, nil 20 | } 21 | 22 | func (n *Nqn) Vendor() string { 23 | return n[0] 24 | } 25 | 26 | func (n *Nqn) Subsystem() string { 27 | return n[1] 28 | } 29 | 30 | func (n *Nqn) UnmarshalText(text []byte) error { 31 | s := string(text) 32 | parts := strings.Split(s, ":") 33 | if len(parts) != 3 { 34 | return malformedNqn(s) 35 | } 36 | 37 | n[0] = parts[0] 38 | n[1] = parts[2] 39 | return nil 40 | } 41 | 42 | func (n *Nqn) UnmarshalJSON(text []byte) error { 43 | var s string 44 | err := json.Unmarshal(text, &s) 45 | if err != nil { 46 | return err 47 | } 48 | return n.UnmarshalText([]byte(s)) 49 | } 50 | 51 | func (n Nqn) MarshalText() ([]byte, error) { 52 | return []byte(n.String()), nil 53 | } 54 | 55 | func (n Nqn) MarshalJSON() ([]byte, error) { 56 | return json.Marshal(n.String()) 57 | } 58 | 59 | func (n Nqn) String() string { 60 | return fmt.Sprintf("%s:nvme:%s", n[0], n[1]) 61 | } 62 | 63 | type malformedNqn string 64 | 65 | func (m malformedNqn) Error() string { 66 | return fmt.Sprintf("NQN '%s' malformed, expected :nvme:", string(m)) 67 | } 68 | -------------------------------------------------------------------------------- /integration-tests/Makefile: -------------------------------------------------------------------------------- 1 | TEST_SUITE_IMAGE ?= linstor-gateway-e2e:latest 2 | BASE_OS_IMAGE_SOURCE ?= https://vault.almalinux.org/9.5/cloud/x86_64/images/AlmaLinux-9-GenericCloud-9.5-20241120.x86_64.qcow2 3 | BASE_IMAGE ?= $$LINBIT_DOCKER_REGISTRY/linstor-gateway-tests/base:alma-9 4 | LINSTOR_GATEWAY_VERSION ?= 0.0.0.$$CI_COMMIT_SHA 5 | TORUN ?= all 6 | NVMS ?= 12 7 | 8 | run: 9 | mkdir -p tests-out 10 | vmshed \ 11 | --quiet \ 12 | --out-dir "$(shell readlink -f tests-out)" \ 13 | --startvm 20 \ 14 | --nvms $(NVMS) \ 15 | --vms virter/vms.toml \ 16 | --tests virter/tests.toml \ 17 | --set values.TestSuiteImage=$(TEST_SUITE_IMAGE) \ 18 | --torun $(TORUN) \ 19 | --set values.LinstorGatewayVersion=$(LINSTOR_GATEWAY_VERSION) 20 | 21 | base_image: 22 | virter image pull linstor-gateway-tests-base-os $(BASE_OS_IMAGE_SOURCE) 23 | virter image build linstor-gateway-tests-base-os \ 24 | --provision virter/provision-base.toml \ 25 | --set values.LinbitRepoBaseURL=$$LINBIT_REGISTRY_URL/repository/packages-linbit-com \ 26 | --set values.CIRepoBaseURL=$$LINBIT_REGISTRY_URL/repository/ci-yum/rhel9 \ 27 | $(BASE_IMAGE) 28 | 29 | docker: 30 | docker build -t $(TEST_SUITE_IMAGE) . 31 | -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | # :wrench: LINSTOR Gateway Configuration File 2 | 3 | The LINSTOR Gateway daemon can be configured using a [toml](https://toml.io) file. 4 | 5 | ## Location 6 | 7 | By default, the application will look for the configuration file in `/etc/linstor-gateway/linstor-gateway.toml`. This 8 | can be overridden with the `--config` command line flag. 9 | 10 | ## Options 11 | 12 | ### LINSTOR 13 | 14 | | Key | Default Value | Description | 15 | |---------------|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 16 | | `linstor.controllers` | `[]` | A list of LINSTOR controllers to try.
Each of these IP addresses or hostnames is probed for a LINSTOR controller; the first one that sends a valid response is used.
This should include all nodes in the cluster that could potentially host the LINSTOR controller.
If this list is empty, `localhost:3370` will be used as the LINSTOR controller. | 17 | 18 | ## Example 19 | 20 | ```toml 21 | [linstor] 22 | controllers = ["10.10.1.1", "10.10.1.2", "10.10.1.3"] 23 | ``` -------------------------------------------------------------------------------- /pkg/rest/nfs_get.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/gorilla/mux" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | func (s *server) NFSGet(all bool) http.HandlerFunc { 13 | return func(w http.ResponseWriter, r *http.Request) { 14 | resource := mux.Vars(r)["resource"] 15 | 16 | cfg, err := s.nfs.Get(r.Context(), resource) 17 | if err != nil { 18 | MustError(http.StatusInternalServerError, w, "failed to fetch resource status: %v", err) 19 | return 20 | } 21 | 22 | if cfg == nil { 23 | MustError(http.StatusNotFound, w, "no resource found") 24 | return 25 | } 26 | 27 | if all { 28 | w.WriteHeader(http.StatusOK) 29 | enc := json.NewEncoder(w) 30 | 31 | err = enc.Encode(cfg) 32 | if err != nil { 33 | log.WithError(err).Warn("failed to write response") 34 | } 35 | } else { 36 | id, err := strconv.Atoi(mux.Vars(r)["id"]) 37 | if err != nil { 38 | MustError(http.StatusBadRequest, w, "invalid volume number %q: %v", mux.Vars(r)["id"], err) 39 | return 40 | } 41 | 42 | vol := cfg.VolumeConfig(id) 43 | if vol == nil { 44 | MustError(http.StatusNotFound, w, "no volume found for resource %s, export %d", resource, id) 45 | return 46 | } 47 | 48 | w.WriteHeader(http.StatusOK) 49 | enc := json.NewEncoder(w) 50 | 51 | err = enc.Encode(vol) 52 | if err != nil { 53 | log.WithError(err).Warn("failed to write response") 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /client/nfs.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/LINBIT/linstor-gateway/pkg/nfs" 7 | ) 8 | 9 | type NFSService struct { 10 | client *Client 11 | } 12 | 13 | func (s *NFSService) GetAll(ctx context.Context) ([]*nfs.ResourceConfig, error) { 14 | var configs []*nfs.ResourceConfig 15 | _, err := s.client.doGET(ctx, "/api/v2/nfs", &configs) 16 | return configs, err 17 | } 18 | 19 | func (s *NFSService) Create(ctx context.Context, config *nfs.ResourceConfig) (*nfs.ResourceConfig, error) { 20 | var ret *nfs.ResourceConfig 21 | _, err := s.client.doPOST(ctx, "/api/v2/nfs", config, &ret) 22 | return ret, err 23 | } 24 | 25 | func (s *NFSService) Get(ctx context.Context, name string) (*nfs.ResourceConfig, error) { 26 | var config *nfs.ResourceConfig 27 | _, err := s.client.doGET(ctx, "/api/v2/nfs/"+name, &config) 28 | return config, err 29 | } 30 | 31 | func (s *NFSService) Delete(ctx context.Context, name string) error { 32 | _, err := s.client.doDELETE(ctx, "/api/v2/nfs/"+name, nil) 33 | return err 34 | } 35 | 36 | func (s *NFSService) Start(ctx context.Context, name string) (*nfs.ResourceConfig, error) { 37 | var ret *nfs.ResourceConfig 38 | _, err := s.client.doPOST(ctx, "/api/v2/nfs/"+name+"/start", nil, &ret) 39 | return ret, err 40 | } 41 | 42 | func (s *NFSService) Stop(ctx context.Context, name string) (*nfs.ResourceConfig, error) { 43 | var ret *nfs.ResourceConfig 44 | _, err := s.client.doPOST(ctx, "/api/v2/nfs/"+name+"/stop", nil, &ret) 45 | return ret, err 46 | } 47 | -------------------------------------------------------------------------------- /pkg/common/net.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net" 7 | ) 8 | 9 | type IpCidr struct { 10 | net.IPNet 11 | } 12 | 13 | func (s *IpCidr) IP() net.IP { 14 | return s.IPNet.IP 15 | } 16 | 17 | func (s *IpCidr) Prefix() int { 18 | ones, _ := s.Mask.Size() 19 | return ones 20 | } 21 | 22 | func (s *IpCidr) Type() string { 23 | return "ip-cidr" 24 | } 25 | 26 | func (s *IpCidr) Set(raw string) error { 27 | service, err := ServiceIPFromString(raw) 28 | if err != nil { 29 | return err 30 | } 31 | 32 | *s = service 33 | return nil 34 | } 35 | 36 | func (s IpCidr) MarshalJSON() ([]byte, error) { 37 | return json.Marshal(s.IPNet.String()) 38 | } 39 | 40 | func (s *IpCidr) UnmarshalJSON(b []byte) error { 41 | var str string 42 | err := json.Unmarshal(b, &str) 43 | if err != nil { 44 | return err 45 | } 46 | 47 | ser, err := ServiceIPFromString(str) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | s.IPNet = ser.IPNet 53 | 54 | return nil 55 | } 56 | 57 | func ServiceIPFromString(s string) (IpCidr, error) { 58 | ip, ipnet, err := net.ParseCIDR(s) 59 | if err != nil { 60 | return IpCidr{}, fmt.Errorf("failed to parse service ip: %w", err) 61 | } 62 | 63 | ipnet.IP = ip 64 | return IpCidr{IPNet: *ipnet}, nil 65 | } 66 | 67 | func ServiceIPFromParts(ip net.IP, prefix int) IpCidr { 68 | bits := 32 69 | if ip.To4() == nil { 70 | bits = 128 71 | } 72 | 73 | return IpCidr{ 74 | IPNet: net.IPNet{ 75 | IP: ip, 76 | Mask: net.CIDRMask(prefix, bits), 77 | }, 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /pkg/nvmeof/nvmeof_test.go: -------------------------------------------------------------------------------- 1 | package nvmeof_test 2 | 3 | import ( 4 | "github.com/icza/gog" 5 | "net" 6 | "testing" 7 | 8 | "github.com/LINBIT/golinstor/client" 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/LINBIT/linstor-gateway/pkg/common" 12 | "github.com/LINBIT/linstor-gateway/pkg/nvmeof" 13 | ) 14 | 15 | func TestResource_RoundTrip(t *testing.T) { 16 | t.Parallel() 17 | 18 | testcases := []nvmeof.ResourceConfig{ 19 | { 20 | NQN: nvmeof.Nqn{"nqn.com.example.test", "example-resource"}, 21 | Volumes: []common.VolumeConfig{ 22 | {Number: 2, SizeKiB: 1024}, 23 | }, 24 | ResourceGroup: "rg1", 25 | ServiceIP: common.ServiceIPFromParts(net.IP{192, 168, 127, 1}, 24), 26 | }, 27 | } 28 | 29 | for i := range testcases { 30 | tcase := &testcases[i] 31 | t.Run(tcase.NQN.String(), func(t *testing.T) { 32 | t.Parallel() 33 | 34 | encoded, err := tcase.ToPromoter([]client.ResourceWithVolumes{ 35 | {Volumes: []client.Volume{{VolumeNumber: 2, DevicePath: "/dev/drbd1002"}}}, 36 | }) 37 | assert.NoError(t, err) 38 | 39 | decoded, err := nvmeof.FromPromoter( 40 | encoded, 41 | &client.ResourceDefinition{ResourceGroupName: "rg1"}, 42 | []client.VolumeDefinition{ 43 | {VolumeNumber: gog.Ptr(int32(2)), SizeKib: 1024}, 44 | }, 45 | ) 46 | assert.NoError(t, err) 47 | assert.Equal(t, tcase.NQN, decoded.NQN) 48 | assert.Equal(t, tcase.ServiceIP.String(), decoded.ServiceIP.String()) 49 | assert.Equal(t, tcase.Volumes, decoded.Volumes) 50 | assert.Equal(t, tcase.ResourceGroup, decoded.ResourceGroup) 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pkg/reactor/service_test.go: -------------------------------------------------------------------------------- 1 | package reactor 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestSystemdService_UnmarshalText(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | text string 12 | expected SystemdService 13 | wantErr bool 14 | }{{ 15 | name: "empty string", 16 | text: "", 17 | expected: SystemdService{ 18 | Name: "", 19 | }, 20 | }, { 21 | name: "with name", 22 | text: "linstordb.mount", 23 | expected: SystemdService{ 24 | Name: "linstordb.mount", 25 | }, 26 | }} 27 | for _, tt := range tests { 28 | t.Run(tt.name, func(t *testing.T) { 29 | r := SystemdService{} 30 | err := r.UnmarshalText([]byte(tt.text)) 31 | if tt.wantErr { 32 | assert.Error(t, err) 33 | } else { 34 | assert.NoError(t, err) 35 | assert.Equal(t, tt.expected, r) 36 | } 37 | }) 38 | } 39 | } 40 | 41 | func TestSystemdService_MarshalText(t *testing.T) { 42 | tests := []struct { 43 | name string 44 | service SystemdService 45 | expected string 46 | wantErr bool 47 | }{{ 48 | name: "empty", 49 | service: SystemdService{}, 50 | expected: "", 51 | }, { 52 | name: "with name", 53 | service: SystemdService{ 54 | Name: "linstordb.mount", 55 | }, 56 | expected: "linstordb.mount", 57 | }} 58 | for _, tt := range tests { 59 | t.Run(tt.name, func(t *testing.T) { 60 | text, err := tt.service.MarshalText() 61 | if tt.wantErr { 62 | assert.Error(t, err) 63 | } else { 64 | assert.NoError(t, err) 65 | assert.Equal(t, tt.expected, string(text)) 66 | } 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /pkg/rest/iscsi_delete.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/gorilla/mux" 9 | log "github.com/sirupsen/logrus" 10 | 11 | "github.com/LINBIT/linstor-gateway/pkg/iscsi" 12 | ) 13 | 14 | // ISCSIDelete deletes a highly-available iSCSI target via the REST-API 15 | func (s *server) ISCSIDelete(all bool) http.HandlerFunc { 16 | return func(writer http.ResponseWriter, request *http.Request) { 17 | ctx := request.Context() 18 | 19 | iqn, err := iscsi.NewIqn(mux.Vars(request)["iqn"]) 20 | if err != nil { 21 | MustError(http.StatusBadRequest, writer, "malformed iqn: %v", err) 22 | return 23 | } 24 | 25 | if all { 26 | err = s.iscsi.Delete(ctx, iqn) 27 | if err != nil { 28 | MustError(http.StatusInternalServerError, writer, "delete failed: %v", err) 29 | return 30 | } 31 | } else { 32 | lun, err := strconv.Atoi(mux.Vars(request)["lun"]) 33 | if err != nil { 34 | MustError(http.StatusInternalServerError, writer, "invalid LUN: %v", err) 35 | return 36 | } 37 | 38 | oldCfg, err := s.iscsi.DeleteVolume(ctx, iqn, lun) 39 | if err != nil { 40 | MustError(http.StatusInternalServerError, writer, "error deleting volume: %v", err) 41 | return 42 | } 43 | 44 | if oldCfg == nil { 45 | MustError(http.StatusNotFound, writer, "no resource found for iqn %s", iqn) 46 | return 47 | } 48 | } 49 | 50 | writer.WriteHeader(http.StatusOK) 51 | enc := json.NewEncoder(writer) 52 | 53 | err = enc.Encode(struct{}{}) 54 | if err != nil { 55 | log.WithError(err).Warn("failed to write response") 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /docs/md/linstor-gateway_nvme.md: -------------------------------------------------------------------------------- 1 | ## linstor-gateway nvme 2 | 3 | Manages Highly-Available NVME targets 4 | 5 | ### Synopsis 6 | 7 | nvme manages highly available NVME targets by leveraging LINSTOR and DRBD. 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for nvme 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --config string Config file to load (default "/etc/linstor-gateway/linstor-gateway.toml") 19 | -c, --connect string LINSTOR Gateway server to connect to (default "http://localhost:8337") 20 | --loglevel string Set the log level (as defined by logrus) (default "info") 21 | ``` 22 | 23 | ### SEE ALSO 24 | 25 | * [linstor-gateway](linstor-gateway.md) - Manage linstor-gateway targets and exports 26 | * [linstor-gateway nvme add-volume](linstor-gateway_nvme_add-volume.md) - Add a new volume to an existing NVMe-oF target 27 | * [linstor-gateway nvme create](linstor-gateway_nvme_create.md) - Create a new NVMe-oF target 28 | * [linstor-gateway nvme delete](linstor-gateway_nvme_delete.md) - Delete existing NVMe-oF targets 29 | * [linstor-gateway nvme delete-volume](linstor-gateway_nvme_delete-volume.md) - Delete a volume of an existing NVMe-oF target 30 | * [linstor-gateway nvme list](linstor-gateway_nvme_list.md) - list configured NVMe-oF targets 31 | * [linstor-gateway nvme start](linstor-gateway_nvme_start.md) - Start a stopped NVMe-oF target 32 | * [linstor-gateway nvme stop](linstor-gateway_nvme_stop.md) - Stop a started NVMe-oF target 33 | * [linstor-gateway nvme upgrade](linstor-gateway_nvme_upgrade.md) - Check existing resources and upgrade their configuration if necessary 34 | 35 | -------------------------------------------------------------------------------- /pkg/rest/iscsi_get.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/gorilla/mux" 9 | log "github.com/sirupsen/logrus" 10 | 11 | "github.com/LINBIT/linstor-gateway/pkg/iscsi" 12 | ) 13 | 14 | func (s *server) ISCSIGet(all bool) http.HandlerFunc { 15 | return func(w http.ResponseWriter, r *http.Request) { 16 | iqn, err := iscsi.NewIqn(mux.Vars(r)["iqn"]) 17 | if err != nil { 18 | MustError(http.StatusBadRequest, w, "malformed iqn: %v", err) 19 | return 20 | } 21 | 22 | cfg, err := s.iscsi.Get(r.Context(), iqn) 23 | if err != nil { 24 | MustError(http.StatusInternalServerError, w, "failed to fetch resource status: %v", err) 25 | return 26 | } 27 | 28 | if cfg == nil { 29 | MustError(http.StatusNotFound, w, "no resource found for iqn %s", iqn) 30 | return 31 | } 32 | 33 | if all { 34 | w.WriteHeader(http.StatusOK) 35 | enc := json.NewEncoder(w) 36 | 37 | err = enc.Encode(cfg) 38 | if err != nil { 39 | log.WithError(err).Warn("failed to write response") 40 | } 41 | } else { 42 | lun, err := strconv.Atoi(mux.Vars(r)["lun"]) 43 | if err != nil { 44 | MustError(http.StatusBadRequest, w, "invalid LUN: %v", err) 45 | return 46 | } 47 | 48 | vol := cfg.VolumeConfig(lun) 49 | if vol == nil { 50 | MustError(http.StatusNotFound, w, "no volume found for iqn %s, lun %d", iqn, lun) 51 | return 52 | } 53 | 54 | w.WriteHeader(http.StatusOK) 55 | enc := json.NewEncoder(w) 56 | 57 | err = enc.Encode(vol) 58 | if err != nil { 59 | log.WithError(err).Warn("failed to write response") 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /pkg/rest/nvmeof_get.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/gorilla/mux" 9 | log "github.com/sirupsen/logrus" 10 | 11 | "github.com/LINBIT/linstor-gateway/pkg/nvmeof" 12 | ) 13 | 14 | func (s *server) NVMeoFGet(all bool) func(http.ResponseWriter, *http.Request) { 15 | return func(writer http.ResponseWriter, request *http.Request) { 16 | ctx := request.Context() 17 | 18 | nqn, err := nvmeof.NewNqn(mux.Vars(request)["nqn"]) 19 | if err != nil { 20 | MustError(http.StatusBadRequest, writer, "malformed nqn: %v", err) 21 | return 22 | } 23 | 24 | cfg, err := s.nvmeof.Get(ctx, nqn) 25 | if err != nil { 26 | MustError(http.StatusInternalServerError, writer, "failed to fetch resource status: %v", err) 27 | return 28 | } 29 | 30 | if cfg == nil { 31 | MustError(http.StatusNotFound, writer, "no resource found for nqn %s", nqn) 32 | return 33 | } 34 | 35 | if all { 36 | writer.WriteHeader(http.StatusOK) 37 | err = json.NewEncoder(writer).Encode(cfg) 38 | if err != nil { 39 | log.WithError(err).Warn("failed to write response") 40 | } 41 | } else { 42 | nsid, err := strconv.Atoi(mux.Vars(request)["nsid"]) 43 | if err != nil { 44 | MustError(http.StatusInternalServerError, writer, "wrong namespace id format: %v", err) 45 | return 46 | } 47 | 48 | volCfg := cfg.VolumeConfig(nsid) 49 | if volCfg == nil { 50 | MustError(http.StatusNotFound, writer, "no volume found for nqn %s, nsid %d", nqn, nsid) 51 | return 52 | } 53 | 54 | writer.WriteHeader(http.StatusOK) 55 | err = json.NewEncoder(writer).Encode(volCfg) 56 | if err != nil { 57 | log.WithError(err).Warn("failed to write response") 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /integration-tests/virter/inventory/virter-dyn-inventory: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import json 3 | import os 4 | import socket 5 | import sys 6 | 7 | DEFAULT_HOSTVARS = { 8 | "ansible_ssh_private_key_file": "/root/.ssh/id_rsa", 9 | "ansible_ssh_common_args": "-o UserKnownHostsFile=/root/.ssh/known_hosts", 10 | "ansible_user": "root", 11 | } 12 | 13 | USAGE = f"""{sys.argv[0]}: Build an ansible inventory in a virter provision run. 14 | 15 | Actions: 16 | --host HOST Output information of just a single host 17 | --list Output the inventory built from the virter environment 18 | """ 19 | 20 | 21 | def build_inventory(): 22 | targets = sorted(os.environ["TARGETS"].split(",")) 23 | 24 | all_nodes = {x: {"ansible_host": x, **DEFAULT_HOSTVARS, "ip": socket.gethostbyname(x)} for x in targets} 25 | return { 26 | "_meta": { 27 | "hostvars": all_nodes, 28 | }, 29 | "all": {"hosts": list(all_nodes.keys())}, 30 | "linstor_controller": {"hosts": [min(all_nodes.keys())]} 31 | } 32 | 33 | 34 | def main(): 35 | if len(sys.argv) < 2: 36 | print(USAGE, file=sys.stderr) 37 | sys.exit(1) 38 | 39 | if sys.argv[1] == "--list": 40 | encoded = json.dumps(build_inventory()) 41 | print(encoded) 42 | return 43 | if sys.argv[1] == "--host": 44 | if len(sys.argv) != 3: 45 | print(USAGE, file=sys.stderr) 46 | sys.exit(1) 47 | inventory = build_inventory() 48 | hostvar = inventory.get("_meta", {}).get('hostvars', {}).get(sys.argv[2]) 49 | encoded = json.dumps(hostvar) 50 | print(encoded) 51 | return 52 | 53 | print(USAGE, file=sys.stderr) 54 | sys.exit(1) 55 | 56 | 57 | if __name__ == '__main__': 58 | main() 59 | -------------------------------------------------------------------------------- /docs/md/linstor-gateway_iscsi.md: -------------------------------------------------------------------------------- 1 | ## linstor-gateway iscsi 2 | 3 | Manages Highly-Available iSCSI targets 4 | 5 | ### Synopsis 6 | 7 | linstor-gateway iscsi manages highly available iSCSI targets by leveraging 8 | LINSTOR and drbd-reactor. Setting up LINSTOR, including storage pools and resource groups, 9 | as well as drbd-reactor is a prerequisite to use this tool. 10 | 11 | ### Options 12 | 13 | ``` 14 | -h, --help help for iscsi 15 | ``` 16 | 17 | ### Options inherited from parent commands 18 | 19 | ``` 20 | --config string Config file to load (default "/etc/linstor-gateway/linstor-gateway.toml") 21 | -c, --connect string LINSTOR Gateway server to connect to (default "http://localhost:8337") 22 | --loglevel string Set the log level (as defined by logrus) (default "info") 23 | ``` 24 | 25 | ### SEE ALSO 26 | 27 | * [linstor-gateway](linstor-gateway.md) - Manage linstor-gateway targets and exports 28 | * [linstor-gateway iscsi add-volume](linstor-gateway_iscsi_add-volume.md) - Add a new logical unit to an existing iSCSI target 29 | * [linstor-gateway iscsi create](linstor-gateway_iscsi_create.md) - Creates an iSCSI target 30 | * [linstor-gateway iscsi delete](linstor-gateway_iscsi_delete.md) - Deletes an iSCSI target 31 | * [linstor-gateway iscsi delete-volume](linstor-gateway_iscsi_delete-volume.md) - Delete a logical unit of an existing iSCSI target 32 | * [linstor-gateway iscsi list](linstor-gateway_iscsi_list.md) - Lists iSCSI targets 33 | * [linstor-gateway iscsi start](linstor-gateway_iscsi_start.md) - Starts an iSCSI target 34 | * [linstor-gateway iscsi stop](linstor-gateway_iscsi_stop.md) - Stops an iSCSI target 35 | * [linstor-gateway iscsi upgrade](linstor-gateway_iscsi_upgrade.md) - Check existing resources and upgrade their configuration if necessary 36 | 37 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # Sequence of patterns matched against refs/tags 4 | tags: 5 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 6 | 7 | name: Upload Release Asset 8 | 9 | jobs: 10 | build: 11 | name: Upload Release Asset 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | - name: Setup GO 17 | uses: actions/setup-go@v4 18 | with: 19 | go-version: 1.21 20 | - name: Build project 21 | run: | 22 | make release 23 | - name: Extract Release Notes 24 | id: extract_release_notes 25 | uses: ffurrer2/extract-release-notes@v1.17.0 26 | - name: Create Release 27 | id: create_release 28 | uses: actions/create-release@v1 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | with: 32 | tag_name: ${{ github.ref }} 33 | release_name: Release ${{ github.ref }} 34 | draft: false 35 | prerelease: false 36 | body: ${{ steps.extract_release_notes.outputs.release_notes }} 37 | - name: Upload Release Asset 38 | id: upload-release-asset 39 | uses: actions/upload-release-asset@v1 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 42 | with: 43 | upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps 44 | asset_path: ./linstor-gateway-linux-amd64 45 | asset_name: linstor-gateway-linux-amd64 46 | asset_content_type: application/octet-stream 47 | -------------------------------------------------------------------------------- /pkg/rest/nvmeof_delete.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/gorilla/mux" 9 | log "github.com/sirupsen/logrus" 10 | 11 | "github.com/LINBIT/linstor-gateway/pkg/nvmeof" 12 | ) 13 | 14 | func (s *server) NVMeoFDelete(all bool) func(http.ResponseWriter, *http.Request) { 15 | return func(writer http.ResponseWriter, request *http.Request) { 16 | ctx := request.Context() 17 | 18 | nqn, err := nvmeof.NewNqn(mux.Vars(request)["nqn"]) 19 | if err != nil { 20 | MustError(http.StatusBadRequest, writer, "malformed nqn: %v", err) 21 | return 22 | } 23 | 24 | if all { 25 | deployed, err := s.nvmeof.Get(ctx, nqn) 26 | if err != nil { 27 | MustError(http.StatusInternalServerError, writer, "failed to query target: %v", err) 28 | } 29 | if deployed == nil { 30 | MustError(http.StatusNotFound, writer, "no resource found for nqn %s", nqn) 31 | } 32 | err = s.nvmeof.Delete(ctx, nqn) 33 | if err != nil { 34 | MustError(http.StatusInternalServerError, writer, "nvmeof delete failed: %v", err) 35 | return 36 | } 37 | } else { 38 | nsid, err := strconv.Atoi(mux.Vars(request)["nsid"]) 39 | if err != nil { 40 | MustError(http.StatusInternalServerError, writer, "wrong namespace id format: %v", err) 41 | return 42 | } 43 | 44 | oldCfg, err := s.nvmeof.DeleteVolume(ctx, nqn, nsid) 45 | if err != nil { 46 | MustError(http.StatusInternalServerError, writer, "error deleting volume: %v", err) 47 | return 48 | } 49 | 50 | if oldCfg == nil { 51 | MustError(http.StatusNotFound, writer, "no resource found for nqn %s", nqn) 52 | return 53 | } 54 | } 55 | 56 | writer.WriteHeader(http.StatusOK) 57 | enc := json.NewEncoder(writer) 58 | 59 | err = enc.Encode(struct{}{}) 60 | if err != nil { 61 | log.WithError(err).Warn("failed to write response") 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /docs/md/linstor-gateway_iscsi_create.md: -------------------------------------------------------------------------------- 1 | ## linstor-gateway iscsi create 2 | 3 | Creates an iSCSI target 4 | 5 | ### Synopsis 6 | 7 | Creates a highly available iSCSI target based on LINSTOR and drbd-reactor. 8 | At first it creates a new resource within the LINSTOR system, using the 9 | specified resource group. The name of the linstor resources is derived 10 | from the IQN's World Wide Name, which must be unique. 11 | After that it creates a configuration for drbd-reactor to manage the 12 | high availability primitives. 13 | 14 | ``` 15 | linstor-gateway iscsi create IQN SERVICE_IPS [VOLUME_SIZE]... [flags] 16 | ``` 17 | 18 | ### Examples 19 | 20 | ``` 21 | linstor-gateway iscsi create iqn.2019-08.com.linbit:example 192.168.122.181/24 2G 22 | ``` 23 | 24 | ### Options 25 | 26 | ``` 27 | --allowed-initiators strings Restrict which initiator IQNs are allowed to connect to the target 28 | --gross Make all size options specify gross size, i.e. the actual space used on disk 29 | -h, --help help for create 30 | --implementation string Set the iSCSI target implementation to use ("iet", "tgt", "lio", "lio-t", or "scst") 31 | -p, --password string Set the password to use for CHAP authentication 32 | -r, --resource-group string Set the LINSTOR resource group (default "DfltRscGrp") 33 | -u, --username string Set the username to use for CHAP authentication 34 | ``` 35 | 36 | ### Options inherited from parent commands 37 | 38 | ``` 39 | --config string Config file to load (default "/etc/linstor-gateway/linstor-gateway.toml") 40 | -c, --connect string LINSTOR Gateway server to connect to (default "http://localhost:8337") 41 | --loglevel string Set the log level (as defined by logrus) (default "info") 42 | ``` 43 | 44 | ### SEE ALSO 45 | 46 | * [linstor-gateway iscsi](linstor-gateway_iscsi.md) - Manages Highly-Available iSCSI targets 47 | 48 | -------------------------------------------------------------------------------- /docs/md/linstor-gateway_check-health.md: -------------------------------------------------------------------------------- 1 | ## linstor-gateway check-health 2 | 3 | Check if all requirements and dependencies are met on the current system 4 | 5 | ### Synopsis 6 | 7 | Check if all requirements and dependencies are met on the current system. 8 | 9 | The "mode" argument can be used to specify the type of node this 10 | system is intended to be used as. 11 | 12 | An "agent" node is responsible for actually running the highly available storage 13 | endpoint (for example, an iSCSI target). Thus, the health check ensures the 14 | current system is correctly configured to host the different kinds of storage 15 | protocols supported by LINSTOR Gateway. 16 | 17 | A "server" node acts as a relay between the client and the LINSTOR controller. 18 | The only requirement is that the LINSTOR controller can be reached. 19 | 20 | A "client" node interacts with the LINSTOR Gateway API. Its only requirement is 21 | that a LINSTOR Gateway server can be reached. 22 | 23 | 24 | ``` 25 | linstor-gateway check-health [flags] 26 | ``` 27 | 28 | ### Options 29 | 30 | ``` 31 | --controllers strings List of LINSTOR controllers to try to connect to (default from $LS_CONTROLLERS, or localhost:3370) 32 | -h, --help help for check-health 33 | --iscsi-backends strings List of iSCSI backends to check for (one of 'lio-t', 'scst') (default [lio-t,scst]) 34 | -m, --mode string Which type of node to check requirements for. Can be "agent", "server", or "client" (default "agent") 35 | ``` 36 | 37 | ### Options inherited from parent commands 38 | 39 | ``` 40 | --config string Config file to load (default "/etc/linstor-gateway/linstor-gateway.toml") 41 | -c, --connect string LINSTOR Gateway server to connect to (default "http://localhost:8337") 42 | --loglevel string Set the log level (as defined by logrus) (default "info") 43 | ``` 44 | 45 | ### SEE ALSO 46 | 47 | * [linstor-gateway](linstor-gateway.md) - Manage linstor-gateway targets and exports 48 | 49 | ###### Auto generated by spf13/cobra on 4-Dec-2025 50 | -------------------------------------------------------------------------------- /pkg/rest/iscsi_add_volume.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/gorilla/mux" 9 | log "github.com/sirupsen/logrus" 10 | 11 | "github.com/LINBIT/linstor-gateway/pkg/common" 12 | "github.com/LINBIT/linstor-gateway/pkg/iscsi" 13 | ) 14 | 15 | func (s *server) ISCSIAddVolume() func(http.ResponseWriter, *http.Request) { 16 | return func(writer http.ResponseWriter, request *http.Request) { 17 | ctx := request.Context() 18 | 19 | iqn, err := iscsi.NewIqn(mux.Vars(request)["iqn"]) 20 | if err != nil { 21 | MustError(http.StatusBadRequest, writer, "malformed iqn: %v", err) 22 | return 23 | } 24 | 25 | lun, err := strconv.Atoi(mux.Vars(request)["lun"]) 26 | if err != nil { 27 | MustError(http.StatusInternalServerError, writer, "malformed LUN: %v", err) 28 | return 29 | } 30 | 31 | var vCfg common.VolumeConfig 32 | decoder := json.NewDecoder(request.Body) 33 | err = decoder.Decode(&vCfg) 34 | if err != nil { 35 | MustError(http.StatusBadRequest, writer, "failed to parse request body: %v", err) 36 | return 37 | } 38 | 39 | if vCfg.Number != 0 && vCfg.Number != lun { 40 | MustError(http.StatusBadRequest, writer, "expected volume number to be %d, but request body has %d", lun, vCfg.Number) 41 | return 42 | } 43 | 44 | // Fill in default 45 | vCfg.Number = lun 46 | 47 | if lun < 1 { 48 | MustError(http.StatusBadRequest, writer, "volume number must be positive, is %d", lun) 49 | return 50 | } 51 | 52 | cfg, err := s.iscsi.AddVolume(ctx, iqn, &vCfg) 53 | if err != nil { 54 | MustError(http.StatusInternalServerError, writer, "failed to add volume to resource: %v", err) 55 | return 56 | } 57 | 58 | if cfg == nil { 59 | MustError(http.StatusNotFound, writer, "no resource found for iqn %s", iqn) 60 | return 61 | } 62 | 63 | volCfg := cfg.VolumeConfig(lun) 64 | 65 | writer.WriteHeader(http.StatusOK) 66 | err = json.NewEncoder(writer).Encode(volCfg) 67 | if err != nil { 68 | log.WithError(err).Warn("failed to write response") 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /pkg/rest/nvmeof_add_volume.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strconv" 7 | 8 | "github.com/gorilla/mux" 9 | log "github.com/sirupsen/logrus" 10 | 11 | "github.com/LINBIT/linstor-gateway/pkg/common" 12 | "github.com/LINBIT/linstor-gateway/pkg/nvmeof" 13 | ) 14 | 15 | func (s *server) NVMeoFAddVolume() func(http.ResponseWriter, *http.Request) { 16 | return func(writer http.ResponseWriter, request *http.Request) { 17 | ctx := request.Context() 18 | 19 | nqn, err := nvmeof.NewNqn(mux.Vars(request)["nqn"]) 20 | if err != nil { 21 | MustError(http.StatusBadRequest, writer, "malformed nqn: %v", err) 22 | return 23 | } 24 | 25 | nsid, err := strconv.Atoi(mux.Vars(request)["nsid"]) 26 | if err != nil { 27 | MustError(http.StatusInternalServerError, writer, "wrong namespace id format: %v", err) 28 | return 29 | } 30 | 31 | var vCfg common.VolumeConfig 32 | decoder := json.NewDecoder(request.Body) 33 | err = decoder.Decode(&vCfg) 34 | if err != nil { 35 | MustError(http.StatusBadRequest, writer, "failed to parse request body: %v", err) 36 | return 37 | } 38 | 39 | if vCfg.Number != 0 && vCfg.Number != nsid { 40 | MustError(http.StatusBadRequest, writer, "expected volume number to be %d, but request body has %d", nsid, vCfg.Number) 41 | return 42 | } 43 | 44 | // Fill in default 45 | vCfg.Number = nsid 46 | 47 | if nsid < 1 { 48 | MustError(http.StatusBadRequest, writer, "volume number must be positive, is %d", nsid) 49 | return 50 | } 51 | 52 | cfg, err := s.nvmeof.AddVolume(ctx, nqn, &vCfg) 53 | if err != nil { 54 | MustError(http.StatusInternalServerError, writer, "failed to add volume to resource: %v", err) 55 | return 56 | } 57 | 58 | if cfg == nil { 59 | MustError(http.StatusNotFound, writer, "no resource found for nqn %s", nqn) 60 | return 61 | } 62 | 63 | volCfg := cfg.VolumeConfig(nsid) 64 | 65 | writer.WriteHeader(http.StatusOK) 66 | err = json.NewEncoder(writer).Encode(volCfg) 67 | if err != nil { 68 | log.WithError(err).Warn("failed to write response") 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /docs/md/linstor-gateway_nfs_create.md: -------------------------------------------------------------------------------- 1 | ## linstor-gateway nfs create 2 | 3 | Creates an NFS export 4 | 5 | ### Synopsis 6 | 7 | Creates a highly available NFS export based on LINSTOR and drbd-reactor. 8 | At first it creates a new resource within the LINSTOR system under the 9 | specified name and using the specified resource group. 10 | After that it creates a drbd-reactor configuration to bring up a highly available NFS 11 | export. 12 | 13 | !!! NOTE that only one NFS resource can exist in a cluster. 14 | To create multiple exports, create a single resource with multiple volumes. 15 | 16 | ``` 17 | linstor-gateway nfs create NAME SERVICE_IP [VOLUME_SIZE]... [flags] 18 | ``` 19 | 20 | ### Examples 21 | 22 | ``` 23 | linstor-gateway nfs create example 192.168.211.122/24 2G 24 | linstor-gateway nfs create restricted 10.10.22.44/16 2G --allowed-ips 10.10.0.0/16 25 | linstor-gateway nfs create multi 172.16.16.55/24 1G 2G --export-path /music --export-path /movies 26 | 27 | ``` 28 | 29 | ### Options 30 | 31 | ``` 32 | --allowed-ips ip-cidr Set the IP address mask of clients that are allowed access (default 0.0.0.0/0) 33 | -p, --export-path strings Set the export path, relative to /srv/gateway-exports. Can be specified multiple times when creating more than one volume (default [/]) 34 | -f, --filesystem string File system type to use (ext4 or xfs) (default "ext4") 35 | --gross Make all size options specify gross size, i.e. the actual space used on disk 36 | -h, --help help for create 37 | -r, --resource-group string LINSTOR resource group to use (default "DfltRscGrp") 38 | ``` 39 | 40 | ### Options inherited from parent commands 41 | 42 | ``` 43 | --config string Config file to load (default "/etc/linstor-gateway/linstor-gateway.toml") 44 | -c, --connect string LINSTOR Gateway server to connect to (default "http://localhost:8337") 45 | --loglevel string Set the log level (as defined by logrus) (default "info") 46 | ``` 47 | 48 | ### SEE ALSO 49 | 50 | * [linstor-gateway nfs](linstor-gateway_nfs.md) - Manages Highly-Available NFS exports 51 | 52 | -------------------------------------------------------------------------------- /client/iscsi_test.go: -------------------------------------------------------------------------------- 1 | package client_test 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/LINBIT/linstor-gateway/pkg/common" 6 | "github.com/LINBIT/linstor-gateway/pkg/iscsi" 7 | "github.com/stretchr/testify/assert" 8 | "reflect" 9 | "strings" 10 | "testing" 11 | ) 12 | 13 | func ipnet(str string) common.IpCidr { 14 | ip, err := common.ServiceIPFromString(str) 15 | if err != nil { 16 | panic(err) 17 | } 18 | return ip 19 | } 20 | 21 | func TestISCSIList(t *testing.T) { 22 | testcases := []struct { 23 | name string 24 | response string 25 | actual interface{} 26 | expected interface{} 27 | }{ 28 | { 29 | response: `{"iqn":"iqn.2021-08.com.linbit:target1","resource_group":"DfltRscGrp","volumes":[{"number":1,"size_kib":47185920}],"service_ips":["10.43.6.223/16"],"status":{"state":"OK","service":"Started","primary":"test3","nodes":["test1","test2","test3"],"volumes":[{"number":1,"state":"OK"}]}}`, 30 | actual: &iscsi.ResourceConfig{}, 31 | expected: &iscsi.ResourceConfig{ 32 | IQN: iscsi.Iqn{"iqn.2021-08.com.linbit", "target1"}, 33 | AllowedInitiators: nil, 34 | ResourceGroup: "DfltRscGrp", 35 | Volumes: []common.VolumeConfig{ 36 | { 37 | Number: 1, 38 | SizeKiB: 47185920, 39 | }, 40 | }, 41 | Username: "", Password: "", 42 | ServiceIPs: []common.IpCidr{ipnet("10.43.6.223/16")}, 43 | Status: common.ResourceStatus{ 44 | State: common.ResourceStateOK, 45 | Service: common.ServiceStateStarted, 46 | Primary: "test3", 47 | Nodes: []string{"test1", "test2", "test3"}, 48 | Volumes: []common.VolumeState{ 49 | { 50 | Number: 1, 51 | State: common.ResourceStateOK, 52 | }, 53 | }, 54 | }, 55 | }, 56 | }, 57 | } 58 | 59 | t.Parallel() 60 | for i := range testcases { 61 | tcase := &testcases[i] 62 | t.Run(reflect.TypeOf(tcase.expected).Name(), func(t *testing.T) { 63 | err := json.NewDecoder(strings.NewReader(tcase.response)).Decode(tcase.actual) 64 | if !assert.NoError(t, err) { 65 | t.FailNow() 66 | } 67 | 68 | assert.Equal(t, tcase.expected, tcase.actual) 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /cmd/check_health.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | log "github.com/sirupsen/logrus" 7 | "github.com/spf13/cobra" 8 | "github.com/spf13/viper" 9 | 10 | "github.com/LINBIT/linstor-gateway/pkg/healthcheck" 11 | ) 12 | 13 | func checkHealthCommand() *cobra.Command { 14 | var defaultIscsiBackends = []string{"lio-t", "scst"} 15 | 16 | var mode string 17 | var iscsiBackends []string 18 | cmd := &cobra.Command{ 19 | Use: "check-health", 20 | Short: "Check if all requirements and dependencies are met on the current system", 21 | Long: `Check if all requirements and dependencies are met on the current system. 22 | 23 | The "mode" argument can be used to specify the type of node this 24 | system is intended to be used as. 25 | 26 | An "agent" node is responsible for actually running the highly available storage 27 | endpoint (for example, an iSCSI target). Thus, the health check ensures the 28 | current system is correctly configured to host the different kinds of storage 29 | protocols supported by LINSTOR Gateway. 30 | 31 | A "server" node acts as a relay between the client and the LINSTOR controller. 32 | The only requirement is that the LINSTOR controller can be reached. 33 | 34 | A "client" node interacts with the LINSTOR Gateway API. Its only requirement is 35 | that a LINSTOR Gateway server can be reached. 36 | `, 37 | Args: cobra.NoArgs, 38 | Run: func(cmd *cobra.Command, args []string) { 39 | controllers := viper.GetStringSlice("linstor.controllers") 40 | err := healthcheck.CheckRequirements(mode, iscsiBackends, controllers, cli) 41 | if err != nil { 42 | fmt.Println() 43 | log.Fatalf("Health check failed: %v", err) 44 | } 45 | }, 46 | } 47 | cmd.Flags().StringSlice("controllers", nil, "List of LINSTOR controllers to try to connect to (default from $LS_CONTROLLERS, or localhost:3370)") 48 | viper.BindPFlag("linstor.controllers", cmd.Flags().Lookup("controllers")) 49 | cmd.Flags().StringVarP(&mode, "mode", "m", "agent", `Which type of node to check requirements for. Can be "agent", "server", or "client"`) 50 | cmd.Flags().StringSliceVar(&iscsiBackends, "iscsi-backends", defaultIscsiBackends, "List of iSCSI backends to check for (one of 'lio-t', 'scst')") 51 | 52 | return cmd 53 | } 54 | -------------------------------------------------------------------------------- /pkg/reactor/reactor_test.go: -------------------------------------------------------------------------------- 1 | package reactor 2 | 3 | import ( 4 | "github.com/LINBIT/golinstor/client" 5 | "github.com/stretchr/testify/assert" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func TestFilterConfigs(t *testing.T) { 11 | t.Parallel() 12 | 13 | testcases := []struct { 14 | name string 15 | files []client.ExternalFile 16 | expectedConfigs []PromoterConfig 17 | expectedPaths []string 18 | wantErr bool 19 | }{{ 20 | name: "empty files", 21 | files: []client.ExternalFile{}, 22 | expectedConfigs: []PromoterConfig{}, 23 | expectedPaths: []string{}, 24 | }, { 25 | name: "one file", 26 | files: []client.ExternalFile{ 27 | { 28 | Path: filepath.Join(promoterDir, "linstor-gateway-iscsi-target1.toml"), 29 | Content: []byte(`[[promoter]]`), 30 | }, 31 | }, 32 | expectedConfigs: []PromoterConfig{{Resources: nil}}, 33 | expectedPaths: []string{filepath.Join(promoterDir, "linstor-gateway-iscsi-target1.toml")}, 34 | }, { 35 | name: "one file with invalid contents", 36 | files: []client.ExternalFile{ 37 | { 38 | Path: filepath.Join(promoterDir, "linstor-gateway-iscsi-target1.toml"), 39 | Content: []byte(`don't know what this is, but it's not toml!`), 40 | }, 41 | }, 42 | wantErr: true, 43 | }, { 44 | name: "one relevant file", 45 | files: []client.ExternalFile{ 46 | { 47 | Path: filepath.Join(promoterDir, "linstor-gateway-iscsi-target1.toml"), 48 | Content: []byte(`[[promoter]]`), 49 | }, 50 | {Path: "/some/other/file"}, 51 | {Path: filepath.Join(promoterDir, "oops-not-the-right-pattern.toml")}, 52 | }, 53 | expectedConfigs: []PromoterConfig{{Resources: nil}}, 54 | expectedPaths: []string{filepath.Join(promoterDir, "linstor-gateway-iscsi-target1.toml")}, 55 | }} 56 | 57 | for i := range testcases { 58 | tcase := &testcases[i] 59 | t.Run(tcase.name, func(t *testing.T) { 60 | t.Parallel() 61 | 62 | configs, paths, err := filterConfigs(tcase.files) 63 | if tcase.wantErr { 64 | assert.Error(t, err) 65 | } else { 66 | assert.NoError(t, err) 67 | assert.Equal(t, tcase.expectedConfigs, configs) 68 | assert.Equal(t, tcase.expectedPaths, paths) 69 | } 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /integration-tests/virter/roles/linstor-gateway/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Add LINBIT repo 3 | yum_repository: 4 | name: drbd 5 | description: LINBIT Packages for LINSTOR and DRBD 6 | baseurl: "{{ linbit_repo_baseurl }}/yum/rhel{{ rhel_major_version }}/drbd-9/x86_64" 7 | gpgcheck: true 8 | gpgkey: https://packages.linbit.com/package-signing-pubkey.asc 9 | tags: 10 | - preload 11 | 12 | - name: Add CI repo 13 | yum_repository: 14 | name: linbit-ci 15 | description: LINBIT CI Packages 16 | baseurl: "{{ ci_repo_baseurl }}" 17 | gpgcheck: false 18 | metadata_expire: "0" 19 | tags: 20 | - preload 21 | 22 | - name: Install resource-agents 23 | yum: 24 | pkg: 25 | - resource-agents 26 | enablerepo: drbd 27 | tags: 28 | - preload 29 | 30 | - name: Install resource-agents helpers 31 | yum: 32 | pkg: 33 | - iptables 34 | - targetcli 35 | tags: 36 | - preload 37 | 38 | - name: Install scst utils 39 | yum: 40 | pkg: 41 | - scstadmin 42 | - scst 43 | enablerepo: drbd 44 | tags: 45 | - preload 46 | 47 | - name: Install kmod-scst 48 | yum: 49 | pkg: 50 | - "kmod-scst-*_{{ kernel_version | regex_replace('^(\\d+\\.\\d+.\\d+)-(.*)\\.el.*$', '\\1_\\2') }}*" 51 | enablerepo: drbd 52 | tags: 53 | - preload 54 | 55 | - name: Configure automatic module loading 56 | copy: 57 | dest: /etc/modules-load.d/scst.conf 58 | content: | 59 | scst 60 | iscsi-scst 61 | scst_vdisk 62 | tags: 63 | - preload 64 | 65 | - name: Enable iscsi-scst service 66 | service: 67 | name: iscsi-scst 68 | state: stopped 69 | enabled: yes 70 | tags: 71 | - preload 72 | 73 | - name: Install nvme CLI 74 | yum: 75 | pkg: 76 | - nvme-cli 77 | - nvmetcli 78 | tags: 79 | - preload 80 | 81 | - name: Install LINSTOR Gateway 82 | yum: 83 | pkg: 84 | - "linstor-gateway-{{ linstor_gateway_version }}" 85 | update_cache: true 86 | register: linstor_gateway_install 87 | retries: 5 88 | until: linstor_gateway_install is success 89 | tags: 90 | - testimage 91 | 92 | - name: Disable nfs server 93 | service: 94 | name: nfs-server 95 | state: stopped 96 | enabled: no 97 | tags: 98 | - preload 99 | -------------------------------------------------------------------------------- /pkg/iscsi/iqn_test.go: -------------------------------------------------------------------------------- 1 | package iscsi_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/LINBIT/linstor-gateway/pkg/iscsi" 9 | ) 10 | 11 | func TestCheckIQN(t *testing.T) { 12 | t.Parallel() 13 | 14 | cases := []struct { 15 | descr string 16 | input string 17 | expectError bool 18 | }{{ 19 | descr: "default case", 20 | input: "iqn.2019-08.com.linbit:example", 21 | expectError: false, 22 | }, { 23 | descr: "missing date", 24 | input: "iqn.com.linbit:example", 25 | expectError: true, 26 | }, { 27 | descr: "missing domain", 28 | input: "iqn.2019-08:example", 29 | expectError: true, 30 | }, { 31 | descr: "missing unique part", 32 | input: "iqn.2019-08.com.linbit", 33 | expectError: true, 34 | }, { 35 | descr: "missing unique part, but with colon", 36 | input: "iqn.2019-08.com.linbit:", 37 | expectError: true, 38 | }, { 39 | descr: "invalid unique part, starts with digit", 40 | input: "iqn.2019-08.com.linbit:123example", 41 | expectError: true, 42 | }, { 43 | descr: "invalid unique part, too short", 44 | input: "iqn.2019-08.com.linbit:e", 45 | expectError: true, 46 | }, { 47 | descr: "contains _", 48 | input: "iqn.2019-08.com.lin_bit:example", 49 | expectError: true, 50 | }, { 51 | descr: "contains space", 52 | input: "iqn.2019-08.com.linbit:exa mple", 53 | expectError: true, 54 | }, { 55 | descr: "empty string", 56 | input: "", 57 | expectError: true, 58 | }, { 59 | descr: "missing iqn", 60 | input: "2019-08.com.linbit:example", 61 | expectError: true, 62 | }, { 63 | descr: "uppercase domain name", 64 | input: "iqn.2019-08.CoM.LiNBiT:example", 65 | expectError: true, 66 | }, { 67 | descr: "uppercase unique part", 68 | input: "iqn.2019-08.com.linbit:EXAmple", 69 | expectError: true, 70 | }} 71 | 72 | for i := range cases { 73 | tcase := &cases[i] 74 | t.Run(tcase.descr, func(t *testing.T) { 75 | t.Parallel() 76 | 77 | iqn, err := iscsi.NewIqn(tcase.input) 78 | if !tcase.expectError { 79 | assert.NoError(t, err) 80 | assert.Equal(t, tcase.input, iqn.String()) 81 | } else { 82 | assert.Error(t, err) 83 | } 84 | }) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /pkg/rest/server.go: -------------------------------------------------------------------------------- 1 | // Package rest provides the REST API to create highly-available iSCSI targets. 2 | package rest 3 | 4 | import ( 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "sync" 9 | 10 | log "github.com/sirupsen/logrus" 11 | 12 | "github.com/LINBIT/linstor-gateway/pkg/iscsi" 13 | "github.com/LINBIT/linstor-gateway/pkg/nfs" 14 | "github.com/LINBIT/linstor-gateway/pkg/nvmeof" 15 | 16 | "github.com/gorilla/mux" 17 | "github.com/rs/cors" 18 | ) 19 | 20 | type server struct { 21 | router *mux.Router 22 | iscsi *iscsi.ISCSI 23 | nfs *nfs.NFS 24 | nvmeof *nvmeof.NVMeoF 25 | sync.Mutex 26 | } 27 | 28 | // Error is the type that is returned in case of an error. 29 | type Error struct { 30 | Code string `json:"code"` 31 | Message string `json:"message"` 32 | } 33 | 34 | func (e Error) Error() string { 35 | return e.Message 36 | } 37 | 38 | // Errorf takes a StatusCode, a ResponseWriter and a format string. 39 | // It sets up the REST response and writes it to the ResponseWriter 40 | // It also sets the according error code. 41 | func Errorf(code int, w http.ResponseWriter, format string, a ...interface{}) (n int, err error) { 42 | e := Error{ 43 | Code: http.StatusText(code), 44 | Message: fmt.Sprintf(format, a...), 45 | } 46 | 47 | b, err := json.Marshal(&e) 48 | if err != nil { 49 | return 0, err 50 | } 51 | 52 | w.WriteHeader(code) 53 | return fmt.Fprint(w, string(b)) 54 | } 55 | 56 | func MustError(code int, w http.ResponseWriter, format string, a ...interface{}) { 57 | _, err := Errorf(code, w, format, a...) 58 | if err != nil { 59 | log.WithError(err).Warn("failed to write error response") 60 | } 61 | } 62 | 63 | // ListenAndServe is the entry point for the REST API 64 | func ListenAndServe(addr string, controllers []string) { 65 | iscsi, err := iscsi.New(controllers) 66 | if err != nil { 67 | log.Fatalf("Failed to initialize ISCSI: %v", err) 68 | } 69 | nfs, err := nfs.New(controllers) 70 | if err != nil { 71 | log.Fatalf("Failed to initialize NFS: %v", err) 72 | } 73 | nvmeof, err := nvmeof.New(controllers) 74 | if err != nil { 75 | log.Fatalf("Failed to initialize NVMeoF: %v", err) 76 | } 77 | s := &server{ 78 | router: mux.NewRouter(), 79 | iscsi: iscsi, 80 | nfs: nfs, 81 | nvmeof: nvmeof, 82 | } 83 | 84 | s.routes() 85 | 86 | log.Fatal(http.ListenAndServe(addr, cors.Default().Handler(s.router))) 87 | } 88 | -------------------------------------------------------------------------------- /pkg/upgrade/iscsi.go: -------------------------------------------------------------------------------- 1 | package upgrade 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/LINBIT/golinstor/client" 7 | "github.com/LINBIT/linstor-gateway/pkg/iscsi" 8 | "github.com/LINBIT/linstor-gateway/pkg/reactor" 9 | ) 10 | 11 | // iscsiMigrations defines the operations for upgrading a single version. 12 | // The array index ("n") denotes the starting version; the function at 13 | // index "n" migrates from version "n" to version "n+1". 14 | var iscsiMigrations = []func(cfg *reactor.PromoterConfig) error{ 15 | 0: removeID, 16 | } 17 | 18 | func upgradeIscsi(ctx context.Context, linstor *client.Client, name string, forceYes bool, dryRun bool) (bool, error) { 19 | const gatewayConfigPath = "/etc/drbd-reactor.d/linstor-gateway-iscsi-%s.toml" 20 | cfg, _, _, _, err := parseExistingConfig(ctx, linstor, fmt.Sprintf(gatewayConfigPath, name)) 21 | if err != nil { 22 | return false, err 23 | } 24 | if cfg.Metadata.LinstorGatewaySchemaVersion > iscsi.CurrentVersion { 25 | return false, fmt.Errorf("schema version %d is not supported", 26 | cfg.Metadata.LinstorGatewaySchemaVersion) 27 | } 28 | newCfg, _, _, _, err := parseExistingConfig(ctx, linstor, fmt.Sprintf(gatewayConfigPath, name)) 29 | if err != nil { 30 | return false, err 31 | } 32 | 33 | for i := cfg.Metadata.LinstorGatewaySchemaVersion; i < iscsi.CurrentVersion; i++ { 34 | err := iscsiMigrations[i](newCfg) 35 | if err != nil { 36 | return false, fmt.Errorf("failed to migrate from version %d to %d: %w", i, i+1, err) 37 | } 38 | } 39 | newCfg.Metadata.LinstorGatewaySchemaVersion = iscsi.CurrentVersion 40 | 41 | return maybeWriteNewConfig(ctx, linstor, cfg, newCfg, fmt.Sprintf(iscsi.IDFormat, name), forceYes, dryRun) 42 | } 43 | 44 | func Iscsi(ctx context.Context, linstor *client.Client, iqn iscsi.Iqn, forceYes bool, dryRun bool) error { 45 | var didAny bool 46 | didDrbd, err := upgradeDrbdOptions(ctx, linstor, iqn.WWN(), forceYes, dryRun) 47 | if err != nil { 48 | return fmt.Errorf("failed to upgrade drbd options: %w", err) 49 | } 50 | if didDrbd { 51 | didAny = true 52 | } 53 | didIscsi, err := upgradeIscsi(ctx, linstor, iqn.WWN(), forceYes, dryRun) 54 | if err != nil { 55 | return fmt.Errorf("failed to upgrade promoter config: %w", err) 56 | } 57 | if didIscsi { 58 | didAny = true 59 | } 60 | if !didAny { 61 | fmt.Printf("%s is already up to date.\n", iqn.WWN()) 62 | } 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /pkg/upgrade/nvmeof.go: -------------------------------------------------------------------------------- 1 | package upgrade 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/LINBIT/golinstor/client" 7 | "github.com/LINBIT/linstor-gateway/pkg/nvmeof" 8 | "github.com/LINBIT/linstor-gateway/pkg/reactor" 9 | ) 10 | 11 | // nvmeOfMigrations defines the operations for upgrading a single version. 12 | // The array index ("n") denotes the starting version; the function at 13 | // index "n" migrates from version "n" to version "n+1". 14 | var nvmeOfMigrations = []func(cfg *reactor.PromoterConfig) error{ 15 | 0: removeID, 16 | } 17 | 18 | func upgradeNvmeOf(ctx context.Context, linstor *client.Client, name string, forceYes, dryRun bool) (bool, error) { 19 | const gatewayConfigPath = "/etc/drbd-reactor.d/linstor-gateway-nvmeof-%s.toml" 20 | cfg, _, _, _, err := parseExistingConfig(ctx, linstor, fmt.Sprintf(gatewayConfigPath, name)) 21 | if err != nil { 22 | return false, err 23 | } 24 | if cfg.Metadata.LinstorGatewaySchemaVersion > nvmeof.CurrentVersion { 25 | return false, fmt.Errorf("schema version %d is not supported", 26 | cfg.Metadata.LinstorGatewaySchemaVersion) 27 | } 28 | newCfg, _, _, _, err := parseExistingConfig(ctx, linstor, fmt.Sprintf(gatewayConfigPath, name)) 29 | if err != nil { 30 | return false, err 31 | } 32 | 33 | for i := cfg.Metadata.LinstorGatewaySchemaVersion; i < nvmeof.CurrentVersion; i++ { 34 | err := nvmeOfMigrations[i](newCfg) 35 | if err != nil { 36 | return false, fmt.Errorf("failed to migrate from version %d to %d: %w", i, i+1, err) 37 | } 38 | } 39 | newCfg.Metadata.LinstorGatewaySchemaVersion = nvmeof.CurrentVersion 40 | 41 | return maybeWriteNewConfig(ctx, linstor, cfg, newCfg, fmt.Sprintf(nvmeof.IDFormat, name), forceYes, dryRun) 42 | } 43 | 44 | func NvmeOf(ctx context.Context, linstor *client.Client, nqn nvmeof.Nqn, forceYes, dryRun bool) error { 45 | var didAny bool 46 | didDrbd, err := upgradeDrbdOptions(ctx, linstor, nqn.Subsystem(), forceYes, dryRun) 47 | if err != nil { 48 | return fmt.Errorf("failed to upgrade drbd options: %w", err) 49 | } 50 | if didDrbd { 51 | didAny = true 52 | } 53 | didNvme, err := upgradeNvmeOf(ctx, linstor, nqn.Subsystem(), forceYes, dryRun) 54 | if err != nil { 55 | return fmt.Errorf("failed to upgrade promoter config: %w", err) 56 | } 57 | if didNvme { 58 | didAny = true 59 | } 60 | if !didAny { 61 | fmt.Printf("%s is already up to date.\n", nqn.Subsystem()) 62 | } 63 | return nil 64 | } 65 | -------------------------------------------------------------------------------- /client/iscsi.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/LINBIT/linstor-gateway/pkg/common" 8 | "github.com/LINBIT/linstor-gateway/pkg/iscsi" 9 | ) 10 | 11 | type ISCSIService struct { 12 | client *Client 13 | } 14 | 15 | func (s *ISCSIService) GetAll(ctx context.Context) ([]*iscsi.ResourceConfig, error) { 16 | var configs []*iscsi.ResourceConfig 17 | _, err := s.client.doGET(ctx, "/api/v2/iscsi", &configs) 18 | return configs, err 19 | } 20 | 21 | func (s *ISCSIService) Create(ctx context.Context, config *iscsi.ResourceConfig) (*iscsi.ResourceConfig, error) { 22 | var ret *iscsi.ResourceConfig 23 | _, err := s.client.doPOST(ctx, "/api/v2/iscsi", config, &ret) 24 | return ret, err 25 | } 26 | 27 | func (s *ISCSIService) Get(ctx context.Context, iqn iscsi.Iqn) (*iscsi.ResourceConfig, error) { 28 | var config *iscsi.ResourceConfig 29 | _, err := s.client.doGET(ctx, "/api/v2/iscsi/"+iqn.String(), &config) 30 | return config, err 31 | } 32 | 33 | func (s *ISCSIService) Delete(ctx context.Context, iqn iscsi.Iqn) error { 34 | _, err := s.client.doDELETE(ctx, "/api/v2/iscsi/"+iqn.String(), nil) 35 | return err 36 | } 37 | 38 | func (s *ISCSIService) Start(ctx context.Context, iqn iscsi.Iqn) (*iscsi.ResourceConfig, error) { 39 | var ret *iscsi.ResourceConfig 40 | _, err := s.client.doPOST(ctx, "/api/v2/iscsi/"+iqn.String()+"/start", nil, &ret) 41 | return ret, err 42 | } 43 | 44 | func (s *ISCSIService) Stop(ctx context.Context, iqn iscsi.Iqn) (*iscsi.ResourceConfig, error) { 45 | var ret *iscsi.ResourceConfig 46 | _, err := s.client.doPOST(ctx, "/api/v2/iscsi/"+iqn.String()+"/stop", nil, &ret) 47 | return ret, err 48 | } 49 | 50 | func (s *ISCSIService) GetLogicalUnit(ctx context.Context, iqn iscsi.Iqn, lun int) (*common.VolumeConfig, error) { 51 | var config *common.VolumeConfig 52 | _, err := s.client.doGET(ctx, fmt.Sprintf("/api/v2/iscsi/%s/%d", iqn.String(), lun), &config) 53 | return config, err 54 | } 55 | 56 | func (s *ISCSIService) AddLogicalUnit(ctx context.Context, iqn iscsi.Iqn, volume *common.VolumeConfig) (*common.VolumeConfig, error) { 57 | var ret *common.VolumeConfig 58 | _, err := s.client.doPUT(ctx, fmt.Sprintf("/api/v2/iscsi/%s/%d", iqn.String(), volume.Number), volume, &ret) 59 | return ret, err 60 | } 61 | 62 | func (s *ISCSIService) DeleteLogicalUnit(ctx context.Context, iqn iscsi.Iqn, lun int) error { 63 | _, err := s.client.doDELETE(ctx, fmt.Sprintf("/api/v2/iscsi/%s/%d", iqn.String(), lun), nil) 64 | return err 65 | } 66 | -------------------------------------------------------------------------------- /pkg/common/resource_config.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/LINBIT/golinstor/client" 10 | log "github.com/sirupsen/logrus" 11 | 12 | "github.com/LINBIT/linstor-gateway/pkg/reactor" 13 | ) 14 | 15 | const ( 16 | clusterPrivateVolumeSizeKiB = 64 * 1024 // 64MiB 17 | clusterPrivateVolumeFileSystem = "ext4" 18 | ClusterPrivateVolumeMountPath = "/srv/ha/internal" 19 | ClusterPrivateVolumeAgentName = "fs_cluster_private" 20 | ) 21 | 22 | func DevicePath(vol client.Volume) string { 23 | devPath := vol.DevicePath 24 | for k, v := range vol.Props { 25 | if strings.HasPrefix(k, "Satellite/Device/Symlinks/") { 26 | devPath = v 27 | } 28 | 29 | // Prefer the "by-res" symlinks 30 | if strings.Contains(v, "/by-res/") { 31 | break 32 | } 33 | } 34 | return devPath 35 | } 36 | 37 | func ClusterPrivateVolume() VolumeConfig { 38 | return VolumeConfig{ 39 | Number: 0, 40 | SizeKiB: clusterPrivateVolumeSizeKiB, 41 | FileSystem: clusterPrivateVolumeFileSystem, 42 | FileSystemRootOwner: UserGroup{User: "root", Group: "root"}, 43 | } 44 | } 45 | 46 | func ClusterPrivateVolumeAgent(deployedVol client.Volume, resource string) *reactor.ResourceAgent { 47 | return &reactor.ResourceAgent{ 48 | Type: "ocf:heartbeat:Filesystem", 49 | Name: ClusterPrivateVolumeAgentName, 50 | Attributes: map[string]string{ 51 | "device": DevicePath(deployedVol), 52 | "directory": filepath.Join(ClusterPrivateVolumeMountPath, resource), 53 | "fstype": clusterPrivateVolumeFileSystem, 54 | "run_fsck": "no", 55 | }, 56 | } 57 | } 58 | 59 | func CheckIPCollision(config reactor.PromoterConfig, checkIP net.IP) error { 60 | name, rscCfg := config.FirstResource() 61 | if rscCfg == nil { 62 | return fmt.Errorf("no resource found in config") 63 | } 64 | for _, entry := range rscCfg.Start { 65 | switch agent := entry.(type) { 66 | case *reactor.ResourceAgent: 67 | switch agent.Type { 68 | case "ocf:heartbeat:IPaddr2": 69 | ip := net.ParseIP(agent.Attributes["ip"]) 70 | if ip == nil { 71 | return fmt.Errorf("malformed IP address %s in agent %s of config %s", 72 | agent.Attributes["ip"], agent.Name, name) 73 | } 74 | log.Debugf("checking IP %s", ip) 75 | if ip.Equal(checkIP) { 76 | return fmt.Errorf("IP address %s already in use by config %s", 77 | ip.String(), name) 78 | } 79 | } 80 | } 81 | } 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/LINBIT/linstor-gateway 2 | 3 | require ( 4 | bitbucket.org/creachadair/shell v0.0.8 5 | github.com/LINBIT/golinstor v0.56.1 6 | github.com/coreos/go-systemd/v22 v22.5.0 7 | github.com/fatih/color v1.18.0 8 | github.com/google/go-cmp v0.7.0 9 | github.com/google/uuid v1.6.0 10 | github.com/gorilla/mux v1.8.1 11 | github.com/icza/gog v0.0.0-20241010132004-5da24f18211d 12 | github.com/mitchellh/go-ps v1.0.0 13 | github.com/moul/http2curl v1.0.0 14 | github.com/olekukonko/tablewriter v0.0.5 15 | github.com/pelletier/go-toml v1.9.5 16 | github.com/rck/unit v0.0.3 17 | github.com/rs/cors v1.11.1 18 | github.com/sergi/go-diff v1.4.0 19 | github.com/sirupsen/logrus v1.9.3 20 | github.com/spf13/cobra v1.9.1 21 | github.com/spf13/viper v1.20.1 22 | github.com/stretchr/testify v1.10.0 23 | ) 24 | 25 | require ( 26 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 27 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 28 | github.com/donovanhide/eventsource v0.0.0-20210830082556-c59027999da0 // indirect 29 | github.com/fsnotify/fsnotify v1.9.0 // indirect 30 | github.com/go-viper/mapstructure/v2 v2.3.0 // indirect 31 | github.com/godbus/dbus/v5 v5.1.0 // indirect 32 | github.com/google/go-querystring v1.1.0 // indirect 33 | github.com/gopherjs/gopherjs v1.17.2 // indirect 34 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 35 | github.com/mattn/go-colorable v0.1.14 // indirect 36 | github.com/mattn/go-isatty v0.0.20 // indirect 37 | github.com/mattn/go-runewidth v0.0.16 // indirect 38 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 39 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 40 | github.com/rivo/uniseg v0.4.7 // indirect 41 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 42 | github.com/sagikazarmark/locafero v0.9.0 // indirect 43 | github.com/smartystreets/assertions v1.13.0 // indirect 44 | github.com/smartystreets/goconvey v1.7.2 // indirect 45 | github.com/sourcegraph/conc v0.3.0 // indirect 46 | github.com/spf13/afero v1.14.0 // indirect 47 | github.com/spf13/cast v1.9.2 // indirect 48 | github.com/spf13/pflag v1.0.6 // indirect 49 | github.com/subosito/gotenv v1.6.0 // indirect 50 | go.uber.org/multierr v1.11.0 // indirect 51 | golang.org/x/sys v0.33.0 // indirect 52 | golang.org/x/text v0.26.0 // indirect 53 | golang.org/x/time v0.12.0 // indirect 54 | gopkg.in/yaml.v3 v3.0.1 // indirect 55 | moul.io/http2curl/v2 v2.3.0 // indirect 56 | ) 57 | 58 | go 1.23.0 59 | -------------------------------------------------------------------------------- /client/nvmeof.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/LINBIT/linstor-gateway/pkg/common" 8 | "github.com/LINBIT/linstor-gateway/pkg/nvmeof" 9 | ) 10 | 11 | type NvmeOfService struct { 12 | client *Client 13 | } 14 | 15 | func (s *NvmeOfService) GetAll(ctx context.Context) ([]nvmeof.ResourceConfig, error) { 16 | var configs []nvmeof.ResourceConfig 17 | _, err := s.client.doGET(ctx, "/api/v2/nvme-of", &configs) 18 | return configs, err 19 | } 20 | 21 | func (s *NvmeOfService) Create(ctx context.Context, config *nvmeof.ResourceConfig) (*nvmeof.ResourceConfig, error) { 22 | var ret *nvmeof.ResourceConfig 23 | _, err := s.client.doPOST(ctx, "/api/v2/nvme-of", config, &ret) 24 | return ret, err 25 | } 26 | 27 | func (s *NvmeOfService) Get(ctx context.Context, nqn nvmeof.Nqn) (*nvmeof.ResourceConfig, error) { 28 | var config *nvmeof.ResourceConfig 29 | _, err := s.client.doGET(ctx, "/api/v2/nvme-of/"+nqn.String(), &config) 30 | return config, err 31 | } 32 | 33 | func (s *NvmeOfService) Delete(ctx context.Context, nqn nvmeof.Nqn) error { 34 | _, err := s.client.doDELETE(ctx, "/api/v2/nvme-of/"+nqn.String(), nil) 35 | return err 36 | } 37 | 38 | func (s *NvmeOfService) Start(ctx context.Context, nqn nvmeof.Nqn) (*nvmeof.ResourceConfig, error) { 39 | var ret *nvmeof.ResourceConfig 40 | _, err := s.client.doPOST(ctx, "/api/v2/nvme-of/"+nqn.String()+"/start", nil, &ret) 41 | return ret, err 42 | } 43 | 44 | func (s *NvmeOfService) Stop(ctx context.Context, nqn nvmeof.Nqn) (*nvmeof.ResourceConfig, error) { 45 | var ret *nvmeof.ResourceConfig 46 | _, err := s.client.doPOST(ctx, "/api/v2/nvme-of/"+nqn.String()+"/stop", nil, &ret) 47 | return ret, err 48 | } 49 | 50 | func (s *NvmeOfService) GetVolume(ctx context.Context, nqn nvmeof.Nqn, lun int) (*common.VolumeConfig, error) { 51 | var config *common.VolumeConfig 52 | _, err := s.client.doGET(ctx, fmt.Sprintf("/api/v2/nvme-of/%s/%d", nqn.String(), lun), &config) 53 | return config, err 54 | } 55 | 56 | func (s *NvmeOfService) AddVolume(ctx context.Context, nqn nvmeof.Nqn, volume *common.VolumeConfig) (*common.VolumeConfig, error) { 57 | var ret *common.VolumeConfig 58 | _, err := s.client.doPUT(ctx, fmt.Sprintf("/api/v2/nvme-of/%s/%d", nqn.String(), volume.Number), volume, &ret) 59 | return ret, err 60 | } 61 | 62 | func (s *NvmeOfService) DeleteVolume(ctx context.Context, nqn nvmeof.Nqn, volume int) error { 63 | _, err := s.client.doDELETE(ctx, fmt.Sprintf("/api/v2/nvme-of/%s/%d", nqn.String(), volume), nil) 64 | return err 65 | } 66 | -------------------------------------------------------------------------------- /pkg/reactor/resourceagent.go: -------------------------------------------------------------------------------- 1 | package reactor 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | 9 | "bitbucket.org/creachadair/shell" 10 | ) 11 | 12 | // ResourceAgent is an entry within a drbd-reactor config that describes an 13 | // ocf resource agent. 14 | // The structure of such an ocf resource definition is as follows: 15 | // 16 | // ocf:$vendor:$agent $instance-id name=value ... 17 | // 18 | // For details on how these, fields are decoded, see UnmarshalText. 19 | type ResourceAgent struct { 20 | Type string 21 | Name string 22 | Attributes map[string]string 23 | } 24 | 25 | // UnmarshalText parses a ResourceAgent from its string representation, as 26 | // defined by the drbd-reactor configuration format. 27 | // The structure of such an ocf resource definition is as follows: 28 | // 29 | // ocf:$vendor:$agent $instance-id name=value ... 30 | // 31 | // The first part, "ocf:$vendor:$agent" will be put into the "Type" field of the 32 | // resulting ResourceAgent struct. 33 | // $instance-id is the unique name of the resource agent instance, and will end 34 | // up in the "Name" field of the ResourceAgent struct. 35 | // After these fields follow an arbitrary number of optional key-value pairs. 36 | // They will be parsed into the "Attributes" map of the ResourceAgent struct. 37 | func (r *ResourceAgent) UnmarshalText(text []byte) error { 38 | parts, valid := shell.Split(string(text)) 39 | if !valid || len(parts) < 2 { 40 | return errors.New("expected at least type and name") 41 | } 42 | 43 | r.Type = parts[0] 44 | r.Name = parts[1] 45 | for _, arg := range parts[2:] { 46 | kv := strings.SplitN(arg, "=", 2) 47 | if len(kv) != 2 { 48 | return errors.New("expected key=value pairs as arguments") 49 | } 50 | 51 | if r.Attributes == nil { 52 | r.Attributes = map[string]string{} 53 | } 54 | r.Attributes[kv[0]] = kv[1] 55 | } 56 | 57 | return nil 58 | } 59 | 60 | func (r ResourceAgent) MarshalText() (text []byte, err error) { 61 | if r.Type == "" { 62 | return nil, fmt.Errorf("invalid resource agent without type") 63 | } 64 | if r.Name == "" { 65 | return nil, fmt.Errorf("invalid resource agent without name") 66 | } 67 | args := make([]string, 0, len(r.Attributes)) 68 | for k, v := range r.Attributes { 69 | args = append(args, fmt.Sprintf("%s=%s", k, shell.Quote(v))) 70 | } 71 | 72 | // Ensure consistent serialization order 73 | sort.Strings(args) 74 | 75 | return []byte(strings.Trim(fmt.Sprintf("%s %s %s", r.Type, r.Name, strings.Join(args, " ")), " ")), nil 76 | } 77 | 78 | var _ StartEntry = &ResourceAgent{} 79 | -------------------------------------------------------------------------------- /pkg/upgrade/drbd.go: -------------------------------------------------------------------------------- 1 | package upgrade 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/LINBIT/golinstor/client" 7 | "github.com/LINBIT/linstor-gateway/pkg/linstorcontrol" 8 | "github.com/LINBIT/linstor-gateway/pkg/prompt" 9 | "github.com/olekukonko/tablewriter" 10 | log "github.com/sirupsen/logrus" 11 | "os" 12 | ) 13 | 14 | func checkDrbdOptions(resDef client.ResourceDefinition) map[string][2]string { 15 | overrides := make(map[string][2]string) 16 | for key, targetValue := range linstorcontrol.DefaultResourceProps() { 17 | if resDef.Props[key] == targetValue { 18 | log.WithFields(log.Fields{ 19 | "key": key, 20 | "fromValue": resDef.Props[key], 21 | }).Debugf("DRBD option already correctly set") 22 | continue 23 | } 24 | fromValue := resDef.Props[key] 25 | log.WithFields(log.Fields{ 26 | "key": key, 27 | "fromValue": fromValue, 28 | "toValue": targetValue, 29 | }).Debugf("Changing DRBD option") 30 | overrides[key] = [2]string{fromValue, targetValue} 31 | } 32 | return overrides 33 | } 34 | 35 | // upgradeDrbdOptions checks if the options of the given resource are current, 36 | // and changes them if necessary. It returns a boolean indicating whether any 37 | // changes were made, and an error, if any. 38 | func upgradeDrbdOptions(ctx context.Context, linstor *client.Client, resource string, forceYes bool, dryRun bool) (bool, error) { 39 | resDef, err := linstor.ResourceDefinitions.Get(ctx, resource) 40 | if err != nil { 41 | return false, fmt.Errorf("failed to get resource definition: %w", err) 42 | } 43 | replaceOptions := checkDrbdOptions(resDef) 44 | if len(replaceOptions) == 0 { 45 | // nothing to do 46 | return false, nil 47 | } 48 | fmt.Println("The following resource options need to be changed:") 49 | table := tablewriter.NewWriter(os.Stdout) 50 | table.SetHeader([]string{"Property", "Old Value", "New Value"}) 51 | 52 | overrides := make(map[string]string, len(replaceOptions)) 53 | for k, v := range replaceOptions { 54 | table.SetColumnColor(tablewriter.Colors{}, tablewriter.Colors{tablewriter.FgRedColor}, tablewriter.Colors{tablewriter.FgGreenColor}) 55 | table.Append([]string{k, v[0], v[1]}) 56 | overrides[k] = v[1] 57 | } 58 | table.Render() // Send output 59 | fmt.Println() 60 | if dryRun { 61 | return true, nil 62 | } 63 | if !forceYes { 64 | yes := prompt.Confirm("Change these options now?") 65 | if !yes { 66 | // abort 67 | return false, fmt.Errorf("aborted") 68 | } 69 | } 70 | err = linstor.ResourceDefinitions.Modify(ctx, resource, client.GenericPropsModify{ 71 | OverrideProps: overrides, 72 | }) 73 | if err != nil { 74 | return false, fmt.Errorf("failed to modify resource definition: %w", err) 75 | } 76 | return true, nil 77 | } 78 | -------------------------------------------------------------------------------- /pkg/healthcheck/reactor.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "fmt" 5 | "github.com/fatih/color" 6 | log "github.com/sirupsen/logrus" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | type checkReactorAutoReload struct { 13 | } 14 | 15 | func fileContentsEqual(filenameA, filenameB string) (bool, error) { 16 | a, err := os.ReadFile(filenameA) 17 | if err != nil { 18 | return false, fmt.Errorf("could not read %s: %w", filenameA, err) 19 | } 20 | 21 | b, err := os.ReadFile(filenameB) 22 | if err != nil { 23 | return false, fmt.Errorf("could not read %s: %w", filenameB, err) 24 | } 25 | 26 | return string(a) == string(b), nil 27 | } 28 | 29 | func (c *checkReactorAutoReload) check(prevError bool) error { 30 | if prevError { 31 | // reactor not installed, no need to check 32 | return nil 33 | } 34 | status, err := unitStatus("drbd-reactor-reload.path") 35 | if err != nil { 36 | return err 37 | } 38 | if status.ActiveState != "active" { 39 | return fmt.Errorf("service drbd-reactor-reload.path is not started") 40 | } 41 | 42 | dir := guessReactorReloadDir() 43 | sourcePath := filepath.Join(dir, "drbd-reactor-reload.path") 44 | sourceService := filepath.Join(dir, "drbd-reactor-reload.service") 45 | 46 | destPath := "/etc/systemd/system/drbd-reactor-reload.path" 47 | destService := "/etc/systemd/system/drbd-reactor-reload.service" 48 | 49 | for _, path := range [][]string{{sourcePath, destPath}, {sourceService, destService}} { 50 | equal, err := fileContentsEqual(path[0], path[1]) 51 | if err != nil { 52 | return fmt.Errorf("could not compare %s and %s: %w", path[0], path[1], err) 53 | } 54 | if !equal { 55 | return fmt.Errorf("%s differs from %s", path[0], path[1]) 56 | } 57 | } 58 | 59 | return nil 60 | } 61 | 62 | func guessReactorReloadDir() string { 63 | paths := []string{ 64 | "/usr/share/doc/drbd-reactor/examples", 65 | "/usr/share/doc/drbd-reactor", 66 | "/usr/share/doc/drbd-reactor-*/examples", 67 | "/usr/share/doc/drbd-reactor-*", 68 | } 69 | for _, p := range paths { 70 | matches, err := filepath.Glob(p) 71 | if err != nil { 72 | log.Debugf("Glob failed: %v", err) 73 | continue 74 | } 75 | log.Debugf("Glob %s -> %v", p, matches) 76 | if len(matches) > 0 { 77 | return matches[0] 78 | } 79 | } 80 | return "" 81 | } 82 | 83 | func (c *checkReactorAutoReload) format(err error) string { 84 | dir := guessReactorReloadDir() 85 | var b strings.Builder 86 | fmt.Fprintf(&b, " %s drbd-reactor is not configured to automatically reload\n", color.RedString("✗")) 87 | fmt.Fprintf(&b, " %s\n", faint("→ %s", err.Error())) 88 | if dir != "" { 89 | path := filepath.Join(dir, "drbd-reactor-reload.{path,service}") 90 | fmt.Fprintf(&b, " Please execute:\n") 91 | fmt.Fprintf(&b, " %s\n", bold("cp %s /etc/systemd/system/", path)) 92 | fmt.Fprintf(&b, " %s\n", bold("systemctl enable --now drbd-reactor-reload.path")) 93 | } 94 | fmt.Fprintf(&b, " Learn more at https://github.com/LINBIT/drbd-reactor/#automatic-reload\n") 95 | return b.String() 96 | } 97 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROG := linstor-gateway 2 | 3 | GOSOURCES=$(shell find . -type f -name "*.go" -not -path "./vendor/*" -printf "%P\n") 4 | DESTDIR = 5 | 6 | all: linstor-gateway 7 | 8 | linstor-gateway: $(GOSOURCES) version.env 9 | . ./version.env; \ 10 | NAME="$@"; \ 11 | [ -n "$(GOOS)" ] && NAME="$${NAME}-$(GOOS)"; \ 12 | [ -n "$(GOARCH)" ] && NAME="$${NAME}-$(GOARCH)"; \ 13 | go build -o "$$NAME" \ 14 | -ldflags "-X github.com/LINBIT/linstor-gateway/pkg/version.Version=$${VERSION} \ 15 | -X 'github.com/LINBIT/linstor-gateway/pkg/version.BuildDate=$(shell LC_ALL=C date --utc)' \ 16 | -X github.com/LINBIT/linstor-gateway/pkg/version.GitCommit=$${GITHASH}" 17 | 18 | .PHONY: install 19 | install: 20 | install -D -m 0750 $(PROG) $(DESTDIR)/usr/sbin/$(PROG) 21 | install -d -m 0750 $(DESTDIR)/etc/linstor-gateway 22 | install -D -m 0644 $(PROG).service $(DESTDIR)/usr/lib/systemd/system/$(PROG).service 23 | 24 | .PHONY: release 25 | release: 26 | make --always-make linstor-gateway GOOS=linux GOARCH=amd64 27 | 28 | # internal, public doc on swagger 29 | docs/rest/index.html: docs/rest_v1_openapi.yaml 30 | docker run --user="$$(id -u):$$(id -g)" --rm -v $$PWD/docs:/local \ 31 | openapitools/openapi-generator-cli generate -i /local/rest_v1_openapi.yaml -g html -o /local/rest 32 | 33 | # internal, public doc on swagger 34 | api-doc: docs/rest/index.html 35 | 36 | .PHONY: md-doc 37 | md-doc: linstor-gateway 38 | ./linstor-gateway docs 39 | 40 | .PHONY: test 41 | test: 42 | go test ./... 43 | 44 | .PHONY: prepare-release 45 | prepare-release: test md-doc 46 | go mod tidy 47 | 48 | vendor: go.mod go.sum 49 | go mod vendor 50 | 51 | version.env: 52 | if [ -n "$(VERSION)" ]; then \ 53 | VERSION="$(VERSION)"; \ 54 | else \ 55 | # default to latest git tag \ 56 | VERSION=$(shell git describe --abbrev=0 --tags | tr -d 'v'); \ 57 | fi; \ 58 | echo "VERSION=$${VERSION}" > version.env 59 | echo "GITHASH=$(shell git describe --abbrev=0 --always)" >> version.env 60 | 61 | .PHONY: debrelease 62 | debrelease: clean clean-version vendor version.env checkVERSION 63 | dh_clean || true 64 | tar --transform="s,^,linstor-gateway-$(VERSION)/," --owner=0 --group=0 -czf linstor-gateway-$(VERSION).tar.gz \ 65 | $(GOSOURCES) go.mod go.sum vendor version.env Makefile \ 66 | debian linstor-gateway.spec linstor-gateway.service linstor-gateway.xml 67 | 68 | ifndef VERSION 69 | checkVERSION: 70 | $(error environment variable VERSION is not set) 71 | else 72 | checkVERSION: 73 | ifdef FORCE 74 | true 75 | else 76 | test -z "$$(git ls-files -m)" || { echo "Uncommitted files in working directory"; exit 1; } 77 | lbvers.py check --base=$(BASE) --build=$(BUILD) --build-nr=$(BUILD_NR) --pkg-nr=$(PKG_NR) \ 78 | --rpm-spec=linstor-gateway.spec --debian-changelog=debian/changelog --changelog=CHANGELOG.md 79 | endif 80 | endif 81 | 82 | .PHONY: clean 83 | clean: 84 | rm -f linstor-gateway 85 | 86 | .PHONY: clean-version 87 | clean-version: 88 | rm -f version.env 89 | 90 | sbom/linstor-gateway.cdx.json: 91 | @mkdir -p sbom 92 | go run github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@latest app -json -output $@ 93 | -------------------------------------------------------------------------------- /pkg/upgrade/upgrade.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package upgrade migrates existing resources to the latest version. 3 | We will always try to upgrade to the most modern version of the drbd-reactor configuration. 4 | */ 5 | package upgrade 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "github.com/LINBIT/golinstor/client" 11 | "github.com/LINBIT/linstor-gateway/pkg/prompt" 12 | "github.com/LINBIT/linstor-gateway/pkg/reactor" 13 | "github.com/google/go-cmp/cmp" 14 | "github.com/pelletier/go-toml" 15 | "github.com/sergi/go-diff/diffmatchpatch" 16 | "strings" 17 | ) 18 | 19 | func encode(cfg *reactor.PromoterConfig) (string, error) { 20 | buffer := strings.Builder{} 21 | encoder := toml.NewEncoder(&buffer).ArraysWithOneElementPerLine(true) 22 | 23 | err := encoder.Encode(&reactor.Config{Promoter: []reactor.PromoterConfig{*cfg}}) 24 | if err != nil { 25 | return "", fmt.Errorf("error encoding toml: %w", err) 26 | } 27 | return buffer.String(), nil 28 | } 29 | 30 | func parseExistingConfig(ctx context.Context, linstor *client.Client, path string) (*reactor.PromoterConfig, *client.ResourceDefinition, []client.VolumeDefinition, []client.ResourceWithVolumes, error) { 31 | file, err := linstor.Controller.GetExternalFile(ctx, path) 32 | if err != nil { 33 | return nil, nil, nil, nil, fmt.Errorf("failed to fetch promoter config: %w", err) 34 | } 35 | 36 | var fullCfg reactor.Config 37 | err = toml.Unmarshal(file.Content, &fullCfg) 38 | if err != nil { 39 | return nil, nil, nil, nil, fmt.Errorf("failed to decode promoter config: %w", err) 40 | } 41 | cfg := &fullCfg.Promoter[0] 42 | 43 | resourceDefinition, _, volumeDefinitions, resources, err := cfg.DeployedResources(ctx, linstor) 44 | if err != nil { 45 | return nil, nil, nil, nil, fmt.Errorf("failed to fetch deployed resources: %w", err) 46 | } 47 | return cfg, resourceDefinition, volumeDefinitions, resources, nil 48 | } 49 | 50 | func maybeWriteNewConfig(ctx context.Context, linstor *client.Client, oldConfig *reactor.PromoterConfig, newConfig *reactor.PromoterConfig, id string, forceYes, dryRun bool) (bool, error) { 51 | if cmp.Equal(oldConfig, newConfig) { 52 | // nothing to do 53 | return false, nil 54 | } 55 | oldToml, err := encode(oldConfig) 56 | if err != nil { 57 | return false, fmt.Errorf("failed to encode old promoter config: %w", err) 58 | } 59 | newToml, err := encode(newConfig) 60 | if err != nil { 61 | return false, fmt.Errorf("failed to marshal new config to toml: %w", err) 62 | } 63 | fmt.Println("The following configuration changes are necessary:") 64 | dmp := diffmatchpatch.New() 65 | diffs := dmp.DiffCleanupSemantic(dmp.DiffMain(oldToml, newToml, false)) 66 | fmt.Println(dmp.DiffPrettyText(diffs)) 67 | fmt.Println() 68 | if dryRun { 69 | return true, nil 70 | } 71 | if !forceYes { 72 | yes := prompt.Confirm("Apply this configuration now?") 73 | if !yes { 74 | // abort 75 | return false, fmt.Errorf("aborted") 76 | } 77 | } 78 | err = reactor.EnsureConfig(ctx, linstor, newConfig, id) 79 | if err != nil { 80 | return true, fmt.Errorf("failed to install config: %w", err) 81 | } 82 | return true, nil 83 | } 84 | -------------------------------------------------------------------------------- /pkg/healthcheck/linstor.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/LINBIT/linstor-gateway/pkg/linstorcontrol" 7 | "github.com/fatih/color" 8 | "github.com/pelletier/go-toml" 9 | "github.com/spf13/viper" 10 | "os" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | const satelliteConfigFile = "/etc/linstor/linstor_satellite.toml" 16 | 17 | type checkLinstor struct { 18 | controllers []string 19 | } 20 | 21 | func (c *checkLinstor) check(bool) error { 22 | ctx, done := context.WithTimeout(context.Background(), 5*time.Second) 23 | defer done() 24 | cli, err := linstorcontrol.Default(c.controllers) 25 | if err != nil { 26 | return err 27 | } 28 | _, err = cli.Controller.GetVersion(ctx) 29 | if err != nil { 30 | return err 31 | } 32 | return nil 33 | } 34 | 35 | func (c *checkLinstor) format(err error) string { 36 | var b strings.Builder 37 | fmt.Fprintf(&b, " %s %s\n", color.RedString("✗"), "No connection to a LINSTOR controller") 38 | fmt.Fprintf(&b, " %s\n", err.Error()) 39 | fmt.Fprintf(&b, " Make sure that either\n") 40 | fmt.Fprintf(&b, " • the %s command line option, or\n", bold("--controllers")) 41 | fmt.Fprintf(&b, " • the %s environment variable, or\n", bold("LS_CONTROLLERS")) 42 | fmt.Fprintf(&b, " • the %s key in your configuration file (%s)\n", bold("linstor.controllers"), bold(viper.ConfigFileUsed())) 43 | fmt.Fprintf(&b, " contain an URL to a LINSTOR controller, or that the LINSTOR controller is running on this machine.\n") 44 | return b.String() 45 | } 46 | 47 | type checkFileWhitelist struct { 48 | } 49 | 50 | func (c *checkFileWhitelist) check(bool) error { 51 | f, err := os.Open(satelliteConfigFile) 52 | if err != nil { 53 | return fmt.Errorf("failed to open file: %w", err) 54 | } 55 | defer f.Close() 56 | 57 | var satelliteConfig struct { 58 | Files struct { 59 | AllowExtFiles []string `toml:"allowExtFiles"` 60 | } `toml:"files"` 61 | } 62 | err = toml.NewDecoder(f).Decode(&satelliteConfig) 63 | if err != nil { 64 | return fmt.Errorf("failed to decode satellite config: %w", err) 65 | } 66 | 67 | expect := []string{ 68 | "/etc/systemd/system", "/etc/systemd/system/linstor-satellite.service.d", "/etc/drbd-reactor.d", 69 | } 70 | if !containsAll(satelliteConfig.Files.AllowExtFiles, expect) { 71 | return fmt.Errorf("unexpected allowExtFiles value") 72 | } 73 | return nil 74 | } 75 | 76 | func (c *checkFileWhitelist) format(err error) string { 77 | var b strings.Builder 78 | fmt.Fprintf(&b, " %s The LINSTOR satellite is not configured correctly on this node\n", color.RedString("✗")) 79 | fmt.Fprintf(&b, " %s\n", err.Error()) 80 | fmt.Fprintf(&b, " Edit the LINSTOR satellite configuration file (%s) to include the following:\n\n", bold(satelliteConfigFile)) 81 | fmt.Fprintf(&b, " [files]\n") 82 | fmt.Fprintf(&b, ` allowExtFiles = ["/etc/systemd/system", "/etc/systemd/system/linstor-satellite.service.d", "/etc/drbd-reactor.d"]`+"\n\n") 83 | fmt.Fprintf(&b, " and execute %s.\n", bold("systemctl restart linstor-satellite.service")) 84 | return b.String() 85 | } 86 | -------------------------------------------------------------------------------- /pkg/iscsi/iqn.go: -------------------------------------------------------------------------------- 1 | package iscsi 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "regexp" 7 | ) 8 | 9 | var ( 10 | // This format is currently dictated by the iSCSI target backend, 11 | // specifically the rtslib-fb library. 12 | // A notable difference in this implementation (which also differs from 13 | // RFC3720, where the IQN format is defined) is that we require the 14 | // "unique" part after the colon to be present. 15 | // We also impose a stricter format for the domain name, allowing only 16 | // lowercase alphanumeric characters and hyphens. 17 | // rtslib-fb converts the iqn to lowercase at some point, so we cannot 18 | // allow uppercase characters. 19 | // 20 | // See also the source code of rtslib-fb for the original regex: 21 | // https://github.com/open-iscsi/rtslib-fb/blob/b5be390be961/rtslib/utils.py#L384 22 | regexIQN = `iqn\.\d{4}-[0-1][0-9]\.[a-z0-9-]+\.[a-z0-9-]+` 23 | 24 | // This format is mandated by LINSTOR. Since we use the unique part 25 | // directly for LINSTOR resource names, it needs to be compliant. 26 | // Can only contain lowercase alphanumeric characters and hyphens (for the 27 | // same reason as the domain name). 28 | // The name must not start with a digit, and must be at least two characters 29 | // long. 30 | // Note: while LINSTOR does allow underscores, rtslib-fb does not. See 31 | // the GitHub link above: it checks for `not re.search('_', wwn)` 32 | regexResourceName = `[a-z][a-z0-9-]+` 33 | 34 | regexWWN = regexp.MustCompile(`^(` + regexIQN + `):(` + regexResourceName + `)$`) 35 | ) 36 | 37 | type Iqn [2]string 38 | 39 | func (i *Iqn) Set(s string) error { 40 | iqn, err := NewIqn(s) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | *i = iqn 46 | return nil 47 | } 48 | 49 | func (i *Iqn) Type() string { 50 | return "iqn" 51 | } 52 | 53 | func (i Iqn) String() string { 54 | return fmt.Sprintf("%s:%s", i[0], i[1]) 55 | } 56 | 57 | func (i *Iqn) WWN() string { 58 | return i[1] 59 | } 60 | 61 | func (i *Iqn) UnmarshalText(b []byte) error { 62 | match := regexWWN.FindStringSubmatch(string(b)) 63 | 64 | if match == nil || len(match) != 3 { 65 | return invalidIqn(b) 66 | } 67 | 68 | *i = [2]string{match[1], match[2]} 69 | 70 | return nil 71 | } 72 | 73 | func (i *Iqn) UnmarshalJSON(b []byte) error { 74 | var raw string 75 | err := json.Unmarshal(b, &raw) 76 | if err != nil { 77 | return err 78 | } 79 | 80 | return i.UnmarshalText([]byte(raw)) 81 | } 82 | 83 | func (i Iqn) MarshalText() ([]byte, error) { 84 | return []byte(i.String()), nil 85 | } 86 | 87 | func (i Iqn) MarshalJSON() ([]byte, error) { 88 | return json.Marshal(i.String()) 89 | } 90 | 91 | func NewIqn(s string) (Iqn, error) { 92 | var iqn Iqn 93 | err := iqn.UnmarshalText([]byte(s)) 94 | if err != nil { 95 | return Iqn{}, err 96 | } 97 | 98 | return iqn, nil 99 | } 100 | 101 | type invalidIqn string 102 | 103 | func (i invalidIqn) Error() string { 104 | return fmt.Sprintf("'%s' is not a valid IQN. expected format: "+ 105 | "iqn.YYYY-MM.DOTTED.DOMAIN.NAME:UNIQUE_RESOURCE_NAME (only lowercase characters, digits, and \"-\")", 106 | string(i)) 107 | } 108 | -------------------------------------------------------------------------------- /pkg/rest/routes.go: -------------------------------------------------------------------------------- 1 | package rest 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/LINBIT/linstor-gateway/pkg/version" 7 | ) 8 | 9 | // serverNameMiddleware adds a "Server" header to the response, identifying the linstor-gateway server. 10 | func serverNameMiddleware(next http.Handler) http.Handler { 11 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 12 | w.Header().Set("Server", "linstor-gateway/"+version.Version) 13 | next.ServeHTTP(w, r) 14 | }) 15 | } 16 | 17 | func (s *server) routes() { 18 | s.router.Use(serverNameMiddleware) 19 | 20 | apiv2 := s.router.PathPrefix("/api/v2").Subrouter() 21 | apiv2.Use(func(handler http.Handler) http.Handler { 22 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 | w.Header().Add("Content-Type", "application/json") 24 | handler.ServeHTTP(w, r) 25 | }) 26 | }) 27 | 28 | apiv2.HandleFunc("/status", s.APIStatus()).Methods("GET") 29 | 30 | iscsiv2 := apiv2.PathPrefix("/iscsi").Subrouter() 31 | iscsiv2.HandleFunc("", s.ISCSIList()).Methods("GET") 32 | iscsiv2.HandleFunc("", s.ISCSICreate()).Methods("POST") 33 | iscsiv2.HandleFunc("/{iqn}", s.ISCSIGet(true)).Methods("GET") 34 | iscsiv2.HandleFunc("/{iqn}", s.ISCSIDelete(true)).Methods("DELETE") 35 | iscsiv2.HandleFunc("/{iqn}/start", s.ISCSIStart()).Methods("POST") 36 | iscsiv2.HandleFunc("/{iqn}/stop", s.ISCSIStop()).Methods("POST") 37 | iscsiv2.HandleFunc("/{iqn}/{lun}", s.ISCSIGet(false)).Methods("GET") 38 | iscsiv2.HandleFunc("/{iqn}/{lun}", s.ISCSIAddVolume()).Methods("PUT") 39 | iscsiv2.HandleFunc("/{iqn}/{lun}", s.ISCSIDelete(false)).Methods("DELETE") 40 | 41 | nfsv2 := apiv2.PathPrefix("/nfs").Subrouter() 42 | nfsv2.HandleFunc("", s.NFSList()).Methods("GET") 43 | nfsv2.HandleFunc("", s.NFSCreate()).Methods("POST") 44 | nfsv2.HandleFunc("/{resource}", s.NFSGet(true)).Methods("GET") 45 | nfsv2.HandleFunc("/{resource}", s.NFSDelete(true)).Methods("DELETE") 46 | nfsv2.HandleFunc("/{resource}/start", s.NFSStart()).Methods("POST") 47 | nfsv2.HandleFunc("/{resource}/stop", s.NFSStop()).Methods("POST") 48 | nfsv2.HandleFunc("/{resource}/{id}", s.NFSGet(false)).Methods("GET") 49 | // No add volume: LINSTOR refuses to create a filesystem on volume that are added after the resource is deployed. 50 | nfsv2.HandleFunc("/{resource}/{id}", s.NFSDelete(false)).Methods("DELETE") 51 | 52 | nvmeofv2 := apiv2.PathPrefix("/nvme-of").Subrouter() 53 | nvmeofv2.HandleFunc("", s.NVMeoFList()).Methods("GET") 54 | nvmeofv2.HandleFunc("", s.NVMeoFCreate()).Methods("POST") 55 | nvmeofv2.HandleFunc("/{nqn}", s.NVMeoFGet(true)).Methods("GET") 56 | nvmeofv2.HandleFunc("/{nqn}", s.NVMeoFDelete(true)).Methods("DELETE") 57 | nvmeofv2.HandleFunc("/{nqn}/start", s.NVMeoFStart()).Methods("POST") 58 | nvmeofv2.HandleFunc("/{nqn}/stop", s.NVMeoFStop()).Methods("POST") 59 | nvmeofv2.HandleFunc("/{nqn}/{nsid}", s.NVMeoFGet(false)).Methods("GET") 60 | nvmeofv2.HandleFunc("/{nqn}/{nsid}", s.NVMeoFAddVolume()).Methods("PUT") 61 | nvmeofv2.HandleFunc("/{nqn}/{nsid}", s.NVMeoFDelete(false)).Methods("DELETE") 62 | 63 | // gorilla/mux usually does not apply middlewares to the NotFoundHandler. To apply the serverNameMiddleware, 64 | // overwrite the NotFoundHandler with a new route that has the middleware applied. 65 | s.router.NotFoundHandler = s.router.NewRoute().HandlerFunc(http.NotFound).GetHandler() 66 | } 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

LINSTOR Logo

2 | 3 | # LINSTOR Gateway 4 | 5 | GitHub release (latest SemVer) GitHub GitHub Workflow Status Slack Channel 6 | 7 | LINSTOR Gateway manages highly available **iSCSI targets**, **NFS exports**, and 8 | **NVMe-oF targets** by leveraging [LINSTOR](https://github.com/LINBIT/linstor-server) 9 | and [drbd-reactor](https://github.com/LINBIT/drbd-reactor). 10 | 11 | ## Getting started 12 | 13 | Refer to the [_Understanding LINSTOR Gateway_](https://linbit.com/drbd-user-guide/linstorgateway-guide-1_0-en/) user guide which outlines some of the basic knowledge needed to effectively operate and administer a storage cluster that relies on LINSTOR Gateway. 14 | This guide also provides some insight into the design decisions that were made while implementing LINSTOR Gateway, and gives an overview of how its internals work. 15 | 16 | ### Installation 17 | 18 | For a step-by-step tutorial on setting up a LINSTOR Gateway cluster, refer to 19 | this blog post: 20 | [Create a Highly Available iSCSI Target Using LINSTOR Gateway](https://linbit.com/blog/create-a-highly-available-iscsi-target-using-linstor-gateway/). 21 | 22 | ## Requirements 23 | 24 | LINSTOR Gateway provides a built-in health check that automatically tests whether all requirements are correctly met on 25 | the current host. 26 | 27 | Simply enter the following command, and follow any suggestions that the command output might show: 28 | 29 | ``` 30 | linstor-gateway check-health 31 | ``` 32 | 33 | ## Documentation 34 | 35 | If you want to learn more about LINSTOR Gateway, here are some pointers for further reading. 36 | 37 | ### Command line 38 | 39 | Help for the command line interface is available by running: 40 | 41 | ``` 42 | linstor-gateway help 43 | ``` 44 | 45 | The same information can also be browsed in Markdown format [here](./docs/md/linstor-gateway.md). 46 | 47 | ### Configuration 48 | 49 | LINSTOR Gateway takes a configuration file. Refer to its documentation [here](./docs/config.md). 50 | 51 | ### Internals 52 | 53 | The LINSTOR Gateway command line client communicates with the server by using a REST API, which is 54 | documented [here](https://app.swaggerhub.com/apis-docs/Linstor/linstor-gateway/). 55 | 56 | It also exposes a Go client for the REST 57 | API: Go Reference 58 | 59 | ## Building 60 | 61 | If you want to test the latest unstable version of LINSTOR Gateway, you can build the git version from sources: 62 | 63 | ``` 64 | git clone https://github.com/LINBIT/linstor-gateway 65 | cd linstor-gateway 66 | make 67 | ``` 68 | -------------------------------------------------------------------------------- /pkg/reactor/resourceagent_test.go: -------------------------------------------------------------------------------- 1 | package reactor 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestResourceAgent_UnmarshalText(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | text string 12 | expected ResourceAgent 13 | wantErr bool 14 | }{{ 15 | name: "empty string", 16 | text: "", 17 | wantErr: true, 18 | }, { 19 | name: "only type and name", 20 | text: "ocf:heartbeat:IPaddr2 my_ip", 21 | expected: ResourceAgent{ 22 | Type: "ocf:heartbeat:IPaddr2", 23 | Name: "my_ip", 24 | }, 25 | }, { 26 | name: "with attributes", 27 | text: "ocf:heartbeat:IPaddr2 my_ip ip=1.2.3.4 netmask=24", 28 | expected: ResourceAgent{ 29 | Type: "ocf:heartbeat:IPaddr2", 30 | Name: "my_ip", 31 | Attributes: map[string]string{ 32 | "ip": "1.2.3.4", 33 | "netmask": "24", 34 | }, 35 | }, 36 | }, { 37 | name: "malformed attribute", 38 | text: "ocf:heartbeat:IPaddr2 my_ip ip1.2.3.4", 39 | wantErr: true, 40 | }, { 41 | name: "attribute with space", 42 | text: "ocf:heartbeat:IPaddr2 my_ip title='my great IP'", 43 | expected: ResourceAgent{ 44 | Type: "ocf:heartbeat:IPaddr2", 45 | Name: "my_ip", 46 | Attributes: map[string]string{ 47 | "title": "my great IP", 48 | }, 49 | }, 50 | }} 51 | for _, tt := range tests { 52 | t.Run(tt.name, func(t *testing.T) { 53 | r := ResourceAgent{} 54 | err := r.UnmarshalText([]byte(tt.text)) 55 | if tt.wantErr { 56 | assert.Error(t, err) 57 | } else { 58 | assert.NoError(t, err) 59 | assert.Equal(t, tt.expected, r) 60 | } 61 | }) 62 | } 63 | } 64 | 65 | func TestResourceAgent_MarshalText(t *testing.T) { 66 | tests := []struct { 67 | name string 68 | agent ResourceAgent 69 | expected string 70 | wantErr bool 71 | }{{ 72 | name: "empty", 73 | agent: ResourceAgent{}, 74 | wantErr: true, 75 | }, { 76 | name: "no type", 77 | agent: ResourceAgent{ 78 | Name: "my_ip", 79 | }, 80 | wantErr: true, 81 | }, { 82 | name: "no name", 83 | agent: ResourceAgent{ 84 | Type: "ocf:heartbeat:IPaddr2", 85 | }, 86 | wantErr: true, 87 | }, { 88 | name: "only type and name", 89 | agent: ResourceAgent{ 90 | Type: "ocf:heartbeat:IPaddr2", 91 | Name: "my_ip", 92 | }, 93 | expected: "ocf:heartbeat:IPaddr2 my_ip", 94 | }, { 95 | name: "with attributes", 96 | agent: ResourceAgent{ 97 | Type: "ocf:heartbeat:IPaddr2", 98 | Name: "my_ip", 99 | Attributes: map[string]string{ 100 | "ip": "1.2.3.4", 101 | "netmask": "24", 102 | }, 103 | }, 104 | expected: "ocf:heartbeat:IPaddr2 my_ip ip=1.2.3.4 netmask=24", 105 | }, { 106 | name: "attributes are sorted", 107 | agent: ResourceAgent{ 108 | Type: "ocf:heartbeat:IPaddr2", 109 | Name: "my_ip", 110 | Attributes: map[string]string{ 111 | "c": "3", 112 | "a": "1", 113 | "d": "4", 114 | "b": "2", 115 | }, 116 | }, 117 | expected: "ocf:heartbeat:IPaddr2 my_ip a=1 b=2 c=3 d=4", 118 | }, { 119 | name: "malformed attribute", 120 | agent: ResourceAgent{ 121 | Type: "ocf:heartbeat:IPaddr2", 122 | Name: "my_ip", 123 | Attributes: map[string]string{ 124 | "ip": "1.2=3.4", 125 | }, 126 | }, 127 | expected: "ocf:heartbeat:IPaddr2 my_ip ip='1.2=3.4'", 128 | }, { 129 | name: "attribute with space", 130 | agent: ResourceAgent{ 131 | Type: "ocf:heartbeat:IPaddr2", 132 | Name: "my_ip", 133 | Attributes: map[string]string{ 134 | "title": "my great IP", 135 | }, 136 | }, 137 | expected: "ocf:heartbeat:IPaddr2 my_ip title='my great IP'", 138 | }} 139 | for _, tt := range tests { 140 | t.Run(tt.name, func(t *testing.T) { 141 | text, err := tt.agent.MarshalText() 142 | if tt.wantErr { 143 | assert.Error(t, err) 144 | } else { 145 | assert.NoError(t, err) 146 | assert.Equal(t, tt.expected, string(text)) 147 | } 148 | }) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "os" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/LINBIT/linstor-gateway/client" 11 | "github.com/LINBIT/linstor-gateway/pkg/version" 12 | 13 | log "github.com/sirupsen/logrus" 14 | "github.com/spf13/cobra" 15 | "github.com/spf13/viper" 16 | ) 17 | 18 | var ( 19 | cfgFile string 20 | loglevel string 21 | host string 22 | cli *client.Client 23 | ) 24 | 25 | func contains(haystack []string, needle string) bool { 26 | for _, v := range haystack { 27 | if v == needle { 28 | return true 29 | } 30 | } 31 | 32 | return false 33 | } 34 | 35 | func parseBaseURL(urlString string) (*url.URL, error) { 36 | // Check scheme 37 | urlSplit := strings.Split(urlString, "://") 38 | 39 | if len(urlSplit) == 1 { 40 | if urlSplit[0] == "" { 41 | urlSplit[0] = client.DefaultHost 42 | } 43 | urlSplit = []string{client.DefaultScheme, urlSplit[0]} 44 | } 45 | 46 | if len(urlSplit) != 2 { 47 | return nil, fmt.Errorf("URL with multiple scheme separators. parts: %v", urlSplit) 48 | } 49 | scheme, endpoint := urlSplit[0], urlSplit[1] 50 | 51 | // Check port 52 | endpointSplit := strings.Split(endpoint, ":") 53 | if len(endpointSplit) == 1 { 54 | endpointSplit = []string{endpointSplit[0], strconv.Itoa(client.DefaultPort)} 55 | } 56 | if len(endpointSplit) != 2 { 57 | return nil, fmt.Errorf("URL with multiple port separators. parts: %v", endpointSplit) 58 | } 59 | host, port := endpointSplit[0], endpointSplit[1] 60 | 61 | return url.Parse(fmt.Sprintf("%s://%s:%s", scheme, host, port)) 62 | } 63 | 64 | // rootCommand represents the base command when called without any subcommands 65 | func rootCommand() *cobra.Command { 66 | if len(os.Args) < 1 { 67 | log.Fatal("Program started with a zero-length argument list") 68 | } 69 | 70 | rootCmd := &cobra.Command{ 71 | Use: "linstor-gateway", 72 | Version: version.Version, 73 | Short: "Manage linstor-gateway targets and exports", 74 | Args: cobra.NoArgs, 75 | SilenceUsage: true, 76 | SilenceErrors: true, 77 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 78 | level, err := log.ParseLevel(loglevel) 79 | if err != nil { 80 | return err 81 | } 82 | log.SetLevel(level) 83 | 84 | base, err := parseBaseURL(host) 85 | if err != nil { 86 | return err 87 | } 88 | cli, err = client.NewClient( 89 | client.BaseURL(base), 90 | client.Log(log.StandardLogger()), 91 | client.UserAgent(version.UserAgent()), 92 | ) 93 | if err != nil { 94 | return fmt.Errorf("failed to connect to LINSTOR Gateway server: %w", err) 95 | } 96 | return nil 97 | }, 98 | } 99 | rootCmd.AddCommand(iscsiCommands()) 100 | rootCmd.AddCommand(nfsCommands()) 101 | rootCmd.AddCommand(nvmeCommands()) 102 | rootCmd.AddCommand(serverCommand()) 103 | rootCmd.AddCommand(versionCommand()) 104 | rootCmd.AddCommand(completionCommand(rootCmd)) 105 | rootCmd.AddCommand(docsCommand(rootCmd)) 106 | rootCmd.AddCommand(checkHealthCommand()) 107 | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "/etc/linstor-gateway/linstor-gateway.toml", "Config file to load") 108 | defaultConnect := fmt.Sprintf("%s://%s:%d", client.DefaultScheme, client.DefaultHost, client.DefaultPort) 109 | rootCmd.PersistentFlags().StringVarP(&host, "connect", "c", defaultConnect, "LINSTOR Gateway server to connect to") 110 | rootCmd.PersistentFlags().StringVar(&loglevel, "loglevel", log.InfoLevel.String(), "Set the log level (as defined by logrus)") 111 | return rootCmd 112 | } 113 | 114 | func initConfig() { 115 | viper.SetDefault("linstor.controllers", "") 116 | viper.SetConfigType("toml") 117 | viper.SetConfigFile(cfgFile) 118 | viper.ReadInConfig() 119 | } 120 | 121 | // Execute adds all child commands to the root command and sets flags appropriately. 122 | // This is called by main.main(). It only needs to happen once to the rootCmd. 123 | func Execute() { 124 | cobra.OnInitialize(initConfig) 125 | rootCmd := rootCommand() 126 | if err := rootCmd.Execute(); err != nil { 127 | log.Error(err) 128 | os.Exit(1) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /pkg/upgrade/nfs.go: -------------------------------------------------------------------------------- 1 | package upgrade 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/LINBIT/golinstor/client" 7 | "github.com/LINBIT/linstor-gateway/pkg/iscsi" 8 | "github.com/LINBIT/linstor-gateway/pkg/nfs" 9 | "github.com/LINBIT/linstor-gateway/pkg/reactor" 10 | ) 11 | 12 | // nfsMigrations defines the operations for upgrading a single version. 13 | // The array index ("n") denotes the starting version; the function at 14 | // index "n" migrates from version "n" to version "n+1". 15 | var nfsMigrations = []func(cfg *reactor.PromoterConfig) error{ 16 | 0: initialMigrations, 17 | } 18 | 19 | // initialMigrations combines two migrations that make up version 1. 20 | func initialMigrations(cfg *reactor.PromoterConfig) error { 21 | if err := removeID(cfg); err != nil { 22 | return err 23 | } 24 | return switchIpAndNfsServer(cfg) 25 | } 26 | 27 | // switchIpAndNfsServer changes the order of the resource agents such that 28 | // the service IP is started before the NFS server. This was previously not 29 | // the case, leading to an NFS server that is not limited to the service IP. 30 | func switchIpAndNfsServer(cfg *reactor.PromoterConfig) error { 31 | id := firstResourceId(cfg) 32 | firstResource := cfg.Resources[id] 33 | var serviceIpIndex, nfsServerIndex int 34 | for i, entry := range firstResource.Start { 35 | switch agent := entry.(type) { 36 | case *reactor.ResourceAgent: 37 | if agent.Type == "ocf:heartbeat:IPaddr2" { 38 | serviceIpIndex = i 39 | } 40 | if agent.Type == "ocf:heartbeat:nfsserver" { 41 | nfsServerIndex = i 42 | } 43 | } 44 | } 45 | 46 | // slice up the "start" list and create a new one with this order: 47 | // 1. everything before the nfs server 48 | // 2. the service IP 49 | // 3. the NFS server 50 | // 4. all the exportfs entries 51 | // 5. everything after the original service IP index 52 | start := firstResource.Start[:nfsServerIndex] 53 | serviceIpEntry := firstResource.Start[serviceIpIndex] 54 | nfsServerEntry := firstResource.Start[nfsServerIndex] 55 | exportFsEntries := firstResource.Start[nfsServerIndex+1 : serviceIpIndex] 56 | rest := firstResource.Start[serviceIpIndex+1:] 57 | 58 | var s []reactor.StartEntry 59 | s = append(s, start...) 60 | s = append(s, serviceIpEntry) 61 | s = append(s, nfsServerEntry) 62 | s = append(s, exportFsEntries...) 63 | s = append(s, rest...) 64 | firstResource.Start = s 65 | cfg.Resources[id] = firstResource 66 | return nil 67 | } 68 | 69 | func upgradeNfs(ctx context.Context, linstor *client.Client, name string, forceYes bool, dryRun bool) (bool, error) { 70 | const gatewayConfigPath = "/etc/drbd-reactor.d/linstor-gateway-nfs-%s.toml" 71 | cfg, _, _, _, err := parseExistingConfig(ctx, linstor, fmt.Sprintf(gatewayConfigPath, name)) 72 | if err != nil { 73 | return false, err 74 | } 75 | if cfg.Metadata.LinstorGatewaySchemaVersion > iscsi.CurrentVersion { 76 | return false, fmt.Errorf("schema version %d is not supported", 77 | cfg.Metadata.LinstorGatewaySchemaVersion) 78 | } 79 | newCfg, _, _, _, err := parseExistingConfig(ctx, linstor, fmt.Sprintf(gatewayConfigPath, name)) 80 | if err != nil { 81 | return false, err 82 | } 83 | 84 | for i := cfg.Metadata.LinstorGatewaySchemaVersion; i < nfs.CurrentVersion; i++ { 85 | err := nfsMigrations[i](newCfg) 86 | if err != nil { 87 | return false, fmt.Errorf("failed to migrate from version %d to %d: %w", i, i+1, err) 88 | } 89 | } 90 | newCfg.Metadata.LinstorGatewaySchemaVersion = nfs.CurrentVersion 91 | 92 | return maybeWriteNewConfig(ctx, linstor, cfg, newCfg, fmt.Sprintf(nfs.IDFormat, name), forceYes, dryRun) 93 | } 94 | 95 | func Nfs(ctx context.Context, linstor *client.Client, name string, forceYes bool, dryRun bool) error { 96 | var didAny bool 97 | didDrbd, err := upgradeDrbdOptions(ctx, linstor, name, forceYes, dryRun) 98 | if err != nil { 99 | return fmt.Errorf("failed to upgrade drbd options: %w", err) 100 | } 101 | if didDrbd { 102 | didAny = true 103 | } 104 | didNfs, err := upgradeNfs(ctx, linstor, name, forceYes, dryRun) 105 | if err != nil { 106 | return fmt.Errorf("failed to upgrade promoter config: %w", err) 107 | } 108 | if didNfs { 109 | didAny = true 110 | } 111 | if !didAny { 112 | fmt.Printf("%s is already up to date.\n", name) 113 | } 114 | return nil 115 | } 116 | -------------------------------------------------------------------------------- /integration-tests/virter/roles/linstor-cluster/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Get installed kernel packages 3 | package_facts: 4 | manager: auto 5 | tags: 6 | - preload 7 | 8 | - name: Remove all other kernels 9 | yum: 10 | name: "{{ item }}" 11 | state: absent 12 | with_items: "{{ ansible_facts.packages | dict2items | selectattr('key', 'search', '^kernel-[0-9]') | map(attribute='key') | list }}" 13 | tags: 14 | - preload 15 | 16 | - name: Install required kernel version 17 | yum: 18 | name: kernel-{{ kernel_version }} 19 | state: present 20 | tags: 21 | - preload 22 | 23 | - name: List installed kernels 24 | yum: 25 | list: kernel 26 | register: installed_kernels 27 | tags: 28 | - preload 29 | 30 | - debug: 31 | var: installed_kernels 32 | tags: 33 | - preload 34 | 35 | - name: Add linstor repo 36 | yum_repository: 37 | name: drbd 38 | description: LINBIT Packages for LINSTOR and DRBD 39 | baseurl: "{{ linbit_repo_baseurl }}/yum/rhel{{ rhel_major_version }}/drbd-9/x86_64" 40 | gpgcheck: true 41 | gpgkey: https://packages.linbit.com/package-signing-pubkey.asc 42 | tags: 43 | - preload 44 | 45 | # cat /etc/os-release | curl -sT - -X POST https://bestmodule.drbd.io/api/v1/best/$(uname -r) 46 | - name: Determine best kmod 47 | uri: 48 | url: https://bestmodule.drbd.io/api/v1/best/{{ kernel_version }} 49 | src: "/etc/os-release" 50 | remote_src: "True" 51 | method: POST 52 | body_format: raw 53 | return_content: true 54 | register: best_kmod 55 | tags: 56 | - preload 57 | 58 | - debug: 59 | var: best_kmod.content 60 | tags: 61 | - preload 62 | 63 | - name: Install DRBD 64 | yum: 65 | pkg: 66 | - drbd-utils 67 | - "{{ best_kmod.content | regex_replace('(.*)\\.rpm', '\\1') }}" 68 | notify: 69 | - Restart satellite 70 | tags: 71 | - preload 72 | 73 | - name: Configure DRBD utils to not phone home 74 | lineinfile: 75 | path: /etc/drbd.d/global_common.conf 76 | regexp: 'usage-count' 77 | line: "\tusage-count no;" 78 | state: present 79 | validate: drbdadm -c %s dump 80 | tags: 81 | - preload 82 | 83 | - name: Install software components 84 | yum: 85 | pkg: 86 | - gnupg 87 | - lvm2 88 | - linstor-controller 89 | - linstor-satellite 90 | - linstor-client 91 | notify: 92 | - Restart satellite 93 | tags: 94 | - preload 95 | 96 | - name: Trigger handlers 97 | meta: flush_handlers 98 | 99 | - name: Add blacklist for DRBD devices 100 | copy: 101 | src: lvm.conf 102 | dest: /etc/lvm/lvm.conf 103 | owner: root 104 | mode: 0644 105 | notify: 106 | - Restart satellite 107 | tags: 108 | - preload 109 | 110 | - name: Populate service facts 111 | service_facts: 112 | tags: 113 | - preload 114 | 115 | 116 | - name: Stop multipathd, as it could screw with DRBD 117 | service: 118 | name: multipathd 119 | state: stopped 120 | enabled: no 121 | when: '"multipathd.service" in services' 122 | tags: 123 | - preload 124 | 125 | - name: Restart systemd-udevd to ensure the hostname got updated 126 | service: 127 | name: systemd-udevd 128 | state: restarted 129 | tags: 130 | - preload 131 | 132 | - name: Configure satellite 133 | copy: 134 | src: linstor_satellite.toml 135 | dest: /etc/linstor/linstor_satellite.toml 136 | owner: root 137 | mode: 0644 138 | notify: 139 | - Restart satellite 140 | tags: 141 | - preload 142 | 143 | - name: Enable satellite 144 | service: 145 | name: linstor-satellite 146 | enabled: yes 147 | notify: 148 | - Restart satellite 149 | tags: 150 | - preload 151 | 152 | - name: Set up linstor-controller service (on controller) 153 | service: 154 | name: linstor-controller 155 | enabled: yes 156 | state: started 157 | when: inventory_hostname in linstor_controller_hosts 158 | tags: 159 | - run 160 | 161 | - name: Wait for controller to come online 162 | wait_for: 163 | port: 3370 164 | when: inventory_hostname in linstor_controller_hosts 165 | tags: 166 | - run 167 | 168 | - name: Disable linstor-controller service (on non-controllers) 169 | service: 170 | name: linstor-controller 171 | enabled: no 172 | state: stopped 173 | when: inventory_hostname not in linstor_controller_hosts 174 | tags: 175 | - run 176 | 177 | - name: Create linstor configuration file 178 | template: 179 | src: linstor-client.conf.j2 180 | dest: /etc/linstor/linstor-client.conf 181 | tags: 182 | - run 183 | -------------------------------------------------------------------------------- /client/client_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | "net/url" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func parseURL(str string) *url.URL { 17 | u, _ := url.Parse(str) 18 | return u 19 | } 20 | 21 | type testData struct { 22 | A string `json:"a"` 23 | B int `json:"b"` 24 | } 25 | 26 | func TestNewRequest(t *testing.T) { 27 | type params struct { 28 | method string 29 | path string 30 | body interface{} 31 | } 32 | type wantData struct { 33 | Method string 34 | URL *url.URL 35 | Body string 36 | ContentType string 37 | } 38 | cases := []struct { 39 | name string 40 | in params 41 | want wantData 42 | wantError bool 43 | }{{ 44 | name: `invalid URL`, 45 | in: params{method: "GET", path: "ht tp://localhost", body: nil}, 46 | wantError: true, 47 | }, { 48 | name: `default case`, 49 | in: params{method: "GET", path: "/test", body: nil}, 50 | want: wantData{Method: "GET", URL: parseURL("http://localhost:8337/test"), Body: ""}, 51 | }, { 52 | name: `body`, 53 | in: params{method: "POST", path: "/test", body: testData{ 54 | A: "test", 55 | B: 4711, 56 | }}, 57 | want: wantData{Method: "POST", URL: parseURL("http://localhost:8337/test"), Body: "{\"a\":\"test\",\"b\":4711}\n", ContentType: "application/json"}, 58 | }, { 59 | name: `invalid body`, 60 | in: params{method: "POST", path: "/test", body: make(chan int)}, // channels cannot be marshalled, causing json.Marshal to fail, 61 | wantError: true, 62 | }, { 63 | name: `invalid method`, 64 | in: params{method: "PO:ST"}, 65 | wantError: true, 66 | }} 67 | 68 | cli, err := NewClient(Log(t)) 69 | assert.NoError(t, err) 70 | 71 | t.Parallel() 72 | for _, tt := range cases { 73 | t.Run(tt.name, func(t *testing.T) { 74 | got, err := cli.newRequest(tt.in.method, tt.in.path, tt.in.body) 75 | if tt.wantError { 76 | assert.Error(t, err) 77 | } else { 78 | assert.NoError(t, err) 79 | assert.Equal(t, tt.want.Method, got.Method) 80 | assert.Equal(t, tt.want.URL, got.URL) 81 | if tt.want.Body == "" { 82 | assert.Nil(t, got.Body) 83 | } else { 84 | assert.NotNil(t, got.Body) 85 | gotBytes, err := ioutil.ReadAll(got.Body) 86 | assert.NoError(t, err) 87 | assert.Equal(t, tt.want.Body, string(gotBytes)) 88 | } 89 | if tt.want.ContentType != "" { 90 | assert.Contains(t, got.Header, "Content-Type") 91 | assert.Len(t, got.Header["Content-Type"], 1) 92 | assert.Equal(t, tt.want.ContentType, got.Header["Content-Type"][0]) 93 | } 94 | } 95 | }) 96 | } 97 | } 98 | 99 | func TestDo(t *testing.T) { 100 | type wantData struct { 101 | status int 102 | body interface{} 103 | } 104 | cases := []struct { 105 | name string 106 | method string 107 | handler http.HandlerFunc 108 | want wantData 109 | wantError bool 110 | }{{ 111 | name: `default case`, 112 | method: "GET", 113 | handler: func(w http.ResponseWriter, r *http.Request) { 114 | want := testData{A: "teststring", B: 4711} 115 | w.WriteHeader(http.StatusOK) 116 | err := json.NewEncoder(w).Encode(&want) 117 | if err != nil { 118 | t.Fatal(err) 119 | } 120 | }, 121 | want: wantData{ 122 | status: 200, 123 | body: testData{A: "teststring", B: 4711}, 124 | }, 125 | }, { 126 | name: `error 404`, 127 | method: "GET", 128 | handler: func(w http.ResponseWriter, r *http.Request) { 129 | w.WriteHeader(http.StatusNotFound) 130 | }, 131 | wantError: true, 132 | }, { 133 | name: `error 500`, 134 | method: "GET", 135 | handler: func(w http.ResponseWriter, r *http.Request) { 136 | w.WriteHeader(http.StatusInternalServerError) 137 | }, 138 | wantError: true, 139 | }} 140 | 141 | t.Parallel() 142 | for _, tt := range cases { 143 | t.Run(tt.name, func(t *testing.T) { 144 | server := httptest.NewServer(tt.handler) 145 | defer server.Close() 146 | 147 | base, err := url.Parse(server.URL) 148 | require.NoError(t, err) 149 | 150 | cli, err := NewClient(BaseURL(base), Log(t)) 151 | require.NoError(t, err) 152 | 153 | req, err := cli.newRequest(tt.method, "/testurl", nil) 154 | require.NoError(t, err) 155 | 156 | var got testData 157 | resp, err := cli.do(context.Background(), req, &got) 158 | if tt.wantError { 159 | require.Error(t, err) 160 | } else { 161 | require.NoError(t, err) 162 | require.Equal(t, tt.want.status, resp.StatusCode) 163 | require.Equal(t, tt.want.body, got) 164 | } 165 | }) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /pkg/common/resource.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "time" 9 | 10 | "github.com/LINBIT/golinstor/client" 11 | ) 12 | 13 | type UserGroup struct { 14 | User string 15 | Group string 16 | } 17 | 18 | func (u *UserGroup) String() string { 19 | return fmt.Sprintf("%s:%s", u.User, u.Group) 20 | } 21 | 22 | type VolumeConfig struct { 23 | Number int `json:"number"` 24 | SizeKiB uint64 `json:"size_kib"` 25 | FileSystem string `json:"file_system,omitempty"` 26 | FileSystemRootOwner UserGroup `json:"file_system_root_owner,omitempty"` 27 | } 28 | 29 | type ResourceStatus struct { 30 | State ResourceState `json:"state"` 31 | Service ServiceState `json:"service"` 32 | Primary string `json:"primary"` 33 | Nodes []string `json:"nodes"` 34 | Volumes []VolumeState `json:"volumes"` 35 | } 36 | 37 | type Volume struct { 38 | Volume VolumeConfig `json:"volume"` 39 | Status VolumeState `json:"status"` 40 | } 41 | 42 | type VolumeState struct { 43 | Number int `json:"number"` 44 | State ResourceState `json:"state"` 45 | } 46 | 47 | type ResourceState int 48 | 49 | const ( 50 | Unknown ResourceState = iota 51 | ResourceStateOK 52 | ResourceStateDegraded 53 | ResourceStateBad 54 | ) 55 | 56 | func (l ResourceState) String() string { 57 | switch l { 58 | case ResourceStateOK: 59 | return "OK" 60 | case ResourceStateDegraded: 61 | return "Degraded" 62 | case ResourceStateBad: 63 | return "Bad" 64 | } 65 | return "Unknown" 66 | } 67 | 68 | func (l ResourceState) MarshalJSON() ([]byte, error) { return json.Marshal(l.String()) } 69 | 70 | func (l *ResourceState) UnmarshalJSON(text []byte) error { 71 | var raw string 72 | err := json.Unmarshal(text, &raw) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | switch raw { 78 | case "OK": 79 | *l = ResourceStateOK 80 | case "Degraded": 81 | *l = ResourceStateDegraded 82 | case "Bad": 83 | *l = ResourceStateBad 84 | case "Unknown": 85 | *l = Unknown 86 | default: 87 | return errors.New(fmt.Sprintf("unknown resource state: %s", string(text))) 88 | } 89 | 90 | return nil 91 | } 92 | 93 | type ServiceState int 94 | 95 | const ( 96 | ServiceStateStopped ServiceState = iota 97 | ServiceStateStarted 98 | ) 99 | 100 | func (s ServiceState) String() string { 101 | switch s { 102 | case ServiceStateStarted: 103 | return "Started" 104 | case ServiceStateStopped: 105 | return "Stopped" 106 | } 107 | 108 | return "Unknown" 109 | } 110 | 111 | func (s ServiceState) MarshalJSON() ([]byte, error) { return json.Marshal(s.String()) } 112 | 113 | func (s *ServiceState) UnmarshalJSON(text []byte) error { 114 | var raw string 115 | err := json.Unmarshal(text, &raw) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | switch raw { 121 | case "Started": 122 | *s = ServiceStateStarted 123 | case "Stopped": 124 | *s = ServiceStateStopped 125 | default: 126 | return errors.New(fmt.Sprintf("unknown service state: %s", s)) 127 | } 128 | 129 | return nil 130 | } 131 | 132 | func AnyResourcesInUse(resources []client.ResourceWithVolumes) bool { 133 | for _, resource := range resources { 134 | if resource.State != nil && resource.State.InUse != nil && *resource.State.InUse { 135 | return true 136 | } 137 | } 138 | 139 | return false 140 | } 141 | 142 | func NoResourcesInUse(resources []client.ResourceWithVolumes) bool { 143 | return !AnyResourcesInUse(resources) 144 | } 145 | 146 | func WaitUntilResourceCondition(ctx context.Context, cli *client.Client, name string, condition func([]client.ResourceWithVolumes) bool) error { 147 | for { 148 | resources, err := cli.Resources.GetResourceView(ctx, &client.ListOpts{Resource: []string{name}}) 149 | if err != nil { 150 | return err 151 | } 152 | 153 | if condition(resources) { 154 | return nil 155 | } 156 | 157 | time.Sleep(3 * time.Second) 158 | } 159 | } 160 | 161 | func getInUseNode(ctx context.Context, cli *client.Client, name string) (string, error) { 162 | resources, err := cli.Resources.GetResourceView(ctx, &client.ListOpts{Resource: []string{name}}) 163 | if err != nil { 164 | return "", err 165 | } 166 | for _, resource := range resources { 167 | if resource.State != nil && resource.State.InUse != nil && *resource.State.InUse { 168 | return resource.NodeName, nil 169 | } 170 | } 171 | 172 | return "", nil 173 | } 174 | 175 | // AssertResourceInUseStable records the node that a resource is running on, and then monitors the resource to check 176 | // that it remains on the same node for a few seconds. This is useful for sanity-checking that a resource has started 177 | // up and is healthy. 178 | func AssertResourceInUseStable(ctx context.Context, cli *client.Client, name string) error { 179 | initialNode, err := getInUseNode(ctx, cli, name) 180 | if err != nil { 181 | return fmt.Errorf("failed to get InUse node for resource %s: %w", name, err) 182 | } 183 | if initialNode == "" { 184 | return fmt.Errorf("resource %s is not in use on any node", name) 185 | } 186 | 187 | count := 0 188 | for { 189 | node, err := getInUseNode(ctx, cli, name) 190 | if err != nil { 191 | return err 192 | } 193 | if node != initialNode { 194 | return fmt.Errorf("resource startup failed on node %s", initialNode) 195 | } 196 | 197 | time.Sleep(1 * time.Second) 198 | count++ 199 | if count > 5 { 200 | return nil 201 | } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /pkg/healthcheck/healthcheck.go: -------------------------------------------------------------------------------- 1 | package healthcheck 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/fatih/color" 8 | 9 | "github.com/LINBIT/linstor-gateway/client" 10 | ) 11 | 12 | var bold = color.New(color.Bold).SprintfFunc() 13 | var faint = color.New(color.Faint).SprintfFunc() 14 | var errNotFound = errors.New("not found") 15 | 16 | type checker interface { 17 | check(prevError bool) error 18 | format(err error) string 19 | } 20 | 21 | func category(name string, checks ...checker) error { 22 | var prevError bool 23 | var msgs []string 24 | for _, c := range checks { 25 | err := c.check(prevError) 26 | if err != nil { 27 | prevError = true 28 | msgs = append(msgs, c.format(err)) 29 | } 30 | } 31 | 32 | if len(msgs) > 0 { 33 | fmt.Printf("%s %s\n", color.YellowString("[!]"), name) 34 | for _, m := range msgs { 35 | fmt.Print(m) 36 | } 37 | return fmt.Errorf("some checks failed") 38 | } 39 | fmt.Printf("%s %s\n", color.GreenString("[✓]"), name) 40 | return nil 41 | } 42 | 43 | func containsAll(haystack []string, needles []string) bool { 44 | for _, n := range needles { 45 | if !contains(haystack, n) { 46 | return false 47 | } 48 | } 49 | return true 50 | } 51 | 52 | func contains(haystack []string, needle string) bool { 53 | for _, h := range haystack { 54 | if h == needle { 55 | return true 56 | } 57 | } 58 | return false 59 | } 60 | 61 | func toMap(slice []string) map[string]struct{} { 62 | m := make(map[string]struct{}) 63 | for _, s := range slice { 64 | m[s] = struct{}{} 65 | } 66 | return m 67 | } 68 | 69 | func checkAgent(iscsiBackends []string) error { 70 | errs := 0 71 | if err := category( 72 | "System Utilities", 73 | &checkInPath{binary: "iptables", packageName: "iptables"}, 74 | ); err != nil { 75 | errs++ 76 | } 77 | err := category( 78 | "LINSTOR", 79 | &checkFileWhitelist{}, 80 | ) 81 | if err != nil { 82 | errs++ 83 | } 84 | err = category( 85 | "drbd-reactor", 86 | &checkInPath{binary: "drbd-reactor", packageName: "drbd-reactor"}, 87 | &checkStartedAndEnabled{"drbd-reactor.service", "drbd-reactor"}, 88 | &checkReactorAutoReload{}, 89 | ) 90 | if err != nil { 91 | errs++ 92 | } 93 | err = category( 94 | "Resource Agents", 95 | &checkFileExists{filename: "/usr/lib/ocf/resource.d/heartbeat", packageName: "resource-agents", isDirectory: true}, 96 | &checkFileExists{ 97 | filename: "/usr/lib/ocf/resource.d/heartbeat/nvmet-subsystem", 98 | packageName: "resource-agents", 99 | hint: "The nvmet-* resource agents are only shipped with resource-agents 4.9.0 or later. See https://github.com/ClusterLabs/resource-agents for instructions on how to manually install a newer version.", 100 | }, 101 | // Temporary workaround: debian packaging for resource-agents does not include psmisc as a dependency. 102 | // TODO Remove this check once the packaging is fixed on all relevant distributions. 103 | // See also: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1095291 104 | &checkInPath{binary: "fuser", packageName: "psmisc"}, 105 | ) 106 | if err != nil { 107 | errs++ 108 | } 109 | 110 | var iscsiChecks []checker 111 | backendsMap := toMap(iscsiBackends) 112 | for backend := range backendsMap { 113 | switch backend { 114 | case "lio-t": 115 | iscsiChecks = append(iscsiChecks, 116 | &checkInPath{binary: "targetcli", packageName: "targetcli", hint: "targetcli is only required for the LIO target (lio-t) backend. If you are not planning on using LIO target, try excluding it via `--iscsi-backends`."}, 117 | ) 118 | case "scst": 119 | iscsiChecks = append(iscsiChecks, 120 | &checkInPath{binary: "scstadmin", packageName: "scstadmin", hint: "scstadmin is only required for the SCST backend. If you are not planning on using SCST, try excluding it via `--iscsi-backends`."}, 121 | &checkKernelModuleLoaded{"scst", "scst"}, 122 | &checkKernelModuleLoaded{"iscsi_scst", "scst"}, 123 | &checkKernelModuleLoaded{"scst_vdisk", "scst"}, 124 | &checkProcessRunning{"iscsi-scstd", "scst"}, 125 | ) 126 | } 127 | } 128 | if err := category("iSCSI", iscsiChecks...); err != nil { 129 | errs++ 130 | } 131 | err = category( 132 | "NVMe-oF", 133 | &checkInPath{binary: "nvmetcli", packageName: "nvmetcli", hint: "nvmetcli is not (yet) packaged on all distributions. See https://git.infradead.org/users/hch/nvmetcli.git for instructions on how to manually install it."}, 134 | &checkKernelModuleLoaded{"nvmet", "nvmetcli"}, 135 | ) 136 | if err != nil { 137 | errs++ 138 | } 139 | err = category( 140 | "NFS", 141 | &checkNotStartedButLoaded{"nfs-server.service", "nfs-server"}, 142 | ) 143 | if err != nil { 144 | errs++ 145 | } 146 | if errs > 0 { 147 | return fmt.Errorf("found %d issues", errs) 148 | } 149 | return nil 150 | } 151 | 152 | func checkServer(controllers []string) error { 153 | errs := 0 154 | err := category( 155 | "LINSTOR", 156 | &checkLinstor{controllers}, 157 | ) 158 | if err != nil { 159 | errs++ 160 | } 161 | if errs > 0 { 162 | return fmt.Errorf("found %d issues", errs) 163 | } 164 | return nil 165 | } 166 | 167 | func checkClient(cli *client.Client) error { 168 | errs := 0 169 | err := category( 170 | "Server Connection", 171 | &checkGatewayServerConnection{cli}, 172 | ) 173 | if err != nil { 174 | errs++ 175 | } 176 | if errs > 0 { 177 | return fmt.Errorf("found %d issues", errs) 178 | } 179 | return nil 180 | } 181 | 182 | func CheckRequirements(mode string, iscsiBackends []string, controllers []string, cli *client.Client) error { 183 | doPrint := func() { 184 | fmt.Printf("Checking %s requirements.\n\n", bold(mode)) 185 | } 186 | switch mode { 187 | case "agent": 188 | doPrint() 189 | return checkAgent(iscsiBackends) 190 | case "server": 191 | doPrint() 192 | return checkServer(controllers) 193 | case "client": 194 | doPrint() 195 | return checkClient(cli) 196 | default: 197 | return fmt.Errorf("unknown mode %q. Expected \"agent\", \"server\", or \"client\"", mode) 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /linstor-gateway.spec: -------------------------------------------------------------------------------- 1 | %define _firewalldir /usr/lib/firewalld 2 | 3 | %if 0%{?suse_version} 4 | %define firewall_macros_package firewall-macros 5 | %else 6 | %define firewall_macros_package firewalld-filesystem 7 | %endif 8 | 9 | Name: linstor-gateway 10 | Version: 2.0.0 11 | Release: 1 12 | Summary: Exposes highly available LINSTOR storage via iSCSI, NFS, or NVMe-OF. 13 | %global tarball_version %(echo "%{version}" | sed -e 's/~rc/-rc/' -e 's/~alpha/-alpha/') 14 | 15 | URL: https://www.github.com/LINBIT/linstor-gateway 16 | Source: %{name}-%{tarball_version}.tar.gz 17 | BuildRoot: %{buildroot} 18 | BuildRequires: %{firewall_macros_package} 19 | License: GPLv3+ 20 | ExclusiveOS: linux 21 | 22 | %description 23 | LINSTOR Gateway manages highly available iSCSI targets, NFS exports, and NVMe-oF 24 | targets by leveraging LINSTOR and drbd-reactor. 25 | 26 | %prep 27 | %setup -q -n %{name}-%{tarball_version} 28 | 29 | %build 30 | make 31 | 32 | %install 33 | install -D -m 755 %{_builddir}/%{name}-%{tarball_version}/%{name} %{buildroot}/%{_sbindir}/%{name} 34 | install -D -m 644 %{name}.service %{buildroot}%{_unitdir}/%{name}.service 35 | install -D -m 644 %{name}.xml %{buildroot}%{_firewalldir}/services/%{name}.xml 36 | 37 | %post 38 | %systemd_post %{name}.service 39 | %firewalld_reload 40 | # if drbd-reactor is already installed, install the auto-reload service 41 | find %{_datadir}/doc/drbd-reactor* \ 42 | \( -name 'drbd-reactor-reload.path' -o -name 'drbd-reactor-reload.service' \) \ 43 | -exec cp {} %{_unitdir}/ \; || true 44 | 45 | %preun 46 | %systemd_preun %{name}.service 47 | 48 | %postun 49 | %systemd_postun %{name}.service 50 | 51 | %files 52 | %defattr(-,root,root) 53 | %{_sbindir}/%{name} 54 | %{_unitdir}/%{name}.service 55 | %dir %{_firewalldir} 56 | %dir %{_firewalldir}/services 57 | %{_firewalldir}/services/%{name}.xml 58 | 59 | %changelog 60 | * Thu Dec 04 2025 Christoph Böhmwalder - 2.0.0-1 61 | - New upstream release 62 | 63 | * Tue Jul 08 2025 Christoph Böhmwalder - 1.9.0-1 64 | - New upstream release 65 | 66 | * Tue Mar 18 2025 Christoph Böhmwalder - 1.8.0-1 67 | - New upstream release 68 | 69 | * Tue Nov 26 2024 Christoph Böhmwalder - 1.7.0-1 70 | - New upstream release 71 | 72 | * Thu Jul 18 2024 Christoph Böhmwalder - 1.6.0-1 73 | - New upstream release 74 | 75 | * Thu Jul 04 2024 Christoph Böhmwalder - 1.6.0~rc.1-1 76 | - New upstream release 77 | 78 | * Wed Apr 17 2024 Christoph Böhmwalder - 1.5.0-1 79 | - New upstream release 80 | 81 | * Tue Apr 09 2024 Christoph Böhmwalder - 1.5.0~rc.1-1 82 | - New upstream release 83 | 84 | * Mon Mar 04 2024 Christoph Böhmwalder - 1.4.0-1 85 | - New upstream release 86 | 87 | * Tue Feb 20 2024 Christoph Böhmwalder - 1.4.0~rc.1-1 88 | - New upstream release 89 | 90 | * Tue Oct 24 2023 Christoph Böhmwalder - 1.3.0-1 91 | - New upstream release 92 | 93 | * Mon Oct 16 2023 Christoph Böhmwalder - 1.3.0~rc.1-1 94 | - New upstream release 95 | 96 | * Tue Mar 14 2023 Christoph Böhmwalder - 1.2.0-1 97 | - New upstream release 98 | 99 | * Mon Mar 06 2023 Christoph Böhmwalder - 1.1.1-1 100 | - New upstream release 101 | 102 | * Fri Feb 24 2023 Christoph Böhmwalder - 1.1.0-1 103 | - New upstream release 104 | 105 | * Mon Nov 21 2022 Christoph Böhmwalder - 1.0.0-1 106 | - New upstream release 107 | 108 | * Fri Nov 4 2022 Christoph Böhmwalder - 1.0.0~rc.1-1 109 | - New upstream release 110 | 111 | * Tue Jul 26 2022 Christoph Böhmwalder - 0.13.1-1 112 | - New upstream release 113 | 114 | * Mon Jun 27 2022 Christoph Böhmwalder - 0.13.0-1 115 | - New upstream release 116 | 117 | * Tue May 3 2022 Christoph Böhmwalder - 0.12.1-1 118 | - New upstream release 119 | 120 | * Sun Apr 3 2022 Christoph Böhmwalder - 0.12.0-1 121 | - New upstream release 122 | 123 | * Thu Mar 17 2022 Christoph Böhmwalder - 0.12.0~rc.1-1 124 | - New upstream release 125 | 126 | * Mon Feb 14 2022 Christoph Böhmwalder - 0.11.0-1 127 | - New upstream release 128 | 129 | * Tue Feb 8 2022 Christoph Böhmwalder - 0.11.0~rc.2-1 130 | - New upstream release 131 | 132 | * Mon Jan 31 2022 Christoph Böhmwalder - 0.11.0~rc.1-1 133 | - New upstream release 134 | 135 | * Wed Nov 24 2021 Christoph Böhmwalder - 0.10.0-1 136 | - New upstream release 137 | 138 | * Wed Nov 17 2021 Christoph Böhmwalder - 0.10.0~rc.1-1 139 | - New upstream release 140 | 141 | * Tue Sep 28 2021 Christoph Böhmwalder - 0.9.0-1 142 | - New upstream release 143 | 144 | * Thu Sep 23 2021 Christoph Böhmwalder - 0.9.0~rc.3-1 145 | - New upstream release 146 | 147 | * Wed Sep 15 2021 Christoph Böhmwalder - 0.9.0~rc.2-1 148 | - New upstream release 149 | 150 | * Wed Sep 1 2021 Christoph Böhmwalder - 0.9.0~rc.1-1 151 | - New upstream release 152 | 153 | * Tue Mar 23 2021 Christoph Böhmwalder - 0.8.0-1 154 | - New upstream release 155 | 156 | * Fri Dec 04 2020 Christoph Böhmwalder - 0.7.0-1 157 | - Rename to linstor-gateway 158 | 159 | * Wed Oct 09 2019 Roland Kammerer - 0.1.0-1 160 | - Initial Release 161 | -------------------------------------------------------------------------------- /client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net/http" 11 | "net/url" 12 | "os" 13 | 14 | "github.com/moul/http2curl" 15 | 16 | "github.com/LINBIT/linstor-gateway/pkg/rest" 17 | ) 18 | 19 | const ( 20 | DefaultHost = "localhost" 21 | DefaultScheme = "http" 22 | DefaultPort = 8337 23 | ) 24 | 25 | type Client struct { 26 | httpClient *http.Client 27 | baseURL *url.URL 28 | log interface{} // must be either Logger, TestLogger, or LeveledLogger 29 | userAgent string 30 | 31 | Iscsi *ISCSIService 32 | Nfs *NFSService 33 | NvmeOf *NvmeOfService 34 | Status *StatusService 35 | } 36 | 37 | type clientError string 38 | 39 | func (e clientError) Error() string { return string(e) } 40 | 41 | const ( 42 | // NotFoundError is the error type returned in case of a 404 error. This is required to test for this kind of error. 43 | NotFoundError = clientError("404 Not Found") 44 | ) 45 | 46 | type LogLevel string 47 | 48 | const ( 49 | LevelDebug LogLevel = "DEBUG" 50 | LevelInfo LogLevel = "INFO" 51 | LevelWarn LogLevel = "WARN" 52 | LevelError LogLevel = "ERROR" 53 | ) 54 | 55 | // Logger represents a standard logger interface 56 | type Logger interface { 57 | Printf(string, ...interface{}) 58 | } 59 | 60 | // TestLogger represents a logger interface used in tests, as in testing.T 61 | type TestLogger interface { 62 | Logf(string, ...interface{}) 63 | } 64 | 65 | // LeveledLogger interface implements the basic methods that a logger library needs 66 | type LeveledLogger interface { 67 | Errorf(string, ...interface{}) 68 | Infof(string, ...interface{}) 69 | Debugf(string, ...interface{}) 70 | Warnf(string, ...interface{}) 71 | } 72 | 73 | func NewClient(options ...Option) (*Client, error) { 74 | defaultBase, err := url.Parse(fmt.Sprintf("%s://%s:%d", DefaultScheme, DefaultHost, DefaultPort)) 75 | if err != nil { 76 | return nil, fmt.Errorf("failed to parse default URL: %w", err) 77 | } 78 | 79 | c := &Client{ 80 | httpClient: &http.Client{}, 81 | log: log.New(os.Stderr, "", 0), 82 | baseURL: defaultBase, 83 | } 84 | 85 | for _, opt := range options { 86 | if err := opt(c); err != nil { 87 | return nil, err 88 | } 89 | } 90 | 91 | c.Iscsi = &ISCSIService{c} 92 | c.Nfs = &NFSService{c} 93 | c.NvmeOf = &NvmeOfService{c} 94 | c.Status = &StatusService{c} 95 | return c, nil 96 | } 97 | 98 | func (c *Client) logf(level LogLevel, msg string, args ...interface{}) { 99 | switch l := c.log.(type) { 100 | case LeveledLogger: 101 | switch level { 102 | case LevelDebug: 103 | l.Debugf(msg, args...) 104 | case LevelInfo: 105 | l.Infof(msg, args...) 106 | case LevelWarn: 107 | l.Warnf(msg, args...) 108 | case LevelError: 109 | l.Errorf(msg, args...) 110 | } 111 | case TestLogger: 112 | l.Logf("[%s] %s", level, fmt.Sprintf(msg, args...)) 113 | case Logger: 114 | l.Printf("[%s] %s", level, fmt.Sprintf(msg, args...)) 115 | } 116 | } 117 | 118 | func (c *Client) newRequest(method, path string, body interface{}) (*http.Request, error) { 119 | rel, err := url.Parse(path) 120 | if err != nil { 121 | return nil, err 122 | } 123 | 124 | u := c.baseURL.ResolveReference(rel) 125 | 126 | var buf io.ReadWriter 127 | if body != nil { 128 | buf = new(bytes.Buffer) 129 | err := json.NewEncoder(buf).Encode(body) 130 | if err != nil { 131 | return nil, err 132 | } 133 | c.logf(LevelDebug, "%s", buf) 134 | } 135 | 136 | req, err := http.NewRequest(method, u.String(), buf) 137 | if err != nil { 138 | return nil, err 139 | } 140 | if body != nil { 141 | req.Header.Set("Content-Type", "application/json") 142 | } 143 | if c.userAgent != "" { 144 | req.Header.Set("User-Agent", c.userAgent) 145 | } 146 | req.Header.Set("Accept", "application/json") 147 | 148 | return req, nil 149 | } 150 | 151 | func (c *Client) curlify(req *http.Request) (string, error) { 152 | cc, err := http2curl.GetCurlCommand(req) 153 | if err != nil { 154 | return "", err 155 | } 156 | return cc.String(), nil 157 | } 158 | 159 | func (c *Client) logCurlify(req *http.Request) { 160 | var msg string 161 | if curl, err := c.curlify(req); err != nil { 162 | msg = err.Error() 163 | } else { 164 | msg = curl 165 | } 166 | 167 | c.logf(LevelDebug, "%s", msg) 168 | } 169 | 170 | func (c *Client) do(ctx context.Context, req *http.Request, v interface{}) (*http.Response, error) { 171 | req = req.WithContext(ctx) 172 | 173 | c.logCurlify(req) 174 | 175 | resp, err := c.httpClient.Do(req) 176 | if err != nil { 177 | select { 178 | case <-ctx.Done(): 179 | return nil, ctx.Err() 180 | default: 181 | } 182 | return nil, err 183 | } 184 | defer resp.Body.Close() 185 | 186 | if resp.StatusCode < 200 || resp.StatusCode >= 400 { 187 | msg := fmt.Sprintf("Status code not within 200 to 400, but %d (%s)", 188 | resp.StatusCode, http.StatusText(resp.StatusCode)) 189 | c.logf(LevelDebug, "%s", msg) 190 | if resp.StatusCode == 404 { 191 | return nil, NotFoundError 192 | } 193 | 194 | var e rest.Error 195 | err = json.NewDecoder(resp.Body).Decode(&e) 196 | if err != nil { 197 | return nil, fmt.Errorf("failed to decode error response: %w", err) 198 | } 199 | return nil, e 200 | } 201 | 202 | if v != nil { 203 | err = json.NewDecoder(resp.Body).Decode(v) 204 | } 205 | return resp, err 206 | } 207 | 208 | func (c *Client) doGET(ctx context.Context, url string, ret interface{}) (*http.Response, error) { 209 | req, err := c.newRequest("GET", url, nil) 210 | if err != nil { 211 | return nil, err 212 | } 213 | return c.do(ctx, req, &ret) 214 | } 215 | 216 | func (c *Client) doPOST(ctx context.Context, url string, body interface{}, ret interface{}) (*http.Response, error) { 217 | req, err := c.newRequest("POST", url, body) 218 | if err != nil { 219 | return nil, err 220 | } 221 | 222 | return c.do(ctx, req, &ret) 223 | } 224 | 225 | func (c *Client) doPUT(ctx context.Context, url string, body interface{}, ret interface{}) (*http.Response, error) { 226 | req, err := c.newRequest("PUT", url, body) 227 | if err != nil { 228 | return nil, err 229 | } 230 | 231 | return c.do(ctx, req, &ret) 232 | } 233 | 234 | func (c *Client) doDELETE(ctx context.Context, url string, body interface{}) (*http.Response, error) { 235 | req, err := c.newRequest("DELETE", url, body) 236 | if err != nil { 237 | return nil, err 238 | } 239 | 240 | return c.do(ctx, req, nil) 241 | } 242 | -------------------------------------------------------------------------------- /pkg/linstorcontrol/linstorcontrol_test.go: -------------------------------------------------------------------------------- 1 | package linstorcontrol 2 | 3 | import ( 4 | "github.com/LINBIT/golinstor/client" 5 | "github.com/LINBIT/linstor-gateway/pkg/common" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func resState(inUse bool) *client.ResourceState { 11 | return &client.ResourceState{ 12 | InUse: &inUse, 13 | } 14 | } 15 | 16 | func volume(number int32, diskState string) client.Volume { 17 | return client.Volume{ 18 | VolumeNumber: number, 19 | State: client.VolumeState{ 20 | DiskState: diskState, 21 | }, 22 | } 23 | } 24 | 25 | func TestStatusFromResources(t *testing.T) { 26 | defaultResourceDefinition := &client.ResourceDefinition{ 27 | Name: "test-resource", 28 | ResourceGroupName: "test-group", 29 | Props: map[string]string{ 30 | "files/path/to/config.toml": "True", 31 | }, 32 | } 33 | defaultResourceGroup := &client.ResourceGroup{ 34 | Name: "test-group", 35 | Props: nil, 36 | SelectFilter: client.AutoSelectFilter{ 37 | PlaceCount: 2, 38 | }, 39 | } 40 | type args struct { 41 | serviceCfgPath string 42 | definition *client.ResourceDefinition 43 | group *client.ResourceGroup 44 | resources []client.ResourceWithVolumes 45 | } 46 | tests := []struct { 47 | name string 48 | args args 49 | want common.ResourceStatus 50 | }{ 51 | { 52 | name: "two-replicas-one-diskless", 53 | args: args{ 54 | serviceCfgPath: "/path/to/config.toml", 55 | resources: []client.ResourceWithVolumes{{ 56 | Resource: client.Resource{Name: "test-resource", NodeName: "node1", State: resState(true)}, 57 | Volumes: []client.Volume{ 58 | volume(0, "UpToDate"), 59 | volume(1, "UpToDate"), 60 | }, 61 | }, { 62 | Resource: client.Resource{Name: "test-resource", NodeName: "node2", State: resState(false)}, 63 | Volumes: []client.Volume{ 64 | volume(0, "UpToDate"), 65 | volume(1, "UpToDate"), 66 | }, 67 | }, { 68 | Resource: client.Resource{Name: "test-resource", NodeName: "node3", State: resState(false)}, 69 | Volumes: []client.Volume{ 70 | volume(0, "Diskless"), 71 | volume(1, "Diskless"), 72 | }, 73 | }}, 74 | }, 75 | want: common.ResourceStatus{ 76 | State: common.ResourceStateOK, 77 | Service: common.ServiceStateStarted, 78 | Primary: "node1", 79 | Nodes: []string{"node1", "node2", "node3"}, 80 | Volumes: []common.VolumeState{ 81 | {Number: 0, State: common.ResourceStateOK}, 82 | {Number: 1, State: common.ResourceStateOK}, 83 | }, 84 | }, 85 | }, { 86 | name: "degraded-one-replica-one-diskless", 87 | args: args{ 88 | serviceCfgPath: "/path/to/config.toml", 89 | resources: []client.ResourceWithVolumes{{ 90 | Resource: client.Resource{Name: "test-resource", NodeName: "node1", State: resState(false)}, 91 | Volumes: []client.Volume{ 92 | volume(0, "UpToDate"), 93 | volume(1, "UpToDate"), 94 | }, 95 | }, { 96 | Resource: client.Resource{Name: "test-resource", NodeName: "node2", State: resState(true)}, 97 | Volumes: []client.Volume{ 98 | volume(0, "Diskless"), 99 | volume(1, "Diskless"), 100 | }, 101 | }}, 102 | }, 103 | want: common.ResourceStatus{ 104 | State: common.ResourceStateDegraded, 105 | Service: common.ServiceStateStarted, 106 | Primary: "node2", 107 | Nodes: []string{"node1", "node2"}, 108 | Volumes: []common.VolumeState{ 109 | {Number: 0, State: common.ResourceStateDegraded}, 110 | {Number: 1, State: common.ResourceStateDegraded}, 111 | }, 112 | }, 113 | }, { 114 | name: "unknown-no-resources", 115 | args: args{ 116 | serviceCfgPath: "/path/to/config.toml", 117 | resources: []client.ResourceWithVolumes{}, 118 | }, 119 | want: common.ResourceStatus{ 120 | State: common.Unknown, 121 | Service: common.ServiceStateStopped, 122 | Primary: "", 123 | Nodes: []string{}, 124 | Volumes: []common.VolumeState{}, 125 | }, 126 | }, { 127 | name: "config-not-deployed", 128 | args: args{ 129 | serviceCfgPath: "/path/to/config.toml", 130 | resources: []client.ResourceWithVolumes{{ 131 | Resource: client.Resource{Name: "test-resource", NodeName: "node1", State: resState(true)}, 132 | Volumes: []client.Volume{ 133 | volume(0, "UpToDate"), 134 | volume(1, "UpToDate"), 135 | }, 136 | }, { 137 | Resource: client.Resource{Name: "test-resource", NodeName: "node2", State: resState(false)}, 138 | Volumes: []client.Volume{ 139 | volume(0, "UpToDate"), 140 | volume(1, "UpToDate"), 141 | }, 142 | }}, 143 | definition: &client.ResourceDefinition{ 144 | Name: "test-resource", 145 | ResourceGroupName: "test-group", 146 | Props: map[string]string{}, 147 | }, 148 | }, 149 | want: common.ResourceStatus{ 150 | State: common.ResourceStateOK, 151 | Service: common.ServiceStateStopped, 152 | Primary: "node1", 153 | Nodes: []string{"node1", "node2"}, 154 | Volumes: []common.VolumeState{ 155 | {Number: 0, State: common.ResourceStateOK}, 156 | {Number: 1, State: common.ResourceStateOK}, 157 | }, 158 | }, 159 | }, { 160 | name: "config-deployed-but-no-resource-in-use", 161 | args: args{ 162 | serviceCfgPath: "/path/to/config.toml", 163 | resources: []client.ResourceWithVolumes{{ 164 | Resource: client.Resource{Name: "test-resource", NodeName: "node1", State: resState(false)}, 165 | Volumes: []client.Volume{ 166 | volume(0, "UpToDate"), 167 | volume(1, "UpToDate"), 168 | }, 169 | }, { 170 | Resource: client.Resource{Name: "test-resource", NodeName: "node2", State: resState(false)}, 171 | Volumes: []client.Volume{ 172 | volume(0, "UpToDate"), 173 | volume(1, "UpToDate"), 174 | }, 175 | }}, 176 | }, 177 | want: common.ResourceStatus{ 178 | State: common.ResourceStateOK, 179 | Service: common.ServiceStateStopped, 180 | Primary: "", 181 | Nodes: []string{"node1", "node2"}, 182 | Volumes: []common.VolumeState{ 183 | {Number: 0, State: common.ResourceStateOK}, 184 | {Number: 1, State: common.ResourceStateOK}, 185 | }, 186 | }, 187 | }, 188 | } 189 | for _, tt := range tests { 190 | t.Run(tt.name, func(t *testing.T) { 191 | if tt.args.definition == nil { 192 | tt.args.definition = defaultResourceDefinition 193 | } 194 | if tt.args.group == nil { 195 | tt.args.group = defaultResourceGroup 196 | } 197 | got := StatusFromResources(tt.args.serviceCfgPath, tt.args.definition, tt.args.group, tt.args.resources) 198 | assert.Equal(t, tt.want, got) 199 | }) 200 | } 201 | } 202 | --------------------------------------------------------------------------------