diff --git a/.travis.yml b/.travis.yml index b72fdd4..95728c8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,12 +8,11 @@ language: go jobs: include: - stage: build - script: ./scripts/build.sh - - stage: test - script: ./scripts/test.sh + script: make build + - stage: check + script: make check + - stage: unit-test + script: make unit-test - stage: deploy - script: ./scripts/deploy.sh + script: make deploy if: tag =~ ^v\d - - stage: deploy-latest - if: tag =~ ^v\d - script: ./scripts/deploy.sh diff --git a/Dockerfile b/Dockerfile index ef127b1..063ea99 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,4 +14,4 @@ CMD ["/go/bin/docker-volume-linode"] FROM alpine COPY --from=builder /go/bin/docker-volume-linode . RUN apk update && apk add ca-certificates e2fsprogs -CMD ["docker-volume-sshfs"] +CMD ["./docker-volume-linode"] diff --git a/Gopkg.lock b/Gopkg.lock index 1d4c9d6..b8436bd 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -9,6 +9,14 @@ revision = "67921128fb397dd80339870d2193d6b1e6856fd4" version = "v0.4.8" +[[projects]] + branch = "master" + digest = "1:35641e79ed3328947a972bdd5a242da9ce1f8ff38718e81473c00e123416c5e3" + name = "github.com/chiefy/linodego" + packages = ["."] + pruneopts = "UT" + revision = "8b573a1a69b79d8f7939722bccecbe51f421a049" + [[projects]] digest = "1:5155f7153c694dc8e2efd74d799a27fd54e65778fa3f0c3e17626df724857db9" name = "github.com/coreos/go-systemd" @@ -27,14 +35,14 @@ [[projects]] branch = "master" - digest = "1:08ea16b5dd27a05b8f31bdad8f6c26c7ac3e0b0d41662e7115f0cd7c69753bd2" + digest = "1:fd9d4c708d9b224f57778f886b30760a8f9d821f6842e726b4d2e5dbef940ad2" name = "github.com/docker/go-plugins-helpers" packages = [ "sdk", "volume", ] pruneopts = "UT" - revision = "61cb8e2334204460162c8bd2417cd43cb71da66f" + revision = "39e3ca757823ff8ec10601ed043c02d6d4074be8" [[projects]] digest = "1:865079840386857c809b72ce300be7580cb50d3d3129ce11bf9aa6ca2bc1934a" @@ -44,6 +52,14 @@ revision = "5b77d2a35fb0ede96d138fc9a99f5c9b6aef11b4" version = "v1.7.0" +[[projects]] + digest = "1:2ff22b45e9aa8f80ea4a4687f6f9d714a31f1b6c074a223671ae7ce837d4389c" + name = "github.com/go-resty/resty" + packages = ["."] + pruneopts = "UT" + revision = "fccc498aed22c31ff3768bcac00795f94149a21d" + version = "v1.7.0" + [[projects]] branch = "master" digest = "1:aa4a0d5ee237ad793fc840e278cd70393369f4d9077a3a067033c3bb0dc15870" @@ -86,7 +102,7 @@ [[projects]] branch = "master" - digest = "1:2fb3eca607b08f5cc98a929f2fe478335c6e2ab87726f872a3bce35d83d2f3ed" + digest = "1:130b7bef00e8efbf9411a0d84ae50756a71deead6c0e6600ee8601e5e2308540" name = "golang.org/x/net" packages = [ "idna", @@ -95,18 +111,18 @@ "publicsuffix", ] pruneopts = "UT" - revision = "4cb1c02c05b0e749b0365f61ae859a8e0cfceed9" + revision = "a680a1efc54dd51c040b3b5ce4939ea3cf2ea0d1" [[projects]] branch = "master" - digest = "1:c6026af29863bef3d7f476755d52df0799592b0054a11c6ae80b1f37472f1eec" + digest = "1:72d6244a51be9611f08994aca19677fcc31676b3e7b742c37e129e6ece4ad8fc" name = "golang.org/x/sys" packages = [ "unix", "windows", ] pruneopts = "UT" - revision = "7138fd3d9dc8335c567ca206f4333fb75eb05d56" + revision = "ac767d655b305d4e9612f5f6e33120b9176c4ad4" [[projects]] digest = "1:7509ba4347d1f8de6ae9be8818b0cd1abc3deeffe28aeaf4be6d4b6b5178d9ca" @@ -131,22 +147,14 @@ revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" version = "v0.3.0" -[[projects]] - digest = "1:a5e84e917d904e61d7493cee0706b2203847986d4e181f528028f8ed3a7c3c5d" - name = "gopkg.in/resty.v1" - packages = ["."] - pruneopts = "UT" - revision = "fc3ad735b5565cada42d34bc298573fd3fe5adc6" - version = "v1.6" - [solve-meta] analyzer-name = "dep" analyzer-version = 1 input-imports = [ + "github.com/chiefy/linodego", "github.com/docker/go-plugins-helpers/volume", "github.com/libgolang/config", "github.com/libgolang/log", - "gopkg.in/resty.v1", ] solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index ed867ed..4fe8519 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -30,8 +30,8 @@ version = "1.0.3" [[constraint]] - name = "gopkg.in/resty.v1" - version = "1.6.0" + name = "github.com/chiefy/linodego" + branch = "master" [prune] go-tests = true diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..176f2cd --- /dev/null +++ b/Makefile @@ -0,0 +1,112 @@ + +# Build Arguments +TRAVIS_BRANCH ?= test +TRAVIS_BUILD_NUMBER ?= 9999 + +# Deploy Arguments +DOCKER_PASSWORD ?= xxxxx + +# Test Arguments +TEST_TOKEN ?= xyz +TEST_REGION ?= xyz +TEST_LABEL ?= xyz + +GOPATH=$(shell go env GOPATH) + +# e.g: docker-volume-linode:rootfs.30 +PLUGIN_NAME_ROOTFS=docker-volume-linode:rootfs.${TRAVIS_BUILD_NUMBER} + +# e.g: docker-volume-linode:master.30 +# e.g: docker-volume-linode:v1.1.30 +PLUGIN_NAME=libgolang/docker-volume-linode:${TRAVIS_BRANCH}.${TRAVIS_BUILD_NUMBER} +PLUGIN_NAME_LATEST=libgolang/docker-volume-linode:latest + +PLUGIN_DIR=plugin-contents-dir + +all: clean build + +deploy: build + # Login to docker + @echo '${DOCKER_PASSWORD}' | docker login -u libgolang --password-stdin + # Push images + docker plugin push ${PLUGIN_NAME} + docker plugin push ${PLUGIN_NAME_LATEST} + +build: $(PLUGIN_DIR) + # load plugin with versionied tag + docker plugin rm -f ${PLUGIN_NAME} 2>/dev/null || true + docker plugin create ${PLUGIN_NAME} ./$(PLUGIN_DIR) + # load plugin with `latest` tag + docker plugin rm -f ${PLUGIN_NAME_LATEST} 2>/dev/null || true + docker plugin create ${PLUGIN_NAME_LATEST} ./$(PLUGIN_DIR) + +$(PLUGIN_DIR): $(GOPATH)/bin/dep *.go Dockerfile + # compile + dep ensure + docker build --no-cache -q -t ${PLUGIN_NAME_ROOTFS} . + # assemble + mkdir -p ./$(PLUGIN_DIR)/rootfs + docker create --name tmp ${PLUGIN_NAME_ROOTFS} + docker export tmp | tar -x -C ./$(PLUGIN_DIR)/rootfs + cp config.json ./$(PLUGIN_DIR)/ + docker rm -vf tmp + +# Run Integration Tests +# Requires TEST_* Variables to be set +test: test-pre-check \ + build \ + test-setup \ + test-create-volume-50 \ + test-rm-volume-50 \ + test-create-volume \ + test-use-volume \ + clean-volumes + +test-create-volume: + docker volume create -d libgolang/docker-volume-linode test-volume-default-size + +test-create-volume-50: + docker volume create -d libgolang/docker-volume-linode -o size=50 test-volume-50g + +test-rm-volume-50: + docker volume rm test-volume-50g + +test-use-volume: + docker run --rm -i -v test-volume-default-size:/mnt busybox touch /mnt/abc.txt + docker run --rm -i -v test-volume-default-size:/mnt busybox test -f /mnt/abc.txt || false + +test-pre-check: + @if [ "${TEST_TOKEN}" = "xyz" ] || [ "${TEST_REGION}" = "xyz" ] || [ "${TEST_LABEL}" = "xyz" ] ; then \ + echo -en "#############################\nYou must set TEST_* Variables\n#############################\n"; exit 1; fi + +test-setup: + @docker plugin set libgolang/docker-volume-linode LINODE_TOKEN=${TEST_TOKEN} LINODE_REGION=${TEST_REGION} LINODE_LABEL=${TEST_LABEL} + docker plugin enable libgolang/docker-volume-linode + +check: $(GOPATH)/bin/dep + # Tools + go get -u github.com/tsenart/deadcode + go get -u github.com/kisielk/errcheck + go get -u golang.org/x/lint/golint + # Run Code Tests + dep ensure + go vet + errcheck + golint + deadcode + +unit-test: $(GOPATH)/bin/dep + dep ensure + go test + +$(GOPATH)/bin/dep: + go get -u github.com/golang/dep/cmd/dep + +.PHONY clean: + rm -fr $(PLUGIN_DIR) + +clean-volumes: + docker volume ls -q | grep 'test-' | xargs docker volume rm +clean-installed-plugins: + docker plugin ls | grep libgolang | grep -v ID | awk '{print $$1}' | xargs docker plugin rm -f + diff --git a/README.md b/README.md index ee05b49..a52ef59 100644 --- a/README.md +++ b/README.md @@ -5,137 +5,157 @@ [![Build Status](https://travis-ci.org/libgolang/docker-volume-linode.svg?branch=master)](https://travis-ci.org/libgolang/docker-volume-linode) ## Requirements + - Linux (tested on Ubuntu 18.04, should work with other versions and distros) - Docker (tested on version 17, should work with other versions) ## Installation -- Install +### Install - ``` - docker plugin install libgolang/docker-volume-linode - ``` +```sh +docker plugin install libgolang/docker-volume-linode +``` -- Configuration +### Configuration - ``` - docker plugin set libgolang/docker-volume-linode LINODE_TOKEN= - docker plugin set libgolang/docker-volume-linode LINODE_REGION= - docker plugin set libgolang/docker-volume-linode LINODE_LABEL= - ``` +```sh +docker plugin set libgolang/docker-volume-linode LINODE_TOKEN= +docker plugin set libgolang/docker-volume-linode LINODE_REGION= +docker plugin set libgolang/docker-volume-linode LINODE_LABEL= +``` -- Enable +List or regions can be found at: https://api.linode.com/v4/regions - ``` - docker plugin enable libgolang/docker-volume-linode - ``` +### Enable + +```sh +docker plugin enable libgolang/docker-volume-linode +``` - Debugging Configuration - ``` - docker plugin set libgolang/docker-volume-linode LOG_LEVEL=debug - ``` +```sh +docker plugin set libgolang/docker-volume-linode LOG_LEVEL=debug +``` ## Usage + + + ### Create Volume -``` +```sh $ docker volume create -d linode-driver my-test-volume +my-test-volume ``` - ``` - my-test-volume - ``` - ### Create 50G Volume -``` -$ # docker volume create -o size=50 -d linode-driver my-test-volume-50 -| my-test-volume-50 + +```sh +$ docker volume create -o size=50 -d linode-driver my-test-volume-50 +my-test-volume-50 ``` ### List Volumes -``` +```sh $ docker volume ls -| DRIVER VOLUME NAME -| linode-driver my-test-volume -| linode-driver my-test-volume-50 +DRIVER VOLUME NAME +linode-driver my-test-volume +linode-driver my-test-volume-50 ``` ### Remove Volumes -``` + +```sh $ docker volume rm my-test-volume -| my-test-volume +my-test-volume + $ docker volume rm my-test-volume-50 -| my-test-volume-50 +my-test-volume-50 ``` - ### Create and Use Linode Volume -``` + +```sh $ docker volume create -d linode-driver http-volume -| http-volume +http-volume + $ docker run --rm -it -v http-volume:/usr/local/apache2/htdocs/ httpd -| ... -| ... +... +... ``` +### Driver Options +| Option Name | Description | +| --- | --- | +| linode-token | **Required** The Linode APIv4 [Personal Access Token](https://cloud.linode.com/profile/tokens) +| linode-label | **Required** The Linode Label to attach block storage volumes to (defaults to the system hostname) | +| linode-region | The Linode region to create volumes in (inferred if using linode-label, defaults to us-west) | +| socket-file | Sets the socket file/address (defaults to /run/docker/plugins/linode-driver.sock) | +| socket-gid | Sets the socket GID (defaults to 0) | +| mount-root | Sets the root directory for volume mounts (default /mnt) | +| log-level | Log Level (defaults to WARN) | +| log-trace | Set Tracing to true (defaults to false) | -# Manual Installation +## Manual Installation -# Requirements -- Install Golang: https://golang.org/ +### Requirements + +- Install Golang: - Get code and Compile: `go get -u github.com/libgolang/docker-volume-linode` ### Run the driver +```sh +docker-volume-linode --linode-token= --linode-region= --linode-label= ``` -$ docker-volume-linode --linode-token= --linode-region= --linode-label= -``` + or -``` -$ export LINODE_TOKEN= -$ export LINODE_REGION= -$ export LINODE_LABEL= -$ docker-volume-linode +```sh +export LINODE_TOKEN= +export LINODE_REGION= +export LINODE_LABEL= +docker-volume-linode ``` +### Debugging +#### Enable Deug Level on plugin -## Debugging - -# Enable Deug Level on plugin +```sh +docker plugin set libgolang/docker-volume-linode LOG_LEVEL=debug +``` - ``` - docker plugin set libgolang/docker-volume-linode LOG_LEVEL=debug - ``` +#### Enable Deug Level in manual installation -# Enable Deug Level in manual installation -``` -$ docker-volume-linode --linode-token=<...> --linode-region=<...> --linode-label=<...> --log-level=debug +```sh +docker-volume-linode --linode-token=<...> --linode-region=<...> --linode-label=<...> --log-level=debug ``` + or +```sh +export DEBUG_LEVEL=debug +export LINODE_REGION=<...> +export LINODE_LABEL=<...> +export LINODE_LABEL=<...> +docker-volume-linode ``` -$ export DEBUG_LEVEL=debug -$ export LINODE_REGION=<...> -$ export LINODE_LABEL=<...> -$ export LINODE_LABEL=<...> -$ docker-volume-linode -``` - ## Tested On -``` + +```text Ubuntu 18.04 LTS ``` -``` +```text Tested With: Client: Version: 17.12.1-ce @@ -154,4 +174,4 @@ Server: Built: Wed Feb 28 17:46:05 2018 OS/Arch: linux/amd64 Experimental: false - ``` +``` diff --git a/config.json b/config.json index e6748a9..5aa28b2 100644 --- a/config.json +++ b/config.json @@ -3,11 +3,12 @@ "documentation": "https://docs.docker.com/engine/extend/plugins/", "entrypoint": [ "/docker-volume-linode" ], "env": [ - { "name": "LOG_LEVEL", "settable": [ "value" ], "value": "DEBUG" }, + { "name": "LOG_LEVEL", "settable": [ "value" ], "value": "INFO" }, { "name": "LOG_TRACE", "settable": [ "value" ], "value": "0" }, + { "name": "MOUNT_ROOT", "settable": [ "value" ], "value": "/mnt" }, { "name": "LINODE_TOKEN", "settable": [ "value" ], "value": "" }, - { "name": "LINODE_REGION", "settable": [ "value" ], "value": "us-west" }, - { "name": "LINODE_HOST", "settable": [ "value" ], "value": "" } + { "name": "LINODE_REGION", "settable": [ "value" ], "value": "" }, + { "name": "LINODE_LABEL", "settable": [ "value" ], "value": "" } ], "interface": { "socket": "linode-driver.sock", diff --git a/driver.go b/driver.go index 6cb66a3..c97e225 100644 --- a/driver.go +++ b/driver.go @@ -1,40 +1,71 @@ package main import ( + "encoding/json" "fmt" "os" - "os/exec" - "syscall" - "time" + "strconv" + "sync" + "github.com/chiefy/linodego" "github.com/docker/go-plugins-helpers/volume" - "github.com/libgolang/docker-volume-linode/linode" "github.com/libgolang/log" ) type linodeVolumeDriver struct { - linodeAPI linode.API + linodeAPI linodego.Client + instanceID *int + region string + linodeLabel *string + mutex *sync.Mutex } // Constructor -func newLinodeVolumeDriver(linodeAPI linode.API) linodeVolumeDriver { - return linodeVolumeDriver{linodeAPI: linodeAPI} +func newLinodeVolumeDriver(linodeAPI linodego.Client, region string, linodeLabel *string) linodeVolumeDriver { + driver := linodeVolumeDriver{ + linodeAPI: linodeAPI, + region: region, + linodeLabel: linodeLabel, + mutex: &sync.Mutex{}, + } + + if linodeLabel != nil { + jsonFilter, _ := json.Marshal(map[string]string{"label": *linodeLabel}) + listOpts := linodego.NewListOptions(0, string(jsonFilter)) + linodes, lErr := driver.linodeAPI.ListInstances(listOpts) + + if lErr != nil { + log.Error("Could not determine Linode instance ID from Linode label %s due to error: %s", *linodeLabel, lErr) + os.Exit(1) + } else if len(linodes) != 1 { + log.Error("Could not determine Linode instance ID from Linode label %s", *linodeLabel) + os.Exit(1) + } + + driver.instanceID = &linodes[0].ID + } + + return driver } // Get implementation func (driver linodeVolumeDriver) Get(req *volume.GetRequest) (*volume.GetResponse, error) { log.Info("Get(%s)", req.Name) - linVol := driver.linodeAPI.GetVolumeByName(req.Name) - if linVol != nil { - log.Info("Get(): req.Name") - vol := &volume.Volume{ - Name: linVol.Label, - Mountpoint: linVol.Mountpoint(), - } - return &volume.GetResponse{Volume: vol}, nil + linVol, err := driver.findVolumeByLabel(req.Name) + if err != nil { + return nil, log.Err("%s", err) + } + + if linVol == nil { + return nil, log.Err("Got a NIL volume. Volume may not exist.") } - log.Warn("Volume with name %s not found") - return nil, fmt.Errorf("Volume with name %s not found", req.Name) + + vol := linodeVolumeToDockerVolume(linVol) + resp := &volume.GetResponse{Volume: vol} + + log.Info("Get(): %+v", resp) + + return resp, nil } // List implementation @@ -43,50 +74,69 @@ func (driver linodeVolumeDriver) List() (*volume.ListResponse, error) { // var volumes []*volume.Volume - err := driver.linodeAPI.EachVolume(func(linVol linode.Volume) bool { - vol := &volume.Volume{ - Name: linVol.Label, - Mountpoint: linVol.Mountpoint(), - } - volumes = append(volumes, vol) - return true - }) - log.Info("List(): %s", volumes) - return &volume.ListResponse{Volumes: volumes}, err + linVols, err := driver.linodeAPI.ListInstanceVolumes(*driver.instanceID, nil) + if err != nil { + return nil, log.Err("%s", err) + } + log.Debug("Got %d volume count from api", len(linVols)) + for _, linVol := range linVols { + vol := linodeVolumeToDockerVolume(linVol) + log.Debug("Volume: %+v", vol) + volumes = append(volumes, vol) + } + log.Info("List() returning %d: %+v", len(volumes), volumes) + return &volume.ListResponse{Volumes: volumes}, nil } // Create implementation func (driver linodeVolumeDriver) Create(req *volume.CreateRequest) error { log.Info("Create(%s)", req.Name) + driver.mutex.Lock() + defer driver.mutex.Unlock() - vol, err := driver.linodeAPI.CreateVolume(req.Name, req.Options) - if err != nil { - return log.Err("Create(%s) Failed: %s", req.Name, err) + var size int + if sizeOpt, ok := req.Options["size"]; ok { + s, err := strconv.Atoi(sizeOpt) + if err != nil { + return log.Err("Invalid size") + } + size = s } - if err = driver.linodeAPI.Attach(req.Name); err != nil { + createOpts := linodego.VolumeCreateOptions{ + Label: req.Name, + LinodeID: *driver.instanceID, + Size: size, + } + if _, err := driver.linodeAPI.CreateVolume(createOpts); err != nil { return log.Err("Create(%s) Failed: %s", req.Name, err) } - // format drive - log.Info("Creating ext4 filesystem on %s", *vol.FilesystemPath) - cmd := exec.Command("mke2fs", "-t", "ext4", *vol.FilesystemPath) - stdOutAndErr, err := cmd.CombinedOutput() - if err != nil { - return log.Err("Error formatting %s with ext4 filesystem: %s", *vol.FilesystemPath, err) - } - log.Debug("%s", string(stdOutAndErr)) return nil } // Remove implementation func (driver linodeVolumeDriver) Remove(req *volume.RemoveRequest) error { - if err := driver.linodeAPI.Detach(req.Name); err != nil { - return err + // + linVol, err := driver.findVolumeByLabel(req.Name) + if err != nil { + return log.Err("%s", err) } - if err := driver.linodeAPI.RemoveVolume(req.Name); err != nil { - return err + + // Send detach request + if _, err := driver.linodeAPI.DetachVolume(linVol.ID); err != nil { + return log.Err("%s", err) + } + + // Wait for linode to have the volume detached + if err := waitForLinodeVolumeDetachment(driver.linodeAPI, linVol.ID); err != nil { + return log.Err("%s", err) + } + + // Send Delete request + if err := driver.linodeAPI.DeleteVolume(linVol.ID); err != nil { + return log.Err("%s", err) } return nil } @@ -95,18 +145,43 @@ func (driver linodeVolumeDriver) Remove(req *volume.RemoveRequest) error { func (driver linodeVolumeDriver) Mount(req *volume.MountRequest) (*volume.MountResponse, error) { log.Info("Called Mount %s", req.Name) - vol := driver.linodeAPI.GetVolumeByName(req.Name) - if vol == nil { - return nil, log.Err("Volume not found") + driver.mutex.Lock() + defer driver.mutex.Unlock() + + linVol, err := driver.findVolumeByLabel(req.Name) + if err != nil { + return nil, err + } + + // If Volume not already attached to this Linode, then attach + if linVol.LinodeID == nil || *linVol.LinodeID != *driver.instanceID { + // attach + attachOpts := linodego.VolumeAttachOptions{LinodeID: *driver.instanceID} + if ok, err := driver.linodeAPI.AttachVolume(linVol.ID, &attachOpts); err != nil { + return nil, log.Err("Error attaching volume to linode: %s", err) + } else if !ok { + return nil, log.Err("Could not attach volume to linode.") + } + if err := linodego.WaitForVolumeLinodeID(&driver.linodeAPI, linVol.ID, &attachOpts.LinodeID, 180); err != nil { + return nil, log.Err("Error attaching volume to linode: %s", err) + } + } + + // wait for kernel to have block device available + if err := waitForDeviceFileExists(linVol.FilesystemPath, 180); err != nil { + return nil, err } - // attach - if err := driver.linodeAPI.Attach(vol.Label); err != nil { - return nil, log.Err("Error attaching volume to linode: %s", err) + // Format block device if FS not + if GetFSType(linVol.FilesystemPath) == "" { + log.Info("Formatting device:%s;", linVol.FilesystemPath) + if err := Format(linVol.FilesystemPath); err != nil { + return nil, err + } } - // mkdir - mp := vol.Mountpoint() + // Create mount point using label (if not exists) + mp := labelToMountPoint(linVol.Label) if _, err := os.Stat(mp); os.IsNotExist(err) { log.Info("Creating mountpoint directory: %s", mp) if err = os.MkdirAll(mp, 0755); err != nil { @@ -114,17 +189,8 @@ func (driver linodeVolumeDriver) Mount(req *volume.MountRequest) (*volume.MountR } } - // Wait for linode to have the volumen attached - for i := 0; i < 10; i++ { - // found, then break - if _, err := os.Stat(*vol.FilesystemPath); !os.IsNotExist(err) { - break - } - log.Info("Waiting for linode to attach %s", *vol.FilesystemPath) - time.Sleep(time.Second * 2) - } - if err := syscall.Mount(*vol.FilesystemPath, mp, "ext4", syscall.MS_RELATIME, "data=ordered"); err != nil { - return nil, log.Err("Error mouting volume(%s) to directory(%s): %s", *vol.FilesystemPath, mp, err) + if err := Mount(linVol.FilesystemPath, mp); err != nil { + return nil, log.Err("Error mouting volume(%s) to directory(%s): %s", linVol.FilesystemPath, mp, err) } log.Info("Mount Call End: %s", req.Name) @@ -136,12 +202,12 @@ func (driver linodeVolumeDriver) Mount(req *volume.MountRequest) (*volume.MountR func (driver linodeVolumeDriver) Path(req *volume.PathRequest) (*volume.PathResponse, error) { log.Info("Path(%s)", req.Name) - vol := driver.linodeAPI.GetVolumeByName(req.Name) - if vol == nil { - return nil, log.Err("Volume %s not found", req.Name) + linVol, err := driver.findVolumeByLabel(req.Name) + if err != nil { + return nil, err } - mp := vol.Mountpoint() + mp := labelToMountPoint(linVol.Label) log.Info("Path(): %s", mp) return &volume.PathResponse{Mountpoint: mp}, nil } @@ -150,9 +216,15 @@ func (driver linodeVolumeDriver) Path(req *volume.PathRequest) (*volume.PathResp func (driver linodeVolumeDriver) Unmount(req *volume.UnmountRequest) error { log.Info("Unmount(%s)", req.Name) - // - vol := driver.linodeAPI.GetVolumeByName(req.Name) - if err := syscall.Unmount(vol.Mountpoint(), 0); err != nil { + driver.mutex.Lock() + defer driver.mutex.Unlock() + + linVol, err := driver.findVolumeByLabel(req.Name) + if err != nil { + return err + } + + if err := Umount(labelToMountPoint(linVol.Label)); err != nil { return log.Err("Unable to GetVolumeByName(%s): %s", req.Name, err) } @@ -165,3 +237,26 @@ func (driver linodeVolumeDriver) Capabilities() *volume.CapabilitiesResponse { log.Info("Capabilities(): Scope: global") return &volume.CapabilitiesResponse{Capabilities: volume.Capability{Scope: "global"}} } + +// findVolumeByLabel looks up linode volume by label +func (driver linodeVolumeDriver) findVolumeByLabel(volumeLabel string) (*linodego.Volume, error) { + var jsonFilter []byte + var err error + var linVols []*linodego.Volume + + //if jsonFilter, err = json.Marshal(map[string]string{"label": volumeLabel, "region": driver.region}); err != nil { + if jsonFilter, err = json.Marshal(map[string]string{"label": volumeLabel}); err != nil { + return nil, err + } + + listOpts := linodego.NewListOptions(0, string(jsonFilter)) + if linVols, err = driver.linodeAPI.ListInstanceVolumes(*driver.instanceID, listOpts); err != nil { + return nil, err + } + + if len(linVols) != 1 { + return nil, fmt.Errorf("Instance %d Volume with name %s not found", *driver.instanceID, volumeLabel) + } + + return linVols[0], nil +} diff --git a/fs_utils_linux.go b/fs_utils_linux.go new file mode 100644 index 0000000..1e5749b --- /dev/null +++ b/fs_utils_linux.go @@ -0,0 +1,60 @@ +package main + +import ( + "os/exec" + + "strings" + + "github.com/libgolang/log" +) + +const ( + formatFSType = "ext4" +) + +// Format calls mke2fs on path +func Format(path string) error { + cmd := exec.Command("mke2fs", "-t", formatFSType, path) + stdOutAndErr, err := cmd.CombinedOutput() + log.Debug("Mke2fs Output:\n%s", string(stdOutAndErr)) + return err +} + +// Mount mounts device to mountpoint +func Mount(device string, mountpoint string) error { + cmd := exec.Command("mount", device, mountpoint) + output, err := cmd.CombinedOutput() + log.Debug("Mount Output:\n%s", string(output)) + return err +} + +// Umount calls umount command +func Umount(mountpoint string) error { + cmd := exec.Command("umount", mountpoint) + output, err := cmd.CombinedOutput() + log.Debug("Umount Output:\n%s", string(output)) + return err +} + +// GetFSType returns the filesystem type from a block device +// function based on https://github.com/yholkamp/ovh-docker-volume-plugin/blob/master/utils.go +func GetFSType(device string) string { + log.Info("GetFSType(%s)", device) + fsType := "" + out, err := exec.Command("blkid", device).CombinedOutput() + if err != nil { + return fsType + } + + if strings.Contains(string(out), "TYPE=") { + for _, v := range strings.Split(string(out), " ") { + if strings.Contains(v, "TYPE=") { + fsType = strings.Split(v, "=")[1] + fsType = strings.Replace(fsType, "\"", "", -1) + } + } + } + + log.Info("GetFSType(): %s", fsType) + return fsType +} diff --git a/linode/lib.go b/linode/lib.go deleted file mode 100644 index b05f29f..0000000 --- a/linode/lib.go +++ /dev/null @@ -1,363 +0,0 @@ -package linode - -import ( - "fmt" - "os" - "path" - "strconv" - "time" - - "github.com/libgolang/log" - "gopkg.in/resty.v1" -) - -// API interface representing hight level -// operations using Linode API -type API interface { - //VolumeList() []Volume - GetVolumeByName(name string) *Volume - Attach(volumeName string) error - EachVolume(callBack func(Volume) bool) error - CreateVolume(name string, m map[string]string) (*Volume, error) - Detach(volumeName string) error - RemoveVolume(volumeName string) error -} - -// API implementation -type api struct { - token string - region string - host string -} - -// NewAPI new API instance -func NewAPI(token, region, host string) API { - log.Debug("Starting API with: Token: %s; Region: %s; Host: %s;", token, region, host) - return &api{token, region, host} -} - -// CreateVolume creates a linode volume -func (a *api) CreateVolume(name string, m map[string]string) (*Volume, error) { - - // Size - size := 20 - if sizeStr, ok := m["size"]; ok { - if sizeTmp, err := strconv.Atoi(sizeStr); err == nil { - size = sizeTmp - } else { - return nil, log.Err("Unable to parse volume size `%d`", sizeTmp) - } - } - - // Request - req := &Volume{ - Label: name, - Size: size, - Region: a.region, - } - - volItf, err := a.Post("https://api.linode.com/v4/volumes", req, &Volume{}) - if err != nil { - return nil, log.Err("Error requesting colume creation: %s", err) - } - - vol, ok := volItf.(*Volume) - if !ok { - return nil, log.Err("Unable to cast response from server when creating volume: %s", name) - } - - return vol, nil -} - -func (a *api) Detach(volumeName string) error { - volumeID, err := a.getVolumeIDByName(volumeName) - if err != nil { - return log.Err("Unable to get Volume ID by name(%s)", volumeName) - } - - if err := a.detachByVolumeID(volumeID); err != nil { - return err - } - return nil -} - -func (a *api) RemoveVolume(volumeName string) error { - volumeID, err := a.getVolumeIDByName(volumeName) - if err != nil { - return log.Err("Unable to get Volume ID by name(%s)", volumeName) - } - - url := fmt.Sprintf("https://api.linode.com/v4/volumes/%d", volumeID) - if _, err := a.DELETE(url, nil); err != nil { - return err - } - return nil -} - -// Attach attaches a volume to a linode -func (a *api) Attach(volumeName string) error { - linodeID, err := a.getLinodeIDByName(a.host) - if err != nil { - return log.Err("Unable to get Linode ID by name(%s): %s", a.host, err) - } - - vol := a.GetVolumeByName(volumeName) - if vol == nil { - return log.Err("Unable to find Volume name(%s)", volumeName) - } - - if err := a.detachByVolumeID(*vol.ID); err != nil { - return err - } - - // attach - log.Info("Calling attach on volume %d and node %d", *vol.ID, linodeID) - url := fmt.Sprintf("https://api.linode.com/v4/volumes/%d/attach", *vol.ID) - body := AttachRequest{LinodeID: &linodeID} - if _, err := a.Post(url, body, nil); err != nil { - return log.Err("unable to attach volume: %s", err) - } - - // wait for device to become available - for i := 0; i < 60; i++ { - if _, err := os.Stat(*vol.FilesystemPath); !os.IsNotExist(err) { - break - } - log.Info("Waiting for kernel to attach %s", *vol.FilesystemPath) - time.Sleep(2 * time.Second) // sleep 2 seconds - } - if _, err := os.Stat(*vol.FilesystemPath); os.IsNotExist(err) { - return log.Err("Attached volume device(%s) not found", *vol.FilesystemPath) - } - - return nil -} - -func (a *api) detachByVolumeID(volumeID int) error { - // detach - log.Info("Calling detach on volume %d", volumeID) - detachURL := fmt.Sprintf("https://api.linode.com/v4/volumes/%d/detach", volumeID) - if _, err := a.Post(detachURL, nil, nil); err != nil { - return log.Err("Detaching request returned error: %s", err) - } - - // wait for deatch request to finish - for i := 0; i < 60; i++ { - it, err := a.Get(fmt.Sprintf("https://api.linode.com/v4/volumes/%d", volumeID), &Volume{}) - if err != nil { - log.Warn("Detach Wait request failed") - } - vol, ok := it.(*Volume) - if ok { - if vol.LinodeID == nil || *vol.LinodeID == 0 { - return nil // happy path - } - } - - log.Info("Waiting for linode to detach volume(%d)", volumeID) - time.Sleep(2 * time.Second) // sleep 2 seconds - } - - return log.Err("Detaching volumeID %d failed. Timed out!", volumeID) -} - -// getLinodeIDByName resturns the id of the linode given the name or returns empty -// string if not found -func (a *api) getLinodeIDByName(linodeName string) (int, error) { - pages := 1 - for page := 1; page <= pages; page++ { - url := fmt.Sprintf("https://api.linode.com/v4/linode/instances?page=%d", page) - //var err error - it, err := a.Get(url, &ListNodeResponse{}) - if err != nil { - return 0, err - } - resp, ok := it.(*ListNodeResponse) - if !ok { - return 0, fmt.Errorf("Error casting to ListNodeReponse") - } - pages = resp.Pages - for _, n := range resp.Data { - if n.Label == linodeName { - return n.ID, nil - } - } - } - return 0, fmt.Errorf("Not Found") -} - -// EachVolume iterate through all volumes -func (a *api) EachVolume(callBack func(Volume) bool) error { - pages := 1 - for page := 1; page <= pages; page++ { - url := fmt.Sprintf("https://api.linode.com/v4/volumes?page=%d", page) - it, err := a.Get(url, &ListVolumeResponse{}) - if err != nil { - return err - } - resp, ok := it.(*ListVolumeResponse) - if !ok { - return fmt.Errorf("Error casting to ListVolumeReponse") - } - pages = resp.Pages - for _, n := range resp.Data { - if n.Region != a.region { - continue - } - if !callBack(n) { - break - } - } - } - return nil -} - -// GetVolumeByName returns the volume with the given name or nil if not found -func (a *api) GetVolumeByName(name string) *Volume { - var res *Volume - _ = a.EachVolume(func(v Volume) bool { - if v.Label == name { - res = &v - return false - } - return true - }) - return res -} - -func (a *api) getVolumeIDByName(volumeName string) (int, error) { - pages := 1 - for page := 1; page <= pages; page++ { - url := fmt.Sprintf("https://api.linode.com/v4/volumes?page=%d", page) - it, err := a.Get(url, &ListVolumeResponse{}) - if err != nil { - return 0, err - } - resp, ok := it.(*ListVolumeResponse) - if !ok { - return 0, fmt.Errorf("Error casting to ListVolumeReponse") - } - pages = resp.Pages - for _, n := range resp.Data { - if n.Label == volumeName { - return *n.ID, nil - } - } - } - return 0, fmt.Errorf("Not Found") -} - -// Get REST GET request -func (a *api) Get(url string, res interface{}) (interface{}, error) { - log.Debug("GET %s token: %s", url, a.token) - r := resty.R() - if res != nil { - r.SetResult(res) - } - r.SetHeader("Authorization", fmt.Sprintf("Bearer %s", a.token)) - resp, err := r.Get(url) - if err == nil && resp.StatusCode() != 200 { - return nil, fmt.Errorf("GET Request returned error %d: ", resp.StatusCode()) - } - return resp.Result(), err -} - -// Post REST POST request -func (a *api) Post(url string, req interface{}, res interface{}) (interface{}, error) { - log.Debug("POST %s", url) - - r := resty.R() - if req != nil { - log.Debug("%+v", req) - r.SetBody(req) - } - - if res != nil { - r.SetResult(res) - } - - r.SetHeader("Authorization", fmt.Sprintf("Bearer %s", a.token)) - resp, err := r.Post(url) - if err == nil && resp.StatusCode() != 200 { - bytes := resp.Body() - return nil, fmt.Errorf("POST %s returned error [%d]: --- %s", url, resp.StatusCode(), string(bytes)) - } - return resp.Result(), err -} - -// DELETE REST DELETE request -func (a *api) DELETE(url string, res interface{}) (interface{}, error) { - log.Debug("DELETE %s token: %s", url, a.token) - r := resty.R() - if res != nil { - r.SetResult(res) - } - r.SetHeader("Authorization", fmt.Sprintf("Bearer %s", a.token)) - resp, err := r.Delete(url) - if err == nil && resp.StatusCode() != 200 { - return nil, fmt.Errorf("GET Request returned error %d: ", resp.StatusCode()) - } - return resp.Result(), err -} - -// ListNodeResponse list node response -type ListNodeResponse struct { - Data []Node `json:"data"` - Page int `json:"page"` // "page": 1, - Pages int `json:"pages"` // "pages": 1, - Results int `json:"results"` // "results": 1 -} - -// ListVolumeResponse list volume response -type ListVolumeResponse struct { - Data []Volume `json:"data"` - Page int `json:"page"` // "page": 1, - Pages int `json:"pages"` // "pages": 1, - Results int `json:"results"` // "results": 1 -} - -// Node node -type Node struct { - ID int `json:"id"` //"id": 123, - Label string `json:"label"` //"label": "linode123", - Region string `json:"region"` //"region": "us-east", - //"image": "linode/debian9", - //"type": "g6-standard-2", - //"group": "Linode-Group", - //"status": "running", - //"hypervisor": "kvm", - //"created": "2018-01-01T00:01:01", - //"updated": "2018-01-01T00:01:01", - //... - //... - //... -} - -// Volume volume -type Volume struct { - ID *int `json:"id"` // "id": 12345, - Label string `json:"label"` // "label": "my-volume", - FilesystemPath *string `json:"filesystem_path"` // "filesystem_path": "/dev/disk/by-id/scsi-0Linode_Volume_my-volume", - LinodeID *int `json:"linode_id"` // "linode_id": 12346, - Region string `json:"region"` // "region": "us-east", - Size int `json:"size"` - // "status": "active", - // "created": "2018-01-01T00:01:01", - // "updated": "2018-01-01T00:01:01" -} - -// AttachRequest linode Attach Request -type AttachRequest struct { - LinodeID *int `json:"linode_id"` - ConfigID *string `json:"config_id"` -} - -func getHostName() string { - h, _ := os.Hostname() - return h -} - -// Mountpoint returns the mountpoint -func (v *Volume) Mountpoint() string { - return path.Join("/mnt", v.Label) -} diff --git a/main.go b/main.go index dffb1ad..e377e3b 100644 --- a/main.go +++ b/main.go @@ -1,27 +1,37 @@ package main import ( - "fmt" "os" + "github.com/chiefy/linodego" "github.com/docker/go-plugins-helpers/volume" "github.com/libgolang/config" - "github.com/libgolang/docker-volume-linode/linode" "github.com/libgolang/log" ) -const socketAddress = "/run/docker/plugins/linode-driver.sock" +const ( + // DefaultSocketAddress docket file to be created for communication with docker + DefaultSocketAddress = "/run/docker/plugins/linode-driver.sock" + // DefaultSocketGID Group ownership of DefaultSocketAddress + DefaultSocketGID = 0 +) var ( - logLevelParamPtr = config.String("log.leve", "DEBUG", "Log Level. Defaults to WARN") - logTraceParamPtr = config.Bool("log.trace", true, "Set Tracing to true") + // MountRoot Directory to mounting Linode Volume Devices + MountRoot = "/mnt" - linodeTokenParamPtr = config.String("linode.token", "", "Required Personal Access Token generated in Linode Console.") - regionParamPtr = config.String("linode.region", "us-west", "Sets the cluster region") - hostParamPtr = config.String("linode.host", hostname(), "Sets the cluster region") + logLevelParamPtr = config.String("log-level", "DEBUG", "Log Level. Defaults to WARN") + logTraceParamPtr = config.Bool("log-trace", true, "Set Tracing to true") + socketAddressParamPtr = config.String("socket-file", DefaultSocketAddress, "Sets the socket file/address.") + socketGIDParamPtr = config.Int("socket-gid", DefaultSocketGID, "Sets the socket group id.") + mountRootParamPtr = config.String("mount-root", MountRoot, "Sets the root directory for volume mounts.") + linodeTokenParamPtr = config.String("linode-token", "", "Required Personal Access Token generated in Linode Console.") + linodeRegionParamPtr = config.String("linode-region", "", "Required linode region.") + linodeLabelParamPtr = config.String("linode-label", "", "Sets the Linode instance label.") ) func main() { + // config.AppName = "docker-volume-linode" config.Parse() @@ -30,25 +40,40 @@ func main() { log.GetDefaultWriter().SetLevel(log.StrToLevel(*logLevelParamPtr)) log.SetTrace(*logTraceParamPtr) + // check required parameters (token, region and label) + if *linodeTokenParamPtr == "" { + log.Error("LINODE_TOKEN is required.") + os.Exit(1) + } + + if *linodeRegionParamPtr == "" { + log.Error("LINODE_REGION is required.") + os.Exit(1) + } + + if *linodeLabelParamPtr == "" { + log.Error("LINODE_LABEL is required.") + os.Exit(1) + } + + MountRoot = *mountRootParamPtr + // - log.Debug("============================================================") log.Debug("LINODE_TOKEN: %s", *linodeTokenParamPtr) - log.Debug("LINODE_REGION: %s", *regionParamPtr) - log.Debug("LINODE_HOST: %s", *hostParamPtr) - log.Debug("============================================================") + log.Debug("LINODE_REGION: %s", *linodeRegionParamPtr) + log.Debug("LINODE_LABEL: %s", *linodeLabelParamPtr) // Linode API instance - linodeAPI := linode.NewAPI(*linodeTokenParamPtr, *regionParamPtr, *hostParamPtr) + linodeAPI := linodego.NewClient(linodeTokenParamPtr, nil) // Driver instance - driver := newLinodeVolumeDriver(linodeAPI) + driver := newLinodeVolumeDriver(linodeAPI, *linodeRegionParamPtr, linodeLabelParamPtr) // Attach Driver to docker handler := volume.NewHandler(driver) - fmt.Println(handler.ServeUnix(socketAddress, 0)) -} - -func hostname() string { - h, _ := os.Hostname() - return h + serr := handler.ServeUnix(*socketAddressParamPtr, *socketGIDParamPtr) + if serr != nil { + log.Error("failed to bind to the Unix socket: %v", serr) + os.Exit(1) + } } diff --git a/scripts/build.sh b/scripts/build.sh deleted file mode 100755 index 0c93026..0000000 --- a/scripts/build.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -# set: -# -e: Exit on error. -# -x: Display commands. -set -ex - -scriptDir=`dirname $(readlink -f $0)` -source $scriptDir/common.sh - -# Build Step -build - diff --git a/scripts/common.sh b/scripts/common.sh deleted file mode 100644 index 6bd1c2c..0000000 --- a/scripts/common.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/bin/bash - -# e.g: docker-volume-linode:rootfs.30 -export PLUGIN_NAME_ROOTFS=docker-volume-linode:rootfs.${TRAVIS_BUILD_NUMBER} - -# e.g: docker-volume-linode:master.30 -# e.g: docker-volume-linode:v1.1.30 -export PLUGIN_NAME=libgolang/docker-volume-linode:${TRAVIS_BRANCH}.${TRAVIS_BUILD_NUMBER} -export PLUGIN_NAME_LATEST=libgolang/docker-volume-linode:latest - -# Build Step -build () { - compile - assemble-plugin-dir - create-plugin-from-dir ${PLUGIN_NAME} -} - -# Build Latest Step -build-latest () { - compile - assemble-plugin-dir - create-plugin-from-dir ${PLUGIN_NAME_LATEST} -} - -# Deploy Step -deploy () { - # Login to docker - echo "$DOCKER_PASSWORD" | docker login -u libgolang --password-stdin - - # Push image - docker plugin push ${PLUGIN_NAME} -} - -# Deploy Latest Tag Step -deploy-latest () { - # Login to docker - echo "$DOCKER_PASSWORD" | docker login -u libgolang --password-stdin - - # Push image - docker plugin push ${PLUGIN_NAME_LATEST} -} - -compile () { - go get -u github.com/golang/dep/cmd/dep - dep ensure - docker build --no-cache -q -t ${PLUGIN_NAME_ROOTFS} . -} - -assemble-plugin-dir () { - # create plugin - mkdir -p ./plugin/rootfs - docker create --name tmp ${PLUGIN_NAME_ROOTFS} - docker export tmp | tar -x -C ./plugin/rootfs - cp config.json ./plugin/ - docker rm -vf tmp -} - -create-plugin-from-dir () { - docker plugin rm -f $1 || true - docker plugin create $1 ./plugin -} diff --git a/scripts/deploy-latest.sh b/scripts/deploy-latest.sh deleted file mode 100755 index ab9f98e..0000000 --- a/scripts/deploy-latest.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -# set: -# -e: Exit on error. -# -x: Display commands. -set -ex - -scriptDir=`dirname $(readlink -f $0)` -source $scriptDir/common.sh - - -# Deploy Step have to build again, it does not remember -# the docker image built before. -build - - -# Deploy Step -deploy-latest - diff --git a/scripts/deploy.sh b/scripts/deploy.sh deleted file mode 100755 index 979ee4c..0000000 --- a/scripts/deploy.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -# set: -# -e: Exit on error. -# -x: Display commands. -set -ex - -scriptDir=`dirname $(readlink -f $0)` -source $scriptDir/common.sh - - -# Deploy Step have to build again, it does not remember -# the docker image built before. -build - - -# Deploy Step -deploy - diff --git a/scripts/test.sh b/scripts/test.sh deleted file mode 100755 index 4709648..0000000 --- a/scripts/test.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash - -# set: -# -e: Exit on error. -# -x: Display commands. -set -ex - -# Tools -go get -u github.com/tsenart/deadcode -go get -u github.com/kisielk/errcheck -go get -u golang.org/x/lint/golint - -# Run Code Tests -go vet -errcheck -golint -deadcode -go test - -# Test Plugin Functionality -# ... -# ... diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..ff4a0f7 --- /dev/null +++ b/utils.go @@ -0,0 +1,71 @@ +package main + +import ( + "math" + "os" + "path" + "time" + + "github.com/chiefy/linodego" + "github.com/docker/go-plugins-helpers/volume" + "github.com/libgolang/log" +) + +// labelToMountPoint gets the mount-point for a volume +func labelToMountPoint(volumeLabel string) string { + return path.Join(MountRoot, volumeLabel) +} + +// waitForDeviceFileExists waits until path devicePath becomes available or +// times out. +func waitForDeviceFileExists(devicePath string, waitSeconds int) error { + return waitForCondition(waitSeconds, 1, func() bool { + // found, then break + if _, err := os.Stat(devicePath); !os.IsNotExist(err) { + return true // condition met + } + log.Info("Waiting for device %s to be available", devicePath) + return false + }) +} + +func waitForLinodeVolumeDetachment(linodeAPI linodego.Client, volumeID int) error { + // Wait for linode to have the volume detached + return waitForCondition(180, 2, func() bool { + v, err := linodeAPI.GetVolume(volumeID) + if err != nil { + log.Error("%s", err) + return false + } + + if v.LinodeID == nil { + return true // detached + } + + return false + }) +} + +// waitForCondition Waits until condition returns true timeout is reached. If timeout is +// reached it returns error. +func waitForCondition(waitSeconds int, intervalSeconds int, check func() bool) error { + loops := int(math.Ceil(float64(waitSeconds) / float64(intervalSeconds))) + for i := 0; i < loops; i++ { + if check() { + return nil + } + time.Sleep(time.Second * time.Duration(intervalSeconds)) + } + return log.Err("waitForCondition timeout") +} + +// linodeVolumeToDockerVolume converts a linode volume to a docker volume +func linodeVolumeToDockerVolume(lv *linodego.Volume) *volume.Volume { + v := &volume.Volume{ + Name: lv.Label, + Mountpoint: labelToMountPoint(lv.Label), + CreatedAt: lv.Created.Format(time.RFC3339), + Status: make(map[string]interface{}), + } + return v +}