Every Satellite I have inherited was managed by clicking through the UI, and nobody could say why a content view contained what it contained. For a recent multi-environment RHEL estate I put the whole content lifecycle - products, repositories, GPG keys, content views, activation keys, publish and promote - into a git repo driven by the redhat.satellite Ansible collection. These are the patterns that worked and the traps that cost me an afternoon each.

Why Satellite state belongs in git

Content views, filters and activation keys are exactly the kind of state that rots in a UI: changes are invisible, unreviewable and unrepeatable. Once they are YAML in a repo, adding a repository is a merge request, a colleague can review the diff, and rebuilding the Satellite from scratch is one playbook run instead of a week of clicking from memory.

Collection and repo layout

The certified redhat.satellite collection wraps the Satellite API; pin it in collections/requirements.yml like any other dependency:

collections:
  - name: redhat.satellite
    version: 3.14.0
  - name: redhat.satellite_operations
    version: 2.1.0

The repo is a plain Ansible project. All the actual content definitions live in group_vars, split per topic so the diffs stay readable:

inventory/group_vars/satellite/
  products.yml
  content_credentials.yml
  content_views.yml
  activation_keys.yml
  lifecycle_environments.yml
  credentials.yml            # vault-encrypted
playbooks/
  configure.yml
  publish.yml
  promote.yml

The configure playbook is almost embarrassingly small, because the collection ships a role per object type and each role reads its own variable:

- name: Configure Satellite from the repo
  hosts: satellite
  gather_facts: false
  collections:
    - redhat.satellite
  roles:
    - redhat.satellite.organizations
    - redhat.satellite.locations
    - redhat.satellite.manifest
    - redhat.satellite.content_credentials
    - redhat.satellite.repositories
    - redhat.satellite.sync_plans
    - redhat.satellite.lifecycle_environments
    - redhat.satellite.content_views
    - redhat.satellite.activation_keys

Run it with diff mode so you see what changes on the server before it does:

$ ansible-playbook -Dv playbooks/configure.yml --ask-vault-pass

One early mistake: my first version of configure.yml also published and promoted content view versions at the end. Do not do that. Configuration is idempotent and safe to run any time; publishing creates new versions and belongs in its own playbook with its own schedule.

Products and repositories

Red Hat content is enabled through repository sets from the subscription manifest. Third-party products are defined with an URL and a GPG key:

satellite_products:
  - name: Red Hat Enterprise Linux for x86_64
    repository_sets:
      - name: Red Hat Enterprise Linux 9 for x86_64 - BaseOS (RPMs)
        releasever: 9
      - name: Red Hat Enterprise Linux 9 for x86_64 - AppStream (RPMs)
        releasever: 9

  - name: EPEL
    repositories:
      - name: EPEL for RHEL 9
        content_type: yum
        url: https://dl.fedoraproject.org/pub/epel/9/Everything/x86_64/
        gpg_key: EPEL 9 Signing Key
        download_policy: immediate

  - name: Veeam
    repositories:
      - name: Veeam Agent for RHEL 9
        content_type: yum
        url: https://repository.veeam.com/backup/linux/agent/rpm/el/9/
        gpg_key: Veeam Signing Key
        download_policy: immediate

The GPG keys themselves are content credentials, and a URL lookup keeps even those out of the repo as blobs:

satellite_content_credentials:
  - name: EPEL 9 Signing Key
    content_type: gpg_key
    content: "{{ lookup('url', 'https://dl.fedoraproject.org/pub/epel/RPM-GPG-KEY-EPEL-9', split_lines=False) }}"

  - name: Veeam Signing Key
    content_type: gpg_key
    content: "{{ lookup('url', 'https://repository.veeam.com/keys/RPM-E6FBD664', split_lines=False) }}"

Two traps here. First, vendors publish more than one key: my initial Veeam definition pointed at the obvious veeam.gpg from their keys directory, and package installs failed GPG verification until I switched to the key that actually signs the RPMs, published as RPM-E6FBD664. Check the key listing before you trust a filename.

Second, Kickstart repositories. For PXE provisioning you also want the BaseOS and AppStream Kickstart repository sets, and I first enabled them the same way as the RPM ones, with releasever: 9. The repositories role cannot enable that, because Kickstart trees only exist per minor release. The fix:

       - name: Red Hat Enterprise Linux 9 for x86_64 - BaseOS (Kickstart)
-        releasever: 9
+        releasever: 9.3
       - name: Red Hat Enterprise Linux 9 for x86_64 - AppStream (Kickstart)
-        releasever: 9
+        releasever: 9.3

Keep the previous minor release enabled next to the current one while any host group still kickstarts from it.

Content views: do not encode the environment in the name

This is the part I got most wrong. My first modelling pass created one content view per lifecycle environment: cv-rhel-dev, cv-rhel-test, cv-rhel-prod, each listing identical repositories. It felt symmetrical and it was wrong. Content views are versioned; lifecycle environments are where versions get promoted to. Duplicated per-environment views triple the YAML, publish three times the versions, and quietly break the promise that what you tested is what production gets.

The refactor deleted two thirds of the definitions and left one view per content set, plus composite views stacking them per workload:

satellite_content_views:
  - name: cv-rhel-base
    repositories:
      - name: Red Hat Enterprise Linux 9 for x86_64 - BaseOS RPMs 9
        product: Red Hat Enterprise Linux for x86_64
      - name: Red Hat Enterprise Linux 9 for x86_64 - AppStream RPMs 9
        product: Red Hat Enterprise Linux for x86_64
      - name: Veeam Agent for RHEL 9
        product: Veeam

  - name: cv-gitlab
    repositories:
      - name: GitLab CE for RHEL 9
        product: GitLab

  - name: ccv-gitlab
    components:
      - content_view: cv-rhel-base
        latest: true
      - content_view: cv-gitlab
        latest: true

A GitLab server consumes ccv-gitlab: the base OS content everyone gets, plus its own product repos, as one promotable unit.

Activation keys as data

Activation keys are where environment and content view meet, so this is the only place the environment name appears:

satellite_activation_keys:
  - name: ak-rhel-base-test
    lifecycle_environment: TEST
    content_view: cv-rhel-base

  - name: ak-rhel-base-prod
    lifecycle_environment: PROD
    content_view: cv-rhel-base

  - name: ak-gitlab-prod
    lifecycle_environment: PROD
    content_view: ccv-gitlab

These keys are the contract with every other automation you run: kickstart templates reference them, and migration playbooks re-register hosts with them, like the RHEL upgrade campaign I described in Surviving Leapp.

Publishing and promoting on a calendar

Publishing new versions is its own playbook, using the collection’s content_view_publish role against the same satellite_content_views list:

- name: Publish new content view versions
  hosts: satellite
  collections:
    - redhat.satellite
  roles:
    - redhat.satellite.content_view_publish

Promotion follows a fixed cadence instead of ad-hoc clicking: new versions land in TEST on the first Monday of the month, move to QA on the third, and reach PROD on the following first Monday. The playbook works out which Monday it is and refuses to run on any other day:

- name: Work out which Monday of the month today is
  set_fact:
    monday_of_month: "{{ ((ansible_date_time.day | int - 1) // 7) + 1 }}"

- name: Stop unless today is a Monday
  ansible.builtin.fail:
    msg: "This playbook only runs on Mondays."
  when: ansible_date_time.weekday != "Monday"

- name: Promote from TEST to QA on the third Monday
  redhat.satellite.content_view_version:
    username: "{{ satellite_username }}"
    password: "{{ satellite_password }}"
    server_url: "{{ satellite_server_url }}"
    content_view: "{{ item.name }}"
    organization: "{{ satellite_organization }}"
    current_lifecycle_environment: TEST
    lifecycle_environments:
      - QA
  loop: "{{ satellite_content_views | default([], true) }}"
  when: monday_of_month | int == 3

Schedule it weekly from your automation controller and the guard clauses turn a dumb cron trigger into the right action for that week. Hosts in PROD now run content that spent a month being exercised in the earlier environments, and the promotion history in Satellite matches the git history of the repo.

Day-2: adding a new product without the UI

The payoff shows up the first time somebody asks for a new repository. Adding GitLab for the RHEL 9 hosts was one merge request touching four files:

  1. content_credentials.yml: the GitLab package signing key, via URL lookup
  2. products.yml: the product with its yum repository
  3. content_views.yml: cv-gitlab plus the ccv-gitlab composite
  4. activation_keys.yml: ak-gitlab-test and ak-gitlab-prod

Review, merge, run configure.yml, and the next publish/promote cycle carries it through the environments. No screenshots in the change ticket, no “step 14 of 23” wiki page, and six months later the diff still explains itself.

Resources