Skip to content
3 changes: 3 additions & 0 deletions .ansible-lint
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
---
use_default_rules: true
rulesdir:
- .ansible-lint-rules/
enable_list:
- var-naming[no-role-prefix]
exclude_paths:
Expand Down
Empty file added .ansible-lint-rules/__init__.py
Empty file.
52 changes: 52 additions & 0 deletions .ansible-lint-rules/empty_defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Implementation of var-defaults rule."""

from __future__ import annotations

from typing import TYPE_CHECKING

from ansiblelint.rules import AnsibleLintRule
from ansiblelint.utils import parse_yaml_from_file

if TYPE_CHECKING:
from ansiblelint.errors import MatchError
from ansiblelint.file_utils import Lintable


class EmptyDefaultsRule(AnsibleLintRule):
"""Role default variables should not have empty values."""

id = "var-defaults"
severity = "HIGH"
tags = ["idiom"]
version_added = "custom"

_ids = {
"var-defaults[no-empty]": "Role default variables must not be null or empty strings.",
}

def matchyaml(self, file: Lintable) -> list[MatchError]:
"""Return matches for empty defaults in role defaults files."""
results: list[MatchError] = []

if str(file.kind) != "vars" or not file.data:
return results

if not file.role or "defaults" not in file.path.parts:
return results

meta_data = parse_yaml_from_file(str(file.path))
if not isinstance(meta_data, dict):
return results

for key, value in meta_data.items():
if value is None or value == "":
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So just to clarify, The test fixture has passed test_empty_defaults_plugins: [] which is not flagged right. Shouldn't empty lists ([]) also be checked in that case? Or is empty list a valid default?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've kept [] as allowed, as my trail of thought was: "this is usually used for defining additions (e.g. plugins), so an empty list is ok"

results.append(
self.create_matcherror(
message=f"Role default variable '{key}' has an empty value. Use `undef(hint='…')` to indicate defaults that need to be overriden.",
filename=file,
tag="var-defaults[no-empty]",
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this uses id[sub-id] style, as I've used var-naming rule as a base, and this has multiple sub-tags.
after using the two rules in this PR (and also flagging certain vars as noqa for them), I think this is wrong.

We should either:

  • Have a single var-content rule file with multiple tags (var-content[no-empty-defaults], var-content[no-static-secrets])
  • Have dedicated named rules like no-empty-defaults and no-static-secrets

I slightly lean towards the dedicated naming, but eager to hear what you think.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dedicated looks good to me

data=key,
),
)

return results
63 changes: 63 additions & 0 deletions .ansible-lint-rules/no_static_secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Implementation of var-secrets rule."""

from __future__ import annotations

from typing import TYPE_CHECKING

from ansiblelint.rules import AnsibleLintRule
from ansiblelint.text import has_jinja
from ansiblelint.utils import parse_yaml_from_file

if TYPE_CHECKING:
from ansiblelint.errors import MatchError
from ansiblelint.file_utils import Lintable

SECRET_SUFFIXES = (
"_password",
"_passwd",
"_secret",
"_token",
)


class NoStaticSecretsRule(AnsibleLintRule):
"""Variables that look like secrets must not have static default values."""

id = "var-secrets"
severity = "HIGH"
tags = ["security"]
version_added = "custom"

_ids = {
"var-secrets[no-static]": "Secret variables must use Jinja expressions, not static strings.",
}

@staticmethod
def _looks_like_secret(name: str) -> bool:
return any(name.endswith(suffix) for suffix in SECRET_SUFFIXES)

def matchyaml(self, file: Lintable) -> list[MatchError]:
"""Flag secret-looking variables with static string values."""
results: list[MatchError] = []

if str(file.kind) != "vars" or not file.data:
return results

meta_data = parse_yaml_from_file(str(file.path))
if not isinstance(meta_data, dict):
return results

for key, value in meta_data.items():
if not self._looks_like_secret(str(key)):
continue
if isinstance(value, str) and not has_jinja(value):
results.append(
self.create_matcherror(
message=f"Secret variable '{key}' has a static value. Use a Jinja expression instead.",
filename=file,
tag="var-secrets[no-static]",
data=key,
),
)

return results
6 changes: 3 additions & 3 deletions development/roles/foreman_development/defaults/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ foreman_development_client_certificate: "{{ foreman_client_certificate }}"
foreman_development_client_key: "{{ foreman_client_key }}"

foreman_development_admin_user: "admin"
foreman_development_admin_password: "changeme"
foreman_development_admin_password: "changeme" # noqa: var-secrets[no-static]

foreman_development_candlepin_url: "https://localhost:23443/candlepin"

foreman_development_git_repo: "https://github.com/theforeman/foreman.git"
foreman_development_git_revision: "develop"
foreman_development_github_username: ""
foreman_development_github_username: "" # noqa: var-defaults[no-empty]

foreman_development_hammer_git_repo: "https://github.com/theforeman/hammer-cli.git"

Expand All @@ -33,7 +33,7 @@ foreman_development_database_host: "localhost"
foreman_development_database_port: 5432
foreman_development_database_name: "foreman_development"
foreman_development_database_user: "foreman"
foreman_development_database_password: "foreman"
foreman_development_database_password: "foreman" # noqa: var-secrets[no-static]

foreman_development_nodejs_stream: "22"

Expand Down
39 changes: 39 additions & 0 deletions docs/developer/playbooks-and-roles.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,45 @@ include:
- _flavor_features
```

## Secret Management

Secrets (passwords, tokens, OAuth secrets) must never be hardcoded. Use the `ansible.builtin.password` lookup to auto-generate secrets and persist them to files under `obsah_state_path`.

### Pattern

Every secret needs two variables: a `_file` variable pointing to the state file, and the secret variable itself using a `lookup` against that file.

```yaml
example_database_password_file: "{{ obsah_state_path }}/example-db-password"
example_database_password: "{{ lookup('ansible.builtin.password', example_database_password_file, chars=['ascii_letters', 'digits']) }}"
```

The `lookup` generates a random password on first run and writes it to the file. On subsequent runs, it reads the existing value, ensuring the secret is stable across deploys.

### Parameters

| Parameter | Usage |
|-----------|-------|
| `chars` | Character set for generation. Use `['ascii_letters', 'digits']` for passwords safe in URLs and connection strings. |
| `length` | Password length. Defaults to 20 if omitted. Use `length=32` for high-entropy secrets like OAuth tokens. |

### Where to define secrets

Define secrets in `src/vars/` files, not in role `defaults/`. Vars files have higher Ansible precedence and are the effective source of truth at deploy time.

| Secret type | Define in |
|-------------|-----------|
| Database passwords | `src/vars/database.yml` |
| IOP database passwords | `src/vars/database_iop.yml` |
| general passwords/secrets | `src/vars/base.yaml` |
| Foreman-specific passwords/secrets | `src/vars/foreman.yml` |

### Naming conventions

- State file variable: `<service>_<purpose>_password_file` (or `_secret_file` for non-password secrets)
- State file path: `{{ obsah_state_path }}/<service>-<purpose>-password` (use hyphens, no underscores)
- Secret variable: `<service>_<purpose>_password`

## How to Add a New Command

1. Create a directory under `src/playbooks/<command-name>/` (or `development/playbooks/` for dev tools).
Expand Down
4 changes: 1 addition & 3 deletions src/roles/candlepin/defaults/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,5 @@ candlepin_database_host: localhost
candlepin_database_port: 5432
candlepin_database_ssl: false
candlepin_database_ssl_mode: disable
candlepin_database_ssl_ca:
candlepin_database_ssl_ca: # noqa: var-defaults[no-empty]
candlepin_database_ssl_ca_path: /etc/candlepin/certs/db-ca.crt
candlepin_database_ssl_cert:
candlepin_database_ssl_key:
Comment on lines -25 to -26
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

love finding unused vars :)

2 changes: 1 addition & 1 deletion src/roles/foreman/defaults/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ foreman_database_host: localhost
foreman_database_port: 5432
foreman_database_pool: 9
foreman_database_ssl_mode: disable
foreman_database_ssl_ca:
foreman_database_ssl_ca: # noqa: var-defaults[no-empty]
foreman_database_ssl_ca_path: /etc/foreman/db-ca.crt

foreman_name: "{{ ansible_facts['fqdn'] }}"
Expand Down
2 changes: 1 addition & 1 deletion src/roles/hammer/defaults/main.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
hammer_foreman_server_url: "https://{{ ansible_facts['fqdn'] }}"
hammer_ca_certificate: ""
hammer_ca_certificate: "" # noqa: var-defaults[no-empty]
hammer_default_plugins:
- foreman
hammer_plugins: []
Expand Down
2 changes: 1 addition & 1 deletion src/roles/iop_advisor/defaults/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ iop_advisor_registry_auth_file: /etc/foreman/registry-auth.json

iop_advisor_database_name: advisor_db
iop_advisor_database_user: advisor_user
iop_advisor_database_password: CHANGEME
iop_advisor_database_password: "{{ undef(hint='Set a secure database password') }}"
iop_advisor_database_host: host.containers.internal
iop_advisor_database_port: 5432
2 changes: 1 addition & 1 deletion src/roles/iop_inventory/defaults/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ iop_inventory_registry_auth_file: /etc/foreman/registry-auth.json

iop_inventory_database_name: inventory_db
iop_inventory_database_user: inventory_admin
iop_inventory_database_password: CHANGEME
iop_inventory_database_password: "{{ undef(hint='Set a secure database password') }}"
iop_inventory_database_host: host.containers.internal
iop_inventory_database_port: 5432
2 changes: 1 addition & 1 deletion src/roles/iop_remediation/defaults/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ iop_remediation_registry_auth_file: /etc/foreman/registry-auth.json

iop_remediation_database_name: remediations_db
iop_remediation_database_user: remediations_user
iop_remediation_database_password: CHANGEME
iop_remediation_database_password: "{{ undef(hint='Set a secure database password') }}"
iop_remediation_database_host: "host.containers.internal"
iop_remediation_database_port: "5432"
2 changes: 1 addition & 1 deletion src/roles/iop_vmaas/defaults/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ iop_vmaas_registry_auth_file: /etc/foreman/registry-auth.json

iop_vmaas_database_name: vmaas_db
iop_vmaas_database_user: vmaas_admin
iop_vmaas_database_password: CHANGEME
iop_vmaas_database_password: "{{ undef(hint='Set a secure database password') }}"
iop_vmaas_database_host: "host.containers.internal"
iop_vmaas_database_port: "5432"

Expand Down
2 changes: 1 addition & 1 deletion src/roles/iop_vulnerability/defaults/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ iop_vulnerability_registry_auth_file: /etc/foreman/registry-auth.json

iop_vulnerability_database_name: vulnerability_db
iop_vulnerability_database_user: vulnerability_admin
iop_vulnerability_database_password: CHANGEME
iop_vulnerability_database_password: "{{ undef(hint='Set a secure database password') }}"
iop_vulnerability_database_host: "host.containers.internal"
iop_vulnerability_database_port: "5432"

Expand Down
2 changes: 1 addition & 1 deletion src/roles/postgresql/defaults/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ postgresql_restart_policy: always

postgresql_data_dir: /var/lib/pgsql/data

postgresql_admin_password: "CHANGEME"
postgresql_admin_password: "{{ undef(hint='Set a secure database password') }}"

postgresql_max_connections: 500
postgresql_shared_buffers: 512MB
Expand Down
2 changes: 1 addition & 1 deletion src/roles/pulp/defaults/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ pulp_database_user: pulp
pulp_database_host: localhost
pulp_database_port: 5432
pulp_database_ssl_mode: disabled
pulp_database_ssl_ca:
pulp_database_ssl_ca: # noqa: var-defaults[no-empty]
pulp_database_ssl_ca_path: /etc/pulp/certs/db-ca.crt

pulp_settings_database_env:
Expand Down
9 changes: 6 additions & 3 deletions src/vars/base.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ certificates_hostnames:
- "{{ ansible_facts['fqdn'] }}"
- localhost

certificates_ca_password: "CHANGEME"
candlepin_keystore_password: "CHANGEME"
candlepin_oauth_secret: "CHANGEME"
certificates_ca_password_file: "{{ obsah_state_path }}/certificates-ca-password"
certificates_ca_password: "{{ lookup('ansible.builtin.password', certificates_ca_password_file, chars=['ascii_letters', 'digits']) }}"
candlepin_keystore_password_file: "{{ obsah_state_path }}/candlepin-keystore-password"
candlepin_keystore_password: "{{ lookup('ansible.builtin.password', candlepin_keystore_password_file, chars=['ascii_letters', 'digits']) }}"
candlepin_oauth_secret_file: "{{ obsah_state_path }}/candlepin-oauth-secret"
candlepin_oauth_secret: "{{ lookup('ansible.builtin.password', candlepin_oauth_secret_file, chars=['ascii_letters', 'digits'], length=32) }}"

candlepin_ca_key_password: "{{ ca_key_password }}"
candlepin_ca_key: "{{ ca_key }}"
Expand Down
12 changes: 9 additions & 3 deletions src/vars/database.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@ database_ssl_ca:

foreman_database_name: foreman
foreman_database_user: foreman
foreman_database_password: CHANGEME
foreman_database_password_file: "{{ obsah_state_path }}/foreman-db-password"
foreman_database_password: "{{ lookup('ansible.builtin.password', foreman_database_password_file, chars=['ascii_letters', 'digits']) }}"
candlepin_database_name: candlepin
candlepin_database_user: candlepin
candlepin_database_password: CHANGEME
candlepin_database_password_file: "{{ obsah_state_path }}/candlepin-db-password"
candlepin_database_password: "{{ lookup('ansible.builtin.password', candlepin_database_password_file, chars=['ascii_letters', 'digits']) }}"
pulp_database_name: pulp
pulp_database_user: pulp
pulp_database_password: CHANGEME
pulp_database_password_file: "{{ obsah_state_path }}/pulp-db-password"
pulp_database_password: "{{ lookup('ansible.builtin.password', pulp_database_password_file, chars=['ascii_letters', 'digits']) }}"

postgresql_admin_password_file: "{{ obsah_state_path }}/postgresql-admin-password"
postgresql_admin_password: "{{ lookup('ansible.builtin.password', postgresql_admin_password_file, chars=['ascii_letters', 'digits']) }}"

candlepin_database_host: "{{ database_host }}"
candlepin_database_port: "{{ database_port }}"
Expand Down
15 changes: 10 additions & 5 deletions src/vars/database_iop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,36 @@ iop_inventory_database_host: "{{ iop_database_host }}"
iop_inventory_database_port: "{{ iop_database_port }}"
iop_inventory_database_name: inventory_db
iop_inventory_database_user: inventory_admin
iop_inventory_database_password: CHANGEME
iop_inventory_database_password_file: "{{ obsah_state_path }}/iop-inventory-db-password"
iop_inventory_database_password: "{{ lookup('ansible.builtin.password', iop_inventory_database_password_file, chars=['ascii_letters', 'digits']) }}"

iop_advisor_database_host: "{{ iop_database_host }}"
iop_advisor_database_port: "{{ iop_database_port }}"
iop_advisor_database_name: advisor_db
iop_advisor_database_user: advisor_user
iop_advisor_database_password: CHANGEME
iop_advisor_database_password_file: "{{ obsah_state_path }}/iop-advisor-db-password"
iop_advisor_database_password: "{{ lookup('ansible.builtin.password', iop_advisor_database_password_file, chars=['ascii_letters', 'digits']) }}"

iop_remediation_database_host: "{{ iop_database_host }}"
iop_remediation_database_port: "{{ iop_database_port }}"
iop_remediation_database_name: remediations_db
iop_remediation_database_user: remediations_user
iop_remediation_database_password: CHANGEME
iop_remediation_database_password_file: "{{ obsah_state_path }}/iop-remediation-db-password"
iop_remediation_database_password: "{{ lookup('ansible.builtin.password', iop_remediation_database_password_file, chars=['ascii_letters', 'digits']) }}"

iop_vmaas_database_host: "{{ iop_database_host }}"
iop_vmaas_database_port: "{{ iop_database_port }}"
iop_vmaas_database_name: vmaas_db
iop_vmaas_database_user: vmaas_admin
iop_vmaas_database_password: CHANGEME
iop_vmaas_database_password_file: "{{ obsah_state_path }}/iop-vmaas-db-password"
iop_vmaas_database_password: "{{ lookup('ansible.builtin.password', iop_vmaas_database_password_file, chars=['ascii_letters', 'digits']) }}"

iop_vulnerability_database_host: "{{ iop_database_host }}"
iop_vulnerability_database_port: "{{ iop_database_port }}"
iop_vulnerability_database_name: vulnerability_db
iop_vulnerability_database_user: vulnerability_admin
iop_vulnerability_database_password: CHANGEME
iop_vulnerability_database_password_file: "{{ obsah_state_path }}/iop-vulnerability-db-password"
iop_vulnerability_database_password: "{{ lookup('ansible.builtin.password', iop_vulnerability_database_password_file, chars=['ascii_letters', 'digits']) }}"

iop_postgresql_databases:
- name: "{{ iop_inventory_database_name }}"
Expand Down
2 changes: 1 addition & 1 deletion src/vars/installer_certificates.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
ca_key_password: "/root/ssl-build/katello-default-ca.pwd"
ca_key_password: "/root/ssl-build/katello-default-ca.pwd" # noqa: var-secrets[no-static]
ca_certificate: "/root/ssl-build/katello-default-ca.crt"
ca_key: "/root/ssl-build/katello-default-ca.key"
server_certificate: "/root/ssl-build/{{ ansible_facts['fqdn'] }}/{{ ansible_facts['fqdn'] }}-apache.crt"
Expand Down
29 changes: 29 additions & 0 deletions tests/ansible_lint/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Makes ansible-lint pytest fixtures available for lint rule tests."""

import pytest
from ansiblelint.file_utils import Lintable
from ansiblelint.rules import RulesCollection
from ansiblelint.runner import Runner
from ansiblelint.testing.fixtures import * # noqa: F403

CUSTOM_RULESDIR = ".ansible-lint-rules"


@pytest.fixture
def custom_rules(config_options, app): # noqa: F811
"""Return a RulesCollection loaded from .ansible-lint-rules/."""
from ansiblelint.rules import RulesCollection

return RulesCollection(
app=app,
rulesdirs=[CUSTOM_RULESDIR],
options=config_options,
)


@pytest.fixture
def ansible_lint_runner(request, custom_rules: RulesCollection) -> list:
path = request.param[0]
rule_id = request.param[1]
results = Runner(Lintable(path), rules=custom_rules).run()
return [r for r in results if r.rule.id == rule_id]
Loading