Skip to content
Draft
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
23 changes: 22 additions & 1 deletion .pipelines/github-pr-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,29 @@ pr:
- aclmain
- release/*

parameters:
- name: aclPipelinesRef
type: string
default: refs/heads/main
- name: buildMantle
type: boolean
default: false
- name: runKolaAzure
type: boolean
default: false
- name: kolaTests
type: string
default: default
- name: maxRuns
type: number
default: 5

resources:
repositories:
- repository: acl_pipelines
type: git
name: ACL/acl-pipelines
ref: refs/heads/main
ref: ${{ parameters.aclPipelinesRef }}
- repository: azure_container_linux
type: github
endpoint: github.com_acl
Expand All @@ -46,3 +63,7 @@ extends:
parameters:
aclRef: $(Build.SourceBranch)
mantleRef: 'aclmain'
buildMantle: ${{ parameters.buildMantle }}
runKolaAzure: ${{ parameters.runKolaAzure }}
kolaTests: ${{ parameters.kolaTests }}
maxRuns: ${{ parameters.maxRuns }}
137 changes: 137 additions & 0 deletions acl/build_rpm_image.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
# --download-rpms [no-op] Kept for pipeline compatibility
# --group=GROUP Image group: developer|production|prod (default: production)
# --help Show this help message
# --ipe-asset-mode=MODE IPE assets: disabled|ephemeral|external (default: disabled)
# --img-name=NAME Base image name prefix (default: acl_production)
# Final image will be NAME_image.bin, VM image will be NAME_qemu_uefi_image.img
# --keep-vm Keep VM running after scripts complete (write state to .vm-state.env)
Expand Down Expand Up @@ -171,6 +172,13 @@ export IMAGE_VERSION_ID="${IMAGE_VERSION_ID:-}"
export IMAGE_BUILD_ID="${IMAGE_BUILD_ID:-}"
# Extra kernel cmdline args baked into a UKI debug addon (e.g., for boot profiling)
export EXTRA_KERNEL_CMDLINE="${EXTRA_KERNEL_CMDLINE:-}"
# Build-time IPE asset mode. Runtime activation is selected only through the
# Azure IMDS acl-node-security-profile tag.
IPE_ASSET_MODE_OVERRIDE_SET=false
if [[ -v ACL_IPE_ASSET_MODE ]]; then
IPE_ASSET_MODE_OVERRIDE_SET=true
fi
ACL_IPE_ASSET_MODE="${ACL_IPE_ASSET_MODE:-disabled}"

# Pipeline build identifier — used for deterministic gallery image versions in CI.
BUILD_ID="${BUILD_ID:-}"
Expand Down Expand Up @@ -231,6 +239,98 @@ show_help() {
exit 0
}

validate_ipe_boot_path() {
local vm_type="${1:-}"

[[ "${ACL_IPE_CAPABLE}" == "false" ]] && return 0
if [[ "${BOOTLOADER_MODE}" != "uki" ]]; then
error "IPE requires BOOTLOADER_MODE=uki"
return 1
fi
if [[ "${SECURE_BOOT_ENABLED:-true}" != "true" ]]; then
error "IPE requires Secure Boot"
return 1
fi
if [[ "${vm_type}" == "qemu" ]]; then
error "IPE is supported only by the Azure Secure Boot UKI path"
return 1
fi
}

configure_ipe_asset_mode() {
case "${ACL_IPE_ASSET_MODE}" in
disabled)
ACL_IPE_CAPABLE=false
;;
ephemeral)
ACL_IPE_CAPABLE=true
;;
external)
ACL_IPE_CAPABLE=true
;;
*)
error "Invalid IPE asset mode: ${ACL_IPE_ASSET_MODE} (expected disabled, ephemeral, or external)"
return 1
;;
esac
export ACL_IPE_ASSET_MODE ACL_IPE_CAPABLE
}

load_artifact_ipe_asset_mode() {
local artifact_dir="$1"
local mode_file="${artifact_dir}/ipe-asset-mode"
local artifact_mode

if [[ ! -r "${mode_file}" ]]; then
if [[ -d "${artifact_dir}/acl-ipe-ephemeral" ]]; then
error "IPE signing assets found without ${mode_file}"
return 1
fi
if [[ "${IPE_ASSET_MODE_OVERRIDE_SET}" == "true" &&
"${ACL_IPE_ASSET_MODE}" != "disabled" ]]; then
error "Source image has no IPE asset-mode metadata; rebuild it with IPE assets"
return 1
fi
ACL_IPE_ASSET_MODE=disabled
configure_ipe_asset_mode
return 0
fi

read -r artifact_mode < "${mode_file}"
case "${artifact_mode}" in
disabled|ephemeral|external) ;;
*)
error "Invalid IPE asset mode in ${mode_file}: ${artifact_mode}"
return 1
;;
esac
if [[ "${IPE_ASSET_MODE_OVERRIDE_SET}" == "true" &&
"${ACL_IPE_ASSET_MODE}" != "${artifact_mode}" ]]; then
error "Requested IPE asset mode '${ACL_IPE_ASSET_MODE}' does not match source image mode '${artifact_mode}'"
return 1
fi

ACL_IPE_ASSET_MODE="${artifact_mode}"
configure_ipe_asset_mode
}

operation_uses_vm_image() {
[[ "$BUILD_VM_IMAGE" == "true" ]] ||
[[ "$BUILD_TEST_IMAGE" == "true" ]] ||
[[ "$START_VM" == "true" ]] ||
[[ "$RUN_KOLA_TESTS" == "true" ]]
}

operation_uses_gallery_image() {
[[ -n "$ACG_IMAGE_VERSION_ID" ]] || [[ "$REUSE_IMAGE" == "true" ]]
}

operation_uses_local_image_artifact() {
[[ "$BUILD_IMAGE" != "true" ]] &&
! operation_uses_gallery_image &&
operation_uses_vm_image
}

# Parse command line arguments
parse_args() {
while [[ $# -gt 0 ]]; do
Expand All @@ -251,6 +351,16 @@ parse_args() {
GROUP="$2"
shift 2
;;
--ipe-asset-mode=*)
ACL_IPE_ASSET_MODE="${1#*=}"
IPE_ASSET_MODE_OVERRIDE_SET=true
shift
;;
--ipe-asset-mode)
ACL_IPE_ASSET_MODE="$2"
IPE_ASSET_MODE_OVERRIDE_SET=true
shift 2
;;
--img-name=*)
IMG_NAME="${1#*=}"
shift
Expand Down Expand Up @@ -613,6 +723,19 @@ parse_args() {
fi
fi

if operation_uses_local_image_artifact; then
load_artifact_ipe_asset_mode \
"${SCRIPT_DIR}/__build__/images/images/${BOARD}/latest" ||
exit 1
fi
if operation_uses_gallery_image &&
[[ "${START_VM}" == "true" || "${RUN_KOLA_TESTS}" == "true" ]] &&
[[ "${IPE_ASSET_MODE_OVERRIDE_SET}" == "false" ]]; then
warn "ACL_IPE_ASSET_MODE is not set for gallery image validation; defaulting to disabled"
fi

configure_ipe_asset_mode || exit 1

# Normalize group name
case "$GROUP" in
prod|production)
Expand All @@ -636,10 +759,19 @@ parse_args() {
;;
esac
fi
local ipe_vm_type=""
if operation_uses_vm_image; then
ipe_vm_type="${VM_TYPE}"
fi
validate_ipe_boot_path "${ipe_vm_type}" || exit 1

# Add platform-specific host-side tests when --run-tests is used.
if [[ "${RUN_TESTS:-false}" == "true" ]] && [[ "$VM_TYPE" == "azure" ]]; then
RUN_HOST_SCRIPTS+=("./acl/tests/run-selinux-toggle-test.sh")
if [[ "${ACL_IPE_CAPABLE}" == "true" ]] &&
[[ "${SECURE_BOOT_ENABLED:-true}" == "true" ]]; then
RUN_HOST_SCRIPTS+=("./acl/tests/run-ipe-mode-toggle-test.sh")
fi
fi

if [[ "$REUSE_IMAGE" == "true" ]]; then
Expand Down Expand Up @@ -870,6 +1002,7 @@ build_image() {
info " Output: ${OUTPUT_ROOT}"
info " Staging Dir: ${STAGING_DIR}"
info " Force Rebuild: ${FORCE_REBUILD}"
info " IPE Asset Mode: ${ACL_IPE_ASSET_MODE}"
echo

# Count RPMs
Expand Down Expand Up @@ -1054,6 +1187,9 @@ build_vm_image() {
# pulling a stale version.txt into local dev builds.
local version_args=()
local from_dir="${SCRIPT_DIR}/__build__/images/images/${BOARD}/latest"
load_artifact_ipe_asset_mode "${from_dir}" || exit 1
validate_ipe_boot_path "${vm_type}" || exit 1

if [[ "${NO_TTY:-false}" == "true" ]] && [[ -f "${from_dir}/version.txt" ]]; then
info "Installing artifact version.txt into manifest location (CI mode)"
cp "${from_dir}/version.txt" \
Expand Down Expand Up @@ -1261,6 +1397,7 @@ main() {

section "Azure Container Linux Image Builder"
info "Building ${BOARD} ${GROUP} image using Azure Linux RPMs"
info "IPE asset mode: ${ACL_IPE_ASSET_MODE}"

check_prerequisites
print_summary
Expand Down
31 changes: 31 additions & 0 deletions acl/docs/BUILD_RPM_IMAGE_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,37 @@ Build the Flatcar production image using RPM package sources.
./acl/build_rpm_image.sh --rebuild
```

Development and test images can include IPE assets with an ephemeral attached
PKCS#7 policy signature:

```bash
./acl/build_rpm_image.sh --rebuild --ipe-asset-mode=ephemeral
```

Production IPE validation builds use `--ipe-asset-mode=external`; normal
production pipeline defaults remain `disabled` until activation is approved.
The build creates a build-local ephemeral candidate CMS so pre-publish VHDs
remain bootable; the Pipelines collector exports the staged raw policy from
`${BUILD_DIR}/acl-ipe-policy/` for external signing by definition 5425.

```bash
./acl/build_rpm_image.sh --rebuild --ipe-asset-mode=external
```

The candidate CMS is staged at
`${BUILD_DIR}/acl-ipe-policy/acl-ipe-policy.p7b.cred` and installed as a
per-UKI `.extra.d` credential companion. The UKI cmdline includes an
`acl.ipe.policy_sha256=<hash>` token that binds the credential to the signed
kernel command line. At boot, the initramfs loader validates the credential
SHA-256, loads the policy into the kernel IPE subsystem, and only activates it
when Azure IMDS requests permissive mode. Loading is best effort on Azure;
validation or loading failures are logged and leave IPE inactive without
blocking boot.

IPE-capable VM images currently support only the Azure Secure Boot UKI path.
QEMU image conversion must use `--ipe-asset-mode=disabled`, which is also the
default.

**Build output location:** `__build__/images/images/amd64-usr/latest/`

### Phase 4: Build VM Image (Optional)
Expand Down
3 changes: 3 additions & 0 deletions acl/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ The `/usr` partition (USR-A) is a read-only btrfs filesystem with zstd compressi

- The verity hash tree is appended to the USR partition data.
- At boot, `systemd-veritysetup` activates the verity device using kernel command-line parameters embedded in the UKI: `systemd.verity_usr_data`, `systemd.verity_usr_hash`, and `systemd.verity_usr_options=hash-offset=<N>,panic-on-corruption`.
- The current image has one UKI for USR-A. Its detached root-hash signature is stored beside the UKI as a hash-addressed `*.cred` companion. systemd-stub transports it into the initramfs at `/.extra/credentials/`, and the signed UKI command line references the matching filename through `root-hash-signature=`.
- IPE assets are selected through `ACL_IPE_ASSET_MODE=disabled|ephemeral|external`; the default `disabled` mode omits them. Pipelines enable IPE only for Azure Secure Boot UKI images.
- On IPE-capable images, the signed policy candidate CMS is staged at `${BUILD_DIR}/acl-ipe-policy/acl-ipe-policy.p7b.cred` and installed as a per-UKI `.extra.d` credential companion at `EFI/Linux/<uki>.efi.extra.d/acl-ipe-policy.p7b.cred`. The UKI cmdline includes an `acl.ipe.policy_sha256=<hash>` token that binds the credential to the signed kernel command line. At boot, systemd-stub makes the credential available at `/.extra/credentials/acl-ipe-policy.p7b.cred`. On Azure, the initramfs loader validates the credential SHA-256, loads the policy into the kernel, and activates it only when IMDS requests `ipe=permissive` (runtime enforcement state `0`). Loading is best effort: validation or loading failures are logged and leave IPE inactive without blocking boot. Development and test builds use `ephemeral` mode. Production IPE validation uses `external` mode (the build creates a build-local ephemeral candidate; definition 5425 replaces it later), while normal production pipeline defaults remain disabled until activation is approved.
- Any corruption of `/usr` causes an immediate kernel panic, preventing the system from running a tampered image.

The A/B partition scheme (USR-A / USR-B) enables safe updates: the inactive slot is written, verified, and then atomically switched on reboot.
Expand Down
102 changes: 102 additions & 0 deletions acl/tests/azure-security-profile-test-common.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/bin/bash

SSH_OPTS=()

setup_ssh_opts() {
SSH_OPTS=(
-o StrictHostKeyChecking=no
-o UserKnownHostsFile=/dev/null
-o BatchMode=yes
-o ConnectTimeout=10
-o ServerAliveInterval=5
-o ServerAliveCountMax=2
-i "$VM_SSH_KEY"
)
}

ssh_cmd() {
ssh "${SSH_OPTS[@]}" "${VM_SSH_USER}@${VM_IP}" "$@"
}

imds_security_profile() {
local raw
raw=$(ssh_cmd "curl -sf -H Metadata:true --noproxy '*' \
'http://169.254.169.254/metadata/instance/compute/tagsList?api-version=2021-02-01'" \
2>/dev/null) || return 1
jq -r '.[] | select(.name=="acl-node-security-profile") | .value' <<<"$raw"
}

boot_id() {
ssh_cmd 'cat /proc/sys/kernel/random/boot_id' 2>/dev/null
}

set_security_profile_tag() {
local value="$1"
local vm_id

# Use the generic ARM tag endpoint to avoid round-tripping unrelated VM
# properties through the Compute RP.
vm_id=$(az vm show --resource-group "$VM_RG" --name "$VM_NAME" --query id -o tsv)
if [[ -z "$value" ]]; then
info "Removing acl-node-security-profile tag..."
az tag update \
--resource-id "$vm_id" \
--operation delete \
--tags "acl-node-security-profile=" \
--output none
else
info "Setting acl-node-security-profile=${value}..."
az tag update \
--resource-id "$vm_id" \
--operation merge \
--tags "acl-node-security-profile=${value}" \
--output none
fi

info "Waiting for in-guest IMDS to report tag='${value:-<absent>}'..."
local deadline=$(( $(date +%s) + 60 )) seen
while (( $(date +%s) < deadline )); do
seen=$(imds_security_profile) && [[ "$seen" == "$value" ]] && return 0
sleep 2
done
error "IMDS did not converge to '${value:-<absent>}' within 60s"
return 1
}

reboot_and_wait() {
local old new reboot_timeout
reboot_timeout="${VM_BOOT_TIMEOUT:-$VM_SSH_TIMEOUT}"
old=$(boot_id) || { error "Cannot read boot_id - VM unreachable?"; return 1; }
info "Rebooting VM ${VM_NAME} via SSH (old boot_id=${old})..."
timeout --signal=TERM --kill-after=5s 15s \
ssh "${SSH_OPTS[@]}" "${VM_SSH_USER}@${VM_IP}" "sudo reboot" || true
local deadline=$(( $(date +%s) + reboot_timeout ))
while (( $(date +%s) < deadline )); do
new=$(boot_id) && [[ "$new" != "$old" ]] && {
info "VM rebooted (new boot_id=${new})"
return 0
}
sleep 2
done
warn "VM did not come back after reboot within ${reboot_timeout}s - capturing VM diagnostics"

local diag_dir="${DIAGNOSTICS_DIR:-/tmp}"
mkdir -p "$diag_dir"
local prefix="${diag_dir}/$(date +%Y%m%d-%H%M%S)-${VM_NAME}"
az vm get-instance-view --resource-group "$VM_RG" --name "$VM_NAME" \
--query 'instanceView.{statuses:statuses,vmAgent:vmAgent.statuses}' \
-o json 2>&1 | tee "${prefix}-instance-view.json" || true
az vm boot-diagnostics get-boot-log --resource-group "$VM_RG" --name "$VM_NAME" 2>&1 \
| jq -r . > "${prefix}-serial.log" || true

info "Full serial log: ${prefix}-serial.log ($(wc -c <"${prefix}-serial.log") bytes); last 200 lines:"
tail -200 "${prefix}-serial.log" | sed 's/^/ [serial] /' || true
info "Diagnostics saved to ${prefix}-{instance-view.json,serial.log}"
error "VM did not come back after reboot"
return 1
}

set_security_profile_and_reboot() {
set_security_profile_tag "$1"
reboot_and_wait
}
Loading
Loading