Skip to content
Open
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
8 changes: 5 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,18 @@ LABEL org.opencontainers.image.vendor="Metal3-io"

ARG PKGS_LIST=main-packages-list.txt
ARG EXTRA_PKGS_LIST
ARG PATCH_LIST

# build arguments for source build customization
ARG UPPER_CONSTRAINTS_FILE=upper-constraints.txt
ARG IRONIC_SOURCE=ea2bd759b32a09ec71477cec9194df7e934f5b22 # bugfix/33.0
ARG IRONIC_SOURCE=a26e740d8413f35fb8014a899ad704207374369a # stable/2026.1
ARG SUSHY_SOURCE
ARG GIT_HOST=https://opendev.org
ENV GIT_HOST=${GIT_HOST}

COPY sources /sources/
COPY patches /tmp/patches/
COPY ${UPPER_CONSTRAINTS_FILE} ironic-packages-list ${PKGS_LIST} \
${EXTRA_PKGS_LIST:-$PKGS_LIST} ${PATCH_LIST:-$PKGS_LIST} \
${EXTRA_PKGS_LIST:-$PKGS_LIST} \
/tmp/
COPY ironic-config/inspector.ipxe.j2 ironic-config/httpd-ironic-api.conf.j2 \
ironic-config/ipxe_config.template ironic-config/dnsmasq.conf.j2 \
Expand Down
44 changes: 18 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,29 +199,21 @@ filename as far as it's in the container context.

## Apply project patches to the images during build

When building the image, it is possible to specify a patch of one or more
upstream projects to apply to the image using the **PATCH_LIST** argument in
the cli command, for example:

```bash
podman build -t ironic-image -f Dockerfile --build-arg PATCH_LIST=my-patch-list
```

The **PATCH_LIST** argument is a path to a file under the image context.
Its format is a simple text file that contains references to upstream patches
for the ironic projects.
Each line of the file is in the form:
**project_dir refspec (git_host)**
where:

- **project_dir** is the last part of the project url including the
organization, for example for ironic is _openstack/ironic_
- **refspec** is the gerrit refspec of the patch we want to test, for example if
you want to apply the patch at
<https://review.opendev.org/c/openstack/ironic/+/800084>
the refspec will be _refs/changes/84/800084/22_
Using multiple refspecs is convenient in case we need to test patches that
are connected to each other, either on the same project or on different
projects.
- **git_host** (optional) is the git host from which the project will be cloned.
If unset, `https://opendev.org` is used.
Place patch files under `patches/<project>/` in the image build context. The
build copies that tree to `/tmp/patches` and, after rendering
`ironic-packages-list` with Jinja2, replaces any matching requirement with
`ironic @ file:///sources/<repo>` (and similarly for other projects): the tree
is cloned from `https://opendev.org/<project>.git` (override the host with the
**GIT_HOST** build-arg if needed), patches are applied with `git apply` in
alphabetical order per project, and pip installs from that directory. A plain
`file://` URL is used because `git+file://` makes pip run `git clone`, which
only copies committed objects and would skip uncommitted `git apply` changes.

- **Single path segment** (for example `patches/ironic/`): clones
`openstack/<name>` (same as `openstack/ironic` for `patches/ironic/`).
- **Two segments** (for example `patches/openstack/ironic/`): clones that full
path as the Git project.

The branch or commit comes from the rendered requirement line (for example
`ironic @ git+https://opendev.org/openstack/ironic@<ref>`). If the line has no
`@ref`, the repository default branch is used.
140 changes: 117 additions & 23 deletions patch-image.sh
Original file line number Diff line number Diff line change
@@ -1,32 +1,126 @@
#!/usr/bin/bash
set -ex
set -euxo pipefail

PATCH_FILE="/tmp/${PATCH_LIST}"
VARS="PROJECT REFSPEC GIT_HOST"
# Apply patches from patches/<project>/*.patch before pip installs from
# ironic-packages-list-final. For each project with patch files, the matching
# requirement line is replaced with file:///sources/<repo> (not git+file: pip
# would clone from .git and omit uncommitted git-apply changes).

declare -a REQS=(
git-core
python3.12-pip
)
PATCHES_ROOT="/tmp/patches"
IRONIC_PKG_LIST_FINAL="/tmp/ironic-packages-list-final"

dnf install -y "${REQS[@]}"
[[ -d "${PATCHES_ROOT}" ]] || exit 0

while IFS= read -r line; do
# shellcheck disable=SC2086,SC2229
IFS=' ' read -r $VARS <<< "$line"
PROJ_NAME=$(echo "$PROJECT" | cut -d "/" -f2)
PROJ_URL="${GIT_HOST:-"https://opendev.org"}/$PROJECT"
export PATCHES_ROOT IRONIC_PKG_LIST_FINAL
export GIT_HOST="${GIT_HOST:-https://opendev.org}"

cd /tmp
git clone "$PROJ_URL"
cd "$PROJ_NAME"
git fetch "$PROJ_URL" "$REFSPEC"
git checkout FETCH_HEAD
python3.12 <<'PY'
import os
import re
import shutil
import subprocess
import sys

SKIP_GENERATE_AUTHORS=1 SKIP_WRITE_GIT_CHANGELOG=1 python3.12 setup.py sdist
python3.12 -m pip install --prefix /usr dist/*.tar.gz
done < "$PATCH_FILE"
PATCHES_ROOT = os.environ["PATCHES_ROOT"]
FINAL = os.environ["IRONIC_PKG_LIST_FINAL"]
GIT_BASE = os.environ.get("GIT_HOST", "https://opendev.org").rstrip("/")
SOURCES = "/sources"

dnf remove -y "${REQS[@]}"

cd /
def git(*args: str, cwd: str | None = None) -> None:
subprocess.run(["git", *args], cwd=cwd, check=True)


def project_for_rel(rel: str) -> str:
return rel if "/" in rel else f"openstack/{rel}"


def line_matches_project(line: str, project: str, short: str) -> bool:
# Patched installs use a plain file:// URL so pip uses the working tree.
# git+file:// would make pip git-clone the repo, which only copies commits
# and drops uncommitted changes from git apply.
if f"file:///sources/{short}" in line:
return True
if f"git+file:///sources/{short}" in line:
return True
boundary = re.compile(re.escape("/" + project) + r"(?=@|[#\"'\s]|$)")
return boundary.search(line) is not None


def parse_pkg_url(line: str, short: str) -> tuple[str, str]:
if " @ " in line:
pkg, url_spec = line.split(" @ ", 1)
return pkg.strip(), url_spec.strip()
return short, line.strip()


def extract_ref(url_spec: str, project: str) -> str | None:
mark = "/" + project
if mark not in url_spec:
return None
i = url_spec.index(mark) + len(mark)
if i < len(url_spec) and url_spec[i] == "@":
return url_spec[i + 1 :].split("#")[0].strip()
return None


# Collect patch files grouped by project directory (relative to PATCHES_ROOT).
by_rel: dict[str, list[str]] = {}
for root, _dirs, files in os.walk(PATCHES_ROOT):
for name in sorted(files):
if not name.endswith((".patch", ".diff")):
continue
path = os.path.join(root, name)
rel = os.path.relpath(os.path.dirname(path), PATCHES_ROOT)
if rel == ".":
print(
f"WARNING: ignore patch at root of {PATCHES_ROOT}: {path}",
file=sys.stderr,
)
continue
by_rel.setdefault(rel, []).append(path)

for rel in by_rel:
by_rel[rel] = sorted(by_rel[rel])

if not by_rel:
sys.exit(0)

with open(FINAL) as f:
lines = f.read().splitlines()

for rel in sorted(by_rel.keys()):
project = project_for_rel(rel)
short = project.split("/")[-1]
match_idx = None
for i, line in enumerate(lines):
if line_matches_project(line, project, short):
match_idx = i
break
if match_idx is None:
print(
f"WARNING: no requirement line for {project}; skip patches under {rel!r}",
file=sys.stderr,
)
continue

line = lines[match_idx]
pkg, url_spec = parse_pkg_url(line, short)
ref = extract_ref(url_spec, project)

dest = os.path.join(SOURCES, short)
shutil.rmtree(dest, ignore_errors=True)
os.makedirs(SOURCES, exist_ok=True)
git("clone", f"{GIT_BASE}/{project}.git", dest)
if ref:
git("fetch", "origin", ref, cwd=dest)
git("checkout", "FETCH_HEAD", cwd=dest)

for patch_path in by_rel[rel]:
git("apply", patch_path, cwd=dest)

lines[match_idx] = f"{pkg} @ file:///sources/{short}"

with open(FINAL, "w") as f:
f.write("\n".join(lines) + "\n")
PY
Loading
Loading