├── .github ├── ISSUE_TEMPLATE │ ├── bug.yml │ ├── feature.yml │ └── help.yml ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml ├── labels.yml ├── release.yml └── workflows │ ├── codeql.yml │ ├── dependency-review.yml │ ├── labeler.yml │ ├── pull_request.yml │ └── release.yaml ├── .gitignore ├── .golangci.yml ├── CODEOWNERS ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── config.json ├── driver.go ├── fs_utils_linux.go ├── go.mod ├── go.sum ├── integration-test ├── harden.yaml.j2 └── test.yaml ├── main.go └── utils.go /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: ["bug"] 5 | body: 6 | - type: textarea 7 | id: docker-version 8 | attributes: 9 | label: Docker Version 10 | description: What version of Docker are you running? `docker version` 11 | validations: 12 | required: true 13 | 14 | - type: textarea 15 | id: error 16 | attributes: 17 | label: Error Output 18 | description: If you received an error output that is too long, use Gists 19 | 20 | - type: textarea 21 | id: expected 22 | attributes: 23 | label: Expected Behavior 24 | description: What should have happened? 25 | 26 | - type: textarea 27 | id: actual 28 | attributes: 29 | label: Actual Behavior 30 | description: What actually happened? 31 | 32 | - type: textarea 33 | id: reproduce 34 | attributes: 35 | label: Steps to Reproduce 36 | description: List any custom configurations and the steps to reproduce this error 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yml: -------------------------------------------------------------------------------- 1 | name: Enhancement 2 | description: Request a feature 3 | title: "[Feature]: " 4 | labels: ["enhancement"] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Description 10 | description: What would you like this feature to do in detail? 11 | validations: 12 | required: true 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/help.yml: -------------------------------------------------------------------------------- 1 | name: Help 2 | description: You're pretty sure it's not a bug but you can't figure out why it's not working 3 | title: "[Help]: " 4 | labels: ["help wanted"] 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Description 10 | description: What are you attempting to do, what error messages are you getting? 11 | validations: 12 | required: true 13 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 📝 Description 2 | 3 | **What does this PR do and why is this change necessary?** 4 | 5 | ## ✔️ How to Test 6 | 7 | **What are the steps to reproduce the issue or verify the changes?** 8 | 9 | **How do I run the relevant unit/integration tests?** 10 | 11 | ## 📷 Preview 12 | 13 | **If applicable, include a screenshot or code snippet of this change. Otherwise, please remove this section.** -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | # PR Labels 2 | - name: new-feature 3 | description: for new features in the changelog. 4 | color: 225fee 5 | - name: project 6 | description: for new projects in the changelog. 7 | color: 46BAF0 8 | - name: improvement 9 | description: for improvements in existing functionality in the changelog. 10 | color: 22ee47 11 | - name: repo-ci-improvement 12 | description: for improvements in the repository or CI workflow in the changelog. 13 | color: c922ee 14 | - name: bugfix 15 | description: for any bug fixes in the changelog. 16 | color: ed8e21 17 | - name: documentation 18 | description: for updates to the documentation in the changelog. 19 | color: d3e1e6 20 | - name: dependencies 21 | description: dependency updates usually from dependabot 22 | color: 5c9dff 23 | - name: testing 24 | description: for updates to the testing suite in the changelog. 25 | color: 933ac9 26 | - name: breaking-change 27 | description: for breaking changes in the changelog. 28 | color: ff0000 29 | - name: ignore-for-release 30 | description: PRs you do not want to render in the changelog 31 | color: 7b8eac 32 | - name: do-not-merge 33 | description: PRs that should not be merged until the commented issue is resolved 34 | color: eb1515 35 | # Issue Labels 36 | - name: enhancement 37 | description: issues that request a enhancement 38 | color: 22ee47 39 | - name: bug 40 | description: issues that report a bug 41 | color: ed8e21 42 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | categories: 6 | - title: 📋 New Project 7 | labels: 8 | - project 9 | - title: ⚠️ Breaking Change 10 | labels: 11 | - breaking-change 12 | - title: 🐛 Bug Fixes 13 | labels: 14 | - bugfix 15 | - title: 🚀 New Features 16 | labels: 17 | - new-feature 18 | - title: 💡 Improvements 19 | labels: 20 | - improvement 21 | - title: 🧪 Testing Improvements 22 | labels: 23 | - testing 24 | - title: ⚙️ Repo/CI Improvements 25 | labels: 26 | - repo-ci-improvement 27 | - title: 📖 Documentation 28 | labels: 29 | - documentation 30 | - title: 📦 Dependency Updates 31 | labels: 32 | - dependencies 33 | - title: Other Changes 34 | labels: 35 | - "*" 36 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL Advanced" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | schedule: 9 | - cron: '0 13 * * 5' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze (${{ matrix.language }}) 14 | runs-on: ubuntu-latest 15 | permissions: 16 | security-events: write 17 | 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | include: 22 | - language: go 23 | build-mode: autobuild 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v3 31 | with: 32 | languages: ${{ matrix.language }} 33 | build-mode: ${{ matrix.build-mode }} 34 | queries: security-and-quality 35 | 36 | 37 | - name: Perform CodeQL Analysis 38 | uses: github/codeql-action/analyze@v3 39 | with: 40 | category: "/language:${{matrix.language}}" 41 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: 'Dependency review' 2 | on: 3 | pull_request: 4 | branches: [ "main" ] 5 | 6 | permissions: 7 | contents: read 8 | pull-requests: write 9 | 10 | jobs: 11 | dependency-review: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: 'Checkout repository' 15 | uses: actions/checkout@v4 16 | - name: 'Dependency Review' 17 | uses: actions/dependency-review-action@v4 18 | with: 19 | comment-summary-in-pr: on-failure 20 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: labeler 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | paths: 8 | - '.github/labels.yml' 9 | - '.github/workflows/labeler.yml' 10 | pull_request: 11 | paths: 12 | - '.github/labels.yml' 13 | - '.github/workflows/labeler.yml' 14 | 15 | jobs: 16 | labeler: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - 20 | name: Checkout 21 | uses: actions/checkout@v4 22 | - 23 | name: Run Labeler 24 | uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 25 | with: 26 | github-token: ${{ secrets.GITHUB_TOKEN }} 27 | yaml-file: .github/labels.yml 28 | dry-run: ${{ github.event_name == 'pull_request' }} 29 | exclude: | 30 | help* 31 | *issue 32 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | on: pull_request 2 | name: Pull Request 3 | jobs: 4 | test: 5 | name: Run tests 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - uses: actions/setup-go@v5 10 | with: 11 | go-version: "stable" 12 | - name: build 13 | run: make build 14 | - name: check 15 | run: make check 16 | - name: unit-test 17 | run: make unit-test 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Docker Plugin Publish 2 | on: 3 | workflow_dispatch: null 4 | release: 5 | types: [ published ] 6 | jobs: 7 | oci_publish: 8 | name: Build and publish the docker plugin 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Clone Repository 12 | uses: actions/checkout@v4 13 | 14 | - name: setup python 3 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: '3.x' 18 | 19 | - name: Set up QEMU 20 | uses: docker/setup-qemu-action@v2 21 | 22 | - name: Set up Docker Buildx 23 | uses: docker/setup-buildx-action@v3 24 | 25 | - name: Login to Docker Hub 26 | uses: docker/login-action@v3 27 | with: 28 | username: ${{ secrets.DOCKERHUB_USERNAME }} 29 | password: ${{ secrets.DOCKERHUB_TOKEN }} 30 | 31 | - name: Build and push to DockerHub 32 | run: make deploy 33 | env: 34 | PLUGIN_VERSION: ${{ github.event.release.tag_name }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /plugin-contents-dir/ 2 | .DS_Store -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | run: 4 | tests: false 5 | 6 | linters: 7 | settings: 8 | dupl: 9 | threshold: 100 10 | 11 | gomoddirectives: 12 | replace-allow-list: 13 | - github.com/linode/linodego 14 | 15 | govet: 16 | disable: 17 | - shadow 18 | 19 | revive: 20 | rules: 21 | - name: unused-parameter 22 | severity: warning 23 | disabled: true 24 | 25 | staticcheck: 26 | checks: ["all", "-ST1005"] 27 | 28 | exclusions: 29 | generated: lax 30 | presets: 31 | - comments 32 | - common-false-positives 33 | - legacy 34 | - std-error-handling 35 | paths: 36 | - third_party$ 37 | - builtin$ 38 | - examples$ 39 | 40 | formatters: 41 | exclusions: 42 | generated: lax 43 | paths: 44 | - third_party$ 45 | - builtin$ 46 | - examples$ 47 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @linode/dx 2 | 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | :+1::tada: First off, we appreciate you taking the time to contribute! THANK YOU! :tada::+1: 4 | 5 | We put together the handy guide below to help you get support for your work. Read on! 6 | 7 | ## I Just Want to Ask the Maintainers a Question 8 | 9 | The [Linode Community](https://www.linode.com/community/questions/) is a great place to get additional support. 10 | 11 | ## How Do I Submit A (Good) Bug Report or Feature Request 12 | 13 | Please open a [GitHub issue](/../../issues/new/choose) to report bugs or suggest features. 14 | 15 | Please accurately fill out the appropriate GitHub issue form. 16 | 17 | When filing an issue or feature request, help us avoid duplication and redundant effort -- check existing open or recently closed issues first. 18 | 19 | Detailed bug reports and requests are easier for us to work with. Please include the following in your issue: 20 | 21 | * A reproducible test case or series of steps 22 | * The version of our code being used 23 | * Any modifications you've made, relevant to the bug 24 | * Anything unusual about your environment or deployment 25 | * Screenshots and code samples where illustrative and helpful 26 | 27 | ## How to Open a Pull Request 28 | 29 | We follow the [fork and pull model](https://opensource.guide/how-to-contribute/#opening-a-pull-request) for open source contributions. 30 | 31 | Tips for a faster merge: 32 | * address one feature or bug per pull request. 33 | * large formatting changes make it hard for us to focus on your work. 34 | * follow language coding conventions. 35 | * make sure that tests pass. 36 | * make sure your commits are atomic, [addressing one change per commit](https://chris.beams.io/posts/git-commit/). 37 | * add tests! 38 | 39 | ## Code of Conduct 40 | 41 | This project follows the [Linode Community Code of Conduct](https://www.linode.com/community/questions/conduct). 42 | 43 | ## Vulnerability Reporting 44 | 45 | If you discover a potential security issue in this project we ask that you notify Linode Security via our [vulnerability reporting process](https://hackerone.com/linode). Please do **not** create a public github issue. 46 | 47 | ## Licensing 48 | 49 | See the [LICENSE file](/LICENSE) for our project's licensing. 50 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23-alpine as builder 2 | ENV GO111MODULE=on 3 | ARG VERSION=0 4 | COPY . /docker-volume-linode 5 | WORKDIR /docker-volume-linode 6 | RUN apk update && apk add git \ 7 | && apk add --no-cache --virtual .build-deps gcc libc-dev \ 8 | && go install --ldflags "-extldflags '-static' -X main.VERSION=$VERSION" \ 9 | && apk del .build-deps 10 | 11 | FROM alpine 12 | COPY --from=builder /go/bin/docker-volume-linode . 13 | RUN apk update && apk add ca-certificates e2fsprogs xfsprogs btrfs-progs util-linux 14 | CMD ["./docker-volume-linode"] 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notics 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | # Build Arguments 3 | REPO_SLUG ?= linode/docker-volume-linode 4 | 5 | # Deploy Arguments 6 | DOCKER_USERNAME ?= xxxxx 7 | DOCKER_PASSWORD ?= xxxxx 8 | 9 | # Test Arguments 10 | TEST_TOKEN ?= $(LINODE_TOKEN) 11 | 12 | # Quick Test Arguments 13 | QUICKTEST_SSH_PUBKEY ?= ~/.ssh/id_rsa.pub 14 | QUICKTEST_SKIP_TESTS ?= 0 15 | 16 | GOPATH=$(shell go env GOPATH) 17 | 18 | PLUGIN_VERSION=$(shell git describe --tags --always --abbrev=0) 19 | 20 | PLUGIN_NAME_ROOTFS=docker-volume-linode:rootfs.${PLUGIN_VERSION} 21 | PLUGIN_NAME=${REPO_SLUG}:${PLUGIN_VERSION} 22 | PLUGIN_NAME_LATEST=${REPO_SLUG}:latest 23 | 24 | PLUGIN_DIR=plugin-contents-dir 25 | 26 | export GO111MODULE=on 27 | 28 | all: clean build 29 | 30 | deploy: $(PLUGIN_DIR) 31 | # workaround for plugin 32 | docker plugin rm -f ${PLUGIN_NAME} 2>/dev/null || true 33 | docker plugin create ${PLUGIN_NAME} ./$(PLUGIN_DIR) 34 | docker plugin push ${PLUGIN_NAME} 35 | docker plugin rm -f ${PLUGIN_NAME} 2>/dev/null || true 36 | 37 | # load plugin with `latest` tag 38 | docker plugin rm -f ${PLUGIN_NAME_LATEST} 2>/dev/null || true 39 | docker plugin create ${PLUGIN_NAME_LATEST} ./$(PLUGIN_DIR) 40 | docker plugin push ${PLUGIN_NAME_LATEST} 41 | docker plugin rm -f ${PLUGIN_NAME_LATEST} 2>/dev/null || true 42 | 43 | docker-login: 44 | # Login to docker 45 | echo '${DOCKER_PASSWORD}' | docker login -u "${DOCKER_USERNAME}" --password-stdin 46 | 47 | build: $(PLUGIN_DIR) 48 | # load plugin with versionied tag 49 | # docker plugin rm -f ${PLUGIN_NAME} 2>/dev/null || true 50 | # docker plugin create ${PLUGIN_NAME} ./$(PLUGIN_DIR) 51 | # load plugin with `latest` tag 52 | docker plugin rm -f ${PLUGIN_NAME} 2>/dev/null || true 53 | docker plugin rm -f ${PLUGIN_NAME_LATEST} 2>/dev/null || true 54 | docker plugin create ${PLUGIN_NAME_LATEST} ./$(PLUGIN_DIR) 55 | 56 | $(PLUGIN_DIR): *.go Dockerfile 57 | # compile 58 | docker build --build-arg VERSION="${PLUGIN_VERSION}" --no-cache -q -t ${PLUGIN_NAME_ROOTFS} . 59 | 60 | # assemble 61 | mkdir -p ./$(PLUGIN_DIR)/rootfs 62 | docker create --name tmp ${PLUGIN_NAME_ROOTFS} 63 | docker export tmp | tar -x -C ./$(PLUGIN_DIR)/rootfs 64 | cp config.json ./$(PLUGIN_DIR)/ 65 | docker rm -vf tmp 66 | 67 | # Provision a test environment for docker-volume-linode using Ansible. 68 | .PHONY: int-test 69 | int-test: 70 | ANSIBLE_HOST_KEY_CHECKING=False ANSIBLE_STDOUT_CALLBACK=yaml \ 71 | ansible-playbook -v --extra-vars \ 72 | "ssh_pubkey_path=${QUICKTEST_SSH_PUBKEY} skip_tests=${QUICKTEST_SKIP_TESTS}" \ 73 | integration-test/test.yaml 74 | 75 | # Run Integration Tests 76 | # Requires TEST_* Variables to be set 77 | .PHONY: local-test 78 | local-test: test-pre-check \ 79 | build \ 80 | test-setup \ 81 | test-create-volume-50 \ 82 | test-rm-volume-50 \ 83 | test-create-volume \ 84 | test-use-volume \ 85 | clean-volumes 86 | 87 | test-create-volume: 88 | docker volume create -d $(PLUGIN_NAME_LATEST) -o delete-on-remove=true test-volume-default-size 89 | 90 | test-create-volume-50: 91 | docker volume create -d $(PLUGIN_NAME_LATEST) -o delete-on-remove=true -o size=50 test-volume-50g 92 | 93 | test-rm-volume-50: 94 | docker volume rm test-volume-50g 95 | 96 | test-use-volume: 97 | docker run --rm -i -v test-volume-default-size:/mnt busybox touch /mnt/abc.txt 98 | docker run --rm -i -v test-volume-default-size:/mnt busybox test -f /mnt/abc.txt || false 99 | 100 | test-pre-check: 101 | @if [ "${TEST_TOKEN}" = "xyz" ]; then \ 102 | echo -en "#############################\nYou must set TEST_* Variables\n#############################\n"; exit 1; fi 103 | 104 | test-setup: 105 | @docker plugin set $(PLUGIN_NAME_LATEST) linode-token=${TEST_TOKEN} 106 | docker plugin enable $(PLUGIN_NAME_LATEST) 107 | 108 | check: 109 | docker run --rm -v $(PWD):/app -w /app golangci/golangci-lint:latest golangci-lint run --timeout 15m0s 110 | 111 | unit-test: 112 | GOOS=linux go test 113 | 114 | .PHONY clean: 115 | rm -fr $(PLUGIN_DIR) 116 | 117 | clean-volumes: 118 | docker volume ls -q | grep 'test-' | xargs docker volume rm 119 | clean-installed-plugins: 120 | docker plugin ls | grep linode | grep -v ID | awk '{print $$1}' | xargs docker plugin rm -f 121 | 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Docker Volume Driver For Linode 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/linode/docker-volume-linode/linode.svg)](https://pkg.go.dev/github.com/linode/docker-volume-linode/) 4 | [![Build](/../../actions/workflows/pull_request.yml/badge.svg)](/../../actions/workflows/pull_request.yml) 5 | 6 | This [volume plugin](https://docs.docker.com/engine/extend/plugins_volume/) adds the ability to manage [Linode Block Storage](https://www.linode.com/blockstorage) as [Docker Volumes](https://docs.docker.com/storage/volumes/) from within a Linode. 7 | [Good use cases for volumes](https://docs.docker.com/storage/#good-use-cases-for-volumes) include off-node storage to avoid size constraints or moving a container and the related volume between nodes in a [Swarm](https://github.com/linode/docker-machine-driver-linode#provisioning-docker-swarm). 8 | 9 | ## Requirements 10 | 11 | - Linux (tested on Fedora 34, should work with other versions and distributions) 12 | - Docker (tested on version 20, should work with other versions) 13 | 14 | ## Installation 15 | 16 | ```sh 17 | docker plugin install --alias linode --grant-all-permissions \ 18 | linode/docker-volume-linode \ 19 | linode-token= 20 | ``` 21 | 22 | ### Driver Options 23 | 24 | | Option Name | Description | 25 | | --- | --- | 26 | | linode-token | **Required** The Linode APIv4 [Personal Access Token](https://cloud.linode.com/profile/tokens) to use. (requires `linodes:read_write volumes:read_write events:read_only`) 27 | | linode-label | The label of the current Linode. This is only necessary if your Linode does not have a resolvable Link Local IPv6 Address. 28 | | force-attach | If true, volumes will be forcibly attached to the current Linode if already attached to another Linode. (defaults to false) WARNING: Forcibly reattaching volumes can result in data loss if a volume is not properly unmounted. 29 | | mount-root | Sets the root directory for volume mounts (defaults to /mnt) | 30 | | log-level | Sets log level to debug,info,warn,error (defaults to info) | 31 | | socket-user | Sets the user to create the docker socket with (defaults to root) | 32 | 33 | Options can be set once for all future uses with [`docker plugin set`](https://docs.docker.com/engine/reference/commandline/plugin_set/#extended-description). 34 | 35 | ### Changing the plugin configuration 36 | 37 | The plugin can also be configured (or reconfigured) in multiple steps. 38 | 39 | ```sh 40 | docker plugin install --alias linode linode/docker-volume-linode 41 | docker plugin disable linode 42 | docker plugin set linode linode-token= 43 | docker plugin enable linode 44 | ``` 45 | 46 | - For all options see [Driver Options](#Driver-Options) section 47 | 48 | ### Docker Swarm 49 | 50 | Volumes can be mounted to one container at the time because Linux Block Storage volumes can only be attached to one Linode at the time. 51 | 52 | ## Usage 53 | 54 | All examples assume the driver has been aliased to `linode`. 55 | 56 | ### Create Volume 57 | 58 | Linode Block Storage volumes can be created and managed using the [docker volume create](https://docs.docker.com/engine/reference/commandline/volume_create/) command. 59 | 60 | ```sh 61 | $ docker volume create -d linode my-test-volume 62 | my-test-volume 63 | ``` 64 | 65 | If a named volume already exists on the Linode account and it is in the same region of the Linode, it will be reattached if possible. A Linode Volume can be attached to a single Linode at a time. 66 | 67 | #### Create Options 68 | 69 | The driver offers [driver specific volume create options](https://docs.docker.com/engine/reference/commandline/volume_create/#driver-specific-options): 70 | 71 | | Option | Type | Default | Description | 72 | | --- | --- | --- | --- | 73 | | `size` | int | `10` | the size (in GB) of the volume to be created. Volumes must be at least 10GB in size, so the default is 10GB. 74 | | `filesystem` | string | `ext4` | the filesystem argument for `mkfs` when formating the new (raw) volume (xfs, btrfs, ext4) 75 | | `delete-on-remove` | bool | `false`| if the Linode volume should be deleted when removed 76 | 77 | ```sh 78 | $ docker volume create -o size=50 -d linode my-test-volume-50 79 | my-test-volume-50 80 | ``` 81 | 82 | Volumes can also be created and attached from `docker run`: 83 | 84 | ```sh 85 | docker run -it --rm --mount volume-driver=linode,source=test-vol,destination=/test,volume-opt=size=25 alpine 86 | ``` 87 | 88 | Multiple create options can be supplied: 89 | 90 | ```sh 91 | docker run -it --rm --mount volume-driver=linode,source=test-vol,destination=/test,volume-opt=size=25,volume-opt=filesystem=btrfs,volume-opt=delete-on-remove=true alpine 92 | ``` 93 | 94 | ### List Volumes 95 | 96 | ```sh 97 | $ docker volume ls 98 | DRIVER VOLUME NAME 99 | linode:latest my-test-volume 100 | linode:latest my-test-volume-50 101 | ``` 102 | 103 | ### Use Volume 104 | 105 | ```sh 106 | $ docker run --rm -it -v my-test-volume:/usr/local/apache2/htdocs/ httpd 107 | ... 108 | ``` 109 | 110 | ### Remove Volumes 111 | 112 | ```sh 113 | $ docker volume rm my-test-volume 114 | my-test-volume 115 | 116 | $ docker volume rm my-test-volume-50 117 | my-test-volume-50 118 | ``` 119 | 120 | ## Manual Installation 121 | 122 | - Install Golang: 123 | - Get code and Compile: `go get -u github.com/linode/docker-volume-linode` 124 | 125 | ### Run the driver 126 | 127 | ```sh 128 | docker-volume-linode --linode-token= 129 | ``` 130 | 131 | ### Debugging 132 | 133 | #### Enable Debug Level on plugin 134 | 135 | The driver name when running manually is the same name as the socket file. 136 | 137 | ```sh 138 | docker plugin set docker-volume-linode log-level=debug 139 | ``` 140 | 141 | #### Enable Debug Level in manual installation 142 | 143 | ```sh 144 | docker-volume-linode --linode-token=<...> --log-level=debug 145 | ``` 146 | 147 | ## Development 148 | 149 | A great place to get started is the Docker Engine managed plugin system [documentation](https://docs.docker.com/engine/extend/#create-a-volumedriver). 150 | 151 | ## Running Integration Tests 152 | 153 | The integration tests for this project can be easily run using the `make int-test` target. 154 | This target provisions and connects to a Linode instance, uploads the plugin, builds it, enables it, 155 | and runs the integration test suite. Subsequent runs of this target will re-use the existing Linode instance. 156 | 157 | In order to run this target, Ansible and the [Linode Ansible Collection](https://github.com/linode/ansible_linode/) 158 | must be installed on the local machine: 159 | 160 | ```bash 161 | pip install ansible 162 | 163 | ansible-galaxy collection install linode.cloud 164 | 165 | pip install -r https://raw.githubusercontent.com/linode/ansible_linode/main/requirements.txt 166 | ``` 167 | 168 | The integration test suite also requires that a full-access [Linode Personal Access Token](https://www.linode.com/docs/products/tools/api/guides/manage-api-tokens/) 169 | be exported as the `LINODE_TOKEN` environment variable. 170 | 171 | ```bash 172 | export LINODE_TOKEN=EXAMPLETOKEN 173 | ``` 174 | 175 | The integration test suite can now be run: 176 | 177 | ```bash 178 | make int-test 179 | ``` 180 | 181 | NOTE: This target requires an existing SSH key be created. If an SSH key exists at a path other than 182 | `~/.ssh/id_rsa`, the `QUICKTEST_SSH_PUBKEY` argument can be specified: 183 | 184 | ```bash 185 | make QUICKTEST_SSH_PUBKEY="~/.ssh/mykey.pub" int-test 186 | ``` 187 | 188 | If you would like to create a test environment for docker-volume-linode without running the integration test suite, 189 | the `QUICKTEST_SKIP_TESTS` argument can be specified: 190 | 191 | ```bash 192 | make QUICKTEST_SKIP_TESTS=1 int-test 193 | ``` 194 | 195 | ## Discussion / Help 196 | 197 | Join us at [#linodego](https://gophers.slack.com/messages/CAG93EB2S) on the [gophers slack](https://gophers.slack.com) 198 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Linode Volume plugin for Docker", 3 | "documentation": "https://docs.docker.com/engine/extend/plugins/", 4 | "entrypoint": [ "/docker-volume-linode" ], 5 | "env": [ 6 | { "name": "linode-token", "settable": [ "value" ], "value": "" }, 7 | { "name": "linode-label", "settable": [ "value" ], "value": "" }, 8 | { "name": "force-attach", "settable": [ "value" ], "value": "false" }, 9 | { "name": "socket-user", "settable": [ "value" ], "value": "root" }, 10 | { "name": "mount-root", "settable": [ "value" ], "value": "/mnt" }, 11 | { "name": "log-level", "settable": [ "value" ], "value": "info" } 12 | ], 13 | "interface": { 14 | "socket": "linode.sock", 15 | "types": [ "docker.volumedriver/1.1" ] 16 | }, 17 | "linux": { 18 | "allowAllDevices": true, 19 | "capabilities": [ "CAP_SYS_ADMIN" ] 20 | }, 21 | "PropagatedMount": "/mnt", 22 | "mounts": [ 23 | { 24 | "name": "/dev", 25 | "destination": "/dev", 26 | "options": [ "rbind" ], 27 | "source": "/dev", 28 | "type": "bind" 29 | } 30 | ], 31 | "network": { 32 | "type": "host" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /driver.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net" 8 | "os" 9 | "path" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "github.com/docker/go-plugins-helpers/volume" 16 | metadata "github.com/linode/go-metadata" 17 | "github.com/linode/linodego" 18 | log "github.com/sirupsen/logrus" 19 | ) 20 | 21 | type linodeVolumeDriver struct { 22 | instanceID int 23 | region string 24 | linodeLabel string 25 | linodeToken string 26 | mountRoot string 27 | mutex *sync.Mutex 28 | linodeAPIPtr *linodego.Client 29 | } 30 | 31 | const ( 32 | fsTagPrefix = "docker-volume-filesystem-" 33 | ) 34 | 35 | // Constructor 36 | func newLinodeVolumeDriver(linodeLabel, linodeToken, mountRoot string) linodeVolumeDriver { 37 | driver := linodeVolumeDriver{ 38 | linodeToken: linodeToken, 39 | linodeLabel: linodeLabel, 40 | mountRoot: mountRoot, 41 | mutex: &sync.Mutex{}, 42 | } 43 | if _, err := driver.linodeAPI(); err != nil { 44 | log.Fatalf("Could not initialize Linode API: %s", err) 45 | } 46 | 47 | return driver 48 | } 49 | 50 | func (driver *linodeVolumeDriver) linodeAPI() (*linodego.Client, error) { 51 | if driver.linodeToken == "" { 52 | return nil, fmt.Errorf("Linode Token required. Set the token by calling \"docker plugin set linode-token=\"") 53 | } 54 | 55 | if driver.linodeAPIPtr != nil { 56 | return driver.linodeAPIPtr, nil 57 | } 58 | 59 | driver.linodeAPIPtr = setupLinodeAPI(driver.linodeToken) 60 | 61 | if driver.instanceID == 0 { 62 | if err := driver.determineLinodeID(); err != nil { 63 | driver.linodeAPIPtr = nil 64 | return nil, err 65 | } 66 | } 67 | 68 | return driver.linodeAPIPtr, nil 69 | } 70 | 71 | func setupLinodeAPI(token string) *linodego.Client { 72 | api := linodego.NewClient(nil) 73 | 74 | ua := fmt.Sprintf("docker-volume-linode/%s linodego/%s", VERSION, linodego.Version) 75 | api.SetUserAgent(ua) 76 | 77 | api.SetToken(token) 78 | 79 | return &api 80 | } 81 | 82 | func metadataServicesAvailable() bool { 83 | conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:80", metadata.APIHost), 2*time.Second) 84 | if err != nil { 85 | return false 86 | } 87 | 88 | conn.Close() 89 | return true 90 | } 91 | 92 | func (driver *linodeVolumeDriver) determineLinodeID() error { 93 | if metadataServicesAvailable() { 94 | err := driver.determineLinodeIDFromMetadata() 95 | if err != nil { 96 | log.Error( 97 | "Failed to get linode info from Linode metadata service. " + 98 | "Other methods will be used.", 99 | ) 100 | } 101 | } 102 | 103 | if driver.linodeLabel == "" { 104 | // If the label isn't defined, we should determine the IP through the network interface 105 | log.Info("Using network interface to determine Linode ID") 106 | 107 | if err := driver.determineLinodeIDFromNetworking(); err != nil { 108 | return fmt.Errorf("Failed to determine Linode ID from networking: %s\n"+ 109 | "If this error continues to occur or if you are using a custom network configuration, "+ 110 | "consider using the `linode-label` flag.", err) 111 | } 112 | 113 | return nil 114 | } 115 | 116 | return driver.determineLinodeIDFromLabel() 117 | } 118 | 119 | func (driver *linodeVolumeDriver) determineLinodeIDFromMetadata() error { 120 | client, err := metadata.NewClient(context.Background()) 121 | if err != nil { 122 | return err 123 | } 124 | 125 | instanceInfo, err := client.GetInstance(context.Background()) 126 | if err != nil { 127 | return err 128 | } 129 | 130 | driver.instanceID = instanceInfo.ID 131 | driver.region = instanceInfo.Region 132 | driver.linodeLabel = instanceInfo.Label 133 | 134 | return nil 135 | } 136 | 137 | func (driver *linodeVolumeDriver) determineLinodeIDFromLabel() error { 138 | jsonFilter, _ := json.Marshal(map[string]string{"label": driver.linodeLabel}) 139 | listOpts := linodego.NewListOptions(0, string(jsonFilter)) 140 | linodes, lErr := driver.linodeAPIPtr.ListInstances(context.Background(), listOpts) 141 | 142 | if lErr != nil { 143 | return fmt.Errorf("Could not determine Linode instance ID from Linode label %s due to error: %s", driver.linodeLabel, lErr) 144 | } else if len(linodes) != 1 { 145 | return fmt.Errorf("Could not determine Linode instance ID from Linode label %s", driver.linodeLabel) 146 | } 147 | 148 | driver.instanceID = linodes[0].ID 149 | if driver.region == "" { 150 | driver.region = linodes[0].Region 151 | } 152 | return nil 153 | } 154 | 155 | func (driver *linodeVolumeDriver) resolveMachineLinkLocal() (string, error) { 156 | // We only want to filter on eth0 for Link Local. 157 | iface, err := net.InterfaceByName("eth0") 158 | if err != nil { 159 | return "", err 160 | } 161 | 162 | addrs, err := iface.Addrs() 163 | if err != nil { 164 | return "", err 165 | } 166 | 167 | for _, addr := range addrs { 168 | if ifa, ok := addr.(*net.IPNet); ok { 169 | if ifa.IP.To4() != nil { 170 | continue 171 | } 172 | 173 | if !ifa.IP.IsLinkLocalUnicast() { 174 | continue 175 | } 176 | return strings.Split(addr.String(), "/")[0], nil 177 | } 178 | } 179 | 180 | return "", fmt.Errorf("no link local ipv6 address found") 181 | } 182 | 183 | func (driver *linodeVolumeDriver) determineLinodeIDFromNetworking() error { 184 | linkLocal, err := driver.resolveMachineLinkLocal() 185 | if err != nil { 186 | return fmt.Errorf("failed to determine linode id from networking: %s", err) 187 | } 188 | 189 | instances, err := driver.linodeAPIPtr.ListInstances(context.Background(), nil) 190 | if err != nil { 191 | return fmt.Errorf("failed to list instances: %s", err) 192 | } 193 | 194 | for _, instance := range instances { 195 | ips, err := driver.linodeAPIPtr.GetInstanceIPAddresses(context.Background(), instance.ID) 196 | if err != nil { 197 | return fmt.Errorf("failed to get ip addresses for instance %d: %s", instance.ID, err) 198 | } 199 | 200 | if ips.IPv6.LinkLocal == nil || ips.IPv6.LinkLocal.Address != linkLocal { 201 | continue 202 | } 203 | 204 | driver.instanceID = instance.ID 205 | driver.region = instance.Region 206 | return nil 207 | } 208 | 209 | return fmt.Errorf("instance with link local address %s not found", linkLocal) 210 | } 211 | 212 | // Get implementation 213 | func (driver *linodeVolumeDriver) Get(req *volume.GetRequest) (*volume.GetResponse, error) { 214 | log.Infof("Get(%s)", req.Name) 215 | linVol, err := driver.findVolumeByLabel(req.Name) 216 | if err != nil { 217 | return nil, err 218 | } 219 | 220 | if linVol == nil { 221 | return nil, fmt.Errorf("got a NIL volume. Volume may not exist") 222 | } 223 | 224 | mp := driver.labelToMountPoint(linVol.Label) 225 | vol := linodeVolumeToDockerVolume(*linVol, mp) 226 | resp := &volume.GetResponse{Volume: vol} 227 | 228 | log.Infof("Get(): {Name: %s; Mountpoint: %s;}", vol.Name, vol.Mountpoint) 229 | 230 | return resp, nil 231 | } 232 | 233 | // List implementation 234 | func (driver *linodeVolumeDriver) List() (*volume.ListResponse, error) { 235 | log.Infof("List()") 236 | 237 | var jsonFilter []byte 238 | var err error 239 | 240 | // 241 | api, err := driver.linodeAPI() 242 | if err != nil { 243 | return nil, err 244 | } 245 | 246 | // 247 | var volumes []*volume.Volume 248 | 249 | // filters 250 | if jsonFilter, err = json.Marshal(map[string]string{"region": driver.region}); err != nil { 251 | return nil, err 252 | } 253 | listOpts := linodego.NewListOptions(0, string(jsonFilter)) 254 | log.Debug("linode api listOpts: ", listOpts) 255 | 256 | linVols, err := api.ListVolumes(context.Background(), listOpts) 257 | if err != nil { 258 | return nil, err 259 | } 260 | log.Debugf("Got %d volume count from api", len(linVols)) 261 | for _, linVol := range linVols { 262 | mp := driver.labelToMountPoint(linVol.Label) 263 | vol := linodeVolumeToDockerVolume(linVol, mp) 264 | log.Debugf("Volume: %+v", vol) 265 | volumes = append(volumes, vol) 266 | } 267 | log.Infof("List() returning %d volumes", len(volumes)) 268 | return &volume.ListResponse{Volumes: volumes}, nil 269 | } 270 | 271 | // Create implementation 272 | func (driver *linodeVolumeDriver) Create(req *volume.CreateRequest) error { 273 | log.Infof("Create(%s)", req.Name) 274 | 275 | api, err := driver.linodeAPI() 276 | if err != nil { 277 | return err 278 | } 279 | 280 | driver.mutex.Lock() 281 | defer driver.mutex.Unlock() 282 | 283 | var size int 284 | 285 | if sizeOpt, ok := req.Options["size"]; ok { 286 | s, err := strconv.Atoi(sizeOpt) 287 | if err != nil { 288 | return fmt.Errorf("Invalid size") 289 | } 290 | size = s 291 | } 292 | 293 | createOpts := linodego.VolumeCreateOptions{ 294 | Label: req.Name, 295 | Region: driver.region, 296 | Size: size, 297 | } 298 | 299 | if fsOpt, ok := req.Options["filesystem"]; ok { 300 | createOpts.Tags = append(createOpts.Tags, fsTagPrefix+fsOpt) 301 | } 302 | 303 | if deleteOpt, ok := req.Options["delete-on-remove"]; ok { 304 | b, err := strconv.ParseBool(deleteOpt) 305 | if err != nil { 306 | return fmt.Errorf("Invalid delete-on-remove argument") 307 | } 308 | if b { 309 | createOpts.Tags = append(createOpts.Tags, "docker-volume-delete-on-remove") 310 | } 311 | } 312 | 313 | volume, err := api.CreateVolume(context.Background(), createOpts) 314 | if err != nil { 315 | return fmt.Errorf("Create(%s) Failed: %s", req.Name, err) 316 | } 317 | 318 | _, err = driver.linodeAPIPtr.WaitForVolumeStatus( 319 | context.Background(), volume.ID, linodego.VolumeActive, 600, 320 | ) 321 | if err != nil { 322 | return fmt.Errorf( 323 | "Failed to wait for volume %d to be active: %w", volume.ID, err, 324 | ) 325 | } 326 | 327 | return nil 328 | } 329 | 330 | // Remove implementation 331 | func (driver *linodeVolumeDriver) Remove(req *volume.RemoveRequest) error { 332 | driver.mutex.Lock() 333 | defer driver.mutex.Unlock() 334 | 335 | // 336 | api, err := driver.linodeAPI() 337 | if err != nil { 338 | return err 339 | } 340 | 341 | // 342 | linVol, err := driver.findVolumeByLabel(req.Name) 343 | if err != nil { 344 | return err 345 | } 346 | 347 | // Send detach request 348 | if err := detachAndWait(api, linVol.ID); err != nil { 349 | return err 350 | } 351 | 352 | // Optionally send Delete request 353 | for _, t := range linVol.Tags { 354 | if t == "docker-volume-delete-on-remove" { 355 | if err := api.DeleteVolume(context.Background(), linVol.ID); err != nil { 356 | return err 357 | } 358 | break 359 | } 360 | } 361 | 362 | return nil 363 | } 364 | 365 | // Mount implementation 366 | func (driver *linodeVolumeDriver) Mount(req *volume.MountRequest) (*volume.MountResponse, error) { 367 | log.Infof("Called Mount %s", req.Name) 368 | 369 | api, err := driver.linodeAPI() 370 | if err != nil { 371 | return nil, err 372 | } 373 | 374 | linVol, err := driver.findVolumeByLabel(req.Name) 375 | if err != nil { 376 | return nil, err 377 | } 378 | 379 | linVol, err = api.GetVolume(context.Background(), linVol.ID) 380 | if err != nil { 381 | return nil, err 382 | } 383 | 384 | // Ensure the volume is not currently mounted 385 | if err := driver.ensureVolumeAttached(linVol.ID); err != nil { 386 | return nil, fmt.Errorf("failed to attach volume: %s", err) 387 | } 388 | 389 | // wait for kernel to have block device available 390 | if err := waitForDeviceFileExists(linVol.FilesystemPath, 300); err != nil { 391 | return nil, err 392 | } 393 | 394 | // Format block device if no FS found 395 | if GetFSType(linVol.FilesystemPath) == "" { 396 | log.Infof("Formatting device:%s;", linVol.FilesystemPath) 397 | filesystem := "ext4" 398 | for _, tag := range linVol.Tags { 399 | if strings.HasPrefix(tag, fsTagPrefix) { 400 | filesystem = tag[len(fsTagPrefix):] 401 | break 402 | } 403 | } 404 | if err := Format(linVol.FilesystemPath, filesystem); err != nil { 405 | return nil, err 406 | } 407 | } 408 | 409 | // Create mount point using label (if not exists) 410 | mp := driver.labelToMountPoint(linVol.Label) 411 | if _, err := os.Stat(mp); os.IsNotExist(err) { 412 | log.Infof("Creating mountpoint directory: %s", mp) 413 | if err = os.MkdirAll(mp, 0o755); err != nil { 414 | return nil, fmt.Errorf("Error creating mountpoint directory(%s): %s", mp, err) 415 | } 416 | } 417 | 418 | if err := Mount(linVol.FilesystemPath, mp); err != nil { 419 | return nil, fmt.Errorf("Error mounting volume(%s) to directory(%s): %s", linVol.FilesystemPath, mp, err) 420 | } 421 | 422 | log.Infof("Mount Call End: %s", req.Name) 423 | return &volume.MountResponse{Mountpoint: mp}, nil 424 | } 425 | 426 | // Path implementation 427 | func (driver *linodeVolumeDriver) Path(req *volume.PathRequest) (*volume.PathResponse, error) { 428 | log.Infof("Path(%s)", req.Name) 429 | 430 | linVol, err := driver.findVolumeByLabel(req.Name) 431 | if err != nil { 432 | return nil, err 433 | } 434 | 435 | mp := driver.labelToMountPoint(linVol.Label) 436 | log.Infof("Path(): %s", mp) 437 | return &volume.PathResponse{Mountpoint: mp}, nil 438 | } 439 | 440 | // Unmount implementation 441 | func (driver *linodeVolumeDriver) Unmount(req *volume.UnmountRequest) error { 442 | api, err := driver.linodeAPI() 443 | if err != nil { 444 | return err 445 | } 446 | 447 | log.Infof("Unmount(%s)", req.Name) 448 | 449 | linVol, err := driver.findVolumeByLabel(req.Name) 450 | if err != nil { 451 | return err 452 | } 453 | 454 | if err := Umount(driver.labelToMountPoint(linVol.Label)); err != nil { 455 | return fmt.Errorf("Unable to Unmount(%s): %s", req.Name, err) 456 | } 457 | 458 | log.Infof("Unmount(): %s", req.Name) 459 | 460 | // The volume is detached from the Linode at unmount 461 | // to allow remote Linodes to infer whether a volume is 462 | // mounted 463 | if err := detachAndWait(api, linVol.ID); err != nil { 464 | return err 465 | } 466 | 467 | return nil 468 | } 469 | 470 | // Capabilities implementation 471 | func (driver *linodeVolumeDriver) Capabilities() *volume.CapabilitiesResponse { 472 | log.Infof("Capabilities(): Scope: global") 473 | return &volume.CapabilitiesResponse{Capabilities: volume.Capability{Scope: "global"}} 474 | } 475 | 476 | // labelToMountPoint gets the mount-point for a volume 477 | func (driver *linodeVolumeDriver) labelToMountPoint(volumeLabel string) string { 478 | return path.Join(driver.mountRoot, volumeLabel) 479 | } 480 | 481 | // findVolumeByLabel looks up linode volume by label 482 | func (driver *linodeVolumeDriver) findVolumeByLabel(volumeLabel string) (*linodego.Volume, error) { 483 | var jsonFilter []byte 484 | var err error 485 | var linVols []linodego.Volume 486 | 487 | api, err := driver.linodeAPI() 488 | if err != nil { 489 | return nil, err 490 | } 491 | 492 | if jsonFilter, err = json.Marshal(map[string]string{"label": volumeLabel, "region": driver.region}); err != nil { 493 | return nil, err 494 | } 495 | 496 | listOpts := linodego.NewListOptions(0, string(jsonFilter)) 497 | if linVols, err = api.ListVolumes(context.Background(), listOpts); err != nil { 498 | return nil, err 499 | } 500 | 501 | if len(linVols) != 1 { 502 | return nil, fmt.Errorf("Instance %d Volume with name %s not found", driver.instanceID, volumeLabel) 503 | } 504 | 505 | return &linVols[0], nil 506 | } 507 | 508 | func detachAndWait(api *linodego.Client, volumeID int) error { 509 | // Send detach request 510 | if err := api.DetachVolume(context.Background(), volumeID); err != nil { 511 | return fmt.Errorf("Error detaching volumeID(%d): %s", volumeID, err) 512 | } 513 | 514 | // Wait for linode to have the volume detached 515 | if err := waitForLinodeVolumeDetachment(*api, volumeID, 180); err != nil { 516 | return fmt.Errorf("Error waiting for detachment of volumeID(%d): %s", volumeID, err) 517 | } 518 | return nil 519 | } 520 | 521 | func attachAndWait(api *linodego.Client, volumeID int, linodeID int) error { 522 | // attach 523 | attachOpts := linodego.VolumeAttachOptions{LinodeID: linodeID} 524 | if _, err := api.AttachVolume(context.Background(), volumeID, &attachOpts); err != nil { 525 | return fmt.Errorf("Error attaching volume(%d) to linode(%d): %s", volumeID, linodeID, err) 526 | } 527 | 528 | if _, err := api.WaitForVolumeLinodeID(context.Background(), volumeID, &linodeID, 300); err != nil { 529 | return fmt.Errorf("Error waiting for attachment of volume(%d) to linode(%d): %s", volumeID, linodeID, err) 530 | } 531 | return nil 532 | } 533 | 534 | // ensureVolumeAttached attempts to attach a volume to the current Linode instance 535 | func (driver *linodeVolumeDriver) ensureVolumeAttached(volumeID int) error { 536 | // TODO: validate whether a volume is in use in a local container 537 | 538 | api, err := driver.linodeAPI() 539 | if err != nil { 540 | return err 541 | } 542 | 543 | // Wait for detachment if already detaching 544 | if err := waitForVolumeNotBusy(api, volumeID); err != nil { 545 | return err 546 | } 547 | 548 | // Fetch volume 549 | vol, err := api.GetVolume(context.Background(), volumeID) 550 | if err != nil { 551 | return err 552 | } 553 | 554 | // If volume is already attached, do nothing 555 | if vol.LinodeID != nil && *vol.LinodeID == driver.instanceID { 556 | return nil 557 | } 558 | 559 | // Forcibly attach the volume if forceAttach is enabled 560 | if forceAttach && vol.LinodeID != nil && *vol.LinodeID != driver.instanceID { 561 | if err := detachAndWait(api, volumeID); err != nil { 562 | return err 563 | } 564 | 565 | return attachAndWait(api, volumeID, driver.instanceID) 566 | } 567 | 568 | // Throw an error if the instance is not in an attachable state 569 | if vol.LinodeID != nil && *vol.LinodeID != driver.instanceID { 570 | return fmt.Errorf("failed to attach volume: volume is currently attached to linode %d", *vol.LinodeID) 571 | } 572 | 573 | return attachAndWait(api, volumeID, driver.instanceID) 574 | } 575 | 576 | // waitForVolumeNotBusy checks whether a volume is currently busy. 577 | func waitForVolumeNotBusy(api *linodego.Client, volumeID int) error { 578 | vol, err := api.GetVolume(context.Background(), volumeID) 579 | if err != nil { 580 | return err 581 | } 582 | 583 | if vol.LinodeID == nil { 584 | return nil 585 | } 586 | 587 | filter := linodego.Filter{} 588 | 589 | filter.AddField(linodego.Eq, "entity.id", volumeID) 590 | filter.AddField(linodego.Eq, "entity.type", "volume") 591 | filter.OrderBy = "created" 592 | filter.Order = "desc" 593 | 594 | detachFilterStr, err := filter.MarshalJSON() 595 | if err != nil { 596 | return err 597 | } 598 | 599 | events, err := api.ListEvents(context.Background(), 600 | &linodego.ListOptions{Filter: string(detachFilterStr)}) 601 | if err != nil { 602 | return err 603 | } 604 | 605 | for _, event := range events { 606 | if event.Status != "started" { 607 | continue 608 | } 609 | 610 | if err := waitForEventFinished(api, event.ID); err != nil { 611 | return err 612 | } 613 | } 614 | 615 | return nil 616 | } 617 | 618 | func waitForEventFinished(api *linodego.Client, eventID int) error { 619 | ctx, cancel := context.WithTimeout(context.Background(), time.Duration(60)*time.Second) 620 | defer cancel() 621 | 622 | ticker := time.NewTicker(2000 * time.Millisecond) 623 | defer ticker.Stop() 624 | for { 625 | select { 626 | case <-ticker.C: 627 | event, err := api.GetEvent(ctx, eventID) 628 | if err != nil { 629 | return err 630 | } 631 | 632 | if event.Status == "finished" || event.Status == "failed" { 633 | return nil 634 | } 635 | 636 | case <-ctx.Done(): 637 | return fmt.Errorf("error waiting for event(%d) completion: %v", eventID, ctx.Err()) 638 | } 639 | } 640 | } 641 | -------------------------------------------------------------------------------- /fs_utils_linux.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os/exec" 5 | "strings" 6 | 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // Format calls mke2fs on path 11 | func Format(path string, formatFSType string) error { 12 | cmd := exec.Command("mkfs", "-t", formatFSType, path) 13 | stdOutAndErr, err := cmd.CombinedOutput() 14 | log.Debugf("Mke2fs Output:\n%s", stdOutAndErr) 15 | return err 16 | } 17 | 18 | // Mount mounts device to mountpoint 19 | func Mount(device string, mountpoint string) error { 20 | log.Debugf("calling mount %s %s", device, mountpoint) 21 | cmd := exec.Command("mount", device, mountpoint) 22 | output, err := cmd.CombinedOutput() 23 | log.Debugf("Mount Output:\n%s", string(output)) 24 | return err 25 | } 26 | 27 | // Umount calls umount command 28 | func Umount(mountpoint string) error { 29 | cmd := exec.Command("umount", mountpoint) 30 | output, err := cmd.CombinedOutput() 31 | log.Debugf("Umount Output:\n%s", string(output)) 32 | return err 33 | } 34 | 35 | // GetFSType returns the filesystem type from a block device 36 | // function based on https://github.com/yholkamp/ovh-docker-volume-plugin/blob/master/utils.go 37 | func GetFSType(device string) string { 38 | log.Infof("GetFSType(%s)", device) 39 | fsType := "" 40 | out, err := exec.Command("blkid", device).CombinedOutput() 41 | if err != nil { 42 | return fsType 43 | } 44 | 45 | if strings.Contains(string(out), "TYPE=") { 46 | for _, v := range strings.Split(string(out), " ") { 47 | if strings.Contains(v, "TYPE=") { 48 | fsType = strings.Split(v, "=")[1] 49 | fsType = strings.ReplaceAll(fsType, "\"", "") 50 | } 51 | } 52 | } 53 | 54 | log.Infof("GetFSType(): %s", fsType) 55 | return fsType 56 | } 57 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/linode/docker-volume-linode 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.7 6 | 7 | require ( 8 | github.com/docker/go-plugins-helpers v0.0.0-20211224144127-6eecb7beb651 9 | github.com/linode/go-metadata v0.2.2 10 | github.com/linode/linodego v1.52.1 11 | github.com/sirupsen/logrus v1.9.3 12 | ) 13 | 14 | require ( 15 | github.com/Microsoft/go-winio v0.6.1 // indirect 16 | github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect 17 | github.com/docker/go-connections v0.5.0 // indirect 18 | github.com/go-resty/resty/v2 v2.16.5 // indirect 19 | github.com/google/go-querystring v1.1.0 // indirect 20 | golang.org/x/mod v0.17.0 // indirect 21 | golang.org/x/net v0.40.0 // indirect 22 | golang.org/x/sync v0.14.0 // indirect 23 | golang.org/x/sys v0.33.0 // indirect 24 | golang.org/x/text v0.25.0 // indirect 25 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect 26 | gopkg.in/ini.v1 v1.66.6 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /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/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= 4 | github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= 9 | github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= 10 | github.com/docker/go-plugins-helpers v0.0.0-20211224144127-6eecb7beb651 h1:YcvzLmdrP/b8kLAGJ8GT7bdncgCAiWxJZIlt84D+RJg= 11 | github.com/docker/go-plugins-helpers v0.0.0-20211224144127-6eecb7beb651/go.mod h1:LFyLie6XcDbyKGeVK6bHe+9aJTYCxWLBg5IrJZOaXKA= 12 | github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= 13 | github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= 14 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 15 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 16 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 17 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 18 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 19 | github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k= 20 | github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= 21 | github.com/linode/go-metadata v0.2.2 h1:UbgM0tC5lnIpF1GrWieuZLs47t+Tnt8js1QScLO7FUM= 22 | github.com/linode/go-metadata v0.2.2/go.mod h1:yJUJMW1qfji7pzKdzhpBcYyxqcFRrUUKDxBOSVv2OhE= 23 | github.com/linode/linodego v1.52.1 h1:HJ1cz1n9n3chRP9UrtqmP91+xTi0Q5l+H/4z4tpkwgQ= 24 | github.com/linode/linodego v1.52.1/go.mod h1:zEN2sX+cSdp67EuRY1HJiyuLujoa7HqvVwNEcJv3iXw= 25 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 26 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 27 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 28 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 29 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 30 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 31 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 32 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 33 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 34 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 35 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 36 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 37 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 38 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 39 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 40 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 41 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 43 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 44 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 45 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 46 | golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= 47 | golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 48 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 49 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 50 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 51 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 52 | gopkg.in/ini.v1 v1.66.6 h1:LATuAqN/shcYAOkv3wl2L4rkaKqkcgTBQjOyYDvcPKI= 53 | gopkg.in/ini.v1 v1.66.6/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 54 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 55 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 56 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 57 | -------------------------------------------------------------------------------- /integration-test/harden.yaml.j2: -------------------------------------------------------------------------------- 1 | #cloud-config 2 | hostname: dx-dev-vm 3 | 4 | package_update: true 5 | package_upgrade: true 6 | packages: 7 | - fail2ban 8 | 9 | ssh_pwauth: false 10 | disable_root: true 11 | 12 | users: 13 | - default 14 | - name: linodedx 15 | groups: docker 16 | gecos: The primary account for development on this VM. 17 | shell: /bin/bash 18 | sudo: ALL=(ALL) NOPASSWD:ALL 19 | lock_passwd: true 20 | ssh_authorized_keys: 21 | - '{{ ssh_pubkey }}' 22 | 23 | write_files: 24 | # Root login over SSH isn't fully disabled by disable_root 25 | - path: /etc/ssh/sshd_config.d/51-disable-root.conf 26 | permissions: "0600" 27 | content: | 28 | PermitRootLogin no 29 | 30 | runcmd: 31 | - service ssh restart 32 | - service fail2ban start --enable 33 | 34 | - ufw default deny incoming 35 | - ufw default allow outgoing 36 | - ufw allow 80,443,21,22/tcp 37 | - ufw enable -------------------------------------------------------------------------------- /integration-test/test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Deploy Test Linode 3 | hosts: localhost 4 | vars: 5 | ssh_pubkey_path: ~/.ssh/id_rsa.pub 6 | label: docker-volume-test 7 | type: g6-nanode-1 8 | region: us-mia 9 | temp_token_name: docker-volume-linode-dev 10 | token_duration_seconds: 3600 11 | tasks: 12 | - name: Ensure the previous token has been removed 13 | no_log: true 14 | linode.cloud.token: 15 | label: "{{ temp_token_name }}" 16 | state: absent 17 | 18 | - set_fact: 19 | ssh_pubkey: '{{ lookup("file", ssh_pubkey_path) }}' 20 | 21 | - name: Create a temporary token for the plugin to consume 22 | no_log: true 23 | linode.cloud.token: 24 | label: "{{ temp_token_name }}" 25 | scopes: "events:read_write linodes:read_write volumes:read_write" 26 | 27 | # This token should expire in an hour by default 28 | expiry: "{{ '%Y-%m-%dT%H:%M:%S' | strftime((ansible_date_time.epoch | int + token_duration_seconds), utc=True) }}" 29 | 30 | state: present 31 | register: temp_token 32 | 33 | - name: Ensure the test instance is created 34 | linode.cloud.instance: 35 | label: "{{ label }}" 36 | type: "{{ type }}" 37 | region: "{{ region }}" 38 | image: linode/ubuntu24.04 39 | booted: true 40 | metadata: 41 | user_data: '{{ lookup("template", playbook_dir ~ "/harden.yaml.j2") }}' 42 | state: present 43 | register: create_inst 44 | 45 | - name: Wait for SSH to be ready 46 | wait_for: host="{{ create_inst.instance.ipv4[0] }}" port=22 delay=1 timeout=300 47 | 48 | - name: Append host to the in-memory inventory 49 | no_log: true 50 | add_host: 51 | hostname: "test-runner" 52 | ansible_host: "{{ create_inst.instance.ipv4[0] }}" 53 | groups: test_runner 54 | ansible_user: linodedx 55 | ansible_ssh_retries: 50 56 | temp_token: "{{ temp_token.token.token }}" 57 | 58 | - name: Configure the test instance 59 | hosts: test_runner 60 | remote_user: linodedx 61 | gather_facts: no 62 | vars: 63 | skip_tests: 0 64 | dest_dir: /home/linodedx/docker-volume-linode 65 | tasks: 66 | - name: Wait for cloud-init to finish initialization 67 | command: cloud-init status --format json 68 | retries: 30 69 | delay: 5 70 | register: cloud_init_status 71 | until: cloud_init_status.rc == 0 and (cloud_init_status.stdout | from_json)["status"] == "done" 72 | 73 | - name: Update repositories and install necessary packages 74 | become: yes 75 | ansible.builtin.apt: 76 | name: 77 | - docker.io 78 | - python3-pip 79 | - rsync 80 | - make 81 | update_cache: true 82 | 83 | - name: Start and enable the Docker service 84 | service: 85 | name: docker 86 | state: started 87 | enabled: yes 88 | 89 | - name: Remove any existing project files 90 | file: 91 | path: "{{ dest_dir }}" 92 | state: absent 93 | 94 | - name: Copy the local project to the remote 95 | synchronize: 96 | src: ../../ 97 | dest: "{{ dest_dir }}" 98 | rsync_opts: 99 | - "--exclude=.git" 100 | 101 | - name: Run the test suite 102 | no_log: true 103 | command: "make local-test" 104 | args: 105 | chdir: "{{ dest_dir }}" 106 | when: 107 | - skip_tests != "1" 108 | environment: 109 | LINODE_TOKEN: "{{ temp_token }}" 110 | PLUGIN_VERSION: dev 111 | 112 | - name: Output the test instance IP 113 | debug: 114 | msg: "{{ ansible_host }}" 115 | 116 | - name: Clean up 117 | hosts: localhost 118 | gather_facts: no 119 | vars: 120 | temp_token_name: docker-volume-linode-dev 121 | 122 | tasks: 123 | - name: Remove the temp token 124 | linode.cloud.token: 125 | label: "{{ temp_token_name }}" 126 | state: absent 127 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | "os/user" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/docker/go-plugins-helpers/volume" 11 | log "github.com/sirupsen/logrus" 12 | ) 13 | 14 | // VERSION set by --ldflags "-X main.VERSION=$VERSION" 15 | var VERSION string 16 | 17 | var ( 18 | forceAttach = cfgBool("force-attach", false, "If true, volumes will be forcibly attached to the current Linode if already attached to another Linode.") 19 | mountRoot = cfgString("mount-root", "/mnt", "The location to mount volumes to.") 20 | socketUser = cfgString("socket-user", "root", "Sets the user to create the socket with.") 21 | logLevel = cfgString("log-level", "info", "Sets log level: debug,info,warn,error") 22 | linodeToken = cfgString("linode-token", "", "Required Personal Access Token generated in Linode Console.") 23 | linodeLabel = cfgString("linode-label", "", "Sets the Linode Instance Label (defaults to the OS HOSTNAME)") 24 | ) 25 | 26 | func main() { 27 | flag.Parse() 28 | 29 | log.SetOutput(os.Stdout) 30 | level, err := log.ParseLevel(*logLevel) 31 | if err != nil { 32 | level = log.InfoLevel 33 | } 34 | log.SetLevel(level) 35 | 36 | log.Infof("docker-volume-linode/%s", VERSION) 37 | 38 | // check required parameters (token and label) 39 | if *linodeToken == "" { 40 | log.Fatal("linode-token is required.") 41 | } 42 | 43 | log.Debugf("linode-token: %s", *linodeToken) 44 | log.Debugf("linode-label: %s", *linodeLabel) 45 | 46 | driver := newLinodeVolumeDriver(*linodeLabel, *linodeToken, *mountRoot) 47 | handler := volume.NewHandler(&driver) 48 | log.Debug("connecting to socket ", *socketUser) 49 | u, _ := user.Lookup(*socketUser) 50 | gid, _ := strconv.Atoi(u.Gid) 51 | log.Println(handler.ServeUnix("linode", gid)) 52 | //if serr != nil { 53 | // log.Errorf("failed to bind to the Unix socket: %v", serr) 54 | // os.Exit(1) 55 | //} 56 | } 57 | 58 | func cfgString(name string, def string, desc string) *string { 59 | newDef := def 60 | if val, found := getEnv(name); found { 61 | newDef = val 62 | } 63 | return flag.String(name, newDef, desc) 64 | } 65 | 66 | func cfgBool(name string, def bool, desc string) bool { 67 | val, found := getEnv(name) 68 | if !found { 69 | return false 70 | } 71 | 72 | valNormalized := strings.ToLower(val) 73 | return valNormalized == "true" || valNormalized == "1" 74 | } 75 | 76 | func getEnv(name string) (string, bool) { 77 | if val, found := os.LookupEnv(name); found { 78 | return val, true 79 | } 80 | 81 | name = strings.ToUpper(name) 82 | name = strings.ReplaceAll(name, "-", "_") 83 | 84 | if val, found := os.LookupEnv(name); found { 85 | return val, true 86 | } 87 | 88 | return "", false 89 | } 90 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "math" 7 | "os" 8 | "time" 9 | 10 | "github.com/docker/go-plugins-helpers/volume" 11 | "github.com/linode/linodego" 12 | log "github.com/sirupsen/logrus" 13 | ) 14 | 15 | // waitForDeviceFileExists waits until path devicePath becomes available or 16 | // times out. 17 | func waitForDeviceFileExists(devicePath string, waitSeconds int) error { 18 | return waitForCondition(waitSeconds, 1, func() bool { 19 | // found, then break 20 | if _, err := os.Stat(devicePath); !os.IsNotExist(err) { 21 | return true // condition met 22 | } 23 | log.Infof("Waiting for device %s to be available", devicePath) 24 | return false 25 | }) 26 | } 27 | 28 | func waitForLinodeVolumeDetachment(linodeAPI linodego.Client, volumeID, timeout int) error { 29 | // Wait for linode to have the volume detached 30 | return waitForCondition(timeout, 2, func() bool { 31 | v, err := linodeAPI.GetVolume(context.Background(), volumeID) 32 | if err != nil { 33 | log.Error(err) 34 | return false 35 | } 36 | 37 | return v.LinodeID == nil 38 | }) 39 | } 40 | 41 | // waitForCondition Waits until condition returns true timeout is reached. If timeout is 42 | // reached it returns error. 43 | func waitForCondition(waitSeconds int, intervalSeconds int, check func() bool) error { 44 | loops := int(math.Ceil(float64(waitSeconds) / float64(intervalSeconds))) 45 | for i := 0; i < loops; i++ { 46 | if check() { 47 | return nil 48 | } 49 | time.Sleep(time.Second * time.Duration(intervalSeconds)) 50 | } 51 | return errors.New("waitForCondition timeout") 52 | } 53 | 54 | // linodeVolumeToDockerVolume converts a linode volume to a docker volume 55 | func linodeVolumeToDockerVolume(lv linodego.Volume, mp string) *volume.Volume { 56 | v := &volume.Volume{ 57 | Name: lv.Label, 58 | Mountpoint: mp, 59 | CreatedAt: lv.Created.Format(time.RFC3339), 60 | Status: make(map[string]interface{}), 61 | } 62 | return v 63 | } 64 | --------------------------------------------------------------------------------