Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 47 additions & 5 deletions .github/workflows/dstack-ingress-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,31 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4

- name: Parse version from tag
- name: Parse and check version
run: |
VERSION=${GITHUB_REF#refs/tags/dstack-ingress-v}
# The image records its version from the committed VERSION file, so
# that a plain checkout reproduces the digest. The release tag only
# selects which commit to build and must agree with that file.
VERSION=$(tr -d '[:space:]' < custom-domain/dstack-ingress/VERSION)
if [ -z "${VERSION}" ]; then
echo "Unable to parse version from ref: ${GITHUB_REF}" >&2
echo "custom-domain/dstack-ingress/VERSION is empty" >&2
exit 1
fi
case "${GITHUB_REF}" in
refs/tags/dstack-ingress-v*)
TAG_VERSION=${GITHUB_REF#refs/tags/dstack-ingress-v}
if [ "${TAG_VERSION}" != "${VERSION}" ]; then
echo "Tag dstack-ingress-v${TAG_VERSION} does not match custom-domain/dstack-ingress/VERSION (${VERSION})." >&2
echo "Update VERSION and re-tag, so the image version matches the release." >&2
exit 1
fi
;;
*)
echo "This workflow builds a release and must run on a dstack-ingress-v* tag." >&2
echo "Got ref: ${GITHUB_REF}. Re-run it selecting the release tag." >&2
exit 1
;;
esac
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
echo "IMAGE_REFERENCE=${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${VERSION}" >> "$GITHUB_ENV"
echo "Parsed version: ${VERSION}"
Expand All @@ -49,7 +67,7 @@ jobs:
env:
IMAGE_REFERENCE: ${{ env.IMAGE_REFERENCE }}
run: |
./build-image.sh --push "${IMAGE_REFERENCE}"
./build-image.sh --require-clean --push "${IMAGE_REFERENCE}"

- name: Capture image digest
id: capture-digest
Expand Down Expand Up @@ -79,6 +97,7 @@ jobs:
echo ""
echo "- Tag: \`${IMAGE_REFERENCE}\`"
echo "- Digest: \`${IMAGE_DIGEST}\`"
echo "- Source: \`${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/tree/${GITHUB_SHA}/custom-domain/dstack-ingress\`"
echo "- Sigstore: https://search.sigstore.dev/?hash=${IMAGE_DIGEST}"
} >> "$GITHUB_STEP_SUMMARY"

Expand All @@ -90,4 +109,27 @@ jobs:

| Image | Digest | Verification |
|---|---|---|
| ${{ env.IMAGE_REFERENCE }} | ${{ steps.capture-digest.outputs.digest }} | [Verify on Sigstore](https://search.sigstore.dev/?hash=${{ steps.capture-digest.outputs.digest }}) |
| ${{ env.IMAGE_REFERENCE }} | ${{ steps.capture-digest.outputs.digest }} | [Verify on Sigstore](https://search.sigstore.dev/?hash=${{ steps.capture-digest.outputs.digest }}) |

## Source

Built from [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/tree/${{ github.sha }}/custom-domain/dstack-ingress). The image records its source repository, commit and version as OCI labels and manifest annotations:

```bash
skopeo inspect docker://${{ env.IMAGE_REFERENCE }} | jq .Labels
skopeo inspect --raw docker://${{ env.IMAGE_REFERENCE }} | jq .annotations
```

## Reproducible Build

Build on a native Linux amd64 host with Docker Buildx, Skopeo, jq and Git installed:

```bash
git clone ${{ github.server_url }}/${{ github.repository }}.git
cd dstack-examples/custom-domain/dstack-ingress
git checkout ${{ github.sha }}
./build-image.sh
skopeo inspect oci-archive:./oci.tar | jq -r '.Digest'
```

Expected digest: `${{ steps.capture-digest.outputs.digest }}`
11 changes: 11 additions & 0 deletions custom-domain/dstack-ingress/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Keep the build context independent of local artifacts, so untracked local
# files can neither leak into the image nor change its digest.
.git
.gitignore
.dockerignore
.claude/
CLAUDE.md
test/
oci.tar
.pytest_cache/
**/__pycache__/
1 change: 1 addition & 0 deletions custom-domain/dstack-ingress/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
/test/
__pycache__
/oci.tar
/.BUILD_INFO
5 changes: 4 additions & 1 deletion custom-domain/dstack-ingress/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,10 @@ RUN --mount=type=bind,source=scripts,target=/tmp/scripts,ro \
ENV PATH="/scripts:$PATH"
ENV PYTHONPATH="/scripts"
ENV PYTHONUNBUFFERED=1
COPY --chmod=666 .GIT_REV /etc/
# Source metadata generated by build-image.sh (same key=value set as the OCI
# labels and manifest annotations), so a running container can identify its
# own source revision. Printed by the entrypoint at startup.
COPY --chmod=644 .BUILD_INFO /etc/dstack-ingress/build-info

ENTRYPOINT ["/scripts/entrypoint.sh"]
CMD ["haproxy", "-W", "-f", "/etc/haproxy/haproxy.cfg"]
24 changes: 23 additions & 1 deletion custom-domain/dstack-ingress/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,29 @@ To disable the built-in evidence endpoint and serve evidence files only through
./build-image.sh --push yourusername/dstack-ingress:tag
```

The build script ensures reproducibility via pinned packages, deterministic timestamps, and specific buildkit version.
The build script ensures reproducibility via pinned packages, deterministic timestamps, and specific buildkit version. Building the same commit from a clean checkout produces the same image digest; CI runs the same script with `--require-clean`.

### Image metadata

Every image records where it came from, using the standard [OCI image annotation keys](https://github.com/opencontainers/image-spec/blob/main/annotations.md). The values are derived from the git checkout only (commit, the `VERSION` file, the Dockerfile base image), so they do not disturb reproducibility. The same key/value set is written to three places:

| Location | How to read it |
|---|---|
| Image config labels | `skopeo inspect docker://dstacktee/dstack-ingress:<tag> \| jq .Labels` or `docker inspect --format '{{json .Config.Labels}}' <image>` |
| Image manifest annotations | `skopeo inspect --raw docker://dstacktee/dstack-ingress:<tag> \| jq .annotations` |
| `/etc/dstack-ingress/build-info` inside the image | `docker run --rm --entrypoint cat <image> /etc/dstack-ingress/build-info`; also printed as the first line of the container log |

| Key | Value |
|---|---|
| `org.opencontainers.image.source` | Repository URL (`SOURCE_URL` env when building from a fork) |
| `org.opencontainers.image.revision` | Git commit; suffixed with `-dirty` when built from an unclean tree |
| `org.opencontainers.image.version` | Contents of `VERSION`; the release tag `dstack-ingress-v<version>` must match |
| `org.opencontainers.image.url` / `.documentation` | This directory / README at that exact commit |
| `org.opencontainers.image.base.name` / `.base.digest` | The pinned haproxy base image |

To reproduce a published image, check out the commit from its `revision` label and run `./build-image.sh` on a native Linux amd64 host with Docker Buildx, Skopeo, jq and Git installed; the digest printed at the end must match the registry. Releases are additionally signed with SLSA provenance, verifiable with `gh attestation verify oci://docker.io/dstacktee/dstack-ingress:<tag> --owner Dstack-TEE`.

Bumping the version is a source change: update `VERSION`, commit, then tag `dstack-ingress-v<version>`.

## License

Expand Down
1 change: 1 addition & 0 deletions custom-domain/dstack-ingress/VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
2.6
136 changes: 117 additions & 19 deletions custom-domain/dstack-ingress/build-image.sh
Original file line number Diff line number Diff line change
@@ -1,23 +1,52 @@
#!/bin/bash
#
# Reproducible image build. The same script runs in CI and on a developer
# machine, so everything that ends up in the image -- including the OCI
# labels/annotations that point back to the source -- is derived from the git
# checkout only. Nothing that ends up in the image may depend on wall-clock
# time, the CI run, or the build host, or the digest stops being reproducible.

set -euo pipefail

usage() {
echo "Usage: $0 [--push <repo>[:<tag>]] [--require-clean]"
echo ""
echo " --push <repo> Push the built image to the given registry reference."
echo " --require-clean Fail instead of warn when the working tree has"
echo " uncommitted or untracked changes (used by CI)."
echo ""
echo "Environment:"
echo " SOURCE_URL Repository URL recorded in the image metadata."
echo " Defaults to the canonical upstream repository; set it"
echo " when building from a fork."
}

# Parse command line arguments
PUSH=false
REPO=""
REQUIRE_CLEAN=false

while [[ $# -gt 0 ]]; do
case $1 in
--push)
PUSH=true
REPO="$2"
REPO="${2:-}"
if [ -z "$REPO" ]; then
echo "Error: --push requires a repository argument"
echo "Usage: $0 [--push <repo>[:<tag>]]"
echo "Error: --push requires a repository argument" >&2
usage >&2
exit 1
fi
shift 2
;;
--require-clean)
REQUIRE_CLEAN=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Usage: $0 [--push <repo>[:<tag>]]"
usage >&2
exit 1
;;
esac
Expand All @@ -35,23 +64,97 @@ for required in docker skopeo jq git; do
require_command "$required"
done

cd "$(dirname "$0")"

# ---------------------------------------------------------------------------
# Source metadata. Every value below is a function of the checked-out commit
# (plus SOURCE_URL for forks), so a rebuild of the same commit yields the same
# labels and therefore the same digest.
# ---------------------------------------------------------------------------
SOURCE_URL="${SOURCE_URL:-https://github.com/Dstack-TEE/dstack-examples}"
SOURCE_URL="${SOURCE_URL%/}"
SUBDIR="$(git rev-parse --show-prefix)"
SUBDIR="${SUBDIR%/}"
GIT_REV="$(git rev-parse HEAD)"
VERSION="$(tr -d '[:space:]' < VERSION)"
if [ -z "$VERSION" ]; then
echo "Error: VERSION file is empty" >&2
exit 1
fi

# Untracked files count as dirty: scripts/ is copied wholesale into the image.
DIRTY="$(git status --porcelain --untracked-files=all -- .)"
if [ -n "$DIRTY" ]; then
if [ "$REQUIRE_CLEAN" = true ]; then
echo "Error: working tree is not clean; refusing to build a release image:" >&2
echo "$DIRTY" >&2
exit 1
fi
echo "Warning: working tree is not clean; the image will be marked dirty and" >&2
echo " its digest will not match a build of commit ${GIT_REV}." >&2
GIT_REV="${GIT_REV}-dirty"
fi

# Base image, kept in sync with the Dockerfile FROM line.
BASE_REF="$(sed -n 's/^FROM[[:space:]][[:space:]]*\([^[:space:]][^[:space:]]*\).*/\1/p' Dockerfile | head -n1)"
BASE_NAME="${BASE_REF%%@*}"
BASE_DIGEST="${BASE_REF#*@}"
case "$BASE_NAME" in
*/*) ;;
*) BASE_NAME="docker.io/library/${BASE_NAME}" ;;
esac
if [ "$BASE_DIGEST" = "$BASE_REF" ]; then
echo "Error: Dockerfile FROM must pin the base image by digest" >&2
exit 1
fi

# OCI standard keys: https://github.com/opencontainers/image-spec/blob/main/annotations.md
METADATA=(
"org.opencontainers.image.title=dstack-ingress"
"org.opencontainers.image.description=TLS ingress for dstack TEE applications with ACME certificates and attestation evidence"
"org.opencontainers.image.source=${SOURCE_URL}"
"org.opencontainers.image.revision=${GIT_REV}"
"org.opencontainers.image.version=${VERSION}"
"org.opencontainers.image.url=${SOURCE_URL}/tree/${GIT_REV%-dirty}/${SUBDIR}"
"org.opencontainers.image.documentation=${SOURCE_URL}/blob/${GIT_REV%-dirty}/${SUBDIR}/README.md"
"org.opencontainers.image.licenses=MIT"
"org.opencontainers.image.base.name=${BASE_NAME}"
"org.opencontainers.image.base.digest=${BASE_DIGEST}"
)

# The same key=value set goes to three places: image config labels (docker
# inspect, skopeo inspect), image manifest annotations (visible in the registry
# without fetching the config) and a file inside the image (readable from the
# running container, printed by the entrypoint).
METADATA_ARGS=()
for kv in "${METADATA[@]}"; do
METADATA_ARGS+=(--label "$kv" --annotation "manifest:$kv")
done

BUILD_INFO=.BUILD_INFO
cleanup() {
rm -f "$BUILD_INFO"
docker rmi "$TEMP_TAG" >/dev/null 2>&1 || true
}
TEMP_TAG="dstack-ingress-temp:$(date +%s)"
trap cleanup EXIT

printf '%s\n' "${METADATA[@]}" > "$BUILD_INFO"

echo "Image metadata:"
sed 's/^/ /' "$BUILD_INFO"
echo ""

# Check if buildkit_20 already exists before creating it
if ! docker buildx inspect buildkit_20 &>/dev/null; then
docker buildx create --use --driver-opt image=moby/buildkit:v0.20.2 --name buildkit_20
fi
touch pinned-packages.txt
git rev-parse HEAD > .GIT_REV
TEMP_TAG="dstack-ingress-temp:$(date +%s)"
docker buildx build --builder buildkit_20 --no-cache --build-arg SOURCE_DATE_EPOCH="0" \
"${METADATA_ARGS[@]}" \
--output type=oci,dest=./oci.tar,rewrite-timestamp=true \
--output type=docker,name="$TEMP_TAG" .

if [ "$?" -ne 0 ]; then
echo "Build failed"
rm .GIT_REV
exit 1
fi

echo "Build completed, manifest digest:"
echo ""
skopeo inspect oci-archive:./oci.tar | jq .Digest
Expand All @@ -71,7 +174,7 @@ else
echo " skopeo copy --insecure-policy oci-archive:./oci.tar docker://<repo>[:<tag>]"
echo ""
echo " Pushing image to dstacktee org:"
echo " skopeo copy --insecure-policy oci-archive:./oci.tar docker://dstacktee/dstack-ingress:$(date +%Y%m%d) --authfile ~/.docker/config.json"
echo " skopeo copy --insecure-policy oci-archive:./oci.tar docker://dstacktee/dstack-ingress:${VERSION} --authfile ~/.docker/config.json"
fi
echo ""

Expand All @@ -80,8 +183,3 @@ echo "Extracting package information from built image: $TEMP_TAG"
docker run --rm --entrypoint bash "$TEMP_TAG" -c "dpkg -l | grep '^ii' | awk '{print \$2\"=\"\$3}' | sort" > pinned-packages.txt

echo "Package information extracted to pinned-packages.txt ($(wc -l < pinned-packages.txt) packages)"

# Clean up the temporary image from Docker daemon
docker rmi "$TEMP_TAG" 2>/dev/null || true

rm .GIT_REV
8 changes: 8 additions & 0 deletions custom-domain/dstack-ingress/scripts/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@

set -e

# Identify the build first, so the line is there even if validation below
# fails. The same values are in the image labels and manifest annotations.
if [ -r /etc/dstack-ingress/build-info ]; then
echo "dstack-ingress $(sed -n 's/^org.opencontainers.image.version=//p' /etc/dstack-ingress/build-info)" \
"revision $(sed -n 's/^org.opencontainers.image.revision=//p' /etc/dstack-ingress/build-info)" \
"source $(sed -n 's/^org.opencontainers.image.source=//p' /etc/dstack-ingress/build-info)"
fi

source /scripts/functions.sh

PORT=${PORT:-443}
Expand Down
Loading