From 1c50dc4b874d82620f1e722786e708b010dd99dd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 11:29:58 +0000 Subject: [PATCH 1/2] Update dependency kimdre/doco-cd to v0.88.0 --- .doco-cd-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.doco-cd-version b/.doco-cd-version index 5100761..b5a1220 100644 --- a/.doco-cd-version +++ b/.doco-cd-version @@ -1 +1 @@ -v0.82.1 +v0.88.0 From 74feb5da90ae8bbc4a241c7353f813c90ebc95b4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 23 May 2026 11:30:20 +0000 Subject: [PATCH 2/2] [Auto] Sync source code for v0.88.0 --- doco-cd-src/.github/workflows/build-dev.yaml | 2 +- doco-cd-src/.github/workflows/build.yaml | 8 +- doco-cd-src/.github/workflows/docs.yaml | 35 +- .../image-vulnerability-scanning.yml | 2 +- doco-cd-src/.github/workflows/test.yaml | 2 +- doco-cd-src/.gitignore | 1 + doco-cd-src/.golangci.yaml | 1 + doco-cd-src/CODEOWNERS | 1 + doco-cd-src/CONTRIBUTING.md | 2 +- doco-cd-src/Dockerfile | 24 +- doco-cd-src/README.md | 5 +- doco-cd-src/cmd/doco-cd/apiserver.go | 32 + doco-cd-src/cmd/doco-cd/handler.go | 36 +- doco-cd-src/cmd/doco-cd/handler_api.go | 119 ++- doco-cd-src/cmd/doco-cd/handler_api_test.go | 136 ++- doco-cd-src/cmd/doco-cd/handler_poll.go | 31 +- doco-cd-src/cmd/doco-cd/handler_poll_test.go | 7 +- doco-cd-src/cmd/doco-cd/handler_webhook.go | 38 +- .../cmd/doco-cd/handler_webhook_test.go | 13 +- doco-cd-src/cmd/doco-cd/main.go | 185 ++-- doco-cd-src/cmd/doco-cd/main_test.go | 20 +- doco-cd-src/cmd/doco-cd/version.go | 60 +- doco-cd-src/cmd/doco-cd/version_test.go | 71 +- doco-cd-src/dev.compose.yaml | 9 +- doco-cd-src/go.mod | 46 +- doco-cd-src/go.sum | 95 +- .../internal/config/{ => app}/app_config.go | 118 ++- .../internal/config/app/app_config_test.go | 259 ++++++ .../internal/config/app_config_test.go | 143 --- .../internal/config/deploy/auto_discovery.go | 291 ++++++ .../config/deploy/auto_discovery_test.go | 508 ++++++++++ doco-cd-src/internal/config/deploy/build.go | 9 + doco-cd-src/internal/config/deploy/deploy.go | 485 ++++++++++ .../deploy_test.go} | 293 +++--- doco-cd-src/internal/config/deploy/destroy.go | 68 ++ .../internal/config/deploy/destroy_test.go | 113 +++ doco-cd-src/internal/config/deploy/dotenv.go | 71 ++ doco-cd-src/internal/config/deploy/git.go | 16 + .../internal/config/deploy/reconciliation.go | 120 +++ .../config/deploy/reconciliation_test.go | 398 ++++++++ doco-cd-src/internal/config/deploy_config.go | 561 ----------- doco-cd-src/internal/config/errors.go | 3 - .../internal/config/poll/poll_config.go | 126 +++ .../config/{ => poll}/poll_config_test.go | 40 +- doco-cd-src/internal/config/poll_config.go | 123 --- .../internal/config/testdata/invalid.yaml | 3 - .../internal/config/testdata/valid.yaml | 4 - doco-cd-src/internal/config/utils.go | 61 -- doco-cd-src/internal/docker/compose.go | 248 +++-- .../docker/compose_start_services_test.go | 80 ++ doco-cd-src/internal/docker/compose_test.go | 51 +- doco-cd-src/internal/docker/container_run.go | 116 +++ .../internal/docker/container_run_test.go | 62 ++ doco-cd-src/internal/docker/images.go | 575 ++++++++++++ doco-cd-src/internal/docker/images_test.go | 549 +++++++++++ doco-cd-src/internal/docker/job_schedule.go | 155 ++++ .../internal/docker/job_schedule_test.go | 120 +++ .../docker/job_schedule_validation.go | 43 + .../docker/job_schedule_validation_test.go | 162 ++++ doco-cd-src/internal/docker/labels.go | 46 +- doco-cd-src/internal/docker/service_utils.go | 47 +- .../internal/docker/service_utils_test.go | 290 ++++-- doco-cd-src/internal/docker/state.go | 40 + doco-cd-src/internal/docker/swarm.go | 63 +- .../docker/swarm/deploy_composefile.go | 58 +- .../internal/docker/swarm/deploy_wait_test.go | 113 +++ doco-cd-src/internal/docker/swarm/service.go | 6 +- doco-cd-src/internal/docker/swarm_job.go | 90 +- doco-cd-src/internal/docker/swarm_test.go | 8 +- doco-cd-src/internal/docker/utils.go | 4 +- doco-cd-src/internal/git/auth.go | 295 +++++- doco-cd-src/internal/git/auth_test.go | 166 ++++ doco-cd-src/internal/git/git.go | 26 +- doco-cd-src/internal/git/git_test.go | 39 +- .../internal/git/githubapp/resolver.go | 330 +++++++ .../{stages/utils.go => git/names.go} | 11 +- .../utils_test.go => git/names_test.go} | 18 +- doco-cd-src/internal/git/repo_matches_test.go | 6 +- doco-cd-src/internal/git/ssh/helper.go | 20 + doco-cd-src/internal/git/ssh/ssh_agent.go | 64 +- .../internal/git/ssh/ssh_agent_test.go | 90 +- doco-cd-src/internal/graceful/graceful.go | 224 +++++ .../internal/graceful/graceful_test.go | 298 ++++++ doco-cd-src/internal/graceful/http.go | 42 + doco-cd-src/internal/graceful/oncechan.go | 26 + .../internal/graceful/oncechan_test.go | 13 + doco-cd-src/internal/graceful/safe.go | 20 + doco-cd-src/internal/graceful/safe_test.go | 26 + doco-cd-src/internal/graceful/shutdown.go | 49 + .../internal/graceful/shutdown_test.go | 30 + doco-cd-src/internal/lock/lock.go | 14 + doco-cd-src/internal/lock/lock_test.go | 64 ++ doco-cd-src/internal/logger/attrs.go | 131 +++ doco-cd-src/internal/logger/logger.go | 5 +- doco-cd-src/internal/logger/logger_test.go | 57 ++ .../internal/notification/notification.go | 163 +++- .../notification/notification_test.go | 156 +++- doco-cd-src/internal/prometheus/collectors.go | 28 + doco-cd-src/internal/prometheus/metrics.go | 22 +- .../internal/prometheus/metrics_test.go | 9 +- doco-cd-src/internal/reconciliation/clean.go | 154 +-- doco-cd-src/internal/reconciliation/deploy.go | 74 +- .../internal/reconciliation/deploy_test.go | 117 ++- .../internal/reconciliation/manager.go | 187 ++++ .../internal/reconciliation/reconciliation.go | 410 +++++--- .../reconciliation_event_test.go | 725 +++++++++++++++ .../reconciliation/reconciliation_helpers.go | 229 +++++ .../reconciliation_integration_test.go | 368 ++++++++ .../reconciliation/reconciliation_restart.go | 311 +++++++ .../reconciliation/reconciliation_startup.go | 249 +++++ doco-cd-src/internal/restapi/api_test.go | 4 +- doco-cd-src/internal/scheduler/scheduler.go | 878 ++++++++++++++++++ .../internal/scheduler/scheduler_test.go | 224 +++++ .../secretprovider/1password/cache.go | 145 +++ .../secretprovider/1password/client.go | 136 +-- .../1password/client_cache_test.go | 70 ++ .../1password/client_connect.go | 139 +++ .../1password/client_connect_test.go | 164 ++++ .../1password/client_resolver.go | 28 + .../1password/client_service_account.go | 94 ++ .../secretprovider/1password/client_test.go | 6 +- .../secretprovider/1password/config.go | 40 +- .../secretprovider/1password/config_test.go | 227 +++++ .../secretprovider/1password/connect_ref.go | 78 ++ .../1password/connect_ref_test.go | 80 ++ .../1password/testdata/.gitignore | 1 + .../1password/testdata/op-connect.compose.yml | 57 ++ .../awssecretsmanager/client_test.go | 4 +- .../bitwardensecretsmanager/client_test.go | 4 +- .../secretprovider/infisical/client_test.go | 4 +- .../internal/secretprovider/openbao/config.go | 2 +- .../internal/secretprovider/secretprovider.go | 2 +- .../secretprovider/secretprovider_test.go | 4 +- .../types/secret_ref.go} | 2 +- .../types/secret_ref_test.go} | 2 +- .../internal/secretprovider/webhook/config.go | 4 +- doco-cd-src/internal/stages/run.go | 10 +- doco-cd-src/internal/stages/stage_1_init.go | 12 +- .../internal/stages/stage_2_pre-deploy.go | 78 +- doco-cd-src/internal/stages/stage_3_deploy.go | 4 +- .../internal/stages/stage_3_destroy.go | 8 +- .../internal/stages/stage_4_post-deploy.go | 13 +- .../internal/stages/stage_4_post-destroy.go | 28 +- doco-cd-src/internal/stages/types.go | 34 +- doco-cd-src/internal/test/compose.go | 2 +- doco-cd-src/wiki/README.md | 10 +- doco-cd-src/wiki/docs/Advanced/Encryption.md | 57 +- .../wiki/docs/Advanced/Job-Scheduling.md | 213 +++++ .../wiki/docs/Advanced/Notifications.md | 71 +- .../Advanced/Pre-Post-Deployment-Scripts.md | 43 +- .../Advanced/Private-Container-Registries.md | 28 + doco-cd-src/wiki/docs/Advanced/Swarm-Mode.md | 2 +- .../wiki/docs/Advanced/Tips-and-Tricks.md | 51 +- doco-cd-src/wiki/docs/App-Settings.md | 94 +- doco-cd-src/wiki/docs/Core-Concepts.md | 4 +- doco-cd-src/wiki/docs/Deploy-Settings.md | 642 ++++++++----- doco-cd-src/wiki/docs/Docker-Settings.md | 22 + .../wiki/docs/Endpoints/Healthcheck.md | 31 + doco-cd-src/wiki/docs/Endpoints/Metrics.md | 4 +- doco-cd-src/wiki/docs/Endpoints/REST-API.md | 54 +- .../wiki/docs/Endpoints/Webhook-Listener.md | 7 +- .../External-Secrets/1Password-Connect.md | 139 +++ .../wiki/docs/External-Secrets/1Password.md | 34 +- .../Bitwarden-Vault-Vaultwarden.md | 88 +- .../wiki/docs/External-Secrets/Webhook.md | 172 ++-- doco-cd-src/wiki/docs/Getting-Started.md | 6 +- doco-cd-src/wiki/docs/Git-Settings.md | 136 +++ doco-cd-src/wiki/docs/Poll-Settings.md | 2 +- doco-cd-src/wiki/docs/Setup-Access-Token.md | 42 +- doco-cd-src/wiki/docs/Setup-SSH-Key.md | 5 + doco-cd-src/wiki/docs/Setup-Webhook.md | 2 +- .../docs/_snippets/reconciliation-note.md | 9 - doco-cd-src/wiki/docs/index.md | 13 +- doco-cd-src/wiki/docs/wiki/README.md | 4 +- doco-cd-src/wiki/includes/abbreviations.md | 6 + .../includes/git-auth-domains.example.yaml | 19 + .../wiki/includes/reconciliation-note.md | 13 + doco-cd-src/wiki/requirements.txt | 4 +- doco-cd-src/wiki/zensical.toml | 10 +- 179 files changed, 14808 insertions(+), 2803 deletions(-) create mode 100644 doco-cd-src/CODEOWNERS create mode 100644 doco-cd-src/cmd/doco-cd/apiserver.go rename doco-cd-src/internal/config/{ => app}/app_config.go (64%) create mode 100644 doco-cd-src/internal/config/app/app_config_test.go delete mode 100644 doco-cd-src/internal/config/app_config_test.go create mode 100644 doco-cd-src/internal/config/deploy/auto_discovery.go create mode 100644 doco-cd-src/internal/config/deploy/auto_discovery_test.go create mode 100644 doco-cd-src/internal/config/deploy/build.go create mode 100644 doco-cd-src/internal/config/deploy/deploy.go rename doco-cd-src/internal/config/{deploy_config_test.go => deploy/deploy_test.go} (74%) create mode 100644 doco-cd-src/internal/config/deploy/destroy.go create mode 100644 doco-cd-src/internal/config/deploy/destroy_test.go create mode 100644 doco-cd-src/internal/config/deploy/dotenv.go create mode 100644 doco-cd-src/internal/config/deploy/git.go create mode 100644 doco-cd-src/internal/config/deploy/reconciliation.go create mode 100644 doco-cd-src/internal/config/deploy/reconciliation_test.go delete mode 100644 doco-cd-src/internal/config/deploy_config.go create mode 100644 doco-cd-src/internal/config/poll/poll_config.go rename doco-cd-src/internal/config/{ => poll}/poll_config_test.go (73%) delete mode 100644 doco-cd-src/internal/config/poll_config.go delete mode 100644 doco-cd-src/internal/config/testdata/invalid.yaml delete mode 100644 doco-cd-src/internal/config/testdata/valid.yaml create mode 100644 doco-cd-src/internal/docker/compose_start_services_test.go create mode 100644 doco-cd-src/internal/docker/container_run.go create mode 100644 doco-cd-src/internal/docker/container_run_test.go create mode 100644 doco-cd-src/internal/docker/images.go create mode 100644 doco-cd-src/internal/docker/images_test.go create mode 100644 doco-cd-src/internal/docker/job_schedule.go create mode 100644 doco-cd-src/internal/docker/job_schedule_test.go create mode 100644 doco-cd-src/internal/docker/job_schedule_validation.go create mode 100644 doco-cd-src/internal/docker/job_schedule_validation_test.go create mode 100644 doco-cd-src/internal/docker/state.go create mode 100644 doco-cd-src/internal/docker/swarm/deploy_wait_test.go create mode 100644 doco-cd-src/internal/git/auth_test.go create mode 100644 doco-cd-src/internal/git/githubapp/resolver.go rename doco-cd-src/internal/{stages/utils.go => git/names.go} (57%) rename doco-cd-src/internal/{stages/utils_test.go => git/names_test.go} (72%) create mode 100644 doco-cd-src/internal/git/ssh/helper.go create mode 100644 doco-cd-src/internal/graceful/graceful.go create mode 100644 doco-cd-src/internal/graceful/graceful_test.go create mode 100644 doco-cd-src/internal/graceful/http.go create mode 100644 doco-cd-src/internal/graceful/oncechan.go create mode 100644 doco-cd-src/internal/graceful/oncechan_test.go create mode 100644 doco-cd-src/internal/graceful/safe.go create mode 100644 doco-cd-src/internal/graceful/safe_test.go create mode 100644 doco-cd-src/internal/graceful/shutdown.go create mode 100644 doco-cd-src/internal/graceful/shutdown_test.go create mode 100644 doco-cd-src/internal/logger/attrs.go create mode 100644 doco-cd-src/internal/reconciliation/manager.go create mode 100644 doco-cd-src/internal/reconciliation/reconciliation_event_test.go create mode 100644 doco-cd-src/internal/reconciliation/reconciliation_helpers.go create mode 100644 doco-cd-src/internal/reconciliation/reconciliation_integration_test.go create mode 100644 doco-cd-src/internal/reconciliation/reconciliation_restart.go create mode 100644 doco-cd-src/internal/reconciliation/reconciliation_startup.go create mode 100644 doco-cd-src/internal/scheduler/scheduler.go create mode 100644 doco-cd-src/internal/scheduler/scheduler_test.go create mode 100644 doco-cd-src/internal/secretprovider/1password/cache.go create mode 100644 doco-cd-src/internal/secretprovider/1password/client_cache_test.go create mode 100644 doco-cd-src/internal/secretprovider/1password/client_connect.go create mode 100644 doco-cd-src/internal/secretprovider/1password/client_connect_test.go create mode 100644 doco-cd-src/internal/secretprovider/1password/client_resolver.go create mode 100644 doco-cd-src/internal/secretprovider/1password/client_service_account.go create mode 100644 doco-cd-src/internal/secretprovider/1password/config_test.go create mode 100644 doco-cd-src/internal/secretprovider/1password/connect_ref.go create mode 100644 doco-cd-src/internal/secretprovider/1password/connect_ref_test.go create mode 100644 doco-cd-src/internal/secretprovider/1password/testdata/.gitignore create mode 100644 doco-cd-src/internal/secretprovider/1password/testdata/op-connect.compose.yml rename doco-cd-src/internal/{config/external_secret_ref.go => secretprovider/types/secret_ref.go} (99%) rename doco-cd-src/internal/{config/external_secret_ref_test.go => secretprovider/types/secret_ref_test.go} (99%) create mode 100644 doco-cd-src/wiki/docs/Advanced/Job-Scheduling.md create mode 100644 doco-cd-src/wiki/docs/Docker-Settings.md create mode 100644 doco-cd-src/wiki/docs/Endpoints/Healthcheck.md create mode 100644 doco-cd-src/wiki/docs/External-Secrets/1Password-Connect.md create mode 100644 doco-cd-src/wiki/docs/Git-Settings.md delete mode 100644 doco-cd-src/wiki/docs/_snippets/reconciliation-note.md create mode 100644 doco-cd-src/wiki/includes/abbreviations.md create mode 100644 doco-cd-src/wiki/includes/git-auth-domains.example.yaml create mode 100644 doco-cd-src/wiki/includes/reconciliation-note.md diff --git a/doco-cd-src/.github/workflows/build-dev.yaml b/doco-cd-src/.github/workflows/build-dev.yaml index 1d0e2b8..554e7bf 100644 --- a/doco-cd-src/.github/workflows/build-dev.yaml +++ b/doco-cd-src/.github/workflows/build-dev.yaml @@ -74,7 +74,7 @@ jobs: severity: 'CRITICAL,HIGH' - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4 with: sarif_file: 'trivy-results.sarif' diff --git a/doco-cd-src/.github/workflows/build.yaml b/doco-cd-src/.github/workflows/build.yaml index 804349d..d170d57 100644 --- a/doco-cd-src/.github/workflows/build.yaml +++ b/doco-cd-src/.github/workflows/build.yaml @@ -7,7 +7,7 @@ on: jobs: build: - uses: docker/github-builder/.github/workflows/build.yml@7d2a02426d4b989616ba5aaee4e879afd4134b0d # v1 + uses: docker/github-builder/.github/workflows/build.yml@c2782c55efa56a01b9c30021db8f5ec3993228a3 # v1 permissions: contents: read # to fetch the repository content id-token: write # for signing attestation(s) with GitHub OIDC Token @@ -16,6 +16,12 @@ jobs: build-args: | APP_VERSION=${{ github.ref_name }} platforms: linux/amd64,linux/arm64,linux/arm/v7 + #,linux/riscv64 + runner: | + default=ubuntu-24.04 + linux/arm=ubuntu-24.04-arm + linux/arm64=ubuntu-24.04-arm + #linux/riscv64=ubuntu-24.04-riscv cache: true cache-mode: max output: image diff --git a/doco-cd-src/.github/workflows/docs.yaml b/doco-cd-src/.github/workflows/docs.yaml index 7ab3874..e794115 100644 --- a/doco-cd-src/.github/workflows/docs.yaml +++ b/doco-cd-src/.github/workflows/docs.yaml @@ -2,6 +2,11 @@ name: Publish Documentation on: workflow_dispatch: + inputs: + version: + description: 'Version to publish docs for (e.g., v1.2.3)' + required: false + type: string push: branches: - main @@ -46,35 +51,37 @@ jobs: run: mike deploy --branch $DOCS_BRANCH --config-file ./wiki/zensical.toml --push next - name: Publish release docs - if: ${{ github.event_name == 'release' && !github.event.release.prerelease }} + if: ${{ (github.event_name == 'release' && !github.event.release.prerelease) || (github.event_name == 'workflow_dispatch' && github.event.inputs.version) }} env: - VERSION: ${{ github.event.release.tag_name }} + VERSION: ${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.version }} run: | MAJOR_MINOR="$(echo $VERSION | sed -E 's/^(v[0-9]+\.[0-9]+)\..*$/\1/')" - mike deploy --branch $DOCS_BRANCH --config-file ./wiki/zensical.toml --push --alias-type redirect --update-aliases "$MAJOR_MINOR" latest - mike set-default --branch $DOCS_BRANCH --config-file ./wiki/zensical.toml --push latest - - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - ref: ${{ env.DOCS_BRANCH }} + mike deploy --branch $DOCS_BRANCH --config-file ./wiki/zensical.toml --alias-type symlink --update-aliases "$MAJOR_MINOR" latest + mike set-default --branch $DOCS_BRANCH --config-file ./wiki/zensical.toml latest # This is a bit hacky, but it ensures that the sitemap.xml always points to the latest version of the docs - name: Create symlinks to latest Sitemap.xml run: | + git fetch origin "$DOCS_BRANCH" + git checkout $DOCS_BRANCH + # Add a symlink to the latest sitemap.xml in the root of the gh-pages branch, pointing to the latest version's sitemap.xml ln -fs latest/sitemap.xml sitemap.xml git add sitemap.xml # TODO: Remove this once the symlink alias bug is fixed in Zensical (https://github.com/zensical/ui/issues/126) - VERSION="$(jq -r '.[] | select(.aliases[] == "latest") | .version' versions.json)" - ln -sf "../$VERSION/sitemap.xml" latest/sitemap.xml - git add latest/sitemap.xml + # Resolve the real path of the sitemap through the 'latest' symlink and replace versioned URLs with /latest/ + REAL_SITEMAP="$(git ls-files --full-name $(realpath latest/sitemap.xml))" + sed -E -i 's#/v[0-9]+\.[0-9]+/#/latest/#g' "$REAL_SITEMAP" + + git add "$REAL_SITEMAP" + + git status --short # Push the changes to the gh-pages branch - git commit -m "Update latest sitemap.xml symlink to $VERSION" || echo "No changes to commit" - git push origin $DOCS_BRANCH + git diff --cached --quiet && echo "No changes to commit" || git commit --amend --no-edit || git commit -m "docs: fix sitemap URLs to use /latest/" + git push --force-with-lease origin $DOCS_BRANCH # # deploy: # name: Deploy to GitHub Pages diff --git a/doco-cd-src/.github/workflows/image-vulnerability-scanning.yml b/doco-cd-src/.github/workflows/image-vulnerability-scanning.yml index fc8d5f1..fa586ce 100644 --- a/doco-cd-src/.github/workflows/image-vulnerability-scanning.yml +++ b/doco-cd-src/.github/workflows/image-vulnerability-scanning.yml @@ -66,7 +66,7 @@ jobs: severity: 'CRITICAL,HIGH' - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4 + uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4 with: sarif_file: 'trivy-results.sarif' ref: ${{ github.head_ref || github.ref }} diff --git a/doco-cd-src/.github/workflows/test.yaml b/doco-cd-src/.github/workflows/test.yaml index 959c86d..3fcafe2 100644 --- a/doco-cd-src/.github/workflows/test.yaml +++ b/doco-cd-src/.github/workflows/test.yaml @@ -39,7 +39,7 @@ jobs: - uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2 with: check_filenames: true - skip: ./.git,go.mod,go.sum + skip: ./.git,go.mod,go.sum,./wiki/docs/index.md ignore_words_list: AtLeast,AtMost lint: diff --git a/doco-cd-src/.gitignore b/doco-cd-src/.gitignore index f536b28..a7202d5 100644 --- a/doco-cd-src/.gitignore +++ b/doco-cd-src/.gitignore @@ -38,6 +38,7 @@ cover.out cover.html docker-config.json +git-auth-domains.yaml # Agents/Copilot files issue.md diff --git a/doco-cd-src/.golangci.yaml b/doco-cd-src/.golangci.yaml index 53336f3..8c67056 100644 --- a/doco-cd-src/.golangci.yaml +++ b/doco-cd-src/.golangci.yaml @@ -54,6 +54,7 @@ linters: - fmt.Println - io.Closer.Close - io.Writer.Write + - strings.Builder.WriteString - name: unreachable-code - name: unused-parameter - name: var-declaration diff --git a/doco-cd-src/CODEOWNERS b/doco-cd-src/CODEOWNERS new file mode 100644 index 0000000..3f5c0af --- /dev/null +++ b/doco-cd-src/CODEOWNERS @@ -0,0 +1 @@ +* @kimdre \ No newline at end of file diff --git a/doco-cd-src/CONTRIBUTING.md b/doco-cd-src/CONTRIBUTING.md index 8dbcfe4..0b7e908 100644 --- a/doco-cd-src/CONTRIBUTING.md +++ b/doco-cd-src/CONTRIBUTING.md @@ -5,7 +5,7 @@ Thank you for your support. Help is always appreciated! ## Have an issue, idea or question? - Ask questions or discuss ideas on [GitHub Discussions](https://github.com/kimdre/doco-cd/discussions) -- Report bugs or suggest features by [opening an issue](https://github.com/kimdre/doco-cd/issues/new) +- Report bugs or suggest features by [opening an issue](https://github.com/kimdre/doco-cd/issues/new/choose) ## Want to contribute your code? diff --git a/doco-cd-src/Dockerfile b/doco-cd-src/Dockerfile index c48be16..32186c6 100644 --- a/doco-cd-src/Dockerfile +++ b/doco-cd-src/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1@sha256:2780b5c3bab67f1f76c781860de469442999ed1a0d7992a5efdf2cffc0e3d769 -FROM golang:1.26.2@sha256:1e598ea5752ae26c093b746fd73c5095af97d6f2d679c43e83e0eac484a33dc3 AS prerequisites +FROM golang:1.26.3@sha256:2981696eed011d747340d7252620932677929cce7d2d539602f56a8d7e9b660b AS prerequisites ARG APP_VERSION=dev ARG DISABLE_BITWARDEN=false @@ -9,14 +9,16 @@ ARG TARGETVARIANT # Set destination for COPY WORKDIR /app -# Automatically disable Bitwarden for armv7 -# The Bitwarden Go SDK does not support 32-bit ARM architecture -RUN if [ "$TARGETARCH" = "arm" ] && [ "$TARGETVARIANT" = "v7" ]; then \ - echo "Detected armv7 architecture - Bitwarden support will be disabled"; \ +# Automatically disable Bitwarden for armv7 and riscv64 +# The Bitwarden Go SDK does not support 32-bit ARM architecture or RISC-V 64-bit architecture +RUN if ([ "$TARGETARCH" = "arm" ] && [ "$TARGETVARIANT" = "v7" ]) || ([ "$TARGETARCH" = "riscv64" ]); then \ + echo "Detected unsupported ${TARGETARCH} ${TARGETVARIANT} architecture - Bitwarden support will be disabled"; \ fi -# Install prerequisites for Bitwarden SDK (only if not disabled and not armv7) -RUN if [ "$DISABLE_BITWARDEN" != "true" ] && ! ([ "$TARGETARCH" = "arm" ] && [ "$TARGETVARIANT" = "v7" ]); then \ +# Install prerequisites for Bitwarden SDK (only if not disabled and not armv7 or riscv64) +RUN if [ "$DISABLE_BITWARDEN" != "true" ] && \ + ! ([ "$TARGETARCH" = "arm" ] && [ "$TARGETVARIANT" = "v7" ]) && \ + ! ([ "$TARGETARCH" = "riscv64" ]); then \ apt-get update && apt-get install -y \ musl-tools \ && rm -rf /var/lib/apt/lists/*; \ @@ -45,18 +47,18 @@ ARG TARGETVARIANT COPY . . # Build with or without Bitwarden support -# armv7 builds are automatically built without Bitwarden +# armv7 and riscv64 builds are automatically built without Bitwarden # CGO_ENABLED=1 and CC=musl-gcc are required for Bitwarden SDK when enabled # For builds without Bitwarden, CGO is not needed RUN --mount=type=cache,target=/go/pkg/mod/ \ --mount=type=cache,target="/root/.cache/go-build" \ --mount=type=bind,target=. \ - if [ "$DISABLE_BITWARDEN" = "true" ] || ([ "$TARGETARCH" = "arm" ] && [ "$TARGETVARIANT" = "v7" ]); then \ + if [ "$DISABLE_BITWARDEN" = "true" ] || ([ "$TARGETARCH" = "arm" ] && [ "$TARGETVARIANT" = "v7" ]) || ([ "$TARGETARCH" = "riscv64" ]); then \ echo "Building without Bitwarden support"; \ - CGO_ENABLED=1 go build -tags nobitwarden -ldflags="-s -w -X github.com/kimdre/doco-cd/internal/config.AppVersion=${APP_VERSION}" -o / ./...; \ + CGO_ENABLED=1 go build -tags nobitwarden -ldflags="-s -w -X github.com/kimdre/doco-cd/internal/config/app.Version=${APP_VERSION}" -o / ./...; \ else \ echo "Building with Bitwarden support"; \ - CGO_ENABLED=1 CC=musl-gcc go build -ldflags="-s -w -X github.com/kimdre/doco-cd/internal/config.AppVersion=${APP_VERSION} ${BW_SDK_BUILD_FLAGS}" -o / ./...; \ + CGO_ENABLED=1 CC=musl-gcc go build -ldflags="-s -w -X github.com/kimdre/doco-cd/internal/config/app.Version=${APP_VERSION} ${BW_SDK_BUILD_FLAGS}" -o / ./...; \ fi FROM gcr.io/distroless/base-debian13@sha256:c83f022002fc917a92501a8c30c605efdad3010157ba2c8998a2cbf213299201 AS release diff --git a/doco-cd-src/README.md b/doco-cd-src/README.md index 8f9e8f9..db7edb3 100644 --- a/doco-cd-src/README.md +++ b/doco-cd-src/README.md @@ -29,6 +29,7 @@ You can think of it as a simple Portainer or ArgoCD alternative for Docker. - Supports various [Git providers](https://doco.cd/latest/#supported-git-providers) - Supports both Docker Compose projects and Swarm stacks in [Swarm mode](https://doco.cd/latest/Advanced/Swarm-Mode/). - Provides [notifications](https://doco.cd/latest/Advanced/Notifications/) and [Prometheus metrics](https://doco.cd/latest/Endpoints/Metrics/) for monitoring. +- Supports [Job Scheduling / Cron Jobs](https://doco.cd/latest/Advanced/Job-Scheduling/) for running periodic tasks. ## Documentation @@ -37,11 +38,11 @@ You can find the documentation at [doco.cd](https://doco.cd/latest/). ## Community - Ask questions or discuss ideas on [GitHub Discussions](https://github.com/kimdre/doco-cd/discussions) -- Report bugs or suggest features by [opening an issue](https://github.com/kimdre/doco-cd/issues/new) +- Report bugs or suggest features by [opening an issue](https://github.com/kimdre/doco-cd/issues/new/choose) ## Contributing -Contributions are welcome! Please see the [contributing guidelines](https://github.com/kimdre/doco-cd/blob/main/CONTRIBUTING.md) for more information. +Contributions are welcome! Please see the [contributing guidelines](https://doco.cd/latest/Contributing/) for more information. ## Star History diff --git a/doco-cd-src/cmd/doco-cd/apiserver.go b/doco-cd-src/cmd/doco-cd/apiserver.go new file mode 100644 index 0000000..7739317 --- /dev/null +++ b/doco-cd-src/cmd/doco-cd/apiserver.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/kimdre/doco-cd/internal/config/app" + "github.com/kimdre/doco-cd/internal/graceful" + "github.com/kimdre/doco-cd/internal/logger" +) + +func registryApiServer(c *app.Config, h *handlerData, log *logger.Logger) { + // Register API endpoints + apiServerMux := http.NewServeMux() + enabledApiEndpoints := registerApiEndpoints(c, h, log, apiServerMux) + + log.Info( + "listening for events", + slog.Int("http_port", int(c.HttpPort)), + slog.Any("enabled_endpoints", enabledApiEndpoints), + ) + + server := &http.Server{ + Addr: fmt.Sprintf(":%d", c.HttpPort), + ReadHeaderTimeout: 3 * time.Second, + Handler: apiServerMux, + } + + graceful.RegisterServer(graceful.NewHttpServer("api", server)) +} diff --git a/doco-cd-src/cmd/doco-cd/handler.go b/doco-cd-src/cmd/doco-cd/handler.go index ba5438a..212aaf9 100644 --- a/doco-cd-src/cmd/doco-cd/handler.go +++ b/doco-cd-src/cmd/doco-cd/handler.go @@ -12,6 +12,10 @@ import ( "github.com/moby/moby/api/types/container" "github.com/kimdre/doco-cd/internal/config" + + "github.com/kimdre/doco-cd/internal/config/app" + "github.com/kimdre/doco-cd/internal/config/deploy" + "github.com/kimdre/doco-cd/internal/config/poll" "github.com/kimdre/doco-cd/internal/docker/swarm" "github.com/kimdre/doco-cd/internal/filesystem" "github.com/kimdre/doco-cd/internal/git" @@ -40,7 +44,7 @@ func (r handleError) Error() string { } func handle(ctx context.Context, jobLog *slog.Logger, - appConfig *config.AppConfig, + appConfig *app.Config, dataMountPoint container.MountPoint, secretProvider *secretprovider.SecretProvider, dockerCli command.Cli, @@ -48,9 +52,21 @@ func handle(ctx context.Context, jobLog *slog.Logger, cloneURL string, ref string, private bool, metadata notification.Metadata, customTarget string, testName string, - pollConfig config.PollConfig, + pollConfig poll.Config, payload webhook.ParsedPayload, ) error { + git.ConfigureAuthResolver( + appConfig.GitAuthDomains, + appConfig.SSHPrivateKey, + appConfig.SSHPrivateKeyPassphrase, + appConfig.GitAccessToken, + git.GitHubAppConfig{ + ID: appConfig.GitHubAppID, + PrivateKey: appConfig.GitHubAppPrivateKey, + InstallationID: appConfig.GitHubAppInstallationID, + }, + ) + repoName := git.GetRepoName(cloneURL) jobLog = jobLog.With( @@ -118,12 +134,22 @@ func handle(ctx context.Context, jobLog *slog.Logger, jobLog.Debug("retrieving deployment configuration") - var deployConfigs []*config.DeployConfig + var deployConfigs []*deploy.Config + + gitOpts := &deploy.GitOptions{ + SSHPrivateKey: appConfig.SSHPrivateKey, + SSHPrivateKeyPassphrase: appConfig.SSHPrivateKeyPassphrase, + GitAccessToken: appConfig.GitAccessToken, + SkipTLSVerification: appConfig.SkipTLSVerification, + HttpProxy: appConfig.HttpProxy, + GitCloneSubmodules: appConfig.GitCloneSubmodules, + GitCloneDepth: appConfig.GitCloneDepth, + } switch jobTrigger { case stages.JobTriggerWebhook: // Get the deployment configs from the repository - deployConfigs, err = config.GetDeployConfigs(internalRepoPath, appConfig.DeployConfigBaseDir, payload.Name, customTarget, payload.Ref) + deployConfigs, err = deploy.GetConfigs(internalRepoPath, appConfig.DeployConfigBaseDir, payload.Name, customTarget, payload.Ref, gitOpts) if err != nil { return handleError{ err: err, @@ -136,7 +162,7 @@ func handle(ctx context.Context, jobLog *slog.Logger, shortName := filepath.Base(repoName) // Resolve deployment configs (prefer inline in poll config when present) - deployConfigs, err = config.ResolveDeployConfigs(pollConfig, internalRepoPath, appConfig.DeployConfigBaseDir, shortName) + deployConfigs, err = deploy.ResolveConfigs(pollConfig.Deployments, pollConfig.CustomTarget, pollConfig.Reference, internalRepoPath, appConfig.DeployConfigBaseDir, shortName, gitOpts) if err != nil { return handleError{ err: err, diff --git a/doco-cd-src/cmd/doco-cd/handler_api.go b/doco-cd-src/cmd/doco-cd/handler_api.go index e7af7c1..28e4017 100644 --- a/doco-cd-src/cmd/doco-cd/handler_api.go +++ b/doco-cd-src/cmd/doco-cd/handler_api.go @@ -11,19 +11,21 @@ import ( "sync" "time" - "github.com/kimdre/doco-cd/internal/config" + "github.com/kimdre/doco-cd/internal/config/app" + "github.com/kimdre/doco-cd/internal/config/poll" "github.com/kimdre/doco-cd/internal/docker" "github.com/kimdre/doco-cd/internal/docker/swarm" "github.com/kimdre/doco-cd/internal/logger" "github.com/kimdre/doco-cd/internal/notification" restAPI "github.com/kimdre/doco-cd/internal/restapi" + "github.com/kimdre/doco-cd/internal/scheduler" "github.com/kimdre/doco-cd/internal/utils/id" ) // registerApiEndpoints registers the API endpoints based on the application configuration and // returns a list of all enabled endpoints. -func registerApiEndpoints(c *config.AppConfig, h *handlerData, log *logger.Logger, mux *http.ServeMux) []string { +func registerApiEndpoints(c *app.Config, h *handlerData, log *logger.Logger, mux *http.ServeMux) []string { var enabledEndpoints []string type endpoint struct { @@ -41,6 +43,8 @@ func registerApiEndpoints(c *config.AppConfig, h *handlerData, log *logger.Logge enabledEndpoints = append(enabledEndpoints, apiPath) endpoints := []endpoint{ + {apiPath + "/jobs", h.GetScheduledJobsHandler}, + {apiPath + "/job/{jobName}/run", h.TriggerScheduledJobHandler}, {apiPath + "/projects", h.GetProjectsApiHandler}, {apiPath + "/project/{projectName}", h.ProjectApiHandler}, {apiPath + "/project/{projectName}/{action}", h.ProjectActionApiHandler}, @@ -77,6 +81,113 @@ func registerApiEndpoints(c *config.AppConfig, h *handlerData, log *logger.Logge return enabledEndpoints } +// GetScheduledJobsHandler handles API requests to list scheduler-managed jobs. +func (h *handlerData) GetScheduledJobsHandler(w http.ResponseWriter, r *http.Request) { + jobID := id.GenID() + jobLog := h.log.With(slog.String("job_id", jobID), slog.String("ip", r.RemoteAddr)) + + jobLog.Debug("received api request") + + if !requireMethod(w, jobLog, r, http.MethodGet) { + return + } + + if !restAPI.ValidateApiKey(r, h.appConfig.ApiSecret) { + jobLog.Error(restAPI.ErrInvalidApiKey.Error()) + JSONError(w, restAPI.ErrInvalidApiKey.Error(), "", jobID, http.StatusUnauthorized) + + return + } + + stackName := getQueryParam(r, w, jobLog, jobID, "stack", "string", "").(string) + + jobs, err := scheduler.ListJobs(r.Context(), h.dockerCli, stackName) + if err != nil { + errMsg := "failed to list scheduled jobs" + jobLog.With(logger.ErrAttr(err)).Error(errMsg) + JSONError(w, errMsg, err.Error(), jobID, http.StatusInternalServerError) + + return + } + + JSONResponse(w, jobs, jobID, http.StatusOK) +} + +// TriggerScheduledJobHandler handles API requests to run one configured scheduled job immediately. +func (h *handlerData) TriggerScheduledJobHandler(w http.ResponseWriter, r *http.Request) { + jobID := id.GenID() + jobLog := h.log.With(slog.String("job_id", jobID), slog.String("ip", r.RemoteAddr)) + + jobLog.Debug("received api request") + + if !requireMethod(w, jobLog, r, http.MethodPost) { + return + } + + if !restAPI.ValidateApiKey(r, h.appConfig.ApiSecret) { + jobLog.Error(restAPI.ErrInvalidApiKey.Error()) + JSONError(w, restAPI.ErrInvalidApiKey.Error(), "", jobID, http.StatusUnauthorized) + + return + } + + jobName := r.PathValue("jobName") + if jobName == "" { + err := errors.New("missing job name") + jobLog.Error(err.Error()) + JSONError(w, err, "", jobID, http.StatusBadRequest) + + return + } + + stackName := getQueryParam(r, w, jobLog, jobID, "stack", "string", "").(string) + wait := getQueryParam(r, w, jobLog, jobID, "wait", "bool", true).(bool) + + triggerFn := func(ctx context.Context) error { + runID, err := scheduler.TriggerNow(ctx, h.dockerCli, h.log.Logger, jobName, stackName) + + runLog := jobLog + if runID != "" { + runLog = runLog.With(slog.String("scheduled_run_id", runID)) + } + + if err == nil { + runLog.Info("scheduled job run triggered", slog.String("job", jobName), slog.String("stack", stackName)) + return nil + } + + runLog.With(logger.ErrAttr(err)).Error("failed to trigger scheduled job run", slog.String("job", jobName), slog.String("stack", stackName)) + + return err + } + + if !wait { + go func(ctx context.Context) { + _ = triggerFn(ctx) + }(context.WithoutCancel(r.Context())) + + JSONResponse(w, "scheduled job run accepted", jobID, http.StatusAccepted) + + return + } + + err := triggerFn(r.Context()) + if err != nil { + switch { + case errors.Is(err, scheduler.ErrScheduledJobNotFound): + JSONError(w, err.Error(), "", jobID, http.StatusNotFound) + case errors.Is(err, scheduler.ErrScheduledJobDisabled), errors.Is(err, scheduler.ErrScheduledJobAmbiguous): + JSONError(w, err.Error(), "", jobID, http.StatusConflict) + default: + JSONError(w, "failed to trigger scheduled job run", err.Error(), jobID, http.StatusInternalServerError) + } + + return + } + + JSONResponse(w, "scheduled job run completed", jobID, http.StatusOK) +} + // HealthCheckHandler handles health check requests. func (h *handlerData) HealthCheckHandler(w http.ResponseWriter, _ *http.Request) { var ( @@ -709,7 +820,7 @@ func (h *handlerData) TriggerPollHandler(w http.ResponseWriter, r *http.Request) decoder := json.NewDecoder(r.Body) defer r.Body.Close() - var pollConfigs []config.PollConfig + var pollConfigs []poll.Config if err := decoder.Decode(&pollConfigs); err != nil { errMsg := "failed to decode json in body" h.log.Error(errMsg, logger.ErrAttr(err)) @@ -744,7 +855,7 @@ func (h *handlerData) TriggerPollHandler(w http.ResponseWriter, r *http.Request) p.RunOnce = true p.Interval = 0 - pollJob := &config.PollJob{ + pollJob := &poll.Job{ Config: p, LastRun: 0, NextRun: 0, diff --git a/doco-cd-src/cmd/doco-cd/handler_api_test.go b/doco-cd-src/cmd/doco-cd/handler_api_test.go index 75cc732..5990e95 100644 --- a/doco-cd-src/cmd/doco-cd/handler_api_test.go +++ b/doco-cd-src/cmd/doco-cd/handler_api_test.go @@ -13,9 +13,10 @@ import ( "github.com/docker/compose/v5/pkg/compose" "github.com/moby/moby/api/types/container" + "github.com/kimdre/doco-cd/internal/config/app" + "github.com/kimdre/doco-cd/internal/test" - "github.com/kimdre/doco-cd/internal/config" "github.com/kimdre/doco-cd/internal/docker" "github.com/kimdre/doco-cd/internal/docker/swarm" "github.com/kimdre/doco-cd/internal/logger" @@ -29,7 +30,7 @@ func TestHandlerData_HealthCheckHandler(t *testing.T) { expectedResponse := `{"content":"healthy","job_id":"[a-f0-9-]{36}"}` expectedStatusCode := http.StatusOK - appConfig, err := config.GetAppConfig() + appConfig, err := app.GetConfig() if err != nil { t.Fatal(err) } @@ -51,7 +52,7 @@ func TestHandlerData_HealthCheckHandler(t *testing.T) { h := handlerData{ dockerCli: dockerCli, appConfig: appConfig, - appVersion: config.AppVersion, + appVersion: app.Version, log: log, } @@ -79,7 +80,7 @@ func TestHandlerData_HealthCheckHandler(t *testing.T) { } func TestHandlerData_ProjectApiHandler(t *testing.T) { - appConfig, err := config.GetAppConfig() + appConfig, err := app.GetConfig() if err != nil { t.Fatal(err) } @@ -103,7 +104,7 @@ func TestHandlerData_ProjectApiHandler(t *testing.T) { h := handlerData{ dockerCli: dockerCli, appConfig: appConfig, - appVersion: config.AppVersion, + appVersion: app.Version, dataMountPoint: container.MountPoint{ Type: "bind", Source: tmpDir, @@ -227,7 +228,7 @@ func TestHandlerData_TriggerPollHandler(t *testing.T) { }, } - appConfig, err := config.GetAppConfig() + appConfig, err := app.GetConfig() if err != nil { t.Fatal(err) } @@ -256,7 +257,7 @@ func TestHandlerData_TriggerPollHandler(t *testing.T) { h := handlerData{ dockerCli: dockerCli, appConfig: appConfig, - appVersion: config.AppVersion, + appVersion: app.Version, log: logger.New(logger.LevelCritical), testName: test.ConvertTestName(t.Name()), } @@ -310,3 +311,124 @@ func TestHandlerData_TriggerPollHandler(t *testing.T) { }) } } + +func TestHandlerData_TriggerScheduledJobHandlerValidation(t *testing.T) { + t.Parallel() + + appConfig, err := app.GetConfig() + if err != nil { + t.Fatal(err) + } + + h := handlerData{ + appConfig: appConfig, + log: logger.New(logger.LevelCritical), + } + + tests := []struct { + name string + method string + setAPIKey bool + expectedStatus int + }{ + { + name: "invalid method", + method: http.MethodGet, + setAPIKey: true, + expectedStatus: http.StatusMethodNotAllowed, + }, + { + name: "missing api key", + method: http.MethodPost, + setAPIKey: false, + expectedStatus: http.StatusUnauthorized, + }, + } + + endpoint := path.Join(apiPath, "/job/{jobName}/run") + requestPath := path.Join(apiPath, "/job/example-job/run") + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + req, err := http.NewRequest(tc.method, requestPath, nil) + if err != nil { + t.Fatal(err) + } + + if tc.setAPIKey { + req.Header.Set(restAPI.KeyHeader, appConfig.ApiSecret) + } + + rr := httptest.NewRecorder() + mux := http.NewServeMux() + mux.HandleFunc(endpoint, h.TriggerScheduledJobHandler) + mux.ServeHTTP(rr, req) + + if rr.Code != tc.expectedStatus { + t.Fatalf("handler returned wrong status code: got %v want %v", rr.Code, tc.expectedStatus) + } + }) + } +} + +func TestHandlerData_GetScheduledJobsHandlerValidation(t *testing.T) { + t.Parallel() + + appConfig, err := app.GetConfig() + if err != nil { + t.Fatal(err) + } + + h := handlerData{ + appConfig: appConfig, + log: logger.New(logger.LevelCritical), + } + + tests := []struct { + name string + method string + setAPIKey bool + expectedStatus int + }{ + { + name: "invalid method", + method: http.MethodPost, + setAPIKey: true, + expectedStatus: http.StatusMethodNotAllowed, + }, + { + name: "missing api key", + method: http.MethodGet, + setAPIKey: false, + expectedStatus: http.StatusUnauthorized, + }, + } + + endpoint := path.Join(apiPath, "/jobs") + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + req, err := http.NewRequest(tc.method, endpoint, nil) + if err != nil { + t.Fatal(err) + } + + if tc.setAPIKey { + req.Header.Set(restAPI.KeyHeader, appConfig.ApiSecret) + } + + rr := httptest.NewRecorder() + mux := http.NewServeMux() + mux.HandleFunc(endpoint, h.GetScheduledJobsHandler) + mux.ServeHTTP(rr, req) + + if rr.Code != tc.expectedStatus { + t.Fatalf("handler returned wrong status code: got %v want %v", rr.Code, tc.expectedStatus) + } + }) + } +} diff --git a/doco-cd-src/cmd/doco-cd/handler_poll.go b/doco-cd-src/cmd/doco-cd/handler_poll.go index 9f22fac..f259633 100644 --- a/doco-cd-src/cmd/doco-cd/handler_poll.go +++ b/doco-cd-src/cmd/doco-cd/handler_poll.go @@ -9,12 +9,14 @@ import ( "github.com/docker/cli/cli/command" "github.com/moby/moby/api/types/container" + "github.com/kimdre/doco-cd/internal/config/app" + "github.com/kimdre/doco-cd/internal/config/poll" + "github.com/kimdre/doco-cd/internal/lock" "github.com/kimdre/doco-cd/internal/notification" "github.com/kimdre/doco-cd/internal/secretprovider" "github.com/kimdre/doco-cd/internal/stages" - "github.com/kimdre/doco-cd/internal/config" "github.com/kimdre/doco-cd/internal/git" log "github.com/kimdre/doco-cd/internal/logger" "github.com/kimdre/doco-cd/internal/prometheus" @@ -23,31 +25,31 @@ import ( ) // StartPoll initializes PollJob with the provided configuration and starts the PollHandler goroutine. -func StartPoll(h *handlerData, pollConfig config.PollConfig, wg *sync.WaitGroup) error { +func StartPoll(ctx context.Context, h *handlerData, pollConfig poll.Config, wg *sync.WaitGroup) error { if pollConfig.Interval == 0 && !pollConfig.RunOnce { - h.log.Info("polling job disabled by config", "config", pollConfig) + h.log.Info("polling job disabled by config", "config", &pollConfig) return nil } - pollJob := &config.PollJob{ + pollJob := &poll.Job{ Config: pollConfig, LastRun: 0, NextRun: 0, } - h.log.Debug("Starting poll handler", "config", pollConfig) + h.log.Debug("Starting poll handler", "config", &pollConfig) wg.Go(func() { - h.PollHandler(context.Background(), pollJob) - h.log.Debug("PollJob handler stopped", "config", pollConfig) + h.PollHandler(ctx, pollJob) + h.log.Debug("PollJob handler stopped", "config", &pollConfig) }) return nil } // PollHandler is a function that handles polling for changes in a repository. -func (h *handlerData) PollHandler(ctx context.Context, pollJob *config.PollJob) { +func (h *handlerData) PollHandler(ctx context.Context, pollJob *poll.Job) { repoName := git.GetRepoName(string(pollJob.Config.CloneUrl)) logger := h.log.With(slog.String("repository", repoName)) @@ -91,7 +93,14 @@ func (h *handlerData) PollHandler(ctx context.Context, pollJob *config.PollJob) } pollJob.LastRun = time.Now().Unix() - time.Sleep(time.Duration(pollJob.Config.Interval) * time.Second) + + select { + case <-ctx.Done(): + logger.Debug("ctx is done in poll handler") + return + case <-time.After(time.Duration(pollJob.Config.Interval) * time.Second): + continue + } } } @@ -115,7 +124,7 @@ func pollError(jobLog *slog.Logger, metadata notification.Metadata, err error) { } // RunPoll deploys compose projects based on the provided configuration. -func RunPoll(ctx context.Context, pollConfig config.PollConfig, appConfig *config.AppConfig, dataMountPoint container.MountPoint, +func RunPoll(ctx context.Context, pollConfig poll.Config, appConfig *app.Config, dataMountPoint container.MountPoint, dockerCli command.Cli, logger *slog.Logger, metadata notification.Metadata, secretProvider *secretprovider.SecretProvider, ) error { startTime := time.Now() @@ -130,7 +139,7 @@ func RunPoll(ctx context.Context, pollConfig config.PollConfig, appConfig *confi jobLog.Info("polling repository", slog.Group("trigger", slog.String("event", string(stages.JobTriggerPoll)), - slog.Any("config", pollConfig))) + slog.Any("config", &pollConfig))) deployErr := handle(ctx, jobLog, appConfig, dataMountPoint, secretProvider, dockerCli, diff --git a/doco-cd-src/cmd/doco-cd/handler_poll_test.go b/doco-cd-src/cmd/doco-cd/handler_poll_test.go index 31339d7..447fe19 100644 --- a/doco-cd-src/cmd/doco-cd/handler_poll_test.go +++ b/doco-cd-src/cmd/doco-cd/handler_poll_test.go @@ -5,6 +5,8 @@ import ( "errors" "testing" + "github.com/kimdre/doco-cd/internal/config/app" + "github.com/kimdre/doco-cd/internal/config/poll" "github.com/kimdre/doco-cd/internal/docker/swarm" "github.com/kimdre/doco-cd/internal/notification" "github.com/kimdre/doco-cd/internal/secretprovider" @@ -17,7 +19,6 @@ import ( "github.com/docker/compose/v5/pkg/compose" "github.com/moby/moby/api/types/container" - "github.com/kimdre/doco-cd/internal/config" "github.com/kimdre/doco-cd/internal/docker" "github.com/kimdre/doco-cd/internal/encryption" "github.com/kimdre/doco-cd/internal/logger" @@ -29,7 +30,7 @@ func TestRunPoll(t *testing.T) { log := logger.New(logger.LevelCritical) ctx := context.Background() - pollConfig := config.PollConfig{ + pollConfig := poll.Config{ CloneUrl: "https://github.com/kimdre/doco-cd_tests.git", Reference: "main", Interval: 10, @@ -44,7 +45,7 @@ func TestRunPoll(t *testing.T) { t.Log("Testing in Swarm mode, using 'swarm-mode' reference") } - appConfig, err := config.GetAppConfig() + appConfig, err := app.GetConfig() if err != nil { t.Fatal(err) } diff --git a/doco-cd-src/cmd/doco-cd/handler_webhook.go b/doco-cd-src/cmd/doco-cd/handler_webhook.go index 2f5fd6c..2662db6 100644 --- a/doco-cd-src/cmd/doco-cd/handler_webhook.go +++ b/doco-cd-src/cmd/doco-cd/handler_webhook.go @@ -12,12 +12,14 @@ import ( "github.com/docker/cli/cli/command" "github.com/moby/moby/api/types/container" + "github.com/kimdre/doco-cd/internal/config/app" + "github.com/kimdre/doco-cd/internal/config/poll" + "github.com/kimdre/doco-cd/internal/lock" "github.com/kimdre/doco-cd/internal/notification" "github.com/kimdre/doco-cd/internal/secretprovider" "github.com/kimdre/doco-cd/internal/stages" - "github.com/kimdre/doco-cd/internal/config" "github.com/kimdre/doco-cd/internal/git" "github.com/kimdre/doco-cd/internal/logger" "github.com/kimdre/doco-cd/internal/prometheus" @@ -28,7 +30,7 @@ import ( var ErrInvalidHTTPMethod = errors.New("invalid http method") type handlerData struct { - appConfig *config.AppConfig // Application configuration + appConfig *app.Config // Application configuration appVersion string // Application version dataMountPoint container.MountPoint // Mount point for the data directory dockerCli command.Cli // Docker CLI client @@ -64,7 +66,7 @@ func onError(w http.ResponseWriter, log *slog.Logger, errMsg string, details any } // HandleEvent executes the deployment process for a given webhook event. -func HandleEvent(ctx context.Context, jobLog *slog.Logger, w http.ResponseWriter, appConfig *config.AppConfig, +func HandleEvent(ctx context.Context, jobLog *slog.Logger, w http.ResponseWriter, appConfig *app.Config, dataMountPoint container.MountPoint, payload webhook.ParsedPayload, customTarget string, metadata notification.Metadata, dockerCli command.Cli, secretProvider *secretprovider.SecretProvider, testName string, @@ -92,14 +94,38 @@ func HandleEvent(ctx context.Context, jobLog *slog.Logger, w http.ResponseWriter } cloneUrl := payload.CloneURL - if appConfig.SSHPrivateKey != "" { - cloneUrl = payload.SSHUrl + + git.ConfigureAuthResolver( + appConfig.GitAuthDomains, + appConfig.SSHPrivateKey, + appConfig.SSHPrivateKeyPassphrase, + appConfig.GitAccessToken, + git.GitHubAppConfig{ + ID: appConfig.GitHubAppID, + PrivateKey: appConfig.GitHubAppPrivateKey, + InstallationID: appConfig.GitHubAppInstallationID, + }, + ) + + // Only attempt SSH clone when URL-specific credentials include an SSH private key. + resolvedSSH := git.ResolveAuthConfig(payload.SSHUrl, appConfig.SSHPrivateKey, appConfig.SSHPrivateKeyPassphrase, appConfig.GitAccessToken) + if payload.SSHUrl != "" && resolvedSSH.SSHPrivateKey != "" { + sshAuth, authErr := git.GetAuthMethod(payload.SSHUrl, appConfig.SSHPrivateKey, appConfig.SSHPrivateKeyPassphrase, appConfig.GitAccessToken) + if authErr != nil { + onError(w, jobLog.With(logger.ErrAttr(authErr)), "failed to resolve SSH auth method", authErr.Error(), http.StatusInternalServerError, metadata) + + return + } + + if sshAuth != nil { + cloneUrl = payload.SSHUrl + } } deployErr := handle(ctx, jobLog, appConfig, dataMountPoint, secretProvider, dockerCli, stages.JobTriggerWebhook, cloneUrl, payload.Ref, payload.Private, - metadata, customTarget, testName, config.PollConfig{}, payload, + metadata, customTarget, testName, poll.Config{}, payload, ) if deployErr != nil { // In synchronous mode we should return an error to the caller diff --git a/doco-cd-src/cmd/doco-cd/handler_webhook_test.go b/doco-cd-src/cmd/doco-cd/handler_webhook_test.go index a3cfd5b..440027d 100644 --- a/doco-cd-src/cmd/doco-cd/handler_webhook_test.go +++ b/doco-cd-src/cmd/doco-cd/handler_webhook_test.go @@ -21,13 +21,14 @@ import ( "github.com/moby/moby/api/types/network" "github.com/moby/moby/client" + "github.com/kimdre/doco-cd/internal/config/app" + "github.com/kimdre/doco-cd/internal/git" "github.com/kimdre/doco-cd/internal/test" "github.com/kimdre/doco-cd/internal/docker/swarm" - "github.com/kimdre/doco-cd/internal/config" "github.com/kimdre/doco-cd/internal/docker" "github.com/kimdre/doco-cd/internal/encryption" "github.com/kimdre/doco-cd/internal/logger" @@ -45,7 +46,7 @@ const ( ` ) -func newWebhookRequest(t *testing.T, url string, payload []byte, appConfig *config.AppConfig) *http.Request { +func newWebhookRequest(t *testing.T, url string, payload []byte, appConfig *app.Config) *http.Request { t.Helper() req, err := http.NewRequest("POST", url, bytes.NewReader(payload)) @@ -109,7 +110,7 @@ func TestHandlerData_WebhookHandler(t *testing.T) { t.Fatal(err) } - appConfig, err := config.GetAppConfig() + appConfig, err := app.GetConfig() if err != nil { t.Fatal(err) } @@ -133,7 +134,7 @@ func TestHandlerData_WebhookHandler(t *testing.T) { h := handlerData{ dockerCli: dockerCli, appConfig: appConfig, - appVersion: config.AppVersion, + appVersion: app.Version, dataMountPoint: container.MountPoint{ Type: "bind", Source: tmpDir, @@ -310,7 +311,7 @@ func TestHandlerData_WebhookHandler(t *testing.T) { func TestWebhookHandler_WaitQueryParam(t *testing.T) { encryption.SetupAgeKeyEnvVar(t) - appConfig, err := config.GetAppConfig() + appConfig, err := app.GetConfig() if err != nil { t.Fatal(err) } @@ -319,7 +320,7 @@ func TestWebhookHandler_WaitQueryParam(t *testing.T) { h := handlerData{ appConfig: appConfig, - appVersion: config.AppVersion, + appVersion: app.Version, dataMountPoint: container.MountPoint{ Type: "bind", Source: t.TempDir(), diff --git a/doco-cd-src/cmd/doco-cd/main.go b/doco-cd-src/cmd/doco-cd/main.go index 54c0405..711f35e 100644 --- a/doco-cd-src/cmd/doco-cd/main.go +++ b/doco-cd-src/cmd/doco-cd/main.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "log/slog" - "net/http" "os" "path/filepath" "regexp" @@ -17,17 +16,20 @@ import ( "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" - "github.com/kimdre/doco-cd/internal/notification" + "github.com/kimdre/doco-cd/internal/config/app" + "github.com/kimdre/doco-cd/internal/git" "github.com/kimdre/doco-cd/internal/git/ssh" + "github.com/kimdre/doco-cd/internal/graceful" + "github.com/kimdre/doco-cd/internal/reconciliation" "github.com/kimdre/doco-cd/cmd/doco-cd/healthcheck" + "github.com/kimdre/doco-cd/internal/scheduler" "github.com/kimdre/doco-cd/internal/secretprovider" "github.com/kimdre/doco-cd/internal/docker/swarm" - "github.com/kimdre/doco-cd/internal/config" "github.com/kimdre/doco-cd/internal/docker" "github.com/kimdre/doco-cd/internal/filesystem" "github.com/kimdre/doco-cd/internal/logger" @@ -88,17 +90,44 @@ func CreateMountpointSymlink(m container.MountPoint) error { } func main() { - ctx := context.Background() + // split to app to make defer work when os.Exit(). + if err := run(); err != nil { + slog.Error("application stopped with error", logger.ErrAttr(err)) + os.Exit(1) + } + + slog.Info("application stopped normally") +} + +// run is the main entry point for the application. +// It initializes the application, sets up necessary resources, and starts the server. +func run() error { + ctx, rootCancel := context.WithCancel(context.Background()) + + defer rootCancel() // Set the default log level to debug log := logger.New(slog.LevelDebug) // Get the application configuration - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { log.Critical("failed to get application configuration", logger.ErrAttr(err)) + return err } + git.ConfigureAuthResolver( + c.GitAuthDomains, + c.SSHPrivateKey, + c.SSHPrivateKeyPassphrase, + c.GitAccessToken, + git.GitHubAppConfig{ + ID: c.GitHubAppID, + PrivateKey: c.GitHubAppPrivateKey, + InstallationID: c.GitHubAppInstallationID, + }, + ) + // Parse the log level from the app configuration logLevel, err := logger.ParseLevel(c.LogLevel) if err != nil { @@ -111,19 +140,20 @@ func main() { if len(os.Args) > 1 && os.Args[1] == "healthcheck" { checkUrl := fmt.Sprintf("http://localhost:%d%s", c.HttpPort, healthPath) - err = healthcheck.Check(ctx, checkUrl) + err := healthcheck.Check(ctx, checkUrl) if err != nil { log.Critical("health check failed", logger.ErrAttr(err), slog.String("url", checkUrl)) - os.Exit(1) + return err } log.Info("health check successful", slog.String("url", checkUrl)) - os.Exit(0) + + return nil } - log.Info("starting application", slog.String("version", config.AppVersion), slog.String("log_level", c.LogLevel)) + log.Info("starting application", slog.String("version", app.Version), slog.String("log_level", c.LogLevel)) - prometheus.AppInfo.WithLabelValues(config.AppVersion, c.LogLevel, time.Now().Format(time.RFC3339)).Set(1) + prometheus.AppInfo.WithLabelValues(app.Version, c.LogLevel, time.Now().Format(time.RFC3339)).Set(1) // Log if proxy is used if c.HttpProxy != (transport.ProxyOptions{}) { @@ -136,6 +166,7 @@ func main() { err, errType := docker.VerifyDockerAPIAccess() if err != nil { log.Critical(errType.Error(), logger.ErrAttr(err)) + return err } log.Debug("connection to docker socket was successful") @@ -144,7 +175,7 @@ func main() { if err != nil { log.Critical("failed to create docker client", logger.ErrAttr(err)) - return + return err } dockerClient := dockerCli.Client() @@ -161,7 +192,7 @@ func main() { if c.DockerSwarmFeatures { if err := swarm.RefreshModeEnabled(ctx, dockerClient); err != nil { log.Critical("failed to check if docker daemon is a swarm manager", logger.ErrAttr(err)) - return + return err } } else { swarm.SetDisableSwarmFeature(true) @@ -180,7 +211,7 @@ func main() { if err != nil { log.Critical("failed to retrieve doco-cd container id", logger.ErrAttr(err)) - return + return err } log.Debug("retrieved doco-cd container id", slog.String("container_id", appContainerID)) @@ -189,6 +220,7 @@ func main() { dataMountPoint, err := docker.GetMountPointByDestination(dockerClient, appContainerID, dataPath) if err != nil { log.Critical(fmt.Sprintf("failed to retrieve %s mount point for container %s", dataPath, appContainerID), logger.ErrAttr(err)) + return err } log.Debug("retrieved doco-cd data mount point", @@ -199,86 +231,37 @@ func main() { ) // Check if data mount point is writable - err = docker.CheckMountPointWriteable(dataMountPoint) - if err != nil { + if err := docker.CheckMountPointWriteable(dataMountPoint); err != nil { log.Critical(fmt.Sprintf("failed to check if %s mount point is writable", dataPath), logger.ErrAttr(err)) + return err } - err = CreateMountpointSymlink(dataMountPoint) - if err != nil { + if err := CreateMountpointSymlink(dataMountPoint); err != nil { log.Critical(fmt.Sprintf("failed to create symlink for %s mount point", dataMountPoint.Destination), logger.ErrAttr(err)) - return + return err } - go func() { - latestVersion, err := getLatestAppReleaseVersion() - if err != nil { - log.Error("failed to get latest application release version", logger.ErrAttr(err)) - } else { - if config.AppVersion != latestVersion { - log.Warn("new application version available", - slog.String("current", config.AppVersion), - slog.String("latest", latestVersion), - ) - - err = notification.Send(notification.Info, - "New version of doco-cd is available", - fmt.Sprintf("Current Version: %s\nLatest Version: %s\n\nhttps://github.com/kimdre/doco-cd/releases", config.AppVersion, latestVersion), - notification.Metadata{}) - if err != nil { - return - } - } else { - log.Debug("application is up to date", slog.String("version", config.AppVersion)) - } - } - }() + var wg sync.WaitGroup + defer wg.Wait() + + graceful.SafeGo(&wg, log.Logger, + func() { + notificationForNewAppVersion(log.Logger) + }, + ) // Initialize SSH agent if SSH private key is provided if c.SSHPrivateKey != "" { - agentCtx, cancel := context.WithCancel(ctx) - defer cancel() - - go func() { - err = ssh.StartSSHAgent(agentCtx, ssh.SocketAgentSocketPath) - if err != nil { - log.Critical("failed to start SSH agent", logger.ErrAttr(err)) // nolint:contextcheck - } else { - log.Debug("SSH agent started") - } - }() - - // Wait for the agent socket to appear (max 2s) - deadline := time.Now().Add(2 * time.Second) - - for { - if _, err = os.Stat(ssh.SocketAgentSocketPath); err == nil { - break - } - - if time.Now().After(deadline) { - log.Critical("SSH agent socket file does not exist", slog.String("path", ssh.SocketAgentSocketPath)) - return - } - - time.Sleep(10 * time.Millisecond) - } - - // Add the SSH private key to the agent - err = ssh.AddKeyToAgent([]byte(c.SSHPrivateKey), c.SSHPrivateKeyPassphrase) - if err != nil { - log.Critical("failed to add SSH private key to agent", logger.ErrAttr(err)) - return - } + ssh.RegisterSSHAgent(ctx, log.Logger, c.SSHPrivateKey, c.SSHPrivateKeyPassphrase) } // Initialize the secret provider - secretProvider, err := secretprovider.Initialize(ctx, c.SecretProvider, config.AppVersion) + secretProvider, err := secretprovider.Initialize(ctx, c.SecretProvider, app.Version) if err != nil { log.Critical("failed to initialize secret provider", logger.ErrAttr(err)) - return + return err } if secretProvider != nil { @@ -289,7 +272,7 @@ func main() { h := handlerData{ appConfig: c, - appVersion: config.AppVersion, + appVersion: app.Version, dataMountPoint: dataMountPoint, dockerCli: dockerCli, log: log, @@ -299,54 +282,36 @@ func main() { // Initialize the deployer limiter according to configuration reconciliation.InitializeDeployerLimiter(c.MaxConcurrentDeployments) - // Register API endpoints - apiServerMux := http.NewServeMux() - enabledApiEndpoints := registerApiEndpoints(c, &h, log, apiServerMux) - - log.Info( - "listening for events", - slog.Int("http_port", int(c.HttpPort)), - slog.Any("enabled_endpoints", enabledApiEndpoints), - ) - - var wg sync.WaitGroup - if len(c.PollConfig) > 0 { + // cancel poll jobs + defer rootCancel() + log.Info( "poll configuration found, scheduling polling jobs", slog.Any("poll_config", logger.BuildSliceLogValue(c.PollConfig, "Deployments.Internal")), ) for _, pollConfig := range c.PollConfig { - err = StartPoll(&h, pollConfig, &wg) + err = StartPoll(ctx, &h, pollConfig, &wg) if err != nil { log.Critical("failed to scheduling polling jobs", logger.ErrAttr(err)) - return + return err } } } - go func() { - log.Info("serving prometheus metrics", slog.Int("http_port", int(c.MetricsPort)), slog.String("path", prometheus.MetricsPath)) + graceful.SafeGo(&wg, log.Logger, func() { + scheduler.Start(ctx, h.dockerCli, log.Logger, &wg) + }) - if err = prometheus.Serve(c.MetricsPort); err != nil { - log.Critical("failed to start Prometheus metrics server", logger.ErrAttr(err)) - } else { - log.Debug("Prometheus metrics server started successfully", slog.Int("port", int(c.MetricsPort))) - } - }() - - server := &http.Server{ - Addr: fmt.Sprintf(":%d", c.HttpPort), - ReadHeaderTimeout: 3 * time.Second, - Handler: apiServerMux, - } + registryApiServer(c, &h, log) + prometheus.RegisterServer(c.MetricsPort, log) - err = server.ListenAndServe() - if err != nil { - log.Error(fmt.Sprintf("failed to listen on port: %v", c.HttpPort), logger.ErrAttr(err)) + if err := graceful.Serve(log.Logger); err != nil { + log.Critical("failed to serve", logger.ErrAttr(err)) + return err } - wg.Wait() + return nil } diff --git a/doco-cd-src/cmd/doco-cd/main_test.go b/doco-cd-src/cmd/doco-cd/main_test.go index 0048ffc..ee48c27 100644 --- a/doco-cd-src/cmd/doco-cd/main_test.go +++ b/doco-cd-src/cmd/doco-cd/main_test.go @@ -19,6 +19,8 @@ import ( "github.com/docker/compose/v5/pkg/compose" "github.com/moby/moby/api/types/container" + "github.com/kimdre/doco-cd/internal/config/app" + "github.com/kimdre/doco-cd/internal/notification" "github.com/kimdre/doco-cd/internal/secretprovider/bitwardensecretsmanager" "github.com/kimdre/doco-cd/internal/utils/id" @@ -28,7 +30,6 @@ import ( "github.com/kimdre/doco-cd/internal/docker/swarm" "github.com/kimdre/doco-cd/internal/secretprovider" - "github.com/kimdre/doco-cd/internal/config" "github.com/kimdre/doco-cd/internal/docker" "github.com/kimdre/doco-cd/internal/encryption" "github.com/kimdre/doco-cd/internal/git" @@ -201,7 +202,7 @@ func TestHandleEvent(t *testing.T) { }, } - appConfig, err := config.GetAppConfig() + appConfig, err := app.GetConfig() if err != nil { t.Fatalf("failed to get app config: %s", err.Error()) } @@ -211,6 +212,12 @@ func TestHandleEvent(t *testing.T) { t.Fatalf("Failed to create Docker CLI: %v", err) } + t.Cleanup(func() { + if closeErr := dockerCli.Client().Close(); closeErr != nil { + t.Logf("Failed to close Docker client: %v", closeErr) + } + }) + if err := swarm.RefreshModeEnabled(t.Context(), dockerCli.Client()); err != nil { log.Fatalf("Failed to check if Docker daemon is in Swarm mode: %v", err) } @@ -251,13 +258,6 @@ func TestHandleEvent(t *testing.T) { ctx := context.Background() - t.Cleanup(func() { - err = dockerCli.Client().Close() - if err != nil { - return - } - }) - err = docker.VerifySocketConnection() if err != nil { t.Fatalf("Failed to verify docker socket connection: %v", err) @@ -318,7 +318,7 @@ func TestHandleEvent(t *testing.T) { retry.Attempts(3), retry.Delay(1*time.Second), retry.RetryIf(func(err error) bool { - return strings.Contains(err.Error(), "No such image:") + return strings.Contains(strings.ToLower(err.Error()), "no such image") }), ).Do(func() error { HandleEvent( diff --git a/doco-cd-src/cmd/doco-cd/version.go b/doco-cd-src/cmd/doco-cd/version.go index 9b2f3f1..9613317 100644 --- a/doco-cd-src/cmd/doco-cd/version.go +++ b/doco-cd-src/cmd/doco-cd/version.go @@ -4,27 +4,43 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "net/http" "time" "github.com/avast/retry-go/v5" + + "github.com/kimdre/doco-cd/internal/config/app" + + "github.com/kimdre/doco-cd/internal/logger" + "github.com/kimdre/doco-cd/internal/notification" ) +type githubRelease struct { + TagName string `json:"tag_name"` + IsPreRelease bool `json:"prerelease"` + IsDraft bool `json:"draft"` +} + // getLatestAppVersion gets the latest application version from the GitHub releases API. func getLatestAppReleaseVersion() (string, error) { const releaseApiUrl = "https://api.github.com/repos/kimdre/doco-cd/releases" + httpClient := &http.Client{Timeout: 3 * time.Second} + + return getLatestAppReleaseVersionFromURL(releaseApiUrl, httpClient) +} + +func getLatestAppReleaseVersionFromURL(releaseApiURL string, httpClient *http.Client) (string, error) { + if httpClient == nil { + httpClient = http.DefaultClient + } + var ( - releases []struct { - TagName string `json:"tag_name"` - IsPreRelease bool `json:"prerelease"` - IsDraft bool `json:"draft"` - } - resp *http.Response + releases []githubRelease + resp *http.Response ) - httpClient := &http.Client{Timeout: 3 * time.Second} - err := retry.New( retry.Attempts(5), retry.Delay(250*time.Millisecond), @@ -33,14 +49,14 @@ func getLatestAppReleaseVersion() (string, error) { func() error { var err error - resp, err = httpClient.Get(releaseApiUrl) + resp, err = httpClient.Get(releaseApiURL) if err != nil { return err } defer func() { if resp.Body != nil { - resp.Body.Close() + _ = resp.Body.Close() } }() @@ -63,3 +79,27 @@ func getLatestAppReleaseVersion() (string, error) { return "", errors.New("no stable release found") } + +func notificationForNewAppVersion(log *slog.Logger) { + latestVersion, err := getLatestAppReleaseVersion() + if err != nil { + log.Error("failed to get latest application release version", logger.ErrAttr(err)) + } else { + if app.Version != latestVersion { + log.Warn("new application version available", + slog.String("current", app.Version), + slog.String("latest", latestVersion), + ) + + err = notification.Send(notification.Info, + "New version of doco-cd is available", + fmt.Sprintf("Current Version: %s\nLatest Version: %s\n\nhttps://github.com/kimdre/doco-cd/releases", app.Version, latestVersion), + notification.Metadata{}) + if err != nil { + return + } + } else { + log.Debug("application is up to date", slog.String("version", app.Version)) + } + } +} diff --git a/doco-cd-src/cmd/doco-cd/version_test.go b/doco-cd-src/cmd/doco-cd/version_test.go index 8fada71..94940fb 100644 --- a/doco-cd-src/cmd/doco-cd/version_test.go +++ b/doco-cd-src/cmd/doco-cd/version_test.go @@ -1,20 +1,83 @@ package main import ( + "net/http" + "net/http/httptest" + "strings" "testing" ) func TestGetLatestAppReleaseVersion(t *testing.T) { t.Parallel() - version, err := getLatestAppReleaseVersion() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/releases" { + t.Fatalf("expected request path /releases, got %q", r.URL.Path) + } + + w.Header().Set("Content-Type", "application/json") + + _, err := w.Write([]byte(`[ + {"tag_name":"v9.9.9-rc1","prerelease":true,"draft":false}, + {"tag_name":"v9.9.9","prerelease":false,"draft":false}, + {"tag_name":"v9.9.8","prerelease":false,"draft":false} + ]`)) + if err != nil { + t.Fatalf("failed to write test response: %v", err) + } + })) + defer server.Close() + + version, err := getLatestAppReleaseVersionFromURL(server.URL+"/releases", server.Client()) if err != nil { t.Fatalf("expected no error, got %v", err) } - if version == "" { - t.Fatal("expected a version string, got empty") + if version != "v9.9.9" { + t.Fatalf("expected latest stable version %q, got %q", "v9.9.9", version) + } +} + +func TestGetLatestAppReleaseVersion_StatusError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "rate limited", http.StatusForbidden) + })) + defer server.Close() + + _, err := getLatestAppReleaseVersionFromURL(server.URL, server.Client()) + if err == nil { + t.Fatal("expected an error, got nil") } - t.Logf("Latest version: %s", version) + if got := err.Error(); got == "" || !strings.Contains(got, "403 Forbidden") { + t.Fatalf("expected 403 error, got %q", got) + } +} + +func TestGetLatestAppReleaseVersion_NoStableRelease(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + + _, err := w.Write([]byte(`[ + {"tag_name":"v9.9.9-rc1","prerelease":true,"draft":false}, + {"tag_name":"v9.9.9-draft","prerelease":false,"draft":true} + ]`)) + if err != nil { + t.Fatalf("failed to write test response: %v", err) + } + })) + defer server.Close() + + _, err := getLatestAppReleaseVersionFromURL(server.URL, server.Client()) + if err == nil { + t.Fatal("expected an error, got nil") + } + + if got := err.Error(); got != "no stable release found" { + t.Fatalf("expected %q, got %q", "no stable release found", got) + } } diff --git a/doco-cd-src/dev.compose.yaml b/doco-cd-src/dev.compose.yaml index a480db8..7bd29e2 100644 --- a/doco-cd-src/dev.compose.yaml +++ b/doco-cd-src/dev.compose.yaml @@ -23,7 +23,7 @@ services: proxy: condition: "service_started" apprise: - condition: "service_started" + condition: "service_healthy" required: false ports: - "80:80" @@ -74,10 +74,11 @@ services: TZ: Europe/Berlin APPRISE_WORKER_COUNT: 1 #healthcheck: - # test: [ "CMD", "curl", "-f", "http://localhost:8000/" ] - # interval: 2s + # test: [ "CMD-SHELL", "curl -fsS http://localhost:8000/status >/dev/null || exit 1" ] + # interval: 30s # timeout: 5s - # retries: 10 + # retries: 3 + # start_period: 20s grafana: # Dashboard: https://github.com/kimdre/doco-cd/discussions/583 diff --git a/doco-cd-src/go.mod b/doco-cd-src/go.mod index 0abcea5..c4dd12d 100644 --- a/doco-cd-src/go.mod +++ b/doco-cd-src/go.mod @@ -1,6 +1,6 @@ module github.com/kimdre/doco-cd -go 1.26.2 +go 1.26.3 tool ( github.com/bombsimon/wsl/v5/cmd/wsl @@ -11,16 +11,16 @@ tool ( require ( github.com/1password/onepassword-sdk-go v0.4.0 - github.com/aws/aws-sdk-go-v2 v1.41.6 - github.com/aws/aws-sdk-go-v2/config v1.32.16 - github.com/aws/aws-sdk-go-v2/credentials v1.19.15 - github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.6 - github.com/caarlos0/env/v11 v11.4.0 + github.com/aws/aws-sdk-go-v2 v1.41.7 + github.com/aws/aws-sdk-go-v2/config v1.32.17 + github.com/aws/aws-sdk-go-v2/credentials v1.19.16 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.7 + github.com/caarlos0/env/v11 v11.4.1 github.com/compose-spec/compose-go/v2 v2.10.2 github.com/containerd/errdefs v1.0.0 github.com/creasty/defaults v1.8.0 github.com/distribution/reference v0.6.0 - github.com/docker/cli v29.4.1+incompatible + github.com/docker/cli v29.4.3+incompatible github.com/getsops/sops/v3 v3.12.2 github.com/go-git/go-billy/v5 v5.8.0 github.com/go-git/go-git/v5 v5.18.0 @@ -40,12 +40,15 @@ require ( require golang.org/x/sync v0.20.0 require ( + github.com/1Password/connect-sdk-go v1.5.3 github.com/avast/retry-go/v5 v5.0.0 github.com/bitwarden/sdk-go/v2 v2.0.0 github.com/docker/compose/v5 v5.1.3 github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 github.com/opencontainers/go-digest v1.0.0 + github.com/opentracing/opentracing-go v1.2.0 github.com/prometheus/common v0.67.5 + github.com/robfig/cron/v3 v3.0.1 github.com/veqryn/slog-dedup v0.6.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -80,25 +83,25 @@ require ( github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.2 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect github.com/aws/aws-sdk-go-v2/service/kms v1.50.0 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect - github.com/aws/smithy-go v1.25.0 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect + github.com/aws/smithy-go v1.25.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect - github.com/bombsimon/wsl/v5 v5.6.0 // indirect + github.com/bombsimon/wsl/v5 v5.8.0 // indirect github.com/buger/goterm v1.0.4 // indirect github.com/catenacyber/perfsprint v0.10.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect @@ -245,6 +248,8 @@ require ( github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect + github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect + github.com/uber/jaeger-lib v2.4.1+incompatible // indirect github.com/urfave/cli v1.22.17 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect @@ -270,6 +275,7 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect go.opentelemetry.io/otel/trace v1.42.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.uber.org/atomic v1.9.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 // indirect golang.org/x/mod v0.35.0 // indirect @@ -291,6 +297,6 @@ require ( gopkg.in/ini.v1 v1.67.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect modernc.org/b/v2 v2.1.2 // indirect - mvdan.cc/gofumpt v0.9.2 // indirect + mvdan.cc/gofumpt v0.10.0 // indirect tags.cncf.io/container-device-interface v1.1.0 // indirect ) diff --git a/doco-cd-src/go.sum b/doco-cd-src/go.sum index 9acd911..aed1b05 100644 --- a/doco-cd-src/go.sum +++ b/doco-cd-src/go.sum @@ -35,6 +35,8 @@ filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= +github.com/1Password/connect-sdk-go v1.5.3 h1:KyjJ+kCKj6BwB2Y8tPM1Ixg5uIS6HsB0uWA8U38p/Uk= +github.com/1Password/connect-sdk-go v1.5.3/go.mod h1:5rSymY4oIYtS4G3t0oMkGAXBeoYiukV3vkqlnEjIDJs= github.com/1password/onepassword-sdk-go v0.4.0 h1:Nou39yuC6Q0om03irkh5UurfPdX3wx26qZZhQeC9TBU= github.com/1password/onepassword-sdk-go v0.4.0/go.mod h1:j/CbzhucTywjlYrd6SE6k0LcQaFZ2l8OLBsAsOYtvD0= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= @@ -69,6 +71,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob0t8PQPMybUNFM= +github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= @@ -90,60 +94,60 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/avast/retry-go/v5 v5.0.0 h1:kf1Qc2UsTZ4qq8elDymqfbISvkyMuhgRxuJqX2NHP7k= github.com/avast/retry-go/v5 v5.0.0/go.mod h1://d+usmKWio1agtZfS1H/ltTqwtIfBnRq9zEwjc3eH8= -github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= -github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c= -github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM= -github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc= -github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4= -github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg= +github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= +github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.2 h1:1i1SUOTLk0TbMh7+eJYxgv1r1f47BfR69LL6yaELoI0= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.2/go.mod h1:bo7DhmS/OyVeAJTC768nEk92YKWskqJ4gn0gB5e59qQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= github.com/aws/aws-sdk-go-v2/service/kms v1.50.0 h1:XSvRJBoDObL6Sn4cRmvH9wqjxjL7wf1ZDolUEyP7hw4= github.com/aws/aws-sdk-go-v2/service/kms v1.50.0/go.mod h1:1SdcmEGUEQE1mrU2sIgeHtcMSxHuybhPvuEPANzIDfI= github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA= github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= -github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.6 h1:XR42AXidhYs4HwH0I+yElLXVt7zb2hAyNHQJe6Blv7w= -github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.6/go.mod h1:nOTsSVQlAsgwVRdtZYtECSnsInF8IUhrpnclCPat7Fs= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo= -github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= -github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.7 h1:JUGKqUnJHbXpS8uyuICP/zpQ+vXUIXW2zTEqjMLCqrY= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.7/go.mod h1:l/cqI7ujYqBuTR6Ll13d9/gG/uUdlVzJ1UDltEEBTOo= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bitwarden/sdk-go/v2 v2.0.0 h1:7Vf+uOvJe22yrOe2aRg2SFe5iQEP92Res0KzCVDUBfQ= github.com/bitwarden/sdk-go/v2 v2.0.0/go.mod h1:6Sfb4IdZ9tnggeFj8Ty4MLkWUyC2pNlFUoAZE0Dapfw= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= -github.com/bombsimon/wsl/v5 v5.6.0 h1:4z+/sBqC5vUmSp1O0mS+czxwH9+LKXtCWtHH9rZGQL8= -github.com/bombsimon/wsl/v5 v5.6.0/go.mod h1:Uqt2EfrMj2NV8UGoN1f1Y3m0NpUVCsUdrNCdet+8LvU= +github.com/bombsimon/wsl/v5 v5.8.0 h1:JTkyfs4yl8SPejrCF2GdABXE+mO1WvM7iUYzRWlsxDs= +github.com/bombsimon/wsl/v5 v5.8.0/go.mod h1:AbOLsulgkqP4ZnitHf9gwPtCOGlrzkk0jb0uNxRSY0o= github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY= github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE= -github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc= -github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= +github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ= github.com/catenacyber/perfsprint v0.10.1/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -226,8 +230,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/buildx v0.33.0 h1:xuZeuQe/C/2tvLDgiIA6+Ynq3FFWSfsGNWIHM3q1hD8= github.com/docker/buildx v0.33.0/go.mod h1:7JVma62htERKE5iy5YD1q64PKiAHUzXuhSBd4oq3I74= -github.com/docker/cli v29.4.1+incompatible h1:02RT8QqqwtGRn+6SYypv8IUEbD/ltY6sfKCJIoUcGzk= -github.com/docker/cli v29.4.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.4.3+incompatible h1:u+UliYm2J/rYrIh2FqHQg32neRG8GjbvNuwQRTzGspU= +github.com/docker/cli v29.4.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/compose/v5 v5.1.3 h1:Pe8JKGKnL/9xVvuflKmtlCR6AfPTGbML/PbpMJA+Gks= github.com/docker/compose/v5 v5.1.3/go.mod h1:5WS4y+TCdaA6unNMIuVbp7SbMZ1m6KduTAyqAtD8vz8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= @@ -350,8 +354,8 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91 github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= -github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= -github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474= +github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g= github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= @@ -588,6 +592,8 @@ github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5 github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/selinux v1.13.1 h1:A8nNeceYngH9Ow++M+VVEwJVpdFmrlxsN22F+ISDCJE= github.com/opencontainers/selinux v1.13.1/go.mod h1:S10WXZ/osk2kWOYKy1x2f/eXF5ZHJoUs8UU/2caNRbg= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/oracle/oci-go-sdk/v65 v65.95.2 h1:0HJ0AgpLydp/DtvYrF2d4str2BjXOVAeNbuW7E07g94= github.com/oracle/oci-go-sdk/v65 v65.95.2/go.mod h1:u6XRPsw9tPziBh76K7GrrRXPa8P8W3BQeqJ6ZZt9VLA= github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw= @@ -622,6 +628,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= @@ -673,6 +681,7 @@ github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xI github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -710,6 +719,10 @@ github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c h1:5a2XDQ github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c/go.mod h1:g85IafeFJZLxlzZCDRu4JLpfS7HKzR+Hw9qRh3bVzDI= github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= +github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o= +github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg= +github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= @@ -779,6 +792,8 @@ go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhn go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= @@ -976,8 +991,8 @@ modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= -mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4= -mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s= +mvdan.cc/gofumpt v0.10.0 h1:yGGpRS2pBN2OQIi7b21IXknJna7faPkFaVfHLrN6Euo= +mvdan.cc/gofumpt v0.10.0/go.mod h1:sU2ElXHzOEmvoPqfutYG7uunlueR4K2T1JFml40SzP4= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= tags.cncf.io/container-device-interface v1.1.0 h1:RnxNhxF1JOu6CJUVpetTYvrXHdxw9j9jFYgZpI+anSY= diff --git a/doco-cd-src/internal/config/app_config.go b/doco-cd-src/internal/config/app/app_config.go similarity index 64% rename from doco-cd-src/internal/config/app_config.go rename to doco-cd-src/internal/config/app/app_config.go index d863a32..8f7a1d6 100644 --- a/doco-cd-src/internal/config/app_config.go +++ b/doco-cd-src/internal/config/app/app_config.go @@ -1,9 +1,13 @@ -package config +package app import ( + "errors" "fmt" "strings" + "github.com/kimdre/doco-cd/internal/config" + "github.com/kimdre/doco-cd/internal/config/poll" + "github.com/kimdre/doco-cd/internal/git" "github.com/kimdre/doco-cd/internal/notification" "github.com/go-git/go-git/v5/plumbing/transport" @@ -11,13 +15,16 @@ import ( "gopkg.in/validator.v2" ) -const AppName = "doco-cd" // Name of the application +const Name = "doco-cd" // Name of the application -var AppVersion = "dev" // Version of the application, to be set during build time +var ( + Version = "dev" // Version of the application, to be set during build time + ErrInvalidLogLevel = validator.TextErr{Err: errors.New("invalid log level, must be one of debug, info, warn, error")} +) -// AppConfig is used to configure this application +// Config is used to configure this application // https://github.com/caarlos0/env?tab=readme-ov-file#env-tag-options -type AppConfig struct { +type Config struct { LogLevel string `env:"LOG_LEVEL,notEmpty" envDefault:"info"` // LogLevel is the log level for the application HttpPort uint16 `env:"HTTP_PORT,notEmpty" envDefault:"80" validate:"min=1,max=65535"` // HttpPort is the port the HTTP server will listen on HttpProxyString string `env:"HTTP_PROXY"` // HttpProxyString is the HTTP proxy URL as a string @@ -28,6 +35,14 @@ type AppConfig struct { WebhookSecretFile string `env:"WEBHOOK_SECRET_FILE,file"` // WebhookSecretFile is the file containing the WebhookSecret GitAccessToken string `env:"GIT_ACCESS_TOKEN"` // GitAccessToken is the access token used to authenticate with the Git server (e.g. GitHub) for private repositories GitAccessTokenFile string `env:"GIT_ACCESS_TOKEN_FILE,file"` // GitAccessTokenFile is the file containing the GitAccessToken + GitHubAppID string `env:"GITHUB_APP_ID"` // GitHubAppID is the GitHub App identifier used to mint installation access tokens + GitHubAppIDFile string `env:"GITHUB_APP_ID_FILE,file"` // GitHubAppIDFile is the file containing the GitHub App identifier + GitHubAppPrivateKey string `env:"GITHUB_APP_PRIVATE_KEY"` // GitHubAppPrivateKey is the PEM private key for the GitHub App + GitHubAppPrivateKeyFile string `env:"GITHUB_APP_PRIVATE_KEY_FILE,file"` // GitHubAppPrivateKeyFile is the file containing the GitHub App private key + GitHubAppInstallationID int64 `env:"GITHUB_APP_INSTALLATION_ID"` // GitHubAppInstallationID optionally pins a specific installation id (0 means auto-detect via owner/repo) + GitAuthDomainsYAML string `env:"GIT_AUTH_DOMAINS"` // GitAuthDomainsYAML is the YAML configuration for domain-scoped Git credentials + GitAuthDomainsFile string `env:"GIT_AUTH_DOMAINS_FILE,file"` // GitAuthDomainsFile is the file containing the YAML configuration for domain-scoped Git credentials + GitAuthDomains []git.ScopedAuthConfig `yaml:"-"` // GitAuthDomains holds parsed domain-scoped Git credentials GitCloneDepth int `env:"GIT_CLONE_DEPTH,notEmpty" envDefault:"0" validate:"min=0"` // GitCloneDepth limits the number of commits to fetch. 0 means full clone (no depth limit). A positive value enables shallow clones. GitCloneSubmodules bool `env:"GIT_CLONE_SUBMODULES,notEmpty" envDefault:"true"` // GitCloneSubmodules controls whether git submodules are cloned SSHPrivateKey string `env:"SSH_PRIVATE_KEY"` // SSHPrivateKey is the SSH private key used for SSH authentication with Git repositories @@ -42,10 +57,10 @@ type AppConfig struct { PassEnv bool `env:"PASS_ENV"` // PassEnv controls whether environment variables from the doco-cd container should be passed to the deployment environment for docker compose variable interpolation. Use with caution, as this may expose sensitive information to the deployment environment. PollConfigYAML string `env:"POLL_CONFIG"` // PollConfigYAML is the unparsed string containing the PollConfig in YAML format PollConfigFile string `env:"POLL_CONFIG_FILE,file"` // PollConfigFile is the file containing the PollConfig in YAML format - PollConfig []PollConfig `yaml:"-"` // PollConfig is the YAML configuration for polling Git repositories for changes + PollConfig []poll.Config `yaml:"-"` // PollConfig is the YAML configuration for polling Git repositories for changes MaxPayloadSize int64 `env:"MAX_PAYLOAD_SIZE,notEmpty" envDefault:"1048576"` // MaxPayloadSize is the maximum size of the payload in bytes that the HTTP server will accept (default 1MB = 1048576 bytes) MetricsPort uint16 `env:"METRICS_PORT,notEmpty" envDefault:"9120" validate:"min=1,max=65535"` // MetricsPort is the port the prometheus metrics server will listen on - AppriseApiURL HttpUrl `env:"APPRISE_API_URL" validate:"httpUrl"` // AppriseApiURL is the URL of the Apprise notification service + AppriseApiURL config.HttpUrl `env:"APPRISE_API_URL" validate:"httpUrl"` // AppriseApiURL is the URL of the Apprise notification service AppriseNotifyUrls string `env:"APPRISE_NOTIFY_URLS"` // AppriseNotifyUrls is a comma-separated list of URLs to notify via the Apprise notification service AppriseNotifyUrlsFile string `env:"APPRISE_NOTIFY_URLS_FILE,file"` // AppriseNotifyUrlsFile is the file containing the AppriseNotifyUrls AppriseNotifyLevel string `env:"APPRISE_NOTIFY_LEVEL,notEmpty" envDefault:"success"` // AppriseNotifyLevel is the level of notifications to send via the Apprise notification service @@ -54,32 +69,45 @@ type AppConfig struct { MaxConcurrentDeployments uint `env:"MAX_CONCURRENT_DEPLOYMENTS,notEmpty" envDefault:"4" validate:"min=1"` // Maximum number of concurrent deployments allowed } -// GetAppConfig returns the configuration. -func GetAppConfig() (*AppConfig, error) { - cfg := AppConfig{} +// GetConfig returns the app Config. +func GetConfig() (*Config, error) { + cfg := Config{} - mappings := []EnvVarFileMapping{ + mappings := []config.EnvVarFileMapping{ {EnvName: "API_SECRET", EnvValue: &cfg.ApiSecret, FileValue: &cfg.ApiSecretFile, AllowUnset: true}, {EnvName: "APPRISE_NOTIFY_URLS", EnvValue: &cfg.AppriseNotifyUrls, FileValue: &cfg.AppriseNotifyUrlsFile, AllowUnset: true}, {EnvName: "GIT_ACCESS_TOKEN", EnvValue: &cfg.GitAccessToken, FileValue: &cfg.GitAccessTokenFile, AllowUnset: true}, + {EnvName: "GITHUB_APP_ID", EnvValue: &cfg.GitHubAppID, FileValue: &cfg.GitHubAppIDFile, AllowUnset: true}, + {EnvName: "GITHUB_APP_PRIVATE_KEY", EnvValue: &cfg.GitHubAppPrivateKey, FileValue: &cfg.GitHubAppPrivateKeyFile, AllowUnset: true}, + {EnvName: "GIT_AUTH_DOMAINS", EnvValue: &cfg.GitAuthDomainsYAML, FileValue: &cfg.GitAuthDomainsFile, AllowUnset: true}, {EnvName: "SSH_PRIVATE_KEY", EnvValue: &cfg.SSHPrivateKey, FileValue: &cfg.SSHPrivateKeyFile, AllowUnset: true}, {EnvName: "SSH_PRIVATE_KEY_PASSPHRASE", EnvValue: &cfg.SSHPrivateKeyPassphrase, FileValue: &cfg.SSHPrivateKeyPassphraseFile, AllowUnset: true}, {EnvName: "WEBHOOK_SECRET", EnvValue: &cfg.WebhookSecret, FileValue: &cfg.WebhookSecretFile, AllowUnset: true}, } - err := ParseConfigFromEnv(&cfg, &mappings) + err := config.ParseConfigFromEnv(&cfg, &mappings) if err != nil { - return nil, fmt.Errorf("%w: %w", ErrParseConfigFailed, err) + return nil, fmt.Errorf("%w: %w", config.ErrParseConfigFailed, err) } - err = cfg.ParsePollConfig() + err = cfg.parsePollConfig() if err != nil { return nil, fmt.Errorf("failed to parse poll config: %w", err) } + err = cfg.parseGitAuthDomains() + if err != nil { + return nil, fmt.Errorf("failed to parse GIT_AUTH_DOMAINS: %w", err) + } + + err = cfg.validateGitAuthConfig() + if err != nil { + return nil, err + } + for _, pollConfig := range cfg.PollConfig { if err = pollConfig.Validate(); err != nil { - return nil, fmt.Errorf("%w: %w", ErrInvalidPollConfig, err) + return nil, fmt.Errorf("%w: %w", poll.ErrInvalidConfig, err) } } @@ -116,10 +144,62 @@ func GetAppConfig() (*AppConfig, error) { return &cfg, nil } -// ParsePollConfig parses the PollConfig from either the PollConfigYAML string or the PollConfigFile. -func (cfg *AppConfig) ParsePollConfig() error { +// parseGitAuthDomains parses domain-scoped Git credentials from GIT_AUTH_DOMAINS (or *_FILE content). +func (cfg *Config) parseGitAuthDomains() error { + if strings.TrimSpace(cfg.GitAuthDomainsYAML) == "" { + cfg.GitAuthDomains = []git.ScopedAuthConfig{} + + return nil + } + + if err := yaml.Unmarshal([]byte(cfg.GitAuthDomainsYAML), &cfg.GitAuthDomains); err != nil { + return err + } + + return nil +} + +func (cfg *Config) validateGitAuthConfig() error { + cfg.GitHubAppID = strings.TrimSpace(cfg.GitHubAppID) + cfg.GitHubAppPrivateKey = strings.TrimSpace(cfg.GitHubAppPrivateKey) + + globalToken := strings.TrimSpace(cfg.GitAccessToken) + + hasCompleteGlobalApp := cfg.GitHubAppID != "" && cfg.GitHubAppPrivateKey != "" + if hasCompleteGlobalApp { + if globalToken != "" { + return errors.New("GIT_ACCESS_TOKEN cannot be combined with global GitHub App credentials") + } + } else { + // Incomplete global app credentials are ignored to keep startup resilient in mixed environments. + cfg.GitHubAppID = "" + cfg.GitHubAppPrivateKey = "" + cfg.GitHubAppInstallationID = 0 + } + + for i, entry := range cfg.GitAuthDomains { + hasToken := strings.TrimSpace(entry.GitAccessToken) != "" + hasSSH := strings.TrimSpace(entry.SSHPrivateKey) != "" + hasApp := strings.TrimSpace(entry.GitHubAppID) != "" || strings.TrimSpace(entry.GitHubAppPrivateKey) != "" + + if hasApp { + if strings.TrimSpace(entry.GitHubAppID) == "" || strings.TrimSpace(entry.GitHubAppPrivateKey) == "" { + return fmt.Errorf("GIT_AUTH_DOMAINS[%d]: both github_app_id and github_app_private_key are required", i) + } + + if hasToken || hasSSH { + return fmt.Errorf("GIT_AUTH_DOMAINS[%d]: github app credentials cannot be combined with git_access_token or ssh_private_key", i) + } + } + } + + return nil +} + +// parsePollConfig parses the PollConfig from either the PollConfigYAML string or the PollConfigFile. +func (cfg *Config) parsePollConfig() error { if cfg.PollConfigYAML != "" && cfg.PollConfigFile != "" { - return ErrBothPollConfigSet + return poll.ErrBothConfigSet } if cfg.PollConfigYAML != "" { @@ -130,7 +210,7 @@ func (cfg *AppConfig) ParsePollConfig() error { return yaml.Unmarshal([]byte(cfg.PollConfigFile), &cfg.PollConfig) } - cfg.PollConfig = []PollConfig{} // Default to an empty slice if no config is provided + cfg.PollConfig = []poll.Config{} // Default to an empty slice if no config is provided return nil } diff --git a/doco-cd-src/internal/config/app/app_config_test.go b/doco-cd-src/internal/config/app/app_config_test.go new file mode 100644 index 0000000..c54094c --- /dev/null +++ b/doco-cd-src/internal/config/app/app_config_test.go @@ -0,0 +1,259 @@ +package app + +import ( + "errors" + "os" + "path" + "strconv" + "testing" + + "github.com/kimdre/doco-cd/internal/config" + "github.com/kimdre/doco-cd/internal/filesystem" +) + +func TestGetConfig(t *testing.T) { + // Set up test cases + tests := []struct { + name string + envVars map[string]string + dockerSecrets map[string]string + expectedErr error + }{ + { + name: "valid config", + envVars: map[string]string{ + "LOG_LEVEL": "info", + "HTTP_PORT": "8080", + "WEBHOOK_SECRET": "secret", + "AUTH_TYPE": "oauth2", + "GIT_ACCESS_TOKEN": "token", + "SKIP_TLS_VERIFICATION": "false", + }, + dockerSecrets: nil, + expectedErr: nil, + }, + { + name: "invalid log level", + envVars: map[string]string{ + "LOG_LEVEL": "invalid", + "WEBHOOK_SECRET": "secret", + "GIT_ACCESS_TOKEN": "token", + }, + dockerSecrets: nil, + expectedErr: ErrInvalidLogLevel, + }, + { + name: "valid config with docker secrets", + envVars: map[string]string{ + "LOG_LEVEL": "info", + "HTTP_PORT": "8080", + "AUTH_TYPE": "oauth2", + "SKIP_TLS_VERIFICATION": "false", + }, + dockerSecrets: map[string]string{ + "WEBHOOK_SECRET": "webh00k_secret", + "GIT_ACCESS_TOKEN": "t0ken", + }, + expectedErr: nil, + }, + { + name: "config with duplicate secrets", + envVars: map[string]string{ + "LOG_LEVEL": "info", + "HTTP_PORT": "8080", + "AUTH_TYPE": "oauth2", + "SKIP_TLS_VERIFICATION": "false", + "WEBHOOK_SECRET": "webh00k_secret", + }, + dockerSecrets: map[string]string{ + "WEBHOOK_SECRET": "webh00k_secret", + "GIT_ACCESS_TOKEN": "t0ken", + }, + expectedErr: config.ErrBothSecretsSet, + }, + { + name: "valid config with scoped git auth domains", + envVars: map[string]string{ + "LOG_LEVEL": "info", + "HTTP_PORT": "8080", + "WEBHOOK_SECRET": "secret", + "GIT_AUTH_DOMAINS": "- domains:\n - github.com\n git_access_token: gh-token\n- domains:\n - '*.example.com'\n ssh_private_key: test-key\n ssh_private_key_passphrase: pass", + }, + dockerSecrets: nil, + expectedErr: nil, + }, + { + name: "valid config with scoped git auth domains from file", + envVars: map[string]string{ + "LOG_LEVEL": "info", + "HTTP_PORT": "8080", + "WEBHOOK_SECRET": "secret", + }, + dockerSecrets: map[string]string{ + "GIT_AUTH_DOMAINS": "- domains:\n - gitlab.com\n git_access_token: gl-token", + }, + expectedErr: nil, + }, + { + name: "config with duplicate scoped git auth domains", + envVars: map[string]string{ + "LOG_LEVEL": "info", + "HTTP_PORT": "8080", + "WEBHOOK_SECRET": "secret", + "GIT_AUTH_DOMAINS": "- domains:\n - github.com\n git_access_token: gh-token", + }, + dockerSecrets: map[string]string{ + "GIT_AUTH_DOMAINS": "- domains:\n - gitlab.com\n git_access_token: gl-token", + }, + expectedErr: config.ErrBothSecretsSet, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.dockerSecrets != nil { + secretsPath := path.Join(t.TempDir(), "/run/secrets/") + + // Create the Docker secrets directory + if err := os.MkdirAll(secretsPath, filesystem.PermDir); err != nil { + t.Fatalf("failed to create Docker secrets directory: %v", err) + } + + // Set up Docker secrets as environment variables + for k, v := range tt.dockerSecrets { + // Temporarily unset the original environment variable if it exists to avoid conflicts with the *_FILE variable + if _, exists := os.LookupEnv(k); exists { + t.Setenv(k, "") + } + + secretFileEnvVar := k + "_FILE" + secretFilePath := path.Join(secretsPath, k) + + // Set the app config *_FILE environment variable + t.Logf("Set environment file variable %s to %s with content '%s'", secretFileEnvVar, secretFilePath, v) + + t.Setenv(secretFileEnvVar, secretFilePath) + + if err := os.WriteFile(secretFilePath, []byte(v), filesystem.PermOwner); err != nil { + t.Fatalf("failed to write Docker secret: %v", err) + } + } + } + + // Set up the environment + for k, v := range tt.envVars { + t.Logf("Set environment variable %s to %s", k, v) + t.Setenv(k, v) + } + + // Run the test + cfg, err := GetConfig() + if err != nil { + if errors.Is(err, tt.expectedErr) { + return + } + + t.Fatalf("expected error to be '%v', got '%v'", tt.expectedErr, err) + } + + if tt.dockerSecrets != nil { + // Compare the config values with the expected values + if expectedWebhookSecret, ok := tt.dockerSecrets["WEBHOOK_SECRET"]; ok && cfg.WebhookSecret != expectedWebhookSecret { + t.Errorf("expected WebhookSecret to be '%s', got '%s'", expectedWebhookSecret, cfg.WebhookSecret) + } + + if expectedGitAccessToken, ok := tt.dockerSecrets["GIT_ACCESS_TOKEN"]; ok && cfg.GitAccessToken != expectedGitAccessToken { + t.Errorf("expected GitAccessToken to be '%s', got '%s'", expectedGitAccessToken, cfg.GitAccessToken) + } + + httpPort, err := strconv.ParseUint(tt.envVars["HTTP_PORT"], 10, 16) + if err != nil { + t.Fatalf("failed to parse HTTP_PORT: %v", err) + } + + if cfg.HttpPort != uint16(httpPort) { + t.Errorf("expected HttpPort to be '%d', got '%d'", httpPort, cfg.HttpPort) + } + } + + if _, ok := tt.envVars["GIT_AUTH_DOMAINS"]; ok { + if len(cfg.GitAuthDomains) != 2 { + t.Fatalf("expected 2 scoped git auth entries, got %d", len(cfg.GitAuthDomains)) + } + + if cfg.GitAuthDomains[0].GitAccessToken != "gh-token" { + t.Fatalf("expected first scoped token to be 'gh-token', got '%s'", cfg.GitAuthDomains[0].GitAccessToken) + } + + if len(cfg.GitAuthDomains[1].Domains) != 1 || cfg.GitAuthDomains[1].Domains[0] != "*.example.com" { + t.Fatalf("expected wildcard domain '*.example.com', got '%v'", cfg.GitAuthDomains[1].Domains) + } + } + + if tt.dockerSecrets != nil { + if _, ok := tt.dockerSecrets["GIT_AUTH_DOMAINS"]; ok { + if len(cfg.GitAuthDomains) != 1 { + t.Fatalf("expected 1 scoped git auth entry from file, got %d", len(cfg.GitAuthDomains)) + } + + if cfg.GitAuthDomains[0].GitAccessToken != "gl-token" { + t.Fatalf("expected scoped token from file to be 'gl-token', got '%s'", cfg.GitAuthDomains[0].GitAccessToken) + } + } + } + }) + } +} + +func TestGetConfig_GlobalGitHubAppValidation(t *testing.T) { + t.Setenv("LOG_LEVEL", "info") + t.Setenv("HTTP_PORT", "8080") + t.Setenv("WEBHOOK_SECRET", "secret") + t.Setenv("GIT_ACCESS_TOKEN", "") + t.Setenv("GIT_ACCESS_TOKEN_FILE", "") + t.Setenv("GITHUB_APP_ID", "12345") + t.Setenv("GITHUB_APP_PRIVATE_KEY", "test-private-key") + + if _, err := GetConfig(); err != nil { + t.Fatalf("expected global GitHub App config to be accepted, got %v", err) + } +} + +func TestGetConfig_GlobalGitHubAppRejectsTokenMix(t *testing.T) { + t.Setenv("LOG_LEVEL", "info") + t.Setenv("HTTP_PORT", "8080") + t.Setenv("WEBHOOK_SECRET", "secret") + t.Setenv("GIT_ACCESS_TOKEN_FILE", "") + t.Setenv("GITHUB_APP_ID", "12345") + t.Setenv("GITHUB_APP_PRIVATE_KEY", "test-private-key") + t.Setenv("GIT_ACCESS_TOKEN", "token") + + if _, err := GetConfig(); err == nil { + t.Fatal("expected an error when combining GIT_ACCESS_TOKEN with global GitHub App credentials") + } +} + +func TestGetConfig_ScopedGitHubAppValidation(t *testing.T) { + t.Setenv("LOG_LEVEL", "info") + t.Setenv("HTTP_PORT", "8080") + t.Setenv("WEBHOOK_SECRET", "secret") + t.Setenv("GIT_ACCESS_TOKEN", "") + t.Setenv("GIT_ACCESS_TOKEN_FILE", "") + t.Setenv("GIT_AUTH_DOMAINS", "- domains:\n - github.com\n github_app_id: '12345'\n github_app_private_key: test-private-key") + + if _, err := GetConfig(); err != nil { + t.Fatalf("expected scoped GitHub App config to be accepted, got %v", err) + } +} + +func TestGetConfig_ScopedGitHubAppRejectsTokenMix(t *testing.T) { + t.Setenv("LOG_LEVEL", "info") + t.Setenv("HTTP_PORT", "8080") + t.Setenv("WEBHOOK_SECRET", "secret") + t.Setenv("GIT_ACCESS_TOKEN_FILE", "") + t.Setenv("GIT_AUTH_DOMAINS", "- domains:\n - github.com\n git_access_token: gh-token\n github_app_id: '12345'\n github_app_private_key: test-private-key") + + if _, err := GetConfig(); err == nil { + t.Fatal("expected an error when combining scoped git_access_token with scoped github app credentials") + } +} diff --git a/doco-cd-src/internal/config/app_config_test.go b/doco-cd-src/internal/config/app_config_test.go deleted file mode 100644 index 83faa19..0000000 --- a/doco-cd-src/internal/config/app_config_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package config - -import ( - "errors" - "os" - "path" - "strconv" - "testing" - - "github.com/kimdre/doco-cd/internal/filesystem" -) - -func TestGetAppConfig(t *testing.T) { - // Set up test cases - tests := []struct { - name string - envVars map[string]string - dockerSecrets map[string]string - expectedErr error - }{ - { - name: "valid config", - envVars: map[string]string{ - "LOG_LEVEL": "info", - "HTTP_PORT": "8080", - "WEBHOOK_SECRET": "secret", - "AUTH_TYPE": "oauth2", - "GIT_ACCESS_TOKEN": "token", - "SKIP_TLS_VERIFICATION": "false", - }, - dockerSecrets: nil, - expectedErr: nil, - }, - { - name: "invalid log level", - envVars: map[string]string{ - "LOG_LEVEL": "invalid", - "WEBHOOK_SECRET": "secret", - "GIT_ACCESS_TOKEN": "token", - }, - dockerSecrets: nil, - expectedErr: ErrInvalidLogLevel, - }, - { - name: "valid config with docker secrets", - envVars: map[string]string{ - "LOG_LEVEL": "info", - "HTTP_PORT": "8080", - "AUTH_TYPE": "oauth2", - "SKIP_TLS_VERIFICATION": "false", - }, - dockerSecrets: map[string]string{ - "WEBHOOK_SECRET": "webh00k_secret", - "GIT_ACCESS_TOKEN": "t0ken", - }, - expectedErr: nil, - }, - { - name: "config with duplicate secrets", - envVars: map[string]string{ - "LOG_LEVEL": "info", - "HTTP_PORT": "8080", - "AUTH_TYPE": "oauth2", - "SKIP_TLS_VERIFICATION": "false", - "WEBHOOK_SECRET": "webh00k_secret", - }, - dockerSecrets: map[string]string{ - "WEBHOOK_SECRET": "webh00k_secret", - "GIT_ACCESS_TOKEN": "t0ken", - }, - expectedErr: ErrBothSecretsSet, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.dockerSecrets != nil { - secretsPath := path.Join(t.TempDir(), "/run/secrets/") - - // Create the Docker secrets directory - if err := os.MkdirAll(secretsPath, filesystem.PermDir); err != nil { - t.Fatalf("failed to create Docker secrets directory: %v", err) - } - - // Set up Docker secrets as environment variables - for k, v := range tt.dockerSecrets { - // Temporarily unset the original environment variable if it exists to avoid conflicts with the *_FILE variable - if _, exists := os.LookupEnv(k); exists { - t.Setenv(k, "") - } - - secretFileEnvVar := k + "_FILE" - secretFilePath := path.Join(secretsPath, k) - - // Set the app config *_FILE environment variable - t.Logf("Set environment file variable %s to %s with content '%s'", secretFileEnvVar, secretFilePath, v) - - t.Setenv(secretFileEnvVar, secretFilePath) - - if err := os.WriteFile(secretFilePath, []byte(v), filesystem.PermOwner); err != nil { - t.Fatalf("failed to write Docker secret: %v", err) - } - } - } - - // Set up the environment - for k, v := range tt.envVars { - t.Logf("Set environment variable %s to %s", k, v) - t.Setenv(k, v) - } - - // Run the test - cfg, err := GetAppConfig() - if err != nil { - if errors.Is(err, tt.expectedErr) { - return - } - - t.Fatalf("expected error to be '%v', got '%v'", tt.expectedErr, err) - } - - if tt.dockerSecrets != nil { - // Compare the config values with the expected values - if cfg.WebhookSecret != tt.dockerSecrets["WEBHOOK_SECRET"] { - t.Errorf("expected WebhookSecret to be '%s', got '%s'", tt.dockerSecrets["WEBHOOK_SECRET"], cfg.WebhookSecret) - } - - if cfg.GitAccessToken != tt.dockerSecrets["GIT_ACCESS_TOKEN"] { - t.Errorf("expected GitAccessToken to be '%s', got '%s'", tt.dockerSecrets["GIT_ACCESS_TOKEN"], cfg.GitAccessToken) - } - - httpPort, err := strconv.ParseUint(tt.envVars["HTTP_PORT"], 10, 16) - if err != nil { - t.Fatalf("failed to parse HTTP_PORT: %v", err) - } - - if cfg.HttpPort != uint16(httpPort) { - t.Errorf("expected HttpPort to be '%d', got '%d'", httpPort, cfg.HttpPort) - } - } - }) - } -} diff --git a/doco-cd-src/internal/config/deploy/auto_discovery.go b/doco-cd-src/internal/config/deploy/auto_discovery.go new file mode 100644 index 0000000..0a9a03f --- /dev/null +++ b/doco-cd-src/internal/config/deploy/auto_discovery.go @@ -0,0 +1,291 @@ +package deploy + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "maps" + "os" + "path/filepath" + "reflect" + "strings" + + "go.yaml.in/yaml/v3" + + secrettypes "github.com/kimdre/doco-cd/internal/secretprovider/types" +) + +// AutoDiscoveryConfig holds auto-discovery settings for a deployment. +type AutoDiscoveryConfig struct { + Enabled bool `yaml:"enabled" json:"enabled" default:"false"` // Enabled enables autodiscovery of services to deploy in the working directory + ScanDepth int `yaml:"depth" json:"depth" default:"0"` // ScanDepth is the maximum depth of subdirectories to scan for docker-compose files + Delete bool `yaml:"delete" json:"delete" default:"true"` // Delete removes obsolete auto-discovered deployments that are no longer present in the repository +} + +func (c *AutoDiscoveryConfig) UnmarshalYAML(node *yaml.Node) error { + switch node.Kind { + case yaml.ScalarNode: + var enabled bool + if err := node.Decode(&enabled); err != nil { + return errors.New("invalid auto_discovery value: expected bool or object") + } + + c.Enabled = enabled + + return nil + case yaml.MappingNode: + type plain AutoDiscoveryConfig + + decoded := plain(*c) + if err := node.Decode(&decoded); err != nil { + return err + } + + *c = AutoDiscoveryConfig(decoded) + + return nil + default: + return errors.New("invalid auto_discovery value: expected bool or object") + } +} + +func (c *AutoDiscoveryConfig) UnmarshalJSON(data []byte) error { + if bytes.Equal(bytes.TrimSpace(data), []byte("true")) || bytes.Equal(bytes.TrimSpace(data), []byte("false")) { + var enabled bool + if err := json.Unmarshal(data, &enabled); err != nil { + return errors.New("invalid auto_discovery value: expected bool or object") + } + + c.Enabled = enabled + + return nil + } + + type plain AutoDiscoveryConfig + + decoded := plain(*c) + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + + *c = AutoDiscoveryConfig(decoded) + + return nil +} + +// expandInlineAutoDiscoverConfigs replaces inline deployments that have auto-discovery +// enabled with the discovered deployments rooted at repoRoot. +func expandInlineAutoDiscoverConfigs(repoRoot string, deployments []*Config) ([]*Config, error) { + expanded := make([]*Config, 0, len(deployments)) + + for _, deployment := range deployments { + if !deployment.AutoDiscovery.Enabled { + expanded = append(expanded, deployment) + continue + } + + discoveredConfigs, err := autoDiscoverDeployments(repoRoot, deployment) + if err != nil { + return nil, fmt.Errorf("failed to auto-discover deployment configurations: %w", err) + } + + expanded = append(expanded, discoveredConfigs...) + } + + return expanded, nil +} + +// autoDiscoverDeployments scans for subdirectories containing docker-compose files +// and generates Config entries for each. +// repoRoot is the absolute path to the repository root. +// baseConfig.WorkingDirectory is treated as repo-root-relative. +func autoDiscoverDeployments(repoRoot string, baseConfig *Config) ([]*Config, error) { + var configs []*Config + + searchPath := filepath.Join(repoRoot, baseConfig.WorkingDirectory) + + err := filepath.WalkDir(searchPath, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + // Calculate the depth of the current path relative to the search path + rel, err := filepath.Rel(searchPath, p) + if err != nil { + return err + } + + depth := 0 + if rel != "." { + depth = len(strings.Split(rel, string(os.PathSeparator))) + } + + // Skip directories that exceed the maximum depth if ScanDepth is set greater than 0 + if d.IsDir() && depth > baseConfig.AutoDiscovery.ScanDepth && baseConfig.AutoDiscovery.ScanDepth > 0 { + return filepath.SkipDir + } + + if !d.IsDir() { + return nil + } + + // Check if the directory contains any docker-compose files + for _, composeFile := range baseConfig.ComposeFiles { + composeFilePath := filepath.Join(p, composeFile) + if _, err = os.Stat(composeFilePath); err == nil { + c := &Config{} + deepCopy(baseConfig, c) + + stackDirName := filepath.Base(p) // Get the stack name from the directory name where the compose file is located + repoName := filepath.Base(repoRoot) // Get the repository name from the repo root path + + if baseConfig.Name != "" && stackDirName == repoName { + c.Name = baseConfig.Name + } else { + c.Name = stackDirName + } + + c.WorkingDirectory, err = filepath.Rel(repoRoot, p) + if err != nil { + return err + } + + // Check for a nested .doco-cd config file alongside the compose file and + // merge any overridable fields from it on top of the base config copy. + for _, cfgName := range DefaultDeploymentConfigFileNames { + localCfgPath := filepath.Join(p, cfgName) + if _, statErr := os.Stat(localCfgPath); statErr != nil { + continue + } + + localConfigs, parseErr := GetConfigFromYAML(localCfgPath, false) + if parseErr != nil { + return fmt.Errorf("failed to parse nested .doco-cd config at %s: %w", localCfgPath, parseErr) + } + + if len(localConfigs) > 1 { + return fmt.Errorf("%w: %s contains %d documents", ErrMultipleYAMLDocuments, localCfgPath, len(localConfigs)) + } + + mergeConfig(c, localConfigs[0]) + + break // use first found config file name (.yaml preferred over .yml) + } + + if err = c.Validate(); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidConfig, err) + } + + configs = append(configs, c) + + break + } + } + + return nil + }) + if err != nil { + return nil, err + } + + return configs, nil +} + +// mergeConfig merges Config fields from override into base, but only for fields +// tagged with `doco:"allowOverride"`. Protected fields (reference, repository_url, +// auto_discovery, git_depth) are never overridden. +// Merge semantics: +// - Maps: merged key-by-key (override wins on key collision) +// - Slices: replaced entirely if the override slice is non-empty +// - Nested structs: all sub-fields are merged (parent tag opts them in) +// - Scalars: replaced if the override holds a non-zero value. +func mergeConfig(base, override *Config) { + mergeStructByTag(reflect.ValueOf(base).Elem(), reflect.ValueOf(override).Elem()) +} + +// mergeStructByTag iterates a struct's fields and merges only those tagged doco:"allowOverride". +func mergeStructByTag(base, override reflect.Value) { + t := base.Type() + for i := 0; i < t.NumField(); i++ { + if t.Field(i).Tag.Get("doco") != "allowOverride" { + continue + } + + mergeField(base.Field(i), override.Field(i)) + } +} + +// mergeField applies a single field merge from override into base. +// For structs the merge recurses into all sub-fields (no tag check – parent has opted in). +func mergeField(base, override reflect.Value) { + switch base.Kind() { + case reflect.Map: + if override.IsNil() || override.Len() == 0 { + return + } + + if base.IsNil() { + base.Set(reflect.MakeMap(base.Type())) + } + + for _, k := range override.MapKeys() { + base.SetMapIndex(k, override.MapIndex(k)) + } + + case reflect.Slice: + if override.IsNil() || override.Len() == 0 { + return + } + + base.Set(override) + + case reflect.Struct: + // Recurse into all sub-fields; the parent tag already opted them in. + mergeAllStructFields(base, override) + + default: + // Scalar: apply only when the override holds a non-zero value. + if !override.IsZero() { + base.Set(override) + } + } +} + +// mergeAllStructFields merges every field of override into base without tag checks. +func mergeAllStructFields(base, override reflect.Value) { + for i := 0; i < base.NumField(); i++ { + mergeField(base.Field(i), override.Field(i)) + } +} + +// deepCopy creates a deep copy of a Config struct. +func deepCopy(src, dst *Config) { + *dst = *src + + // Deep copy maps and slices + if src.ComposeFiles != nil { + dst.ComposeFiles = make([]string, len(src.ComposeFiles)) + copy(dst.ComposeFiles, src.ComposeFiles) + } + + if src.EnvFiles != nil { + dst.EnvFiles = make([]string, len(src.EnvFiles)) + copy(dst.EnvFiles, src.EnvFiles) + } + + if src.BuildOpts.Args != nil { + dst.BuildOpts.Args = make(map[string]string) + maps.Copy(dst.BuildOpts.Args, src.BuildOpts.Args) + } + + if src.Profiles != nil { + dst.Profiles = make([]string, len(src.Profiles)) + copy(dst.Profiles, src.Profiles) + } + + if src.ExternalSecrets != nil { + dst.ExternalSecrets = make(map[string]secrettypes.ExternalSecretRef) + maps.Copy(dst.ExternalSecrets, src.ExternalSecrets) + } +} diff --git a/doco-cd-src/internal/config/deploy/auto_discovery_test.go b/doco-cd-src/internal/config/deploy/auto_discovery_test.go new file mode 100644 index 0000000..f0df919 --- /dev/null +++ b/doco-cd-src/internal/config/deploy/auto_discovery_test.go @@ -0,0 +1,508 @@ +package deploy + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + secrettypes "github.com/kimdre/doco-cd/internal/secretprovider/types" +) + +func TestConfig_AutoDiscoveryBoolOrObject(t *testing.T) { + t.Parallel() + + t.Run("yaml bool true uses defaults", func(t *testing.T) { + t.Parallel() + + filePath := filepath.Join(t.TempDir(), ".doco-cd.yaml") + + err := createTestFile(t, filePath, `name: test +compose_files: ["compose.yaml"] +auto_discovery: true +`) + if err != nil { + t.Fatal(err) + } + + configs, err := GetConfigFromYAML(filePath, true) + if err != nil { + t.Fatal(err) + } + + if !configs[0].AutoDiscovery.Enabled { + t.Fatal("expected auto_discovery.enabled to be true") + } + + if configs[0].AutoDiscovery.ScanDepth != 0 { + t.Fatalf("expected default auto_discovery.depth 0, got %d", configs[0].AutoDiscovery.ScanDepth) + } + + if !configs[0].AutoDiscovery.Delete { + t.Fatal("expected default auto_discovery.delete to be true") + } + }) + + t.Run("yaml object still works", func(t *testing.T) { + t.Parallel() + + filePath := filepath.Join(t.TempDir(), ".doco-cd.yaml") + + err := createTestFile(t, filePath, `name: test +compose_files: ["compose.yaml"] +auto_discovery: + enabled: true + depth: 2 + delete: false +`) + if err != nil { + t.Fatal(err) + } + + configs, err := GetConfigFromYAML(filePath, true) + if err != nil { + t.Fatal(err) + } + + if !configs[0].AutoDiscovery.Enabled { + t.Fatal("expected auto_discovery.enabled to be true") + } + + if configs[0].AutoDiscovery.ScanDepth != 2 { + t.Fatalf("expected auto_discovery.depth 2, got %d", configs[0].AutoDiscovery.ScanDepth) + } + + if configs[0].AutoDiscovery.Delete { + t.Fatal("expected auto_discovery.delete to be false") + } + }) + + t.Run("json bool true uses defaults", func(t *testing.T) { + t.Parallel() + + var cfg Config + if err := json.Unmarshal([]byte(`{"name":"test","compose_files":["compose.yaml"],"auto_discovery":true}`), &cfg); err != nil { + t.Fatal(err) + } + + if !cfg.AutoDiscovery.Enabled { + t.Fatal("expected auto_discovery.enabled to be true") + } + + if cfg.AutoDiscovery.ScanDepth != 0 { + t.Fatalf("expected default auto_discovery.depth 0, got %d", cfg.AutoDiscovery.ScanDepth) + } + + if !cfg.AutoDiscovery.Delete { + t.Fatal("expected default auto_discovery.delete to be true") + } + }) +} + +// --------------------------------------------------------------------------- +// mergeConfig tests +// --------------------------------------------------------------------------- + +func TestMergeConfig(t *testing.T) { + t.Parallel() + + t.Run("MergeExternalSecrets_KeyByKey", func(t *testing.T) { + t.Parallel() + + base := &Config{ + Name: "base", + ExternalSecrets: map[string]secrettypes.ExternalSecretRef{ + "BASE_SECRET": {LegacyRef: "base-ref"}, + }, + } + override := &Config{ + ExternalSecrets: map[string]secrettypes.ExternalSecretRef{ + "OVERRIDE_SECRET": {LegacyRef: "override-ref"}, + }, + } + + mergeConfig(base, override) + + if base.ExternalSecrets["BASE_SECRET"].LegacyRef != "base-ref" { + t.Error("base key should be preserved") + } + + if base.ExternalSecrets["OVERRIDE_SECRET"].LegacyRef != "override-ref" { + t.Error("override key should be merged in") + } + }) + + t.Run("MergeExternalSecrets_OverrideWinsOnCollision", func(t *testing.T) { + t.Parallel() + + base := &Config{ + ExternalSecrets: map[string]secrettypes.ExternalSecretRef{ + "SECRET": {LegacyRef: "base-ref"}, + }, + } + override := &Config{ + ExternalSecrets: map[string]secrettypes.ExternalSecretRef{ + "SECRET": {LegacyRef: "override-ref"}, + }, + } + + mergeConfig(base, override) + + if base.ExternalSecrets["SECRET"].LegacyRef != "override-ref" { + t.Errorf("override value should win, got %q", base.ExternalSecrets["SECRET"].LegacyRef) + } + }) + + t.Run("MergeEnvironment_KeyByKey", func(t *testing.T) { + t.Parallel() + + base := &Config{ + Environment: map[string]string{"BASE_VAR": "base"}, + } + override := &Config{ + Environment: map[string]string{"OVERRIDE_VAR": "override"}, + } + + mergeConfig(base, override) + + if base.Environment["BASE_VAR"] != "base" { + t.Error("base env var should be preserved") + } + + if base.Environment["OVERRIDE_VAR"] != "override" { + t.Error("override env var should be merged") + } + }) + + t.Run("MergeBuildArgs_KeyByKey", func(t *testing.T) { + t.Parallel() + + base := &Config{} + base.BuildOpts.Args = map[string]string{"BASE_ARG": "base"} + + override := &Config{} + override.BuildOpts.Args = map[string]string{"OVERRIDE_ARG": "override"} + + mergeConfig(base, override) + + if base.BuildOpts.Args["BASE_ARG"] != "base" { + t.Error("base build arg should be preserved") + } + + if base.BuildOpts.Args["OVERRIDE_ARG"] != "override" { + t.Error("override build arg should be merged") + } + }) + + t.Run("MergeSlice_ReplacedWhenNonEmpty", func(t *testing.T) { + t.Parallel() + + base := &Config{Profiles: []string{"base-profile"}} + override := &Config{Profiles: []string{"override-profile"}} + + mergeConfig(base, override) + + if len(base.Profiles) != 1 || base.Profiles[0] != "override-profile" { + t.Errorf("profiles should be replaced, got %v", base.Profiles) + } + }) + + t.Run("MergeSlice_UnchangedWhenEmpty", func(t *testing.T) { + t.Parallel() + + base := &Config{Profiles: []string{"base-profile"}} + override := &Config{} // no profiles set + + mergeConfig(base, override) + + if len(base.Profiles) != 1 || base.Profiles[0] != "base-profile" { + t.Errorf("profiles should be unchanged, got %v", base.Profiles) + } + }) + + t.Run("MergeScalar_Timeout", func(t *testing.T) { + t.Parallel() + + base := &Config{Timeout: 180} + override := &Config{Timeout: 60} + + mergeConfig(base, override) + + if base.Timeout != 60 { + t.Errorf("timeout should be overridden to 60, got %d", base.Timeout) + } + }) + + t.Run("MergeScalar_Name", func(t *testing.T) { + t.Parallel() + + base := &Config{Name: "base-name"} + override := &Config{Name: "override-name"} + + mergeConfig(base, override) + + if base.Name != "override-name" { + t.Errorf("name should be overridden, got %q", base.Name) + } + }) + + t.Run("ProtectedFields_NotOverridden", func(t *testing.T) { + t.Parallel() + + base := &Config{ + Reference: "refs/heads/main", + RepositoryUrl: "https://example.com/base.git", + GitDepth: 5, + } + base.AutoDiscovery.ScanDepth = 3 + + override := &Config{ + Reference: "refs/heads/other", + RepositoryUrl: "https://example.com/override.git", + GitDepth: 99, + } + override.AutoDiscovery.ScanDepth = 99 + + mergeConfig(base, override) + + if base.Reference != "refs/heads/main" { + t.Errorf("Reference should not be overridden, got %q", base.Reference) + } + + if base.RepositoryUrl != "https://example.com/base.git" { + t.Errorf("RepositoryUrl should not be overridden, got %q", base.RepositoryUrl) + } + + if base.GitDepth != 5 { + t.Errorf("GitDepth should not be overridden, got %d", base.GitDepth) + } + + if base.AutoDiscovery.ScanDepth != 3 { + t.Errorf("AutoDiscovery.ScanDepth should not be overridden, got %d", base.AutoDiscovery.ScanDepth) + } + }) + + t.Run("MergeReconciliation_NestedStruct", func(t *testing.T) { + t.Parallel() + + base := &Config{} + base.Reconciliation.RestartLimit = 5 + base.Reconciliation.RestartWindow = 300 + + override := &Config{} + override.Reconciliation.RestartLimit = 10 + + mergeConfig(base, override) + + if base.Reconciliation.RestartLimit != 10 { + t.Errorf("RestartLimit should be overridden to 10, got %d", base.Reconciliation.RestartLimit) + } + + if base.Reconciliation.RestartWindow != 300 { + t.Errorf("RestartWindow should remain 300, got %d", base.Reconciliation.RestartWindow) + } + }) +} + +// --------------------------------------------------------------------------- +// autoDiscoverDeployments with nested config tests +// --------------------------------------------------------------------------- + +func TestAutoDiscoverDeployments_WithNestedConfig(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + serviceDir := filepath.Join(repoRoot, "service1") + + if err := os.MkdirAll(serviceDir, 0o750); err != nil { + t.Fatal(err) + } + + if err := createTestFile(t, filepath.Join(serviceDir, "compose.yaml"), "services:\n web:\n image: nginx"); err != nil { + t.Fatal(err) + } + + // Write a nested .doco-cd.yaml in service1/ that adds external secrets + nestedCfg := `external_secrets: + MY_SECRET: "op://vault/item/field" +environment: + EXTRA_VAR: "hello" +` + if err := createTestFile(t, filepath.Join(serviceDir, ".doco-cd.yaml"), nestedCfg); err != nil { + t.Fatal(err) + } + + baseConfig := &Config{ + WorkingDirectory: ".", + ComposeFiles: []string{"compose.yaml"}, + AutoDiscovery: AutoDiscoveryConfig{Enabled: true}, + ExternalSecrets: map[string]secrettypes.ExternalSecretRef{ + "BASE_SECRET": {LegacyRef: "base-ref"}, + }, + } + + configs, err := autoDiscoverDeployments(repoRoot, baseConfig) + if err != nil { + t.Fatal(err) + } + + if len(configs) != 1 { + t.Fatalf("expected 1 config, got %d", len(configs)) + } + + cfg := configs[0] + + // base secret should be preserved + if cfg.ExternalSecrets["BASE_SECRET"].LegacyRef != "base-ref" { + t.Errorf("base secret should be preserved, got %q", cfg.ExternalSecrets["BASE_SECRET"].LegacyRef) + } + + // nested secret should be merged in + if cfg.ExternalSecrets["MY_SECRET"].LegacyRef != "op://vault/item/field" { + t.Errorf("nested secret should be merged, got %q", cfg.ExternalSecrets["MY_SECRET"].LegacyRef) + } + + // nested environment should be merged in + if cfg.Environment["EXTRA_VAR"] != "hello" { + t.Errorf("nested env var should be merged, got %q", cfg.Environment["EXTRA_VAR"]) + } +} + +func TestAutoDiscoverDeployments_WithNestedConfig_EnvironmentOnly_DoesNotOverrideComposeFiles(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + serviceDir := filepath.Join(repoRoot, "service1") + + if err := os.MkdirAll(serviceDir, 0o750); err != nil { + t.Fatal(err) + } + + if err := createTestFile(t, filepath.Join(serviceDir, "test.compose.yaml"), "services:\n web:\n image: nginx"); err != nil { + t.Fatal(err) + } + + if err := createTestFile(t, filepath.Join(serviceDir, ".doco-cd.yml"), "environment:\n SUB: nested\n"); err != nil { + t.Fatal(err) + } + + baseConfig := &Config{ + WorkingDirectory: ".", + ComposeFiles: []string{"test.compose.yaml"}, + AutoDiscovery: AutoDiscoveryConfig{Enabled: true}, + Environment: map[string]string{"BASE": "root"}, + } + + configs, err := autoDiscoverDeployments(repoRoot, baseConfig) + if err != nil { + t.Fatal(err) + } + + if len(configs) != 1 { + t.Fatalf("expected 1 config, got %d", len(configs)) + } + + cfg := configs[0] + + if cfg.Name != "service1" { + t.Errorf("expected discovered name 'service1', got %q", cfg.Name) + } + + if cfg.WorkingDirectory != "service1" { + t.Errorf("expected working directory 'service1', got %q", cfg.WorkingDirectory) + } + + if !reflect.DeepEqual(cfg.ComposeFiles, []string{"test.compose.yaml"}) { + t.Errorf("expected compose_files to remain [test.compose.yaml], got %v", cfg.ComposeFiles) + } + + if cfg.Environment["BASE"] != "root" { + t.Errorf("expected base env BASE=root to be preserved, got %q", cfg.Environment["BASE"]) + } + + if cfg.Environment["SUB"] != "nested" { + t.Errorf("expected nested env SUB=nested to be merged, got %q", cfg.Environment["SUB"]) + } +} + +func TestAutoDiscoverDeployments_NestedConfig_MultipleDocumentsError(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + serviceDir := filepath.Join(repoRoot, "service1") + + if err := os.MkdirAll(serviceDir, 0o750); err != nil { + t.Fatal(err) + } + + if err := createTestFile(t, filepath.Join(serviceDir, "compose.yaml"), "services:\n web:\n image: nginx"); err != nil { + t.Fatal(err) + } + + // Two YAML documents in the nested config – should error + multiDoc := `external_secrets: + SECRET1: ref1 +--- +external_secrets: + SECRET2: ref2 +` + if err := createTestFile(t, filepath.Join(serviceDir, ".doco-cd.yaml"), multiDoc); err != nil { + t.Fatal(err) + } + + baseConfig := &Config{ + WorkingDirectory: ".", + ComposeFiles: []string{"compose.yaml"}, + AutoDiscovery: AutoDiscoveryConfig{Enabled: true}, + } + + _, err := autoDiscoverDeployments(repoRoot, baseConfig) + if err == nil { + t.Fatal("expected error for multiple YAML documents in nested config, got nil") + } + + if !errors.Is(err, ErrMultipleYAMLDocuments) { + t.Errorf("expected ErrMultipleYAMLDocuments, got %v", err) + } +} + +func TestAutoDiscoverDeployments_NoNestedConfig_BackwardsCompatible(t *testing.T) { + t.Parallel() + + repoRoot := t.TempDir() + serviceDir := filepath.Join(repoRoot, "myservice") + + if err := os.MkdirAll(serviceDir, 0o750); err != nil { + t.Fatal(err) + } + + if err := createTestFile(t, filepath.Join(serviceDir, "compose.yaml"), "services:\n web:\n image: nginx"); err != nil { + t.Fatal(err) + } + + baseConfig := &Config{ + WorkingDirectory: ".", + ComposeFiles: []string{"compose.yaml"}, + AutoDiscovery: AutoDiscoveryConfig{Enabled: true}, + Timeout: 300, + } + + configs, err := autoDiscoverDeployments(repoRoot, baseConfig) + if err != nil { + t.Fatal(err) + } + + if len(configs) != 1 { + t.Fatalf("expected 1 config, got %d", len(configs)) + } + + if configs[0].Timeout != 300 { + t.Errorf("expected timeout 300 from base config, got %d", configs[0].Timeout) + } + + if configs[0].Name != "myservice" { + t.Errorf("expected name 'myservice', got %q", configs[0].Name) + } +} diff --git a/doco-cd-src/internal/config/deploy/build.go b/doco-cd-src/internal/config/deploy/build.go new file mode 100644 index 0000000..c569c27 --- /dev/null +++ b/doco-cd-src/internal/config/deploy/build.go @@ -0,0 +1,9 @@ +package deploy + +// BuildConfig holds build options for a deployment. +type BuildConfig struct { + ForceImagePull bool `yaml:"force_image_pull" json:"force_image_pull" default:"false"` // ForceImagePull always attempt to pull a newer version of the image + Quiet bool `yaml:"quiet" json:"quiet" default:"false"` // Quiet suppresses the build output + Args map[string]string `yaml:"args" json:"args"` // BuildArgs is a map of build-time arguments to pass to the build process + NoCache bool `yaml:"no_cache" json:"no_cache" default:"false"` // NoCache disables the use of the cache when building images +} diff --git a/doco-cd-src/internal/config/deploy/deploy.go b/doco-cd-src/internal/config/deploy/deploy.go new file mode 100644 index 0000000..b349fc3 --- /dev/null +++ b/doco-cd-src/internal/config/deploy/deploy.go @@ -0,0 +1,485 @@ +package deploy + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path" + "path/filepath" + "strings" + + "github.com/compose-spec/compose-go/v2/cli" + "github.com/creasty/defaults" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "go.yaml.in/yaml/v3" + "gopkg.in/validator.v2" + + "github.com/kimdre/doco-cd/internal/config" + + secrettypes "github.com/kimdre/doco-cd/internal/secretprovider/types" + + gitInternal "github.com/kimdre/doco-cd/internal/git" + + "github.com/kimdre/doco-cd/internal/logger" +) + +var ( + DefaultDeploymentConfigFileNames = []string{".doco-cd.yaml", ".doco-cd.yml"} + CustomDeploymentConfigFileNames = []string{".doco-cd.%s.yaml", ".doco-cd.%s.yml"} + ErrConfigFileNotFound = errors.New("configuration file not found in repository") + ErrDuplicateProjectName = errors.New("duplicate project/stack name found in configuration file") + ErrInvalidConfig = errors.New("invalid deploy configuration") + ErrKeyNotFound = errors.New("key not found") + ErrInvalidFilePath = errors.New("invalid file path") + ErrMultipleYAMLDocuments = errors.New("nested .doco-cd configuration file must contain only a single YAML document") +) + +// Config is the structure of the deployment configuration file. +type Config struct { + Name string `yaml:"name" json:"name" doco:"allowOverride"` // Name of the docker-compose deployment / stack + RepositoryUrl config.HttpUrl `yaml:"repository_url" json:"repository_url" default:"" validate:"httpUrl"` // RepositoryUrl is the http URL of the Git repository to deploy + WebhookEventFilter string `yaml:"webhook_filter" json:"webhook_filter" default:"" doco:"allowOverride"` // WebhookEventFilter is a regular expression to whitelist deployment triggers based on the webhook event payload (e.g., branch like "^refs/heads/main$" or "main", tag like "^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$" or "v[0-9]+\.[0-9]+\.[0-9]+") + Reference string `yaml:"reference" json:"reference" default:""` // Reference is the Git reference to the deployment, e.g., refs/heads/main, main, refs/tags/v1.0.0 or v1.0.0 + WorkingDirectory string `yaml:"working_dir" json:"working_dir" default:"." doco:"allowOverride"` // WorkingDirectory is the working directory for the deployment + ComposeFiles []string `yaml:"compose_files" json:"compose_files" default:"[\"compose.yaml\", \"compose.yml\", \"docker-compose.yml\", \"docker-compose.yaml\"]" doco:"allowOverride"` // ComposeFiles is the list of docker-compose files to use + Environment map[string]string `yaml:"environment" json:"environment" doco:"allowOverride"` // Environment is a map of environment variables to use for variable interpolation in the compose files + EnvFiles []string `yaml:"env_files" json:"env_files" default:"[\".env\"]" doco:"allowOverride"` // EnvFiles is the list of dotenv files to use for variable interpolation + RemoveOrphans bool `yaml:"remove_orphans" json:"remove_orphans" default:"true" doco:"allowOverride"` // RemoveOrphans removes containers for services not defined in the Compose file + PruneImages bool `yaml:"prune_images" json:"prune_images" default:"true" doco:"allowOverride"` // PruneImages removes images that are no longer used by any service + ForceRecreate bool `yaml:"force_recreate" json:"force_recreate" default:"false" doco:"allowOverride"` // ForceRecreate forces the recreation/redeployment of containers even if the configuration has not changed + ForceImagePull bool `yaml:"force_image_pull" json:"force_image_pull" default:"false" doco:"allowOverride"` // ForceImagePull always pulls the latest version of the image tags you've specified if a newer version is available + Timeout int `yaml:"timeout" json:"timeout" default:"180" doco:"allowOverride"` // Timeout is the time in seconds to wait for the deployment to finish before timing out + BuildOpts BuildConfig `yaml:"build" json:"build" doco:"allowOverride"` // BuildOpts is the build options for the deployment + GitDepth int `yaml:"git_depth" json:"git_depth" default:"0"` // GitDepth limits the number of commits to fetch. 0 means use global GIT_CLONE_DEPTH. A positive value overrides the global setting. + Destroy DestroyConfig `yaml:"destroy" json:"destroy" doco:"allowOverride"` // Destroy configures destruction of the deployment and related resources + Profiles []string `yaml:"profiles" json:"profiles" default:"[]" doco:"allowOverride"` // Profiles is a list of profiles to use for the deployment, e.g., ["dev", "prod"]. See https://docs.docker.com/compose/how-tos/profiles/ + ExternalSecrets map[string]secrettypes.ExternalSecretRef `yaml:"external_secrets" json:"external_secrets" doco:"allowOverride"` // ExternalSecrets maps env vars to legacy string references or structured references (e.g. webhook store_ref/remote_ref). + AutoDiscovery AutoDiscoveryConfig `yaml:"auto_discovery" json:"auto_discovery"` // AutoDiscovery configures autodiscovery of services to deploy in the working directory + Reconciliation ReconciliationConfig `yaml:"reconciliation" json:"reconciliation" doco:"allowOverride"` // Reconciliation is the configuration for the reconciliation feature + Internal struct { + File string `yaml:"-"` // File is the path to the deployment configuration file + Environment map[string]string // Environment stores environment variables for variable interpolation in the compose project + Hash string `yaml:"-"` // Hash is a hash of the Config struct + } // Internal holds internal configuration values that are not set by the user +} + +// ResolveGitDepth returns the effective git clone depth. +// If the deploy-level GitDepth is > 0, it overrides the global value. +// Otherwise, the global depth is used. 0 means full clone (no limit). +func (c *Config) ResolveGitDepth(globalDepth int) int { + if c.GitDepth > 0 { + return c.GitDepth + } + + return globalDepth +} + +// New creates a Config with default values. +func New(name, reference string) *Config { + return &Config{ + Name: name, + Reference: reference, + WorkingDirectory: ".", + ComposeFiles: cli.DefaultFileNames, + } +} + +// LogValue implements the slog.LogValuer interface for Config. +func (c *Config) LogValue() slog.Value { + return logger.BuildLogValue(c, "Internal") +} + +func (c *Config) Validate() error { + if c.Name == "" && !c.AutoDiscovery.Enabled { + return fmt.Errorf("%w: name", ErrKeyNotFound) + } + + if c.GitDepth < 0 { + return fmt.Errorf("%w: git_depth must be >= 0", ErrInvalidConfig) + } + + if c.Reconciliation.RestartTimeout < 0 { + return fmt.Errorf("%w: reconciliation.restart_timeout must be >= 0", ErrInvalidConfig) + } + + c.Reconciliation.RestartSignal = strings.ToUpper(strings.TrimSpace(c.Reconciliation.RestartSignal)) + + if c.Reconciliation.RestartLimit < 0 { + return fmt.Errorf("%w: reconciliation.restart_limit must be >= 0", ErrInvalidConfig) + } + + if c.Reconciliation.RestartWindow < 0 { + return fmt.Errorf("%w: reconciliation.restart_window must be >= 0", ErrInvalidConfig) + } + + if c.Reconciliation.RestartLimit > 0 && c.Reconciliation.RestartWindow == 0 { + return fmt.Errorf("%w: reconciliation.restart_window must be > 0 when reconciliation.restart_limit is set", ErrInvalidConfig) + } + + c.WorkingDirectory = filepath.Clean(c.WorkingDirectory) + if !filepath.IsLocal(c.WorkingDirectory) { + c.WorkingDirectory = filepath.Join(".", c.WorkingDirectory) + } + + if len(c.ComposeFiles) == 0 { + return fmt.Errorf("%w: compose_files", ErrKeyNotFound) + } + + cleanComposeFiles := make([]string, 0, len(c.ComposeFiles)) + // Sanitize the compose file path + for _, file := range c.ComposeFiles { + cleaned := filepath.Clean(file) + + if filepath.IsAbs(cleaned) { + return fmt.Errorf("%w: %s", ErrInvalidFilePath, file) + } + + if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(os.PathSeparator)) { + return fmt.Errorf("%w: %s", ErrInvalidFilePath, file) + } + + full := filepath.Join(c.WorkingDirectory, cleaned) + + rel, err := filepath.Rel(c.WorkingDirectory, full) + if err != nil { + return fmt.Errorf("%w: %s", ErrInvalidFilePath, file) + } + + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return fmt.Errorf("%w: %s", ErrInvalidFilePath, file) + } + + cleanComposeFiles = append(cleanComposeFiles, cleaned) + } + + c.ComposeFiles = cleanComposeFiles + + if err := c.normalizeReconciliationEvents(); err != nil { + return err + } + + return nil +} + +func (c *Config) UnmarshalYAML(unmarshal func(any) error) error { + err := defaults.Set(c) + if err != nil { + return err + } + + type Plain Config + + if err := unmarshal((*Plain)(c)); err != nil { + return err + } + + return nil +} + +func (c *Config) UnmarshalJSON(data []byte) error { + err := defaults.Set(c) + if err != nil { + return err + } + + type Plain Config + + if err := json.Unmarshal(data, (*Plain)(c)); err != nil { + return err + } + + return nil +} + +// Hash returns a hash of the Config struct (without changing the order of its elements). +func (c *Config) Hash() (string, error) { + data, err := yaml.Marshal(c) + if err != nil { + return "", err + } + + return fmt.Sprintf("%x", sha256.Sum256(data)), nil +} + +// GetConfigFromYAML reads a YAML file and unmarshals it into a slice of Config structs. +// When applyDefaults is true, default values are applied to each config (normal usage). +// When applyDefaults is false, omitted fields remain zero/nil — used for nested auto-discovery +// overrides so unset fields do not accidentally replace base/discovered values during merge. +func GetConfigFromYAML(f string, applyDefaults bool) ([]*Config, error) { + b, err := os.ReadFile(f) // #nosec G304 + if err != nil { + return nil, fmt.Errorf("failed to read file: %v", err) + } + + dec := yaml.NewDecoder(bytes.NewReader(b)) + + var configs []*Config + + // Use a type alias to bypass the UnmarshalYAML hook (which injects defaults) + // when the caller explicitly does not want defaults applied. + type configNoDefaults Config + + for { + var c Config + + if applyDefaults { + err = dec.Decode(&c) + } else { + var raw configNoDefaults + + err = dec.Decode(&raw) + c = Config(raw) + } + + if err != nil { + if err == io.EOF { + break + } + + return nil, fmt.Errorf("failed to decode yaml: %v", err) + } + + c.Internal.File = f + + configs = append(configs, &c) + } + + if len(configs) == 0 { + return nil, errors.New("no yaml documents found in file") + } + + return configs, nil +} + +// GetConfigs returns either the deployment configuration from the repository or the default configuration. +// gitOpts is optional (may be nil) and is only required when AutoDiscovery with a remote RepositoryUrl is used. +func GetConfigs(repoRoot, configBaseDir, name, customTarget, reference string, gitOpts *GitOptions) ([]*Config, error) { + configDir := filepath.Join(repoRoot, configBaseDir) + + files, err := os.ReadDir(configDir) + if err != nil { + return nil, err + } + + var DeploymentConfigFileNames []string + + if reference == "" { + reference = DefaultReference + } + + if customTarget != "" { + for _, configFile := range CustomDeploymentConfigFileNames { + DeploymentConfigFileNames = append(DeploymentConfigFileNames, fmt.Sprintf(configFile, customTarget)) + } + } else { + DeploymentConfigFileNames = DefaultDeploymentConfigFileNames + } + + // Get repo and change to reference in c.Reference if it is different to the current reference in the repoRoot, + // otherwise it will cause issues with the auto-discovery + baseRepo, err := git.PlainOpen(repoRoot) + if err != nil { + return nil, fmt.Errorf("failed to open git repository at %s: %w", repoRoot, err) + } + + // Compare the resolved reference with the current HEAD reference, if they are different then skip the auto-discovery for this deployment config + headRef, err := baseRepo.Head() + if err != nil { + return nil, fmt.Errorf("%w: %w", gitInternal.ErrGetHeadFailed, err) + } + + // Checkout repo to different reference + w, err := baseRepo.Worktree() + if err != nil { + return nil, fmt.Errorf("failed to get git worktree: %w", err) + } + + // Defer checkout back to original HEAD reference after the deployment is done + defer func(branch plumbing.ReferenceName) { + err = w.Checkout(&git.CheckoutOptions{ + Branch: branch, + Keep: true, + }) + if err != nil { + slog.Error("failed to checkout back to original HEAD reference after deployment", "error", err) + } + }(headRef.Name()) + + var configs []*Config + for _, configFile := range DeploymentConfigFileNames { + configs, err = getConfigsFromFile(configDir, files, configFile) + if err != nil { + if errors.Is(err, ErrConfigFileNotFound) { + continue + } + + return nil, err + } + + // Build a new slice to avoid modifying the slice we're iterating over + var expandedConfigs []*Config + + // Ensure gitOpts is not nil for AutoDiscovery operations + opts := gitOpts + if opts == nil { + opts = &GitOptions{} + } + + // Handle autodiscover deployment configs + for _, c := range configs { + if c.Reference == "" { + // If the reference is not already set in the deployment config file, set it to the current reference + c.Reference = reference + } + + repoDir := repoRoot + // Check for configs with AutoDiscover enabled, if true then remove this config and add new configs based on discovered compose files + if c.AutoDiscovery.Enabled { + if c.RepositoryUrl != "" { + auth, err := gitInternal.GetAuthMethod(string(c.RepositoryUrl), opts.SSHPrivateKey, opts.SSHPrivateKeyPassphrase, opts.GitAccessToken) + if err != nil { + return nil, fmt.Errorf("failed to get auth method: %w", err) + } + + repoDir = path.Join(path.Dir(repoRoot), gitInternal.GetRepoName(string(c.RepositoryUrl))) + + // Clone the repository to repoDir if it does not exist, otherwise fetch the latest changes and checkout to the correct reference + _, err = gitInternal.CloneRepository(repoDir, string(c.RepositoryUrl), c.Reference, opts.SkipTLSVerification, opts.HttpProxy, auth, opts.GitCloneSubmodules, c.ResolveGitDepth(opts.GitCloneDepth)) + if err != nil { + if errors.Is(err, git.ErrRepositoryAlreadyExists) { + _, err = gitInternal.UpdateRepository(repoDir, string(c.RepositoryUrl), c.Reference, opts.SkipTLSVerification, opts.HttpProxy, auth, opts.GitCloneSubmodules, c.ResolveGitDepth(opts.GitCloneDepth)) + if err != nil { + return nil, fmt.Errorf("failed to update repository: %w", err) + } + } else { + return nil, fmt.Errorf("failed to clone repository: %w", err) + } + } + } else { + auth, err := gitInternal.GetAuthMethod(string(c.RepositoryUrl), opts.SSHPrivateKey, opts.SSHPrivateKeyPassphrase, opts.GitAccessToken) + if err != nil { + return nil, fmt.Errorf("failed to get auth method: %w", err) + } + + unlock := gitInternal.AcquirePathLock(repoRoot) + err = gitInternal.CheckoutRepository(baseRepo, c.Reference, auth, opts.GitCloneSubmodules) + + unlock() + + if err != nil { + return nil, fmt.Errorf("failed to checkout repository to reference %s: %w", c.Reference, err) + } + } + + discoveredConfigs, err := autoDiscoverDeployments(repoDir, c) + if err != nil { + return nil, fmt.Errorf("failed to auto-discover deployment configurations: %w", err) + } + + // Add the discovered configs to the expanded list + expandedConfigs = append(expandedConfigs, discoveredConfigs...) + } else { + // Keep non-autodiscover configs as-is + expandedConfigs = append(expandedConfigs, c) + } + } + + if expandedConfigs != nil { + if err = validator.Validate(expandedConfigs); err != nil { + return nil, err + } + + // Check if the stack/project names are not unique + err = ValidateUniqueProjectNames(expandedConfigs) + if err != nil { + return nil, err + } + + return expandedConfigs, nil + } + } + + if customTarget != "" { + return nil, fmt.Errorf("%w: .doco-cd.%s.y(a)ml", ErrConfigFileNotFound, customTarget) + } + + return []*Config{New(name, reference)}, nil +} + +// getConfigsFromFile returns the deployment configurations from the repository or nil if not found. +func getConfigsFromFile(dir string, files []os.DirEntry, configFile string) ([]*Config, error) { + for _, f := range files { + if f.IsDir() { + continue + } + + if f.Name() == configFile { + // Get contents of deploy config file + configs, err := GetConfigFromYAML(path.Join(dir, f.Name()), true) + if err != nil { + return nil, err + } + + // Validate all deploy configs + for _, c := range configs { + if err = c.Validate(); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err) + } + } + + if configs != nil { + return configs, nil + } + } + } + + return nil, ErrConfigFileNotFound +} + +// ValidateUniqueProjectNames checks if the project names in the configs are unique. +func ValidateUniqueProjectNames(configs []*Config) error { + names := make(map[string]bool) + for _, dc := range configs { + if names[dc.Name] { + return fmt.Errorf("%w: %s", ErrDuplicateProjectName, dc.Name) + } + + names[dc.Name] = true + } + + return nil +} + +// ResolveConfigs returns Deployment Config's for a poll run, preferring inline +// deployments defined on the PollConfig when provided. Falls back to repository +// configuration files or default values when no inline deployments are present. +// repoRoot is the absolute path to the repository root. +// configBaseDir is the relative path from repo root where config files are located. +// gitOpts is optional (may be nil) and is only required when AutoDiscovery with a remote RepositoryUrl is used. +func ResolveConfigs(inlineDeployments []*Config, customTarget, reference, repoRoot, configBaseDir, name string, gitOpts *GitOptions) ([]*Config, error) { + // Prefer inline deployments when present + if len(inlineDeployments) > 0 { + // Apply reference to inline deployments if not already set + for _, d := range inlineDeployments { + if d.Reference == "" { + d.Reference = reference + } + } + + configs, err := expandInlineAutoDiscoverConfigs(repoRoot, inlineDeployments) + if err != nil { + return nil, err + } + + return configs, nil + } + + // No inline deployments, use repository config discovery + return GetConfigs(repoRoot, configBaseDir, name, customTarget, reference, gitOpts) +} diff --git a/doco-cd-src/internal/config/deploy_config_test.go b/doco-cd-src/internal/config/deploy/deploy_test.go similarity index 74% rename from doco-cd-src/internal/config/deploy_config_test.go rename to doco-cd-src/internal/config/deploy/deploy_test.go index 6e205c2..2471366 100644 --- a/doco-cd-src/internal/config/deploy_config_test.go +++ b/doco-cd-src/internal/config/deploy/deploy_test.go @@ -1,4 +1,4 @@ -package config +package deploy import ( "errors" @@ -10,11 +10,14 @@ import ( "testing" "time" + "github.com/creasty/defaults" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "gopkg.in/validator.v2" + "github.com/kimdre/doco-cd/internal/config" + "github.com/kimdre/doco-cd/internal/filesystem" ) @@ -29,7 +32,7 @@ func createTestFile(t *testing.T, fileName string, content string) error { return nil } -func TestGetDeployConfigs(t *testing.T) { +func TestGetConfigs(t *testing.T) { t.Parallel() t.Run("Valid Config", func(t *testing.T) { @@ -41,7 +44,7 @@ func TestGetDeployConfigs(t *testing.T) { composeFiles := []string{"test.compose.yaml"} customTarget := "" - deployConfig := fmt.Sprintf(`name: %s + dc := fmt.Sprintf(`name: %s reference: %s working_dir: %s compose_files: @@ -54,12 +57,12 @@ compose_files: filePath := filepath.Join(dirName, fileName) - err := createTestFile(t, filePath, deployConfig) + err := createTestFile(t, filePath, dc) if err != nil { t.Fatal(err) } - configs, err := GetDeployConfigs(dirName, ".", t.Name(), customTarget, reference) + configs, err := GetConfigs(dirName, ".", t.Name(), customTarget, reference, nil) if err != nil { t.Fatal(err) } @@ -68,36 +71,36 @@ compose_files: t.Fatalf("expected 1 config, got %d", len(configs)) } - config := configs[0] + c := configs[0] - if config.Name != t.Name() { - t.Errorf("expected name to be %v, got %s", t.Name(), config.Name) + if c.Name != t.Name() { + t.Errorf("expected name to be %v, got %s", t.Name(), c.Name) } - if config.Reference != reference { - t.Errorf("expected reference to be %v, got %s", reference, config.Reference) + if c.Reference != reference { + t.Errorf("expected reference to be %v, got %s", reference, c.Reference) } - if config.WorkingDirectory != filepath.Join(".", workingDirectory) { - t.Errorf("expected working directory to be '%v', got '%s'", workingDirectory, config.WorkingDirectory) + if c.WorkingDirectory != filepath.Join(".", workingDirectory) { + t.Errorf("expected working directory to be '%v', got '%s'", workingDirectory, c.WorkingDirectory) } - if !reflect.DeepEqual(config.ComposeFiles, composeFiles) { - t.Errorf("expected compose files to be %v, got %v", composeFiles, config.ComposeFiles) + if !reflect.DeepEqual(c.ComposeFiles, composeFiles) { + t.Errorf("expected compose files to be %v, got %v", composeFiles, c.ComposeFiles) } }) } -func TestGetDeployConfigs_DefaultValues(t *testing.T) { +func TestGetConfigs_DefaultValues(t *testing.T) { t.Parallel() - defaultConfig := DefaultDeployConfig(t.Name(), DefaultReference) + defaultConfig := New(t.Name(), DefaultReference) dirName := t.TempDir() createTestRepo(t, dirName) - configs, err := GetDeployConfigs(dirName, ".", t.Name(), "", "") + configs, err := GetConfigs(dirName, ".", t.Name(), "", "", nil) if err != nil { t.Fatal(err) } @@ -106,53 +109,53 @@ func TestGetDeployConfigs_DefaultValues(t *testing.T) { t.Fatalf("expected 1 config, got %d", len(configs)) } - config := configs[0] + dc := configs[0] - if config.Name != t.Name() { - t.Errorf("expected name to be %v, got %s", t.Name(), config.Name) + if dc.Name != t.Name() { + t.Errorf("expected name to be %v, got %s", t.Name(), dc.Name) } - if config.Reference != defaultConfig.Reference { - t.Errorf("expected reference to be %s, got %s", defaultConfig.Reference, config.Reference) + if dc.Reference != defaultConfig.Reference { + t.Errorf("expected reference to be %s, got %s", defaultConfig.Reference, dc.Reference) } - if config.WorkingDirectory != defaultConfig.WorkingDirectory { - t.Errorf("expected working directory to be %s, got %s", defaultConfig.WorkingDirectory, config.WorkingDirectory) + if dc.WorkingDirectory != defaultConfig.WorkingDirectory { + t.Errorf("expected working directory to be %s, got %s", defaultConfig.WorkingDirectory, dc.WorkingDirectory) } - if !reflect.DeepEqual(config.ComposeFiles, defaultConfig.ComposeFiles) { - t.Errorf("expected compose files to be %v, got %v", defaultConfig.ComposeFiles, config.ComposeFiles) + if !reflect.DeepEqual(dc.ComposeFiles, defaultConfig.ComposeFiles) { + t.Errorf("expected compose files to be %v, got %v", defaultConfig.ComposeFiles, dc.ComposeFiles) } } -// TestGetDeployConfigs_DuplicateProjectName checks if the function returns an error +// TestGetConfigs_DuplicateProjectName checks if the function returns an error // when there are duplicate project names in the config files. -func TestGetDeployConfigs_DuplicateProjectName(t *testing.T) { +func TestGetConfigs_DuplicateProjectName(t *testing.T) { t.Parallel() - config := DeployConfig{ + dc := Config{ Name: t.Name(), Reference: "refs/heads/test", WorkingDirectory: "/test", ComposeFiles: []string{"test.compose.yaml"}, } - configs := []*DeployConfig{&config, &config} + configs := []*Config{&dc, &dc} - err := validateUniqueProjectNames(configs) + err := ValidateUniqueProjectNames(configs) if !errors.Is(err, ErrDuplicateProjectName) { t.Fatal("expected error for duplicate project names, got nil") } } -// TestGetDeployConfigs_InvalidRepositoryURL checks if the function returns an error when the repository URL is an SSH URL +// TestGetConfigs_InvalidRepositoryURL checks if the function returns an error when the repository URL is an SSH URL // The init function panics if the validator for HttpUrl is not registered correctly. -func TestGetDeployConfigs_RepositoryURL(t *testing.T) { +func TestGetConfigs_RepositoryURL(t *testing.T) { t.Parallel() testCases := []struct { name string - repoUrl HttpUrl + repoUrl config.HttpUrl expectedErr error }{ { @@ -168,7 +171,7 @@ func TestGetDeployConfigs_RepositoryURL(t *testing.T) { { name: "Invalid HTTP URL", repoUrl: "github.com/kimdre/doco-cd", - expectedErr: fmt.Errorf("RepositoryUrl: %w", ErrInvalidHttpUrl), + expectedErr: fmt.Errorf("RepositoryUrl: %w", config.ErrInvalidHttpUrl), }, { name: "SSH URL", @@ -185,12 +188,12 @@ func TestGetDeployConfigs_RepositoryURL(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - config := DeployConfig{ + dc := Config{ Name: tc.name, RepositoryUrl: tc.repoUrl, } - err := validator.Validate(config) + err := validator.Validate(dc) if err == nil && tc.expectedErr != nil { t.Fatalf("expected error %v, got nil", tc.expectedErr) } @@ -202,24 +205,22 @@ func TestGetDeployConfigs_RepositoryURL(t *testing.T) { } } -func TestResolveDeployConfigs_InlineOverride(t *testing.T) { +func TestResolveConfigs_InlineOverride(t *testing.T) { t.Parallel() dirName := t.TempDir() - poll := PollConfig{ - CloneUrl: "https://example.com/repo.git", - Reference: "refs/heads/main", - Interval: 60, - Deployments: []*DeployConfig{{Name: "inline-stack"}}, + deployment := &Config{Name: "inline-stack"} + // Apply defaults to deployment + if err := defaults.Set(deployment); err != nil { + t.Fatalf("failed to set defaults: %v", err) } - // Validate poll config to ensure inline deployments are validated - if err := poll.Validate(); err != nil { - t.Fatalf("unexpected validation error: %v", err) - } + deployments := []*Config{deployment} + customTarget := "" + reference := "refs/heads/main" - configs, err := ResolveDeployConfigs(poll, dirName, ".", "repo") + configs, err := ResolveConfigs(deployments, customTarget, reference, dirName, ".", "repo", nil) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -235,8 +236,8 @@ func TestResolveDeployConfigs_InlineOverride(t *testing.T) { } // Reference defaults to poll reference when unset inline - if cfg.Reference != poll.Reference { - t.Errorf("expected reference to be '%s', got '%s'", poll.Reference, cfg.Reference) + if cfg.Reference != reference { + t.Errorf("expected reference to be '%s', got '%s'", reference, cfg.Reference) } // Verify defaults applied @@ -249,23 +250,20 @@ func TestResolveDeployConfigs_InlineOverride(t *testing.T) { } } -func TestResolveDeployConfigs_InlineMissingName(t *testing.T) { +func TestResolveConfigs_InlineMissingName(t *testing.T) { t.Parallel() - poll := PollConfig{ - CloneUrl: "https://example.com/repo.git", - Reference: "refs/heads/main", - Interval: 60, - Deployments: []*DeployConfig{{}}, // Missing name should error - } + deployments := []*Config{{}} - err := poll.Validate() - if !errors.Is(err, ErrInvalidConfig) { - t.Fatalf("expected error %v, got %v", ErrInvalidConfig, err) + // Empty name deployment should error when validated + for _, d := range deployments { + if err := d.Validate(); err == nil { + t.Fatalf("expected error for missing name, got nil") + } } } -func TestResolveDeployConfigs_InlineAutoDiscover(t *testing.T) { +func TestResolveConfigs_InlineAutoDiscover(t *testing.T) { t.Parallel() repoRoot := t.TempDir() @@ -285,20 +283,20 @@ func TestResolveDeployConfigs_InlineAutoDiscover(t *testing.T) { } } - poll := PollConfig{ - CloneUrl: "https://example.com/repo.git", - Reference: "refs/heads/main", - Interval: 60, - Deployments: []*DeployConfig{ - {WorkingDirectory: "services", AutoDiscover: true}, - }, + deployment := &Config{ + WorkingDirectory: "services", + AutoDiscovery: AutoDiscoveryConfig{Enabled: true}, } - - if err := poll.Validate(); err != nil { - t.Fatalf("unexpected validation error: %v", err) + // Apply defaults to deployment + if err := defaults.Set(deployment); err != nil { + t.Fatalf("failed to set defaults: %v", err) } - configs, err := ResolveDeployConfigs(poll, repoRoot, ".", t.Name()) + deployments := []*Config{deployment} + customTarget := "" + reference := "refs/heads/main" + + configs, err := ResolveConfigs(deployments, customTarget, reference, repoRoot, ".", t.Name(), nil) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -324,15 +322,15 @@ func TestResolveDeployConfigs_InlineAutoDiscover(t *testing.T) { } } -func TestGetDeployConfigs_WithSubdirectory(t *testing.T) { +func TestGetConfigs_WithSubdirectory(t *testing.T) { t.Parallel() fileName := ".doco-cd.yaml" reference := "refs/heads/main" - deployConfigBaseDir := "configs" + configBaseDir := "configs" customTarget := "" - deployConfig := fmt.Sprintf(`name: %s + dc := fmt.Sprintf(`name: %s reference: %s `, t.Name(), reference) @@ -342,7 +340,7 @@ reference: %s createTestRepo(t, repoRoot) // Create subdirectory for configs - configDir := filepath.Join(repoRoot, deployConfigBaseDir) + configDir := filepath.Join(repoRoot, configBaseDir) err := os.MkdirAll(configDir, 0o750) if err != nil { @@ -352,13 +350,13 @@ reference: %s // Create config file in subdirectory filePath := filepath.Join(configDir, fileName) - err = createTestFile(t, filePath, deployConfig) + err = createTestFile(t, filePath, dc) if err != nil { t.Fatal(err) } - // Test with subdirectory as deployConfigBaseDir - configs, err := GetDeployConfigs(repoRoot, deployConfigBaseDir, t.Name(), customTarget, reference) + // Test with subdirectory as configBaseDir + configs, err := GetConfigs(repoRoot, configBaseDir, t.Name(), customTarget, reference, nil) if err != nil { t.Fatal(err) } @@ -367,25 +365,25 @@ reference: %s t.Fatalf("expected 1 config, got %d", len(configs)) } - config := configs[0] - if config.Name != t.Name() { - t.Errorf("expected name to be %v, got %s", t.Name(), config.Name) + c := configs[0] + if c.Name != t.Name() { + t.Errorf("expected name to be %v, got %s", t.Name(), c.Name) } - if config.Reference != reference { - t.Errorf("expected reference to be %v, got %s", reference, config.Reference) + if c.Reference != reference { + t.Errorf("expected reference to be %v, got %s", reference, c.Reference) } } -func TestGetDeployConfigs_WithRootDirectory(t *testing.T) { +func TestGetConfigs_WithRootDirectory(t *testing.T) { t.Parallel() fileName := ".doco-cd.yaml" reference := "refs/heads/main" - deployConfigBaseDir := "." + configBaseDir := "." customTarget := "" - deployConfig := fmt.Sprintf(`name: %s + dc := fmt.Sprintf(`name: %s reference: %s `, t.Name(), reference) @@ -395,13 +393,13 @@ reference: %s filePath := filepath.Join(repoRoot, fileName) - err := createTestFile(t, filePath, deployConfig) + err := createTestFile(t, filePath, dc) if err != nil { t.Fatal(err) } - // Test with root directory as deployConfigBaseDir - configs, err := GetDeployConfigs(repoRoot, deployConfigBaseDir, t.Name(), customTarget, reference) + // Test with root directory as configBaseDir + configs, err := GetConfigs(repoRoot, configBaseDir, t.Name(), customTarget, reference, nil) if err != nil { t.Fatal(err) } @@ -410,13 +408,13 @@ reference: %s t.Fatalf("expected 1 config, got %d", len(configs)) } - config := configs[0] - if config.Name != t.Name() { - t.Errorf("expected name to be %v, got %s", t.Name(), config.Name) + c := configs[0] + if c.Name != t.Name() { + t.Errorf("expected name to be %v, got %s", t.Name(), c.Name) } } -func TestGetDeployConfigs_WithAutoDiscovery(t *testing.T) { +func TestGetConfigs_WithAutoDiscovery(t *testing.T) { t.Parallel() repoRoot := t.TempDir() @@ -436,20 +434,21 @@ func TestGetDeployConfigs_WithAutoDiscovery(t *testing.T) { t.Fatal(err) } - deployConfig := fmt.Sprintf(`name: %s + dc := fmt.Sprintf(`name: %s reference: main -auto_discover: true +auto_discovery: + enabled: true `, t.Name()) filePath := filepath.Join(repoRoot, ".doco-cd.yaml") - err = createTestFile(t, filePath, deployConfig) + err = createTestFile(t, filePath, dc) if err != nil { t.Fatal(err) } // Test with auto-discovery enabled - configs, err := GetDeployConfigs(repoRoot, ".", t.Name(), "", "main") + configs, err := GetConfigs(repoRoot, ".", t.Name(), "", "main", nil) if err != nil { t.Fatal(err) } @@ -462,12 +461,12 @@ auto_discover: true t.Errorf("expected name to be %v, got %s", t.Name(), configs[0].Name) } - if !configs[0].AutoDiscover { - t.Errorf("expected AutoDiscover to be true, got false") + if !configs[0].AutoDiscovery.Enabled { + t.Errorf("expected AutoDiscovery.Enabled to be true, got false") } } -func TestGetDeployConfigs_WithAutoDiscovery_OnDifferentBranch(t *testing.T) { +func TestGetConfigs_WithAutoDiscovery_OnDifferentBranch(t *testing.T) { t.Parallel() repoRoot := t.TempDir() @@ -514,20 +513,21 @@ func TestGetDeployConfigs_WithAutoDiscovery_OnDifferentBranch(t *testing.T) { t.Fatal(err) } - deployConfig := fmt.Sprintf(`name: %s + dc := fmt.Sprintf(`name: %s reference: refs/heads/feature-branch -auto_discover: true +auto_discovery: + enabled: true `, t.Name()) filePath := filepath.Join(repoRoot, ".doco-cd.yaml") - err = createTestFile(t, filePath, deployConfig) + err = createTestFile(t, filePath, dc) if err != nil { t.Fatal(err) } // Test with auto-discovery enabled on feature branch - configs, err := GetDeployConfigs(repoRoot, ".", t.Name(), "", "refs/heads/feature-branch") + configs, err := GetConfigs(repoRoot, ".", t.Name(), "", "refs/heads/feature-branch", nil) if err != nil { t.Fatal(err) } @@ -540,12 +540,12 @@ auto_discover: true t.Errorf("expected name to be %v, got %s", t.Name(), configs[0].Name) } - if !configs[0].AutoDiscover { - t.Errorf("expected AutoDiscover to be true, got false") + if !configs[0].AutoDiscovery.Enabled { + t.Errorf("expected AutoDiscovery.Enabled to be true, got false") } } -func TestGetDeployConfigs_WithAutoDiscovery_WithRemoteUrl(t *testing.T) { +func TestGetConfigs_WithAutoDiscovery_WithRemoteUrl(t *testing.T) { t.Parallel() testCases := []struct { @@ -575,21 +575,22 @@ func TestGetDeployConfigs_WithAutoDiscovery_WithRemoteUrl(t *testing.T) { createTestRepo(t, subDir) - deployConfig := fmt.Sprintf(`name: %s + dc := fmt.Sprintf(`name: %s reference: %s -auto_discover: true +auto_discovery: + enabled: true repository_url: https://github.com/kimdre/doco-cd_tests.git `, t.Name(), tc.branch) filePath := filepath.Join(subDir, ".doco-cd.yaml") - err := createTestFile(t, filePath, deployConfig) + err := createTestFile(t, filePath, dc) if err != nil { t.Fatal(err) } // Test with auto-discovery enabled and repository URL set (should ignore repository URL for discovery) - configs, err := GetDeployConfigs(subDir, ".", t.Name(), "", "main") + configs, err := GetConfigs(subDir, ".", t.Name(), "", "main", nil) if err != nil { t.Fatal(err) } @@ -598,16 +599,16 @@ repository_url: https://github.com/kimdre/doco-cd_tests.git t.Fatalf("expected 1 config, got %d", len(configs)) } - if tc.expectedConfigs == 1 && configs[0].Name != t.Name() { - t.Errorf("expected name to be %v, got %s", t.Name(), configs[0].Name) + if tc.expectedConfigs == 1 && configs[0].Name != "test-deploy" { + t.Errorf("expected name to be 'test-deploy' (from nested config), got %s", configs[0].Name) } else if tc.expectedConfigs == 2 { if configs[0].Name != "app1" && configs[1].Name != "app2" { t.Fatalf("expected names to be 'app1' and 'app2', got '%s' and '%s'", configs[0].Name, configs[1].Name) } } - if !configs[0].AutoDiscover { - t.Errorf("expected AutoDiscover to be true, got false") + if !configs[0].AutoDiscovery.Enabled { + t.Errorf("expected AutoDiscovery.Enabled to be true, got false") } if configs[0].Reference != tc.branch { @@ -617,14 +618,14 @@ repository_url: https://github.com/kimdre/doco-cd_tests.git } } -func TestResolveDeployConfigs_WithSubdirectory(t *testing.T) { +func TestResolveConfigs_WithSubdirectory(t *testing.T) { t.Parallel() fileName := ".doco-cd.yaml" reference := "refs/heads/main" - deployConfigBaseDir := "config" + configBaseDir := "config" - deployConfig := fmt.Sprintf(`name: %s + dc := fmt.Sprintf(`name: %s reference: %s `, t.Name(), reference) @@ -632,7 +633,7 @@ reference: %s createTestRepo(t, repoRoot) - configDir := filepath.Join(repoRoot, deployConfigBaseDir) + configDir := filepath.Join(repoRoot, configBaseDir) err := os.MkdirAll(configDir, 0o750) if err != nil { @@ -641,18 +642,12 @@ reference: %s filePath := filepath.Join(configDir, fileName) - err = createTestFile(t, filePath, deployConfig) + err = createTestFile(t, filePath, dc) if err != nil { t.Fatal(err) } - poll := PollConfig{ - CloneUrl: "https://example.com/repo.git", - Reference: reference, - Interval: 60, - } - - configs, err := ResolveDeployConfigs(poll, repoRoot, deployConfigBaseDir, t.Name()) + configs, err := ResolveConfigs(nil, "", reference, repoRoot, configBaseDir, t.Name(), nil) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -697,10 +692,10 @@ func TestAutoDiscoverDeployments_BasicDiscovery(t *testing.T) { t.Fatal(err) } - baseConfig := &DeployConfig{ + baseConfig := &Config{ WorkingDirectory: ".", ComposeFiles: []string{"compose.yaml", "docker-compose.yml"}, - AutoDiscover: true, + AutoDiscovery: AutoDiscoveryConfig{Enabled: true}, } configs, err := autoDiscoverDeployments(repoRoot, baseConfig) @@ -762,10 +757,10 @@ func TestAutoDiscoverDeployments_WithWorkingDirectory(t *testing.T) { t.Fatal(err) } - baseConfig := &DeployConfig{ + baseConfig := &Config{ WorkingDirectory: "services", ComposeFiles: []string{"compose.yaml"}, - AutoDiscover: true, + AutoDiscovery: AutoDiscoveryConfig{Enabled: true}, } configs, err := autoDiscoverDeployments(repoRoot, baseConfig) @@ -819,12 +814,12 @@ func TestAutoDiscoverDeployments_WithDepthLimit(t *testing.T) { t.Fatal(err) } - baseConfig := &DeployConfig{ + baseConfig := &Config{ WorkingDirectory: ".", ComposeFiles: []string{"compose.yaml"}, - AutoDiscover: true, + AutoDiscovery: AutoDiscoveryConfig{Enabled: true}, } - baseConfig.AutoDiscoverOpts.ScanDepth = 2 + baseConfig.AutoDiscovery.ScanDepth = 2 configs, err := autoDiscoverDeployments(repoRoot, baseConfig) if err != nil { @@ -862,10 +857,10 @@ func TestAutoDiscoverDeployments_NoComposeFiles(t *testing.T) { t.Fatal(err) } - baseConfig := &DeployConfig{ + baseConfig := &Config{ WorkingDirectory: ".", ComposeFiles: []string{"compose.yaml"}, - AutoDiscover: true, + AutoDiscovery: AutoDiscoveryConfig{Enabled: true}, } configs, err := autoDiscoverDeployments(repoRoot, baseConfig) @@ -895,10 +890,10 @@ func TestAutoDiscoverDeployments_InheritBaseConfig(t *testing.T) { t.Fatal(err) } - baseConfig := &DeployConfig{ + baseConfig := &Config{ WorkingDirectory: ".", ComposeFiles: []string{"compose.yaml"}, - AutoDiscover: true, + AutoDiscovery: AutoDiscoveryConfig{Enabled: true}, Reference: "refs/heads/main", RemoveOrphans: false, ForceRecreate: true, @@ -999,7 +994,7 @@ func createTestRepo(t *testing.T, repoPath string) (repo *git.Repository) { return repo } -func TestGetDeployConfigs_WithAutoDiscovery_WithRemoteUrl_WithMultipleConfigs(t *testing.T) { +func TestGetConfigs_WithAutoDiscovery_WithRemoteUrl_WithMultipleConfigs(t *testing.T) { t.Parallel() repoRoot := t.TempDir() @@ -1007,12 +1002,13 @@ func TestGetDeployConfigs_WithAutoDiscovery_WithRemoteUrl_WithMultipleConfigs(t createTestRepo(t, repoRoot) // Two deploy configs in one file using YAML document separator - deployConfig := ` + dc := ` # Config for main branch - should discover 1 deployment with name 'test' name: main-stack repository_url: https://github.com/kimdre/doco-cd_tests.git reference: main -auto_discover: true +auto_discovery: + enabled: true --- # Config for doco-cd repo - should discover 1 deployment with name 'test'' name: test-stack @@ -1020,23 +1016,25 @@ repository_url: https://github.com/kimdre/doco-cd.git reference: main compose_files: ["test.compose.yaml"] working_dir: test -auto_discover: true +auto_discovery: + enabled: true --- # Config for dual branch - should discover 2 deployments with names 'app1' and 'app2' name: dual-stack repository_url: https://github.com/kimdre/doco-cd_tests.git reference: dual -auto_discover: true +auto_discovery: + enabled: true ` filePath := filepath.Join(repoRoot, ".doco-cd.yaml") - err := createTestFile(t, filePath, deployConfig) + err := createTestFile(t, filePath, dc) if err != nil { t.Fatal(err) } - configs, err := GetDeployConfigs(repoRoot, ".", t.Name(), "", "main") + configs, err := GetConfigs(repoRoot, ".", t.Name(), "", "main", nil) if err != nil { t.Fatal(err) } @@ -1060,7 +1058,8 @@ auto_discover: true case "https://github.com/kimdre/doco-cd_tests.git": if (cfg.Name == "app1" || cfg.Name == "app2") && cfg.Reference == "dual" { found++ - } else if cfg.Name == "main-stack" && cfg.Reference == "main" { + } else if cfg.Name == "test-deploy" && cfg.Reference == "main" { + // Name overridden by nested .doco-cd.yaml in the remote repo (was "main-stack") found++ } } diff --git a/doco-cd-src/internal/config/deploy/destroy.go b/doco-cd-src/internal/config/deploy/destroy.go new file mode 100644 index 0000000..6ec55eb --- /dev/null +++ b/doco-cd-src/internal/config/deploy/destroy.go @@ -0,0 +1,68 @@ +package deploy + +import ( + "bytes" + "encoding/json" + "errors" + + "go.yaml.in/yaml/v3" +) + +// DestroyConfig holds options for destroying a deployment. +type DestroyConfig struct { + Enabled bool `yaml:"enabled" json:"enabled" default:"false"` // Enabled removes the deployment and all its resources from the Docker host + RemoveVolumes bool `yaml:"remove_volumes" json:"remove_volumes" default:"true"` // RemoveVolumes removes the volumes used by the deployment (always enabled in docker swarm mode) + RemoveImages bool `yaml:"remove_images" json:"remove_images" default:"true"` // RemoveImages removes the images used by the deployment (currently not supported in docker swarm mode) + RemoveRepoDir bool `yaml:"remove_dir" json:"remove_dir" default:"true"` // RemoveRepoDir removes the repository directory after the deployment is destroyed +} + +func (c *DestroyConfig) UnmarshalYAML(node *yaml.Node) error { + switch node.Kind { + case yaml.ScalarNode: + var enabled bool + if err := node.Decode(&enabled); err != nil { + return errors.New("invalid destroy value: expected bool or object") + } + + c.Enabled = enabled + + return nil + case yaml.MappingNode: + type plain DestroyConfig + + decoded := plain(*c) + if err := node.Decode(&decoded); err != nil { + return err + } + + *c = DestroyConfig(decoded) + + return nil + default: + return errors.New("invalid destroy value: expected bool or object") + } +} + +func (c *DestroyConfig) UnmarshalJSON(data []byte) error { + if bytes.Equal(bytes.TrimSpace(data), []byte("true")) || bytes.Equal(bytes.TrimSpace(data), []byte("false")) { + var enabled bool + if err := json.Unmarshal(data, &enabled); err != nil { + return errors.New("invalid destroy value: expected bool or object") + } + + c.Enabled = enabled + + return nil + } + + type plain DestroyConfig + + decoded := plain(*c) + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + + *c = DestroyConfig(decoded) + + return nil +} diff --git a/doco-cd-src/internal/config/deploy/destroy_test.go b/doco-cd-src/internal/config/deploy/destroy_test.go new file mode 100644 index 0000000..a53c1ed --- /dev/null +++ b/doco-cd-src/internal/config/deploy/destroy_test.go @@ -0,0 +1,113 @@ +package deploy + +import ( + "encoding/json" + "path/filepath" + "testing" +) + +func TestConfig_DestroyBoolOrObject(t *testing.T) { + t.Parallel() + + t.Run("yaml bool true uses defaults", func(t *testing.T) { + t.Parallel() + + filePath := filepath.Join(t.TempDir(), ".doco-cd.yaml") + + err := createTestFile(t, filePath, `name: test +compose_files: ["compose.yaml"] +destroy: true +`) + if err != nil { + t.Fatal(err) + } + + configs, err := GetConfigFromYAML(filePath, true) + if err != nil { + t.Fatal(err) + } + + if !configs[0].Destroy.Enabled { + t.Fatal("expected destroy.enabled to be true") + } + + if !configs[0].Destroy.RemoveVolumes || !configs[0].Destroy.RemoveImages || !configs[0].Destroy.RemoveRepoDir { + t.Fatalf("expected destroy defaults to stay true, got %+v", configs[0].Destroy) + } + }) + + t.Run("yaml object still works", func(t *testing.T) { + t.Parallel() + + filePath := filepath.Join(t.TempDir(), ".doco-cd.yaml") + + err := createTestFile(t, filePath, `name: test +compose_files: ["compose.yaml"] +destroy: + enabled: true + remove_volumes: false + remove_images: false + remove_dir: false +`) + if err != nil { + t.Fatal(err) + } + + configs, err := GetConfigFromYAML(filePath, true) + if err != nil { + t.Fatal(err) + } + + if !configs[0].Destroy.Enabled { + t.Fatal("expected destroy.enabled to be true") + } + + if configs[0].Destroy.RemoveVolumes || configs[0].Destroy.RemoveImages || configs[0].Destroy.RemoveRepoDir { + t.Fatalf("expected destroy object values to be respected, got %+v", configs[0].Destroy) + } + }) + + t.Run("yaml bool no-default decode only toggles enable", func(t *testing.T) { + t.Parallel() + + filePath := filepath.Join(t.TempDir(), ".doco-cd.yaml") + + err := createTestFile(t, filePath, `name: test +compose_files: ["compose.yaml"] +destroy: true +`) + if err != nil { + t.Fatal(err) + } + + configs, err := GetConfigFromYAML(filePath, false) + if err != nil { + t.Fatal(err) + } + + if !configs[0].Destroy.Enabled { + t.Fatal("expected destroy.enabled to be true") + } + + if configs[0].Destroy.RemoveVolumes || configs[0].Destroy.RemoveImages || configs[0].Destroy.RemoveRepoDir { + t.Fatalf("expected no-default decode to leave destroy option flags unset, got %+v", configs[0].Destroy) + } + }) + + t.Run("json bool true uses defaults", func(t *testing.T) { + t.Parallel() + + var cfg Config + if err := json.Unmarshal([]byte(`{"name":"test","compose_files":["compose.yaml"],"destroy":true}`), &cfg); err != nil { + t.Fatal(err) + } + + if !cfg.Destroy.Enabled { + t.Fatal("expected destroy.enabled to be true") + } + + if !cfg.Destroy.RemoveVolumes || !cfg.Destroy.RemoveImages || !cfg.Destroy.RemoveRepoDir { + t.Fatalf("expected destroy defaults to stay true, got %+v", cfg.Destroy) + } + }) +} diff --git a/doco-cd-src/internal/config/deploy/dotenv.go b/doco-cd-src/internal/config/deploy/dotenv.go new file mode 100644 index 0000000..73342c1 --- /dev/null +++ b/doco-cd-src/internal/config/deploy/dotenv.go @@ -0,0 +1,71 @@ +package deploy + +import ( + "fmt" + "maps" + "os" + "path/filepath" + "strings" + + "github.com/joho/godotenv" + + "github.com/kimdre/doco-cd/internal/encryption" +) + +// LoadLocalDotEnv processes local dotenv files and loads their variables into the Config.Internal Environment map. +// Remote dotenv files (prefixed with "remote:") are collected and left in Config.EnvFiles for later processing. +func LoadLocalDotEnv(config *Config, basePath string) error { + const remotePrefix = "remote:" + + var remoteEnvFiles []string // List of env files that are not local and will be processed later + + if len(config.Internal.Environment) == 0 { + config.Internal.Environment = make(map[string]string) + } + + for _, f := range config.EnvFiles { + // Process any env-files that are local and not in the remote repository (see repository_url) + if !strings.HasPrefix(f, remotePrefix) { + absPath := filepath.Join(basePath, f) + + // Decrypt file if needed + isEncrypted, err := encryption.IsEncryptedFile(absPath) + if err != nil { + if os.IsNotExist(err) && f == ".env" { + // It's okay if the default .env file doesn't exist + continue + } + + return fmt.Errorf("failed to check if env file is encrypted %s: %w", absPath, err) + } + + var envMap map[string]string + + if isEncrypted { + decryptedContent, err := encryption.DecryptFile(absPath) + if err != nil { + return fmt.Errorf("failed to decrypt env file %s: %w", absPath, err) + } + + envMap, err = godotenv.UnmarshalBytes(decryptedContent) + if err != nil { + return fmt.Errorf("failed to parse decrypted env file %s: %w", absPath, err) + } + } else { + envMap, err = godotenv.Read(absPath) + if err != nil { + return fmt.Errorf("failed to read local env file %s: %w", absPath, err) + } + } + + maps.Copy(config.Internal.Environment, envMap) + } else { + f = strings.TrimPrefix(f, remotePrefix) + remoteEnvFiles = append(remoteEnvFiles, f) + } + } + + config.EnvFiles = remoteEnvFiles + + return nil +} diff --git a/doco-cd-src/internal/config/deploy/git.go b/doco-cd-src/internal/config/deploy/git.go new file mode 100644 index 0000000..70fb6fc --- /dev/null +++ b/doco-cd-src/internal/config/deploy/git.go @@ -0,0 +1,16 @@ +package deploy + +import "github.com/go-git/go-git/v5/plumbing/transport" + +const DefaultReference = "refs/heads/main" + +// GitOptions holds git-related options for operations that require cloning or fetching remote repositories. +type GitOptions struct { + SSHPrivateKey string + SSHPrivateKeyPassphrase string + GitAccessToken string + SkipTLSVerification bool + HttpProxy transport.ProxyOptions + GitCloneSubmodules bool + GitCloneDepth int +} diff --git a/doco-cd-src/internal/config/deploy/reconciliation.go b/doco-cd-src/internal/config/deploy/reconciliation.go new file mode 100644 index 0000000..319b378 --- /dev/null +++ b/doco-cd-src/internal/config/deploy/reconciliation.go @@ -0,0 +1,120 @@ +package deploy + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" + + "go.yaml.in/yaml/v3" +) + +var supportedReconciliationEvents = map[string]struct{}{ + "die": {}, + "destroy": {}, + "update": {}, + "stop": {}, + "kill": {}, + "oom": {}, + "unhealthy": {}, +} + +// ReconciliationConfig holds settings for the reconciliation feature. +type ReconciliationConfig struct { + Enabled bool `yaml:"enabled" json:"enabled" default:"true"` // Enabled enables the reconciliation feature + Events []string `yaml:"events" json:"events" default:"[\"unhealthy\"]"` // Events is the list of Docker container actions that trigger reconciliation + RestartTimeout int `yaml:"restart_timeout" json:"restart_timeout" default:"10"` // RestartTimeout is the timeout in seconds to wait before killing a container during a restart + RestartSignal string `yaml:"restart_signal" json:"restart_signal" default:""` // RestartSignal is the signal sent to stop containers during a restart. If not set, the default of the Docker daemon is used (SIGTERM). + RestartLimit int `yaml:"restart_limit" json:"restart_limit" default:"5"` // RestartLimit suppresses further unhealthy-triggered restarts after this many restarts in the configured window. Set to 0 to disable suppression. + RestartWindow int `yaml:"restart_window" json:"restart_window" default:"300"` // RestartWindow is the time window in seconds used with RestartLimit. +} + +func (c *ReconciliationConfig) UnmarshalYAML(node *yaml.Node) error { + switch node.Kind { + case yaml.ScalarNode: + var enabled bool + if err := node.Decode(&enabled); err != nil { + return errors.New("invalid reconciliation value: expected bool or object") + } + + c.Enabled = enabled + + return nil + case yaml.MappingNode: + type plain ReconciliationConfig + + decoded := plain(*c) + if err := node.Decode(&decoded); err != nil { + return err + } + + *c = ReconciliationConfig(decoded) + + return nil + default: + return errors.New("invalid reconciliation value: expected bool or object") + } +} + +func (c *ReconciliationConfig) UnmarshalJSON(data []byte) error { + if bytes.Equal(bytes.TrimSpace(data), []byte("true")) || bytes.Equal(bytes.TrimSpace(data), []byte("false")) { + var enabled bool + if err := json.Unmarshal(data, &enabled); err != nil { + return errors.New("invalid reconciliation value: expected bool or object") + } + + c.Enabled = enabled + + return nil + } + + type plain ReconciliationConfig + + decoded := plain(*c) + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + + *c = ReconciliationConfig(decoded) + + return nil +} + +func (c *Config) normalizeReconciliationEvents() error { + if len(c.Reconciliation.Events) == 0 { + c.Reconciliation.Enabled = false + return nil + } + + normalized := make([]string, 0, len(c.Reconciliation.Events)) + seen := make(map[string]struct{}, len(c.Reconciliation.Events)) + + for _, rawEvent := range c.Reconciliation.Events { + event := strings.ToLower(strings.TrimSpace(rawEvent)) + + switch event { + case "remove", "delete": + event = "destroy" + } + + if event == "" { + return fmt.Errorf("%w: reconciliation.events contains an empty event", ErrInvalidConfig) + } + + if _, ok := supportedReconciliationEvents[event]; !ok { + return fmt.Errorf("%w: unsupported reconciliation event %q", ErrInvalidConfig, rawEvent) + } + + if _, exists := seen[event]; exists { + continue + } + + seen[event] = struct{}{} + normalized = append(normalized, event) + } + + c.Reconciliation.Events = normalized + + return nil +} diff --git a/doco-cd-src/internal/config/deploy/reconciliation_test.go b/doco-cd-src/internal/config/deploy/reconciliation_test.go new file mode 100644 index 0000000..33dd180 --- /dev/null +++ b/doco-cd-src/internal/config/deploy/reconciliation_test.go @@ -0,0 +1,398 @@ +package deploy + +import ( + "encoding/json" + "path/filepath" + "reflect" + "strings" + "testing" + + "go.yaml.in/yaml/v3" +) + +func TestConfig_ReconciliationEvents_Default(t *testing.T) { + t.Parallel() + + filePath := filepath.Join(t.TempDir(), ".doco-cd.yaml") + + err := createTestFile(t, filePath, `name: test +compose_files: ["compose.yaml"] +`) + if err != nil { + t.Fatal(err) + } + + configs, err := GetConfigFromYAML(filePath, true) + if err != nil { + t.Fatal(err) + } + + if err = configs[0].Validate(); err != nil { + t.Fatal(err) + } + + want := append([]string(nil), configs[0].Reconciliation.Events...) + if !reflect.DeepEqual(want, configs[0].Reconciliation.Events) { + t.Fatalf("expected reconciliation events %v, got %v", want, configs[0].Reconciliation.Events) + } + + if configs[0].Reconciliation.RestartTimeout != 10 { + t.Fatalf("expected default reconciliation restart_timeout 10, got %d", configs[0].Reconciliation.RestartTimeout) + } + + if configs[0].Reconciliation.RestartSignal != "" { + t.Fatalf("expected default restart_signal empty string, got %q", configs[0].Reconciliation.RestartSignal) + } + + if configs[0].Reconciliation.RestartLimit != 5 { + t.Fatalf("expected default restart_limit 5, got %d", configs[0].Reconciliation.RestartLimit) + } + + if configs[0].Reconciliation.RestartWindow != 300 { + t.Fatalf("expected default restart_window 300, got %d", configs[0].Reconciliation.RestartWindow) + } +} + +func TestConfig_BoolOrObjectRejectsInvalidScalarTypes(t *testing.T) { + t.Parallel() + + var cfg Config + + err := json.Unmarshal([]byte(`{"name":"test","compose_files":["compose.yaml"],"auto_discovery":1}`), &cfg) + if err == nil { + t.Fatal("expected error for numeric auto_discovery") + } + + if !strings.Contains(err.Error(), "cannot unmarshal") { + t.Fatalf("expected unmarshal error, got %v", err) + } + + var raw struct { + AutoDiscovery AutoDiscoveryConfig `yaml:"auto_discovery"` + } + + err = yaml.Unmarshal([]byte("auto_discovery: 1\n"), &raw) + if err == nil { + t.Fatal("expected error for numeric auto_discovery yaml value") + } +} + +func TestConfig_ReconciliationEvents_Normalize(t *testing.T) { + t.Parallel() + + filePath := filepath.Join(t.TempDir(), ".doco-cd.yaml") + + err := createTestFile(t, filePath, `name: test +compose_files: ["compose.yaml"] +reconciliation: + events: + - " DIE " + - destroy + - " UNHEALTHY " + - " unhealthy " + - update + - remove + - delete +`) + if err != nil { + t.Fatal(err) + } + + configs, err := GetConfigFromYAML(filePath, true) + if err != nil { + t.Fatal(err) + } + + if err = configs[0].Validate(); err != nil { + t.Fatal(err) + } + + want := []string{"die", "destroy", "unhealthy", "update"} + if !reflect.DeepEqual(want, configs[0].Reconciliation.Events) { + t.Fatalf("expected normalized reconciliation events %v, got %v", want, configs[0].Reconciliation.Events) + } +} + +func TestConfig_ReconciliationRestartSignal_Normalize(t *testing.T) { + t.Parallel() + + filePath := filepath.Join(t.TempDir(), ".doco-cd.yaml") + + err := createTestFile(t, filePath, `name: test +compose_files: ["compose.yaml"] +reconciliation: + restart_signal: " sigquit " +`) + if err != nil { + t.Fatal(err) + } + + configs, err := GetConfigFromYAML(filePath, true) + if err != nil { + t.Fatal(err) + } + + if err = configs[0].Validate(); err != nil { + t.Fatal(err) + } + + if configs[0].Reconciliation.RestartSignal != "SIGQUIT" { + t.Fatalf("expected normalized restart_signal SIGQUIT, got %q", configs[0].Reconciliation.RestartSignal) + } +} + +func TestConfig_ReconciliationEvents_Invalid(t *testing.T) { + t.Parallel() + + filePath := filepath.Join(t.TempDir(), ".doco-cd.yaml") + + err := createTestFile(t, filePath, `name: test +compose_files: ["compose.yaml"] +reconciliation: + events: ["created"] +`) + if err != nil { + t.Fatal(err) + } + + configs, err := GetConfigFromYAML(filePath, true) + if err != nil { + t.Fatal(err) + } + + err = configs[0].Validate() + if err == nil { + t.Fatal("expected invalid reconciliation event error") + } + + if !strings.Contains(err.Error(), "unsupported reconciliation event") { + t.Fatalf("expected unsupported reconciliation event error, got %v", err) + } +} + +func TestConfig_ReconciliationRestartSuppression_Invalid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + yaml string + match string + }{ + { + name: "negative limit", + yaml: `name: test +compose_files: ["compose.yaml"] +reconciliation: + restart_limit: -1 +`, + match: "reconciliation.restart_limit", + }, + { + name: "negative window", + yaml: `name: test +compose_files: ["compose.yaml"] +reconciliation: + restart_window: -10 +`, + match: "reconciliation.restart_window", + }, + { + name: "limit requires positive window", + yaml: `name: test +compose_files: ["compose.yaml"] +reconciliation: + restart_limit: 3 + restart_window: 0 +`, + match: "reconciliation.restart_window", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + filePath := filepath.Join(t.TempDir(), ".doco-cd.yaml") + if err := createTestFile(t, filePath, tc.yaml); err != nil { + t.Fatal(err) + } + + configs, err := GetConfigFromYAML(filePath, true) + if err != nil { + t.Fatal(err) + } + + err = configs[0].Validate() + if err == nil { + t.Fatalf("expected validation error containing %q", tc.match) + } + + if !strings.Contains(err.Error(), tc.match) { + t.Fatalf("expected error to contain %q, got %v", tc.match, err) + } + }) + } +} + +func TestReconciliationConfig_UnmarshalYAML_BooleanTrue(t *testing.T) { + t.Parallel() + + yamlStr := ` +name: test-deploy +reconciliation: true +` + + var cfg Config + + err := yaml.Unmarshal([]byte(yamlStr), &cfg) + if err != nil { + t.Fatalf("failed to unmarshal yaml: %v", err) + } + + if !cfg.Reconciliation.Enabled { + t.Errorf("expected reconciliation.enabled to be true, got false") + } + + // Should have default events + if len(cfg.Reconciliation.Events) != 1 || cfg.Reconciliation.Events[0] != "unhealthy" { + t.Errorf("expected default event [unhealthy], got %v", cfg.Reconciliation.Events) + } +} + +func TestReconciliationConfig_UnmarshalYAML_BooleanFalse(t *testing.T) { + t.Parallel() + + yamlStr := ` +name: test-deploy +reconciliation: false +` + + var cfg Config + + err := yaml.Unmarshal([]byte(yamlStr), &cfg) + if err != nil { + t.Fatalf("failed to unmarshal yaml: %v", err) + } + + if cfg.Reconciliation.Enabled { + t.Errorf("expected reconciliation.enabled to be false, got true") + } +} + +func TestReconciliationConfig_UnmarshalYAML_Object(t *testing.T) { + t.Parallel() + + yamlStr := ` +name: test-deploy +reconciliation: + enabled: true + restart_timeout: 30 + restart_signal: SIGQUIT + restart_limit: 5 + restart_window: 300 + events: + - destroy + - unhealthy +` + + var cfg Config + + err := yaml.Unmarshal([]byte(yamlStr), &cfg) + if err != nil { + t.Fatalf("failed to unmarshal yaml: %v", err) + } + + if !cfg.Reconciliation.Enabled { + t.Errorf("expected reconciliation.enabled to be true, got false") + } + + if cfg.Reconciliation.RestartTimeout != 30 { + t.Errorf("expected restart_timeout 30, got %d", cfg.Reconciliation.RestartTimeout) + } + + if cfg.Reconciliation.RestartSignal != "SIGQUIT" { + t.Errorf("expected restart_signal SIGQUIT, got %s", cfg.Reconciliation.RestartSignal) + } + + if len(cfg.Reconciliation.Events) != 2 { + t.Errorf("expected 2 events, got %d", len(cfg.Reconciliation.Events)) + } +} + +func TestReconciliationConfig_UnmarshalJSON_BooleanTrue(t *testing.T) { + t.Parallel() + + jsonStr := `{"name":"test-deploy","reconciliation":true}` + + var cfg Config + + err := json.Unmarshal([]byte(jsonStr), &cfg) + if err != nil { + t.Fatalf("failed to unmarshal json: %v", err) + } + + if !cfg.Reconciliation.Enabled { + t.Errorf("expected reconciliation.enabled to be true, got false") + } + + // Should have default events + if len(cfg.Reconciliation.Events) != 1 || cfg.Reconciliation.Events[0] != "unhealthy" { + t.Errorf("expected default event [unhealthy], got %v", cfg.Reconciliation.Events) + } +} + +func TestReconciliationConfig_UnmarshalJSON_BooleanFalse(t *testing.T) { + t.Parallel() + + jsonStr := `{"name":"test-deploy","reconciliation":false}` + + var cfg Config + + err := json.Unmarshal([]byte(jsonStr), &cfg) + if err != nil { + t.Fatalf("failed to unmarshal json: %v", err) + } + + if cfg.Reconciliation.Enabled { + t.Errorf("expected reconciliation.enabled to be false, got true") + } +} + +func TestReconciliationConfig_UnmarshalJSON_Object(t *testing.T) { + t.Parallel() + + jsonStr := `{ + "name":"test-deploy", + "reconciliation":{ + "enabled":true, + "restart_timeout":30, + "restart_signal":"SIGQUIT", + "restart_limit":5, + "restart_window":300, + "events":["destroy","unhealthy"] + } + }` + + var cfg Config + + err := json.Unmarshal([]byte(jsonStr), &cfg) + if err != nil { + t.Fatalf("failed to unmarshal json: %v", err) + } + + if !cfg.Reconciliation.Enabled { + t.Errorf("expected reconciliation.enabled to be true, got false") + } + + if cfg.Reconciliation.RestartTimeout != 30 { + t.Errorf("expected restart_timeout 30, got %d", cfg.Reconciliation.RestartTimeout) + } + + if cfg.Reconciliation.RestartSignal != "SIGQUIT" { + t.Errorf("expected restart_signal SIGQUIT, got %s", cfg.Reconciliation.RestartSignal) + } + + if len(cfg.Reconciliation.Events) != 2 { + t.Errorf("expected 2 events, got %d", len(cfg.Reconciliation.Events)) + } +} diff --git a/doco-cd-src/internal/config/deploy_config.go b/doco-cd-src/internal/config/deploy_config.go deleted file mode 100644 index 880915b..0000000 --- a/doco-cd-src/internal/config/deploy_config.go +++ /dev/null @@ -1,561 +0,0 @@ -package config - -import ( - "bytes" - "crypto/sha256" - "errors" - "fmt" - "io" - "log/slog" - "maps" - "os" - "path" - "path/filepath" - "strings" - - "github.com/compose-spec/compose-go/v2/cli" - "github.com/creasty/defaults" - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - "go.yaml.in/yaml/v3" - "gopkg.in/validator.v2" - - gitInternal "github.com/kimdre/doco-cd/internal/git" - - "github.com/kimdre/doco-cd/internal/logger" -) - -var ( - DefaultDeploymentConfigFileNames = []string{".doco-cd.yaml", ".doco-cd.yml"} - CustomDeploymentConfigFileNames = []string{".doco-cd.%s.yaml", ".doco-cd.%s.yml"} - ErrConfigFileNotFound = errors.New("configuration file not found in repository") - ErrDuplicateProjectName = errors.New("duplicate project/stack name found in configuration file") - ErrInvalidConfig = errors.New("invalid deploy configuration") - ErrKeyNotFound = errors.New("key not found") - ErrInvalidFilePath = errors.New("invalid file path") -) - -const DefaultReference = "refs/heads/main" - -// DeployConfig is the structure of the deployment configuration file. -type DeployConfig struct { - Name string `yaml:"name" json:"name"` // Name of the docker-compose deployment / stack - RepositoryUrl HttpUrl `yaml:"repository_url" json:"repository_url" default:"" validate:"httpUrl"` // RepositoryUrl is the http URL of the Git repository to deploy - WebhookEventFilter string `yaml:"webhook_filter" json:"webhook_filter" default:""` // WebhookEventFilter is a regular expression to whitelist deployment triggers based on the webhook event payload (e.g., branch like "^refs/heads/main$" or "main", tag like "^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$" or "v[0-9]+\.[0-9]+\.[0-9]+") - Reference string `yaml:"reference" json:"reference" default:""` // Reference is the Git reference to the deployment, e.g., refs/heads/main, main, refs/tags/v1.0.0 or v1.0.0 - WorkingDirectory string `yaml:"working_dir" json:"working_dir" default:"."` // WorkingDirectory is the working directory for the deployment - ComposeFiles []string `yaml:"compose_files" json:"compose_files" default:"[\"compose.yaml\", \"compose.yml\", \"docker-compose.yml\", \"docker-compose.yaml\"]"` // ComposeFiles is the list of docker-compose files to use - Environment map[string]string `yaml:"environment" json:"environment"` // Environment is a map of environment variables to use for variable interpolation in the compose files - EnvFiles []string `yaml:"env_files" json:"env_files" default:"[\".env\"]"` // EnvFiles is the list of dotenv files to use for variable interpolation - RemoveOrphans bool `yaml:"remove_orphans" json:"remove_orphans" default:"true"` // RemoveOrphans removes containers for services not defined in the Compose file - PruneImages bool `yaml:"prune_images" json:"prune_images" default:"true"` // PruneImages removes images that are no longer used by any service in the deployment or any other running container - ForceRecreate bool `yaml:"force_recreate" json:"force_recreate" default:"false"` // ForceRecreate forces the recreation/redeployment of containers even if the configuration has not changed - ForceImagePull bool `yaml:"force_image_pull" json:"force_image_pull" default:"false"` // ForceImagePull always pulls the latest version of the image tags you've specified if a newer version is available - Timeout int `yaml:"timeout" json:"timeout" default:"180"` // Timeout is the time in seconds to wait for the deployment to finish in seconds before timing out - BuildOpts struct { - ForceImagePull bool `yaml:"force_image_pull" json:"force_image_pull" default:"false"` // ForceImagePull always attempt to pull a newer version of the image - Quiet bool `yaml:"quiet" json:"quiet" default:"false"` // Quiet suppresses the build output - Args map[string]string `yaml:"args" json:"args"` // BuildArgs is a map of build-time arguments to pass to the build process - NoCache bool `yaml:"no_cache" json:"no_cache" default:"false"` // NoCache disables the use of the cache when building images - } `yaml:"build_opts"` // BuildOpts is the build options for the deployment - GitDepth int `yaml:"git_depth" json:"git_depth" default:"0"` // GitDepth limits the number of commits to fetch. 0 means use global GIT_CLONE_DEPTH. A positive value overrides the global setting. - Destroy bool `yaml:"destroy" json:"destroy" default:"false"` // Destroy removes the deployment and all its resources from the Docker host - DestroyOpts struct { - RemoveVolumes bool `yaml:"remove_volumes" json:"remove_volumes" default:"true"` // RemoveVolumes removes the volumes used by the deployment (always enabled in docker swarm mode) - RemoveImages bool `yaml:"remove_images" json:"remove_images" default:"true"` // RemoveImages removes the images used by the deployment (currently not supported in docker swarm mode) - RemoveRepoDir bool `yaml:"remove_dir" json:"remove_dir" default:"true"` // RemoveRepoDir removes the repository directory after the deployment is destroyed - } `yaml:"destroy_opts" json:"destroy_opts"` // DestroyOpts is the destroy options for the deployment - Profiles []string `yaml:"profiles" json:"profiles" default:"[]"` // Profiles is a list of profiles to use for the deployment, e.g., ["dev", "prod"]. See https://docs.docker.com/compose/how-tos/profiles/ - ExternalSecrets map[string]ExternalSecretRef `yaml:"external_secrets" json:"external_secrets"` // ExternalSecrets maps env vars to legacy string references or structured references (e.g. webhook store_ref/remote_ref). - AutoDiscover bool `yaml:"auto_discover" json:"auto_discover" default:"false"` // AutoDiscover enables autodiscovery of services to deploy in the working directory by checking for subdirectories with docker-compose files - AutoDiscoverOpts struct { - ScanDepth int `yaml:"depth" json:"depth" default:"0"` // ScanDepth is the maximum depth of subdirectories to scan for docker-compose files - Delete bool `yaml:"delete" json:"delete" default:"true"` // Delete removes obsolete auto-discovered deployments that are no longer present in the repository - } `yaml:"auto_discover_opts" json:"auto_discover_opts"` // AutoDiscoverOpts are options for the autodiscovery feature - Reconciliation struct { - Enabled bool `yaml:"enabled" json:"enabled" default:"true"` // Enabled enables the reconciliation feature - Interval int `yaml:"interval" json:"interval" default:"60"` // Interval is the interval in seconds at which the reconciliation job is run - } `yaml:"reconciliation" json:"reconciliation"` // Reconciliation is the configuration for the reconciliation feature - Internal struct { - File string `yaml:"-"` // File is the path to the deployment configuration file in the repository (if RepositoryUrl is not set) or in the cloned repository (if RepositoryUrl is set) - Environment map[string]string // Environment is stores environment variables for variable interpolation in the compose project - Hash string `yaml:"-"` // Hash is a hash of the DeployConfig struct (without changing the order of its elements) - } // Internal holds internal configuration values that are not set by the user -} - -// ResolveGitDepth returns the effective git clone depth. -// If the deploy-level GitDepth is > 0, it overrides the global value. -// Otherwise the global depth is used. 0 means full clone (no limit). -func (c *DeployConfig) ResolveGitDepth(globalDepth int) int { - if c.GitDepth > 0 { - return c.GitDepth - } - - return globalDepth -} - -// DefaultDeployConfig creates a DeployConfig with default values. -func DefaultDeployConfig(name, reference string) *DeployConfig { - return &DeployConfig{ - Name: name, - Reference: reference, - WorkingDirectory: ".", - ComposeFiles: cli.DefaultFileNames, - } -} - -// LogValue implements the slog.LogValuer interface for DeployConfig. -func (c DeployConfig) LogValue() slog.Value { - return logger.BuildLogValue(c, "Internal") -} - -func (c *DeployConfig) validateConfig() error { - if c.Name == "" && !c.AutoDiscover { - return fmt.Errorf("%w: name", ErrKeyNotFound) - } - - if c.GitDepth < 0 { - return fmt.Errorf("%w: git_depth must be >= 0", ErrInvalidConfig) - } - - c.WorkingDirectory = filepath.Clean(c.WorkingDirectory) - if !filepath.IsLocal(c.WorkingDirectory) { - c.WorkingDirectory = filepath.Join(".", c.WorkingDirectory) - } - - if len(c.ComposeFiles) == 0 { - return fmt.Errorf("%w: compose_files", ErrKeyNotFound) - } - - cleanComposeFiles := make([]string, 0, len(c.ComposeFiles)) - // Sanitize the compose file path - for _, file := range c.ComposeFiles { - cleaned := filepath.Clean(file) - - if filepath.IsAbs(cleaned) { - return fmt.Errorf("%w: %s", ErrInvalidFilePath, file) - } - - if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(os.PathSeparator)) { - return fmt.Errorf("%w: %s", ErrInvalidFilePath, file) - } - - full := filepath.Join(c.WorkingDirectory, cleaned) - - rel, err := filepath.Rel(c.WorkingDirectory, full) - if err != nil { - return fmt.Errorf("%w: %s", ErrInvalidFilePath, file) - } - - if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { - return fmt.Errorf("%w: %s", ErrInvalidFilePath, file) - } - - cleanComposeFiles = append(cleanComposeFiles, cleaned) - } - - c.ComposeFiles = cleanComposeFiles - - return nil -} - -func (c *DeployConfig) UnmarshalYAML(unmarshal func(any) error) error { - err := defaults.Set(c) - if err != nil { - return err - } - - type Plain DeployConfig - - if err := unmarshal((*Plain)(c)); err != nil { - return err - } - - return nil -} - -// Hash returns a hash of the DeployConfig struct (without changing the order of its elements). -func (c *DeployConfig) Hash() (string, error) { - data, err := yaml.Marshal(c) - if err != nil { - return "", err - } - - return fmt.Sprintf("%x", sha256.Sum256(data)), nil -} - -// GetDeployConfigFromYAML reads a YAML file and unmarshals it into a slice of DeployConfig structs. -func GetDeployConfigFromYAML(f string) ([]*DeployConfig, error) { - b, err := os.ReadFile(f) // #nosec G304 - if err != nil { - return nil, fmt.Errorf("failed to read file: %v", err) - } - - // Read all YAML documents in the file and unmarshal them into a slice of DeployConfig structs - dec := yaml.NewDecoder(bytes.NewReader(b)) - - var configs []*DeployConfig - - for { - var c DeployConfig - - err = dec.Decode(&c) - if err != nil { - if err == io.EOF { - break - } - - return nil, fmt.Errorf("failed to decode yaml: %v", err) - } - - c.Internal.File = f - - configs = append(configs, &c) - } - - if len(configs) == 0 { - return nil, errors.New("no yaml documents found in file") - } - - return configs, nil -} - -// GetDeployConfigs returns either the deployment configuration from the repository or the default configuration. -func GetDeployConfigs(repoRoot, deployConfigBaseDir, name, customTarget, reference string) ([]*DeployConfig, error) { - configDir := filepath.Join(repoRoot, deployConfigBaseDir) - - files, err := os.ReadDir(configDir) - if err != nil { - return nil, err - } - - var DeploymentConfigFileNames []string - - if reference == "" { - reference = DefaultReference - } - - if customTarget != "" { - for _, configFile := range CustomDeploymentConfigFileNames { - DeploymentConfigFileNames = append(DeploymentConfigFileNames, fmt.Sprintf(configFile, customTarget)) - } - } else { - DeploymentConfigFileNames = DefaultDeploymentConfigFileNames - } - - // Get repo and change to reference in c.Reference if it is different to the current reference in the repoRoot, - // otherwise it will cause issues with the auto-discovery - baseRepo, err := git.PlainOpen(repoRoot) - if err != nil { - return nil, fmt.Errorf("failed to open git repository at %s: %w", repoRoot, err) - } - - // Compare the resolved reference with the current HEAD reference, if they are different then skip the auto-discovery for this deployment config - headRef, err := baseRepo.Head() - if err != nil { - return nil, fmt.Errorf("%w: %w", gitInternal.ErrGetHeadFailed, err) - } - - // Checkout repo to different reference - w, err := baseRepo.Worktree() - if err != nil { - return nil, fmt.Errorf("failed to get git worktree: %w", err) - } - - // Defer checkout back to original HEAD reference after the deployment is done - defer func(branch plumbing.ReferenceName) { - err = w.Checkout(&git.CheckoutOptions{ - Branch: branch, - Keep: true, - }) - if err != nil { - slog.Error("failed to checkout back to original HEAD reference after deployment", "error", err) - } - }(headRef.Name()) - - var configs []*DeployConfig - for _, configFile := range DeploymentConfigFileNames { - configs, err = getDeployConfigsFromFile(configDir, files, configFile) - if err != nil { - if errors.Is(err, ErrConfigFileNotFound) { - continue - } - - return nil, err - } - - appConfig, err := GetAppConfig() - if err != nil { - return nil, fmt.Errorf("failed to get app config: %w", err) - } - - // Build a new slice to avoid modifying the slice we're iterating over - var expandedConfigs []*DeployConfig - - // Handle autodiscover deployment configs - for _, c := range configs { - if c.Reference == "" { - // If the reference is not already set in the deployment config file, set it to the current reference - c.Reference = reference - } - - repoDir := repoRoot - // Check for deployConfigs with AutoDiscover enabled, if true then remove this config and add new configs based on discovered compose files - if c.AutoDiscover { - if c.RepositoryUrl != "" { - auth, err := gitInternal.GetAuthMethod(string(c.RepositoryUrl), appConfig.SSHPrivateKey, appConfig.SSHPrivateKeyPassphrase, appConfig.GitAccessToken) - if err != nil { - return nil, fmt.Errorf("failed to get auth method: %w", err) - } - - repoDir = path.Join(path.Dir(repoRoot), gitInternal.GetRepoName(string(c.RepositoryUrl))) - - // Clone the repository to repoDir if it does not exist, otherwise fetch the latest changes and checkout to the correct reference - _, err = gitInternal.CloneRepository(repoDir, string(c.RepositoryUrl), c.Reference, appConfig.SkipTLSVerification, appConfig.HttpProxy, auth, appConfig.GitCloneSubmodules, c.ResolveGitDepth(appConfig.GitCloneDepth)) - if err != nil { - if errors.Is(err, git.ErrRepositoryAlreadyExists) { - _, err = gitInternal.UpdateRepository(repoDir, string(c.RepositoryUrl), c.Reference, appConfig.SkipTLSVerification, appConfig.HttpProxy, auth, appConfig.GitCloneSubmodules, c.ResolveGitDepth(appConfig.GitCloneDepth)) - if err != nil { - return nil, fmt.Errorf("failed to update repository: %w", err) - } - } else { - return nil, fmt.Errorf("failed to clone repository: %w", err) - } - } - } else { - auth, err := gitInternal.GetAuthMethod(string(c.RepositoryUrl), appConfig.SSHPrivateKey, appConfig.SSHPrivateKeyPassphrase, appConfig.GitAccessToken) - if err != nil { - return nil, fmt.Errorf("failed to get auth method: %w", err) - } - - unlock := gitInternal.AcquirePathLock(repoRoot) - err = gitInternal.CheckoutRepository(baseRepo, c.Reference, auth, appConfig.GitCloneSubmodules) - - unlock() - - if err != nil { - return nil, fmt.Errorf("failed to checkout repository to reference %s: %w", c.Reference, err) - } - } - - discoveredConfigs, err := autoDiscoverDeployments(repoDir, c) - if err != nil { - return nil, fmt.Errorf("failed to auto-discover deployment configurations: %w", err) - } - - // Add the discovered configs to the expanded list - expandedConfigs = append(expandedConfigs, discoveredConfigs...) - } else { - // Keep non-autodiscover configs as-is - expandedConfigs = append(expandedConfigs, c) - } - } - - if expandedConfigs != nil { - if err = validator.Validate(expandedConfigs); err != nil { - return nil, err - } - - // Check if the stack/project names are not unique - err = validateUniqueProjectNames(expandedConfigs) - if err != nil { - return nil, err - } - - return expandedConfigs, nil - } - } - - if customTarget != "" { - return nil, fmt.Errorf("%w: .doco-cd.%s.y(a)ml", ErrConfigFileNotFound, customTarget) - } - - return []*DeployConfig{DefaultDeployConfig(name, reference)}, nil -} - -// getDeployConfigsFromFile returns the deployment configurations from the repository or nil if not found. -func getDeployConfigsFromFile(dir string, files []os.DirEntry, configFile string) ([]*DeployConfig, error) { - for _, f := range files { - if f.IsDir() { - continue - } - - if f.Name() == configFile { - // Get contents of deploy config file - configs, err := GetDeployConfigFromYAML(path.Join(dir, f.Name())) - if err != nil { - return nil, err - } - - // Validate all deploy configs - for _, c := range configs { - if err = c.validateConfig(); err != nil { - return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err) - } - } - - if configs != nil { - return configs, nil - } - } - } - - return nil, ErrConfigFileNotFound -} - -// validateUniqueProjectNames checks if the project names in the configs are unique. -func validateUniqueProjectNames(configs []*DeployConfig) error { - names := make(map[string]bool) - for _, config := range configs { - if names[config.Name] { - return fmt.Errorf("%w: %s", ErrDuplicateProjectName, config.Name) - } - - names[config.Name] = true - } - - return nil -} - -// ResolveDeployConfigs returns deployment configs for a poll run, preferring inline -// deployments defined on the PollConfig when provided. Falls back to repository -// configuration files or default values when no inline deployments are present. -// repoRoot is the absolute path to the repository root. -// deployConfigBaseDir is the relative path from repo root where config files are located. -func ResolveDeployConfigs(poll PollConfig, repoRoot, deployConfigBaseDir, name string) ([]*DeployConfig, error) { - // Prefer inline deployments when present - if len(poll.Deployments) > 0 { - configs, err := expandInlineAutoDiscoverConfigs(repoRoot, poll.Deployments) - if err != nil { - return nil, err - } - - return configs, nil - } - - // No inline deployments, use repository config discovery - return GetDeployConfigs(repoRoot, deployConfigBaseDir, name, poll.CustomTarget, poll.Reference) -} - -// expandInlineAutoDiscoverConfigs replaces inline deployments that have auto-discovery -// enabled with the discovered deployments rooted at repoRoot. -func expandInlineAutoDiscoverConfigs(repoRoot string, deployments []*DeployConfig) ([]*DeployConfig, error) { - expanded := make([]*DeployConfig, 0, len(deployments)) - - for _, deployment := range deployments { - if !deployment.AutoDiscover { - expanded = append(expanded, deployment) - continue - } - - discoveredConfigs, err := autoDiscoverDeployments(repoRoot, deployment) - if err != nil { - return nil, fmt.Errorf("failed to auto-discover deployment configurations: %w", err) - } - - expanded = append(expanded, discoveredConfigs...) - } - - return expanded, nil -} - -// autoDiscoverDeployments scans for subdirectories containing docker-compose files -// and generates DeployConfig entries for each. -// repoRoot is the absolute path to the repository root. -// baseDeployConfig.WorkingDirectory is treated as repo-root-relative. -func autoDiscoverDeployments(repoRoot string, baseDeployConfig *DeployConfig) ([]*DeployConfig, error) { - var configs []*DeployConfig - - searchPath := filepath.Join(repoRoot, baseDeployConfig.WorkingDirectory) - - err := filepath.WalkDir(searchPath, func(p string, d os.DirEntry, err error) error { - if err != nil { - return err - } - - // Calculate the depth of the current path relative to the search path - rel, err := filepath.Rel(searchPath, p) - if err != nil { - return err - } - - depth := 0 - if rel != "." { - depth = len(strings.Split(rel, string(os.PathSeparator))) - } - - // Skip directories that exceed the maximum depth if ScanDepth is set greater than 0 - if d.IsDir() && depth > baseDeployConfig.AutoDiscoverOpts.ScanDepth && baseDeployConfig.AutoDiscoverOpts.ScanDepth > 0 { - return filepath.SkipDir - } - - if !d.IsDir() { - return nil - } - - // Check if the directory contains any docker-compose files - for _, composeFile := range baseDeployConfig.ComposeFiles { - composeFilePath := filepath.Join(p, composeFile) - if _, err = os.Stat(composeFilePath); err == nil { - c := &DeployConfig{} - deepCopy(baseDeployConfig, c) - - stackDirName := filepath.Base(p) // Get the stack name from the directory name where the compose file is located - repoName := filepath.Base(repoRoot) // Get the repository name from the repo root path - - if baseDeployConfig.Name != "" && stackDirName == repoName { - c.Name = baseDeployConfig.Name - } else { - c.Name = stackDirName - } - - c.WorkingDirectory, err = filepath.Rel(repoRoot, p) - if err != nil { - return err - } - - configs = append(configs, c) - - break - } - } - - return nil - }) - if err != nil { - return nil, err - } - - return configs, nil -} - -// deepCopy creates a deep copy of a DeployConfig struct. -func deepCopy(src, dst *DeployConfig) { - *dst = *src - - // Deep copy maps and slices - if src.ComposeFiles != nil { - dst.ComposeFiles = make([]string, len(src.ComposeFiles)) - copy(dst.ComposeFiles, src.ComposeFiles) - } - - if src.EnvFiles != nil { - dst.EnvFiles = make([]string, len(src.EnvFiles)) - copy(dst.EnvFiles, src.EnvFiles) - } - - if src.BuildOpts.Args != nil { - dst.BuildOpts.Args = make(map[string]string) - maps.Copy(dst.BuildOpts.Args, src.BuildOpts.Args) - } - - if src.Profiles != nil { - dst.Profiles = make([]string, len(src.Profiles)) - copy(dst.Profiles, src.Profiles) - } - - if src.ExternalSecrets != nil { - dst.ExternalSecrets = make(map[string]ExternalSecretRef) - maps.Copy(dst.ExternalSecrets, src.ExternalSecrets) - } -} diff --git a/doco-cd-src/internal/config/errors.go b/doco-cd-src/internal/config/errors.go index 70e1b3a..e058972 100644 --- a/doco-cd-src/internal/config/errors.go +++ b/doco-cd-src/internal/config/errors.go @@ -2,12 +2,9 @@ package config import ( "errors" - - "gopkg.in/validator.v2" ) var ( - ErrInvalidLogLevel = validator.TextErr{Err: errors.New("invalid log level, must be one of debug, info, warn, error")} ErrBothSecretsSet = errors.New("both secrets are set, please use one or the other") ErrBothSecretsNotSet = errors.New("neither secrets are set, please use one or the other") ErrInvalidHttpUrl = errors.New("invalid HTTP URL") diff --git a/doco-cd-src/internal/config/poll/poll_config.go b/doco-cd-src/internal/config/poll/poll_config.go new file mode 100644 index 0000000..a529385 --- /dev/null +++ b/doco-cd-src/internal/config/poll/poll_config.go @@ -0,0 +1,126 @@ +package poll + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + + "github.com/creasty/defaults" + "gopkg.in/validator.v2" + + "github.com/kimdre/doco-cd/internal/config" + + "github.com/kimdre/doco-cd/internal/config/deploy" + "github.com/kimdre/doco-cd/internal/logger" +) + +type Config struct { + CloneUrl config.HttpUrl `yaml:"url" json:"url" validate:"httpUrl"` // CloneUrl is the URL to clone the Git repository that is used to poll for changes + Reference string `yaml:"reference" json:"reference" default:"refs/heads/main"` // Reference is the Git reference to the deployment, e.g., refs/heads/main, main, refs/tags/v1.0.0 or v1.0.0 + Interval int `yaml:"interval" default:"180"` // Interval is the interval in seconds to poll for changes + CustomTarget string `yaml:"target" json:"target" default:""` // CustomTarget is the name of an optional custom deployment config file, e.g. ".doco-cd.custom-name.yaml" + RunOnce bool `yaml:"run_once" default:"false"` // RunOnce when true, performs a single run and exits + Deployments []*deploy.Config `yaml:"deployments" json:"deployments" default:"[]"` // Deployments allows defining deployment configs inline in the poll configuration +} + +type Job struct { + Config Config // config is the Config for this instance + LastRun int64 // LastRun is the last time this instance ran + NextRun int64 // NextRun is the next time this instance should run +} + +const MinPollInterval = 10 // Minimum allowed poll interval in seconds + +var ( + ErrInvalidConfig = errors.New("invalid poll configuration") + ErrBothConfigSet = errors.New("both POLL_CONFIG and POLL_CONFIG_FILE are set, please use one or the other") + ErrIntervalTooLow = errors.New("poll interval too low") +) + +// LogValue implements the slog.LogValuer interface for Config. +func (c *Config) LogValue() slog.Value { + return logger.BuildLogValue(c, "Deployments.Internal") +} + +// Validate checks if the Config is valid. +func (c *Config) Validate() error { + if c.CloneUrl == "" { + return fmt.Errorf("%w: url", deploy.ErrKeyNotFound) + } + + if c.Reference == "" { + return fmt.Errorf("%w: reference", deploy.ErrKeyNotFound) + } + + if c.Interval < MinPollInterval && c.Interval != 0 { + return fmt.Errorf("%w: must be at least %d seconds", ErrIntervalTooLow, MinPollInterval) + } + + // If inline deployments are defined, validate them + if len(c.Deployments) > 0 { + for _, d := range c.Deployments { + // Ensure DeployConfig defaults are applied when defined inline or programmatically + if err := defaults.Set(d); err != nil { + return err + } + + // If reference isn't set on the deployment, inherit from poll config + if d.Reference == "" { + d.Reference = c.Reference + } + + // Validate the deployment configuration (ensures name is present and paths are sane) + if err := d.Validate(); err != nil { + return fmt.Errorf("%w: %v", deploy.ErrInvalidConfig, err) + } + } + + // Ensure unique stack names across inline deployments + if err := deploy.ValidateUniqueProjectNames(c.Deployments); err != nil { + return err + } + } + + err := validator.Validate(c) + if err != nil { + return fmt.Errorf("%w: %v", ErrInvalidConfig, err) + } + + return nil +} + +// String returns a string representation of the Config. +func (c *Config) String() string { + return fmt.Sprintf("Config{CloneUrl: %s, Reference: %s, Interval: %d}", c.CloneUrl, c.Reference, c.Interval) +} + +func (c *Config) UnmarshalYAML(unmarshal func(any) error) error { + err := defaults.Set(c) + if err != nil { + return err + } + + type Plain Config + + if err := unmarshal((*Plain)(c)); err != nil { + return err + } + + return nil +} + +func (c *Config) UnmarshalJSON(data []byte) error { + err := defaults.Set(c) + if err != nil { + return err + } + + type Plain Config + + if err := json.Unmarshal(data, (*Plain)(c)); err != nil { + return err + } + + return nil +} diff --git a/doco-cd-src/internal/config/poll_config_test.go b/doco-cd-src/internal/config/poll/poll_config_test.go similarity index 73% rename from doco-cd-src/internal/config/poll_config_test.go rename to doco-cd-src/internal/config/poll/poll_config_test.go index f57ce1a..2b0b852 100644 --- a/doco-cd-src/internal/config/poll_config_test.go +++ b/doco-cd-src/internal/config/poll/poll_config_test.go @@ -1,21 +1,23 @@ -package config +package poll import ( "errors" "testing" + + "github.com/kimdre/doco-cd/internal/config/deploy" ) -func TestPollConfig_Validate(t *testing.T) { +func TestConfig_Validate(t *testing.T) { t.Parallel() testCases := []struct { name string - config PollConfig + config Config expected error }{ { name: "Valid config", - config: PollConfig{ + config: Config{ CloneUrl: "https://example.com/repo.git", Reference: "main", Interval: 10, @@ -24,43 +26,43 @@ func TestPollConfig_Validate(t *testing.T) { }, { name: "Invalid config - empty CloneUrl", - config: PollConfig{ + config: Config{ CloneUrl: "", Reference: "main", Interval: 10, }, - expected: ErrKeyNotFound, + expected: deploy.ErrKeyNotFound, }, { name: "Invalid config - empty Reference", - config: PollConfig{ + config: Config{ CloneUrl: "https://example.com/repo.git", Reference: "", Interval: 10, }, - expected: ErrKeyNotFound, + expected: deploy.ErrKeyNotFound, }, { name: "Invalid config - negative Interval", - config: PollConfig{ + config: Config{ CloneUrl: "https://example.com/repo.git", Reference: "main", Interval: -5, }, - expected: ErrPollIntervalTooLow, + expected: ErrIntervalTooLow, }, { name: "Invalid config - 5s Interval", - config: PollConfig{ + config: Config{ CloneUrl: "https://example.com/repo.git", Reference: "main", Interval: 5, }, - expected: ErrPollIntervalTooLow, + expected: ErrIntervalTooLow, }, { name: "Invalid config - zero Interval (Disabled)", - config: PollConfig{ + config: Config{ CloneUrl: "https://example.com/repo.git", Reference: "main", Interval: 0, @@ -80,31 +82,31 @@ func TestPollConfig_Validate(t *testing.T) { } } -func TestPollConfig_String(t *testing.T) { +func TestConfig_String(t *testing.T) { t.Parallel() testCases := []struct { name string - config PollConfig + config Config expected string }{ { name: "Valid config", - config: PollConfig{ + config: Config{ CloneUrl: "https://example.com/repo.git", Reference: "main", Interval: 10, }, - expected: "PollConfig{CloneUrl: https://example.com/repo.git, Reference: main, Interval: 10}", + expected: "Config{CloneUrl: https://example.com/repo.git, Reference: main, Interval: 10}", }, { name: "Basic config", - config: PollConfig{ + config: Config{ CloneUrl: "https://example.com/repo.git", Reference: "main", Interval: 180, }, - expected: "PollConfig{CloneUrl: https://example.com/repo.git, Reference: main, Interval: 180}", + expected: "Config{CloneUrl: https://example.com/repo.git, Reference: main, Interval: 180}", }, } for _, tc := range testCases { diff --git a/doco-cd-src/internal/config/poll_config.go b/doco-cd-src/internal/config/poll_config.go deleted file mode 100644 index fcbc061..0000000 --- a/doco-cd-src/internal/config/poll_config.go +++ /dev/null @@ -1,123 +0,0 @@ -package config - -import ( - "encoding/json" - "errors" - "fmt" - "log/slog" - - "github.com/creasty/defaults" - "gopkg.in/validator.v2" - - "github.com/kimdre/doco-cd/internal/logger" -) - -var ( - ErrInvalidPollConfig = errors.New("invalid poll configuration") - ErrBothPollConfigSet = errors.New("both POLL_CONFIG and POLL_CONFIG_FILE are set, please use one or the other") - ErrPollIntervalTooLow = errors.New("poll interval too low") -) - -type PollConfig struct { - CloneUrl HttpUrl `yaml:"url" json:"url" validate:"httpUrl"` // CloneUrl is the URL to clone the Git repository that is used to poll for changes - Reference string `yaml:"reference" json:"reference" default:"refs/heads/main"` // Reference is the Git reference to the deployment, e.g., refs/heads/main, main, refs/tags/v1.0.0 or v1.0.0 - Interval int `yaml:"interval" default:"180"` // Interval is the interval in seconds to poll for changes - CustomTarget string `yaml:"target" json:"target" default:""` // CustomTarget is the name of an optional custom deployment config file, e.g. ".doco-cd.custom-name.yaml" - RunOnce bool `yaml:"run_once" default:"false"` // RunOnce when true, performs a single run and exits - Deployments []*DeployConfig `yaml:"deployments" json:"deployments" default:"[]"` // Deployments allows defining deployment configs inline in the poll configuration -} - -type PollJob struct { - Config PollConfig // config is the PollConfig for this instance - LastRun int64 // LastRun is the last time this instance ran - NextRun int64 // NextRun is the next time this instance should run -} - -const MinPollInterval = 10 // Minimum allowed poll interval in seconds - -// LogValue implements the slog.LogValuer interface for PollConfig. -func (c PollConfig) LogValue() slog.Value { - return logger.BuildLogValue(c, "Deployments.Internal") -} - -// Validate checks if the PollConfig is valid. -func (c *PollConfig) Validate() error { - if c.CloneUrl == "" { - return fmt.Errorf("%w: url", ErrKeyNotFound) - } - - if c.Reference == "" { - return fmt.Errorf("%w: reference", ErrKeyNotFound) - } - - if c.Interval < MinPollInterval && c.Interval != 0 { - return fmt.Errorf("%w: must be at least %d seconds", ErrPollIntervalTooLow, MinPollInterval) - } - - // If inline deployments are defined, validate them - if len(c.Deployments) > 0 { - for _, d := range c.Deployments { - // Ensure DeployConfig defaults are applied when defined inline or programmatically - if err := defaults.Set(d); err != nil { - return err - } - - // If reference isn't set on the deployment, inherit from poll config - if d.Reference == "" { - d.Reference = c.Reference - } - - // Validate the deployment configuration (ensures name is present and paths are sane) - if err := d.validateConfig(); err != nil { - return fmt.Errorf("%w: %v", ErrInvalidConfig, err) - } - } - - // Ensure unique stack names across inline deployments - if err := validateUniqueProjectNames(c.Deployments); err != nil { - return err - } - } - - err := validator.Validate(c) - if err != nil { - return fmt.Errorf("%w: %v", ErrInvalidPollConfig, err) - } - - return nil -} - -// String returns a string representation of the PollConfig. -func (c *PollConfig) String() string { - return fmt.Sprintf("PollConfig{CloneUrl: %s, Reference: %s, Interval: %d}", c.CloneUrl, c.Reference, c.Interval) -} - -func (c *PollConfig) UnmarshalYAML(unmarshal func(any) error) error { - err := defaults.Set(c) - if err != nil { - return err - } - - type Plain PollConfig - - if err := unmarshal((*Plain)(c)); err != nil { - return err - } - - return nil -} - -func (c *PollConfig) UnmarshalJSON(data []byte) error { - err := defaults.Set(c) - if err != nil { - return err - } - - type Plain PollConfig - - if err := json.Unmarshal(data, (*Plain)(c)); err != nil { - return err - } - - return nil -} diff --git a/doco-cd-src/internal/config/testdata/invalid.yaml b/doco-cd-src/internal/config/testdata/invalid.yaml deleted file mode 100644 index 2c5d75a..0000000 --- a/doco-cd-src/internal/config/testdata/invalid.yaml +++ /dev/null @@ -1,3 +0,0 @@ -name: invalid example -reference=12.345 -compose_files: { this is invalid } \ No newline at end of file diff --git a/doco-cd-src/internal/config/testdata/valid.yaml b/doco-cd-src/internal/config/testdata/valid.yaml deleted file mode 100644 index 63b1aea..0000000 --- a/doco-cd-src/internal/config/testdata/valid.yaml +++ /dev/null @@ -1,4 +0,0 @@ -name: test-deploy -reference: refs/heads/main -compose_files: - - dev.compose.yaml \ No newline at end of file diff --git a/doco-cd-src/internal/config/utils.go b/doco-cd-src/internal/config/utils.go index 3bfea0f..2e92dca 100644 --- a/doco-cd-src/internal/config/utils.go +++ b/doco-cd-src/internal/config/utils.go @@ -2,13 +2,10 @@ package config import ( "fmt" - "maps" "os" - "path/filepath" "strings" "github.com/caarlos0/env/v11" - "github.com/joho/godotenv" "gopkg.in/validator.v2" "github.com/kimdre/doco-cd/internal/encryption" @@ -79,61 +76,3 @@ func ParseConfigFromEnv(config any, mappings *[]EnvVarFileMapping) error { return nil } - -// LoadLocalDotEnv processes local dotenv files and loads their variables into the DeployConfig.Internal.Environment map. -// Remote dotenv files (prefixed with "remote:") are collected and left in DeployConfig.EnvFiles for later processing. -func LoadLocalDotEnv(deployConfig *DeployConfig, basePath string) error { - const remotePrefix = "remote:" - - var remoteEnvFiles []string // List of env files that are not local and will be processed later - - if len(deployConfig.Internal.Environment) == 0 { - deployConfig.Internal.Environment = make(map[string]string) - } - - for _, f := range deployConfig.EnvFiles { - // Process any env-files that are local and not in the remote repository (see repository_url) - if !strings.HasPrefix(f, remotePrefix) { - absPath := filepath.Join(basePath, f) - - // Decrypt file if needed - isEncrypted, err := encryption.IsEncryptedFile(absPath) - if err != nil { - if os.IsNotExist(err) && f == ".env" { - // It's okay if the default .env file doesn't exist - continue - } - - return fmt.Errorf("failed to check if env file is encrypted %s: %w", absPath, err) - } - - var envMap map[string]string - - if isEncrypted { - decryptedContent, err := encryption.DecryptFile(absPath) - if err != nil { - return fmt.Errorf("failed to decrypt env file %s: %w", absPath, err) - } - - envMap, err = godotenv.UnmarshalBytes(decryptedContent) - if err != nil { - return fmt.Errorf("failed to parse decrypted env file %s: %w", absPath, err) - } - } else { - envMap, err = godotenv.Read(absPath) - if err != nil { - return fmt.Errorf("failed to read local env file %s: %w", absPath, err) - } - } - - maps.Copy(deployConfig.Internal.Environment, envMap) - } else { - f = strings.TrimPrefix(f, remotePrefix) - remoteEnvFiles = append(remoteEnvFiles, f) - } - } - - deployConfig.EnvFiles = remoteEnvFiles - - return nil -} diff --git a/doco-cd-src/internal/docker/compose.go b/doco-cd-src/internal/docker/compose.go index 3fa15b2..053c564 100644 --- a/doco-cd-src/internal/docker/compose.go +++ b/doco-cd-src/internal/docker/compose.go @@ -16,8 +16,11 @@ import ( "strings" "time" + "github.com/kimdre/doco-cd/internal/config/app" + "github.com/kimdre/doco-cd/internal/config/deploy" "github.com/kimdre/doco-cd/internal/encryption" "github.com/kimdre/doco-cd/internal/filesystem" + "github.com/kimdre/doco-cd/internal/lock" "github.com/kimdre/doco-cd/internal/utils/module" "github.com/kimdre/doco-cd/internal/docker/swarm" @@ -37,7 +40,6 @@ import ( "github.com/docker/compose/v5/pkg/api" "github.com/docker/compose/v5/pkg/compose" - "github.com/kimdre/doco-cd/internal/config" "github.com/kimdre/doco-cd/internal/prometheus" "github.com/kimdre/doco-cd/internal/webhook" ) @@ -103,7 +105,7 @@ addComposeServiceLabels adds the labels docker compose expects to exist on servi This is required for future compose operations to work, such as finding containers that are part of a service. */ -func addComposeServiceLabels(project *types.Project, deployConfig *config.DeployConfig, payload *webhook.ParsedPayload, +func addComposeServiceLabels(project *types.Project, deployConfig *deploy.Config, payload *webhook.ParsedPayload, workingDir, appVersion, timestamp, composeVersion, latestCommit, projectHash string, ) { for i, s := range project.Services { @@ -116,38 +118,38 @@ func addComposeServiceLabels(project *types.Project, deployConfig *config.Deploy } s.CustomLabels = map[string]string{ - DocoCDLabels.Metadata.Manager: config.AppName, - DocoCDLabels.Metadata.Version: appVersion, - DocoCDLabels.Deployment.Name: deployConfig.Name, - DocoCDLabels.Deployment.Timestamp: timestamp, - DocoCDLabels.Deployment.ComposeHash: projectHash, - DocoCDLabels.Deployment.WorkingDir: workingDir, - DocoCDLabels.Deployment.Trigger: payload.CommitSHA, - DocoCDLabels.Deployment.CommitSHA: latestCommit, - DocoCDLabels.Deployment.TargetRef: deployConfig.Reference, - DocoCDLabels.Deployment.ConfigHash: deployConfig.Internal.Hash, - DocoCDLabels.Deployment.AutoDiscover: strconv.FormatBool(deployConfig.AutoDiscover), - DocoCDLabels.Deployment.AutoDiscoverDelete: strconv.FormatBool(deployConfig.AutoDiscoverOpts.Delete), - DocoCDLabels.Repository.Name: payload.FullName, - DocoCDLabels.Repository.URL: payload.WebURL, - api.ProjectLabel: project.Name, - api.ServiceLabel: s.Name, - api.WorkingDirLabel: project.WorkingDir, - api.ConfigFilesLabel: strings.Join(project.ComposeFiles, ","), - api.VersionLabel: composeVersion, - api.OneoffLabel: "False", // default, will be overridden by docker compose - api.DependenciesLabel: strings.Join(dependencies, ","), + DocoCDLabels.Metadata.Manager: app.Name, + DocoCDLabels.Metadata.Version: appVersion, + DocoCDLabels.Deployment.Name: deployConfig.Name, + DocoCDLabels.Deployment.Timestamp: timestamp, + DocoCDLabels.Deployment.ComposeHash: projectHash, + DocoCDLabels.Deployment.WorkingDir: workingDir, + DocoCDLabels.Deployment.Trigger: payload.CommitSHA, + DocoCDLabels.Deployment.CommitSHA: latestCommit, + DocoCDLabels.Deployment.TargetRef: deployConfig.Reference, + DocoCDLabels.Deployment.ConfigHash: deployConfig.Internal.Hash, + DocoCDLabels.Deployment.AutoDiscovery: strconv.FormatBool(deployConfig.AutoDiscovery.Enabled), + DocoCDLabels.Deployment.AutoDiscoveryDelete: strconv.FormatBool(deployConfig.AutoDiscovery.Delete), + DocoCDLabels.Repository.Name: payload.FullName, + DocoCDLabels.Repository.URL: payload.WebURL, + api.ProjectLabel: project.Name, + api.ServiceLabel: s.Name, + api.WorkingDirLabel: project.WorkingDir, + api.ConfigFilesLabel: strings.Join(project.ComposeFiles, ","), + api.VersionLabel: composeVersion, + api.OneoffLabel: "False", // default, will be overridden by docker compose + api.DependenciesLabel: strings.Join(dependencies, ","), } project.Services[i] = s } } -func addComposeVolumeLabels(project *types.Project, deployConfig *config.DeployConfig, payload *webhook.ParsedPayload, +func addComposeVolumeLabels(project *types.Project, deployConfig *deploy.Config, payload *webhook.ParsedPayload, appVersion, timestamp, composeVersion, latestCommit, projectHash string, ) { for i, v := range project.Volumes { v.CustomLabels = map[string]string{ - DocoCDLabels.Metadata.Manager: config.AppName, + DocoCDLabels.Metadata.Manager: app.Name, DocoCDLabels.Metadata.Version: appVersion, DocoCDLabels.Deployment.Name: deployConfig.Name, DocoCDLabels.Deployment.Timestamp: timestamp, @@ -175,7 +177,7 @@ func LoadCompose(ctx context.Context, repoPath, workingDir, projectName string, // the specified working directory. Without this, concurrent deployments with // different working directories would fail since they share the same process // working directory. - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { return nil, fmt.Errorf("failed to get app config: %w", err) } @@ -300,7 +302,7 @@ func LoadCompose(ctx context.Context, repoPath, workingDir, projectName string, // deployCompose deploys a project as specified by the Docker Compose specification (LoadCompose). func deployCompose(ctx context.Context, dockerCli command.Cli, project *types.Project, - deployConfig *config.DeployConfig, recreateMode string, services []string, + deployConfig *deploy.Config, recreateMode string, services []string, needSignal []SignalService, ) error { var ( @@ -323,7 +325,10 @@ func deployCompose(ctx context.Context, dockerCli command.Cli, project *types.Pr if deployConfig.PruneImages { beforeImages, err = service.Images(ctx, project.Name, api.ImagesOptions{}) if err != nil { - return fmt.Errorf("failed to get existing images: %w", err) + // No such image error is okay since we wanted to remove the image anyway + if !strings.Contains(strings.ToLower(err.Error()), ErrNoSuchImage.Error()) { + return fmt.Errorf("failed to get existing images: %w", err) + } } } @@ -372,31 +377,39 @@ func deployCompose(ctx context.Context, dockerCli command.Cli, project *types.Pr QuietPull: true, } - startOpts := api.StartOptions{ - Project: project, - Wait: true, - WaitTimeout: time.Duration(deployConfig.Timeout) * time.Second, + startServices, err := getStartServicesForDeploy(project) + if err != nil { + return err } - err = service.Up(ctx, project, api.UpOptions{ - Create: createOpts, - Start: startOpts, - }) + err = service.Create(ctx, project, createOpts) if err != nil { - if errors.Is(err, ErrNoContainerToStart) { - err = service.Start(ctx, project.Name, startOpts) - if err != nil { + return err + } + + if len(startServices) > 0 { + startOpts := api.StartOptions{ + Project: project, + Wait: true, + WaitTimeout: time.Duration(deployConfig.Timeout) * time.Second, + Services: startServices, + } + + err = service.Start(ctx, project.Name, startOpts) + if err != nil { + if !errors.Is(err, ErrNoContainerToStart) { return err } - } else { - return err } } if deployConfig.PruneImages { afterImages, err = service.Images(ctx, project.Name, api.ImagesOptions{}) if err != nil { - return fmt.Errorf("failed to get images after deployment: %w", err) + // No such image error is okay since we wanted to remove the image anyway + if !strings.Contains(strings.ToLower(err.Error()), ErrNoSuchImage.Error()) { + return fmt.Errorf("failed to get images after deployment: %w", err) + } } // Determine unused images by comparing image SHAs used by services before and after the deployment @@ -422,7 +435,7 @@ func deployCompose(ctx context.Context, dockerCli command.Cli, project *types.Pr // DeployStack deploys the stack using the provided deployment configuration. func DeployStack( jobLog *slog.Logger, externalRepoPath string, ctx *context.Context, - dockerCli command.Cli, payload *webhook.ParsedPayload, deployConfig *config.DeployConfig, + dockerCli command.Cli, payload *webhook.ParsedPayload, deployConfig *deploy.Config, detectedChanges []Change, needSignal []SignalService, latestCommit, appVersion string, ) error { startTime := time.Now() @@ -430,6 +443,13 @@ func DeployStack( stackLog := jobLog. With(slog.String("stack", deployConfig.Name)) + stackLog.Debug("waiting for scheduler/deploy lock") + lock.LockScheduledDeploy() + + defer lock.UnlockScheduledDeploy() + + stackLog.Debug("acquired scheduler/deploy lock") + // Path on the host externalWorkingDir := path.Join(externalRepoPath, deployConfig.WorkingDirectory) @@ -447,6 +467,10 @@ func DeployStack( return fmt.Errorf("failed to load compose config: %w", err) } + if err = validateScheduledJobPolicies(project, swarm.GetModeEnabled()); err != nil { + return fmt.Errorf("invalid scheduled job restart policy: %w", err) + } + done := make(chan struct{}) defer close(done) @@ -563,6 +587,14 @@ func DeployStack( } } + // cache the deployment status after successful deployment + setDeployStatusToCache(gitInternal.GetRepoName(payload.CloneURL), deployConfig.Name, + deployStatus{ + CommitSHA: latestCommit, + ComposeHash: projectHash, + }, + ) + prometheus.DeploymentsTotal.WithLabelValues(deployConfig.Name).Inc() prometheus.DeploymentDuration.WithLabelValues(deployConfig.Name).Observe(time.Since(startTime).Seconds()) @@ -572,7 +604,7 @@ func DeployStack( // DestroyStack destroys the stack using the provided deployment configuration. func DestroyStack( jobLog *slog.Logger, ctx *context.Context, - dockerCli *command.Cli, deployConfig *config.DeployConfig, + dockerCli *command.Cli, deployConfig *deploy.Config, ) error { stackLog := jobLog. With(slog.String("stack", deployConfig.Name)) @@ -596,10 +628,10 @@ func DestroyStack( downOpts := api.DownOptions{ RemoveOrphans: deployConfig.RemoveOrphans, - Volumes: deployConfig.DestroyOpts.RemoveVolumes, + Volumes: deployConfig.Destroy.RemoveVolumes, } - if deployConfig.DestroyOpts.RemoveImages { + if deployConfig.Destroy.RemoveImages { downOpts.Images = "all" } @@ -857,7 +889,7 @@ func (i IgnoredInfo) IsEmpty() bool { } func (i IgnoredInfo) IsNeedSignal() bool { - return len(i.NeedSendSignal) == 0 + return len(i.NeedSendSignal) > 0 } type SignalService struct { @@ -1008,89 +1040,6 @@ func GetProjectContainers(ctx context.Context, dockerCli command.Cli, projectNam }) } -// pruneImages tries to remove the specified image IDs from the Docker host and returns a list of pruned image IDs. -// If an image is still in use by a running container, the image won't be removed. -func pruneImages(ctx context.Context, dockerCli command.Cli, images []string) ([]string, error) { - var prunedImages []string - - for _, img := range images { - result, err := dockerCli.Client().ImageRemove(ctx, img, client.ImageRemoveOptions{ - Force: true, - PruneChildren: true, - }) - if err != nil { - if strings.Contains(err.Error(), "image is being used by running container") { - // Ignore error if image is being used by a running container - continue - } - - if strings.Contains(strings.ToLower(err.Error()), "no such image") || strings.Contains(strings.ToLower(err.Error()), "not found") { - // Ignore error if image does not exist - continue - } - - return nil, fmt.Errorf("failed to remove image %s: %w", img, err) - } - - for _, r := range result.Items { - if r.Deleted != "" { - prunedImages = append(prunedImages, r.Deleted) - } else if r.Untagged != "" { - prunedImages = append(prunedImages, r.Untagged) - } - } - } - - return prunedImages, nil -} - -// PullImages pulls all images defined in the compose project. -func PullImages(ctx context.Context, dockerCli command.Cli, projectName string) error { - service, err := compose.NewComposeService(dockerCli) - if err != nil { - return err - } - - containers, err := GetProjectContainers(ctx, dockerCli, projectName) - if err != nil { - return fmt.Errorf("failed to get project containers: %w", err) - } - - containerNames := make([]string, 0, len(containers)) - for _, c := range containers { - containerNames = append(containerNames, c.Name) - } - - project, err := service.Generate(ctx, api.GenerateOptions{ProjectName: projectName, Containers: containerNames}) - if err != nil { - return fmt.Errorf("failed to generate project: %w", err) - } - - return service.Pull(ctx, project, api.PullOptions{ - Quiet: true, - }) -} - -// GetImages retrieves all image IDs used by the services in the compose project. -func GetImages(ctx context.Context, dockerCli command.Cli, projectName string) (set.Set[string], error) { - service, err := compose.NewComposeService(dockerCli) - if err != nil { - return nil, err - } - - imageSummaries, err := service.Images(ctx, projectName, api.ImagesOptions{}) - if err != nil { - return nil, fmt.Errorf("failed to get images: %w", err) - } - - images := set.New[string]() - for _, img := range imageSummaries { - images.Add(img.ID) - } - - return images, nil -} - // CheckDefaultComposeFiles checks if the default compose files are used and returns them if true. func CheckDefaultComposeFiles(composeFiles []string, workingDir string) ([]string, error) { if reflect.DeepEqual(composeFiles, cli.DefaultFileNames) { @@ -1219,3 +1168,42 @@ func DecryptProjectFiles(repoPath string, p *types.Project) ([]string, error) { return decryptedFiles, nil } + +func getStartServicesForDeploy(project *types.Project) ([]string, error) { + startServices := make([]string, 0, len(project.Services)) + + for serviceName, svc := range project.Services { + labels := getServiceSchedulerLabels(svc) + _, hasScheduleLabel := labels[docoCDJobLabelNames.JobEnabled] + + _, enabled, err := ParseJobScheduleLabels(labels) + if err != nil { + return nil, fmt.Errorf("service %s: %w", serviceName, err) + } + + if enabled || hasScheduleLabel { + continue + } + + if svc.GetScale() == 0 { + continue + } + + startServices = append(startServices, serviceName) + } + + return startServices, nil +} + +func getServiceSchedulerLabels(svc types.ServiceConfig) map[string]string { + if len(svc.CustomLabels) == 0 { + return svc.Labels + } + + labels := make(map[string]string, len(svc.Labels)+len(svc.CustomLabels)) + maps.Copy(labels, svc.Labels) + + maps.Copy(labels, svc.CustomLabels) + + return labels +} diff --git a/doco-cd-src/internal/docker/compose_start_services_test.go b/doco-cd-src/internal/docker/compose_start_services_test.go new file mode 100644 index 0000000..de58878 --- /dev/null +++ b/doco-cd-src/internal/docker/compose_start_services_test.go @@ -0,0 +1,80 @@ +package docker + +import ( + "testing" + + "github.com/compose-spec/compose-go/v2/types" +) + +func TestGetStartServicesForDeploy(t *testing.T) { + t.Parallel() + + project := &types.Project{ + Services: types.Services{ + "api": { + Name: "api", + }, + "scaled-down": { + Name: "scaled-down", + Scale: new(0), + }, + "disabled": { + Name: "disabled", + Labels: map[string]string{ + docoCDJobLabelNames.JobEnabled: "false", + }, + }, + "web": { + Name: "web", + Labels: map[string]string{ + docoCDJobLabelNames.JobEnabled: "true", + docoCDJobLabelNames.JobSchedule: "*/5 * * * *", + }, + }, + "custom": { + Name: "custom", + CustomLabels: map[string]string{ + docoCDJobLabelNames.JobEnabled: "true", + docoCDJobLabelNames.JobSchedule: "@every 30m", + }, + }, + "job": { + Name: "job", + Labels: map[string]string{ + docoCDJobLabelNames.JobEnabled: "true", + docoCDJobLabelNames.JobSchedule: "@hourly", + docoCDJobLabelNames.JobExecutionMode: string(JobExecutionModeOneOff), + }, + }, + }, + } + + services, err := getStartServicesForDeploy(project) + if err != nil { + t.Fatalf("getStartServicesForDeploy() failed: %v", err) + } + + if len(services) != 1 || services[0] != "api" { + t.Fatalf("unexpected start services: %v", services) + } +} + +func TestGetStartServicesForDeploy_InvalidLabels(t *testing.T) { + t.Parallel() + + project := &types.Project{ + Services: types.Services{ + "bad": { + Name: "bad", + Labels: map[string]string{ + docoCDJobLabelNames.JobEnabled: "true", + docoCDJobLabelNames.JobSchedule: "not-a-valid-schedule", + }, + }, + }, + } + + if _, err := getStartServicesForDeploy(project); err == nil { + t.Fatalf("expected error for invalid schedule labels") + } +} diff --git a/doco-cd-src/internal/docker/compose_test.go b/doco-cd-src/internal/docker/compose_test.go index fd5b0a3..c972070 100644 --- a/doco-cd-src/internal/docker/compose_test.go +++ b/doco-cd-src/internal/docker/compose_test.go @@ -18,6 +18,10 @@ import ( "github.com/avast/retry-go/v5" + "github.com/kimdre/doco-cd/internal/config/app" + "github.com/kimdre/doco-cd/internal/config/deploy" + secrettypes "github.com/kimdre/doco-cd/internal/secretprovider/types" + "github.com/kimdre/doco-cd/internal/test" "github.com/kimdre/doco-cd/internal/utils/id" @@ -33,7 +37,6 @@ import ( "github.com/docker/compose/v5/pkg/api" "github.com/docker/compose/v5/pkg/compose" - "github.com/kimdre/doco-cd/internal/config" "github.com/kimdre/doco-cd/internal/encryption" "github.com/kimdre/doco-cd/internal/filesystem" "github.com/kimdre/doco-cd/internal/git" @@ -130,7 +133,7 @@ func TestDeployCompose(t *testing.T) { ctx := context.Background() - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatal(err) } @@ -235,7 +238,7 @@ compose_files: t.Fatal(err) } - deployConfigs, err := config.GetDeployConfigs(tmpDir, c.DeployConfigBaseDir, stackName, customTarget, p.Ref) + deployConfigs, err := deploy.GetConfigs(tmpDir, c.DeployConfigBaseDir, stackName, customTarget, p.Ref, nil) if err != nil { t.Fatal(err) } @@ -269,7 +272,7 @@ compose_files: jobLog := testLog.With(slog.String("job_id", jobID)) if secretProvider != nil && len(deployConf.ExternalSecrets) > 0 { - encodedSecrets, err := config.EncodeExternalSecretRefs(deployConf.ExternalSecrets) + encodedSecrets, err := secrettypes.EncodeExternalSecretRefs(deployConf.ExternalSecrets) if err != nil { t.Fatalf("failed to encode external secret references: %s", err.Error()) } @@ -286,7 +289,7 @@ compose_files: retry.Attempts(3), retry.Delay(1*time.Second), retry.RetryIf(func(err error) bool { - return strings.Contains(err.Error(), "No such image:") + return strings.Contains(strings.ToLower(err.Error()), ErrNoSuchImage.Error()) }), ).Do(func() error { return DeployStack(jobLog, repoPath, &ctx, dockerCli, &p, deployConf, @@ -368,6 +371,26 @@ compose_files: t.Fatalf("expected no labeled containers after destruction, got %d", len(serviceLabels)) } + stats, err := GetLatestDeployStatus(ctx, dockerClient, p.CloneURL, stackName) + if err != nil { + t.Fatalf("GetLatestDeployStatus err: %v", err) + } + + t.Log("Verifying deployment status after destruction", stats) + + if stats.GetDeploymentCommitSHA() != latestCommit { + t.Fatalf("expected latest deployed commit SHA to be '%s', got '%s'", latestCommit, stats.GetDeploymentCommitSHA()) + } + + projectHash, err := ProjectHash(project) + if err != nil { + t.Fatalf("ProjectHash err: %v", err) + } + + if stats.GetDeploymentComposeHash() != projectHash { + t.Fatalf("expected latest deployed compose hash to be '%s', got '%s'", projectHash, stats.GetDeploymentComposeHash()) + } + t.Log("Finished destroying deployment with no errors") } } @@ -1442,7 +1465,7 @@ func TestProjectFilesHaveChanges(t *testing.T) { }, } - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatalf("Failed to get app config: %v", err) } @@ -1472,7 +1495,7 @@ func TestProjectFilesHaveChanges(t *testing.T) { t.Fatalf("Failed to checkout old commit: %v", err) } - deployConfigs, err := config.GetDeployConfigs(tmpDir, ".", t.Name(), "", "") + deployConfigs, err := deploy.GetConfigs(tmpDir, ".", t.Name(), "", "", nil) if err != nil { t.Fatal(err) } @@ -1773,7 +1796,7 @@ func TestInjectSecretsToProject(t *testing.T) { filePath := filepath.Join(tmpDir, "test.compose.yaml") createComposeFile(t, filePath, composeContents) - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatal(err) } @@ -1868,7 +1891,7 @@ func TestRestartProject(t *testing.T) { test.ComposeUp(ctx, t, test.WithYAML(generateComposeContents())) - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatal(err) } @@ -1893,7 +1916,7 @@ func TestStopProject(t *testing.T) { test.ComposeUp(ctx, t, test.WithYAML(generateComposeContents())) - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatal(err) } @@ -1918,7 +1941,7 @@ func TestStartProject(t *testing.T) { test.ComposeUp(ctx, t, test.WithYAML(generateComposeContents())) - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatal(err) } @@ -1953,7 +1976,7 @@ func TestRemoveProject(t *testing.T) { test.ComposeUp(ctx, t, test.WithYAML(generateComposeContents())) - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatal(err) } @@ -1989,7 +2012,7 @@ func TestGetProject(t *testing.T) { test.ComposeUp(ctx, t, test.WithYAML(generateComposeContents())) - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatal(err) } @@ -2018,7 +2041,7 @@ func TestGetProjects(t *testing.T) { test.ComposeUp(ctx, t, test.WithYAML(generateComposeContents())) - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatal(err) } diff --git a/doco-cd-src/internal/docker/container_run.go b/doco-cd-src/internal/docker/container_run.go new file mode 100644 index 0000000..2df066e --- /dev/null +++ b/doco-cd-src/internal/docker/container_run.go @@ -0,0 +1,116 @@ +package docker + +import ( + "context" + "fmt" + "strings" + "time" + + containerTypes "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/network" + "github.com/moby/moby/client" +) + +type containerRunAction string + +const ( + containerRunActionStart containerRunAction = "start" + containerRunActionRestart containerRunAction = "restart" +) + +func RestartContainer(ctx context.Context, apiClient client.APIClient, containerID string) error { + inspectResult, err := apiClient.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{}) + if err != nil { + return fmt.Errorf("inspect container %s: %w", containerID, err) + } + + switch getContainerRunAction(inspectResult.Container) { + case containerRunActionStart: + if _, err = apiClient.ContainerStart(ctx, containerID, client.ContainerStartOptions{}); err != nil { + return fmt.Errorf("start container %s: %w", containerID, err) + } + default: + if _, err = apiClient.ContainerRestart(ctx, containerID, client.ContainerRestartOptions{}); err != nil { + return fmt.Errorf("restart container %s: %w", containerID, err) + } + } + + return nil +} + +func getContainerRunAction(inspectResult containerTypes.InspectResponse) containerRunAction { + if inspectResult.State == nil { + return containerRunActionRestart + } + + if !inspectResult.State.Running { + return containerRunActionStart + } + + return containerRunActionRestart +} + +func RunContainerOneOffFromExisting(ctx context.Context, apiClient client.APIClient, containerID string) error { + inspectResult, err := apiClient.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{}) + if err != nil { + return fmt.Errorf("inspect container %s: %w", containerID, err) + } + + if inspectResult.Container.Config == nil { + return fmt.Errorf("container %s has no config", containerID) + } + + config := inspectResult.Container.Config + + hostConfig := inspectResult.Container.HostConfig + if hostConfig != nil { + hostConfig.RestartPolicy = containerTypes.RestartPolicy{Name: "no"} + hostConfig.AutoRemove = true + } + + baseName := strings.TrimPrefix(inspectResult.Container.Name, "/") + + baseName = strings.ReplaceAll(baseName, "/", "-") + if baseName == "" { + baseName = containerID[:12] + } + + tmpName := fmt.Sprintf("%s-doco-job-%d", baseName, time.Now().UTC().UnixNano()) + + createResult, err := apiClient.ContainerCreate(ctx, client.ContainerCreateOptions{ + Config: config, + HostConfig: hostConfig, + NetworkingConfig: &network.NetworkingConfig{}, + Name: tmpName, + }) + if err != nil { + return fmt.Errorf("create one-off container from %s: %w", containerID, err) + } + + // Subscribe to wait BEFORE starting so we don't race with a fast-exiting + // (auto-removed) container: if ContainerStart is called first the container + // may finish and be removed before ContainerWait registers, causing a + // "No such container" error. + waitResult := apiClient.ContainerWait(ctx, createResult.ID, client.ContainerWaitOptions{Condition: containerTypes.WaitConditionNotRunning}) + + if _, err = apiClient.ContainerStart(ctx, createResult.ID, client.ContainerStartOptions{}); err != nil { + return fmt.Errorf("start one-off container %s: %w", createResult.ID, err) + } + + select { + case waitErr := <-waitResult.Error: + if waitErr != nil { + return fmt.Errorf("wait for one-off container %s: %w", createResult.ID, waitErr) + } + case waitStatus := <-waitResult.Result: + if waitStatus.Error != nil && waitStatus.Error.Message != "" { + return fmt.Errorf("one-off container %s failed: %s", createResult.ID, waitStatus.Error.Message) + } + + if waitStatus.StatusCode != 0 { + return fmt.Errorf("one-off container %s exited with status %d", createResult.ID, waitStatus.StatusCode) + } + } + + return nil +} diff --git a/doco-cd-src/internal/docker/container_run_test.go b/doco-cd-src/internal/docker/container_run_test.go new file mode 100644 index 0000000..17cf92a --- /dev/null +++ b/doco-cd-src/internal/docker/container_run_test.go @@ -0,0 +1,62 @@ +package docker + +import ( + "testing" + + containerTypes "github.com/moby/moby/api/types/container" +) + +func TestGetContainerRunAction(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + state *containerTypes.State + want containerRunAction + baseOK bool + }{ + { + name: "missing inspect state defaults to restart", + want: containerRunActionRestart, + baseOK: false, + }, + { + name: "nil state defaults to restart", + want: containerRunActionRestart, + baseOK: true, + }, + { + name: "running container restarts", + state: &containerTypes.State{Running: true}, + want: containerRunActionRestart, + baseOK: true, + }, + { + name: "created container starts", + state: &containerTypes.State{Running: false, Status: "created"}, + want: containerRunActionStart, + baseOK: true, + }, + { + name: "exited container starts", + state: &containerTypes.State{Running: false, Status: "exited"}, + want: containerRunActionStart, + baseOK: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + inspect := containerTypes.InspectResponse{} + if tt.baseOK { + inspect.State = tt.state + } + + if got := getContainerRunAction(inspect); got != tt.want { + t.Fatalf("getContainerRunAction() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/doco-cd-src/internal/docker/images.go b/doco-cd-src/internal/docker/images.go new file mode 100644 index 0000000..635f8bd --- /dev/null +++ b/doco-cd-src/internal/docker/images.go @@ -0,0 +1,575 @@ +package docker + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "strings" + + "github.com/compose-spec/compose-go/v2/types" + distreference "github.com/distribution/reference" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/compose/convert" + "github.com/docker/cli/cli/config/configfile" + configtypes "github.com/docker/cli/cli/config/types" + "github.com/docker/compose/v5/pkg/api" + "github.com/docker/compose/v5/pkg/compose" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" + + swarmInternal "github.com/kimdre/doco-cd/internal/docker/swarm" + "github.com/kimdre/doco-cd/internal/utils/set" +) + +var ErrNoSuchImage = errors.New("no such image") // Image does not exist + +const ( + dockerHubDomain = "docker.io" + dockerHubIndexDomain = "index.docker.io" + dockerHubRegistryHost = "registry-1.docker.io" + dockerHubAuthConfigKey = "https://index.docker.io/v1/" + dockerContentDigest = "Docker-Content-Digest" + wwwAuthenticateHeader = "Www-Authenticate" + registryAuthBearer = "Bearer" +) + +var ( + registryManifestAccepts = strings.Join([]string{ + "application/vnd.oci.image.index.v1+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.docker.distribution.manifest.v2+json", + }, ",") + registryDigestHTTPClient = http.DefaultClient +) + +// registryAuthForImage returns a base64-encoded auth string for the registry +// that hosts the given image ref, sourced from the Docker config file. +// Returns an empty string on any error (unauthenticated access is attempted). +func registryAuthForImage(dockerCli command.Cli, imageRef string) string { + encoded, err := command.RetrieveAuthTokenFromImage(dockerCli.ConfigFile(), imageRef) + if err != nil { + return "" + } + + return encoded +} + +// registryDigestForRef queries the registry for the current manifest digest of +// the given image reference without downloading any image layers. +func registryDigestForRef(ctx context.Context, dockerCli command.Cli, imageRef string) (string, error) { + digest, err := registryDigestHeadLookup(ctx, dockerCli, imageRef) + if err == nil { + slog.Debug("registry HEAD digest lookup successful", slog.String("ref", imageRef), slog.String("digest", digest)) + return digest, nil + } + + slog.Warn("registry HEAD digest lookup failed, falling back to distribution inspect", slog.String("ref", imageRef), slog.String("err", err.Error())) + + return registryDigestDistributionLookup(ctx, dockerCli, imageRef) +} + +// registryDigestForRefViaDistributionInspect asks the Docker Engine API for +// the remote descriptor digest of the image reference. +func registryDigestForRefViaDistributionInspect(ctx context.Context, dockerCli command.Cli, imageRef string) (string, error) { + info, err := dockerCli.Client().DistributionInspect(ctx, imageRef, client.DistributionInspectOptions{ + EncodedRegistryAuth: registryAuthForImage(dockerCli, imageRef), + }) + if err != nil { + return "", fmt.Errorf("registry inspect failed for %s: %w", imageRef, err) + } + + return info.Descriptor.Digest.String(), nil +} + +// registryDigestForRefViaHEAD queries the registry manifest endpoint directly +// using HEAD and returns Docker-Content-Digest when available. +func registryDigestForRefViaHEAD(ctx context.Context, dockerCli command.Cli, imageRef string) (string, error) { + return registryDigestForRefViaHEADWithClient(ctx, dockerCli.ConfigFile(), imageRef, registryDigestHTTPClient) +} + +func registryDigestForRefViaHEADWithClient(ctx context.Context, cfg *configfile.ConfigFile, imageRef string, httpClient *http.Client) (string, error) { + manifestURL, authConfigKey, scope, err := registryManifestURL(imageRef) + if err != nil { + return "", fmt.Errorf("invalid image reference %q: %w", imageRef, err) + } + + authConfig := registryAuthConfigForKey(cfg, authConfigKey) + + digest, err := registryManifestDigestHEAD(ctx, httpClient, manifestURL, scope, authConfig) + if err != nil { + return "", fmt.Errorf("registry HEAD inspect failed for %s: %w", imageRef, err) + } + + return digest, nil +} + +func registryManifestURL(imageRef string) (string, string, string, error) { + namedRef, err := distreference.ParseNormalizedNamed(imageRef) + if err != nil { + return "", "", "", err + } + + domain := distreference.Domain(namedRef) + registryHost := domain + + authConfigKey := domain + if domain == dockerHubDomain || domain == dockerHubIndexDomain { + registryHost = dockerHubRegistryHost + authConfigKey = dockerHubAuthConfigKey + } + + repositoryPath := distreference.Path(namedRef) + + manifestRef := "" + if taggedRef, ok := distreference.TagNameOnly(namedRef).(distreference.NamedTagged); ok { + manifestRef = taggedRef.Tag() + } + + if canonicalRef, ok := namedRef.(distreference.Canonical); ok { + manifestRef = canonicalRef.Digest().String() + } + + if manifestRef == "" { + return "", "", "", errors.New("manifest reference could not be resolved") + } + + manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", registryHost, repositoryPath, url.PathEscape(manifestRef)) + scope := fmt.Sprintf("repository:%s:pull", repositoryPath) + + return manifestURL, authConfigKey, scope, nil +} + +func registryAuthConfigForKey(cfg *configfile.ConfigFile, authConfigKey string) configtypes.AuthConfig { + if cfg == nil { + return configtypes.AuthConfig{} + } + + authConfig, err := cfg.GetAuthConfig(authConfigKey) + if err != nil { + return configtypes.AuthConfig{} + } + + return authConfig +} + +func registryManifestDigestHEAD(ctx context.Context, httpClient *http.Client, manifestURL, scope string, authConfig configtypes.AuthConfig) (string, error) { + if httpClient == nil { + httpClient = http.DefaultClient + } + + resp, err := executeRegistryManifestHeadRequest(ctx, httpClient, manifestURL, authConfig, "") + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + challenge, parseErr := parseBearerAuthChallenge(resp.Header.Get(wwwAuthenticateHeader)) + if parseErr != nil { + return "", fmt.Errorf("registry unauthorized and challenge parse failed: %w", parseErr) + } + + token, tokenErr := fetchRegistryBearerToken(ctx, httpClient, challenge, scope, authConfig) + if tokenErr != nil { + return "", tokenErr + } + + retryResp, retryErr := executeRegistryManifestHeadRequest(ctx, httpClient, manifestURL, authConfig, token) + if retryErr != nil { + return "", retryErr + } + defer retryResp.Body.Close() + + return digestFromRegistryResponse(retryResp) + } + + return digestFromRegistryResponse(resp) +} + +func executeRegistryManifestHeadRequest(ctx context.Context, httpClient *http.Client, manifestURL string, authConfig configtypes.AuthConfig, bearerToken string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodHead, manifestURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", registryManifestAccepts) + + switch { + case bearerToken != "": + req.Header.Set("Authorization", registryAuthBearer+" "+bearerToken) + case authConfig.RegistryToken != "": + req.Header.Set("Authorization", registryAuthBearer+" "+authConfig.RegistryToken) + case authConfig.IdentityToken != "": + req.Header.Set("Authorization", registryAuthBearer+" "+authConfig.IdentityToken) + case authConfig.Username != "" || authConfig.Password != "": + req.SetBasicAuth(authConfig.Username, authConfig.Password) + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + + return resp, nil +} + +func digestFromRegistryResponse(resp *http.Response) (string, error) { + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return "", fmt.Errorf("registry returned status %d", resp.StatusCode) + } + + digest := strings.TrimSpace(resp.Header.Get(dockerContentDigest)) + if digest == "" { + return "", fmt.Errorf("registry response missing %s header", dockerContentDigest) + } + + return digest, nil +} + +type bearerAuthChallenge struct { + Realm string + Service string + Scope string +} + +func parseBearerAuthChallenge(value string) (bearerAuthChallenge, error) { + scheme, params, ok := strings.Cut(strings.TrimSpace(value), " ") + if !ok || !strings.EqualFold(scheme, registryAuthBearer) { + return bearerAuthChallenge{}, fmt.Errorf("unsupported challenge %q", value) + } + + challenge := bearerAuthChallenge{} + + for pair := range strings.SplitSeq(params, ",") { + key, rawVal, found := strings.Cut(strings.TrimSpace(pair), "=") + if !found { + continue + } + + val := strings.Trim(strings.TrimSpace(rawVal), "\"") + + switch strings.ToLower(key) { + case "realm": + challenge.Realm = val + case "service": + challenge.Service = val + case "scope": + challenge.Scope = val + } + } + + if challenge.Realm == "" { + return bearerAuthChallenge{}, errors.New("challenge missing realm") + } + + return challenge, nil +} + +func fetchRegistryBearerToken(ctx context.Context, httpClient *http.Client, challenge bearerAuthChallenge, fallbackScope string, authConfig configtypes.AuthConfig) (string, error) { + tokenURL, err := url.Parse(challenge.Realm) + if err != nil { + return "", fmt.Errorf("invalid bearer realm %q: %w", challenge.Realm, err) + } + + query := tokenURL.Query() + if challenge.Service != "" { + query.Set("service", challenge.Service) + } + + scope := challenge.Scope + if scope == "" { + scope = fallbackScope + } + + if scope != "" { + query.Set("scope", scope) + } + + tokenURL.RawQuery = query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL.String(), nil) + if err != nil { + return "", err + } + + switch { + case authConfig.RegistryToken != "": + req.Header.Set("Authorization", registryAuthBearer+" "+authConfig.RegistryToken) + case authConfig.Username != "" || authConfig.Password != "": + req.SetBasicAuth(authConfig.Username, authConfig.Password) + case authConfig.IdentityToken != "": + req.Header.Set("Authorization", registryAuthBearer+" "+authConfig.IdentityToken) + } + + resp, err := httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return "", fmt.Errorf("token service returned status %d", resp.StatusCode) + } + + var payload struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", err + } + + if payload.Token != "" { + return payload.Token, nil + } + + if payload.AccessToken != "" { + return payload.AccessToken, nil + } + + return "", errors.New("token response missing token") +} + +// digestFromReference extracts the digest part from an image reference in +// "name@digest" form and returns an empty string when no digest is present. +func digestFromReference(ref string) string { + _, digest, ok := strings.Cut(ref, "@") + if !ok { + return "" + } + + return digest +} + +// digestFromRepoDigests returns the first digest found in RepoDigests. +// It returns an empty string when no digest entry can be parsed. +func digestFromRepoDigests(repoDigests []string) string { + for _, repoDigest := range repoDigests { + digest := digestFromReference(repoDigest) + if digest != "" { + return digest + } + } + + return "" +} + +// getDeployedServiceImageDigests collects deployed service digests keyed by +// service name for the given project in both Swarm and non-Swarm modes. +func getDeployedServiceImageDigests(ctx context.Context, dockerCli command.Cli, projectName string, logger *slog.Logger) (map[string]string, error) { + deployed := make(map[string]string) + + if swarmInternal.GetModeEnabled() { + services, err := swarmInternal.GetStackServices(ctx, dockerCli.Client(), projectName) + if err != nil { + return nil, fmt.Errorf("failed to list swarm services for %s: %w", projectName, err) + } + + ns := convert.NewNamespace(projectName) + for _, svc := range services { + svcName := ns.Descope(svc.Spec.Name) + + digest := digestFromReference(svc.Spec.TaskTemplate.ContainerSpec.Image) + if digest == "" { + logger.Warn("deployed swarm service image has no digest", slog.String("service", svcName), slog.String("image", svc.Spec.TaskTemplate.ContainerSpec.Image)) + continue + } + + deployed[svcName] = digest + } + + return deployed, nil + } + + containers, err := GetProjectContainers(ctx, dockerCli, projectName) + if err != nil { + return nil, fmt.Errorf("failed to list project containers for %s: %w", projectName, err) + } + + selected := make(map[string]api.ContainerSummary) + + for _, cont := range containers { + svcName := cont.Labels[api.ServiceLabel] + if svcName == "" { + continue + } + + existing, ok := selected[svcName] + if !ok || (existing.State != container.StateRunning && cont.State == container.StateRunning) { + selected[svcName] = cont + } + } + + for svcName, cont := range selected { + contInspect, err := dockerCli.Client().ContainerInspect(ctx, cont.ID, client.ContainerInspectOptions{}) + if err != nil { + logger.Warn("failed to inspect deployed container", slog.String("service", svcName), slog.String("container_id", cont.ID), slog.String("err", err.Error())) + continue + } + + imageID := contInspect.Container.Image + + img, err := dockerCli.Client().ImageInspect(ctx, imageID) + if err != nil { + logger.Warn("failed to inspect deployed image", slog.String("service", svcName), slog.String("image_id", imageID), slog.String("err", err.Error())) + continue + } + + digest := digestFromRepoDigests(img.RepoDigests) + if digest == "" { + logger.Warn("deployed image has no digest", slog.String("service", svcName), slog.String("image_id", imageID)) + continue + } + + deployed[svcName] = digest + } + + return deployed, nil +} + +// pruneImages tries to remove the specified image IDs from the Docker host and +// returns a list of pruned image IDs. Images still in use are silently skipped. +func pruneImages(ctx context.Context, dockerCli command.Cli, images []string) ([]string, error) { + var prunedImages []string + + for _, img := range images { + result, err := dockerCli.Client().ImageRemove(ctx, img, client.ImageRemoveOptions{ + Force: true, + PruneChildren: true, + }) + if err != nil { + switch { + case strings.Contains(err.Error(), "image is being used by running container"): + continue + case strings.Contains(strings.ToLower(err.Error()), ErrNoSuchImage.Error()), + strings.Contains(strings.ToLower(err.Error()), "not found"): + continue + default: + return nil, fmt.Errorf("failed to remove image %s: %w", img, err) + } + } + + for _, r := range result.Items { + switch { + case r.Deleted != "": + prunedImages = append(prunedImages, r.Deleted) + case r.Untagged != "": + prunedImages = append(prunedImages, r.Untagged) + } + } + } + + return prunedImages, nil +} + +// PullImages pulls all images defined in the named compose project. +func PullImages(ctx context.Context, dockerCli command.Cli, projectName string) error { + service, err := compose.NewComposeService(dockerCli) + if err != nil { + return err + } + + containers, err := GetProjectContainers(ctx, dockerCli, projectName) + if err != nil { + return fmt.Errorf("failed to get project containers: %w", err) + } + + containerNames := make([]string, 0, len(containers)) + for _, c := range containers { + containerNames = append(containerNames, c.Name) + } + + project, err := service.Generate(ctx, api.GenerateOptions{ProjectName: projectName, Containers: containerNames}) + if err != nil { + return fmt.Errorf("failed to generate project: %w", err) + } + + return service.Pull(ctx, project, api.PullOptions{Quiet: true}) +} + +// vars used to allow overriding in tests without needing to mock the entire function. +var ( + registryDigestLookup = registryDigestForRef // registryDigestLookup fetches registry digests for image refs, can be overridden in tests + deployedServiceDigestLookup = getDeployedServiceImageDigests // deployedServiceDigestLookup fetches deployed service image digests, can be overridden in tests + registryDigestHeadLookup = registryDigestForRefViaHEAD + registryDigestDistributionLookup = registryDigestForRefViaDistributionInspect +) + +// HaveDeployedServiceImageDigestsChanged checks if any currently deployed +// service image digest differs from the registry digest of the configured image ref. +// +// This compares: +// 1. deployed service image digest (currently running/deployed) +// 2. registry digest of configured service image reference (DistributionInspect) +// +// Returns true as soon as one service differs. +func HaveDeployedServiceImageDigestsChanged(ctx context.Context, dockerCli command.Cli, project *types.Project, logger *slog.Logger) (bool, error) { + // service name -> configured image ref + configuredRefs := make(map[string]string) + uniqueRefs := set.New[string]() + + for _, svc := range project.Services { + if svc.Image == "" { + continue + } + + configuredRefs[svc.Name] = svc.Image + uniqueRefs.Add(svc.Image) + } + + if len(configuredRefs) == 0 { + return false, nil + } + + registryDigests := make(map[string]string, uniqueRefs.Len()) + for _, ref := range uniqueRefs.ToSlice() { + digest, err := registryDigestLookup(ctx, dockerCli, ref) + if err != nil { + logger.Warn("could not fetch registry digest", slog.String("ref", ref), slog.String("err", err.Error())) + continue + } + + registryDigests[ref] = digest + } + + deployedDigests, err := deployedServiceDigestLookup(ctx, dockerCli, project.Name, logger) + if err != nil { + return false, err + } + + for serviceName, configuredRef := range configuredRefs { + registryDigest, ok := registryDigests[configuredRef] + if !ok { + logger.Warn("registry digest unavailable, skipping service", slog.String("service", serviceName), slog.String("ref", configuredRef)) + continue + } + + deployedDigest, ok := deployedDigests[serviceName] + if !ok { + logger.Debug("deployed service image digest unavailable, treating as changed", slog.String("service", serviceName), slog.String("ref", configuredRef)) + return true, nil + } + + if deployedDigest != registryDigest { + logger.Info("service image digest changed", + slog.String("service", serviceName), + slog.Group("image", + slog.String("ref", configuredRef), + slog.Group("digest", + slog.String("deployed", deployedDigest), + slog.String("registry", registryDigest), + ), + ), + ) + + return true, nil + } + } + + return false, nil +} diff --git a/doco-cd-src/internal/docker/images_test.go b/doco-cd-src/internal/docker/images_test.go new file mode 100644 index 0000000..9015905 --- /dev/null +++ b/doco-cd-src/internal/docker/images_test.go @@ -0,0 +1,549 @@ +package docker + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/config/configfile" + configtypes "github.com/docker/cli/cli/config/types" +) + +const registryIntegrationEnvVar = "DOCO_CD_RUN_REGISTRY_INTEGRATION_TESTS" + +func TestDigestFromReference(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ref string + want string + }{ + {name: "reference with digest", ref: "nginx@sha256:abc", want: "sha256:abc"}, + {name: "reference without digest", ref: "nginx:latest", want: ""}, + {name: "empty", ref: "", want: ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := digestFromReference(tc.ref); got != tc.want { + t.Fatalf("digestFromReference() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestDigestFromRepoDigests(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + repoDigests []string + want string + }{ + {name: "first valid digest", repoDigests: []string{"nginx@sha256:abc", "nginx@sha256:def"}, want: "sha256:abc"}, + {name: "skips invalid entry", repoDigests: []string{"not-a-digest", "nginx@sha256:def"}, want: "sha256:def"}, + {name: "none", repoDigests: []string{"not-a-digest"}, want: ""}, + {name: "empty", repoDigests: nil, want: ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := digestFromRepoDigests(tc.repoDigests); got != tc.want { + t.Fatalf("digestFromRepoDigests() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestRegistryManifestURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + imageRef string + wantURL string + wantAuthKey string + wantScope string + }{ + { + name: "gcr.io", + imageRef: "gcr.io/google-containers/pause:3.9", + wantURL: "https://gcr.io/v2/google-containers/pause/manifests/3.9", + wantAuthKey: "gcr.io", + wantScope: "repository:google-containers/pause:pull", + }, + { + name: "ghcr.io", + imageRef: "ghcr.io/octo-org/octo-image:1.2.3", + wantURL: "https://ghcr.io/v2/octo-org/octo-image/manifests/1.2.3", + wantAuthKey: "ghcr.io", + wantScope: "repository:octo-org/octo-image:pull", + }, + { + name: "registry.gitlab.com", + imageRef: "registry.gitlab.com/group/project/image:latest", + wantURL: "https://registry.gitlab.com/v2/group/project/image/manifests/latest", + wantAuthKey: "registry.gitlab.com", + wantScope: "repository:group/project/image:pull", + }, + { + name: "docker.io maps to Docker Hub registry host and auth key", + imageRef: "docker.io/library/nginx:latest", + wantURL: "https://registry-1.docker.io/v2/library/nginx/manifests/latest", + wantAuthKey: "https://index.docker.io/v1/", + wantScope: "repository:library/nginx:pull", + }, + { + name: "index.docker.io maps to Docker Hub registry host and auth key", + imageRef: "index.docker.io/library/nginx:latest", + wantURL: "https://registry-1.docker.io/v2/library/nginx/manifests/latest", + wantAuthKey: "https://index.docker.io/v1/", + wantScope: "repository:library/nginx:pull", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + gotURL, gotAuthKey, gotScope, err := registryManifestURL(tc.imageRef) + if err != nil { + t.Fatalf("registryManifestURL(%q) error = %v", tc.imageRef, err) + } + + if gotURL != tc.wantURL { + t.Fatalf("registryManifestURL(%q) url = %q, want %q", tc.imageRef, gotURL, tc.wantURL) + } + + if gotAuthKey != tc.wantAuthKey { + t.Fatalf("registryManifestURL(%q) auth key = %q, want %q", tc.imageRef, gotAuthKey, tc.wantAuthKey) + } + + if gotScope != tc.wantScope { + t.Fatalf("registryManifestURL(%q) scope = %q, want %q", tc.imageRef, gotScope, tc.wantScope) + } + }) + } +} + +func TestHaveDeployedServiceImageDigestsChanged(t *testing.T) { + ctx := context.Background() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + expectedLookupErr := errors.New("deployed lookup failed") + + oldRegistryLookup := registryDigestLookup + oldDeployedLookup := deployedServiceDigestLookup + + t.Cleanup(func() { + registryDigestLookup = oldRegistryLookup + deployedServiceDigestLookup = oldDeployedLookup + }) + + tests := []struct { + name string + project *types.Project + registryDigest string + deployedDigests map[string]string + deployedLookupErr error + wantChanged bool + wantErr error + wantRegistryCalls int + wantRegistryCallRefs []string + }{ + { + name: "no configured images", + project: &types.Project{ + Name: "test", + Services: types.Services{ + "web": {Name: "web"}, + }, + }, + wantChanged: false, + wantRegistryCalls: 0, + }, + { + name: "returns true when deployed digest missing", + project: &types.Project{ + Name: "test", + Services: types.Services{ + "web": {Name: "web", Image: "nginx:latest"}, + }, + }, + registryDigest: "sha256:new", + deployedDigests: map[string]string{}, + wantChanged: true, + wantRegistryCalls: 1, + wantRegistryCallRefs: []string{"nginx:latest"}, + }, + { + name: "returns true on digest mismatch", + project: &types.Project{ + Name: "test", + Services: types.Services{ + "web": {Name: "web", Image: "nginx:latest"}, + }, + }, + registryDigest: "sha256:new", + deployedDigests: map[string]string{"web": "sha256:old"}, + wantChanged: true, + wantRegistryCalls: 1, + wantRegistryCallRefs: []string{"nginx:latest"}, + }, + { + name: "returns false when digests match", + project: &types.Project{ + Name: "test", + Services: types.Services{ + "web": {Name: "web", Image: "nginx:latest"}, + }, + }, + registryDigest: "sha256:same", + deployedDigests: map[string]string{"web": "sha256:same"}, + wantChanged: false, + wantRegistryCalls: 1, + wantRegistryCallRefs: []string{"nginx:latest"}, + }, + { + name: "returns deployed lookup error", + project: &types.Project{ + Name: "test", + Services: types.Services{ + "web": {Name: "web", Image: "nginx:latest"}, + }, + }, + registryDigest: "sha256:same", + deployedLookupErr: expectedLookupErr, + wantErr: expectedLookupErr, + wantRegistryCalls: 1, + wantRegistryCallRefs: []string{"nginx:latest"}, + }, + { + name: "deduplicates registry lookups by image ref", + project: &types.Project{ + Name: "test", + Services: types.Services{ + "web": {Name: "web", Image: "nginx:latest"}, + "worker": {Name: "worker", Image: "nginx:latest"}, + }, + }, + registryDigest: "sha256:same", + deployedDigests: map[string]string{"web": "sha256:same", "worker": "sha256:same"}, + wantChanged: false, + wantRegistryCalls: 1, + wantRegistryCallRefs: []string{"nginx:latest"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + registryCalls := make([]string, 0) + registryDigestLookup = func(_ context.Context, _ command.Cli, ref string) (string, error) { + registryCalls = append(registryCalls, ref) + return tc.registryDigest, nil + } + deployedServiceDigestLookup = func(context.Context, command.Cli, string, *slog.Logger) (map[string]string, error) { + if tc.deployedLookupErr != nil { + return nil, tc.deployedLookupErr + } + + return tc.deployedDigests, nil + } + + changed, err := HaveDeployedServiceImageDigestsChanged(ctx, nil, tc.project, logger) + if !errors.Is(err, tc.wantErr) { + t.Fatalf("expected error %v, got %v", tc.wantErr, err) + } + + if changed != tc.wantChanged { + t.Fatalf("changed = %v, want %v", changed, tc.wantChanged) + } + + if len(registryCalls) != tc.wantRegistryCalls { + t.Fatalf("expected %d registry lookup(s), got %d", tc.wantRegistryCalls, len(registryCalls)) + } + + for i, wantRef := range tc.wantRegistryCallRefs { + if i >= len(registryCalls) { + t.Fatalf("missing registry lookup ref at index %d, want %q", i, wantRef) + } + + if registryCalls[i] != wantRef { + t.Fatalf("registry lookup ref at index %d = %q, want %q", i, registryCalls[i], wantRef) + } + } + }) + } +} + +func TestRegistryDigestForRefPrefersHEAD(t *testing.T) { + t.Parallel() + + oldHeadLookup := registryDigestHeadLookup + oldDistributionLookup := registryDigestDistributionLookup + + t.Cleanup(func() { + registryDigestHeadLookup = oldHeadLookup + registryDigestDistributionLookup = oldDistributionLookup + }) + + registryDigestHeadLookup = func(context.Context, command.Cli, string) (string, error) { + return "sha256:head", nil + } + registryDigestDistributionLookup = func(context.Context, command.Cli, string) (string, error) { + t.Fatal("distribution inspect fallback should not be called") + return "", nil + } + + got, err := registryDigestForRef(context.Background(), nil, "nginx:latest") + if err != nil { + t.Fatalf("registryDigestForRef() unexpected error: %v", err) + } + + if got != "sha256:head" { + t.Fatalf("registryDigestForRef() = %q, want %q", got, "sha256:head") + } +} + +func TestRegistryDigestForRef_FallsBackToDistributionInspect(t *testing.T) { + t.Parallel() + + oldHeadLookup := registryDigestHeadLookup + oldDistributionLookup := registryDigestDistributionLookup + + t.Cleanup(func() { + registryDigestHeadLookup = oldHeadLookup + registryDigestDistributionLookup = oldDistributionLookup + }) + + registryDigestHeadLookup = func(context.Context, command.Cli, string) (string, error) { + return "", errors.New("head failed") + } + registryDigestDistributionLookup = func(context.Context, command.Cli, string) (string, error) { + return "sha256:fallback", nil + } + + got, err := registryDigestForRef(context.Background(), nil, "nginx:latest") + if err != nil { + t.Fatalf("registryDigestForRef() unexpected error: %v", err) + } + + if got != "sha256:fallback" { + t.Fatalf("registryDigestForRef() = %q, want %q", got, "sha256:fallback") + } +} + +func TestRegistryDigestForRef_FallsBackWhenHEADMissingDigestHeader(t *testing.T) { + t.Parallel() + + // HEAD server returns 200 but omits Docker-Content-Digest, so the HEAD path errors. + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // deliberately omit the Docker-Content-Digest header + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(server.Close) + + parsed, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("failed to parse test server URL: %v", err) + } + + imageRef := parsed.Host + "/team/app:latest" + + // Confirm the HEAD path alone returns an error. + _, hErr := registryDigestForRefViaHEADWithClient(context.Background(), &configfile.ConfigFile{}, imageRef, server.Client()) + if hErr == nil { + t.Fatal("expected HEAD lookup to fail without Docker-Content-Digest header, but it succeeded") + } + + // Now wire up the full registryDigestForRef call: + // - override the HEAD lookup to use the test server's HTTP client + // - override the distribution lookup to return a known digest (simulating Docker Engine fallback) + oldHeadLookup := registryDigestHeadLookup + oldDistributionLookup := registryDigestDistributionLookup + + t.Cleanup(func() { + registryDigestHeadLookup = oldHeadLookup + registryDigestDistributionLookup = oldDistributionLookup + }) + + registryDigestHeadLookup = func(ctx context.Context, _ command.Cli, ref string) (string, error) { + return registryDigestForRefViaHEADWithClient(ctx, &configfile.ConfigFile{}, ref, server.Client()) + } + registryDigestDistributionLookup = func(_ context.Context, _ command.Cli, _ string) (string, error) { + return "sha256:from-distribution-fallback", nil + } + + got, err := registryDigestForRef(context.Background(), nil, imageRef) + if err != nil { + t.Fatalf("registryDigestForRef() unexpected error: %v", err) + } + + if got != "sha256:from-distribution-fallback" { + t.Fatalf("registryDigestForRef() = %q, want %q", got, "sha256:from-distribution-fallback") + } +} + +func TestRegistryDigestForRefViaHEADWithClient_UsesHEAD(t *testing.T) { + t.Parallel() + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodHead { + t.Fatalf("unexpected method: got %s, want %s", r.Method, http.MethodHead) + } + + if r.URL.Path != "/v2/team/app/manifests/latest" { + t.Fatalf("unexpected path: got %s", r.URL.Path) + } + + w.Header().Set(dockerContentDigest, "sha256:from-head") + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(server.Close) + + parsed, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("failed to parse test server URL: %v", err) + } + + imageRef := parsed.Host + "/team/app:latest" + + got, err := registryDigestForRefViaHEADWithClient(context.Background(), &configfile.ConfigFile{}, imageRef, server.Client()) + if err != nil { + t.Fatalf("registryDigestForRefViaHEADWithClient() unexpected error: %v", err) + } + + if got != "sha256:from-head" { + t.Fatalf("registryDigestForRefViaHEADWithClient() = %q, want %q", got, "sha256:from-head") + } +} + +func TestRegistryDigestForRefViaHEADWithClient_BearerChallenge(t *testing.T) { + t.Parallel() + + var tokenURL string + + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/token": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token":"registry-token"}`)) + case "/v2/team/app/manifests/latest": + if !strings.HasPrefix(r.Header.Get("Authorization"), registryAuthBearer+" ") || r.Header.Get("Authorization") != registryAuthBearer+" registry-token" { + w.Header().Set(wwwAuthenticateHeader, fmt.Sprintf(`Bearer realm=%q,service=%q,scope=%q`, tokenURL, "test-registry", "repository:team/app:pull")) + w.WriteHeader(http.StatusUnauthorized) + + return + } + + w.Header().Set(dockerContentDigest, "sha256:from-bearer") + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + tokenURL = server.URL + "/token" + + parsed, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("failed to parse test server URL: %v", err) + } + + imageRef := parsed.Host + "/team/app:latest" + + got, err := registryDigestForRefViaHEADWithClient(context.Background(), &configfile.ConfigFile{}, imageRef, server.Client()) + if err != nil { + t.Fatalf("registryDigestForRefViaHEADWithClient() unexpected error: %v", err) + } + + if got != "sha256:from-bearer" { + t.Fatalf("registryDigestForRefViaHEADWithClient() = %q, want %q", got, "sha256:from-bearer") + } +} + +func TestRegistryDigestForRefViaHEADWithClient_GHCR_DocoCD_Integration(t *testing.T) { + t.Parallel() + + requireRegistryIntegrationTestGate(t) + + const imageRef = "ghcr.io/kimdre/doco-cd:latest" + + var digestWithoutCreds string + + t.Run("without registry credentials", func(t *testing.T) { + t.Parallel() + + digest, err := registryDigestForRefViaHEADWithClient(t.Context(), &configfile.ConfigFile{}, imageRef, http.DefaultClient) + if err != nil { + t.Fatalf("registryDigestForRefViaHEADWithClient() unexpected error: %v", err) + } + + if !strings.HasPrefix(digest, "sha256:") { + t.Fatalf("digest = %q, want sha256:*", digest) + } + + digestWithoutCreds = digest + }) + + t.Run("with registry credentials if set", func(t *testing.T) { + t.Parallel() + + username := strings.TrimSpace(os.Getenv("GHCR_USERNAME")) + + token := strings.TrimSpace(os.Getenv("GHCR_TOKEN")) + if username == "" || token == "" { + t.Skip("set GHCR_USERNAME and GHCR_TOKEN to run credentialed ghcr.io integration lookup") + } + + cfg := &configfile.ConfigFile{ + AuthConfigs: map[string]configtypes.AuthConfig{ + "ghcr.io": { + Username: username, + Password: token, + ServerAddress: "ghcr.io", + }, + }, + } + + digest, err := registryDigestForRefViaHEADWithClient(t.Context(), cfg, imageRef, http.DefaultClient) + if err != nil { + t.Fatalf("registryDigestForRefViaHEADWithClient() unexpected error: %v", err) + } + + if !strings.HasPrefix(digest, "sha256:") { + t.Fatalf("digest = %q, want sha256:*", digest) + } + + if digestWithoutCreds == "" { + t.Fatalf("digest from unauthenticated subtest must be set") + } + }) +} + +func requireRegistryIntegrationTestGate(t *testing.T) { + t.Helper() + + if testing.Short() { + t.Skip("skipping registry integration tests in short mode") + } + + if os.Getenv(registryIntegrationEnvVar) != "1" { + t.Skipf("set %s=1 to run registry integration tests", registryIntegrationEnvVar) + } +} diff --git a/doco-cd-src/internal/docker/job_schedule.go b/doco-cd-src/internal/docker/job_schedule.go new file mode 100644 index 0000000..985f825 --- /dev/null +++ b/doco-cd-src/internal/docker/job_schedule.go @@ -0,0 +1,155 @@ +package docker + +import ( + "fmt" + "log/slog" + "strconv" + "strings" + + "github.com/robfig/cron/v3" +) + +type JobExecutionMode string + +const ( + JobExecutionModeRestart JobExecutionMode = "restart" + JobExecutionModeOneOff JobExecutionMode = "one_off" + + // JobExecutionModeOneShotDeprecated is the deprecated alias for JobExecutionModeOneOff. + // + // Deprecated: still accepted for backward compatibility but will log a warning + // TODO: Remove in a future release. + JobExecutionModeOneShotDeprecated JobExecutionMode = "one_shot" +) + +type JobNotifyOn string + +const ( + JobNotifyNone JobNotifyOn = "none" + JobNotifySuccess JobNotifyOn = "success" + JobNotifyFailure JobNotifyOn = "failure" + JobNotifyAll JobNotifyOn = "all" +) + +type JobScheduleConfig struct { + Enabled bool + Schedule string + SkipRunning bool + ExecutionMode JobExecutionMode + NotifyOn JobNotifyOn + SwarmReplicas uint64 +} + +func (c JobScheduleConfig) ShouldNotifySuccess() bool { + return c.NotifyOn == JobNotifyAll || c.NotifyOn == JobNotifySuccess +} + +func (c JobScheduleConfig) ShouldNotifyFailure() bool { + return c.NotifyOn == JobNotifyAll || c.NotifyOn == JobNotifyFailure +} + +func NewJobScheduleParser() cron.Parser { + // 5-field cron format with descriptors and @every durations. Seconds are intentionally unsupported. + return cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor) +} + +func ParseJobScheduleExpression(spec string) (cron.Schedule, error) { + schedule, err := NewJobScheduleParser().Parse(strings.TrimSpace(spec)) + if err != nil { + return nil, fmt.Errorf("invalid job schedule %q: %w", spec, err) + } + + return schedule, nil +} + +func ParseJobScheduleLabels(labels map[string]string, log ...*slog.Logger) (JobScheduleConfig, bool, error) { + var logger *slog.Logger + if len(log) > 0 && log[0] != nil { + logger = log[0] + } else { + logger = slog.Default() + } + + cfg := JobScheduleConfig{ + ExecutionMode: JobExecutionModeRestart, + NotifyOn: JobNotifyAll, + SwarmReplicas: 1, + } + + enabledRaw, exists := labels[docoCDJobLabelNames.JobEnabled] + if !exists { + return cfg, false, nil + } + + enabled, err := strconv.ParseBool(strings.TrimSpace(enabledRaw)) + if err != nil { + return cfg, false, fmt.Errorf("invalid %s label value %q", docoCDJobLabelNames.JobEnabled, enabledRaw) + } + + if !enabled { + return cfg, false, nil + } + + cfg.Enabled = true + + schedule := strings.TrimSpace(labels[docoCDJobLabelNames.JobSchedule]) + if schedule == "" { + return cfg, false, fmt.Errorf("%s label is required when %s=true", docoCDJobLabelNames.JobSchedule, docoCDJobLabelNames.JobEnabled) + } + + if _, err = ParseJobScheduleExpression(schedule); err != nil { + return cfg, false, err + } + + cfg.Schedule = schedule + + if skipRaw, ok := labels[docoCDJobLabelNames.JobSkipRunning]; ok { + skip, parseErr := strconv.ParseBool(strings.TrimSpace(skipRaw)) + if parseErr != nil { + return cfg, false, fmt.Errorf("invalid %s label value %q", docoCDJobLabelNames.JobSkipRunning, skipRaw) + } + + cfg.SkipRunning = skip + } + + if modeRaw, ok := labels[docoCDJobLabelNames.JobExecutionMode]; ok { + mode := JobExecutionMode(strings.TrimSpace(modeRaw)) + switch mode { + case JobExecutionModeRestart, JobExecutionModeOneOff: + cfg.ExecutionMode = mode + case JobExecutionModeOneShotDeprecated: + logger.Warn( + fmt.Sprintf("label %s: value %q is deprecated, use %q instead", docoCDJobLabelNames.JobExecutionMode, JobExecutionModeOneShotDeprecated, JobExecutionModeOneOff), + slog.String("label", docoCDJobLabelNames.JobExecutionMode), + ) + cfg.ExecutionMode = JobExecutionModeOneOff + default: + return cfg, false, fmt.Errorf("invalid %s label value %q", docoCDJobLabelNames.JobExecutionMode, modeRaw) + } + } + + if notifyRaw, ok := labels[docoCDJobLabelNames.JobNotifyOn]; ok { + notifyOn := JobNotifyOn(strings.TrimSpace(notifyRaw)) + switch notifyOn { + case JobNotifyNone, JobNotifySuccess, JobNotifyFailure, JobNotifyAll: + cfg.NotifyOn = notifyOn + default: + return cfg, false, fmt.Errorf("invalid %s label value %q", docoCDJobLabelNames.JobNotifyOn, notifyRaw) + } + } + + if replicasRaw, ok := labels[docoCDJobLabelNames.JobSwarmReplicas]; ok { + replicas, parseErr := strconv.ParseUint(strings.TrimSpace(replicasRaw), 10, 64) + if parseErr != nil { + return cfg, false, fmt.Errorf("invalid %s label value %q", docoCDJobLabelNames.JobSwarmReplicas, replicasRaw) + } + + if replicas == 0 { + return cfg, false, fmt.Errorf("%s must be greater than zero", docoCDJobLabelNames.JobSwarmReplicas) + } + + cfg.SwarmReplicas = replicas + } + + return cfg, true, nil +} diff --git a/doco-cd-src/internal/docker/job_schedule_test.go b/doco-cd-src/internal/docker/job_schedule_test.go new file mode 100644 index 0000000..5669c86 --- /dev/null +++ b/doco-cd-src/internal/docker/job_schedule_test.go @@ -0,0 +1,120 @@ +package docker + +import "testing" + +func TestParseJobScheduleExpression(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec string + wantErr bool + }{ + {name: "valid 5-field", spec: "*/5 * * * *", wantErr: false}, + {name: "valid every duration", spec: "@every 1h30m", wantErr: false}, + {name: "invalid seconds field", spec: "*/5 * * * * *", wantErr: true}, + {name: "invalid expression", spec: "every minute", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := ParseJobScheduleExpression(tt.spec) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseJobScheduleExpression() err=%v wantErr=%v", err, tt.wantErr) + } + }) + } +} + +func TestParseJobScheduleLabels(t *testing.T) { + t.Parallel() + + labels := map[string]string{ + docoCDJobLabelNames.JobEnabled: "true", + docoCDJobLabelNames.JobSchedule: "*/10 * * * *", + docoCDJobLabelNames.JobSkipRunning: "true", + docoCDJobLabelNames.JobExecutionMode: string(JobExecutionModeOneOff), + docoCDJobLabelNames.JobNotifyOn: string(JobNotifyFailure), + docoCDJobLabelNames.JobSwarmReplicas: "3", + } + + cfg, enabled, err := ParseJobScheduleLabels(labels) + if err != nil { + t.Fatalf("ParseJobScheduleLabels() failed: %v", err) + } + + if !enabled { + t.Fatalf("expected enabled=true") + } + + if cfg.ExecutionMode != JobExecutionModeOneOff { + t.Fatalf("unexpected execution mode: %s", cfg.ExecutionMode) + } + + if cfg.NotifyOn != JobNotifyFailure { + t.Fatalf("unexpected notify_on: %s", cfg.NotifyOn) + } + + if !cfg.SkipRunning { + t.Fatalf("expected skip_running=true") + } + + if cfg.SwarmReplicas != 3 { + t.Fatalf("unexpected swarm replicas: %d", cfg.SwarmReplicas) + } +} + +func TestParseJobScheduleLabels_OneShotDeprecatedAlias(t *testing.T) { + t.Parallel() + + cfg, enabled, err := ParseJobScheduleLabels(map[string]string{ + docoCDJobLabelNames.JobEnabled: "true", + docoCDJobLabelNames.JobSchedule: "0 * * * *", + docoCDJobLabelNames.JobExecutionMode: string(JobExecutionModeOneShotDeprecated), + }) + if err != nil { + t.Fatalf("ParseJobScheduleLabels() failed with deprecated one_shot alias: %v", err) + } + + if !enabled { + t.Fatalf("expected enabled=true") + } + + if cfg.ExecutionMode != JobExecutionModeOneOff { + t.Fatalf("expected execution mode to be normalized to %q, got %q", JobExecutionModeOneOff, cfg.ExecutionMode) + } +} + +func TestParseJobScheduleLabels_Defaults(t *testing.T) { + t.Parallel() + + cfg, enabled, err := ParseJobScheduleLabels(map[string]string{ + docoCDJobLabelNames.JobEnabled: "true", + docoCDJobLabelNames.JobSchedule: "0 * * * *", + }) + if err != nil { + t.Fatalf("ParseJobScheduleLabels() failed: %v", err) + } + + if !enabled { + t.Fatalf("expected enabled=true") + } + + if cfg.ExecutionMode != JobExecutionModeRestart { + t.Fatalf("unexpected default execution mode: %s", cfg.ExecutionMode) + } + + if cfg.NotifyOn != JobNotifyAll { + t.Fatalf("unexpected default notify_on: %s", cfg.NotifyOn) + } + + if cfg.SkipRunning { + t.Fatalf("expected default skip_running=false") + } + + if cfg.SwarmReplicas != 1 { + t.Fatalf("expected default swarm replicas=1, got %d", cfg.SwarmReplicas) + } +} diff --git a/doco-cd-src/internal/docker/job_schedule_validation.go b/doco-cd-src/internal/docker/job_schedule_validation.go new file mode 100644 index 0000000..04f66b8 --- /dev/null +++ b/doco-cd-src/internal/docker/job_schedule_validation.go @@ -0,0 +1,43 @@ +package docker + +import ( + "fmt" + "strings" + + "github.com/compose-spec/compose-go/v2/types" +) + +func validateScheduledJobPolicies(project *types.Project, swarmMode bool) error { + for serviceName, svc := range project.Services { + _, enabled, err := ParseJobScheduleLabels(svc.Labels) + if err != nil { + return fmt.Errorf("service %s: %w", serviceName, err) + } + + if !enabled { + continue + } + + if swarmMode { + if svc.Deploy == nil || svc.Deploy.RestartPolicy == nil { + continue + } + + condition := strings.ToLower(strings.TrimSpace(svc.Deploy.RestartPolicy.Condition)) + if condition == "none" { + continue + } + + return fmt.Errorf("service %s: deploy.restart_policy.condition=%q is not allowed for scheduled services; use none or unset restart_policy", serviceName, svc.Deploy.RestartPolicy.Condition) + } + + restart := strings.ToLower(strings.TrimSpace(svc.Restart)) + if restart == "" || restart == "no" { + continue + } + + return fmt.Errorf("service %s: restart=%q is not allowed for scheduled services; use no or unset", serviceName, svc.Restart) + } + + return nil +} diff --git a/doco-cd-src/internal/docker/job_schedule_validation_test.go b/doco-cd-src/internal/docker/job_schedule_validation_test.go new file mode 100644 index 0000000..adfbe1a --- /dev/null +++ b/doco-cd-src/internal/docker/job_schedule_validation_test.go @@ -0,0 +1,162 @@ +package docker + +import ( + "testing" + + "github.com/compose-spec/compose-go/v2/types" +) + +func TestValidateScheduledJobPolicies(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + swarmMode bool + project *types.Project + wantErr bool + }{ + { + name: "standalone allows restart no for scheduled restart mode", + swarmMode: false, + project: &types.Project{ + Services: types.Services{ + "ok": { + Name: "ok", + Restart: "no", + Labels: map[string]string{ + docoCDJobLabelNames.JobEnabled: "true", + docoCDJobLabelNames.JobSchedule: "*/10 * * * *", + }, + }, + }, + }, + wantErr: false, + }, + { + name: "standalone rejects restart always for scheduled restart mode", + swarmMode: false, + project: &types.Project{ + Services: types.Services{ + "bad": { + Name: "bad", + Restart: "always", + Labels: map[string]string{ + docoCDJobLabelNames.JobEnabled: "true", + docoCDJobLabelNames.JobSchedule: "*/10 * * * *", + }, + }, + }, + }, + wantErr: true, + }, + { + name: "standalone allows one_off with restart unset", + swarmMode: false, + project: &types.Project{ + Services: types.Services{ + "ok-one-off": { + Name: "ok-one-off", + Labels: map[string]string{ + docoCDJobLabelNames.JobEnabled: "true", + docoCDJobLabelNames.JobSchedule: "*/10 * * * *", + docoCDJobLabelNames.JobExecutionMode: string(JobExecutionModeOneOff), + }, + }, + }, + }, + wantErr: false, + }, + { + name: "swarm rejects restart policy condition any", + swarmMode: true, + project: &types.Project{ + Services: types.Services{ + "bad-job": { + Name: "bad-job", + Deploy: &types.DeployConfig{ + RestartPolicy: &types.RestartPolicy{ + Condition: "any", + }, + }, + Labels: map[string]string{ + docoCDJobLabelNames.JobEnabled: "true", + docoCDJobLabelNames.JobSchedule: "0 * * * *", + docoCDJobLabelNames.JobExecutionMode: string(JobExecutionModeOneOff), + }, + }, + }, + }, + wantErr: true, + }, + { + name: "swarm allows restart policy none", + swarmMode: true, + project: &types.Project{ + Services: types.Services{ + "ok-job": { + Name: "ok-job", + Deploy: &types.DeployConfig{ + Mode: "global-job", + RestartPolicy: &types.RestartPolicy{ + Condition: "none", + }, + }, + Labels: map[string]string{ + docoCDJobLabelNames.JobEnabled: "true", + docoCDJobLabelNames.JobSchedule: "@every 1h", + docoCDJobLabelNames.JobExecutionMode: string(JobExecutionModeOneOff), + }, + }, + }, + }, + wantErr: false, + }, + { + name: "swarm rejects empty restart policy condition when policy is set", + swarmMode: true, + project: &types.Project{ + Services: types.Services{ + "bad-empty": { + Name: "bad-empty", + Deploy: &types.DeployConfig{ + RestartPolicy: &types.RestartPolicy{}, + }, + Labels: map[string]string{ + docoCDJobLabelNames.JobEnabled: "true", + docoCDJobLabelNames.JobSchedule: "*/5 * * * *", + }, + }, + }, + }, + wantErr: true, + }, + { + name: "swarm allows unset restart policy", + swarmMode: true, + project: &types.Project{ + Services: types.Services{ + "ok-unset": { + Name: "ok-unset", + Deploy: &types.DeployConfig{}, + Labels: map[string]string{ + docoCDJobLabelNames.JobEnabled: "true", + docoCDJobLabelNames.JobSchedule: "*/5 * * * *", + }, + }, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateScheduledJobPolicies(tt.project, tt.swarmMode) + if (err != nil) != tt.wantErr { + t.Fatalf("validateScheduledJobPolicies() err=%v wantErr=%v", err, tt.wantErr) + } + }) + } +} diff --git a/doco-cd-src/internal/docker/labels.go b/doco-cd-src/internal/docker/labels.go index 7e77b4d..64745b3 100644 --- a/doco-cd-src/internal/docker/labels.go +++ b/doco-cd-src/internal/docker/labels.go @@ -16,8 +16,8 @@ type docoCdLabelNamesDeployment struct { Trigger string // Poll or SHA of the commit that triggered the deployment CommitSHA string // SHA of the commit that is currently deployed ConfigHash string // SHA256 hash of the deploy-config used during deployment - AutoDiscover string // Whether the deployment was auto-discovered - AutoDiscoverDelete string // Whether auto-discovered deployment is allowed to be deleted + AutoDiscovery string // Whether the deployment was auto-discovered + AutoDiscoveryDelete string // Whether auto-discovered deployment is allowed to be deleted RecreateIgnore string // Whether the deployment file changes should ignore recreate RecreateIgnoreSignal string // Signal service when deployment file changes and ignore recreate } @@ -50,8 +50,8 @@ var DocoCDLabels = docoCdLabelNames{ CommitSHA: "cd.doco.deployment.target.sha", Trigger: "cd.doco.deployment.trigger", ConfigHash: "cd.doco.deployment.config.sha", - AutoDiscover: "cd.doco.deployment.auto_discover", - AutoDiscoverDelete: "cd.doco.deployment.auto_discover.delete", + AutoDiscovery: "cd.doco.deployment.auto_discovery", + AutoDiscoveryDelete: "cd.doco.deployment.auto_discovery.delete", RecreateIgnore: "cd.doco.deployment.recreate.ignore", RecreateIgnoreSignal: "cd.doco.deployment.recreate.ignore.signal", }, @@ -61,12 +61,38 @@ var DocoCDLabels = docoCdLabelNames{ }, } +/* +DeprecatedAutoDiscoverLabel and DeprecatedAutoDiscoverDeleteLabel are the old label names +kept for backwards-compatible reads. New deployments only write the new labels. + +Deprecated: Use DocoCDLabels.Deployment.AutoDiscovery and DocoCDLabels.Deployment.AutoDiscoveryDelete instead. + +TODO: Remove in a future release. +*/ +const ( + DeprecatedAutoDiscoverLabel = "cd.doco.deployment.auto_discover" + DeprecatedAutoDiscoverDeleteLabel = "cd.doco.deployment.auto_discover.delete" +) + var docoCDJobLabelNames = struct { - JobSchedule string // Schedule of the job (if applicable) in cron format - JobLastRun string // Timestamp of the last run in RFC3339 format - JobNextRun string // Timestamp of the next scheduled run in RFC3339 format + JobEnabled string // Enable scheduling for a service/container + JobSchedule string // Schedule of the job in 5-field cron format or @every duration + JobSkipRunning string // Skip a schedule trigger when a previous run is still in progress + JobExecutionMode string // Defines if a run restarts/reruns the job or starts an ephemeral one-off execution + JobNotifyOn string // Controls notification behavior: none, success, failure, all + JobSwarmReplicas string // Number of replicas for one-off replicated-job runs in swarm mode + JobLastRun string // Timestamp of the last run in RFC3339 format + JobNextRun string // Timestamp of the next scheduled run in RFC3339 format }{ - JobSchedule: "cd.doco.job.schedule", - JobLastRun: "cd.doco.job.last_run", - JobNextRun: "cd.doco.job.next_run", + JobEnabled: "cd.doco.job.enabled", + JobSchedule: "cd.doco.job.schedule", + JobSkipRunning: "cd.doco.job.skip_running", + JobExecutionMode: "cd.doco.job.execution_mode", + JobNotifyOn: "cd.doco.job.notify_on", + JobSwarmReplicas: "cd.doco.job.swarm.replicas", + JobLastRun: "cd.doco.job.last_run", + JobNextRun: "cd.doco.job.next_run", } + +// DocoCDJobLabels exposes the scheduler/job labels for consumers outside this package. +var DocoCDJobLabels = docoCDJobLabelNames diff --git a/doco-cd-src/internal/docker/service_utils.go b/doco-cd-src/internal/docker/service_utils.go index 93b6dcc..f4f8175 100644 --- a/doco-cd-src/internal/docker/service_utils.go +++ b/doco-cd-src/internal/docker/service_utils.go @@ -5,6 +5,7 @@ import ( "fmt" "maps" "strings" + "sync" "github.com/compose-spec/compose-go/v2/types" "github.com/docker/cli/cli/compose/convert" @@ -13,6 +14,7 @@ import ( "github.com/moby/moby/client" swarmInternal "github.com/kimdre/doco-cd/internal/docker/swarm" + "github.com/kimdre/doco-cd/internal/git" "github.com/kimdre/doco-cd/internal/utils/set" ) @@ -32,35 +34,53 @@ type ServiceStatus struct { } type LatestServiceStatus struct { - // The labels may be different in different services, but project-level labels should be the same. - Labels Labels + deploymentCommitSHA string + deploymentComposeHash string DeployedStatus map[Service]ServiceStatus } +func (l LatestServiceStatus) GetDeploymentCommitSHA() string { + return l.deploymentCommitSHA +} + +func (l LatestServiceStatus) GetDeploymentComposeHash() string { + return l.deploymentComposeHash +} + // GetLatestDeployStatus retrieves the deployed status for a given repository and deploy name. -func GetLatestDeployStatus(ctx context.Context, client client.APIClient, repoName, deployName string) (LatestServiceStatus, error) { +func GetLatestDeployStatus(ctx context.Context, client client.APIClient, cloneURL string, deployName string) (LatestServiceStatus, error) { serviceLabels, err := getDeployStatus(ctx, client, deployName) if err != nil { return LatestServiceStatus{}, fmt.Errorf("failed to retrieve service labels: %w", err) } - return getLatestServiceStatus(serviceLabels, repoName), nil + return getLatestServiceStatus(&deployStatusCache, serviceLabels, cloneURL, deployName), nil } -func getLatestServiceStatus(statusMap map[Service]ServiceStatus, repoName string) LatestServiceStatus { +func getLatestServiceStatus(cacheMap *sync.Map, statusMap map[Service]ServiceStatus, cloneURL string, deployName string) LatestServiceStatus { ret := LatestServiceStatus{ DeployedStatus: make(map[Service]ServiceStatus), - Labels: make(Labels), } - var latestTimestamp string + var ( + latestLabels Labels + latestTimestamp string + ) for serviceName, state := range statusMap { + // Always include the service in the deployed inventory, regardless of whether it has + // cd.doco.* labels. Containers that are missing those labels (e.g. started via the + // Docker CLI directly or deployed before doco-cd stamped them) are still genuinely + // running and must not be reported as "service not deployed" by CheckServiceMismatch. + ret.DeployedStatus[serviceName] = state + + // Only use containers that carry doco-cd repository labels for deployment metadata + // (latest commit SHA, compose hash). This keeps the two concerns separate. labels := state.Labels name, ok := labels[DocoCDLabels.Repository.Name] - if !ok || name != repoName { + if !ok || name != git.GetFullName(cloneURL) { // When a service matches and others don't, // using 'break' will return a random result. continue @@ -72,10 +92,17 @@ func getLatestServiceStatus(statusMap map[Service]ServiceStatus, repoName string // TODO: If timestamps are equal, the result may be random for simultaneous deployments. if timestamp >= latestTimestamp { latestTimestamp = timestamp - ret.Labels = labels + latestLabels = labels } + } - ret.DeployedStatus[serviceName] = state + cache, ok := getDeployStatusFromCache(cacheMap, git.GetRepoName(cloneURL), deployName) + if ok { + ret.deploymentCommitSHA = cache.CommitSHA + ret.deploymentComposeHash = cache.ComposeHash + } else { + ret.deploymentCommitSHA, _ = latestLabels.getDeploymentCommitSHA() + ret.deploymentComposeHash, _ = latestLabels.getDeploymentComposeHash() } return ret diff --git a/doco-cd-src/internal/docker/service_utils_test.go b/doco-cd-src/internal/docker/service_utils_test.go index 6305774..2838515 100644 --- a/doco-cd-src/internal/docker/service_utils_test.go +++ b/doco-cd-src/internal/docker/service_utils_test.go @@ -6,13 +6,15 @@ import ( "reflect" "slices" "strings" + "sync" "testing" "time" "github.com/avast/retry-go/v5" "github.com/compose-spec/compose-go/v2/types" - "github.com/kimdre/doco-cd/internal/config" + "github.com/kimdre/doco-cd/internal/config/deploy" + "github.com/kimdre/doco-cd/internal/docker/swarm" "github.com/kimdre/doco-cd/internal/git" "github.com/kimdre/doco-cd/internal/test" @@ -22,18 +24,26 @@ import ( func Test_getLatestServiceState(t *testing.T) { t.Parallel() + cache := &sync.Map{} + cache.Store(getDeployStatusCacheKey("github.com/owner/repo", "cache"), deployStatus{ + ComposeHash: "cache_compose_hash", + CommitSHA: "cache_commit_sha", + }) + tests := []struct { name string serviceStatus map[Service]ServiceStatus repoName string + deployName string + cache *sync.Map want LatestServiceStatus }{ { name: "empty serviceLabels", serviceStatus: map[Service]ServiceStatus{}, repoName: "repo", + deployName: "deploy", want: LatestServiceStatus{ - Labels: Labels{}, DeployedStatus: map[Service]ServiceStatus{}, }, }, @@ -42,20 +52,81 @@ func Test_getLatestServiceState(t *testing.T) { serviceStatus: map[Service]ServiceStatus{ "svc1": { Labels: Labels{ - DocoCDLabels.Repository.Name: "repo", + DocoCDLabels.Repository.Name: "repo", + DocoCDLabels.Deployment.CommitSHA: "commit_sha", + DocoCDLabels.Deployment.ComposeHash: "compose_hash", }, Replicas: 1, }, }, repoName: "repo", want: LatestServiceStatus{ - Labels: Labels{ - DocoCDLabels.Repository.Name: "repo", + deploymentCommitSHA: "commit_sha", + deploymentComposeHash: "compose_hash", + DeployedStatus: map[Service]ServiceStatus{ + "svc1": { + Labels: Labels{ + DocoCDLabels.Repository.Name: "repo", + DocoCDLabels.Deployment.CommitSHA: "commit_sha", + DocoCDLabels.Deployment.ComposeHash: "compose_hash", + }, + Replicas: 1, + }, + }, + }, + }, + { + name: "single service but repo this is full clone URL", + serviceStatus: map[Service]ServiceStatus{ + "svc1": { + Labels: Labels{ + DocoCDLabels.Repository.Name: "owner/repo", + DocoCDLabels.Deployment.CommitSHA: "commit_sha", + DocoCDLabels.Deployment.ComposeHash: "compose_hash", + }, + Replicas: 1, }, + }, + repoName: "https://github.com/owner/repo.git", + want: LatestServiceStatus{ + deploymentCommitSHA: "commit_sha", + deploymentComposeHash: "compose_hash", DeployedStatus: map[Service]ServiceStatus{ "svc1": { Labels: Labels{ - DocoCDLabels.Repository.Name: "repo", + DocoCDLabels.Repository.Name: "owner/repo", + DocoCDLabels.Deployment.CommitSHA: "commit_sha", + DocoCDLabels.Deployment.ComposeHash: "compose_hash", + }, + Replicas: 1, + }, + }, + }, + }, + { + name: "cache hit, single service with no timestamp", + serviceStatus: map[Service]ServiceStatus{ + "svc1": { + Labels: Labels{ + DocoCDLabels.Repository.Name: "owner/repo", + DocoCDLabels.Deployment.CommitSHA: "commit_sha", + DocoCDLabels.Deployment.ComposeHash: "compose_hash", + }, + Replicas: 1, + }, + }, + repoName: "https://github.com/owner/repo.git", + deployName: "cache", + cache: cache, + want: LatestServiceStatus{ + deploymentCommitSHA: "cache_commit_sha", + deploymentComposeHash: "cache_compose_hash", + DeployedStatus: map[Service]ServiceStatus{ + "svc1": { + Labels: Labels{ + DocoCDLabels.Repository.Name: "owner/repo", + DocoCDLabels.Deployment.CommitSHA: "commit_sha", + DocoCDLabels.Deployment.ComposeHash: "compose_hash", }, Replicas: 1, }, @@ -67,23 +138,25 @@ func Test_getLatestServiceState(t *testing.T) { serviceStatus: map[Service]ServiceStatus{ "svc1": { Labels: Labels{ - DocoCDLabels.Repository.Name: "repo", - DocoCDLabels.Deployment.Timestamp: "2006-01-02T15:04:05Z07:00", + DocoCDLabels.Repository.Name: "repo", + DocoCDLabels.Deployment.Timestamp: "2006-01-02T15:04:05Z07:00", + DocoCDLabels.Deployment.CommitSHA: "commit_sha", + DocoCDLabels.Deployment.ComposeHash: "compose_hash", }, Replicas: 1, }, }, repoName: "repo", want: LatestServiceStatus{ - Labels: Labels{ - DocoCDLabels.Repository.Name: "repo", - DocoCDLabels.Deployment.Timestamp: "2006-01-02T15:04:05Z07:00", - }, + deploymentCommitSHA: "commit_sha", + deploymentComposeHash: "compose_hash", DeployedStatus: map[Service]ServiceStatus{ "svc1": { Labels: Labels{ - DocoCDLabels.Repository.Name: "repo", - DocoCDLabels.Deployment.Timestamp: "2006-01-02T15:04:05Z07:00", + DocoCDLabels.Repository.Name: "repo", + DocoCDLabels.Deployment.Timestamp: "2006-01-02T15:04:05Z07:00", + DocoCDLabels.Deployment.CommitSHA: "commit_sha", + DocoCDLabels.Deployment.ComposeHash: "compose_hash", }, Replicas: 1, }, @@ -91,6 +164,8 @@ func Test_getLatestServiceState(t *testing.T) { }, }, { + // All project containers are included in DeployedStatus regardless of repo label. + // The repo-name filter only governs metadata selection (commit SHA, compose hash). name: "two service with timestamp but repo not match", serviceStatus: map[Service]ServiceStatus{ "svc1": { @@ -110,39 +185,70 @@ func Test_getLatestServiceState(t *testing.T) { }, repoName: "repo", want: LatestServiceStatus{ - Labels: Labels{}, - DeployedStatus: map[Service]ServiceStatus{}, + deploymentCommitSHA: "", + deploymentComposeHash: "", + DeployedStatus: map[Service]ServiceStatus{ + "svc1": { + Labels: Labels{ + DocoCDLabels.Repository.Name: "repo-2", + DocoCDLabels.Deployment.Timestamp: "2006-01-02T15:04:05Z07:00", + }, + Replicas: 1, + }, + "svc2": { + Labels: Labels{ + DocoCDLabels.Repository.Name: "repo-2", + DocoCDLabels.Deployment.Timestamp: "2016-01-02T15:04:05Z07:00", + }, + Replicas: 1, + }, + }, }, }, { + // svc2 has a different repo label but is still part of the project — it must appear + // in DeployedStatus. Only metadata (commit SHA, compose hash) is drawn from svc1. name: "two service with timestamp but repo mixed", serviceStatus: map[Service]ServiceStatus{ "svc1": { Labels: Labels{ - DocoCDLabels.Repository.Name: "repo", - DocoCDLabels.Deployment.Timestamp: "2006-01-02T15:04:05Z07:00", + DocoCDLabels.Repository.Name: "repo", + DocoCDLabels.Deployment.Timestamp: "2006-01-02T15:04:05Z07:00", + DocoCDLabels.Deployment.CommitSHA: "commit_sha1", + DocoCDLabels.Deployment.ComposeHash: "compose_hash1", }, Replicas: 1, }, "svc2": { Labels: Labels{ - DocoCDLabels.Repository.Name: "repo-2", - DocoCDLabels.Deployment.Timestamp: "2016-01-02T15:04:05Z07:00", + DocoCDLabels.Repository.Name: "repo-2", + DocoCDLabels.Deployment.Timestamp: "2016-01-02T15:04:05Z07:00", + DocoCDLabels.Deployment.CommitSHA: "commit_sha2", + DocoCDLabels.Deployment.ComposeHash: "compose_hash2", }, Replicas: 1, }, }, repoName: "repo", want: LatestServiceStatus{ - Labels: Labels{ - DocoCDLabels.Repository.Name: "repo", - DocoCDLabels.Deployment.Timestamp: "2006-01-02T15:04:05Z07:00", - }, + deploymentCommitSHA: "commit_sha1", + deploymentComposeHash: "compose_hash1", DeployedStatus: map[Service]ServiceStatus{ "svc1": { Labels: Labels{ - DocoCDLabels.Repository.Name: "repo", - DocoCDLabels.Deployment.Timestamp: "2006-01-02T15:04:05Z07:00", + DocoCDLabels.Repository.Name: "repo", + DocoCDLabels.Deployment.Timestamp: "2006-01-02T15:04:05Z07:00", + DocoCDLabels.Deployment.CommitSHA: "commit_sha1", + DocoCDLabels.Deployment.ComposeHash: "compose_hash1", + }, + Replicas: 1, + }, + "svc2": { + Labels: Labels{ + DocoCDLabels.Repository.Name: "repo-2", + DocoCDLabels.Deployment.Timestamp: "2016-01-02T15:04:05Z07:00", + DocoCDLabels.Deployment.CommitSHA: "commit_sha2", + DocoCDLabels.Deployment.ComposeHash: "compose_hash2", }, Replicas: 1, }, @@ -150,25 +256,49 @@ func Test_getLatestServiceState(t *testing.T) { }, }, { + // Containers without a cd.doco.repository.name label contribute no metadata but + // are still counted as deployed so CheckServiceMismatch does not flag them. name: "two service with timestamp but repo mismatch", serviceStatus: map[Service]ServiceStatus{ "svc1": { Labels: Labels{ - DocoCDLabels.Deployment.Timestamp: "2006-01-02T15:04:05Z07:00", + DocoCDLabels.Deployment.Timestamp: "2006-01-02T15:04:05Z07:00", + DocoCDLabels.Deployment.CommitSHA: "commit_sha1", + DocoCDLabels.Deployment.ComposeHash: "compose_hash1", }, Replicas: 1, }, "svc2": { Labels: Labels{ - DocoCDLabels.Deployment.Timestamp: "2016-01-02T15:04:05Z07:00", + DocoCDLabels.Deployment.Timestamp: "2016-01-02T15:04:05Z07:00", + DocoCDLabels.Deployment.CommitSHA: "commit_sha2", + DocoCDLabels.Deployment.ComposeHash: "compose_hash2", }, Replicas: 1, }, }, repoName: "repo", want: LatestServiceStatus{ - Labels: Labels{}, - DeployedStatus: map[Service]ServiceStatus{}, + deploymentCommitSHA: "", + deploymentComposeHash: "", + DeployedStatus: map[Service]ServiceStatus{ + "svc1": { + Labels: Labels{ + DocoCDLabels.Deployment.Timestamp: "2006-01-02T15:04:05Z07:00", + DocoCDLabels.Deployment.CommitSHA: "commit_sha1", + DocoCDLabels.Deployment.ComposeHash: "compose_hash1", + }, + Replicas: 1, + }, + "svc2": { + Labels: Labels{ + DocoCDLabels.Deployment.Timestamp: "2016-01-02T15:04:05Z07:00", + DocoCDLabels.Deployment.CommitSHA: "commit_sha2", + DocoCDLabels.Deployment.ComposeHash: "compose_hash2", + }, + Replicas: 1, + }, + }, }, }, { @@ -176,43 +306,92 @@ func Test_getLatestServiceState(t *testing.T) { serviceStatus: map[Service]ServiceStatus{ "svc1": { Labels: Labels{ - DocoCDLabels.Repository.Name: "", - DocoCDLabels.Deployment.Timestamp: "2006-01-02T15:04:05Z07:00", + DocoCDLabels.Repository.Name: "", + DocoCDLabels.Deployment.Timestamp: "2006-01-02T15:04:05Z07:00", + DocoCDLabels.Deployment.CommitSHA: "commit_sha1", + DocoCDLabels.Deployment.ComposeHash: "compose_hash1", }, Replicas: 1, }, "svc2": { Labels: Labels{ - DocoCDLabels.Repository.Name: "", - DocoCDLabels.Deployment.Timestamp: "2016-01-02T15:04:05Z07:00", + DocoCDLabels.Repository.Name: "", + DocoCDLabels.Deployment.Timestamp: "2016-01-02T15:04:05Z07:00", + DocoCDLabels.Deployment.CommitSHA: "commit_sha2", + DocoCDLabels.Deployment.ComposeHash: "compose_hash2", }, Replicas: 2, }, }, repoName: "", want: LatestServiceStatus{ - Labels: Labels{ - DocoCDLabels.Repository.Name: "", - DocoCDLabels.Deployment.Timestamp: "2016-01-02T15:04:05Z07:00", - }, + deploymentCommitSHA: "commit_sha2", + deploymentComposeHash: "compose_hash2", DeployedStatus: map[Service]ServiceStatus{ "svc2": { Labels: Labels{ - DocoCDLabels.Repository.Name: "", - DocoCDLabels.Deployment.Timestamp: "2016-01-02T15:04:05Z07:00", + DocoCDLabels.Repository.Name: "", + DocoCDLabels.Deployment.Timestamp: "2016-01-02T15:04:05Z07:00", + DocoCDLabels.Deployment.CommitSHA: "commit_sha2", + DocoCDLabels.Deployment.ComposeHash: "compose_hash2", }, Replicas: 2, }, "svc1": { Labels: Labels{ - DocoCDLabels.Repository.Name: "", - DocoCDLabels.Deployment.Timestamp: "2006-01-02T15:04:05Z07:00", + DocoCDLabels.Repository.Name: "", + DocoCDLabels.Deployment.Timestamp: "2006-01-02T15:04:05Z07:00", + DocoCDLabels.Deployment.CommitSHA: "commit_sha1", + DocoCDLabels.Deployment.ComposeHash: "compose_hash1", }, Replicas: 1, }, }, }, }, + { + // Regression test: containers that have no cd.doco.* labels at all (e.g. recreated + // via the Docker CLI directly or started before doco-cd first deployed the stack) + // must still appear in DeployedStatus so CheckServiceMismatch does not produce a + // false "service not deployed" mismatch and trigger an infinite redeployment loop. + name: "some services missing cd.doco.* labels entirely", + serviceStatus: map[Service]ServiceStatus{ + "labeled": { + Labels: Labels{ + DocoCDLabels.Repository.Name: "repo", + DocoCDLabels.Deployment.Timestamp: "2026-01-01T00:00:00Z", + DocoCDLabels.Deployment.CommitSHA: "commit_sha", + DocoCDLabels.Deployment.ComposeHash: "compose_hash", + }, + Replicas: 1, + }, + "unlabeled": { + // No cd.doco.* labels — simulates a container recreated outside doco-cd. + Labels: Labels{}, + Replicas: 1, + }, + }, + repoName: "repo", + want: LatestServiceStatus{ + deploymentCommitSHA: "commit_sha", + deploymentComposeHash: "compose_hash", + DeployedStatus: map[Service]ServiceStatus{ + "labeled": { + Labels: Labels{ + DocoCDLabels.Repository.Name: "repo", + DocoCDLabels.Deployment.Timestamp: "2026-01-01T00:00:00Z", + DocoCDLabels.Deployment.CommitSHA: "commit_sha", + DocoCDLabels.Deployment.ComposeHash: "compose_hash", + }, + Replicas: 1, + }, + "unlabeled": { + Labels: Labels{}, + Replicas: 1, + }, + }, + }, + }, { name: "two service with timestamp", serviceStatus: map[Service]ServiceStatus{ @@ -225,18 +404,18 @@ func Test_getLatestServiceState(t *testing.T) { }, "svc2": { Labels: Labels{ - DocoCDLabels.Repository.Name: "repo", - DocoCDLabels.Deployment.Timestamp: "2016-01-02T15:04:05Z07:00", + DocoCDLabels.Repository.Name: "repo", + DocoCDLabels.Deployment.Timestamp: "2016-01-02T15:04:05Z07:00", + DocoCDLabels.Deployment.CommitSHA: "commit_sha2", + DocoCDLabels.Deployment.ComposeHash: "compose_hash2", }, Replicas: 1, }, }, repoName: "repo", want: LatestServiceStatus{ - Labels: Labels{ - DocoCDLabels.Repository.Name: "repo", - DocoCDLabels.Deployment.Timestamp: "2016-01-02T15:04:05Z07:00", - }, + deploymentCommitSHA: "commit_sha2", + deploymentComposeHash: "compose_hash2", DeployedStatus: map[Service]ServiceStatus{ "svc1": { Labels: Labels{ @@ -247,8 +426,10 @@ func Test_getLatestServiceState(t *testing.T) { }, "svc2": { Labels: Labels{ - DocoCDLabels.Repository.Name: "repo", - DocoCDLabels.Deployment.Timestamp: "2016-01-02T15:04:05Z07:00", + DocoCDLabels.Repository.Name: "repo", + DocoCDLabels.Deployment.Timestamp: "2016-01-02T15:04:05Z07:00", + DocoCDLabels.Deployment.CommitSHA: "commit_sha2", + DocoCDLabels.Deployment.ComposeHash: "compose_hash2", }, Replicas: 1, }, @@ -258,7 +439,12 @@ func Test_getLatestServiceState(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := getLatestServiceStatus(tt.serviceStatus, tt.repoName) + cache := &sync.Map{} + if tt.cache != nil { + cache = tt.cache + } + + got := getLatestServiceStatus(cache, tt.serviceStatus, tt.repoName, tt.deployName) if !reflect.DeepEqual(got, tt.want) { t.Errorf("GetLatestServiceState() = %v, want %v", got, tt.want) @@ -425,7 +611,7 @@ services: repoName := "repoName" - deployCfg := &config.DeployConfig{ + deployCfg := &deploy.Config{ Name: stackName, RemoveOrphans: true, } diff --git a/doco-cd-src/internal/docker/state.go b/doco-cd-src/internal/docker/state.go new file mode 100644 index 0000000..66f2b5a --- /dev/null +++ b/doco-cd-src/internal/docker/state.go @@ -0,0 +1,40 @@ +package docker + +import ( + "strings" + "sync" +) + +type deployStatus struct { + ComposeHash string + CommitSHA string +} + +// for case when some services(not all) are removed, the compose_hash, commit_sha will change, +// but docker compose will not recreate the remaining services(compose_hash, commit_sha), +// the next time when we compare the deployment status, we will get the old compose_hash and commit_sha. +// +// so we need to the cache the deployment status after successful deployment +// https://github.com/kimdre/doco-cd/issues/1262 + +// map[repoName:deployName]deployStatus +// repoName should use git.RepoName() function to get host/owner/repo, e.g., github.com/kimdre/doco-cd. +var deployStatusCache sync.Map + +func getDeployStatusCacheKey(repoName string, deployName string) string { + return strings.Join([]string{repoName, deployName}, ":") +} + +func getDeployStatusFromCache(cacheMap *sync.Map, repoName string, deployName string) (deployStatus, bool) { + if value, ok := cacheMap.Load(getDeployStatusCacheKey(repoName, deployName)); ok { + if status, valid := value.(deployStatus); valid { + return status, true + } + } + + return deployStatus{}, false +} + +func setDeployStatusToCache(repoName string, deployName string, status deployStatus) { + deployStatusCache.Store(getDeployStatusCacheKey(repoName, deployName), status) +} diff --git a/doco-cd-src/internal/docker/swarm.go b/doco-cd-src/internal/docker/swarm.go index 935d443..2372b5b 100644 --- a/doco-cd-src/internal/docker/swarm.go +++ b/doco-cd-src/internal/docker/swarm.go @@ -18,6 +18,9 @@ import ( swarmTypes "github.com/moby/moby/api/types/swarm" dockerClient "github.com/moby/moby/client" + "github.com/kimdre/doco-cd/internal/config/app" + "github.com/kimdre/doco-cd/internal/config/deploy" + swarmInternal "github.com/kimdre/doco-cd/internal/docker/swarm" "github.com/compose-spec/compose-go/v2/types" @@ -27,8 +30,6 @@ import ( "github.com/kimdre/doco-cd/internal/docker/options" "github.com/kimdre/doco-cd/internal/webhook" - - "github.com/kimdre/doco-cd/internal/config" ) var ( @@ -38,7 +39,7 @@ var ( // LoadSwarmStack loads a Docker Swarm stack using the provided project and deploy configuration. func LoadSwarmStack(dockerCli command.Cli, project *types.Project, - deployConfig *config.DeployConfig, externalWorkingDir string, + deployConfig *deploy.Config, externalWorkingDir string, ) (*composetypes.Config, *options.Deploy, error) { opts := options.Deploy{ Composefiles: project.ComposeFiles, @@ -82,24 +83,32 @@ func RemoveSwarmStack(ctx context.Context, dockerCli command.Cli, namespace stri } // addSwarmServiceLabels adds custom labels to the service containers in a Docker Swarm stack. -func addSwarmServiceLabels(stack *composetypes.Config, deployConfig *config.DeployConfig, payload *webhook.ParsedPayload, +func addSwarmServiceLabels(stack *composetypes.Config, deployConfig *deploy.Config, payload *webhook.ParsedPayload, repoDir, appVersion, timestamp, latestCommit, projectHash string, ) { customLabels := map[string]string{ - DocoCDLabels.Metadata.Manager: config.AppName, - DocoCDLabels.Metadata.Version: appVersion, - DocoCDLabels.Deployment.Name: deployConfig.Name, - DocoCDLabels.Deployment.Timestamp: timestamp, - DocoCDLabels.Deployment.ComposeHash: projectHash, - DocoCDLabels.Deployment.WorkingDir: repoDir, - DocoCDLabels.Deployment.Trigger: payload.CommitSHA, - DocoCDLabels.Deployment.CommitSHA: latestCommit, - DocoCDLabels.Deployment.TargetRef: deployConfig.Reference, - DocoCDLabels.Deployment.ConfigHash: deployConfig.Internal.Hash, - DocoCDLabels.Deployment.AutoDiscover: strconv.FormatBool(deployConfig.AutoDiscover), - DocoCDLabels.Deployment.AutoDiscoverDelete: strconv.FormatBool(deployConfig.AutoDiscoverOpts.Delete), - DocoCDLabels.Repository.Name: payload.FullName, - DocoCDLabels.Repository.URL: payload.WebURL, + DocoCDLabels.Metadata.Manager: app.Name, + DocoCDLabels.Metadata.Version: appVersion, + DocoCDLabels.Deployment.Name: deployConfig.Name, + DocoCDLabels.Deployment.Timestamp: timestamp, + DocoCDLabels.Deployment.ComposeHash: projectHash, + DocoCDLabels.Deployment.WorkingDir: repoDir, + DocoCDLabels.Deployment.Trigger: payload.CommitSHA, + DocoCDLabels.Deployment.CommitSHA: latestCommit, + DocoCDLabels.Deployment.TargetRef: deployConfig.Reference, + DocoCDLabels.Deployment.ConfigHash: deployConfig.Internal.Hash, + DocoCDLabels.Deployment.AutoDiscovery: strconv.FormatBool(deployConfig.AutoDiscovery.Enabled), + DocoCDLabels.Deployment.AutoDiscoveryDelete: strconv.FormatBool(deployConfig.AutoDiscovery.Delete), + DocoCDLabels.Repository.Name: payload.FullName, + DocoCDLabels.Repository.URL: payload.WebURL, + } + + // Service-level labels (ServiceSpec.Annotations.Labels) are required for Docker + // service events to be filterable by label. These are set via Deploy.Labels. + serviceLevelLabels := map[string]string{ + DocoCDLabels.Metadata.Manager: app.Name, + DocoCDLabels.Deployment.Name: deployConfig.Name, + DocoCDLabels.Repository.Name: payload.FullName, } for i, s := range stack.Services { @@ -109,16 +118,22 @@ func addSwarmServiceLabels(stack *composetypes.Config, deployConfig *config.Depl maps.Copy(s.Labels, customLabels) + if s.Deploy.Labels == nil { + s.Deploy.Labels = make(map[string]string) + } + + maps.Copy(s.Deploy.Labels, serviceLevelLabels) + stack.Services[i] = s } } // addSwarmVolumeLabels adds custom labels to the volumes in a Docker Swarm stack. -func addSwarmVolumeLabels(stack *composetypes.Config, deployConfig *config.DeployConfig, payload *webhook.ParsedPayload, +func addSwarmVolumeLabels(stack *composetypes.Config, deployConfig *deploy.Config, payload *webhook.ParsedPayload, repoDir, appVersion, timestamp, latestCommit string, ) { customLabels := map[string]string{ - DocoCDLabels.Metadata.Manager: config.AppName, + DocoCDLabels.Metadata.Manager: app.Name, DocoCDLabels.Metadata.Version: appVersion, DocoCDLabels.Deployment.Name: deployConfig.Name, DocoCDLabels.Deployment.Timestamp: timestamp, @@ -142,11 +157,11 @@ func addSwarmVolumeLabels(stack *composetypes.Config, deployConfig *config.Deplo } // addSwarmConfigLabels adds custom labels to the configs in a Docker Swarm stack. -func addSwarmConfigLabels(stack *composetypes.Config, deployConfig *config.DeployConfig, payload *webhook.ParsedPayload, +func addSwarmConfigLabels(stack *composetypes.Config, deployConfig *deploy.Config, payload *webhook.ParsedPayload, repoDir, appVersion, timestamp, latestCommit string, ) { customLabels := map[string]string{ - DocoCDLabels.Metadata.Manager: config.AppName, + DocoCDLabels.Metadata.Manager: app.Name, DocoCDLabels.Metadata.Version: appVersion, DocoCDLabels.Deployment.Name: deployConfig.Name, DocoCDLabels.Deployment.Timestamp: timestamp, @@ -169,11 +184,11 @@ func addSwarmConfigLabels(stack *composetypes.Config, deployConfig *config.Deplo } } -func addSwarmSecretLabels(stack *composetypes.Config, deployConfig *config.DeployConfig, payload *webhook.ParsedPayload, +func addSwarmSecretLabels(stack *composetypes.Config, deployConfig *deploy.Config, payload *webhook.ParsedPayload, repoDir, appVersion, timestamp, latestCommit string, ) { customLabels := map[string]string{ - DocoCDLabels.Metadata.Manager: config.AppName, + DocoCDLabels.Metadata.Manager: app.Name, DocoCDLabels.Metadata.Version: appVersion, DocoCDLabels.Deployment.Name: deployConfig.Name, DocoCDLabels.Deployment.Timestamp: timestamp, diff --git a/doco-cd-src/internal/docker/swarm/deploy_composefile.go b/doco-cd-src/internal/docker/swarm/deploy_composefile.go index d0f9fe4..8061eb5 100644 --- a/doco-cd-src/internal/docker/swarm/deploy_composefile.go +++ b/doco-cd-src/internal/docker/swarm/deploy_composefile.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "strconv" + "strings" "github.com/containerd/errdefs" "github.com/docker/cli/cli/command" @@ -89,7 +91,18 @@ func deployCompose(ctx context.Context, dockerCli command.Cli, opts *options.Dep return nil } - return WaitOnServices(ctx, dockerCli, serviceIDs) + // Exclude job-mode and scheduler-managed services from the wait. + // Job-mode services converge only after completions, and scheduler-managed + // services are intentionally allowed to be non-running at deploy time. + waitIDs := make([]string, 0, len(serviceIDs)) + + for _, entry := range serviceIDs { + if shouldWaitForService(entry) { + waitIDs = append(waitIDs, entry.id) + } + } + + return WaitOnServices(ctx, dockerCli, waitIDs) } func getServicesDeclaredNetworks(serviceConfigs []composetypes.ServiceConfig) set.Set[string] { @@ -218,7 +231,15 @@ func createNetworks(ctx context.Context, dockerCLI command.Cli, namespace conver return nil } -func deployServices(ctx context.Context, dockerCLI command.Cli, services map[string]swarmTypes.ServiceSpec, namespace convert.Namespace, sendAuth bool, resolveImage string) ([]string, error) { +type deployedService struct { + id string + isJobMode bool + isScheduled bool +} + +const scheduledJobEnabledLabel = "cd.doco.job.enabled" + +func deployServices(ctx context.Context, dockerCLI command.Cli, services map[string]swarmTypes.ServiceSpec, namespace convert.Namespace, sendAuth bool, resolveImage string) ([]deployedService, error) { apiClient := dockerCLI.Client() out := dockerCLI.Out() @@ -232,7 +253,7 @@ func deployServices(ctx context.Context, dockerCLI command.Cli, services map[str existingServiceMap[service.Spec.Name] = service } - var serviceIDs []string + var deployed []deployedService for internalName, serviceSpec := range services { var ( @@ -241,6 +262,9 @@ func deployServices(ctx context.Context, dockerCLI command.Cli, services map[str encodedAuth string ) + isJob := serviceSpec.Mode.ReplicatedJob != nil || serviceSpec.Mode.GlobalJob != nil + isScheduled := isScheduledServiceSpec(serviceSpec) + if sendAuth { // Retrieve encoded auth token from the image reference encodedAuth, err = command.RetrieveAuthTokenFromImage(dockerCLI.ConfigFile(), image) @@ -298,7 +322,7 @@ func deployServices(ctx context.Context, dockerCLI command.Cli, services map[str _, _ = fmt.Fprintln(dockerCLI.Err(), warning) } - serviceIDs = append(serviceIDs, service.ID) + deployed = append(deployed, deployedService{id: service.ID, isJobMode: isJob, isScheduled: isScheduled}) } else { _, _ = fmt.Fprintln(out, "Creating service", name) @@ -317,11 +341,33 @@ func deployServices(ctx context.Context, dockerCLI command.Cli, services map[str return nil, fmt.Errorf("failed to create service %s: %w", name, err) } - serviceIDs = append(serviceIDs, response.ID) + deployed = append(deployed, deployedService{id: response.ID, isJobMode: isJob, isScheduled: isScheduled}) } } - return serviceIDs, nil + return deployed, nil +} + +func shouldWaitForService(svc deployedService) bool { + return !svc.isJobMode && !svc.isScheduled +} + +func isScheduledServiceSpec(spec swarmTypes.ServiceSpec) bool { + if spec.TaskTemplate.ContainerSpec == nil || spec.TaskTemplate.ContainerSpec.Labels == nil { + return false + } + + raw, ok := spec.TaskTemplate.ContainerSpec.Labels[scheduledJobEnabledLabel] + if !ok { + return false + } + + enabled, err := strconv.ParseBool(strings.TrimSpace(raw)) + if err != nil { + return false + } + + return enabled } // WaitOnServices waits for the specified Swarm services to complete. diff --git a/doco-cd-src/internal/docker/swarm/deploy_wait_test.go b/doco-cd-src/internal/docker/swarm/deploy_wait_test.go new file mode 100644 index 0000000..7f18270 --- /dev/null +++ b/doco-cd-src/internal/docker/swarm/deploy_wait_test.go @@ -0,0 +1,113 @@ +package swarm + +import ( + "testing" + + swarmTypes "github.com/moby/moby/api/types/swarm" +) + +func TestShouldWaitForService(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + svc deployedService + want bool + }{ + { + name: "wait regular service", + svc: deployedService{ + id: "svc-1", + }, + want: true, + }, + { + name: "skip job mode service", + svc: deployedService{ + id: "svc-2", + isJobMode: true, + }, + want: false, + }, + { + name: "skip scheduled service", + svc: deployedService{ + id: "svc-3", + isScheduled: true, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := shouldWaitForService(tt.svc); got != tt.want { + t.Fatalf("shouldWaitForService() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsScheduledServiceSpec(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec swarmTypes.ServiceSpec + want bool + }{ + { + name: "missing container spec", + spec: swarmTypes.ServiceSpec{}, + want: false, + }, + { + name: "missing label", + spec: swarmTypes.ServiceSpec{ + TaskTemplate: swarmTypes.TaskSpec{ + ContainerSpec: &swarmTypes.ContainerSpec{Labels: map[string]string{}}, + }, + }, + want: false, + }, + { + name: "enabled true", + spec: swarmTypes.ServiceSpec{ + TaskTemplate: swarmTypes.TaskSpec{ + ContainerSpec: &swarmTypes.ContainerSpec{Labels: map[string]string{scheduledJobEnabledLabel: "true"}}, + }, + }, + want: true, + }, + { + name: "enabled false", + spec: swarmTypes.ServiceSpec{ + TaskTemplate: swarmTypes.TaskSpec{ + ContainerSpec: &swarmTypes.ContainerSpec{Labels: map[string]string{scheduledJobEnabledLabel: "false"}}, + }, + }, + want: false, + }, + { + name: "invalid bool", + spec: swarmTypes.ServiceSpec{ + TaskTemplate: swarmTypes.TaskSpec{ + ContainerSpec: &swarmTypes.ContainerSpec{Labels: map[string]string{scheduledJobEnabledLabel: "yup"}}, + }, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := isScheduledServiceSpec(tt.spec); got != tt.want { + t.Fatalf("isScheduledServiceSpec() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/doco-cd-src/internal/docker/swarm/service.go b/doco-cd-src/internal/docker/swarm/service.go index 140ee85..dd6297d 100644 --- a/doco-cd-src/internal/docker/swarm/service.go +++ b/doco-cd-src/internal/docker/swarm/service.go @@ -19,9 +19,9 @@ import ( type Service struct { ID string swarm.Meta - Spec swarm.ServiceSpec `json:",omitempty"` - PreviousSpec *swarm.ServiceSpec `json:",omitempty"` - Endpoint swarm.Endpoint `json:",omitempty"` + Spec swarm.ServiceSpec + PreviousSpec *swarm.ServiceSpec `json:",omitempty"` + Endpoint swarm.Endpoint UpdateStatus *swarm.UpdateStatus `json:",omitempty"` // ServiceStatus is an optional, extra field indicating the number of diff --git a/doco-cd-src/internal/docker/swarm_job.go b/doco-cd-src/internal/docker/swarm_job.go index cc2704e..34ed3d6 100644 --- a/doco-cd-src/internal/docker/swarm_job.go +++ b/doco-cd-src/internal/docker/swarm_job.go @@ -12,11 +12,12 @@ import ( "github.com/avast/retry-go/v5" "github.com/docker/cli/cli/command" + "github.com/kimdre/doco-cd/internal/config/app" + "github.com/moby/moby/api/types/mount" swarmTypes "github.com/moby/moby/api/types/swarm" "github.com/moby/moby/client" - "github.com/kimdre/doco-cd/internal/config" "github.com/kimdre/doco-cd/internal/docker/swarm" ) @@ -56,7 +57,7 @@ func RunSwarmJob(ctx context.Context, dockerCLI command.Cli, mode swarm.DeployMo // fix conflict error // Error response from daemon: rpc error: code = Unknown desc = update out of sequence - name := fmt.Sprintf("%s_%s", config.AppName, title) + name := fmt.Sprintf("%s_%s", app.Name, title) lock := getSwarmJobLock(name) lock.Lock() @@ -66,8 +67,8 @@ func RunSwarmJob(ctx context.Context, dockerCLI command.Cli, mode swarm.DeployMo Annotations: swarmTypes.Annotations{ Name: name, Labels: map[string]string{ - DocoCDLabels.Metadata.Manager: config.AppName, - DocoCDLabels.Metadata.Version: config.AppVersion, + DocoCDLabels.Metadata.Manager: app.Name, + DocoCDLabels.Metadata.Version: app.Version, DocoCDLabels.Deployment.Trigger: title, }, }, @@ -190,3 +191,84 @@ func RunImageRemoveJob(ctx context.Context, dockerCLI command.Cli, images []stri args := append([]string{"docker", "image", "rm", "--force"}, images...) return RunSwarmJob(ctx, dockerCLI, swarm.DeployModeGlobalJob, args, "image-remove") } + +type SwarmOneOffFromServiceOptions struct { + Replicas uint64 + SendRegistryAuth bool +} + +// RunSwarmOneOffFromService creates a temporary job service from an existing service spec and waits for completion. +func RunSwarmOneOffFromService(ctx context.Context, dockerCLI command.Cli, serviceName string, opts SwarmOneOffFromServiceOptions) error { + apiClient := dockerCLI.Client() + + if opts.Replicas == 0 { + opts.Replicas = 1 + } + + inspectResult, err := apiClient.ServiceInspect(ctx, serviceName, client.ServiceInspectOptions{}) + if err != nil { + return fmt.Errorf("inspect service %s: %w", serviceName, err) + } + + sourceService := inspectResult.Service + oneOffSpec := sourceService.Spec + oneOffSpec.Name = fmt.Sprintf("%s-doco-job-%d", sourceService.Spec.Name, time.Now().UTC().UnixNano()) + + if oneOffSpec.TaskTemplate.ContainerSpec == nil { + return fmt.Errorf("service %s has no task container spec", serviceName) + } + + if oneOffSpec.Labels == nil { + oneOffSpec.Labels = map[string]string{} + } + + oneOffSpec.Labels[DocoCDLabels.Metadata.Manager] = app.Name + oneOffSpec.Labels[DocoCDLabels.Deployment.Trigger] = "job.schedule" + + if sourceService.Spec.Mode.Global != nil || sourceService.Spec.Mode.GlobalJob != nil { + oneOffSpec.Mode = swarmTypes.ServiceMode{ + GlobalJob: &swarmTypes.GlobalJob{}, + } + } else { + oneOffSpec.Mode = swarmTypes.ServiceMode{ + ReplicatedJob: &swarmTypes.ReplicatedJob{ + TotalCompletions: &opts.Replicas, + MaxConcurrent: &opts.Replicas, + }, + } + } + + oneOffSpec.UpdateConfig = nil + oneOffSpec.RollbackConfig = nil + oneOffSpec.TaskTemplate.RestartPolicy = &swarmTypes.RestartPolicy{ + Condition: swarmTypes.RestartPolicyConditionNone, + } + + createOpts := client.ServiceCreateOptions{ + Spec: oneOffSpec, + } + + if opts.SendRegistryAuth { + encodedAuth, authErr := command.RetrieveAuthTokenFromImage(dockerCLI.ConfigFile(), oneOffSpec.TaskTemplate.ContainerSpec.Image) + if authErr != nil { + return fmt.Errorf("retrieve auth token from image: %w", authErr) + } + + createOpts.EncodedRegistryAuth = encodedAuth + } + + createResult, err := apiClient.ServiceCreate(ctx, createOpts) + if err != nil { + return fmt.Errorf("create one-off service from %s: %w", serviceName, err) + } + + defer func() { + _, _ = apiClient.ServiceRemove(context.WithoutCancel(ctx), createResult.ID, client.ServiceRemoveOptions{}) + }() + + if err = swarm.WaitOnServices(ctx, dockerCLI, []string{createResult.ID}); err != nil { + return fmt.Errorf("wait one-off service %s: %w", createResult.ID, err) + } + + return nil +} diff --git a/doco-cd-src/internal/docker/swarm_test.go b/doco-cd-src/internal/docker/swarm_test.go index c438993..7382dd3 100644 --- a/doco-cd-src/internal/docker/swarm_test.go +++ b/doco-cd-src/internal/docker/swarm_test.go @@ -8,6 +8,9 @@ import ( "github.com/avast/retry-go/v5" + "github.com/kimdre/doco-cd/internal/config/app" + "github.com/kimdre/doco-cd/internal/config/deploy" + "github.com/kimdre/doco-cd/internal/encryption" "github.com/kimdre/doco-cd/internal/test" @@ -15,7 +18,6 @@ import ( "github.com/kimdre/doco-cd/internal/git" - "github.com/kimdre/doco-cd/internal/config" "github.com/kimdre/doco-cd/internal/webhook" ) @@ -39,7 +41,7 @@ func TestDeploySwarmStack(t *testing.T) { tmpDir := t.TempDir() - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatalf("Failed to get app config: %v", err) } @@ -73,7 +75,7 @@ func TestDeploySwarmStack(t *testing.T) { t.Fatal(err) } - deployConfigs, err := config.GetDeployConfigs(tmpDir, c.DeployConfigBaseDir, stackName, customTarget, p.Ref) + deployConfigs, err := deploy.GetConfigs(tmpDir, c.DeployConfigBaseDir, stackName, customTarget, p.Ref, nil) if err != nil { t.Fatal(err) } diff --git a/doco-cd-src/internal/docker/utils.go b/doco-cd-src/internal/docker/utils.go index 5f42c44..aadca4e 100644 --- a/doco-cd-src/internal/docker/utils.go +++ b/doco-cd-src/internal/docker/utils.go @@ -54,11 +54,11 @@ func (l Labels) Get(key string) (string, bool) { return v, ok } -func (l Labels) GetDeploymentCommitSHA() (string, bool) { +func (l Labels) getDeploymentCommitSHA() (string, bool) { return l.Get(DocoCDLabels.Deployment.CommitSHA) } -func (l Labels) GetDeploymentComposeHash() (string, bool) { +func (l Labels) getDeploymentComposeHash() (string, bool) { return l.Get(DocoCDLabels.Deployment.ComposeHash) } diff --git a/doco-cd-src/internal/git/auth.go b/doco-cd-src/internal/git/auth.go index 29d7c81..d81ef97 100644 --- a/doco-cd-src/internal/git/auth.go +++ b/doco-cd-src/internal/git/auth.go @@ -2,21 +2,310 @@ package git import ( "fmt" + "net/url" "strings" + "sync" "github.com/go-git/go-git/v5/plumbing/transport" githttp "github.com/go-git/go-git/v5/plumbing/transport/http" gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh" + "github.com/kimdre/doco-cd/internal/git/githubapp" "github.com/kimdre/doco-cd/internal/git/ssh" ) +// ScopedAuthConfig maps credentials to one or more Git host/domain patterns. +// Patterns support exact hosts (e.g. github.com) and wildcard subdomains (e.g. *.example.com). +type ScopedAuthConfig struct { + Domains []string `yaml:"domains"` + GitAccessToken string `yaml:"git_access_token"` + SSHPrivateKey string `yaml:"ssh_private_key"` + SSHPrivateKeyPassphrase string `yaml:"ssh_private_key_passphrase"` + GitHubAppID string `yaml:"github_app_id"` + GitHubAppPrivateKey string `yaml:"github_app_private_key"` + GitHubAppInstallationID int64 `yaml:"github_app_installation_id"` +} + +// GitHubAppConfig contains credentials used to mint short-lived GitHub App installation tokens. +type GitHubAppConfig struct { + ID string + PrivateKey string + InstallationID int64 +} + +// ResolvedAuthConfig contains the final credentials selected for a given repository URL. +type ResolvedAuthConfig struct { + SSHPrivateKey string + SSHPrivateKeyPassphrase string + GitAccessToken string + GitHubApp GitHubAppConfig +} + +type authResolver struct { + scoped []ScopedAuthConfig + globalPrivateKey string + globalKeyPassphrase string + globalToken string + globalGitHubApp GitHubAppConfig +} + +var ( + authResolverMu sync.RWMutex + configuredResolver = authResolver{} +) + +// ConfigureAuthResolver configures domain-scoped and global Git credentials. +// This should be called when application config is loaded or updated. +func ConfigureAuthResolver(scoped []ScopedAuthConfig, + globalPrivateKey, globalKeyPassphrase, globalToken string, + globalGitHubApp GitHubAppConfig, +) { + authResolverMu.Lock() + defer authResolverMu.Unlock() + + configuredResolver = authResolver{ + scoped: append([]ScopedAuthConfig(nil), scoped...), + globalPrivateKey: globalPrivateKey, + globalKeyPassphrase: globalKeyPassphrase, + globalToken: globalToken, + globalGitHubApp: GitHubAppConfig{ + ID: strings.TrimSpace(globalGitHubApp.ID), + PrivateKey: strings.TrimSpace(globalGitHubApp.PrivateKey), + InstallationID: globalGitHubApp.InstallationID, + }, + } +} + +var githubAppTokenProvider = resolveGitHubAppInstallationToken + +func resolveGitHubAppInstallationToken(repoURL string, cfg GitHubAppConfig) (string, error) { + return githubapp.ResolveInstallationToken(repoURL, githubapp.Config{ + ID: cfg.ID, + PrivateKey: cfg.PrivateKey, + InstallationID: cfg.InstallationID, + }) +} + +// swapGitHubAppTokenProviderForTest replaces the GitHub App token provider and returns a restore function. +func swapGitHubAppTokenProviderForTest(provider func(string, GitHubAppConfig) (string, error)) func() { + authResolverMu.Lock() + old := githubAppTokenProvider + githubAppTokenProvider = provider + authResolverMu.Unlock() + + return func() { + authResolverMu.Lock() + githubAppTokenProvider = old + authResolverMu.Unlock() + } +} + +// ResolveAuthConfig resolves credentials for a repository URL using exact domain matches, +// then the most specific wildcard suffix, and finally global fallback credentials. +func ResolveAuthConfig(url, privateKey, keyPassphrase, token string) ResolvedAuthConfig { + authResolverMu.RLock() + + resolver := configuredResolver + + authResolverMu.RUnlock() + + host := parseGitHost(url) + if host == "" { + return ResolvedAuthConfig{ + SSHPrivateKey: privateKey, + SSHPrivateKeyPassphrase: keyPassphrase, + GitAccessToken: token, + } + } + + resolvedGlobalPrivateKey := strings.TrimSpace(resolver.globalPrivateKey) + resolvedGlobalPassphrase := resolver.globalKeyPassphrase + resolvedGlobalToken := strings.TrimSpace(resolver.globalToken) + resolvedGlobalGitHubApp := resolver.globalGitHubApp + + if resolvedGlobalPrivateKey != "" && strings.TrimSpace(privateKey) == "" { + privateKey = resolvedGlobalPrivateKey + keyPassphrase = resolvedGlobalPassphrase + } + + if resolvedGlobalToken != "" && strings.TrimSpace(token) == "" { + token = resolvedGlobalToken + } + + resolved := ResolvedAuthConfig{ + SSHPrivateKey: privateKey, + SSHPrivateKeyPassphrase: keyPassphrase, + GitAccessToken: token, + GitHubApp: resolvedGlobalGitHubApp, + } + + if len(resolver.scoped) == 0 { + return resolved + } + + // Exact domain matches always win. + for _, entry := range resolver.scoped { + for _, domain := range entry.Domains { + if normalizeHost(domain) == host { + return pickCredentials(entry, resolver, resolved) + } + } + } + + // Then choose the wildcard with the longest suffix (most specific). + bestIdx := -1 + bestSuffixLen := -1 + + for i, entry := range resolver.scoped { + for _, domain := range entry.Domains { + suffix, ok := wildcardSuffix(domain) + if !ok { + continue + } + + if wildcardMatches(host, suffix) && len(suffix) > bestSuffixLen { + bestIdx = i + bestSuffixLen = len(suffix) + } + } + } + + if bestIdx >= 0 { + return pickCredentials(resolver.scoped[bestIdx], resolver, resolved) + } + + return resolved +} + +// ResolveScopedCredentials resolves credentials for a repository URL using exact domain matches, +// then the most specific wildcard suffix, and finally global fallback credentials. +func ResolveScopedCredentials(url, privateKey, keyPassphrase, token string) (string, string, string) { + resolved := ResolveAuthConfig(url, privateKey, keyPassphrase, token) + + return resolved.SSHPrivateKey, resolved.SSHPrivateKeyPassphrase, resolved.GitAccessToken +} + +func pickCredentials(entry ScopedAuthConfig, resolver authResolver, base ResolvedAuthConfig) ResolvedAuthConfig { + resolvedPrivateKey := strings.TrimSpace(entry.SSHPrivateKey) + resolvedPassphrase := entry.SSHPrivateKeyPassphrase + resolvedToken := strings.TrimSpace(entry.GitAccessToken) + resolvedGitHubApp := GitHubAppConfig{ + ID: strings.TrimSpace(entry.GitHubAppID), + PrivateKey: strings.TrimSpace(entry.GitHubAppPrivateKey), + InstallationID: entry.GitHubAppInstallationID, + } + + if resolvedPrivateKey == "" { + resolvedPrivateKey = strings.TrimSpace(resolver.globalPrivateKey) + resolvedPassphrase = resolver.globalKeyPassphrase + } + + if resolvedToken == "" { + resolvedToken = strings.TrimSpace(resolver.globalToken) + } + + if resolvedGitHubApp.ID == "" || resolvedGitHubApp.PrivateKey == "" { + resolvedGitHubApp = resolver.globalGitHubApp + } + + if resolvedPrivateKey == "" { + resolvedPrivateKey = base.SSHPrivateKey + resolvedPassphrase = base.SSHPrivateKeyPassphrase + } + + if resolvedToken == "" { + resolvedToken = base.GitAccessToken + } + + if resolvedGitHubApp.ID == "" || resolvedGitHubApp.PrivateKey == "" { + resolvedGitHubApp = base.GitHubApp + } + + return ResolvedAuthConfig{ + SSHPrivateKey: resolvedPrivateKey, + SSHPrivateKeyPassphrase: resolvedPassphrase, + GitAccessToken: resolvedToken, + GitHubApp: resolvedGitHubApp, + } +} + +func normalizeHost(host string) string { + host = strings.TrimSpace(strings.ToLower(host)) + + return strings.TrimSuffix(host, ".") +} + +func parseGitHost(rawURL string) string { + u := strings.TrimSpace(rawURL) + if u == "" { + return "" + } + + if strings.Contains(u, "@") && strings.Contains(u, ":") && !strings.Contains(u, "://") { + parts := strings.SplitN(u, "@", 2) + if len(parts) != 2 { + return "" + } + + hostPath := strings.SplitN(parts[1], ":", 2) + if len(hostPath) != 2 { + return "" + } + + return normalizeHost(hostPath[0]) + } + + parsed, err := url.Parse(u) + if err != nil { + return "" + } + + if parsed.Host == "" { + return "" + } + + return normalizeHost(parsed.Hostname()) +} + +func wildcardSuffix(domain string) (string, bool) { + d := normalizeHost(domain) + + after, ok := strings.CutPrefix(d, "*.") + if !ok || after == "" { + return "", false + } + + return after, true +} + +func wildcardMatches(host, suffix string) bool { + // Wildcards for subdomains must not match the apex domain. + if host == suffix { + return false + } + + return strings.HasSuffix(host, "."+suffix) +} + // GetAuthMethod determines the appropriate authentication method based on the URL and provided credentials. func GetAuthMethod(url, privateKey, keyPassphrase, token string) (transport.AuthMethod, error) { + resolved := ResolveAuthConfig(url, privateKey, keyPassphrase, token) + if IsSSH(url) { - return SSHAuth(privateKey, keyPassphrase) - } else if token != "" { - return HttpTokenAuth(token), nil + return SSHAuth(resolved.SSHPrivateKey, resolved.SSHPrivateKeyPassphrase) + } + + if resolved.GitAccessToken != "" { + return HttpTokenAuth(resolved.GitAccessToken), nil + } + + if resolved.GitHubApp.ID != "" && resolved.GitHubApp.PrivateKey != "" { + installationToken, err := githubAppTokenProvider(url, resolved.GitHubApp) + if err != nil { + return nil, fmt.Errorf("failed to resolve GitHub App installation token: %w", err) + } + + return HttpTokenAuth(installationToken), nil } return nil, nil diff --git a/doco-cd-src/internal/git/auth_test.go b/doco-cd-src/internal/git/auth_test.go new file mode 100644 index 0000000..9124540 --- /dev/null +++ b/doco-cd-src/internal/git/auth_test.go @@ -0,0 +1,166 @@ +package git + +import ( + "testing" +) + +func TestResolveScopedCredentials_ExactBeatsWildcard(t *testing.T) { + ConfigureAuthResolver([]ScopedAuthConfig{ + { + Domains: []string{"*.github.com"}, + GitAccessToken: "wildcard-token", + }, + { + Domains: []string{"api.github.com"}, + GitAccessToken: "exact-token", + }, + }, "", "", "", GitHubAppConfig{}) + t.Cleanup(func() { + ConfigureAuthResolver(nil, "", "", "", GitHubAppConfig{}) + }) + + _, _, token := ResolveScopedCredentials("https://api.github.com/org/repo.git", "", "", "") + if token != "exact-token" { + t.Fatalf("expected exact token to win, got '%s'", token) + } +} + +func TestResolveScopedCredentials_LongestWildcardSuffixWins(t *testing.T) { + ConfigureAuthResolver([]ScopedAuthConfig{ + { + Domains: []string{"*.example.com"}, + GitAccessToken: "broad-token", + }, + { + Domains: []string{"*.foo.example.com"}, + GitAccessToken: "specific-token", + }, + }, "", "", "", GitHubAppConfig{}) + t.Cleanup(func() { + ConfigureAuthResolver(nil, "", "", "", GitHubAppConfig{}) + }) + + _, _, token := ResolveScopedCredentials("https://git.foo.example.com/org/repo.git", "", "", "") + if token != "specific-token" { + t.Fatalf("expected most specific wildcard token, got '%s'", token) + } +} + +func TestResolveScopedCredentials_WildcardDoesNotMatchApex(t *testing.T) { + ConfigureAuthResolver([]ScopedAuthConfig{ + { + Domains: []string{"*.example.com"}, + GitAccessToken: "wildcard-token", + }, + }, "", "", "", GitHubAppConfig{}) + t.Cleanup(func() { + ConfigureAuthResolver(nil, "", "", "", GitHubAppConfig{}) + }) + + _, _, token := ResolveScopedCredentials("https://example.com/org/repo.git", "", "", "") + if token != "" { + t.Fatalf("expected no wildcard match for apex domain, got '%s'", token) + } +} + +func TestResolveScopedCredentials_GlobalFallback(t *testing.T) { + ConfigureAuthResolver(nil, "", "", "global-token", GitHubAppConfig{}) + t.Cleanup(func() { + ConfigureAuthResolver(nil, "", "", "", GitHubAppConfig{}) + }) + + _, _, token := ResolveScopedCredentials("https://gitlab.com/group/repo.git", "", "", "") + if token != "global-token" { + t.Fatalf("expected global fallback token, got '%s'", token) + } +} + +func TestGetAuthMethod_UsesScopedHTTPToken(t *testing.T) { + ConfigureAuthResolver([]ScopedAuthConfig{ + { + Domains: []string{"gitlab.com"}, + GitAccessToken: "scoped-token", + }, + }, "", "", "", GitHubAppConfig{}) + t.Cleanup(func() { + ConfigureAuthResolver(nil, "", "", "", GitHubAppConfig{}) + }) + + auth, err := GetAuthMethod("https://gitlab.com/group/repo.git", "", "", "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if auth == nil { + t.Fatal("expected auth method, got nil") + } + + if auth.Name() != "http-basic-auth" { + t.Fatalf("expected http-basic-auth, got '%s'", auth.Name()) + } +} + +func TestGetAuthMethod_UsesGlobalGitHubAppToken(t *testing.T) { + ConfigureAuthResolver(nil, "", "", "", GitHubAppConfig{ + ID: "12345", + PrivateKey: "test-private-key", + }) + + oldProvider := swapGitHubAppTokenProviderForTest(func(_ string, _ GitHubAppConfig) (string, error) { + return "ghs-install-token", nil + }) + + t.Cleanup(func() { + oldProvider() + ConfigureAuthResolver(nil, "", "", "", GitHubAppConfig{}) + }) + + auth, err := GetAuthMethod("https://github.com/org/repo.git", "", "", "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if auth == nil { + t.Fatal("expected auth method, got nil") + } + + if auth.Name() != "http-basic-auth" { + t.Fatalf("expected http-basic-auth, got '%s'", auth.Name()) + } +} + +func TestGetAuthMethod_UsesScopedGitHubAppToken(t *testing.T) { + ConfigureAuthResolver([]ScopedAuthConfig{ + { + Domains: []string{"github.com"}, + GitHubAppID: "99999", + GitHubAppPrivateKey: "scoped-private-key", + }, + }, "", "", "", GitHubAppConfig{}) + + oldProvider := swapGitHubAppTokenProviderForTest(func(_ string, cfg GitHubAppConfig) (string, error) { + if cfg.ID != "99999" { + t.Fatalf("expected scoped app id 99999, got %s", cfg.ID) + } + + return "ghs-scoped-install-token", nil + }) + + t.Cleanup(func() { + oldProvider() + ConfigureAuthResolver(nil, "", "", "", GitHubAppConfig{}) + }) + + auth, err := GetAuthMethod("https://github.com/org/repo.git", "", "", "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if auth == nil { + t.Fatal("expected auth method, got nil") + } + + if auth.Name() != "http-basic-auth" { + t.Fatalf("expected http-basic-auth, got '%s'", auth.Name()) + } +} diff --git a/doco-cd-src/internal/git/git.go b/doco-cd-src/internal/git/git.go index 3797780..793b18e 100644 --- a/doco-cd-src/internal/git/git.go +++ b/doco-cd-src/internal/git/git.go @@ -608,6 +608,17 @@ func updateSubmodules(repo *git.Repository, auth transport.AuthMethod, depth int Depth: depth, } + if subCfg := submodule.Config(); subCfg != nil && subCfg.URL != "" { + resolvedAuth, err := GetAuthMethod(subCfg.URL, "", "", "") + if err != nil { + return fmt.Errorf("failed to resolve auth method for submodule %s: %w", subCfg.Path, err) + } + + if resolvedAuth != nil { + opts.Auth = resolvedAuth + } + } + err = retrier.Do( func() error { if err = submodule.Update(opts); err != nil { @@ -1036,17 +1047,6 @@ func normalizeOwnerRepo(p string) string { // Trim trailing '.git' p = strings.TrimSuffix(p, ".git") - // Clean path and split - clean := path.Clean(p) - - parts := strings.Split(clean, "/") - if len(parts) < 2 { - // Not enough segments to form owner/repo - return clean // safest fallback; avoids panic - } - - owner := parts[len(parts)-2] - repo := parts[len(parts)-1] - - return owner + "/" + repo + // Clean path + return path.Clean(p) } diff --git a/doco-cd-src/internal/git/git_test.go b/doco-cd-src/internal/git/git_test.go index 7c99e06..ac35e55 100644 --- a/doco-cd-src/internal/git/git_test.go +++ b/doco-cd-src/internal/git/git_test.go @@ -8,9 +8,10 @@ import ( "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" + "github.com/kimdre/doco-cd/internal/config/app" + "github.com/kimdre/doco-cd/internal/git" - "github.com/kimdre/doco-cd/internal/config" "github.com/kimdre/doco-cd/internal/encryption" ) @@ -71,7 +72,7 @@ func TestHttpTokenAuth(t *testing.T) { func TestCloneRepository(t *testing.T) { t.Parallel() - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatalf("Failed to get app config: %v", err) } @@ -177,7 +178,7 @@ func TestCloneRepository(t *testing.T) { func TestCloneRepository_WithSubmodule(t *testing.T) { t.Parallel() - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatalf("Failed to get app config: %v", err) } @@ -332,7 +333,7 @@ func TestUpdateRepository(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatalf("Failed to get app config: %v", err) } @@ -418,7 +419,7 @@ func TestUpdateRepository(t *testing.T) { func TestUpdateRepository_WithSubmodule(t *testing.T) { t.Parallel() - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatalf("Failed to get app config: %v", err) } @@ -552,7 +553,7 @@ func TestGetReferenceSet(t *testing.T) { }, } - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatalf("Failed to get app config: %v", err) } @@ -602,7 +603,7 @@ func TestGetReferenceSet(t *testing.T) { func TestUpdateRepository_KeepUntrackedFiles(t *testing.T) { t.Parallel() - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatalf("Failed to get app config: %v", err) } @@ -673,7 +674,7 @@ func TestUpdateRepository_KeepUntrackedFiles(t *testing.T) { func TestGetLatestCommit(t *testing.T) { t.Parallel() - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatalf("Failed to get app config: %v", err) } @@ -724,7 +725,7 @@ suGsdNHOvMRQWLzq9VJiJUyOG29zayIQ4Q3pZlcoRINpUI9yl4/eFza7P4MEHDVBLF531K X3nAnZomTg2czfus92AmR+3kYDWvBE1WkpieAaRfVTuBtNcB41rOAZMLQ001zhVF2qdb+D +tvLTkrbIyLPEbZOBHuCH+mVgPefYCRXsB9Nw= -----END OPENSSH PRIVATE KEY-----` - encryptedKeyPassphrase = config.AppName + encryptedKeyPassphrase = app.Name unencryptedKey = `-----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW QyNTUxOQAAACCU6Sk58h0kd2bUvHHvyS1JQiLgBf6yKaIbpGlK8TEfVAAAAJgBQMSpAUDE @@ -886,6 +887,18 @@ func TestGetRepoName(t *testing.T) { cloneURL: "https://oauth2:TOKEN@github.com/kimdre/doco-cd_tests.git", // #nosec G101 -- This is a test URL, not a real token expected: "github.com/kimdre/doco-cd_tests", }, + { + cloneURL: "http://git.example.com/infra/alpha/local/netbird-doco.git", + expected: "git.example.com/infra/alpha/local/netbird-doco", + }, + { + cloneURL: "git@gitlab.com:gitlab-org/5-minute-production-app/sandbox/cats.git", + expected: "gitlab.com/gitlab-org/5-minute-production-app/sandbox/cats", + }, + { + cloneURL: "https://gitlab.com/gitlab-org/5-minute-production-app/sandbox/cats.git", + expected: "gitlab.com/gitlab-org/5-minute-production-app/sandbox/cats", + }, } for _, tt := range tests { t.Run(tt.cloneURL, func(t *testing.T) { @@ -900,7 +913,7 @@ func TestGetRepoName(t *testing.T) { func TestCloneRepository_FullClone(t *testing.T) { t.Parallel() - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatalf("Failed to get app config: %v", err) } @@ -965,7 +978,7 @@ func TestCloneRepository_FullClone(t *testing.T) { func TestCloneRepository_ShallowClone(t *testing.T) { t.Parallel() - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatalf("Failed to get app config: %v", err) } @@ -1048,7 +1061,7 @@ func TestCloneRepository_ShallowClone(t *testing.T) { func TestUpdateRepository_ShallowToFullTransition(t *testing.T) { t.Parallel() - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatalf("Failed to get app config: %v", err) } @@ -1150,7 +1163,7 @@ func TestUpdateRepository_ShallowToFullTransition(t *testing.T) { func TestUpdateRepository_FullToShallowTransition(t *testing.T) { t.Parallel() - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatalf("Failed to get app config: %v", err) } diff --git a/doco-cd-src/internal/git/githubapp/resolver.go b/doco-cd-src/internal/git/githubapp/resolver.go new file mode 100644 index 0000000..2e9dca6 --- /dev/null +++ b/doco-cd-src/internal/git/githubapp/resolver.go @@ -0,0 +1,330 @@ +package githubapp + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const tokenRenewalBuffer = 30 * time.Second + +// Config contains credentials used to mint short-lived GitHub App installation tokens. +type Config struct { + ID string + PrivateKey string + InstallationID int64 +} + +var ( + apiHTTPClient = &http.Client{Timeout: 15 * time.Second} + nowFn = time.Now + + tokenCacheMu sync.RWMutex + tokenCache = map[string]cachedToken{} +) + +type cachedToken struct { + Token string + ExpiresAt time.Time +} + +type installationResponse struct { + ID int64 `json:"id"` +} + +type accessTokenResponse struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` +} + +// ResolveInstallationToken mints (or reuses a cached) installation access token for a repository URL. +func ResolveInstallationToken(repoURL string, cfg Config) (string, error) { + appID := strings.TrimSpace(cfg.ID) + + privateKey := strings.TrimSpace(cfg.PrivateKey) + if appID == "" || privateKey == "" { + return "", errors.New("github app id and private key are required") + } + + host := parseGitHost(repoURL) + if host == "" { + return "", errors.New("failed to parse host from repository URL") + } + + owner, repo, err := ownerRepoFromURL(repoURL) + if err != nil { + return "", err + } + + installationID := cfg.InstallationID + if installationID == 0 { + installationID, err = lookupInstallationID(host, owner, repo, appID, privateKey) + if err != nil { + return "", err + } + } + + cacheKey := fmt.Sprintf("%s|%s|%d", host, appID, installationID) + if token, ok := getCachedToken(cacheKey); ok { + return token, nil + } + + tokenResp, err := createInstallationToken(host, installationID, appID, privateKey) + if err != nil { + return "", err + } + + cacheToken(cacheKey, tokenResp.Token, tokenResp.ExpiresAt) + + return tokenResp.Token, nil +} + +func getCachedToken(cacheKey string) (string, bool) { + tokenCacheMu.RLock() + + entry, ok := tokenCache[cacheKey] + + tokenCacheMu.RUnlock() + + if !ok { + return "", false + } + + if nowFn().Add(tokenRenewalBuffer).After(entry.ExpiresAt) { + return "", false + } + + return entry.Token, true +} + +func cacheToken(cacheKey, token string, expiresAt time.Time) { + tokenCacheMu.Lock() + defer tokenCacheMu.Unlock() + + tokenCache[cacheKey] = cachedToken{Token: token, ExpiresAt: expiresAt} +} + +func ownerRepoFromURL(repoURL string) (string, string, error) { + u := strings.TrimSpace(repoURL) + if u == "" { + return "", "", errors.New("failed to parse owner/repo from URL") + } + + if strings.Contains(u, "@") && strings.Contains(u, ":") && !strings.Contains(u, "://") { + parts := strings.SplitN(u, "@", 2) + if len(parts) != 2 { + return "", "", errors.New("failed to parse owner/repo from URL") + } + + hostPath := strings.SplitN(parts[1], ":", 2) + if len(hostPath) != 2 { + return "", "", errors.New("failed to parse owner/repo from URL") + } + + return splitOwnerRepo(hostPath[1]) + } + + parsed, err := url.Parse(u) + if err != nil { + return "", "", errors.New("failed to parse owner/repo from URL") + } + + return splitOwnerRepo(strings.TrimPrefix(parsed.Path, "/")) +} + +func splitOwnerRepo(path string) (string, string, error) { + p := strings.TrimSpace(path) + p = strings.TrimPrefix(p, "/") + p = strings.TrimSuffix(p, "/") + p = strings.TrimSuffix(p, ".git") + + parts := strings.Split(p, "/") + if len(parts) < 2 { + return "", "", errors.New("failed to parse owner/repo from URL") + } + + owner := strings.TrimSpace(parts[0]) + + repo := strings.TrimSpace(parts[1]) + if owner == "" || repo == "" { + return "", "", errors.New("failed to parse owner/repo from URL") + } + + return owner, repo, nil +} + +func lookupInstallationID(host, owner, repo, appID, privateKey string) (int64, error) { + jwtToken, err := createAppJWT(appID, privateKey) + if err != nil { + return 0, err + } + + apiBase := apiBaseURL(host) + endpoint := fmt.Sprintf("%s/repos/%s/%s/installation", apiBase, owner, repo) + + var resp installationResponse + if err := doAPIRequest(http.MethodGet, endpoint, jwtToken, nil, &resp); err != nil { + return 0, err + } + + if resp.ID == 0 { + return 0, errors.New("github installation id not found for repository") + } + + return resp.ID, nil +} + +func createInstallationToken(host string, installationID int64, appID, privateKey string) (accessTokenResponse, error) { + jwtToken, err := createAppJWT(appID, privateKey) + if err != nil { + return accessTokenResponse{}, err + } + + apiBase := apiBaseURL(host) + endpoint := fmt.Sprintf("%s/app/installations/%d/access_tokens", apiBase, installationID) + + var resp accessTokenResponse + if err := doAPIRequest(http.MethodPost, endpoint, jwtToken, map[string]string{}, &resp); err != nil { + return accessTokenResponse{}, err + } + + if resp.Token == "" { + return accessTokenResponse{}, errors.New("github installation access token is empty") + } + + if resp.ExpiresAt.IsZero() { + resp.ExpiresAt = nowFn().Add(1 * time.Minute) + } + + return resp, nil +} + +func createAppJWT(appID, privateKey string) (string, error) { + key, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privateKey)) + if err != nil { + return "", fmt.Errorf("failed to parse GitHub App private key: %w", err) + } + + now := nowFn() + claims := jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now.Add(-30 * time.Second)), + ExpiresAt: jwt.NewNumericDate(now.Add(9 * time.Minute)), + Issuer: appID, + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + + signed, err := token.SignedString(key) + if err != nil { + return "", fmt.Errorf("failed to sign GitHub App JWT: %w", err) + } + + return signed, nil +} + +func doAPIRequest(method, endpoint, jwtToken string, payload any, out any) error { + var body io.Reader + + if payload != nil { + raw, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to encode GitHub API payload: %w", err) + } + + body = bytes.NewBuffer(raw) + } + + req, err := http.NewRequest(method, endpoint, body) + if err != nil { + return fmt.Errorf("failed to create GitHub API request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+jwtToken) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := apiHTTPClient.Do(req) + if err != nil { + return fmt.Errorf("GitHub API request failed: %w", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("GitHub API request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))) + } + + if out == nil { + return nil + } + + if err := json.Unmarshal(bodyBytes, out); err != nil { + return fmt.Errorf("failed to decode GitHub API response: %w", err) + } + + return nil +} + +func apiBaseURL(host string) string { + h := strings.ToLower(strings.TrimSpace(host)) + if h == "github.com" || h == "www.github.com" { + return "https://api.github.com" + } + + if strings.HasPrefix(h, "api.") { + return "https://" + h + } + + return "https://" + h + "/api/v3" +} + +func parseGitHost(rawURL string) string { + u := strings.TrimSpace(rawURL) + if u == "" { + return "" + } + + if strings.Contains(u, "@") && strings.Contains(u, ":") && !strings.Contains(u, "://") { + parts := strings.SplitN(u, "@", 2) + if len(parts) != 2 { + return "" + } + + hostPath := strings.SplitN(parts[1], ":", 2) + if len(hostPath) != 2 { + return "" + } + + return normalizeHost(hostPath[0]) + } + + parsed, err := url.Parse(u) + if err != nil { + return "" + } + + if parsed.Host == "" { + return "" + } + + return normalizeHost(parsed.Hostname()) +} + +func normalizeHost(host string) string { + host = strings.TrimSpace(strings.ToLower(host)) + + return strings.TrimSuffix(host, ".") +} diff --git a/doco-cd-src/internal/stages/utils.go b/doco-cd-src/internal/git/names.go similarity index 57% rename from doco-cd-src/internal/stages/utils.go rename to doco-cd-src/internal/git/names.go index dee32d6..844e354 100644 --- a/doco-cd-src/internal/stages/utils.go +++ b/doco-cd-src/internal/git/names.go @@ -1,17 +1,14 @@ -package stages +package git import ( "strings" - - "github.com/kimdre/doco-cd/internal/config" - "github.com/kimdre/doco-cd/internal/git" ) -// getFullName extracts the full repository name without the domain part from the clone URL. +// GetFullName extracts the full repository name without the domain part from the clone URL. // E.g., "github.com/kimdre/doco-cd" becomes "kimdre/doco-cd" // or "git.example.com/doco-cd" becomes "doco-cd". -func getFullName(cloneURL config.HttpUrl) string { - repoName := git.GetRepoName(string(cloneURL)) +func GetFullName(cloneURL string) string { + repoName := GetRepoName(cloneURL) parts := strings.Split(repoName, "/") fullName := repoName diff --git a/doco-cd-src/internal/stages/utils_test.go b/doco-cd-src/internal/git/names_test.go similarity index 72% rename from doco-cd-src/internal/stages/utils_test.go rename to doco-cd-src/internal/git/names_test.go index 998b1da..243e72d 100644 --- a/doco-cd-src/internal/stages/utils_test.go +++ b/doco-cd-src/internal/git/names_test.go @@ -1,9 +1,7 @@ -package stages +package git import ( "testing" - - "github.com/kimdre/doco-cd/internal/config" ) func TestGetFullName(t *testing.T) { @@ -44,12 +42,24 @@ func TestGetFullName(t *testing.T) { cloneURL: "https://oauth2:TOKEN@github.com/kimdre/doco-cd_tests.git", // #nosec G101 -- This is a test URL, not a real token expected: "kimdre/doco-cd_tests", }, + { + cloneURL: "http://git.example.com/infra/alpha/local/netbird-doco.git", + expected: "infra/alpha/local/netbird-doco", + }, + { + cloneURL: "git@gitlab.com:gitlab-org/5-minute-production-app/sandbox/cats.git", + expected: "gitlab-org/5-minute-production-app/sandbox/cats", + }, + { + cloneURL: "https://gitlab.com/gitlab-org/5-minute-production-app/sandbox/cats.git", + expected: "gitlab-org/5-minute-production-app/sandbox/cats", + }, } for _, tt := range tests { t.Run(tt.cloneURL, func(t *testing.T) { t.Parallel() - result := getFullName(config.HttpUrl(tt.cloneURL)) + result := GetFullName(tt.cloneURL) if result != tt.expected { t.Errorf("getFullName failed for %s: expected %s, got %s", tt.cloneURL, tt.expected, result) } diff --git a/doco-cd-src/internal/git/repo_matches_test.go b/doco-cd-src/internal/git/repo_matches_test.go index f53af76..355cfd7 100644 --- a/doco-cd-src/internal/git/repo_matches_test.go +++ b/doco-cd-src/internal/git/repo_matches_test.go @@ -3,7 +3,7 @@ package git_test import ( "testing" - "github.com/kimdre/doco-cd/internal/config" + "github.com/kimdre/doco-cd/internal/config/app" "github.com/kimdre/doco-cd/internal/git" ) @@ -21,7 +21,7 @@ func TestMatchesHead(t *testing.T) { {"matches commit SHA", "a6e74091c5bb5913c0daff4d3fc8c1d1b2ad826b"}, } - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatalf("Failed to get app config: %v", err) } @@ -80,7 +80,7 @@ func TestMatchesHead(t *testing.T) { func TestMatchesHead_AfterCheckoutToDifferentBranch(t *testing.T) { t.Parallel() - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatalf("Failed to get app config: %v", err) } diff --git a/doco-cd-src/internal/git/ssh/helper.go b/doco-cd-src/internal/git/ssh/helper.go new file mode 100644 index 0000000..a5e782d --- /dev/null +++ b/doco-cd-src/internal/git/ssh/helper.go @@ -0,0 +1,20 @@ +package ssh + +import ( + "context" + "log/slog" + + "github.com/kimdre/doco-cd/internal/graceful" +) + +func RegisterSSHAgent(ctx context.Context, log *slog.Logger, privateKey string, passphrase string) { + agentCtx, agentCancel := context.WithCancel(ctx) + serveFunc := func(_ context.Context) error { + return startSSHAgent(agentCtx, log, socketAgentSocketPath, []byte(privateKey), passphrase) + } + + graceful.RegisterServerFunc("SSH Agent", serveFunc, func(_ context.Context) error { + agentCancel() + return nil + }) +} diff --git a/doco-cd-src/internal/git/ssh/ssh_agent.go b/doco-cd-src/internal/git/ssh/ssh_agent.go index 21c8056..afb946d 100644 --- a/doco-cd-src/internal/git/ssh/ssh_agent.go +++ b/doco-cd-src/internal/git/ssh/ssh_agent.go @@ -7,36 +7,36 @@ import ( "errors" "fmt" "io" - "log" + "log/slog" "net" "os" "path/filepath" + "sync" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" + + "github.com/kimdre/doco-cd/internal/logger" ) const ( SocketAgentSocketEnvVar = "SSH_AUTH_SOCK" ) -var SocketAgentSocketPath = filepath.Join(os.TempDir(), "ssh_agent.sock") +var socketAgentSocketPath = filepath.Join(os.TempDir(), "ssh_agent.sock") + +var ErrSSHAgentSocketPathEmpty = errors.New("socket path cannot be empty") // cleanupSocketFile removes the socket file at the specified path. func cleanupSocketFile(socketPath string) { - if socketPath == "" { - socketPath = SocketAgentSocketPath - } - _ = os.Remove(socketPath) } -// StartSSHAgent starts an SSH agent that listens on a Unix domain socket at the specified path. -// If no path is provided, it defaults to SocketAgentSocketPath. +// startSSHAgent starts an SSH agent that listens on a Unix domain socket at the specified path. // The function runs until the provided context is canceled. -func StartSSHAgent(ctx context.Context, socketPath string) error { +func startSSHAgent(ctx context.Context, log *slog.Logger, socketPath string, privateKey []byte, keyPassphrase string) error { if socketPath == "" { - socketPath = SocketAgentSocketPath + return ErrSSHAgentSocketPathEmpty } socketPath = filepath.Clean(socketPath) @@ -50,6 +50,15 @@ func StartSSHAgent(ctx context.Context, socketPath string) error { } defer listener.Close() // nolint:errcheck + wg := &sync.WaitGroup{} + defer wg.Wait() + // close the listener on context cancellation + wg.Go(func() { + defer listener.Close() // nolint:errcheck + + <-ctx.Done() + }) + // Set the SSH_AUTH_SOCK environment variable to point to the socket err = os.Setenv(SocketAgentSocketEnvVar, socketPath) if err != nil { @@ -59,13 +68,12 @@ func StartSSHAgent(ctx context.Context, socketPath string) error { defer cleanupSocketFile(socketPath) keyring := agent.NewKeyring() + if err := addKeyToAgent(keyring, privateKey, keyPassphrase); err != nil { + return err + } // Accept loop with context awareness - errCh := make(chan error, 1) - - go func() { - defer close(errCh) - + wg.Go(func() { for { // Non-blocking stop check select { @@ -81,23 +89,23 @@ func StartSSHAgent(ctx context.Context, socketPath string) error { return } // Log and continue on transient errors - log.Println(err) + log.Warn("Failed to accept SSH agent connection", logger.ErrAttr(err)) continue } - go func(c net.Conn) { - defer c.Close() // nolint:errcheck + wg.Go(func() { + defer conn.Close() // nolint:errcheck - if err := agent.ServeAgent(keyring, c); err != nil { + if err := agent.ServeAgent(keyring, conn); err != nil { // Ignore expected close conditions if !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) { - log.Println("Error serving SSH agent:", err) + log.Warn("Error serving SSH agent:", logger.ErrAttr(err)) } } - }(conn) + }) } - }() + }) // Wait for context cancellation <-ctx.Done() @@ -105,16 +113,8 @@ func StartSSHAgent(ctx context.Context, socketPath string) error { return nil } -// AddKeyToAgent adds a private key to the SSH agent running at the socket specified. -func AddKeyToAgent(privateKey []byte, keyPassphrase string) error { - conn, err := net.Dial("unix", SocketAgentSocketPath) - if err != nil { - return fmt.Errorf("failed to connect to SSH agent socket: %w", err) - } - defer conn.Close() // nolint:errcheck - - agentClient := agent.NewClient(conn) - +// addKeyToAgent adds a private key to the SSH agent running at the socket specified. +func addKeyToAgent(agentClient agent.Agent, privateKey []byte, keyPassphrase string) error { rawKey, err := getRawPrivateKey(privateKey, keyPassphrase) if err != nil { return err diff --git a/doco-cd-src/internal/git/ssh/ssh_agent_test.go b/doco-cd-src/internal/git/ssh/ssh_agent_test.go index a870e9c..bf20937 100644 --- a/doco-cd-src/internal/git/ssh/ssh_agent_test.go +++ b/doco-cd-src/internal/git/ssh/ssh_agent_test.go @@ -7,9 +7,11 @@ import ( "crypto/rand" "crypto/x509" "encoding/pem" + "log/slog" "net" "os" "path/filepath" + "sync" "testing" "time" @@ -17,72 +19,32 @@ import ( "golang.org/x/crypto/ssh/agent" ) -func TestListenSocketAgent(t *testing.T) { +func TestListenSocketAgentAddKeyToAgent(t *testing.T) { socketPath := filepath.Join(t.TempDir(), "ssh-agent.sock") - SocketAgentSocketPath = socketPath - KnownHostsFilePath = filepath.Join(t.TempDir(), "known_hosts_test") - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - - // Start the SSH agent listener in a separate goroutine - errChan := make(chan error, 1) - - go func() { - errChan <- StartSSHAgent(ctx, socketPath) - }() - - // Wait until the socket appears or timeout - deadline := time.Now().Add(2 * time.Second) - - for { - if _, err := os.Stat(socketPath); err == nil { - break - } - - if time.Now().After(deadline) { - t.Fatalf("SSH agent socket file does not exist: %s", socketPath) - } - - time.Sleep(10 * time.Millisecond) - } - - // Try to connect to the agent socket - dial, err := net.Dial("unix", socketPath) + // Generate a test SSH key pair + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { - t.Fatalf("Failed to connect to SSH agent socket: %v", err) + t.Fatalf("Failed to generate test SSH key: %v", err) } - err = dial.Close() + // Serialize the private key to PEM format + privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey) if err != nil { - t.Fatalf("Failed to close connection to SSH agent socket: %v", err) + t.Fatalf("Failed to marshal private key to PKCS8: %v", err) } - // Stop the agent - cancel() - - // Drain error (should be nil on normal shutdown) - select { - case e := <-errChan: - if e != nil && ctx.Err() == nil { - t.Fatalf("Failed to start SSH agent listener: %v", e) - } - case <-time.After(1 * time.Second): - t.Fatalf("listener did not exit on cancel") - } -} + privateKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateKeyDER}) -func TestAddKeyToAgent(t *testing.T) { - // Start the SSH agent - socketPath := filepath.Join(t.TempDir(), "ssh-agent.sock") - SocketAgentSocketPath = socketPath + wg := &sync.WaitGroup{} + defer wg.Wait() ctx, cancel := context.WithCancel(t.Context()) defer cancel() - - go func() { - _ = StartSSHAgent(ctx, socketPath) - }() + // Start the SSH agent + wg.Go(func() { + _ = startSSHAgent(ctx, slog.Default(), socketPath, privateKeyPEM, "") + }) // Wait until the socket appears or timeout deadline := time.Now().Add(2 * time.Second) @@ -99,26 +61,6 @@ func TestAddKeyToAgent(t *testing.T) { time.Sleep(10 * time.Millisecond) } - // Generate a test SSH key pair - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatalf("Failed to generate test SSH key: %v", err) - } - - // Serialize the private key to PEM format - privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey) - if err != nil { - t.Fatalf("Failed to marshal private key to PKCS8: %v", err) - } - - privateKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateKeyDER}) - - // Add the key to the agent - err = AddKeyToAgent(privateKeyPEM, "") - if err != nil { - t.Fatalf("Failed to add key to SSH agent: %v", err) - } - // Connect to the agent and verify the key was added agentConn, err := net.Dial("unix", socketPath) if err != nil { diff --git a/doco-cd-src/internal/graceful/graceful.go b/doco-cd-src/internal/graceful/graceful.go new file mode 100644 index 0000000..45219f6 --- /dev/null +++ b/doco-cd-src/internal/graceful/graceful.go @@ -0,0 +1,224 @@ +package graceful + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/kimdre/doco-cd/internal/logger" +) + +type Server interface { + // Name returns the name of the server, used for logging and debugging purposes. + Name() string + // Shutdown gracefully shuts down the server without interrupting any active connections. + Shutdown(ctx context.Context) error + // Serve starts the server and blocks until the server is stopped or an error occurs. + // if the server is stopped gracefully, it should return nil. + // If the server is stopped due to an error, it should return the error. + // if any server is stopped, all servers will be stopped gracefully. + Serve(ctx context.Context) error +} + +type handler struct { + lock sync.Mutex + servers []Server + // shutdownFuncs is a list of functions to be called on shutdown + shutdownFuncs []shutdownFunc +} + +type shutdownFunc struct { + name string + f func() +} + +var defaultHandler handler + +func init() { + defaultHandler = newHandler() +} + +func newHandler() handler { + return handler{} +} + +// RegisterServer registers a server to be started and stopped gracefully. +func (h *handler) RegisterServer(server Server) { + h.lock.Lock() + defer h.lock.Unlock() + + h.servers = append(h.servers, server) +} + +// see [handler.RegisterServer]. +func RegisterServer(server Server) { + defaultHandler.RegisterServer(server) +} + +// GetServers returns all registered servers. +func (h *handler) GetServers() []Server { + h.lock.Lock() + defer h.lock.Unlock() + + return h.servers +} + +type graceFuncServer struct { + name string + serveFunc func(ctx context.Context) error + shutdownFunc func(ctx context.Context) error +} + +func (s *graceFuncServer) Name() string { + return s.name +} + +func (s *graceFuncServer) Shutdown(ctx context.Context) error { + return s.shutdownFunc(ctx) +} + +func (s *graceFuncServer) Serve(ctx context.Context) error { + return s.serveFunc(ctx) +} + +// RegisterServerFunc registers server and shutdown function to be started and stopped gracefully. +func (h *handler) RegisterServerFunc(name string, serveFunc func(ctx context.Context) error, shutdownFunc func(ctx context.Context) error) { + h.RegisterServer(&graceFuncServer{ + name: name, + serveFunc: serveFunc, + shutdownFunc: shutdownFunc, + }) +} + +// see [handler.RegisterServerFunc]. +func RegisterServerFunc(name string, serveFunc func(ctx context.Context) error, shutdownFunc func(ctx context.Context) error) { + defaultHandler.RegisterServerFunc(name, serveFunc, shutdownFunc) +} + +const shutdownTimeout = 10 * time.Second + +// see [handler.Serve]. +func Serve(log *slog.Logger) error { + return defaultHandler.Serve(log) +} + +// Serve starts all registered servers and waits for a shutdown signal or any server to stop. +// When a shutdown signal is received or any server stops, it will attempt to gracefully shut down all servers. +// It returns an error if any server in encounters an error during serving or shutdown. +// It will wait for all servers to shut down gracefully before returning. +// It will call all registered shutdown functions before returning. +func (h *handler) Serve(log *slog.Logger) error { + signalCtx, signalStop := signal.NotifyContext( + context.Background(), + syscall.SIGINT, syscall.SIGTERM) + defer signalStop() + + serveCloseChan := newOnceChan[struct{}]() + + var wg sync.WaitGroup + + serveCtx, serveStop := context.WithCancel(context.Background()) + defer serveStop() + + servers := h.GetServers() + + errChan := make(chan error, len(servers)*2) + for _, server := range servers { + wg.Go(func() { + serverName := server.Name() + + defer func() { + if r := recover(); r != nil { + log.Error("goroutine panicked on server.Serve", + slog.Any("recover", r), + slog.String("name", serverName), + ) + + errChan <- fmt.Errorf("%s server.Serve panic: %v", serverName, r) + } + }() + + // Stop all servers if any server is stopped + defer func() { + serveCloseChan.Close() + log.Debug("server.Serve stopped", slog.String("name", serverName)) + }() + + log.Debug("server.Serve started", slog.String("name", serverName)) + + if err := server.Serve(serveCtx); err != nil { + log.Warn("server.Serve failed", slog.String("name", serverName), logger.ErrAttr(err)) + + errChan <- errors.New(serverName + " server error: " + err.Error()) + } + }) + } + + // Wait for either a shutdown signal or any server to stop + select { + case <-signalCtx.Done(): + log.Info("shutdown signal received", + slog.Any("signal", signalCtx.Err()), + ) + case <-serveCloseChan.Done(): + log.Info("some server stopped") + } + + // shutdown all servers gracefully + shutdownCtx, shutdownStop := context.WithTimeout(context.Background(), shutdownTimeout) + defer shutdownStop() + + for _, server := range servers { + wg.Go(func() { + serverName := server.Name() + + defer func() { + if r := recover(); r != nil { + log.Error("goroutine panicked on server.Shutdown", + slog.Any("recover", r), + slog.String("name", serverName), + ) + + errChan <- fmt.Errorf("%s server.Shutdown panic: %v", serverName, r) + } + }() + + log.Debug("call server.Shutdown", slog.String("name", serverName)) + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Warn("server.Shutdown failed", + slog.String("name", serverName), + logger.ErrAttr(err), + ) + + errChan <- err + } + }) + } + + // wait for servers to shutdown gracefully + log.Info("Waiting for ongoing jobs to finish") + wg.Wait() + + serveStop() + + var errs []error + + close(errChan) + + for err := range errChan { + if err != nil { + errs = append(errs, err) + } + } + + h.runRegisteredShutdownFuncs(log) + log.Info("server shutdown gracefully") + + return errors.Join(errs...) +} diff --git a/doco-cd-src/internal/graceful/graceful_test.go b/doco-cd-src/internal/graceful/graceful_test.go new file mode 100644 index 0000000..80bb095 --- /dev/null +++ b/doco-cd-src/internal/graceful/graceful_test.go @@ -0,0 +1,298 @@ +package graceful + +import ( + "context" + "errors" + "log/slog" + "os" + "strings" + "sync" + "syscall" + "testing" + "time" +) + +type testServer struct { + name string + serve func(ctx context.Context) error + shutdown func(ctx context.Context) error +} + +func (s *testServer) Name() string { + return s.name +} + +func (s *testServer) Serve(ctx context.Context) error { + return s.serve(ctx) +} + +func (s *testServer) Shutdown(ctx context.Context) error { + return s.shutdown(ctx) +} + +func getLog() *slog.Logger { + return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })) +} + +func TestServe_ShutsDownServersOnStop(t *testing.T) { + t.Parallel() + + handler := newHandler() + + shutdownCalled := make(chan struct{}) + + handler.RegisterServerFunc("svc1", + func(_ context.Context) error { + return nil + }, + func(_ context.Context) error { + close(shutdownCalled) + return nil + }, + ) + + shutdownCalled2 := make(chan struct{}) + + handler.RegisterServer(&testServer{ + name: "svc2", + serve: func(_ context.Context) error { + return nil + }, + shutdown: func(_ context.Context) error { + close(shutdownCalled2) + return nil + }, + }) + + log := getLog() + if err := handler.Serve(log); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + select { + case <-shutdownCalled: + // success + default: + t.Fatal("expected shutdown to be called") + } + + select { + case <-shutdownCalled2: + // success + default: + t.Fatal("expected shutdown to be called") + } +} + +func TestServeRecoversFromPanic(t *testing.T) { + t.Parallel() + + handler := newHandler() + + shutdownCalled := make(chan struct{}) + + handler.RegisterServerFunc("panic", + func(_ context.Context) error { + panic("test panic") + }, + func(_ context.Context) error { + close(shutdownCalled) + panic("panic on shutdown") + }, + ) + + shutdownCalled2 := make(chan struct{}) + + handler.RegisterServer(&testServer{ + name: "svc2", + serve: func(_ context.Context) error { + <-shutdownCalled2 + return nil + }, + shutdown: func(_ context.Context) error { + close(shutdownCalled2) + return nil + }, + }) + + log := getLog() + if err := handler.Serve(log); !strings.Contains(err.Error(), "panic server.Serve panic: test panic") { + t.Fatalf("expected error, got %v", err) + } + + select { + case <-shutdownCalled: + // success + default: + t.Fatal("expected shutdown to be called") + } + + select { + case <-shutdownCalled2: + // success + default: + t.Fatal("expected shutdown to be called") + } +} + +func TestServe_ReturnsCombinedErrors(t *testing.T) { + t.Parallel() + + handler := newHandler() + + handler.RegisterServerFunc("error-server", + func(_ context.Context) error { + return errors.New("serve failure") + }, + func(_ context.Context) error { + return errors.New("shutdown failure") + }, + ) + + log := getLog() + + err := handler.Serve(log) + if err == nil { + t.Fatal("expected error") + } + + errMsg := err.Error() + if !strings.Contains(errMsg, "serve failure") { + t.Fatalf("expected serve failure in error, got %q", errMsg) + } + + if !strings.Contains(errMsg, "shutdown failure") { + t.Fatalf("expected shutdown failure in error, got %q", errMsg) + } +} + +func TestServe_HandlesSignalAndShutsDown(t *testing.T) { + handler := newHandler() + + shutdownCalled := make(chan struct{}) + stopServe := make(chan struct{}) + + handler.RegisterServer(&testServer{ + name: "signal-server", + serve: func(ctx context.Context) error { + select { + case <-stopServe: + return nil + case <-ctx.Done(): + return ctx.Err() + } + }, + shutdown: func(_ context.Context) error { + close(shutdownCalled) + close(stopServe) + + return nil + }, + }) + + log := getLog() + result := make(chan error, 1) + + wg := sync.WaitGroup{} + defer wg.Wait() + + wg.Go(func() { + result <- handler.Serve(log) + }) + + // Wait for Serve to start and register the signal handler. + time.Sleep(50 * time.Millisecond) + + if err := syscall.Kill(os.Getpid(), syscall.SIGTERM); err != nil { + t.Fatalf("failed to send SIGTERM: %v", err) + } + + select { + case err := <-result: + if err != nil { + t.Fatalf("expected no error from Serve, got %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Serve did not return after SIGTERM") + } + + select { + case <-shutdownCalled: + // success + default: + t.Fatal("expected shutdown to be called") + } +} + +func TestServe_MultipleServersShutdownConcurrently(t *testing.T) { + t.Parallel() + + handler := newHandler() + + shutdownEvents := make(chan string, 2) + stopFirst := make(chan struct{}) + closeFirst := sync.Once{} + + handler.RegisterServer(&testServer{ + name: "blocking-server", + serve: func(_ context.Context) error { + <-stopFirst + return nil + }, + shutdown: func(_ context.Context) error { + closeFirst.Do(func() { close(stopFirst) }) + + shutdownEvents <- "blocking" + + return errors.New("blocking shutdown failure") + }, + }) + + handler.RegisterServer(&testServer{ + name: "error-server", + serve: func(_ context.Context) error { + return errors.New("serve failure") + }, + shutdown: func(_ context.Context) error { + shutdownEvents <- "error" + return errors.New("error shutdown failure") + }, + }) + + log := getLog() + + err := handler.Serve(log) + if err == nil { + t.Fatal("expected error") + } + + errMsg := err.Error() + if !strings.Contains(errMsg, "serve failure") { + t.Fatalf("expected serve failure in error, got %q", errMsg) + } + + if !strings.Contains(errMsg, "blocking shutdown failure") { + t.Fatalf("expected blocking shutdown failure in error, got %q", errMsg) + } + + if !strings.Contains(errMsg, "error shutdown failure") { + t.Fatalf("expected error shutdown failure in error, got %q", errMsg) + } + + received := map[string]bool{} + + for range 2 { + select { + case event := <-shutdownEvents: + received[event] = true + case <-time.After(2 * time.Second): + t.Fatal("expected both servers to be shutdown") + } + } + + if !received["blocking"] || !received["error"] { + t.Fatalf("expected shutdown of both servers, got %v", received) + } +} diff --git a/doco-cd-src/internal/graceful/http.go b/doco-cd-src/internal/graceful/http.go new file mode 100644 index 0000000..34b4886 --- /dev/null +++ b/doco-cd-src/internal/graceful/http.go @@ -0,0 +1,42 @@ +package graceful + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" +) + +type graceHttpServer struct { + name string + server *http.Server +} + +func (s *graceHttpServer) Name() string { + return s.name +} + +func (s *graceHttpServer) Shutdown(ctx context.Context) error { + return s.server.Shutdown(ctx) +} + +func (s *graceHttpServer) Serve(ctx context.Context) error { + s.server.BaseContext = func(_ net.Listener) context.Context { + return ctx + } + + err := s.server.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("failed to listen on %v, error: %w", s.server.Addr, err) + } + // Server was closed gracefully, no need to return an error + return nil +} + +func NewHttpServer(name string, server *http.Server) Server { + return &graceHttpServer{ + name: name, + server: server, + } +} diff --git a/doco-cd-src/internal/graceful/oncechan.go b/doco-cd-src/internal/graceful/oncechan.go new file mode 100644 index 0000000..7c398f2 --- /dev/null +++ b/doco-cd-src/internal/graceful/oncechan.go @@ -0,0 +1,26 @@ +package graceful + +import ( + "sync" +) + +type onceChan[T any] struct { + ch chan T + once sync.Once +} + +func newOnceChan[T any]() *onceChan[T] { + return &onceChan[T]{ + ch: make(chan T), + } +} + +func (o *onceChan[T]) Close() { + o.once.Do(func() { + close(o.ch) + }) +} + +func (o *onceChan[T]) Done() <-chan T { + return o.ch +} diff --git a/doco-cd-src/internal/graceful/oncechan_test.go b/doco-cd-src/internal/graceful/oncechan_test.go new file mode 100644 index 0000000..604f214 --- /dev/null +++ b/doco-cd-src/internal/graceful/oncechan_test.go @@ -0,0 +1,13 @@ +package graceful + +import "testing" + +func TestOnceChan(t *testing.T) { + t.Parallel() + + ch := newOnceChan[int]() + // close twice should not panic + ch.Close() + ch.Close() + <-ch.Done() +} diff --git a/doco-cd-src/internal/graceful/safe.go b/doco-cd-src/internal/graceful/safe.go new file mode 100644 index 0000000..5203803 --- /dev/null +++ b/doco-cd-src/internal/graceful/safe.go @@ -0,0 +1,20 @@ +package graceful + +import ( + "log/slog" + "sync" +) + +// SafeGo starts a new goroutine and adds it to wg. +// it also recovers from any panic in the goroutine and logs it using the provided logger. +func SafeGo(wg *sync.WaitGroup, log *slog.Logger, f func()) { + wg.Go(func() { + defer func() { + if r := recover(); r != nil { + log.Error("goroutine panicked", slog.Any("recover", r)) + } + }() + + f() + }) +} diff --git a/doco-cd-src/internal/graceful/safe_test.go b/doco-cd-src/internal/graceful/safe_test.go new file mode 100644 index 0000000..8960860 --- /dev/null +++ b/doco-cd-src/internal/graceful/safe_test.go @@ -0,0 +1,26 @@ +package graceful_test + +import ( + "log/slog" + "sync" + "testing" + + "github.com/kimdre/doco-cd/internal/graceful" +) + +func TestSafeGo(t *testing.T) { + t.Parallel() + + wg := &sync.WaitGroup{} + + defer wg.Wait() + + log := slog.Default() + graceful.SafeGo(wg, log, func() { + t.Logf("test output with panic") + panic("test panic") + }) + graceful.SafeGo(wg, log, func() { + t.Logf("test output with no panic") + }) +} diff --git a/doco-cd-src/internal/graceful/shutdown.go b/doco-cd-src/internal/graceful/shutdown.go new file mode 100644 index 0000000..6a13cb2 --- /dev/null +++ b/doco-cd-src/internal/graceful/shutdown.go @@ -0,0 +1,49 @@ +package graceful + +import ( + "log/slog" + "sync" +) + +// RegistryShutdownFunc registers function called on shutdown. +func (h *handler) RegistryShutdownFunc(name string, f func()) { + h.lock.Lock() + defer h.lock.Unlock() + + h.shutdownFuncs = append(h.shutdownFuncs, shutdownFunc{ + name: name, + f: f, + }) +} + +// RegistryShutdownFunc registers function called on shutdown to default handler. +func RegistryShutdownFunc(name string, f func()) { + defaultHandler.RegistryShutdownFunc(name, f) +} + +func (h *handler) getRegisteredShutdownFuncs() []shutdownFunc { + h.lock.Lock() + defer h.lock.Unlock() + + return h.shutdownFuncs +} + +func (h *handler) runRegisteredShutdownFuncs(log *slog.Logger) { + funcs := h.getRegisteredShutdownFuncs() + + log.Debug("calling registered shutdown functions") + + wg := sync.WaitGroup{} + for _, f := range funcs { + SafeGo(&wg, log, func() { + log.Debug("started run shutdown function", slog.String("name", f.name)) + defer log.Debug("finished run shutdown function", slog.String("name", f.name)) + + f.f() + }) + } + + wg.Wait() + + log.Debug("finished registered shutdown functions") +} diff --git a/doco-cd-src/internal/graceful/shutdown_test.go b/doco-cd-src/internal/graceful/shutdown_test.go new file mode 100644 index 0000000..0443b28 --- /dev/null +++ b/doco-cd-src/internal/graceful/shutdown_test.go @@ -0,0 +1,30 @@ +package graceful + +import ( + "context" + "sync/atomic" + "testing" +) + +func TestRunRegisteredShutdownFuncs(t *testing.T) { + t.Parallel() + + called := atomic.Bool{} + handler := newHandler() + handler.RegistryShutdownFunc("test", func() { + called.Store(true) + }) + handler.RegisterServerFunc("svc", func(_ context.Context) error { + return nil + }, func(_ context.Context) error { + return nil + }) + + if err := handler.Serve(getLog()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !called.Load() { + t.Fatalf("expected called to be true") + } +} diff --git a/doco-cd-src/internal/lock/lock.go b/doco-cd-src/internal/lock/lock.go index 6782c34..4a21c1a 100644 --- a/doco-cd-src/internal/lock/lock.go +++ b/doco-cd-src/internal/lock/lock.go @@ -37,8 +37,22 @@ func (l *RepoLock) Holder() string { var repoLocks sync.Map // Map to hold locks for each repository +// scheduledDeployLock enforces strict mutual exclusion between scheduled runs and deployments. +var scheduledDeployLock sync.Mutex + // GetRepoLock retrieves or creates a RepoLock for the given repoName. func GetRepoLock(repoName string) *RepoLock { lockIface, _ := repoLocks.LoadOrStore(repoName, &RepoLock{}) return lockIface.(*RepoLock) } + +// LockScheduledDeploy acquires the global scheduler/deployment lock. +// While held, scheduled runs and deployments are mutually exclusive. +func LockScheduledDeploy() { + scheduledDeployLock.Lock() +} + +// UnlockScheduledDeploy releases the scheduler/deployment lock. +func UnlockScheduledDeploy() { + scheduledDeployLock.Unlock() +} diff --git a/doco-cd-src/internal/lock/lock_test.go b/doco-cd-src/internal/lock/lock_test.go index b63c300..87594b0 100644 --- a/doco-cd-src/internal/lock/lock_test.go +++ b/doco-cd-src/internal/lock/lock_test.go @@ -4,6 +4,7 @@ import ( "strconv" "sync" "testing" + "time" ) // reset helper to isolate tests. @@ -177,3 +178,66 @@ func TestRepoLock_IndependentRepos(t *testing.T) { la.Unlock() lb.Unlock() } + +func TestScheduledDeployLock_MutualExclusion(t *testing.T) { + t.Parallel() + + ready := make(chan struct{}) + release := make(chan struct{}) + done := make(chan struct{}) + + go func() { + LockScheduledDeploy() + close(ready) + <-release + UnlockScheduledDeploy() + close(done) + }() + + <-ready + + acquired := make(chan struct{}) + + go func() { + LockScheduledDeploy() + close(acquired) + UnlockScheduledDeploy() + }() + + select { + case <-acquired: + t.Fatalf("expected second lock acquisition to block while first holder is active") + case <-time.After(50 * time.Millisecond): + } + + close(release) + + select { + case <-acquired: + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for blocked lock acquisition") + } + + <-done +} + +func TestScheduledDeployLock_ReacquireAfterUnlock(t *testing.T) { + t.Parallel() + + LockScheduledDeploy() + UnlockScheduledDeploy() + + done := make(chan struct{}) + + go func() { + LockScheduledDeploy() + UnlockScheduledDeploy() + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("expected lock reacquisition to succeed after unlock") + } +} diff --git a/doco-cd-src/internal/logger/attrs.go b/doco-cd-src/internal/logger/attrs.go new file mode 100644 index 0000000..e581e60 --- /dev/null +++ b/doco-cd-src/internal/logger/attrs.go @@ -0,0 +1,131 @@ +package logger + +import ( + "context" + "log/slog" +) + +// WithoutAttr returns a new logger with the given attribute key removed from the +// logger's currently attached attributes. +func WithoutAttr(l *slog.Logger, key string) *slog.Logger { + if l == nil { + return nil + } + + if h, ok := l.Handler().(*attrFilterHandler); ok { + return slog.New(h.withoutAttr(key)) + } + + return slog.New(&recordAttrFilterHandler{ + next: l.Handler(), + removeKey: key, + }) +} + +// attrFilterHandler captures logger-level attrs and reapplies them to each record. +// It lets WithoutAttr remove already-attached attrs when this wrapper is present. +type attrFilterHandler struct { + next slog.Handler + attrs []slog.Attr +} + +func newAttrFilterHandler(next slog.Handler) *attrFilterHandler { + return &attrFilterHandler{next: next} +} + +func (h *attrFilterHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.next.Enabled(ctx, level) +} + +func (h *attrFilterHandler) Handle(ctx context.Context, record slog.Record) error { + filtered := slog.NewRecord(record.Time, record.Level, record.Message, record.PC) + filtered.AddAttrs(h.attrs...) + appendFilteredRecordAttrs(&filtered, record, "") + + return h.next.Handle(ctx, filtered) +} + +func (h *attrFilterHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + merged := make([]slog.Attr, 0, len(h.attrs)+len(attrs)) + merged = append(merged, h.attrs...) + merged = append(merged, attrs...) + + return &attrFilterHandler{ + next: h.next, + attrs: merged, + } +} + +func (h *attrFilterHandler) WithGroup(name string) slog.Handler { + attrs := make([]slog.Attr, len(h.attrs)) + copy(attrs, h.attrs) + + return &attrFilterHandler{ + next: h.next.WithGroup(name), + attrs: attrs, + } +} + +func (h *attrFilterHandler) withoutAttr(key string) *attrFilterHandler { + return &attrFilterHandler{ + next: h.next, + attrs: filterAttrs(h.attrs, key), + } +} + +// recordAttrFilterHandler removes one attribute key from emitted records. +// It is used when the underlying handler is not attrFilterHandler. +type recordAttrFilterHandler struct { + next slog.Handler + removeKey string +} + +func (h *recordAttrFilterHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.next.Enabled(ctx, level) +} + +func (h *recordAttrFilterHandler) Handle(ctx context.Context, record slog.Record) error { + filtered := slog.NewRecord(record.Time, record.Level, record.Message, record.PC) + appendFilteredRecordAttrs(&filtered, record, h.removeKey) + + return h.next.Handle(ctx, filtered) +} + +func (h *recordAttrFilterHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &recordAttrFilterHandler{ + next: h.next.WithAttrs(filterAttrs(attrs, h.removeKey)), + removeKey: h.removeKey, + } +} + +func (h *recordAttrFilterHandler) WithGroup(name string) slog.Handler { + return &recordAttrFilterHandler{ + next: h.next.WithGroup(name), + removeKey: h.removeKey, + } +} + +func filterAttrs(attrs []slog.Attr, removeKey string) []slog.Attr { + filtered := make([]slog.Attr, 0, len(attrs)) + for _, attr := range attrs { + if attr.Key == removeKey { + continue + } + + filtered = append(filtered, attr) + } + + return filtered +} + +func appendFilteredRecordAttrs(dst *slog.Record, src slog.Record, removeKey string) { + src.Attrs(func(attr slog.Attr) bool { + if removeKey != "" && attr.Key == removeKey { + return true + } + + dst.AddAttrs(attr) + + return true + }) +} diff --git a/doco-cd-src/internal/logger/logger.go b/doco-cd-src/internal/logger/logger.go index 91cc3a4..2b6a02f 100644 --- a/doco-cd-src/internal/logger/logger.go +++ b/doco-cd-src/internal/logger/logger.go @@ -79,7 +79,7 @@ func New(logLevel slog.Level) *Logger { ) overwriter := slog.New( - slogdedup.NewOverwriteHandler(jh, nil), + newAttrFilterHandler(slogdedup.NewOverwriteHandler(jh, nil)), ) slog.SetDefault(overwriter) @@ -91,8 +91,7 @@ func New(logLevel slog.Level) *Logger { } } -// Critical logs a message at the critical level and exits the application. +// Critical logs a message at the critical level. func (l *Logger) Critical(msg string, args ...any) { l.Log(context.Background(), LevelCritical, msg, args...) - os.Exit(1) } diff --git a/doco-cd-src/internal/logger/logger_test.go b/doco-cd-src/internal/logger/logger_test.go index 86eb87b..be04b19 100644 --- a/doco-cd-src/internal/logger/logger_test.go +++ b/doco-cd-src/internal/logger/logger_test.go @@ -1,6 +1,8 @@ package logger import ( + "bytes" + "encoding/json" "errors" "log/slog" "testing" @@ -147,3 +149,58 @@ func TestLogger_ParseLevel(t *testing.T) { }) } } + +func TestWithoutAttr_RemovesAttachedAttribute(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + + base := slog.New(newAttrFilterHandler(slog.NewJSONHandler(&buf, nil))) + log := base.With(slog.String("job_id", "123"), slog.String("repository", "repo")) + + WithoutAttr(log, "job_id").Info("hello") + + entry := decodeJSONLogLine(t, buf.Bytes()) + if _, exists := entry["job_id"]; exists { + t.Fatalf("expected job_id to be removed, got %v", entry) + } + + if got := entry["repository"]; got != "repo" { + t.Fatalf("expected repository attr to be preserved, got %v", got) + } + + if got := entry["msg"]; got != "hello" { + t.Fatalf("expected msg to be hello, got %v", got) + } +} + +func TestWithoutAttr_MissingAttributeKeepsLoggerOutput(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + + base := slog.New(newAttrFilterHandler(slog.NewJSONHandler(&buf, nil))) + log := base.With(slog.String("repository", "repo")) + + WithoutAttr(log, "job_id").Info("hello", slog.String("stack", "app")) + + entry := decodeJSONLogLine(t, buf.Bytes()) + if got := entry["repository"]; got != "repo" { + t.Fatalf("expected repository attr to be preserved, got %v", got) + } + + if got := entry["stack"]; got != "app" { + t.Fatalf("expected stack attr to be preserved, got %v", got) + } +} + +func decodeJSONLogLine(t *testing.T, raw []byte) map[string]any { + t.Helper() + + var entry map[string]any + if err := json.Unmarshal(bytes.TrimSpace(raw), &entry); err != nil { + t.Fatalf("failed to decode log line %q: %v", string(raw), err) + } + + return entry +} diff --git a/doco-cd-src/internal/notification/notification.go b/doco-cd-src/internal/notification/notification.go index ff71b4e..de32da0 100644 --- a/doco-cd-src/internal/notification/notification.go +++ b/doco-cd-src/internal/notification/notification.go @@ -5,8 +5,11 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" + "sort" "strings" + "sync" ) type level int @@ -33,6 +36,7 @@ var levelEmojis = map[level]string{ } var ( + appriseConfigMu sync.RWMutex appriseApiURL = "" appriseNotifyUrls = "" appriseNotifyLevel = Info @@ -50,10 +54,15 @@ type appriseRequest struct { } type Metadata struct { - Repository string - Stack string - Revision string - JobID string + Repository string + Stack string + Revision string + JobID string + TraceID string + ReconciliationEvent string + AffectedActorKind string + AffectedActorID string + AffectedActorName string } // parseLevel converts a string representation of a log level to the level type. @@ -86,10 +95,19 @@ func send(apiUrl, notifyUrls, title, message, level string) error { resp, err := http.Post(apiUrl, "application/json", bytes.NewBuffer(jsonData)) // #nosec G107 if err != nil { + if strings.Contains(err.Error(), "malformed HTTP status code") { + return ErrNotifyFailed + } + return fmt.Errorf("failed to send request to Apprise: %w", err) } - defer resp.Body.Close() // nolint:errcheck + defer func() { + _ = resp.Body.Close() + }() + + // Drain the body so the underlying transport can safely reuse the connection. + _, _ = io.Copy(io.Discard, resp.Body) switch resp.StatusCode { case http.StatusOK: @@ -105,26 +123,38 @@ func send(apiUrl, notifyUrls, title, message, level string) error { // SetAppriseConfig sets the configuration for the Apprise notification service. func SetAppriseConfig(apiURL, notifyUrls, notifyLevel string) { + appriseConfigMu.Lock() + defer appriseConfigMu.Unlock() + appriseApiURL = apiURL appriseNotifyUrls = notifyUrls appriseNotifyLevel = parseLevel(notifyLevel) } +func getAppriseConfig() (string, string, level) { + appriseConfigMu.RLock() + defer appriseConfigMu.RUnlock() + + return appriseApiURL, appriseNotifyUrls, appriseNotifyLevel +} + // Send sends a notification using the Apprise service based on the provided configuration and parameters. func Send(level level, title, message string, metadata Metadata) error { - if appriseApiURL == "" || appriseNotifyUrls == "" { + apiURL, notifyURLs, notifyLevel := getAppriseConfig() + + if apiURL == "" || notifyURLs == "" { return nil } - if level < appriseNotifyLevel { + if level < notifyLevel { return nil // Do not send notification if the level is lower than the configured level } - title = levelEmojis[level] + " " + title + title = formatTitle(level, title, metadata) message = formatMessage(message, metadata) - err := send(appriseApiURL, appriseNotifyUrls, title, message, logLevels[level]) + err := send(apiURL, notifyURLs, title, message, logLevels[level]) if err != nil { return fmt.Errorf("failed to send notification: %w", err) } @@ -132,34 +162,115 @@ func Send(level level, title, message string, metadata Metadata) error { return nil } -// formatMessage formats the message by adding a newline after the first colon and appending the revision if provided. +func formatTitle(level level, title string, metadata Metadata) string { + formattedTitle := strings.TrimSpace(title) + + if strings.TrimSpace(metadata.ReconciliationEvent) != "" { + formattedTitle = "[R] " + formattedTitle + } + + return levelEmojis[level] + " " + formattedTitle +} + +// formatMessage renders notifications as plain message text followed by structured metadata. func formatMessage(message string, m Metadata) string { - if !strings.Contains(message, "\n") { - message = strings.Replace(message, ": ", ":\n", 1) + var sb strings.Builder + + trimmedMessage := strings.TrimRight(message, "\n") + isReconciliation := strings.TrimSpace(m.ReconciliationEvent) != "" + + sb.WriteString(trimmedMessage) + + fields := map[string]string{} + reconciliationFields := map[string]string{} + + if m.Repository != "" { + fields["repository"] = m.Repository + } + + if m.Stack != "" { + fields["stack"] = m.Stack } - var metadataInfo string + if m.Revision != "" { + fields["revision"] = m.Revision + } - fields := []struct { - key, value string - }{ - {"repository", m.Repository}, - {"stack", m.Stack}, - {"revision", m.Revision}, - {"job_id", m.JobID}, + if m.JobID != "" && !isReconciliation { + fields["job_id"] = m.JobID } - var sb strings.Builder + if m.ReconciliationEvent != "" { + reconciliationFields["event"] = m.ReconciliationEvent + } + + if m.TraceID != "" && isReconciliation { + reconciliationFields["trace_id"] = m.TraceID + } - for _, f := range fields { - if f.value != "" { - _, _ = fmt.Fprintf(&sb, "\n%s: %s", f.key, f.value) + actorKind := strings.TrimSpace(strings.ToLower(m.AffectedActorKind)) + switch actorKind { + case "container": + if m.AffectedActorID != "" { + reconciliationFields["container_id"] = m.AffectedActorID } + + if m.AffectedActorName != "" { + reconciliationFields["container_name"] = m.AffectedActorName + } + case "service": + if m.AffectedActorID != "" { + reconciliationFields["service_id"] = m.AffectedActorID + } + + if m.AffectedActorName != "" { + reconciliationFields["service_name"] = m.AffectedActorName + } + } + + if len(fields) == 0 && len(reconciliationFields) == 0 { + return sb.String() + } + + if trimmedMessage != "" { + sb.WriteString("\n\n") + } + + keys := make([]string, 0, len(fields)) + for key := range fields { + keys = append(keys, key) + } + + sort.Strings(keys) + + for idx, key := range keys { + if idx > 0 { + sb.WriteString("\n") + } + + _, _ = fmt.Fprintf(&sb, "%s: %s", key, fields[key]) } - metadataInfo += sb.String() + if len(reconciliationFields) > 0 { + if len(keys) > 0 { + sb.WriteString("\n") + } + + sb.WriteString("reconciliation:") + + reconciliationKeys := make([]string, 0, len(reconciliationFields)) + for key := range reconciliationFields { + reconciliationKeys = append(reconciliationKeys, key) + } + + sort.Strings(reconciliationKeys) + + for _, key := range reconciliationKeys { + _, _ = fmt.Fprintf(&sb, "\n %s: %s", key, reconciliationFields[key]) + } + } - return fmt.Sprintf("%s\n%s", message, metadataInfo) + return sb.String() } func GetRevision(reference, commitSHA string) string { diff --git a/doco-cd-src/internal/notification/notification_test.go b/doco-cd-src/internal/notification/notification_test.go index a88982f..eebadbc 100644 --- a/doco-cd-src/internal/notification/notification_test.go +++ b/doco-cd-src/internal/notification/notification_test.go @@ -2,6 +2,7 @@ package notification import ( "context" + "errors" "fmt" "testing" @@ -13,19 +14,20 @@ func TestSend(t *testing.T) { t.Parallel() testCases := []struct { - name string - appriseUrl string - expectedError string + name string + appriseURL string + expectedErr error }{ { - name: "Valid Service URL", - appriseUrl: "apprise://%s", - expectedError: "", + name: "Valid Service URL", + appriseURL: "apprise://%s", + // nil means success is expected + expectedErr: nil, }, { - name: "Invalid Service URL", - appriseUrl: "pover://wrong@test", - expectedError: "failed to send notification: " + ErrNotifyFailed.Error(), + name: "Invalid Service URL", + appriseURL: "pover://wrong@test", + expectedErr: ErrNotifyFailed, }, } @@ -56,15 +58,19 @@ func TestSend(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Cannot run tests in parallel because SetAppriseConfig modifies global variables - SetAppriseConfig("http://"+endpoint+"/notify", fmt.Sprint(tc.appriseUrl, endpoint), "info") + SetAppriseConfig("http://"+endpoint+"/notify", fmt.Sprintf(tc.appriseURL, endpoint), "info") err := Send(Info, "Test Notification", "This is a test message", metadata) - if err != nil { - if tc.expectedError == "" { - t.Errorf("unexpected error: %v", err) - } else if err.Error() != tc.expectedError { - t.Errorf("expected error: %s, got: %s", tc.expectedError, err.Error()) + if tc.expectedErr == nil { + if err != nil { + t.Fatalf("unexpected error: %v", err) } + + return + } + + if !errors.Is(err, tc.expectedErr) { + t.Fatalf("expected error wrapping %q, got: %v", tc.expectedErr, err) } }) } @@ -124,7 +130,7 @@ func TestFormatMessage(t *testing.T) { t.Parallel() message := formatMessage("Deployment failed: timeout reached", Metadata{}) - expected := "Deployment failed:\ntimeout reached\n" + expected := "Deployment failed: timeout reached" if message != expected { t.Errorf("expected %q, got %q", expected, message) @@ -135,10 +141,126 @@ func TestFormatMessage(t *testing.T) { t.Parallel() message := formatMessage("Current Version: v0.80.0\nLatest Version: v0.80.1", Metadata{}) - expected := "Current Version: v0.80.0\nLatest Version: v0.80.1\n" + expected := "Current Version: v0.80.0\nLatest Version: v0.80.1" + + if message != expected { + t.Errorf("expected %q, got %q", expected, message) + } + }) + + t.Run("reconciliation metadata includes event and affected actor", func(t *testing.T) { + t.Parallel() + + message := formatMessage("Deployment triggered", Metadata{ + Repository: "acme/api", + Stack: "prod", + JobID: "job-1", + TraceID: "trace-123", + ReconciliationEvent: "unhealthy", + AffectedActorKind: "service", + AffectedActorID: "abc123def456", + AffectedActorName: "prod_api", + }) + expected := "Deployment triggered\n\nrepository: acme/api\nstack: prod\nreconciliation:\n event: unhealthy\n service_id: abc123def456\n service_name: prod_api\n trace_id: trace-123" if message != expected { t.Errorf("expected %q, got %q", expected, message) } }) + + t.Run("reconciliation-only metadata is nested under reconciliation", func(t *testing.T) { + t.Parallel() + + message := formatMessage("Restart suppressed", Metadata{ + ReconciliationEvent: "unhealthy", + AffectedActorKind: "container", + AffectedActorID: "abc123", + AffectedActorName: "web_1", + }) + expected := "Restart suppressed\n\nreconciliation:\n container_id: abc123\n container_name: web_1\n event: unhealthy" + + if message != expected { + t.Errorf("expected %q, got %q", expected, message) + } + }) + + t.Run("metadata values remain unquoted", func(t *testing.T) { + t.Parallel() + + message := formatMessage("Deploy done", Metadata{ + Repository: "acme/o'hara", + }) + expected := "Deploy done\n\nrepository: acme/o'hara" + + if message != expected { + t.Errorf("expected %q, got %q", expected, message) + } + }) + + t.Run("non-reconciliation message keeps job id", func(t *testing.T) { + t.Parallel() + + message := formatMessage("Deploy done", Metadata{ + Repository: "acme/repo", + JobID: "job-99", + }) + expected := "Deploy done\n\njob_id: job-99\nrepository: acme/repo" + + if message != expected { + t.Errorf("expected %q, got %q", expected, message) + } + }) + + t.Run("unknown actor kind does not emit actor id or name keys", func(t *testing.T) { + t.Parallel() + + message := formatMessage("Reconciled", Metadata{ + ReconciliationEvent: "update", + AffectedActorKind: "task", + AffectedActorID: "zzz111", + AffectedActorName: "ignored", + }) + expected := "Reconciled\n\nreconciliation:\n event: update" + + if message != expected { + t.Errorf("expected %q, got %q", expected, message) + } + }) +} + +func TestFormatTitle(t *testing.T) { + t.Parallel() + + t.Run("regular title does not include reconciliation marker", func(t *testing.T) { + t.Parallel() + + title := formatTitle(Success, "Deployment completed", Metadata{}) + expected := "✅ Deployment completed" + + if title != expected { + t.Errorf("expected %q, got %q", expected, title) + } + }) + + t.Run("reconciliation title includes short marker", func(t *testing.T) { + t.Parallel() + + title := formatTitle(Success, "Deployment completed", Metadata{ReconciliationEvent: "unhealthy"}) + expected := "✅ [R] Deployment completed" + + if title != expected { + t.Errorf("expected %q, got %q", expected, title) + } + }) + + t.Run("whitespace reconciliation event is ignored", func(t *testing.T) { + t.Parallel() + + title := formatTitle(Warning, "Service restarted", Metadata{ReconciliationEvent: " "}) + expected := "⚠️ Service restarted" + + if title != expected { + t.Errorf("expected %q, got %q", expected, title) + } + }) } diff --git a/doco-cd-src/internal/prometheus/collectors.go b/doco-cd-src/internal/prometheus/collectors.go index 378131a..63ca349 100644 --- a/doco-cd-src/internal/prometheus/collectors.go +++ b/doco-cd-src/internal/prometheus/collectors.go @@ -9,6 +9,8 @@ func init() { WebhookRequestsTotal, WebhookErrorsTotal, WebhookDuration, DeploymentsTotal, DeploymentErrorsTotal, DeploymentDuration, DeploymentsActive, DeploymentsQueued, + ScheduledRunsTotal, ScheduledRunErrorsTotal, ScheduledRunSkippedTotal, + ScheduledRunDuration, ScheduledRunsActive, ) } @@ -81,6 +83,32 @@ var ( Name: "deployments_queued", Help: "Number of queued deployments waiting to start", }, []string{"repository"}) + ScheduledRunsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: MetricsNamespace, + Name: "scheduled_runs_total", + Help: "Total number of scheduled job runs processed", + }, []string{"stack", "job", "mode", "execution_mode"}) + ScheduledRunErrorsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: MetricsNamespace, + Name: "scheduled_run_errors_total", + Help: "Total number of failed scheduled job runs", + }, []string{"stack", "job", "mode", "execution_mode"}) + ScheduledRunSkippedTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: MetricsNamespace, + Name: "scheduled_run_skipped_total", + Help: "Total number of skipped scheduled job runs", + }, []string{"stack", "job", "mode", "execution_mode", "reason"}) + ScheduledRunDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: MetricsNamespace, + Name: "scheduled_run_duration_seconds", + Help: "Duration of scheduled job runs in seconds", + Buckets: prometheus.DefBuckets, + }, []string{"stack", "job", "mode", "execution_mode"}) + ScheduledRunsActive = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: MetricsNamespace, + Name: "scheduled_runs_active", + Help: "Number of currently active scheduled job runs", + }, []string{"stack", "job", "mode", "execution_mode"}) /* --8<-- [end:collectors] Add new collectors above this comment */ ) diff --git a/doco-cd-src/internal/prometheus/metrics.go b/doco-cd-src/internal/prometheus/metrics.go index 9447f4e..b6c4d21 100644 --- a/doco-cd-src/internal/prometheus/metrics.go +++ b/doco-cd-src/internal/prometheus/metrics.go @@ -2,10 +2,14 @@ package prometheus import ( "fmt" + "log/slog" "net/http" "time" "github.com/prometheus/client_golang/prometheus/promhttp" + + "github.com/kimdre/doco-cd/internal/graceful" + "github.com/kimdre/doco-cd/internal/logger" ) const ( @@ -13,8 +17,13 @@ const ( MetricsPath = "/metrics" // Path for exposing metrics via HTTP ) -// Serve starts the Prometheus metrics server. -func Serve(port uint16) error { +// RegisterServer registers the Prometheus metrics server. +func RegisterServer(port uint16, log *logger.Logger) { + log.Info("serving prometheus metrics", + slog.Int("http_port", int(port)), + slog.String("path", MetricsPath), + ) + mux := http.NewServeMux() mux.Handle(MetricsPath, promhttp.Handler()) @@ -24,12 +33,5 @@ func Serve(port uint16) error { Handler: mux, } - http.Handle(MetricsPath, promhttp.Handler()) - - err := server.ListenAndServe() - if err != nil { - return err - } - - return nil + graceful.RegisterServer(graceful.NewHttpServer("prometheus", server)) } diff --git a/doco-cd-src/internal/prometheus/metrics_test.go b/doco-cd-src/internal/prometheus/metrics_test.go index 2a4c2d6..84a53a4 100644 --- a/doco-cd-src/internal/prometheus/metrics_test.go +++ b/doco-cd-src/internal/prometheus/metrics_test.go @@ -9,7 +9,7 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" - "github.com/kimdre/doco-cd/internal/config" + "github.com/kimdre/doco-cd/internal/config/app" ) // TestServe tests the metrics endpoint serving functionality. @@ -19,12 +19,13 @@ func TestServe(t *testing.T) { expectedStatusCode := 200 expectedContentType := "text/plain; version=0.0.4; charset=utf-8; escaping=underscores" - appConfig, err := config.GetAppConfig() + appConfig, err := app.GetConfig() if err != nil { t.Fatalf("Failed to get app config: %v", err) } AppInfo.WithLabelValues("test", appConfig.LogLevel, time.Now().Format(time.RFC3339)).Set(1) + ScheduledRunsTotal.WithLabelValues("test-stack", "backup", "container", "restart").Inc() req, err := http.NewRequest("GET", MetricsPath, nil) if err != nil { @@ -53,4 +54,8 @@ func TestServe(t *testing.T) { if !strings.Contains(rr.Body.String(), "doco_cd_info") { t.Error("Expected response body to contain 'doco_cd_info' metric, but it does not") } + + if !strings.Contains(rr.Body.String(), "doco_cd_scheduled_runs_total") { + t.Error("Expected response body to contain 'doco_cd_scheduled_runs_total' metric, but it does not") + } } diff --git a/doco-cd-src/internal/reconciliation/clean.go b/doco-cd-src/internal/reconciliation/clean.go index bf4bf76..32268c0 100644 --- a/doco-cd-src/internal/reconciliation/clean.go +++ b/doco-cd-src/internal/reconciliation/clean.go @@ -4,17 +4,19 @@ import ( "context" "fmt" "log/slog" + "maps" "slices" "strconv" "github.com/docker/cli/cli/command" + deployConfig "github.com/kimdre/doco-cd/internal/config/deploy" + "github.com/kimdre/doco-cd/internal/git" "github.com/kimdre/doco-cd/internal/logger" "github.com/kimdre/doco-cd/internal/notification" - "github.com/kimdre/doco-cd/internal/config" "github.com/kimdre/doco-cd/internal/docker" ) @@ -22,13 +24,13 @@ import ( // the current deployment configurations but still exist on the Docker host. func cleanupObsoleteAutoDiscoveredContainers(ctx context.Context, jobLog *slog.Logger, dockerCli command.Cli, - cloneUrl string, deployConfigs []*config.DeployConfig, metadata notification.Metadata, + cloneUrl string, deployConfigs []*deployConfig.Config, metadata notification.Metadata, ) error { autoDiscoveredNames := make(map[string]bool) for _, cfg := range deployConfigs { - if cfg.AutoDiscover { - autoDiscoveredNames[cfg.Name] = cfg.AutoDiscoverOpts.Delete + if cfg.AutoDiscovery.Enabled { + autoDiscoveredNames[cfg.Name] = cfg.AutoDiscovery.Delete } } @@ -36,86 +38,112 @@ func cleanupObsoleteAutoDiscoveredContainers(ctx context.Context, jobLog *slog.L var processedStacks []string - serviceLabels, err := docker.GetLabeledServices(ctx, dockerCli.Client(), docker.DocoCDLabels.Deployment.AutoDiscover, "true") - if err == nil { - for _, labels := range serviceLabels { - stackName := labels[docker.DocoCDLabels.Deployment.Name] + // Query both new and deprecated labels. We keep reading the deprecated label to + // handle containers deployed before the label rename. + newServiceLabels, err := docker.GetLabeledServices(ctx, dockerCli.Client(), docker.DocoCDLabels.Deployment.AutoDiscovery, "true") + if err != nil { + return fmt.Errorf("failed to retrieve containers for auto-discovery cleanup: %w", err) + } - // Skip container if it has already been removed in this cleanup run - if slices.Contains(processedStacks, stackName) { - continue - } + deprecatedServiceLabels, err := docker.GetLabeledServices(ctx, dockerCli.Client(), docker.DeprecatedAutoDiscoverLabel, "true") //nolint:staticcheck // fallback for pre-rename containers + if err != nil { + return fmt.Errorf("failed to retrieve containers for auto-discovery cleanup: %w", err) + } - stackLog := jobLog.With(slog.String("stack", stackName)) + if len(deprecatedServiceLabels) > 0 { + jobLog.Warn("found containers with deprecated label, please recreate them to migrate to the new label", + slog.String("deprecated_label", docker.DeprecatedAutoDiscoverLabel), //nolint:staticcheck // include deprecated label key in warning for migration clarity + slog.String("new_label", docker.DocoCDLabels.Deployment.AutoDiscovery), + ) + } - labelUrl := labels[docker.DocoCDLabels.Repository.URL] + // Merge label maps and prefer the new label set when a service appears in both. + serviceLabels := make(map[docker.Service]map[string]string, len(deprecatedServiceLabels)+len(newServiceLabels)) + maps.Copy(serviceLabels, deprecatedServiceLabels) - // cloneUrl may not be in the same format as labelUrl - // (e.g., "https://github.com/kimdre/doco-cd.git" vs. "https://github.com/kimdre/doco-cd") - // or my different protocols (e.g., "ssh://git@github.com/kimdre/doco-cd.git" vs. "https://github.com/kimdre/doco-cd") - cloneUrlRepoName := git.GetRepoName(cloneUrl) - labelUrlRepoName := git.GetRepoName(labelUrl) + maps.Copy(serviceLabels, newServiceLabels) - match := cloneUrlRepoName == labelUrlRepoName + for _, labels := range serviceLabels { + stackName := labels[docker.DocoCDLabels.Deployment.Name] - stackLog.Debug("checking auto-discovered stack for repository match", - slog.Group("repo_url", - slog.String("clone_url", cloneUrl), - slog.String("clone_url_repo_name", cloneUrlRepoName), - slog.String("label_url", labelUrl), - slog.String("label_url_repo_name", labelUrlRepoName), - ), - slog.Bool("match", match), - ) + // Skip container if it has already been removed in this cleanup run + if slices.Contains(processedStacks, stackName) { + continue + } - if match { - stackLog.Debug("checking auto-discovered stack for obsolescence") + stackLog := jobLog.With(slog.String("stack", stackName)) - if _, found := autoDiscoveredNames[stackName]; !found { - autoDiscoverDelete := labels[docker.DocoCDLabels.Deployment.AutoDiscoverDelete] - if autoDiscoverDelete == "" { - autoDiscoverDelete = "true" // Default to true if label is missing - } + labelUrl := labels[docker.DocoCDLabels.Repository.URL] - deleteEnabled, err := strconv.ParseBool(autoDiscoverDelete) - if err != nil { - return err - } + // cloneUrl may not be in the same format as labelUrl + // (e.g., "https://github.com/kimdre/doco-cd.git" vs. "https://github.com/kimdre/doco-cd") + // or my different protocols (e.g., "ssh://git@github.com/kimdre/doco-cd.git" vs. "https://github.com/kimdre/doco-cd") + cloneUrlRepoName := git.GetRepoName(cloneUrl) + labelUrlRepoName := git.GetRepoName(labelUrl) - if !deleteEnabled { - stackLog.Debug("skipping removal of obsolete auto-discovered stack as per configuration") + match := cloneUrlRepoName == labelUrlRepoName - processedStacks = append(processedStacks, stackName) + stackLog.Debug("checking auto-discovered stack for repository match", + slog.Group("repo_url", + slog.String("clone_url", cloneUrl), + slog.String("clone_url_repo_name", cloneUrlRepoName), + slog.String("label_url", labelUrl), + slog.String("label_url_repo_name", labelUrlRepoName), + ), + slog.Bool("match", match), + ) - continue - } + if match { + stackLog.Debug("checking auto-discovered stack for obsolescence") - stackLog.Info("removing obsolete auto-discovered stack") + if _, found := autoDiscoveredNames[stackName]; !found { + autoDiscoverDelete := labels[docker.DocoCDLabels.Deployment.AutoDiscoveryDelete] + if autoDiscoverDelete == "" { + // Fall back to deprecated label + autoDiscoverDelete = labels[docker.DeprecatedAutoDiscoverDeleteLabel] //nolint:staticcheck // fallback for pre-rename containers + } - removeConfig := &config.DeployConfig{Name: stackName, Destroy: true} - removeConfig.DestroyOpts.RemoveVolumes = true - removeConfig.DestroyOpts.RemoveImages = true - removeConfig.DestroyOpts.RemoveRepoDir = false // Do not remove repo dir for auto-discovered stacks + if autoDiscoverDelete == "" { + autoDiscoverDelete = "true" // Default to true if label is missing + } - err = docker.DestroyStack(jobLog, &ctx, &dockerCli, removeConfig) - if err != nil { - return fmt.Errorf("failed to remove obsolete auto-discovered stack '%s': %w", stackName, err) - } + deleteEnabled, err := strconv.ParseBool(autoDiscoverDelete) + if err != nil { + return err + } - err = notification.Send(notification.Success, "Stack destroyed", "successfully destroyed stack "+removeConfig.Name, metadata) - if err != nil { - stackLog.Error("failed to send notification", logger.ErrAttr(err)) - } + if !deleteEnabled { + stackLog.Debug("skipping removal of obsolete auto-discovered stack as per configuration") - stackLog.Info("removed obsolete auto-discovered stack", slog.String("stack", stackName)) processedStacks = append(processedStacks, stackName) + + continue + } + + stackLog.Info("removing obsolete auto-discovered stack") + + removeConfig := &deployConfig.Config{Name: stackName} + removeConfig.Destroy.Enabled = true + removeConfig.Destroy.RemoveVolumes = true + removeConfig.Destroy.RemoveImages = true + removeConfig.Destroy.RemoveRepoDir = false // Do not remove repo dir for auto-discovered stacks + + err = docker.DestroyStack(jobLog, &ctx, &dockerCli, removeConfig) + if err != nil { + return fmt.Errorf("failed to remove obsolete auto-discovered stack '%s': %w", stackName, err) + } + + err = notification.Send(notification.Success, "Stack destroyed", "successfully destroyed stack "+removeConfig.Name, metadata) + if err != nil { + stackLog.Error("failed to send notification", logger.ErrAttr(err)) } - } else { - stackLog.Debug("skipping auto-discovered stack as it belongs to a different repository") + + stackLog.Info("removed obsolete auto-discovered stack", slog.String("stack", stackName)) + processedStacks = append(processedStacks, stackName) } + } else { + stackLog.Debug("skipping auto-discovered stack as it belongs to a different repository") } - } else { - return fmt.Errorf("failed to retrieve containers for auto-discovery cleanup: %w", err) } return nil diff --git a/doco-cd-src/internal/reconciliation/deploy.go b/doco-cd-src/internal/reconciliation/deploy.go index 066c480..8971dc9 100644 --- a/doco-cd-src/internal/reconciliation/deploy.go +++ b/doco-cd-src/internal/reconciliation/deploy.go @@ -10,7 +10,9 @@ import ( "github.com/docker/cli/cli/command" "github.com/moby/moby/api/types/container" - "github.com/kimdre/doco-cd/internal/config" + "github.com/kimdre/doco-cd/internal/config/app" + deployConfig "github.com/kimdre/doco-cd/internal/config/deploy" + "github.com/kimdre/doco-cd/internal/logger" "github.com/kimdre/doco-cd/internal/notification" "github.com/kimdre/doco-cd/internal/secretprovider" @@ -21,14 +23,14 @@ import ( func Deploy(ctx context.Context, jobLog *slog.Logger, - appConfig *config.AppConfig, + appConfig *app.Config, dataMountPoint container.MountPoint, dockerCli command.Cli, secretProvider *secretprovider.SecretProvider, metadata notification.Metadata, jobTrigger stages.JobTrigger, repoData stages.RepositoryData, - deployConfigs []*config.DeployConfig, + deployConfigs []*deployConfig.Config, payload *webhook.ParsedPayload, testName string, ) error { @@ -36,34 +38,38 @@ func Deploy(ctx context.Context, dataMountPoint, dockerCli, secretProvider, metadata, jobTrigger, repoData, deployConfigs, payload, testName) - // always add reconciliation job - reconciliationHandler.addJob(ctx, jobInfo{ - appConfig: appConfig, - dataMountPoint: dataMountPoint, - dockerCli: dockerCli, - secretProvider: secretProvider, - jobLog: jobLog, - metadata: metadata, - jobTrigger: jobTrigger, - repoData: repoData, - deployConfigs: deployConfigs, - payload: payload, - testName: testName, - }) + // Skip long-lived reconciliation listeners for test-triggered deployments. + // Test runs use testName only to make stacks unique and do not need background + // Docker event watchers that can outlive the test and race with TempDir cleanup. + if testName == "" { + reconciliationHandler.addJob(ctx, jobInfo{ + appConfig: appConfig, + dataMountPoint: dataMountPoint, + dockerCli: dockerCli, + secretProvider: secretProvider, + jobLog: jobLog, + metadata: metadata, + jobTrigger: jobTrigger, + repoData: repoData, + deployConfigs: deployConfigs, + payload: payload, + testName: testName, + }) + } return err } func deploy(ctx context.Context, jobLog *slog.Logger, - appConfig *config.AppConfig, + appConfig *app.Config, dataMountPoint container.MountPoint, dockerCli command.Cli, secretProvider *secretprovider.SecretProvider, metadata notification.Metadata, jobTrigger stages.JobTrigger, repoData stages.RepositoryData, - deployConfigs []*config.DeployConfig, + deployConfigs []*deployConfig.Config, payload *webhook.ParsedPayload, testName string, ) error { @@ -76,51 +82,55 @@ func deploy(ctx context.Context, return handleDeploy(ctx, jobLog, appConfig, dataMountPoint, dockerCli, secretProvider, metadata.JobID, jobTrigger, - repoData, deployConfigs, payload, testName) + repoData, deployConfigs, payload, testName, metadata) } func handleDeploy(ctx context.Context, jobLog *slog.Logger, - appConfig *config.AppConfig, + appConfig *app.Config, dataMountPoint container.MountPoint, dockerCli command.Cli, secretProvider *secretprovider.SecretProvider, jobID string, jobTrigger stages.JobTrigger, repoData stages.RepositoryData, - deployConfigs []*config.DeployConfig, + deployConfigs []*deployConfig.Config, payload *webhook.ParsedPayload, testName string, + metadata notification.Metadata, ) error { // We'll run each deployment concurrently but grouped by repo+reference and limited by the global deployerLimiter. var wg sync.WaitGroup resultCh := make(chan error, len(deployConfigs)) - for _, deployConfig := range deployConfigs { + for _, config := range deployConfigs { deployLog := jobLog. WithGroup("deploy"). With( - slog.String("stack", deployConfig.Name), - slog.String("reference", deployConfig.Reference)) + slog.String("stack", config.Name), + slog.String("reference", config.Reference)) // Used to make test deployments unique and prevent conflicts between tests when running in parallel. // It is not used in production. if testName != "" { - deployConfig.Name = test.ConvertTestName(testName) + config.Name = test.ConvertTestName(testName) } + reconciliationHandler.startStackDeployment(repoData.Name, config.Name) + wg.Add(1) - go func(dc *config.DeployConfig) { + go func(dc *deployConfig.Config) { defer wg.Done() + defer reconciliationHandler.finishStackDeployment(repoData.Name, dc.Name) err := handleOneDeploy(ctx, deployLog, appConfig, dataMountPoint, dockerCli, secretProvider, - dc, jobID, jobTrigger, repoData, payload) + dc, jobID, jobTrigger, repoData, payload, metadata) resultCh <- err - }(deployConfig) + }(config) } // Wait for all deployments to complete @@ -140,14 +150,15 @@ func handleDeploy(ctx context.Context, } func handleOneDeploy(ctx context.Context, deployLog *slog.Logger, - appConfig *config.AppConfig, dataMountPoint container.MountPoint, + appConfig *app.Config, dataMountPoint container.MountPoint, dockerCli command.Cli, secretProvider *secretprovider.SecretProvider, - dc *config.DeployConfig, + dc *deployConfig.Config, jobID string, jobTrigger stages.JobTrigger, repoData stages.RepositoryData, payLad *webhook.ParsedPayload, + metadata notification.Metadata, ) error { if deployerLimiter != nil { deployLog.Debug("queuing deployment") @@ -173,6 +184,7 @@ func handleOneDeploy(ctx context.Context, deployLog *slog.Logger, appConfig, dc, secretProvider, + metadata, ) err := stageMgr.RunStages(ctx) diff --git a/doco-cd-src/internal/reconciliation/deploy_test.go b/doco-cd-src/internal/reconciliation/deploy_test.go index 08734dc..180e07d 100644 --- a/doco-cd-src/internal/reconciliation/deploy_test.go +++ b/doco-cd-src/internal/reconciliation/deploy_test.go @@ -11,10 +11,15 @@ import ( "testing" "time" + "github.com/containerd/errdefs" + composeapi "github.com/docker/compose/v5/pkg/api" "github.com/moby/moby/api/types/container" "github.com/moby/moby/client" "github.com/kimdre/doco-cd/internal/config" + + "github.com/kimdre/doco-cd/internal/config/app" + deployConfig "github.com/kimdre/doco-cd/internal/config/deploy" "github.com/kimdre/doco-cd/internal/docker" "github.com/kimdre/doco-cd/internal/encryption" "github.com/kimdre/doco-cd/internal/git" @@ -33,7 +38,7 @@ func TestDeploy(t *testing.T) { ctx := t.Context() - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatal(err) } @@ -81,7 +86,9 @@ func TestDeploy(t *testing.T) { } tmpDir := t.TempDir() - repoName := git.GetRepoName(p.CloneURL) + // Use a test-unique repository name so this test's reconciliation job key does not + // collide with other package tests that may run in parallel. + repoName := test.ConvertTestName(t.Name()) + "-repo" repoPath := filepath.Join(tmpDir, repoName) _, err = git.CloneOrUpdateRepository(log, p.CloneURL, p.Ref, @@ -94,25 +101,35 @@ func TestDeploy(t *testing.T) { stackName := test.ConvertTestName(t.Name()) - dcs, err := config.GetDeployConfigs(repoPath, c.DeployConfigBaseDir, stackName, "", p.Ref) + dcs, err := deployConfig.GetConfigs(repoPath, c.DeployConfigBaseDir, stackName, "", p.Ref, nil) // commit have 5 apps // https://github.com/kimdre/doco-cd_tests/blob/7be81e788a40724cee7542eec00a2af0c4340eba/.doco-cd.yml for _, dc := range dcs { dc.Name = stackName + "-" + dc.Name - dc.Reconciliation.Interval = 5 } dcs[0].Reconciliation.Enabled = false - dcs[1].Reconciliation.Interval = 10 + dcs[1].Reconciliation.Events = []string{"stop"} + + // The default reconciliation events don't include "die". + // Explicitly enable it for dcs[2..4] so the test's forceful container + // removal (which emits a "die" event) triggers reconciliation as expected. + for _, dc := range dcs[2:] { + dc.Reconciliation.Events = []string{"die"} + } t.Cleanup(func() { + for _, dc := range dcs { + waitForStackDeploymentToFinish(t, repoName, dc.Name, 20*time.Second) + } + reconciliationHandler.close() for _, dc := range dcs { ctx := context.Background() - if err := docker.DestroyStack(log, &ctx, &dockerCli, dc); err != nil { - t.Error("docker.DestroyStack err", err) + if err := destroyTestStack(ctx, dockerCli.Client(), dc.Name); err != nil { + t.Error("destroyTestStack err", err) } } }) @@ -153,8 +170,6 @@ func TestDeploy(t *testing.T) { firstPartWanted := []string{wanted[2], wanted[3], wanted[4]} - secondPartWanted := []string{wanted[1], wanted[2], wanted[3], wanted[4]} - slices.Sort(wanted) got, err := getRunningContainerNames(ctx, dockerCli.Client(), stackName) @@ -166,6 +181,9 @@ func TestDeploy(t *testing.T) { t.Fatalf("first get running , expected %v, got %v", wanted, got) } + // Give the reconciliation event listener a moment to subscribe before deleting containers. + time.Sleep(time.Second) + if err := rmContainer(ctx, t, dockerCli.Client(), wanted); err != nil { t.Fatal("rm container err:", err) } @@ -179,26 +197,23 @@ func TestDeploy(t *testing.T) { t.Fatalf("rm container, get containers, expected empty, got %v", got) } - time.Sleep(time.Second * 7) - - got, err = getRunningContainerNames(ctx, dockerCli.Client(), stackName) - if err != nil { - t.Fatal("get containers err:", err) - } + deadline := time.Now().Add(20 * time.Second) - if !reflect.DeepEqual(firstPartWanted, got) { - t.Fatalf("start +7s, get containers, expected %v, got %v", firstPartWanted, got) - } + for { + got, err = getRunningContainerNames(ctx, dockerCli.Client(), stackName) + if err != nil { + t.Fatal("get containers err:", err) + } - time.Sleep(time.Second * 5) + if reflect.DeepEqual(firstPartWanted, got) { + break + } - got, err = getRunningContainerNames(ctx, dockerCli.Client(), stackName) - if err != nil { - t.Fatal("get containers err:", err) - } + if time.Now().After(deadline) { + t.Fatalf("start +20s, get containers, expected %v, got %v", firstPartWanted, got) + } - if !reflect.DeepEqual(secondPartWanted, got) { - t.Fatalf("start +12s, get containers, expected %v, got %v", secondPartWanted, got) + time.Sleep(250 * time.Millisecond) } } @@ -245,3 +260,55 @@ func rmContainer(ctx context.Context, t *testing.T, cli client.APIClient, contai return nil } + +func waitForStackDeploymentToFinish(t *testing.T, repository, stack string, timeout time.Duration) { + t.Helper() + + deadline := time.Now().Add(timeout) + + for { + if !reconciliationHandler.isStackDeploymentInProgress(repository, stack) { + return + } + + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for reconciliation deployment to finish for stack %q in repository %q", stack, repository) + } + + time.Sleep(100 * time.Millisecond) + } +} + +func destroyTestStack(ctx context.Context, cli client.APIClient, stackName string) error { + containers, err := docker.GetLabeledContainers(ctx, cli, composeapi.ProjectLabel, stackName, true) + if err != nil { + return err + } + + for _, c := range containers { + _, err = cli.ContainerRemove(ctx, c.ID, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}) + if err != nil && !errdefs.IsNotFound(err) { + return err + } + } + + networks, err := cli.NetworkList(ctx, client.NetworkListOptions{ + Filters: make(client.Filters).Add("label", composeapi.ProjectLabel+"="+stackName), + }) + if err != nil { + return err + } + + for _, nw := range networks.Items { + _, err = cli.NetworkRemove(ctx, nw.ID, client.NetworkRemoveOptions{}) + if err != nil && !errdefs.IsNotFound(err) { + return err + } + } + + if err := docker.RemoveLabeledVolumes(ctx, cli, stackName); err != nil { + return err + } + + return nil +} diff --git a/doco-cd-src/internal/reconciliation/manager.go b/doco-cd-src/internal/reconciliation/manager.go new file mode 100644 index 0000000..b3fd83d --- /dev/null +++ b/doco-cd-src/internal/reconciliation/manager.go @@ -0,0 +1,187 @@ +package reconciliation + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/docker/cli/cli/command" + "github.com/moby/moby/api/types/container" + + "github.com/kimdre/doco-cd/internal/config/app" + deployConfig "github.com/kimdre/doco-cd/internal/config/deploy" + + "github.com/kimdre/doco-cd/internal/graceful" + "github.com/kimdre/doco-cd/internal/notification" + "github.com/kimdre/doco-cd/internal/secretprovider" + "github.com/kimdre/doco-cd/internal/stages" + "github.com/kimdre/doco-cd/internal/webhook" +) + +var reconciliationHandler *reconciliation + +func init() { + reconciliationHandler = newReconciliation() +} + +func init() { + graceful.RegistryShutdownFunc("close_reconciliation", func() { + reconciliationHandler.close() + }) +} + +type jobInfo struct { + appConfig *app.Config + dataMountPoint container.MountPoint + dockerCli command.Cli + secretProvider *secretprovider.SecretProvider + + jobLog *slog.Logger + + metadata notification.Metadata + jobTrigger stages.JobTrigger + repoData stages.RepositoryData + deployConfigs []*deployConfig.Config + payload *webhook.ParsedPayload + testName string +} + +type job struct { + info jobInfo + deployConfigGroupByEvent map[string][]*deployConfig.Config // key is the docker event action name (for example "die" or "unhealthy"). + unhealthyRestartHistory map[string][]time.Time // key is the docker container ID, value is the list of timestamps of recent unhealthy restart events for that container. + restartSuppressUntil map[string]time.Time // key is the docker container ID that was restarted, value is the timestamp until which follow-up events from that restart should be suppressed. + closeChan chan struct{} +} + +func newJob(info jobInfo, deployConfigGroupByEvent map[string][]*deployConfig.Config) *job { + return &job{ + info: info, + deployConfigGroupByEvent: deployConfigGroupByEvent, + unhealthyRestartHistory: make(map[string][]time.Time), + restartSuppressUntil: make(map[string]time.Time), + closeChan: make(chan struct{}), + } +} + +func (j *job) close() { + if j == nil { + return + } + + close(j.closeChan) +} + +type reconciliation struct { + m sync.Mutex + + repoJobs map[string]*job + deployingStacks map[string]int +} + +func newReconciliation() *reconciliation { + return &reconciliation{ + repoJobs: make(map[string]*job), + deployingStacks: make(map[string]int), + m: sync.Mutex{}, + } +} + +func (r *reconciliation) close() { + r.m.Lock() + defer r.m.Unlock() + + for _, job := range r.repoJobs { + job.close() + } + + r.repoJobs = make(map[string]*job) + r.deployingStacks = make(map[string]int) +} + +func stackDeploymentKey(repository, stack string) string { + return repository + "/" + stack +} + +func (r *reconciliation) startStackDeployment(repository, stack string) { + if repository == "" || stack == "" { + return + } + + key := stackDeploymentKey(repository, stack) + + r.m.Lock() + r.deployingStacks[key]++ + r.m.Unlock() +} + +func (r *reconciliation) finishStackDeployment(repository, stack string) { + if repository == "" || stack == "" { + return + } + + key := stackDeploymentKey(repository, stack) + + r.m.Lock() + defer r.m.Unlock() + + count := r.deployingStacks[key] + if count <= 1 { + delete(r.deployingStacks, key) + return + } + + r.deployingStacks[key] = count - 1 +} + +func (r *reconciliation) isStackDeploymentInProgress(repository, stack string) bool { + if repository == "" || stack == "" { + return false + } + + key := stackDeploymentKey(repository, stack) + + r.m.Lock() + defer r.m.Unlock() + + return r.deployingStacks[key] > 0 +} + +func (r *reconciliation) addJob(ctx context.Context, info jobInfo) { + cfg := getDeployConfigGroupByEvent(info.deployConfigs) + if len(cfg) == 0 { + return + } + + r.m.Lock() + defer r.m.Unlock() + + old := r.repoJobs[info.repoData.Name] + old.close() + + // start new + newJob := newJob(info, cfg) + + r.repoJobs[info.repoData.Name] = newJob + go newJob.run(context.WithoutCancel(ctx)) +} + +func getDeployConfigGroupByEvent(dcs []*deployConfig.Config) map[string][]*deployConfig.Config { + m := make(map[string][]*deployConfig.Config) + + for _, dc := range dcs { + if r := dc.Reconciliation; r.Enabled { + for _, event := range r.Events { + action := normalizeReconciliationEventAction(event) + if action == "" { + continue + } + + m[action] = append(m[action], dc) + } + } + } + + return m +} diff --git a/doco-cd-src/internal/reconciliation/reconciliation.go b/doco-cd-src/internal/reconciliation/reconciliation.go index c48ee51..2587659 100644 --- a/doco-cd-src/internal/reconciliation/reconciliation.go +++ b/doco-cd-src/internal/reconciliation/reconciliation.go @@ -2,123 +2,318 @@ package reconciliation import ( "context" + "errors" "log/slog" + "maps" + "strconv" + "strings" "sync" "time" - "github.com/docker/cli/cli/command" - "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/events" + "github.com/moby/moby/client" - "github.com/kimdre/doco-cd/internal/config" + "github.com/kimdre/doco-cd/internal/config/app" + deployConfig "github.com/kimdre/doco-cd/internal/config/deploy" + + "github.com/kimdre/doco-cd/internal/docker" + "github.com/kimdre/doco-cd/internal/docker/swarm" + gitInternal "github.com/kimdre/doco-cd/internal/git" "github.com/kimdre/doco-cd/internal/lock" "github.com/kimdre/doco-cd/internal/logger" - "github.com/kimdre/doco-cd/internal/notification" - "github.com/kimdre/doco-cd/internal/secretprovider" - "github.com/kimdre/doco-cd/internal/stages" "github.com/kimdre/doco-cd/internal/utils/id" - "github.com/kimdre/doco-cd/internal/webhook" ) -var reconciliationHandler *reconciliation +const reconciliationTraceIDAttr = "doco_cd_reconciliation_trace_id" -func init() { - reconciliationHandler = newReconciliation() -} +// Rewind the Docker events since-cursor slightly to avoid precision edge cases +// around listener restarts and startup recovery. +const reconciliationSinceSafetySkew = 3 * time.Second + +func (j *job) run(ctx context.Context) { + jobLog := j.info.jobLog + + swarmMode := swarm.GetModeEnabled() + + filterArgs := make(client.Filters) + filterArgs.Add("type", dockerEventTypeForMode(swarmMode)) + + if !swarmMode { + filterArgs.Add("label", docker.DocoCDLabels.Metadata.Manager+"="+app.Name) + + repositoryLabelValue := gitInternal.GetFullName(string(j.info.repoData.CloneURL)) + if j.info.payload != nil && strings.TrimSpace(j.info.payload.FullName) != "" { + repositoryLabelValue = j.info.payload.FullName + } + + filterArgs.Add("label", docker.DocoCDLabels.Repository.Name+"="+repositoryLabelValue) + } + + for _, eventFilter := range dockerEventFiltersForActions(mapsKeys(j.deployConfigGroupByEvent), swarmMode) { + filterArgs.Add("event", eventFilter) + } + + eventSinceCursor := time.Now().UTC().Add(-reconciliationSinceSafetySkew) + + // On job startup, perform a one-time check for already-unhealthy containers + // so reconciliation can recover them without waiting for a new health event. + // Run the one-time startup recovery checks in parallel, but wait for both to + // finish before subscribing to Docker events so all startup healing happens + // against a stable initial view of the daemon state. + var startupRecoveryWG sync.WaitGroup + + startupRecoveryWG.Go(func() { + j.restartUnhealthyContainersOnStartup(ctx, jobLog) + }) + + startupRecoveryWG.Go(func() { + j.redeployMissingServicesOnStartup(ctx, jobLog) + }) + + startupRecoveryWG.Wait() + + const reconnectDelay = 5 * time.Second + + for { + // Check exit conditions before (re)connecting. + select { + case <-ctx.Done(): + jobLog.Debug("ctx is done") + return + case <-j.closeChan: + jobLog.Debug("channel is closed") + return + default: + } + + listenerCtx, cancel := context.WithCancel(ctx) + + eventResult := j.info.dockerCli.Client().Events(listenerCtx, client.EventsListOptions{ + Filters: filterArgs, + Since: dockerEventsSinceValue(eventSinceCursor), + }) + + reconnect, newestEventTime := j.runEventLoop(ctx, jobLog, eventResult.Messages, eventResult.Err) -type jobInfo struct { - appConfig *config.AppConfig - dataMountPoint container.MountPoint - dockerCli command.Cli - secretProvider *secretprovider.SecretProvider + if !newestEventTime.IsZero() { + nextCursor := newestEventTime.UTC().Add(-reconciliationSinceSafetySkew) + if nextCursor.After(eventSinceCursor) { + eventSinceCursor = nextCursor + } + } + + cancel() - jobLog *slog.Logger + if !reconnect { + return + } - metadata notification.Metadata - jobTrigger stages.JobTrigger - repoData stages.RepositoryData - deployConfigs []*config.DeployConfig - payload *webhook.ParsedPayload - testName string + jobLog.Debug("docker event listener disconnected, reconnecting", slog.Duration("delay", reconnectDelay)) + + select { + case <-ctx.Done(): + jobLog.Debug("ctx is done") + return + case <-j.closeChan: + jobLog.Debug("channel is closed") + return + case <-time.After(reconnectDelay): + } + } } -type job struct { - info jobInfo +// runEventLoop processes Docker events until the listener disconnects or the job is stopped. +// Returns true when the caller should reconnect, false when it should exit permanently. +func (j *job) runEventLoop(ctx context.Context, jobLog *slog.Logger, eventCh <-chan events.Message, errCh <-chan error) (bool, time.Time) { + var newestEventTime time.Time - // key is the interval in second - deployConfigGroupByInterval map[int][]*config.DeployConfig - closeChan chan struct{} + for { + select { + case <-ctx.Done(): + return false, newestEventTime + case <-j.closeChan: + return false, newestEventTime + case err, ok := <-errCh: + if !ok { + jobLog.Debug("docker events error channel closed") + return true, newestEventTime // reconnect + } + + if err != nil && !errors.Is(err, context.Canceled) { + jobLog.Error("docker event listener failed", logger.ErrAttr(err)) + return true, newestEventTime // reconnect after error + } + case event, ok := <-eventCh: + if !ok { + jobLog.Debug("docker events channel closed") + return true, newestEventTime // reconnect + } + + eventTime := dockerEventTime(event) + if eventTime.After(newestEventTime) { + newestEventTime = eventTime + } + + j.handleEvent(ctx, jobLog, event) + } + } } -func newJob(info jobInfo, deployConfigGroupByInterval map[int][]*config.DeployConfig) *job { - return &job{ - info: info, - deployConfigGroupByInterval: deployConfigGroupByInterval, - closeChan: make(chan struct{}), +// dockerEventsSinceValue returns a string representation of the given time to be used as the "since" parameter for the Docker events API. +// If the given time is zero, it returns an empty string to indicate no "since" filter. +func dockerEventsSinceValue(cursor time.Time) string { + if cursor.IsZero() { + return "" } + + return strconv.FormatInt(cursor.UTC().Unix(), 10) } -func (j *job) close() { - if j == nil { - return +func dockerEventTime(event events.Message) time.Time { + if event.TimeNano > 0 { + return time.Unix(0, event.TimeNano).UTC() } - close(j.closeChan) + if event.Time > 0 { + return time.Unix(event.Time, 0).UTC() + } + + return time.Time{} } -func (j *job) run(ctx context.Context) { - jobLog := j.info.jobLog +func (j *job) handleEvent(ctx context.Context, jobLog *slog.Logger, event events.Message) { + action := normalizeReconciliationEventAction(string(event.Action)) + dcs := j.deployConfigGroupByEvent[action] - jobLog.Debug("reconciliation loop started") - defer jobLog.Debug("reconciliation loop stopped") + if len(dcs) == 0 { + return + } - wg := sync.WaitGroup{} + stackName := stackNameFromEvent(event, dcs) + if stackName == "" { + return + } - for interval, dcs := range j.deployConfigGroupByInterval { - if len(dcs) > 0 { - wg.Go(func() { - j.runByInterval(ctx, interval, dcs) - }) + stackDCs := deployConfigsByName(dcs, stackName) + if len(stackDCs) == 0 { + return + } + + // Skip reconciliation if all matching configs have destroy enabled + // to prevent attempting to redeploy stacks that are being destroyed + allDestroyEnabled := true + + for _, dc := range stackDCs { + if !dc.Destroy.Enabled { + allDestroyEnabled = false + break } } - wg.Wait() -} + if allDestroyEnabled { + jobLog.Debug("skipping reconciliation for stack with destroy enabled", + slog.String("event", action), + slog.String("stack", stackName), + ) -func (j *job) runByInterval(ctx context.Context, interval int, dcs []*config.DeployConfig) { - if len(dcs) == 0 { return } - jobLog := j.info.jobLog.With( - slog.Int("interval", interval), - ) + if reconciliationHandler.isStackDeploymentInProgress(j.info.metadata.Repository, stackName) { + jobLog.Debug("suppressing reconciliation event while stack deployment is in progress", + slog.String("event", action), + slog.String("stack", stackName), + ) - jobLog.Debug("reconciliation interval worker started") - defer jobLog.Debug("reconciliation interval worker stopped") + return + } - for { - select { - case <-ctx.Done(): - jobLog.Debug("ctx is done") - return - case <-j.closeChan: - jobLog.Debug("channel is closed") + if suppress, remaining := j.shouldSuppressRestartFollowupEvent(action, event); suppress { + jobLog.Debug("suppressing follow-up event from self-initiated container restart", + slog.String("event", action), + slog.String("container_name", event.Actor.Attributes["name"]), + slog.String("restart_cooldown_remaining", remaining.Truncate(time.Second).String()), + slog.String("stack", stackName), + ) + + return + } + + stackID := j.info.metadata.Repository + "/" + stackName + stackLock := lock.GetRepoLock(stackID) + + if !stackLock.TryLock(id.GenID()) { + jobLog.Debug("skipping reconciliation, already in progress for this stack", slog.String("stack", stackName)) + return + } + defer stackLock.Unlock() + + actorGroupName := "container" + if swarm.GetModeEnabled() { + actorGroupName = "service" + } + + traceID := id.GenID() + event = withReconciliationTraceID(event, traceID) + + eventLog := logger. + WithoutAttr(jobLog, "job_id"). + With( + slog.Group("reconciliation", + slog.String("event", action), + slog.Group(actorGroupName, + slog.String("id", shortID(event.Actor.ID)), + slog.String("name", event.Actor.Attributes["name"]), + ), + slog.String("trace_id", traceID), + ), + slog.String("stack", stackName), + ) + + // For restart-oriented events the container is still present — restart it + // directly instead of going through a full redeploy pipeline. + if isRestartReconciliationAction(action) { + restartDC := selectRestartDeployConfig(stackDCs, event.Actor.Attributes) + if restartDC == nil { + eventLog.Warn("skipping restart reconciliation, no deploy config matched stack") return - case <-time.After(time.Second * time.Duration(interval)): - j.deploy(ctx, jobLog, dcs) } + + if len(stackDCs) > 1 { + eventLog.Warn("multiple deploy configs matched restart event, using first match", slog.Int("deploy_config_count", len(stackDCs))) + } + + restartResult := j.restartContainer(ctx, eventLog, event, restartDC) + if restartResult.fallbackToDeploy { + if event.Actor.ID != "" { + waitForContainerRemovalSettled(ctx, eventLog, j.info.dockerCli.Client(), event.Actor.ID, containerRemovalSettleTimeout) + } + + j.deploy(ctx, eventLog, stackDCs, action, event, traceID) + } + + return + } + + // When the event references a container that is being force-removed, Docker may + // still report it as "Removing" by the time we begin reconciliation, which causes + // docker compose to fail with "container is marked for removal and cannot be + // started". Wait briefly for the container to either be fully removed or settle + // into a stable state before re-deploying. + if event.Actor.ID != "" { + waitForContainerRemovalSettled(ctx, eventLog, j.info.dockerCli.Client(), event.Actor.ID, containerRemovalSettleTimeout) } + + j.deploy(ctx, eventLog, stackDCs, action, event, traceID) } -func (j *job) deploy(ctx context.Context, jobLog *slog.Logger, dcs []*config.DeployConfig) { +func (j *job) deploy(ctx context.Context, jobLog *slog.Logger, dcs []*deployConfig.Config, action string, event events.Message, traceID string) { repoLock := lock.GetRepoLock(j.info.metadata.Repository) repoLock.Lock() defer repoLock.Unlock() - jobLog = jobLog.With(slog.String("reconciliation_id", id.GenID())) - - jobLog.Debug("reconciliation started") - defer jobLog.Debug("reconciliation completed") + jobLog.Info("reconciliation started") + defer jobLog.Info("reconciliation completed") if err := cleanupObsoleteAutoDiscoveredContainers(ctx, jobLog, j.info.dockerCli, string(j.info.repoData.CloneURL), @@ -127,65 +322,50 @@ func (j *job) deploy(ctx context.Context, jobLog *slog.Logger, dcs []*config.Dep jobLog.Error("failed to clean up obsolete auto-discovered containers", logger.ErrAttr(err)) } - if err := handleDeploy(ctx, jobLog, j.info.appConfig, - j.info.dataMountPoint, j.info.dockerCli, - j.info.secretProvider, j.info.metadata.JobID, j.info.jobTrigger, - j.info.repoData, dcs, j.info.payload, j.info.testName); err != nil { - jobLog.Error("failed to deploy", logger.ErrAttr(err)) - } -} - -type reconciliation struct { - m sync.Mutex + // Reconciliation deploys should always force recreate so missing containers are restored + // even when there are no Git/compose changes. + reconcileDCs := cloneDeployConfigsWithForcedRecreate(dcs) - repoJobs map[string]*job -} - -func newReconciliation() *reconciliation { - return &reconciliation{ - repoJobs: make(map[string]*job), - m: sync.Mutex{}, + // Enrich metadata with reconciliation event information for deploy notifications + actorKind := "container" + if swarm.GetModeEnabled() { + actorKind = "service" } -} -func (r *reconciliation) close() { - r.m.Lock() - defer r.m.Unlock() + metadata := j.info.metadata + metadata.ReconciliationEvent = action + metadata.TraceID = strings.TrimSpace(traceID) + metadata.AffectedActorKind = actorKind + metadata.AffectedActorID = shortID(event.Actor.ID) + metadata.AffectedActorName = strings.TrimSpace(event.Actor.Attributes["name"]) - for _, job := range r.repoJobs { - job.close() + if err := handleDeploy(ctx, jobLog, j.info.appConfig, + j.info.dataMountPoint, j.info.dockerCli, + j.info.secretProvider, metadata.JobID, j.info.jobTrigger, + j.info.repoData, reconcileDCs, j.info.payload, j.info.testName, metadata); err != nil { + jobLog.Error("failed to deploy", logger.ErrAttr(err)) } - - r.repoJobs = make(map[string]*job) } -func (r *reconciliation) addJob(ctx context.Context, info jobInfo) { - cfg := getDeployConfigGroupByInterval(info.deployConfigs) - if len(cfg) == 0 { - return +func withReconciliationTraceID(event events.Message, traceID string) events.Message { + if strings.TrimSpace(traceID) == "" { + return event } - r.m.Lock() - defer r.m.Unlock() + attributes := map[string]string{} + maps.Copy(attributes, event.Actor.Attributes) - old := r.repoJobs[info.repoData.Name] - old.close() + attributes[reconciliationTraceIDAttr] = traceID - // start new - newJob := newJob(info, cfg) + event.Actor.Attributes = attributes - r.repoJobs[info.repoData.Name] = newJob - go newJob.run(context.WithoutCancel(ctx)) + return event } -func getDeployConfigGroupByInterval(dcs []*config.DeployConfig) map[int][]*config.DeployConfig { - m := make(map[int][]*config.DeployConfig) - - for _, dc := range dcs { - if r := dc.Reconciliation; r.Enabled { - m[r.Interval] = append(m[r.Interval], dc) - } +func reconciliationTraceIDFromEvent(event events.Message) string { + if event.Actor.Attributes == nil { + return "" } - return m + return strings.TrimSpace(event.Actor.Attributes[reconciliationTraceIDAttr]) } diff --git a/doco-cd-src/internal/reconciliation/reconciliation_event_test.go b/doco-cd-src/internal/reconciliation/reconciliation_event_test.go new file mode 100644 index 0000000..6859969 --- /dev/null +++ b/doco-cd-src/internal/reconciliation/reconciliation_event_test.go @@ -0,0 +1,725 @@ +package reconciliation + +import ( + "errors" + "reflect" + "slices" + "testing" + "time" + + "github.com/moby/moby/api/types/events" + + deployConfig "github.com/kimdre/doco-cd/internal/config/deploy" + + "github.com/kimdre/doco-cd/internal/docker" + "github.com/kimdre/doco-cd/internal/docker/swarm" + "github.com/kimdre/doco-cd/internal/notification" +) + +func TestGetDeployConfigGroupByEvent(t *testing.T) { + t.Parallel() + + dc1 := deployConfig.New("stack-1", "main") + dc1.Reconciliation.Enabled = true + dc1.Reconciliation.Events = []string{"die", "destroy"} + + dc2 := deployConfig.New("stack-2", "main") + dc2.Reconciliation.Enabled = true + dc2.Reconciliation.Events = []string{"unhealthy"} + + dc3 := deployConfig.New("stack-3", "main") + dc3.Reconciliation.Enabled = false + dc3.Reconciliation.Events = []string{"die"} + + grouped := getDeployConfigGroupByEvent([]*deployConfig.Config{dc1, dc2, dc3}) + + if len(grouped["die"]) != 1 || grouped["die"][0].Name != "stack-1" { + t.Fatalf("expected die event to include only stack-1, got %#v", grouped["die"]) + } + + if len(grouped["destroy"]) != 1 || grouped["destroy"][0].Name != "stack-1" { + t.Fatalf("expected destroy event to include only stack-1, got %#v", grouped["destroy"]) + } + + if len(grouped["unhealthy"]) != 1 || grouped["unhealthy"][0].Name != "stack-2" { + t.Fatalf("expected unhealthy event to include only stack-2, got %#v", grouped["unhealthy"]) + } + + if _, ok := grouped["stop"]; ok { + t.Fatalf("did not expect stop event group, got %#v", grouped["stop"]) + } +} + +func TestNormalizeReconciliationEventAction(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + " DIE ": "die", + "remove": "destroy", + " delete ": "destroy", + " UPDATE ": "update", + "health_status: unhealthy": "unhealthy", + "health_status: healthy": "health_status: healthy", + } + + for input, want := range tests { + got := normalizeReconciliationEventAction(input) + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected normalized action %q for input %q, got %q", want, input, got) + } + } +} + +func TestDockerEventFiltersForActions(t *testing.T) { + t.Parallel() + + got := dockerEventFiltersForActions([]string{" die ", "destroy", "unhealthy", "health_status: unhealthy", "die", "delete"}, false) + slices.Sort(got) + + want := []string{"destroy", "die", "health_status: unhealthy"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected docker event filters %v, got %v", want, got) + } +} + +func TestDockerEventFiltersForActions_Swarm(t *testing.T) { + t.Parallel() + + got := dockerEventFiltersForActions([]string{"destroy", "delete"}, true) + slices.Sort(got) + + want := []string{"remove"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected docker event filters %v, got %v", want, got) + } +} + +func TestDockerEventFiltersForActions_SwarmUpdate(t *testing.T) { + t.Parallel() + + got := dockerEventFiltersForActions([]string{"update"}, true) + slices.Sort(got) + + want := []string{"update"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected docker event filters %v, got %v", want, got) + } +} + +func TestDockerEventsSinceValue(t *testing.T) { + t.Parallel() + + if got := dockerEventsSinceValue(time.Time{}); got != "" { + t.Fatalf("expected empty since value for zero cursor, got %q", got) + } + + cursor := time.Unix(1735689600, 500).UTC() + if got := dockerEventsSinceValue(cursor); got != "1735689600" { + t.Fatalf("expected unix-seconds since value, got %q", got) + } +} + +func TestDockerEventTime(t *testing.T) { + t.Parallel() + + t.Run("prefers time nano", func(t *testing.T) { + t.Parallel() + + event := events.Message{Time: 100, TimeNano: 200_000_000_300} + want := time.Unix(0, 200_000_000_300).UTC() + + if got := dockerEventTime(event); !got.Equal(want) { + t.Fatalf("expected %s, got %s", want, got) + } + }) + + t.Run("falls back to seconds", func(t *testing.T) { + t.Parallel() + + event := events.Message{Time: 1710000000} + want := time.Unix(1710000000, 0).UTC() + + if got := dockerEventTime(event); !got.Equal(want) { + t.Fatalf("expected %s, got %s", want, got) + } + }) + + t.Run("zero when event has no time", func(t *testing.T) { + t.Parallel() + + if got := dockerEventTime(events.Message{}); !got.IsZero() { + t.Fatalf("expected zero time, got %s", got) + } + }) +} + +func TestShouldFallbackToDeployOnRestartError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want bool + }{ + { + name: "nil error", + err: nil, + want: false, + }, + { + name: "container marked for removal", + err: errors.New("Error response from daemon: Cannot restart container abc: container is marked for removal and cannot be started"), + want: true, + }, + { + name: "missing container", + err: errors.New("Error response from daemon: No such container: abc"), + want: true, + }, + { + name: "other restart error", + err: errors.New("Error response from daemon: cannot stop container"), + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if got := shouldFallbackToDeployOnRestartError(tc.err); got != tc.want { + t.Fatalf("expected fallback=%t, got %t", tc.want, got) + } + }) + } +} + +func TestIsRestartReconciliationAction(t *testing.T) { + t.Parallel() + + tests := map[string]bool{ + "unhealthy": true, + "oom": true, + "kill": true, + "stop": true, + "die": false, + "destroy": false, + "update": false, + } + + for action, want := range tests { + t.Run(action, func(t *testing.T) { + t.Parallel() + + got := isRestartReconciliationAction(action) + if got != want { + t.Fatalf("expected restart routing %t for action %q, got %t", want, action, got) + } + }) + } +} + +func TestIsRestartFollowupAction(t *testing.T) { + t.Parallel() + + tests := map[string]bool{ + "die": true, + "stop": true, + "kill": true, + "unhealthy": false, + "oom": false, + "destroy": false, + } + + for action, want := range tests { + t.Run(action, func(t *testing.T) { + t.Parallel() + + got := isRestartFollowupAction(action) + if got != want { + t.Fatalf("expected follow-up suppression %t for action %q, got %t", want, action, got) + } + }) + } +} + +func TestRestartFollowupSuppressionWindow(t *testing.T) { + t.Parallel() + + if got := restartFollowupSuppressionWindow(30); got != 40*time.Second { + t.Fatalf("expected suppression window 40s, got %s", got) + } + + if got := restartFollowupSuppressionWindow(0); got != 10*time.Second { + t.Fatalf("expected suppression window 10s, got %s", got) + } +} + +func TestEvaluateUnhealthyRestartLimit(t *testing.T) { + t.Parallel() + + now := time.Now() + window := 10 * time.Second + + t.Run("allow when below limit", func(t *testing.T) { + t.Parallel() + + history := []time.Time{now.Add(-9 * time.Second), now.Add(-3 * time.Second)} + + suppressed, updated := evaluateUnhealthyRestartLimit(history, now, 3, window) + if suppressed { + t.Fatal("expected restart to be allowed") + } + + if len(updated) != 3 { + t.Fatalf("expected history size 3, got %d", len(updated)) + } + }) + + t.Run("suppress when limit reached in window", func(t *testing.T) { + t.Parallel() + + history := []time.Time{now.Add(-9 * time.Second), now.Add(-3 * time.Second), now.Add(-1 * time.Second)} + + suppressed, updated := evaluateUnhealthyRestartLimit(history, now, 3, window) + if !suppressed { + t.Fatal("expected restart to be suppressed") + } + + if len(updated) != 3 { + t.Fatalf("expected history size 3, got %d", len(updated)) + } + }) + + t.Run("prunes entries outside window", func(t *testing.T) { + t.Parallel() + + history := []time.Time{now.Add(-25 * time.Second), now.Add(-15 * time.Second), now.Add(-1 * time.Second)} + + suppressed, updated := evaluateUnhealthyRestartLimit(history, now, 3, window) + if suppressed { + t.Fatal("expected restart to be allowed after old entries are pruned") + } + + if len(updated) != 2 { + t.Fatalf("expected history size 2 after prune+append, got %d", len(updated)) + } + }) +} + +func TestCloneDeployConfigsWithForcedRecreate(t *testing.T) { + t.Parallel() + + dc1 := deployConfig.New("stack-a", "main") + dc1.ForceRecreate = false + dc2 := deployConfig.New("stack-b", "main") + dc2.ForceRecreate = false + + cloned := cloneDeployConfigsWithForcedRecreate([]*deployConfig.Config{dc1, dc2}) + + if len(cloned) != 2 { + t.Fatalf("expected 2 cloned deploy configs, got %d", len(cloned)) + } + + for i, dc := range cloned { + if dc == nil { + t.Fatalf("expected cloned deploy config at index %d to be non-nil", i) + } + + if !dc.ForceRecreate { + t.Fatalf("expected ForceRecreate to be true for cloned config at index %d", i) + } + } + + if dc1.ForceRecreate || dc2.ForceRecreate { + t.Fatal("expected source deploy configs to remain unmodified") + } +} + +func TestStackDeploymentInProgressTracking(t *testing.T) { + t.Parallel() + + r := newReconciliation() + repo := "github.com/example/repo" + stack := "stack-a" + + if r.isStackDeploymentInProgress(repo, stack) { + t.Fatal("expected stack deployment to be initially not in progress") + } + + r.startStackDeployment(repo, stack) + + if !r.isStackDeploymentInProgress(repo, stack) { + t.Fatal("expected stack deployment to be marked in progress") + } + + // Reference counting should keep stack marked as in-progress until all marks are cleared. + r.startStackDeployment(repo, stack) + r.finishStackDeployment(repo, stack) + + if !r.isStackDeploymentInProgress(repo, stack) { + t.Fatal("expected stack deployment to remain in progress after one of two marks is cleared") + } + + r.finishStackDeployment(repo, stack) + + if r.isStackDeploymentInProgress(repo, stack) { + t.Fatal("expected stack deployment to be cleared after all marks are removed") + } +} + +func TestRestartOptionsFromDeployConfig(t *testing.T) { + t.Parallel() + + t.Run("from deploy config takes priority", func(t *testing.T) { + t.Parallel() + + dc := deployConfig.New("stack-a", "main") + dc.Reconciliation.RestartTimeout = 30 + dc.Reconciliation.RestartSignal = "SIGQUIT" + + opts := restartOptionsFromDeployConfig(dc) + if opts.Timeout == nil || *opts.Timeout != 30 { + t.Fatalf("expected restart timeout 30, got %+v", opts.Timeout) + } + + if opts.Signal != "SIGQUIT" { + t.Fatalf("expected restart signal SIGQUIT from deploy config, got %q", opts.Signal) + } + }) +} + +func TestRestartNotificationMetadata(t *testing.T) { + t.Parallel() + + metadata := restartNotificationMetadata(notification.Metadata{ + Repository: "owner/repo", + Stack: "stack-a", + JobID: "job-1", + }, "unhealthy", "container", "1234567890abcdef", "stack-a-web-1", "trace-1") + + if metadata.Repository != "owner/repo" || metadata.Stack != "stack-a" || metadata.JobID != "job-1" { + t.Fatalf("expected base metadata to be preserved, got %#v", metadata) + } + + if metadata.ReconciliationEvent != "unhealthy" { + t.Fatalf("expected reconciliation event unhealthy, got %q", metadata.ReconciliationEvent) + } + + if metadata.TraceID != "trace-1" { + t.Fatalf("expected trace id trace-1, got %q", metadata.TraceID) + } + + if metadata.AffectedActorKind != "container" { + t.Fatalf("expected actor kind container, got %q", metadata.AffectedActorKind) + } + + if metadata.AffectedActorID != "1234567890ab" { + t.Fatalf("expected short actor id 1234567890ab, got %q", metadata.AffectedActorID) + } + + if metadata.AffectedActorName != "stack-a-web-1" { + t.Fatalf("expected actor name stack-a-web-1, got %q", metadata.AffectedActorName) + } +} + +func TestRestartNotificationActorKindTitle(t *testing.T) { + t.Parallel() + + tests := map[string]string{ + "container": "Container", + "service": "Service", + "": "", + } + + for input, want := range tests { + if got := restartNotificationActorKindTitle(input); got != want { + t.Fatalf("expected title %q for %q, got %q", want, input, got) + } + } +} + +func TestSelectRestartDeployConfig(t *testing.T) { + t.Parallel() + + if got := selectRestartDeployConfig(nil, nil); got != nil { + t.Fatalf("expected nil for empty deploy config list, got %#v", got) + } + + dc1 := deployConfig.New("stack-a", "main") + dc1.Internal.Hash = "hash-a" + dc2 := deployConfig.New("stack-b", "main") + dc2.Internal.Hash = "hash-b" + + got := selectRestartDeployConfig([]*deployConfig.Config{nil, dc1, dc2}, nil) + if got != dc1 { + t.Fatalf("expected first non-nil deploy config to be selected") + } + + got = selectRestartDeployConfig([]*deployConfig.Config{dc1, dc2}, map[string]string{ + docker.DocoCDLabels.Deployment.ConfigHash: "hash-b", + }) + if got != dc2 { + t.Fatalf("expected config hash match to be selected") + } +} + +func TestShouldSuppressRestartFollowupEvent(t *testing.T) { + t.Parallel() + + containerID := "container-1" + j := &job{ + restartSuppressUntil: map[string]time.Time{ + containerID: time.Now().Add(5 * time.Second), + }, + } + + event := events.Message{Actor: events.Actor{ID: containerID}} + + if suppress, remaining := j.shouldSuppressRestartFollowupEvent("die", event); !suppress { + t.Fatal("expected die follow-up event to be suppressed") + } else if remaining <= 0 { + t.Fatalf("expected positive remaining suppression duration, got %s", remaining) + } + + if _, ok := j.restartSuppressUntil[containerID]; !ok { + t.Fatal("expected suppression marker to remain active during suppression window") + } +} + +func TestShouldSuppressRestartFollowupEvent_MultipleFollowupEvents(t *testing.T) { + t.Parallel() + + containerID := "container-1" + j := &job{ + restartSuppressUntil: map[string]time.Time{ + containerID: time.Now().Add(5 * time.Second), + }, + } + + event := events.Message{Actor: events.Actor{ID: containerID}} + + for _, action := range []string{"stop", "die", "kill"} { + if suppress, remaining := j.shouldSuppressRestartFollowupEvent(action, event); !suppress { + t.Fatalf("expected %q follow-up event to be suppressed", action) + } else if remaining <= 0 { + t.Fatalf("expected positive remaining suppression duration for %q, got %s", action, remaining) + } + } + + if _, ok := j.restartSuppressUntil[containerID]; !ok { + t.Fatal("expected suppression marker to remain until the suppression window expires") + } +} + +func TestShouldSuppressRestartFollowupEvent_Expired(t *testing.T) { + t.Parallel() + + containerID := "container-1" + j := &job{ + restartSuppressUntil: map[string]time.Time{ + containerID: time.Now().Add(-1 * time.Second), + }, + } + + event := events.Message{Actor: events.Actor{ID: containerID}} + + if suppress, remaining := j.shouldSuppressRestartFollowupEvent("die", event); suppress { + t.Fatal("expected expired suppression marker to be ignored") + } else if remaining != 0 { + t.Fatalf("expected zero remaining duration for expired marker, got %s", remaining) + } + + if _, ok := j.restartSuppressUntil[containerID]; ok { + t.Fatal("expected expired suppression marker to be cleaned up") + } +} + +func TestShouldSuppressRestartFollowupEvent_NonFollowupAction(t *testing.T) { + t.Parallel() + + containerID := "container-1" + j := &job{ + restartSuppressUntil: map[string]time.Time{ + containerID: time.Now().Add(5 * time.Second), + }, + } + + event := events.Message{Actor: events.Actor{ID: containerID}} + + if suppress, remaining := j.shouldSuppressRestartFollowupEvent("destroy", event); suppress { + t.Fatal("expected non-follow-up action not to be suppressed") + } else if remaining != 0 { + t.Fatalf("expected zero remaining duration for non-follow-up action, got %s", remaining) + } + + if _, ok := j.restartSuppressUntil[containerID]; !ok { + t.Fatal("expected suppression marker to remain for unrelated actions") + } +} + +func TestStackNameFromEvent(t *testing.T) { + t.Parallel() + + candidates := []*deployConfig.Config{ + deployConfig.New("stack-a", "main"), + deployConfig.New("stack-b", "main"), + } + + tests := []struct { + name string + event events.Message + want string + }{ + { + name: "deployment label", + event: events.Message{Actor: events.Actor{ + Attributes: map[string]string{docker.DocoCDLabels.Deployment.Name: "stack-a"}, + }}, + want: "stack-a", + }, + { + name: "stack namespace label", + event: events.Message{Actor: events.Actor{ + Attributes: map[string]string{swarm.StackNamespaceLabel: "stack-b"}, + }}, + want: "stack-b", + }, + { + name: "stack namespace label not in candidates", + event: events.Message{Actor: events.Actor{ + Attributes: map[string]string{swarm.StackNamespaceLabel: "other-stack"}, + }}, + want: "", + }, + { + name: "service name fallback", + event: events.Message{Actor: events.Actor{ + Attributes: map[string]string{"name": "stack-a_web"}, + }}, + want: "stack-a", + }, + { + name: "swarm service name attribute", + event: events.Message{Actor: events.Actor{ + Attributes: map[string]string{"com.docker.swarm.service.name": "stack-b_api"}, + }}, + want: "stack-b", + }, + { + name: "swarm task name attribute", + event: events.Message{Actor: events.Actor{ + Attributes: map[string]string{"com.docker.swarm.task.name": "stack-a_web.1.abc123"}, + }}, + want: "stack-a", + }, + { + name: "service attr fallback", + event: events.Message{Actor: events.Actor{ + Attributes: map[string]string{"service": "stack-b_api"}, + }}, + want: "stack-b", + }, + { + name: "unknown service", + event: events.Message{Actor: events.Actor{ + Attributes: map[string]string{"name": "other_web"}, + }}, + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := stackNameFromEvent(tc.event, candidates) + if got != tc.want { + t.Fatalf("expected stack name %q, got %q", tc.want, got) + } + }) + } +} + +func TestDeployConfigsByName(t *testing.T) { + t.Parallel() + + dc1 := deployConfig.New("stack-a", "main") + dc2 := deployConfig.New("stack-b", "main") + dc3 := deployConfig.New("stack-a", "main") + + got := deployConfigsByName([]*deployConfig.Config{dc1, dc2, dc3}, "stack-a") + + if len(got) != 2 { + t.Fatalf("expected 2 deploy configs, got %d", len(got)) + } + + if got[0].Name != "stack-a" || got[1].Name != "stack-a" { + t.Fatalf("expected only stack-a deploy configs, got %#v", got) + } +} + +func TestUniqueRedeployDCsFromGroupByEvent(t *testing.T) { + t.Parallel() + + dcDie := deployConfig.New("stack-die", "main") + dcDestroy := deployConfig.New("stack-destroy", "main") + dcBoth := deployConfig.New("stack-both", "main") // registered under two redeploy events + dcRestart := deployConfig.New("stack-restart", "main") // only restart events → must be excluded + + grouped := map[string][]*deployConfig.Config{ + "die": {dcDie, dcBoth}, + "destroy": {dcDestroy, dcBoth}, // dcBoth appears again — must be deduplicated + "unhealthy": {dcRestart}, // restart-oriented — must be excluded + "stop": {dcRestart}, // restart-oriented — must be excluded + } + + got := uniqueRedeployDCsFromGroupByEvent(grouped) + + // Build a name set for order-independent assertion. + names := make(map[string]struct{}, len(got)) + for _, dc := range got { + names[dc.Name] = struct{}{} + } + + if len(got) != 3 { + t.Fatalf("expected 3 unique redeploy configs, got %d: %v", len(got), names) + } + + for _, wantName := range []string{"stack-die", "stack-destroy", "stack-both"} { + if _, ok := names[wantName]; !ok { + t.Errorf("expected %q to be included, got %v", wantName, names) + } + } + + if _, ok := names["stack-restart"]; ok { + t.Error("expected stack-restart to be excluded (only restart-oriented events)") + } +} + +func TestUniqueRedeployDCsFromGroupByEvent_EmptyWhenOnlyRestartEvents(t *testing.T) { + t.Parallel() + + dc := deployConfig.New("stack-a", "main") + + grouped := map[string][]*deployConfig.Config{ + "unhealthy": {dc}, + "oom": {dc}, + "kill": {dc}, + "stop": {dc}, + } + + got := uniqueRedeployDCsFromGroupByEvent(grouped) + if len(got) != 0 { + t.Fatalf("expected empty result for all-restart events, got %d configs", len(got)) + } +} + +func TestUniqueRedeployDCsFromGroupByEvent_EmptyInput(t *testing.T) { + t.Parallel() + + got := uniqueRedeployDCsFromGroupByEvent(map[string][]*deployConfig.Config{}) + if len(got) != 0 { + t.Fatalf("expected empty result for empty input, got %d", len(got)) + } +} diff --git a/doco-cd-src/internal/reconciliation/reconciliation_helpers.go b/doco-cd-src/internal/reconciliation/reconciliation_helpers.go new file mode 100644 index 0000000..7013e07 --- /dev/null +++ b/doco-cd-src/internal/reconciliation/reconciliation_helpers.go @@ -0,0 +1,229 @@ +package reconciliation + +import ( + "context" + "log/slog" + "strings" + "time" + + "github.com/containerd/errdefs" + "github.com/moby/moby/api/types/events" + "github.com/moby/moby/client" + + deployConfig "github.com/kimdre/doco-cd/internal/config/deploy" + + "github.com/kimdre/doco-cd/internal/docker" + "github.com/kimdre/doco-cd/internal/docker/swarm" + "github.com/kimdre/doco-cd/internal/logger" + "github.com/kimdre/doco-cd/internal/utils/set" +) + +func dockerEventTypeForMode(swarmMode bool) string { + if swarmMode { + return "service" + } + + return "container" +} + +func dockerEventFiltersForActions(actions []string, swarmMode bool) []string { + filters := set.New[string]() + + for _, rawAction := range actions { + action := normalizeReconciliationEventAction(rawAction) + if action == "" { + continue + } + + switch action { + case "unhealthy": + // Only subscribe to unhealthy health transitions, not healthy ones. + filters.Add("health_status: unhealthy") + case "destroy": + if swarmMode { + filters.Add("remove") + continue + } + + filters.Add("destroy") + default: + filters.Add(action) + } + } + + return filters.ToSlice() +} + +// containerRemovalSettleTimeout caps how long handleEvent waits for a force-removed +// container to be fully gone before kicking off a reconciliation deploy. +const containerRemovalSettleTimeout = 15 * time.Second + +// waitForContainerRemovalSettled polls the given container until it is either gone +// (inspect returns not-found) or no longer reported as "removing", or until the +// timeout elapses. This prevents a race between Docker's async container teardown +// and docker compose trying to recreate the container. +func waitForContainerRemovalSettled(ctx context.Context, jobLog *slog.Logger, cli client.APIClient, containerID string, timeout time.Duration) { + if containerID == "" || timeout <= 0 { + return + } + + deadline := time.Now().Add(timeout) + + for { + inspectResult, err := cli.ContainerInspect(ctx, containerID, client.ContainerInspectOptions{}) + if err != nil { + // Treat any inspect error (most importantly "no such container") as + // "container is gone, safe to proceed". + if errdefs.IsNotFound(err) { + return + } + + jobLog.Debug("failed to inspect container while waiting for removal to settle", + slog.String("container_id", shortID(containerID)), + logger.ErrAttr(err), + ) + + return + } + + state := inspectResult.Container.State + if state == nil || !strings.EqualFold(strings.TrimSpace(string(state.Status)), "removing") { + return + } + + if !time.Now().Before(deadline) { + jobLog.Debug("timed out waiting for container removal to settle", + slog.String("container_id", shortID(containerID)), + slog.Duration("timeout", timeout), + ) + + return + } + + select { + case <-ctx.Done(): + return + case <-time.After(100 * time.Millisecond): + } + } +} + +func deployConfigsByName(dcs []*deployConfig.Config, name string) []*deployConfig.Config { + result := make([]*deployConfig.Config, 0, len(dcs)) + + for _, dc := range dcs { + if dc.Name == name { + result = append(result, dc) + } + } + + return result +} + +// stackNameFromEvent attempts to determine the stack name referenced by the given Docker event +// by examining various event attributes and matching them against the candidate config.DeployConfig configs. +// Returns an empty string when no stack name could be determined or matched. +func stackNameFromEvent(event events.Message, candidates []*deployConfig.Config) string { + attrs := event.Actor.Attributes + + for _, key := range []string{ + docker.DocoCDLabels.Deployment.Name, + swarm.StackNamespaceLabel, + "name", + "service", + "com.docker.swarm.service.name", + "com.docker.swarm.task.name", + } { + identifier := strings.TrimSpace(attrs[key]) + if identifier == "" { + continue + } + + if matched := matchCandidateStackName(identifier, candidates); matched != "" { + return matched + } + } + + return "" +} + +// matchCandidateStackName checks if the given identifier matches any of the candidate DeployConfig stack names, +// either as an exact match or as a prefix followed by typical Docker naming separators. +func matchCandidateStackName(identifier string, candidates []*deployConfig.Config) string { + identifier = strings.TrimSpace(identifier) + if identifier == "" { + return "" + } + + for _, dc := range candidates { + if dc == nil { + continue + } + + name := strings.TrimSpace(dc.Name) + if name == "" { + continue + } + + if identifier == name { + return name + } + + // Docker Swarm service names are typically formatted as _. + // Some event attributes can also contain task/container names such as + // _.., so matching by prefix keeps this resilient. + if strings.HasPrefix(identifier, name+"_") || + strings.HasPrefix(identifier, name+".") || + strings.HasPrefix(identifier, name+"-") { + return dc.Name + } + } + + return "" +} + +func cloneDeployConfigsWithForcedRecreate(dcs []*deployConfig.Config) []*deployConfig.Config { + reconcileDCs := make([]*deployConfig.Config, len(dcs)) + + for i, dc := range dcs { + dcCopy := *dc + dcCopy.ForceRecreate = true + reconcileDCs[i] = &dcCopy + } + + return reconcileDCs +} + +func normalizeReconciliationEventAction(action string) string { + action = strings.ToLower(strings.TrimSpace(action)) + action = strings.Join(strings.Fields(action), " ") + + switch action { + case "remove", "delete": + return "destroy" + case "health_status: unhealthy": + return "unhealthy" + } + + return action +} + +// shortID returns the first 12 characters of a container ID, matching the Docker CLI convention. +func shortID(id string) string { + if len(id) > 12 { + return id[:12] + } + + return id +} + +// mapsKeys returns the keys of the given map as a slice. +func mapsKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + + for key := range m { + keys = append(keys, key) + } + + return keys +} diff --git a/doco-cd-src/internal/reconciliation/reconciliation_integration_test.go b/doco-cd-src/internal/reconciliation/reconciliation_integration_test.go new file mode 100644 index 0000000..1b3d08c --- /dev/null +++ b/doco-cd-src/internal/reconciliation/reconciliation_integration_test.go @@ -0,0 +1,368 @@ +package reconciliation + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + "testing" + "time" + + "github.com/docker/compose/v5/pkg/api" + "github.com/moby/moby/client" + + "github.com/kimdre/doco-cd/internal/config" + + "github.com/kimdre/doco-cd/internal/config/app" + deployConfig "github.com/kimdre/doco-cd/internal/config/deploy" + "github.com/kimdre/doco-cd/internal/docker" + dockerSwarm "github.com/kimdre/doco-cd/internal/docker/swarm" + "github.com/kimdre/doco-cd/internal/logger" + "github.com/kimdre/doco-cd/internal/notification" + "github.com/kimdre/doco-cd/internal/stages" + internaltest "github.com/kimdre/doco-cd/internal/test" + "github.com/kimdre/doco-cd/internal/webhook" +) + +const dockerIntegrationEnvVar = "DOCO_CD_RUN_DOCKER_INTEGRATION_TESTS" + +func TestReconciliationDockerEventActions(t *testing.T) { + requireDockerIntegrationTestGate(t) + + ctx := t.Context() + + tests := []struct { + name string + wantAction string + composeYAML string + trigger func(context.Context, *testing.T, *internaltest.ComposeStack) + }{ + { + name: "die", + wantAction: "die", + composeYAML: runningServiceComposeYAML(), + trigger: func(ctx context.Context, t *testing.T, stack *internaltest.ComposeStack) { + t.Helper() + containerID := stack.ServiceContainerID(ctx, t, "app") + + _, err := stack.Client.ContainerKill(ctx, containerID, client.ContainerKillOptions{Signal: "SIGKILL"}) + if err != nil { + t.Fatalf("failed to kill container %s: %v", containerID, err) + } + }, + }, + { + name: "destroy", + wantAction: "destroy", + composeYAML: runningServiceComposeYAML(), + trigger: func(ctx context.Context, t *testing.T, stack *internaltest.ComposeStack) { + t.Helper() + containerID := stack.ServiceContainerID(ctx, t, "app") + + _, err := stack.Client.ContainerRemove(ctx, containerID, client.ContainerRemoveOptions{Force: true}) + if err != nil { + t.Fatalf("failed to remove container %s: %v", containerID, err) + } + }, + }, + { + name: "stop", + wantAction: "stop", + composeYAML: runningServiceComposeYAML(), + trigger: func(ctx context.Context, t *testing.T, stack *internaltest.ComposeStack) { + t.Helper() + containerID := stack.ServiceContainerID(ctx, t, "app") + timeout := 1 + + _, err := stack.Client.ContainerStop(ctx, containerID, client.ContainerStopOptions{Timeout: &timeout}) + if err != nil { + t.Fatalf("failed to stop container %s: %v", containerID, err) + } + }, + }, + { + name: "kill", + wantAction: "kill", + composeYAML: runningServiceComposeYAML(), + trigger: func(ctx context.Context, t *testing.T, stack *internaltest.ComposeStack) { + t.Helper() + containerID := stack.ServiceContainerID(ctx, t, "app") + + _, err := stack.Client.ContainerKill(ctx, containerID, client.ContainerKillOptions{Signal: "SIGKILL"}) + if err != nil { + t.Fatalf("failed to kill container %s: %v", containerID, err) + } + }, + }, + { + name: "oom", + wantAction: "oom", + composeYAML: oomServiceComposeYAML(), + trigger: func(ctx context.Context, t *testing.T, stack *internaltest.ComposeStack) { + t.Helper() + + go func() { + _, _ = stack.Exec(ctx, t, "app", []string{"python", "-c", "chunks=[]\nwhile True:\n chunks.append('x' * 1024 * 1024)"}) + }() + }, + }, + { + name: "health_status_unhealthy", + wantAction: "unhealthy", + composeYAML: unhealthyOnDemandComposeYAML(), + trigger: func(ctx context.Context, t *testing.T, stack *internaltest.ComposeStack) { + t.Helper() + + if exitCode, _ := stack.Exec(ctx, t, "app", []string{"sh", "-c", "rm -f /tmp/healthy"}); exitCode != 0 { + t.Fatalf("expected health-trigger command to succeed, got exit code %d", exitCode) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stackName := internaltest.ConvertTestName(t.Name()) + stack := internaltest.ComposeUp(ctx, t, + internaltest.WithName(stackName), + internaltest.WithYAML(tt.composeYAML), + internaltest.WithWaitTimeout(90*time.Second), + ) + + waitForExpectedDockerEvent(ctx, t, stack.Client, stack.Name, tt.wantAction, func() { + tt.trigger(ctx, t, stack) + }) + }) + } +} + +func TestReconciliationStopEventRestartSuppressionIntegration(t *testing.T) { + requireDockerIntegrationTestGate(t) + + ctx := t.Context() + stackName := internaltest.ConvertTestName(t.Name()) + repositoryName := "kimdre/doco-cd_tests" + + stack := internaltest.ComposeUp(ctx, t, + internaltest.WithName(stackName), + internaltest.WithYAML(restartMarkerComposeYAML()), + internaltest.WithWaitTimeout(90*time.Second), + internaltest.WithCustomLabel(map[string]string{ + docker.DocoCDLabels.Metadata.Manager: app.Name, + docker.DocoCDLabels.Repository.Name: repositoryName, + docker.DocoCDLabels.Deployment.Name: stackName, + docker.DocoCDLabels.Deployment.TargetRef: "main", + }), + ) + + logSince := time.Now().Add(-2 * time.Second) + waitForBootMarkerCount(ctx, t, stack, logSince, 1, 20*time.Second) + + dc := deployConfig.New(stackName, "main") + dc.Reconciliation.Enabled = true + dc.Reconciliation.Events = []string{"stop"} + dc.Reconciliation.RestartTimeout = 1 + + jobLog := logger.New(slog.LevelError).Logger + reconcileJob := newJob(jobInfo{ + jobLog: jobLog, + dockerCli: stack.DockerCli, + metadata: notification.Metadata{Repository: repositoryName, Stack: stackName, JobID: "test-job"}, + repoData: stages.RepositoryData{CloneURL: config.HttpUrl("https://github.com/kimdre/doco-cd_tests.git"), Name: repositoryName}, + payload: &webhook.ParsedPayload{FullName: repositoryName}, + deployConfigs: []*deployConfig.Config{dc}, + }, getDeployConfigGroupByEvent([]*deployConfig.Config{dc})) + + runCtx, cancel := context.WithCancel(ctx) + + t.Cleanup(func() { + cancel() + reconcileJob.close() + }) + + go reconcileJob.run(runCtx) + + // Give the reconciliation listener a brief moment to subscribe before triggering the restart. + time.Sleep(1 * time.Second) + + containerID := stack.ServiceContainerID(ctx, t, "app") + restartTimeout := 1 + + if _, err := stack.Client.ContainerRestart(ctx, containerID, client.ContainerRestartOptions{Timeout: &restartTimeout}); err != nil { + t.Fatalf("failed to restart container %s: %v", containerID, err) + } + + // Initial start + user restart + one reconciliation restart. + waitForBootMarkerCount(ctx, t, stack, logSince, 3, 35*time.Second) + + // Regression assertion: no additional restart loop should happen after follow-up stop/die/kill events. + assertBootMarkerCountStable(ctx, t, stack, logSince, 3, 6*time.Second) +} + +func requireDockerIntegrationTestGate(t *testing.T) { + t.Helper() + + if testing.Short() { + t.Skip("skipping Docker reconciliation integration tests in short mode") + } + + if os.Getenv(dockerIntegrationEnvVar) != "1" { + t.Skipf("set %s=1 to run Docker reconciliation integration tests", dockerIntegrationEnvVar) + } + + dockerCli, err := internaltest.NewDockerCli() + if err != nil { + t.Skipf("skipping Docker reconciliation integration tests: %v", err) + } + + defer func() { + _ = dockerCli.Client().Close() + }() + + if err := dockerSwarm.RefreshModeEnabled(t.Context(), dockerCli.Client()); err != nil { + t.Fatalf("failed to inspect Docker swarm mode: %v", err) + } + + if dockerSwarm.GetModeEnabled() { + t.Skip("reconciliation Docker event integration tests require non-Swarm mode") + } +} + +func waitForExpectedDockerEvent(ctx context.Context, t *testing.T, cli client.APIClient, stackName, wantAction string, trigger func()) { + t.Helper() + + filterArgs := make(client.Filters) + filterArgs.Add("type", "container") + filterArgs.Add("label", fmt.Sprintf("%s=%s", api.ProjectLabel, stackName)) + + listenerCtx, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + + eventsResult := cli.Events(listenerCtx, client.EventsListOptions{Filters: filterArgs}) + + trigger() + + seen := map[string]struct{}{} + + for { + select { + case msg, ok := <-eventsResult.Messages: + if !ok { + t.Fatalf("docker events channel closed before observing %q, seen=%v", wantAction, mapKeys(seen)) + } + + action := normalizeReconciliationEventAction(string(msg.Action)) + + seen[action] = struct{}{} + if action == wantAction { + return + } + case err, ok := <-eventsResult.Err: + if !ok { + t.Fatalf("docker events error channel closed before observing %q, seen=%v", wantAction, mapKeys(seen)) + } + + if err != nil { + t.Fatalf("docker events listener failed while waiting for %q: %v (seen=%v)", wantAction, err, mapKeys(seen)) + } + case <-listenerCtx.Done(): + t.Fatalf("timed out waiting for docker event %q, seen=%v", wantAction, mapKeys(seen)) + } + } +} + +func mapKeys(m map[string]struct{}) []string { + ret := make([]string, 0, len(m)) + for key := range m { + ret = append(ret, key) + } + + return ret +} + +func waitForBootMarkerCount(ctx context.Context, t *testing.T, stack *internaltest.ComposeStack, since time.Time, want int, timeout time.Duration) { + t.Helper() + + deadline := time.Now().Add(timeout) + + for { + if time.Now().After(deadline) { + got := bootMarkerCount(stack.ContainerLogs(ctx, t, "app", since)) + t.Fatalf("timed out waiting for %d boot markers, got %d", want, got) + } + + got := bootMarkerCount(stack.ContainerLogs(ctx, t, "app", since)) + if got >= want { + return + } + + time.Sleep(250 * time.Millisecond) + } +} + +func assertBootMarkerCountStable(ctx context.Context, t *testing.T, stack *internaltest.ComposeStack, since time.Time, expected int, duration time.Duration) { + t.Helper() + + deadline := time.Now().Add(duration) + + for { + got := bootMarkerCount(stack.ContainerLogs(ctx, t, "app", since)) + if got != expected { + t.Fatalf("expected boot marker count to remain %d, got %d", expected, got) + } + + if time.Now().After(deadline) { + return + } + + time.Sleep(250 * time.Millisecond) + } +} + +func bootMarkerCount(logs string) int { + return strings.Count(logs, "BOOT_MARKER") +} + +func runningServiceComposeYAML() string { + return `services: + app: + image: alpine:3.20 + restart: "no" + command: ["sh", "-c", "trap : TERM INT; while true; do sleep 1; done"] +` +} + +func oomServiceComposeYAML() string { + return `services: + app: + image: python:3.12-alpine + restart: "no" + mem_limit: 64m + command: ["python", "-c", "import time; time.sleep(3600)"] +` +} + +func unhealthyOnDemandComposeYAML() string { + return `services: + app: + image: alpine:3.20 + restart: "no" + command: ["sh", "-c", "touch /tmp/healthy; trap : TERM INT; while true; do sleep 1; done"] + healthcheck: + test: ["CMD-SHELL", "test -f /tmp/healthy"] + interval: 1s + timeout: 1s + retries: 1 + start_period: 1s +` +} + +func restartMarkerComposeYAML() string { + return `services: + app: + image: alpine:3.20 + restart: "no" + command: ["sh", "-c", "echo BOOT_MARKER; trap : TERM INT; while true; do sleep 1; done"] +` +} diff --git a/doco-cd-src/internal/reconciliation/reconciliation_restart.go b/doco-cd-src/internal/reconciliation/reconciliation_restart.go new file mode 100644 index 0000000..bfce12d --- /dev/null +++ b/doco-cd-src/internal/reconciliation/reconciliation_restart.go @@ -0,0 +1,311 @@ +package reconciliation + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/containerd/errdefs" + "github.com/moby/moby/api/types/events" + "github.com/moby/moby/client" + + deployConfig "github.com/kimdre/doco-cd/internal/config/deploy" + + "github.com/kimdre/doco-cd/internal/docker" + "github.com/kimdre/doco-cd/internal/docker/swarm" + "github.com/kimdre/doco-cd/internal/logger" + "github.com/kimdre/doco-cd/internal/notification" +) + +type restartAttemptResult struct { + fallbackToDeploy bool +} + +// restartContainer restarts a single container identified by the Docker event, +// using the restart timeout configured in the deploy config (default 10 s). +// Used for restart-oriented events where the container is still present. +func (j *job) restartContainer(ctx context.Context, jobLog *slog.Logger, event events.Message, dc *deployConfig.Config) restartAttemptResult { + containerID := event.Actor.ID + containerName := event.Actor.Attributes["name"] + restartOpts := restartOptionsFromDeployConfig(dc) + action := normalizeReconciliationEventAction(string(event.Action)) + + actorKind := restartNotificationActorKind() + actorKindTitle := restartNotificationActorKindTitle(actorKind) + + restartTimeout := 10 + if restartOpts.Timeout != nil { + restartTimeout = *restartOpts.Timeout + } + + restartLog := jobLog.With( + slog.Int("restart_timeout", restartTimeout), + ) + if restartOpts.Signal != "" { + restartLog = restartLog.With(slog.String("restart_signal", restartOpts.Signal)) + } + + if suppressed := j.shouldSuppressUnhealthyRestart(restartLog, event, dc); suppressed { + return restartAttemptResult{} + } + + j.markRestartFollowupSuppression(containerID, restartTimeout) + + restartLog.Info("restarting " + actorKind) + + metadata := restartNotificationMetadata(j.info.metadata, action, actorKind, containerID, containerName, reconciliationTraceIDFromEvent(event)) + + if _, err := j.info.dockerCli.Client().ContainerRestart(ctx, containerID, restartOpts); err != nil { + delete(j.restartSuppressUntil, containerID) + + if shouldFallbackToDeployOnRestartError(err) { + restartLog.Warn("container restart failed because the target is no longer restartable, falling back to redeploy", logger.ErrAttr(err)) + return restartAttemptResult{fallbackToDeploy: true} + } + + restartLog.Error("failed to restart container", logger.ErrAttr(err)) + + if notifyErr := notification.Send( + notification.Failure, + actorKindTitle+" restart failed", + fmt.Sprintf("%s %s (%s) could not be restarted on %q event: %s", actorKindTitle, containerName, shortID(containerID), action, err.Error()), + metadata, + ); notifyErr != nil { + restartLog.Error("failed to send restart failure notification", logger.ErrAttr(notifyErr)) + } + + return restartAttemptResult{} + } + + restartLog.Info(actorKind + " restarted successfully") + + if notifyErr := notification.Send( + notification.Success, + actorKindTitle+" restarted", + fmt.Sprintf("%s %s (%s) was restarted successfully on %q event", actorKindTitle, containerName, shortID(containerID), action), + metadata, + ); notifyErr != nil { + restartLog.Error("failed to send restart success notification", logger.ErrAttr(notifyErr)) + } + + return restartAttemptResult{} +} + +func shouldFallbackToDeployOnRestartError(err error) bool { + if err == nil { + return false + } + + if errdefs.IsNotFound(err) { + return true + } + + errText := strings.ToLower(strings.TrimSpace(err.Error())) + if strings.Contains(errText, "no such container") { + return true + } + + return strings.Contains(errText, "marked for removal") && strings.Contains(errText, "cannot be started") +} + +func restartNotificationMetadata(base notification.Metadata, action, actorKind, actorID, actorName, traceID string) notification.Metadata { + metadata := base + metadata.ReconciliationEvent = action + metadata.TraceID = strings.TrimSpace(traceID) + metadata.AffectedActorKind = actorKind + metadata.AffectedActorID = shortID(actorID) + metadata.AffectedActorName = strings.TrimSpace(actorName) + + return metadata +} + +func restartNotificationActorKind() string { + if swarm.GetModeEnabled() { + return "service" + } + + return "container" +} + +func restartNotificationActorKindTitle(actorKind string) string { + if actorKind == "" { + return "" + } + + return strings.ToUpper(actorKind[:1]) + actorKind[1:] +} + +func (j *job) shouldSuppressRestartFollowupEvent(action string, event events.Message) (bool, time.Duration) { + if !isRestartFollowupAction(action) { + return false, 0 + } + + containerID := event.Actor.ID + if containerID == "" { + return false, 0 + } + + until, ok := j.restartSuppressUntil[containerID] + if !ok { + return false, 0 + } + + now := time.Now() + if !now.Before(until) { + delete(j.restartSuppressUntil, containerID) + return false, 0 + } + + return true, until.Sub(now) +} + +func (j *job) markRestartFollowupSuppression(containerID string, timeoutSeconds int) { + if containerID == "" { + return + } + + suppressionWindow := restartFollowupSuppressionWindow(timeoutSeconds) + j.restartSuppressUntil[containerID] = time.Now().Add(suppressionWindow) +} + +func restartFollowupSuppressionWindow(timeoutSeconds int) time.Duration { + if timeoutSeconds < 0 { + timeoutSeconds = 0 + } + + // ContainerRestart may block up to the restart timeout before the follow-up die/stop/kill + // event is processed by this loop, so include the configured timeout plus a small buffer. + return time.Duration(timeoutSeconds)*time.Second + 10*time.Second +} + +func isRestartFollowupAction(action string) bool { + switch action { + case "die", "stop", "kill": + return true + default: + return false + } +} + +func (j *job) shouldSuppressUnhealthyRestart(jobLog *slog.Logger, event events.Message, dc *deployConfig.Config) bool { + action := normalizeReconciliationEventAction(string(event.Action)) + if action != "unhealthy" { + return false + } + + containerID := event.Actor.ID + if containerID == "" || dc == nil { + return false + } + + limit := dc.Reconciliation.RestartLimit + + windowSeconds := dc.Reconciliation.RestartWindow + if limit <= 0 || windowSeconds <= 0 { + return false + } + + now := time.Now() + window := time.Duration(windowSeconds) * time.Second + history := j.unhealthyRestartHistory[containerID] + suppressed, updatedHistory := evaluateUnhealthyRestartLimit(history, now, limit, window) + j.unhealthyRestartHistory[containerID] = updatedHistory + + if !suppressed { + return false + } + + msg := fmt.Sprintf("suppressed unhealthy auto-restart after %d restarts in %s", limit, window) + jobLog.Warn(msg, + slog.Int("restart_limit", limit), + slog.Int("restart_window_seconds", windowSeconds), + ) + + actorKind := restartNotificationActorKind() + metadata := restartNotificationMetadata(j.info.metadata, action, actorKind, containerID, event.Actor.Attributes["name"], reconciliationTraceIDFromEvent(event)) + + if notifyErr := notification.Send( + notification.Warning, + restartNotificationActorKindTitle(actorKind)+" restart suppressed", + msg, + metadata, + ); notifyErr != nil { + jobLog.Error("failed to send unhealthy restart suppression notification", logger.ErrAttr(notifyErr)) + } + + return true +} + +func restartOptionsFromDeployConfig(dc *deployConfig.Config) client.ContainerRestartOptions { + timeout := 10 + if dc != nil && dc.Reconciliation.RestartTimeout > 0 { + timeout = dc.Reconciliation.RestartTimeout + } + + opts := client.ContainerRestartOptions{Timeout: &timeout} + + if dc != nil { + opts.Signal = strings.TrimSpace(dc.Reconciliation.RestartSignal) + } + + return opts +} + +func selectRestartDeployConfig(dcs []*deployConfig.Config, labels map[string]string) *deployConfig.Config { + configHash := "" + if labels != nil { + configHash = strings.TrimSpace(labels[docker.DocoCDLabels.Deployment.ConfigHash]) + } + + if configHash != "" { + for _, dc := range dcs { + if dc == nil { + continue + } + + if strings.TrimSpace(dc.Internal.Hash) == configHash { + return dc + } + } + } + + for _, dc := range dcs { + if dc != nil { + return dc + } + } + + return nil +} + +func evaluateUnhealthyRestartLimit(history []time.Time, now time.Time, limit int, window time.Duration) (bool, []time.Time) { + if limit <= 0 || window <= 0 { + return false, history + } + + pruned := make([]time.Time, 0, len(history)+1) + for _, ts := range history { + if now.Sub(ts) < window { + pruned = append(pruned, ts) + } + } + + if len(pruned) >= limit { + return true, pruned + } + + pruned = append(pruned, now) + + return false, pruned +} + +func isRestartReconciliationAction(action string) bool { + switch action { + case "unhealthy", "oom", "kill", "stop": + return true + default: + return false + } +} diff --git a/doco-cd-src/internal/reconciliation/reconciliation_startup.go b/doco-cd-src/internal/reconciliation/reconciliation_startup.go new file mode 100644 index 0000000..9767a02 --- /dev/null +++ b/doco-cd-src/internal/reconciliation/reconciliation_startup.go @@ -0,0 +1,249 @@ +package reconciliation + +import ( + "context" + "log/slog" + "strings" + + "github.com/moby/moby/api/types/events" + "github.com/moby/moby/client" + + "github.com/kimdre/doco-cd/internal/config/app" + deployConfig "github.com/kimdre/doco-cd/internal/config/deploy" + + "github.com/kimdre/doco-cd/internal/docker" + "github.com/kimdre/doco-cd/internal/docker/swarm" + gitInternal "github.com/kimdre/doco-cd/internal/git" + "github.com/kimdre/doco-cd/internal/logger" + "github.com/kimdre/doco-cd/internal/utils/id" + "github.com/kimdre/doco-cd/internal/utils/set" +) + +func (j *job) restartUnhealthyContainersOnStartup(ctx context.Context, jobLog *slog.Logger) { + unhealthyDCs := j.deployConfigGroupByEvent["unhealthy"] + if len(unhealthyDCs) == 0 || swarm.GetModeEnabled() { + return + } + + repositoryLabelValue := gitInternal.GetFullName(string(j.info.repoData.CloneURL)) + if j.info.payload != nil && strings.TrimSpace(j.info.payload.FullName) != "" { + repositoryLabelValue = j.info.payload.FullName + } + + filterArgs := make(client.Filters) + filterArgs.Add("label", docker.DocoCDLabels.Metadata.Manager+"="+app.Name) + filterArgs.Add("label", docker.DocoCDLabels.Repository.Name+"="+repositoryLabelValue) + + containerResult, err := j.info.dockerCli.Client().ContainerList(ctx, client.ContainerListOptions{All: true, Filters: filterArgs}) + if err != nil { + jobLog.Error("failed to list containers for startup unhealthy scan", logger.ErrAttr(err)) + return + } + + for _, c := range containerResult.Items { + stackName := strings.TrimSpace(c.Labels[docker.DocoCDLabels.Deployment.Name]) + if stackName == "" { + continue + } + + stackDCs := deployConfigsByName(unhealthyDCs, stackName) + + restartDC := selectRestartDeployConfig(stackDCs, c.Labels) + if restartDC == nil { + continue + } + + inspectResult, err := j.info.dockerCli.Client().ContainerInspect(ctx, c.ID, client.ContainerInspectOptions{}) + if err != nil { + jobLog.Debug("failed to inspect container during startup unhealthy scan", + slog.String("container_id", shortID(c.ID)), + logger.ErrAttr(err), + ) + + continue + } + + inspect := inspectResult.Container + + if inspect.State == nil || inspect.State.Health == nil || strings.ToLower(strings.TrimSpace(string(inspect.State.Health.Status))) != "unhealthy" { + continue + } + + containerName := "" + if len(c.Names) > 0 { + containerName = strings.TrimPrefix(c.Names[0], "/") + } + + traceID := id.GenID() + + eventLog := logger. + WithoutAttr(jobLog, "job_id"). + With( + // Keep one trace ID for both logs and notifications for this reconciliation action. + slog.Group("reconciliation", + slog.String("event", "startup_unhealthy"), + slog.Group("container", + slog.String("id", shortID(c.ID)), + slog.String("name", containerName), + ), + slog.String("trace_id", traceID), + ), + slog.String("stack", stackName), + ) + + restartEvent := withReconciliationTraceID(events.Message{ + Action: events.Action("unhealthy"), + Actor: events.Actor{ + ID: c.ID, + Attributes: map[string]string{ + "name": containerName, + }, + }, + }, traceID) + + j.restartContainer(ctx, eventLog, restartEvent, restartDC) + } +} + +// uniqueRedeployDCsFromGroupByEvent returns a deduplicated slice (by stack name) of deploy configs +// that have at least one non-restart reconciliation event configured (e.g. "die", "destroy", "update"). +// These are the stacks that should be redeployed when their containers/services go missing. +func uniqueRedeployDCsFromGroupByEvent(grouped map[string][]*deployConfig.Config) []*deployConfig.Config { + seen := set.New[string]() + + var result []*deployConfig.Config + + for action, dcs := range grouped { + if isRestartReconciliationAction(action) { + continue + } + + for _, dc := range dcs { + if dc == nil { + continue + } + + if !seen.Contains(dc.Name) { + seen.Add(dc.Name) + result = append(result, dc) + } + } + } + + return result +} + +// redeployMissingServicesOnStartup performs a one-time startup check for stacks whose +// reconciliation is configured for redeploy-oriented events (e.g., "die", "destroy", "update") +// and triggers a redeploy for any stacks that are completely missing their containers/services. +func (j *job) redeployMissingServicesOnStartup(ctx context.Context, jobLog *slog.Logger) { + candidates := uniqueRedeployDCsFromGroupByEvent(j.deployConfigGroupByEvent) + if len(candidates) == 0 { + return + } + + var missingDCs []*deployConfig.Config + + if swarm.GetModeEnabled() { + missingDCs = j.findMissingSwarmServicesOnStartup(ctx, jobLog, candidates) + } else { + missingDCs = j.findMissingContainersOnStartup(ctx, jobLog, candidates) + } + + if len(missingDCs) == 0 { + return + } + + traceID := id.GenID() + + eventLog := logger. + WithoutAttr(jobLog, "job_id"). + With( + slog.Group("reconciliation", + slog.String("event", "startup_missing"), + slog.String("trace_id", traceID), + ), + ) + + j.deploy(ctx, eventLog, missingDCs, "startup_missing", events.Message{}, traceID) +} + +// findMissingContainersOnStartup lists all running containers for this repository and returns +// deploy configs whose stacks have no running containers at all. +func (j *job) findMissingContainersOnStartup(ctx context.Context, jobLog *slog.Logger, candidates []*deployConfig.Config) []*deployConfig.Config { + repositoryLabelValue := gitInternal.GetFullName(string(j.info.repoData.CloneURL)) + if j.info.payload != nil && strings.TrimSpace(j.info.payload.FullName) != "" { + repositoryLabelValue = j.info.payload.FullName + } + + filterArgs := make(client.Filters) + filterArgs.Add("label", docker.DocoCDLabels.Metadata.Manager+"="+app.Name) + filterArgs.Add("label", docker.DocoCDLabels.Repository.Name+"="+repositoryLabelValue) + + containerResult, err := j.info.dockerCli.Client().ContainerList(ctx, client.ContainerListOptions{ + All: false, // running only + Filters: filterArgs, + }) + if err != nil { + jobLog.Error("failed to list containers for startup missing scan", logger.ErrAttr(err)) + return nil + } + + runningStacks := set.New[string]() + + for _, c := range containerResult.Items { + if stackName := strings.TrimSpace(c.Labels[docker.DocoCDLabels.Deployment.Name]); stackName != "" { + runningStacks.Add(stackName) + } + } + + var missing []*deployConfig.Config + + for _, dc := range candidates { + if !runningStacks.Contains(dc.Name) { + jobLog.Debug("detected missing containers on startup", slog.String("stack", dc.Name)) + missing = append(missing, dc) + } + } + + return missing +} + +// findMissingSwarmServicesOnStartup lists all swarm services for this repository and returns +// deploy configs whose stacks have no deployed services at all. +func (j *job) findMissingSwarmServicesOnStartup(ctx context.Context, jobLog *slog.Logger, candidates []*deployConfig.Config) []*deployConfig.Config { + repositoryLabelValue := gitInternal.GetFullName(string(j.info.repoData.CloneURL)) + if j.info.payload != nil && strings.TrimSpace(j.info.payload.FullName) != "" { + repositoryLabelValue = j.info.payload.FullName + } + + services, err := swarm.GetServicesByLabel(ctx, j.info.dockerCli.Client(), docker.DocoCDLabels.Metadata.Manager, app.Name) + if err != nil { + jobLog.Error("failed to list swarm services for startup missing scan", logger.ErrAttr(err)) + return nil + } + + existingStacks := set.New[string]() + + for _, svc := range services { + // Filter by repository to avoid matching services from other repos on the same swarm. + if strings.TrimSpace(svc.Spec.Labels[docker.DocoCDLabels.Repository.Name]) != repositoryLabelValue { + continue + } + + if stackName := strings.TrimSpace(svc.Spec.Labels[docker.DocoCDLabels.Deployment.Name]); stackName != "" { + existingStacks.Add(stackName) + } + } + + var missing []*deployConfig.Config + + for _, dc := range candidates { + if !existingStacks.Contains(dc.Name) { + jobLog.Debug("detected missing swarm services on startup", slog.String("stack", dc.Name)) + missing = append(missing, dc) + } + } + + return missing +} diff --git a/doco-cd-src/internal/restapi/api_test.go b/doco-cd-src/internal/restapi/api_test.go index 3cb8572..e9a4519 100644 --- a/doco-cd-src/internal/restapi/api_test.go +++ b/doco-cd-src/internal/restapi/api_test.go @@ -4,13 +4,13 @@ import ( "net/http" "testing" - "github.com/kimdre/doco-cd/internal/config" + "github.com/kimdre/doco-cd/internal/config/app" ) func TestValidateApiKey(t *testing.T) { t.Parallel() - appConfig, err := config.GetAppConfig() + appConfig, err := app.GetConfig() if err != nil { t.Fatalf("Failed to get app config: %v", err) } diff --git a/doco-cd-src/internal/scheduler/scheduler.go b/doco-cd-src/internal/scheduler/scheduler.go new file mode 100644 index 0000000..67d3afb --- /dev/null +++ b/doco-cd-src/internal/scheduler/scheduler.go @@ -0,0 +1,878 @@ +package scheduler + +import ( + "context" + "errors" + "fmt" + "log/slog" + "maps" + "strconv" + "strings" + "sync" + "time" + + "github.com/docker/cli/cli/command" + "github.com/docker/compose/v5/pkg/api" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" + "github.com/robfig/cron/v3" + + "github.com/kimdre/doco-cd/internal/docker" + "github.com/kimdre/doco-cd/internal/docker/swarm" + "github.com/kimdre/doco-cd/internal/graceful" + "github.com/kimdre/doco-cd/internal/lock" + "github.com/kimdre/doco-cd/internal/logger" + "github.com/kimdre/doco-cd/internal/notification" + "github.com/kimdre/doco-cd/internal/prometheus" + "github.com/kimdre/doco-cd/internal/utils/id" +) + +const ( + schedulerEventReconnectDelay = time.Second + schedulerRefreshRetryDelay = time.Second +) + +var ( + ErrScheduledJobNotFound = errors.New("scheduled job not found") + ErrScheduledJobDisabled = errors.New("scheduled job is disabled") + ErrScheduledJobAmbiguous = errors.New("multiple scheduled jobs matched, narrow your selection") + + runtimeStatesMu sync.RWMutex + runtimeStates = map[string]scheduledJobState{} +) + +type scheduledJobMode string + +const ( + scheduledJobModeContainer scheduledJobMode = "container" + scheduledjobModeSwarm scheduledJobMode = "swarm" +) + +type scheduledJob struct { + key string + name string + id string + mode scheduledJobMode + labels map[string]string +} + +type scheduledJobState struct { + fingerprint string + schedule cron.Schedule + lastRun time.Time + nextRun time.Time + cfg docker.JobScheduleConfig +} + +type scheduler struct { + dockerCli command.Cli + log *slog.Logger + wg *sync.WaitGroup + + states map[string]scheduledJobState + + runningMu sync.Mutex + running map[string]bool +} + +// JobInfo describes one scheduler-managed target and its runtime scheduling status. +type JobInfo struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Stack string `json:"stack,omitempty"` + Mode string `json:"mode"` + Schedule string `json:"schedule,omitempty"` + ExecutionMode docker.JobExecutionMode `json:"execution_mode,omitempty"` + SkipRunning bool `json:"skip_running"` + NotifyOn docker.JobNotifyOn `json:"notify_on,omitempty"` + Replicas uint64 `json:"replicas,omitempty"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + NextRunAt *time.Time `json:"next_run_at,omitempty"` + LabelNextRunAt *time.Time `json:"label_next_run_at,omitempty"` + Repository string `json:"repository,omitempty"` + ScheduleError string `json:"schedule_error,omitempty"` + Valid bool `json:"valid"` +} + +func Start(ctx context.Context, dockerCli command.Cli, log *slog.Logger, wg *sync.WaitGroup) { + if dockerCli == nil || log == nil || wg == nil { + return + } + + s := &scheduler{ + dockerCli: dockerCli, + log: log.With(slog.String("component", "scheduler")), + wg: wg, + states: map[string]scheduledJobState{}, + running: map[string]bool{}, + } + + s.run(ctx) +} + +// ListJobs returns all discovered scheduler jobs, optionally filtered by stack name. +func ListJobs(ctx context.Context, dockerCli command.Cli, stackName string) ([]JobInfo, error) { + if dockerCli == nil { + return nil, errors.New("docker cli is required") + } + + s := &scheduler{dockerCli: dockerCli} + + jobs, err := s.discoverJobs(ctx) + if err != nil { + return nil, fmt.Errorf("failed to discover scheduled jobs: %w", err) + } + + now := schedulerNow() + stackName = strings.TrimSpace(stackName) + result := make([]JobInfo, 0, len(jobs)) + states := getRuntimeStatesSnapshot() + + for _, job := range jobs { + stack := getJobStackName(job) + if stackName != "" && stack != stackName { + continue + } + + info := JobInfo{ + Name: job.name, + Stack: stack, + Mode: string(job.mode), + Repository: job.labels[docker.DocoCDLabels.Repository.Name], + Valid: true, + } + + info.LastRunAt = parseRFC3339Time(job.labels[docker.DocoCDJobLabels.JobLastRun]) + info.LabelNextRunAt = parseRFC3339Time(job.labels[docker.DocoCDJobLabels.JobNextRun]) + + cfg, enabled, parseErr := docker.ParseJobScheduleLabels(job.labels, s.log) + if parseErr != nil { + info.Valid = false + info.ScheduleError = parseErr.Error() + result = append(result, info) + + continue + } + + info.Enabled = enabled + if !enabled { + result = append(result, info) + continue + } + + info.Schedule = cfg.Schedule + info.ExecutionMode = cfg.ExecutionMode + info.SkipRunning = cfg.SkipRunning + info.NotifyOn = cfg.NotifyOn + info.Replicas = cfg.SwarmReplicas + + schedule, scheduleErr := docker.ParseJobScheduleExpression(cfg.Schedule) + if scheduleErr != nil { + info.Valid = false + info.ScheduleError = scheduleErr.Error() + result = append(result, info) + + continue + } + + nextRun := schedule.Next(now) + if state, ok := states[job.key]; ok && !state.nextRun.IsZero() { + if !state.lastRun.IsZero() { + info.LastRunAt = new(state.lastRun) + } + + nextRun = state.nextRun + } + + info.NextRunAt = &nextRun + + result = append(result, info) + } + + return result, nil +} + +// TriggerNow executes one configured scheduled job immediately. +// Job selection matches by container/service name and optional stack name. +func TriggerNow(ctx context.Context, dockerCli command.Cli, log *slog.Logger, jobName, stackName string) (string, error) { + if dockerCli == nil { + return "", errors.New("docker cli is required") + } + + if strings.TrimSpace(jobName) == "" { + return "", errors.New("job name is required") + } + + if log == nil { + log = slog.Default() + } + + s := &scheduler{ + dockerCli: dockerCli, + log: log.With(slog.String("component", "scheduler")), + } + + jobs, err := s.discoverJobs(ctx) + if err != nil { + return "", fmt.Errorf("failed to discover scheduled jobs: %w", err) + } + + job, cfg, err := findRunnableJob(jobs, strings.TrimSpace(jobName), strings.TrimSpace(stackName)) + if err != nil { + return "", err + } + + runID := id.GenID() + runLog := s.log.With( + slog.String("job_id", runID), + slog.String("job", job.name), + slog.String("stack", getJobStackName(job)), + slog.String("mode", string(job.mode)), + slog.String("execution_mode", string(cfg.ExecutionMode)), + ) + + runLog.Info("triggering scheduled run via API") + + lock.LockScheduledDeploy() + + defer lock.UnlockScheduledDeploy() + + err = s.executeScheduledRun(ctx, job, cfg) + setRuntimeLastRun(job.key, schedulerNow()) + + if err != nil { + runLog.Error("scheduled run failed", logger.ErrAttr(err)) + s.sendRunNotification(job, cfg, runID, false, "Scheduled job failed", fmt.Sprintf("scheduled job '%s' failed to run: %v", job.name, err)) + + return runID, err + } + + runLog.Info("scheduled run completed") + s.sendRunNotification(job, cfg, runID, true, "Scheduled job completed", fmt.Sprintf("scheduled job '%s' completed successfully", job.name)) + + return runID, nil +} + +func findRunnableJob(jobs []scheduledJob, jobName, stackName string) (scheduledJob, docker.JobScheduleConfig, error) { + var ( + matchedJob scheduledJob + matchedCfg docker.JobScheduleConfig + matches int + ) + + for _, job := range jobs { + if job.name != jobName { + continue + } + + if stackName != "" && getJobStackName(job) != stackName { + continue + } + + cfg, enabled, err := docker.ParseJobScheduleLabels(job.labels) + if err != nil { + return scheduledJob{}, docker.JobScheduleConfig{}, fmt.Errorf("job %q has invalid schedule labels: %w", jobName, err) + } + + if !enabled { + return scheduledJob{}, docker.JobScheduleConfig{}, ErrScheduledJobDisabled + } + + matchedJob = job + matchedCfg = cfg + matches++ + } + + if matches == 0 { + return scheduledJob{}, docker.JobScheduleConfig{}, ErrScheduledJobNotFound + } + + if matches > 1 { + return scheduledJob{}, docker.JobScheduleConfig{}, ErrScheduledJobAmbiguous + } + + return matchedJob, matchedCfg, nil +} + +func (s *scheduler) run(ctx context.Context) { + jobChanges := s.watchJobChanges(ctx) + timer := time.NewTimer(time.Hour) + + stopTimer(timer) + defer timer.Stop() + + s.log.Info("starting scheduler") + + nextRun, hasNextRun := s.refreshJobs(ctx, schedulerNow()) + + for { + setTimerToNextRun(timer, schedulerNow(), nextRun, hasNextRun) + + select { + case <-ctx.Done(): + s.log.Info("scheduler stopped") + return + case _, ok := <-jobChanges: + if !ok { + jobChanges = nil + continue + } + + nextRun, hasNextRun = s.refreshJobs(ctx, schedulerNow()) + case t := <-timer.C: + nextRun, hasNextRun = s.refreshJobs(ctx, t) + } + } +} + +func (s *scheduler) refreshJobs(ctx context.Context, now time.Time) (time.Time, bool) { + jobs, err := s.discoverJobs(ctx) + if err != nil { + s.log.Error("failed to discover scheduled jobs", logger.ErrAttr(err)) + return now.Add(schedulerRefreshRetryDelay), true + } + + active := make(map[string]struct{}, len(jobs)) + discoveredByKey := make(map[string]scheduledJob, len(jobs)) + + var nearestNextRun time.Time + + for _, job := range jobs { + discoveredByKey[job.key] = job + + cfg, enabled, parseErr := docker.ParseJobScheduleLabels(job.labels, s.log) + if parseErr != nil { + s.log.Warn("ignoring job with invalid schedule labels", + slog.String("job", job.name), + slog.String("mode", string(job.mode)), + logger.ErrAttr(parseErr), + ) + + continue + } + + if !enabled { + continue + } + + active[job.key] = struct{}{} + + fingerprint := getScheduleFingerprint(cfg) + + state, ok := s.states[job.key] + if !ok || state.fingerprint != fingerprint { + schedule, scheduleErr := docker.ParseJobScheduleExpression(cfg.Schedule) + if scheduleErr != nil { + s.log.Warn("ignoring job with invalid schedule", + slog.String("job", job.name), + slog.String("schedule", cfg.Schedule), + logger.ErrAttr(scheduleErr), + ) + + continue + } + + state = scheduledJobState{ + fingerprint: fingerprint, + schedule: schedule, + nextRun: schedule.Next(now), + cfg: cfg, + } + + s.states[job.key] = state + s.log.Info("job scheduled", + slog.String("job", job.name), + slog.String("mode", string(job.mode)), + slog.String("schedule", cfg.Schedule), + slog.String("next_run", state.nextRun.Format(time.RFC3339)), + ) + } + + if !now.Before(state.nextRun) { + scheduledAt := state.nextRun + state.lastRun = scheduledAt + state.nextRun = nextScheduledRun(state.schedule, scheduledAt, now) + s.states[job.key] = state + + s.triggerRun(context.WithoutCancel(ctx), job, state.cfg, scheduledAt) + } + + if nearestNextRun.IsZero() || state.nextRun.Before(nearestNextRun) { + nearestNextRun = state.nextRun + } + } + + for key := range s.states { + if _, exists := active[key]; !exists { + if job, ok := discoveredByKey[key]; ok { + s.log.Info("job unscheduled", + slog.String("job", job.name), + slog.String("stack", getJobStackName(job)), + slog.String("mode", string(job.mode)), + slog.String("reason", "disabled"), + ) + } else { + s.log.Info("job unscheduled", + slog.String("job_key", key), + slog.String("reason", "removed"), + ) + } + + delete(s.states, key) + } + } + + if nearestNextRun.IsZero() { + nearestNextRun, _ = getNearestNextRun(s.states) + } + + setRuntimeStatesSnapshot(s.states) + + return nearestNextRun, !nearestNextRun.IsZero() +} + +func (s *scheduler) watchJobChanges(ctx context.Context) <-chan struct{} { + changes := make(chan struct{}, 1) + + graceful.SafeGo(s.wg, s.log, func() { + defer close(changes) + + for ctx.Err() == nil { + filters := make(client.Filters) + if swarm.GetModeEnabled() { + filters.Add("type", "service") + + for _, action := range []string{"create", "update", "remove"} { + filters.Add("event", action) + } + } else { + filters.Add("type", "container") + + for _, action := range []string{"create", "start", "rename", "destroy"} { + filters.Add("event", action) + } + } + + eventResult := s.dockerCli.Client().Events(ctx, client.EventsListOptions{Filters: filters}) + + reconnect := false + for !reconnect { + select { + case <-ctx.Done(): + return + case _, ok := <-eventResult.Messages: + if !ok { + reconnect = true + continue + } + + s.notifyJobChange(changes) + case err, ok := <-eventResult.Err: + if !ok { + reconnect = true + continue + } + + if err != nil && ctx.Err() == nil { + s.log.Debug("scheduler job change listener error", logger.ErrAttr(err)) + } + + reconnect = true + } + } + + select { + case <-ctx.Done(): + return + case <-time.After(schedulerEventReconnectDelay): + } + } + }) + + return changes +} + +func (s *scheduler) notifyJobChange(changes chan<- struct{}) { + select { + case changes <- struct{}{}: + default: + } +} + +func (s *scheduler) discoverJobs(ctx context.Context) ([]scheduledJob, error) { + if s.dockerCli == nil { + return nil, nil + } + + if swarm.GetModeEnabled() { + services, err := s.dockerCli.Client().ServiceList(ctx, client.ServiceListOptions{}) + if err != nil { + return nil, err + } + + result := make([]scheduledJob, 0, len(services.Items)) + for _, svc := range services.Items { + labels := map[string]string{} + if svc.Spec.TaskTemplate.ContainerSpec != nil && svc.Spec.TaskTemplate.ContainerSpec.Labels != nil { + labels = svc.Spec.TaskTemplate.ContainerSpec.Labels + } + + result = append(result, scheduledJob{ + key: "swarm:" + svc.ID, + name: svc.Spec.Name, + id: svc.Spec.Name, + mode: scheduledjobModeSwarm, + labels: labels, + }) + } + + return result, nil + } + + containers, err := s.dockerCli.Client().ContainerList(ctx, client.ContainerListOptions{ + All: true, + Filters: make(client.Filters).Add("label", docker.DocoCDJobLabels.JobEnabled), + }) + if err != nil { + return nil, err + } + + jobByKey := make(map[string]scheduledJob) + + for _, c := range containers.Items { + name := strings.TrimPrefix(firstContainerName(c.Names), "/") + if name == "" { + name = c.ID[:12] + } + + service := c.Labels[api.ServiceLabel] + project := c.Labels[api.ProjectLabel] + + key := "container:" + c.ID + if project != "" && service != "" { + key = "container:" + project + "/" + service + } + + existing, exists := jobByKey[key] + if exists && existing.id != "" && c.State != container.StateRunning { + continue + } + + jobByKey[key] = scheduledJob{ + key: key, + name: name, + id: c.ID, + mode: scheduledJobModeContainer, + labels: c.Labels, + } + } + + result := make([]scheduledJob, 0, len(jobByKey)) + for _, job := range jobByKey { + result = append(result, job) + } + + return result, nil +} + +func (s *scheduler) triggerRun(ctx context.Context, job scheduledJob, cfg docker.JobScheduleConfig, now time.Time) { + stackName := getJobStackName(job) + metricLabels := getScheduledRunMetricLabels(job, cfg, stackName) + + if cfg.SkipRunning && s.isRunInProgress(job.key) { + s.log.Warn("skipping scheduled run because previous run is still in progress", + slog.String("job", job.name), + slog.String("stack", stackName), + slog.String("mode", string(job.mode)), + ) + + prometheus.ScheduledRunSkippedTotal.WithLabelValues(append(metricLabels, "still_running")...).Inc() + + return + } + + s.setRunInProgress(job.key, true) + + graceful.SafeGo(s.wg, s.log, func() { + defer s.setRunInProgress(job.key, false) + + runStart := time.Now() + runFailed := false + + prometheus.ScheduledRunsActive.WithLabelValues(metricLabels...).Inc() + defer prometheus.ScheduledRunsActive.WithLabelValues(metricLabels...).Dec() + defer func() { + prometheus.ScheduledRunDuration.WithLabelValues(metricLabels...).Observe(time.Since(runStart).Seconds()) + }() + defer prometheus.ScheduledRunsTotal.WithLabelValues(metricLabels...).Inc() + defer func() { + if runFailed { + prometheus.ScheduledRunErrorsTotal.WithLabelValues(metricLabels...).Inc() + } + }() + + runID := id.GenID() + + runLog := s.log.With( + slog.String("job_id", runID), + slog.String("job", job.name), + slog.String("stack", stackName), + slog.String("mode", string(job.mode)), + slog.String("execution_mode", string(cfg.ExecutionMode)), + slog.String("scheduled_at", now.Format(time.RFC3339)), + ) + + runLog.Debug("waiting for scheduler/deploy lock") + lock.LockScheduledDeploy() + + defer lock.UnlockScheduledDeploy() + + runLog.Debug("acquired scheduler/deploy lock") + + runLog.Debug("triggering scheduled run") + + err := s.executeScheduledRun(ctx, job, cfg) + if err != nil { + runFailed = true + + runLog.Error("scheduled run failed", logger.ErrAttr(err)) + s.sendRunNotification(job, cfg, runID, false, "Scheduled job failed", fmt.Sprintf("scheduled job '%s' failed to run: %v", job.name, err)) + + return + } + + runLog.Info("scheduled run completed", slog.String("next_run", s.states[job.key].nextRun.Format(time.RFC3339))) + s.sendRunNotification(job, cfg, runID, true, "Scheduled job completed", fmt.Sprintf("scheduled job '%s' completed successfully", job.name)) + }) +} + +func (s *scheduler) executeScheduledRun(ctx context.Context, job scheduledJob, cfg docker.JobScheduleConfig) error { + switch job.mode { + case scheduledJobModeContainer: + switch cfg.ExecutionMode { + case docker.JobExecutionModeOneOff: + return docker.RunContainerOneOffFromExisting(ctx, s.dockerCli.Client(), job.id) + default: + return docker.RestartContainer(ctx, s.dockerCli.Client(), job.id) + } + case scheduledjobModeSwarm: + switch cfg.ExecutionMode { + case docker.JobExecutionModeOneOff: + return docker.RunSwarmOneOffFromService(ctx, s.dockerCli, job.id, docker.SwarmOneOffFromServiceOptions{ + Replicas: cfg.SwarmReplicas, + SendRegistryAuth: true, + }) + default: + err := docker.RerunJobService(ctx, s.dockerCli.Client(), job.id) + if err == nil { + return nil + } + + if errors.Is(err, docker.ErrNotAJobService) { + return docker.RestartService(ctx, s.dockerCli.Client(), job.id) + } + + return err + } + default: + return fmt.Errorf("unsupported scheduled job mode %q", job.mode) + } +} + +func (s *scheduler) sendRunNotification(job scheduledJob, cfg docker.JobScheduleConfig, runID string, success bool, title, msg string) { + shouldSend := cfg.ShouldNotifyFailure() + lvl := notification.Failure + + if success { + shouldSend = cfg.ShouldNotifySuccess() + lvl = notification.Success + } + + if !shouldSend { + return + } + + actorKind := "container" + if job.mode == scheduledjobModeSwarm { + actorKind = "service" + } + + metadata := notification.Metadata{ + Repository: job.labels[docker.DocoCDLabels.Repository.Name], + Stack: job.labels[docker.DocoCDLabels.Deployment.Name], + Revision: notification.GetRevision("", job.labels[docker.DocoCDLabels.Deployment.CommitSHA]), + JobID: runID, + AffectedActorKind: actorKind, + AffectedActorName: job.name, + } + + if err := notification.Send(lvl, title, msg, metadata); err != nil { + s.log.Error("failed to send scheduled job notification", logger.ErrAttr(err), slog.String("job", job.name)) + } +} + +func (s *scheduler) isRunInProgress(key string) bool { + s.runningMu.Lock() + defer s.runningMu.Unlock() + + return s.running[key] +} + +func (s *scheduler) setRunInProgress(key string, inProgress bool) { + s.runningMu.Lock() + defer s.runningMu.Unlock() + + if inProgress { + s.running[key] = true + return + } + + delete(s.running, key) +} + +func getScheduleFingerprint(cfg docker.JobScheduleConfig) string { + return strings.Join([]string{ + cfg.Schedule, + string(cfg.ExecutionMode), + strconv.FormatBool(cfg.SkipRunning), + string(cfg.NotifyOn), + strconv.FormatUint(cfg.SwarmReplicas, 10), + }, "|") +} + +func getScheduledRunMetricLabels(job scheduledJob, cfg docker.JobScheduleConfig, stackName string) []string { + return []string{stackName, job.name, string(job.mode), string(cfg.ExecutionMode)} +} + +func nextScheduledRun(schedule cron.Schedule, scheduledAt, now time.Time) time.Time { + nextRun := schedule.Next(scheduledAt) + for !now.Before(nextRun) { + nextRun = schedule.Next(nextRun) + } + + return nextRun +} + +func getNearestNextRun(states map[string]scheduledJobState) (time.Time, bool) { + var nearest time.Time + + for _, state := range states { + if state.nextRun.IsZero() { + continue + } + + if nearest.IsZero() || state.nextRun.Before(nearest) { + nearest = state.nextRun + } + } + + return nearest, !nearest.IsZero() +} + +func setTimerToNextRun(timer *time.Timer, now, nextRun time.Time, enabled bool) { + stopTimer(timer) + + if !enabled { + return + } + + delay := time.Until(nextRun) + if !nextRun.IsZero() { + delay = nextRun.Sub(now) + } + + if delay < 0 { + delay = 0 + } + + timer.Reset(delay) +} + +func stopTimer(timer *time.Timer) { + if timer == nil { + return + } + + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } +} + +func getJobStackName(job scheduledJob) string { + if stack := strings.TrimSpace(job.labels[docker.DocoCDLabels.Deployment.Name]); stack != "" { + return stack + } + + if stack := strings.TrimSpace(job.labels[swarm.StackNamespaceLabel]); stack != "" { + return stack + } + + if stack := strings.TrimSpace(job.labels[api.ProjectLabel]); stack != "" { + return stack + } + + return "" +} + +func firstContainerName(names []string) string { + if len(names) == 0 { + return "" + } + + return names[0] +} + +func parseRFC3339Time(raw string) *time.Time { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + t, err := time.Parse(time.RFC3339, raw) + if err != nil { + return nil + } + + return new(t.UTC()) +} + +// schedulerNow returns the current time in local timezone for consistent scheduling behavior regardless of host timezone settings. +func schedulerNow() time.Time { + return time.Now().In(time.Local) +} + +func setRuntimeStatesSnapshot(states map[string]scheduledJobState) { + runtimeStatesMu.Lock() + defer runtimeStatesMu.Unlock() + + next := make(map[string]scheduledJobState, len(states)) + maps.Copy(next, states) + + runtimeStates = next +} + +func getRuntimeStatesSnapshot() map[string]scheduledJobState { + runtimeStatesMu.RLock() + defer runtimeStatesMu.RUnlock() + + ret := make(map[string]scheduledJobState, len(runtimeStates)) + maps.Copy(ret, runtimeStates) + + return ret +} + +func setRuntimeLastRun(key string, lastRun time.Time) { + if key == "" { + return + } + + runtimeStatesMu.Lock() + defer runtimeStatesMu.Unlock() + + state := runtimeStates[key] + state.lastRun = lastRun + runtimeStates[key] = state +} diff --git a/doco-cd-src/internal/scheduler/scheduler_test.go b/doco-cd-src/internal/scheduler/scheduler_test.go new file mode 100644 index 0000000..cbe2542 --- /dev/null +++ b/doco-cd-src/internal/scheduler/scheduler_test.go @@ -0,0 +1,224 @@ +package scheduler + +import ( + "errors" + "testing" + "time" + + "github.com/docker/compose/v5/pkg/api" + + "github.com/kimdre/doco-cd/internal/docker" + "github.com/kimdre/doco-cd/internal/docker/swarm" +) + +func TestNextScheduledRun_PreservesScheduleAlignment(t *testing.T) { + t.Parallel() + + schedule, err := docker.ParseJobScheduleExpression("@every 1m") + if err != nil { + t.Fatalf("ParseJobScheduleExpression() failed: %v", err) + } + + scheduledAt := time.Date(2026, time.May, 9, 12, 0, 0, 0, time.UTC) + now := scheduledAt.Add(250 * time.Millisecond) + + got := nextScheduledRun(schedule, scheduledAt, now) + + want := scheduledAt.Add(time.Minute) + if !got.Equal(want) { + t.Fatalf("nextScheduledRun() = %s, want %s", got.Format(time.RFC3339Nano), want.Format(time.RFC3339Nano)) + } +} + +func TestNextScheduledRun_SkipsMissedRunsWithoutDrift(t *testing.T) { + t.Parallel() + + schedule, err := docker.ParseJobScheduleExpression("@every 1m") + if err != nil { + t.Fatalf("ParseJobScheduleExpression() failed: %v", err) + } + + scheduledAt := time.Date(2026, time.May, 9, 12, 0, 0, 0, time.UTC) + now := scheduledAt.Add(3*time.Minute + 30*time.Second) + + got := nextScheduledRun(schedule, scheduledAt, now) + + want := time.Date(2026, time.May, 9, 12, 4, 0, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("nextScheduledRun() = %s, want %s", got.Format(time.RFC3339Nano), want.Format(time.RFC3339Nano)) + } +} + +func TestGetNearestNextRun(t *testing.T) { + t.Parallel() + + want := time.Date(2026, time.May, 9, 12, 1, 0, 0, time.UTC) + + got, ok := getNearestNextRun(map[string]scheduledJobState{ + "later": { + nextRun: time.Date(2026, time.May, 9, 12, 5, 0, 0, time.UTC), + }, + "earlier": { + nextRun: want, + }, + "zero": {}, + }) + if !ok { + t.Fatalf("getNearestNextRun() reported no next run") + } + + if !got.Equal(want) { + t.Fatalf("getNearestNextRun() = %s, want %s", got.Format(time.RFC3339Nano), want.Format(time.RFC3339Nano)) + } +} + +func TestGetJobStackName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + labels map[string]string + want string + }{ + { + name: "deployment label has priority", + labels: map[string]string{ + docker.DocoCDLabels.Deployment.Name: "doco-stack", + api.ProjectLabel: "compose-project", + swarm.StackNamespaceLabel: "swarm-stack", + }, + want: "doco-stack", + }, + { + name: "swarm namespace fallback", + labels: map[string]string{ + swarm.StackNamespaceLabel: "swarm-stack", + }, + want: "swarm-stack", + }, + { + name: "compose project fallback", + labels: map[string]string{ + api.ProjectLabel: "compose-project", + }, + want: "compose-project", + }, + { + name: "missing labels", + labels: map[string]string{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := getJobStackName(scheduledJob{labels: tt.labels}) + if got != tt.want { + t.Fatalf("getJobStackName() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFindRunnableJob(t *testing.T) { + t.Parallel() + + validLabels := map[string]string{ + docker.DocoCDJobLabels.JobEnabled: "true", + docker.DocoCDJobLabels.JobSchedule: "@every 1m", + } + + tests := []struct { + name string + jobs []scheduledJob + jobName string + stackName string + wantErr error + }{ + { + name: "single matching job", + jobs: []scheduledJob{ + {name: "stack-backup-1", labels: validLabels}, + }, + jobName: "stack-backup-1", + }, + { + name: "stack filter avoids ambiguity", + jobs: []scheduledJob{ + {name: "backup", labels: map[string]string{docker.DocoCDJobLabels.JobEnabled: "true", docker.DocoCDJobLabels.JobSchedule: "@every 1m", api.ProjectLabel: "stack-a"}}, + {name: "backup", labels: map[string]string{docker.DocoCDJobLabels.JobEnabled: "true", docker.DocoCDJobLabels.JobSchedule: "@every 1m", api.ProjectLabel: "stack-b"}}, + }, + jobName: "backup", + stackName: "stack-a", + }, + { + name: "job not found", + jobs: []scheduledJob{ + {name: "other", labels: validLabels}, + }, + jobName: "backup", + wantErr: ErrScheduledJobNotFound, + }, + { + name: "job disabled", + jobs: []scheduledJob{ + {name: "backup", labels: map[string]string{docker.DocoCDJobLabels.JobEnabled: "false", docker.DocoCDJobLabels.JobSchedule: "@every 1m"}}, + }, + jobName: "backup", + wantErr: ErrScheduledJobDisabled, + }, + { + name: "ambiguous job name", + jobs: []scheduledJob{ + {name: "backup", labels: validLabels}, + {name: "backup", labels: validLabels}, + }, + jobName: "backup", + wantErr: ErrScheduledJobAmbiguous, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, _, err := findRunnableJob(tt.jobs, tt.jobName, tt.stackName) + if tt.wantErr == nil && err != nil { + t.Fatalf("findRunnableJob() unexpected error = %v", err) + } + + if tt.wantErr != nil && !errors.Is(err, tt.wantErr) { + t.Fatalf("findRunnableJob() error = %v, want %v", err, tt.wantErr) + } + }) + } +} + +func TestParseJobScheduleExpression_NextRunUsesLocalTimezone_Berlin(t *testing.T) { + berlin, err := time.LoadLocation("Europe/Berlin") + if err != nil { + t.Fatalf("time.LoadLocation() failed: %v", err) + } + + originalLocal := time.Local + time.Local = berlin + + t.Cleanup(func() { + time.Local = originalLocal + }) + + schedule, err := docker.ParseJobScheduleExpression("0 */6 * * *") + if err != nil { + t.Fatalf("ParseJobScheduleExpression() failed: %v", err) + } + + now := time.Date(2026, time.May, 11, 0, 30, 0, 0, time.Local) + got := schedule.Next(now) + want := time.Date(2026, time.May, 11, 6, 0, 0, 0, time.Local) + + if !got.Equal(want) { + t.Fatalf("schedule.Next() = %s, want %s", got.Format(time.RFC3339), want.Format(time.RFC3339)) + } +} diff --git a/doco-cd-src/internal/secretprovider/1password/cache.go b/doco-cd-src/internal/secretprovider/1password/cache.go new file mode 100644 index 0000000..db3cc18 --- /dev/null +++ b/doco-cd-src/internal/secretprovider/1password/cache.go @@ -0,0 +1,145 @@ +package onepassword + +import ( + "container/list" + "sync" + "time" +) + +type cacheEntry struct { + value string + expiresAt time.Time +} + +type Cache struct { + enabled bool + ttl time.Duration + maxSize int + mu sync.RWMutex + entries map[string]cacheEntry + order *list.List + nodes map[string]*list.Element +} + +// NewCache creates a new cache instance with the given configuration. +func NewCache(enabled bool, ttl time.Duration, maxSize int) *Cache { + return &Cache{ + enabled: enabled, + ttl: ttl, + maxSize: maxSize, + entries: make(map[string]cacheEntry), + order: list.New(), + nodes: make(map[string]*list.Element), + } +} + +// Get retrieves a cached value by key if it exists and hasn't expired. +// Returns the value and true if found and valid, empty string and false otherwise. +func (c *Cache) Get(key string) (string, bool) { + if !c.enabled { + return "", false + } + + now := time.Now() + + c.mu.Lock() + defer c.mu.Unlock() + + entry, ok := c.entries[key] + + if !ok { + return "", false + } + + if now.After(entry.expiresAt) { + if current, exists := c.entries[key]; exists && now.After(current.expiresAt) { + c.deleteEntry(key) + } + + return "", false + } + + c.touchEntry(key) + + return entry.value, true +} + +// Set stores a value in the cache with an expiration time based on the configured TTL. +func (c *Cache) Set(key, value string) { + if !c.enabled { + return + } + + c.mu.Lock() + defer c.mu.Unlock() + + if c.entries == nil { + c.entries = make(map[string]cacheEntry) + } + + if c.order == nil { + c.order = list.New() + } + + if c.nodes == nil { + c.nodes = make(map[string]*list.Element) + } + + if _, exists := c.entries[key]; !exists && c.maxSize > 0 && len(c.entries) >= c.maxSize { + c.evictLeastRecentlyUsed() + } + + c.entries[key] = cacheEntry{value: value, expiresAt: time.Now().Add(c.ttl)} + c.touchEntry(key) +} + +// touchEntry moves the given key to the front of the LRU order list. +// Must be called while holding the lock. +func (c *Cache) touchEntry(key string) { + if c.order == nil { + c.order = list.New() + } + + if c.nodes == nil { + c.nodes = make(map[string]*list.Element) + } + + if node, ok := c.nodes[key]; ok { + c.order.MoveToFront(node) + return + } + + c.nodes[key] = c.order.PushFront(key) +} + +// deleteEntry removes a key from the cache. +// Must be called while holding the lock. +func (c *Cache) deleteEntry(key string) { + delete(c.entries, key) + + if node, ok := c.nodes[key]; ok { + c.order.Remove(node) + delete(c.nodes, key) + } +} + +// evictLeastRecentlyUsed removes the least recently used item from the cache. +// Must be called while holding the lock. +func (c *Cache) evictLeastRecentlyUsed() { + if c.order == nil { + return + } + + leastRecent := c.order.Back() + if leastRecent == nil { + return + } + + key, ok := leastRecent.Value.(string) + if !ok { + c.order.Remove(leastRecent) + return + } + + c.deleteEntry(key) +} diff --git a/doco-cd-src/internal/secretprovider/1password/client.go b/doco-cd-src/internal/secretprovider/1password/client.go index 7c306c6..54687cd 100644 --- a/doco-cd-src/internal/secretprovider/1password/client.go +++ b/doco-cd-src/internal/secretprovider/1password/client.go @@ -3,10 +3,6 @@ package onepassword import ( "context" "errors" - "fmt" - "strings" - - "github.com/1password/onepassword-sdk-go" secrettypes "github.com/kimdre/doco-cd/internal/secretprovider/types" ) @@ -17,103 +13,111 @@ const ( var ErrInvalidClientID = errors.New("invalid client id") +type authMode string + +const ( + authModeServiceAccount authMode = "service_account" + authModeConnect authMode = "connect" +) + type Provider struct { - Client *onepassword.Client - accessToken string - version string + mode authMode + + serviceClient *serviceAccountClient + connectClient connectClient + + accessToken string + connectHost string + connectToken string + version string + + cache *Cache } func (p *Provider) Name() string { return Name } -// NewProvider creates a new Provider instance for 1Password and performs login using the provided service account token. -func NewProvider(ctx context.Context, accessToken, version string) (*Provider, error) { - client, err := onepassword.NewClient( - ctx, - onepassword.WithServiceAccountToken(accessToken), - onepassword.WithIntegrationInfo("doco-cd", version), - ) - if err != nil { - return nil, err +// NewProvider creates a new Provider instance for 1Password using Connect if configured, otherwise service-account auth. +func NewProvider(ctx context.Context, cfg *Config, version string) (*Provider, error) { + provider := &Provider{ + accessToken: cfg.AccessToken, + connectHost: cfg.ConnectHost, + connectToken: cfg.ConnectToken, + version: version, + cache: NewCache(cfg.CacheEnabled, cfg.CacheTTL, cfg.CacheMaxSize), } - provider := &Provider{Client: client, accessToken: accessToken, version: version} + if cfg.UseConnect() { + provider.mode = authModeConnect + provider.cache.enabled = false - return provider, nil -} + if err := provider.initializeConnectClient(); err != nil { + return nil, err + } -// renewSession renews the session for the Provider Client by creating a new Client instance with the same access token and version. -func renewSession(ctx context.Context, p *Provider) error { - newProvider, err := NewProvider(ctx, p.accessToken, p.version) - if err != nil { - return fmt.Errorf("failed to renew secret provider client session: %w", err) + return provider, nil } - // Set new client - p.Client = newProvider.Client + provider.mode = authModeServiceAccount - return nil + if err := provider.initializeServiceAccountClient(ctx); err != nil { + return nil, err + } + + return provider, nil +} + +func (p *Provider) getCachedSecret(uri string) (string, bool) { + return p.cache.Get(uri) +} + +func (p *Provider) setCachedSecret(uri, value string) { + p.cache.Set(uri, value) } // GetSecret retrieves a secret value from 1Password using the provided URI. func (p *Provider) GetSecret(ctx context.Context, uri string) (string, error) { - if err := onepassword.Secrets.ValidateSecretReference(ctx, uri); err != nil { - return "", err + if cachedSecret, ok := p.getCachedSecret(uri); ok { + return cachedSecret, nil } - secret, err := p.Client.Secrets().Resolve(ctx, uri) + secret, err := p.resolveSecret(ctx, uri) if err != nil { - if strings.Contains(err.Error(), ErrInvalidClientID.Error()) { - // Attempt to renew session and retry - if err = renewSession(ctx, p); err != nil { - return "", fmt.Errorf("failed to renew secret provider client session: %w", err) - } - - secret, err = p.Client.Secrets().Resolve(ctx, uri) - if err != nil { - return "", fmt.Errorf("failed to resolve secret after renewing session: %w", err) - } - } else { - return "", err - } + return "", err } + p.setCachedSecret(uri, secret) + return secret, nil } // GetSecrets retrieves multiple secrets from 1Password using the provided list of secret references. func (p *Provider) GetSecrets(ctx context.Context, uris []string) (map[string]string, error) { + result := make(map[string]string, len(uris)) + missing := make([]string, 0, len(uris)) + for _, uri := range uris { - if err := onepassword.Secrets.ValidateSecretReference(ctx, uri); err != nil { - return nil, err + if cachedSecret, ok := p.getCachedSecret(uri); ok { + result[uri] = cachedSecret + continue } + + missing = append(missing, uri) } - secrets, err := p.Client.Secrets().ResolveAll(ctx, uris) - if err != nil { - if strings.Contains(err.Error(), ErrInvalidClientID.Error()) { - // Attempt to renew session and retry - if err = renewSession(ctx, p); err != nil { - return nil, fmt.Errorf("failed to renew secret provider client session: %w", err) - } - - secrets, err = p.Client.Secrets().ResolveAll(ctx, uris) - if err != nil { - return nil, fmt.Errorf("failed to resolve secrets after renewing session: %w", err) - } - } else { - return nil, err - } + if len(missing) == 0 { + return result, nil } - result := make(map[string]string, len(secrets.IndividualResponses)) - for uri, secret := range secrets.IndividualResponses { - if secret.Error != nil { - return nil, fmt.Errorf("error resolving secret '%s': %s", uri, secret.Error.Type) - } + resolved, err := p.resolveSecrets(ctx, missing) + if err != nil { + return nil, err + } - result[uri] = secret.Content.Secret + for uri, secret := range resolved { + result[uri] = secret + p.setCachedSecret(uri, secret) } return result, nil diff --git a/doco-cd-src/internal/secretprovider/1password/client_cache_test.go b/doco-cd-src/internal/secretprovider/1password/client_cache_test.go new file mode 100644 index 0000000..47f5c3f --- /dev/null +++ b/doco-cd-src/internal/secretprovider/1password/client_cache_test.go @@ -0,0 +1,70 @@ +package onepassword + +import ( + "testing" + "time" +) + +func TestProviderCache_GetAndExpire(t *testing.T) { + provider := &Provider{ + cache: NewCache(true, 15*time.Millisecond, 100), + } + + provider.setCachedSecret("op://vault/item/field", "cached-value") + + value, ok := provider.getCachedSecret("op://vault/item/field") + if !ok { + t.Fatal("expected cache hit") + } + + if value != "cached-value" { + t.Fatalf("expected cached value, got %q", value) + } + + time.Sleep(20 * time.Millisecond) + + _, ok = provider.getCachedSecret("op://vault/item/field") + if ok { + t.Fatal("expected cache miss after ttl expiration") + } +} + +func TestProviderCache_Disabled(t *testing.T) { + provider := &Provider{ + cache: NewCache(false, time.Minute, 100), + } + + provider.setCachedSecret("op://vault/item/field", "cached-value") + + _, ok := provider.getCachedSecret("op://vault/item/field") + if ok { + t.Fatal("expected cache miss when cache is disabled") + } +} + +func TestProviderCache_EvictsLeastRecentlyUsedWhenFull(t *testing.T) { + provider := &Provider{ + cache: NewCache(true, time.Minute, 2), + } + + provider.setCachedSecret("op://vault/item/a", "A") + provider.setCachedSecret("op://vault/item/b", "B") + + if _, ok := provider.getCachedSecret("op://vault/item/a"); !ok { + t.Fatal("expected cache hit for A") + } + + provider.setCachedSecret("op://vault/item/c", "C") + + if _, ok := provider.getCachedSecret("op://vault/item/b"); ok { + t.Fatal("expected B to be evicted as least recently used") + } + + if value, ok := provider.getCachedSecret("op://vault/item/a"); !ok || value != "A" { + t.Fatalf("expected A to remain in cache, got value=%q hit=%v", value, ok) + } + + if value, ok := provider.getCachedSecret("op://vault/item/c"); !ok || value != "C" { + t.Fatalf("expected C to remain in cache, got value=%q hit=%v", value, ok) + } +} diff --git a/doco-cd-src/internal/secretprovider/1password/client_connect.go b/doco-cd-src/internal/secretprovider/1password/client_connect.go new file mode 100644 index 0000000..b820d06 --- /dev/null +++ b/doco-cd-src/internal/secretprovider/1password/client_connect.go @@ -0,0 +1,139 @@ +package onepassword + +import ( + "context" + "fmt" + "sync" + + connectsdk "github.com/1Password/connect-sdk-go/connect" + connectonepassword "github.com/1Password/connect-sdk-go/onepassword" + "github.com/opentracing/opentracing-go" + "golang.org/x/sync/errgroup" +) + +const defaultMaxConcurrentSecrets = 10 + +var connectGlobalTracerInit sync.Once + +type connectClient interface { + GetItem(itemQuery, vaultQuery string) (*connectonepassword.Item, error) +} + +type connectSDKClient struct { + inner connectsdk.Client +} + +func (c connectSDKClient) GetItem(itemQuery, vaultQuery string) (*connectonepassword.Item, error) { + return c.inner.GetItem(itemQuery, vaultQuery) +} + +func (p *Provider) initializeConnectClient() error { + ensureConnectSDKGlobalTracerDisabled() + + p.connectClient = connectSDKClient{inner: connectsdk.NewClientWithUserAgent(p.connectHost, p.connectToken, "doco-cd/"+p.version)} + + return nil +} + +func ensureConnectSDKGlobalTracerDisabled() { + connectGlobalTracerInit.Do(func() { + // Disable global tracing so the Connect SDK cannot initialize Jaeger tracing. + opentracing.SetGlobalTracer(opentracing.NoopTracer{}) + }) +} + +func (p *Provider) resolveConnectSecret(_ context.Context, uri string) (string, error) { + ref, err := ParseOPSecretReference(uri) + if err != nil { + return "", err + } + + item, err := p.connectClient.GetItem(ref.Item, ref.Vault) + if err != nil { + return "", err + } + + value, ok := findConnectFieldValue(item, ref) + if !ok { + return "", fmt.Errorf("secret field not found for reference: %s", uri) + } + + return value, nil +} + +func (p *Provider) resolveConnectSecrets(ctx context.Context, uris []string) (map[string]string, error) { + if len(uris) == 0 { + return make(map[string]string), nil + } + + result := make(map[string]string, len(uris)) + + var resultMutex sync.Mutex + + eg, egCtx := errgroup.WithContext(ctx) + eg.SetLimit(defaultMaxConcurrentSecrets) + + for _, uri := range uris { + eg.Go(func() error { + secret, err := p.resolveConnectSecret(egCtx, uri) + if err != nil { + return fmt.Errorf("failed to resolve secret for %s: %w", uri, err) + } + + resultMutex.Lock() + result[uri] = secret + resultMutex.Unlock() + + return nil + }) + } + + if err := eg.Wait(); err != nil { + return nil, err + } + + return result, nil +} + +func findConnectFieldValue(item *connectonepassword.Item, ref *OPSecretReference) (string, bool) { + selector := ref.Field + if ref.Section != "" { + selector = ref.Section + "." + ref.Field + } + + for _, field := range item.Fields { + if field == nil { + continue + } + + if field.Label != ref.Field { + continue + } + + if ref.Section != "" { + if field.Section == nil || field.Section.Label != ref.Section { + continue + } + } + + if ref.Attribute == "otp" { + if field.TOTP == "" { + return "", false + } + + return field.TOTP, true + } + + return field.Value, true + } + + if ref.Attribute == "otp" { + return "", false + } + + if value := item.GetValue(selector); value != "" { + return value, true + } + + return "", false +} diff --git a/doco-cd-src/internal/secretprovider/1password/client_connect_test.go b/doco-cd-src/internal/secretprovider/1password/client_connect_test.go new file mode 100644 index 0000000..7c576cf --- /dev/null +++ b/doco-cd-src/internal/secretprovider/1password/client_connect_test.go @@ -0,0 +1,164 @@ +package onepassword + +import ( + "strings" + "sync" + "testing" + + "github.com/opentracing/opentracing-go" + "github.com/opentracing/opentracing-go/mocktracer" + + connectonepassword "github.com/1Password/connect-sdk-go/onepassword" +) + +type mockConnectClient struct { + item *connectonepassword.Item + err error + calls int + lastItemQuery string + lastVaultQuery string +} + +func (m *mockConnectClient) GetItem(itemQuery, vaultQuery string) (*connectonepassword.Item, error) { + m.calls++ + m.lastItemQuery = itemQuery + m.lastVaultQuery = vaultQuery + + if m.err != nil { + return nil, m.err + } + + return m.item, nil +} + +func TestProvider_ResolveConnectSecret_FieldSelection(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + uri string + item *connectonepassword.Item + expected string + expectErr bool + errContains string + expectedItem string + expectedVault string + }{ + { + name: "section matching selects field from matching section", + uri: "op://TestVault/TestItem/Production/password", + item: &connectonepassword.Item{ + Fields: []*connectonepassword.ItemField{ + {Label: "password", Value: "dev-secret", Section: &connectonepassword.ItemSection{Label: "Development"}}, + {Label: "password", Value: "prod-secret", Section: &connectonepassword.ItemSection{Label: "Production"}}, + }, + }, + expected: "prod-secret", + expectedItem: "TestItem", + expectedVault: "TestVault", + }, + { + name: "otp attribute returns totp value", + uri: "op://TestVault/TestItem/one-time%20password?attribute=otp", + item: &connectonepassword.Item{ + Fields: []*connectonepassword.ItemField{ + {Label: "one-time password", Value: "ignored", TOTP: "123456"}, + }, + }, + expected: "123456", + expectedItem: "TestItem", + expectedVault: "TestVault", + }, + { + name: "otp attribute fails when totp is missing", + uri: "op://TestVault/TestItem/one-time%20password?attribute=otp", + item: &connectonepassword.Item{ + Fields: []*connectonepassword.ItemField{ + {Label: "one-time password", Value: "plain-value"}, + }, + }, + expectErr: true, + errContains: "secret field not found", + expectedItem: "TestItem", + expectedVault: "TestVault", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + mock := &mockConnectClient{item: tc.item} + provider := &Provider{connectClient: mock} + + got, err := provider.resolveConnectSecret(t.Context(), tc.uri) + if tc.expectErr { + if err == nil { + t.Fatal("expected error, got nil") + } + + if tc.errContains != "" && !strings.Contains(err.Error(), tc.errContains) { + t.Fatalf("expected error to contain %q, got %v", tc.errContains, err) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got != tc.expected { + t.Fatalf("expected %q, got %q", tc.expected, got) + } + } + + if mock.calls != 1 { + t.Fatalf("expected connect client to be called once, got %d", mock.calls) + } + + if mock.lastItemQuery != tc.expectedItem { + t.Fatalf("expected item query %q, got %q", tc.expectedItem, mock.lastItemQuery) + } + + if mock.lastVaultQuery != tc.expectedVault { + t.Fatalf("expected vault query %q, got %q", tc.expectedVault, mock.lastVaultQuery) + } + }) + } +} + +func TestEnsureConnectSDKGlobalTracerDisabled_OverridesWithNoopTracer(t *testing.T) { + connectGlobalTracerInit = sync.Once{} + + existingTracer := mocktracer.New() + opentracing.SetGlobalTracer(existingTracer) + + ensureConnectSDKGlobalTracerDisabled() + + if _, ok := opentracing.GlobalTracer().(opentracing.NoopTracer); !ok { + t.Fatal("expected global tracer to be opentracing.NoopTracer") + } +} + +func TestProvider_InitializeConnectClient_DisablesGlobalTracing(t *testing.T) { + connectGlobalTracerInit = sync.Once{} + + existingTracer := mocktracer.New() + opentracing.SetGlobalTracer(existingTracer) + + provider := &Provider{ + connectHost: "http://op-connect-api:8080", + connectToken: "test-token", + version: "test", + } + + if err := provider.initializeConnectClient(); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if provider.connectClient == nil { + t.Fatal("expected connect client to be initialized") + } + + if _, ok := opentracing.GlobalTracer().(opentracing.NoopTracer); !ok { + t.Fatal("expected initializeConnectClient to set global tracer to opentracing.NoopTracer") + } +} diff --git a/doco-cd-src/internal/secretprovider/1password/client_resolver.go b/doco-cd-src/internal/secretprovider/1password/client_resolver.go new file mode 100644 index 0000000..dceb603 --- /dev/null +++ b/doco-cd-src/internal/secretprovider/1password/client_resolver.go @@ -0,0 +1,28 @@ +package onepassword + +import ( + "context" + "fmt" +) + +func (p *Provider) resolveSecret(ctx context.Context, uri string) (string, error) { + switch p.mode { + case authModeConnect: + return p.resolveConnectSecret(ctx, uri) + case authModeServiceAccount: + return p.resolveServiceAccountSecret(ctx, uri) + default: + return "", fmt.Errorf("unsupported 1password auth mode: %s", p.mode) + } +} + +func (p *Provider) resolveSecrets(ctx context.Context, uris []string) (map[string]string, error) { + switch p.mode { + case authModeConnect: + return p.resolveConnectSecrets(ctx, uris) + case authModeServiceAccount: + return p.resolveServiceAccountSecrets(ctx, uris) + default: + return nil, fmt.Errorf("unsupported 1password auth mode: %s", p.mode) + } +} diff --git a/doco-cd-src/internal/secretprovider/1password/client_service_account.go b/doco-cd-src/internal/secretprovider/1password/client_service_account.go new file mode 100644 index 0000000..a2db94e --- /dev/null +++ b/doco-cd-src/internal/secretprovider/1password/client_service_account.go @@ -0,0 +1,94 @@ +package onepassword + +import ( + "context" + "fmt" + "strings" + + opsdk "github.com/1password/onepassword-sdk-go" +) + +type serviceAccountClient = opsdk.Client + +func (p *Provider) initializeServiceAccountClient(ctx context.Context) error { + client, err := opsdk.NewClient( + ctx, + opsdk.WithServiceAccountToken(p.accessToken), + opsdk.WithIntegrationInfo("doco-cd", p.version), + ) + if err != nil { + return err + } + + p.serviceClient = client + + return nil +} + +func (p *Provider) renewServiceAccountSession(ctx context.Context) error { + if err := p.initializeServiceAccountClient(ctx); err != nil { + return fmt.Errorf("failed to renew secret provider client session: %w", err) + } + + return nil +} + +func (p *Provider) resolveServiceAccountSecret(ctx context.Context, uri string) (string, error) { + if err := opsdk.Secrets.ValidateSecretReference(ctx, uri); err != nil { + return "", err + } + + secret, err := p.serviceClient.Secrets().Resolve(ctx, uri) + if err != nil { + if strings.Contains(err.Error(), ErrInvalidClientID.Error()) { + if renewErr := p.renewServiceAccountSession(ctx); renewErr != nil { + return "", renewErr + } + + secret, err = p.serviceClient.Secrets().Resolve(ctx, uri) + if err != nil { + return "", fmt.Errorf("failed to resolve secret after renewing session: %w", err) + } + } else { + return "", err + } + } + + return secret, nil +} + +func (p *Provider) resolveServiceAccountSecrets(ctx context.Context, uris []string) (map[string]string, error) { + for _, uri := range uris { + if err := opsdk.Secrets.ValidateSecretReference(ctx, uri); err != nil { + return nil, err + } + } + + secrets, err := p.serviceClient.Secrets().ResolveAll(ctx, uris) + if err != nil { + if strings.Contains(err.Error(), ErrInvalidClientID.Error()) { + if renewErr := p.renewServiceAccountSession(ctx); renewErr != nil { + return nil, renewErr + } + + secrets, err = p.serviceClient.Secrets().ResolveAll(ctx, uris) + if err != nil { + return nil, fmt.Errorf("failed to resolve secrets after renewing session: %w", err) + } + } else { + return nil, err + } + } + + result := make(map[string]string, len(uris)) + + for uri, secret := range secrets.IndividualResponses { + if secret.Error != nil { + return nil, fmt.Errorf("error resolving secret '%s': %s", uri, secret.Error.Type) + } + + result[uri] = secret.Content.Secret + } + + return result, nil +} diff --git a/doco-cd-src/internal/secretprovider/1password/client_test.go b/doco-cd-src/internal/secretprovider/1password/client_test.go index 0ecef45..888c3cd 100644 --- a/doco-cd-src/internal/secretprovider/1password/client_test.go +++ b/doco-cd-src/internal/secretprovider/1password/client_test.go @@ -3,13 +3,13 @@ package onepassword import ( "testing" - "github.com/kimdre/doco-cd/internal/config" + "github.com/kimdre/doco-cd/internal/config/app" ) func skipWrongProvider(t *testing.T) { t.Helper() - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatalf("unable to get app config: %v", err) } @@ -52,7 +52,7 @@ func TestProvider_GetSecret_OnePassword(t *testing.T) { t.Fatalf("unable to get config: %v", err) } - provider, err := NewProvider(t.Context(), cfg.AccessToken, "test") + provider, err := NewProvider(t.Context(), cfg, "test") if err != nil { t.Fatalf("Failed to create OnePassword provider: %v", err) } diff --git a/doco-cd-src/internal/secretprovider/1password/config.go b/doco-cd-src/internal/secretprovider/1password/config.go index b88dfac..fe90ebc 100644 --- a/doco-cd-src/internal/secretprovider/1password/config.go +++ b/doco-cd-src/internal/secretprovider/1password/config.go @@ -2,13 +2,24 @@ package onepassword import ( "fmt" + "time" "github.com/kimdre/doco-cd/internal/config" ) type Config struct { - AccessToken string `env:"SECRET_PROVIDER_ACCESS_TOKEN" validate:"nonzero"` // #nosec G117 -- Access token for authenticating with the secret provider - AccessTokenFile string `env:"SECRET_PROVIDER_ACCESS_TOKEN_FILE,file"` // Path to a file containing the access token + AccessToken string `env:"SECRET_PROVIDER_ACCESS_TOKEN"` // #nosec G117 -- Access token for authenticating with the secret provider + AccessTokenFile string `env:"SECRET_PROVIDER_ACCESS_TOKEN_FILE,file"` // Path to a file containing the access token + ConnectHost string `env:"SECRET_PROVIDER_CONNECT_HOST"` // Base URL of the 1Password Connect API + ConnectToken string `env:"SECRET_PROVIDER_CONNECT_TOKEN"` // #nosec G117 -- Access token for authenticating with the Connect API + ConnectTokenFile string `env:"SECRET_PROVIDER_CONNECT_TOKEN_FILE,file"` // Path to a file containing the Connect API token + CacheEnabled bool `env:"SECRET_PROVIDER_CACHE_ENABLED,notEmpty" envDefault:"false"` // Enables in-memory caching for resolved secrets + CacheTTL time.Duration `env:"SECRET_PROVIDER_CACHE_TTL,notEmpty" envDefault:"5m"` // Cache TTL for resolved secrets + CacheMaxSize int `env:"SECRET_PROVIDER_CACHE_MAX_SIZE,notEmpty" envDefault:"100" validate:"min=1"` // Maximum number of secrets stored in the in-memory cache +} + +func (c Config) UseConnect() bool { + return c.ConnectHost != "" && c.ConnectToken != "" } // GetConfig retrieves and parses the configuration for the Bitwarden Secrets Manager from environment variables. @@ -16,7 +27,8 @@ func GetConfig() (*Config, error) { cfg := Config{} mappings := []config.EnvVarFileMapping{ - {EnvName: "SECRET_PROVIDER_ACCESS_TOKEN", EnvValue: &cfg.AccessToken, FileValue: &cfg.AccessTokenFile, AllowUnset: false}, + {EnvName: "SECRET_PROVIDER_ACCESS_TOKEN", EnvValue: &cfg.AccessToken, FileValue: &cfg.AccessTokenFile, AllowUnset: true}, + {EnvName: "SECRET_PROVIDER_CONNECT_TOKEN", EnvValue: &cfg.ConnectToken, FileValue: &cfg.ConnectTokenFile, AllowUnset: true}, } err := config.ParseConfigFromEnv(&cfg, &mappings) @@ -24,5 +36,27 @@ func GetConfig() (*Config, error) { return nil, fmt.Errorf("%w: %w", config.ErrParseConfigFailed, err) } + connectHostSet := cfg.ConnectHost != "" + connectTokenSet := cfg.ConnectToken != "" + + if connectHostSet != connectTokenSet { + return nil, fmt.Errorf("%w: SECRET_PROVIDER_CONNECT_HOST and SECRET_PROVIDER_CONNECT_TOKEN (or SECRET_PROVIDER_CONNECT_TOKEN_FILE) must be set together", config.ErrParseConfigFailed) + } + + if cfg.UseConnect() { + // Connect Server already caches vault data, so disable local cache in this mode. + cfg.CacheEnabled = false + + return &cfg, nil + } + + if cfg.AccessToken == "" { + return nil, fmt.Errorf("%w: SECRET_PROVIDER_ACCESS_TOKEN (or SECRET_PROVIDER_ACCESS_TOKEN_FILE) is required when SECRET_PROVIDER_CONNECT_HOST/SECRET_PROVIDER_CONNECT_TOKEN are not set", config.ErrParseConfigFailed) + } + + if cfg.CacheEnabled && cfg.CacheTTL <= 0 { + return nil, fmt.Errorf("%w: SECRET_PROVIDER_CACHE_TTL must be greater than 0 when cache is enabled", config.ErrParseConfigFailed) + } + return &cfg, nil } diff --git a/doco-cd-src/internal/secretprovider/1password/config_test.go b/doco-cd-src/internal/secretprovider/1password/config_test.go new file mode 100644 index 0000000..394fdf1 --- /dev/null +++ b/doco-cd-src/internal/secretprovider/1password/config_test.go @@ -0,0 +1,227 @@ +package onepassword + +import ( + "errors" + "os" + "testing" + "time" + + "github.com/kimdre/doco-cd/internal/config" +) + +func TestGetConfig_DefaultCacheSettings(t *testing.T) { + t.Setenv("SECRET_PROVIDER_ACCESS_TOKEN", "test-token") // #nosec G101 + t.Setenv("SECRET_PROVIDER_ACCESS_TOKEN_FILE", "") + t.Setenv("OP_CONNECT_HOST", "") + t.Setenv("SECRET_PROVIDER_CONNECT_TOKEN", "") + t.Setenv("SECRET_PROVIDER_CONNECT_TOKEN_FILE", "") + t.Setenv("SECRET_PROVIDER_CACHE_ENABLED", "") + t.Setenv("SECRET_PROVIDER_CACHE_TTL", "") + t.Setenv("SECRET_PROVIDER_CACHE_MAX_SIZE", "") + + cfg, err := GetConfig() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if cfg.CacheEnabled { + t.Fatalf("expected cache to be disabled by default") + } + + if cfg.CacheTTL != 5*time.Minute { + t.Fatalf("expected default cache ttl 5m, got %s", cfg.CacheTTL) + } + + if cfg.CacheMaxSize != 100 { + t.Fatalf("expected default cache max size 100, got %d", cfg.CacheMaxSize) + } + + if cfg.UseConnect() { + t.Fatal("expected service-account mode by default") + } +} + +func TestGetConfig_DisabledCacheAllowsZeroTTL(t *testing.T) { + t.Setenv("SECRET_PROVIDER_ACCESS_TOKEN", "test-token") // #nosec G101 + t.Setenv("SECRET_PROVIDER_ACCESS_TOKEN_FILE", "") + t.Setenv("OP_CONNECT_HOST", "") + t.Setenv("SECRET_PROVIDER_CONNECT_TOKEN", "") + t.Setenv("SECRET_PROVIDER_CONNECT_TOKEN_FILE", "") + t.Setenv("SECRET_PROVIDER_CACHE_ENABLED", "false") + t.Setenv("SECRET_PROVIDER_CACHE_TTL", "0s") + t.Setenv("SECRET_PROVIDER_CACHE_MAX_SIZE", "100") + + cfg, err := GetConfig() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if cfg.CacheEnabled { + t.Fatalf("expected cache to be disabled") + } + + if cfg.CacheTTL != 0 { + t.Fatalf("expected cache ttl 0s, got %s", cfg.CacheTTL) + } +} + +func TestGetConfig_EnabledCacheRequiresPositiveTTL(t *testing.T) { + t.Setenv("SECRET_PROVIDER_ACCESS_TOKEN", "test-token") // #nosec G101 + t.Setenv("SECRET_PROVIDER_ACCESS_TOKEN_FILE", "") + t.Setenv("OP_CONNECT_HOST", "") + t.Setenv("SECRET_PROVIDER_CONNECT_TOKEN", "") + t.Setenv("SECRET_PROVIDER_CONNECT_TOKEN_FILE", "") + t.Setenv("SECRET_PROVIDER_CACHE_ENABLED", "true") + t.Setenv("SECRET_PROVIDER_CACHE_TTL", "0s") + t.Setenv("SECRET_PROVIDER_CACHE_MAX_SIZE", "100") + + _, err := GetConfig() + if err == nil { + t.Fatal("expected error, got nil") + } + + if !errors.Is(err, config.ErrParseConfigFailed) { + t.Fatalf("expected ErrParseConfigFailed, got %v", err) + } +} + +func TestGetConfig_ConnectModeWithToken(t *testing.T) { + t.Setenv("SECRET_PROVIDER_ACCESS_TOKEN", "") + t.Setenv("SECRET_PROVIDER_ACCESS_TOKEN_FILE", "") + t.Setenv("SECRET_PROVIDER_CONNECT_HOST", "http://op-connect-api:8080") + t.Setenv("SECRET_PROVIDER_CONNECT_TOKEN", "connect-token") // #nosec G101 + t.Setenv("SECRET_PROVIDER_CONNECT_TOKEN_FILE", "") + t.Setenv("SECRET_PROVIDER_CACHE_ENABLED", "true") + t.Setenv("SECRET_PROVIDER_CACHE_TTL", "5m") + t.Setenv("SECRET_PROVIDER_CACHE_MAX_SIZE", "100") + + cfg, err := GetConfig() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if !cfg.UseConnect() { + t.Fatal("expected connect mode") + } + + if cfg.CacheEnabled { + t.Fatal("expected cache to be disabled in connect mode") + } +} + +func TestGetConfig_ConnectModeWithTokenFile(t *testing.T) { + tokenFile, err := os.CreateTemp(t.TempDir(), "op-connect-token") + if err != nil { + t.Fatalf("failed to create temp token file: %v", err) + } + + if _, err = tokenFile.WriteString("connect-token"); err != nil { + t.Fatalf("failed to write temp token file: %v", err) + } + + if err = tokenFile.Close(); err != nil { + t.Fatalf("failed to close temp token file: %v", err) + } + + t.Setenv("SECRET_PROVIDER_ACCESS_TOKEN", "") + t.Setenv("SECRET_PROVIDER_ACCESS_TOKEN_FILE", "") + t.Setenv("SECRET_PROVIDER_CONNECT_HOST", "http://op-connect-api:8080") + t.Setenv("SECRET_PROVIDER_CONNECT_TOKEN", "") + t.Setenv("SECRET_PROVIDER_CONNECT_TOKEN_FILE", tokenFile.Name()) // #nosec G101 + t.Setenv("SECRET_PROVIDER_CACHE_ENABLED", "false") + t.Setenv("SECRET_PROVIDER_CACHE_TTL", "5m") + t.Setenv("SECRET_PROVIDER_CACHE_MAX_SIZE", "100") + + cfg, err := GetConfig() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if !cfg.UseConnect() { + t.Fatal("expected connect mode") + } +} + +func TestGetConfig_ConnectModeRequiresBothHostAndToken(t *testing.T) { + t.Setenv("SECRET_PROVIDER_ACCESS_TOKEN", "") + t.Setenv("SECRET_PROVIDER_ACCESS_TOKEN_FILE", "") + t.Setenv("SECRET_PROVIDER_CONNECT_HOST", "http://op-connect-api:8080") + t.Setenv("SECRET_PROVIDER_CONNECT_TOKEN", "") + t.Setenv("SECRET_PROVIDER_CONNECT_TOKEN_FILE", "") + t.Setenv("SECRET_PROVIDER_CACHE_ENABLED", "false") + t.Setenv("SECRET_PROVIDER_CACHE_TTL", "5m") + t.Setenv("SECRET_PROVIDER_CACHE_MAX_SIZE", "100") + + _, err := GetConfig() + if err == nil { + t.Fatal("expected error, got nil") + } + + if !errors.Is(err, config.ErrParseConfigFailed) { + t.Fatalf("expected ErrParseConfigFailed, got %v", err) + } +} + +func TestGetConfig_ConnectPreferredOverServiceAccount(t *testing.T) { + t.Setenv("SECRET_PROVIDER_ACCESS_TOKEN", "service-token") // #nosec G101 + t.Setenv("SECRET_PROVIDER_ACCESS_TOKEN_FILE", "") + t.Setenv("SECRET_PROVIDER_CONNECT_HOST", "http://op-connect-api:8080") + t.Setenv("SECRET_PROVIDER_CONNECT_TOKEN", "connect-token") // #nosec G101 + t.Setenv("SECRET_PROVIDER_CONNECT_TOKEN_FILE", "") + t.Setenv("SECRET_PROVIDER_CACHE_ENABLED", "true") + t.Setenv("SECRET_PROVIDER_CACHE_TTL", "5m") + t.Setenv("SECRET_PROVIDER_CACHE_MAX_SIZE", "100") + + cfg, err := GetConfig() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if !cfg.UseConnect() { + t.Fatal("expected connect mode to be preferred") + } + + if cfg.CacheEnabled { + t.Fatal("expected cache to be disabled in connect mode") + } +} + +func TestGetConfig_ServiceAccountRequiredWithoutConnect(t *testing.T) { + t.Setenv("SECRET_PROVIDER_ACCESS_TOKEN", "") + t.Setenv("SECRET_PROVIDER_ACCESS_TOKEN_FILE", "") + t.Setenv("SECRET_PROVIDER_CONNECT_HOST", "") + t.Setenv("SECRET_PROVIDER_CONNECT_TOKEN", "") + t.Setenv("SECRET_PROVIDER_CONNECT_TOKEN_FILE", "") + t.Setenv("SECRET_PROVIDER_CACHE_ENABLED", "false") + t.Setenv("SECRET_PROVIDER_CACHE_TTL", "5m") + t.Setenv("SECRET_PROVIDER_CACHE_MAX_SIZE", "100") + + _, err := GetConfig() + if err == nil { + t.Fatal("expected error, got nil") + } + + if !errors.Is(err, config.ErrParseConfigFailed) { + t.Fatalf("expected ErrParseConfigFailed, got %v", err) + } +} + +func TestGetConfig_CacheMaxSizeRequiresMinimumOfOne(t *testing.T) { + t.Setenv("SECRET_PROVIDER_ACCESS_TOKEN", "test-token") // #nosec G101 + t.Setenv("SECRET_PROVIDER_ACCESS_TOKEN_FILE", "") + t.Setenv("OP_CONNECT_HOST", "") + t.Setenv("OP_CONNECT_TOKEN", "") + t.Setenv("OP_CONNECT_TOKEN_FILE", "") + t.Setenv("SECRET_PROVIDER_CACHE_ENABLED", "true") + t.Setenv("SECRET_PROVIDER_CACHE_TTL", "5m") + t.Setenv("SECRET_PROVIDER_CACHE_MAX_SIZE", "0") + + _, err := GetConfig() + if err == nil { + t.Fatal("expected error, got nil") + } + + if !errors.Is(err, config.ErrParseConfigFailed) { + t.Fatalf("expected ErrParseConfigFailed, got %v", err) + } +} diff --git a/doco-cd-src/internal/secretprovider/1password/connect_ref.go b/doco-cd-src/internal/secretprovider/1password/connect_ref.go new file mode 100644 index 0000000..9759a5d --- /dev/null +++ b/doco-cd-src/internal/secretprovider/1password/connect_ref.go @@ -0,0 +1,78 @@ +package onepassword + +import ( + "errors" + "fmt" + "net/url" + "strings" +) + +type OPSecretReference struct { + Vault string + Item string + Section string + Field string + Attribute string +} + +func ParseOPSecretReference(ref string) (*OPSecretReference, error) { + parsed, err := url.Parse(ref) + if err != nil { + return nil, fmt.Errorf("invalid secret reference: %w", err) + } + + if parsed.Scheme != "op" { + return nil, fmt.Errorf("invalid secret reference scheme: %s", parsed.Scheme) + } + + vault, err := url.PathUnescape(parsed.Host) + if err != nil { + return nil, fmt.Errorf("failed to decode vault segment: %w", err) + } + + if strings.TrimSpace(vault) == "" { + return nil, errors.New("invalid secret reference: vault segment is required") + } + + segments, err := parseReferencePathSegments(parsed.Path) + if err != nil { + return nil, err + } + + attribute := strings.TrimSpace(parsed.Query().Get("attribute")) + if attribute != "" && attribute != "otp" { + return nil, fmt.Errorf("unsupported secret reference attribute: %s", attribute) + } + + out := &OPSecretReference{Vault: vault, Item: segments[0], Field: segments[len(segments)-1], Attribute: attribute} + if len(segments) == 3 { + out.Section = segments[1] + } + + return out, nil +} + +func parseReferencePathSegments(path string) ([]string, error) { + trimmed := strings.Trim(path, "/") + + parts := strings.Split(trimmed, "/") + if len(parts) != 2 && len(parts) != 3 { + return nil, errors.New("invalid secret reference path: expected item/field or item/section/field") + } + + segments := make([]string, 0, len(parts)) + for _, part := range parts { + decoded, err := url.PathUnescape(part) + if err != nil { + return nil, fmt.Errorf("failed to decode secret reference segment: %w", err) + } + + if strings.TrimSpace(decoded) == "" { + return nil, errors.New("invalid secret reference path: empty segment") + } + + segments = append(segments, decoded) + } + + return segments, nil +} diff --git a/doco-cd-src/internal/secretprovider/1password/connect_ref_test.go b/doco-cd-src/internal/secretprovider/1password/connect_ref_test.go new file mode 100644 index 0000000..207d4c6 --- /dev/null +++ b/doco-cd-src/internal/secretprovider/1password/connect_ref_test.go @@ -0,0 +1,80 @@ +package onepassword + +import "testing" + +func TestParseOPSecretReference(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ref string + wantVault string + wantItem string + wantSect string + wantField string + wantAttr string + wantErr bool + }{ + { + name: "item field", + ref: "op://MyVault/MyItem/MyField", + wantVault: "MyVault", + wantItem: "MyItem", + wantField: "MyField", + }, + { + name: "section field", + ref: "op://MyVault/MyItem/MySection/MyField", + wantVault: "MyVault", + wantItem: "MyItem", + wantSect: "MySection", + wantField: "MyField", + }, + { + name: "otp attribute", + ref: "op://MyVault/MyItem/one-time%20password?attribute=otp", + wantVault: "MyVault", + wantItem: "MyItem", + wantField: "one-time password", + wantAttr: "otp", + }, + { + name: "invalid scheme", + ref: "http://MyVault/MyItem/MyField", + wantErr: true, + }, + { + name: "invalid path length", + ref: "op://MyVault/MyItem", + wantErr: true, + }, + { + name: "unsupported attribute", + ref: "op://MyVault/MyItem/MyField?attribute=foo", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := ParseOPSecretReference(tc.ref) + if tc.wantErr { + if err == nil { + t.Fatal("expected parse error") + } + + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if got.Vault != tc.wantVault || got.Item != tc.wantItem || got.Section != tc.wantSect || got.Field != tc.wantField || got.Attribute != tc.wantAttr { + t.Fatalf("got %+v", got) + } + }) + } +} diff --git a/doco-cd-src/internal/secretprovider/1password/testdata/.gitignore b/doco-cd-src/internal/secretprovider/1password/testdata/.gitignore new file mode 100644 index 0000000..76d595f --- /dev/null +++ b/doco-cd-src/internal/secretprovider/1password/testdata/.gitignore @@ -0,0 +1 @@ +1password-credentials.json \ No newline at end of file diff --git a/doco-cd-src/internal/secretprovider/1password/testdata/op-connect.compose.yml b/doco-cd-src/internal/secretprovider/1password/testdata/op-connect.compose.yml new file mode 100644 index 0000000..77317d1 --- /dev/null +++ b/doco-cd-src/internal/secretprovider/1password/testdata/op-connect.compose.yml @@ -0,0 +1,57 @@ +services: + init-tools: + image: busybox:latest + entrypoint: "sh -xec" + command: + - cp /bin/busybox /tools/wget + volumes: + - op_tools:/tools + + op-connect-api: + image: 1password/connect-api:latest@sha256:e915c0c843972f02b0e7e2de502bda8bd4a092288b3f1866098a857bd715a281 + ports: + - "8080:8080" + volumes: + - ./internal/secretprovider/1password/testdata/1password-credentials.json:/home/opuser/.op/1password-credentials.json + - op_data:/home/opuser/.op/data + - op_tools:/opt/tools:ro + depends_on: + init-tools: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "/opt/tools/wget", "--spider", "-q", "http://127.0.0.1:8080/health"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + + op-connect-sync: + image: 1password/connect-sync:latest@sha256:6297ca6136c0f0fb096bc64c49e1bc8df2aab35282ebff8c7bb60745ef176d0d + ports: + - "8081:8080" + volumes: + - ./internal/secretprovider/1password/testdata/1password-credentials.json:/home/opuser/.op/1password-credentials.json + - op_data:/home/opuser/.op/data + - op_tools:/opt/tools:ro + depends_on: + init-tools: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "/opt/tools/wget", "--spider", "-q", "http://127.0.0.1:8080/health"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + + app: # doco-cd + environment: + SECRET_PROVIDER: 1password + SECRET_PROVIDER_CONNECT_HOST: http://op-connect-api:8080 + SECRET_PROVIDER_CONNECT_TOKEN: ${SECRET_PROVIDER_CONNECT_TOKEN} + depends_on: + op-connect-api: + condition: service_healthy + +volumes: + op_data: + op_tools: diff --git a/doco-cd-src/internal/secretprovider/awssecretsmanager/client_test.go b/doco-cd-src/internal/secretprovider/awssecretsmanager/client_test.go index 3e4730d..a3049ab 100644 --- a/doco-cd-src/internal/secretprovider/awssecretsmanager/client_test.go +++ b/doco-cd-src/internal/secretprovider/awssecretsmanager/client_test.go @@ -4,14 +4,14 @@ import ( "reflect" "testing" - "github.com/kimdre/doco-cd/internal/config" + "github.com/kimdre/doco-cd/internal/config/app" secrettypes "github.com/kimdre/doco-cd/internal/secretprovider/types" ) func skipWrongProvider(t *testing.T) { t.Helper() - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatalf("unable to get app config: %v", err) } diff --git a/doco-cd-src/internal/secretprovider/bitwardensecretsmanager/client_test.go b/doco-cd-src/internal/secretprovider/bitwardensecretsmanager/client_test.go index 2b705e3..0cffa9a 100644 --- a/doco-cd-src/internal/secretprovider/bitwardensecretsmanager/client_test.go +++ b/doco-cd-src/internal/secretprovider/bitwardensecretsmanager/client_test.go @@ -7,7 +7,7 @@ import ( "github.com/bitwarden/sdk-go/v2" - "github.com/kimdre/doco-cd/internal/config" + "github.com/kimdre/doco-cd/internal/config/app" ) const ( @@ -17,7 +17,7 @@ const ( ) func skipWrongProvider(t *testing.T) { - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatalf("unable to get app config: %v", err) } diff --git a/doco-cd-src/internal/secretprovider/infisical/client_test.go b/doco-cd-src/internal/secretprovider/infisical/client_test.go index 011dff2..7a63932 100644 --- a/doco-cd-src/internal/secretprovider/infisical/client_test.go +++ b/doco-cd-src/internal/secretprovider/infisical/client_test.go @@ -3,14 +3,14 @@ package infisical import ( "testing" - "github.com/kimdre/doco-cd/internal/config" + "github.com/kimdre/doco-cd/internal/config/app" secrettypes "github.com/kimdre/doco-cd/internal/secretprovider/types" ) func skipWrongProvider(t *testing.T) { t.Helper() - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatalf("unable to get app config: %v", err) } diff --git a/doco-cd-src/internal/secretprovider/openbao/config.go b/doco-cd-src/internal/secretprovider/openbao/config.go index a13c2bd..d6e2479 100644 --- a/doco-cd-src/internal/secretprovider/openbao/config.go +++ b/doco-cd-src/internal/secretprovider/openbao/config.go @@ -8,7 +8,7 @@ import ( type Config struct { SiteUrl string `env:"SECRET_PROVIDER_SITE_URL,notEmpty"` // URL of the secret provider - AccessToken string `env:"SECRET_PROVIDER_ACCESS_TOKEN,notEmpty"` // #nosec G117 -- Access token for authenticating with the secret provider + AccessToken string `env:"SECRET_PROVIDER_ACCESS_TOKEN"` // #nosec G117 -- Access token for authenticating with the secret provider AccessTokenFile string `env:"SECRET_PROVIDER_ACCESS_TOKEN_FILE,file"` // Path to a file containing the access token } diff --git a/doco-cd-src/internal/secretprovider/secretprovider.go b/doco-cd-src/internal/secretprovider/secretprovider.go index 0acebf6..8bcfad0 100644 --- a/doco-cd-src/internal/secretprovider/secretprovider.go +++ b/doco-cd-src/internal/secretprovider/secretprovider.go @@ -81,7 +81,7 @@ func Initialize(ctx context.Context, provider, version string) (SecretProvider, return nil, cfgErr } - p, err = onepassword.NewProvider(ctx, cfg.AccessToken, version) + p, err = onepassword.NewProvider(ctx, cfg, version) case infisical.Name: cfg, cfgErr := infisical.GetConfig() if cfgErr != nil { diff --git a/doco-cd-src/internal/secretprovider/secretprovider_test.go b/doco-cd-src/internal/secretprovider/secretprovider_test.go index e119317..5c811cc 100644 --- a/doco-cd-src/internal/secretprovider/secretprovider_test.go +++ b/doco-cd-src/internal/secretprovider/secretprovider_test.go @@ -4,7 +4,7 @@ import ( "errors" "testing" - "github.com/kimdre/doco-cd/internal/config" + "github.com/kimdre/doco-cd/internal/config/app" "github.com/kimdre/doco-cd/internal/secretprovider" "github.com/kimdre/doco-cd/internal/secretprovider/bitwardensecretsmanager" ) @@ -14,7 +14,7 @@ func TestInitialize(t *testing.T) { ctx := t.Context() - c, err := config.GetAppConfig() + c, err := app.GetConfig() if err != nil { t.Fatal(err) } diff --git a/doco-cd-src/internal/config/external_secret_ref.go b/doco-cd-src/internal/secretprovider/types/secret_ref.go similarity index 99% rename from doco-cd-src/internal/config/external_secret_ref.go rename to doco-cd-src/internal/secretprovider/types/secret_ref.go index defb356..56ecd2e 100644 --- a/doco-cd-src/internal/config/external_secret_ref.go +++ b/doco-cd-src/internal/secretprovider/types/secret_ref.go @@ -1,4 +1,4 @@ -package config +package secrettypes import ( "encoding/json" diff --git a/doco-cd-src/internal/config/external_secret_ref_test.go b/doco-cd-src/internal/secretprovider/types/secret_ref_test.go similarity index 99% rename from doco-cd-src/internal/config/external_secret_ref_test.go rename to doco-cd-src/internal/secretprovider/types/secret_ref_test.go index 7d08dcc..4741ce3 100644 --- a/doco-cd-src/internal/config/external_secret_ref_test.go +++ b/doco-cd-src/internal/secretprovider/types/secret_ref_test.go @@ -1,4 +1,4 @@ -package config +package secrettypes import ( "reflect" diff --git a/doco-cd-src/internal/secretprovider/webhook/config.go b/doco-cd-src/internal/secretprovider/webhook/config.go index 87258e5..55f8a63 100644 --- a/doco-cd-src/internal/secretprovider/webhook/config.go +++ b/doco-cd-src/internal/secretprovider/webhook/config.go @@ -11,6 +11,8 @@ import ( prococo "github.com/prometheus/common/config" "go.yaml.in/yaml/v3" + "github.com/kimdre/doco-cd/internal/config/app" + "github.com/kimdre/doco-cd/internal/config" ) @@ -63,7 +65,7 @@ func (c *Config) NewRoundTripperWithContext(ctx context.Context) (http.RoundTrip } return prococo.NewRoundTripperFromConfigWithContext(ctx, httpcfg, - "secretprovider-webhook", prococo.WithUserAgent(config.AppName+"/"+config.AppVersion)) + "secretprovider-webhook", prococo.WithUserAgent(app.Name+"/"+app.Version)) } func parseStoresYAML(input string) (map[string]*Store, error) { diff --git a/doco-cd-src/internal/stages/run.go b/doco-cd-src/internal/stages/run.go index 69bacdf..400cb00 100644 --- a/doco-cd-src/internal/stages/run.go +++ b/doco-cd-src/internal/stages/run.go @@ -41,12 +41,14 @@ func (s *StageManager) GetDestroyStageOrder() StageOrder { Order: []StageName{ StageInit, StageDestroy, + StagePostDestroy, StageCleanup, }, Funcs: map[StageName]StageFunc{ - StageInit: func(ctx context.Context, stageLog *slog.Logger) error { return s.RunInitStage(ctx, stageLog) }, - StageDestroy: func(ctx context.Context, stageLog *slog.Logger) error { return s.RunDestroyStage(ctx, stageLog) }, - StageCleanup: func(ctx context.Context, stageLog *slog.Logger) error { return s.RunCleanupStage(ctx, stageLog) }, + StageInit: func(ctx context.Context, stageLog *slog.Logger) error { return s.RunInitStage(ctx, stageLog) }, + StageDestroy: func(ctx context.Context, stageLog *slog.Logger) error { return s.RunDestroyStage(ctx, stageLog) }, + StagePostDestroy: func(ctx context.Context, stageLog *slog.Logger) error { return s.RunPostDestroyStage(ctx, stageLog) }, + StageCleanup: func(ctx context.Context, stageLog *slog.Logger) error { return s.RunCleanupStage(ctx, stageLog) }, }, } } @@ -54,7 +56,7 @@ func (s *StageManager) GetDestroyStageOrder() StageOrder { // RunStages executes the stages in the defined order. func (s *StageManager) RunStages(ctx context.Context) error { stageOrder := s.GetDeployStageOrder() - if s.DeployConfig.Destroy { + if s.DeployConfig.Destroy.Enabled { stageOrder = s.GetDestroyStageOrder() } diff --git a/doco-cd-src/internal/stages/stage_1_init.go b/doco-cd-src/internal/stages/stage_1_init.go index ff1dd45..b317de6 100644 --- a/doco-cd-src/internal/stages/stage_1_init.go +++ b/doco-cd-src/internal/stages/stage_1_init.go @@ -10,7 +10,7 @@ import ( "regexp" "time" - "github.com/kimdre/doco-cd/internal/config" + "github.com/kimdre/doco-cd/internal/config/deploy" "github.com/kimdre/doco-cd/internal/docker" "github.com/kimdre/doco-cd/internal/filesystem" "github.com/kimdre/doco-cd/internal/git" @@ -49,7 +49,7 @@ func (s *StageManager) RunInitStage(ctx context.Context, stageLog *slog.Logger) // Load local (without remote: prefix) dotenv files before paths get updated to remote repository // Remote dotenv files get read later - err = config.LoadLocalDotEnv(s.DeployConfig, s.Repository.PathInternal) + err = deploy.LoadLocalDotEnv(s.DeployConfig, s.Repository.PathInternal) if err != nil { return fmt.Errorf("failed to parse local env files: %w", err) } @@ -118,7 +118,7 @@ func (s *StageManager) RunInitStage(ctx context.Context, stageLog *slog.Logger) } // Now also load remote dotenv files - err = config.LoadLocalDotEnv(s.DeployConfig, filepath.Join(s.Repository.PathInternal, s.DeployConfig.WorkingDirectory)) + err = deploy.LoadLocalDotEnv(s.DeployConfig, filepath.Join(s.Repository.PathInternal, s.DeployConfig.WorkingDirectory)) if err != nil { return fmt.Errorf("failed to parse remote env files: %w", err) } @@ -132,7 +132,7 @@ func (s *StageManager) RunInitStage(ctx context.Context, stageLog *slog.Logger) maps.Copy(s.DeployConfig.Internal.Environment, s.DeployConfig.Environment) } - if s.DeployConfig.Destroy { + if s.DeployConfig.Destroy.Enabled { // Skip deployment if another project with the same name already exists // Check if containers do not belong to this repository or if doco-cd does not manage the stack correctRepo := true @@ -145,7 +145,7 @@ func (s *StageManager) RunInitStage(ctx context.Context, stageLog *slog.Logger) for _, labels := range serviceLabels { name, ok := labels[docker.DocoCDLabels.Repository.Name] - if !ok || name != getFullName(s.Repository.CloneURL) { + if !ok || name != git.GetFullName(string(s.Repository.CloneURL)) { correctRepo = false break } @@ -181,7 +181,7 @@ func (s *StageManager) RunInitStage(ctx context.Context, stageLog *slog.Logger) Name: git.GetRepoName(string(s.Repository.CloneURL)), Ref: s.DeployConfig.Reference, CommitSHA: string(JobTriggerPoll), - FullName: getFullName(s.Repository.CloneURL), + FullName: git.GetFullName(string(s.Repository.CloneURL)), CloneURL: string(s.Repository.CloneURL), WebURL: string(s.Repository.CloneURL), } diff --git a/doco-cd-src/internal/stages/stage_2_pre-deploy.go b/doco-cd-src/internal/stages/stage_2_pre-deploy.go index fd018b2..50c3b42 100644 --- a/doco-cd-src/internal/stages/stage_2_pre-deploy.go +++ b/doco-cd-src/internal/stages/stage_2_pre-deploy.go @@ -12,12 +12,10 @@ import ( "github.com/go-git/go-git/v5/plumbing" - "github.com/kimdre/doco-cd/internal/config" - - "github.com/kimdre/doco-cd/internal/docker/swarm" - "github.com/kimdre/doco-cd/internal/utils/set" + secrettypes "github.com/kimdre/doco-cd/internal/secretprovider/types" "github.com/kimdre/doco-cd/internal/docker" + "github.com/kimdre/doco-cd/internal/docker/swarm" "github.com/kimdre/doco-cd/internal/git" ) @@ -29,7 +27,7 @@ func shouldSkipDeployment(composeChanged bool, ) bool { return !composeChanged && len(changedServices) == 0 && - ignoredInfo.IsNeedSignal() && + !ignoredInfo.IsNeedSignal() && !imagesChanged && len(mismatchServices) == 0 } @@ -50,7 +48,7 @@ func (s *StageManager) RunPreDeployStage(ctx context.Context, stageLog *slog.Log if s.SecretProvider != nil && *s.SecretProvider != nil && len(s.DeployConfig.ExternalSecrets) > 0 { stageLog.Debug("resolving external secrets", slog.Any("external_secrets", s.DeployConfig.ExternalSecrets)) - encodedSecrets, err := config.EncodeExternalSecretRefs(s.DeployConfig.ExternalSecrets) + encodedSecrets, err := secrettypes.EncodeExternalSecretRefs(s.DeployConfig.ExternalSecrets) if err != nil { return fmt.Errorf("failed to encode external secret references: %w", err) } @@ -75,57 +73,12 @@ func (s *StageManager) RunPreDeployStage(ctx context.Context, stageLog *slog.Log return fmt.Errorf("failed to hash deploy configuration: %w", err) } - if s.DeployConfig.ForceRecreate { - stageLog.Debug("force recreate enabled, skipping pre-deploy image pull check") - } else if s.DeployConfig.ForceImagePull { - stageLog.Debug("force image pull enabled, checking for image updates") - - var ( - beforeImages set.Set[string] - afterImages set.Set[string] - ) - - containers, _ := docker.GetProjectContainers(ctx, s.Docker.Cmd, s.DeployConfig.Name) - - if len(containers) > 0 { - beforeImages, err = docker.GetImages(ctx, s.Docker.Cmd, s.DeployConfig.Name) - if err != nil { - return fmt.Errorf("failed to get images before pull: %w", err) - } - - err = docker.PullImages(ctx, s.Docker.Cmd, s.DeployConfig.Name) - if err != nil { - return fmt.Errorf("failed to pull images: %w", err) - } - - afterImages, err = docker.GetImages(ctx, s.Docker.Cmd, s.DeployConfig.Name) - if err != nil { - return fmt.Errorf("failed to get images after pull: %w", err) - } - - for img := range afterImages { - if !beforeImages.Contains(img) { - imagesChanged = true - break - } - } - - if imagesChanged { - stageLog.Debug("images have changed after pull, proceeding with deployment") - } else { - stageLog.Debug("images have not changed after pull") - } - } else { - stageLog.Debug("no running containers found for the deployment, skipping image pull check") - } - } - - deployedState, err := docker.GetLatestDeployStatus(ctx, s.Docker.Cmd.Client(), getFullName(s.Repository.CloneURL), s.DeployConfig.Name) + deployedState, err := docker.GetLatestDeployStatus(ctx, s.Docker.Cmd.Client(), string(s.Repository.CloneURL), s.DeployConfig.Name) if err != nil { return fmt.Errorf("failed to get latest state from deployed services: %w", err) } - if deployedCommit, _ := deployedState.Labels.GetDeploymentCommitSHA(); deployedCommit != "" { + if deployedCommit := deployedState.GetDeploymentCommitSHA(); deployedCommit != "" { latestCommit, err := git.GetLatestCommit(s.Repository.Git, s.DeployConfig.Reference) if err != nil { return fmt.Errorf("failed to get latest commit: %w", err) @@ -163,12 +116,29 @@ func (s *StageManager) RunPreDeployStage(ctx context.Context, stageLog *slog.Log return fmt.Errorf("failed to load compose project: %w", err) } + if s.DeployConfig.ForceRecreate { + stageLog.Debug("force recreate enabled, skipping pre-deploy image pull check") + } else if s.DeployConfig.ForceImagePull { + stageLog.Debug("force image pull enabled, checking deployed image digests against registry") + + imagesChanged, err = docker.HaveDeployedServiceImageDigestsChanged(ctx, s.Docker.Cmd, s.Docker.Project, stageLog) + if err != nil { + return fmt.Errorf("failed to compare deployed service image digests: %w", err) + } + + if imagesChanged { + stageLog.Debug("deployed image digests differ from registry, proceeding with deployment") + } else { + stageLog.Debug("deployed image digests match registry") + } + } + newHash, err := docker.ProjectHash(s.Docker.Project) if err != nil { return fmt.Errorf("failed to get project hash: %w", err) } - curProjectHash, _ := deployedState.Labels.GetDeploymentComposeHash() + curProjectHash := deployedState.GetDeploymentComposeHash() composeChanged := newHash != curProjectHash if composeChanged { diff --git a/doco-cd-src/internal/stages/stage_3_deploy.go b/doco-cd-src/internal/stages/stage_3_deploy.go index 951a013..66486d7 100644 --- a/doco-cd-src/internal/stages/stage_3_deploy.go +++ b/doco-cd-src/internal/stages/stage_3_deploy.go @@ -6,7 +6,7 @@ import ( "log/slog" "time" - "github.com/kimdre/doco-cd/internal/config" + "github.com/kimdre/doco-cd/internal/config/app" "github.com/kimdre/doco-cd/internal/docker" "github.com/kimdre/doco-cd/internal/git" ) @@ -26,7 +26,7 @@ func (s *StageManager) RunDeployStage(ctx context.Context, stageLog *slog.Logger err = docker.DeployStack(stageLog, s.Repository.PathExternal, &ctx, s.Docker.Cmd, s.Payload, s.DeployConfig, s.DeployState.changedServices, s.DeployState.ignoredInfo.NeedSendSignal, - latestCommit, config.AppVersion) + latestCommit, app.Version) if err != nil { return fmt.Errorf("failed to deploy stack %s: %w", s.DeployConfig.Name, err) } diff --git a/doco-cd-src/internal/stages/stage_3_destroy.go b/doco-cd-src/internal/stages/stage_3_destroy.go index 381739f..a5c0c63 100644 --- a/doco-cd-src/internal/stages/stage_3_destroy.go +++ b/doco-cd-src/internal/stages/stage_3_destroy.go @@ -8,7 +8,7 @@ import ( "path/filepath" "time" - "github.com/kimdre/doco-cd/internal/config" + "github.com/kimdre/doco-cd/internal/config/app" "github.com/kimdre/doco-cd/internal/docker" "github.com/kimdre/doco-cd/internal/docker/swarm" ) @@ -38,7 +38,7 @@ func (s *StageManager) RunDestroyStage(ctx context.Context, stageLog *slog.Logge // Find deployed commit and external secrets hash from labels of deployed services for _, labels := range serviceLabels { - if labels[docker.DocoCDLabels.Metadata.Manager] == config.AppName { + if labels[docker.DocoCDLabels.Metadata.Manager] == app.Name { managed = true break } @@ -53,14 +53,14 @@ func (s *StageManager) RunDestroyStage(ctx context.Context, stageLog *slog.Logge return fmt.Errorf("failed to destroy stack: %w", err) } - if swarm.GetModeEnabled() && s.DeployConfig.DestroyOpts.RemoveVolumes { + if swarm.GetModeEnabled() && s.DeployConfig.Destroy.RemoveVolumes { err = docker.RemoveLabeledVolumes(ctx, s.Docker.Cmd.Client(), s.DeployConfig.Name) if err != nil { return fmt.Errorf("failed to remove volumes: %w", err) } } - if s.DeployConfig.DestroyOpts.RemoveRepoDir { + if s.DeployConfig.Destroy.RemoveRepoDir { // Remove the repository directory after destroying the stack stageLog.Debug("removing deployment directory", slog.String("path", s.Repository.PathExternal)) // Check if the parent directory has multiple subdirectories/repos diff --git a/doco-cd-src/internal/stages/stage_4_post-deploy.go b/doco-cd-src/internal/stages/stage_4_post-deploy.go index 47c321d..bd27b7b 100644 --- a/doco-cd-src/internal/stages/stage_4_post-deploy.go +++ b/doco-cd-src/internal/stages/stage_4_post-deploy.go @@ -28,14 +28,13 @@ func (s *StageManager) RunPostDeployStage(_ context.Context, stageLog *slog.Logg return fmt.Errorf("failed to get short commit SHA: %w", err) } - metadata := notification.Metadata{ - Repository: s.Repository.Name, - Stack: s.DeployConfig.Name, - Revision: notification.GetRevision(s.DeployConfig.Reference, shortCommit), - JobID: s.JobID, - } + metadata := s.Metadata + metadata.Repository = s.Repository.Name + metadata.Stack = s.DeployConfig.Name + metadata.Revision = notification.GetRevision(s.DeployConfig.Reference, shortCommit) + metadata.JobID = s.JobID - err = notification.Send(notification.Success, "Stack deployed", "successfully deployed stack "+s.DeployConfig.Name, metadata) + err = notification.Send(notification.Success, "Deployment completed", "Successfully deployed stack "+s.DeployConfig.Name, metadata) if err != nil { stageLog.Error("failed to send notification", logger.ErrAttr(err)) } diff --git a/doco-cd-src/internal/stages/stage_4_post-destroy.go b/doco-cd-src/internal/stages/stage_4_post-destroy.go index 7cae828..219996c 100644 --- a/doco-cd-src/internal/stages/stage_4_post-destroy.go +++ b/doco-cd-src/internal/stages/stage_4_post-destroy.go @@ -2,40 +2,26 @@ package stages import ( "context" - "fmt" "log/slog" "time" - "github.com/kimdre/doco-cd/internal/git" "github.com/kimdre/doco-cd/internal/logger" "github.com/kimdre/doco-cd/internal/notification" ) func (s *StageManager) RunPostDestroyStage(_ context.Context, stageLog *slog.Logger) error { - s.Stages.PostDeploy.StartedAt = time.Now() + s.Stages.PostDestroy.StartedAt = time.Now() defer func() { - s.Stages.PostDeploy.FinishedAt = time.Now() + s.Stages.PostDestroy.FinishedAt = time.Now() }() - latestCommit, err := git.GetLatestCommit(s.Repository.Git, s.DeployConfig.Reference) - if err != nil { - return fmt.Errorf("failed to get latest commit: %w", err) - } - - shortCommit, err := git.GetShortestUniqueCommitSHA(s.Repository.Git, latestCommit, git.DefaultShortSHALength) - if err != nil { - return fmt.Errorf("failed to get short commit sha: %w", err) - } - - metadata := notification.Metadata{ - Repository: s.Repository.Name, - Stack: s.DeployConfig.Name, - Revision: notification.GetRevision(s.DeployConfig.Reference, shortCommit), - JobID: s.JobID, - } + metadata := s.Metadata + metadata.Repository = s.Repository.Name + metadata.Stack = s.DeployConfig.Name + metadata.JobID = s.JobID - err = notification.Send(notification.Success, "Stack destroyed", "successfully destroyed stack "+s.DeployConfig.Name, metadata) + err := notification.Send(notification.Success, "Stack destroyed", "successfully destroyed stack "+s.DeployConfig.Name, metadata) if err != nil { stageLog.Error("failed to send notification", logger.ErrAttr(err)) } diff --git a/doco-cd-src/internal/stages/types.go b/doco-cd-src/internal/stages/types.go index e3da047..94f50cb 100644 --- a/doco-cd-src/internal/stages/types.go +++ b/doco-cd-src/internal/stages/types.go @@ -10,10 +10,13 @@ import ( "github.com/go-git/go-git/v5" "github.com/moby/moby/api/types/container" + types2 "github.com/kimdre/doco-cd/internal/config" + + "github.com/kimdre/doco-cd/internal/config/app" + "github.com/kimdre/doco-cd/internal/config/deploy" "github.com/kimdre/doco-cd/internal/docker" "github.com/kimdre/doco-cd/internal/notification" - "github.com/kimdre/doco-cd/internal/config" gitInternal "github.com/kimdre/doco-cd/internal/git" "github.com/kimdre/doco-cd/internal/secretprovider" "github.com/kimdre/doco-cd/internal/webhook" @@ -106,7 +109,7 @@ type Stages struct { // RepositoryData holds information about the triggering repository. type RepositoryData struct { - CloneURL config.HttpUrl // Repository clone URL (e.g., "https://github.com/user/my-repo.git") + CloneURL types2.HttpUrl // Repository clone URL (e.g., "https://github.com/user/my-repo.git") Name string // Repository name (e.g., "user/my-repo") PathInternal string // Path to the repository inside the container PathExternal string // Path to the repository on the host machine @@ -133,13 +136,14 @@ type StageManager struct { JobID string // Unique identifier for the job JobTrigger JobTrigger // Trigger type for the job (e.g., "webhook", "poll") NotifyFailureFunc NotifyFailureFunc // Function to call on failure - AppConfig *config.AppConfig - DeployConfig *config.DeployConfig + AppConfig *app.Config + DeployConfig *deploy.Config DeployState *DeploymentState Docker *Docker Payload *webhook.ParsedPayload Repository *RepositoryData SecretProvider *secretprovider.SecretProvider + Metadata notification.Metadata // Notification metadata (may include reconciliation event info) } type NotifyFailureFunc func(log *slog.Logger, err error, metadata notification.Metadata) @@ -148,8 +152,9 @@ type NotifyFailureFunc func(log *slog.Logger, err error, metadata notification.M func NewStageManager(jobID string, jobTrigger JobTrigger, log *slog.Logger, failNotifyFunc NotifyFailureFunc, repoData *RepositoryData, dockerData *Docker, payload *webhook.ParsedPayload, - appConfig *config.AppConfig, deployConfig *config.DeployConfig, + appConfig *app.Config, deployConfig *deploy.Config, secretProvider *secretprovider.SecretProvider, + metadata notification.Metadata, ) *StageManager { return &StageManager{ Log: log.With(), @@ -163,6 +168,7 @@ func NewStageManager(jobID string, jobTrigger JobTrigger, log *slog.Logger, Payload: payload, Repository: repoData, SecretProvider: secretProvider, + Metadata: metadata, Stages: &Stages{ Init: &InitStageData{ MetaData: NewMetaData(StageInit), @@ -174,11 +180,14 @@ func NewStageManager(jobID string, jobTrigger JobTrigger, log *slog.Logger, MetaData: NewMetaData(StageDeploy), }, Destroy: &DestroyStageData{ - MetaData: NewMetaData(StageDeploy), + MetaData: NewMetaData(StageDestroy), }, PostDeploy: &PostDeployStageData{ MetaData: NewMetaData(StagePostDeploy), }, + PostDestroy: &PostDestroyStageData{ + MetaData: NewMetaData(StagePostDestroy), + }, Cleanup: &CleanupStageData{ MetaData: NewMetaData(StageCleanup), }, @@ -232,11 +241,12 @@ func (s *StageManager) NotifyFailure(notifyErr error) { revision := notification.GetRevision(s.DeployConfig.Reference, commitSha) - s.NotifyFailureFunc(s.Log, notifyErr, notification.Metadata{ - Repository: s.Repository.Name, - Stack: s.DeployConfig.Name, - Revision: revision, - JobID: s.JobID, - }) + metadata := s.Metadata + metadata.Repository = s.Repository.Name + metadata.Stack = s.DeployConfig.Name + metadata.Revision = revision + metadata.JobID = s.JobID + + s.NotifyFailureFunc(s.Log, notifyErr, metadata) } } diff --git a/doco-cd-src/internal/test/compose.go b/doco-cd-src/internal/test/compose.go index 3b34148..65ae055 100644 --- a/doco-cd-src/internal/test/compose.go +++ b/doco-cd-src/internal/test/compose.go @@ -213,7 +213,7 @@ func ComposeUp(ctx context.Context, t *testing.T, opts ...ComposeOption) *Compos retry.Attempts(3), retry.RetryIf(func(err error) bool { // Retry if error contains "No such image" - return strings.Contains(err.Error(), "No such image:") + return strings.Contains(strings.ToLower(err.Error()), "no such image") }), ).Do( func() error { diff --git a/doco-cd-src/wiki/README.md b/doco-cd-src/wiki/README.md index b24425b..9e75d0c 100644 --- a/doco-cd-src/wiki/README.md +++ b/doco-cd-src/wiki/README.md @@ -61,12 +61,10 @@ Please follow these rules when contributing to the wiki: The workflow in `.github/workflows/docs.yaml` publishes docs to the `gh-pages` branch when a GitHub release is published, which GitHub Pages serves from. -Supported release tag formats: - -- stable: `vX.Y.Z` -- prerelease: `vX.Y.Z-rc.N` (release candidates) - -Each release is published to `//` (for example `/v1.2.3/`). The site root redirects to the latest stable release. +- Release tag format for stable/normal docs releases: `vX.Y.Z` +- Each stable release is published to `//` without the patch version (for example `v0.80.3` -> `/v0.80/`). +- The site root redirects to the `/latest/` alias, which always serves the latest stable release. This allows users to link to `doco.cd/latest/` for the most recent stable docs without needing to update links for each release. +- Changes to `main` are published to `/next/` for testing and preview purpose. ## Custom domain diff --git a/doco-cd-src/wiki/docs/Advanced/Encryption.md b/doco-cd-src/wiki/docs/Advanced/Encryption.md index fe6bd17..716d79b 100644 --- a/doco-cd-src/wiki/docs/Advanced/Encryption.md +++ b/doco-cd-src/wiki/docs/Advanced/Encryption.md @@ -14,16 +14,17 @@ Doco-CD supports the encryption of sensitive data in your doco-cd app config and SOPS supports files in the following formats: -- YAML files with the `.yaml` or `.yml` extension -- JSON files with the `.json` extension -- Dotenv files with the `.env` extension -- Ini files with the `.ini` extension -- Binary/text files (default format) +| Format | Required file extension | Example | +|-------------|-----------------------------------------------------|--------------------------------------------| +| YAML | `.yaml` or `.yml` | `example.yaml` | +| JSON | `.json` | `example.json` | +| Dotenv | `.env` | `example.env` | +| INI | `.ini` | `example.ini` | +| Binary/Text | _any other or none_
**Fallback/Default format** | `example.txt`
`example` (no extension) | ## Usage with SOPS and age -!!! note - I recommend to use [SOPS with age](https://getsops.io/docs/#encrypting-using-age) for encrypting your deployment files. +!!! tip "I recommend to use [SOPS with age](https://getsops.io/docs/#encrypting-using-age) for encrypting your deployment files." For this, you need to @@ -78,7 +79,7 @@ secrets: file: sops_age_key.txt ``` -1. docker secrets are always mounted in the `/run/secrets/` directory if no target is specified +1. Docker [Secrets](https://docs.docker.com/reference/compose-file/services/#secrets) are always mounted in the `/run/secrets/` directory if no target is specified ### App configuration with SOPS-encrypted values @@ -86,26 +87,27 @@ To use encrypted values in the doco-cd app configuration, store secrets in encry `*_FILE` environment variables (for example, `GIT_ACCESS_TOKEN_FILE`). Each variable should point to the encrypted file path inside the container. -For example, to use an encrypted Git access token, create a text file with the token and encrypt it with SOPS: -```bash -printf "my-git-access-token" > git-access-token.txt -sops encrypt --age age1g3lcl... git-access-token.txt > git-access-token.enc.txt -``` - -Then set the `GIT_ACCESS_TOKEN_FILE` environment variable in your `docker-compose.yml` file to the encrypted file path: +!!! example "Encrypted Git access token" + To use an encrypted Git access token, create a text file with the token and encrypt it with SOPS: + ```bash + printf "my-git-access-token" > git-access-token.txt + sops encrypt --age age1g3lcl... git-access-token.txt > git-access-token.enc.txt + ``` -```yaml title="docker-compose.yml" hl_lines="3-6 8-11" -services: - app: - environment: - GIT_ACCESS_TOKEN_FILE: /path/to/git-access-token + Then set the `GIT_ACCESS_TOKEN_FILE` environment variable in your `docker-compose.yml` file to the encrypted file path: + + ```yaml title="docker-compose.yml" hl_lines="3-6 8-11" + services: + app: + environment: + GIT_ACCESS_TOKEN_FILE: /path/to/git-access-token + secrets: + - git_access_token + secrets: - - git_access_token - -secrets: - git_access_token: - file: git-access-token.enc.txt -``` + git_access_token: + file: git-access-token.enc.txt + ``` ### Deployment with a SOPS-encrypted file @@ -121,8 +123,7 @@ Generate the encrypted file with SOPS: sops encrypt --age age1g3lcl... secrets.env > secrets.enc.env ``` -!!! tip - You can later edit the encrypted file in-place with +!!! tip "You can later edit the encrypted file in-place with" ```sh sops edit secrets.enc.env ``` diff --git a/doco-cd-src/wiki/docs/Advanced/Job-Scheduling.md b/doco-cd-src/wiki/docs/Advanced/Job-Scheduling.md new file mode 100644 index 0000000..ec48932 --- /dev/null +++ b/doco-cd-src/wiki/docs/Advanced/Job-Scheduling.md @@ -0,0 +1,213 @@ +--- +tags: + - Advanced + - Deployment + - Docker + - Swarm Mode +--- + +# Job Scheduling + +The built-in job scheduler allows you to run containers/services defined in your docker compose files as scheduled jobs based on cron-like schedules or predefined intervals. +This is useful for running periodic tasks such as backups, maintenance scripts, or any recurring workloads without needing an external scheduler. + +## Schedule formats + +- [Cron expressions](https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format) **without** seconds (`minute hour day-of-month month day-of-week`) +- [Predefined schedules](https://pkg.go.dev/github.com/robfig/cron#hdr-Predefined_schedules) like `@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly` +- [Intervals](https://pkg.go.dev/github.com/robfig/cron#hdr-Intervals) like `@every ` (for example `@every 30m`) + +!!! tip + Use an online cron expression generator like [crontab.guru](https://crontab.guru/) to create and validate cron expressions. + +!!! example "Schedule examples" + + === "Every 15 minutes" + + === "Using cron expression" + + ```yaml title="docker-compose.yml" + services: + backup: + image: example/backup:latest + labels: + cd.doco.job.enabled: "true" + cd.doco.job.schedule: "*/15 * * * *" + ``` + + === "Using interval format" + + ```yaml title="docker-compose.yml" + services: + backup: + image: example/backup:latest + labels: + cd.doco.job.enabled: "true" + cd.doco.job.schedule: "@every 15m" + ``` + + === "Weekdays at 02:30" + + ```yaml title="docker-compose.yml" + services: + backup: + image: example/backup:latest + labels: + cd.doco.job.enabled: "true" + cd.doco.job.schedule: "30 2 * * 1-5" + ``` + + === "First day of month at midnight" + + === "Using cron expression" + + ```yaml title="docker-compose.yml" + services: + cleanup: + image: example/backup:latest + labels: + cd.doco.job.enabled: "true" + cd.doco.job.schedule: "0 0 1 * *" + ``` + + === "Using predefined schedule" + + ```yaml title="docker-compose.yml" + services: + cleanup: + image: example/backup:latest + labels: + cd.doco.job.enabled: "true" + cd.doco.job.schedule: "@monthly" + ``` + +## Execution modes + +The execution mode determines how scheduled jobs are run and managed by doco-cd and can be configured using the `cd.doco.job.execution_mode` label on the service. + +### `restart` + +By default, scheduled jobs will be executed in `restart` mode, which means the service will be created on deployment +and then re-/started at the scheduled time without being removed after completion. + +### `one_off` + +Alternatively, you can configure scheduled jobs to run in `one_off` mode, which means a new ephemeral container will +be created for each scheduled run and removed after completion. + +!!! note + You won't be able to see the container or its logs after the job has completed, + so make sure to configure appropriate logging (e.g., log to a persistent file or logging service like [Loki](https://grafana.com/docs/loki/latest/)) + if you need to keep track of job runs and [notifications](Notifications.md) if needed. + +??? info "`one_off` behavior in Docker Swarm" + + In Docker Swarm, `one_off` does **not** modify the source service mode permanently. + Instead, doco-cd creates a temporary job service for each scheduled run, waits for completion, + and removes that temporary service afterwards. + + This means the original service may still show `replicated`/`global` when inspected, + while each one-off execution runs as a temporary `replicated-job`/`global-job` service. + + See also [Swarm `deploy.mode` configuration](#swarm-deploymode) for how the original service's deploy mode affects the temporary job service's deploy mode in one-off executions. + + **Behavior summary** + + | `cd.doco.job.execution_mode` | What doco-cd acts on | Service mode after run | + |------------------------------|----------------------|------------------------| + | `restart` | Existing service | Unchanged | + | `one_off` | Temporary clone | Source unchanged | + +??? warning "Deprecated: `one_shot` has been renamed to `one_off`" + `one_shot` has been renamed to `one_off` and will be removed in a future release. + Use `one_off` instead. The old value is still accepted for backward compatibility but will log a warning. + +## Configuration + +??? example "How to set service labels in a docker compose file" + To set service labels in a docker compose file, include them in the `labels` section of your service definition: + + ```yaml title="docker-compose.yml" + services: + app: + image: ghcr.io/example/app:latest + labels: + cd.doco.job.enabled: "true" + cd.doco.job.schedule: "@every 15m" + ``` + +!!! note "Restart policy constraints" + + - Docker (Standalone): service `restart` must be unset or `no` + - Docker Swarm: service `deploy.restart_policy.condition` must be unset or `none` + +Use the following service labels to configure scheduled jobs: + +| Label | Type | Description | Default | +|------------------------------|---------|-----------------------------------------------------------------------------------------------------------|-----------| +| `cd.doco.job.enabled` | boolean | Enable scheduling for this service/container | `false` | +| `cd.doco.job.schedule` | string | [Schedule format](#schedule-formats) to use | | +| `cd.doco.job.execution_mode` | string | [`restart`](#restart) (default behavior) or [`one_off`](#one_off) (ephemeral execution) | `restart` | +| `cd.doco.job.skip_running` | boolean | Do not run the job if a previous scheduled run is still active/running | `false` | +| `cd.doco.job.notify_on` | string | [Notification](Notifications.md) behavior for scheduled runs: `none`, `success`, `failure`, `all` | `all` | +| `cd.doco.job.swarm.replicas` | integer | Number of completions/concurrency for swarm one-off jobs in `replicated` [deploy mode](#swarm-deploymode) | `1` | + +### Swarm `deploy.mode` + +When using Docker Swarm, you can configure the deploy mode for scheduled jobs using the `deploy.mode` field in your docker compose file. + +The following mapping applies to scheduled runs in `one_off` mode: + +- If the service uses `#!yaml deploy.mode: global`, the job run is created as `global-job` +- If the service uses `#!yaml deploy.mode: replicated` or does not specify a deploy mode, the job run is created as `replicated-job` with the number of completions/concurrency determined by the `cd.doco.job.swarm.replicas` label. + +## Examples + +=== "Prune swarm nodes" + + Prune Docker system every hour on all swarm nodes using a global one-off job service + + ```yaml title="docker-compose.yml" + services: + prune: + image: docker:latest + command: ["docker", "system", "prune", "-f"] + volumes: + - "/var/run/docker.sock:/var/run/docker.sock" + deploy: + mode: global + restart_policy: + condition: none + labels: + cd.doco.job.enabled: "true" + cd.doco.job.schedule: "@hourly" + cd.doco.job.execution_mode: "one_off" + ``` + +=== "Backup" + + Run a backup script every day at 02:00, but skip if the previous run is still active + + ```yaml title="docker-compose.yml" + services: + backup: + image: ghcr.io/my-org/backup:1.2.3 + command: ["/backup.sh"] + restart: no + labels: + cd.doco.job.enabled: "true" + cd.doco.job.schedule: "0 2 * * *" + cd.doco.job.skip_running: "true" + ``` + +## Timezone + +Scheduled jobs are triggered based on the timezone of the doco-cd instance, which is determined by the `TZ` environment variable or defaults to UTC if not set. +You can find a list of all possible timezone values on [wikipedia](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). + +## Daylight saving time (DST) + +When DST changes occur in the configured [timezone](#timezone), scheduled jobs will adjust accordingly: + +- If a scheduled time is skipped due to DST (e.g., clocks move forward), the job will not run at that time. +- If a scheduled time occurs twice due to DST (e.g., clocks move backward), the job will run at both occurrences of that time. \ No newline at end of file diff --git a/doco-cd-src/wiki/docs/Advanced/Notifications.md b/doco-cd-src/wiki/docs/Advanced/Notifications.md index e5e75b2..177d06b 100644 --- a/doco-cd-src/wiki/docs/Advanced/Notifications.md +++ b/doco-cd-src/wiki/docs/Advanced/Notifications.md @@ -5,31 +5,32 @@ tags: - Notifications --- -Doco-CD can be configured to send notifications with [Apprise](https://github.com/caronc/apprise) to various services when a deployment is started, finished, or failed. -You can find a list of all supported services and platforms in the [Apprise documentation](https://github.com/caronc/apprise/wiki#notification-services). +Doco-CD can be configured to send notifications with [Apprise](https://github.com/caronc/apprise) to various services when a deployment is started, finished, or failed and on [reconciliation](../Deploy-Settings.md#reconciliation-settings) events. +You can find a list of all supported services and platforms in the [Apprise documentation](https://appriseit.com/). For that, specify the required settings in the app `environment` section and add an Apprise container to your `docker-compose.yml` file. ## Settings -| Key | Type | Description | Default value | -|----------------------------|--------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| -| `APPRISE_API_URL` | string | The URL of the Apprise API to send notifications to (e.g. `http://apprise:8000/notify`) | | -| `APPRISE_NOTIFY_URLS` | string | A comma-separated list of Apprise-URLs to send notifications to the [supported services/platforms](https://github.com/caronc/apprise/wiki#notification-services) (e.g. `pover://{user_key}@{token},mailto://{user}:{password}@{domain}`) | | -| `APPRISE_NOTIFY_URLS_FILE` | string | Path to the file inside the container containing the Apprise-URLs (see `APPRISE_NOTIFY_URLS`). Mutually exclusive with `APPRISE_NOTIFY_URLS`. | | -| `APPRISE_NOTIFY_LEVEL` | string | The minimum level of notifications to send. Possible values: `info`, `success`, `warning`, `failure` | `success` | +| Key | Type | Description | Default value | +|----------------------------|--------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| +| `APPRISE_API_URL` | string | The URL of the Apprise API to send notifications to (e.g. `http://apprise:8000/notify`) | | +| `APPRISE_NOTIFY_URLS` | string | A comma-separated list of Apprise-URLs to send notifications to the [supported services/platforms](https://appriseit.com/services/) (e.g. `pover://{user_key}@{token},mailto://{user}:{password}@{domain}`) | | +| `APPRISE_NOTIFY_URLS_FILE` | string | Path to the file inside the container containing the Apprise-URLs (see `APPRISE_NOTIFY_URLS`). Mutually exclusive with `APPRISE_NOTIFY_URLS`. | | +| `APPRISE_NOTIFY_LEVEL` | string | The minimum level of notifications to send. Possible values: `info`, `success`, `warning`, `failure` | `success` | ## Example `docker-compose.yml` Adjust your `docker-compose.yml` file to include the Apprise service and the necessary environment variables for the app: -```yaml title="docker-compose.yml" hl_lines="5-10 12-19" +```yaml title="docker-compose.yml" hl_lines="5-11 13-26" services: app: container_name: doco-cd # add the code below to your existing docker-compose.yml file depends_on: - - apprise + apprise: + condition: service_healthy environment: APPRISE_API_URL: http://apprise:8000/notify APPRISE_NOTIFY_LEVEL: success @@ -43,4 +44,52 @@ services: environment: TZ: Europe/Berlin APPRISE_WORKER_COUNT: 1 -``` \ No newline at end of file + healthcheck: + test: [ "CMD-SHELL", "curl -fsS http://localhost:8000/status >/dev/null || exit 1" ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s +``` + +## Metadata fields + +When a notification is sent, the following metadata fields are included in the notification body: + +| Field name | Description | Example | +|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------| +| `job_id` | Unique ID of the deployment job that triggered the notification (not included for [reconciliation notifications](#reconciliation-notifications)) | | +| `repository` | Repository name | `github.com/my/repo` | +| `revision` | Branch/tag and Commit SHA that was deployed | `main (abc123)`, `v1.0.0 (def456)` | +| `stack` | Project/Stack name | `my-stack` | + +## Reconciliation notifications + +If a notification was triggered by reconciliation, the title gets a short `[R]` marker. + +!!! example "Example notification titles" + + - Regular deploy notification title: `✅ Deployment completed` + - Reconciliation notification title: `✅ [R] Deployment completed` + +Reconciliation notifications also include a `reconciliation:` block in the body [metadata](#metadata-fields). + +### Metadata fields + +=== "Docker Standalone" + + | Field name | Description | + |------------------|------------------------------------------------| + | `event` | reconciliation event that triggered the action | + | `container_id` | affected container name | + | `container_name` | affected container name | + | `trace_id` | reconciliation trace ID for log correlation | + +=== "Docker Swarm" + + | Field name | Description | + |-----------------|------------------------------------------------| + | `event` | reconciliation event that triggered the action | + | `service_id` | affected service name | + | `service_name` | affected service name | + | `trace_id` | reconciliation trace ID for log correlation | diff --git a/doco-cd-src/wiki/docs/Advanced/Pre-Post-Deployment-Scripts.md b/doco-cd-src/wiki/docs/Advanced/Pre-Post-Deployment-Scripts.md index 379cf9f..c15ca41 100644 --- a/doco-cd-src/wiki/docs/Advanced/Pre-Post-Deployment-Scripts.md +++ b/doco-cd-src/wiki/docs/Advanced/Pre-Post-Deployment-Scripts.md @@ -19,7 +19,7 @@ Available options to run scripts/commands during deployment or container lifecyc - [Sidecar Containers](#sidecar-containers) - [Compose Lifecycle Hooks](#compose-lifecycle-hooks) ---8<-- "wiki/docs/_snippets/reconciliation-note.md" +--8<-- "wiki/includes/reconciliation-note.md" ## Init Containers @@ -33,13 +33,13 @@ Some common use cases for init containers include: We use the `depends_on` option with the `condition: service_completed_successfully` condition to ensure that the main application container waits for the init container to complete successfully before starting. The init container will run its specified commands and exit with a status code of 0 to indicate success, allowing the main application container to start afterward. -!!! success "Recommended Restart Policy for One-Time Script Services" +!!! success "Recommended [Restart Policy](https://docs.docker.com/reference/compose-file/services/#restart) for One-Time Script Services" It is recommended to use `#!yaml restart: on-failure` for one-time script services to allow them to remain stopped after successful completion, while still enabling automatic restarts in case of failures. ### Example -```yaml title="docker-compose.yml" hl_lines="1-2 4-18 23 28-30" -x-common-env: &common-env +```yaml title="docker-compose.yml" hl_lines="1-2 5-19 24 29-31" +x-common-env: &common-env # (4)! MYVAR: world # We will use this variable in both init and app containers services: @@ -48,7 +48,7 @@ services: restart: on-failure:3 # (3)! environment: <<: *common-env - entrypoint: "sh -c" # Use sh -c as the entrypoint to run multiple commands in "command" section + entrypoint: "sh -c" # (5)! volumes: - ./web:/web working_dir: /web @@ -73,8 +73,10 @@ services: ``` 1. Double dollar-sign (`$$`) is required to use variables in the shell script, otherwise Docker Compose will try to resolve it as a variable in the `docker-compose.yml` file instead of passing it to the container. -2. Wait for init container to complete/stop with exit code 0 -3. Using `restart: on-failure:3` allows the init container to be retried up to 3 times in case of failure during script execution, while still allowing it to remain stopped after successful completion. To retry indefinitely, you can use `restart: on-failure` without a retry limit. +2. Wait for init container to complete/stop with exit code `0` using [`depends_on`](https://docs.docker.com/reference/compose-file/services/#depends_on) +3. Using `restart: on-failure:3` allows the init container to be retried up to 3 times in case of failure during script execution, while still allowing it to remain stopped after successful completion. To retry indefinitely, you can use `restart: on-failure` without a retry limit. See [Restart Policy Documentation](https://docs.docker.com/reference/compose-file/services/#restart). +4. See [Fragments](https://docs.docker.com/reference/compose-file/fragments/) and [Extensions](https://docs.docker.com/reference/compose-file/extension/) in the Compose File Reference for more details on how to use YAML anchors and aliases to share common configuration between services. +5. Use `sh -c` as the [entrypoint](https://docs.docker.com/reference/compose-file/services/#entrypoint) to run multiple commands in the [`command`](https://docs.docker.com/reference/compose-file/services/#command) section - If you have a shell script in your repo for the init stuff, you can remove `entrypoint` and mount the script directly and run it via the `command` option: ```yaml title="docker-compose.yml" @@ -89,15 +91,18 @@ services: #### container exited (0) -If the deployment fails with an error containing a message like `container exited (0)`, try to add a short sleep at the end of the init container commands. This is a workaround for a known issue where the init container may exit before the main container starts waiting for it, causing the main container to miss the successful completion of the init container. Adding a short sleep ensures that the init container has time to exit properly before the main container checks its status. +If the deployment fails with an error containing a message like `container exited (0)`, try to add a short sleep at the end of the init container commands. +This is a workaround for a known issue where the init container may exit before the main container starts waiting for it, causing the main container to miss the successful completion of the init container. +Adding a short sleep ensures that the init container has time to exit properly before the main container checks its status. -**Example**: -```yaml title="Add a sleep command to the init container in your docker-compose.yml" -entrypoint: ["/bin/sh", "-c"] -command: [" && sleep 3"] # (1)! -``` +!!! example "Add a sleep command to the init container in your docker-compose.yml" + The sleep duration can be adjusted based on the expected time for the init commands to complete. + ```yaml title="docker-compose.yml" + entrypoint: ["/bin/sh", "-c"] + command: [" && sleep 3"] # (1)! + ``` -1. Depending on the complexity of your init commands, you may need to adjust the sleep duration. + 1. Depending on the complexity of your init commands, you may need to adjust the sleep duration. Related issue: [#1115](https://github.com/kimdre/doco-cd/issues/1115) @@ -105,13 +110,14 @@ Related issue: [#1115](https://github.com/kimdre/doco-cd/issues/1115) Sidecar containers are additional containers that run alongside your main application containers. They can be used to provide auxiliary services, such as background tasks, metrics collection, or log forwarding. Some common use cases for sidecar containers include: -- Background tasks, e.g. cron jobs or scheduled tasks + +- Background tasks, e.g. cron jobs or scheduled tasks (See also [Job Scheduling / Cron Jobs](Job-Scheduling.md) for the built-in scheduling feature) - Metrics collection for monitoring tools like Prometheus - Log forwarding to external systems ### Example -```yaml title="docker-compose.yml" hl_lines="12-25" +```yaml title="docker-compose.yml" hl_lines="12-26" volumes: webdata: @@ -163,7 +169,7 @@ This example demonstrates how to use the `post_start` hook to set up the correct - Second hook sets appropriate read/write permissions 4. **Application Runs**: The application can now access the volume with proper permissions -```yaml title="docker-compose.yml" +```yaml title="docker-compose.yml" hl_lines="7-11" services: app: image: backend @@ -194,7 +200,7 @@ This example demonstrates how to use the `pre_stop` hook to run cleanup tasks be 3. Notify monitoring system 3. **Container Stops**: After hooks complete, container proceeds with shutdown or restart -```yaml title="docker-compose.yml" +```yaml title="docker-compose.yml" hl_lines="4-7" services: app: image: backend @@ -210,4 +216,3 @@ services: - [Docker Compose Post Start Hook Documentation](https://docs.docker.com/reference/compose-file/services/#post_start) - [Docker Compose Pre Stop Hook Documentation](https://docs.docker.com/reference/compose-file/services/#pre_stop) - [Reconciliation settings](../Deploy-Settings.md#reconciliation-settings) -- [Known Limitations: Reconciliation behavior for one-time services](../Known-Limitations.md#reconciliation-behavior-for-one-time-services) diff --git a/doco-cd-src/wiki/docs/Advanced/Private-Container-Registries.md b/doco-cd-src/wiki/docs/Advanced/Private-Container-Registries.md index 77a8797..b2c6f7c 100644 --- a/doco-cd-src/wiki/docs/Advanced/Private-Container-Registries.md +++ b/doco-cd-src/wiki/docs/Advanced/Private-Container-Registries.md @@ -9,6 +9,34 @@ tags: To access a private container registry, you need to provide the credentials by adding the docker config file `~/.docker/config.json` to the doco-cd container. +## Mounting an existing docker config file + +You can mount your existing `~/.docker/config.json` file from the host to the container if you have already added the credentials using `docker login` on the host machine. + +??? example "How to add credentials using `docker login`" + + Run `docker login` to add the credentials to the config file: + + ```sh + docker login my.registry.example + ``` + + If the login is successful, the credentials will be stored in the `~/.docker/config.json` file on your host machine. You can then mount this file to the doco-cd container to allow it to access the private registry. + +Mount the config file to the container: + +```yaml title="docker-compose.yml" hl_lines="7" +services: + app: + container_name: doco-cd + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - data:/data + - ~/.docker/config.json:/root/.docker/config.json:ro +``` + +## Using a custom docker config file + 1. Encode your credentials to base64 (here we use `printf` to avoid the trailing newline, you can also use `echo -n`): ```sh diff --git a/doco-cd-src/wiki/docs/Advanced/Swarm-Mode.md b/doco-cd-src/wiki/docs/Advanced/Swarm-Mode.md index bf8d550..80ebb55 100644 --- a/doco-cd-src/wiki/docs/Advanced/Swarm-Mode.md +++ b/doco-cd-src/wiki/docs/Advanced/Swarm-Mode.md @@ -10,7 +10,7 @@ This is useful for deploying applications across multiple nodes and ensuring hig If the Docker daemon is running in Swarm mode, doco-cd will detect this automatically and deploy everything as Swarm stacks instead of simple Compose projects. -You can overwrite this to always deploy as Compose projects while running doco-cd in a Swarm environment by setting the `DOCKER_SWARM_FEATURES` environment variable to `false` (See the [Docker-specific App Settings](../App-Settings.md#docker-specific-settings)). +You can overwrite this to always deploy as Compose projects while running doco-cd in a Swarm environment by setting the `DOCKER_SWARM_FEATURES` environment variable to `false` (See the [Docker Settings](../Docker-Settings.md)). The deployment happens the same way as with Docker Compose projects, see the [Deploy Settings](../Deploy-Settings.md) diff --git a/doco-cd-src/wiki/docs/Advanced/Tips-and-Tricks.md b/doco-cd-src/wiki/docs/Advanced/Tips-and-Tricks.md index 662a2b9..8ef1c4c 100644 --- a/doco-cd-src/wiki/docs/Advanced/Tips-and-Tricks.md +++ b/doco-cd-src/wiki/docs/Advanced/Tips-and-Tricks.md @@ -11,32 +11,33 @@ Some Tips and Tricks for using application. ## Removing a container service -### Docker Standalone +=== "Docker Standalone" -You can add the `scale: 0` option in the `docker-compose.yml` file to remove a service (container). -The `scale` option sets the number of containers to run for the service. Setting it to `0` will scale the service down to zero containers. - -```yaml title="docker-compose.yml" hl_lines="3" -services: - webserver: - scale: 0 - image: nginx -``` - -!!! tip - If you set the `scale: 0` option to all services in the docker compose file, the entire project will be stopped - and removed, excluding any volumes, networks, and images. + You can add the `scale: 0` option in the `docker-compose.yml` file to remove a service (container). + The `scale` option sets the number of containers to run for the service. Setting it to `0` will scale the service down to zero containers. - To delete volumes, networks, and images, you can use the `destroy` option in the deployment configuration file (See [Destroy settings](Deploy-Settings.md#destroy-settings)). + ```yaml title="docker-compose.yml" hl_lines="3" + services: + webserver: + scale: 0 + image: nginx + ``` -### Docker Swarm +=== "Docker Swarm" -Add the following line to the `deploy` section of the service in the `docker-compose.yml` file to remove a service (container) in Docker Swarm mode: - -```yaml title="docker-compose.yml" hl_lines="3-4" -services: - webserver: - deploy: - replicas: 0 - image: nginx -``` + Add the following line to the `deploy` section of the service in the `docker-compose.yml` file to remove a service (container) in Docker Swarm mode: + + ```yaml title="docker-compose.yml" hl_lines="3-4" + services: + webserver: + deploy: + replicas: 0 + image: nginx + ``` + +!!! note + If you scale down all services in a Docker project or Swarm stack, the entire project will be stopped, + but the volumes, configs, secrets, and networks will still exist. + + !!! tip + To delete volumes, networks, and images, you can use `destroy: true` for the default destructive cleanup behavior, or the full `destroy` object for custom removal options (See [Destroy settings](../Deploy-Settings.md#destroy-settings)). diff --git a/doco-cd-src/wiki/docs/App-Settings.md b/doco-cd-src/wiki/docs/App-Settings.md index f1a34f8..5f333e6 100644 --- a/doco-cd-src/wiki/docs/App-Settings.md +++ b/doco-cd-src/wiki/docs/App-Settings.md @@ -5,62 +5,36 @@ tags: # Application Settings -## Available Settings +## General Settings The application can be configured using the following environment variables: -| Key | Type | Description | Default value | -|-----------------------------------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------| -| `API_SECRET` | string | Secret that is used to authenticate requests to the REST API (see [REST API](Endpoints/REST-API.md)) | ` ` (Rest API is disabled when not specified) | -| `API_SECRET_FILE` | string | Path to the file containing the API secret (Mutually exclusive with `API_SECRET`). | ` ` | -| `AUTH_TYPE` | string | AuthType is the type of authentication to use when cloning repositories via **http** | `oauth2` | -| `DEPLOY_CONFIG_BASE_DIR` | string | Relative Path to the directory containing the deployment configuration files **in all repositories**. **NOTE**: This does not affect/alter the `working_dir` path in the deploy config. It must still be relative to the repository root. | `/` | -| `GIT_ACCESS_TOKEN` | string | Access token for cloning repositories (required for private repositories) via **HTTP**, see [Access Token Setup](Setup-Access-Token.md) | ` ` (Optional for public repositories but recommended) | -| `GIT_ACCESS_TOKEN_FILE` | string | Path to the file containing the Git Access Token (Mutually exclusive with `GIT_ACCESS_TOKEN`). | ` ` | -| `GIT_CLONE_DEPTH` | number | Limits the number of commits fetched during clone/fetch operations (shallow clone). `0` means full clone (no depth limit). Can be overridden per deployment via the [`git_depth`](Deploy-Settings.md) setting. When a requested ref is outside the shallow depth, doco-cd will automatically deepen incrementally before falling back to a full fetch. | `0` | -| `GIT_CLONE_SUBMODULES` | boolean | Whether Git submodules are cloned too | `true` | -| `HTTP_PORT` | number | Port on which the application will listen for incoming webhooks, API requests and [healthchecks](#healthcheck) | `80` | -| `HTTP_PROXY` | string | HTTP proxy to use for outgoing requests (e.g. `http://username:password@proxy.com:8080`) | ` ` (Ignored when not specified) | -| `LOG_LEVEL` | string | Log level of the app. Possible values: `debug`, `info`, `warn`, `error` | `INFO` | -| `MAX_CONCURRENT_DEPLOYMENTS` | number | Maximum number of concurrent deployments allowed | `4` | -| `MAX_DEPLOYMENT_LOOP_COUNT` | number | When the deployment loop detection should trigger a forced re-deployment on consecutive deployments for the same commit. Set to `0`, to disable the detection logic. | `2` | -| `MAX_PAYLOAD_SIZE` | number | The maximum size of the webhook payload in bytes that the HTTP server will accept | `1048576` (1MB = 1 * 1024 * 1024) | -| `METRICS_PORT` | number | Port on which the application will expose [Prometheus metrics](Endpoints/Metrics.md) | `9120` | -| `PASS_ENV` | boolean | Controls whether environment variables from the doco-cd container should be passed to the deployment environment for docker compose variable interpolation. Use with caution, as this may expose sensitive information to the deployment environment. | `false` | -| `POLL_CONFIG` | list | A list/array of poll configurations provided in YAML format (see [Poll Settings](Poll-Settings.md)) | ` ` (Ignored when not specified) | -| `POLL_CONFIG_FILE` | string | Path to the file inside the container containing the poll configurations in YAML format (see [Poll Settings](Poll-Settings.md)) | ` ` (Ignored when not specified) | -| `SKIP_TLS_VERIFICATION` | boolean | Skip TLS verification when cloning repositories. | `false` | -| `SSH_PRIVATE_KEY` | string | The private key used for cloning repositories via SSH, see [SSH Key Setup](Setup-SSH-Key.md) | ` ` | -| `SSH_PRIVATE_KEY_FILE` | string | Path to the file containing the SSH private key | ` ` | -| `SSH_PRIVATE_KEY_PASSPHRASE` | string | Passphrase for the SSH private key (if key was generated with a passphrase) | ` ` | -| `SSH_PRIVATE_KEY_PASSPHRASE_FILE` | string | Path to the file containing the SSH private key passphrase | ` ` | -| `TZ` | string | The timezone used in the container and for timestamps in logs | `UTC` | -| `WEBHOOK_SECRET` | string | Secret that is used by webhooks for authentication to the application | ` ` (Webhook endpoint is disabled when not specified) | -| `WEBHOOK_SECRET_FILE` | string | Path to the file containing the Git access token (Mutually exclusive with `WEBHOOK_SECRET`). | ` ` | - -## Docker-specific Settings - -Settings to configure the Docker client used by doco-cd to interact with the Docker daemon. -By default, the Docker client will use the settings from the host system. - -!!! note "All of these settings are optional." - - -| Key | Type | Description | Default value | -|-------------------------|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| -| `DOCKER_API_VERSION` | string | Overwrites the API version that doco-cd will use to connect to the Docker Daemon (e.g. `"1.49"`) | | -| `DOCKER_CERT_PATH` | string | The directory from which to load the TLS certificates ("ca.pem", "cert.pem", "key.pem'). The directory has to be accessible from inside the container, e.g. by using a bind mount | | -| `DOCKER_HOST` | string | The url that doco-cd will use to connect to the Docker Daemon (e.g. `tcp://192.168.0.10:2375`) | | -| `DOCKER_QUIET_DEPLOY` | boolean | Disable the status output of Docker Compose deployments (e.g. pull, create, start, healthy) in the application logs | `true` | -| `DOCKER_TLS_VERIFY` | boolean | Enable or disable TLS verification | | -| `DOCKER_SWARM_FEATURES` | boolean | Enable the use Docker Swarm Mode features if the app has detected that it is running in a Docker Swarm environment | `true` | +| Key | Type | Description | Default | +|-----------------------------------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------| +| `API_SECRET` | string | Secret that is used to authenticate requests to the REST API (see [REST API](Endpoints/REST-API.md)) | Rest API is disabled when not specified | +| `API_SECRET_FILE` | string | Path to the file containing the API secret (Mutually exclusive with `API_SECRET`). | | +| `DEPLOY_CONFIG_BASE_DIR` | string | Relative Path to the directory containing the deployment configuration files **in all repositories**. **NOTE**: This does not affect/alter the `working_dir` path in the deploy config. It must still be relative to the repository root. | `/` | +| `HTTP_PORT` | number | Port on which the application will listen for incoming webhooks, API requests and [healthchecks](Endpoints/Healthcheck.md) | `80` | +| `HTTP_PROXY` | string | HTTP proxy to use for outgoing requests (e.g. `http://username:password@proxy.com:8080`) | Ignored when not specified | +| `LOG_LEVEL` | string | Log level of the app. Possible values: `debug`, `info`, `warn`, `error` | `INFO` | +| `MAX_CONCURRENT_DEPLOYMENTS` | number | Maximum number of concurrent deployments allowed | `4` | +| `MAX_DEPLOYMENT_LOOP_COUNT` | number | When the deployment loop detection should trigger a forced re-deployment on consecutive deployments for the same commit. Set to `0`, to disable the detection logic. | `2` | +| `MAX_PAYLOAD_SIZE` | number | The maximum size of the webhook payload in bytes that the HTTP server will accept | `1048576` (1MB = 1 * 1024 * 1024) | +| `METRICS_PORT` | number | Port on which the application will expose [Prometheus metrics](Endpoints/Metrics.md) | `9120` | +| `PASS_ENV` | boolean | Controls whether environment variables from the doco-cd container should be passed to the deployment environment for docker compose variable interpolation. Use with caution, as this may expose sensitive information to the deployment environment. | `false` | +| `POLL_CONFIG` | list | A list/array of poll configurations provided in YAML format (see [Poll Settings](Poll-Settings.md)) | Ignored when not specified | +| `POLL_CONFIG_FILE` | string | Path to the file inside the container containing the poll configurations in YAML format (see [Poll Settings](Poll-Settings.md)) | gnored when not specified | +| `TZ` | string | The [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) used in the container. | `UTC` | +| `WEBHOOK_SECRET` | string | Secret that is used by webhooks for authentication to the application | Webhook endpoint is disabled when not specified | +| `WEBHOOK_SECRET_FILE` | string | Path to the file containing the Git access token (Mutually exclusive with `WEBHOOK_SECRET`). | | ## Notification Settings -Doco-CD can be configured to send notifications with [Apprise](https://github.com/caronc/apprise) to various services when a deployment is started, finished, or failed. +Doco-CD can be configured to send [Notifications](Advanced/Notifications.md) with [Apprise](https://github.com/caronc/apprise) to various services when a deployment is started, finished, failed, or triggered by [reconciliation](Deploy-Settings.md#reconciliation-settings). -See the [Notifications](Advanced/Notifications.md) wiki page for more information on how to configure notifications. +Reconciliation-triggered notifications use a short `[R]` marker in the title. +See [Reconciliation notifications](Advanced/Notifications.md#reconciliation-notifications) for configuration and format details. ## Encrypting sensitive data @@ -68,30 +42,6 @@ Doco-CD supports the encryption of sensitive data in your doco-cd app config and See the [Encryption](Advanced/Encryption.md) wiki page for more information on how to use SOPS with Doco-CD. -## Healthcheck - -The doco-cd image contains a docker health check checks against `http://localhost:${HTTP_PORT}/v1/health` inside the container. - -You can adjust the health check settings in your `docker-compose.yml` file like this: - -```yaml title="docker-compose.yml" -services: - app: - container_name: doco-cd - healthcheck: - start_period: 15s - interval: 30s - timeout: 5s - retries: 3 -``` - -You can see the health status of the container with the following command: - -```sh -docker inspect --format='{{json .State.Health}}' doco-cd -``` - - ## Specifying the settings You can set the settings directly in the `docker-compose.yml` file with the `environment` option diff --git a/doco-cd-src/wiki/docs/Core-Concepts.md b/doco-cd-src/wiki/docs/Core-Concepts.md index 9c5bab7..5e4f28d 100644 --- a/doco-cd-src/wiki/docs/Core-Concepts.md +++ b/doco-cd-src/wiki/docs/Core-Concepts.md @@ -8,8 +8,6 @@ tags: This page explains the key terms and concepts used throughout the Doco-CD documentation. Familiarity with [Git](https://git-scm.com/), [Docker](https://docs.docker.com/), and basic [GitOps](https://about.gitlab.com/topics/gitops/) principles is assumed. ---- - ## How Doco-CD Works Doco-CD follows a **GitOps** model: your Git repository is the single source of truth for both application code and deployment configuration. @@ -84,6 +82,8 @@ They are injected into the deployment environment as environment variables or Do See [External Secrets](External-Secrets/index.md) for supported providers and usage. +### Encryption + Doco-CD supports decrypting files encrypted with [SOPS](https://getsops.io/) at deployment time. This allows sensitive values in deployment and compose files to be stored encrypted in the Git repository. diff --git a/doco-cd-src/wiki/docs/Deploy-Settings.md b/doco-cd-src/wiki/docs/Deploy-Settings.md index c441324..3ff585f 100644 --- a/doco-cd-src/wiki/docs/Deploy-Settings.md +++ b/doco-cd-src/wiki/docs/Deploy-Settings.md @@ -8,7 +8,7 @@ tags: Deployments in `Doco-CD` run as concurrent tasks. Each deployment is defined by a deployment configuration file (e.g. `.doco-cd.yml`) that controls how it runs. -Enable `auto_discover` to generate multiple deployments from a single config by scanning subdirectories for Docker Compose files. +Enable `auto_discovery` to generate multiple deployments from a single config by scanning subdirectories for Docker Compose files. It can be configured either as a boolean shorthand (`true`/`false`) or as a nested object. Concurrent tasks are grouped by repository and Git reference (e.g. branch or tag). Deployments from the same repository but different references run sequentially, while those with the same repository and reference run in parallel. @@ -31,149 +31,227 @@ The docker compose deployment can be configured inside the [deployment configura !!! note "Settings without a default value are required." -| Key | Type | Description | Default value | -|--------------------|------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------| -| `name` | string | Name of the deployed stack / project / application. | | -| `reference` | string | Git reference to deploy from, must be either a branch (e.g. `main` or `refs/heads/main`) or tag (e.g. `v1.0.0.` or `refs/tags/v1.0.0`) | - Polling: the reference from the [Poll Config](Poll-Settings.md)
- Webhooks: the reference of the webhook payload | -| `repository_url` | string | HTTP clone URL of another repository that contains the docker compose files to be deployed. If specified, the deployment runs from there. Also set `reference` to specify the branch. | ` ` (Ignored when not specified) | -| `working_dir` | string | The working directory for the deployment. | `.` (Root/base directory of cloned repository) | -| `compose_files` | array of strings | List of docker-compose and overwrite files to use (in descending order, first file gets read first and following files overwrite/merge previous configs). Unknown/Non-existing files get skipped. | `["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"]` | -| `environment` | map of strings | A map of environment variables to use for [variable interpolation](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation) in the compose files. Overwrites entries from `env_files` with the same key/name. | `null` (No environment variables) | -| `env_files` | array of strings | List of dotenv files to use for [variable interpolation](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation). Subsequent .env files overwrite each other. If the default `.env` file does not exist, it will be ignored.
If `repository_url` is also specified to deploy from a different repo, you can use the `remote:` syntax to specify, that the dotenv file is located in the remote repository and should be loaded from there | `[".env"]` | -| `profiles` | array of strings | List of [compose profiles](https://docs.docker.com/compose/how-tos/profiles/) to use for the deployment, e.g., ["prod", "debug"]. | `[]` | -| `webhook_filter` | string | A regular expression to whitelist deployment triggers based on the webhook event payload. See the [Webhook Filter](#webhook-filter) Section below. | ` ` (Ignored when not specified) | -| `remove_orphans` | boolean | Remove/Prune containers/services that are not (or no longer) defined in the Compose file. | `true` | -| `prune_images` | boolean | Prune images that are no longer in use after a deployment. If the image is still used by any other container, it won't get deleted. | `true` | -| `force_recreate` | boolean | Forces the recreation/redeployment of containers even if the configuration has not changed. | `false` | -| `force_image_pull` | boolean | Always pulls the latest version of the image tags you've specified if a newer version is available. | `false` | -| `timeout` | number | The time in seconds to wait for the deployment to finish before timing out. | `180` | -| `git_depth` | number | Limits the number of commits fetched during clone/fetch (shallow clone). `0` means use the global [`GIT_CLONE_DEPTH`](App-Settings.md) value. A positive integer overrides the global setting for this deployment. When a requested ref (tag/SHA) is outside the shallow depth, doco-cd automatically deepens incrementally before falling back to a full fetch. Changing this value on an existing repo triggers an automatic re-clone. | `0` (use global) | -| `destroy` | boolean | (⚠️ Destructive) Remove the deployed compose stack/project and its resources from the Docker host. Can be further configured using the [destroy_opts](#destroy-settings) setting. | `false` | -| `auto_discover` | boolean | Enables autodiscovery of services to deploy in the working directory by scanning for subdirectories with docker-compose files with the naming `docker-compose.y(a)ml` or `compose.y(a)ml`. Can be further configured using the [auto_discover_opts](#auto-discover-settings) setting. | `false` | -| `reconciliation` | object | Enables periodic reconciliation. See [reconciliation settings](#reconciliation-settings) for more details. | `{enabled: true, interval: 60}` | - - -### Example - -#### With default values - -When using the default values, most settings can be omitted. - -```yaml title=".doco-cd.yml" -name: some-project # (1)! -``` +| Key | Type | Description | Default value | +|--------------------|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------| +| `name` | string | Name of the deployed stack / project / application. | | +| `reference` | string | Git reference to deploy from, must be either a branch (e.g. `main` or `refs/heads/main`) or tag (e.g. `v1.0.0.` or `refs/tags/v1.0.0`) | - Polling: the reference from the [Poll Config](Poll-Settings.md)
- Webhooks: the reference of the webhook payload | +| `repository_url` | string | HTTP clone URL of another repository that contains the docker compose files to be deployed. If specified, the deployment runs from there. Also set `reference` to specify the branch. | ` ` (Ignored when not specified) | +| `working_dir` | string | The working directory for the deployment. | `.` (Root/base directory of cloned repository) | +| `compose_files` | array of strings | List of docker-compose and overwrite files to use (in descending order, first file gets read first and following files [overwrite/merge](https://docs.docker.com/reference/compose-file/merge/) previous configs). Unknown/Non-existing files get skipped. | `["compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"]` | +| `environment` | map of strings | A map of environment variables to use for [variable interpolation](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation) in the compose files. Overwrites entries from `env_files` with the same key/name. | `null` (No environment variables) | +| `env_files` | array of strings | List of dotenv files to use for [variable interpolation](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation). Subsequent .env files overwrite each other. If the default `.env` file does not exist, it will be ignored.
If `repository_url` is also specified to deploy from a different repo, you can use the `remote:` syntax to specify, that the dotenv file is located in the remote repository and should be loaded from there | `[".env"]` | +| `profiles` | array of strings | List of [compose profiles](https://docs.docker.com/compose/how-tos/profiles/) to use for the deployment, e.g., `#!yaml ["prod", "debug"]`. | `[]` | +| `webhook_filter` | string | A regular expression to whitelist deployment triggers based on the webhook event payload. See the [Webhook Filter](#webhook-filter) Section below. | ` ` (Ignored when not specified) | +| `remove_orphans` | boolean | Remove/Prune containers/services that are not (or no longer) defined in the Compose file. | `true` | +| `prune_images` | boolean | Prune images that are no longer in use after a deployment. If the image is still used by any other container, it won't get deleted. | `true` | +| `force_recreate` | boolean | Forces the recreation/redeployment of containers even if the configuration has not changed. | `false` | +| `force_image_pull` | boolean | Always pulls the latest version of the image tags you've specified if a newer version is available. | `false` | +| `timeout` | number | The time in seconds to wait for the deployment to finish before timing out. | `180` | +| `git_depth` | number | Limits the number of commits fetched during clone/fetch (shallow clone). `0` means use the global [`GIT_CLONE_DEPTH`](App-Settings.md) value. A positive integer overrides the global setting for this deployment. When a requested ref (tag/SHA) is outside the shallow depth, doco-cd automatically deepens incrementally before falling back to a full fetch. Changing this value on an existing repo triggers an automatic re-clone. | `0` (use global) | +| `destroy` | boolean \| object | (⚠️ Destructive) Configure stack/project destruction behavior. Use `destroy: true` as shorthand to enable destruction with default options, or use `destroy.enabled: true` inside the object form to customize removal behavior. See [Destroy settings](#destroy-settings). | see [Destroy settings](#destroy-settings) | +| `auto_discovery` | boolean \| object | Enables [autodiscovery](#auto-discovery) of services to deploy in the working directory by scanning for subdirectories with Docker Compose files (see the `compose_files` setting). Use `auto_discovery: true` as shorthand to enable it with default options, or use the object form to customize settings such as `depth` and `delete`. See [Auto-Discovery Settings](#auto-discovery-settings). | see [Auto-Discovery Settings](#auto-discovery-settings) | +| `reconciliation` | boolean \| object | Enables event-driven reconciliation for deployments. Use `reconciliation: true` as shorthand to enable reconciliation with default options, or use the object form to customize settings. See [reconciliation settings](#reconciliation-settings). | see [Reconciliation Settings](#reconciliation-settings) | + + +!!! example + + === "With default values" + + When using the default values, most settings can be omitted. + + ```yaml title=".doco-cd.yml" + name: some-project # (1)! + ``` + + 1. Name of the deployed stack/project + + === "With custom values" + + ```yaml title=".doco-cd.yml" + name: some-project # (1)! + reference: other-branch # (2)! + working_dir: myapp/deployment # (3)! + compose_files: # (4)! + - prod.compose.yml + - service-overwrite.yml + profiles: + - debug # (5)! + ``` + + 1. Name of the deployed stack/project + 2. The branch or tag to deploy from + 3. The working directory for the deployment, relative to the root of the repository. In this case, doco-cd will look for the compose files in the `myapp/deployment` subdirectory. + 4. The list of compose files to use for the deployment in descending order. In this case, doco-cd will first read the `prod.compose.yml` file and then overwrite/merge it with the `service-overwrite.yml` file. + 5. Deploys services with the `debug` profile in addition to the core/main services (that have no profiles) + + === "From remote repository" + + When deploying your docker compose stack from a different repository, the `repository_url` setting must be specified. + The `reference` and `working_dir` are used in this case to specify the branch/tag and subdirectory of the other repository that contains the docker compose files. + + You can use the `env_files` setting to define which dotenv files will be loaded from the local and which from the remote repository. + To specify, that a dotenv file should be loaded from the remote repository, use the `remote:` syntax. + Entries/Keys, that appear in multiple files, get overwritten by the next occurrence and remote dotenv files have higher priority than local ones. + + ```yaml title=".doco-cd.yml" + name: some-project # (1)! + repository_url: https://github.com/my-org/myapp.git # (2)! + reference: main # (3)! + working_dir: myapp/docker # (4)! + env_files: # (5)! + - base.env # (6)! + - remote:test.env # (7)! + ``` + + 1. Name of the deployed stack/project + 2. Clone and deploy from this repository instead of the repository where the deployment config file is located. + 3. The branch or tag to deploy from in the remote repository (`my-org/myapp`). + 4. The working directory for the deployment, relative to the root of the remote repository. In this case, doco-cd will look for the compose files in the `myapp/docker` subdirectory. + 5. List of dotenv files to use in descending order. Existing variables get overwritten by the next occurrence. In this case, variables from `test.env` in the remote repository will overwrite variables from `base.env` in the local repository. + 6. Read file from local repository + 7. Read file from remote repository + + ```dotenv title="base.env" + TEST=base + HELLO=world + ``` + + ```dotenv title="test.env in remote repository" + TEST=changed + ``` + + This will result in the following environment variables being set for the deployment in the remote repository: + + ```dotenv + TEST=changed + HELLO=world + ``` + +### Auto-Discovery + +If `auto_discovery` is enabled, doco-cd will try to find projects/stacks to deploy by searching for docker compose files (see the `compose_files` setting) in subdirectories in the working directory (`working_dir`). +Doco-cd will internally generate new deploy configs based on the directory name and inherits all other settings from the base deploy config inside the `.doco-cd.yml` file or the inline deployment config inside the poll config. +When an app is no longer available in the `working_dir` (e.g. deleted or moved to another directory outside the working dir), doco-cd will automatically remove the deployed project/stack from the docker host. -1. Name of the deployed stack/project +#### Auto-Discovery settings -#### With custom values +`auto_discovery` accepts either a boolean or a nested object in the deployment configuration file. Use `auto_discovery: true` to enable it with defaults, or use the object form below to customize `depth` and `delete`. -```yaml title=".doco-cd.yml" -name: some-project # (1)! -reference: other-branch # (2)! -working_dir: myapp/deployment # (3)! -compose_files: # (4)! - - prod.compose.yml - - service-overwrite.yml -profiles: - - debug # (5)! -``` +| Key | Type | Description | Default value | +|-----------|---------|------------------------------------------------------------------------------------------------------|---------------| +| `enabled` | boolean | Enables auto-discovery of services to deploy in the working directory | `false` | +| `depth` | number | Maximum depth of subdirectories to scan for docker-compose files, set to `0` for no limit | `0` | +| `delete` | boolean | Auto-remove obsolete auto-discovered deployments that are no longer present in the working directory | `true` | -1. Name of the deployed stack/project -2. The branch or tag to deploy from -3. The working directory for the deployment, relative to the root of the repository. In this case, doco-cd will look for the compose files in the `myapp/deployment` subdirectory. -4. The list of compose files to use for the deployment in descending order. In this case, doco-cd will first read the `prod.compose.yml` file and then overwrite/merge it with the `service-overwrite.yml` file. -5. Deploys services with the `debug` profile in addition to the core/main services (that have no profiles) - -#### From remote repository - -When deploying your docker compose stack from a different repository, the `repository_url` setting must be specified. -The `reference` and `working_dir` are used in this case to specify the branch/tag and subdirectory of the other repository that contains the docker compose files. - -You can use the `env_files` setting to define which dotenv files will be loaded from the local and which from the remote repository. -To specify, that a dotenv file should be loaded from the remote repository, use the `remote:` syntax. -Entries/Keys, that appear in multiple files, get overwritten by the next occurrence and remote dotenv files have higher priority than local ones. - -```yaml title=".doco-cd.yml" -name: some-project # (1)! -repository_url: https://github.com/my-org/myapp.git # (2)! -reference: main # (3)! -working_dir: myapp/docker # (4)! -env_files: # (5)! - - base.env # (6)! - - remote:test.env # (7)! -``` +!!! example +
-1. Name of the deployed stack/project -2. Clone and deploy from this repository instead of the repository where the deployment config file is located. -3. The branch or tag to deploy from in the remote repository (`my-org/myapp`). -4. The working directory for the deployment, relative to the root of the remote repository. In this case, doco-cd will look for the compose files in the `myapp/docker` subdirectory. -5. List of dotenv files to use in descending order. Existing variables get overwritten by the next occurrence. In this case, variables from `test.env` in the remote repository will overwrite variables from `base.env` in the local repository. -6. Read file from local repository -7. Read file from remote repository - -```dotenv title="base.env" -TEST=base -HELLO=world -``` + - With a file structure like this + ``` title="File structure" + .doco-cd.yml + apps/ + ├── wordpress/ + │ ├── docker-compose.yml + │ └── .env + ├── nginx/ + │ ├── docker-compose.yaml + │ └── configs/ + │ └── nginx.conf + └── misc/ + └── image.png + ``` + + - And a `.doco-cd.yml` with the following content: -```dotenv title="test.env in remote repository" -TEST=changed -``` + === "Default settings" -This will result in the following environment variables being set for the deployment in the remote repository: + ```yaml title=".doco-cd.yml" + auto_discovery: true + ``` -```dotenv -TEST=changed -HELLO=world -``` + === "Custom settings" -### Auto discover settings + ```yaml title=".doco-cd.yml" + working_dir: apps/ + auto_discovery: + enabled: true + depth: 1 + ``` +
-If `auto_discover` is set to `true`, doco-cd will try to auto-discover projects/stacks to deploy by searching for `docker-compose.y(a)ml` or `compose.y(a)ml` files in subdirectories in the working directory (`working_dir`). -Doco-cd will internally generate new deploy configs based on the directory name and inherits all other settings from the base deploy config inside the `.doco-cd.yml` file or the inline deployment config inside the poll config. -When an app is no longer available in the `working_dir` (e.g. deleted or moved to another directory outside the working dir), doco-cd will automatically remove the deployed project/stack from the docker host. + Doco-cd would deploy 2 stacks to the docker host: `wordpress` and `nginx` -Specify all auto-discover settings in a nested `auto_discover_opts` object in the deployment configuration file (See example below). +#### Nested config overrides -| Key | Type | Description | Default value | -|----------|---------|------------------------------------------------------------------------------------------------------|---------------| -| `depth` | number | Maximum depth of subdirectories to scan for docker-compose files, set to `0` for no limit | `0` | -| `delete` | boolean | Auto-remove obsolete auto-discovered deployments that are no longer present in the working directory | `true` | +For each auto-discovered compose directory, doco-cd also checks for a local [deployment config file](#deployment-configuration-file) in that directory. -### Example +If a nested config file exists, doco-cd merges it on top of the discovered deployment config. -With a file structure like this -``` -.doco-cd.yml -apps/ -├── wordpress/ -│ ├── docker-compose.yml -│ └── .env -├── nginx/ -│ ├── docker-compose.yaml -│ └── configs/ -│ └── nginx.conf -└── misc/ - └── image.png -``` +!!! warning "Nested `.doco-cd.yml` files must contain exactly one YAML document." + If a nested file contains multiple documents (`#!yaml ---`), auto-discovery fails for that run with an error. -and a `.doco-cd.yml` with the following content: -```yaml title=".doco-cd.yml" -working_dir: apps/ -auto_discover: true -auto_discover_opts: - depth: 1 -``` +##### Merge behavior + +- Maps are merged key-by-key (`external_secrets`, `environment`, `build.args`) +- Slices replace the base value when the nested value is non-empty +- Scalar values override the base value when the nested value is non-zero/non-empty +- Nested objects (such as `build`, `destroy`, `reconciliation`) are merged recursively + +##### Non-overridable Fields + +The following fields are always inherited from the base/root deployment config: -doco-cd would deploy 2 stacks to the docker host: -- wordpress -- nginx +- `reference` +- `repository_url` +- `auto_discovery` +- `git_depth` + +#### Example + +!!! example + + ``` title="File structure" + .doco-cd.yml + apps/ + ├── wordpress/ + │ ├── .doco-cd.yml + │ ├── docker-compose.yml + │ └── .env + ├── nginx/ + │ ├── .doco-cd.yml + │ └── docker-compose.yaml + └── misc/ + └── image.png + ``` + + ```yaml title=".doco-cd.yml (root)" + working_dir: apps/ + auto_discovery: + enabled: true + depth: 1 + external_secrets: + SHARED_SECRET: "op://vault/shared/field" + ``` + + ```yaml title="apps/wordpress/.doco-cd.yml" + name: wordpress-prod + external_secrets: + WORDPRESS_SECRET_1: "op://vault/item/field" + environment: + WP_ENV: production + ``` + + Result for discovered `wordpress` deployment: + + - `name` becomes `wordpress-prod` + - `working_dir` remains auto-discovered (`apps/wordpress/`) unless explicitly overridden + - `external_secrets` contains both `SHARED_SECRET` and `WORDPRESS_SECRET_1` ### Build settings The following settings can be used to build docker images during a deployment (Like `docker compose build` or `docker compose up --build`). -Specify all build-settings in a nested `build_opts` object in the deployment configuration file (See example below). +Specify all build-settings in a nested `build` object in the deployment configuration file (See example below). | Key | Type | Description | Default value | |--------------------|----------------|------------------------------------------------------------|---------------| @@ -182,64 +260,138 @@ Specify all build-settings in a nested `build_opts` object in the deployment con | `args` | map of strings | A map of build-time arguments to pass to the build process | `null` | | `no_cache` | boolean | Disables the use of the cache when building images | `false` | -#### Example - -```yaml title=".doco-cd.yml" -name: some-project -build_opts: - force_image_pull: true - args: - BUILD_DATE: 2021-01-01 - VCS_REF: 123456 - no_cache: true -``` +!!! example + ```yaml title=".doco-cd.yml" + name: some-project + build: + force_image_pull: true + args: + BUILD_DATE: 2021-01-01 + VCS_REF: 123456 + no_cache: true + ``` ### Destroy settings -The following settings can be used to configure further how the deployed compose stack/project will be removed (if `destroy` is set to `true`): +The following settings can be used to configure how the deployed compose stack/project will be removed. -Specify all destroy-settings in a nested `destroy_opts` object in the deployment configuration file (See example below). +`destroy` accepts either a boolean or a nested object in the deployment configuration file. Use `destroy: true` to enable destructive removal with default options, or use the object form below to customize which resources are removed. -| Key | Type | Description | Default value | -|------------------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| -| `remove_volumes` | boolean | Remove all volumes used by the deployment (always `true` in docker swarm mode) | `true` | -| `remove_images` | boolean | Remove all images used by the deployment (currently not supported in docker swarm mode) | `true` | -| `remove_dir` | boolean | Remove the cloned repository in the data directory after the deployment is removed (Setting this to `false` is useful e.g. when you use bind mounts and want to keep the data) | `true` | +| Key | Type | Description | Default value | +|------------------|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| +| `enabled` | boolean | Enable destructive removal of the deployment and its resources. | `false` | +| `remove_volumes` | boolean | Remove all volumes used by the deployment (always `true` in docker swarm mode) | `true` | +| `remove_images` | boolean | Remove all images used by the deployment (currently not supported in docker swarm mode) | `true` | +| `remove_dir` | boolean | Remove the cloned repository in the data directory after the deployment is removed (Setting this to `false` is useful e.g. when you use bind mounts with relative paths and want to keep the data or if you have multiple services in the same repo and only wish to destroy one) | `true` | -#### Example +!!! example -```yaml title=".doco-cd.yml" -name: some-project -destroy: true -destroy_opts: - remove_volumes: true - remove_images: false - remove_dir: false -``` + === "Boolean with default options" -### Reconciliation settings + ```yaml title=".doco-cd.yml" + name: some-project + destroy: true + ``` + + This shorthand enables destruction with the default options (`remove_volumes: true`, `remove_images: true`, `remove_dir: true`). -Reconciliation is an optional periodic check that compares the currently running services with the expected deployment state. -If drift is detected, doco-cd automatically reapplies the deployment to bring the stack back to the desired state. + === "Object with custom options" -The following settings can be used to configure periodic reconciliation. + ```yaml title=".doco-cd.yml" + name: some-project + destroy: + enabled: true + remove_volumes: true + remove_images: false + remove_dir: false + ``` + +### Reconciliation Settings + +Reconciliation is an optional event-driven check that compares the currently running Docker services/containers with the expected deployment state. +When configured container events occur, doco-cd either reapplies the deployment or directly restarts the affected container, depending on the event type. !!! warning - The currently implemented state will be lost when doco-cd restarts. + The currently implemented state will be lost when doco-cd gets restarted and will be restored in the next poll or webhook event. -| Key | Type | Description | Default value | -|------------|---------|------------------------------------------------------|---------------| -| `enabled` | boolean | Enable periodic reconciliation. | `true` | -| `interval` | int | The time in seconds between two reconciliation runs. | `60` | +`reconciliation` accepts either a boolean or a nested object in the deployment configuration file. Use `reconciliation: true` to enable it with defaults, or use the object form below to customize the settings. ---8<-- "wiki/docs/_snippets/reconciliation-note.md" +The following settings can be used to configure reconciliation triggers. + +| Key | Type | Description | Default value | +|-------------------|------------------|-------------------------------------------------------------------------------------------------------------------------------|-----------------| +| `enabled` | boolean | Enable reconciliation. | `true` | +| `events` | array of strings | Docker container/service events that trigger reconciliation. See [supported events](#supported-events) below. | `["unhealthy"]` | +| `restart_timeout` | number | Timeout in seconds used when restarting containers for reconciliation [events](#supported-events) that trigger a restart. | `10` | +| `restart_signal` | string | Signal used for reconciliation restarts. If not set, the `StopSignal` of the container image is used (defaults to `SIGTERM`). | | +| `restart_limit` | number | Maximum number of automatic restarts allowed for a container in the restart window. Set to `0` to disable suppression. | `5` | +| `restart_window` | number | Time window in seconds used with `restart_limit` to detect flapping health checks. | `300` | + +--8<-- "wiki/includes/reconciliation-note.md" + +#### Supported Events + +Events can be triggered by changes in the container state, configuration updates outside Doco-CD (e.g. via Docker CLI), or health status changes. +The following events are supported as reconciliation triggers in Docker (Standalone) and Docker Swarm deployments: + +=== "Docker Standalone" + + | Event | Description | Action | + |-------------|----------------------------------------------------------|----------| + | `die` | The container process exited | Redeploy | + | `destroy` | The container was removed | Redeploy | + | `stop` | The container was stopped gracefully | Restart | + | `kill` | The container was terminated by a signal | Restart | + | `oom` | The container was killed because it ran out of memory | Restart | + | `unhealthy` | The container health check status changed to _unhealthy_ | Restart | -```yaml title=".doco-cd.yml" -name: some-project -reconciliation: - enabled: true - interval: 60 -``` + + !!! info "Flapping health checks" + For `unhealthy` events, doco-cd suppresses further automatic restarts after `restart_limit` restarts within `restart_window` seconds (See [reconciliation settings](#reconciliation-settings)). + + !!! warning "Overlapping events" + Some events, like the `die` event, also get triggered when a container is restarted, stopped or killed, so make sure to + configure the events according to the desired behavior. + + To prevent unexpected behavior, doco-cd suppresses follow-up events for a container after the first event + that triggered a reconciliation for that container until the reconciliation process is finished. + + ??? example + If both `die` and `stop` events are configured, and a container is stopped, the `stop` event will also trigger a `die` event. + However, doco-cd will only react to the first event (e.g. `stop`) and suppress the follow-up `die` event. + +=== "Docker Swarm Mode" + + | Event | Description | Action | + |-----------|-------------------------------------------------------------|----------| + | `destroy` | The service was removed | Redeploy | + | `update` | The service configuration was updated (for example scaling) | Redeploy | + +#### Examples + +=== "Boolean with default options" + + Enable reconciliation with default options: + + ```yaml title=".doco-cd.yml" + name: some-project + reconciliation: true + ``` + +=== "Object with custom options" + + ```yaml title=".doco-cd.yml" + name: some-project + reconciliation: + enabled: true + restart_timeout: 30 + restart_signal: SIGQUIT + restart_limit: 5 + restart_window: 300 + events: + - destroy + - unhealthy + ``` ### Webhook Filter @@ -258,22 +410,21 @@ Depending on the event, the reference in a webhook payload has a pattern of You can specify the filter explicitly or in a loose form. Explicit regular expressions are recommended. See [Go Regular Expressions](https://pkg.go.dev/regexp/syntax) for more information on the syntax. -#### Explicit examples -- Only on events on the main branch: `^refs/heads/main$` -- Only on tag events with semantic versioning: `^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$`) - -#### Loose examples -- Must contain `stable` somewhere in the reference: `stable` +!!! example + === "Explicit regular expression" -!!! warning - Loose expressions can allow references that might not be wanted. + - Only on events on the main branch: `^refs/heads/main$` + - Only on tag events with semantic versioning: `^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$` -E.g. `refs/heads/main` (without `^` and `$`) also allows `refs/heads/main-something` + === "Loose regular expression" + - Must contain `stable` somewhere in the reference: `stable` + !!! warning + Loose expressions can allow references that might not be wanted. -### Service Labels + E.g. `refs/heads/main` (without `^` and `$`) also allows `refs/heads/main-something` -#### Avoid service recreation when configs, secrets or bind mounts change +### Prevent recreation on config, secret or bind mount changes When using docker compose with configs, secrets or bind mounts, changes to these resources will trigger a recreation of the service containers by default. To avoid this, you can set the `cd.doco.deployment.recreate.ignore` service label to a YAML list of scopes that should be ignored for recreation. @@ -284,31 +435,34 @@ It accepts one or more of the following scopes: `configs`, `secrets`, `bindMount 1. `configs` and `secrets` items refer to names defined in the top-level `configs` and `secrets` sections. 2. `bindMounts` items refer to the **target paths** of bind mounts (not the source paths). -##### Example - -**Single line YAML value** +!!! example -!!! example "Quotes are required" - Quotes are required to prevent YAML parsing errors due to the colons and brackets in the value + === "Single line YAML value" + + !!! warning "Quotes are required" + Quotes are required to prevent YAML parsing errors due to the colons and brackets in the value + + ```yaml title="docker-compose.yml" + cd.doco.deployment.recreate.ignore: "{configs: [app, nginx], secrets: [db], bindMounts: [/etc/caddy]}" + ``` -```yaml title="docker-compose.yml" -cd.doco.deployment.recreate.ignore: "{configs: [app, nginx], secrets: [db], bindMounts: [/etc/caddy]}" -``` + === "Multiline YAML value" -**Multiline YAML value** + !!! tip "Use multiline YAML for better readability" -Or as a multiline YAML for better readability: + ```yaml title="docker-compose.yml" + cd.doco.deployment.recreate.ignore: >- + { + configs: [app, nginx], + secrets: [db], + bindMounts: [/etc/caddy] + } + ``` -```yaml title="docker-compose.yml" -cd.doco.deployment.recreate.ignore: >- - { - configs: [app, nginx], - secrets: [db], - bindMounts: [/etc/caddy] - } -``` +#### Send signal on ignored recreation -Add the `cd.doco.deployment.recreate.ignore.signal` label to send a signal to a service when it is ignored. By default, no signal is sent. This requires `cd.doco.deployment.recreate.ignore` to be set. +Add the `cd.doco.deployment.recreate.ignore.signal` label to send a signal to a service when it is ignored. +By default, no signal is sent. This requires [`cd.doco.deployment.recreate.ignore`](#prevent-recreation-on-config-secret-or-bind-mount-changes) to be set. Both labels must not be empty if they are present. @@ -347,53 +501,55 @@ services: ## Multiple service deployments -Multiple service deployments can be configured in a single deployment config file by specifying multiple YAML documents (separated by `---`). - -```yaml title=".doco-cd.yml" -name: app1 -working_dir: app1 ---- -name: app2 -working_dir: app2 -timeout: 600 ---- -name: app3 -working_dir: app3 -compose_files: - - custom.yml -``` - -### Example - -#### Same directory - -All docker compose files are located in the same base directory. - -```yaml title=".doco-cd.yml" -name: gitea -compose_files: - - gitea.yml ---- -name: paperless-ngx -compose_files: - - paperless.yml - - paperless-overwrite.yml -``` - -#### Sub-directories - -When docker compose files are located in subdirectories. - -```yaml title=".doco-cd.yml" -name: gitea -working_dir: gitea ---- -name: paperless-ngx -working_dir: paperless-ngx -compose_files: - - docker-compose.yml - - docker-compose.overwrite.yml -``` +Multiple service deployments can be configured in a single deployment config file by specifying multiple YAML documents (separated by `#!yaml ---`). + +!!! example + + === "Basic" + + ```yaml title=".doco-cd.yml" + name: app1 + working_dir: app1 + --- + name: app2 + working_dir: app2 + timeout: 600 + --- + name: app3 + working_dir: app3 + compose_files: + - custom.yml + ``` + + === "Same directory" + + All docker compose files are located in the same base directory. + + ```yaml title=".doco-cd.yml" + name: gitea + compose_files: + - gitea.yml + --- + name: paperless-ngx + compose_files: + - paperless.yml + - paperless-overwrite.yml + ``` + + === "Sub-directories" + + When docker compose files are located in subdirectories. + + ```yaml title=".doco-cd.yml" + name: gitea + working_dir: gitea + --- + name: paperless-ngx + working_dir: paperless-ngx + compose_files: + - docker-compose.yml + - docker-compose.overwrite.yml + ``` ## Multiple deployment targets diff --git a/doco-cd-src/wiki/docs/Docker-Settings.md b/doco-cd-src/wiki/docs/Docker-Settings.md new file mode 100644 index 0000000..c65def0 --- /dev/null +++ b/doco-cd-src/wiki/docs/Docker-Settings.md @@ -0,0 +1,22 @@ +--- +tags: + - Configuration +--- + +# Docker Settings + +Settings to configure the Docker client used by Doco-CD to interact with the Docker daemon. +All of these settings are optional and can be set using [environment variables](App-Settings.md#specifying-the-settings) when running the Doco-CD container. + +!!! tip "Docker CLI environment variables are supported" + Doco-CD supports most of the standard Docker CLI environment variables to configure the Docker client. + See the [Docker CLI documentation](https://docs.docker.com/engine/reference/commandline/cli/#environment-variables) for more information on available Docker CLI environment variables. + +| Key | Type | Description | Default value | +|-------------------------|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| +| `DOCKER_API_VERSION` | string | Overwrites the API version that doco-cd will use to connect to the Docker Daemon (e.g. `"1.49"`) | | +| `DOCKER_CERT_PATH` | string | The directory from which to load the TLS certificates ("ca.pem", "cert.pem", "key.pem'). The directory has to be accessible from inside the container, e.g. by using a bind mount | | +| `DOCKER_HOST` | string | The url that doco-cd will use to connect to the Docker Daemon (e.g. `tcp://192.168.0.10:2375`) | | +| `DOCKER_QUIET_DEPLOY` | boolean | Disable the status output of Docker Compose deployments (e.g. pull, create, start, healthy) in the application logs | `true` | +| `DOCKER_TLS_VERIFY` | boolean | Enable or disable TLS verification | | +| `DOCKER_SWARM_FEATURES` | boolean | Enable the use Docker Swarm Mode features if the app has detected that it is running in a Docker Swarm environment | `true` | diff --git a/doco-cd-src/wiki/docs/Endpoints/Healthcheck.md b/doco-cd-src/wiki/docs/Endpoints/Healthcheck.md new file mode 100644 index 0000000..5313b56 --- /dev/null +++ b/doco-cd-src/wiki/docs/Endpoints/Healthcheck.md @@ -0,0 +1,31 @@ +--- +tags: + - Reference + - Endpoints + - Monitoring +--- + +# Healthcheck + +The Doco-CD image has a Docker health check that checks against `http://localhost:${HTTP_PORT}/v1/health` inside the container. + +You can adjust the health check settings in your `docker-compose.yml` file like this: + +```yaml title="docker-compose.yml" +services: + app: + container_name: doco-cd + healthcheck: + start_period: 15s + interval: 30s + timeout: 5s + retries: 3 +``` + +You can see the health status of the container with the following command: + +```sh +docker inspect --format='{{json .State.Health}}' doco-cd +``` + +See also the [Health Check API Endpoint](REST-API.md#health-check). \ No newline at end of file diff --git a/doco-cd-src/wiki/docs/Endpoints/Metrics.md b/doco-cd-src/wiki/docs/Endpoints/Metrics.md index c01457d..035480f 100644 --- a/doco-cd-src/wiki/docs/Endpoints/Metrics.md +++ b/doco-cd-src/wiki/docs/Endpoints/Metrics.md @@ -2,14 +2,14 @@ tags: - Reference - Endpoints - - Metrics + - Monitoring --- # Prometheus Metrics The application exposes Prometheus metrics at the `/metrics` endpoint. This endpoint provides various metrics about the application's performance and health, which can be scraped by a Prometheus server for monitoring purposes. -By default, this endpoint is available on Port `9120`, but can be configured using the `METRICS_PORT` environment variable, see [App Settings](../App-Settings.md#available-settings). +By default, this endpoint is available on Port `9120`, but can be configured using the `METRICS_PORT` environment variable, see [App Settings](../App-Settings.md#general-settings). ## Available Metrics diff --git a/doco-cd-src/wiki/docs/Endpoints/REST-API.md b/doco-cd-src/wiki/docs/Endpoints/REST-API.md index 71ab123..e7aa96c 100644 --- a/doco-cd-src/wiki/docs/Endpoints/REST-API.md +++ b/doco-cd-src/wiki/docs/Endpoints/REST-API.md @@ -11,7 +11,7 @@ Doco-CD exposes a RESTful API at the `/v1/api` endpoint. ## Authentication -Set the `API_SECRET` or `API_SECRET_FILE` environment variable in the container to enable the API, see [App Settings](../App-Settings.md#available-settings). +Set the `API_SECRET` or `API_SECRET_FILE` environment variable in the container to enable the API, see [App Settings](../App-Settings.md#general-settings). Use the `x-api-key` header to authenticate requests to the API using the secret value. @@ -93,6 +93,58 @@ curl --request POST \ ]' ``` +### Scheduled Jobs + +| Endpoint | Method | Description | Query Parameters | +|-----------------------------|--------|----------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `/v1/api/jobs` | GET | List all discovered [scheduled jobs](../Advanced/Job-Scheduling.md) | - `stack` (string, optional): Return scheduled jobs only for one stack/project. | +| `/v1/api/job/{jobName}/run` | POST | Trigger a configured [scheduled job](../Advanced/Job-Scheduling.md) immediately. | - `stack` (string, optional): Limit matching to a specific stack/project.
- `wait` (boolean, default: `true`): Wait for the triggered run to finish before responding. | + +??? question "What is the `jobName` for a scheduled job?" + `jobName` is the runtime name of the scheduled target: + + - Docker (standalone): the container name (for example `my-stack-backup-1`) + - Docker Swarm: the service name (for example `my-stack_backup`) + +??? question "How do I find the `jobName` for a scheduled job?" + You can get the `jobName` from the scheduler logs (`"job":"..."`) or from `GET /v1/api/jobs`. + +- If multiple jobs share the same `jobName`, provide `stack` to disambiguate for the run endpoint. +- If the matched job is disabled, the run endpoint returns a conflict response. + +**Common run endpoint outcomes** + +- `200 OK`: run triggered and completed (`wait=true`). +- `202 Accepted`: run accepted and running in background (`wait=false`). +- `404 Not Found`: no scheduled job matched `jobName` (and optional `stack`). +- `409 Conflict`: matched job is disabled or the selection is ambiguous. + +#### Example Request + +=== "Trigger a job by container name" + + ```sh + curl --request POST \ + --url 'https://cd.example.com/v1/api/job/my-stack-backup-1/run?wait=true' \ + --header 'x-api-key: your-api-key' + ``` + +=== "Trigger a job by service name in a specific stack" + + ```sh + curl --request POST \ + --url 'https://cd.example.com/v1/api/job/backup/run?stack=my-stack&wait=true' \ + --header 'x-api-key: your-api-key' + ``` + +=== "List all scheduled jobs for a specific stack" + + ```sh + curl --request GET \ + --url 'https://cd.example.com/v1/api/jobs?stack=my-stack' \ + --header 'x-api-key: your-api-key' + ``` + ### Compose Projects !!! note diff --git a/doco-cd-src/wiki/docs/Endpoints/Webhook-Listener.md b/doco-cd-src/wiki/docs/Endpoints/Webhook-Listener.md index 8d53390..d01ed2c 100644 --- a/doco-cd-src/wiki/docs/Endpoints/Webhook-Listener.md +++ b/doco-cd-src/wiki/docs/Endpoints/Webhook-Listener.md @@ -9,7 +9,12 @@ tags: The webhook payload is expected to be in JSON format and must contain the payload from a supported the Git provider, see [Supported Git Providers](../index.md#supported-git-providers). -The application listens for incoming webhooks on the `/v1/webhook` endpoint with the port specified by the `HTTP_PORT` environment variable, see [App Settings](../App-Settings.md#available-settings). +The application listens for incoming webhooks on the `/v1/webhook` endpoint with the port specified by the `HTTP_PORT` environment variable, see [App Settings](../App-Settings.md#general-settings). + +## Allow/deny trigger events + +By default, all incoming webhooks are accepted and trigger deployments if they match a deployment configuration. +See [Webhook Filter](../Deploy-Settings.md#webhook-filter) for more granular control over which webhooks should trigger deployments. ## With custom Target diff --git a/doco-cd-src/wiki/docs/External-Secrets/1Password-Connect.md b/doco-cd-src/wiki/docs/External-Secrets/1Password-Connect.md new file mode 100644 index 0000000..2805d96 --- /dev/null +++ b/doco-cd-src/wiki/docs/External-Secrets/1Password-Connect.md @@ -0,0 +1,139 @@ +--- +tags: + - Advanced + - Secrets + - Configuration +--- + +# 1Password Connect + +A [1Password Connect](https://developer.1password.com/docs/connect/) Server is a self-hosted proxy that caches vault data locally and serves secrets over a simple HTTP API. This is useful when you are deploying frequently or have multiple instances that would otherwise hit 1Password API rate limits. + +Unlike service account authentication (see the [1Password provider](1Password.md)) (which makes direct calls to the 1Password cloud API), Connect Server allows you to: + +- Avoid rate limiting by caching vault data locally +- Reduce latency for secret lookups +- Keep all secret requests within your infrastructure + +## Environment Variables + +To use 1Password Connect, configure these variables for the `doco-cd` container: + +| Key | Value | +|--------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `SECRET_PROVIDER` | `1password` | +| `SECRET_PROVIDER_CONNECT_HOST` | Base URL of your Connect API Server (for example: `http://op-connect-api:8080`). | +| `SECRET_PROVIDER_CONNECT_TOKEN` | API token used by `doco-cd` to authenticate against Connect API. Generated in [1Password Connect setup][1password-connect-setup]. Mutually exclusive with `SECRET_PROVIDER_CONNECT_TOKEN_FILE`. | +| `SECRET_PROVIDER_CONNECT_TOKEN_FILE` | Path to the file containing the Connect API token inside the container. Mutually exclusive with `SECRET_PROVIDER_CONNECT_TOKEN`. | + +For the Connect containers themselves, you also need a `1password-credentials.json` credentials file +to authenticate `op-connect-api`/`op-connect-sync` with your 1Password account and allow vault sync. +Download it from your [1Password Connect setup][1password-connect-setup]. + +## Deployment configuration + +Add a mapping/reference between the environment variable you want to set in the docker compose project/stack and the URI to the secret in 1Password. + +See their docs for the correct syntax and how to get a secret reference of your secret: https://developer.1password.com/docs/cli/secret-reference-syntax/ + +A valid secret reference should use the syntax: +`op:////[section/]` + +To get a one-time password, append the `?attribute=otp` query parameter to a secret reference that points to a one-time password field in 1Password: +`op:////[section/]one-time password?attribute=otp` + +!!! warning + Connect tokens can only access vaults for which you have granted read permissions during token creation. + +## Setup Steps + +### Example Compose Setup + +Deploy 1Password Connect alongside doco-cd: + +- Follow the [1Password Connect Server documentation](https://developer.1password.com/docs/connect/get-started/?deploy=docker) to get your Connect server credentials and set up the `op-connect-api` and `op-connect-sync` containers. +- For the server configuration options, refer to the [1Password Connect Server Configuration](https://developer.1password.com/docs/connect/server-configuration/) docs. +- Place `1password-credentials.json` next to your compose file (as shown below), or adjust the bind mount path to your preferred secure location (For a token file example, see the [Using a token file](#configuring-doco-cd-to-authenticate-with-connect-server-using-a-token-file) section below). + +```yaml title="docker-compose.yml" hl_lines="2-16 21-25 27-28" +services: + op-connect-api: + image: 1password/connect-api:latest + ports: + - "8080:8080" + volumes: + - ./1password-credentials.json:/home/opuser/.op/1password-credentials.json # (1)! + - op_data:/home/opuser/.op/data + + op-connect-sync: + image: 1password/connect-sync:latest + ports: + - "8081:8080" + volumes: + - ./1password-credentials.json:/home/opuser/.op/1password-credentials.json # (2)! + - op_data:/home/opuser/.op/data + + app: # your doco-cd container + image: kimdre/doco-cd:latest + environment: + SECRET_PROVIDER: 1password + SECRET_PROVIDER_CONNECT_HOST: http://op-connect-api:8080 + SECRET_PROVIDER_CONNECT_TOKEN: ${SECRET_PROVIDER_CONNECT_TOKEN} # (3)! + depends_on: + - op-connect-api + +volumes: + op_data: +``` + +1. Download the `1password-credentials.json` file from your [Secrets Automation workflow][1password-connect-setup] and mount it into both `op-connect-api` and `op-connect-sync` containers. +2. Download the `1password-credentials.json` file from your [Secrets Automation workflow][1password-connect-setup] and mount it into both `op-connect-api` and `op-connect-sync` containers. +3. Create the Connect server _Secrets Automation_ workflow by following the [docs][1password-connect-setup]. + +Example `.env` values for the compose file above: + +```bash title=".env" +SECRET_PROVIDER_CONNECT_TOKEN=xxxxxx # (1)! +``` + +1. Used by doco-cd to call op-connect-api + +### Configuring doco-cd to authenticate with Connect Server + +Set these [environment variables](#environment-variables) to use 1Password Connect Sever in your `doco-cd` container: + +=== "Using a direct token" + + ```bash title="Environment Variables for doco-cd" + SECRET_PROVIDER=1password + SECRET_PROVIDER_CONNECT_HOST=http://op-connect-api:8080 + SECRET_PROVIDER_CONNECT_TOKEN=your-connect-token + ``` + +=== "Using a token file" + + ```bash title="Environment Variables for doco-cd" + SECRET_PROVIDER=1password + SECRET_PROVIDER_CONNECT_HOST=http://op-connect-api:8080 + SECRET_PROVIDER_CONNECT_TOKEN_FILE=/run/secrets/op_connect_token + ``` + + Mount the Connect token file as a secret or volume into the `doco-cd` container at the specified path: + + ```yaml title="docker-compose.yml" + services: + app: + image: kimdre/doco-cd:latest + environment: + SECRET_PROVIDER: 1password + SECRET_PROVIDER_CONNECT_HOST: http://op-connect-api:8080 + SECRET_PROVIDER_CONNECT_TOKEN_FILE: /run/secrets/op_connect_token + secrets: + - op_connect_token + + secrets: + op_connect_token: + file: ./op_connect_token.txt + ``` + +[1password-connect-setup]: https://developer.1password.com/docs/connect/get-started/?deploy=docker#step-1 \ No newline at end of file diff --git a/doco-cd-src/wiki/docs/External-Secrets/1Password.md b/doco-cd-src/wiki/docs/External-Secrets/1Password.md index 4e35494..10a1b59 100644 --- a/doco-cd-src/wiki/docs/External-Secrets/1Password.md +++ b/doco-cd-src/wiki/docs/External-Secrets/1Password.md @@ -10,15 +10,21 @@ tags: !!! info The start time and memory usage of the doco-cd container, as well as the runtime of a job, can increase significantly when using this secret provider. +!!! tip "Using 1Password Connect Server" + For improved performance and to avoid API rate limits in high-volume deployments, consider using [1Password Connect Server](1Password-Connect.md) instead of service account authentication. + ## Environment Variables -To use 1Password, you need to set the following environment variables: +To use 1Password, configure these variables for the `doco-cd` container + +| Key | Value | +|--------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `SECRET_PROVIDER` | `1password` | +| `SECRET_PROVIDER_ACCESS_TOKEN` | Access token of a service account, see [the docs](https://developer.1password.com/docs/service-accounts/security/) and [here](https://developer.1password.com/docs/sdks/setup-tutorial/#part-1-set-up-a-1password-service-account) | +| `SECRET_PROVIDER_ACCESS_TOKEN_FILE` | Path to the file containing the service account token inside the container | -| Key | Value | -|-------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `SECRET_PROVIDER` | `1password` | -| `SECRET_PROVIDER_ACCESS_TOKEN` | Access token of a service account, see [the docs](https://developer.1password.com/docs/service-accounts/security/) and [here](https://developer.1password.com/docs/sdks/setup-tutorial/#part-1-set-up-a-1password-service-account) | -| `SECRET_PROVIDER_ACCESS_TOKEN_FILE` | Path to the file containing the access token inside the container | +!!! tip "API Rate Limit" + If you hit the API rate limit, you can also enable client-side caching for resolved secrets. See the [Client-Side Caching](#client-side-caching) section below for more details. ## Deployment configuration @@ -44,3 +50,19 @@ name: myapp external_secrets: DB_PASSWORD: "op://vault/item/field" ``` + +## Client-Side Caching + +Optional client-side caching[^1] reduces 1Password API calls when using service account authentication. Enable and configure caching with the following environment variables: + +| Key | Type | Value | Default | +|----------------------------------|-----------|----------------------------------------------------------------------------------------------------------------------------------|:--------| +| `SECRET_PROVIDER_CACHE_ENABLED` | `boolean` | Enables in-memory caching for resolved secrets | `false` | +| `SECRET_PROVIDER_CACHE_TTL` | `string` | Cache TTL for resolved secrets as a [Go duration](https://pkg.go.dev/time#ParseDuration) string (for example: `30s`, `5m`, `1h`) | `5m` | +| `SECRET_PROVIDER_CACHE_MAX_SIZE` | `number` | Maximum number of secrets stored in cache before least-recently-used entries are evicted | `100` | + +!!! warning "If the cache TTL is too long, secrets may become outdated." + +[^1]: + Client-side caching can only be used with service account authentication. + When using [1Password Connect Server](1Password-Connect.md), client-side caching is automatically disabled because the Connect Server already handles caching for you. diff --git a/doco-cd-src/wiki/docs/External-Secrets/Bitwarden-Vault-Vaultwarden.md b/doco-cd-src/wiki/docs/External-Secrets/Bitwarden-Vault-Vaultwarden.md index 5237b1d..5d5573e 100644 --- a/doco-cd-src/wiki/docs/External-Secrets/Bitwarden-Vault-Vaultwarden.md +++ b/doco-cd-src/wiki/docs/External-Secrets/Bitwarden-Vault-Vaultwarden.md @@ -95,21 +95,23 @@ volumes: !!! note For all configuration options, refer to the image documentation at https://github.com/kimdre/bitwarden-rest-api-server#getting-started. -- `BW_HOST` (optional) - Bitwarden or Vaultwarden API host. Defaults to `https://vault.bitwarden.com`. - For Vaultwarden, use your self-hosted instance URL (e.g., `https://vault.example.com`) -- `BW_CLIENTID` (required) - Client ID from your personal API Key credentials -- `BW_CLIENTSECRET` (required) - Client Secret from your personal API Key credentials -- `BW_PASSWORD` (required) - Master password for your Bitwarden account +| Variable | Required | Description | Default | +|-------------------|----------|---------------------------------------------------------------------------------------------------------------------------------------|-------------------------------| +| `BW_HOST` | No | Bitwarden or Vaultwarden API host.
For Vaultwarden, use your self-hosted instance URL (for example, `https://vault.example.com`). | `https://vault.bitwarden.com` | +| `BW_CLIENTID` | Yes | Client ID from your personal API Key credentials. | N/A | +| `BW_CLIENTSECRET` | Yes | Client Secret from your personal API Key credentials. | N/A | +| `BW_PASSWORD` | Yes | Master password for your Bitwarden account. | N/A | Store these values in a `.env` file or set them inside the compose file. For detailed information on setting up your personal API Key, see: https://bitwarden.com/help/personal-api-key/ -#### Finding Item UUIDs +### Finding Item UUIDs To reference secrets from Bitwarden in your `.doco-cd.yml`, you need the UUID of the Bitwarden item (vault entry). +A UUID has the format `12345678-aaaa-bbbb-cccc-123456789abc`. -##### Using Bitwarden CLI +#### Using Bitwarden CLI ```bash # Login to Bitwarden (if not already logged in) @@ -144,7 +146,7 @@ Example CLI output: ] ``` -##### Using Bitwarden Web Vault +#### Using Bitwarden Web Vault 1. Open your Bitwarden vault at https://vault.bitwarden.com (or your Vaultwarden URL) 2. Click on an item to view its details @@ -174,44 +176,46 @@ In this example: - `bitwarden-login` fetches built-in login fields such as `username` and `password` - `bitwarden-fields` fetches custom fields by name -### Minimal `.doco-cd.yml` example +### Example `.doco-cd.yml` with Bitwarden Vault secrets -```yaml title=".doco-cd.yml" -name: myapp -external_secrets: - DB_PASSWORD: - store_ref: bitwarden-login - remote_ref: - key: 12345678-aaaa-bbbb-cccc-123456789abc - property: password -``` +=== "Basic example" -### Extended `.doco-cd.yml` example - -```yaml title=".doco-cd.yml" -name: myapp -external_secrets: - DB_USERNAME: - store_ref: bitwarden-login - remote_ref: - key: 12345678-aaaa-bbbb-cccc-123456789abc - property: username - - DB_PASSWORD: - store_ref: bitwarden-login - remote_ref: - key: 12345678-aaaa-bbbb-cccc-123456789abc - property: password - - API_KEY: - store_ref: bitwarden-fields - remote_ref: - key: dddddddd-1111-2222-3333-eeeeeeeeeeee - property: api_key -``` + ```yaml title=".doco-cd.yml" + name: myapp + external_secrets: + DB_PASSWORD: + store_ref: bitwarden-login + remote_ref: + key: 12345678-aaaa-bbbb-cccc-123456789abc + property: password + ``` +=== "Extended example" + + ```yaml title=".doco-cd.yml" + name: myapp + external_secrets: + DB_USERNAME: + store_ref: bitwarden-login + remote_ref: + key: 12345678-aaaa-bbbb-cccc-123456789abc + property: username + + DB_PASSWORD: + store_ref: bitwarden-login + remote_ref: + key: 12345678-aaaa-bbbb-cccc-123456789abc + property: password + + API_KEY: + store_ref: bitwarden-fields + remote_ref: + key: dddddddd-1111-2222-3333-eeeeeeeeeeee + property: api_key + ``` + With this setup, Doco-CD resolves the secret value by: - + 1. rendering the configured store templates using `remote_ref` 2. calling the Bitwarden sidecar over HTTP 3. extracting the value from the JSON response with `json_path` diff --git a/doco-cd-src/wiki/docs/External-Secrets/Webhook.md b/doco-cd-src/wiki/docs/External-Secrets/Webhook.md index 9520a9b..7427d54 100644 --- a/doco-cd-src/wiki/docs/External-Secrets/Webhook.md +++ b/doco-cd-src/wiki/docs/External-Secrets/Webhook.md @@ -28,56 +28,83 @@ To use it, set the following environment variables: ### Format -A store must define: +A store supports the following fields: -- `name` -- `version` (currently `v1`) -- `url` -- `json_path` +| Field | Required | Description | +|-------------|----------|--------------------------------------------------------------------------------------| +| `name` | Yes | Store name (must be unique). | +| `version` | Yes | Store schema version (currently `v1`). | +| `url` | Yes | Request URL template. | +| `json_path` | Yes | [JMESPath](https://jmespath.org/) expression used to extract the final secret value. | +| `method` | No | HTTP method. Defaults to `GET`. | +| `headers` | No | Optional HTTP headers map (template-supported). | +| `body` | No | Optional HTTP request body template. | -And can optionally define: +!!! warning "The provider fails fast when:" -- `method` (defaults to `GET`) -- `headers` -- `body` + - `store_ref` does not exist + - a referenced `remote_ref` field is missing + - `json_path` is missing or renders empty + +!!! example "Examples for `json_path`" + + - `data.login.password` + - `data.fields[?name=='password'].value` + +### Example store definitions + +The webhook provider supports two definition styles: + +- `#!yaml stores:` map style (multiple named stores in one document) +- top-level single-store style (can be combined using YAML multi-document `#!yaml ---`) + +=== "`#!yaml stores:` map style" -`json_path` expressions use [JMESPath](https://jmespath.org/) syntax (for example `data.login.password` and `data.fields[?name=='password'].value`). + ```yaml title="secret-stores.yml" + stores: + bitwarden-login: + version: v1 + url: "http://bitwarden-api:8087/object/item/{{ .remote_ref.key }}" + method: GET + headers: + Content-Type: application/json + json_path: "data.login.{{ .remote_ref.property }}" -#### Example: map/list schema (`stores:`) and multi-document support + bitwarden-fields: + version: v1 + url: "http://bitwarden-api:8087/object/item/{{ .remote_ref.key }}" + method: GET + json_path: "data.fields[?name=='{{ .remote_ref.property }}'].value" + ``` -```yaml title="Example Secret Stores (e.g. secret-stores.yml)" -stores: - bitwarden-login: +=== "Single-store multi-document style" + + ```yaml title="secret-stores.yml" + name: bitwarden-login version: v1 url: "http://bitwarden-api:8087/object/item/{{ .remote_ref.key }}" method: GET headers: Content-Type: application/json json_path: "data.login.{{ .remote_ref.property }}" - - bitwarden-fields: + --- + name: akeyless version: v1 - url: "http://bitwarden-api:8087/object/item/{{ .remote_ref.key }}" - method: GET - json_path: "data.fields[?name=='{{ .remote_ref.property }}'].value" ---- -name: akeyless -version: v1 -url: "https://api.akeyless.io/v2/get-secret-value" -method: POST -headers: - Content-Type: application/json - Authorization: "Basic {{ print .auth.username \":\" .auth.password | b64enc }}" -body: '{"secret_name":"{{ .remote_ref.key }}","auth_method_access_token":"{{ .auth.token }}"}' -json_path: "value" -``` + url: "https://api.akeyless.io/v2/get-secret-value" + method: POST + headers: + Content-Type: application/json + Authorization: "Basic {{ print .auth.username \":\" .auth.password | b64enc }}" + body: '{"secret_name":"{{ .remote_ref.key }}","auth_method_access_token":"{{ .auth.token }}"}' + json_path: "value" + ``` ## Deployment Configuration For webhook, `external_secrets` entries must use object references. -Legacy string refs (e.g. `DB_PASSWORD: some-id`) are rejected with a clear error. +Legacy string refs (e.g. `#!yaml DB_PASSWORD: some-id`) are rejected with a clear error. -```yaml +```yaml title=".doco-cd.yml" name: myapp external_secrets: DB_USERNAME: @@ -101,27 +128,64 @@ external_secrets: ## Template Parameters -All store templates (`url`, `headers`, `body`, `json_path`) can use: - -| Key | Description | -|--------------|------------------------------------------------------------------------------------------------------------------------------------------| -| `remote_ref` | The object provided for that secret in `.doco-cd.yml`.
Can contain any fields, for example `key` and `property` in the example above | -| `auth` | Provider auth values from `SECRET_PROVIDER_AUTH_*` env vars | +All templated store fields (`url`, `headers`, `body`, `json_path`) can access the following objects: + +| Parameter | Description | +|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `remote_ref` | The per-secret object from `.doco-cd.yml` under `external_secrets..remote_ref`.
You can define any keys (for example `key`, `property`, `query`, `filters`). | +| `auth` | Provider auth values from `SECRET_PROVIDER_AUTH_*` environment variables.
Available keys: `username`, `password`, `token`, `api_key`. | + +!!! example + + ```yaml title=".doco-cd.yml" + external_secrets: + DB_PASSWORD: + store_ref: bitwarden-login + remote_ref: + key: 12345678-aaaa-bbbb-cccc-123456789abc + property: password + ``` + + ```yaml title="store definition" + url: "http://bitwarden-api:8087/object/item/{{ .remote_ref.key }}" + json_path: "data.login.{{ .remote_ref.property }}" + headers: + Authorization: "Bearer {{ .auth.token }}" + ``` + + Rendered values (example): + + - `url`: `http://bitwarden-api:8087/object/item/12345678-aaaa-bbbb-cccc-123456789abc` + - `json_path`: `data.login.password` ## Template Functions -The following functions are available for use in all template fields: - -| Function | Description | Input | Template | Result | -|-------------|------------------------------|------------------|------------------------------------|-------------------| -| `b64enc` | Encode input to base64 | `secret123` | `{{ "secret123" \| b64enc }}` | `c2VjcmV0MTIz` | -| `b64dec` | Decode base64 input | `c2VjcmV0MTIz` | `{{ "c2VjcmV0MTIz" \| b64dec }}` | `secret123` | -| `urlencode` | URL encode input | `hello world` | `{{ "hello world" \| urlencode }}` | `hello+world` | -| `urldecode` | URL decode input | `hello+world` | `{{ "hello+world" \| urldecode }}` | `hello world` | -| `json` | Convert input to JSON string | `map[key:value]` | `{{ .remote_ref.data \| json }}` | `{"key":"value"}` | -| `toUpper` | Convert input to uppercase | `hello` | `{{ "hello" \| toUpper }}` | `HELLO` | -| `toLower` | Convert input to lowercase | `HELLO` | `{{ "HELLO" \| toLower }}` | `hello` | -| `trim` | Trim whitespace from input | ` hello ` | `{{ " hello " \| trim }}` | `hello` | +Template functions are available in all templated fields (`url`, `headers`, `body`, `json_path`). +Functions can be chained with `|` from left to right. + +| Function | Purpose | +|-------------|----------------------------------------| +| `b64enc` | Base64-encode a value | +| `b64dec` | Base64-decode a value | +| `urlencode` | URL-encode a value | +| `urldecode` | URL-decode a value | +| `json` | Convert a value to a JSON string | +| `toUpper` | Convert text to uppercase | +| `toLower` | Convert text to lowercase | +| `trim` | Remove leading and trailing whitespace | + +??? example "Function examples" + + - `{{ "secret123" | b64enc }}` -> `c2VjcmV0MTIz` + - `{{ "c2VjcmV0MTIz" | b64dec }}` -> `secret123` + - `{{ "hello world" | urlencode }}` -> `hello+world` + - `{{ "hello+world" | urldecode }}` -> `hello world` + - `{{ .remote_ref.data | json }}` -> `{"key":"value"}` + - `{{ .remote_ref.data | json | b64enc }}` -> `eyJrZXkiOiJ2YWx1ZSJ9` (JSON string encoded in Base64) + - `{{ "hello" | toUpper }}` -> `HELLO` + - `{{ "HELLO" | toLower }}` -> `hello` + - `{{ " hello " | trim }}` -> `hello` + - `{{ " hello " | trim | toUpper }}` -> `HELLO` ## Examples @@ -174,11 +238,3 @@ json_path: "secret[?key=='{{ .remote_ref.encoded_id | b64dec | trim }}']" With `remote_ref.encoded_id=c2VjcmV0LWtleQ==`: - Result: `secret[?key=='secret-key']` - -!!! warning - The provider fails fast when: - - - `store_ref` does not exist - - a referenced `remote_ref` field is missing - - `json_path` is missing or renders empty - diff --git a/doco-cd-src/wiki/docs/Getting-Started.md b/doco-cd-src/wiki/docs/Getting-Started.md index a437ff8..82a3fe4 100644 --- a/doco-cd-src/wiki/docs/Getting-Started.md +++ b/doco-cd-src/wiki/docs/Getting-Started.md @@ -35,7 +35,7 @@ The Git access token is used to authenticate with your Git provider (GitHub, Git You can use doco-cd without a Git Access Token if the repositories you want to use for your deployments are publicly accessible. However, it is still recommended to use one in that case to for example avoid rate limits. -If you set a Git access token, doco-cd will always use it to authenticate with your Git provider. See [Setup Access Token](Setup-Access-Token.md) to create this access token and set the `GIT_ACCESS_TOKEN` environment variable to the access token value. +Set `GIT_ACCESS_TOKEN` for a global fallback token, or use `GIT_AUTH_DOMAINS` / `GIT_AUTH_DOMAINS_FILE` for per-domain credentials. See [Setup Access Token](Setup-Access-Token.md) for examples. ## Deployment triggers @@ -117,6 +117,10 @@ See the [External Secrets](External-Secrets/index.md) wiki page for more informa If you want to pull images from a private registry, see [Private Container Registries](Advanced/Private-Container-Registries.md) in the wiki. +### Job Scheduling / Cron Jobs + +Doco-CD supports job scheduling and cron jobs for running periodic tasks. See the [Job Scheduling](Advanced/Job-Scheduling.md) wiki page for more information on how to configure and use this feature. + ### Sending Notifications Doco-CD supports sending notifications about deployment events to various services. See the [Notifications](Advanced/Notifications.md) wiki page for more information on how to set up notifications. diff --git a/doco-cd-src/wiki/docs/Git-Settings.md b/doco-cd-src/wiki/docs/Git-Settings.md new file mode 100644 index 0000000..a7fae76 --- /dev/null +++ b/doco-cd-src/wiki/docs/Git-Settings.md @@ -0,0 +1,136 @@ +--- +tags: + - Configuration +--- + +# Git Settings + +Settings to configure Git authentication and clone behavior. + +## General + +| Key | Type | Description | Default | +|-----------------------------------|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------| +| `GIT_CLONE_DEPTH` | number | Limits the number of commits fetched during clone/fetch operations (shallow clone). `0` means full clone (no depth limit). Can be overridden per deployment via the [`git_depth`](Deploy-Settings.md) setting. When a requested ref is outside the shallow depth, doco-cd automatically deepens incrementally before falling back to a full fetch. | `0` | +| `GIT_CLONE_SUBMODULES` | boolean | Whether Git submodules are cloned too. | `true` | +| `SKIP_TLS_VERIFICATION` | boolean | Skip TLS verification when cloning repositories. | `false` | + +## Authentication + +The following settings configure how Doco-CD authenticates with Git providers when cloning/pulling repositories. + +You can use either + +- HTTP(S) authentication with access tokens +- SSH authentication with private keys. +- For multiple domains/providers, see the [Domain-scoped authentication](#domain-scoped-authentication) section below. + +| Key | Type | Description | Default | +|-----------------------------------|--------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------| +| `AUTH_TYPE` | string | AuthType is the type of authentication to use when cloning repositories via **http**. | `oauth2` | +| `GIT_ACCESS_TOKEN` | string | Access token for cloning repositories (required for private repositories) via **HTTP**, see [Access Token Setup](Setup-Access-Token.md). See also [Domain-scoped authentication](#domain-scoped-authentication). | Optional for public repositories but recommended | +| `GIT_ACCESS_TOKEN_FILE` | string | Path to the file containing the Git Access Token (mutually exclusive with `GIT_ACCESS_TOKEN`). | | +| `GIT_AUTH_DOMAINS` | list | YAML list of domain-scoped Git credentials (HTTP token, SSH key, and GitHub App credentials). Supports exact domains and wildcard subdomains like `*.example.com` (see [Domain-scoped authentication](#domain-scoped-authentication)). Mutually exclusive with `GIT_AUTH_DOMAINS_FILE`. | | +| `GIT_AUTH_DOMAINS_FILE` | string | Path to a file containing the YAML configuration for `GIT_AUTH_DOMAINS` (mutually exclusive with `GIT_AUTH_DOMAINS`). | | +| `SSH_PRIVATE_KEY` | string | The private key used for cloning repositories via SSH, see [SSH Key Setup](Setup-SSH-Key.md). See also [Domain-scoped authentication](#domain-scoped-authentication). | | +| `SSH_PRIVATE_KEY_FILE` | string | Path to the file containing the SSH private key. | | +| `SSH_PRIVATE_KEY_PASSPHRASE` | string | Passphrase for the SSH private key (if the key was generated with a passphrase). | | +| `SSH_PRIVATE_KEY_PASSPHRASE_FILE` | string | Path to the file containing the SSH private key passphrase. | | + +## Domain-scoped Authentication + +Use domain-scoped config when you fetch from multiple Git providers/domains and need separate credentials. + +### Syntax and Format + +The domain-scoped authentication configuration is a YAML list where each entry defines credentials for one or more domains. + +#### Entry Structure + +Each entry in the list has the following structure: + +```yaml +- domains: # (Required) List of domain names or patterns + - domain1.com + - domain2.com + - '*.example.com' + git_access_token: xxx # (Optional) HTTP token for git access + ssh_private_key: | # (Optional) SSH private key content + -----BEGIN OPENSSH PRIVATE KEY----- + ... + -----END OPENSSH PRIVATE KEY----- + ssh_private_key_passphrase: xxx # (Optional) Passphrase for encrypted SSH key +``` + +#### Available Options + +| Field | Type | Required | Description | +|------------------------------|--------|----------|----------------------------------------------------------------------------------------------------------------------| +| `domains` | list | Yes | List of domain names to apply these credentials to. Supports exact domains and wildcard patterns. | +| `git_access_token` | string | No | HTTP(S) access token for authenticating with the Git provider. Cannot be used with `ssh_private_key`. | +| `ssh_private_key` | string | No | SSH private key content (multi-line). Cannot be used with `git_access_token`. | +| `ssh_private_key_passphrase` | string | No | Passphrase for the SSH private key if it was generated with encryption. Only used with `ssh_private_key`. | +| `github_app_id` | string | No | GitHub App ID. Requires `github_app_private_key`. Cannot be used with `git_access_token` or `ssh_private_key`. | +| `github_app_private_key` | string | No | GitHub App private key (PEM). Requires `github_app_id`. Cannot be used with `git_access_token` or `ssh_private_key`. | +| `github_app_installation_id` | number | No | Optional installation ID override for this domain entry. If omitted, installation is auto-detected by owner/repo. | + +#### Authentication Method Selection + +- **Use `git_access_token`** for HTTP(S) based Git access +- **Use `ssh_private_key`** (and optionally `ssh_private_key_passphrase`) for SSH-based Git access +- **Use `github_app_id` + `github_app_private_key`** for GitHub App based HTTP(S) access +- Do not mix methods in the same entry + +### Matching Behavior + +- Exact domain match wins over wildcard entries. +- If multiple wildcard patterns match, the longest suffix wins (most specific). +- Wildcards only match subdomains. Example: `*.example.com` matches `git.example.com`, but not `example.com`. +- If no domain entry matches, doco-cd falls back to global `GIT_ACCESS_TOKEN` / `SSH_PRIVATE_KEY` values if set. +- Submodule remotes are resolved independently, so each submodule can use credentials for its own domain. + +### Examples + +=== "Using `GIT_AUTH_DOMAINS`" + + ```yaml title="docker-compose.yml" + services: + app: + environment: + GIT_AUTH_DOMAINS: | + --8<-- "wiki/includes/git-auth-domains.example.yaml" + ``` + +=== "Using `GIT_AUTH_DOMAINS_FILE`" + + You can also store the YAML in a file and load it with `GIT_AUTH_DOMAINS_FILE`. + + ```yaml title="git-auth-domains.yaml" + --8<-- "wiki/includes/git-auth-domains.example.yaml" + ``` + + ```yaml title="docker-compose.yml" + services: + app: + environment: + GIT_AUTH_DOMAINS_FILE: /run/secrets/git_auth_domains + secrets: + - git_auth_domains + + secrets: + git_auth_domains: + file: ./git-auth-domains.yaml + ``` + +## GitHub Apps + +[GitHub Apps](https://docs.github.com/en/apps) are supported natively and can be configured globally (see below) or [per domain](#domain-scoped-authentication). +Doco-CD will auto-detect the installation by repository _owner/name_ and mint short-lived installation access tokens. + +| Key | Type | Description | Default value | +|---------------------------------|--------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------| +| `GITHUB_APP_ID` | string | ID of the GitHub App, used to mint installation access tokens for GitHub repositories. Requires `GITHUB_APP_PRIVATE_KEY`. Mutually exclusive with global `GIT_ACCESS_TOKEN`. | | +| `GITHUB_APP_ID_FILE` | string | Path to the file containing `GITHUB_APP_ID` (mutually exclusive with `GITHUB_APP_ID`). | | +| `GITHUB_APP_PRIVATE_KEY` | string | GitHub App private key in PEM format. Requires `GITHUB_APP_ID`. | | +| `GITHUB_APP_PRIVATE_KEY_FILE` | string | Path to the file containing `GITHUB_APP_PRIVATE_KEY` (mutually exclusive with `GITHUB_APP_PRIVATE_KEY`). | | +| `GITHUB_APP_INSTALLATION_ID` | number | Optional installation ID override for global GitHub App auth. If unset, doco-cd resolves installation by _owner/repository_ automatically. | `0` (auto-detect) | diff --git a/doco-cd-src/wiki/docs/Poll-Settings.md b/doco-cd-src/wiki/docs/Poll-Settings.md index efc706f..cf78650 100644 --- a/doco-cd-src/wiki/docs/Poll-Settings.md +++ b/doco-cd-src/wiki/docs/Poll-Settings.md @@ -32,7 +32,7 @@ They must be in the format of a YAML list/array (also called YAML Sequence) and #### Using a YAML anchor -With a YAML anchor, you can define the poll configuration outside the service definition. +With a YAML anchor (See [Fragments](https://docs.docker.com/reference/compose-file/fragments/) and [Extensions](https://docs.docker.com/reference/compose-file/extension/) in Docker Compose), you can define the poll configuration outside the service definition. ```yaml title="docker-compose.yaml" hl_lines="1-6 19" x-poll-config: &poll-config diff --git a/doco-cd-src/wiki/docs/Setup-Access-Token.md b/doco-cd-src/wiki/docs/Setup-Access-Token.md index 2017b6a..123eeb1 100644 --- a/doco-cd-src/wiki/docs/Setup-Access-Token.md +++ b/doco-cd-src/wiki/docs/Setup-Access-Token.md @@ -8,33 +8,31 @@ tags: This page shows how to set up a Git Access Token for your deployments. -!!! info - The Git Access Token is used to authenticate with your Git provider (GitHub, GitLab, Bitbucket, etc.) and to clone or fetch your repositories via HTTP. - - !!! tip - You can use doco-cd without a Git Access Token if the repositories you want to use for your deployments are publicly accessible. However, it is still recommended to use one in that case to for example avoid rate limits. - - If you set a Git Access Token, doco-cd will always use it to authenticate with your Git provider. +The Git Access Token is used to authenticate with your Git provider (GitHub, GitLab, Bitbucket, etc.) and to clone or fetch your repositories via HTTP. + +!!! tip "Usage without Git Access Token" + You can use doco-cd without a Git Access Token if the repositories you want to use for your deployments are publicly accessible. However, it is still recommended to use one in that case to for example avoid rate limits. + +!!! info "About Git Authentication" + See [Git Authentication](Git-Settings.md#authentication) for more information on how doco-cd handles Git authentication + and how to set up global and per-domain credentials. ## Git Providers === "GitHub" - You can either use a Personal Access Token (PAT) or a GitHub App. - - !!! question "How to create an access token" - See the GitHub docs for - - - [Personal Access Tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). - - [GitHub Apps](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app). - **Permissions** + You can either use a Personal Access Token (PAT) or a GitHub App. === "Personal Access Token (Classic)" - - The minimum required scope is `repo` + See the GitHub docs for [Personal Access Token (Classic)](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic). + + The minimum required scope is `repo` === "Fine-grained tokens" - + + See the GitHub docs for [Fine-grained tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token). + - Repository access - Set to `Public Repositories (read-only)` for only public repositories. - Set to `All Repositories` for all repositories. @@ -42,6 +40,16 @@ This page shows how to set up a Git Access Token for your deployments. - `Contents` -> `Read-only` - `Metadata` -> `Read-only` + === "GitHub Apps" + + GitHub Apps are [supported natively](Git-Settings.md#github-apps) in Doco-CD. + + See the GitHub docs for registering a [GitHub Apps](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app). + + - The minimum required permissions are: + - `Repository contents` -> `Read-only` + - `Repository metadata` -> `Read-only` + === "Gitea, Forgejo, Gogs" 1. Go to your user settings. 2. Click on `Applications`. diff --git a/doco-cd-src/wiki/docs/Setup-SSH-Key.md b/doco-cd-src/wiki/docs/Setup-SSH-Key.md index 124d315..dc6d955 100644 --- a/doco-cd-src/wiki/docs/Setup-SSH-Key.md +++ b/doco-cd-src/wiki/docs/Setup-SSH-Key.md @@ -50,6 +50,11 @@ If the connection is successful, you should see a message indicating that you ha You need to configure doco-cd to use the private key (`id_ed25519` or the file path you specified) for SSH authentication. See the app config on the [App Settings](App-Settings.md) wiki page for more information on how to set the SSH private key in doco-cd. + +!!! tip "About Git Authentication" + See [Git Authentication](Git-Settings.md#authentication) for more information on how doco-cd handles Git authentication + and how to set up global and per-domain credentials. + An example using Docker Compose: ```yaml title="docker-compose.yml" diff --git a/doco-cd-src/wiki/docs/Setup-Webhook.md b/doco-cd-src/wiki/docs/Setup-Webhook.md index 6f514ad..c8025ec 100644 --- a/doco-cd-src/wiki/docs/Setup-Webhook.md +++ b/doco-cd-src/wiki/docs/Setup-Webhook.md @@ -14,7 +14,7 @@ This page shows how to set up a webhook for your deployments. ## Webhook Endpoint -To enable the webhook endpoint, you need to set the `WEBHOOK_SECRET` [environment variable](App-Settings.md#available-settings) to a secure secret value and publish the webhook port (default is `80`, see the `HTTP_PORT` [environment variable](App-Settings.md#available-settings)) in the doco-cd `docker-compose.yml` file. +To enable the webhook endpoint, you need to set the `WEBHOOK_SECRET` [environment variable](App-Settings.md#general-settings) to a secure secret value and publish the webhook port (default is `80`, see the `HTTP_PORT` [environment variable](App-Settings.md#general-settings)) in the doco-cd `docker-compose.yml` file. You can use tools like [pwgen](https://linux.die.net/man/1/pwgen) or [openssl](https://www.openssl.org/) to generate a random secret for the `WEBHOOK_SECRET`. diff --git a/doco-cd-src/wiki/docs/_snippets/reconciliation-note.md b/doco-cd-src/wiki/docs/_snippets/reconciliation-note.md deleted file mode 100644 index d7a47b5..0000000 --- a/doco-cd-src/wiki/docs/_snippets/reconciliation-note.md +++ /dev/null @@ -1,9 +0,0 @@ -!!! note - [Reconciliation](../Deploy-Settings.md#reconciliation-settings) for non-Swarm deployments follows classic Compose `restart` semantics: - - - Services with `#!yaml restart: always` or `#!yaml restart: unless-stopped` are expected to stay running. - - Services with no explicit `restart` policy are treated as `#!yaml restart: "no"`. - - Services with `#!yaml restart: on-failure` may remain exited after success, and `#!yaml restart: "no"` is treated as one-time behavior and is not reconciled back to running. - - Swarm handling is separate and uses Swarm service modes and `#!yaml deploy.restart_policy` behavior. - - More information on the restart policies can be found in the [Docker Compose specification](https://docs.docker.com/reference/compose-file/services/#restart). \ No newline at end of file diff --git a/doco-cd-src/wiki/docs/index.md b/doco-cd-src/wiki/docs/index.md index f716730..5ab13a4 100644 --- a/doco-cd-src/wiki/docs/index.md +++ b/doco-cd-src/wiki/docs/index.md @@ -23,6 +23,7 @@ You can think of it as a simple Portainer or ArgoCD alternative for Docker. - Supports various [Git providers](#supported-git-providers) - Supports both Docker Compose projects and Swarm stacks in [Swarm mode](Advanced/Swarm-Mode.md). - Provides [notifications](Advanced/Notifications.md) and [Prometheus metrics](Endpoints/Metrics.md) for monitoring. +- Supports [Job Scheduling / Cron Jobs](Advanced/Job-Scheduling.md) for running periodic tasks. ## Getting Started @@ -68,7 +69,17 @@ ghcr.io/kimdre/doco-cd:0.80.0 ## Community - Ask questions on [GitHub Discussions](https://github.com/kimdre/doco-cd/discussions) -- Report bugs or suggest features by [opening an issue](https://github.com/kimdre/doco-cd/issues/new) +- Report bugs or suggest features by [opening an issue](https://github.com/kimdre/doco-cd/issues/new/choose) + +## In the Media + +Doco-CD has been featured by industry media and technical publications: + +| Date | Publication | Article | +|------------|-------------|----------------------------------------------------------------------------------------------------------------------------| +| 2026-05-01 | c't Magazin | [(German) c't 10/2026](https://www.heise.de/select/ct/2026/10/2609115553794560316) | +| 2026-04-22 | heise+ | [(German) Watchtower and alternatives: how to keep Docker containers automatically up to date](https://heise.de/-11243856) | +| 2025-11-14 | selfh.st | [Weekly: 2025-11-14](https://selfh.st/weekly/2025-11-14/) | ## Contributing diff --git a/doco-cd-src/wiki/docs/wiki/README.md b/doco-cd-src/wiki/docs/wiki/README.md index c44002a..0e23dba 100644 --- a/doco-cd-src/wiki/docs/wiki/README.md +++ b/doco-cd-src/wiki/docs/wiki/README.md @@ -1,3 +1,5 @@ -[//]: # (This file is a workaround to include the Documentation Guidelines wiki/README.md in the Wiki) +--- +comment: This file is a workaround to include the Documentation Guidelines from "wiki/README.md" in the Wiki +--- --8<-- "wiki/README.md" \ No newline at end of file diff --git a/doco-cd-src/wiki/includes/abbreviations.md b/doco-cd-src/wiki/includes/abbreviations.md new file mode 100644 index 0000000..255b0ef --- /dev/null +++ b/doco-cd-src/wiki/includes/abbreviations.md @@ -0,0 +1,6 @@ +[This is the global docs glossary]: <> (https://zensical.org/docs/authoring/tooltips/?h=glos#add-a-glossary) + +*[AWS]: Amazon Web Services +*[ARN]: Amazon Resource Name (ARN): A unique identifier for resources within Amazon Web Services (AWS) +*[TTL]: Time To Live: A caching term that refers to the duration for which a cached item is considered valid before it needs to be refreshed or re-fetched from the original source. +*[UUID]: Universally Unique Identifier \ No newline at end of file diff --git a/doco-cd-src/wiki/includes/git-auth-domains.example.yaml b/doco-cd-src/wiki/includes/git-auth-domains.example.yaml new file mode 100644 index 0000000..af20dc0 --- /dev/null +++ b/doco-cd-src/wiki/includes/git-auth-domains.example.yaml @@ -0,0 +1,19 @@ +# Example value for GIT_AUTH_DOMAINS +- domains: + - github.com + github_app_id: "123456" + github_app_private_key: | + -----BEGIN RSA PRIVATE KEY----- + ... + -----END RSA PRIVATE KEY----- + # Optional: pin a known installation id. If omitted, doco-cd resolves it by owner/repo. + # github_app_installation_id: 987654321 + +- domains: + - gitlab.com + - '*.corp.example.com' + ssh_private_key: | + -----BEGIN OPENSSH PRIVATE KEY----- + ... + -----END OPENSSH PRIVATE KEY----- + ssh_private_key_passphrase: "" diff --git a/doco-cd-src/wiki/includes/reconciliation-note.md b/doco-cd-src/wiki/includes/reconciliation-note.md new file mode 100644 index 0000000..860e748 --- /dev/null +++ b/doco-cd-src/wiki/includes/reconciliation-note.md @@ -0,0 +1,13 @@ +!!! note "[Reconciliation](../docs/Deploy-Settings.md#reconciliation-settings) for non-Swarm deployments follows classic Compose `restart` semantics" + + - Services with `#!yaml restart: always` or `#!yaml restart: unless-stopped` are expected to stay running. + - Services with no explicit `restart` policy are treated as `#!yaml restart: "no"`. + - Services with `#!yaml restart: on-failure` may remain exited after success (exited with status code `0`), and `#!yaml restart: "no"` is treated as one-time behavior and is not reconciled back to running. + - Restart events use the stop signal that is configured in the container image (`StopSignal` in the image config); you can override this with `#!yaml reconciliation.restart_signal`. If neither is available, `SIGTERM` is used. + - To prevent endless restart loops caused by flappy health checks, `#!yaml unhealthy` restarts are rate-limited via `#!yaml reconciliation.restart_limit` and `#!yaml reconciliation.restart_window`. + + More information on the restart policies can be found in the [Docker Compose specification](https://docs.docker.com/reference/compose-file/services/#restart). + + !!! abstract "Reconciliation in Docker Swarm" + Docker Swarm manages some desired-state reconciliation by itself with Swarm service modes and [`#!yaml deploy.restart_policy`](https://docs.docker.com/reference/compose-file/deploy/#restart_policy) behavior. See Docker's documentation on [desired state reconciliation](https://docs.docker.com/engine/swarm/#desired-state-reconciliation). + Doco-CD's reconciliation for Swarm deployments only manages service updates and scaling, but not container restarts or health status. diff --git a/doco-cd-src/wiki/requirements.txt b/doco-cd-src/wiki/requirements.txt index f4021ee..d5c123c 100644 --- a/doco-cd-src/wiki/requirements.txt +++ b/doco-cd-src/wiki/requirements.txt @@ -1,2 +1,2 @@ -zensical==0.0.34 -git+https://github.com/squidfunk/mike.git \ No newline at end of file +zensical==0.0.41 +git+https://github.com/squidfunk/mike.git@2.2.0+zensical-0.1.0 \ No newline at end of file diff --git a/doco-cd-src/wiki/zensical.toml b/doco-cd-src/wiki/zensical.toml index d0550fd..db73e29 100644 --- a/doco-cd-src/wiki/zensical.toml +++ b/doco-cd-src/wiki/zensical.toml @@ -30,6 +30,8 @@ nav = [ { Configuration = [ { "App Settings" = "App-Settings.md" }, { "Deploy Settings" = "Deploy-Settings.md" }, + { "Docker Settings" = "Docker-Settings.md" }, + { "Git Settings" = "Git-Settings.md" }, { "Poll Settings" = "Poll-Settings.md" }, ] }, { Advanced = [ @@ -42,16 +44,19 @@ nav = [ { "Bitwarden Secrets Manager" = "External-Secrets/Bitwarden-Secrets-Manager.md" }, { "Bitwarden Vault / Vaultwarden" = "External-Secrets/Bitwarden-Vault-Vaultwarden.md" }, { "1Password" = "External-Secrets/1Password.md" }, + { "1Password Connect" = "External-Secrets/1Password-Connect.md" }, { Infisical = "External-Secrets/Infisical.md" }, { OpenBao = "External-Secrets/Openbao.md" }, { Webhook = "External-Secrets/Webhook.md" }] }, { Notifications = "Advanced/Notifications.md" }, { "Pre- / Post-Deployment Scripts" = "Advanced/Pre-Post-Deployment-Scripts.md" }, - { "Tips & Tricks" = "Advanced/Tips-and-Tricks.md" } + { "Job Scheduling / Cron Jobs" = "Advanced/Job-Scheduling.md" }, + { "Tips & Tricks" = "Advanced/Tips-and-Tricks.md" }, ] }, { Reference = [ { "Core Concepts" = "Core-Concepts.md" }, { Endpoints = [ + { "Healthcheck" = "Endpoints/Healthcheck.md" }, { "Prometheus Metrics / Monitoring" = "Endpoints/Metrics.md" }, { "REST API" = "Endpoints/REST-API.md" }, { "Webhook Listener" = "Endpoints/Webhook-Listener.md" }, @@ -85,6 +90,7 @@ features = [ "content.code.select", "content.code.copy", "content.code.annotate", + "content.footnote.tooltips", "navigation.footer", "navigation.indexes", "navigation.instant", @@ -183,6 +189,8 @@ custom_checkbox = true [project.markdown_extensions.pymdownx.tilde] [project.markdown_extensions.pymdownx.snippets] +auto_append = ["wiki/includes/abbreviations.md"] +check_paths = true # zensical [project.markdown_extensions.zensical.extensions.preview]