├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── config.json ├── index.js ├── npm-shrinkwrap.json ├── package.json └── payload.json /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8-alpine 2 | RUN apk update && apk add docker 3 | RUN mkdir -p /usr/src/app 4 | COPY index.js /usr/src/app 5 | COPY config.json /usr/src/app 6 | COPY package.json /usr/src/app 7 | COPY npm-shrinkwrap.json /usr/src/app 8 | WORKDIR /usr/src/app 9 | RUN npm install 10 | EXPOSE 80 11 | CMD [ "npm", "start" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Iain Collins 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Docker Deploy Webhook 2 | 3 | A web service for automated deployment of releases from Docker Hub to a Docker Swarm, triggered by a Docker Hub webhook (which can in turn be triggred by pushing to GitHub). 4 | 5 | screen shot 2018-02-02 at 18 55 18 6 | 7 | Flow for automated deployment: 8 | 9 | * Configure Docker Hub to build an image when a GitHub repository is updated. 10 | * Configure Docker Hub to call this service via webhook when a new image is available. 11 | * Configure and deploy this service to your Docker Swarm cluster. 12 | * When a new image is built, it will update the Docker Service in the Swarm. 13 | 14 | This webhook is intended for use with private Docker Hub repositories and self hosted Docker Swarm instances. 15 | 16 | To get started, clone this repository, add an image of it to your Docker Hub account, configure `config.json` and deploy it to your Docker Swarm as a service (see steps below). 17 | 18 | [Read more about this service in this blog post.](https://medium.com/@iaincollins/docker-swarm-automated-deployment-cb477767dfcf) 19 | 20 | ## Configuration 21 | 22 | Supported environment variables: 23 | 24 | PORT="8080" // Port to run on 25 | CONFIG="production" // Which part of the config.json file to load 26 | TOKEN="123-456-ABC-DEF" // A token used to restrict access to the webhook 27 | USERNAME="docker-hub-username" // A Docker Hub account username 28 | PASSWORD="docker-hub-password" // A Docker Hub account password 29 | 30 | The `config.json` file defines each environment: 31 | 32 | { 33 | "production": {}, 34 | "development": {} 35 | } 36 | 37 | Inside each environment config is the name of an image and tag to listen for, and the service that should be updated to run it: 38 | 39 | { 40 | "production": { 41 | "my-org/my-repo:latest": { 42 | "service": "my-docker-service" 43 | } 44 | }, 45 | "development": { 46 | "my-org/my-repo:development": { 47 | "service": "my-docker-service" 48 | } 49 | } 50 | } 51 | 52 | You can use the `CONFIG` environment variable to tell `docker-deploy-webhook` which section to use when it loads - this is useful if you have multiple Docker Swarm instances - e.g. production, development. 53 | 54 | You use the same callback URL for all services, when `docker-deploy-webhook` receives an update for an image and tag is it is configured for it will push that release to the service associated with it in `config.json`. 55 | 56 | ## Deploy to Docker Swarm 57 | 58 | swarm-manager000000> docker login 59 | swarm-manager000000> docker service create \ 60 | --name docker-deploy-webhook \ 61 | --with-registry-auth \ 62 | --constraint "node.role==manager" \ 63 | --publish=8080:8080 \ 64 | --mount type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock \ 65 | -e PORT="8080" \ 66 | -e CONFIG="production" \ 67 | -e TOKEN="123456ABCDEF" \ 68 | -e USERNAME="docker-hub-username" \ 69 | -e PASSWORD="docker-hub-password" \ 70 | your-org-name/decoders-deploy-webhook:latest 71 | 72 | Note: This example exposes the service directly on port 8080. 73 | 74 | ## Configure Docker Hub to use Webhook 75 | 76 | Use the "**Create automated build**" option in Docker Hub to automatically build an image in Docker Hub when changes are pushed to a GitHub repository, then add a webhook to the Docker Hub image repository. 77 | 78 | The URL to specify for the webhook in Docker Hub will be `${your-server}/webhook/${your-token}`. 79 | 80 | e.g. https://example.com/webhook/123456ABCDEF 81 | 82 | You can configure multiple webhooks for a Docker Hub repository (e.g. one webhook on your production cluster, one on development, etc). 83 | 84 | While all webhooks will receive the callback, the specific image that has just been built (e.g. `:latest`, `:edge`, etc.) will only be deployed to an environment if the webhook service running on it has it whitelisted in the `config.json` block for that environment. 85 | 86 | ## Testing 87 | 88 | To test locally with the example payload: 89 | 90 | curl -v -H "Content-Type: application/json" --data @payload.json http://localhost:3000/webhook/123456ABCDEF 91 | 92 | To test in production with the example payload: 93 | 94 | curl -v -H "Content-Type: application/json" --data @payload.json https://example.com/webhook/123456ABCDEF 95 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "production": { 3 | "my-org/my-repo:latest": { 4 | "service": "my-docker-service" 5 | }, 6 | "my-org/my-other-repo:latest": { 7 | "service": "my-other-docker-service" 8 | } 9 | }, 10 | "development": { 11 | "my-org/my-repo:development": { 12 | "service": "my-docker-service" 13 | }, 14 | "my-org/my-other-repo:development": { 15 | "service": "my-other-docker-service" 16 | } 17 | 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A service for automated deployment from Docker Hub to Docker Swarm 3 | * https://docs.docker.com/docker-hub/webhooks/ 4 | */ 5 | process.env.PORT = process.env.PORT || 3000 6 | 7 | const express = require('express') 8 | const bodyParser = require('body-parser') 9 | const child_process = require('child_process') 10 | const app = express() 11 | const Package = require('./package.json') 12 | const services = require(`./config.json`)[process.env.CONFIG || 'production'] 13 | 14 | if (!process.env.TOKEN || !process.env.USERNAME || !process.env.PASSWORD) 15 | return console.error("Error: You must set a TOKEN, USERNAME and PASSWORD as environment variables.") 16 | 17 | const dockerCommand = process.env.DOCKER || '/usr/bin/docker' 18 | const token = process.env.TOKEN || '' 19 | const username = process.env.USERNAME || '' 20 | const password = process.env.PASSWORD || '' 21 | const registry = process.env.REGISTRY || '' 22 | 23 | app.use(bodyParser.json()) 24 | app.use(bodyParser.urlencoded({ extended: true })) 25 | 26 | app.post('/webhook/:token', (req, res) => { 27 | if (!req.params.token || req.params.token != token) { 28 | console.log("Webhook called with invalid or missing token.") 29 | return res.status(401).send('Access Denied: Token Invalid\n').end() 30 | } 31 | 32 | // Send response back right away if token was valid 33 | res.send('OK') 34 | 35 | const payload = req.body 36 | const image = `${payload.repository.repo_name}:${payload.push_data.tag}` 37 | 38 | if (!services[image]) return console.log(`Received updated for "${image}" but not configured to handle updates for this image.`) 39 | 40 | const service = services[image].service 41 | 42 | // Make sure we are logged in to be able to pull the image 43 | child_process.exec(`${dockerCommand} login -u "${username}" -p "${password}" ${registry}`, 44 | (error, stdout, stderr) => { 45 | if (error) return console.error(error) 46 | 47 | // Deploy the image and force a restart of the associated service 48 | console.log(`Deploying ${image} to ${service}…`) 49 | child_process.exec(`${dockerCommand} service update ${service} --force --with-registry-auth --image=${image}`, 50 | (error, stdout, stderr) => { 51 | if (error) { 52 | console.error(`Failed to deploy ${image} to ${service}!`) 53 | return console.error(error) 54 | } 55 | console.log(`Deployed ${image} to ${service} successfully and restarted the service.`) 56 | }) 57 | }) 58 | }) 59 | 60 | app.all('*', (req, res) => { 61 | res.send('') 62 | }) 63 | 64 | app.listen(process.env.PORT, err => { 65 | if (err) throw err 66 | console.log(`Listening for webhooks on http://localhost:${process.env.PORT}/webhook/${token}`) 67 | }) 68 | -------------------------------------------------------------------------------- /npm-shrinkwrap.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docker-deploy-webhook", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "accepts": { 6 | "version": "1.3.4", 7 | "from": "accepts@>=1.3.4 <1.4.0", 8 | "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.4.tgz" 9 | }, 10 | "array-flatten": { 11 | "version": "1.1.1", 12 | "from": "array-flatten@1.1.1", 13 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz" 14 | }, 15 | "body-parser": { 16 | "version": "1.18.2", 17 | "from": "body-parser@latest", 18 | "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.2.tgz" 19 | }, 20 | "bytes": { 21 | "version": "3.0.0", 22 | "from": "bytes@3.0.0", 23 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz" 24 | }, 25 | "content-disposition": { 26 | "version": "0.5.2", 27 | "from": "content-disposition@0.5.2", 28 | "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz" 29 | }, 30 | "content-type": { 31 | "version": "1.0.4", 32 | "from": "content-type@>=1.0.4 <1.1.0", 33 | "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz" 34 | }, 35 | "cookie": { 36 | "version": "0.3.1", 37 | "from": "cookie@0.3.1", 38 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz" 39 | }, 40 | "cookie-signature": { 41 | "version": "1.0.6", 42 | "from": "cookie-signature@1.0.6", 43 | "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz" 44 | }, 45 | "debug": { 46 | "version": "2.6.9", 47 | "from": "debug@2.6.9", 48 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" 49 | }, 50 | "depd": { 51 | "version": "1.1.2", 52 | "from": "depd@>=1.1.1 <1.2.0", 53 | "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz" 54 | }, 55 | "destroy": { 56 | "version": "1.0.4", 57 | "from": "destroy@>=1.0.4 <1.1.0", 58 | "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz" 59 | }, 60 | "ee-first": { 61 | "version": "1.1.1", 62 | "from": "ee-first@1.1.1", 63 | "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz" 64 | }, 65 | "encodeurl": { 66 | "version": "1.0.2", 67 | "from": "encodeurl@>=1.0.1 <1.1.0", 68 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz" 69 | }, 70 | "escape-html": { 71 | "version": "1.0.3", 72 | "from": "escape-html@>=1.0.3 <1.1.0", 73 | "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz" 74 | }, 75 | "etag": { 76 | "version": "1.8.1", 77 | "from": "etag@>=1.8.1 <1.9.0", 78 | "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz" 79 | }, 80 | "express": { 81 | "version": "4.16.2", 82 | "from": "express@latest", 83 | "resolved": "https://registry.npmjs.org/express/-/express-4.16.2.tgz", 84 | "dependencies": { 85 | "setprototypeof": { 86 | "version": "1.1.0", 87 | "from": "setprototypeof@1.1.0", 88 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz" 89 | }, 90 | "statuses": { 91 | "version": "1.3.1", 92 | "from": "statuses@>=1.3.1 <1.4.0", 93 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz" 94 | } 95 | } 96 | }, 97 | "finalhandler": { 98 | "version": "1.1.0", 99 | "from": "finalhandler@1.1.0", 100 | "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.0.tgz", 101 | "dependencies": { 102 | "statuses": { 103 | "version": "1.3.1", 104 | "from": "statuses@>=1.3.1 <1.4.0", 105 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz" 106 | } 107 | } 108 | }, 109 | "forwarded": { 110 | "version": "0.1.2", 111 | "from": "forwarded@>=0.1.2 <0.2.0", 112 | "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz" 113 | }, 114 | "fresh": { 115 | "version": "0.5.2", 116 | "from": "fresh@0.5.2", 117 | "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz" 118 | }, 119 | "http-errors": { 120 | "version": "1.6.2", 121 | "from": "http-errors@>=1.6.2 <1.7.0", 122 | "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.2.tgz", 123 | "dependencies": { 124 | "depd": { 125 | "version": "1.1.1", 126 | "from": "depd@1.1.1", 127 | "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.1.tgz" 128 | } 129 | } 130 | }, 131 | "iconv-lite": { 132 | "version": "0.4.19", 133 | "from": "iconv-lite@0.4.19", 134 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz" 135 | }, 136 | "inherits": { 137 | "version": "2.0.3", 138 | "from": "inherits@2.0.3", 139 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz" 140 | }, 141 | "ipaddr.js": { 142 | "version": "1.5.2", 143 | "from": "ipaddr.js@1.5.2", 144 | "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.5.2.tgz" 145 | }, 146 | "media-typer": { 147 | "version": "0.3.0", 148 | "from": "media-typer@0.3.0", 149 | "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz" 150 | }, 151 | "merge-descriptors": { 152 | "version": "1.0.1", 153 | "from": "merge-descriptors@1.0.1", 154 | "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz" 155 | }, 156 | "methods": { 157 | "version": "1.1.2", 158 | "from": "methods@>=1.1.2 <1.2.0", 159 | "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz" 160 | }, 161 | "mime": { 162 | "version": "1.4.1", 163 | "from": "mime@1.4.1", 164 | "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz" 165 | }, 166 | "mime-db": { 167 | "version": "1.30.0", 168 | "from": "mime-db@>=1.30.0 <1.31.0", 169 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.30.0.tgz" 170 | }, 171 | "mime-types": { 172 | "version": "2.1.17", 173 | "from": "mime-types@>=2.1.15 <2.2.0", 174 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.17.tgz" 175 | }, 176 | "ms": { 177 | "version": "2.0.0", 178 | "from": "ms@2.0.0", 179 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" 180 | }, 181 | "negotiator": { 182 | "version": "0.6.1", 183 | "from": "negotiator@0.6.1", 184 | "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz" 185 | }, 186 | "on-finished": { 187 | "version": "2.3.0", 188 | "from": "on-finished@>=2.3.0 <2.4.0", 189 | "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz" 190 | }, 191 | "parseurl": { 192 | "version": "1.3.2", 193 | "from": "parseurl@>=1.3.2 <1.4.0", 194 | "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.2.tgz" 195 | }, 196 | "path-to-regexp": { 197 | "version": "0.1.7", 198 | "from": "path-to-regexp@0.1.7", 199 | "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz" 200 | }, 201 | "proxy-addr": { 202 | "version": "2.0.2", 203 | "from": "proxy-addr@>=2.0.2 <2.1.0", 204 | "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.2.tgz" 205 | }, 206 | "qs": { 207 | "version": "6.5.1", 208 | "from": "qs@6.5.1", 209 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz" 210 | }, 211 | "range-parser": { 212 | "version": "1.2.0", 213 | "from": "range-parser@>=1.2.0 <1.3.0", 214 | "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz" 215 | }, 216 | "raw-body": { 217 | "version": "2.3.2", 218 | "from": "raw-body@2.3.2", 219 | "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.2.tgz" 220 | }, 221 | "safe-buffer": { 222 | "version": "5.1.1", 223 | "from": "safe-buffer@5.1.1", 224 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz" 225 | }, 226 | "send": { 227 | "version": "0.16.1", 228 | "from": "send@0.16.1", 229 | "resolved": "https://registry.npmjs.org/send/-/send-0.16.1.tgz", 230 | "dependencies": { 231 | "statuses": { 232 | "version": "1.3.1", 233 | "from": "statuses@>=1.3.1 <1.4.0", 234 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz" 235 | } 236 | } 237 | }, 238 | "serve-static": { 239 | "version": "1.13.1", 240 | "from": "serve-static@1.13.1", 241 | "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.13.1.tgz" 242 | }, 243 | "setprototypeof": { 244 | "version": "1.0.3", 245 | "from": "setprototypeof@1.0.3", 246 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.3.tgz" 247 | }, 248 | "statuses": { 249 | "version": "1.4.0", 250 | "from": "statuses@>=1.3.1 <2.0.0", 251 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz" 252 | }, 253 | "type-is": { 254 | "version": "1.6.15", 255 | "from": "type-is@>=1.6.15 <1.7.0", 256 | "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.15.tgz" 257 | }, 258 | "unpipe": { 259 | "version": "1.0.0", 260 | "from": "unpipe@1.0.0", 261 | "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" 262 | }, 263 | "utils-merge": { 264 | "version": "1.0.1", 265 | "from": "utils-merge@1.0.1", 266 | "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz" 267 | }, 268 | "vary": { 269 | "version": "1.1.2", 270 | "from": "vary@>=1.1.2 <1.2.0", 271 | "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz" 272 | } 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docker-deploy-webhook", 3 | "version": "1.0.0", 4 | "description": "Automated deployment of releases from Docker Hub to a Docker Swarm", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node index.js" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "body-parser": "^1.18.2", 14 | "express": "^4.16.2" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /payload.json: -------------------------------------------------------------------------------- 1 | { 2 | "callback_url": "https://registry.hub.docker.com/u/svendowideit/testhook/hook/2141b5bi5i5b02bec211i4eeih0242eg11000a/", 3 | "push_data": { 4 | "images": [ 5 | "27d47432a69bca5f2700e4dff7de0388ed65f9d3fb1ec645e2bc24c223dc1cc3", 6 | "51a9c7c1f8bb2fa19bcd09789a34e63f35abb80044bc10196e304f6634cc582c", 7 | "..." 8 | ], 9 | "pushed_at": 1.417566161e+09, 10 | "pusher": "trustedbuilder", 11 | "tag": "latest" 12 | }, 13 | "repository": { 14 | "comment_count": "0", 15 | "date_created": 1.417494799e+09, 16 | "description": "", 17 | "dockerfile": "#\n# BUILD\u0009\u0009docker build -t svendowideit/apt-cacher .\n# RUN\u0009\u0009docker run -d -p 3142:3142 -name apt-cacher-run apt-cacher\n#\n# and then you can run containers with:\n# \u0009\u0009docker run -t -i -rm -e http_proxy http://192.168.1.2:3142/ debian bash\n#\nFROM\u0009\u0009ubuntu\n\n\nVOLUME\u0009\u0009[\/var/cache/apt-cacher-ng]\nRUN\u0009\u0009apt-get update ; apt-get install -yq apt-cacher-ng\n\nEXPOSE \u0009\u00093142\nCMD\u0009\u0009chmod 777 /var/cache/apt-cacher-ng ; /etc/init.d/apt-cacher-ng start ; tail -f /var/log/apt-cacher-ng/*\n", 18 | "full_description": "Docker Hub based automated build from a GitHub repo", 19 | "is_official": false, 20 | "is_private": true, 21 | "is_trusted": true, 22 | "name": "testhook", 23 | "namespace": "svendowideit", 24 | "owner": "svendowideit", 25 | "repo_name": "svendowideit/testhook", 26 | "repo_url": "https://registry.hub.docker.com/u/svendowideit/testhook/", 27 | "star_count": 0, 28 | "status": "Active" 29 | } 30 | } --------------------------------------------------------------------------------