A tool to import and manage private VM images across cloud providers. It automates the steps required to import a disk image as a registered cloud image (AMI on AWS, Gallery Image on Azure, Custom Image on GCP) and optionally share it across accounts/projects.
Before you begin, ensure you have the following:
-
Cloud Account: An active AWS, Azure, or GCP account
-
Local tools:
- Azure only:
azcopy— required for VHD upload (brew install azcopyon macOS)
- Azure only:
-
Cloud Credentials (set as environment variables):
AWS:
AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_DEFAULT_REGION
Azure:
ARM_CLIENT_ID ARM_CLIENT_SECRET ARM_TENANT_ID ARM_SUBSCRIPTION_ID ARM_LOCATION_NAME AZURE_STORAGE_ACCOUNT # required when using azblob:// backed-url AZURE_STORAGE_KEY # required when using azblob:// backed-url
GCP:
GOOGLE_PROJECT # GCP project ID where images will be created GOOGLE_CREDENTIALS # Service account key JSON (inline string) GOOGLE_REGION # Default GCP region (e.g. us-central1) GOOGLE_IMAGE_STORAGE_LOCATIONS # Optional: comma-separated multi-regions for image storage # Default: us,eu,asia (pre-caches in all regions for fast Spot VM boot globally) # Override: us (US only), eu (EU only), etc.
| Flag | Description |
|---|---|
--project-name |
Unique name for this import run — used to isolate Pulumi state |
--backed-url |
Backend for Pulumi state: s3://bucket/path, azblob://container/path, gs://bucket/path, or file:///local/path. See naming conventions below. |
--replicate |
Replicate the image across regions. AWS/Azure: copies to all available regions. GCP: creates imagename-us, imagename-eu, imagename-asia via image-from-image (no re-upload), each stored in its respective multi-region for faster cold-start boot times. |
--share-orgs-ids |
Comma-separated list of identifiers to share the image with: AWS org ARNs, Azure tenant IDs, or GCP project IDs |
--tags |
Comma-separated tags to apply: key1=value1,key2=value2 |
--debug |
Enable debug logging |
--debug-level |
Verbosity level 1–9 (default: 3) |
| Flag | Description |
|---|---|
--image-path |
Local path to the image file (.raw for AWS/GCP, .vhd for Azure) |
--image-name |
Name to register the image under in the cloud provider |
| Flag | Description |
|---|---|
--bundle-uri |
Accessible URI to the SNC bundle (http/https/file) |
--shasum-uri |
Accessible URI to the bundle checksum file |
--arch |
Architecture: x86_64 or arm64 (default: x86_64) |
| Flag | Description |
|---|---|
--keep-state |
Keep Pulumi state in the backend after destroy (default: false) |
--force-destroy |
Remove Pulumi lock files before destroying (use to recover from a crashed import) |
| Flag | Description |
|---|---|
--image-name |
Image name to look up in the cloud provider |
The productization team uses the following conventions for --backed-url, mirrored across all three providers:
| Provider | Convention |
|---|---|
| AWS | s3://aipcc-productization/cloud-importer |
| Azure | azblob://aipcc-productization/cloud-importer |
| GCP | gs://aipcc-productization/cloud-importer |
For local development, use file:///path/to/state — no cloud bucket needed. See Developer Testing.
Imports a RHEL AI disk image to a cloud provider. The raw image must be downloaded separately by an authenticated user who has agreed to the EULA. See the RHEL AI installation guide.
podman run --rm --name import-rhelai -d \
-v ${PWD}:/workspace:z \
-e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} \
-e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} \
-e AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION} \
quay.io/aipcc-cicd/cloud-importer:latest rhelai aws \
--project-name "rhelai3-136d47d1" \
--backed-url s3://bucket/folder \
--image-name rhelai3-136d47d1 \
--image-path "/workspace/rhel-ai-nvidia-aws-1.5-x86_64.raw" \
--share-orgs-ids arn:aws:organizations::XXXXX:organization/XXXXX \
--replicate \
--debug \
--debug-level 9
podman logs -f import-rhelaipodman run --rm --name import-rhelai-azure -d \
-v ${PWD}:/workspace:z \
-e ARM_TENANT_ID=${ARM_TENANT_ID} \
-e ARM_CLIENT_ID=${ARM_CLIENT_ID} \
-e ARM_CLIENT_SECRET=${ARM_CLIENT_SECRET} \
-e ARM_SUBSCRIPTION_ID=${ARM_SUBSCRIPTION_ID} \
-e ARM_LOCATION_NAME=${ARM_LOCATION_NAME} \
-e AZURE_STORAGE_ACCOUNT=${AZURE_STORAGE_ACCOUNT} \
-e AZURE_STORAGE_KEY=${AZURE_STORAGE_KEY} \
quay.io/aipcc-cicd/cloud-importer:latest rhelai az \
--project-name "rhelai3-136d47d1" \
--backed-url azblob://blobcontainer/folder \
--image-name rhelai3-136d47d1 \
--image-path "/workspace/rhel-ai-nvidia-aws-1.5-x86_64.vhd" \
--share-orgs-ids tenantId1,tenantId2 \
--replicate \
--debug \
--debug-level 9
podman logs -f import-rhelai-azurepodman run --rm --name import-rhelai-gcp -d \
-v ${PWD}:/workspace:z \
-e GOOGLE_PROJECT=${GOOGLE_PROJECT} \
-e GOOGLE_CREDENTIALS=${GOOGLE_CREDENTIALS} \
-e GOOGLE_REGION=${GOOGLE_REGION} \
quay.io/aipcc-cicd/cloud-importer:latest rhelai gcp \
--project-name "rhelai3-136d47d1" \
--backed-url gs://bucket/folder \
--image-name rhelai3-136d47d1 \
--image-path "/workspace/rhel-ai-nvidia-aws-1.5-x86_64.raw" \
--share-orgs-ids gcp-project-a,gcp-project-b \
--debug \
--debug-level 9
podman logs -f import-rhelai-gcpNote: For GCP,
--replicatecreatesimagename-us,imagename-eu, andimagename-asiacopies via image-from-image (no re-upload). Consumer tooling is responsible for mapping zone prefix to image name:us-*→-us,europe-*→-eu,asia-*→-asia, all other zones → canonical image.
Transforms the bundle generated by snc, uploads it, and registers it as a cloud provider image. The resulting image can be used to create ephemeral OpenShift Local clusters.
podman run --rm --name import-snc -d \
-v ${PWD}:/workspace:z \
-e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} \
-e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} \
-e AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION} \
quay.io/aipcc-cicd/cloud-importer:latest snc aws \
--project-name "snc-4.20.0" \
--backed-url s3://bucket/folder \
--bundle-uri ${BUNDLE_URL} \
--shasum-uri ${SHASUM_URL} \
--arch ${ARCH} \
--replicate \
--share-orgs-ids arn:aws:organizations::XXXXX:organization/XXXXX \
--debug \
--debug-level 9podman run --rm --name import-snc-azure -d \
-v ${PWD}:/workspace:z \
-e ARM_CLIENT_ID=${ARM_CLIENT_ID} \
-e ARM_CLIENT_SECRET=${ARM_CLIENT_SECRET} \
-e ARM_TENANT_ID=${ARM_TENANT_ID} \
-e ARM_SUBSCRIPTION_ID=${ARM_SUBSCRIPTION_ID} \
-e ARM_LOCATION_NAME=${ARM_LOCATION_NAME} \
-e AZURE_STORAGE_ACCOUNT=${AZURE_STORAGE_ACCOUNT} \
-e AZURE_STORAGE_KEY=${AZURE_STORAGE_KEY} \
quay.io/aipcc-cicd/cloud-importer:latest snc az \
--project-name "snc-4.20.0" \
--backed-url azblob://blobcontainer/folder \
--bundle-uri ${BUNDLE_URL} \
--shasum-uri ${SHASUM_URL} \
--arch ${ARCH} \
--replicate \
--share-orgs-ids tenantId1,tenantId2 \
--debug \
--debug-level 9podman run --rm --name import-snc-gcp -d \
-e GOOGLE_PROJECT=${GOOGLE_PROJECT} \
-e GOOGLE_CREDENTIALS=${GOOGLE_CREDENTIALS} \
-e GOOGLE_REGION=${GOOGLE_REGION} \
quay.io/aipcc-cicd/cloud-importer:latest snc gcp \
--project-name "snc-4.20.0" \
--backed-url gs://bucket/folder \
--bundle-uri ${BUNDLE_URL} \
--shasum-uri ${SHASUM_URL} \
--arch ${ARCH} \
--share-orgs-ids gcp-project-a,gcp-project-b \
--debug \
--debug-level 9Verifies whether an image with the given name already exists in the cloud provider. Exits 0 if found, 1 if not found, 2 on error.
podman run --rm \
-e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} \
-e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} \
-e AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION} \
quay.io/aipcc-cicd/cloud-importer:latest check aws \
--image-name rhelai3-136d47d1podman run --rm \
-e ARM_CLIENT_ID=${ARM_CLIENT_ID} \
-e ARM_CLIENT_SECRET=${ARM_CLIENT_SECRET} \
-e ARM_TENANT_ID=${ARM_TENANT_ID} \
-e ARM_SUBSCRIPTION_ID=${ARM_SUBSCRIPTION_ID} \
-e ARM_LOCATION_NAME=${ARM_LOCATION_NAME} \
quay.io/aipcc-cicd/cloud-importer:latest check az \
--image-name rhelai3-136d47d1podman run --rm \
-e GOOGLE_PROJECT=${GOOGLE_PROJECT} \
-e GOOGLE_CREDENTIALS=${GOOGLE_CREDENTIALS} \
quay.io/aipcc-cicd/cloud-importer:latest check gcp \
--image-name rhelai3-136d47d1Destroys all cloud resources associated with an import run and removes the Pulumi state. Run with the same --project-name and --backed-url used during import. Credentials must match the provider used for the original import.
podman run --rm \
-e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} \
-e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} \
-e AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION} \
quay.io/aipcc-cicd/cloud-importer:latest destroy \
--project-name "snc-4.20.0" \
--backed-url s3://bucket/folderpodman run --rm \
-e ARM_CLIENT_ID=${ARM_CLIENT_ID} \
-e ARM_CLIENT_SECRET=${ARM_CLIENT_SECRET} \
-e ARM_TENANT_ID=${ARM_TENANT_ID} \
-e ARM_SUBSCRIPTION_ID=${ARM_SUBSCRIPTION_ID} \
-e ARM_LOCATION_NAME=${ARM_LOCATION_NAME} \
quay.io/aipcc-cicd/cloud-importer:latest destroy \
--project-name "snc-4.20.0" \
--backed-url azblob://blobcontainer/folderpodman run --rm \
-e GOOGLE_PROJECT=${GOOGLE_PROJECT} \
-e GOOGLE_CREDENTIALS=${GOOGLE_CREDENTIALS} \
quay.io/aipcc-cicd/cloud-importer:latest destroy \
--project-name "snc-4.20.0" \
--backed-url gs://bucket/folderFor local testing, store Pulumi state in the mounted workspace directory — no cloud storage bucket needed. Load credentials from Bitwarden and pass them with name-only -e flags so values never appear in shell history or ps output.
| Bitwarden item | username field |
password field |
notes field |
|---|---|---|---|
AWS_ACCESS |
AWS_ACCESS_KEY_ID value |
AWS_SECRET_ACCESS_KEY value |
— |
AZ_SP |
ARM_CLIENT_ID value |
ARM_CLIENT_SECRET value |
— |
AZ_STORAGE |
AZURE_STORAGE_ACCOUNT value |
AZURE_STORAGE_KEY value |
— |
GCP_SA_KEY |
GOOGLE_PROJECT value |
— | service account key JSON |
First, unlock your Bitwarden vault and establish a session:
export BW_SESSION=$(bw unlock --raw)AWS:
export AWS_ACCESS_KEY_ID=$(bw get username "AWS_ACCESS")
export AWS_SECRET_ACCESS_KEY=$(bw get password "AWS_ACCESS")
export AWS_DEFAULT_REGION=us-east-1Azure:
export ARM_CLIENT_ID=$(bw get username "AZ_SP")
export ARM_CLIENT_SECRET=$(bw get password "AZ_SP")
export ARM_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
export ARM_SUBSCRIPTION_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
export ARM_LOCATION_NAME=eastus
export AZURE_STORAGE_ACCOUNT=$(bw get username "AZ_STORAGE")
export AZURE_STORAGE_KEY=$(bw get password "AZ_STORAGE")GCP:
export GOOGLE_PROJECT=$(bw get username "GCP_SA_KEY")
export GOOGLE_REGION=us-central1
export GOOGLE_CREDENTIALS=$(bw get notes "GCP_SA_KEY" | jq -c .) # compact multiline JSON to single lineAWS:
podman run --rm --name import-rhelai -d \
--user 0 \
-v ${PWD}:/workspace:z \
-e AWS_ACCESS_KEY_ID \
-e AWS_SECRET_ACCESS_KEY \
-e AWS_DEFAULT_REGION \
quay.io/aipcc-cicd/cloud-importer:latest rhelai aws \
--project-name "rhelai-dev-test" \
--backed-url "file:///workspace" \
--image-name "rhelai-dev-test" \
--image-path "/workspace/rhel-ai-nvidia-aws-1.5-x86_64.raw" \
--debug \
--debug-level 9
podman logs -f import-rhelaiGCP:
podman run --rm --name import-rhelai-gcp -d \
--user 0 \
-v ${PWD}:/workspace:z \
-e GOOGLE_PROJECT \
-e GOOGLE_CREDENTIALS \
-e GOOGLE_REGION \
quay.io/aipcc-cicd/cloud-importer:latest rhelai gcp \
--project-name "rhelai-dev-test" \
--backed-url "file:///workspace" \
--image-name "rhelai-dev-test" \
--image-path "/workspace/disk.raw" \
--debug \
--debug-level 9
podman logs -f import-rhelai-gcpAzure:
podman run --rm --name import-rhelai-azure -d \
--user 0 \
-v ${PWD}:/workspace:z \
-e ARM_TENANT_ID \
-e ARM_CLIENT_ID \
-e ARM_CLIENT_SECRET \
-e ARM_SUBSCRIPTION_ID \
-e ARM_LOCATION_NAME \
quay.io/aipcc-cicd/cloud-importer:latest rhelai az \
--project-name "rhelai-dev-test" \
--backed-url "file:///workspace" \
--image-name "rhelai-dev-test" \
--image-path "/workspace/rhel-ai-nvidia-aws-1.5-x86_64.vhd" \
--debug \
--debug-level 9
podman logs -f import-rhelai-azurePulumi state is written to ${PWD}/rhelai-dev-test/ — you can delete it when you are done, after you have cleaned up any images you uploaded as part of the test.
When testing with a cloud backend (gs://, s3://, azblob://) instead of file://, Pulumi's
passphrase secrets manager requires PULUMI_CONFIG_PASSPHRASE to be set. For development runs where
there are no sensitive stack secrets, set it to an empty string:
PULUMI_CONFIG_PASSPHRASE="" /path/to/importer rhelai gcp \
--project-name "rhelai-dev-test" \
--backed-url "gs://my-state-bucket/cloud-importer" \
--image-name "rhelai-dev-test" \
--image-path "/path/to/disk.raw"Note:
PULUMI_CONFIG_PASSPHRASEis not required when usingfile://(the local backend) for most development scenarios. It is required for all cloud backends. In CI/CD it is expected to be set in the environment by the pipeline configuration.
After a successful import, the following commands show how to launch a short-lived test VM to confirm the image boots correctly and is the expected OS/version. The region you run the VM in must have access to the image and will benefit from having default networking already established — if it doesn't, you will need to create or configure the necessary networking yourself. Remember to delete the test VM when done.
# Launch a test instance
aws ec2 run-instances \
--image-id <ami-id-from-import-output> \
--instance-type t3.medium \
--region ${AWS_DEFAULT_REGION} \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=image-test}]' \
--query 'Instances[0].InstanceId' --output text
# Wait for it to be running, then SSH
aws ec2 wait instance-running --instance-ids <instance-id>
ssh ec2-user@<public-ip>
# Verify OS / RHEL AI version
cat /etc/os-release
ilab --version # for RHEL AI images
# Clean up
aws ec2 terminate-instances --instance-ids <instance-id># Launch a test VM from the gallery image
az vm create \
--resource-group aipcc-productization \
--name image-test \
--image aipcc-productization/aipcc-gallery/rhelai3-136d47d1/latest \
--size Standard_D4s_v3 \
--admin-username azureuser \
--generate-ssh-keys
# SSH and verify
ssh azureuser@<public-ip>
cat /etc/os-release
ilab --version # for RHEL AI images
# Clean up
az vm delete --resource-group aipcc-productization --name image-test --yes# Launch a test VM (use --preemptible for a cheaper spot-equivalent test)
gcloud compute instances create image-test \
--image rhelai3-136d47d1 \
--image-project ${GOOGLE_PROJECT} \
--machine-type n2-standard-4 \
--zone ${GOOGLE_REGION}-a \
--preemptible
# SSH and verify
gcloud compute ssh image-test --zone ${GOOGLE_REGION}-a
cat /etc/os-release
ilab --version # for RHEL AI images
# Clean up
gcloud compute instances delete image-test --zone ${GOOGLE_REGION}-a --quietVersioned images are published to quay.io/aipcc-cicd/cloud-importer on every tag push.
To trigger a release:
- Create a release branch:
git checkout -b release-9.9.9
- Update
VERSIONin the Makefile:VERSION ?= 9.9.9 - Regenerate Tekton tasks:
make tkn-update
- Commit and tag:
git add Makefile tkn/ git commit -m "chore(cut) v9.9.9" git tag v9.9.9 - Push the tag:
git push upstream v9.9.9
- The release workflow will:
- Build and push
quay.io/aipcc-cicd/cloud-importer:v9.9.9 - Also tag it as
quay.io/aipcc-cicd/cloud-importer:latest - Create a GitHub Release with auto-generated notes
- Build and push
cloud-importer performs the following steps:
1. Bundle Download (SNC only)
- Downloads the OpenShift Local bundle and its checksum from the provided URIs
- The Linux (libvirt) bundle containing the
qcow2image is easiest to convert to raw/VHD/tar.gz
- The Linux (libvirt) bundle containing the
- Verifies the bundle integrity using the checksum
- Troubleshooting: Double-check
--bundle-uriand--shasum-urivalues if errors occur here
2. Disk Extraction (SNC only)
- Decompresses the
.xzarchive and extracts files - Locates the
qcow2disk image and converts it to the provider's required format:- AWS:
.raw - Azure:
.vhd - GCP:
disk.raw.tar.gz(a compressed tar archive containingdisk.raw)
- AWS:
- Troubleshooting:
- Corrupted archive: remove the local bundle and re-run
- Disk space: ensure ~60 GB free for the downloaded bundle and extracted image
3. Upload to cloud storage
- Uploads the prepared disk image to temporary cloud storage (S3, Azure Blob, or GCS)
- Troubleshooting: Verify credentials have write permissions to the storage service
4. Image registration
- AWS: Initiates a VM import task → EBS snapshot → AMI registration
- Azure: Creates a Compute Gallery, Gallery Image Definition, and Image Version pointing to the blob
- GCP: Creates a Compute Engine Custom Image from the GCS source URI
- Troubleshooting:
- AWS IAM role: The
vmimportrole is created automatically if it doesn't exist. If import fails, verify your user hasec2:ImportSnapshotandec2:DescribeImportSnapshotTaskspermissions - GCP: Ensure the
compute.images.createpermission is granted to the service account whose credentials are inGOOGLE_CREDENTIALS
- AWS IAM role: The
5. Stuck imports / lock files
If a previous run crashed and left a Pulumi lock, re-run with --force-destroy added to the destroy command to clear the lock before retrying.