Pin node images to the checked-out source tree — fix v1 code / :latest image skew (#19) #31
@@ -38,6 +38,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT
|
echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT
|
||||||
echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT
|
echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT
|
||||||
|
# Source pin (node-26#19): the git tree hash of the build context.
|
||||||
|
# A node pulls src-<tree of its own checkout> — scripts/image-tags.sh
|
||||||
|
# computes the identical value — so its code and image always match.
|
||||||
|
echo "SRC_TAG=src-$(git rev-parse HEAD:drb-edge-node)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
- uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
@@ -47,6 +51,7 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }}
|
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }}
|
||||||
|
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.SRC_TAG }}
|
||||||
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:latest
|
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:latest
|
||||||
cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache
|
cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache
|
||||||
cache-to: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache,mode=max
|
cache-to: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache,mode=max
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT
|
echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT
|
||||||
echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT
|
echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT
|
||||||
|
# Source pin (node-26#19): the git tree hash of the build context.
|
||||||
|
# A node pulls src-<tree of its own checkout> — scripts/image-tags.sh
|
||||||
|
# computes the identical value — so its code and image always match.
|
||||||
|
echo "SRC_TAG=src-$(git rev-parse HEAD:icecast)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
- uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
@@ -47,6 +51,7 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }}
|
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }}
|
||||||
|
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.SRC_TAG }}
|
||||||
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:latest
|
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:latest
|
||||||
cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache
|
cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache
|
||||||
cache-to: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache,mode=max
|
cache-to: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache,mode=max
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT
|
echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT
|
||||||
echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT
|
echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT
|
||||||
|
# Source pin (node-26#19): the git tree hash of the build context.
|
||||||
|
# A node pulls src-<tree of its own checkout> — scripts/image-tags.sh
|
||||||
|
# computes the identical value — so its code and image always match.
|
||||||
|
echo "SRC_TAG=src-$(git rev-parse HEAD:op25-container)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
- uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
@@ -47,6 +51,7 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }}
|
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }}
|
||||||
|
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.SRC_TAG }}
|
||||||
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:stable
|
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:stable
|
||||||
# Registry cache — avoids full recompile of GnuRadio/OP25 when only app code changes
|
# Registry cache — avoids full recompile of GnuRadio/OP25 when only app code changes
|
||||||
cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache
|
cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT
|
echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')" >> $GITHUB_OUTPUT
|
||||||
echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT
|
echo "VERSION=$(git describe --tags --always | sed 's/^v//')" >> $GITHUB_OUTPUT
|
||||||
|
# Source pin (node-26#19): the git tree hash of the build context.
|
||||||
|
# A node pulls src-<tree of its own checkout> — scripts/image-tags.sh
|
||||||
|
# computes the identical value — so its code and image always match.
|
||||||
|
echo "SRC_TAG=src-$(git rev-parse HEAD:secondary-sdr-container)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
- uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
@@ -47,6 +51,7 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }}
|
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.VERSION }}
|
||||||
|
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.SRC_TAG }}
|
||||||
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:latest
|
git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:latest
|
||||||
cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache
|
cache-from: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache
|
||||||
cache-to: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache,mode=max
|
cache-to: type=registry,ref=git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}/${{ env.CONTAINER_NAME }}:buildcache,mode=max
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
name: Release images
|
||||||
|
|
||||||
|
# Make sure every node image a release ref needs exists in the registry
|
||||||
|
# (node-26#19). A node on `v1` pulls <image>:src-<tree hash of that image's
|
||||||
|
# directory at v1>. The per-image build workflows only run when their own
|
||||||
|
# directory changes on main, and anything built before #19 has no src- tag at
|
||||||
|
# all, so a release tag can point at trees nobody ever built. This fills the
|
||||||
|
# gaps: for each image, if src-<tree> is already in the registry it does
|
||||||
|
# nothing; otherwise it builds and pushes it (registry cache keeps op25 off a
|
||||||
|
# full recompile when only its app code changed).
|
||||||
|
#
|
||||||
|
# Runs on every v* tag push, including a re-cut `v1`. Also runnable by hand
|
||||||
|
# (workflow_dispatch) on any ref — run it once on the ref `v1` points at
|
||||||
|
# before re-cutting `v1` onto a commit that contains #19, so nodes never see
|
||||||
|
# a moment where their pinned images don't exist yet.
|
||||||
|
#
|
||||||
|
# Pushes ONLY src- tags. :latest / :stable keep tracking main, as before.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ensure:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- name: edge-node
|
||||||
|
context: drb-edge-node
|
||||||
|
- name: icecast
|
||||||
|
context: icecast
|
||||||
|
- name: op25-client
|
||||||
|
context: op25-container
|
||||||
|
- name: secondary-sdr
|
||||||
|
context: secondary-sdr-container
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
config-inline: |
|
||||||
|
[registry."git.vpn.cusano.net"]
|
||||||
|
http = false
|
||||||
|
insecure = false
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.vpn.cusano.net
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.BUILD_TOKEN }}
|
||||||
|
|
||||||
|
- name: Resolve source tag
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
REPO_NAME="$(echo ${GITHUB_REPOSITORY} | awk -F'/' '{print $2}')"
|
||||||
|
# Must match scripts/image-tags.sh and the build-*.yml workflows.
|
||||||
|
IMAGE="git.vpn.cusano.net/${{ vars.DOCKER_ORG }}/${REPO_NAME}/${{ matrix.name }}"
|
||||||
|
TAG="src-$(git rev-parse HEAD:${{ matrix.context }})"
|
||||||
|
echo "IMAGE=$IMAGE" >> $GITHUB_OUTPUT
|
||||||
|
echo "TAG=$TAG" >> $GITHUB_OUTPUT
|
||||||
|
if docker buildx imagetools inspect "$IMAGE:$TAG" >/dev/null 2>&1; then
|
||||||
|
echo "EXISTS=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "$IMAGE:$TAG already published — nothing to do"
|
||||||
|
else
|
||||||
|
echo "EXISTS=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "$IMAGE:$TAG missing — building it"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- if: steps.meta.outputs.EXISTS != 'true'
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./${{ matrix.context }}
|
||||||
|
file: ./${{ matrix.context }}/Dockerfile
|
||||||
|
# arm64 only, same as the build-*.yml workflows (node-26#28).
|
||||||
|
platforms: linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.IMAGE }}:${{ steps.meta.outputs.TAG }}
|
||||||
|
cache-from: type=registry,ref=${{ steps.meta.outputs.IMAGE }}:buildcache
|
||||||
@@ -13,6 +13,7 @@ venv/
|
|||||||
# Runtime-generated configs and recordings
|
# Runtime-generated configs and recordings
|
||||||
configs/
|
configs/
|
||||||
recordings/
|
recordings/
|
||||||
|
.drb-update-compose.yml
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
+8
-4
@@ -1,6 +1,10 @@
|
|||||||
|
# Image tags: a provisioned node pins every image to the source tree it has
|
||||||
|
# checked out (DRB_TAG_* = src-<git tree hash>, written into .env by install.sh
|
||||||
|
# and scripts/drb-update.sh via scripts/image-tags.sh — node-26#19). Unset, as
|
||||||
|
# in local dev, they fall back to :latest / :stable, which CI builds from main.
|
||||||
services:
|
services:
|
||||||
icecast:
|
icecast:
|
||||||
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/icecast:latest
|
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/icecast:${DRB_TAG_ICECAST:-latest}
|
||||||
build: ./icecast
|
build: ./icecast
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
network_mode: host
|
||||||
@@ -16,7 +20,7 @@ services:
|
|||||||
# rather than being exposed. edge-node still reaches both over localhost
|
# rather than being exposed. edge-node still reaches both over localhost
|
||||||
# because it shares this host network namespace.
|
# because it shares this host network namespace.
|
||||||
op25:
|
op25:
|
||||||
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/op25-client:stable
|
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/op25-client:${DRB_TAG_OP25:-stable}
|
||||||
build: ./op25-container
|
build: ./op25-container
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
privileged: true
|
privileged: true
|
||||||
@@ -38,7 +42,7 @@ services:
|
|||||||
# node-26#9. Same network/device access as op25 for the same reason: it
|
# node-26#9. Same network/device access as op25 for the same reason: it
|
||||||
# needs the raw USB device, not a virtualized one.
|
# needs the raw USB device, not a virtualized one.
|
||||||
secondary-sdr:
|
secondary-sdr:
|
||||||
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/secondary-sdr:latest
|
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/secondary-sdr:${DRB_TAG_SECONDARY_SDR:-latest}
|
||||||
build: ./secondary-sdr-container
|
build: ./secondary-sdr-container
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
privileged: true
|
privileged: true
|
||||||
@@ -48,7 +52,7 @@ services:
|
|||||||
- /dev:/dev
|
- /dev:/dev
|
||||||
|
|
||||||
edge-node:
|
edge-node:
|
||||||
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/edge-node:latest
|
image: ${IMAGE_REGISTRY:-git.vpn.cusano.net}/${DOCKER_ORG:-logan}/${DOCKER_REPO:-node-26}/edge-node:${DRB_TAG_EDGE_NODE:-latest}
|
||||||
build: ./drb-edge-node
|
build: ./drb-edge-node
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
|||||||
@@ -24,21 +24,37 @@ pytestmark = pytest.mark.skipif(
|
|||||||
)
|
)
|
||||||
|
|
||||||
REPO = "https://git.example.net/logan/node-26.git"
|
REPO = "https://git.example.net/logan/node-26.git"
|
||||||
OLD, NEW = "a" * 40, "b" * 40
|
OLD, NEW, TREE = "a" * 40, "b" * 40, "c" * 40
|
||||||
|
REPO_ROOT = SCRIPT.parents[1]
|
||||||
|
|
||||||
STUB_GIT = r'''#!/usr/bin/env bash
|
STUB_GIT = r'''#!/usr/bin/env bash
|
||||||
echo "git $*" >> "$STUB/calls"
|
echo "git $*" >> "$STUB/calls"
|
||||||
while [ "$1" = -c ]; do shift 2; done
|
while [ "$1" = -c ] || [ "$1" = -C ]; do shift 2; done
|
||||||
case "$1" in
|
case "$1" in
|
||||||
rev-parse) if [ "$2" = HEAD ]; then cat "$STUB/head"; else echo "$STUB_NEW"; fi ;;
|
rev-parse)
|
||||||
|
if [ "$2" = HEAD ]; then cat "$STUB/head"
|
||||||
|
elif [ "$2" = --verify ]; then echo "$STUB_TREE" # tree hash of <commit>:<dir>
|
||||||
|
else echo "$STUB_NEW"; fi ;;
|
||||||
remote) echo "$STUB_ORIGIN" ;;
|
remote) echo "$STUB_ORIGIN" ;;
|
||||||
fetch) exit 0 ;;
|
fetch) exit 0 ;;
|
||||||
show-ref) [ "$4" = "refs/tags/v1" ] ;;
|
show-ref) [ "$4" = "refs/tags/v1" ] ;;
|
||||||
|
cat-file) [ -z "${STUB_NO_PIN:-}" ] ;; # target has scripts/image-tags.sh
|
||||||
|
show) case "$2" in
|
||||||
|
*:scripts/image-tags.sh) cat "$STUB_REPO/scripts/image-tags.sh" ;;
|
||||||
|
*:docker-compose.yml) cat "$STUB_REPO/docker-compose.yml" ;;
|
||||||
|
esac ;;
|
||||||
checkout) echo "${@: -1}" > "$STUB/head" ;;
|
checkout) echo "${@: -1}" > "$STUB/head" ;;
|
||||||
*) exit 0 ;;
|
*) exit 0 ;;
|
||||||
esac
|
esac
|
||||||
'''
|
'''
|
||||||
STUB_DOCKER = '#!/usr/bin/env bash\necho "docker $*" >> "$STUB/calls"\n'
|
STUB_DOCKER = r'''#!/usr/bin/env bash
|
||||||
|
echo "docker $*" >> "$STUB/calls"
|
||||||
|
case "$*" in
|
||||||
|
*"config --images"*) echo "reg.example/edge-node:$STUB_PULLED_TAG" ;;
|
||||||
|
"image inspect"*) [ -z "${STUB_MISSING_IMAGE:-}" ] || exit 1 ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
'''
|
||||||
STUB_CURL = r'''#!/usr/bin/env bash
|
STUB_CURL = r'''#!/usr/bin/env bash
|
||||||
echo "curl $*" >> "$STUB/calls"
|
echo "curl $*" >> "$STUB/calls"
|
||||||
for a in "$@"; do case "$a" in @*) cat "${a#@}" >> "$STUB/headers" ;; esac; done
|
for a in "$@"; do case "$a" in @*) cat "${a#@}" >> "$STUB/headers" ;; esac; done
|
||||||
@@ -79,6 +95,7 @@ def env(tmp_path):
|
|||||||
e.update({
|
e.update({
|
||||||
"PATH": f"{bindir}:{os.environ['PATH']}",
|
"PATH": f"{bindir}:{os.environ['PATH']}",
|
||||||
"STUB": str(stub), "STUB_NEW": NEW, "STUB_ORIGIN": REPO,
|
"STUB": str(stub), "STUB_NEW": NEW, "STUB_ORIGIN": REPO,
|
||||||
|
"STUB_TREE": TREE, "STUB_REPO": str(REPO_ROOT), "STUB_PULLED_TAG": f"src-{TREE}",
|
||||||
"DRB_UPDATER_CONF": str(conf),
|
"DRB_UPDATER_CONF": str(conf),
|
||||||
"DRB_UPDATER_STATE": str(tmp_path / "state"),
|
"DRB_UPDATER_STATE": str(tmp_path / "state"),
|
||||||
"DRB_UPDATER_LOCK": str(tmp_path / "lock"),
|
"DRB_UPDATER_LOCK": str(tmp_path / "lock"),
|
||||||
@@ -94,6 +111,10 @@ def _calls(env):
|
|||||||
return (env["stub"] / "calls").read_text()
|
return (env["stub"] / "calls").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def _pulled(calls: str) -> bool:
|
||||||
|
return any(line.startswith("docker compose ") and line.endswith(" pull") for line in calls.splitlines())
|
||||||
|
|
||||||
|
|
||||||
def test_no_request_means_checkin_only(env):
|
def test_no_request_means_checkin_only(env):
|
||||||
r = _run(env)
|
r = _run(env)
|
||||||
assert r.returncode == 0, r.stderr
|
assert r.returncode == 0, r.stderr
|
||||||
@@ -108,7 +129,7 @@ def test_c2_request_updates_once_and_reports(env):
|
|||||||
r = _run(env)
|
r = _run(env)
|
||||||
assert r.returncode == 0, r.stderr + r.stdout
|
assert r.returncode == 0, r.stderr + r.stdout
|
||||||
calls = _calls(env)
|
calls = _calls(env)
|
||||||
assert "docker compose pull" in calls
|
assert _pulled(calls)
|
||||||
assert "docker compose up -d --no-build --force-recreate --remove-orphans" in calls
|
assert "docker compose up -d --no-build --force-recreate --remove-orphans" in calls
|
||||||
assert (env["stub"] / "head").read_text().strip() == NEW
|
assert (env["stub"] / "head").read_text().strip() == NEW
|
||||||
assert '"applied_request_at":"2026-10-04T12:00:00+00:00"' in calls
|
assert '"applied_request_at":"2026-10-04T12:00:00+00:00"' in calls
|
||||||
@@ -126,7 +147,7 @@ def test_mqtt_sentinel_triggers_update_and_is_consumed(env):
|
|||||||
assert r.returncode == 0, r.stderr
|
assert r.returncode == 0, r.stderr
|
||||||
assert not sentinel.exists()
|
assert not sentinel.exists()
|
||||||
calls = _calls(env)
|
calls = _calls(env)
|
||||||
assert "docker compose pull" in calls
|
assert _pulled(calls)
|
||||||
assert "evil" not in calls and "rm -rf" not in calls
|
assert "evil" not in calls and "rm -rf" not in calls
|
||||||
|
|
||||||
|
|
||||||
@@ -153,7 +174,7 @@ def test_refuses_when_env_registry_differs(env):
|
|||||||
(env["inst"] / "configs" / "update-requested").write_text("")
|
(env["inst"] / "configs" / "update-requested").write_text("")
|
||||||
r = _run(env)
|
r = _run(env)
|
||||||
assert r.returncode != 0
|
assert r.returncode != 0
|
||||||
assert "docker compose pull" not in _calls(env)
|
assert not _pulled(_calls(env))
|
||||||
|
|
||||||
|
|
||||||
def test_non_https_repo_in_conf_is_rejected(env):
|
def test_non_https_repo_in_conf_is_rejected(env):
|
||||||
@@ -177,3 +198,42 @@ def test_plain_http_c2_skips_checkin(env):
|
|||||||
r = _run(env)
|
r = _run(env)
|
||||||
assert r.returncode == 0
|
assert r.returncode == 0
|
||||||
assert "curl" not in _calls(env)
|
assert "curl" not in _calls(env)
|
||||||
|
|
||||||
|
|
||||||
|
# ── node-26#19: images pinned to the target's source tree ─────────────────
|
||||||
|
|
||||||
|
def test_pins_images_and_pulls_before_checkout(env):
|
||||||
|
(env["inst"] / "configs" / "update-requested").write_text("")
|
||||||
|
r = _run(env)
|
||||||
|
assert r.returncode == 0, r.stderr + r.stdout
|
||||||
|
lines = _calls(env).splitlines()
|
||||||
|
pull = next(i for i, ln in enumerate(lines) if ln.startswith("docker compose ") and ln.endswith(" pull"))
|
||||||
|
checkout = next(i for i, ln in enumerate(lines) if ln.startswith("git") and " checkout " in ln)
|
||||||
|
assert pull < checkout, "images must be pulled before the new code is checked out"
|
||||||
|
dotenv = (env["inst"] / ".env").read_text()
|
||||||
|
for var in ("DRB_TAG_EDGE_NODE", "DRB_TAG_OP25", "DRB_TAG_ICECAST", "DRB_TAG_SECONDARY_SDR"):
|
||||||
|
assert f"{var}=src-{TREE}\n" in dotenv
|
||||||
|
assert dotenv.startswith("NODE_ID=node-7\n") # existing lines kept
|
||||||
|
assert not (env["inst"] / ".drb-update-compose.yml").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_pinned_image_changes_nothing(env):
|
||||||
|
env["env"]["STUB_MISSING_IMAGE"] = "1"
|
||||||
|
(env["inst"] / "configs" / "update-requested").write_text("")
|
||||||
|
r = _run(env)
|
||||||
|
assert r.returncode != 0
|
||||||
|
assert (env["stub"] / "head").read_text().strip() == OLD
|
||||||
|
calls = _calls(env)
|
||||||
|
assert " checkout " not in calls
|
||||||
|
assert "up -d" not in calls
|
||||||
|
assert "DRB_TAG_" not in (env["inst"] / ".env").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_target_without_pinning_still_updates(env):
|
||||||
|
env["env"]["STUB_NO_PIN"] = "1"
|
||||||
|
(env["inst"] / "configs" / "update-requested").write_text("")
|
||||||
|
r = _run(env)
|
||||||
|
assert r.returncode == 0, r.stderr + r.stdout
|
||||||
|
assert (env["stub"] / "head").read_text().strip() == NEW
|
||||||
|
assert _pulled(_calls(env))
|
||||||
|
assert "DRB_TAG_" not in (env["inst"] / ".env").read_text()
|
||||||
|
|||||||
+30
-8
@@ -10,7 +10,7 @@
|
|||||||
# --track-main for the owner's own always-latest nodes)
|
# --track-main for the owner's own always-latest nodes)
|
||||||
#
|
#
|
||||||
# Hosting, the pinned ref, and setup.sh's retirement are settled (owner
|
# Hosting, the pinned ref, and setup.sh's retirement are settled (owner
|
||||||
# decisions, 2026-09-06). One standing hazard remains — D3 below.
|
# decisions, 2026-09-06). D3 below is mitigated by image pinning (node-26#19).
|
||||||
#
|
#
|
||||||
# What it does, in order:
|
# What it does, in order:
|
||||||
# 1. Preflight (root, arch, apt, SDR present)
|
# 1. Preflight (root, arch, apt, SDR present)
|
||||||
@@ -48,15 +48,18 @@
|
|||||||
# the mint panel / node-26#4). DEFAULT_REF below is `v1`. `--track-main`
|
# the mint panel / node-26#4). DEFAULT_REF below is `v1`. `--track-main`
|
||||||
# stays as an opt-in for the owner's own nodes.
|
# stays as an opt-in for the owner's own nodes.
|
||||||
#
|
#
|
||||||
# STANDING HAZARD
|
# MITIGATED HAZARD
|
||||||
#
|
#
|
||||||
# D3 SOURCE-OVERLAY. docker-compose.yml bind-mounts ./drb-edge-node/app and
|
# D3 SOURCE-OVERLAY. docker-compose.yml bind-mounts ./drb-edge-node/app and
|
||||||
# ./op25-container/app OVER the image's /app. So even in prebuilt mode
|
# ./op25-container/app OVER the image's /app, so the running Python is
|
||||||
# the running Python is the CLONED REF's code against the pulled image's
|
# the CLONED REF's code against the pulled image's dependencies. Those
|
||||||
# dependencies. `v1` == the commit CI built the current :latest/:stable
|
# used to be :latest/:stable, built from main — silently a different
|
||||||
# from, so today they match — but the moment `v1` and the image tags
|
# commit from `v1`. Since node-26#19 every image is pinned to
|
||||||
# diverge this silently mixes them. Fix: drop those two mounts from the
|
# src-<git tree hash of its own directory at the checked-out ref>
|
||||||
# prod compose path, or always retag images from the same ref as `v1`.
|
# (scripts/image-tags.sh, written into .env as DRB_TAG_*), which CI tags
|
||||||
|
# identically. The mounts now overlay the same code the image was built
|
||||||
|
# from. A pinned image that was never built fails the pull loudly; the
|
||||||
|
# "Release images" workflow builds any a release tag is missing.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -334,6 +337,20 @@ EOF
|
|||||||
ok ".env written for '$NODE_ID'"
|
ok ".env written for '$NODE_ID'"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Pin every image to the source just checked out (node-26#19, D3). Written
|
||||||
|
# into .env — fresh or preserved — so later manual `docker compose` runs on
|
||||||
|
# this node use the same pins.
|
||||||
|
# A --ref older than #19 has no helper; its compose file hardcodes its tags.
|
||||||
|
if [ -f "$INSTALL_DIR/scripts/image-tags.sh" ]; then
|
||||||
|
# shellcheck source=scripts/image-tags.sh
|
||||||
|
. "$INSTALL_DIR/scripts/image-tags.sh"
|
||||||
|
drb_pin_image_tags "$INSTALL_DIR/.env" "$INSTALL_DIR" "$RESOLVED_SHA" || die "could not write image pins to .env"
|
||||||
|
ok "images pinned to the source at ${RESOLVED_SHA:0:12} (DRB_TAG_* in .env)"
|
||||||
|
else
|
||||||
|
warn "$REF predates image pinning (node-26#19) — using its compose file's default image tags"
|
||||||
|
drb_images_present() { return 0; }
|
||||||
|
fi
|
||||||
|
|
||||||
# ── 5. Enrollment ───────────────────────────────────────────────────────────
|
# ── 5. Enrollment ───────────────────────────────────────────────────────────
|
||||||
# Client half of Server/drb-c2-core/app/routers/enrollment.py. It does NOT
|
# Client half of Server/drb-c2-core/app/routers/enrollment.py. It does NOT
|
||||||
# exist in the edge-node app today (mqtt_manager.py:73-85 says so explicitly),
|
# exist in the edge-node app today (mqtt_manager.py:73-85 says so explicitly),
|
||||||
@@ -570,6 +587,11 @@ if [ "$DO_START" = 1 ]; then
|
|||||||
requirement or a transient network error: re-run with DRB_REGISTRY_USER /
|
requirement or a transient network error: re-run with DRB_REGISTRY_USER /
|
||||||
DRB_REGISTRY_PASS set, or with --build to compile on the Pi (~1h for op25)."
|
DRB_REGISTRY_PASS set, or with --build to compile on the Pi (~1h for op25)."
|
||||||
fi
|
fi
|
||||||
|
# compose pull only WARNS for a missing image that also has a build:
|
||||||
|
# section, so check the pinned images really arrived.
|
||||||
|
drb_images_present || die "the images pinned to $REF (${RESOLVED_SHA:0:12}) are not in
|
||||||
|
$REGISTRY yet. CI may still be building them — wait and re-run; the owner can
|
||||||
|
run the 'Release images' workflow on that ref; or re-run with --build."
|
||||||
docker compose up --no-build -d
|
docker compose up --no-build -d
|
||||||
fi
|
fi
|
||||||
# ALWAYS recreate edge-node (node-26#14): `up -d` leaves an already-running
|
# ALWAYS recreate edge-node (node-26#14): `up -d` leaves an already-running
|
||||||
|
|||||||
+60
-8
@@ -17,7 +17,9 @@
|
|||||||
# * Node credentials go to curl via a 0600 header file, never argv.
|
# * Node credentials go to curl via a 0600 header file, never argv.
|
||||||
# * C2 must be https.
|
# * C2 must be https.
|
||||||
# What this does NOT defend against: a compromised git host or registry —
|
# What this does NOT defend against: a compromised git host or registry —
|
||||||
# refs and images are not signed (see the issue filed with node-26#15).
|
# refs and images are not signed (node-26#17).
|
||||||
|
# * Images are pinned to the checked-out source (node-26#19): each one is
|
||||||
|
# pulled as src-<git tree hash of its build directory>, never :latest.
|
||||||
#
|
#
|
||||||
# The whole body is inside main(), called on the last line: bash parses the
|
# The whole body is inside main(), called on the last line: bash parses the
|
||||||
# function completely before running it, so `git checkout` replacing this
|
# function completely before running it, so `git checkout` replacing this
|
||||||
@@ -146,6 +148,47 @@ do_update() {
|
|||||||
fi
|
fi
|
||||||
local old new
|
local old new
|
||||||
old="$(git rev-parse HEAD)"; new="$(git rev-parse "$target^{commit}")"
|
old="$(git rev-parse HEAD)"; new="$(git rev-parse "$target^{commit}")"
|
||||||
|
|
||||||
|
local env_registry
|
||||||
|
env_registry="$(grep -E '^IMAGE_REGISTRY=' .env | head -1 | cut -d= -f2- | tr -d '"' || true)"
|
||||||
|
[ "${env_registry:-git.vpn.cusano.net}" = "$REGISTRY" ] \
|
||||||
|
|| { err ".env IMAGE_REGISTRY '$env_registry' != configured '$REGISTRY' — refusing"; return 1; }
|
||||||
|
|
||||||
|
# Image pins for the TARGET commit (node-26#19): each image is tagged with
|
||||||
|
# the git tree hash of its own source directory, so the images always match
|
||||||
|
# the code being checked out. The helper is read from the target commit so
|
||||||
|
# its service list matches the target's compose file.
|
||||||
|
# Read into a variable, not `source <(...)`: a failed `git show` must leave
|
||||||
|
# pin=0 rather than "succeed" with no functions defined.
|
||||||
|
local pin=0 helper
|
||||||
|
if git cat-file -e "$new:scripts/image-tags.sh" 2>/dev/null; then
|
||||||
|
helper="$(git show "$new:scripts/image-tags.sh")" && [ -n "$helper" ] && eval "$helper" \
|
||||||
|
&& declare -F drb_image_tags drb_images_present drb_pin_image_tags >/dev/null && pin=1
|
||||||
|
fi
|
||||||
|
[ "$pin" = 1 ] || log "target ${new:0:12} predates image pinning — using its compose file's default tags"
|
||||||
|
|
||||||
|
if [ "$BUILD" != 1 ]; then
|
||||||
|
# Pull BEFORE checkout. The app code is bind-mounted from the checkout
|
||||||
|
# (install.sh D3) and edge-node runs uvicorn --reload, so checking out first
|
||||||
|
# would run the new code on the old images for as long as the pull takes —
|
||||||
|
# or for good, if the pinned images don't exist. Pulling against the
|
||||||
|
# target's own compose file means a failed pull changes nothing. It is
|
||||||
|
# written next to the real one so its relative paths (.env, env_file,
|
||||||
|
# build contexts) resolve exactly the same way.
|
||||||
|
local tfile="$INSTALL_DIR/.drb-update-compose.yml" pulled=0
|
||||||
|
git show "$new:docker-compose.yml" > "$tfile" || { rm -f "$tfile"; return 1; }
|
||||||
|
local -a tc=(-f "$tfile" --project-directory "$INSTALL_DIR")
|
||||||
|
(
|
||||||
|
if [ "$pin" = 1 ]; then
|
||||||
|
while IFS= read -r l; do export "$l"; done < <(drb_image_tags "$INSTALL_DIR" "$new")
|
||||||
|
fi
|
||||||
|
docker compose "${tc[@]}" pull || exit 1
|
||||||
|
if [ "$pin" = 1 ]; then drb_images_present "${tc[@]}" || exit 1; fi
|
||||||
|
) && pulled=1
|
||||||
|
rm -f "$tfile"
|
||||||
|
[ "$pulled" = 1 ] || { err "images for ${new:0:12} are not available — nothing changed (CI may still be building them; the 'Release images' workflow publishes any that are missing)"; return 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$old" != "$new" ]; then
|
if [ "$old" != "$new" ]; then
|
||||||
git -c advice.detachedHead=false checkout --force --detach "$new" || return 1
|
git -c advice.detachedHead=false checkout --force --detach "$new" || return 1
|
||||||
log "checked out $REF: ${old:0:12} -> ${new:0:12}"
|
log "checked out $REF: ${old:0:12} -> ${new:0:12}"
|
||||||
@@ -153,19 +196,28 @@ do_update() {
|
|||||||
bash scripts/install-updater.sh --refresh || log "unit refresh failed (continuing with the old units)"
|
bash scripts/install-updater.sh --refresh || log "unit refresh failed (continuing with the old units)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local env_registry
|
if [ "$pin" = 1 ]; then
|
||||||
env_registry="$(grep -E '^IMAGE_REGISTRY=' .env | head -1 | cut -d= -f2- | tr -d '"' || true)"
|
drb_pin_image_tags "$INSTALL_DIR/.env" "$INSTALL_DIR" "$new" || { err "could not write image pins to .env"; return 1; }
|
||||||
[ "${env_registry:-git.vpn.cusano.net}" = "$REGISTRY" ] \
|
fi
|
||||||
|| { err ".env IMAGE_REGISTRY '$env_registry' != configured '$REGISTRY' — refusing"; return 1; }
|
|
||||||
|
|
||||||
if [ "$BUILD" = 1 ]; then
|
if [ "$BUILD" = 1 ]; then
|
||||||
docker compose build || return 1
|
docker compose build || return 1 # tags the local builds with the same pins
|
||||||
else
|
|
||||||
docker compose pull || return 1
|
|
||||||
fi
|
fi
|
||||||
# --force-recreate: app code is bind-mounted from the checkout (install.sh
|
# --force-recreate: app code is bind-mounted from the checkout (install.sh
|
||||||
# D3), so a code-only change also needs every container restarted.
|
# D3), so a code-only change also needs every container restarted.
|
||||||
docker compose up -d --no-build --force-recreate --remove-orphans || return 1
|
docker compose up -d --no-build --force-recreate --remove-orphans || return 1
|
||||||
|
|
||||||
|
# Drop pinned images this node no longer runs. `image prune` only removes
|
||||||
|
# dangling images, and every previous src- tag is still tagged. `image rm`
|
||||||
|
# without -f refuses anything a container still uses.
|
||||||
|
if [ "$pin" = 1 ]; then
|
||||||
|
local keep ref
|
||||||
|
keep="$(drb_image_tags "$INSTALL_DIR" "$new" | cut -d= -f2)"
|
||||||
|
docker images --format '{{.Repository}}:{{.Tag}}' | grep -E ':src-[0-9a-f]{40}$' \
|
||||||
|
| while IFS= read -r ref; do
|
||||||
|
grep -qxF "${ref##*:}" <<< "$keep" || docker image rm "$ref" >/dev/null 2>&1 || true
|
||||||
|
done || true
|
||||||
|
fi
|
||||||
docker image prune -f >/dev/null 2>&1 || true
|
docker image prune -f >/dev/null 2>&1 || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# shellcheck shell=bash
|
||||||
|
#
|
||||||
|
# Pin each node image to the exact source it was built from (node-26#19).
|
||||||
|
# Sourced by install.sh and scripts/drb-update.sh; not run directly.
|
||||||
|
#
|
||||||
|
# The tag is the git TREE hash of the image's build context, `src-<tree>`.
|
||||||
|
# CI computes the same value (`git rev-parse HEAD:<dir>` in the build
|
||||||
|
# workflows), so a node asks for the image built from the very directory it
|
||||||
|
# has checked out. That is exact even though each image is only rebuilt when
|
||||||
|
# its own directory changes: an unchanged directory keeps the same tree hash,
|
||||||
|
# and so the same image, across commits. :latest / :stable still exist for
|
||||||
|
# local dev; a provisioned node never uses them.
|
||||||
|
#
|
||||||
|
# docker-compose.yml reads these as ${DRB_TAG_*:-latest}. The values live in
|
||||||
|
# .env so every manual `docker compose ...` on the node sees the same pins.
|
||||||
|
|
||||||
|
# VAR:build-context-dir, one per compose service with an image.
|
||||||
|
DRB_IMAGE_SOURCES="DRB_TAG_ICECAST:icecast DRB_TAG_OP25:op25-container \
|
||||||
|
DRB_TAG_SECONDARY_SDR:secondary-sdr-container DRB_TAG_EDGE_NODE:drb-edge-node"
|
||||||
|
|
||||||
|
# drb_image_tags REPO_DIR COMMIT -> "VAR=src-<tree>" lines. A directory that
|
||||||
|
# does not exist at COMMIT is skipped (its compose file won't reference it).
|
||||||
|
drb_image_tags() {
|
||||||
|
local _p _var _dir _tree
|
||||||
|
for _p in $DRB_IMAGE_SOURCES; do
|
||||||
|
_var="${_p%%:*}"; _dir="${_p#*:}"
|
||||||
|
_tree="$(git -C "$1" rev-parse --verify -q "$2:$_dir" 2>/dev/null)" || continue
|
||||||
|
printf '%s=src-%s\n' "$_var" "$_tree"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# drb_env_set ENV_FILE KEY VALUE — replace KEY's line in place, or append it.
|
||||||
|
# Rewrites through the same inode so .env keeps its 0600 mode and owner.
|
||||||
|
drb_env_set() {
|
||||||
|
local _tmp; _tmp="$(mktemp)"
|
||||||
|
awk -v k="$2" -v v="$3" '
|
||||||
|
index($0, k "=") == 1 { if (!done) print k "=" v; done = 1; next }
|
||||||
|
{ print }
|
||||||
|
END { if (!done) print k "=" v }' "$1" > "$_tmp" && cat "$_tmp" > "$1"
|
||||||
|
local _rc=$?; rm -f "$_tmp"; return $_rc
|
||||||
|
}
|
||||||
|
|
||||||
|
# drb_pin_image_tags ENV_FILE REPO_DIR COMMIT — write the pins into .env and
|
||||||
|
# export them, so compose sees them whether or not it re-reads .env.
|
||||||
|
drb_pin_image_tags() {
|
||||||
|
local _line
|
||||||
|
while IFS= read -r _line; do
|
||||||
|
drb_env_set "$1" "${_line%%=*}" "${_line#*=}" || return 1
|
||||||
|
export "$_line"
|
||||||
|
done < <(drb_image_tags "$2" "$3")
|
||||||
|
}
|
||||||
|
|
||||||
|
# drb_images_present COMPOSE_ARGS... — 0 if every image the compose project
|
||||||
|
# names is in the local image store. `compose pull` only WARNS when a service
|
||||||
|
# that also has a build: section fails to pull, so its exit code alone does
|
||||||
|
# not prove the pinned images exist.
|
||||||
|
drb_images_present() {
|
||||||
|
local _imgs _img _missing=0
|
||||||
|
_imgs="$(docker compose "$@" config --images)" || return 1
|
||||||
|
while IFS= read -r _img; do
|
||||||
|
[ -n "$_img" ] || continue
|
||||||
|
docker image inspect "$_img" >/dev/null 2>&1 || { printf 'missing image: %s\n' "$_img" >&2; _missing=1; }
|
||||||
|
done <<< "$_imgs"
|
||||||
|
return $_missing
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user