├── .github ├── dependabot.yml └── workflows │ ├── main.yaml │ └── release.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── config.json ├── driver.go ├── driver_test.go ├── driver_testwrapper.go ├── go.mod ├── go.sum ├── main.go ├── os.go └── os_test.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | labels: 10 | - dependency 11 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | test: 9 | env: 10 | LATEST_GO: 1.21.x # version used for release 11 | strategy: 12 | matrix: 13 | go: [ 1.21.x ] 14 | os: [ ubuntu-latest ] 15 | arch: 16 | - arm64 17 | - amd64 18 | runs-on: ${{ matrix.os }} 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Setup go 23 | uses: actions/setup-go@v3 24 | with: 25 | go-version: ${{ matrix.go }} 26 | 27 | - run: make test 28 | 29 | test_build: 30 | runs-on: ubuntu-latest 31 | strategy: 32 | matrix: 33 | arch: 34 | - arm64 35 | - amd64 36 | steps: 37 | - uses: actions/checkout@v3 38 | 39 | - name: Set up QEMU 40 | uses: docker/setup-qemu-action@v2 41 | 42 | - name: Set up Docker Buildx 43 | uses: docker/setup-buildx-action@v2 44 | 45 | - run: make create PLUGIN_NAME=ghcr.io/${{ github.repository }} PLUGIN_TAG=$(shell git describe --tags --exact-match 2> /dev/null || echo dev)-${{ matrix.arch }} -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | release_build: 10 | env: 11 | LATEST_GO: 1.21.x # version used for release 12 | strategy: 13 | matrix: 14 | go: [ 1.21.x ] 15 | os: [ ubuntu-latest ] 16 | arch: 17 | - arm64 18 | - amd64 19 | name: ${{ matrix.os }}/go${{ matrix.go }} ${{ matrix.arch }} 20 | runs-on: ${{ matrix.os }} 21 | steps: 22 | - uses: actions/checkout@v3 23 | 24 | - name: Setup go 25 | uses: actions/setup-go@v3 26 | with: 27 | go-version: ${{ matrix.go }} 28 | 29 | - run: make test 30 | 31 | - name: Set up QEMU 32 | uses: docker/setup-qemu-action@v2 33 | 34 | - name: Set up Docker Buildx 35 | uses: docker/setup-buildx-action@v2 36 | 37 | - name: Login to GitHub Container Registry 38 | uses: docker/login-action@v2 39 | with: 40 | registry: ghcr.io 41 | username: ${{ github.actor }} 42 | password: ${{ secrets.GITHUB_TOKEN }} 43 | 44 | - name: Set env 45 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 46 | 47 | - run: | 48 | make push PLUGIN_NAME=ghcr.io/${{ github.repository }} PLUGIN_TAG=$RELEASE_VERSION-${{ matrix.arch }} ARCH=${{ matrix.arch }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docker-volume-hetzner 2 | plugin 3 | temp -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$TARGETPLATFORM golang:1.21.3-alpine as builder 2 | 3 | ENV CGO_ENABLED=0 4 | 5 | RUN apk add --update git 6 | 7 | WORKDIR /plugin 8 | 9 | # warm up go mod cache 10 | COPY go.mod go.sum ./ 11 | RUN go mod download 12 | 13 | COPY . /plugin 14 | RUN go build -v 15 | 16 | 17 | FROM --platform=$TARGETPLATFORM alpine 18 | 19 | RUN apk add --update ca-certificates e2fsprogs xfsprogs 20 | 21 | RUN mkdir -p /run/docker/plugins /mnt/volumes 22 | 23 | COPY --from=builder /plugin/docker-volume-hetzner /plugin/ 24 | 25 | ENTRYPOINT [ "/plugin/docker-volume-hetzner" ] 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Leo Antunes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PLUGIN_NAME = costela/docker-volume-hetzner 2 | PLUGIN_TAG ?= $(shell git describe --tags --exact-match 2> /dev/null || echo dev) 3 | ARCH = amd64 4 | 5 | all: create 6 | 7 | # requires superuser for tmpfs mounts in tests 8 | test: 9 | sudo go test -race -v ./... 10 | 11 | clean: 12 | @rm -rf ./plugin 13 | @docker container rm -vf tmp_plugin_build || true 14 | 15 | rootfs: clean 16 | docker image build --platform=linux/${ARCH} -t ${PLUGIN_NAME}:rootfs . 17 | mkdir -p ./plugin/rootfs 18 | docker container create --name tmp_plugin_build ${PLUGIN_NAME}:rootfs 19 | docker container export tmp_plugin_build | tar -x -C ./plugin/rootfs 20 | cp config.json ./plugin/ 21 | docker container rm -vf tmp_plugin_build 22 | 23 | create: rootfs 24 | docker plugin rm -f ${PLUGIN_NAME}:${PLUGIN_TAG} 2> /dev/null || true 25 | docker plugin create ${PLUGIN_NAME}:${PLUGIN_TAG} ./plugin 26 | 27 | enable: create 28 | docker plugin enable ${PLUGIN_NAME}:${PLUGIN_TAG} 29 | 30 | push: create 31 | docker plugin push ${PLUGIN_NAME}:${PLUGIN_TAG} 32 | 33 | push_latest: create 34 | docker plugin push ${PLUGIN_NAME}:latest 35 | 36 | .PHONY: clean rootfs create enable push 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go Report Card](https://goreportcard.com/badge/github.com/costela/docker-volume-hetzner)](https://goreportcard.com/report/github.com/costela/docker-volume-hetzner) 2 | ![tests](https://github.com/costela/docker-volume-hetzner/actions/workflows/main.yaml/badge.svg) 3 | 4 | # Docker Volume Plugin for Hetzner Cloud 5 | 6 | This plugin manages docker volumes using Hetzner Cloud's volumes. 7 | 8 | **This plugin is still in ALPHA; use at your own risk** 9 | 10 | ## Installation 11 | 12 | To install the plugin, run the following command: 13 | ```shell 14 | $ docker plugin install --alias hetzner ghcr.io/costela/docker-volume-hetzner:...-amd64 15 | ``` 16 | 17 | When using Docker Swarm, this should be done on all nodes in the cluster. 18 | 19 | **Important**: the plugin expects the Docker node's `hostname` to match with the name of the server created on Hetzner Cloud. This should usually be the case, unless explicitly changed. 20 | 21 | #### Plugin privileges 22 | 23 | During installation, you will be prompted to accept the plugins's privilege requirements. The following are required: 24 | 25 | - **network**: used for communicating with the Hetzner Cloud API 26 | - **mount[\/dev\/]**: needed for accessing the Hetzner Cloud Volumes (made available to the host as a SCSI device) 27 | - **allow-all-devices**: actually enable access to the volume devices mentioned above (since the devices cannot be known a priori) 28 | - **capabilities[CAP\_SYS\_ADMIN,CAP\_CHOWN]**: needed for running `mount` and `chown` 29 | 30 | ## Usage 31 | 32 | First, create an API key from the Hetzner Cloud console and save it temporarily. 33 | 34 | Install the plugin as described above. Then, set the API key in the plugin options, where `` is the key you just created: 35 | 36 | ```shell 37 | $ docker plugin disable hetzner 38 | $ docker plugin set hetzner apikey= 39 | $ docker plugin enable hetzner 40 | ``` 41 | 42 | Again, when using Docker Swarm, this should be done on all nodes in the cluster. 43 | 44 | The plugin is then ready to be used, e.g. in a `docker-compose` file, by setting the `driver` option on the docker `volume` definition (assuming the alias `hetzner` passed during installation above). 45 | 46 | For example, when using the following `docker-compose` volume definition in a project called `foo`: 47 | 48 | ```yaml 49 | volumes: 50 | somevolume: 51 | driver: hetzner 52 | ``` 53 | 54 | This will initialize a Hetzner volume named `docker-foo_somevolume` (see the `prefix` configuration below). 55 | 56 | If the volume `docker-foo_somevolume` does not exist in the Hetzner Cloud project, the plugin will do the following: 57 | 58 | 1. Create the Hetzner Cloud (HC) volume 59 | 2. Attach the created HC volume to the node requesting the creation (when using docker swarm, this will be the manager node being used) 60 | 3. Format the HC volume (using `fstype` option; see below) 61 | 4. `chown` the volume to the appropriate `uid`/`gid` if specified. 62 | 63 | The plugin will then mount the volume on the node running its parent service, if any. 64 | 65 | ## Configuration 66 | 67 | The following options can be passed to the plugin via `docker plugin set` (all names **case-sensitive**): 68 | 69 | - **`apikey`** (**required**): authentication token to use when accessing the Hetzner Cloud API 70 | - **`size`** (optional): size of the volume in GB (default: `10`) 71 | - **`fstype`** (optional): filesystem type to be created on new volumes. Currently supported values are `ext{2,3,4}` and `xfs` (default: `ext4`) 72 | - **`prefix`** (optional): prefix to use when naming created volumes; the final name on the HC side will be of the form `prefix-name`, where `name` is the volume name assigned by `docker` (default: `docker`) 73 | - **`loglevel`** (optional): the amount of information that will be output by the plugin. Accepts any value supported by [logrus](https://github.com/sirupsen/logrus) (i.e.: `fatal`, `error`, `warn`, `info` and `debug`; default: `warn`) 74 | - **`use_protection`** (optional): whether to enable/disable deletion protection on creation/deletion. Disable this if you want to manage deletion protection yourself. (default: `true`) 75 | - **`uid`** (optional): which user id to use by default as owners for the filesystem of newly created volumes 76 | - **`gid`** (optional): which group id to use by default as owners for the filesystem of newly created volumes 77 | 78 | Additionally, `size`, `fstype`, `uid` and `gid` can also be passed as options to the driver via `driver_opts`: 79 | 80 | ```yaml 81 | volumes: 82 | somevolume: 83 | driver: hetzner 84 | driver_opts: 85 | size: '42' 86 | fstype: xfs 87 | uid: '999' 88 | gid: '999' 89 | ``` 90 | 91 | :warning: Passing any option besides `size`, `fstype`, `uid` and `gid` to the volume definition will have no effect beyond a warning in the logs. Use `docker plugin set` instead. 92 | 93 | ## Limitations 94 | 95 | - *Concurrent use*: Hetzner Cloud volumes currently cannot be attached to multiple nodes, so the same limitation 96 | applies to the docker volumes using them. This also precludes concurrent use by multiple containers on the same node, 97 | since there is currently no way to enforce docker swarm services to be managed together (cf. kubernetes pods). 98 | - *Single location*: since volumes are currently bound to the location they were created in, this plugin will not 99 | be able to reattach a volume if you have a swarm cluster across locations and its service migrates over the location 100 | boundary. 101 | - *Volume resizing*: docker has no support for updating volume definitions. After a volume is created, its `size` 102 | option is currently ignored. This may be worked around in a future release. 103 | - *Docker partitions*: when used in a docker swarm setup, there is a chance a network hiccup between docker nodes 104 | might be seen as a node down, in which case the scheduler will start the container on a different node and will 105 | "steal" its volume while in use, potentially causing data loss. 106 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Hetzner Cloud Volumes plugin for Docker", 3 | "documentation": "https://github.com/costela/docker-volume-hetzner", 4 | "entrypoint": [ 5 | "/plugin/docker-volume-hetzner" 6 | ], 7 | "env": [ 8 | { 9 | "name": "apikey", 10 | "description": "authentication token to use when accessing the Hetzner Cloud API", 11 | "settable": ["value"], 12 | "value": "" 13 | }, 14 | { 15 | "name": "size", 16 | "description": "standard size of the created volume in GB", 17 | "settable": ["value"], 18 | "value": "10" 19 | }, 20 | { 21 | "name": "prefix", 22 | "description": "prefix to use when naming created volumes", 23 | "settable": ["value"], 24 | "value": "docker" 25 | }, 26 | { 27 | "name": "fstype", 28 | "description": "filesystem type to be created on new volumes", 29 | "settable": ["value"], 30 | "value": "ext4" 31 | }, 32 | { 33 | "name": "uid", 34 | "description": "uid to be assigned on new volumes", 35 | "settable": ["value"], 36 | "value": "0" 37 | }, 38 | { 39 | "name": "gid", 40 | "description": "gid to be assigned on new volumes", 41 | "settable": ["value"], 42 | "value": "0" 43 | }, 44 | { 45 | "name": "use_protection", 46 | "description": "whether to enable/disable delete protection on volumes managed by this plugin", 47 | "settable": ["value"], 48 | "value": "true" 49 | }, 50 | { 51 | "name": "loglevel", 52 | "description": "log level passed to logrus", 53 | "settable": ["value"], 54 | "value": "warn" 55 | } 56 | ], 57 | "interface": { 58 | "socket": "hetzner.sock", 59 | "types": [ 60 | "docker.volumedriver/1.0" 61 | ] 62 | }, 63 | "linux": { 64 | "allowAllDevices": true, 65 | "capabilities": ["CAP_SYS_ADMIN", "CAP_CHOWN"] 66 | }, 67 | "mounts": [ 68 | { 69 | "description": "used to access the dynamically attached block devices", 70 | "destination": "/dev", 71 | "options": ["rbind","rshared"], 72 | "name": "dev", 73 | "source": "/dev/", 74 | "type": "bind" 75 | } 76 | ], 77 | "network": { 78 | "type": "host" 79 | }, 80 | "propagatedmount": "/mnt" 81 | } 82 | -------------------------------------------------------------------------------- /driver.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/docker/docker/pkg/mount" 12 | "github.com/hashicorp/go-multierror" 13 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 14 | "github.com/sirupsen/logrus" 15 | 16 | "github.com/docker/go-plugins-helpers/volume" 17 | ) 18 | 19 | // used in methods that take &bools 20 | var trueVar = true 21 | var falseVar = false 22 | 23 | type hetznerDriver struct { 24 | client hetznerClienter 25 | } 26 | 27 | func newHetznerDriver() *hetznerDriver { 28 | return &hetznerDriver{ 29 | client: &hetznerClient{hcloud.NewClient(hcloud.WithToken(strings.TrimSpace(os.Getenv("apikey"))))}, 30 | } 31 | } 32 | 33 | func (hd *hetznerDriver) Capabilities() *volume.CapabilitiesResponse { 34 | return &volume.CapabilitiesResponse{ 35 | Capabilities: volume.Capability{Scope: "global"}, 36 | } 37 | } 38 | 39 | func (hd *hetznerDriver) Create(req *volume.CreateRequest) error { 40 | validateOptions(req.Name, req.Options) 41 | 42 | prefixedName := prefixName(req.Name) 43 | 44 | logrus.Infof("starting volume creation for %q", prefixedName) 45 | 46 | size, err := strconv.Atoi(getOption("size", req.Options)) 47 | if err != nil { 48 | return fmt.Errorf("converting size %q to int: %w", getOption("size", req.Options), err) 49 | } 50 | 51 | srv, err := hd.getServerForLocalhost() 52 | if err != nil { 53 | return err 54 | } 55 | 56 | opts := hcloud.VolumeCreateOpts{ 57 | Name: prefixedName, 58 | Size: size, 59 | Location: srv.Datacenter.Location, // attach explicitly to be able to wait 60 | Labels: map[string]string{"docker-volume-hetzner": ""}, 61 | } 62 | switch f := getOption("fstype", req.Options); f { 63 | case "xfs", "ext4": 64 | opts.Format = hcloud.String(f) 65 | } 66 | 67 | resp, _, err := hd.client.Volume().Create(context.Background(), opts) 68 | if err != nil { 69 | return fmt.Errorf("creating volume %q: %w", prefixedName, err) 70 | } 71 | if err := hd.waitForAction(resp.Action); err != nil { 72 | return fmt.Errorf("waiting for create volume %q: %w", prefixedName, err) 73 | } 74 | 75 | logrus.Infof("volume %q (%dGB) created on %q; attaching", prefixedName, size, srv.Name) 76 | 77 | act, _, err := hd.client.Volume().Attach(context.Background(), resp.Volume, srv) 78 | if err != nil { 79 | return fmt.Errorf("attaching volume %q to %q: %w", prefixedName, srv.Name, err) 80 | } 81 | if err := hd.waitForAction(act); err != nil { 82 | return fmt.Errorf("waiting for volume attachment: %q to %q: %w", prefixedName, srv.Name, err) 83 | } 84 | 85 | logrus.Infof("volume %q attached to %q", prefixedName, srv.Name) 86 | 87 | if useProtection() { 88 | // be optimistic for now and ignore errors here 89 | _, _, _ = hd.client.Volume().ChangeProtection(context.Background(), resp.Volume, hcloud.VolumeChangeProtectionOpts{Delete: &trueVar}) 90 | } 91 | 92 | if opts.Format == nil { 93 | logrus.Infof("formatting %q as %q", prefixedName, getOption("fstype", req.Options)) 94 | err = mkfs(resp.Volume.LinuxDevice, getOption("fstype", req.Options)) 95 | if err != nil { 96 | return fmt.Errorf("mkfs on %q: %w", resp.Volume.LinuxDevice, err) 97 | } 98 | } 99 | 100 | uid := getOption("uid", req.Options) 101 | gid := getOption("gid", req.Options) 102 | if uid != "0" || gid != "0" { 103 | // string to int 104 | uintParsed, err := strconv.Atoi(uid) 105 | if err != nil { 106 | return fmt.Errorf("parsing uid option value as integer: %s: %w", gid, err) 107 | } 108 | gidParsed, err := strconv.Atoi(gid) 109 | if err != nil { 110 | return fmt.Errorf("parsing gid option value as integer: %s: %w", gid, err) 111 | } 112 | 113 | if err := setPermissions(resp.Volume.LinuxDevice, getOption("fstype", req.Options), uintParsed, gidParsed); err != nil { 114 | return fmt.Errorf("chown %q to '%s:%s': %w", resp.Volume.LinuxDevice, uid, gid, err) 115 | } 116 | } 117 | 118 | return nil 119 | } 120 | 121 | func (hd *hetznerDriver) List() (*volume.ListResponse, error) { 122 | logrus.Infof("got list request") 123 | 124 | vols, err := hd.client.Volume().All(context.Background()) 125 | if err != nil { 126 | return nil, fmt.Errorf("could not list all volumes: %w", err) 127 | } 128 | 129 | mounts, err := getMounts() 130 | if err != nil { 131 | return nil, fmt.Errorf("could not get local mounts: %w", err) 132 | } 133 | 134 | resp := volume.ListResponse{ 135 | Volumes: make([]*volume.Volume, 0, len(vols)), 136 | } 137 | for _, vol := range vols { 138 | if !nameHasPrefix(vol.Name) { 139 | continue 140 | } 141 | v := &volume.Volume{ 142 | Name: unprefixedName(vol.Name), 143 | } 144 | if mountpoint, ok := mounts[vol.LinuxDevice]; ok { 145 | v.Mountpoint = mountpoint 146 | } 147 | resp.Volumes = append(resp.Volumes, v) 148 | } 149 | 150 | return &resp, nil 151 | } 152 | 153 | func (hd *hetznerDriver) Get(req *volume.GetRequest) (*volume.GetResponse, error) { 154 | prefixedName := prefixName(req.Name) 155 | 156 | logrus.Infof("fetching information for volume %q", prefixedName) 157 | 158 | vol, _, err := hd.client.Volume().GetByName(context.Background(), prefixedName) 159 | if err != nil || vol == nil { 160 | return nil, fmt.Errorf("getting cloud volume %q: %w", prefixedName, err) 161 | } 162 | 163 | mounts, err := getMounts() 164 | if err != nil { 165 | return nil, fmt.Errorf("getting local mounts: %w", err) 166 | } 167 | 168 | status := make(map[string]interface{}) 169 | 170 | mountpoint, mounted := mounts[vol.LinuxDevice] 171 | if mounted { 172 | status["mounted"] = true 173 | } 174 | 175 | resp := volume.GetResponse{ 176 | Volume: &volume.Volume{ 177 | Name: unprefixedName(vol.Name), 178 | Mountpoint: mountpoint, 179 | CreatedAt: vol.Created.Format(time.RFC3339), 180 | Status: status, 181 | }, 182 | } 183 | 184 | logrus.Infof("returning info on %q: %#v", prefixedName, resp.Volume) 185 | 186 | return &resp, nil 187 | } 188 | 189 | func (hd *hetznerDriver) Remove(req *volume.RemoveRequest) error { 190 | prefixedName := prefixName(req.Name) 191 | 192 | logrus.Infof("starting volume removal for %q", prefixedName) 193 | 194 | vol, _, err := hd.client.Volume().GetByName(context.Background(), prefixedName) 195 | if err != nil || vol == nil { 196 | return fmt.Errorf("getting cloud volume %q: %w", prefixedName, err) 197 | } 198 | 199 | if useProtection() { 200 | logrus.Infof("disabling protection for %q", prefixedName) 201 | act, _, err := hd.client.Volume().ChangeProtection(context.Background(), vol, hcloud.VolumeChangeProtectionOpts{Delete: &falseVar}) 202 | if err != nil { 203 | return fmt.Errorf("unprotecting volume %q: %w", prefixedName, err) 204 | } 205 | if err := hd.waitForAction(act); err != nil { 206 | return fmt.Errorf("waiting for volume unprotecton %q: %w", prefixedName, err) 207 | } 208 | } 209 | 210 | if vol.Server != nil && vol.Server.ID != 0 { 211 | logrus.Infof("detaching volume %q (attached to %d)", prefixedName, vol.Server.ID) 212 | act, _, err := hd.client.Volume().Detach(context.Background(), vol) 213 | if err != nil { 214 | return fmt.Errorf("detaching volume %q: %w", prefixedName, err) 215 | } 216 | if err := hd.waitForAction(act); err != nil { 217 | return fmt.Errorf("waiting for volume detach on %q: %w", prefixedName, err) 218 | } 219 | } 220 | 221 | _, err = hd.client.Volume().Delete(context.Background(), vol) 222 | if err != nil { 223 | return fmt.Errorf("deleting volume %q: %w", prefixedName, err) 224 | } 225 | 226 | logrus.Infof("volume %q removed successfully", prefixedName) 227 | 228 | return nil 229 | } 230 | 231 | func (hd *hetznerDriver) Path(req *volume.PathRequest) (*volume.PathResponse, error) { 232 | prefixedName := prefixName(req.Name) 233 | 234 | logrus.Infof("got path request for volume %q", prefixedName) 235 | 236 | resp, err := hd.Get(&volume.GetRequest{Name: req.Name}) 237 | if err != nil { 238 | return nil, fmt.Errorf("getting path for volume %q: %w", prefixedName, err) 239 | } 240 | 241 | return &volume.PathResponse{Mountpoint: resp.Volume.Mountpoint}, nil 242 | } 243 | 244 | func (hd *hetznerDriver) Mount(req *volume.MountRequest) (*volume.MountResponse, error) { 245 | prefixedName := prefixName(req.Name) 246 | 247 | logrus.Infof("received mount request for %q as %q", prefixedName, req.ID) 248 | 249 | vol, _, err := hd.client.Volume().GetByName(context.Background(), prefixedName) 250 | if err != nil || vol == nil { 251 | return nil, fmt.Errorf("getting volume %q: %w", prefixedName, err) 252 | } 253 | 254 | if vol.Server != nil && vol.Server.ID != 0 { 255 | volSrv, _, err := hd.client.Server().GetByID(context.Background(), vol.Server.ID) 256 | if err != nil { 257 | return nil, fmt.Errorf("fetching server details for volume %q: %w", prefixedName, err) 258 | } 259 | vol.Server = volSrv 260 | } 261 | 262 | srv, err := hd.getServerForLocalhost() 263 | if err != nil { 264 | return nil, err 265 | } 266 | 267 | if vol.Server == nil || vol.Server.Name != srv.Name { 268 | if vol.Server != nil && vol.Server.Name != "" { 269 | logrus.Infof("detaching volume %q from %q", prefixedName, vol.Server.Name) 270 | act, _, err := hd.client.Volume().Detach(context.Background(), vol) 271 | if err != nil { 272 | return nil, fmt.Errorf("detaching volume %q from %q: %w", vol.Name, vol.Server.Name, err) 273 | } 274 | if err := hd.waitForAction(act); err != nil { 275 | return nil, fmt.Errorf("waiting for volume detachment on %q from %q: %w", vol.Name, vol.Server.Name, err) 276 | } 277 | } 278 | logrus.Infof("attaching volume %q to %q", prefixedName, srv.Name) 279 | act, _, err := hd.client.Volume().Attach(context.Background(), vol, srv) 280 | if err != nil { 281 | return nil, fmt.Errorf("attaching volume %q to %q: %w", vol.Name, srv.Name, err) 282 | } 283 | if err := hd.waitForAction(act); err != nil { 284 | return nil, fmt.Errorf("waiting for volume attachment on %q to %q: %w", vol.Name, srv.Name, err) 285 | } 286 | } 287 | 288 | mountpoint := fmt.Sprintf("%s/%s", propagatedMountPath, req.ID) 289 | 290 | logrus.Infof("creating mountpoint %s", mountpoint) 291 | if err := os.MkdirAll(mountpoint, 0o755); err != nil { 292 | return nil, fmt.Errorf("creating mountpoint %s: %w", mountpoint, err) 293 | } 294 | 295 | logrus.Infof("mounting %q on %q", prefixedName, mountpoint) 296 | 297 | // copy busybox' approach and just try everything we expect might work 298 | var merr error 299 | mounted := false 300 | for _, fstype := range supportedFileystemTypes { 301 | if err := mount.Mount(vol.LinuxDevice, mountpoint, fstype, ""); err == nil { 302 | mounted = true 303 | break 304 | } 305 | merr = multierror.Append(merr, err) 306 | } 307 | if !mounted { 308 | return nil, fmt.Errorf("mounting %q as any of %s: %w", vol.LinuxDevice, supportedFileystemTypes, err) 309 | } 310 | 311 | logrus.Infof("successfully mounted %q on %q", prefixedName, mountpoint) 312 | 313 | return &volume.MountResponse{Mountpoint: mountpoint}, nil 314 | } 315 | 316 | func (hd *hetznerDriver) Unmount(req *volume.UnmountRequest) error { 317 | prefixedName := prefixName(req.Name) 318 | 319 | logrus.Infof("received unmount request for %q as %q", prefixedName, req.ID) 320 | 321 | vol, _, err := hd.client.Volume().GetByName(context.Background(), prefixedName) 322 | if err != nil || vol == nil { 323 | return fmt.Errorf("getting volume %q: %w", prefixedName, err) 324 | } 325 | 326 | mountpoint := fmt.Sprintf("%s/%s", propagatedMountPath, req.ID) 327 | 328 | if err := mount.Unmount(mountpoint); err != nil { 329 | return fmt.Errorf("unmounting %q: %w", mountpoint, err) 330 | } 331 | 332 | logrus.Infof("unmounted %q", mountpoint) 333 | 334 | if err := os.Remove(mountpoint); err != nil { 335 | return fmt.Errorf("removing mountpoint %s: %w", mountpoint, err) 336 | } 337 | 338 | srv, err := hd.getServerForLocalhost() 339 | if err != nil { 340 | return nil 341 | } 342 | 343 | if vol.Server == nil || vol.Server.Name != srv.Name { 344 | return nil 345 | } 346 | 347 | logrus.Infof("detaching volume %q", prefixedName) 348 | 349 | act, _, err := hd.client.Volume().Detach(context.Background(), vol) 350 | if err != nil { 351 | return fmt.Errorf("detaching volume %q: %w", vol.Name, err) 352 | } 353 | if err := hd.waitForAction(act); err != nil { 354 | return fmt.Errorf("waiting for volume detach on %q: %w", vol.Name, err) 355 | } 356 | 357 | return nil 358 | } 359 | 360 | func (hd *hetznerDriver) getServerForLocalhost() (*hcloud.Server, error) { 361 | hostname, err := os.Hostname() 362 | if err != nil { 363 | return nil, fmt.Errorf("getting local hostname: %w", err) 364 | } 365 | 366 | if strings.Contains(hostname, ".") { 367 | logrus.Warnf("hostname contains dot (%q); make sure hostname != FQDN and matches the hcloud server name", hostname) 368 | } 369 | 370 | srv, _, err := hd.client.Server().GetByName(context.Background(), hostname) 371 | if err != nil { 372 | return nil, fmt.Errorf("getting cloud server %q: %w", hostname, err) 373 | } 374 | 375 | return srv, nil 376 | } 377 | 378 | func (hd *hetznerDriver) waitForAction(act *hcloud.Action) error { 379 | _, errs := hd.client.Action().WatchProgress(context.Background(), act) 380 | return <-errs 381 | } 382 | 383 | func validateOptions(volume string, opts map[string]string) { 384 | for k := range opts { 385 | switch k { 386 | case "fstype", "size", "uid", "gid": // OK, noop 387 | default: 388 | logrus.Warnf("unsupported driver_opt %q for volume %s", k, volume) 389 | } 390 | } 391 | } 392 | 393 | func getOption(k string, opts map[string]string) string { 394 | if v, ok := opts[k]; ok { 395 | return v 396 | } 397 | return os.Getenv(k) 398 | } 399 | 400 | func prefixName(name string) string { 401 | s := fmt.Sprintf("%s-%s", os.Getenv("prefix"), name) 402 | if len(s) > 64 { 403 | return s[:64] 404 | } 405 | return s 406 | } 407 | 408 | func unprefixedName(name string) string { 409 | return strings.TrimPrefix(name, fmt.Sprintf("%s-", os.Getenv("prefix"))) 410 | } 411 | 412 | func nameHasPrefix(name string) bool { 413 | return strings.HasPrefix(name, fmt.Sprintf("%s-", os.Getenv("prefix"))) 414 | } 415 | 416 | func useProtection() bool { 417 | return os.Getenv("use_protection") == "true" 418 | } 419 | -------------------------------------------------------------------------------- /driver_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/docker/go-plugins-helpers/volume" 8 | ) 9 | 10 | func TestMain(m *testing.M) { 11 | for k, v := range map[string]string{ 12 | "prefix": "docker", 13 | "fstype": "ext4", 14 | "size": "10", 15 | } { 16 | os.Setenv(k, v) 17 | } 18 | os.Exit(m.Run()) 19 | } 20 | 21 | func Test_getOption(t *testing.T) { 22 | type args struct { 23 | k string 24 | opts map[string]string 25 | } 26 | tests := []struct { 27 | name string 28 | args args 29 | want string 30 | }{ 31 | {"opts first", args{k: "opt", opts: map[string]string{"opt": "bar"}}, "bar"}, 32 | {"env fallback", args{k: "prefix", opts: map[string]string{"opt": "bar"}}, "docker"}, 33 | } 34 | for _, tt := range tests { 35 | t.Run(tt.name, func(t *testing.T) { 36 | if got := getOption(tt.args.k, tt.args.opts); got != tt.want { 37 | t.Errorf("getOption() = %v, want %v", got, tt.want) 38 | } 39 | }) 40 | } 41 | } 42 | 43 | func Test_prefixName(t *testing.T) { 44 | type args struct { 45 | name string 46 | } 47 | tests := []struct { 48 | name string 49 | args args 50 | want string 51 | }{ 52 | {"short", args{name: "foo"}, "docker-foo"}, 53 | { 54 | "long", // API limits it to 64 55 | args{name: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, 56 | "docker-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 57 | }, 58 | } 59 | for _, tt := range tests { 60 | t.Run(tt.name, func(t *testing.T) { 61 | if got := prefixName(tt.args.name); got != tt.want { 62 | t.Errorf("prefixName() = %v, want %v", got, tt.want) 63 | } 64 | }) 65 | } 66 | } 67 | 68 | func Test_unprefixedName(t *testing.T) { 69 | type args struct { 70 | name string 71 | } 72 | tests := []struct { 73 | name string 74 | args args 75 | want string 76 | }{ 77 | {"remove prefix", args{name: "docker-foobar"}, "foobar"}, 78 | } 79 | for _, tt := range tests { 80 | t.Run(tt.name, func(t *testing.T) { 81 | if got := unprefixedName(tt.args.name); got != tt.want { 82 | t.Errorf("unprefixedName() = %v, want %v", got, tt.want) 83 | } 84 | }) 85 | } 86 | } 87 | 88 | func Test_hetznerDriver_Create(t *testing.T) { 89 | type fields struct { 90 | client hetznerClienter 91 | } 92 | type args struct { 93 | req *volume.CreateRequest 94 | } 95 | tests := []struct { 96 | name string 97 | fields fields 98 | args args 99 | wantErr bool 100 | }{ 101 | // TODO: Add test cases. 102 | } 103 | for _, tt := range tests { 104 | t.Run(tt.name, func(t *testing.T) { 105 | hd := &hetznerDriver{ 106 | client: tt.fields.client, 107 | } 108 | if err := hd.Create(tt.args.req); (err != nil) != tt.wantErr { 109 | t.Errorf("hetznerDriver.Create() error = %v, wantErr %v", err, tt.wantErr) 110 | } 111 | }) 112 | } 113 | } 114 | 115 | // func Test_hetznerDriver_List(t *testing.T) { 116 | // type fields struct { 117 | // client *hcloud.Client 118 | // } 119 | // tests := []struct { 120 | // name string 121 | // fields fields 122 | // want *volume.ListResponse 123 | // wantErr bool 124 | // }{ 125 | // // TODO: Add test cases. 126 | // } 127 | // for _, tt := range tests { 128 | // t.Run(tt.name, func(t *testing.T) { 129 | // hd := &hetznerDriver{ 130 | // client: tt.fields.client, 131 | // } 132 | // got, err := hd.List() 133 | // if (err != nil) != tt.wantErr { 134 | // t.Errorf("hetznerDriver.List() error = %v, wantErr %v", err, tt.wantErr) 135 | // return 136 | // } 137 | // if !reflect.DeepEqual(got, tt.want) { 138 | // t.Errorf("hetznerDriver.List() = %v, want %v", got, tt.want) 139 | // } 140 | // }) 141 | // } 142 | // } 143 | 144 | // func Test_hetznerDriver_Get(t *testing.T) { 145 | // type fields struct { 146 | // client *hcloud.Client 147 | // } 148 | // type args struct { 149 | // req *volume.GetRequest 150 | // } 151 | // tests := []struct { 152 | // name string 153 | // fields fields 154 | // args args 155 | // want *volume.GetResponse 156 | // wantErr bool 157 | // }{ 158 | // // TODO: Add test cases. 159 | // } 160 | // for _, tt := range tests { 161 | // t.Run(tt.name, func(t *testing.T) { 162 | // hd := &hetznerDriver{ 163 | // client: tt.fields.client, 164 | // } 165 | // got, err := hd.Get(tt.args.req) 166 | // if (err != nil) != tt.wantErr { 167 | // t.Errorf("hetznerDriver.Get() error = %v, wantErr %v", err, tt.wantErr) 168 | // return 169 | // } 170 | // if !reflect.DeepEqual(got, tt.want) { 171 | // t.Errorf("hetznerDriver.Get() = %v, want %v", got, tt.want) 172 | // } 173 | // }) 174 | // } 175 | // } 176 | 177 | // func Test_hetznerDriver_Remove(t *testing.T) { 178 | // type fields struct { 179 | // client *hcloud.Client 180 | // } 181 | // type args struct { 182 | // req *volume.RemoveRequest 183 | // } 184 | // tests := []struct { 185 | // name string 186 | // fields fields 187 | // args args 188 | // wantErr bool 189 | // }{ 190 | // // TODO: Add test cases. 191 | // } 192 | // for _, tt := range tests { 193 | // t.Run(tt.name, func(t *testing.T) { 194 | // hd := &hetznerDriver{ 195 | // client: tt.fields.client, 196 | // } 197 | // if err := hd.Remove(tt.args.req); (err != nil) != tt.wantErr { 198 | // t.Errorf("hetznerDriver.Remove() error = %v, wantErr %v", err, tt.wantErr) 199 | // } 200 | // }) 201 | // } 202 | // } 203 | 204 | // func Test_hetznerDriver_Path(t *testing.T) { 205 | // type fields struct { 206 | // client *hcloud.Client 207 | // } 208 | // type args struct { 209 | // req *volume.PathRequest 210 | // } 211 | // tests := []struct { 212 | // name string 213 | // fields fields 214 | // args args 215 | // want *volume.PathResponse 216 | // wantErr bool 217 | // }{ 218 | // // TODO: Add test cases. 219 | // } 220 | // for _, tt := range tests { 221 | // t.Run(tt.name, func(t *testing.T) { 222 | // hd := &hetznerDriver{ 223 | // client: tt.fields.client, 224 | // } 225 | // got, err := hd.Path(tt.args.req) 226 | // if (err != nil) != tt.wantErr { 227 | // t.Errorf("hetznerDriver.Path() error = %v, wantErr %v", err, tt.wantErr) 228 | // return 229 | // } 230 | // if !reflect.DeepEqual(got, tt.want) { 231 | // t.Errorf("hetznerDriver.Path() = %v, want %v", got, tt.want) 232 | // } 233 | // }) 234 | // } 235 | // } 236 | 237 | // func Test_hetznerDriver_Mount(t *testing.T) { 238 | // type fields struct { 239 | // client *hcloud.Client 240 | // } 241 | // type args struct { 242 | // req *volume.MountRequest 243 | // } 244 | // tests := []struct { 245 | // name string 246 | // fields fields 247 | // args args 248 | // want *volume.MountResponse 249 | // wantErr bool 250 | // }{ 251 | // // TODO: Add test cases. 252 | // } 253 | // for _, tt := range tests { 254 | // t.Run(tt.name, func(t *testing.T) { 255 | // hd := &hetznerDriver{ 256 | // client: tt.fields.client, 257 | // } 258 | // got, err := hd.Mount(tt.args.req) 259 | // if (err != nil) != tt.wantErr { 260 | // t.Errorf("hetznerDriver.Mount() error = %v, wantErr %v", err, tt.wantErr) 261 | // return 262 | // } 263 | // if !reflect.DeepEqual(got, tt.want) { 264 | // t.Errorf("hetznerDriver.Mount() = %v, want %v", got, tt.want) 265 | // } 266 | // }) 267 | // } 268 | // } 269 | 270 | // func Test_hetznerDriver_Unmount(t *testing.T) { 271 | // type fields struct { 272 | // client *hcloud.Client 273 | // } 274 | // type args struct { 275 | // req *volume.UnmountRequest 276 | // } 277 | // tests := []struct { 278 | // name string 279 | // fields fields 280 | // args args 281 | // wantErr bool 282 | // }{ 283 | // // TODO: Add test cases. 284 | // } 285 | // for _, tt := range tests { 286 | // t.Run(tt.name, func(t *testing.T) { 287 | // hd := &hetznerDriver{ 288 | // client: tt.fields.client, 289 | // } 290 | // if err := hd.Unmount(tt.args.req); (err != nil) != tt.wantErr { 291 | // t.Errorf("hetznerDriver.Unmount() error = %v, wantErr %v", err, tt.wantErr) 292 | // } 293 | // }) 294 | // } 295 | // } 296 | 297 | // func Test_hetznerDriver_getServerForLocalhost(t *testing.T) { 298 | // type fields struct { 299 | // client *hcloud.Client 300 | // } 301 | // tests := []struct { 302 | // name string 303 | // fields fields 304 | // want *hcloud.Server 305 | // wantErr bool 306 | // }{ 307 | // // TODO: Add test cases. 308 | // } 309 | // for _, tt := range tests { 310 | // t.Run(tt.name, func(t *testing.T) { 311 | // hd := &hetznerDriver{ 312 | // client: tt.fields.client, 313 | // } 314 | // got, err := hd.getServerForLocalhost() 315 | // if (err != nil) != tt.wantErr { 316 | // t.Errorf("hetznerDriver.getServerForLocalhost() error = %v, wantErr %v", err, tt.wantErr) 317 | // return 318 | // } 319 | // if !reflect.DeepEqual(got, tt.want) { 320 | // t.Errorf("hetznerDriver.getServerForLocalhost() = %v, want %v", got, tt.want) 321 | // } 322 | // }) 323 | // } 324 | // } 325 | -------------------------------------------------------------------------------- /driver_testwrapper.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/hetznercloud/hcloud-go/v2/hcloud" 7 | ) 8 | 9 | // these types wrap hcloud.Client to make mocking easier 10 | type hetznerClienter interface { 11 | Volume() hetznerVolumeClienter 12 | Server() hetznerServerClienter 13 | Action() hetznerActionClienter 14 | } 15 | 16 | type hetznerVolumeClienter interface { 17 | All(context.Context) ([]*hcloud.Volume, error) 18 | Attach(context.Context, *hcloud.Volume, *hcloud.Server) (*hcloud.Action, *hcloud.Response, error) 19 | ChangeProtection(context.Context, *hcloud.Volume, hcloud.VolumeChangeProtectionOpts) (*hcloud.Action, *hcloud.Response, error) 20 | Create(context.Context, hcloud.VolumeCreateOpts) (hcloud.VolumeCreateResult, *hcloud.Response, error) 21 | Delete(context.Context, *hcloud.Volume) (*hcloud.Response, error) 22 | Detach(context.Context, *hcloud.Volume) (*hcloud.Action, *hcloud.Response, error) 23 | GetByName(context.Context, string) (*hcloud.Volume, *hcloud.Response, error) 24 | } 25 | 26 | type hetznerServerClienter interface { 27 | GetByID(ctx context.Context, id int64) (*hcloud.Server, *hcloud.Response, error) 28 | GetByName(ctx context.Context, name string) (*hcloud.Server, *hcloud.Response, error) 29 | } 30 | 31 | type hetznerActionClienter interface { 32 | WatchProgress(ctx context.Context, action *hcloud.Action) (<-chan int, <-chan error) 33 | } 34 | 35 | type hetznerClient struct { 36 | client *hcloud.Client 37 | } 38 | 39 | func (h *hetznerClient) Volume() hetznerVolumeClienter { 40 | return &h.client.Volume 41 | } 42 | 43 | func (h *hetznerClient) Server() hetznerServerClienter { 44 | return &h.client.Server 45 | } 46 | 47 | func (h *hetznerClient) Action() hetznerActionClienter { 48 | return &h.client.Action 49 | } 50 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/costela/docker-volume-hetzner 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/docker/docker v1.13.1 7 | github.com/docker/go-plugins-helpers v0.0.0-20211224144127-6eecb7beb651 8 | github.com/hashicorp/go-multierror v1.1.1 9 | github.com/hetznercloud/hcloud-go/v2 v2.7.2 10 | github.com/sirupsen/logrus v1.9.3 11 | ) 12 | 13 | require ( 14 | github.com/Microsoft/go-winio v0.6.1 // indirect 15 | github.com/beorn7/perks v1.0.1 // indirect 16 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 17 | github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect 18 | github.com/docker/go-connections v0.4.0 // indirect 19 | github.com/hashicorp/errwrap v1.1.0 // indirect 20 | github.com/prometheus/client_golang v1.19.0 // indirect 21 | github.com/prometheus/client_model v0.5.0 // indirect 22 | github.com/prometheus/common v0.48.0 // indirect 23 | github.com/prometheus/procfs v0.12.0 // indirect 24 | golang.org/x/mod v0.14.0 // indirect 25 | golang.org/x/net v0.24.0 // indirect 26 | golang.org/x/sys v0.19.0 // indirect 27 | golang.org/x/text v0.14.0 // indirect 28 | golang.org/x/tools v0.17.0 // indirect 29 | google.golang.org/protobuf v1.32.0 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 2 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 3 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 4 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 5 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 6 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 | github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= 8 | github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/docker/docker v1.13.1 h1:IkZjBSIc8hBjLpqeAbeE5mca5mNgeatLHBy3GO78BWo= 13 | github.com/docker/docker v1.13.1/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 14 | github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= 15 | github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= 16 | github.com/docker/go-plugins-helpers v0.0.0-20211224144127-6eecb7beb651 h1:YcvzLmdrP/b8kLAGJ8GT7bdncgCAiWxJZIlt84D+RJg= 17 | github.com/docker/go-plugins-helpers v0.0.0-20211224144127-6eecb7beb651/go.mod h1:LFyLie6XcDbyKGeVK6bHe+9aJTYCxWLBg5IrJZOaXKA= 18 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 19 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 20 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 21 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 22 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 23 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 24 | github.com/hetznercloud/hcloud-go/v2 v2.7.2 h1:UlE7n1GQZacCfyjv9tDVUN7HZfOXErPIfM/M039u9A0= 25 | github.com/hetznercloud/hcloud-go/v2 v2.7.2/go.mod h1:49tIV+pXRJTUC7fbFZ03s45LKqSQdOPP5y91eOnJo/k= 26 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 27 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 28 | github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= 29 | github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= 30 | github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= 31 | github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= 32 | github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= 33 | github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= 34 | github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 35 | github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 36 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 37 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 38 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 39 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 40 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 41 | golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= 42 | golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 43 | golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= 44 | golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= 45 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 46 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 47 | golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= 48 | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 49 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 50 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 51 | golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= 52 | golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= 53 | google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= 54 | google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 55 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 56 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 57 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 58 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main // import "github.com/costela/docker-volume-hetzner" 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/docker/go-plugins-helpers/volume" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | const socketAddress = "/run/docker/plugins/hetzner.sock" 12 | const propagatedMountPath = "/mnt" 13 | 14 | func main() { 15 | logrus.SetFormatter(&bareFormatter{}) 16 | 17 | logLevel, err := logrus.ParseLevel(os.Getenv("loglevel")) 18 | if err != nil { 19 | logrus.Fatalf("could not parse log level %s", os.Getenv("loglevel")) 20 | } 21 | 22 | logrus.SetLevel(logLevel) 23 | 24 | hd := newHetznerDriver() 25 | h := volume.NewHandler(hd) 26 | logrus.Infof("listening on %s", socketAddress) 27 | if err := h.ServeUnix(socketAddress, 0); err != nil { 28 | logrus.Fatalf("error serving docker socket: %v", err) 29 | } 30 | } 31 | 32 | type bareFormatter struct{} 33 | 34 | func (bareFormatter) Format(e *logrus.Entry) ([]byte, error) { 35 | return []byte(fmt.Sprintf("%s\n", e.Message)), nil 36 | } 37 | -------------------------------------------------------------------------------- /os.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | 9 | "github.com/docker/docker/pkg/mount" 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | var supportedFileystemTypes = [...]string{"ext4", "xfs", "ext3", "ext2"} 14 | 15 | func getMounts() (map[string]string, error) { 16 | mounts, err := mount.GetMounts() 17 | if err != nil { 18 | return nil, err 19 | } 20 | mountsMap := make(map[string]string, len(mounts)) 21 | for _, mount := range mounts { 22 | mountsMap[mount.Source] = mount.Mountpoint 23 | } 24 | return mountsMap, nil 25 | } 26 | 27 | func mkfs(dev, fstype string) error { 28 | mkfsExec := fmt.Sprintf("/sbin/mkfs.%s", fstype) 29 | cmd := exec.Command(mkfsExec, dev) 30 | var stderr bytes.Buffer 31 | cmd.Stderr = &stderr 32 | if err := cmd.Run(); err != nil { 33 | logrus.Errorf("mkfs stderr: %s", stderr.String()) 34 | return err 35 | } 36 | return nil 37 | } 38 | 39 | func setPermissions(dev, fstype string, uid int, gid int) (err error) { 40 | tmpDir, err := os.MkdirTemp(os.TempDir(), "mnt-*") 41 | if err != nil { 42 | return fmt.Errorf("creating temp dir for chmod: %w", err) 43 | } 44 | 45 | if err := mount.Mount( 46 | dev, 47 | tmpDir, 48 | fstype, 49 | "", 50 | ); err != nil { 51 | // nothing to clean up yet 52 | return fmt.Errorf("mounting: %w", err) 53 | } 54 | 55 | defer func() { 56 | // clean up 57 | if unmountErr := mount.Unmount(tmpDir); err == nil && unmountErr != nil { 58 | err = fmt.Errorf("unmounting after chown: %w", unmountErr) 59 | return 60 | } 61 | 62 | if rmErr := os.Remove(tmpDir); err == nil && rmErr != nil { 63 | err = rmErr 64 | } 65 | }() 66 | 67 | if err := os.Chown(tmpDir, uid, gid); err != nil { 68 | return fmt.Errorf("chowning: %w", err) 69 | } 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /os_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func Test_setPermissions(t *testing.T) { 8 | if got := setPermissions("none", "tmpfs", 33, 33); got != nil { 9 | t.Errorf("setPermissions() = %v, want %v", got, nil) 10 | } 11 | } 12 | --------------------------------------------------------------------------------