Skip to content
Closed
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
121 changes: 120 additions & 1 deletion build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,111 @@ resolve_targets() {
echo "${resolved[@]}"
}

# Given a canonical project URL (or suffix), find all images whose
# sources.txt references it. Prints matching image targets, one per line.
# Used by the content provider to implement `s2i_ci_images: auto`.
# Args: <project-url-or-suffix> [stream]
auto_detect() {
local needle="$1"
local stream="${2:-}"
local matches=()

if [[ -z "${needle}" ]]; then
echo "ERROR: auto-detect requires a project URL or suffix" >&2
return 1
fi

for sources_file in "${CONTAINERS_DIR}"/*/sources.txt \
"${CONTAINERS_DIR}"/*/*/sources.txt; do
[[ -f "${sources_file}" ]] || continue

while IFS=' ' read -r entry_stream name url _branch _hash; do
[[ -z "${entry_stream}" || "${entry_stream}" == \#* ]] && continue
[[ "${name}" == "upper-constraints" ]] && continue
[[ -n "${stream}" && "${entry_stream}" != "${stream}" ]] && continue

# Match the needle against the URL's path (strip scheme + host,
# strip trailing .git). Supports full URLs and short forms like
# "openstack/tempest".
local url_path="${url#*://}" # remove scheme
url_path="${url_path#*/}" # remove hostname
url_path="${url_path%.git}" # remove .git suffix
if [[ "${url_path}" == "${needle}" ||
"${url}" == "${needle}" ||
"${url%.git}" == "${needle}" ]]; then
local rel="${sources_file#"${CONTAINERS_DIR}"/}"
rel="${rel%/sources.txt}"
local project="${rel%%/*}"
# Resolve to actual image targets under this project
for img in $(discover_images); do
if [[ "${img}" == "${project}/"* ]] || [[ "${img}" == "${project}" ]]; then
local already=0
for m in "${matches[@]+"${matches[@]}"}"; do
[[ "${m}" == "${img}" ]] && already=1 && break
done
[[ ${already} -eq 0 ]] && matches+=("${img}")
fi
done
fi
done < "${sources_file}"
done

if [[ ${#matches[@]} -eq 0 ]]; then
echo "ERROR: no images reference '${needle}'" >&2
return 1
fi

printf '%s\n' "${matches[@]}"
}

# List source dependencies for an image target.
# Outputs pipe-delimited records: name|canonical_project|url|dest_dir
# Used by Ansible playbooks to stage Zuul sources without re-parsing
# sources.txt themselves (build.sh is the single source of truth).
# Args: <image-target> [stream]
list_sources() {
local target="$1"
local stream="${2:-${STREAM:-}}"
local project="${target%%/*}"

if [[ -z "${target}" ]]; then
echo "ERROR: list-sources requires an image target" >&2
return 1
fi

local sources_files=()
if [[ "${target}" == "base" ]]; then
[[ -f "${CONTAINERS_DIR}/base/sources.txt" ]] && \
sources_files+=("${CONTAINERS_DIR}/base/sources.txt")
else
[[ -f "${CONTAINERS_DIR}/${project}/sources.txt" ]] && \
sources_files+=("${CONTAINERS_DIR}/${project}/sources.txt")
[[ -f "${CONTAINERS_DIR}/${target}/sources.txt" ]] && \
sources_files+=("${CONTAINERS_DIR}/${target}/sources.txt")
fi

for sources_file in "${sources_files[@]}"; do
while IFS=' ' read -r entry_stream name url _branch _hash; do
[[ -z "${entry_stream}" || "${entry_stream}" == \#* ]] && continue
[[ "${name}" == "upper-constraints" ]] && continue
[[ -n "${stream}" && "${entry_stream}" != "${stream}" ]] && continue

local url_path="${url#*://}"
url_path="${url_path#*/}"
url_path="${url_path%.git}"

local dest_dir
if [[ "${target}" == "base" ]]; then
dest_dir="${CONTAINERS_DIR}/base/src/${name}"
else
dest_dir="${CONTAINERS_DIR}/${project}/src/${name}"
fi

echo "${name}|${url_path}|${url}|${dest_dir}"
done < "${sources_file}"
done
}

# Clone a repo at a branch tip (or tag) and store the resolved commit hash
# in _CLONE_RESULT. Must NOT be called via command substitution ($(...))
# because _AUTO_CLONED assignments would be lost in the subshell.
Expand Down Expand Up @@ -1329,8 +1434,22 @@ case "${ACTION}" in
list)
list_images
;;
auto-detect)
if [[ ${#TARGETS[@]} -eq 0 || "${TARGETS[0]}" == "all" ]]; then
echo "Usage: $0 auto-detect <project-url-or-suffix> [stream]" >&2
exit 1
fi
auto_detect "${TARGETS[0]}" "${TARGETS[1]:-}"
;;
list-sources)
if [[ ${#TARGETS[@]} -eq 0 || "${TARGETS[0]}" == "all" ]]; then
echo "Usage: STREAM=<name> $0 list-sources <image-target>" >&2
exit 1
fi
list_sources "${TARGETS[0]}" "${TARGETS[1]:-}"
;;
*)
echo "Usage: STREAM=<name> $0 {build|build-parallel|push|update-sources|update-lockfiles|install-deps|list} [target ...]"
echo "Usage: STREAM=<name> $0 {build|build-parallel|push|update-sources|update-lockfiles|install-deps|list|auto-detect|list-sources} [target ...]"
echo ""
echo "Images (discovered from containers/):"
for dir_name in $(discover_images); do
Expand Down
64 changes: 64 additions & 0 deletions docs/developer-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,3 +438,67 @@ for a commit shares the same `master-<sha>` tag and consistent OS packages.
STREAM=master ./build.sh update-sources <project>
STREAM=master ./build.sh build <project>
```

## Speculative builds (Zuul integration)

When a patch is submitted against an upstream OpenStack project (e.g.,
`openstack/tempest`), the CI pipeline can automatically build fresh
container images incorporating that patch. This is called a **speculative
build**.

### Auto-detection

The content provider job accepts `s2i_ci_images: auto` to automatically
determine which images need rebuilding based on the triggering project:

```yaml
# In a Zuul job definition
vars:
s2i_ci_images: auto
```

Under the hood, auto-detection:

1. Inspects `zuul.items` to find the projects in the speculative change
queue.
2. Runs `build.sh auto-detect <canonical-project> [stream]` for each,
which scans `sources.txt` files to find which images reference that
project.
3. Replaces `s2i_ci_images` with the de-duplicated list of affected
image targets.

You can test auto-detection locally:

```bash
# Which images would be rebuilt for a tempest patch?
PARALLEL=1 ./build.sh auto-detect openstack/tempest master

# Full URL form also works
PARALLEL=1 ./build.sh auto-detect https://opendev.org/openstack/neutron.git
```

### Source staging

After auto-detection resolves the image list, the pipeline stages Zuul's
source checkouts into the container build contexts. This replaces the
pinned source with the patched version so the built image includes the
speculative change.

The staging playbook (`shared/stage-zuul-sources.yaml`) delegates all
`sources.txt` parsing to `build.sh list-sources`, keeping build.sh as the
single source of truth for the source manifest format.

### Querying source dependencies

`build.sh list-sources` prints pipe-delimited records for all source
dependencies of an image target:

```bash
PARALLEL=1 ./build.sh list-sources tempest/tempest master
# Output: name|canonical_project|url|dest_dir
# tempest|openstack/tempest|https://opendev.org/openstack/tempest.git|/.../containers/tempest/src/tempest
# barbican-tempest-plugin|openstack/barbican-tempest-plugin|https://...
```

This is used by the Ansible playbooks but is also useful for debugging
which upstream repos feed into a given image.
72 changes: 72 additions & 0 deletions playbooks/container-ci/shared/resolve-auto-images.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
# Resolve `s2i_ci_images: auto` by scanning sources.txt files to find
# which images are affected by the projects in the Zuul workspace.
#
# Called from shared/run.yaml when s2i_ci_images == "auto".
# After this task file completes, s2i_ci_images is replaced with the
# de-duplicated list of detected image targets.
#
# Required variables (set by run.yaml before including this):
# s2i_ci_container_repo - path to the s2i-openstack-containers checkout
# s2i_ci_container_project - canonical name of the container project
# s2i_ci_stream - stream name (e.g. "master")

- name: Collect changed projects from Zuul queue
ansible.builtin.set_fact:
s2i_ci_changed_projects: >-
{{ (zuul.items | default([]))
| selectattr('project', 'defined')
| map(attribute='project')
| map(attribute='canonical_name', default='')
| select('ne', '')
| reject('equalto', s2i_ci_container_project)
| unique
| list }}

- name: Fall back to primary project when no queue items
ansible.builtin.set_fact:
s2i_ci_changed_projects: >-
{{ [zuul.project.canonical_name]
| reject('equalto', s2i_ci_container_project)
| list }}
when: s2i_ci_changed_projects | length == 0

- name: Run auto-detect for each changed project
ansible.builtin.command:
argv:
- "{{ s2i_ci_container_repo }}/build.sh"
- auto-detect
- "{{ item }}"
- "{{ s2i_ci_stream }}"
args:
chdir: "{{ s2i_ci_container_repo }}"
loop: "{{ s2i_ci_changed_projects }}"
register: s2i_ci_auto_detect_results
changed_when: false
failed_when: false

- name: Collect auto-detected images
ansible.builtin.set_fact:
s2i_ci_images: >-
{{ s2i_ci_auto_detect_results.results
| selectattr('rc', 'equalto', 0)
| map(attribute='stdout_lines')
| flatten
| unique
| list }}

- name: Fail if no images were detected
ansible.builtin.assert:
that:
- s2i_ci_images | length > 0
fail_msg: >-
Auto-detection found no images for projects:
{{ s2i_ci_changed_projects | join(', ') }}.
Verify that sources.txt references these projects.

- name: Report auto-detected images
ansible.builtin.debug:
msg: >-
Auto-detected {{ s2i_ci_images | length }} image(s) for
{{ s2i_ci_changed_projects | join(', ') }}:
{{ s2i_ci_images | join(', ') }}
Loading
Loading