mirror of
https://github.com/coreos/chunkah.git
synced 2026-11-03 21:49:44 -09:00
tree: initial commit
This commit is contained in:
commit
11ca5eb66a
33 changed files with 44566 additions and 0 deletions
43
.claude/skills/extract-oci-archive/SKILL.md
Normal file
43
.claude/skills/extract-oci-archive/SKILL.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
---
|
||||
name: extract-oci-archive
|
||||
description: Use when you need to extract an OCI archive to inspect its contents, get the rootfs layer, or read the image config.
|
||||
---
|
||||
|
||||
# Extract OCI Archive
|
||||
|
||||
## Extract the OCI archive structure
|
||||
|
||||
```bash
|
||||
mkdir /tmp/oci-extracted
|
||||
tar -xf /path/to/image.ociarchive -C /tmp/oci-extracted
|
||||
```
|
||||
|
||||
## Get the manifest digest from index.json
|
||||
|
||||
```bash
|
||||
MANIFEST=$(jq -r '.manifests[0].digest' /tmp/oci-extracted/index.json \
|
||||
| cut -d: -f2)
|
||||
```
|
||||
|
||||
## Extract a layer to get the rootfs
|
||||
|
||||
For a single-layer image:
|
||||
|
||||
```bash
|
||||
LAYER=$(jq -r '.layers[0].digest' \
|
||||
/tmp/oci-extracted/blobs/sha256/${MANIFEST} | cut -d: -f2)
|
||||
mkdir /tmp/rootfs
|
||||
tar -xf /tmp/oci-extracted/blobs/sha256/${LAYER} -C /tmp/rootfs
|
||||
```
|
||||
|
||||
For multi-layer images, extract each layer in order and overlay them.
|
||||
|
||||
## Read the image config
|
||||
|
||||
The config contains the created timestamp, entrypoint, labels, etc.:
|
||||
|
||||
```bash
|
||||
CONFIG=$(jq -r '.config.digest' \
|
||||
/tmp/oci-extracted/blobs/sha256/${MANIFEST} | cut -d: -f2)
|
||||
jq . /tmp/oci-extracted/blobs/sha256/${CONFIG}
|
||||
```
|
||||
79
.github/workflows/ci.yml
vendored
Normal file
79
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
container: quay.io/fedora/fedora:latest
|
||||
steps:
|
||||
- name: Install tools
|
||||
run: dnf install -y cargo clippy rustfmt just ShellCheck nodejs-npm openssl-devel git
|
||||
- name: Configure git
|
||||
run: git config --global --add safe.directory '*'
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Install markdownlint
|
||||
run: npm install -g markdownlint-cli
|
||||
- name: Build
|
||||
run: just build
|
||||
- name: Check
|
||||
run: just checkall
|
||||
|
||||
e2e-tests:
|
||||
needs: unit-tests
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
container:
|
||||
image: quay.io/fedora/fedora:latest
|
||||
options: --privileged --cgroupns=host -v /var/lib/containers -v /var/tmp -v /tmp
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Install tools
|
||||
run: dnf install -y just buildah podman skopeo jq
|
||||
- name: Build image
|
||||
run: just buildimg
|
||||
- name: Run tests
|
||||
run: just test
|
||||
- name: Push to ghcr.io
|
||||
run: |
|
||||
set -euo pipefail
|
||||
podman login -u ${{ github.actor }} -p ${{ secrets.GITHUB_TOKEN }} ghcr.io
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
podman push chunkah ghcr.io/${{ github.repository }}:pr-${{ github.event.pull_request.number }}-${{ github.sha }}
|
||||
else
|
||||
podman push chunkah ghcr.io/${{ github.repository }}:${{ github.sha }}
|
||||
podman push chunkah ghcr.io/${{ github.repository }}:latest
|
||||
fi
|
||||
|
||||
release:
|
||||
needs: e2e-tests
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Install skopeo
|
||||
run: sudo apt-get update && sudo apt-get install -y skopeo
|
||||
- name: Copy to quay.io
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.docker
|
||||
echo '${{ secrets.QUAY_AUTH }}' > ~/.docker/config.json
|
||||
skopeo copy --src-creds=${{ github.actor }}:${{ secrets.GITHUB_TOKEN }} \
|
||||
docker://ghcr.io/${{ github.repository }}:latest docker://quay.io/jlebon/chunkah:dev
|
||||
skopeo copy --src-creds=${{ github.actor }}:${{ secrets.GITHUB_TOKEN }} \
|
||||
docker://ghcr.io/${{ github.repository }}:latest docker://quay.io/jlebon/chunkah:latest
|
||||
- name: Cleanup old images
|
||||
uses: actions/delete-package-versions@v5
|
||||
with:
|
||||
package-name: chunkah
|
||||
package-type: container
|
||||
min-versions-to-keep: 20
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
85
AGENTS.md
Normal file
85
AGENTS.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# AGENTS.md
|
||||
|
||||
This file provides guidance to coding agents when working with code in this
|
||||
repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
chunkah is an OCI building tool that takes a flat rootfs and outputs a layered
|
||||
OCI image with content-based layers. It "postprocesses" images so that layers
|
||||
are created to maximize layer reuse rather than reflecting Dockerfile structure.
|
||||
|
||||
## Build and Test Commands
|
||||
|
||||
```bash
|
||||
just build # Build the project (dev)
|
||||
just build release # Build the project (release)
|
||||
just check # Run unit tests
|
||||
just clippy # Run clippy linter
|
||||
just fmt # Check code formatting
|
||||
just markdownlint # Lint markdown files
|
||||
just checkall # Run all checks (shellcheck, check, fmt, clippy, markdownlint)
|
||||
just test # Run end-to-end tests (requires built container image)
|
||||
just buildimg # Build chunkah container image
|
||||
just buildimg nochunk # Build chunkah container image without chunking (faster)
|
||||
```
|
||||
|
||||
Run a single test:
|
||||
|
||||
```bash
|
||||
cargo test test_name
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Pipeline
|
||||
|
||||
1. **scan** (`src/scan.rs`) - Walks the rootfs and builds a map of paths to types
|
||||
2. **components** (`src/components/`) - Determines which files belong to which
|
||||
components
|
||||
3. **ocibuilder** (`src/ocibuilder.rs`) - Creates OCI layers from components
|
||||
4. **tar** (`src/tar.rs`) - Writes files to tar archives with proper metadata
|
||||
|
||||
### Component System
|
||||
|
||||
The `ComponentsRepo` trait (`src/components/mod.rs`) defines how different
|
||||
package systems claim files:
|
||||
|
||||
- `rpm` - Claims files based on RPM database, groups by SRPM
|
||||
- `xattr` - Claims files based on `user.component` extended attributes
|
||||
|
||||
Repos have priorities; higher priority repos (lower values) win when claiming
|
||||
paths. Unclaimed files go to `chunkah/unclaimed`.
|
||||
|
||||
### Commands
|
||||
|
||||
- `build` (`src/cmd_build.rs`) - Main command: scans rootfs, assigns
|
||||
components, builds OCI archive
|
||||
- `prescan` (`src/cmd_prescan.rs`) - Hidden command: outputs component manifest
|
||||
for testing
|
||||
|
||||
## Code Guidelines
|
||||
|
||||
### Rust
|
||||
|
||||
- Never use `expect()` or `unwrap()` for error handling. Use `Result` instead.
|
||||
- Add `context()` or `with_context()` to errors (lowercase first word:
|
||||
`context("opening file")`).
|
||||
- After editing: `cargo fmt && just check && just clippy`
|
||||
|
||||
### Bash
|
||||
|
||||
Script header:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
shopt -s inherit_errexit
|
||||
```
|
||||
|
||||
Always use curly braces: `${foo}`, not `$foo`. Run `just shellcheck` after
|
||||
editing.
|
||||
|
||||
### Markdown
|
||||
|
||||
Run `just markdownlint` after editing.
|
||||
1
CLAUDE.md
Symbolic link
1
CLAUDE.md
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
AGENTS.md
|
||||
1431
Cargo.lock
generated
Normal file
1431
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
31
Cargo.toml
Normal file
31
Cargo.toml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
[package]
|
||||
name = "chunkah"
|
||||
version = "0.1.0"
|
||||
description = "An OCI building tool for content-based layers"
|
||||
authors = ["Jonathan Lebon <jonathan@jlebon.com>"]
|
||||
repository = "https://github.com/jlebon/chunkah"
|
||||
license = "MIT OR Apache-2.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
camino = { version = "1", features = ["serde1"] }
|
||||
cap-std = { version = "3", default-features = false, features = ["fs_utf8"] }
|
||||
cap-std-ext = { version = "4", default-features = false, features = ["fs_utf8"] }
|
||||
chrono = "0.4"
|
||||
clap = { version = "4", default-features = false, features = ["derive", "std", "help", "usage", "error-context", "env"] }
|
||||
ctrlc = "3.5.1"
|
||||
flate2 = "1"
|
||||
indexmap = "2"
|
||||
libc = "0.2"
|
||||
ocidir = "0.6"
|
||||
rpm-qa = { git = "https://github.com/jlebon/rpm-qa-rs" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tar = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
fs-set-times = "0.20.3"
|
||||
maplit = "1"
|
||||
tempfile = "3"
|
||||
32
Containerfile
Normal file
32
Containerfile
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Use `just buildimg` to build this, or:
|
||||
#
|
||||
# buildah build --skip-unused-stages=false -t chunkah .
|
||||
|
||||
ARG BASE=quay.io/fedora/fedora-minimal:43
|
||||
ARG FINAL_FROM=oci-archive:out.ociarchive
|
||||
|
||||
FROM ${BASE} AS builder
|
||||
RUN --mount=type=cache,rw,id=dnf,target=/var/cache/libdnf5 \
|
||||
dnf install -y cargo rust pkg-config openssl-devel
|
||||
WORKDIR /build
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY src ./src
|
||||
RUN --mount=type=cache,rw,id=cargo,target=/root/.cargo \
|
||||
--mount=type=cache,rw,id=target,target=/build/target \
|
||||
cargo build --release && cp /build/target/release/chunkah /usr/bin
|
||||
|
||||
FROM ${BASE} AS rootfs
|
||||
RUN --mount=type=cache,id=dnf,target=/mnt \
|
||||
cp -a /mnt /var/cache/libdnf5 && \
|
||||
dnf install -y openssl skopeo && rm -rf /var/cache/*
|
||||
COPY --from=builder /usr/bin/chunkah /usr/bin/chunkah
|
||||
|
||||
FROM rootfs AS rechunk
|
||||
COPY --from=rootfs / /rootfs
|
||||
RUN --mount=type=bind,target=/run/src,rw \
|
||||
chunkah build --rootfs /rootfs > /run/src/out.ociarchive
|
||||
|
||||
FROM ${FINAL_FROM}
|
||||
ENTRYPOINT ["/usr/bin/chunkah"]
|
||||
ENV CHUNKAH_ROOTFS=/chunkah
|
||||
WORKDIR /srv
|
||||
16
Containerfile.splitter
Normal file
16
Containerfile.splitter
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# This file is used to split existing images using `buildah build`. See
|
||||
# README.md for how to use it.
|
||||
|
||||
ARG CHUNKAH=quay.io/jlebon/chunkah:latest
|
||||
ARG CHUNKAH_ARGS=""
|
||||
ARG CHUNKAH_CONFIG_STR
|
||||
|
||||
FROM overridden AS target
|
||||
FROM ${CHUNKAH} AS chunkah
|
||||
ARG CHUNKAH_CONFIG_STR
|
||||
ARG CHUNKAH_ARGS
|
||||
RUN --mount=type=bind,target=/run/src,rw \
|
||||
--mount=from=target,target=/chunkah \
|
||||
chunkah build ${CHUNKAH_ARGS} > /run/src/out.ociarchive
|
||||
|
||||
FROM oci-archive:out.ociarchive
|
||||
44
Justfile
Normal file
44
Justfile
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Build the project
|
||||
build profile="dev":
|
||||
cargo build --profile {{ profile }}
|
||||
|
||||
# Check code formatting
|
||||
fmt:
|
||||
cargo fmt --check
|
||||
|
||||
# Run unit tests
|
||||
check:
|
||||
cargo test
|
||||
|
||||
# Run clippy linter
|
||||
clippy:
|
||||
cargo clippy -- -D warnings
|
||||
|
||||
# Lint shell scripts
|
||||
shellcheck:
|
||||
shellcheck --external-sources --enable=all $(git ls-files '*.sh')
|
||||
|
||||
# Lint markdown files
|
||||
markdownlint:
|
||||
markdownlint $(git ls-files '*.md')
|
||||
|
||||
# Run all checks (shellcheck, unit tests, fmt, clippy, markdownlint)
|
||||
checkall: shellcheck check fmt clippy markdownlint
|
||||
|
||||
# Build chunkah container image
|
||||
buildimg chunk="":
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
buildah="${BUILDAH:-buildah}"
|
||||
args=(-t chunkah --layers=true {{ if chunk == "nochunk" { "--build-arg=FINAL_FROM=rootfs" } else { "--skip-unused-stages=false" } }})
|
||||
# drop this once we can assume 1.43
|
||||
version=$(${buildah} version --json | jq -r '.version')
|
||||
if [[ $(echo -e "${version}\n1.43" | sort -V | head -n1) != "1.43" ]]; then
|
||||
args+=(-v "$PWD:/run/src" --security-opt=label=disable)
|
||||
fi
|
||||
echo ${buildah} build "${args[@]}" .
|
||||
${buildah} build "${args[@]}" .
|
||||
|
||||
# Run end-to-end tests with built chunkah image
|
||||
test *ARGS:
|
||||
./tests/e2e/run.sh {{ ARGS }}
|
||||
202
LICENSE-APACHE
Normal file
202
LICENSE-APACHE
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
5
LICENSE-MIT
Normal file
5
LICENSE-MIT
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
225
README.md
Normal file
225
README.md
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
# chunkah
|
||||
|
||||
An OCI building tool that takes a flat rootfs and outputs a layered OCI image
|
||||
with content-based layers.
|
||||
|
||||
> [!NOTE]
|
||||
> chunkah is currently under heavy development and not yet ready for production.
|
||||
> Experimental usage and feedback is much appreciated!
|
||||
|
||||
## Motivation
|
||||
|
||||
Traditionally, images built using a `Dockerfile` result in a multi-layered image
|
||||
which model how the `Dockerfile` was written. For example, a separate layer
|
||||
is created for each `RUN` and `COPY` instructions. This can cause poor layer
|
||||
caching on clients pulling these images. A single package change may invalidate
|
||||
a layer much larger than the package itself, requiring re-pulling.
|
||||
|
||||
When splitting an image into content-based layers, it doesn't matter how the
|
||||
final contents of the image were derived. The image is "postprocessed" so that
|
||||
layers are created in a way that tries to maximize layer reuse. Commonly, this
|
||||
means grouping together related packages. This has benefits at various levels:
|
||||
at the network level (common layers do not need to be re-pulled), at the storage
|
||||
level (common layers are stored once), and at the runtime level (e.g. libraries
|
||||
are only mapped once).
|
||||
|
||||
chunkah allows you to keep building your image as you currently do, and then
|
||||
perform this content-based layer splitting.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Content agnostic** — Compatible with RPM-based images, but not only. Other
|
||||
package ecosystems can be supported, as well as fully custom content.
|
||||
- **Container-native** — Best used as a container image, either as part of a
|
||||
multi-staged build, or standalone.
|
||||
- **Zero diff** — Apart from modification time, content is never modified.
|
||||
- **Reproducible** — Supports `SOURCE_DATE_EPOCH` for reproducible layers.
|
||||
|
||||
It is a non-goal to support initial building of the root filesystem itself.
|
||||
Lots of tools for that exist already. It is also currently a non-goal to
|
||||
preprocess the rootfs to remove common sources of non-reproducibility (such as
|
||||
[add-determinism]). This can be done by the image build process itself.
|
||||
|
||||
## Installation
|
||||
|
||||
While it's possible to install chunkah as a native CLI tool using `cargo
|
||||
install`, it's primarily intended to be used as a container image:
|
||||
|
||||
```shell
|
||||
podman run -ti --rm quay.io/jlebon/chunkah --help
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
There are two main ways to use chunkah:
|
||||
|
||||
- splitting an existing image
|
||||
- splitting an image at build time
|
||||
|
||||
### Splitting an existing image
|
||||
|
||||
#### Using Podman/Buildah
|
||||
|
||||
When using Podman/Buildah, the most natural way to split an existing image is to
|
||||
use the `Containerfile.splitter`, passing the target image as the `--from`:
|
||||
|
||||
```shell
|
||||
IMG=quay.io/fedora/fedora-minimal:latest
|
||||
buildah build --skip-unused-stages=false --from $IMG \
|
||||
--build-arg CHUNKAH_CONFIG_STR="$(podman inspect $IMG)" \
|
||||
https://github.com/jlebon/chunkah/releases/download/latest/Containerfile.splitter
|
||||
```
|
||||
|
||||
Additional arguments can be passed to chunkah using the CHUNKAH_ARGS build
|
||||
argument.
|
||||
|
||||
> [!NOTE]
|
||||
> You must add the `--skip-unused-stages=false` option (see also [this buildah
|
||||
> RFE][buildah-rfe]).
|
||||
>
|
||||
> For Buildah versions before v1.43, this also requires `-v $(pwd):/run/src
|
||||
> --security-opt=label=disable`.
|
||||
|
||||
Another option is using the chunkah image directly and image mounts:
|
||||
|
||||
```shell
|
||||
IMG=quay.io/fedora/fedora-minimal:latest
|
||||
podman pull $IMG # image must be available locally
|
||||
export CHUNKAH_CONFIG_STR=$(podman inspect $IMG)
|
||||
podman run --rm --mount=type=image,src=$IMG,dest=/chunkah \
|
||||
-e CHUNKAH_CONFIG_STR quay.io/jlebon/chunkah build | podman load
|
||||
```
|
||||
|
||||
#### Using Docker/Moby
|
||||
|
||||
You can use the chunkah image directly using image mounts (requires v28+):
|
||||
|
||||
```shell
|
||||
IMG=quay.io/fedora/fedora-minimal:latest
|
||||
docker pull $IMG # image must be available locally
|
||||
export CHUNKAH_CONFIG_STR=$(docker inspect $IMG)
|
||||
docker run --rm --mount=type=image,src=$IMG,destination=/chunkah \
|
||||
-e CHUNKAH_CONFIG_STR quay.io/jlebon/chunkah build > out.ociarchive
|
||||
docker run --rm -ti -v $(pwd):/srv:z -w /srv quay.io/skopeo/stable \
|
||||
copy oci-archive:out.ociarchive docker-archive:out.dockerarchive
|
||||
docker load -i out.dockerarchive
|
||||
```
|
||||
|
||||
Note the conversion step using `skopeo`; `chunkah` currently only outputs an OCI
|
||||
archive, which `docker load` does not natively support.
|
||||
|
||||
### Splitting an image at build time (buildah/podman only)
|
||||
|
||||
This uses a method called the "`FROM oci-archive:` trick", for lack of a better
|
||||
term. It has the massive advantage of being compatible with a regular `buildah
|
||||
build` flow and also makes it more natural to apply configs to the image, but is
|
||||
specific to the Podman ecosystem. This *will not* work with Docker.
|
||||
|
||||
```Dockerfile
|
||||
FROM quay.io/fedora/fedora-minimal:latest AS builder
|
||||
RUN dnf install -y git-core && dnf clean all
|
||||
|
||||
FROM quay.io/jlebon/chunkah AS chunkah
|
||||
RUN --mount=from=builder,src=/,target=/chunkah,ro \
|
||||
--mount=type=bind,target=/run/src,rw \
|
||||
chunkah build > /run/src/out.ociarchive
|
||||
|
||||
FROM oci-archive:out.ociarchive
|
||||
ENTRYPOINT ["git"]
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> When building your image, you must also add the `--skip-unused-stages=false`
|
||||
> option (see also [this buildah RFE][buildah-rfe]), and you cannot use the
|
||||
> `--jobs` option.
|
||||
>
|
||||
> For Buildah versions before v1.43, this also requires `-v $(pwd):/run/src
|
||||
> --security-opt=label=disable`.
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Understanding components
|
||||
|
||||
A component is a logical grouping of files that belong together. For example,
|
||||
all files from an RPM belong to the same component. Layers are created based on
|
||||
found components.
|
||||
|
||||
A component repo is a source of data from which components can be created. For
|
||||
example, the rpmdb is a component repo (one can imagine similar component repos
|
||||
for other distros). There is also an xattr-based component repo (see the section
|
||||
"Customizing the layers" below). Multiple component repos can be active at once.
|
||||
|
||||
### Customizing the layers
|
||||
|
||||
It is possible to modify how components are assigned to layers by setting the
|
||||
`user.component` xattr on files/directories. This can be done using `setfattr`,
|
||||
e.g.:
|
||||
|
||||
```Dockerfile
|
||||
RUN setfattr -n user.component -v "" /
|
||||
RUN setfattr -n user.component -v "custom-apps" /usr/bin/my-app
|
||||
```
|
||||
|
||||
Note chunkah requires setting an empty string value on the rootfs itself
|
||||
to enable xattr scanning. This avoids having to check the xattrs of the
|
||||
whole rootfs in the common case where this feature is not used. Apart from
|
||||
this detail, this is compatible with rpm-ostree's support for [the same
|
||||
feature](https://coreos.github.io/rpm-ostree/build-chunked-oci/#assigning-files-to-specific-layers).
|
||||
|
||||
### Limiting the number of layers
|
||||
|
||||
By default, the maximum number of layers emitted is 64. This can be increased
|
||||
(up to 448) or decreased using the `--max-layers` option. If the number of
|
||||
components exceeds the maximum, chunkah will pack multiple components together.
|
||||
There is thus a tradeoff in deciding this. Fewer layers means losing the
|
||||
efficiency gains of content-based layers. Too many layers may mean excessive
|
||||
processing and overhead when pushing/pulling the image.
|
||||
|
||||
### Building from a raw rootfs
|
||||
|
||||
For completeness, note it's of course also possible to split any arbitrary
|
||||
rootfs, regardless of where it comes from.
|
||||
|
||||
```shell
|
||||
podman run --rm -v root:/chunkah:z -e CHUNKAH_CONFIG_STR=$(cat config.json) \
|
||||
quay.io/jlebon/chunkah build > out.ociarchive
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The `:z` option will relabel all files for access by the container, which may
|
||||
> be expensive for a large rootfs. You can use `--security-opt=label=disable` to
|
||||
> avoid this, but it disables SELinux separation with the chunkah container.
|
||||
|
||||
### Customizing the OCI image config and annotations
|
||||
|
||||
The OCI image config can be provided via the `--config` option (as a file) or
|
||||
`--config-str`/`CHUNKAH_CONFIG_STR` (inline). The primary format is the [OCI
|
||||
image config] spec as JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"Entrypoint": ["/bin/bash"],
|
||||
"Cmd": ["-c", "echo hi"],
|
||||
"WorkDir": "/root"
|
||||
}
|
||||
```
|
||||
|
||||
The output format of `podman inspect` and `docker inspect` are also supported,
|
||||
mostly for convenience when splitting an existing image, though it does also
|
||||
have the advantage of capturing annotations. Otherwise, it's also possible to
|
||||
set annotations directly using `--annotation`. Labels can also be added via
|
||||
`--label`.
|
||||
|
||||
## Origins
|
||||
|
||||
chunkah is a generalized successor to rpm-ostree's [build-chunked-oci] command
|
||||
which does content-based layer splitting on RPM-based [bootable container
|
||||
images]. Unlike rpm-ostree, chunkah is not tied to bootable containers nor RPMs.
|
||||
The name is a nod to this ancestry and to buildah, with which it integrates
|
||||
well.
|
||||
|
||||
[add-determinism]: https://github.com/keszybz/add-determinism
|
||||
[bootable container images]: https://containers.github.io/bootable/
|
||||
[build-chunked-oci]: https://coreos.github.io/rpm-ostree/build-chunked-oci/
|
||||
[OCI image config]: https://github.com/opencontainers/image-spec/blob/26647a49f642c7d22a1cd3aa0a48e4650a542269/specs-go/v1/config.go#L24
|
||||
[buildah-rfe]: https://github.com/containers/buildah/issues/6621
|
||||
572
src/cmd_build.rs
Normal file
572
src/cmd_build.rs
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use camino::Utf8PathBuf;
|
||||
use cap_std::ambient_authority;
|
||||
use cap_std::fs::Dir;
|
||||
use clap::Parser;
|
||||
use ocidir::oci_spec::image as oci_image;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::components::{Component, FileMap};
|
||||
use crate::ocibuilder::{Builder, Compression};
|
||||
use crate::packing::{PackItem, calculate_packing};
|
||||
use crate::utils;
|
||||
|
||||
#[derive(Parser, Default)]
|
||||
pub struct BuildArgs {
|
||||
/// Path to the rootfs to build from
|
||||
#[arg(long, env = "CHUNKAH_ROOTFS", hide_env_values = true)]
|
||||
rootfs: Utf8PathBuf,
|
||||
|
||||
/// Output file path (defaults to stdout)
|
||||
#[arg(short, long, value_name = "PATH")]
|
||||
output: Option<Utf8PathBuf>,
|
||||
|
||||
/// Maximum number of layers to output
|
||||
#[arg(long, default_value_t = 64)]
|
||||
max_layers: usize,
|
||||
|
||||
/// Read image config from a JSON file
|
||||
///
|
||||
/// The file should contain the .Config element from a podman/docker
|
||||
/// inspect output. This is useful when resplitting an existing image.
|
||||
#[arg(long = "config", value_name = "PATH", conflicts_with = "config_str")]
|
||||
config: Option<Utf8PathBuf>,
|
||||
|
||||
/// Read image config from a JSON string
|
||||
///
|
||||
/// Same as --config but takes a JSON string directly instead of a file path.
|
||||
#[arg(
|
||||
long = "config-str",
|
||||
value_name = "JSON",
|
||||
env = "CHUNKAH_CONFIG_STR",
|
||||
hide_env_values = true
|
||||
)]
|
||||
config_str: Option<String>,
|
||||
|
||||
/// Add a label to the image
|
||||
///
|
||||
/// Format: KEY=VALUE. Can be specified multiple times.
|
||||
#[arg(long = "label", value_name = "KEY=VALUE")]
|
||||
labels: Vec<String>,
|
||||
|
||||
/// Add an annotation to the image manifest
|
||||
///
|
||||
/// Format: KEY=VALUE. Can be specified multiple times.
|
||||
#[arg(long = "annotation", value_name = "KEY=VALUE")]
|
||||
annotations: Vec<String>,
|
||||
|
||||
/// Unix timestamp used as the creation time for the OCI image and as
|
||||
/// the maximum mtime for files without a known build time.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "EPOCH",
|
||||
env = "SOURCE_DATE_EPOCH",
|
||||
hide_env_values = true
|
||||
)]
|
||||
source_date_epoch: Option<u64>,
|
||||
|
||||
/// Compress layers and the OCI archive with gzip
|
||||
///
|
||||
/// By default, layers and the OCI archive are uncompressed. This flag
|
||||
/// enables gzip compression for both.
|
||||
#[arg(long)]
|
||||
compressed: bool,
|
||||
|
||||
/// Gzip compression level (0-9, default: 6)
|
||||
///
|
||||
/// Level 0 is no compression (fastest), 9 is maximum compression (slowest).
|
||||
/// Only applies when --compressed is specified.
|
||||
#[arg(long, value_name = "LEVEL", default_value_t = 6, value_parser = clap::value_parser!(u32).range(0..=9))]
|
||||
compression_level: u32,
|
||||
|
||||
/// Target architecture for the output image
|
||||
///
|
||||
/// If not provided, the architecture from the config is used if found, or
|
||||
/// the current system architecture otherwise.
|
||||
#[arg(long, value_name = "ARCH")]
|
||||
arch: Option<String>,
|
||||
}
|
||||
|
||||
impl BuildArgs {
|
||||
/// Apply CLI overrides to an OCI config, returning a new config.
|
||||
fn apply_to_config(&self, config: oci_image::Config) -> Result<oci_image::Config> {
|
||||
let mut builder = oci_image::ConfigBuilder::default();
|
||||
|
||||
// Copy over all fields from base config. Would be nice if we could
|
||||
// instantiate a ConfigBuilder from a starting Config instead...
|
||||
macro_rules! copy_if_present {
|
||||
($($field:ident),+) => {
|
||||
$(
|
||||
if let Some(v) = config.$field() {
|
||||
builder = builder.$field(v.clone());
|
||||
}
|
||||
)+
|
||||
};
|
||||
}
|
||||
copy_if_present!(
|
||||
user,
|
||||
working_dir,
|
||||
stop_signal,
|
||||
entrypoint,
|
||||
cmd,
|
||||
env,
|
||||
exposed_ports,
|
||||
volumes
|
||||
);
|
||||
|
||||
// labels; CLI args override config
|
||||
let labels =
|
||||
parse_key_value_pairs(&self.labels, config.labels().clone().unwrap_or_default())
|
||||
.context("parsing labels")?;
|
||||
if !labels.is_empty() {
|
||||
builder = builder.labels(labels);
|
||||
}
|
||||
|
||||
builder.build().context("building config")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(args: &BuildArgs) -> Result<()> {
|
||||
let created_epoch = args
|
||||
.source_date_epoch
|
||||
.map_or_else(utils::get_current_epoch, Ok)?;
|
||||
|
||||
// load base config from file, string, or use empty default
|
||||
let parsed = if let Some(path) = &args.config {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("failed to read config file: {}", path))?;
|
||||
parse_config(&content).with_context(|| format!("failed to parse config file: {}", path))?
|
||||
} else if let Some(config_str) = &args.config_str {
|
||||
parse_config(config_str).context("failed to parse config string")?
|
||||
} else {
|
||||
ParsedConfig {
|
||||
config: oci_image::Config::default(),
|
||||
annotations: HashMap::new(),
|
||||
architecture: None,
|
||||
}
|
||||
};
|
||||
|
||||
let architecture = args.arch.as_deref().or(parsed.architecture.as_deref());
|
||||
// get the current arch if not provided, but even if provided, this
|
||||
// normalizes the arch so that `--arch x86_64` also works
|
||||
let architecture = utils::get_goarch(architecture);
|
||||
|
||||
// merge config and CLI annotations
|
||||
let annotations = parse_key_value_pairs(&args.annotations, parsed.annotations)
|
||||
.context("parsing annotations")?;
|
||||
|
||||
let image_config = build_image_config(args, parsed.config, created_epoch, architecture)
|
||||
.context("building image config")?;
|
||||
|
||||
let rootfs = Dir::open_ambient_dir(args.rootfs.as_std_path(), ambient_authority())
|
||||
.with_context(|| format!("opening rootfs {}", args.rootfs))?;
|
||||
|
||||
let components = crate::scan::scan_for_components(&rootfs, created_epoch)
|
||||
.with_context(|| format!("scanning {} for components", args.rootfs))?;
|
||||
|
||||
// pack components down to max layers
|
||||
let components = pack_components(args, components).context("packing components")?;
|
||||
|
||||
// build the OCI image
|
||||
let compression = if args.compressed {
|
||||
Compression::Gzip(args.compression_level)
|
||||
} else {
|
||||
Compression::None
|
||||
};
|
||||
|
||||
let builder = Builder::new(&rootfs, components)
|
||||
.context("creating builder")?
|
||||
.compression(compression)
|
||||
.annotations(annotations)
|
||||
.config(image_config);
|
||||
|
||||
if let Some(output_path) = &args.output {
|
||||
let mut file = std::fs::File::create(output_path)
|
||||
.with_context(|| format!("creating output file {}", output_path))?;
|
||||
builder.build(&mut file)
|
||||
} else {
|
||||
builder.build(&mut std::io::stdout().lock())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse config from a JSON string.
|
||||
///
|
||||
/// Supports three formats:
|
||||
/// 1. Direct OCI config (e.g., `{"Entrypoint": [...]}`)
|
||||
/// 2. podman/docker inspect output array (e.g., `[{"Config": {...}}]`)
|
||||
/// 3. Single inspect output object (e.g., `{"Config": {...}}`)
|
||||
fn parse_config(json_str: &str) -> Result<ParsedConfig> {
|
||||
let input: ConfigInput =
|
||||
serde_json::from_str(json_str).context("failed to parse config JSON")?;
|
||||
match input {
|
||||
ConfigInput::Inspect(mut vec) => vec
|
||||
.pop()
|
||||
.ok_or_else(|| anyhow::anyhow!("inspect output is an empty array")),
|
||||
ConfigInput::InspectOne(parsed) => Ok(parsed),
|
||||
ConfigInput::Direct(config) => Ok(ParsedConfig {
|
||||
config,
|
||||
annotations: HashMap::new(),
|
||||
architecture: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed config data from either OCI config or podman/docker inspect format.
|
||||
/// The serde renames allow this to deserialize from inspect format (with "Config" key).
|
||||
#[derive(Deserialize)]
|
||||
struct ParsedConfig {
|
||||
#[serde(rename = "Config")]
|
||||
config: oci_image::Config,
|
||||
#[serde(rename = "Annotations", default)]
|
||||
annotations: HashMap<String, String>,
|
||||
#[serde(rename = "Architecture")]
|
||||
architecture: Option<String>,
|
||||
}
|
||||
|
||||
/// Config input format - either direct OCI config or podman/docker inspect output.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum ConfigInput {
|
||||
/// podman/docker inspect output (array with Config field)
|
||||
Inspect(Vec<ParsedConfig>),
|
||||
/// Single inspect output object (e.g., first element extracted from array)
|
||||
InspectOne(ParsedConfig),
|
||||
/// Direct OCI config (e.g., `{"Entrypoint": [...]}`)
|
||||
Direct(oci_image::Config),
|
||||
}
|
||||
|
||||
/// Build the OCI image configuration from CLI args and a parsed config.
|
||||
fn build_image_config(
|
||||
args: &BuildArgs,
|
||||
config: oci_image::Config,
|
||||
created: u64,
|
||||
architecture: &str,
|
||||
) -> Result<oci_image::ImageConfiguration> {
|
||||
// apply CLI configs to base OCI config
|
||||
let config = args
|
||||
.apply_to_config(config)
|
||||
.context("applying CLI configs")?;
|
||||
|
||||
// this is empty for now; it gets populated as we add components
|
||||
let rootfs = oci_image::RootFsBuilder::default()
|
||||
.typ("layers")
|
||||
.diff_ids(Vec::<String>::new())
|
||||
.build()?;
|
||||
|
||||
// format the created timestamp as RFC 3339
|
||||
let epoch_i64 = i64::try_from(created)
|
||||
.with_context(|| format!("created timestamp overflows i64: {created}"))?;
|
||||
let created = chrono::DateTime::from_timestamp(epoch_i64, 0)
|
||||
.with_context(|| format!("invalid created timestamp: {}", created))?
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||
|
||||
let image_config = oci_image::ImageConfigurationBuilder::default()
|
||||
.os("linux")
|
||||
.architecture(architecture)
|
||||
.config(config)
|
||||
.rootfs(rootfs)
|
||||
.created(created)
|
||||
.build()?;
|
||||
|
||||
Ok(image_config)
|
||||
}
|
||||
|
||||
/// Parse KEY=VALUE pairs and merge into an existing map.
|
||||
fn parse_key_value_pairs(
|
||||
pairs: &[String],
|
||||
mut map: HashMap<String, String>,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
for pair in pairs {
|
||||
let (k, v) = pair
|
||||
.split_once('=')
|
||||
.with_context(|| format!("must be in KEY=VALUE format: {pair}"))?;
|
||||
anyhow::ensure!(!k.is_empty(), "key cannot be empty: {pair}");
|
||||
map.insert(k.to_string(), v.to_string());
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Packs components into layers according to max_layers constraint.
|
||||
fn pack_components(
|
||||
args: &BuildArgs,
|
||||
components: HashMap<String, Component>,
|
||||
) -> Result<Vec<(String, Component)>> {
|
||||
let max_layers = args.max_layers;
|
||||
|
||||
let mut entries: Vec<Option<(String, Component)>> = components.into_iter().map(Some).collect();
|
||||
|
||||
let items: Vec<PackItem> = entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let (_, comp) = entry.as_ref().unwrap();
|
||||
PackItem {
|
||||
size: comp.files.values().map(|f| f.size).sum(),
|
||||
stability: comp.stability,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let packed_groups = calculate_packing(&items, max_layers);
|
||||
|
||||
let mut result = Vec::with_capacity(packed_groups.len());
|
||||
|
||||
for group in packed_groups {
|
||||
if group.indices.len() == 1 {
|
||||
// single component group
|
||||
let idx = group.indices[0];
|
||||
let (name, component) = entries[idx].take().expect("packing returned invalid index");
|
||||
result.push((name, component));
|
||||
} else {
|
||||
// merged group - combine components
|
||||
let mut names = Vec::with_capacity(group.indices.len());
|
||||
let mut merged_files = FileMap::new();
|
||||
let mut max_mtime_clamp = 0u64;
|
||||
|
||||
for &idx in &group.indices {
|
||||
let (name, comp) = entries[idx].take().expect("packing returned invalid index");
|
||||
names.push(name);
|
||||
// Move "up" the clamp. We're still guaranteed that it's (1)
|
||||
// a reproducible timestamp for this particular group, and
|
||||
// (2) it's very unlikely to be after $now so we'll clamp
|
||||
// scriptlet-created files.
|
||||
max_mtime_clamp = max_mtime_clamp.max(comp.mtime_clamp);
|
||||
merged_files.extend(comp.files);
|
||||
}
|
||||
|
||||
let merged_name = names.join(" ");
|
||||
result.push((
|
||||
merged_name,
|
||||
Component {
|
||||
mtime_clamp: max_mtime_clamp,
|
||||
stability: group.stability,
|
||||
files: merged_files,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const CONFIG_FIXTURE: &str = include_str!("../tests/fixtures/empty.image-config.json");
|
||||
|
||||
#[test]
|
||||
fn test_emptydir_roundtrip() {
|
||||
// Create an OCI archive from an empty rootfs. Then re-open it with
|
||||
// ocidir and check it's what we expect.
|
||||
|
||||
// create a temp directory for the empty rootfs
|
||||
let rootfs_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let args = BuildArgs {
|
||||
rootfs: Utf8PathBuf::try_from(rootfs_dir.path().to_path_buf()).unwrap(),
|
||||
source_date_epoch: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// parse config from fixture file
|
||||
let parsed = parse_config(CONFIG_FIXTURE).unwrap();
|
||||
let image_config = build_image_config(&args, parsed.config, 1, "amd64").unwrap();
|
||||
|
||||
let rootfs =
|
||||
cap_std::fs::Dir::open_ambient_dir(rootfs_dir.path(), cap_std::ambient_authority())
|
||||
.unwrap();
|
||||
|
||||
// create a single empty component for testing
|
||||
let components = vec![(
|
||||
"test".to_string(),
|
||||
Component {
|
||||
mtime_clamp: 1,
|
||||
stability: 0.0,
|
||||
files: Default::default(),
|
||||
},
|
||||
)];
|
||||
|
||||
// and build the OCI image
|
||||
let builder = Builder::new(&rootfs, components)
|
||||
.unwrap()
|
||||
.compression(Compression::None)
|
||||
.annotations(parsed.annotations)
|
||||
.config(image_config);
|
||||
let mut output = Vec::new();
|
||||
builder.build(&mut output).unwrap();
|
||||
|
||||
// now extract it back out and open it as an ocidir
|
||||
let oci_tempdir = tempfile::tempdir().unwrap();
|
||||
let mut archive = tar::Archive::new(output.as_slice());
|
||||
archive.unpack(oci_tempdir.path()).unwrap();
|
||||
|
||||
let oci_dir_cap =
|
||||
cap_std::fs::Dir::open_ambient_dir(oci_tempdir.path(), cap_std::ambient_authority())
|
||||
.unwrap();
|
||||
let oci_dir = ocidir::OciDir::open(oci_dir_cap).unwrap();
|
||||
|
||||
// get the image manifest (we don't set a tag, so use the index)
|
||||
let index = oci_dir.read_index().unwrap();
|
||||
let manifest_desc = index.manifests().first().unwrap();
|
||||
let manifest: oci_image::ImageManifest = oci_dir.read_json_blob(manifest_desc).unwrap();
|
||||
|
||||
// get the image config and verify it matches the fixture
|
||||
let image_config: oci_image::ImageConfiguration =
|
||||
oci_dir.read_json_blob(manifest.config()).unwrap();
|
||||
let config = image_config.config().clone().unwrap();
|
||||
let expected: oci_image::Config = serde_json::from_str(CONFIG_FIXTURE).unwrap();
|
||||
assert_eq!(config, expected);
|
||||
|
||||
// verify the created timestamp is set correctly (epoch 1 = 1970-01-01T00:00:01Z)
|
||||
assert_eq!(
|
||||
image_config.created().as_deref(),
|
||||
Some("1970-01-01T00:00:01Z")
|
||||
);
|
||||
|
||||
// verify there's no history
|
||||
assert!(
|
||||
image_config.history().as_ref().is_none_or(|h| h.is_empty()),
|
||||
"image should have no history entries"
|
||||
);
|
||||
|
||||
// verify there are no layers (empty components are filtered out)
|
||||
assert!(
|
||||
manifest.layers().is_empty(),
|
||||
"empty rootfs should have no layers"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_config_direct_format() {
|
||||
// Test parsing direct OCI config format
|
||||
let json = r#"{"Entrypoint": ["/bin/sh"], "Cmd": ["-c", "echo hi"]}"#;
|
||||
let parsed = parse_config(json).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed.config.entrypoint(),
|
||||
&Some(vec!["/bin/sh".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.config.cmd(),
|
||||
&Some(vec!["-c".to_string(), "echo hi".to_string()])
|
||||
);
|
||||
// Direct OCI config format has no Architecture field
|
||||
assert_eq!(parsed.architecture, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_config_inspect_format() {
|
||||
// Test parsing podman/docker inspect format
|
||||
let json = r#"[{
|
||||
"Config": {
|
||||
"Entrypoint": ["/usr/bin/app"],
|
||||
"Env": ["PATH=/usr/bin"]
|
||||
},
|
||||
"Annotations": {
|
||||
"org.example.key": "value"
|
||||
},
|
||||
"Architecture": "arm64"
|
||||
}]"#;
|
||||
let parsed = parse_config(json).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed.config.entrypoint(),
|
||||
&Some(vec!["/usr/bin/app".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.config.env(),
|
||||
&Some(vec!["PATH=/usr/bin".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.annotations.get("org.example.key"),
|
||||
Some(&"value".to_string())
|
||||
);
|
||||
assert_eq!(parsed.architecture, Some("arm64".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_config_inspect_single_object() {
|
||||
// Test parsing a single inspect object (not wrapped in array)
|
||||
let json = r#"{"Config": {"Entrypoint": ["/bin/app"], "WorkingDir": "/data"}, "Architecture": "amd64"}"#;
|
||||
let parsed = parse_config(json).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed.config.entrypoint(),
|
||||
&Some(vec!["/bin/app".to_string()])
|
||||
);
|
||||
assert_eq!(parsed.config.working_dir(), &Some("/data".to_string()));
|
||||
assert_eq!(parsed.architecture, Some("amd64".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_value_pairs_invalid() {
|
||||
let invalid_pairs = ["", "no-equals", "=", "=value"];
|
||||
|
||||
for pair in invalid_pairs {
|
||||
let pairs = vec![pair.into()];
|
||||
let result = parse_key_value_pairs(&pairs, HashMap::new());
|
||||
assert!(result.is_err(), "pair {:?} should be rejected", pair);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_value_pairs_valid() {
|
||||
use maplit::hashmap;
|
||||
|
||||
let test_cases: &[(&str, HashMap<String, String>)] = &[
|
||||
("key=value", hashmap! { "key".into() => "value".into() }),
|
||||
("key=", hashmap! { "key".into() => "".into() }),
|
||||
(
|
||||
"key=value=with=equals",
|
||||
hashmap! { "key".into() => "value=with=equals".into() },
|
||||
),
|
||||
];
|
||||
|
||||
for (pair, expected) in test_cases {
|
||||
let pairs = vec![(*pair).into()];
|
||||
let result = parse_key_value_pairs(&pairs, HashMap::new()).unwrap();
|
||||
assert_eq!(
|
||||
&result, expected,
|
||||
"pair {:?} should produce {:?}",
|
||||
pair, expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_image_config_labels_override() {
|
||||
// Base config with pre-existing labels
|
||||
let json = r#"{
|
||||
"Labels": {
|
||||
"existing": "from-config",
|
||||
"override-me": "old-value"
|
||||
}
|
||||
}"#;
|
||||
let parsed = parse_config(json).unwrap();
|
||||
|
||||
// CLI labels that override one, add a new one, and later CLI label overrides earlier
|
||||
let args = BuildArgs {
|
||||
labels: vec![
|
||||
"override-me=new-value".into(),
|
||||
"new-label=first".into(),
|
||||
"new-label=second".into(), // later CLI label overrides earlier
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let image_config = build_image_config(&args, parsed.config, 1, "amd64").unwrap();
|
||||
let labels = image_config
|
||||
.config()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.labels()
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(labels.get("existing"), Some(&"from-config".to_string()));
|
||||
assert_eq!(labels.get("override-me"), Some(&"new-value".to_string()));
|
||||
assert_eq!(labels.get("new-label"), Some(&"second".to_string()));
|
||||
}
|
||||
}
|
||||
328
src/components/mod.rs
Normal file
328
src/components/mod.rs
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
mod rpm;
|
||||
mod xattr;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
/// The name of the component for files not claimed by any repo.
|
||||
pub const UNCLAIMED_COMPONENT: &str = "chunkah/unclaimed";
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use camino::{Utf8Path, Utf8PathBuf};
|
||||
use cap_std::fs::Dir;
|
||||
|
||||
/// Seconds per day.
|
||||
pub const SECS_PER_DAY: u64 = 60 * 60 * 24;
|
||||
|
||||
/// Period in days for calculating stability probability.
|
||||
/// TODO: make this configurable via CLI
|
||||
pub const STABILITY_PERIOD_DAYS: f64 = 7.0;
|
||||
|
||||
/// Maximum lookback period in days for changelog analysis.
|
||||
pub const STABILITY_LOOKBACK_DAYS: u64 = 365;
|
||||
|
||||
/// Loaded component repos along with the default mtime to use.
|
||||
pub struct ComponentsRepos {
|
||||
repos: Vec<Box<dyn ComponentsRepo>>,
|
||||
default_mtime_clamp: u64,
|
||||
}
|
||||
|
||||
/// Files belonging to a component.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Component {
|
||||
/// The maximum mtime for files in this component during the build phase.
|
||||
/// File mtimes will be clamped to this value.
|
||||
pub mtime_clamp: u64,
|
||||
/// Probability that the component doesn't change over STABILITY_PERIOD_DAYS.
|
||||
/// Used by the packing algorithm.
|
||||
pub stability: f64,
|
||||
/// The files belonging to this component, with their metadata.
|
||||
pub files: FileMap,
|
||||
}
|
||||
|
||||
/// A map from file paths to their metadata.
|
||||
pub type FileMap = BTreeMap<Utf8PathBuf, FileInfo>;
|
||||
|
||||
/// Cached file metadata from the scan.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileInfo {
|
||||
pub file_type: FileType,
|
||||
pub mode: u32,
|
||||
#[allow(dead_code)]
|
||||
pub size: u64,
|
||||
pub uid: u32,
|
||||
pub gid: u32,
|
||||
pub mtime: u64,
|
||||
pub ino: u64,
|
||||
pub nlink: u64,
|
||||
pub xattrs: Vec<(String, Vec<u8>)>,
|
||||
}
|
||||
|
||||
/// File type for entries in the rootfs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FileType {
|
||||
Directory,
|
||||
File,
|
||||
Symlink,
|
||||
}
|
||||
|
||||
impl FileInfo {
|
||||
/// Create FileInfo from metadata and xattrs.
|
||||
pub fn from_metadata(
|
||||
metadata: &cap_std::fs::Metadata,
|
||||
xattrs: Vec<(String, Vec<u8>)>,
|
||||
) -> Result<Self> {
|
||||
use cap_std::fs::MetadataExt;
|
||||
|
||||
let file_type = if metadata.file_type().is_dir() {
|
||||
FileType::Directory
|
||||
} else if metadata.file_type().is_file() {
|
||||
FileType::File
|
||||
} else if metadata.file_type().is_symlink() {
|
||||
FileType::Symlink
|
||||
} else {
|
||||
anyhow::bail!("unsupported file type");
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
file_type,
|
||||
mode: metadata.mode(),
|
||||
size: metadata.len(),
|
||||
uid: metadata.uid(),
|
||||
gid: metadata.gid(),
|
||||
mtime: metadata.mtime() as u64,
|
||||
ino: metadata.ino(),
|
||||
nlink: metadata.nlink(),
|
||||
xattrs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ComponentsRepos {
|
||||
/// Detect and load all component repos present in the given rootfs.
|
||||
///
|
||||
/// The `files` map is the set of paths in the rootfs. This avoids the xattr
|
||||
/// repo having to walk the rootfs again. The `default_mtime_clamp` will be
|
||||
/// used as the mtime clamp for components that don't have a reproducible
|
||||
/// clamp (e.g. xattr-claimed files, unclaimed files).
|
||||
pub fn load(rootfs: &Dir, files: &FileMap, default_mtime_clamp: u64) -> Result<Self> {
|
||||
let mut repos: Vec<Box<dyn ComponentsRepo>> = Vec::new();
|
||||
|
||||
if let Some(repo) =
|
||||
xattr::XattrRepo::load(files, default_mtime_clamp).context("loading xattrs")?
|
||||
{
|
||||
repos.push(Box::new(repo));
|
||||
}
|
||||
|
||||
if let Some(repo) = rpm::RpmRepo::load(rootfs).context("loading rpmdb")? {
|
||||
repos.push(Box::new(repo));
|
||||
}
|
||||
|
||||
// Other backends (e.g. deb, apk, pip, etc.) would go here...
|
||||
|
||||
Ok(Self {
|
||||
repos,
|
||||
default_mtime_clamp,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns true if no repos were loaded.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.repos.is_empty()
|
||||
}
|
||||
|
||||
/// Claim files from repos and return the mapping of component names to files.
|
||||
///
|
||||
/// Repos are sorted by priority (lower values first) before processing.
|
||||
/// Higher priority repos "win" - if they claim a path, lower priority repos
|
||||
/// are not consulted for that path. All unclaimed paths go into a catch-all.
|
||||
pub fn into_components(mut self, files: FileMap) -> HashMap<String, Component> {
|
||||
let mut claims: HashMap<(usize, ComponentId), FileMap> = HashMap::new();
|
||||
|
||||
// make sure they're in priority order
|
||||
self.repos.sort_by_key(|r| r.default_priority());
|
||||
|
||||
// check for claims!
|
||||
let unclaimed: FileMap = files
|
||||
.into_iter()
|
||||
.filter_map(|(path, file_info)| {
|
||||
// This is O(files x repos), though really the number of active
|
||||
// repos at any time is incredibly small; in the common case, 1.
|
||||
for (repo_idx, repo) in self.repos.iter().enumerate() {
|
||||
let component_ids = repo.claims_for_path(&path, file_info.file_type);
|
||||
if !component_ids.is_empty() {
|
||||
for id in component_ids {
|
||||
claims
|
||||
.entry((repo_idx, id))
|
||||
.or_default()
|
||||
.insert(path.clone(), file_info.clone());
|
||||
}
|
||||
return None; // claimed
|
||||
}
|
||||
}
|
||||
Some((path, file_info)) // not claimed
|
||||
})
|
||||
.collect();
|
||||
|
||||
// build final components map
|
||||
let mut components = HashMap::new();
|
||||
for ((repo_idx, comp_id), files) in claims {
|
||||
let repo = &self.repos[repo_idx];
|
||||
let info = repo.component_info(comp_id);
|
||||
let full_name = format!("{}/{}", repo.name(), info.name);
|
||||
components.insert(
|
||||
full_name,
|
||||
Component {
|
||||
mtime_clamp: info.mtime_clamp,
|
||||
stability: info.stability,
|
||||
files,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// and the catch-all component for anything unclaimed
|
||||
if !unclaimed.is_empty() {
|
||||
components.insert(
|
||||
UNCLAIMED_COMPONENT.into(),
|
||||
Component {
|
||||
mtime_clamp: self.default_mtime_clamp,
|
||||
stability: 0.0, // unclaimed files assumed unstable
|
||||
files: unclaimed,
|
||||
},
|
||||
);
|
||||
}
|
||||
components
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque identifier for a component within a repo.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
struct ComponentId(usize);
|
||||
|
||||
/// Information about a component.
|
||||
struct ComponentInfo<'a> {
|
||||
pub name: &'a str,
|
||||
pub mtime_clamp: u64,
|
||||
pub stability: f64,
|
||||
}
|
||||
|
||||
/// A trait for any type of "repo" of components (e.g., rpm, dpkg, etc.)
|
||||
///
|
||||
/// Components repos are query objects that answer which components claim a path.
|
||||
trait ComponentsRepo {
|
||||
/// Returns the name of this repo type (e.g., "rpm", "xattr").
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Returns the priority of this repo for ordering purposes.
|
||||
///
|
||||
/// Lower values indicate higher priority. Used to determine the order in
|
||||
/// which repos are queried. Higher priority repos "win" - if they claim a
|
||||
/// path, lower priority repos are not consulted. We could make the default
|
||||
/// overridable on the CLI in the future.
|
||||
fn default_priority(&self) -> usize;
|
||||
|
||||
/// Query which components claim this path.
|
||||
///
|
||||
/// Returns a list of component IDs that claim this path. For most paths,
|
||||
/// this returns 0 or 1 ID. Directories shared by multiple packages may
|
||||
/// return multiple IDs.
|
||||
fn claims_for_path(&self, path: &Utf8Path, file_type: FileType) -> Vec<ComponentId>;
|
||||
|
||||
/// Get info about a component by ID.
|
||||
fn component_info(&self, id: ComponentId) -> ComponentInfo<'_>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use camino::Utf8Path;
|
||||
use cap_std::ambient_authority;
|
||||
use cap_std::fs::Dir;
|
||||
use cap_std_ext::dirext::CapStdExtDirExt;
|
||||
|
||||
use super::*;
|
||||
|
||||
const RPM_FIXTURE: &str = include_str!("../../tests/fixtures/fedora.json");
|
||||
|
||||
const XATTR_NAME: &str = "user.component";
|
||||
|
||||
#[test]
|
||||
fn test_into_components() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let rootfs = Dir::open_ambient_dir(tmp.path(), ambient_authority()).unwrap();
|
||||
|
||||
rootfs.create_dir_all("usr/bin").unwrap();
|
||||
rootfs.create_dir_all("usr/lib64").unwrap();
|
||||
rootfs.create_dir_all("usr/lib/sysimage/rpm").unwrap();
|
||||
rootfs.create_dir_all("opt/myapp").unwrap();
|
||||
rootfs.write("usr/bin/bash", "fake bash").unwrap();
|
||||
rootfs.write("usr/lib64/libc.so.6", "fake libc").unwrap();
|
||||
rootfs
|
||||
.write("usr/lib/sysimage/rpm/rpmdb.sqlite", "fake rpmdb")
|
||||
.unwrap();
|
||||
rootfs.write("opt/myapp/config", "config").unwrap();
|
||||
rootfs.write("opt/myapp/data", "data").unwrap();
|
||||
|
||||
// set xattr on /usr/bin/bash to claim it for "xattr-component"
|
||||
rootfs
|
||||
.setxattr("usr/bin/bash", XATTR_NAME, b"xattr-component")
|
||||
.unwrap();
|
||||
|
||||
// set xattr on /opt/myapp/data to claim it for "myapp"
|
||||
rootfs
|
||||
.setxattr("opt/myapp/data", XATTR_NAME, b"myapp")
|
||||
.unwrap();
|
||||
|
||||
let files = crate::scan::scan_rootfs(&rootfs).unwrap();
|
||||
|
||||
let xattr_repo = xattr::XattrRepo::load(&files, 0).unwrap().unwrap();
|
||||
let packages = rpm_qa::load_from_str(RPM_FIXTURE).unwrap();
|
||||
let rpm_repo = rpm::RpmRepo::load_from_packages(packages).unwrap();
|
||||
|
||||
let repos: Vec<Box<dyn ComponentsRepo>> = vec![Box::new(rpm_repo), Box::new(xattr_repo)];
|
||||
let loaded = ComponentsRepos {
|
||||
repos,
|
||||
default_mtime_clamp: 0,
|
||||
};
|
||||
|
||||
let components = loaded.into_components(files);
|
||||
|
||||
// example xattr overrides rpm entry
|
||||
assert!(
|
||||
components["xattr/xattr-component"]
|
||||
.files
|
||||
.contains_key(Utf8Path::new("/usr/bin/bash")),
|
||||
"/usr/bin/bash should belong to xattr/xattr-component"
|
||||
);
|
||||
|
||||
// example rpm entry
|
||||
assert!(
|
||||
components["rpm/glibc"]
|
||||
.files
|
||||
.contains_key(Utf8Path::new("/usr/lib64/libc.so.6")),
|
||||
"/usr/lib64/libc.so.6 should belong to rpm/glibc"
|
||||
);
|
||||
|
||||
// example xattr entry
|
||||
assert!(
|
||||
components["xattr/myapp"]
|
||||
.files
|
||||
.contains_key(Utf8Path::new("/opt/myapp/data")),
|
||||
"/opt/myapp/data should belong to xattr/myapp"
|
||||
);
|
||||
|
||||
// example unclaimed entry
|
||||
assert!(
|
||||
components[UNCLAIMED_COMPONENT]
|
||||
.files
|
||||
.contains_key(Utf8Path::new("/opt/myapp/config")),
|
||||
"/opt/myapp/config should be unclaimed"
|
||||
);
|
||||
|
||||
// rpmdb paths should be unclaimed
|
||||
assert!(
|
||||
components[UNCLAIMED_COMPONENT]
|
||||
.files
|
||||
.contains_key(Utf8Path::new("/usr/lib/sysimage/rpm/rpmdb.sqlite")),
|
||||
"/usr/lib/sysimage/rpm/rpmdb.sqlite should be unclaimed"
|
||||
);
|
||||
}
|
||||
}
|
||||
470
src/components/rpm.rs
Normal file
470
src/components/rpm.rs
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use camino::{Utf8Path, Utf8PathBuf};
|
||||
use cap_std::fs::Dir;
|
||||
use indexmap::IndexMap;
|
||||
use rpm_qa::FileInfo;
|
||||
|
||||
use super::{ComponentId, ComponentInfo, ComponentsRepo, FileType};
|
||||
|
||||
const REPO_NAME: &str = "rpm";
|
||||
|
||||
const RPMDB_PATHS: &[&str] = &["usr/lib/sysimage/rpm", "usr/share/rpm", "var/lib/rpm"];
|
||||
|
||||
/// RPM-based components repo implementation.
|
||||
///
|
||||
/// Uses the RPM database to determine file ownership and groups files
|
||||
/// by their SRPM.
|
||||
pub struct RpmRepo {
|
||||
/// Unique component (SRPM) names mapped to (buildtime, stability), indexed by ComponentId.
|
||||
components: IndexMap<String, (u64, f64)>,
|
||||
|
||||
/// Mapping from path to list of (ComponentId, FileInfo).
|
||||
///
|
||||
/// It's common for directories to be owned by more than one component (i.e.
|
||||
/// from _different_ SRPMs). It's much more uncommon for files/symlinks
|
||||
/// though we do handle it to ensure reproducible layers.
|
||||
path_to_components: HashMap<Utf8PathBuf, Vec<(ComponentId, FileInfo)>>,
|
||||
}
|
||||
|
||||
impl RpmRepo {
|
||||
/// Load the RPM database from the given rootfs.
|
||||
///
|
||||
/// Returns `Ok(None)` if no RPM database is detected.
|
||||
pub fn load(rootfs: &Dir) -> Result<Option<Self>> {
|
||||
if !has_rpmdb(rootfs)? {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let packages = rpm_qa::load_from_rootfs_dir(rootfs).context("loading rpmdb from rootfs")?;
|
||||
Self::load_from_packages(packages).map(Some)
|
||||
}
|
||||
|
||||
pub fn load_from_packages(packages: rpm_qa::Packages) -> Result<Self> {
|
||||
let mut components: IndexMap<String, (u64, f64)> = IndexMap::new();
|
||||
let mut path_to_components: HashMap<Utf8PathBuf, Vec<(ComponentId, FileInfo)>> =
|
||||
HashMap::new();
|
||||
|
||||
for pkg in packages.into_values() {
|
||||
// Use the source RPM as the component name, falling back to package name
|
||||
let component_name: &str = pkg
|
||||
.sourcerpm
|
||||
.as_deref()
|
||||
.map(parse_srpm_name)
|
||||
.unwrap_or(&pkg.name);
|
||||
|
||||
let stability = calculate_stability(&pkg.changelog_times, pkg.buildtime)?;
|
||||
let entry = components.entry(component_name.to_string());
|
||||
let component_id = ComponentId(entry.index());
|
||||
entry.or_insert((pkg.buildtime, stability));
|
||||
|
||||
for (path, file_info) in pkg.files.into_iter() {
|
||||
// Accumulate entries for all file types. Skip if this component
|
||||
// already owns this path (can happen when multiple subpackages
|
||||
// from the same SRPM own the same path).
|
||||
let entries = path_to_components.entry(path).or_default();
|
||||
if !entries.iter().any(|(id, _)| *id == component_id) {
|
||||
entries.push((component_id, file_info));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
components,
|
||||
path_to_components,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ComponentsRepo for RpmRepo {
|
||||
fn name(&self) -> &'static str {
|
||||
REPO_NAME
|
||||
}
|
||||
|
||||
fn default_priority(&self) -> usize {
|
||||
10
|
||||
}
|
||||
|
||||
fn claims_for_path(&self, path: &Utf8Path, file_type: FileType) -> Vec<ComponentId> {
|
||||
// Don't claim RPM database paths - let them fall into chunkah/unclaimed
|
||||
if let Ok(rel_path) = path.strip_prefix("/")
|
||||
&& RPMDB_PATHS.iter().any(|p| rel_path.starts_with(p))
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.path_to_components
|
||||
.get(path)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.iter()
|
||||
.filter(|(_, fi)| file_info_to_file_type(fi) == Some(file_type))
|
||||
.map(|(id, _)| *id)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn component_info(&self, id: ComponentId) -> ComponentInfo<'_> {
|
||||
let (name, (mtime, stability)) = self
|
||||
.components
|
||||
.get_index(id.0)
|
||||
// SAFETY: the ids we're given come from the IndexMap itself when we
|
||||
// inserted the element, so it must be valid.
|
||||
.expect("invalid ComponentId");
|
||||
ComponentInfo {
|
||||
name,
|
||||
mtime_clamp: *mtime,
|
||||
stability: *stability,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if any known RPM database path exists in the rootfs.
|
||||
//
|
||||
// This probably should live in rpm-qa-rs instead.
|
||||
fn has_rpmdb(rootfs: &Dir) -> anyhow::Result<bool> {
|
||||
for path in RPMDB_PATHS {
|
||||
if rootfs
|
||||
.try_exists(path)
|
||||
.with_context(|| format!("checking for {path}"))?
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Parse the SRPM name from a full SRPM filename.
|
||||
///
|
||||
/// e.g., "bash-5.2.15-5.fc40.src.rpm" -> "bash"
|
||||
fn parse_srpm_name(srpm: &str) -> &str {
|
||||
// Remove .src.rpm suffix
|
||||
let without_suffix = srpm.strip_suffix(".src.rpm").unwrap_or(srpm);
|
||||
|
||||
// Find the last two dashes (version-release)
|
||||
// The name is everything before the second-to-last dash
|
||||
let parts: Vec<&str> = without_suffix.rsplitn(3, '-').collect();
|
||||
if parts.len() >= 3 {
|
||||
parts[2]
|
||||
} else {
|
||||
without_suffix
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate stability from changelog timestamps and build time.
|
||||
///
|
||||
/// Uses a Poisson model. I used Gemini Pro 3 to analyzing RPM changelogs from
|
||||
/// Fedora and found that once you filter out high-activity event-driven periods
|
||||
/// (mass rebuilds, Fedora branching events), package updates over a large
|
||||
/// enough period generally follow a Poisson distribution.
|
||||
///
|
||||
/// The lookback period is limited to STABILITY_LOOKBACK_DAYS (1 year).
|
||||
/// If there are no changelog entries, the build time is used as a fallback.
|
||||
fn calculate_stability(changelog_times: &[u64], buildtime: u64) -> Result<f64> {
|
||||
use super::{SECS_PER_DAY, STABILITY_LOOKBACK_DAYS, STABILITY_PERIOD_DAYS};
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.context("getting current time")?
|
||||
.as_secs();
|
||||
|
||||
let lookback_start = now.saturating_sub(STABILITY_LOOKBACK_DAYS * SECS_PER_DAY);
|
||||
|
||||
// If there are no changelog entries, use the buildtime as a single data point
|
||||
let mut relevant_times: Vec<u64> = if changelog_times.is_empty() {
|
||||
vec![buildtime]
|
||||
} else {
|
||||
changelog_times.to_vec()
|
||||
};
|
||||
|
||||
// Filter to entries within the lookback window
|
||||
relevant_times.retain(|&t| t >= lookback_start);
|
||||
|
||||
if relevant_times.is_empty() {
|
||||
// All changelog entries are older than lookback period.
|
||||
// No changes in the past year = very stable.
|
||||
return Ok(0.99);
|
||||
}
|
||||
|
||||
// Find the oldest timestamp in the window
|
||||
let oldest = relevant_times.iter().min().copied().unwrap();
|
||||
|
||||
let span_days = (now.saturating_sub(oldest)) as f64 / SECS_PER_DAY as f64;
|
||||
|
||||
if span_days < 1.0 {
|
||||
// Very recent package, assume unstable
|
||||
return Ok(0.0);
|
||||
}
|
||||
|
||||
let num_changes = relevant_times.len() as f64;
|
||||
|
||||
// lambda in our case is changes per day
|
||||
let lambda = num_changes / span_days;
|
||||
|
||||
Ok((-lambda * STABILITY_PERIOD_DAYS).exp())
|
||||
}
|
||||
|
||||
fn file_info_to_file_type(fi: &FileInfo) -> Option<FileType> {
|
||||
let file_type = (fi.mode as libc::mode_t) & libc::S_IFMT;
|
||||
match file_type {
|
||||
libc::S_IFDIR => Some(FileType::Directory),
|
||||
libc::S_IFREG => Some(FileType::File),
|
||||
libc::S_IFLNK => Some(FileType::Symlink),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use camino::Utf8Path;
|
||||
|
||||
use super::*;
|
||||
|
||||
const FIXTURE: &str = include_str!("../../tests/fixtures/fedora.json");
|
||||
|
||||
#[test]
|
||||
fn test_parse_srpm_name() {
|
||||
// Package names with no dashes in them
|
||||
assert_eq!(parse_srpm_name("bash-5.2.15-5.fc40.src.rpm"), "bash");
|
||||
assert_eq!(parse_srpm_name("systemd-256.4-1.fc41.src.rpm"), "systemd");
|
||||
assert_eq!(parse_srpm_name("python3-3.12.0-1.fc40.src.rpm"), "python3");
|
||||
assert_eq!(parse_srpm_name("glibc-2.39-5.fc40.src.rpm"), "glibc");
|
||||
|
||||
// Package names with dashes in them
|
||||
assert_eq!(
|
||||
parse_srpm_name("python-dateutil-2.8.2-1.fc40.src.rpm"),
|
||||
"python-dateutil"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_srpm_name("cairo-dock-plugins-3.4.1-1.fc40.src.rpm"),
|
||||
"cairo-dock-plugins"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_srpm_name("xorg-x11-server-1.20.14-1.fc40.src.rpm"),
|
||||
"xorg-x11-server"
|
||||
);
|
||||
|
||||
// Edge cases with malformed input
|
||||
// Only one dash (not enough for N-V-R pattern)
|
||||
assert_eq!(parse_srpm_name("name-version"), "name-version");
|
||||
|
||||
// Missing .src.rpm suffix but valid N-V-R pattern
|
||||
assert_eq!(parse_srpm_name("bash-5.2.15-5.fc40"), "bash");
|
||||
|
||||
// No dashes at all
|
||||
assert_eq!(parse_srpm_name("nodash"), "nodash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claims_for_path() {
|
||||
let packages = rpm_qa::load_from_str(FIXTURE).unwrap();
|
||||
let repo = RpmRepo::load_from_packages(packages).unwrap();
|
||||
|
||||
// /usr/bin/bash is a file owned by bash
|
||||
let claims = repo.claims_for_path(Utf8Path::new("/usr/bin/bash"), FileType::File);
|
||||
assert_eq!(claims.len(), 1);
|
||||
let info = repo.component_info(claims[0]);
|
||||
assert_eq!(info.name, "bash");
|
||||
assert_eq!(info.mtime_clamp, 1753299195);
|
||||
|
||||
// /usr/bin/sh is a symlink owned by bash
|
||||
let claims = repo.claims_for_path(Utf8Path::new("/usr/bin/sh"), FileType::Symlink);
|
||||
assert_eq!(claims.len(), 1);
|
||||
let info = repo.component_info(claims[0]);
|
||||
assert_eq!(info.name, "bash");
|
||||
|
||||
// /usr/lib64/libc.so.6 is a file owned by glibc
|
||||
let claims = repo.claims_for_path(Utf8Path::new("/usr/lib64/libc.so.6"), FileType::File);
|
||||
assert_eq!(claims.len(), 1);
|
||||
let info = repo.component_info(claims[0]);
|
||||
assert_eq!(info.name, "glibc");
|
||||
assert_eq!(info.mtime_clamp, 1765791404);
|
||||
|
||||
// Unowned file should not be claimed
|
||||
let claims = repo.claims_for_path(Utf8Path::new("/some/unowned/file"), FileType::File);
|
||||
assert!(claims.is_empty());
|
||||
|
||||
// RPMDB paths should not be claimed even if technically owned by rpm package
|
||||
for rpmdb_path in [
|
||||
"/usr/lib/sysimage/rpm/rpmdb.sqlite",
|
||||
"/usr/share/rpm/macros",
|
||||
"/var/lib/rpm/Packages",
|
||||
] {
|
||||
let claims = repo.claims_for_path(Utf8Path::new(rpmdb_path), FileType::File);
|
||||
assert!(
|
||||
claims.is_empty(),
|
||||
"RPMDB path {} should not be claimed",
|
||||
rpmdb_path
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_claims_for_path_wrong_type() {
|
||||
let packages = rpm_qa::load_from_str(FIXTURE).unwrap();
|
||||
let repo = RpmRepo::load_from_packages(packages).unwrap();
|
||||
|
||||
// /usr/bin/bash is a file in RPM, but we query as symlink
|
||||
let claims = repo.claims_for_path(Utf8Path::new("/usr/bin/bash"), FileType::Symlink);
|
||||
assert!(claims.is_empty());
|
||||
|
||||
// /usr/bin/sh is a symlink in RPM, but we query as file
|
||||
let claims = repo.claims_for_path(Utf8Path::new("/usr/bin/sh"), FileType::File);
|
||||
assert!(claims.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shared_directories_claimed_by_multiple_components() {
|
||||
let packages = rpm_qa::load_from_str(FIXTURE).unwrap();
|
||||
let repo = RpmRepo::load_from_packages(packages).unwrap();
|
||||
|
||||
// /usr/lib/.build-id is a well-known directory shared by many packages
|
||||
let claims = repo.claims_for_path(Utf8Path::new("/usr/lib/.build-id"), FileType::Directory);
|
||||
assert!(
|
||||
claims.len() >= 2,
|
||||
"shared dir should be claimed by multiple components"
|
||||
);
|
||||
|
||||
// Verify well-known packages from the fixture are among the claims
|
||||
let names: std::collections::HashSet<_> = claims
|
||||
.iter()
|
||||
.map(|id| repo.component_info(*id).name)
|
||||
.collect();
|
||||
for pkg in ["bash", "glibc", "coreutils"] {
|
||||
assert!(names.contains(pkg), "{pkg} should claim /usr/lib/.build-id");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_from_rpmdb_sqlite() {
|
||||
use std::process::Command;
|
||||
|
||||
// skip if rpm command is not available
|
||||
let rpm_available = Command::new("rpm").arg("--version").output().is_ok();
|
||||
if !rpm_available {
|
||||
eprintln!("skipping test: rpm command not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// create a temp rootfs with the rpmdb.sqlite fixture
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let rpmdb_dir = tmp.path().join("usr/lib/sysimage/rpm");
|
||||
std::fs::create_dir_all(&rpmdb_dir).unwrap();
|
||||
let fixture_path =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/rpmdb.sqlite");
|
||||
std::fs::copy(&fixture_path, rpmdb_dir.join("rpmdb.sqlite")).unwrap();
|
||||
|
||||
let rootfs =
|
||||
cap_std::fs::Dir::open_ambient_dir(tmp.path(), cap_std::ambient_authority()).unwrap();
|
||||
|
||||
let repo = RpmRepo::load(&rootfs).unwrap().unwrap();
|
||||
|
||||
// Test that paths we know are in filesystem and setup are claimed
|
||||
let claims = repo.claims_for_path(Utf8Path::new("/"), FileType::Directory);
|
||||
assert!(!claims.is_empty(), "/ should be claimed");
|
||||
assert_eq!(repo.component_info(claims[0]).name, "filesystem");
|
||||
|
||||
let claims = repo.claims_for_path(Utf8Path::new("/etc"), FileType::Directory);
|
||||
assert!(!claims.is_empty(), "/etc should be claimed");
|
||||
// /etc is owned by filesystem
|
||||
assert_eq!(repo.component_info(claims[0]).name, "filesystem");
|
||||
|
||||
let claims = repo.claims_for_path(Utf8Path::new("/etc/passwd"), FileType::File);
|
||||
assert!(!claims.is_empty(), "/etc/passwd should be claimed");
|
||||
assert_eq!(repo.component_info(claims[0]).name, "setup");
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn assert_stability_in_range(stability: f64, min: f64, max: f64) {
|
||||
assert!(
|
||||
stability >= min && stability <= max,
|
||||
"stability {stability} not in range [{min}, {max}]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_stability_all_old_entries() {
|
||||
use crate::components::SECS_PER_DAY;
|
||||
|
||||
// All entries older than 1 year should return 0.99
|
||||
let now = now_secs();
|
||||
let old_time = now - (400 * SECS_PER_DAY); // 400 days ago
|
||||
let changelog_times = vec![old_time, old_time - SECS_PER_DAY];
|
||||
let buildtime = old_time;
|
||||
|
||||
let stability = calculate_stability(&changelog_times, buildtime).unwrap();
|
||||
assert_eq!(stability, 0.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_stability_very_recent() {
|
||||
// Package built within 1 day should return 0.0
|
||||
let now = now_secs();
|
||||
let recent_time = now - 3600; // 1 hour ago
|
||||
let changelog_times = vec![recent_time];
|
||||
let buildtime = recent_time;
|
||||
|
||||
let stability = calculate_stability(&changelog_times, buildtime).unwrap();
|
||||
assert_eq!(stability, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_stability_no_changelog_uses_buildtime() {
|
||||
use crate::components::SECS_PER_DAY;
|
||||
|
||||
// No changelog entries should use buildtime as fallback
|
||||
let now = now_secs();
|
||||
let buildtime = now - (30 * SECS_PER_DAY); // 30 days ago
|
||||
let changelog_times: Vec<u64> = vec![];
|
||||
|
||||
let stability = calculate_stability(&changelog_times, buildtime).unwrap();
|
||||
// 1 change over 30 days = lambda of 1/30
|
||||
// stability = e^(-lambda * 7) = e^(-7/30) ≈ 0.79
|
||||
assert_stability_in_range(stability, 0.75, 0.85);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_stability_normal_case() {
|
||||
use crate::components::SECS_PER_DAY;
|
||||
|
||||
// Multiple changelog entries within lookback window
|
||||
let now = now_secs();
|
||||
// 4 changes over 100 days = lambda of 0.04
|
||||
// stability = e^(-0.04 * 7) = e^(-0.28) ≈ 0.76
|
||||
let changelog_times = vec![
|
||||
now - (10 * SECS_PER_DAY),
|
||||
now - (30 * SECS_PER_DAY),
|
||||
now - (60 * SECS_PER_DAY),
|
||||
now - (100 * SECS_PER_DAY),
|
||||
];
|
||||
let buildtime = now - (100 * SECS_PER_DAY);
|
||||
|
||||
let stability = calculate_stability(&changelog_times, buildtime).unwrap();
|
||||
assert_stability_in_range(stability, 0.70, 0.80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_stability_high_frequency() {
|
||||
use crate::components::SECS_PER_DAY;
|
||||
|
||||
// Many changes in a short period = low stability
|
||||
let now = now_secs();
|
||||
// 10 changes over 20 days = lambda of 0.5
|
||||
// stability = e^(-0.5 * 7) = e^(-3.5) ≈ 0.03
|
||||
let changelog_times: Vec<u64> = (0..10)
|
||||
.map(|i| now - ((2 + i * 2) * SECS_PER_DAY))
|
||||
.collect();
|
||||
let buildtime = now - (20 * SECS_PER_DAY);
|
||||
|
||||
let stability = calculate_stability(&changelog_times, buildtime).unwrap();
|
||||
assert_stability_in_range(stability, 0.0, 0.10);
|
||||
}
|
||||
}
|
||||
247
src/components/xattr.rs
Normal file
247
src/components/xattr.rs
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use camino::{Utf8Path, Utf8PathBuf};
|
||||
use indexmap::IndexSet;
|
||||
|
||||
use super::{ComponentId, ComponentInfo, ComponentsRepo, FileInfo, FileMap, FileType};
|
||||
|
||||
const XATTR_NAME: &str = "user.component";
|
||||
const REPO_NAME: &str = "xattr";
|
||||
|
||||
/// Xattr-based components repo implementation.
|
||||
///
|
||||
/// Uses the `user.component` extended attribute to determine file ownership.
|
||||
/// Directories with this xattr apply to all files underneath unless overridden.
|
||||
/// Directory inheritance is pre-computed during load.
|
||||
pub struct XattrRepo {
|
||||
/// Component names, indexed by ComponentId.
|
||||
components: IndexSet<String>,
|
||||
/// Mapping from path to ComponentId (pre-computed with inheritance).
|
||||
path_to_component: HashMap<Utf8PathBuf, ComponentId>,
|
||||
/// Currently, the on-disk mtime is canonical and we clamp it, but it would
|
||||
/// make sense in the future to support another user xattr to specify a
|
||||
/// canonical mtime for easier layer reproducibility.
|
||||
default_mtime_clamp: u64,
|
||||
}
|
||||
|
||||
impl XattrRepo {
|
||||
/// Load xattr repo by scanning rootfs for user.component xattrs.
|
||||
/// Pre-computes directory inheritance for all paths in `files`.
|
||||
/// Uses cached xattrs from FileInfo rather than reading from disk.
|
||||
pub fn load(files: &FileMap, default_mtime_clamp: u64) -> Result<Option<Self>> {
|
||||
let mut components: IndexSet<String> = IndexSet::new();
|
||||
let mut path_to_component: HashMap<Utf8PathBuf, ComponentId> = HashMap::new();
|
||||
|
||||
// Track active directory components: (path, ComponentId)
|
||||
// Directories with xattrs apply their component to descendants
|
||||
let mut dir_stack: Vec<(&Utf8Path, ComponentId)> = Vec::new();
|
||||
|
||||
for (path, file_info) in files {
|
||||
// Pop directories that are not ancestors of current path
|
||||
while let Some((dir_path, _)) = dir_stack.last() {
|
||||
if path.starts_with(dir_path) && path.as_path() != *dir_path {
|
||||
break;
|
||||
}
|
||||
dir_stack.pop();
|
||||
}
|
||||
|
||||
let own_xattr = get_component_xattr(file_info)
|
||||
.with_context(|| format!("reading xattr for {}", path))?;
|
||||
|
||||
// If this path has an xattr, get or create its ComponentId
|
||||
let own_component_id = own_xattr.as_ref().map(|name| {
|
||||
// simplify this when we have either
|
||||
// https://github.com/indexmap-rs/indexmap/issues/355 or
|
||||
// https://github.com/indexmap-rs/indexmap/issues/388
|
||||
let idx = components
|
||||
.get_index_of(name)
|
||||
.unwrap_or_else(|| components.insert_full(name.clone()).0);
|
||||
ComponentId(idx)
|
||||
});
|
||||
|
||||
// If this directory has an xattr, push to stack for children to inherit
|
||||
if file_info.file_type == FileType::Directory
|
||||
&& let Some(id) = own_component_id
|
||||
{
|
||||
dir_stack.push((path.as_path(), id));
|
||||
}
|
||||
|
||||
// Determine effective component: own xattr or inherited from parent dir
|
||||
let effective_id = own_component_id.or_else(|| dir_stack.last().map(|(_, id)| *id));
|
||||
|
||||
if let Some(id) = effective_id {
|
||||
path_to_component.insert(path.clone(), id);
|
||||
}
|
||||
}
|
||||
|
||||
if components.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(Self {
|
||||
components,
|
||||
path_to_component,
|
||||
default_mtime_clamp,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the user.component xattr value from cached xattrs.
|
||||
fn get_component_xattr(file_info: &FileInfo) -> Result<Option<String>> {
|
||||
file_info
|
||||
.xattrs
|
||||
.iter()
|
||||
.find(|(k, _)| k == XATTR_NAME)
|
||||
.map(|(_, v)| {
|
||||
String::from_utf8(v.clone())
|
||||
.map_err(|e| anyhow::anyhow!("invalid UTF-8 in {XATTR_NAME} xattr: {e}"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
impl ComponentsRepo for XattrRepo {
|
||||
fn name(&self) -> &'static str {
|
||||
REPO_NAME
|
||||
}
|
||||
|
||||
fn default_priority(&self) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
fn claims_for_path(&self, path: &Utf8Path, _file_type: FileType) -> Vec<ComponentId> {
|
||||
self.path_to_component
|
||||
.get(path)
|
||||
.map(|id| vec![*id])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn component_info(&self, id: ComponentId) -> ComponentInfo<'_> {
|
||||
ComponentInfo {
|
||||
name: self
|
||||
.components
|
||||
.get_index(id.0)
|
||||
// SAFETY: the ids we're given come from the IndexSet itself
|
||||
// when we inserted the element, so it must be valid.
|
||||
.expect("invalid ComponentId"),
|
||||
mtime_clamp: self.default_mtime_clamp,
|
||||
// TODO: make this configurable via xattr or CLI
|
||||
stability: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use cap_std::ambient_authority;
|
||||
use cap_std::fs::Dir;
|
||||
use cap_std_ext::dirext::CapStdExtDirExt;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Helper to set up a rootfs, run setup, and scan files.
|
||||
/// Returns (tempdir, files) - caller must keep tempdir alive.
|
||||
fn setup_rootfs<F>(setup: F) -> (tempfile::TempDir, FileMap)
|
||||
where
|
||||
F: FnOnce(&Dir),
|
||||
{
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let rootfs = Dir::open_ambient_dir(tmp.path(), ambient_authority()).unwrap();
|
||||
setup(&rootfs);
|
||||
let files = crate::scan::scan_rootfs(&rootfs).unwrap();
|
||||
(tmp, files)
|
||||
}
|
||||
|
||||
/// Helper to set the component xattr on a path.
|
||||
fn set_component(rootfs: &Dir, path: &str, component: &str) {
|
||||
rootfs
|
||||
.setxattr(path, XATTR_NAME, component.as_bytes())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Helper to assert a path is claimed by a specific component.
|
||||
fn assert_component(repo: &XattrRepo, path: &str, file_type: FileType, expected: &str) {
|
||||
let claims = repo.claims_for_path(Utf8Path::new(path), file_type);
|
||||
assert_eq!(claims.len(), 1, "{path} should have exactly one claim");
|
||||
assert_eq!(
|
||||
repo.component_info(claims[0]).name,
|
||||
expected,
|
||||
"{path} should be claimed by {expected}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_xattr_file_overrides_directory() {
|
||||
let (_tmp, files) = setup_rootfs(|rootfs| {
|
||||
// Create a directory with xattr
|
||||
rootfs.create_dir("mydir").unwrap();
|
||||
set_component(rootfs, "mydir", "dircomponent");
|
||||
|
||||
// File with its own xattr overrides directory
|
||||
rootfs.write("mydir/special", "content").unwrap();
|
||||
set_component(rootfs, "mydir/special", "filecomponent");
|
||||
|
||||
// File without xattr inherits from directory
|
||||
rootfs.write("mydir/normal", "content").unwrap();
|
||||
|
||||
// File without xattr outside of directory - should not be claimed
|
||||
rootfs.write("noattr", "content").unwrap();
|
||||
});
|
||||
let repo = XattrRepo::load(&files, 0).unwrap().unwrap();
|
||||
|
||||
// /mydir and /mydir/normal should be dircomponent
|
||||
assert_component(&repo, "/mydir", FileType::Directory, "dircomponent");
|
||||
assert_component(&repo, "/mydir/normal", FileType::File, "dircomponent");
|
||||
|
||||
// /mydir/special should be filecomponent
|
||||
assert_component(&repo, "/mydir/special", FileType::File, "filecomponent");
|
||||
|
||||
// /noattr should not be claimed
|
||||
let claims = repo.claims_for_path(Utf8Path::new("/noattr"), FileType::File);
|
||||
assert!(claims.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_xattr_inheritance() {
|
||||
// Tests nested overrides and sibling isolation:
|
||||
// /a has xattr A, /a/b has xattr B (overrides A), /a/b/c/d has xattr D
|
||||
// /x has xattr X (sibling of /a, should not interfere)
|
||||
let (_tmp, files) = setup_rootfs(|rootfs| {
|
||||
rootfs.create_dir_all("a/b/c/d").unwrap();
|
||||
rootfs.write("a/other", "content").unwrap();
|
||||
rootfs.create_dir("x").unwrap();
|
||||
rootfs.write("x/file", "content").unwrap();
|
||||
|
||||
set_component(rootfs, "a", "compA");
|
||||
set_component(rootfs, "a/b", "compB");
|
||||
set_component(rootfs, "a/b/c/d", "compD");
|
||||
set_component(rootfs, "x", "compX");
|
||||
});
|
||||
let repo = XattrRepo::load(&files, 0).unwrap().unwrap();
|
||||
|
||||
assert_component(&repo, "/a", FileType::Directory, "compA");
|
||||
assert_component(&repo, "/a/other", FileType::File, "compA"); // inherits from /a
|
||||
assert_component(&repo, "/a/b", FileType::Directory, "compB");
|
||||
assert_component(&repo, "/a/b/c", FileType::Directory, "compB"); // inherits from /a/b
|
||||
assert_component(&repo, "/a/b/c/d", FileType::Directory, "compD"); // own xattr
|
||||
assert_component(&repo, "/x", FileType::Directory, "compX"); // sibling of /a
|
||||
assert_component(&repo, "/x/file", FileType::File, "compX"); // inherits from /x
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_xattr_symlink_inherits_from_parent() {
|
||||
// Symlinks don't support user xattrs, but they should inherit from parent directory
|
||||
let (_tmp, files) = setup_rootfs(|rootfs| {
|
||||
rootfs.create_dir("mydir").unwrap();
|
||||
set_component(rootfs, "mydir", "mycomp");
|
||||
|
||||
// Create a symlink inside the directory - it should inherit from parent
|
||||
rootfs.symlink("../somewhere", "mydir/link").unwrap();
|
||||
});
|
||||
let repo = XattrRepo::load(&files, 0).unwrap().unwrap();
|
||||
|
||||
// Both should be claimed by mycomp
|
||||
assert_component(&repo, "/mydir", FileType::Directory, "mycomp");
|
||||
assert_component(&repo, "/mydir/link", FileType::Symlink, "mycomp");
|
||||
}
|
||||
}
|
||||
41
src/main.rs
Normal file
41
src/main.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
mod cmd_build;
|
||||
mod components;
|
||||
mod ocibuilder;
|
||||
#[allow(dead_code)]
|
||||
mod packing;
|
||||
mod scan;
|
||||
mod tar;
|
||||
mod utils;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "chunkah")]
|
||||
#[command(about = "A generalized container image rechunker")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Build an OCI archive from a rootfs
|
||||
Build(Box<cmd_build::BuildArgs>),
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// Set up a SIGINT handler that terminates the process. This is needed
|
||||
// because chunkah may run as PID 1 in a container, which can only receive
|
||||
// signals it has explicit handlers for. This avoids users having to add
|
||||
// e.g. --init to get Ctrl-C to behave as expected.
|
||||
ctrlc::set_handler(|| std::process::exit(130)).context("setting up signal handler")?;
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Command::Build(args) => cmd_build::run(&args)?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
555
src/ocibuilder.rs
Normal file
555
src/ocibuilder.rs
Normal file
|
|
@ -0,0 +1,555 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use cap_std::fs::Dir;
|
||||
use ocidir::oci_spec::image as oci_image;
|
||||
|
||||
use crate::components::Component;
|
||||
|
||||
/// Compression settings for the OCI image.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub enum Compression {
|
||||
/// No compression.
|
||||
#[default]
|
||||
None,
|
||||
/// Gzip compression with the specified level (0-9).
|
||||
Gzip(u32),
|
||||
}
|
||||
|
||||
/// Builder for creating OCI images from components.
|
||||
pub struct Builder {
|
||||
/// The rootfs to build from.
|
||||
rootfs: Dir,
|
||||
/// The OCI directory to build into.
|
||||
oci_dir: cap_std_ext::cap_tempfile::TempDir,
|
||||
/// The components to include in the image, ordered by stability descending.
|
||||
components: Vec<(String, Component)>,
|
||||
/// Compression settings for layers and archive.
|
||||
compression: Compression,
|
||||
/// Annotations to add to the image manifest.
|
||||
annotations: Option<HashMap<String, String>>,
|
||||
/// The image configuration.
|
||||
config: Option<oci_image::ImageConfiguration>,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
/// Create a new Builder with required parameters.
|
||||
pub fn new(rootfs: &Dir, components: Vec<(String, Component)>) -> Result<Self> {
|
||||
let oci_dir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())
|
||||
.context("creating temp directory")?;
|
||||
|
||||
Ok(Self {
|
||||
rootfs: rootfs.try_clone().context("cloning rootfs")?,
|
||||
oci_dir,
|
||||
components,
|
||||
compression: Compression::default(),
|
||||
annotations: None,
|
||||
config: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the compression settings.
|
||||
pub fn compression(mut self, compression: Compression) -> Self {
|
||||
self.compression = compression;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set annotations to add to the image manifest.
|
||||
pub fn annotations(mut self, annotations: HashMap<String, String>) -> Self {
|
||||
self.annotations = Some(annotations);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the image configuration.
|
||||
pub fn config(mut self, config: oci_image::ImageConfiguration) -> Self {
|
||||
self.config = Some(config);
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the OCI image and write it to the given output.
|
||||
pub fn build<W: Write>(self, output: &mut W) -> Result<()> {
|
||||
self.build_oci_dir().context("building OCI directory")?;
|
||||
|
||||
let compression = match self.compression {
|
||||
Compression::None => crate::tar::ArchiveCompression::None,
|
||||
Compression::Gzip(level) => {
|
||||
crate::tar::ArchiveCompression::Gzip(flate2::Compression::new(level))
|
||||
}
|
||||
};
|
||||
|
||||
crate::tar::write_oci_archive(&self.oci_dir, &mut *output, compression)
|
||||
.context("writing OCI archive")?;
|
||||
|
||||
output.flush().context("flushing output")
|
||||
}
|
||||
|
||||
fn build_oci_dir(&self) -> Result<()> {
|
||||
let oci_dir =
|
||||
ocidir::OciDir::ensure(self.oci_dir.try_clone().context("cloning temp directory")?)
|
||||
.context("creating OCI directory")?;
|
||||
|
||||
// first, let's create an empty manifest
|
||||
let mut manifest = oci_dir
|
||||
.new_empty_manifest()
|
||||
.context("creating empty manifest")?
|
||||
.build()
|
||||
.context("building manifest")?;
|
||||
|
||||
let mut config = self.config.clone().unwrap_or_default();
|
||||
|
||||
// this is the important bit: we add all the layers
|
||||
self.add_components(&mut manifest, &mut config)
|
||||
.context("adding layers to OCI directory")?;
|
||||
|
||||
if let Some(annotations) = &self.annotations {
|
||||
manifest.set_annotations(Some(annotations.clone()));
|
||||
}
|
||||
|
||||
let arch = config.architecture().to_string();
|
||||
let platform = oci_image::PlatformBuilder::default()
|
||||
.os("linux")
|
||||
.architecture(arch.as_str())
|
||||
.build()
|
||||
.context("building platform")?;
|
||||
|
||||
oci_dir
|
||||
.insert_manifest_and_config(manifest, config, None, platform)
|
||||
.context("inserting manifest and config")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add layers to the OCI directory and update the manifest and config.
|
||||
fn add_components(
|
||||
&self,
|
||||
manifest: &mut oci_image::ImageManifest,
|
||||
config: &mut oci_image::ImageConfiguration,
|
||||
) -> Result<()> {
|
||||
for (name, component) in &self.components {
|
||||
if component.files.is_empty() {
|
||||
continue;
|
||||
}
|
||||
self.add_component(manifest, config, name, component)
|
||||
.with_context(|| format!("adding component {}", name))?;
|
||||
}
|
||||
|
||||
// Clear history - we don't want to emit it in the output image
|
||||
// XXX: add a e.g. push_layer_without_history_annotated to ocidir
|
||||
config.history_mut().take();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a single component as a layer to the OCI directory.
|
||||
fn add_component(
|
||||
&self,
|
||||
manifest: &mut oci_image::ImageManifest,
|
||||
config: &mut oci_image::ImageConfiguration,
|
||||
name: &str,
|
||||
component: &Component,
|
||||
) -> Result<()> {
|
||||
let oci_dir = ocidir::OciDir::open(self.oci_dir.try_clone().context("cloning oci_dir")?)
|
||||
.context("opening OCI directory")?;
|
||||
let mut tar_builder =
|
||||
crate::tar::create_layer(&oci_dir, self.compression).context("creating layer")?;
|
||||
|
||||
crate::tar::write_files_to_tar(
|
||||
&mut tar_builder,
|
||||
&self.rootfs,
|
||||
&component.files,
|
||||
component.mtime_clamp,
|
||||
)
|
||||
.context("building tar layer")?;
|
||||
|
||||
tar_builder.finish().context("finishing layer tar")?;
|
||||
let layer = tar_builder
|
||||
.into_inner()
|
||||
.context("getting layer writer")?
|
||||
.complete()
|
||||
.context("completing layer")?;
|
||||
|
||||
let annotations = {
|
||||
let mut hm = HashMap::new();
|
||||
hm.insert("org.chunkah.component".to_string(), name.to_string());
|
||||
hm.insert(
|
||||
"org.chunkah.stability".to_string(),
|
||||
format!("{:.3}", component.stability),
|
||||
);
|
||||
hm
|
||||
};
|
||||
|
||||
oci_dir.push_layer_with_history_annotated(manifest, config, layer, Some(annotations), None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use camino::Utf8PathBuf;
|
||||
use cap_std::ambient_authority;
|
||||
use cap_std::fs::PermissionsExt;
|
||||
use cap_std_ext::dirext::CapStdExtDirExt;
|
||||
use maplit::btreeset;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::components::FileMap;
|
||||
|
||||
/// Helper struct for test results
|
||||
struct TestOciResult {
|
||||
oci_dir: ocidir::OciDir,
|
||||
manifest: oci_image::ImageManifest,
|
||||
image_config: oci_image::ImageConfiguration,
|
||||
/// Keep tempdir alive for the lifetime of the test
|
||||
_oci_tempdir: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl TestOciResult {
|
||||
fn first_layer(&self) -> &oci_image::Descriptor {
|
||||
&self.manifest.layers()[0]
|
||||
}
|
||||
|
||||
fn read_layer_tar(
|
||||
&self,
|
||||
layer: &oci_image::Descriptor,
|
||||
) -> tar::Archive<impl std::io::Read + '_> {
|
||||
tar::Archive::new(self.oci_dir.read_blob(layer).unwrap())
|
||||
}
|
||||
|
||||
fn get_layer_tar_entries(
|
||||
&self,
|
||||
layer: &oci_image::Descriptor,
|
||||
) -> Vec<(String, tar::EntryType, u64)> {
|
||||
let mut layer_tar = self.read_layer_tar(layer);
|
||||
layer_tar
|
||||
.entries()
|
||||
.unwrap()
|
||||
.map(|e| {
|
||||
let entry = e.unwrap();
|
||||
let path = entry.path().unwrap().to_string_lossy().to_string();
|
||||
let entry_type = entry.header().entry_type();
|
||||
let size = entry.header().size().unwrap();
|
||||
(path, entry_type, size)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Specification for a test component: (name, paths, mtime_clamp)
|
||||
type ComponentSpec = (&'static str, BTreeSet<Utf8PathBuf>, u64);
|
||||
|
||||
/// Helper to build an OCI archive and extract it for inspection.
|
||||
fn build_and_extract<F>(rootfs_setup: F, specs: Vec<ComponentSpec>) -> TestOciResult
|
||||
where
|
||||
F: FnOnce(&Dir),
|
||||
{
|
||||
// Create temp rootfs and run setup
|
||||
let rootfs_dir = tempfile::tempdir().unwrap();
|
||||
let rootfs = Dir::open_ambient_dir(rootfs_dir.path(), ambient_authority()).unwrap();
|
||||
rootfs_setup(&rootfs);
|
||||
|
||||
// Scan the rootfs once
|
||||
let all_files = crate::scan::scan_rootfs(&rootfs).unwrap();
|
||||
|
||||
// Build components by selecting paths from scanned files
|
||||
let components: Vec<(String, Component)> = specs
|
||||
.into_iter()
|
||||
.map(|(name, paths, mtime_clamp)| {
|
||||
let files: FileMap = paths
|
||||
.iter()
|
||||
.filter_map(|p| all_files.get(p).map(|info| (p.clone(), info.clone())))
|
||||
.collect();
|
||||
(
|
||||
name.to_string(),
|
||||
Component {
|
||||
mtime_clamp,
|
||||
stability: 0.0,
|
||||
files,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Create minimal config
|
||||
let config = oci_image::ImageConfigurationBuilder::default()
|
||||
.os("linux")
|
||||
.architecture("amd64")
|
||||
.rootfs(
|
||||
oci_image::RootFsBuilder::default()
|
||||
.typ("layers")
|
||||
.diff_ids(Vec::<String>::new())
|
||||
.build()
|
||||
.unwrap(),
|
||||
)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
// Build OCI archive
|
||||
let builder = Builder::new(&rootfs, components)
|
||||
.unwrap()
|
||||
.compression(Compression::None)
|
||||
.config(config);
|
||||
let mut output = Vec::new();
|
||||
builder.build(&mut output).unwrap();
|
||||
|
||||
// Extract to tempdir
|
||||
let oci_tempdir = tempfile::tempdir().unwrap();
|
||||
let mut archive = tar::Archive::new(output.as_slice());
|
||||
archive.unpack(oci_tempdir.path()).unwrap();
|
||||
|
||||
// Open with ocidir
|
||||
let oci_dir_cap = Dir::open_ambient_dir(oci_tempdir.path(), ambient_authority()).unwrap();
|
||||
let oci_dir = ocidir::OciDir::open(oci_dir_cap).unwrap();
|
||||
|
||||
// Get manifest
|
||||
let index = oci_dir.read_index().unwrap();
|
||||
let manifest_desc = index.manifests().first().unwrap();
|
||||
let manifest: oci_image::ImageManifest = oci_dir.read_json_blob(manifest_desc).unwrap();
|
||||
|
||||
// Get image config
|
||||
let image_config: oci_image::ImageConfiguration =
|
||||
oci_dir.read_json_blob(manifest.config()).unwrap();
|
||||
|
||||
TestOciResult {
|
||||
oci_dir,
|
||||
manifest,
|
||||
image_config,
|
||||
_oci_tempdir: oci_tempdir,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_components_to_layers() {
|
||||
let result = build_and_extract(
|
||||
|rootfs| {
|
||||
rootfs.write("file_a", "content a").unwrap();
|
||||
rootfs.write("file_b", "content b").unwrap();
|
||||
},
|
||||
vec![
|
||||
(
|
||||
"component_a",
|
||||
btreeset! { Utf8PathBuf::from("/file_a") },
|
||||
1000,
|
||||
),
|
||||
(
|
||||
"component_b",
|
||||
btreeset! { Utf8PathBuf::from("/file_b") },
|
||||
1000,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(result.manifest.layers().len(), 2);
|
||||
|
||||
// Collect layer info (order is not guaranteed with HashMap)
|
||||
let layer_info: Vec<_> = result
|
||||
.manifest
|
||||
.layers()
|
||||
.iter()
|
||||
.map(|layer| {
|
||||
let component = layer
|
||||
.annotations()
|
||||
.as_ref()
|
||||
.and_then(|a| a.get("org.chunkah.component"))
|
||||
.cloned();
|
||||
let entries = result.get_layer_tar_entries(layer);
|
||||
(component, entries)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Find component_a layer
|
||||
let layer_a = layer_info
|
||||
.iter()
|
||||
.find(|(c, _)| c.as_deref() == Some("component_a"))
|
||||
.expect("should have component_a layer");
|
||||
assert_eq!(layer_a.1.len(), 1);
|
||||
assert_eq!(layer_a.1[0].0, "file_a");
|
||||
assert_eq!(layer_a.1[0].1, tar::EntryType::Regular);
|
||||
assert_eq!(layer_a.1[0].2, 9); // "content a".len()
|
||||
|
||||
// Find component_b layer
|
||||
let layer_b = layer_info
|
||||
.iter()
|
||||
.find(|(c, _)| c.as_deref() == Some("component_b"))
|
||||
.expect("should have component_b layer");
|
||||
assert_eq!(layer_b.1.len(), 1);
|
||||
assert_eq!(layer_b.1[0].0, "file_b");
|
||||
assert_eq!(layer_b.1[0].1, tar::EntryType::Regular);
|
||||
assert_eq!(layer_b.1[0].2, 9); // "content b".len()
|
||||
|
||||
// Verify no history entries
|
||||
assert!(
|
||||
result
|
||||
.image_config
|
||||
.history()
|
||||
.as_ref()
|
||||
.is_none_or(|h| h.is_empty()),
|
||||
"image should have no history entries"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_metadata() {
|
||||
let result = build_and_extract(
|
||||
|rootfs| {
|
||||
rootfs.write("executable", "#!/bin/sh\necho hi").unwrap();
|
||||
// Make it executable (0o755)
|
||||
let meta = rootfs.metadata("executable").unwrap();
|
||||
let mut perms = meta.permissions();
|
||||
perms.set_mode(0o755);
|
||||
rootfs.set_permissions("executable", perms).unwrap();
|
||||
|
||||
rootfs.create_dir("mydir").unwrap();
|
||||
rootfs
|
||||
.set_permissions("mydir", cap_std::fs::Permissions::from_mode(0o777))
|
||||
.unwrap();
|
||||
|
||||
rootfs.write("xattr_file", "xattr content").unwrap();
|
||||
rootfs
|
||||
.setxattr("xattr_file", "user.myattr", b"myvalue")
|
||||
.unwrap();
|
||||
|
||||
rootfs.write("target", "target content").unwrap();
|
||||
rootfs.symlink("target", "link").unwrap();
|
||||
},
|
||||
vec![(
|
||||
"test",
|
||||
btreeset! {
|
||||
Utf8PathBuf::from("/executable"),
|
||||
Utf8PathBuf::from("/link"),
|
||||
Utf8PathBuf::from("/mydir"),
|
||||
Utf8PathBuf::from("/target"),
|
||||
Utf8PathBuf::from("/xattr_file"),
|
||||
},
|
||||
1000,
|
||||
)],
|
||||
);
|
||||
|
||||
// Get the layer and check metadata
|
||||
let mut layer_tar = result.read_layer_tar(result.first_layer());
|
||||
let mut found_xattr = false;
|
||||
for entry in layer_tar.entries().unwrap() {
|
||||
let mut entry = entry.unwrap();
|
||||
let path = entry.path().unwrap().to_string_lossy().to_string();
|
||||
let header = entry.header();
|
||||
|
||||
// Check mtime is clamped
|
||||
let mtime = header.mtime().unwrap();
|
||||
assert!(mtime <= 1000, "mtime should be clamped: {}", mtime);
|
||||
|
||||
if path == "executable" {
|
||||
// Check mode preserves executable bit
|
||||
let mode = header.mode().unwrap();
|
||||
assert!(
|
||||
mode & 0o111 != 0,
|
||||
"executable should have execute bits: {:o}",
|
||||
mode
|
||||
);
|
||||
} else if path == "mydir/" {
|
||||
assert_eq!(header.entry_type(), tar::EntryType::Directory);
|
||||
// Verify atypical 777 permissions are preserved
|
||||
let mode = header.mode().unwrap();
|
||||
assert_eq!(
|
||||
mode & 0o777,
|
||||
0o777,
|
||||
"directory should have 777 permissions, got {:o}",
|
||||
mode & 0o777
|
||||
);
|
||||
} else if path == "xattr_file" {
|
||||
// Check xattr survives
|
||||
let pax = entry.pax_extensions().unwrap().unwrap();
|
||||
for ext in pax {
|
||||
let ext = ext.unwrap();
|
||||
if ext.key().unwrap() == "SCHILY.xattr.user.myattr" {
|
||||
assert_eq!(ext.value_bytes(), b"myvalue");
|
||||
found_xattr = true;
|
||||
}
|
||||
}
|
||||
assert!(found_xattr, "xattr should be preserved in layer");
|
||||
} else if path == "link" {
|
||||
// Check symlink
|
||||
assert_eq!(header.entry_type(), tar::EntryType::Symlink);
|
||||
let link_target = header.link_name().unwrap().unwrap();
|
||||
assert_eq!(link_target.to_string_lossy(), "target");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nested_directory_structure() {
|
||||
let result = build_and_extract(
|
||||
|rootfs| {
|
||||
rootfs.create_dir_all("usr/bin").unwrap();
|
||||
rootfs.write("usr/bin/myapp", "app content").unwrap();
|
||||
},
|
||||
vec![(
|
||||
"test",
|
||||
btreeset! {
|
||||
Utf8PathBuf::from("/usr"),
|
||||
Utf8PathBuf::from("/usr/bin"),
|
||||
Utf8PathBuf::from("/usr/bin/myapp"),
|
||||
},
|
||||
1000,
|
||||
)],
|
||||
);
|
||||
|
||||
// Layer should contain the file and parent directories
|
||||
let entries = result.get_layer_tar_entries(&result.manifest.layers()[0]);
|
||||
|
||||
// Should have /usr, /usr/bin, and /usr/bin/myapp
|
||||
// Note: directories have trailing slashes in tar
|
||||
let paths: Vec<_> = entries.iter().map(|(p, _, _)| p.as_str()).collect();
|
||||
assert!(paths.contains(&"usr/"), "should contain usr/: {:?}", paths);
|
||||
assert!(
|
||||
paths.contains(&"usr/bin/"),
|
||||
"should contain usr/bin/: {:?}",
|
||||
paths
|
||||
);
|
||||
assert!(
|
||||
paths.contains(&"usr/bin/myapp"),
|
||||
"should contain usr/bin/myapp: {:?}",
|
||||
paths
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mtime_clamping() {
|
||||
use fs_set_times::{SetTimes, SystemTimeSpec};
|
||||
|
||||
let result = build_and_extract(
|
||||
|rootfs| {
|
||||
// This file has current mtime (>> 500), should be clamped to 500
|
||||
rootfs.write("clamped_file", "content").unwrap();
|
||||
|
||||
// This file has mtime 200 (< 500), should stay at 200
|
||||
rootfs.write("unclamped_file", "content").unwrap();
|
||||
let file = rootfs.open("unclamped_file").unwrap();
|
||||
let mtime = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(200);
|
||||
file.set_times(None, Some(SystemTimeSpec::Absolute(mtime)))
|
||||
.unwrap();
|
||||
},
|
||||
vec![(
|
||||
"test",
|
||||
btreeset! {
|
||||
Utf8PathBuf::from("/clamped_file"),
|
||||
Utf8PathBuf::from("/unclamped_file"),
|
||||
},
|
||||
500,
|
||||
)],
|
||||
);
|
||||
|
||||
let mut layer_tar = result.read_layer_tar(result.first_layer());
|
||||
for entry in layer_tar.entries().unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path().unwrap().to_string_lossy().to_string();
|
||||
let mtime = entry.header().mtime().unwrap();
|
||||
if path == "clamped_file" {
|
||||
assert_eq!(mtime, 500, "clamped_file mtime should be clamped to 500");
|
||||
} else if path == "unclamped_file" {
|
||||
assert_eq!(mtime, 200, "unclamped_file mtime should stay at 200");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
421
src/packing.rs
Normal file
421
src/packing.rs
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
//! # Packing Algorithm
|
||||
//!
|
||||
//! This module implements a clustering algorithm to merge a set of N
|
||||
//! components into K groups while maximizing the group reuse across updates.
|
||||
//! The general approach came out of a session with Gemini 3 Pro on an
|
||||
//! abstracted version of the problem statement.
|
||||
//!
|
||||
//! ## Definitions
|
||||
//!
|
||||
//! 1. The "stability" of a component is the probability it doesn't change.
|
||||
//! 2. The "size" of a component is the sum of all its files.
|
||||
//! 3. The "expected value" of a component is stability x size; basically,
|
||||
//! long-term how much data do we avoid pulling from this component on
|
||||
//! average.
|
||||
//! 4. The expected value of two components combined in the same group is
|
||||
//! (size1 + size2) x (stability1 x stability2).
|
||||
//!
|
||||
//! We want to find the group arrangement which maximize total expected value
|
||||
//! (TEV). The final groups become OCI layers.
|
||||
//!
|
||||
//! ## Algorithm
|
||||
//!
|
||||
//! 1. Create a separate group for each component. If N <= K, we're done.
|
||||
//! 2. Otherwise, we need to merge some groups. Every merge will reduce TEV.
|
||||
//! We should look for the merge that will result in the smallest TEV loss,
|
||||
//! so we first need to calculate the TEV loss for all possible merges. For
|
||||
//! every possible merge of two components, we calculate what the EV of the
|
||||
//! merged group would be and how much smaller it is from the EV of keeping
|
||||
//! them separate. Doing this for every component is O(N^2), but meh, N in
|
||||
//! our case is trivially small for modern computer speeds. Shove all those
|
||||
//! potential merges and their losses in a BinaryHeap. The top will then
|
||||
//! always be the merge with the smallest loss.
|
||||
//! 3. Pop the heap to get the next optimal merge and do that merge. Calculate
|
||||
//! losses for this new merged group vs all the remaining groups and insert
|
||||
//! into the heap.
|
||||
//! 4. Keep doing 3. until we get to K groups.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::BinaryHeap;
|
||||
|
||||
/// Input item for packing
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PackItem {
|
||||
/// Total size in bytes of all files in this component
|
||||
pub size: u64,
|
||||
/// Probability the component doesn't change between updates (0.0 to 1.0)
|
||||
pub stability: f64,
|
||||
}
|
||||
|
||||
/// Output group from packing
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PackGroup {
|
||||
/// Indices into the original input slice
|
||||
pub indices: Vec<usize>,
|
||||
/// Total size in bytes of all files in this group
|
||||
pub size: u64,
|
||||
/// Combined stability of the group (product of individual stabilities)
|
||||
pub stability: f64,
|
||||
}
|
||||
|
||||
impl PackGroup {
|
||||
fn expected_value(&self) -> f64 {
|
||||
self.size as f64 * self.stability
|
||||
}
|
||||
}
|
||||
|
||||
/// A candidate merge operation stored in the heap.
|
||||
#[derive(Debug)]
|
||||
struct MergeCandidate {
|
||||
loss: f64,
|
||||
group_a_id: usize,
|
||||
group_b_id: usize,
|
||||
}
|
||||
|
||||
impl Ord for MergeCandidate {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
// Note here we *reverse* the order of comparison because we want a
|
||||
// min-heap not a max-heap.
|
||||
other
|
||||
.loss
|
||||
.partial_cmp(&self.loss)
|
||||
.unwrap_or(Ordering::Equal)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for MergeCandidate {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for MergeCandidate {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.loss == other.loss
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for MergeCandidate {}
|
||||
|
||||
/// Calculates how to pack items into at most `max_groups` groups in a way
|
||||
/// that attempts to maximize group reuse. See module docstring for algorithm
|
||||
/// details.
|
||||
///
|
||||
/// Returns groups sorted by stability descending (most stable first). Each
|
||||
/// group contains indices into the original input slice.
|
||||
pub fn calculate_packing(items: &[PackItem], max_groups: usize) -> Vec<PackGroup> {
|
||||
if items.is_empty() || max_groups == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let n = items.len();
|
||||
|
||||
// if we already have fewer items than max_groups, no packing is needed
|
||||
if n <= max_groups {
|
||||
let mut result: Vec<PackGroup> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| PackGroup {
|
||||
indices: vec![i],
|
||||
size: item.size,
|
||||
stability: item.stability,
|
||||
})
|
||||
.collect();
|
||||
sort_by_stability_desc(&mut result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// use a Vec<Option> to track active groups; merged groups are appended
|
||||
let mut groups: Vec<Option<PackGroup>> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
Some(PackGroup {
|
||||
indices: vec![i],
|
||||
size: item.size,
|
||||
stability: item.stability,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut active_count = n;
|
||||
let mut merge_candidates = BinaryHeap::new();
|
||||
|
||||
// pre-calculate merge losses for all initial pairs
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
// SAFETY: we just created these groups above
|
||||
let g_a = groups[i].as_ref().unwrap();
|
||||
let g_b = groups[j].as_ref().unwrap();
|
||||
|
||||
let loss = calculate_merge_loss(g_a, g_b);
|
||||
merge_candidates.push(MergeCandidate {
|
||||
loss,
|
||||
group_a_id: i,
|
||||
group_b_id: j,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// do the next best merge until we're within the constraint
|
||||
while active_count > max_groups {
|
||||
let Some(merge_op) = merge_candidates.pop() else {
|
||||
break;
|
||||
};
|
||||
|
||||
// skip stale candidates (groups already merged)
|
||||
if groups[merge_op.group_a_id].is_none() || groups[merge_op.group_b_id].is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// SAFETY: we just verified above that both are Some
|
||||
let g_a = groups[merge_op.group_a_id].take().unwrap();
|
||||
let g_b = groups[merge_op.group_b_id].take().unwrap();
|
||||
|
||||
let mut new_indices = g_a.indices;
|
||||
new_indices.extend(g_b.indices);
|
||||
|
||||
// append merged group
|
||||
let new_id = groups.len();
|
||||
groups.push(Some(PackGroup {
|
||||
indices: new_indices,
|
||||
size: g_a.size + g_b.size,
|
||||
stability: g_a.stability * g_b.stability,
|
||||
}));
|
||||
active_count -= 1;
|
||||
|
||||
// calculate losses between new group and all remaining groups
|
||||
let created_group = groups[new_id].as_ref().unwrap();
|
||||
for (other_id, other_group_opt) in groups.iter().enumerate() {
|
||||
if other_id == new_id {
|
||||
continue;
|
||||
}
|
||||
// is this still an active group?
|
||||
if let Some(other_group) = other_group_opt {
|
||||
let loss = calculate_merge_loss(created_group, other_group);
|
||||
merge_candidates.push(MergeCandidate {
|
||||
loss,
|
||||
group_a_id: new_id,
|
||||
group_b_id: other_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// collect and sort results by stability descending
|
||||
let mut result: Vec<PackGroup> = groups.into_iter().flatten().collect();
|
||||
sort_by_stability_desc(&mut result);
|
||||
result
|
||||
}
|
||||
|
||||
fn sort_by_stability_desc(items: &mut [PackGroup]) {
|
||||
items.sort_by(|a, b| {
|
||||
b.stability
|
||||
.partial_cmp(&a.stability)
|
||||
.unwrap_or(Ordering::Equal)
|
||||
});
|
||||
}
|
||||
|
||||
fn calculate_merge_loss(a: &PackGroup, b: &PackGroup) -> f64 {
|
||||
let ev_separate = a.expected_value() + b.expected_value();
|
||||
|
||||
let combined_size = (a.size + b.size) as f64;
|
||||
let combined_prob = a.stability * b.stability;
|
||||
let ev_merged = combined_size * combined_prob;
|
||||
|
||||
// Loss = expected value destroyed by merging
|
||||
ev_separate - ev_merged
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
// Note from author: it's tricky to test this algorithm properly because
|
||||
// since it's a greedy algorithm, it's not guaranteed to always yield
|
||||
// the truly optimal solution. Here we test some simplified cases. In the
|
||||
// future, it'd be nice to set up a harness with real test data that we can
|
||||
// use to evaluate different algorithms or potential improvements. At least
|
||||
// that way we get a comparative validation of the algorithm.
|
||||
|
||||
/// Verifies invariants that must hold for any valid packing result.
|
||||
fn verify_packing_result(input: &[PackItem], result: &[PackGroup], max_groups: usize) {
|
||||
// check group count respects max_groups
|
||||
assert!(
|
||||
result.len() <= max_groups,
|
||||
"too many groups: {} > {}",
|
||||
result.len(),
|
||||
max_groups
|
||||
);
|
||||
|
||||
// check all indices present exactly once (no loss, no duplication)
|
||||
let mut output_indices: Vec<usize> =
|
||||
result.iter().flat_map(|g| &g.indices).copied().collect();
|
||||
output_indices.sort();
|
||||
let expected_indices: Vec<usize> = (0..input.len()).collect();
|
||||
assert_eq!(output_indices, expected_indices, "indices mismatch");
|
||||
|
||||
// check no empty groups
|
||||
assert!(
|
||||
result.iter().all(|g| !g.indices.is_empty()),
|
||||
"found empty group"
|
||||
);
|
||||
|
||||
// check sorted by stability descending
|
||||
for i in 1..result.len() {
|
||||
assert!(
|
||||
result[i - 1].stability >= result[i].stability,
|
||||
"groups not sorted by stability: {:?}",
|
||||
result.iter().map(|g| g.stability).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
// check total size preserved
|
||||
let input_total: u64 = input.iter().map(|c| c.size).sum();
|
||||
let output_total: u64 = result
|
||||
.iter()
|
||||
.flat_map(|g| &g.indices)
|
||||
.map(|&idx| input[idx].size)
|
||||
.sum();
|
||||
assert_eq!(input_total, output_total, "total size mismatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_edge_cases() {
|
||||
// empty input
|
||||
assert!(calculate_packing(&[], 5).is_empty());
|
||||
|
||||
// max_groups = 0
|
||||
let items = vec![PackItem {
|
||||
size: 100,
|
||||
stability: 0.5,
|
||||
}];
|
||||
assert!(calculate_packing(&items, 0).is_empty());
|
||||
|
||||
// single item
|
||||
let items = vec![PackItem {
|
||||
size: 100,
|
||||
stability: 0.5,
|
||||
}];
|
||||
let result = calculate_packing(&items, 5);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].indices, vec![0]);
|
||||
verify_packing_result(&items, &result, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_packing_needed() {
|
||||
// Items with different stabilities
|
||||
let items = vec![
|
||||
PackItem {
|
||||
size: 100,
|
||||
stability: 0.9,
|
||||
},
|
||||
PackItem {
|
||||
size: 200,
|
||||
stability: 0.8,
|
||||
},
|
||||
PackItem {
|
||||
size: 300,
|
||||
stability: 0.7,
|
||||
},
|
||||
];
|
||||
let result = calculate_packing(&items, 5);
|
||||
assert_eq!(result.len(), 3);
|
||||
// Should be sorted by stability descending
|
||||
assert_eq!(result[0].indices, vec![0]); // 0.9
|
||||
assert_eq!(result[1].indices, vec![1]); // 0.8
|
||||
assert_eq!(result[2].indices, vec![2]); // 0.7
|
||||
verify_packing_result(&items, &result, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_to_one_group() {
|
||||
let items = vec![
|
||||
PackItem {
|
||||
size: 100,
|
||||
stability: 0.5,
|
||||
},
|
||||
PackItem {
|
||||
size: 200,
|
||||
stability: 0.5,
|
||||
},
|
||||
PackItem {
|
||||
size: 300,
|
||||
stability: 0.5,
|
||||
},
|
||||
];
|
||||
let result = calculate_packing(&items, 1);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].indices.len(), 3);
|
||||
// All items should be in the single group
|
||||
let indices: HashSet<usize> = result[0].indices.iter().copied().collect();
|
||||
assert_eq!(indices, HashSet::from([0, 1, 2]));
|
||||
verify_packing_result(&items, &result, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_size_constant_stability_changes() {
|
||||
// merging two high-stability items has less loss than merging
|
||||
// a high-stability with a low-stability item
|
||||
// index 0: stable_1, index 1: stable_2, index 2: unstable
|
||||
let items = vec![
|
||||
PackItem {
|
||||
size: 1000,
|
||||
stability: 0.99,
|
||||
},
|
||||
PackItem {
|
||||
size: 1000,
|
||||
stability: 0.99,
|
||||
},
|
||||
PackItem {
|
||||
size: 1000,
|
||||
stability: 0.3,
|
||||
},
|
||||
];
|
||||
let result = calculate_packing(&items, 2);
|
||||
assert_eq!(result.len(), 2);
|
||||
|
||||
// the two stable items (indices 0 and 1) should be merged together
|
||||
let merged_group = result
|
||||
.iter()
|
||||
.find(|g| g.indices.len() == 2)
|
||||
.expect("one group should have 2 items");
|
||||
let merged_indices: HashSet<usize> = merged_group.indices.iter().copied().collect();
|
||||
assert_eq!(merged_indices, HashSet::from([0, 1]));
|
||||
verify_packing_result(&items, &result, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stability_constant_size_changes() {
|
||||
// with uniform stability, merging smaller items has less loss
|
||||
// index 0: huge, index 1: small1, index 2: small2
|
||||
let items = vec![
|
||||
PackItem {
|
||||
size: 10000,
|
||||
stability: 0.5,
|
||||
},
|
||||
PackItem {
|
||||
size: 10,
|
||||
stability: 0.5,
|
||||
},
|
||||
PackItem {
|
||||
size: 10,
|
||||
stability: 0.5,
|
||||
},
|
||||
];
|
||||
let result = calculate_packing(&items, 2);
|
||||
assert_eq!(result.len(), 2);
|
||||
|
||||
// The two small items (indices 1 and 2) should be merged together (least loss)
|
||||
// The huge item (index 0) should stay separate
|
||||
let huge_group = result.iter().find(|g| g.indices.contains(&0));
|
||||
assert!(huge_group.is_some());
|
||||
assert_eq!(huge_group.unwrap().indices.len(), 1);
|
||||
|
||||
let small_group = result.iter().find(|g| g.indices.contains(&1));
|
||||
assert!(small_group.is_some());
|
||||
assert!(small_group.unwrap().indices.contains(&2));
|
||||
verify_packing_result(&items, &result, 2);
|
||||
}
|
||||
}
|
||||
174
src/scan.rs
Normal file
174
src/scan.rs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::ops::ControlFlow;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use camino::Utf8Path;
|
||||
use cap_std::fs::Dir;
|
||||
use cap_std_ext::dirext::{CapStdExtDirExt, WalkConfiguration};
|
||||
|
||||
use crate::components::{self, Component, FileInfo, FileMap};
|
||||
|
||||
/// Scan the rootfs for components and return a mapping of component names to components.
|
||||
pub fn scan_for_components(
|
||||
rootfs: &Dir,
|
||||
default_mtime_clamp: u64,
|
||||
) -> Result<HashMap<String, Component>> {
|
||||
let files = scan_rootfs(rootfs).context("scanning rootfs")?;
|
||||
|
||||
let repos = components::ComponentsRepos::load(rootfs, &files, default_mtime_clamp)
|
||||
.context("loading components")?;
|
||||
if repos.is_empty() {
|
||||
// error for now but i guess we could just warn and have a single layer
|
||||
anyhow::bail!("no supported component repo found in rootfs");
|
||||
}
|
||||
|
||||
Ok(repos.into_components(files))
|
||||
}
|
||||
|
||||
/// Scan the rootfs and return a map of file paths to their metadata.
|
||||
/// We use cap-std-ext's walk here, which doesn't follow symlinks.
|
||||
pub fn scan_rootfs(rootfs: &Dir) -> Result<FileMap> {
|
||||
let mut files = BTreeMap::new();
|
||||
|
||||
let config = WalkConfiguration::default().path_base(Path::new("/"));
|
||||
|
||||
rootfs
|
||||
.walk(&config, |component| {
|
||||
let path: &Utf8Path = component
|
||||
.path
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("path is not valid UTF-8"))?;
|
||||
|
||||
let rel_path = path.strip_prefix("/").unwrap_or(path);
|
||||
let fs_path = if rel_path.as_str().is_empty() {
|
||||
"."
|
||||
} else {
|
||||
rel_path.as_str()
|
||||
};
|
||||
|
||||
let metadata = rootfs
|
||||
.symlink_metadata(fs_path)
|
||||
.with_context(|| format!("getting metadata for {}", path))?;
|
||||
|
||||
let xattrs = read_xattrs(rootfs, fs_path)
|
||||
.with_context(|| format!("reading xattrs for {}", path))?;
|
||||
|
||||
let file_info = FileInfo::from_metadata(&metadata, xattrs)
|
||||
.with_context(|| format!("processing metadata for {}", path))?;
|
||||
|
||||
files.insert(path.to_owned(), file_info);
|
||||
Ok::<_, anyhow::Error>(ControlFlow::Continue(()))
|
||||
})
|
||||
.context("failed to walk rootfs")?;
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Read all xattrs for a path.
|
||||
pub fn read_xattrs(rootfs: &Dir, fs_path: &str) -> anyhow::Result<Vec<(String, Vec<u8>)>> {
|
||||
use std::ffi::OsStr;
|
||||
|
||||
let xattr_list = rootfs
|
||||
.listxattrs(fs_path)
|
||||
.with_context(|| format!("listing xattrs for {}", fs_path))?;
|
||||
|
||||
let mut xattrs = Vec::new();
|
||||
for key in xattr_list.iter() {
|
||||
// Skip selinux attributes for now. It would only bloat images since
|
||||
// _every_ file has SELinux attributes but they come from the container
|
||||
// runtime, not the tar layer, which is ignored. Bootable containers
|
||||
// could use them, but don't currently. We can make it opt in once it's
|
||||
// desirable.
|
||||
if key == OsStr::new("security.selinux") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(value) = rootfs
|
||||
.getxattr(fs_path, key)
|
||||
.with_context(|| format!("reading xattr {} for {}", key.display(), fs_path))?
|
||||
{
|
||||
// Technically, keeping the key as OsStr would be more correct,
|
||||
// but we'll need UTF-8 to shove it in a PAX header anyway so might
|
||||
// as well error now. Note libarchive and GNU tar differ here.
|
||||
// libarchive does urlencoding, GNU tar just writes the key as is
|
||||
// anyway. We'll cross that bridge when/if we get to it.
|
||||
let key_str = key
|
||||
.to_str()
|
||||
.with_context(|| format!("non-UTF8 xattr key {} on {}", key.display(), fs_path))?;
|
||||
xattrs.push((key_str.to_string(), value));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(xattrs)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use camino::Utf8Path;
|
||||
use cap_std::ambient_authority;
|
||||
|
||||
use super::*;
|
||||
use crate::components::FileType;
|
||||
|
||||
/// Helper to get the file type for a path.
|
||||
fn get_file_type(files: &FileMap, path: &str) -> Option<FileType> {
|
||||
files.get(Utf8Path::new(path)).map(|f| f.file_type)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_rootfs_does_not_follow_symlinks() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let rootfs = Dir::open_ambient_dir(tmp.path(), ambient_authority()).unwrap();
|
||||
|
||||
rootfs.create_dir("realdir").unwrap();
|
||||
rootfs.write("realdir/file.txt", "content").unwrap();
|
||||
rootfs.symlink("realdir", "linkdir").unwrap();
|
||||
rootfs.symlink("enoent", "broken").unwrap();
|
||||
rootfs.symlink("../../../etc/passwd", "escape").unwrap();
|
||||
|
||||
let files = scan_rootfs(&rootfs).unwrap();
|
||||
|
||||
assert_eq!(get_file_type(&files, "/realdir"), Some(FileType::Directory));
|
||||
assert_eq!(
|
||||
get_file_type(&files, "/realdir/file.txt"),
|
||||
Some(FileType::File)
|
||||
);
|
||||
|
||||
assert_eq!(get_file_type(&files, "/linkdir"), Some(FileType::Symlink));
|
||||
assert_eq!(get_file_type(&files, "/broken"), Some(FileType::Symlink));
|
||||
assert_eq!(get_file_type(&files, "/escape"), Some(FileType::Symlink));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_rootfs_empty() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let rootfs = Dir::open_ambient_dir(tmp.path(), ambient_authority()).unwrap();
|
||||
|
||||
let files = scan_rootfs(&rootfs).unwrap();
|
||||
|
||||
// Should be empty. Note even the root directory is not included.
|
||||
// Root entries are not commonly in the tar stream. Container
|
||||
// runtimes ignore them so we may not even have read the real perms,
|
||||
// nor what we emit will be read. Bootable containers and other
|
||||
// OCI-but-not-container-runtime users could make use of them, but we'll
|
||||
// probably want to make it opt in if the use case shows up.
|
||||
assert_eq!(files.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_rootfs_nested_structure() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let rootfs = Dir::open_ambient_dir(tmp.path(), ambient_authority()).unwrap();
|
||||
|
||||
rootfs.create_dir_all("a/b/c").unwrap();
|
||||
rootfs.write("a/b/c/file", "content").unwrap();
|
||||
|
||||
let files = scan_rootfs(&rootfs).unwrap();
|
||||
|
||||
assert_eq!(get_file_type(&files, "/a"), Some(FileType::Directory));
|
||||
assert_eq!(get_file_type(&files, "/a/b"), Some(FileType::Directory));
|
||||
assert_eq!(get_file_type(&files, "/a/b/c"), Some(FileType::Directory));
|
||||
assert_eq!(get_file_type(&files, "/a/b/c/file"), Some(FileType::File));
|
||||
}
|
||||
}
|
||||
670
src/tar.rs
Normal file
670
src/tar.rs
Normal file
|
|
@ -0,0 +1,670 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use camino::{Utf8Path, Utf8PathBuf};
|
||||
use ocidir::oci_spec::image as oci_image;
|
||||
use ocidir::{BlobWriter, WriteComplete};
|
||||
|
||||
use crate::components::{FileInfo, FileMap, FileType};
|
||||
|
||||
/// Compression options for OCI archives.
|
||||
pub enum ArchiveCompression {
|
||||
/// No compression.
|
||||
None,
|
||||
/// Gzip compression with the specified level.
|
||||
Gzip(flate2::Compression),
|
||||
}
|
||||
|
||||
/// A passthrough writer that performs no compression.
|
||||
// XXX: upstream this to ocidir as e.g. create_uncompressed_layer() ?
|
||||
pub(crate) struct NoCompression<'a>(BlobWriter<'a>);
|
||||
|
||||
impl std::io::Write for NoCompression<'_> {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.write(buf)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
self.0.flush()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WriteComplete<BlobWriter<'a>> for NoCompression<'a> {
|
||||
fn complete(self) -> std::io::Result<BlobWriter<'a>> {
|
||||
Ok(self.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer writer that can be either compressed or uncompressed.
|
||||
pub enum LayerWriter<'a> {
|
||||
Uncompressed(ocidir::LayerWriter<'a, NoCompression<'a>>),
|
||||
Gzip(ocidir::LayerWriter<'a, flate2::write::GzEncoder<BlobWriter<'a>>>),
|
||||
}
|
||||
|
||||
impl<'a> Write for LayerWriter<'a> {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
match self {
|
||||
LayerWriter::Uncompressed(w) => w.write(buf),
|
||||
LayerWriter::Gzip(w) => w.write(buf),
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
match self {
|
||||
LayerWriter::Uncompressed(w) => w.flush(),
|
||||
LayerWriter::Gzip(w) => w.flush(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> LayerWriter<'a> {
|
||||
/// Complete the layer and return the layer.
|
||||
pub fn complete(self) -> Result<ocidir::Layer> {
|
||||
match self {
|
||||
LayerWriter::Uncompressed(w) => w.complete().context("completing uncompressed layer"),
|
||||
LayerWriter::Gzip(w) => w.complete().context("completing gzip layer"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a tar builder for a new layer in an OCI directory.
|
||||
pub fn create_layer(
|
||||
oci_dir: &ocidir::OciDir,
|
||||
compression: crate::ocibuilder::Compression,
|
||||
) -> Result<tar::Builder<LayerWriter<'_>>> {
|
||||
let layer_writer = match compression {
|
||||
crate::ocibuilder::Compression::None => {
|
||||
let layer_writer = oci_dir
|
||||
.create_custom_layer(|bw| Ok(NoCompression(bw)), oci_image::MediaType::ImageLayer)
|
||||
.context("creating uncompressed layer writer")?;
|
||||
LayerWriter::Uncompressed(layer_writer)
|
||||
}
|
||||
crate::ocibuilder::Compression::Gzip(level) => {
|
||||
let level = flate2::Compression::new(level);
|
||||
let layer_writer = oci_dir
|
||||
.create_custom_layer(
|
||||
|bw| Ok(flate2::write::GzEncoder::new(bw, level)),
|
||||
oci_image::MediaType::ImageLayerGzip,
|
||||
)
|
||||
.context("creating gzip layer writer")?;
|
||||
LayerWriter::Gzip(layer_writer)
|
||||
}
|
||||
};
|
||||
Ok(tar::Builder::new(layer_writer))
|
||||
}
|
||||
|
||||
/// Build a tar layer from a list of files and return the completed layer.
|
||||
///
|
||||
/// Parent directories are automatically created as needed using metadata from
|
||||
/// the files map. This uses a stack-based approach that leverages the sorted order
|
||||
/// of the input BTreeMap for efficiency.
|
||||
pub fn write_files_to_tar<W: Write>(
|
||||
tar_builder: &mut tar::Builder<W>,
|
||||
rootfs: &cap_std::fs::Dir,
|
||||
files: &FileMap,
|
||||
mtime_clamp: u64,
|
||||
) -> Result<()> {
|
||||
// Stack of written directory paths - leverages sorted iteration order
|
||||
let mut dir_stack: Vec<&Utf8Path> = Vec::new();
|
||||
// Track inode -> first path written for hardlink detection.
|
||||
let mut inode_to_path: HashMap<u64, Utf8PathBuf> = HashMap::new();
|
||||
|
||||
for (path, file_info) in files {
|
||||
// Pop directories that are not ancestors of current path
|
||||
while let Some(top) = dir_stack.last() {
|
||||
if path.starts_with(top) && path.as_path() != *top {
|
||||
break;
|
||||
}
|
||||
dir_stack.pop();
|
||||
}
|
||||
|
||||
// Collect ancestors that need to be written (between stack top and
|
||||
// current path's parent)
|
||||
let ancestors: Vec<_> = path
|
||||
.ancestors()
|
||||
.skip(1) // skip self
|
||||
.filter(|p| !p.as_str().is_empty() && *p != "/")
|
||||
.take_while(|p| dir_stack.last().is_none_or(|top| *p != *top))
|
||||
.collect();
|
||||
|
||||
// Write ancestors in reverse order (shallowest first) and push to stack
|
||||
for ancestor in ancestors.into_iter().rev() {
|
||||
let ancestor_path = Utf8PathBuf::from(ancestor);
|
||||
// XXX: somehow reuse existing FileInfos for that dir, which may
|
||||
// live in other components
|
||||
let ancestor_info = if let Some(info) = files.get(&ancestor_path) {
|
||||
info.clone()
|
||||
} else {
|
||||
let rel_path = ancestor.strip_prefix("/").unwrap_or(ancestor);
|
||||
let metadata = rootfs
|
||||
.symlink_metadata(rel_path)
|
||||
.with_context(|| format!("getting metadata for {}", ancestor))?;
|
||||
let xattrs = crate::scan::read_xattrs(rootfs, rel_path.as_str())
|
||||
.with_context(|| format!("reading xattrs for {}", ancestor))?;
|
||||
FileInfo::from_metadata(&metadata, xattrs)
|
||||
.with_context(|| format!("processing metadata for {}", ancestor))?
|
||||
};
|
||||
write_dir_entry(tar_builder, ancestor, mtime_clamp, &ancestor_info)
|
||||
.with_context(|| format!("writing parent directory {}", ancestor))?;
|
||||
dir_stack.push(ancestor);
|
||||
}
|
||||
|
||||
match file_info.file_type {
|
||||
FileType::Directory => {
|
||||
write_dir_entry(tar_builder, path, mtime_clamp, file_info)?;
|
||||
// We might enter this directory in the next iteration; push it
|
||||
dir_stack.push(path.as_path());
|
||||
}
|
||||
FileType::File => {
|
||||
// Handle hardlinks
|
||||
if file_info.nlink > 1 {
|
||||
if let Some(first_path) = inode_to_path.get(&file_info.ino) {
|
||||
write_hardlink_entry(
|
||||
tar_builder,
|
||||
path,
|
||||
first_path,
|
||||
mtime_clamp,
|
||||
file_info,
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
// First occurrence of this hardlinked file
|
||||
inode_to_path.insert(file_info.ino, path.clone());
|
||||
}
|
||||
write_file_entry(tar_builder, rootfs, path, mtime_clamp, file_info)?;
|
||||
}
|
||||
FileType::Symlink => {
|
||||
write_symlink_entry(tar_builder, rootfs, path, mtime_clamp, file_info)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write the OCI directory as a tar archive to a writer.
|
||||
// XXX: Consider upstreaming this to ocidir-rs.
|
||||
pub fn write_oci_archive<W: Write>(
|
||||
oci_dir: &cap_std::fs::Dir,
|
||||
writer: W,
|
||||
compression: ArchiveCompression,
|
||||
) -> Result<()> {
|
||||
match compression {
|
||||
ArchiveCompression::None => write_oci_archive_to(oci_dir, writer),
|
||||
ArchiveCompression::Gzip(level) => {
|
||||
let gzip_writer = flate2::write::GzEncoder::new(writer, level);
|
||||
write_oci_archive_to(oci_dir, gzip_writer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip leading "/" from a path, returning the path unchanged if no prefix.
|
||||
fn strip_root_prefix(path: &Utf8Path) -> &Utf8Path {
|
||||
path.strip_prefix("/").unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Prepare a tar header with common metadata from FileInfo.
|
||||
fn write_header_from_file_info(header: &mut tar::Header, file_info: &FileInfo, mtime_clamp: u64) {
|
||||
let mtime = std::cmp::min(file_info.mtime, mtime_clamp);
|
||||
header.set_mtime(mtime);
|
||||
header.set_uid(file_info.uid as u64);
|
||||
header.set_gid(file_info.gid as u64);
|
||||
header.set_mode(file_info.mode);
|
||||
}
|
||||
|
||||
/// Append xattrs as PAX extensions to the tar stream.
|
||||
///
|
||||
/// This must be called before appending the actual file entry.
|
||||
/// Uses the SCHILY.xattr.{key} format that tools like tar understand.
|
||||
fn append_xattrs<W: Write>(
|
||||
tar_builder: &mut tar::Builder<W>,
|
||||
xattrs: &[(String, Vec<u8>)],
|
||||
path: &str,
|
||||
) -> Result<()> {
|
||||
if xattrs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let pax_extensions: Vec<_> = xattrs
|
||||
.iter()
|
||||
.map(|(k, v)| (format!("SCHILY.xattr.{k}"), v.clone()))
|
||||
.collect();
|
||||
|
||||
tar_builder
|
||||
.append_pax_extensions(
|
||||
pax_extensions
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_slice())),
|
||||
)
|
||||
.with_context(|| format!("appending xattrs for {}", path))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a directory entry to the tar archive.
|
||||
fn write_dir_entry<W: Write>(
|
||||
tar_builder: &mut tar::Builder<W>,
|
||||
path: &Utf8Path,
|
||||
mtime_clamp: u64,
|
||||
file_info: &FileInfo,
|
||||
) -> Result<()> {
|
||||
let rel_path = strip_root_prefix(path);
|
||||
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_entry_type(tar::EntryType::Directory);
|
||||
header.set_size(0);
|
||||
write_header_from_file_info(&mut header, file_info, mtime_clamp);
|
||||
append_xattrs(tar_builder, &file_info.xattrs, path.as_str())
|
||||
.with_context(|| format!("appending xattrs for {}", path))?;
|
||||
|
||||
let tar_dir_path = if rel_path.as_str().is_empty() {
|
||||
"./".to_string()
|
||||
} else {
|
||||
format!("{}/", rel_path)
|
||||
};
|
||||
tar_builder
|
||||
.append_data(&mut header, &tar_dir_path, std::io::empty())
|
||||
.with_context(|| format!("appending directory {}", path))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a hardlink entry to the tar archive.
|
||||
fn write_hardlink_entry<W: Write>(
|
||||
tar_builder: &mut tar::Builder<W>,
|
||||
path: &Utf8Path,
|
||||
link_target: &Utf8Path,
|
||||
mtime_clamp: u64,
|
||||
file_info: &FileInfo,
|
||||
) -> Result<()> {
|
||||
let rel_path = strip_root_prefix(path);
|
||||
let rel_target = strip_root_prefix(link_target);
|
||||
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_entry_type(tar::EntryType::Link);
|
||||
header.set_size(0);
|
||||
write_header_from_file_info(&mut header, file_info, mtime_clamp);
|
||||
// Mask out file type bits; it's harmless but it matches what GNU tar
|
||||
// and Python's tarfile do as well. Not doing this does though result in
|
||||
// libarchive's strmode not showing the file as 'h' which shows up in diffs
|
||||
// pre vs post-chunkah.
|
||||
header.set_mode(file_info.mode & 0o7777);
|
||||
|
||||
tar_builder
|
||||
.append_link(&mut header, rel_path.as_str(), rel_target.as_str())
|
||||
.with_context(|| format!("appending hardlink {} -> {}", path, link_target))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a regular file entry to the tar archive.
|
||||
fn write_file_entry<W: Write>(
|
||||
tar_builder: &mut tar::Builder<W>,
|
||||
rootfs: &cap_std::fs::Dir,
|
||||
path: &Utf8Path,
|
||||
mtime_clamp: u64,
|
||||
file_info: &FileInfo,
|
||||
) -> Result<()> {
|
||||
let rel_path = strip_root_prefix(path);
|
||||
|
||||
let content = rootfs
|
||||
.read(rel_path)
|
||||
.with_context(|| format!("reading {}", path))?;
|
||||
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_entry_type(tar::EntryType::Regular);
|
||||
header.set_size(content.len() as u64);
|
||||
write_header_from_file_info(&mut header, file_info, mtime_clamp);
|
||||
append_xattrs(tar_builder, &file_info.xattrs, path.as_str())
|
||||
.with_context(|| format!("appending xattrs for {}", path))?;
|
||||
|
||||
tar_builder
|
||||
.append_data(&mut header, rel_path.as_str(), content.as_slice())
|
||||
.with_context(|| format!("appending file {}", path))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a symlink entry to the tar archive.
|
||||
fn write_symlink_entry<W: Write>(
|
||||
tar_builder: &mut tar::Builder<W>,
|
||||
rootfs: &cap_std::fs::Dir,
|
||||
path: &Utf8Path,
|
||||
mtime_clamp: u64,
|
||||
file_info: &FileInfo,
|
||||
) -> Result<()> {
|
||||
let rel_path = strip_root_prefix(path);
|
||||
|
||||
let target = rootfs
|
||||
.read_link_contents(rel_path)
|
||||
.with_context(|| format!("reading symlink {}", path))?;
|
||||
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_entry_type(tar::EntryType::Symlink);
|
||||
header.set_size(0);
|
||||
write_header_from_file_info(&mut header, file_info, mtime_clamp);
|
||||
append_xattrs(tar_builder, &file_info.xattrs, path.as_str())
|
||||
.with_context(|| format!("appending xattrs for {}", path))?;
|
||||
|
||||
tar_builder
|
||||
.append_link(&mut header, rel_path.as_str(), target)
|
||||
.with_context(|| format!("appending symlink {}", path))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_oci_archive_to<W: Write>(oci_dir: &cap_std::fs::Dir, writer: W) -> Result<()> {
|
||||
use cap_std_ext::dirext::CapStdExtDirExt;
|
||||
use std::ops::ControlFlow;
|
||||
|
||||
// Template headers for directories and files - cloned and modified as needed
|
||||
let dir_header = {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_entry_type(tar::EntryType::Directory);
|
||||
header.set_size(0);
|
||||
header.set_mode(0o755);
|
||||
header.set_mtime(0);
|
||||
header.set_uid(0);
|
||||
header.set_gid(0);
|
||||
header
|
||||
};
|
||||
let file_header = {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_entry_type(tar::EntryType::Regular);
|
||||
header.set_mode(0o644);
|
||||
header.set_mtime(0);
|
||||
header.set_uid(0);
|
||||
header.set_gid(0);
|
||||
header
|
||||
};
|
||||
|
||||
let mut tar = tar::Builder::new(writer);
|
||||
let config = cap_std_ext::dirext::WalkConfiguration::default().sort_by_file_name();
|
||||
|
||||
oci_dir
|
||||
.walk(&config, |component| {
|
||||
let path = component.path;
|
||||
if component.file_type.is_dir() {
|
||||
let mut header = dir_header.clone();
|
||||
// Tar directories need a trailing slash
|
||||
let path_str = format!("{}/", path.display());
|
||||
tar.append_data(&mut header, &path_str, std::io::empty())
|
||||
.with_context(|| format!("appending directory {}", path.display()))?;
|
||||
} else if component.file_type.is_file() {
|
||||
let content = component
|
||||
.dir
|
||||
.read(component.filename)
|
||||
.with_context(|| format!("reading {}", path.display()))?;
|
||||
let mut header = file_header.clone();
|
||||
header.set_size(content.len() as u64);
|
||||
tar.append_data(&mut header, path, content.as_slice())
|
||||
.with_context(|| format!("appending {}", path.display()))?;
|
||||
} else {
|
||||
anyhow::bail!("unsupported file type for {}", path.display());
|
||||
}
|
||||
Ok::<_, anyhow::Error>(ControlFlow::Continue(()))
|
||||
})
|
||||
.context("walking OCI directory")?;
|
||||
|
||||
tar.finish().context("finishing tar archive")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cap_std::ambient_authority;
|
||||
use cap_std::fs::Dir;
|
||||
use cap_std_ext::dirext::CapStdExtDirExt;
|
||||
|
||||
/// Helper to create a rootfs in a tempdir, run setup, write files to tar, and return raw bytes.
|
||||
/// Optionally accepts a closure to modify the scanned files before writing to tar.
|
||||
fn write_tar_bytes<F, M>(setup: F, modify_files: Option<M>, mtime_clamp: u64) -> Vec<u8>
|
||||
where
|
||||
F: FnOnce(&Dir),
|
||||
M: FnOnce(&mut FileMap),
|
||||
{
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let rootfs = Dir::open_ambient_dir(tmp.path(), ambient_authority()).unwrap();
|
||||
setup(&rootfs);
|
||||
|
||||
let mut files = crate::scan::scan_rootfs(&rootfs).unwrap();
|
||||
if let Some(modify) = modify_files {
|
||||
modify(&mut files);
|
||||
}
|
||||
let mut output = Vec::new();
|
||||
{
|
||||
let mut tar_builder = tar::Builder::new(&mut output);
|
||||
write_files_to_tar(&mut tar_builder, &rootfs, &files, mtime_clamp).unwrap();
|
||||
tar_builder.finish().unwrap();
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
/// Helper to create a minimal OCI directory structure.
|
||||
fn create_minimal_oci_dir() -> (tempfile::TempDir, Dir) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let oci_dir = Dir::open_ambient_dir(tmp.path(), ambient_authority()).unwrap();
|
||||
oci_dir
|
||||
.write("oci-layout", r#"{"imageLayoutVersion":"1.0.0"}"#)
|
||||
.unwrap();
|
||||
oci_dir.create_dir("blobs").unwrap();
|
||||
(tmp, oci_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_files_to_tar_preserves_xattrs() {
|
||||
let output = write_tar_bytes(
|
||||
|rootfs| {
|
||||
rootfs.write("file", "content").unwrap();
|
||||
rootfs
|
||||
.setxattr("file", "user.testattr", b"testvalue")
|
||||
.unwrap();
|
||||
},
|
||||
None::<fn(&mut FileMap)>,
|
||||
1000,
|
||||
);
|
||||
|
||||
// Extract using tar command (which properly restores xattrs)
|
||||
let extract_dir = tempfile::tempdir().unwrap();
|
||||
let mut child = std::process::Command::new("tar")
|
||||
.args(["xf", "-", "--xattrs"])
|
||||
.current_dir(extract_dir.path())
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
child.stdin.take().unwrap().write_all(&output).unwrap();
|
||||
let status = child.wait().unwrap();
|
||||
assert!(status.success(), "tar extraction failed");
|
||||
|
||||
let extracted = Dir::open_ambient_dir(extract_dir.path(), ambient_authority()).unwrap();
|
||||
let xattr_value = extracted.getxattr("file", "user.testattr").unwrap();
|
||||
assert_eq!(
|
||||
xattr_value.as_deref(),
|
||||
Some(b"testvalue".as_slice()),
|
||||
"file xattr should be preserved after extraction"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_files_to_tar_symlink() {
|
||||
let output = write_tar_bytes(
|
||||
|rootfs| {
|
||||
rootfs.write("target", "content").unwrap();
|
||||
rootfs.symlink("target", "link").unwrap();
|
||||
},
|
||||
None::<fn(&mut FileMap)>,
|
||||
1000,
|
||||
);
|
||||
|
||||
let mut archive = tar::Archive::new(output.as_slice());
|
||||
let mut found_link = false;
|
||||
for entry in archive.entries().unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
if entry.header().entry_type() == tar::EntryType::Symlink {
|
||||
let link_name = entry.header().link_name().unwrap().unwrap();
|
||||
assert_eq!(link_name.to_string_lossy(), "target");
|
||||
found_link = true;
|
||||
}
|
||||
}
|
||||
assert!(found_link, "symlink should be in tar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_files_to_tar_creates_parent_dirs() {
|
||||
// Parent directories not in files are created via symlink_metadata() fallback
|
||||
let output = write_tar_bytes(
|
||||
|rootfs| {
|
||||
rootfs.create_dir_all("a/b/c").unwrap();
|
||||
rootfs.write("a/b/c/file", "content").unwrap();
|
||||
},
|
||||
Some(|files: &mut FileMap| {
|
||||
// Remove parent dirs to test symlink_metadata() fallback
|
||||
files.remove(&Utf8PathBuf::from("/a"));
|
||||
files.remove(&Utf8PathBuf::from("/a/b"));
|
||||
}),
|
||||
1000,
|
||||
);
|
||||
|
||||
let mut archive = tar::Archive::new(output.as_slice());
|
||||
let paths: Vec<_> = archive
|
||||
.entries()
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path().unwrap().to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
// Should have all directories and file
|
||||
assert!(paths.contains(&"a/".to_string()), "missing a/: {:?}", paths);
|
||||
assert!(
|
||||
paths.contains(&"a/b/".to_string()),
|
||||
"missing a/b/: {:?}",
|
||||
paths
|
||||
);
|
||||
assert!(
|
||||
paths.contains(&"a/b/c/".to_string()),
|
||||
"missing a/b/c/: {:?}",
|
||||
paths
|
||||
);
|
||||
assert!(
|
||||
paths.contains(&"a/b/c/file".to_string()),
|
||||
"missing a/b/c/file: {:?}",
|
||||
paths
|
||||
);
|
||||
|
||||
// Directories should come before the file (sorted order)
|
||||
let a_pos = paths.iter().position(|p| p == "a/").unwrap();
|
||||
let ab_pos = paths.iter().position(|p| p == "a/b/").unwrap();
|
||||
let abc_pos = paths.iter().position(|p| p == "a/b/c/").unwrap();
|
||||
let file_pos = paths.iter().position(|p| p == "a/b/c/file").unwrap();
|
||||
assert!(a_pos < ab_pos, "a/ should come before a/b/");
|
||||
assert!(ab_pos < abc_pos, "a/b/ should come before a/b/c/");
|
||||
assert!(abc_pos < file_pos, "a/b/c/ should come before a/b/c/file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_oci_archive_uncompressed() {
|
||||
let (_tmp, oci_dir) = create_minimal_oci_dir();
|
||||
|
||||
let mut output = Vec::new();
|
||||
write_oci_archive(&oci_dir, &mut output, ArchiveCompression::None).unwrap();
|
||||
|
||||
// Verify it's a valid tar
|
||||
let mut archive = tar::Archive::new(output.as_slice());
|
||||
let entries: Vec<_> = archive.entries().unwrap().map(|e| e.unwrap()).collect();
|
||||
assert!(!entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_oci_archive_gzip() {
|
||||
let (_tmp, oci_dir) = create_minimal_oci_dir();
|
||||
|
||||
let mut output = Vec::new();
|
||||
write_oci_archive(
|
||||
&oci_dir,
|
||||
&mut output,
|
||||
ArchiveCompression::Gzip(flate2::Compression::fast()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Verify it's gzip compressed (magic bytes)
|
||||
assert_eq!(output[0], 0x1f);
|
||||
assert_eq!(output[1], 0x8b);
|
||||
|
||||
// Decompress and verify it's a valid tar
|
||||
let decoder = flate2::read::GzDecoder::new(output.as_slice());
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
let entries: Vec<_> = archive.entries().unwrap().map(|e| e.unwrap()).collect();
|
||||
assert!(!entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_files_to_tar_hardlinks() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let rootfs = Dir::open_ambient_dir(tmp.path(), ambient_authority()).unwrap();
|
||||
|
||||
// Create original file and a hardlink to it
|
||||
rootfs.write("original", "content").unwrap();
|
||||
std::fs::hard_link(tmp.path().join("original"), tmp.path().join("hardlink")).unwrap();
|
||||
|
||||
let files = crate::scan::scan_rootfs(&rootfs).unwrap();
|
||||
|
||||
// Verify the scan picked up the hardlink metadata
|
||||
let original_info = files.get(&Utf8PathBuf::from("/original")).unwrap();
|
||||
let hardlink_info = files.get(&Utf8PathBuf::from("/hardlink")).unwrap();
|
||||
assert_eq!(original_info.ino, hardlink_info.ino, "inodes should match");
|
||||
assert!(original_info.nlink > 1, "nlink should be > 1");
|
||||
assert!(hardlink_info.nlink > 1, "nlink should be > 1");
|
||||
|
||||
let mut output = Vec::new();
|
||||
{
|
||||
let mut tar_builder = tar::Builder::new(&mut output);
|
||||
write_files_to_tar(&mut tar_builder, &rootfs, &files, 1000).unwrap();
|
||||
tar_builder.finish().unwrap();
|
||||
}
|
||||
|
||||
// Parse the tar and verify entries
|
||||
let mut archive = tar::Archive::new(output.as_slice());
|
||||
|
||||
for entry in archive.entries().unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let path = entry.path().unwrap().to_string_lossy().to_string();
|
||||
|
||||
match entry.header().entry_type() {
|
||||
tar::EntryType::Regular => assert_eq!(path, "hardlink"),
|
||||
tar::EntryType::Link => {
|
||||
assert_eq!(path, "original");
|
||||
let hardlink_target = entry
|
||||
.header()
|
||||
.link_name()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
assert_eq!(hardlink_target, "hardlink");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity-check they extract as hardlinks
|
||||
let extract_dir = tempfile::tempdir().unwrap();
|
||||
let mut child = std::process::Command::new("tar")
|
||||
.args(["xf", "-"])
|
||||
.current_dir(extract_dir.path())
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
child.stdin.take().unwrap().write_all(&output).unwrap();
|
||||
let status = child.wait().unwrap();
|
||||
assert!(status.success(), "tar extraction failed");
|
||||
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let original_meta = std::fs::metadata(extract_dir.path().join("original")).unwrap();
|
||||
let hardlink_meta = std::fs::metadata(extract_dir.path().join("hardlink")).unwrap();
|
||||
assert_eq!(
|
||||
original_meta.ino(),
|
||||
hardlink_meta.ino(),
|
||||
"extracted files should have same inode"
|
||||
);
|
||||
}
|
||||
}
|
||||
37
src/utils.rs
Normal file
37
src/utils.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub fn get_current_epoch() -> Result<u64> {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.context("system time is before UNIX epoch")
|
||||
.map(|d| d.as_secs())
|
||||
}
|
||||
|
||||
/// Returns the OCI/Go architecture string.
|
||||
///
|
||||
/// If `arch` is provided, translates it to OCI format.
|
||||
/// Otherwise, uses the current system architecture.
|
||||
pub fn get_goarch(arch: Option<&str>) -> &str {
|
||||
match arch.unwrap_or(std::env::consts::ARCH) {
|
||||
"x86_64" => "amd64",
|
||||
"aarch64" => "arm64",
|
||||
"powerpc64" => "ppc64le",
|
||||
arch => arch,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_goarch() {
|
||||
assert_eq!(get_goarch(Some("x86_64")), "amd64");
|
||||
assert_eq!(get_goarch(Some("aarch64")), "arm64");
|
||||
assert_eq!(get_goarch(Some("powerpc64")), "ppc64le");
|
||||
assert_eq!(get_goarch(Some("amd64")), "amd64"); // passthrough
|
||||
assert_eq!(get_goarch(Some("unknown")), "unknown"); // passthrough
|
||||
}
|
||||
}
|
||||
120
tests/e2e/run.sh
Executable file
120
tests/e2e/run.sh
Executable file
|
|
@ -0,0 +1,120 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
shopt -s inherit_errexit
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [OPTIONS] [TEST...]
|
||||
|
||||
Run end-to-end tests for chunkah.
|
||||
|
||||
Options:
|
||||
-v, --verbose Show test output in real-time (default: only on failure)
|
||||
-h, --help Show this help message
|
||||
|
||||
Arguments:
|
||||
TEST Test name(s) to run (without 'test-' prefix and '.sh' suffix).
|
||||
If none specified, all test-*.sh files are run.
|
||||
|
||||
Environment:
|
||||
CHUNKAH_IMG Container image to test (default: localhost/chunkah:latest)
|
||||
|
||||
Examples:
|
||||
$(basename "$0") # Run all tests
|
||||
$(basename "$0") fedora-build # Run only test-fedora-build.sh
|
||||
$(basename "$0") -v fedora-build # Run with verbose output
|
||||
EOF
|
||||
}
|
||||
|
||||
CHUNKAH_IMG=${CHUNKAH_IMG:-localhost/chunkah:latest}
|
||||
export CHUNKAH_IMG
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "${SCRIPT_DIR}"
|
||||
|
||||
ONLY_TESTS=()
|
||||
VERBOSE=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--verbose|-v)
|
||||
VERBOSE=true
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
echo "Error: Unknown option: $1" >&2
|
||||
echo "Try '$(basename "$0") --help' for more information." >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
ONLY_TESTS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Find and run test scripts
|
||||
if [[ ${#ONLY_TESTS[@]} -gt 0 ]]; then
|
||||
tests=()
|
||||
for name in "${ONLY_TESTS[@]}"; do
|
||||
testfile="test-${name}.sh"
|
||||
if [[ ! -f "${testfile}" ]]; then
|
||||
echo "Error: Test not found: ${testfile}" >&2
|
||||
exit 1
|
||||
fi
|
||||
tests+=("${testfile}")
|
||||
done
|
||||
else
|
||||
tests=( test-*.sh )
|
||||
fi
|
||||
|
||||
total=${#tests[@]}
|
||||
passed=0
|
||||
failed=0
|
||||
failed_tests=()
|
||||
|
||||
echo ""
|
||||
echo "Running ${total} e2e test(s)..."
|
||||
echo ""
|
||||
|
||||
for test in "${tests[@]}"; do
|
||||
echo "=== Running ${test} ==="
|
||||
TMPDIR=$(mktemp -d)
|
||||
export TMPDIR
|
||||
abstest=$(realpath "${test}")
|
||||
|
||||
if [[ ${VERBOSE} == true ]]; then exec 3>&2; else exec 3>"${TMPDIR}/.out"; fi
|
||||
if (cd "${TMPDIR}" && bash "${abstest}") >&3 2>&3; then
|
||||
echo "=== PASSED: ${test} ==="
|
||||
passed=$((passed+1))
|
||||
else
|
||||
echo "=== FAILED: ${test} ==="
|
||||
failed=$((failed+1))
|
||||
failed_tests+=("${test}")
|
||||
if [[ ${VERBOSE} == false ]]; then
|
||||
echo "--- Test output ---"
|
||||
cat "${TMPDIR}/.out"
|
||||
echo "--- End output ---"
|
||||
fi
|
||||
fi
|
||||
|
||||
exec 3>&-
|
||||
rm -rf "${TMPDIR}"
|
||||
unset TMPDIR
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo "========================================"
|
||||
echo "Results: ${passed} passed, ${failed} failed"
|
||||
echo "========================================"
|
||||
|
||||
if [[ "${failed}" -gt 0 ]]; then
|
||||
echo "Failed tests:"
|
||||
for t in "${failed_tests[@]}"; do
|
||||
echo " - ${t}"
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
46
tests/e2e/test-fedora-build.sh
Executable file
46
tests/e2e/test-fedora-build.sh
Executable file
|
|
@ -0,0 +1,46 @@
|
|||
#!/bin/bash
|
||||
set -xeuo pipefail
|
||||
shopt -s inherit_errexit
|
||||
|
||||
TARGET_IMAGE="quay.io/fedora/fedora-minimal:latest"
|
||||
CHUNKED_IMAGE="localhost/fedora-minimal-chunked:test"
|
||||
|
||||
# Find repo root from script location
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
cleanup() {
|
||||
podman rmi -f "${CHUNKED_IMAGE}" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
tmp_args=()
|
||||
version=$(${BUILDAH:-buildah} version --json | jq -r '.version')
|
||||
min_version=$(echo -e "${version}\n1.43" | sort -V | head -n1)
|
||||
if [[ "${min_version}" != "1.43" ]]; then
|
||||
tmp_args+=(-v "${PWD}:/run/src" --security-opt=label=disable)
|
||||
fi
|
||||
|
||||
# build split image using Containerfile.splitter API
|
||||
podman pull "${TARGET_IMAGE}"
|
||||
config_str=$(podman inspect "${TARGET_IMAGE}")
|
||||
${BUILDAH:-buildah} build --skip-unused-stages=false \
|
||||
--from "${TARGET_IMAGE}" --build-arg CHUNKAH="${CHUNKAH_IMG:?}" \
|
||||
--build-arg CHUNKAH_CONFIG_STR="${config_str}" \
|
||||
-t "${CHUNKED_IMAGE}" "${tmp_args[@]}" "${REPO_ROOT}/Containerfile.splitter"
|
||||
|
||||
# sanity-check it
|
||||
podman run --rm "${CHUNKED_IMAGE}" cat /etc/os-release | grep Fedora
|
||||
|
||||
# use skopeo to inspect the ociarchive and check for layer annotations
|
||||
layer_annotations=$(skopeo inspect "containers-storage:${CHUNKED_IMAGE}" | \
|
||||
jq -r '.LayersData[].Annotations["org.chunkah.component"] // empty')
|
||||
|
||||
# check for some expected components
|
||||
grep -q "rpm/filesystem" <<< "${layer_annotations}"
|
||||
grep -q "rpm/setup" <<< "${layer_annotations}"
|
||||
grep -q "rpm/glibc" <<< "${layer_annotations}"
|
||||
|
||||
# sanity-check we got at least 16 components
|
||||
n=$(wc -l <<< "${layer_annotations}")
|
||||
[[ ${n} -gt 16 ]]
|
||||
50
tests/e2e/test-fedora-run.sh
Executable file
50
tests/e2e/test-fedora-run.sh
Executable file
|
|
@ -0,0 +1,50 @@
|
|||
#!/bin/bash
|
||||
set -xeuo pipefail
|
||||
shopt -s inherit_errexit
|
||||
|
||||
SOURCE_IMAGE="localhost/fedora:test"
|
||||
CHUNKED_IMAGE="localhost/fedora-chunked:test"
|
||||
|
||||
cleanup() {
|
||||
podman rmi -f "${SOURCE_IMAGE}" 2>/dev/null || true
|
||||
podman rmi -f "${CHUNKED_IMAGE}" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# build a derived image so we can test file cap handling
|
||||
podman build -t "${SOURCE_IMAGE}" -f - <<'EOF'
|
||||
FROM quay.io/fedora/fedora:latest
|
||||
# create a test binary and set a capability on it
|
||||
RUN cp /usr/bin/true /usr/bin/test-caps && setcap cap_net_raw+ep /usr/bin/test-caps
|
||||
EOF
|
||||
CHUNKAH_CONFIG_STR=$(podman inspect "${SOURCE_IMAGE}")
|
||||
|
||||
# run chunkah!
|
||||
podman run --rm --mount=type=image,src="${SOURCE_IMAGE}",target=/chunkah \
|
||||
-e CHUNKAH_CONFIG_STR="${CHUNKAH_CONFIG_STR}" \
|
||||
"${CHUNKAH_IMG:?}" build > out.ociarchive
|
||||
|
||||
# XXX: need to fix 'podman load' to only print image ID on its stdout, like 'podman pull'
|
||||
iid=$(podman load -i out.ociarchive)
|
||||
iid=${iid#*sha256:}
|
||||
podman tag "${iid}" "${CHUNKED_IMAGE}"
|
||||
|
||||
# sanity-check it
|
||||
podman run --rm "${CHUNKED_IMAGE}" cat /etc/os-release | grep Fedora
|
||||
|
||||
# use skopeo to inspect the ociarchive and check for layer annotations
|
||||
layer_annotations=$(skopeo inspect "containers-storage:${CHUNKED_IMAGE}" | \
|
||||
jq -r '.LayersData[].Annotations["org.chunkah.component"] // empty')
|
||||
|
||||
# check for some expected components
|
||||
grep -q "rpm/filesystem" <<< "${layer_annotations}"
|
||||
grep -q "rpm/setup" <<< "${layer_annotations}"
|
||||
grep -q "rpm/glibc" <<< "${layer_annotations}"
|
||||
|
||||
# sanity-check we got at least 16 layers
|
||||
n=$(skopeo inspect "containers-storage:${CHUNKED_IMAGE}" | jq '.LayersData | length')
|
||||
[[ ${n} -ge 16 ]]
|
||||
|
||||
# verify that security.capability xattrs are preserved
|
||||
caps=$(podman run --rm "${CHUNKED_IMAGE}" getcap /usr/bin/test-caps)
|
||||
[[ "${caps}" == *"cap_net_raw=ep"* ]]
|
||||
11
tests/fixtures/empty.Containerfile
vendored
Normal file
11
tests/fixtures/empty.Containerfile
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
FROM scratch
|
||||
USER myuser
|
||||
EXPOSE 8888
|
||||
ENV MYENV1=MYVAL1
|
||||
ENV MYENV2=MYVAL2
|
||||
ENTRYPOINT ["my", "entrypoint"]
|
||||
CMD ["my", "cmd"]
|
||||
VOLUME /myvolume
|
||||
WORKDIR /
|
||||
LABEL mylabel=myval
|
||||
STOPSIGNAL SIGQUIT
|
||||
27
tests/fixtures/empty.image-config.json
vendored
Normal file
27
tests/fixtures/empty.image-config.json
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"User": "myuser",
|
||||
"ExposedPorts": {
|
||||
"8888/tcp": {}
|
||||
},
|
||||
"Env": [
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"MYENV1=MYVAL1",
|
||||
"MYENV2=MYVAL2"
|
||||
],
|
||||
"Entrypoint": [
|
||||
"my",
|
||||
"entrypoint"
|
||||
],
|
||||
"Cmd": [
|
||||
"my",
|
||||
"cmd"
|
||||
],
|
||||
"Volumes": {
|
||||
"/myvolume": {}
|
||||
},
|
||||
"WorkingDir": "/",
|
||||
"Labels": {
|
||||
"mylabel": "myval"
|
||||
},
|
||||
"StopSignal": "SIGQUIT"
|
||||
}
|
||||
38308
tests/fixtures/fedora.json
vendored
Normal file
38308
tests/fixtures/fedora.json
vendored
Normal file
File diff suppressed because one or more lines are too long
13
tests/fixtures/minimal.Containerfile
vendored
Normal file
13
tests/fixtures/minimal.Containerfile
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# This isn't used by any unit tests yet, but I've used the resulting OCI archive
|
||||
# a few times to debug basic OCI parsing/chunking stuff.
|
||||
ARG ADD_PACKAGES=""
|
||||
FROM quay.io/fedora/fedora-minimal AS builder
|
||||
RUN dnf install -y \
|
||||
--installroot /target \
|
||||
--use-host-config \
|
||||
--nodocs \
|
||||
--setopt=install_weak_deps=False \
|
||||
filesystem setup $ADD_PACKAGES && rm -rf /var/cache/*
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /target /
|
||||
41
tests/fixtures/regenerate.sh
vendored
Executable file
41
tests/fixtures/regenerate.sh
vendored
Executable file
|
|
@ -0,0 +1,41 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
shopt -s inherit_errexit
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo ">>> REGENERATING: fedora.json" >&2
|
||||
|
||||
# Packages to cherry-pick from the full rpm -qa output
|
||||
PACKAGES=(
|
||||
bash
|
||||
glibc
|
||||
coreutils
|
||||
rpm
|
||||
shadow-utils
|
||||
util-linux-core
|
||||
setup
|
||||
perl-POSIX
|
||||
)
|
||||
|
||||
# Build jq filter to select packages by name
|
||||
filter=$(printf '"%s",' "${PACKAGES[@]}")
|
||||
filter="[${filter%,}]"
|
||||
|
||||
podman run --rm quay.io/fedora/fedora-minimal:latest rpm -qa --json | \
|
||||
jq -s --argjson names "${filter}" '
|
||||
[.[] | select(.Name as $n | $names | index($n))] | sort_by(.Name) | .[]
|
||||
' > fedora.json
|
||||
|
||||
echo ">>> REGENERATING: empty.image-config.json" >&2
|
||||
buildah build --omit-history -f empty.Containerfile -t chunkah-empty
|
||||
podman inspect chunkah-empty | jq '.[0].Config' > empty.image-config.json
|
||||
podman rmi chunkah-empty
|
||||
|
||||
echo ">>> REGENERATING rpmdb.sqlite" >&2
|
||||
podman rm -f chunkah-test-fixture-tmp
|
||||
podman run --name chunkah-test-fixture-tmp --rm quay.io/hummingbird-ci/builder bash -c '
|
||||
dnf install --installroot /mnt -y --use-host-config --nodocs --setopt=install_weak_deps=False filesystem setup &>2
|
||||
sqlite3 /mnt/usr/lib/sysimage/rpm/rpmdb.sqlite "PRAGMA journal_mode = DELETE;" &>2
|
||||
cat /mnt/usr/lib/sysimage/rpm/rpmdb.sqlite
|
||||
' > rpmdb.sqlite
|
||||
BIN
tests/fixtures/rpmdb.sqlite
vendored
Normal file
BIN
tests/fixtures/rpmdb.sqlite
vendored
Normal file
Binary file not shown.
240
tools/split-and-diff.sh
Executable file
240
tools/split-and-diff.sh
Executable file
|
|
@ -0,0 +1,240 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
shopt -s inherit_errexit
|
||||
|
||||
# Verify an image split through chunkah has identical data/metadata except for
|
||||
# mtimes by comparing rootfs tarballs with diffoscope.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [OPTIONS] <source-image> <output-file>
|
||||
|
||||
Split an image through chunkah and compare the result with diffoscope.
|
||||
|
||||
Options:
|
||||
--raw Compare raw layer tarballs without flattening (requires
|
||||
single-layer images)
|
||||
--no-filter-mtime Don't filter out mtime differences from output
|
||||
-h, --help Show this help message
|
||||
|
||||
Arguments:
|
||||
source-image Source image to split (e.g. docker://quay.io/fedora/fedora-minimal)
|
||||
output-file Path to write diffoscope output (filtered for mtime)
|
||||
|
||||
Environment:
|
||||
CHUNKAH_IMG Container image to use for splitting
|
||||
(default: quay.io/jlebon/chunkah:latest)
|
||||
|
||||
Exit codes:
|
||||
0 No differences found (output file is empty)
|
||||
1 Differences found (output file contains diff)
|
||||
|
||||
Examples:
|
||||
$(basename "$0") docker://quay.io/fedora/fedora-minimal /tmp/diff.txt
|
||||
$(basename "$0") --raw oci-archive:image.ociarchive /tmp/diff.txt
|
||||
CHUNKAH_IMG=localhost/chunkah:latest $(basename "$0") docker://... /tmp/out.txt
|
||||
EOF
|
||||
}
|
||||
|
||||
# Parse options
|
||||
raw_mode=false
|
||||
filter_mtime=true
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "${1}" in
|
||||
--raw)
|
||||
raw_mode=true
|
||||
shift
|
||||
;;
|
||||
--no-filter-mtime)
|
||||
filter_mtime=false
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
-*)
|
||||
echo "Error: Unknown option: ${1}" >&2
|
||||
echo "Try '$(basename "$0") --help' for more information." >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate arguments
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Error: Missing required arguments" >&2
|
||||
echo "Try '$(basename "$0") --help' for more information." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source_image="${1}"
|
||||
output_file="${2}"
|
||||
|
||||
# Check required tools
|
||||
for tool in skopeo podman buildah diffoscope jq; do
|
||||
if ! command -v "${tool}" &> /dev/null; then
|
||||
echo "Error: Required tool '${tool}' not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Generate unique run ID for image names to allow concurrent runs
|
||||
run_id="chunkah-diff-$$-$(date +%s)"
|
||||
|
||||
# Setup cleanup trap with safe handling of unset variables
|
||||
tmpdir=""
|
||||
original_image=""
|
||||
split_image=""
|
||||
# shellcheck disable=SC2329 # invoked via trap
|
||||
cleanup() {
|
||||
if [[ -n "${tmpdir}" ]]; then
|
||||
chmod -R u+w "${tmpdir}"
|
||||
rm -rf "${tmpdir}"
|
||||
fi
|
||||
if [[ -n "${original_image}" ]]; then podman rmi -f "${original_image}" 2>/dev/null || true; fi
|
||||
if [[ -n "${split_image}" ]]; then podman rmi -f "${split_image}" 2>/dev/null || true; fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
|
||||
# Extract rootfs tarball from an OCI archive by flattening all layers
|
||||
extract_rootfs_tarball() {
|
||||
local ociarchive="${1}"
|
||||
local output_tar="${2}"
|
||||
local workdir
|
||||
workdir=$(mktemp -d)
|
||||
|
||||
mkdir -p "${workdir}/oci" "${workdir}/rootfs"
|
||||
tar -xf "${ociarchive}" -C "${workdir}/oci"
|
||||
|
||||
local manifest_digest
|
||||
manifest_digest=$(jq -r '.manifests[0].digest | split(":")[1]' "${workdir}/oci/index.json")
|
||||
|
||||
# Extract each layer in order (auto-detect compression)
|
||||
jq -r '.layers[].digest | split(":")[1]' "${workdir}/oci/blobs/sha256/${manifest_digest}" | \
|
||||
while read -r layer_digest; do
|
||||
local layer_blob="${workdir}/oci/blobs/sha256/${layer_digest}"
|
||||
# Use file magic to detect compression, fallback to gzip
|
||||
case "$(file -b "${layer_blob}")" in
|
||||
*gzip*) tar -xzf "${layer_blob}" -C "${workdir}/rootfs" ;;
|
||||
*zstd*) tar -I zstd -xf "${layer_blob}" -C "${workdir}/rootfs" ;;
|
||||
*POSIX\ tar*) tar -xf "${layer_blob}" -C "${workdir}/rootfs" ;;
|
||||
*) tar -xzf "${layer_blob}" -C "${workdir}/rootfs" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Fix files with permissions 000 so they can be read for comparison
|
||||
find "${workdir}/rootfs" -type f -perm 000 -exec chmod 644 {} +
|
||||
|
||||
# Create deterministic tarball (sorted entries)
|
||||
tar -cf "${output_tar}" -C "${workdir}/rootfs" --sort=name .
|
||||
chmod -R u+w "${workdir}"
|
||||
rm -rf "${workdir}"
|
||||
}
|
||||
|
||||
# Get raw layer tarball from an OCI archive (for --raw mode)
|
||||
get_raw_layer_tarball() {
|
||||
local ociarchive="${1}"
|
||||
local output_tar="${2}"
|
||||
local workdir
|
||||
workdir=$(mktemp -d)
|
||||
|
||||
tar -xf "${ociarchive}" -C "${workdir}"
|
||||
|
||||
local manifest_digest
|
||||
manifest_digest=$(jq -r '.manifests[0].digest | split(":")[1]' "${workdir}/index.json")
|
||||
|
||||
# Get the single layer (--raw mode expects single-layer images)
|
||||
local layer_count
|
||||
layer_count=$(jq '.layers | length' "${workdir}/blobs/sha256/${manifest_digest}")
|
||||
if [[ "${layer_count}" -ne 1 ]]; then
|
||||
echo "Error: --raw mode requires single-layer images, found ${layer_count} layers" >&2
|
||||
rm -rf "${workdir}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local layer_digest
|
||||
layer_digest=$(jq -r '.layers[0].digest | split(":")[1]' "${workdir}/blobs/sha256/${manifest_digest}")
|
||||
|
||||
# Copy the raw layer blob (may be compressed)
|
||||
cp "${workdir}/blobs/sha256/${layer_digest}" "${output_tar}"
|
||||
rm -rf "${workdir}"
|
||||
}
|
||||
|
||||
# Prepare original image
|
||||
echo "Preparing original image..."
|
||||
original_image="${run_id}-original"
|
||||
skopeo copy "${source_image}" "oci-archive:${tmpdir}/original.ociarchive"
|
||||
skopeo copy "oci-archive:${tmpdir}/original.ociarchive" "containers-storage:${original_image}"
|
||||
|
||||
# Split image via Containerfile.splitter
|
||||
echo "Splitting image through chunkah..."
|
||||
config_str=$(podman inspect "${original_image}")
|
||||
|
||||
# Check buildah version for compatibility (< 1.43 needs extra args)
|
||||
buildah_args=()
|
||||
buildah_version=$(buildah version --json | jq -r '.version')
|
||||
min_version=$(echo -e "${buildah_version}\n1.43" | sort -V | head -n1)
|
||||
if [[ "${min_version}" != "1.43" ]]; then
|
||||
buildah_args+=(-v "${tmpdir}:/run/src" --security-opt=label=disable)
|
||||
fi
|
||||
|
||||
# Support custom chunkah image via CHUNKAH_IMG env var
|
||||
chunkah_img="${CHUNKAH_IMG:-quay.io/jlebon/chunkah:latest}"
|
||||
|
||||
pushd "${tmpdir}" > /dev/null
|
||||
buildah build --skip-unused-stages=false \
|
||||
"${buildah_args[@]}" \
|
||||
--from "containers-storage:${original_image}" \
|
||||
--build-arg "CHUNKAH=${chunkah_img}" \
|
||||
--build-arg CHUNKAH_ARGS="--max-layers=1" \
|
||||
--build-arg "CHUNKAH_CONFIG_STR=${config_str}" \
|
||||
"${REPO_ROOT}/Containerfile.splitter"
|
||||
popd > /dev/null
|
||||
|
||||
split_image=$(podman load -i "${tmpdir}/out.ociarchive" | awk '{print $NF}' | sed 's/^sha256://')
|
||||
skopeo copy "containers-storage:${split_image}" "oci-archive:${tmpdir}/split.ociarchive"
|
||||
|
||||
# Extract and compare
|
||||
echo "Comparing images with diffoscope..."
|
||||
if [[ "${raw_mode}" == "true" ]]; then
|
||||
# Raw mode: compare unextracted layer tarballs directly
|
||||
get_raw_layer_tarball "${tmpdir}/original.ociarchive" "${tmpdir}/original-layer.tar"
|
||||
get_raw_layer_tarball "${tmpdir}/split.ociarchive" "${tmpdir}/split-layer.tar"
|
||||
original_tar="${tmpdir}/original-layer.tar"
|
||||
split_tar="${tmpdir}/split-layer.tar"
|
||||
else
|
||||
# Default: flatten layers to rootfs tarballs
|
||||
extract_rootfs_tarball "${tmpdir}/original.ociarchive" "${tmpdir}/original-rootfs.tar"
|
||||
extract_rootfs_tarball "${tmpdir}/split.ociarchive" "${tmpdir}/split-rootfs.tar"
|
||||
original_tar="${tmpdir}/original-rootfs.tar"
|
||||
split_tar="${tmpdir}/split-rootfs.tar"
|
||||
fi
|
||||
|
||||
diffoscope "${original_tar}" "${split_tar}" \
|
||||
> "${tmpdir}/diffoscope-raw.txt" 2>&1 || true
|
||||
|
||||
if [[ "${filter_mtime}" == "true" ]]; then
|
||||
# Filter out mtime-related lines
|
||||
grep -v -E '(mtime|Mtime|modification time)' "${tmpdir}/diffoscope-raw.txt" \
|
||||
> "${output_file}" || true
|
||||
else
|
||||
cp "${tmpdir}/diffoscope-raw.txt" "${output_file}"
|
||||
fi
|
||||
|
||||
# Exit 0 if no differences (empty output), 1 if differences found
|
||||
if [[ -s "${output_file}" ]]; then
|
||||
echo "Differences found (see ${output_file})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "No differences found"
|
||||
exit 0
|
||||
Loading…
Reference in a new issue