From a346323704d6b4d006194b2fc4a16b27c05f58a0 Mon Sep 17 00:00:00 2001 From: Mike Henry <11765982+mikemhenry@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:26:35 -0700 Subject: [PATCH 1/7] First pass at adding apptainer image building --- .github/workflows/build-pipeline.yaml | 22 ++++++++ .gitignore | 3 + build-docker.pkr.hcl | 46 ++++++++++++++- build-scripts/build-singularity-image.sh | 72 ++++++++++++++++++++++++ pixi.toml | 1 + 5 files changed, 141 insertions(+), 3 deletions(-) create mode 100755 build-scripts/build-singularity-image.sh diff --git a/.github/workflows/build-pipeline.yaml b/.github/workflows/build-pipeline.yaml index 3d80117..9eab6b1 100644 --- a/.github/workflows/build-pipeline.yaml +++ b/.github/workflows/build-pipeline.yaml @@ -295,6 +295,16 @@ jobs: with: version: latest + - name: Install Apptainer + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y software-properties-common + sudo add-apt-repository -y ppa:apptainer/ppa + sudo apt-get update + sudo apt-get install -y apptainer + apptainer version + - name: Log in to GHCR run: | echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin @@ -319,6 +329,7 @@ jobs: -var "build_timestamp=${BUILD_TIMESTAMP}" \ -var "additional_tags=${ADDITIONAL_TAGS}" \ -var "docker_repository=${DOCKER_REPO}" \ + -var "build_singularity=true" \ build-docker.pkr.hcl shell: bash @@ -327,6 +338,17 @@ jobs: set -euo pipefail docker push --all-tags "${{ needs.lock-environments.outputs.docker_repository }}" + - name: Upload Singularity image + uses: actions/upload-artifact@v4 + with: + name: singularity-image-${{ needs.lock-environments.outputs.build_timestamp }} + path: | + artifacts/*.sif + artifacts/*.sif.sha256 + if-no-files-found: error + compression-level: 0 + retention-days: 7 + - name: Collect packer manifest outputs id: collect uses: ./.github/actions/collect-packer-metadata diff --git a/.gitignore b/.gitignore index dea804f..15359e1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ packer-manifest.json packer-docker-manifest.json +# Locally generated Singularity/Apptainer images +artifacts/ + # .tfstate files *.tfstate *.tfstate.* diff --git a/build-docker.pkr.hcl b/build-docker.pkr.hcl index 57f034b..b88a519 100644 --- a/build-docker.pkr.hcl +++ b/build-docker.pkr.hcl @@ -19,6 +19,23 @@ variable "docker_repository" { default = "ghcr.io/omsf-eco-infra/omsf" } +variable "build_singularity" { + description = "Create a Singularity Image Format (SIF) image from the tagged Docker image. Requires Apptainer or Singularity on the Packer host." + type = bool + default = false +} + +variable "singularity_output_directory" { + description = "Directory where the generated SIF image and checksum are written." + type = string + default = "artifacts" + + validation { + condition = length(trimspace(var.singularity_output_directory)) > 0 + error_message = "The singularity_output_directory must be a non-empty path." + } +} + variable "ami_name" { description = "Logical name for the Docker image variant (matches AMI naming)." type = string @@ -124,6 +141,8 @@ locals { default_environment = local.default_environment environments_label = local.environments_label docker_tags = jsonencode(local.tag_list) + docker_image = local.docker_image + singularity_image = var.build_singularity ? local.singularity_output_path : "" } remote_environment_root = "/tmp/environments" @@ -147,6 +166,9 @@ locals { primary_tag = "${local.ami_base_with_suffix}-${var.build_timestamp}" tag_list = [local.primary_tag] + docker_image = "${var.docker_repository}:${local.primary_tag}" + singularity_output_path = "${trimspace(var.singularity_output_directory)}/${local.primary_tag}.sif" + label_changes = [for key, value in local.merged_labels : "LABEL ${key}=${jsonencode(value)}"] } @@ -309,9 +331,27 @@ build { } # ── Post-processors ─────────────────────────────────────────────── - post-processor "docker-tag" { - repository = var.docker_repository - tags = local.tag_list + post-processors { + post-processor "docker-tag" { + repository = var.docker_repository + tags = local.tag_list + } + + # The conversion must be chained after docker-tag so docker-daemon can + # resolve the final repository:tag rather than Packer's temporary image. + post-processor "shell-local" { + script = "build-scripts/build-singularity-image.sh" + environment_vars = [ + "BUILD_SINGULARITY=${var.build_singularity}", + "DOCKER_IMAGE=${local.docker_image}", + "SINGULARITY_IMAGE=${local.singularity_output_path}", + ] + execute_command = [ + "/bin/sh", + "-c", + "{{.Vars}} /usr/bin/env bash {{.Script}}", + ] + } } post-processor "manifest" { diff --git a/build-scripts/build-singularity-image.sh b/build-scripts/build-singularity-image.sh new file mode 100755 index 0000000..dd08305 --- /dev/null +++ b/build-scripts/build-singularity-image.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +set -euo pipefail + +build_singularity="${BUILD_SINGULARITY:-false}" +case "${build_singularity}" in + 1|true|yes|TRUE|YES) + ;; + 0|false|no|FALSE|NO|"") + echo "[singularity] SIF creation disabled; skipping" + exit 0 + ;; + *) + echo "[singularity] BUILD_SINGULARITY must be true or false, got: ${build_singularity}" >&2 + exit 2 + ;; +esac + +: "${DOCKER_IMAGE:?DOCKER_IMAGE must name the tagged Docker image to convert}" +: "${SINGULARITY_IMAGE:?SINGULARITY_IMAGE must be the output .sif path}" + +if command -v apptainer >/dev/null 2>&1; then + singularity_command="apptainer" +elif command -v singularity >/dev/null 2>&1; then + singularity_command="singularity" +else + echo "[singularity] Neither apptainer nor singularity is installed" >&2 + exit 1 +fi + +if ! command -v docker >/dev/null 2>&1; then + echo "[singularity] docker is required to access the Packer-built image" >&2 + exit 1 +fi + +if ! docker image inspect "${DOCKER_IMAGE}" >/dev/null 2>&1; then + echo "[singularity] Docker image is not available locally: ${DOCKER_IMAGE}" >&2 + exit 1 +fi + +output_directory="$(dirname -- "${SINGULARITY_IMAGE}")" +output_filename="$(basename -- "${SINGULARITY_IMAGE}")" +mkdir -p "${output_directory}" + +temporary_image="${output_directory}/.${output_filename%.sif}.tmp.$$.sif" +cleanup() { + rm -f -- "${temporary_image}" +} +trap cleanup EXIT + +rm -f -- "${SINGULARITY_IMAGE}" "${SINGULARITY_IMAGE}.sha256" + +echo "[singularity] Converting ${DOCKER_IMAGE} to ${SINGULARITY_IMAGE}" +"${singularity_command}" build \ + --disable-cache \ + --force \ + "${temporary_image}" \ + "docker-daemon:${DOCKER_IMAGE}" + +"${singularity_command}" inspect "${temporary_image}" >/dev/null +mv -- "${temporary_image}" "${SINGULARITY_IMAGE}" + +( + cd "${output_directory}" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${output_filename}" > "${output_filename}.sha256" + else + shasum -a 256 "${output_filename}" > "${output_filename}.sha256" + fi +) + +echo "[singularity] Created ${SINGULARITY_IMAGE}" diff --git a/pixi.toml b/pixi.toml index d023286..517751b 100644 --- a/pixi.toml +++ b/pixi.toml @@ -9,6 +9,7 @@ version = "0.1.0" [tasks] build-ami = "packer init build-ami.pkr.hcl && packer build -var 'ami_name=omsf' -var 'default_environment=openfe' -var 'environments=[\"omsf\"]' build-ami.pkr.hcl" build-docker = "packer init build-docker.pkr.hcl && packer build -var 'ami_name=omsf' -var 'default_environment=openfe' -var 'environments=[\"omsf\"]' -var \"build_timestamp=$(date +%s)\" build-docker.pkr.hcl" +build-docker-and-singularity = "packer init build-docker.pkr.hcl && packer build -var 'ami_name=omsf' -var 'default_environment=openfe' -var 'environments=[\"omsf\"]' -var 'build_singularity=true' -var \"build_timestamp=$(date +%s)\" build-docker.pkr.hcl" [dependencies] python = "*" From bfa110f1c1115ddc7ec84077bf27695e35c830a4 Mon Sep 17 00:00:00 2001 From: Mike Henry <11765982+mikemhenry@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:02:09 -0700 Subject: [PATCH 2/7] bump ci From f2b56a9be0252b601669380295cf6875fe04f0e8 Mon Sep 17 00:00:00 2001 From: Mike Henry <11765982+mikemhenry@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:35:52 -0700 Subject: [PATCH 3/7] build singularity image seperately --- .github/workflows/build-pipeline.yaml | 133 +++++++++++++--- ...usable.yml => test-container-reusable.yml} | 145 ++++++++++++++---- build-docker.pkr.hcl | 66 ++------ build-scripts/build-singularity-image.sh | 73 +++------ pixi.toml | 1 - 5 files changed, 262 insertions(+), 156 deletions(-) rename .github/workflows/{test-docker-reusable.yml => test-container-reusable.yml} (52%) diff --git a/.github/workflows/build-pipeline.yaml b/.github/workflows/build-pipeline.yaml index 9eab6b1..b7e613c 100644 --- a/.github/workflows/build-pipeline.yaml +++ b/.github/workflows/build-pipeline.yaml @@ -18,6 +18,7 @@ env: AWS_REGION: us-east-1 DEFAULT_INSTANCE_TYPE: g5.xlarge DOCKER_REPOSITORY: ghcr.io/omsf-eco-infra/omsf + SIF_REPOSITORY: ghcr.io/omsf-eco-infra/omsf-sif jobs: # ── Step 1: Prepare shared build inputs ──────────────────────────── @@ -295,16 +296,6 @@ jobs: with: version: latest - - name: Install Apptainer - run: | - set -euo pipefail - sudo apt-get update - sudo apt-get install -y software-properties-common - sudo add-apt-repository -y ppa:apptainer/ppa - sudo apt-get update - sudo apt-get install -y apptainer - apptainer version - - name: Log in to GHCR run: | echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin @@ -329,7 +320,6 @@ jobs: -var "build_timestamp=${BUILD_TIMESTAMP}" \ -var "additional_tags=${ADDITIONAL_TAGS}" \ -var "docker_repository=${DOCKER_REPO}" \ - -var "build_singularity=true" \ build-docker.pkr.hcl shell: bash @@ -338,17 +328,6 @@ jobs: set -euo pipefail docker push --all-tags "${{ needs.lock-environments.outputs.docker_repository }}" - - name: Upload Singularity image - uses: actions/upload-artifact@v4 - with: - name: singularity-image-${{ needs.lock-environments.outputs.build_timestamp }} - path: | - artifacts/*.sif - artifacts/*.sif.sha256 - if-no-files-found: error - compression-level: 0 - retention-days: 7 - - name: Collect packer manifest outputs id: collect uses: ./.github/actions/collect-packer-metadata @@ -357,6 +336,61 @@ jobs: kind: docker image_repo: ${{ needs.lock-environments.outputs.docker_repository }} + # ── Step 2c: Build and publish SIF image ──────────────────────────── + build-sif: + name: Build SIF Image + needs: build-docker + if: ${{ needs.build-docker.outputs.image_repo != '' && needs.build-docker.outputs.image_tag != '' }} + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + outputs: + image_repo: ${{ steps.metadata.outputs.image_repo }} + image_tag: ${{ steps.metadata.outputs.image_tag }} + steps: + - uses: actions/checkout@v6 + + - name: Install Apptainer + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y software-properties-common + sudo add-apt-repository -y ppa:apptainer/ppa + sudo apt-get update + sudo apt-get install -y apptainer + apptainer version + + - name: Log in to GHCR + run: | + set -euo pipefail + authfile="${RUNNER_TEMP}/apptainer-auth.json" + echo "${GH_TOKEN}" | apptainer registry login \ + --authfile "${authfile}" \ + --username "${GITHUB_ACTOR}" \ + --password-stdin \ + docker://ghcr.io + echo "APPTAINER_AUTHFILE=${authfile}" >> "${GITHUB_ENV}" + env: + GH_TOKEN: ${{ github.token }} + + - name: Build and publish SIF image + run: build-scripts/build-singularity-image.sh + env: + APPTAINER_TMPDIR: ${{ runner.temp }}/apptainer-tmp + DOCKER_IMAGE: ${{ needs.build-docker.outputs.image_repo }}:${{ needs.build-docker.outputs.image_tag }} + SIF_IMAGE: ${{ env.SIF_REPOSITORY }}:${{ needs.build-docker.outputs.image_tag }} + SIF_OUTPUT: ${{ runner.temp }}/${{ needs.build-docker.outputs.image_tag }}.sif + + - name: Record SIF image metadata + id: metadata + run: | + set -euo pipefail + echo "image_repo=${SIF_REPOSITORY}" >> "${GITHUB_OUTPUT}" + echo "image_tag=${IMAGE_TAG}" >> "${GITHUB_OUTPUT}" + env: + IMAGE_TAG: ${{ needs.build-docker.outputs.image_tag }} + # ── Step 3a: Test AMI environments ───────────────────────────────── # Intentionally dormant while AMI test hangs are under investigation. # The pipeline still computes `environment_matrix`, and the reusable workflow @@ -400,8 +434,9 @@ jobs: fail-fast: false matrix: environment: ${{ fromJson(needs.lock-environments.outputs.environment_matrix) }} - uses: ./.github/workflows/test-docker-reusable.yml + uses: ./.github/workflows/test-container-reusable.yml with: + container_runtime: docker image_repo: ${{ needs.build-docker.outputs.image_repo }} image_tag: ${{ needs.build-docker.outputs.image_tag }} environment_name: ${{ matrix.environment.name }} @@ -412,6 +447,32 @@ jobs: docker_test_assume_role_arn: ${{ secrets.AWS_DOCKER_GHA_RUNNER_ASSUME_ROLE_ARN }} gh_pat: ${{ secrets.GH_PAT }} + # ── Step 3c: Test SIF environments ───────────────────────────────── + test-sif: + name: Test SIF (${{ matrix.environment.name }}) + needs: [lock-environments, build-sif] + if: ${{ needs.build-sif.outputs.image_repo != '' }} + permissions: + id-token: write + contents: read + packages: read + strategy: + fail-fast: false + matrix: + environment: ${{ fromJson(needs.lock-environments.outputs.environment_matrix) }} + uses: ./.github/workflows/test-container-reusable.yml + with: + container_runtime: apptainer + image_repo: ${{ needs.build-sif.outputs.image_repo }} + image_tag: ${{ needs.build-sif.outputs.image_tag }} + environment_name: ${{ matrix.environment.name }} + full_test_script: ${{ matrix.environment.full_script }} + instance_type: ${{ vars.AWS_DOCKER_GHA_RUNNER_INSTANCE_TYPE || needs.lock-environments.outputs.default_instance_type }} + region: ${{ needs.lock-environments.outputs.aws_region }} + secrets: + docker_test_assume_role_arn: ${{ secrets.AWS_DOCKER_GHA_RUNNER_ASSUME_ROLE_ARN }} + gh_pat: ${{ secrets.GH_PAT }} + # ── Step 4: Promote artifacts ────────────────────────────────────── promote: name: Promote artifacts @@ -419,13 +480,15 @@ jobs: # job is re-enabled. # Restore `needs.test-ami.result == 'success'` in the `if` expression below # when `test-ami` is re-enabled. - needs: [lock-environments, build-ami, build-docker, test-docker] + needs: [lock-environments, build-ami, build-docker, build-sif, test-docker, test-sif] if: >- ${{ always() && github.event_name != 'pull_request' && needs.build-ami.outputs.ami_id != '' && needs.build-docker.outputs.image_repo != '' && - needs.test-docker.result == 'success' }} + needs.build-sif.outputs.image_repo != '' && + needs.test-docker.result == 'success' && + needs.test-sif.result == 'success' }} runs-on: ubuntu-latest steps: - name: Checkout repository @@ -514,6 +577,26 @@ jobs: docker push "${IMAGE_REPO}:${PUBLISHED_DATE}" shell: bash + # ── SIF promotion ────────────────────────────────────────────── + - name: Set up ORAS + uses: oras-project/setup-oras@v1 + + - name: Log in to GHCR with ORAS + run: echo "${GH_TOKEN}" | oras login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin + env: + GH_TOKEN: ${{ github.token }} + + - name: Promote SIF image + env: + IMAGE_REPO: ${{ needs.build-sif.outputs.image_repo }} + IMAGE_TAG: ${{ needs.build-sif.outputs.image_tag }} + PUBLISHED_DATE: ${{ needs.lock-environments.outputs.published_date }} + run: | + set -euo pipefail + echo "Tagging ${IMAGE_REPO}:${IMAGE_TAG} as latest and ${PUBLISHED_DATE}" + oras tag "${IMAGE_REPO}:${IMAGE_TAG}" latest "${PUBLISHED_DATE}" + shell: bash + # ── Site metadata export ─────────────────────────────────────── - name: Generate promoted site metadata env: diff --git a/.github/workflows/test-docker-reusable.yml b/.github/workflows/test-container-reusable.yml similarity index 52% rename from .github/workflows/test-docker-reusable.yml rename to .github/workflows/test-container-reusable.yml index 5bb2481..fc24e58 100644 --- a/.github/workflows/test-docker-reusable.yml +++ b/.github/workflows/test-container-reusable.yml @@ -1,14 +1,18 @@ -name: Reusable Docker environment test +name: Reusable container environment test on: workflow_call: inputs: + container_runtime: + description: Container runtime to test (docker or apptainer). + required: true + type: string image_repo: - description: Docker repository containing the image to test. + description: OCI repository containing the image to test. required: true type: string image_tag: - description: Docker image tag to test. + description: Image tag to test. required: true type: string environment_name: @@ -20,7 +24,7 @@ on: required: true type: string instance_type: - description: EC2 instance type to use for the self-hosted Docker test runner. + description: EC2 instance type to use for the self-hosted container test runner. required: false type: string default: g5.xlarge @@ -46,7 +50,7 @@ env: jobs: start-runner: - name: Start Docker test runner + name: Start ${{ inputs.container_runtime }} test runner runs-on: ubuntu-latest permissions: id-token: write @@ -55,21 +59,29 @@ jobs: mapping: ${{ steps.aws-start.outputs.mapping }} instances: ${{ steps.aws-start.outputs.instances }} steps: - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: ${{ secrets.docker_test_assume_role_arn }} - role-session-name: gha-start-docker-test-runner - aws-region: ${{ inputs.region }} - - name: Validate runner configuration run: | set -euo pipefail + case "${CONTAINER_RUNTIME}" in + docker|apptainer) ;; + *) + echo "container_runtime must be docker or apptainer, got: ${CONTAINER_RUNTIME}" >&2 + exit 2 + ;; + esac : "${AWS_GHA_RUNNER_IMAGE_ID:?Repository variable AWS_DOCKER_GHA_RUNNER_IMAGE_ID is required}" : "${RUNNER_INSTANCE_TYPE:?Runner instance type must be set}" env: + CONTAINER_RUNTIME: ${{ inputs.container_runtime }} RUNNER_INSTANCE_TYPE: ${{ inputs.instance_type }} + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.docker_test_assume_role_arn }} + role-session-name: gha-start-container-test-runner + aws-region: ${{ inputs.region }} + - name: Start runner id: aws-start uses: omsf/start-aws-gha-runner@v1.0.0 @@ -83,7 +95,7 @@ jobs: GH_PAT: ${{ secrets.gh_pat }} test: - name: Run Docker test + name: Run ${{ inputs.container_runtime }} test needs: start-runner if: ${{ inputs.image_repo != '' && inputs.image_tag != '' && needs.start-runner.outputs.instances != '' }} runs-on: ${{ fromJson(needs.start-runner.outputs.instances) }} @@ -91,37 +103,81 @@ jobs: contents: read packages: read steps: - - name: Ensure runner dependencies (Ubuntu) + - name: Ensure Docker is installed + if: ${{ inputs.container_runtime == 'docker' }} run: | set -euo pipefail - if command -v docker >/dev/null 2>&1; then - exit 0 + if ! command -v docker >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y docker.io fi - sudo apt-get update - sudo apt-get install -y docker.io + docker --version - - name: Log in to GHCR + - name: Install Apptainer + if: ${{ inputs.container_runtime == 'apptainer' }} run: | - echo "${{ github.token }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + set -euo pipefail + sudo apt-get update + sudo apt-get install -y software-properties-common + sudo add-apt-repository -y ppa:apptainer/ppa + sudo apt-get update + sudo apt-get install -y apptainer + apptainer version + + - name: Log in to GHCR with Docker + if: ${{ inputs.container_runtime == 'docker' }} + run: echo "${GH_TOKEN}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin + env: + GH_TOKEN: ${{ github.token }} - - name: Pull image + - name: Log in to GHCR with Apptainer + if: ${{ inputs.container_runtime == 'apptainer' }} run: | set -euo pipefail - docker pull "${IMAGE_REPO}:${IMAGE_TAG}" + authfile="${RUNNER_TEMP}/apptainer-auth.json" + echo "${GH_TOKEN}" | apptainer registry login \ + --authfile "${authfile}" \ + --username "${GITHUB_ACTOR}" \ + --password-stdin \ + oras://ghcr.io + echo "APPTAINER_AUTHFILE=${authfile}" >> "${GITHUB_ENV}" + env: + GH_TOKEN: ${{ github.token }} + + - name: Pull Docker image + if: ${{ inputs.container_runtime == 'docker' }} + run: docker pull "${IMAGE_REPO}:${IMAGE_TAG}" env: IMAGE_REPO: ${{ inputs.image_repo }} IMAGE_TAG: ${{ inputs.image_tag }} - - name: Verify GPU runtime + - name: Pull SIF image + if: ${{ inputs.container_runtime == 'apptainer' }} run: | set -euo pipefail + apptainer pull \ + --disable-cache \ + --force \ + "${SIF_PATH}" \ + "oras://${IMAGE_REPO}:${IMAGE_TAG}" + env: + IMAGE_REPO: ${{ inputs.image_repo }} + IMAGE_TAG: ${{ inputs.image_tag }} + SIF_PATH: ${{ runner.temp }}/omsf.sif + - name: Verify host GPU runtime + run: | + set -euo pipefail if ! command -v nvidia-smi >/dev/null 2>&1; then - echo "[test] nvidia-smi is not available on the Docker test runner" >&2 + echo "[test] nvidia-smi is not available on the container test runner" >&2 exit 1 fi nvidia-smi -L + - name: Verify Docker GPU runtime + if: ${{ inputs.container_runtime == 'docker' }} + run: | + set -euo pipefail docker run --rm --gpus all \ -e "NVIDIA_VISIBLE_DEVICES=all" \ -e "NVIDIA_DRIVER_CAPABILITIES=compute,utility" \ @@ -131,10 +187,16 @@ jobs: IMAGE_REPO: ${{ inputs.image_repo }} IMAGE_TAG: ${{ inputs.image_tag }} - - name: Run full tests + - name: Verify SIF GPU runtime + if: ${{ inputs.container_runtime == 'apptainer' }} + run: apptainer exec --cleanenv --nv "${SIF_PATH}" nvidia-smi -L + env: + SIF_PATH: ${{ runner.temp }}/omsf.sif + + - name: Run Docker full tests + if: ${{ inputs.container_runtime == 'docker' }} run: | set -euo pipefail - if [[ ! "${ENVIRONMENT_NAME}" =~ ^[A-Za-z0-9_.-]+$ ]]; then echo "[test] Invalid environment name: ${ENVIRONMENT_NAME}" >&2 exit 1 @@ -147,15 +209,40 @@ jobs: -e "NVIDIA_VISIBLE_DEVICES=all" \ -e "NVIDIA_DRIVER_CAPABILITIES=compute,utility" \ "${IMAGE_REPO}:${IMAGE_TAG}" \ - bash "/tmp/${script_path}" + bash "/opt/omsf/${script_path}" env: IMAGE_REPO: ${{ inputs.image_repo }} IMAGE_TAG: ${{ inputs.image_tag }} ENVIRONMENT_NAME: ${{ inputs.environment_name }} FULL_TEST_SCRIPT: ${{ inputs.full_test_script }} + - name: Run SIF full tests + if: ${{ inputs.container_runtime == 'apptainer' }} + run: | + set -euo pipefail + if [[ ! "${ENVIRONMENT_NAME}" =~ ^[A-Za-z0-9_.-]+$ ]]; then + echo "[test] Invalid environment name: ${ENVIRONMENT_NAME}" >&2 + exit 1 + fi + + script_path="${FULL_TEST_SCRIPT#/}" + apptainer exec \ + --cleanenv \ + --nv \ + --env "OMSF_PIXI_WORKSPACE=/opt/omsf/workspace" \ + --env "PIXI_HOME=/opt/omsf/pixi-home" \ + --env "PIXI_DEFAULT_ENVIRONMENT=${ENVIRONMENT_NAME}-test" \ + --env "CONDA_OVERRIDE_CUDA=12" \ + "${SIF_PATH}" \ + /usr/local/bin/omsf-entrypoint.sh \ + bash "/opt/omsf/${script_path}" + env: + ENVIRONMENT_NAME: ${{ inputs.environment_name }} + FULL_TEST_SCRIPT: ${{ inputs.full_test_script }} + SIF_PATH: ${{ runner.temp }}/omsf.sif + stop-runner: - name: Stop Docker test runner + name: Stop ${{ inputs.container_runtime }} test runner needs: [start-runner, test] if: ${{ always() && needs.start-runner.outputs.mapping != '' }} runs-on: ubuntu-latest @@ -167,7 +254,7 @@ jobs: uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ secrets.docker_test_assume_role_arn }} - role-session-name: gha-stop-docker-test-runner + role-session-name: gha-stop-container-test-runner aws-region: ${{ inputs.region }} - name: Stop runner diff --git a/build-docker.pkr.hcl b/build-docker.pkr.hcl index b88a519..d61771b 100644 --- a/build-docker.pkr.hcl +++ b/build-docker.pkr.hcl @@ -19,23 +19,6 @@ variable "docker_repository" { default = "ghcr.io/omsf-eco-infra/omsf" } -variable "build_singularity" { - description = "Create a Singularity Image Format (SIF) image from the tagged Docker image. Requires Apptainer or Singularity on the Packer host." - type = bool - default = false -} - -variable "singularity_output_directory" { - description = "Directory where the generated SIF image and checksum are written." - type = string - default = "artifacts" - - validation { - condition = length(trimspace(var.singularity_output_directory)) > 0 - error_message = "The singularity_output_directory must be a non-empty path." - } -} - variable "ami_name" { description = "Logical name for the Docker image variant (matches AMI naming)." type = string @@ -142,10 +125,9 @@ locals { environments_label = local.environments_label docker_tags = jsonencode(local.tag_list) docker_image = local.docker_image - singularity_image = var.build_singularity ? local.singularity_output_path : "" } - remote_environment_root = "/tmp/environments" + remote_environment_root = "/opt/omsf/environments" remote_pixi_manifest = "${local.remote_environment_root}/pixi.toml" remote_pixi_lock = "${local.remote_environment_root}/pixi.lock" remote_pixi_helper = "${local.remote_environment_root}/pixi-environment-metadata.py" @@ -166,8 +148,7 @@ locals { primary_tag = "${local.ami_base_with_suffix}-${var.build_timestamp}" tag_list = [local.primary_tag] - docker_image = "${var.docker_repository}:${local.primary_tag}" - singularity_output_path = "${trimspace(var.singularity_output_directory)}/${local.primary_tag}.sif" + docker_image = "${var.docker_repository}:${local.primary_tag}" label_changes = [for key, value in local.merged_labels : "LABEL ${key}=${jsonencode(value)}"] } @@ -176,10 +157,10 @@ source "docker" "this" { image = var.docker_base_image commit = true changes = concat([ - "ENV OMSF_PIXI_WORKSPACE=/root", + "ENV OMSF_PIXI_WORKSPACE=/opt/omsf/workspace", "ENV PIXI_DEFAULT_ENVIRONMENT=${local.default_environment}", "ENV OMSF_ENVIRONMENTS=\"${join(" ", local.enabled_environment_names)}\"", - "ENV PIXI_HOME=/root/.pixi-global", + "ENV PIXI_HOME=/opt/omsf/pixi-home", "ENV CONDA_OVERRIDE_CUDA=12", "ENTRYPOINT [\"/usr/local/bin/omsf-entrypoint.sh\"]", "CMD [\"bash\", \"-l\"]", @@ -214,6 +195,7 @@ build { script = "build-scripts/install-pixi.sh" environment_vars = [ "BUILD_ENV=docker", + "PIXI_HOME=/opt/omsf/pixi-home", ] } @@ -277,8 +259,8 @@ build { "DEFAULT_ENVIRONMENT=${local.default_environment}", "BUILD_ENV=docker", "CONDA_OVERRIDE_CUDA=12", - "OMSF_PIXI_WORKSPACE=/root", - "PIXI_HOME=/root/.pixi-global", + "OMSF_PIXI_WORKSPACE=/opt/omsf/workspace", + "PIXI_HOME=/opt/omsf/pixi-home", "PIXI_MANIFEST_SOURCE=${local.remote_pixi_manifest}", ] } @@ -307,8 +289,8 @@ build { "PIXI_ENV_NAME=${provisioner.value}", "BUILD_ENV=docker", "CONDA_OVERRIDE_CUDA=12", - "OMSF_PIXI_WORKSPACE=/root", - "PIXI_HOME=/root/.pixi-global", + "OMSF_PIXI_WORKSPACE=/opt/omsf/workspace", + "PIXI_HOME=/opt/omsf/pixi-home", "KMP_AFFINITY=disabled", "OMP_NUM_THREADS=1", "OMP_PROC_BIND=false", @@ -319,39 +301,23 @@ build { } # ── Cleanup ──────────────────────────────────────────────────────── - # Keep /tmp/environments (test scripts are needed at container runtime). - # The pixi.toml/pixi.lock duplication with /root is trivial. + # Keep /opt/omsf/environments because the full-test scripts are used at + # container runtime. Make the installed workspace readable and executable by + # unprivileged Apptainer users while retaining root ownership. provisioner "shell" { inline_shebang = "/usr/bin/env bash" inline = [ "set -euxo pipefail", "/usr/local/bin/pixi clean cache -y --no-progress || true", + "chmod -R a+rX /opt/omsf", "rm -rf /var/lib/apt/lists/*", ] } # ── Post-processors ─────────────────────────────────────────────── - post-processors { - post-processor "docker-tag" { - repository = var.docker_repository - tags = local.tag_list - } - - # The conversion must be chained after docker-tag so docker-daemon can - # resolve the final repository:tag rather than Packer's temporary image. - post-processor "shell-local" { - script = "build-scripts/build-singularity-image.sh" - environment_vars = [ - "BUILD_SINGULARITY=${var.build_singularity}", - "DOCKER_IMAGE=${local.docker_image}", - "SINGULARITY_IMAGE=${local.singularity_output_path}", - ] - execute_command = [ - "/bin/sh", - "-c", - "{{.Vars}} /usr/bin/env bash {{.Script}}", - ] - } + post-processor "docker-tag" { + repository = var.docker_repository + tags = local.tag_list } post-processor "manifest" { diff --git a/build-scripts/build-singularity-image.sh b/build-scripts/build-singularity-image.sh index dd08305..0404da9 100755 --- a/build-scripts/build-singularity-image.sh +++ b/build-scripts/build-singularity-image.sh @@ -1,72 +1,43 @@ #!/usr/bin/env bash - set -euo pipefail -build_singularity="${BUILD_SINGULARITY:-false}" -case "${build_singularity}" in - 1|true|yes|TRUE|YES) - ;; - 0|false|no|FALSE|NO|"") - echo "[singularity] SIF creation disabled; skipping" - exit 0 - ;; - *) - echo "[singularity] BUILD_SINGULARITY must be true or false, got: ${build_singularity}" >&2 - exit 2 - ;; -esac - -: "${DOCKER_IMAGE:?DOCKER_IMAGE must name the tagged Docker image to convert}" -: "${SINGULARITY_IMAGE:?SINGULARITY_IMAGE must be the output .sif path}" - -if command -v apptainer >/dev/null 2>&1; then - singularity_command="apptainer" -elif command -v singularity >/dev/null 2>&1; then - singularity_command="singularity" -else - echo "[singularity] Neither apptainer nor singularity is installed" >&2 - exit 1 -fi +: "${DOCKER_IMAGE:?DOCKER_IMAGE must identify the Docker image to convert}" +: "${SIF_IMAGE:?SIF_IMAGE must identify the destination OCI artifact}" +: "${SIF_OUTPUT:?SIF_OUTPUT must be the local .sif output path}" -if ! command -v docker >/dev/null 2>&1; then - echo "[singularity] docker is required to access the Packer-built image" >&2 +if ! command -v apptainer >/dev/null 2>&1; then + echo "[sif] apptainer is required" >&2 exit 1 fi -if ! docker image inspect "${DOCKER_IMAGE}" >/dev/null 2>&1; then - echo "[singularity] Docker image is not available locally: ${DOCKER_IMAGE}" >&2 - exit 1 -fi +output_directory="$(dirname -- "${SIF_OUTPUT}")" +output_filename="$(basename -- "${SIF_OUTPUT}")" +temporary_image="${output_directory}/.${output_filename%.sif}.tmp.$$.sif" -output_directory="$(dirname -- "${SINGULARITY_IMAGE}")" -output_filename="$(basename -- "${SINGULARITY_IMAGE}")" mkdir -p "${output_directory}" - -temporary_image="${output_directory}/.${output_filename%.sif}.tmp.$$.sif" +if [[ -n "${APPTAINER_TMPDIR:-}" ]]; then + mkdir -p "${APPTAINER_TMPDIR}" +fi cleanup() { rm -f -- "${temporary_image}" } trap cleanup EXIT -rm -f -- "${SINGULARITY_IMAGE}" "${SINGULARITY_IMAGE}.sha256" +rm -f -- "${SIF_OUTPUT}" + +echo "[sif] Filesystem capacity before conversion" +df -h "${APPTAINER_TMPDIR:-${TMPDIR:-/tmp}}" "${output_directory}" || true -echo "[singularity] Converting ${DOCKER_IMAGE} to ${SINGULARITY_IMAGE}" -"${singularity_command}" build \ +echo "[sif] Converting docker://${DOCKER_IMAGE} to ${SIF_OUTPUT}" +apptainer build \ --disable-cache \ --force \ "${temporary_image}" \ - "docker-daemon:${DOCKER_IMAGE}" + "docker://${DOCKER_IMAGE}" -"${singularity_command}" inspect "${temporary_image}" >/dev/null -mv -- "${temporary_image}" "${SINGULARITY_IMAGE}" +mv -- "${temporary_image}" "${SIF_OUTPUT}" -( - cd "${output_directory}" - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "${output_filename}" > "${output_filename}.sha256" - else - shasum -a 256 "${output_filename}" > "${output_filename}.sha256" - fi -) +echo "[sif] Publishing ${SIF_OUTPUT} to oras://${SIF_IMAGE}" +apptainer push "${SIF_OUTPUT}" "oras://${SIF_IMAGE}" -echo "[singularity] Created ${SINGULARITY_IMAGE}" +echo "[sif] Published oras://${SIF_IMAGE}" diff --git a/pixi.toml b/pixi.toml index 517751b..d023286 100644 --- a/pixi.toml +++ b/pixi.toml @@ -9,7 +9,6 @@ version = "0.1.0" [tasks] build-ami = "packer init build-ami.pkr.hcl && packer build -var 'ami_name=omsf' -var 'default_environment=openfe' -var 'environments=[\"omsf\"]' build-ami.pkr.hcl" build-docker = "packer init build-docker.pkr.hcl && packer build -var 'ami_name=omsf' -var 'default_environment=openfe' -var 'environments=[\"omsf\"]' -var \"build_timestamp=$(date +%s)\" build-docker.pkr.hcl" -build-docker-and-singularity = "packer init build-docker.pkr.hcl && packer build -var 'ami_name=omsf' -var 'default_environment=openfe' -var 'environments=[\"omsf\"]' -var 'build_singularity=true' -var \"build_timestamp=$(date +%s)\" build-docker.pkr.hcl" [dependencies] python = "*" From 2e1bd884827be07b73ded18d29a90166def24066 Mon Sep 17 00:00:00 2001 From: Mike Henry <11765982+mikemhenry@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:10:37 -0700 Subject: [PATCH 4/7] fix auth and file rename, aws expects that filename --- .github/workflows/build-pipeline.yaml | 19 ++++--------------- ...-reusable.yml => test-docker-reusable.yml} | 16 ++-------------- 2 files changed, 6 insertions(+), 29 deletions(-) rename .github/workflows/{test-container-reusable.yml => test-docker-reusable.yml} (94%) diff --git a/.github/workflows/build-pipeline.yaml b/.github/workflows/build-pipeline.yaml index b7e613c..88230b8 100644 --- a/.github/workflows/build-pipeline.yaml +++ b/.github/workflows/build-pipeline.yaml @@ -361,22 +361,11 @@ jobs: sudo apt-get install -y apptainer apptainer version - - name: Log in to GHCR - run: | - set -euo pipefail - authfile="${RUNNER_TEMP}/apptainer-auth.json" - echo "${GH_TOKEN}" | apptainer registry login \ - --authfile "${authfile}" \ - --username "${GITHUB_ACTOR}" \ - --password-stdin \ - docker://ghcr.io - echo "APPTAINER_AUTHFILE=${authfile}" >> "${GITHUB_ENV}" - env: - GH_TOKEN: ${{ github.token }} - - name: Build and publish SIF image run: build-scripts/build-singularity-image.sh env: + APPTAINER_DOCKER_USERNAME: ${{ github.actor }} + APPTAINER_DOCKER_PASSWORD: ${{ github.token }} APPTAINER_TMPDIR: ${{ runner.temp }}/apptainer-tmp DOCKER_IMAGE: ${{ needs.build-docker.outputs.image_repo }}:${{ needs.build-docker.outputs.image_tag }} SIF_IMAGE: ${{ env.SIF_REPOSITORY }}:${{ needs.build-docker.outputs.image_tag }} @@ -434,7 +423,7 @@ jobs: fail-fast: false matrix: environment: ${{ fromJson(needs.lock-environments.outputs.environment_matrix) }} - uses: ./.github/workflows/test-container-reusable.yml + uses: ./.github/workflows/test-docker-reusable.yml with: container_runtime: docker image_repo: ${{ needs.build-docker.outputs.image_repo }} @@ -460,7 +449,7 @@ jobs: fail-fast: false matrix: environment: ${{ fromJson(needs.lock-environments.outputs.environment_matrix) }} - uses: ./.github/workflows/test-container-reusable.yml + uses: ./.github/workflows/test-docker-reusable.yml with: container_runtime: apptainer image_repo: ${{ needs.build-sif.outputs.image_repo }} diff --git a/.github/workflows/test-container-reusable.yml b/.github/workflows/test-docker-reusable.yml similarity index 94% rename from .github/workflows/test-container-reusable.yml rename to .github/workflows/test-docker-reusable.yml index fc24e58..3873033 100644 --- a/.github/workflows/test-container-reusable.yml +++ b/.github/workflows/test-docker-reusable.yml @@ -130,20 +130,6 @@ jobs: env: GH_TOKEN: ${{ github.token }} - - name: Log in to GHCR with Apptainer - if: ${{ inputs.container_runtime == 'apptainer' }} - run: | - set -euo pipefail - authfile="${RUNNER_TEMP}/apptainer-auth.json" - echo "${GH_TOKEN}" | apptainer registry login \ - --authfile "${authfile}" \ - --username "${GITHUB_ACTOR}" \ - --password-stdin \ - oras://ghcr.io - echo "APPTAINER_AUTHFILE=${authfile}" >> "${GITHUB_ENV}" - env: - GH_TOKEN: ${{ github.token }} - - name: Pull Docker image if: ${{ inputs.container_runtime == 'docker' }} run: docker pull "${IMAGE_REPO}:${IMAGE_TAG}" @@ -161,6 +147,8 @@ jobs: "${SIF_PATH}" \ "oras://${IMAGE_REPO}:${IMAGE_TAG}" env: + APPTAINER_DOCKER_USERNAME: ${{ github.actor }} + APPTAINER_DOCKER_PASSWORD: ${{ github.token }} IMAGE_REPO: ${{ inputs.image_repo }} IMAGE_TAG: ${{ inputs.image_tag }} SIF_PATH: ${{ runner.temp }}/omsf.sif From bf6702ec9ad6c67f7afc25731e02fe352f7be510 Mon Sep 17 00:00:00 2001 From: Mike Henry <11765982+mikemhenry@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:18:37 -0700 Subject: [PATCH 5/7] SIF is readonly, can't create new env --- .github/workflows/test-docker-reusable.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-docker-reusable.yml b/.github/workflows/test-docker-reusable.yml index 3873033..a70810b 100644 --- a/.github/workflows/test-docker-reusable.yml +++ b/.github/workflows/test-docker-reusable.yml @@ -192,7 +192,7 @@ jobs: script_path="${FULL_TEST_SCRIPT#/}" docker run --rm --gpus all \ - -e "PIXI_DEFAULT_ENVIRONMENT=${ENVIRONMENT_NAME}-test" \ + -e "PIXI_DEFAULT_ENVIRONMENT=${ENVIRONMENT_NAME}" \ -e "CONDA_OVERRIDE_CUDA=12" \ -e "NVIDIA_VISIBLE_DEVICES=all" \ -e "NVIDIA_DRIVER_CAPABILITIES=compute,utility" \ @@ -219,7 +219,7 @@ jobs: --nv \ --env "OMSF_PIXI_WORKSPACE=/opt/omsf/workspace" \ --env "PIXI_HOME=/opt/omsf/pixi-home" \ - --env "PIXI_DEFAULT_ENVIRONMENT=${ENVIRONMENT_NAME}-test" \ + --env "PIXI_DEFAULT_ENVIRONMENT=${ENVIRONMENT_NAME}" \ --env "CONDA_OVERRIDE_CUDA=12" \ "${SIF_PATH}" \ /usr/local/bin/omsf-entrypoint.sh \ From a9a091e98d4a86be55b69217f8c80e43347d33a9 Mon Sep 17 00:00:00 2001 From: Mike Henry <11765982+mikemhenry@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:46:53 -0700 Subject: [PATCH 6/7] forgot to commit tweak to entry point --- build-scripts/docker-entrypoint.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build-scripts/docker-entrypoint.sh b/build-scripts/docker-entrypoint.sh index 258e39b..75dcc0d 100755 --- a/build-scripts/docker-entrypoint.sh +++ b/build-scripts/docker-entrypoint.sh @@ -4,7 +4,8 @@ set -euo pipefail if [[ -n "${PIXI_DEFAULT_ENVIRONMENT:-}" ]]; then workspace="${OMSF_PIXI_WORKSPACE:-${HOME}}" if [[ -f "${workspace}/pixi.toml" ]]; then - eval "$(pixi shell-hook -m "${workspace}" -e "${PIXI_DEFAULT_ENVIRONMENT}" --shell bash --frozen --no-completions)" + activation_script="$(pixi shell-hook -m "${workspace}" -e "${PIXI_DEFAULT_ENVIRONMENT}" --shell bash --as-is --no-completions)" + eval "${activation_script}" fi fi From 7b5619f3d05ea37b3eedc75287b9ad1ec8337201 Mon Sep 17 00:00:00 2001 From: Mike Henry <11765982+mikemhenry@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:05:22 -0700 Subject: [PATCH 7/7] bump up credential time since we ran out of time during the last build (default is only 1 hour) --- .github/workflows/build-pipeline.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-pipeline.yaml b/.github/workflows/build-pipeline.yaml index 88230b8..d32335f 100644 --- a/.github/workflows/build-pipeline.yaml +++ b/.github/workflows/build-pipeline.yaml @@ -242,6 +242,7 @@ jobs: role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }} role-session-name: gha-ami-builder aws-region: ${{ needs.lock-environments.outputs.aws_region }} + role-duration-seconds: 7200 - name: Set up Packer uses: hashicorp/setup-packer@v3