├── .github └── workflows │ └── docker-publish.yml ├── .gitignore ├── .golangci.yml ├── .license.yml ├── .woodpecker └── publish.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── client.go ├── cron.go ├── docker-compose.yml ├── go.mod ├── go.sum ├── main.go ├── swarm.go ├── swarm_test.go └── version.go /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: [ master ] 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Set up QEMU 24 | uses: docker/setup-qemu-action@v3 25 | 26 | - name: Set up Docker Buildx 27 | uses: docker/setup-buildx-action@v3 28 | 29 | - name: Log into registry ${{ env.REGISTRY }} 30 | if: github.event_name != 'pull_request' 31 | uses: docker/login-action@v3 32 | with: 33 | registry: ${{ env.REGISTRY }} 34 | username: ${{ github.actor }} 35 | password: ${{ secrets.GITHUB_TOKEN }} 36 | 37 | - name: Extract metadata (tags, labels) for Docker 38 | id: meta 39 | uses: docker/metadata-action@v5 40 | with: 41 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 42 | 43 | - name: Build and push Docker image 44 | uses: docker/build-push-action@v5 45 | with: 46 | push: ${{ github.event_name != 'pull_request' }} 47 | platforms: linux/amd64,linux/arm64 48 | tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest,${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:0.1.${{ github.run_number }}-dev 49 | labels: ${{ steps.meta.outputs.labels }} 50 | build-args: | 51 | BUILDKIT_CONTEXT_KEEP_GIT_DIR=true 52 | CI_COMMIT_TAG=${{ github.ref_name }} 53 | cache-from: type=gha 54 | cache-to: type=gha,mode=max 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | cover.out 4 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - gocritic 5 | - predeclared 6 | - revive 7 | - unconvert 8 | settings: 9 | gocritic: 10 | enabled-checks: 11 | - deferInLoop 12 | govet: 13 | disable: 14 | - fieldalignment 15 | enable-all: true 16 | revive: 17 | rules: 18 | - name: package-comments 19 | severity: warning 20 | disabled: true 21 | exclusions: 22 | rules: 23 | - text: 'shadow: declaration of "(err|ctx)" shadows declaration at' 24 | linters: 25 | - govet 26 | formatters: 27 | enable: 28 | - gofumpt 29 | - goimports 30 | -------------------------------------------------------------------------------- /.license.yml: -------------------------------------------------------------------------------- 1 | # Install license tool 2 | # go install github.com/palantir/go-license@v1.41.0 3 | # Verify licences 4 | # find -name "*.go" -not -name catalog.go -not -name "*_mock.go" -not -name "*.gen.go" -exec go-license --config .license.yml --verify {} \; 5 | # Add license to files 6 | # find -name "*.go" -not -name catalog.go -not -name "*_mock.go" -not -name "*.gen.go" -exec go-license --config .license.yml {} \; 7 | 8 | header: | 9 | /* 10 | Copyright {{YEAR}} codestation 11 | 12 | Licensed under the Apache License, Version 2.0 (the "License"); 13 | you may not use this file except in compliance with the License. 14 | You may obtain a copy of the License at 15 | 16 | http://www.apache.org/licenses/LICENSE-2.0 17 | 18 | Unless required by applicable law or agreed to in writing, software 19 | distributed under the License is distributed on an "AS IS" BASIS, 20 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 21 | See the License for the specific language governing permissions and 22 | limitations under the License. 23 | */ 24 | -------------------------------------------------------------------------------- /.woodpecker/publish.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | lint: 3 | image: golangci/golangci-lint:v2.1.2 4 | commands: 5 | - golangci-lint run -v --timeout 10m ./... 6 | environment: 7 | GOPROXY: 8 | from_secret: goproxy_url 9 | 10 | test: 11 | image: golang:1.24 12 | commands: 13 | - go test -coverprofile cover.out -v ./... 14 | - go tool cover -func cover.out 15 | environment: 16 | GOPROXY: 17 | from_secret: goproxy_url 18 | 19 | build: 20 | image: woodpeckerci/plugin-docker-buildx:5.2.2 21 | settings: 22 | cache: registry.megpoid.dev/codestation/swarm-updater:${CI_COMMIT_BRANCH}-cache 23 | repo: registry.megpoid.dev/codestation/swarm-updater 24 | tags: latest 25 | registry: registry.megpoid.dev 26 | config: 27 | from_secret: registry_credentials 28 | build_args: 29 | CI_COMMIT_TAG: "${CI_COMMIT_TAG}" 30 | GOPROXY: 31 | from_secret: goproxy_url 32 | 33 | when: 34 | event: 35 | - push 36 | - manual 37 | branch: 38 | - master 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine AS builder 2 | 3 | ARG CI_COMMIT_TAG 4 | ARG GOPROXY 5 | ENV GOPROXY=${GOPROXY} 6 | 7 | RUN apk add --no-cache git 8 | 9 | WORKDIR /src 10 | COPY go.mod go.sum /src/ 11 | RUN go mod download 12 | COPY . /src/ 13 | 14 | RUN set -ex; \ 15 | CGO_ENABLED=0 go build -o release/swarm-updater \ 16 | -trimpath \ 17 | -ldflags "-w -s \ 18 | -X main.Tag=${CI_COMMIT_TAG}" 19 | 20 | FROM alpine:3.21 21 | LABEL maintainer="codestation " 22 | 23 | RUN apk add --no-cache ca-certificates tzdata 24 | 25 | COPY --from=builder /src/release/swarm-updater /bin/swarm-updater 26 | 27 | ENTRYPOINT ["/bin/swarm-updater"] 28 | -------------------------------------------------------------------------------- /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 notices 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Swarm Updater 2 | 3 | Automatically update Docker services whenever their image is updated. Inspired 4 | on [v2tec/watchtower](https://github.com/v2tec/watchtower) 5 | 6 | ## Update services on demand 7 | 8 | The endpoint `/apis/swarm/v1/update` can be called with a list of images that should be updated on matching services on 9 | the swarm. The tag is optional and will be set to `latest` if not provided. 10 | 11 | ```json 12 | { 13 | "images": [ 14 | "mycompany/myapp" 15 | ] 16 | } 17 | ``` 18 | 19 | ## Options 20 | 21 | Every command-line option has their corresponding environment variable to configure the updater. 22 | 23 | * `--host, -H` Docker daemon socket to connect to. Defaults to "unix:///var/run/docker.sock" but can be pointed at a 24 | remote Docker host by specifying a TCP or SSH endpoint, for example "ssh://user@hostname:port". The host value can 25 | also be provided by setting the `DOCKER_HOST` environment variable. 26 | * `--config, -c` Docker client configuration path. In this directory goes a `config.json` file with the credentials of 27 | the private registries. Defaults to `~/.docker`.The path value can also be provided by setting the `DOCKER_CONFIG` 28 | environment variable. 29 | * `--schedule, -s` [Cron expression](https://godoc.org/github.com/robfig/cron#hdr-CRON_Expression_Format) in 6 fields 30 | (rather than the traditional 5) which defines when and how often to check for new images. 31 | An example: `--schedule "0 0 4 * * *" `. The schedule can also be provided by setting the `SCHEDULE` environment 32 | variable. 33 | Defaults to 1 hour. Use `none` to run the process one time and exit afterward. 34 | * `--label-enable, -l` Watch services where the `xyz.megpoid.swarm-updater.enable` label is set to true. The flag can 35 | also be provided by setting the `LABEL_ENABLE` environment variable to `1`. 36 | * `--blacklist, -b` Service that is excluded from updates. Can be defined multiple times and can be a regular 37 | expression. 38 | Either `--label-enable` or `--blacklist` can be defined, but not both. The comma separated list can also be 39 | provided by setting the `BLACKLIST` environment variable. 40 | * `--debug, -d` Enables debug logging. Can also be enabled by setting the `DEBUG=1` environment variable. 41 | * `--listen, -a` Address to listen for upcoming swarm update requests. Can also be enabled by setting the `LISTEN` 42 | environment variable. 43 | * `--apikey, -k` Key to protect the update endpoint. Can also be enabled by setting the `APIKEY` environment variable. 44 | * `--max-threads, m` Max number of services that should be updating in parallel. Defaults to 1. Can also be enabled by 45 | setting the `MAX_THREADS` environment variable. 46 | * `--help, -h` Show documentation about the supported flags. 47 | 48 | ## Other environment variables 49 | 50 | * `DOCKER_API_VERSION`to set the version of the API to reach, do not set to use the automatic negotiation. 51 | * `DOCKER_CERT_PATH` is the directory to load the certificates from. Used when `--host` is a TCP endpoint. 52 | * `DOCKER_TLS_VERIFY` is used to verify the server's certificate. 53 | 54 | ## Private registry auth 55 | 56 | A file must be placed on `~/.docker/config.json` with the registry credentials (can be overridden with `--config` 57 | or `DOCKER_CONFIG`). The file can be created by using `docker login ` and saving the credentials. 58 | 59 | ## Delay swarm-updater to be the last updated service 60 | 61 | You must add the `xyz.megpoid.swarm-updater=true` label to your service so the updater can delay the update of itself as 62 | the last one. 63 | 64 | ## Only update the image but don't run the container 65 | 66 | You must add the `xyz.megpoid.swarm-updater.update-only=true` label to your service so only the image will be updated ( 67 | useful for cron tasks where the container isn't running most of the time). Note: the service will be reconfigured 68 | with `replicas: 0` so this does nothing with global replication. 69 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 codestation 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package main 18 | 19 | import ( 20 | "context" 21 | 22 | "github.com/docker/cli/cli/command" 23 | "github.com/docker/cli/cli/config/configfile" 24 | "github.com/docker/docker/api/types" 25 | "github.com/docker/docker/api/types/registry" 26 | "github.com/docker/docker/api/types/swarm" 27 | "github.com/docker/docker/client" 28 | ) 29 | 30 | // DockerClient interacts with a Docker Swarm. 31 | type DockerClient interface { 32 | DistributionInspect(ctx context.Context, image, encodedAuth string) (registry.DistributionInspect, error) 33 | RetrieveAuthTokenFromImage(image string) (string, error) 34 | ServiceUpdate(ctx context.Context, serviceID string, version swarm.Version, service swarm.ServiceSpec, options types.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) 35 | ServiceInspectWithRaw(ctx context.Context, serviceID string, opts types.ServiceInspectOptions) (swarm.Service, []byte, error) 36 | ServiceList(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error) 37 | } 38 | 39 | type dockerClient struct { 40 | apiClient *client.Client 41 | configFile *configfile.ConfigFile 42 | } 43 | 44 | func (c *dockerClient) DistributionInspect(ctx context.Context, image, encodedAuth string) (registry.DistributionInspect, error) { 45 | return c.apiClient.DistributionInspect(ctx, image, encodedAuth) 46 | } 47 | 48 | func (c *dockerClient) RetrieveAuthTokenFromImage(image string) (string, error) { 49 | return command.RetrieveAuthTokenFromImage(c.configFile, image) 50 | } 51 | 52 | func (c *dockerClient) ServiceUpdate(ctx context.Context, serviceID string, version swarm.Version, service swarm.ServiceSpec, options types.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) { 53 | return c.apiClient.ServiceUpdate(ctx, serviceID, version, service, options) 54 | } 55 | 56 | func (c *dockerClient) ServiceInspectWithRaw(ctx context.Context, serviceID string, opts types.ServiceInspectOptions) (swarm.Service, []byte, error) { 57 | return c.apiClient.ServiceInspectWithRaw(ctx, serviceID, opts) 58 | } 59 | 60 | func (c *dockerClient) ServiceList(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error) { 61 | return c.apiClient.ServiceList(ctx, options) 62 | } 63 | -------------------------------------------------------------------------------- /cron.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 codestation 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package main 18 | 19 | import ( 20 | "log/slog" 21 | 22 | "github.com/robfig/cron/v3" 23 | ) 24 | 25 | // CronService holds the instantiated cron service. 26 | type CronService struct { 27 | cronService *cron.Cron 28 | tryLockSem chan bool 29 | } 30 | 31 | // NewCronService creates a new cron for the specified function. 32 | func NewCronService(schedule string, cronFunc func()) (*CronService, error) { 33 | cronService := cron.New(cron.WithParser(cron.NewParser( 34 | cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor, 35 | ))) 36 | 37 | tryLockSem := make(chan bool, 1) 38 | tryLockSem <- true 39 | 40 | _, err := cronService.AddFunc( 41 | schedule, 42 | func() { 43 | select { 44 | case v := <-tryLockSem: 45 | defer func() { tryLockSem <- v }() 46 | cronFunc() 47 | default: 48 | slog.Debug("Skipping cron schedule. Already running") 49 | } 50 | }) 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | slog.Debug("Configured cron schedule", "schedule", schedule) 56 | 57 | return &CronService{ 58 | cronService: cronService, 59 | tryLockSem: tryLockSem, 60 | }, nil 61 | } 62 | 63 | // Start initiates the cron schedule. 64 | func (c *CronService) Start() { 65 | c.cronService.Start() 66 | } 67 | 68 | // Stop cancel the schedule and wait until the currently running function is finished. 69 | func (c *CronService) Stop() { 70 | ctx := c.cronService.Stop() 71 | <-ctx.Done() 72 | 73 | slog.Info("Waiting for running update to be finished...") 74 | <-c.tryLockSem 75 | } 76 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | updater: 4 | image: codestation/swarm-updater:latest 5 | environment: 6 | # the updater will ignore all the services starting with traefik_, 7 | # those ending with _postgres and a consul service 8 | BLACKLIST: ^traefik_, _postgres$$, consul_consul 9 | secrets: 10 | - source: config 11 | target: /root/.docker/config.json 12 | volumes: 13 | - /var/run/docker.sock:/var/run/docker.sock 14 | deploy: 15 | labels: 16 | - xyz.megpoid.swarm-updater=true 17 | placement: 18 | constraints: 19 | - node.role == manager 20 | 21 | secrets: 22 | config: 23 | file: ./config.json 24 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.megpoid.dev/swarm-updater 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/distribution/reference v0.6.0 7 | github.com/docker/cli v28.1.0+incompatible 8 | github.com/docker/docker v28.1.0+incompatible 9 | github.com/labstack/echo/v4 v4.13.3 10 | github.com/robfig/cron/v3 v3.0.1 11 | github.com/stretchr/testify v1.10.0 12 | github.com/urfave/cli v1.22.16 13 | ) 14 | 15 | require ( 16 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect 17 | github.com/Microsoft/go-winio v0.6.2 // indirect 18 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 19 | github.com/containerd/log v0.1.0 // indirect 20 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect 21 | github.com/davecgh/go-spew v1.1.1 // indirect 22 | github.com/docker/distribution v2.8.3+incompatible // indirect 23 | github.com/docker/docker-credential-helpers v0.9.3 // indirect 24 | github.com/docker/go-connections v0.5.0 // indirect 25 | github.com/docker/go-metrics v0.0.1 // indirect 26 | github.com/docker/go-units v0.5.0 // indirect 27 | github.com/felixge/httpsnoop v1.0.4 // indirect 28 | github.com/fvbommel/sortorder v1.1.0 // indirect 29 | github.com/go-logr/logr v1.4.2 // indirect 30 | github.com/go-logr/stdr v1.2.2 // indirect 31 | github.com/gogo/protobuf v1.3.2 // indirect 32 | github.com/google/uuid v1.6.0 // indirect 33 | github.com/gorilla/mux v1.8.0 // indirect 34 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect 35 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 36 | github.com/labstack/gommon v0.4.2 // indirect 37 | github.com/mattn/go-colorable v0.1.14 // indirect 38 | github.com/mattn/go-isatty v0.0.20 // indirect 39 | github.com/mattn/go-runewidth v0.0.16 // indirect 40 | github.com/miekg/pkcs11 v1.1.1 // indirect 41 | github.com/moby/docker-image-spec v1.3.1 // indirect 42 | github.com/moby/sys/atomicwriter v0.1.0 // indirect 43 | github.com/moby/sys/sequential v0.6.0 // indirect 44 | github.com/moby/term v0.5.2 // indirect 45 | github.com/morikuni/aec v1.0.0 // indirect 46 | github.com/opencontainers/go-digest v1.0.0 // indirect 47 | github.com/opencontainers/image-spec v1.1.1 // indirect 48 | github.com/pkg/errors v0.9.1 // indirect 49 | github.com/pmezard/go-difflib v1.0.0 // indirect 50 | github.com/prometheus/client_golang v1.16.0 // indirect 51 | github.com/prometheus/client_model v0.4.0 // indirect 52 | github.com/prometheus/common v0.44.0 // indirect 53 | github.com/prometheus/procfs v0.11.0 // indirect 54 | github.com/rivo/uniseg v0.4.7 // indirect 55 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 56 | github.com/sirupsen/logrus v1.9.3 // indirect 57 | github.com/spf13/cobra v1.9.1 // indirect 58 | github.com/spf13/pflag v1.0.6 // indirect 59 | github.com/theupdateframework/notary v0.7.0 // indirect 60 | github.com/valyala/bytebufferpool v1.0.0 // indirect 61 | github.com/valyala/fasttemplate v1.2.2 // indirect 62 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 63 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect 64 | go.opentelemetry.io/otel v1.35.0 // indirect 65 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 // indirect 66 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect 67 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect 68 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect 69 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 70 | go.opentelemetry.io/otel/sdk v1.35.0 // indirect 71 | go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect 72 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 73 | go.opentelemetry.io/proto/otlp v1.5.0 // indirect 74 | golang.org/x/crypto v0.37.0 // indirect 75 | golang.org/x/net v0.39.0 // indirect 76 | golang.org/x/sys v0.32.0 // indirect 77 | golang.org/x/text v0.24.0 // indirect 78 | golang.org/x/time v0.11.0 // indirect 79 | google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e // indirect 80 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect 81 | google.golang.org/grpc v1.71.1 // indirect 82 | google.golang.org/protobuf v1.36.6 // indirect 83 | gopkg.in/yaml.v3 v3.0.1 // indirect 84 | gotest.tools/v3 v3.0.3 // indirect 85 | ) 86 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= 2 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 3 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 4 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 5 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 6 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 7 | github.com/Shopify/logrus-bugsnag v0.0.0-20170309145241-6dbc35f2c30d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= 8 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 9 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 10 | github.com/beorn7/perks v0.0.0-20150223135152-b965b613227f/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 11 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 12 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 13 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 14 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 15 | github.com/bitly/go-hostpool v0.1.0/go.mod h1:4gOCgp6+NZnVqlKyZ/iBZFTAJKembaVENUpMkpg42fw= 16 | github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= 17 | github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= 18 | github.com/bugsnag/bugsnag-go v1.0.5-0.20150529004307-13fd6b8acda0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= 19 | github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50= 20 | github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= 21 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 22 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 23 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 24 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 25 | github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA= 26 | github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= 27 | github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= 28 | github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 29 | github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= 30 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 31 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 32 | github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= 33 | github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 34 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 35 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 36 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 37 | github.com/denisenkom/go-mssqldb v0.0.0-20191128021309-1d7a30a10f73/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= 38 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 39 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 40 | github.com/docker/cli v28.1.0+incompatible h1:WiJhUBbuIH/BsJtth+C1hPwra4P0nsKJiWy9ie5My5s= 41 | github.com/docker/cli v28.1.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= 42 | github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= 43 | github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= 44 | github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= 45 | github.com/docker/docker v28.1.0+incompatible h1:4iqpcWQCt3Txcz7iWIb1U3SZ/n9ffo4U+ryY5/3eOp0= 46 | github.com/docker/docker v28.1.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 47 | github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= 48 | github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= 49 | github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c h1:lzqkGL9b3znc+ZUgi7FlLnqjQhcXxkNM/quxIjBVMD0= 50 | github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c/go.mod h1:CADgU4DSXK5QUlFslkQu2yW2TKzFZcXq/leZfM0UH5Q= 51 | github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= 52 | github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= 53 | github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= 54 | github.com/docker/go-metrics v0.0.0-20180209012529-399ea8c73916/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI= 55 | github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= 56 | github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= 57 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 58 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 59 | github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= 60 | github.com/dvsekhvalnov/jose2go v0.0.0-20170216131308-f21a8cedbbae/go.mod h1:7BvyPhdbLxMXIYTFPLsyJRFMsKmOZnQmzh6Gb+uquuM= 61 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= 62 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 63 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 64 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 65 | github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw= 66 | github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= 67 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 68 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 69 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 70 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 71 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 72 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 73 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 74 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 75 | github.com/go-sql-driver/mysql v1.3.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= 76 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 77 | github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 78 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 79 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 80 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 81 | github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= 82 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 83 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 84 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 85 | github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 86 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 87 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 88 | github.com/google/certificate-transparency-go v1.0.10-0.20180222191210-5ab67e519c93/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= 89 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 90 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 91 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 92 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 93 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 94 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 95 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 96 | github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 97 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 98 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 99 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= 100 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= 101 | github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= 102 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 103 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 104 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 105 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 106 | github.com/jinzhu/gorm v0.0.0-20170222002820-5409931a1bb8/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo= 107 | github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 108 | github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 109 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 110 | github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 111 | github.com/juju/loggo v0.0.0-20190526231331-6e530bcce5d8/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U= 112 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 113 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 114 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 115 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 116 | github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 117 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 118 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 119 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 120 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 121 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 122 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 123 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 124 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 125 | github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= 126 | github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= 127 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 128 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 129 | github.com/lib/pq v0.0.0-20150723085316-0dad96c0b94f/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 130 | github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 131 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 132 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 133 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 134 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 135 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 136 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 137 | github.com/mattn/go-sqlite3 v1.6.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 138 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 139 | github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= 140 | github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= 141 | github.com/miekg/pkcs11 v1.0.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= 142 | github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU= 143 | github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= 144 | github.com/mitchellh/mapstructure v0.0.0-20150613213606-2caf8efc9366/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 145 | github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 146 | github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 147 | github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= 148 | github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= 149 | github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= 150 | github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= 151 | github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= 152 | github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= 153 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 154 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 155 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 156 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 157 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 158 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 159 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 160 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 161 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 162 | github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg= 163 | github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 164 | github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= 165 | github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= 166 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 167 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 168 | github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= 169 | github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 170 | github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 171 | github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= 172 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 173 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 174 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 175 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 176 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 177 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 178 | github.com/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 179 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 180 | github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= 181 | github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= 182 | github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= 183 | github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= 184 | github.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 185 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 186 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 187 | github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= 188 | github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= 189 | github.com/prometheus/common v0.0.0-20180110214958-89604d197083/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 190 | github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 191 | github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= 192 | github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= 193 | github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= 194 | github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 195 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 196 | github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 197 | github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= 198 | github.com/prometheus/procfs v0.11.0 h1:5EAgkfkMl659uZPbe9AS2N68a7Cc1TJbPEuGzFuRbyk= 199 | github.com/prometheus/procfs v0.11.0/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= 200 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 201 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 202 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 203 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= 204 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 205 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 206 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 207 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 208 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 209 | github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= 210 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 211 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 212 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 213 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 214 | github.com/spf13/cast v0.0.0-20150508191742-4d07383ffe94/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg= 215 | github.com/spf13/cobra v0.0.1/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= 216 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 217 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 218 | github.com/spf13/jwalterweatherman v0.0.0-20141219030609-3d60171a6431/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 219 | github.com/spf13/pflag v1.0.0/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 220 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 221 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 222 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 223 | github.com/spf13/viper v0.0.0-20150530192845-be5ff3e4840c/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM= 224 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 225 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 226 | github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 227 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 228 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 229 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 230 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 231 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 232 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 233 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 234 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 235 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 236 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 237 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 238 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 239 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 240 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 241 | github.com/theupdateframework/notary v0.7.0 h1:QyagRZ7wlSpjT5N2qQAh/pN+DVqgekv4DzbAiAiEL3c= 242 | github.com/theupdateframework/notary v0.7.0/go.mod h1:c9DRxcmhHmVLDay4/2fUYdISnHqbFDGRSlXPO0AhYWw= 243 | github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ= 244 | github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= 245 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 246 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 247 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 248 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 249 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 250 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 251 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 252 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 253 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= 254 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= 255 | go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= 256 | go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= 257 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 h1:QcFwRrZLc82r8wODjvyCbP7Ifp3UANaBSmhDSFjnqSc= 258 | go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0/go.mod h1:CXIWhUomyWBG/oY2/r/kLp6K/cmx9e/7DLpBuuGdLCA= 259 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= 260 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= 261 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI= 262 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo= 263 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg= 264 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk= 265 | go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= 266 | go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= 267 | go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= 268 | go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= 269 | go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= 270 | go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= 271 | go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= 272 | go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= 273 | go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= 274 | go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= 275 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 276 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 277 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 278 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 279 | golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 280 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 281 | golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 282 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 283 | golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= 284 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 285 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 286 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 287 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 288 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 289 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 290 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 291 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 292 | golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 293 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 294 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 295 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 296 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 297 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 298 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 299 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 300 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 301 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 302 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 303 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 304 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 305 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 306 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 307 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 308 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 309 | golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 310 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 311 | golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 312 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 313 | golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 314 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 315 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 316 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 317 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 318 | golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= 319 | golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 320 | golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 321 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 322 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 323 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 324 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 325 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 326 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 327 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 328 | golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 329 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 330 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 331 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 332 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 333 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 334 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 335 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 336 | google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e h1:UdXH7Kzbj+Vzastr5nVfccbmFsmYNygVLSPk1pEfDoY= 337 | google.golang.org/genproto/googleapis/api v0.0.0-20250414145226-207652e42e2e/go.mod h1:085qFyf2+XaZlRdCgKNCIZ3afY2p4HHZdoIRpId8F4A= 338 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA= 339 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 340 | google.golang.org/grpc v1.0.5/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= 341 | google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI= 342 | google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= 343 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 344 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 345 | gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= 346 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 347 | gopkg.in/cenkalti/backoff.v2 v2.2.1/go.mod h1:S0QdOvT2AlerfSBkp0O+dk+bbIMaNbEmVk876gPCthU= 348 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 349 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 350 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 351 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 352 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 353 | gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= 354 | gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.1/go.mod h1:WbjuEoo1oadwzQ4apSDU+JTvmllEHtsNHS6y7vFc7iw= 355 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 356 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 357 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 358 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 359 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 360 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 361 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 362 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 363 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 364 | gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= 365 | gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= 366 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 codestation 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package main 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "fmt" 23 | "log/slog" 24 | "net/http" 25 | "os" 26 | "os/signal" 27 | "regexp" 28 | "strings" 29 | "syscall" 30 | "time" 31 | 32 | "github.com/docker/cli/cli/connhelper" 33 | "github.com/docker/docker/client" 34 | "github.com/labstack/echo/v4" 35 | "github.com/labstack/echo/v4/middleware" 36 | "github.com/urfave/cli" 37 | ) 38 | 39 | const ( 40 | DefaultListenAddr = ":8000" 41 | DefaultTimeout = 30 * time.Second 42 | ) 43 | 44 | var blacklist []*regexp.Regexp 45 | 46 | // UpdateRequest has a list of images that should be updated on the services that uses them 47 | type UpdateRequest struct { 48 | Images []string `json:"images"` 49 | } 50 | 51 | func run(c *cli.Context) error { 52 | ctx, cancel := context.WithCancel(context.Background()) 53 | defer cancel() 54 | 55 | var opts []client.Opt 56 | 57 | host := c.String("host") 58 | if host == "" { 59 | host = os.Getenv("DOCKER_HOST") 60 | } 61 | 62 | if host != "" { 63 | if strings.HasPrefix(host, "ssh://") { 64 | helper, err := connhelper.GetConnectionHelper(host) 65 | if err != nil { 66 | return fmt.Errorf("could not connect to SSH host %s: %w", host, err) 67 | } 68 | opts = append(opts, client.WithHost(helper.Host)) 69 | opts = append(opts, client.WithDialContext(helper.Dialer)) 70 | } else { 71 | opts = append(opts, client.WithHost(host)) 72 | } 73 | } 74 | 75 | opts = append(opts, client.WithTLSClientConfigFromEnv()) 76 | 77 | if os.Getenv("DOCKER_API_VERSION") != "" { 78 | opts = append(opts, client.WithVersionFromEnv()) 79 | } else { 80 | opts = append(opts, client.WithAPIVersionNegotiation()) 81 | } 82 | 83 | configDir := c.String("config") 84 | if configDir == "" { 85 | configDir = os.Getenv("DOCKER_CONFIG") 86 | } 87 | 88 | swarm, err := NewSwarm(configDir, opts...) 89 | if err != nil { 90 | return fmt.Errorf("cannot instantiate new Docker swarm client: %w", err) 91 | } 92 | 93 | swarm.LabelEnable = c.Bool("label-enable") 94 | swarm.Blacklist = blacklist 95 | swarm.MaxThreads = c.Int("max-threads") 96 | schedule := c.String("schedule") 97 | 98 | // update the services and exit, if requested 99 | if schedule == "none" { 100 | return swarm.UpdateServices(ctx) 101 | } 102 | 103 | cron, err := NewCronService(schedule, func() { 104 | if updateErr := swarm.UpdateServices(ctx); updateErr != nil { 105 | slog.Error("Failed to update services", "error", updateErr.Error()) 106 | } 107 | }) 108 | if err != nil { 109 | return fmt.Errorf("failed to setup cron, %w", err) 110 | } 111 | 112 | e := echo.New() 113 | e.HideBanner = true 114 | e.Debug = c.Bool("debug") 115 | e.Use(middleware.Recover()) 116 | apiKey := c.String("apikey") 117 | e.Use(middleware.KeyAuth(func(key string, _ echo.Context) (bool, error) { 118 | return key == apiKey, nil 119 | })) 120 | 121 | e.POST("/apis/swarm/v1/update", func(c echo.Context) error { 122 | req := &UpdateRequest{} 123 | if err := c.Bind(req); err != nil { 124 | return echo.NewHTTPError(http.StatusBadRequest, "Bind:"+err.Error()) 125 | } 126 | 127 | if len(req.Images) == 0 { 128 | return echo.NewHTTPError(http.StatusBadRequest, "No images to update") 129 | } 130 | 131 | slog.Info("Received update request", "images", strings.Join(req.Images, ",")) 132 | 133 | if err := swarm.UpdateServices(c.Request().Context(), req.Images...); err != nil { 134 | return echo.NewHTTPError(http.StatusInternalServerError, "Swarm update:"+err.Error()) 135 | } 136 | 137 | return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) 138 | }) 139 | 140 | svr := &http.Server{ 141 | Addr: c.String("listen"), 142 | ReadTimeout: DefaultTimeout, 143 | WriteTimeout: DefaultTimeout, 144 | } 145 | 146 | go func() { 147 | if err := e.StartServer(svr); err != nil && !errors.Is(err, http.ErrServerClosed) { 148 | slog.Error("Failed to start web server", "error", err.Error()) 149 | } 150 | }() 151 | 152 | cron.Start() 153 | 154 | quit := make(chan os.Signal, 1) 155 | signal.Notify(quit, os.Interrupt, syscall.SIGTERM) 156 | <-quit 157 | 158 | if err := e.Shutdown(ctx); err != nil { 159 | slog.Error("Failed to shutdown web server", "error", err.Error()) 160 | } 161 | 162 | cron.Stop() 163 | 164 | return nil 165 | } 166 | 167 | func initialize(c *cli.Context) error { 168 | if c.Bool("label-enable") && (c.IsSet("blacklist") || c.IsSet("blacklist-regex")) { 169 | slog.Error("Do not define a blacklist if label-enable is enabled") 170 | } 171 | 172 | slog.Info("Starting Swarm Updater", 173 | "version", Tag, 174 | "commit", Revision, 175 | "date", LastCommit, 176 | "clean_build", !Modified) 177 | 178 | if c.Bool("debug") { 179 | slog.SetLogLoggerLevel(slog.LevelDebug) 180 | } 181 | 182 | if c.IsSet("blacklist") { 183 | list := c.StringSlice("blacklist") 184 | for _, entry := range list { 185 | rule := strings.TrimSpace(entry) 186 | if rule == "" { 187 | slog.Warn("Ignoring empty rule in blacklist. Did you leave a trailing comma?y") 188 | continue 189 | } 190 | 191 | regex, err := regexp.Compile(rule) 192 | if err != nil { 193 | return fmt.Errorf("failed to compile blacklist regex: %w", err) 194 | } 195 | 196 | blacklist = append(blacklist, regex) 197 | } 198 | 199 | slog.Debug("Blacklist rules compiled", "count", len(list)) 200 | } 201 | 202 | return nil 203 | } 204 | 205 | func printVersion(_ *cli.Context) { 206 | slog.Info("Starting Swarm Updater", 207 | "version", Tag, 208 | "commit", Revision, 209 | "date", LastCommit, 210 | "clean_build", !Modified) 211 | } 212 | 213 | func main() { 214 | app := cli.NewApp() 215 | app.Usage = "automatically update Docker services" 216 | app.Version = Tag 217 | cli.VersionPrinter = printVersion 218 | 219 | app.Flags = []cli.Flag{ 220 | cli.StringFlag{ 221 | Name: "host, H", 222 | Usage: "docker host", 223 | Value: "", 224 | }, 225 | cli.StringFlag{ 226 | Name: "config, c", 227 | Usage: "location of the docker config files", 228 | }, 229 | cli.StringFlag{ 230 | Name: "schedule, s", 231 | Usage: "cron schedule", 232 | Value: "@every 1h", 233 | EnvVar: "SCHEDULE", 234 | }, 235 | cli.BoolFlag{ 236 | Name: "label-enable, l", 237 | Usage: fmt.Sprintf("watch services where %s label is set to true", enabledServiceLabel), 238 | EnvVar: "LABEL_ENABLE", 239 | }, 240 | cli.StringSliceFlag{ 241 | Name: "blacklist, b", 242 | Usage: "regular expression to match service names to ignore", 243 | EnvVar: "BLACKLIST", 244 | }, 245 | cli.BoolFlag{ 246 | Name: "debug, d", 247 | Usage: "enable debug logging", 248 | EnvVar: "DEBUG", 249 | }, 250 | cli.StringFlag{ 251 | Name: "listen, a", 252 | Usage: "listen address", 253 | Value: DefaultListenAddr, 254 | EnvVar: "LISTEN", 255 | }, 256 | cli.StringFlag{ 257 | Name: "apikey, k", 258 | Usage: "api key to protect endpoint", 259 | EnvVar: "APIKEY", 260 | }, 261 | cli.IntFlag{ 262 | Name: "max-threads, m", 263 | Usage: "max threads", 264 | EnvVar: "MAX_THREADS", 265 | Value: 1, 266 | }, 267 | } 268 | 269 | app.Before = initialize 270 | app.Action = run 271 | 272 | if err := app.Run(os.Args); err != nil { 273 | slog.Error("Cannot start program", "error", err.Error()) 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /swarm.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 codestation 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package main 18 | 19 | import ( 20 | "context" 21 | "errors" 22 | "fmt" 23 | "log/slog" 24 | "regexp" 25 | "strings" 26 | "sync" 27 | 28 | "github.com/distribution/reference" 29 | "github.com/docker/cli/cli/config" 30 | "github.com/docker/cli/cli/config/credentials" 31 | _ "github.com/docker/cli/cli/connhelper" 32 | "github.com/docker/docker/api/types" 33 | "github.com/docker/docker/api/types/swarm" 34 | "github.com/docker/docker/client" 35 | ) 36 | 37 | const ( 38 | serviceLabel string = "xyz.megpoid.swarm-updater" 39 | updateOnlyLabel string = "xyz.megpoid.swarm-updater.update-only" 40 | enabledServiceLabel string = "xyz.megpoid.swarm-updater.enable" 41 | ) 42 | 43 | // Swarm struct to handle all the service operations 44 | type Swarm struct { 45 | client DockerClient 46 | Blacklist []*regexp.Regexp 47 | LabelEnable bool 48 | MaxThreads int 49 | // used to protect the service update when ran from cron and http endpoint at the same time 50 | mu sync.Mutex 51 | } 52 | 53 | func (c *Swarm) validService(service swarm.Service) bool { 54 | if c.LabelEnable { 55 | label := service.Spec.Labels[enabledServiceLabel] 56 | 57 | return strings.ToLower(label) == "true" 58 | } 59 | 60 | serviceName := service.Spec.Name 61 | 62 | for _, entry := range c.Blacklist { 63 | if entry.MatchString(serviceName) { 64 | return false 65 | } 66 | } 67 | 68 | return true 69 | } 70 | 71 | // NewSwarm instantiates a new Docker swarm client 72 | func NewSwarm(configDir string, opts ...client.Opt) (*Swarm, error) { 73 | cli, err := client.NewClientWithOpts(opts...) 74 | if err != nil { 75 | return nil, fmt.Errorf("failed to initialize docker client: %w", err) 76 | } 77 | 78 | configFile, err := config.Load(configDir) 79 | if err != nil { 80 | // https://github.com/docker/cli/issues/5075 81 | slog.Warn("failed to load config", "err", err) 82 | } 83 | 84 | if !configFile.ContainsAuth() { 85 | configFile.CredentialsStore = credentials.DetectDefaultStore(configFile.CredentialsStore) 86 | } 87 | 88 | return &Swarm{client: &dockerClient{apiClient: cli, configFile: configFile}, MaxThreads: 1}, nil 89 | } 90 | 91 | func (c *Swarm) serviceList(ctx context.Context) ([]swarm.Service, error) { 92 | services, err := c.client.ServiceList(ctx, types.ServiceListOptions{}) 93 | if err != nil { 94 | return nil, fmt.Errorf("ServiceList failed: %w", err) 95 | } 96 | 97 | return services, nil 98 | } 99 | 100 | func (c *Swarm) updateServiceWithRetries(ctx context.Context, service swarm.Service) error { 101 | var err error 102 | for i := 0; i < 3; i++ { 103 | err = c.updateService(ctx, service) 104 | if err == nil { 105 | return nil 106 | } 107 | 108 | // check if error has "update out of sequence" in the message 109 | if strings.Contains(err.Error(), "update out of sequence") { 110 | slog.Debug("Service update out of sequence, retrying with updated version", "service", service.Spec.Name) 111 | 112 | // fetch a newer service version 113 | updatedService, _, err := c.client.ServiceInspectWithRaw(ctx, service.ID, types.ServiceInspectOptions{}) 114 | if err != nil { 115 | return fmt.Errorf("ServiceInspect failed: %w", err) 116 | } 117 | 118 | service.Version = updatedService.Version 119 | } else { 120 | return err 121 | } 122 | } 123 | 124 | return fmt.Errorf("failed to update service %s after retries", service.Spec.Name) 125 | } 126 | 127 | func (c *Swarm) updateService(ctx context.Context, service swarm.Service) error { 128 | image := service.Spec.TaskTemplate.ContainerSpec.Image 129 | updateOpts := types.ServiceUpdateOptions{} 130 | 131 | // get docker auth 132 | encodedAuth, err := c.client.RetrieveAuthTokenFromImage(image) 133 | if err != nil { 134 | return fmt.Errorf("cannot retrieve auth token from service's image: %w", err) 135 | } 136 | 137 | // do not set auth if is an empty json object 138 | if encodedAuth != "e30=" { 139 | updateOpts.EncodedRegistryAuth = encodedAuth 140 | } 141 | 142 | // remove image hash from name 143 | imageName := strings.Split(image, "@sha")[0] 144 | 145 | // fetch a newer image digest 146 | service.Spec.TaskTemplate.ContainerSpec.Image, err = c.getImageDigest(ctx, imageName, updateOpts.EncodedRegistryAuth) 147 | if err != nil { 148 | return fmt.Errorf("failed to get new image digest: %w", err) 149 | } 150 | 151 | if image == service.Spec.TaskTemplate.ContainerSpec.Image { 152 | slog.Debug("Service is already up to date", "service", service.Spec.Name) 153 | 154 | return nil 155 | } 156 | 157 | if strings.ToLower(service.Spec.Labels[updateOnlyLabel]) == "true" { 158 | if service.Spec.Mode.Replicated != nil && service.Spec.Mode.Replicated.Replicas != nil { 159 | *service.Spec.Mode.Replicated.Replicas = 0 160 | } 161 | } 162 | 163 | slog.Debug("Updating service", "service", service.Spec.Name) 164 | response, err := c.client.ServiceUpdate(ctx, service.ID, service.Version, service.Spec, updateOpts) 165 | if err != nil { 166 | return fmt.Errorf("failed to update service %s: %w", service.Spec.Name, err) 167 | } 168 | 169 | for _, warning := range response.Warnings { 170 | slog.Debug("Response with warnings", "warning", warning) 171 | } 172 | 173 | updatedService, _, err := c.client.ServiceInspectWithRaw(ctx, service.ID, types.ServiceInspectOptions{}) 174 | if err != nil { 175 | return fmt.Errorf("cannot inspect service %s to check update status: %w", service.Spec.Name, err) 176 | } 177 | 178 | previous := updatedService.PreviousSpec.TaskTemplate.ContainerSpec.Image 179 | current := updatedService.Spec.TaskTemplate.ContainerSpec.Image 180 | 181 | if previous != current { 182 | slog.Info("Updated service", "service", service.Spec.Name, "image", current) 183 | } else { 184 | slog.Debug("Service is already up to date", "service", service.Spec.Name) 185 | } 186 | 187 | return nil 188 | } 189 | 190 | // UpdateServices updates all the services from a Docker swarm that matches the specified image name. 191 | // If no images are passed then it updates all the services. 192 | func (c *Swarm) UpdateServices(ctx context.Context, imageName ...string) error { 193 | c.mu.Lock() 194 | defer c.mu.Unlock() 195 | 196 | services, err := c.serviceList(ctx) 197 | if err != nil { 198 | return fmt.Errorf("failed to get service list: %w", err) 199 | } 200 | 201 | var serviceID string 202 | 203 | sem := make(chan struct{}, c.MaxThreads) 204 | var wg sync.WaitGroup 205 | 206 | for _, service := range services { 207 | if c.validService(service) { 208 | sem <- struct{}{} 209 | wg.Add(1) 210 | 211 | go func(service swarm.Service) { 212 | defer wg.Done() 213 | defer func() { <-sem }() 214 | 215 | // try to identify this service 216 | if _, ok := service.Spec.Labels[serviceLabel]; ok { 217 | serviceID = service.ID 218 | return 219 | } 220 | 221 | if len(imageName) > 0 { 222 | hasMatch := false 223 | for _, imageMatch := range imageName { 224 | if strings.HasPrefix(service.Spec.TaskTemplate.ContainerSpec.Image, imageMatch) { 225 | hasMatch = true 226 | break 227 | } 228 | } 229 | 230 | if !hasMatch { 231 | return 232 | } 233 | } 234 | 235 | if err = c.updateServiceWithRetries(ctx, service); err != nil { 236 | if errors.Is(ctx.Err(), context.Canceled) { 237 | slog.Error("Service update canceled", "service", service.Spec.Name) 238 | return 239 | } 240 | slog.Error("Cannot update service", "service", service.Spec.Name, "error", err) 241 | } 242 | }(service) 243 | } else { 244 | slog.Debug("Service was ignored by blacklist or missing label", "service", service.Spec.Name) 245 | } 246 | } 247 | 248 | if serviceID != "" { 249 | // refresh service 250 | service, _, err := c.client.ServiceInspectWithRaw(ctx, serviceID, types.ServiceInspectOptions{}) 251 | if err != nil { 252 | return fmt.Errorf("cannot inspect the service %s: %w", serviceID, err) 253 | } 254 | 255 | err = c.updateServiceWithRetries(ctx, service) 256 | if err != nil { 257 | return fmt.Errorf("failed to update the service %s: %w", serviceID, err) 258 | } 259 | } 260 | 261 | return nil 262 | } 263 | 264 | func (c *Swarm) getImageDigest(ctx context.Context, image, encodedAuth string) (string, error) { 265 | namedRef, err := reference.ParseNormalizedNamed(image) 266 | if err != nil { 267 | return "", fmt.Errorf("failed to parse image name: %w", err) 268 | } 269 | 270 | if _, isCanonical := namedRef.(reference.Canonical); isCanonical { 271 | return "", errors.New("the image name already have a digest") 272 | } 273 | 274 | distributionInspect, err := c.client.DistributionInspect(ctx, image, encodedAuth) 275 | if err != nil { 276 | return "", fmt.Errorf("failed to inspect image: %w", err) 277 | } 278 | 279 | // ensure that image gets a default tag if none is provided 280 | img, err := reference.WithDigest(namedRef, distributionInspect.Descriptor.Digest) 281 | if err != nil { 282 | return "", fmt.Errorf("the image name has an invalid format: %w", err) 283 | } 284 | 285 | return reference.FamiliarString(img), nil 286 | } 287 | -------------------------------------------------------------------------------- /swarm_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 codestation 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package main 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "log/slog" 23 | "regexp" 24 | "testing" 25 | 26 | "github.com/docker/docker/api/types" 27 | "github.com/docker/docker/api/types/registry" 28 | "github.com/docker/docker/api/types/swarm" 29 | test "github.com/stretchr/testify/assert" 30 | ) 31 | 32 | type dockerClientMock struct { 33 | DistributionInspectFn func(ctx context.Context, image, encodedAuth string) (registry.DistributionInspect, error) 34 | RetrieveAuthTokenFromImageFn func(image string) (string, error) 35 | ServiceUpdateFn func(ctx context.Context, serviceID string, version swarm.Version, service swarm.ServiceSpec, options types.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) 36 | ServiceInspectWithRawFn func(ctx context.Context, serviceID string, opts types.ServiceInspectOptions) (swarm.Service, []byte, error) 37 | ServiceListFn func(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error) 38 | } 39 | 40 | func (s *dockerClientMock) DistributionInspect(ctx context.Context, image, encodedAuth string) (registry.DistributionInspect, error) { 41 | if s.DistributionInspectFn != nil { 42 | return s.DistributionInspectFn(ctx, image, encodedAuth) 43 | } 44 | 45 | return registry.DistributionInspect{}, nil 46 | } 47 | 48 | func (s *dockerClientMock) RetrieveAuthTokenFromImage(image string) (string, error) { 49 | if s.RetrieveAuthTokenFromImageFn != nil { 50 | return s.RetrieveAuthTokenFromImageFn(image) 51 | } 52 | 53 | return "", nil 54 | } 55 | 56 | func (s *dockerClientMock) ServiceUpdate(ctx context.Context, serviceID string, version swarm.Version, service swarm.ServiceSpec, options types.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) { 57 | if s.ServiceUpdateFn != nil { 58 | return s.ServiceUpdateFn(ctx, serviceID, version, service, options) 59 | } 60 | 61 | return swarm.ServiceUpdateResponse{}, nil 62 | } 63 | 64 | func (s *dockerClientMock) ServiceInspectWithRaw(ctx context.Context, serviceID string, opts types.ServiceInspectOptions) (swarm.Service, []byte, error) { 65 | if s.ServiceInspectWithRawFn != nil { 66 | return s.ServiceInspectWithRawFn(ctx, serviceID, opts) 67 | } 68 | 69 | return swarm.Service{}, nil, nil 70 | } 71 | 72 | func (s *dockerClientMock) ServiceList(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error) { 73 | if s.ServiceListFn != nil { 74 | return s.ServiceListFn(ctx, options) 75 | } 76 | 77 | return []swarm.Service{}, nil 78 | } 79 | 80 | func TestValidServiceLabel(t *testing.T) { 81 | assert := test.New(t) 82 | 83 | s := Swarm{LabelEnable: true} 84 | service := swarm.Service{} 85 | 86 | ok := s.validService(service) 87 | assert.False(ok) 88 | 89 | service.Spec.Labels = map[string]string{enabledServiceLabel: "false"} 90 | ok = s.validService(service) 91 | assert.False(ok) 92 | 93 | service.Spec.Labels = map[string]string{enabledServiceLabel: "true"} 94 | ok = s.validService(service) 95 | assert.True(ok) 96 | } 97 | 98 | func TestValidServiceBlacklist(t *testing.T) { 99 | assert := test.New(t) 100 | 101 | s := Swarm{LabelEnable: false} 102 | service := swarm.Service{} 103 | service.Spec.Name = "service_foobar" 104 | 105 | ok := s.validService(service) 106 | assert.True(ok) 107 | 108 | s.Blacklist = []*regexp.Regexp{regexp.MustCompile("service_foobar")} 109 | ok = s.validService(service) 110 | assert.False(ok) 111 | 112 | s.Blacklist = []*regexp.Regexp{regexp.MustCompile("service_barfoo")} 113 | ok = s.validService(service) 114 | assert.True(ok) 115 | 116 | s.Blacklist = []*regexp.Regexp{ 117 | regexp.MustCompile("service_barfoo1"), 118 | regexp.MustCompile("service_foobar"), 119 | regexp.MustCompile("service_barfoo2"), 120 | } 121 | ok = s.validService(service) 122 | assert.False(ok) 123 | 124 | s.Blacklist = []*regexp.Regexp{regexp.MustCompile("")} 125 | ok = s.validService(service) 126 | assert.False(ok) 127 | } 128 | 129 | func TestUpdateServiceEmpty(t *testing.T) { 130 | assert := test.New(t) 131 | 132 | mock := dockerClientMock{} 133 | mock.ServiceListFn = func(_ context.Context, _ types.ServiceListOptions) ([]swarm.Service, error) { 134 | return []swarm.Service{}, nil 135 | } 136 | 137 | s := Swarm{client: &mock} 138 | err := s.UpdateServices(context.TODO()) 139 | assert.NoError(err) 140 | } 141 | 142 | func TestUpdateServices(t *testing.T) { 143 | assert := test.New(t) 144 | 145 | services := []swarm.Service{ 146 | { 147 | ID: "1", 148 | Spec: swarm.ServiceSpec{ 149 | Annotations: swarm.Annotations{Name: "service_foo"}, 150 | TaskTemplate: swarm.TaskSpec{ 151 | ContainerSpec: &swarm.ContainerSpec{Image: "foo:latest@sha256:0000000000000000000000000000000000000000000000000000000000000000"}, 152 | }, 153 | }, 154 | PreviousSpec: &swarm.ServiceSpec{ 155 | TaskTemplate: swarm.TaskSpec{ContainerSpec: &swarm.ContainerSpec{}}, 156 | }, 157 | }, 158 | { 159 | ID: "2", 160 | Spec: swarm.ServiceSpec{ 161 | Annotations: swarm.Annotations{Name: "service_bar"}, 162 | TaskTemplate: swarm.TaskSpec{ 163 | ContainerSpec: &swarm.ContainerSpec{Image: "bar:latest@sha256:0000000000000000000000000000000000000000000000000000000000000000"}, 164 | }, 165 | }, 166 | PreviousSpec: &swarm.ServiceSpec{ 167 | TaskTemplate: swarm.TaskSpec{ContainerSpec: &swarm.ContainerSpec{}}, 168 | }, 169 | }, 170 | { 171 | ID: "3", 172 | Spec: swarm.ServiceSpec{ 173 | Annotations: swarm.Annotations{Name: "service_baz"}, 174 | TaskTemplate: swarm.TaskSpec{ 175 | ContainerSpec: &swarm.ContainerSpec{Image: "baz:latest@sha256:0000000000000000000000000000000000000000000000000000000000000000"}, 176 | }, 177 | }, 178 | PreviousSpec: &swarm.ServiceSpec{ 179 | TaskTemplate: swarm.TaskSpec{ContainerSpec: &swarm.ContainerSpec{}}, 180 | }, 181 | }, 182 | } 183 | 184 | mock := dockerClientMock{} 185 | 186 | mock.ServiceListFn = func(_ context.Context, _ types.ServiceListOptions) ([]swarm.Service, error) { 187 | return services, nil 188 | } 189 | 190 | mock.ServiceInspectWithRawFn = func(_ context.Context, serviceID string, _ types.ServiceInspectOptions) (swarm.Service, []byte, error) { 191 | for _, service := range services { 192 | if service.ID == serviceID { 193 | return service, nil, nil 194 | } 195 | } 196 | 197 | assert.Fail("Should be on the service list", "%s isn't on service list", serviceID) 198 | 199 | return swarm.Service{}, nil, fmt.Errorf("service not found: %s", serviceID) 200 | } 201 | 202 | mock.ServiceUpdateFn = func(_ context.Context, serviceID string, _ swarm.Version, service swarm.ServiceSpec, _ types.ServiceUpdateOptions) (swarm.ServiceUpdateResponse, error) { 203 | for _, serv := range services { 204 | if serv.ID == serviceID { 205 | image := service.TaskTemplate.ContainerSpec.Image 206 | regex := regexp.MustCompile(".*@sha256:.*") 207 | matched := regex.MatchString(image) 208 | assert.False(matched, "%s doesn't has the hash stripped", image) 209 | 210 | serv.PreviousSpec.TaskTemplate.ContainerSpec.Image = image 211 | serv.Spec.TaskTemplate.ContainerSpec.Image = image + "@sha256:1111111111111111111111111111111111111111111111111111111111111111" 212 | 213 | return swarm.ServiceUpdateResponse{}, nil 214 | } 215 | } 216 | 217 | assert.Fail("Should be on the service list", "%s isn't on service list", serviceID) 218 | 219 | return swarm.ServiceUpdateResponse{}, fmt.Errorf("service not found: %s", serviceID) 220 | } 221 | 222 | // disable slog output 223 | slog.SetDefault(slog.New(slog.DiscardHandler)) 224 | 225 | s := Swarm{client: &mock, MaxThreads: 1} 226 | err := s.UpdateServices(context.TODO()) 227 | assert.NoError(err) 228 | } 229 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 codestation 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package main 18 | 19 | import ( 20 | "runtime/debug" 21 | "time" 22 | ) 23 | 24 | var ( 25 | // Tag indicates the commit tag 26 | Tag = "none" 27 | // Revision indicates the git commit of the build 28 | Revision = "unknown" 29 | // LastCommit indicates the date of the commit 30 | LastCommit time.Time 31 | // Modified indicates if the binary was built from an unmodified source code 32 | Modified = true 33 | ) 34 | 35 | func init() { 36 | info, ok := debug.ReadBuildInfo() 37 | if ok { 38 | for _, setting := range info.Settings { 39 | switch setting.Key { 40 | case "vcs.revision": 41 | Revision = setting.Value 42 | case "vcs.time": 43 | LastCommit, _ = time.Parse(time.RFC3339, setting.Value) 44 | case "vcs.modified": 45 | Modified = setting.Value == "true" 46 | } 47 | } 48 | } 49 | } 50 | --------------------------------------------------------------------------------