diff --git a/CHANGELOG.md b/CHANGELOG.md index e2c7f5c..0ef9d78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,20 @@ Notable changes to the Evomedia.net Token Savers. ## Unreleased +### Fixed + +- **`zdeploy` on a docker stack built from source shipped nothing after the + first deploy.** The docker kind ran `docker compose pull` and then + `docker compose up -d`, which is right for a stack of published images and + wrong for one built from a `Dockerfile` in the tree: there is nothing to + pull, and `up -d` builds only when the image is *missing*. So the first + deploy worked and every one after it uploaded the new code, started the old + image, and reported success — worse than an error, because the deploy is + green and the container is healthy. A project now opts into building with + `"deploy": { "build": true }`, which runs `docker compose build --pull` so + the base image is refreshed at the same time. Stacks that pull are + unaffected. + ## v1.0.0.0.25 - 2026-09-12 ### Added diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 2a3293d..277dd50 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -10,6 +10,21 @@ Notable changes to the Evomedia.net Token Savers. Unreleased ---------- +Fixed +----- + +- **zdeploy on a docker stack built from source shipped nothing after the + first deploy.** The docker kind ran docker compose pull and then + docker compose up -d, which is right for a stack of published images and + wrong for one built from a Dockerfile in the tree: there is nothing to + pull, and up -d builds only when the image is missing. So the first + deploy worked and every one after it uploaded the new code, started the old + image, and reported success — worse than an error, because the deploy is + green and the container is healthy. A project now opts into building with + "deploy": { "build": true }, which runs docker compose build --pull so + the base image is refreshed at the same time. Stacks that pull are + unaffected. + v1.0.0.0.25 - 2026-09-12 ------------------------ diff --git a/CHECKSUMS.txt b/CHECKSUMS.txt index 6b9215d..6e1eb68 100644 --- a/CHECKSUMS.txt +++ b/CHECKSUMS.txt @@ -8,14 +8,14 @@ dcc50b5f8597a5f0086be56faf3c90e0218343960daa07abaf74e61a7170ffec zbackup_ec2.cm 0cd580b2b09f664003bab6754a0e35a29333dfdb7ca1f7dd4820662bd83f1d2d zchecksums.cmd be392bd3663c1daa4ce659d22b1431c44c1b19fbabd0efaf48502ca8f9f86e56 zchecksums.ps1 72b1c87a13e7121ce8d8ce835cd69a806b5b7a229261d667d9e656219818b208 zdeploy.cmd -687239e3d44e8a865dbe00c5405f1656add02f23703bda86de9bfc9172a96e12 zdeploy.ps1 +925402c761c9d892b3d69ad0d4c3f682704a702b247d1306fa7923886a499022 zdeploy.ps1 fb9435852c344d8a0719041d0ca4968f9769243ddf925e424b78920b42de6e0b zec2.cmd 42990e046d30c392b4434bb38808f10f629ca4c16f4b086affe9ce8dcb3adeb4 zec2.ps1 91448ee9128e8e70fade9dbd8f744cce2d60fad180577ee276fad2a206495cac zec2_rotatekeys.cmd cc6cbae0d50768690691c5dd27a67688aa5e1073e3363d4b4e3cc38b236aac32 zec2_rotatekeys.ps1 c78509f5a705e1db3efcee310706035f7e6983d4bfe1b4ea22627451a0151a83 zec2online.cmd d7f67c2aa1fb91fc12e04ebf54506e8b4a00264178b5b128a9eead05df0f8916 zec2online.ps1 -1af45fd810b171593e8e0cc79a1ef8a1eaf546c327ac095e9838aae30b0015ed ZHelpers.ps1 +76dbaabc9779c6d8898b64b31ef416df1ab4ff8659dd195dc10aacbad7b766c8 ZHelpers.ps1 386c4ce64544584cb9fcd2f28099742cc3590fb1afe75bb7ea884a8bca7b6ed3 zkill.cmd 5f6c0e00dbcc62981252b6b43e3159fc84840f947ed15ca0fdf77b8fa7cece44 zkill.ps1 21a1d371947c10bfe2b86f1a51c6cfc3992b4e084f61097bad851736032d4bdb ZKiller.ps1 diff --git a/ZHelpers.ps1 b/ZHelpers.ps1 index 6bef8bf..adccf1d 100644 --- a/ZHelpers.ps1 +++ b/ZHelpers.ps1 @@ -105,6 +105,33 @@ function Get-ZEdgeProject { } # Remote compose directory for a project: remote.composeDir if set, else remote.path. +# How a docker stack gets its images: built here, or pulled from a registry. +# +# `docker compose pull` is right for a stack of published images and wrong for +# one built from a Dockerfile in the tree - there is nothing to pull. The trap +# is what happens next: `docker compose up -d` builds only when the image is +# MISSING. So the FIRST deploy of a build-from-source stack works, and every +# one after it uploads the new code, starts the old image, and reports success. +# That is worse than an error, because nothing looks wrong: the deploy is +# green, the container is up, and the change simply is not in it. +# +# deploy.build opts a project into building instead. --pull refreshes the base +# image at the same time, so a rebuild also picks up its security updates +# rather than pinning whatever happened to be on the box the first time. +function Get-DockerImageStep { + param($Proj, [Parameter(Mandatory)][string]$RemotePath) + if ($Proj -and $Proj.deploy -and $Proj.deploy.build) { + return @{ + Label = 'docker compose build' + Command = "cd $RemotePath && sudo docker compose build --pull" + } + } + return @{ + Label = 'docker compose pull' + Command = "cd $RemotePath && sudo docker compose pull" + } +} + function Get-RemoteComposeDir { param([Parameter(Mandatory)][string]$Key) $proj = Get-ZProject -Key $Key diff --git a/tests/DockerImageStep.Tests.ps1 b/tests/DockerImageStep.Tests.ps1 new file mode 100644 index 0000000..0374648 --- /dev/null +++ b/tests/DockerImageStep.Tests.ps1 @@ -0,0 +1,82 @@ +# How a docker stack gets its images, and the deploy that shipped nothing. +# +# Invoke-Pester .\tests +# +# `docker compose pull` is right for a stack of published images - Prometheus, +# Grafana, docker-mailserver - and wrong for one built from a Dockerfile in the +# tree, where there is nothing to pull. +# +# The trap is what happens after. `docker compose up -d` builds only when the +# image is MISSING, so the first deploy of a build-from-source stack works and +# every one after it uploads the new code, starts the OLD image, and reports +# success. Green deploy, healthy container, and the change is not in it. That +# is the failure this helper exists to prevent, and it is worse than an error +# because nothing about it looks wrong. +# +# ZHelpers.ps1 is dot-sourced rather than zdeploy.ps1: zdeploy executes its +# main flow on load, helpers only define functions. + +BeforeAll { + . (Join-Path (Split-Path -Parent $PSScriptRoot) "ZHelpers.ps1") + + function New-Proj { param($Build) + if ($null -eq $Build) { return [pscustomobject]@{ deploy = [pscustomobject]@{ gitPull = $true } } } + return [pscustomobject]@{ deploy = [pscustomobject]@{ build = $Build } } + } +} + +Describe 'Get-DockerImageStep - build here, or pull from a registry' { + + It 'pulls by default, so every existing docker stack is unaffected' { + $step = Get-DockerImageStep -Proj (New-Proj $null) -RemotePath '/home/u/stack/x' + $step.Command | Should -BeLike '*docker compose pull*' + $step.Command | Should -Not -BeLike '*build*' + } + + It 'pulls for a project with no deploy block at all' { + $step = Get-DockerImageStep -Proj ([pscustomobject]@{}) -RemotePath '/home/u/stack/x' + $step.Command | Should -BeLike '*docker compose pull*' + } + + It 'builds when the project asks to be built' { + $step = Get-DockerImageStep -Proj (New-Proj $true) -RemotePath '/home/u/stack/x' + $step.Command | Should -BeLike '*docker compose build*' + $step.Command | Should -Not -BeLike '*compose pull*' + } + + It 'still pulls when build is explicitly false' { + $step = Get-DockerImageStep -Proj (New-Proj $false) -RemotePath '/home/u/stack/x' + $step.Command | Should -BeLike '*docker compose pull*' + } + + It 'refreshes the base image on a build, so a rebuild is not pinned to the first one' { + $step = Get-DockerImageStep -Proj (New-Proj $true) -RemotePath '/home/u/stack/x' + $step.Command | Should -BeLike '*--pull*' + } + + It 'runs in the project directory: ' -ForEach @( + @{ Build = $true } + @{ Build = $false } + ) { + $step = Get-DockerImageStep -Proj (New-Proj $Build) -RemotePath '/home/u/stack/ablecamera' + $step.Command | Should -BeLike 'cd /home/u/stack/ablecamera &&*' + } + + It 'labels the step with what it actually does: ' -ForEach @( + @{ Build = $true; Expected = 'docker compose build' } + @{ Build = $false; Expected = 'docker compose pull' } + ) { + (Get-DockerImageStep -Proj (New-Proj $Build) -RemotePath '/x').Label | Should -Be $Expected + } +} + +Describe 'the docker deploy uses it' { + + It 'no longer hardcodes compose pull' { + $text = Get-Content -Raw (Join-Path (Split-Path -Parent $PSScriptRoot) "zdeploy.ps1") + $body = $text.Substring($text.IndexOf('function Invoke-DockerDeploy')) + $body = $body.Substring(0, $body.IndexOf('function Invoke-ZTokensPublish')) + $body | Should -Match 'Get-DockerImageStep' + $body | Should -Not -Match '"docker compose pull"' + } +} diff --git a/zconfig.example.json b/zconfig.example.json index a655e7f..10c5e40 100644 --- a/zconfig.example.json +++ b/zconfig.example.json @@ -122,6 +122,7 @@ "analytics": { "label": "Analytics (any docker compose app)", "kind": "docker", + "_build_note": "Images pulled from a registry need nothing here. If the image is BUILT from a Dockerfile in this tree, add \"deploy\": { \"build\": true } \u2014 without it a redeploy uploads the new code and restarts the OLD image, and reports success.", "localRoot": "C:\\YourRoot\\analytics", "domain": "analytics.yourdomain.com", "remote": { diff --git a/zdeploy.ps1 b/zdeploy.ps1 index 1a16187..cc6dec2 100644 --- a/zdeploy.ps1 +++ b/zdeploy.ps1 @@ -1254,7 +1254,10 @@ function Invoke-DockerDeploy { if ($LASTEXITCODE -ne 0) { throw "SCP failed for $($d.Name) (exit $LASTEXITCODE)" } } - Invoke-Ec2Step "docker compose pull" "cd $remotePath && sudo docker compose pull" + # Built here or pulled from a registry - see Get-DockerImageStep for why + # a build-from-source stack cannot use `pull` and silently ships nothing. + $imageStep = Get-DockerImageStep -Proj $Proj -RemotePath $remotePath + Invoke-Ec2Step $imageStep.Label $imageStep.Command Invoke-Ec2Step "docker compose up -d" "cd $remotePath && sudo docker compose up -d" Invoke-Ec2PostDeployCleanup -Label $Key