diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index d35f37e2..dd17dacd 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/README.md b/README.md index ae08dd4d..ee5a2e35 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/documentation.rst b/docs/documentation.rst index ec046183..25314814 100644 --- a/docs/documentation.rst +++ b/docs/documentation.rst @@ -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 diff --git a/tests/integration/live-update/git-server.py b/tests/integration/live-update/git-server.py new file mode 100644 index 00000000..d83f9b45 --- /dev/null +++ b/tests/integration/live-update/git-server.py @@ -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() diff --git a/tests/integration/live-update/test-live-update.sh b/tests/integration/live-update/test-live-update.sh new file mode 100644 index 00000000..2279905c --- /dev/null +++ b/tests/integration/live-update/test-live-update.sh @@ -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" < "$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" </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.'