├── 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 | 
2 |
3 | # LINSTOR Gateway
4 |
5 |
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:
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 |
--------------------------------------------------------------------------------