Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 15 additions & 76 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ jobs:
build-ansible-arm:
runs-on: ubuntu-26.04-arm
needs: [ build, raw-validate, fetchit-config-target-no-config-validate, fetchit-config-reload-validate, clean-validate, kube-validate, systemd-validate, systemd-enable-validate, systemd-user-enable-validate, systemd-autoupdate-validate, systemd-restart-validate, systemd-validate-exact-file, multi-engine-validate, make-change-to-repo, filetransfer-validate, filetransfer-validate-exact-file, ssh-validate, ansible-validate, loader-validate, disconnected-validate ]
# PRs skip make-change-to-repo; failed or cancelled validation still blocks builds.
# Failed or cancelled validation blocks downstream architecture builds.
if: ${{ !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
Expand Down Expand Up @@ -151,7 +151,7 @@ jobs:
build-systemd-arm:
runs-on: ubuntu-26.04-arm
needs: [ build, raw-validate, fetchit-config-target-no-config-validate, fetchit-config-reload-validate, clean-validate, kube-validate, systemd-validate, systemd-enable-validate, systemd-user-enable-validate, systemd-autoupdate-validate, systemd-restart-validate, systemd-validate-exact-file, multi-engine-validate, make-change-to-repo, filetransfer-validate, filetransfer-validate-exact-file, ssh-validate, ansible-validate, loader-validate, disconnected-validate ]
# PRs skip make-change-to-repo; failed or cancelled validation still blocks builds.
# Failed or cancelled validation blocks downstream architecture builds.
if: ${{ !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
Expand Down Expand Up @@ -1454,98 +1454,37 @@ jobs:

make-change-to-repo:
runs-on: ubuntu-26.04
if: >
(github.event_name == 'push' || github.event_name == 'schedule') &&
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
timeout-minutes: 10
needs: [ build, pull-and-archive ]
permissions:
contents: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
path: main
persist-credentials: false

- name: checkout with token
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
path: ci
ref: ci

- uses: ./main/.github/actions/setup-podman
- uses: ./.github/actions/setup-podman

- name: Enable the podman socket
run: sudo systemctl enable --now podman.socket

- name: ensure value and commit
run: |
if [[ $( grep pink ./ci/examples/raw/color1.json | wc -l ) = "1" ]] ; then
cat <<< $(jq '.Env.APP_COLOR |= "blue"' ./ci/examples/raw/color1.json) > ./ci/examples/raw/color1.json
cd ci
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git commit -m "color changes" -a
git push -f
fi

- name: pull artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: fetchit-image
path: /tmp

- name: Load the image
run: sudo podman load -i /tmp/fetchit.tar

- name: pull artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: colors
path: /tmp

- name: Load the image
run: sudo podman load -i /tmp/colors.tar

- name: tag the image
run: sudo podman tag quay.io/fetchit/fetchit-amd:latest quay.io/fetchit/fetchit:latest

- name: set values relating to the current env
- name: Load engine and sample images from this revision
run: |
sed -i 's| url: https://github.com/containers/fetchit| url: https://github.com/${{ github.repository }}|g' /home/runner/work/fetchit/fetchit/main/examples/ci-config.yaml
sed -i 's| branch: ci| branch: "{{ github.ref }}"|g' /home/runner/work/fetchit/fetchit/main/examples/ci-config.yaml

- name: Start fetchit
run: sudo podman run -d --name fetchit -v fetchit-volume:/opt -v /home/runner/work/fetchit/fetchit/main/examples/ci-config.yaml:/opt/mount/config.yaml -v /run/podman/podman.sock:/run/podman/podman.sock --security-opt label=disable quay.io/fetchit/fetchit-amd:latest

- name: identify colors container
run: timeout 150 bash -c -- 'c=0 ; until [ $c -eq 2 ]; do c=$(sudo podman ps | grep colors | wc -l); done'

- name: Logs
if: always()
run: sudo podman logs fetchit

- name: get environment variable
run: timeout 150 bash -c -- 'c=0 ; until [ $c -eq 1 ]; do c=$(sudo podman exec colors1 env | grep blue| wc -l); done'

- name: update variable
run: |
cat <<< $(jq '.Env.APP_COLOR |= "pink"' ./ci/examples/raw/color1.json) > ./ci/examples/raw/color1.json

- name: Commit files
run: |
cd ci
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git commit -m "color changes" -a
git push -f

- name: Logs
if: always()
run: sudo podman logs fetchit

- name: get environment variable
run: timeout 150 bash -c -- 'c=0 ; until [ $c -eq 1 ]; do c=$(sudo podman exec colors1 env | grep pink| wc -l); done'
sudo podman load -i /tmp/fetchit.tar
sudo podman load -i /tmp/colors.tar

- name: Logs
if: always()
run: sudo podman logs fetchit
- name: Reconcile a commit from an isolated Git repository
run: sudo bash tests/integration/live-update/test-live-update.sh

loader-validate:
runs-on: ubuntu-26.04
Expand Down Expand Up @@ -1726,7 +1665,7 @@ jobs:
build-arm-and-manifest-list:
runs-on: ubuntu-26.04-arm
needs: [ build, raw-validate, podman-secret-validate, podman-config-secret-validate, fetchit-config-target-no-config-validate, fetchit-config-reload-validate, clean-validate, kube-validate, systemd-validate, systemd-enable-validate, systemd-user-enable-validate, systemd-autoupdate-validate, systemd-restart-validate, systemd-validate-exact-file, multi-engine-validate, make-change-to-repo, filetransfer-validate, filetransfer-validate-exact-file, ssh-validate, ansible-validate, loader-validate, disconnected-validate ]
# PRs skip make-change-to-repo; failed or cancelled validation still blocks builds.
# Failed or cancelled validation blocks downstream architecture builds.
if: ${{ !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ go test -mod=readonly -tags 'containers_image_openpgp gssapi providerless netgo
Real SOPS tests additionally require `SOPS_TEST_BINARY` and `AGE_TEST_BINARY`
pointing to the supported SOPS executable and `age-keygen`. CI installs these
tools and runs the unit, ownership, rollback, and runtime lifecycle checks.
The image workflow also tests scheduled Git updates on PRs using a temporary
local repository, without modifying GitHub branches. See the
[testing guide](docs/documentation.rst#scheduled-git-update-testing).
Integration tests use disposable hosts and packaged Podman 5/crun; do not run
host service tests against a personal Podman socket.

Expand Down
31 changes: 31 additions & 0 deletions docs/documentation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,34 @@ release branch. Do not move historical release tags merely to repair docs.
After a successful build, check :doc:`methods`, :doc:`quadlet`, :doc:`lifecycle`,
and :doc:`release_notes` on the hosted version. A successful local/Actions build
alone does not establish that Read the Docs has published the new revision.

Scheduled Git update testing
----------------------------

The image workflow's ``make-change-to-repo`` job runs on pull requests and pushes
using the engine and sample images built from that revision. It starts a
job-local, read-only smart HTTP Git server, deploys two raw workloads, commits
and pushes an update into a temporary bare repository, and waits for FetchIt's
normal minute-based polling to reconcile the new commit. The changed workload
must be replaced, the unchanged workload must retain its container ID, and the
FetchIt process must remain running without a restart.

The test does not write to GitHub, use repository-write credentials, force-push,
or modify the shared ``ci`` branch. Every job has its own Git repository, port,
containers, and state volume. Logs are printed on exit and fixtures are cleaned
up. To reproduce on a Linux host with rootful Podman, enable the Podman socket,
load or build the engine and sample images, then run:

.. code-block:: bash

sudo systemctl enable --now podman.socket
sudo env FETCHIT_TEST_ENGINE_IMAGE=localhost/fetchit:reviewed \
bash tests/integration/live-update/test-live-update.sh

The sample image must be tagged ``quay.io/fetchit/fetchit-sample-app:latest`` in
the same rootful store. For a local build, use:

.. code-block:: bash

sudo podman build -t quay.io/fetchit/fetchit-sample-app:latest \
-f examples/sample-app/Containerfile examples/sample-app
78 changes: 78 additions & 0 deletions tests/integration/live-update/git-server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Loopback-only, read-only smart HTTP Git fixture for the live-update test."""

import argparse
import os
from pathlib import Path
import subprocess
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlsplit


class GitHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.serve_git()

def do_POST(self):
self.serve_git()

def serve_git(self):
request = urlsplit(self.path)
if request.path not in ("/live.git/info/refs", "/live.git/git-upload-pack"):
self.send_error(404)
return
length = int(self.headers.get("Content-Length", "0"))
if length < 0 or length > 16 * 1024 * 1024:
self.send_error(413)
return
environment = os.environ.copy()
environment.update(
GIT_PROJECT_ROOT=self.server.repository_root,
GIT_HTTP_EXPORT_ALL="1",
REQUEST_METHOD=self.command,
PATH_INFO=request.path,
QUERY_STRING=request.query,
CONTENT_TYPE=self.headers.get("Content-Type", ""),
CONTENT_LENGTH=str(length),
REMOTE_ADDR=self.client_address[0],
HTTP_GIT_PROTOCOL=self.headers.get("Git-Protocol", ""),
)
try:
response = subprocess.run(
["git", "http-backend"],
input=self.rfile.read(length),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=environment,
timeout=30,
check=True,
).stdout
headers, body = response.split(b"\r\n\r\n", 1)
except (subprocess.SubprocessError, ValueError):
self.send_error(502, "Git fixture backend failed")
return
status = 200
outgoing = []
for line in headers.decode("ascii").splitlines():
key, value = line.split(":", 1)
if key.lower() == "status":
status = int(value.strip().split()[0])
else:
outgoing.append((key, value.strip()))
self.send_response(status)
for key, value in outgoing:
self.send_header(key, value)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)


if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("repository_root")
parser.add_argument("port_file")
args = parser.parse_args()
with HTTPServer(("127.0.0.1", 0), GitHandler) as server:
server.repository_root = str(Path(args.repository_root).resolve())
Path(args.port_file).write_text(str(server.server_port))
server.serve_forever()
98 changes: 98 additions & 0 deletions tests/integration/live-update/test-live-update.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# Test the real engine's scheduled Git polling using only job-local resources.
set -euo pipefail
engine_image=${FETCHIT_TEST_ENGINE_IMAGE:-quay.io/fetchit/fetchit-amd:latest}
sample_image=quay.io/fetchit/fetchit-sample-app:latest
scratch=$(mktemp -d)
fixture_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
engine="fetchit-live-update-$$"
changing="$engine-changing"
stable="$engine-stable"
state="$engine-state"
server_pid=
cleanup() {
status=$?
podman logs "$engine" || true
cat "$scratch/server.log" || true
podman rm -f "$engine" "$changing" "$stable" >/dev/null 2>&1 || true
podman volume rm "$state" >/dev/null 2>&1 || true
if [[ -n "$server_pid" ]]; then kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true; fi
rm -rf "$scratch"
exit "$status"
}
trap cleanup EXIT
podman image exists "$engine_image"
podman image exists "$sample_image"
mkdir -p "$scratch/server" "$scratch/seed/raw"
git init --bare --initial-branch=main "$scratch/server/live.git"
git -C "$scratch/server/live.git" config http.receivepack false
git init --initial-branch=main "$scratch/seed"
git -C "$scratch/seed" config user.name 'FetchIt CI fixture'
git -C "$scratch/seed" config user.email 'ci@example.invalid'
git -C "$scratch/seed" remote add origin "$scratch/server/live.git"
write_workload() {
local name=$1 color=$2 destination=$3
cat > "$destination" <<EOF
{"Image":"$sample_image","Name":"$name","Env":{"APP_COLOR":"$color"}}
EOF
}
write_workload "$changing" blue "$scratch/seed/raw/changing.json"
write_workload "$stable" green "$scratch/seed/raw/stable.json"
git -C "$scratch/seed" add raw
git -C "$scratch/seed" commit -m 'Initial workload definitions'
git -C "$scratch/seed" push origin main
python3 "$fixture_dir/git-server.py" "$scratch/server" "$scratch/port" > "$scratch/server.log" 2>&1 &
server_pid=$!
for ((attempt = 0; attempt < 50; attempt++)); do
if [[ -s "$scratch/port" ]]; then break; fi
kill -0 "$server_pid"
sleep 0.1
done
port=$(cat "$scratch/port")
# Verify the same smart HTTP transport that FetchIt will use before starting it.
git ls-remote "http://127.0.0.1:$port/live.git" refs/heads/main
cat > "$scratch/config.yaml" <<EOF
targetConfigs:
- url: http://127.0.0.1:$port/live.git
branch: main
raw:
- name: live-update
targetPath: raw
schedule: "*/1 * * * *"
EOF
podman run -d --name "$engine" --network host --security-opt label=disable \
-v "$state:/opt" \
-v "$scratch/config.yaml:/opt/mount/config.yaml:ro" \
-v /run/podman/podman.sock:/run/podman/podman.sock \
"$engine_image"
wait_color() {
local name=$1 color=$2 deadline=$((SECONDS + 150))
while (( SECONDS < deadline )); do
if [[ $(podman inspect --format '{{.State.Running}}' "$engine" 2>/dev/null) != true ]]; then
echo 'FetchIt exited while waiting for reconciliation' >&2
return 1
fi
if [[ $(podman inspect --format '{{.State.Running}}' "$name" 2>/dev/null) == true ]] &&
[[ $(podman exec "$name" printenv APP_COLOR 2>/dev/null) == "$color" ]]; then return 0; fi
sleep 2
done
echo "Timed out waiting for $name to reach APP_COLOR=$color" >&2
return 1
}
wait_color "$changing" blue
wait_color "$stable" green
old_id=$(podman inspect --format '{{.Id}}' "$changing")
stable_id=$(podman inspect --format '{{.Id}}' "$stable")
engine_id=$(podman inspect --format '{{.Id}}' "$engine")
write_workload "$changing" pink "$scratch/seed/raw/changing.json"
git -C "$scratch/seed" add raw/changing.json
git -C "$scratch/seed" commit -m 'Update one running workload'
# This push goes only to the temporary bare repository, never to GitHub.
git -C "$scratch/seed" push origin main
wait_color "$changing" pink
wait_color "$stable" green
test "$(podman inspect --format '{{.Id}}' "$changing")" != "$old_id"
test "$(podman inspect --format '{{.Id}}' "$stable")" = "$stable_id"
test "$(podman inspect --format '{{.Id}}' "$engine")" = "$engine_id"
test "$(podman inspect --format '{{.RestartCount}}' "$engine")" = 0
echo 'Scheduled Git update replaced only the changed workload without restarting FetchIt.'
Loading