From 66c5ea5f58ade9515a4076045ecd9f55be2e3ff6 Mon Sep 17 00:00:00 2001 From: akumari Date: Wed, 8 Apr 2026 14:00:29 +0530 Subject: [PATCH 01/15] Add migration subcommand Implements [SAT-43246](https://redhat.atlassian.net/browse/SAT-43246): Create foremanctl migration subcommand to convert foreman-installer answer files to foremanctl YAML configuration. Mappings include: - Database configuration (host, port, credentials, mode) - Foreman admin credentials - Certificate paths --- docs/migrate-command.md | 128 ++++++++++++ .../migrate/library/migrate_answers.py | 193 ++++++++++++++++++ src/playbooks/migrate/metadata.obsah.yaml | 15 ++ src/playbooks/migrate/migrate.yaml | 38 ++++ tests/unit/migrate_test.py | 181 ++++++++++++++++ 5 files changed, 555 insertions(+) create mode 100644 docs/migrate-command.md create mode 100755 src/playbooks/migrate/library/migrate_answers.py create mode 100644 src/playbooks/migrate/metadata.obsah.yaml create mode 100644 src/playbooks/migrate/migrate.yaml create mode 100644 tests/unit/migrate_test.py diff --git a/docs/migrate-command.md b/docs/migrate-command.md new file mode 100644 index 000000000..8abde56ba --- /dev/null +++ b/docs/migrate-command.md @@ -0,0 +1,128 @@ +# Migrate Command + +## Overview + +The `foremanctl migrate` command converts foreman-installer answer files to the new foremanctl configuration format. + +## Usage + +### Basic Usage + +```bash +# Migrate from default location +foremanctl migrate --output /tmp/new-config.yaml + +# Migrate from custom location +foremanctl migrate --answer-file /path/to/answers.yaml --output /tmp/config.yaml + +# Migrate from backup +foremanctl migrate --root /backup --output /tmp/config.yaml + +# Output to stdout +foremanctl migrate --answer-file /path/to/answers.yaml +``` + +### Command Options + +- `--answer-file PATH` - Path to the foreman-installer answer file (default: `/etc/foreman-installer/scenarios.d/satellite.yaml`) +- `--output PATH` - Path for the migrated configuration (default: stdout) +- `--root PATH` - Root directory for finding the answer file, useful for migrations from backups (default: /) + +## How It Works + +1. **Reads** the old YAML answer file +2. **Parses** the nested parameter structure (e.g., `foreman::db_host`) +3. **Maps** old parameter names to new names using a mapping table +4. **Transforms** values where needed (e.g., boolean to string) +5. **Writes** the new configuration file +6. **Reports** any unmappable parameters as warnings (does not fail) + +## Parameter Mapping + +The migration uses a hardcoded mapping table in `src/playbooks/migrate/library/migrate_answers.py`: + +```python +PARAMETER_MAP = { + # Database + ('foreman', 'db_host'): 'database_host', + ('foreman', 'db_port'): 'database_port', + ('foreman', 'db_database'): 'foreman_database_name', + ('foreman', 'db_username'): 'foreman_database_user', + ('foreman', 'db_password'): 'foreman_database_password', + ('foreman', 'db_manage'): ('database_mode', cast_database_mode), + + # Foreman + ('foreman', 'initial_admin_username'): 'foreman_initial_admin_username', + ('foreman', 'initial_admin_password'): 'foreman_initial_admin_password', + + # Certificates + ('foreman', 'server_ssl_cert'): 'server_certificate', + ('foreman', 'server_ssl_key'): 'server_key', + ('foreman', 'server_ssl_ca'): 'ca_certificate', + + # TODO: Add more mappings here... +} +``` + +### Adding New Mappings + +To add new parameter mappings: + +1. Open `src/playbooks/migrate/library/migrate_answers.py` +2. Add entries to the `PARAMETER_MAP` dictionary +3. Use format: `(old_module, old_param): new_param` +4. For transformations: `(old_module, old_param): (new_param, transform_function)` +5. To ignore a parameter: `(old_module, old_param): 'IGNORE'` + +## Example + +### Input (Old Format) + +```yaml +foreman: + db_host: database.example.com + db_port: 5432 + db_database: foreman + db_username: foreman_user + db_password: secret123 + db_manage: true + initial_admin_username: admin + initial_admin_password: changeme +``` + +### Output (New Format) + +```yaml +ca_certificate: /etc/pki/tls/certs/ca.crt +database_host: database.example.com +database_mode: internal +database_port: 5432 +foreman_database_name: foreman +foreman_database_password: secret123 +foreman_database_user: foreman_user +foreman_initial_admin_password: changeme +foreman_initial_admin_username: admin +``` + +## Testing + +Run the unit tests: + +```bash +python -m pytest tests/unit/migrate_test.py -v +``` + +Test with a sample file: + +```bash +# Create test file +cat > /tmp/test-answers.yaml < 0 %} + Warning: The following parameters could not be mapped: + {% for param in migration_result.unmappable %} + - {{ param }} + {% endfor %} + + These will need to be added to the mapping table. + {% endif %} + + {% if output is defined %} + Output written to: {{ output }} + {% else %} + Output written to stdout + {% endif %} diff --git a/tests/unit/migrate_test.py b/tests/unit/migrate_test.py new file mode 100644 index 000000000..848945918 --- /dev/null +++ b/tests/unit/migrate_test.py @@ -0,0 +1,181 @@ +import pytest +import sys +import os +import tempfile +import yaml + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src/playbooks/migrate/library')) + +import migrate_answers + + +class TestParameterMapping: + """Test the parameter mapping logic""" + + def test_simple_parameter_mapping(self): + """Test basic parameter translation""" + old_config = { + 'foreman': { + 'db_host': 'localhost', + 'db_port': 5432 + } + } + + result = migrate_answers.apply_mappings(old_config) + + assert result['mapped']['database_host'] == 'localhost' + assert result['mapped']['database_port'] == 5432 + assert result['unmappable'] == [] + + def test_parameter_transformation(self): + """Test parameters that need value transformation""" + old_config = { + 'foreman': { + 'db_manage': True + } + } + + result = migrate_answers.apply_mappings(old_config) + + assert result['mapped']['database_mode'] == 'internal' + + old_config['foreman']['db_manage'] = False + result = migrate_answers.apply_mappings(old_config) + assert result['mapped']['database_mode'] == 'external' + + def test_ignore_parameters(self): + """Test parameters marked as IGNORE""" + old_config = { + 'foreman': { + 'db_manage_rake': True, + 'db_host': 'localhost' + } + } + + result = migrate_answers.apply_mappings(old_config) + + assert 'db_manage_rake' not in result['mapped'] + assert 'db_manage_rake' not in str(result['unmappable']) + assert result['mapped']['database_host'] == 'localhost' + + def test_unmappable_parameters(self): + """Test that unmappable parameters are reported""" + old_config = { + 'foreman': { + 'unknown_param': 'value' + }, + 'katello': { + 'enable_ostree': True + } + } + + result = migrate_answers.apply_mappings(old_config) + + assert 'foreman::unknown_param' in result['unmappable'] + assert 'katello::enable_ostree' in result['unmappable'] + assert len(result['unmappable']) == 2 + + def test_empty_config(self): + """Test with empty configuration""" + old_config = {} + + result = migrate_answers.apply_mappings(old_config) + + assert result['mapped'] == {} + assert result['unmappable'] == [] + + def test_mixed_config(self): + """Test with mix of mappable and unmappable parameters""" + old_config = { + 'foreman': { + 'db_host': 'database.example.com', + 'db_port': 5432, + 'unknown_param': 'test', + 'initial_admin_username': 'admin' + } + } + + result = migrate_answers.apply_mappings(old_config) + + assert result['mapped']['database_host'] == 'database.example.com' + assert result['mapped']['database_port'] == 5432 + assert result['mapped']['foreman_initial_admin_username'] == 'admin' + + assert 'foreman::unknown_param' in result['unmappable'] + assert len(result['unmappable']) == 1 + + +class TestFileOperations: + """Test file loading and writing""" + + def test_load_valid_yaml(self): + """Test loading a valid YAML file""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + yaml.dump({'foreman': {'db_host': 'localhost'}}, f) + temp_file = f.name + + try: + result = migrate_answers.load_answer_file(temp_file) + assert result == {'foreman': {'db_host': 'localhost'}} + finally: + os.unlink(temp_file) + + def test_load_empty_yaml(self): + """Test loading an empty YAML file""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write('---\n') + temp_file = f.name + + try: + result = migrate_answers.load_answer_file(temp_file) + assert result == {} + finally: + os.unlink(temp_file) + + def test_load_nonexistent_file(self): + """Test loading a file that doesn't exist""" + with pytest.raises(FileNotFoundError): + migrate_answers.load_answer_file('/nonexistent/file.yaml') + + def test_load_invalid_yaml(self): + """Test loading invalid YAML""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + f.write('invalid: yaml: content: [') + temp_file = f.name + + try: + with pytest.raises(ValueError): + migrate_answers.load_answer_file(temp_file) + finally: + os.unlink(temp_file) + + def test_write_output_to_file(self): + """Test writing output to a file""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + temp_file = f.name + + try: + test_data = {'database_host': 'localhost', 'database_port': 5432} + migrate_answers.write_output(test_data, temp_file) + + with open(temp_file, 'r') as f: + result = yaml.safe_load(f) + assert result == test_data + finally: + os.unlink(temp_file) + + +class TestTransformations: + """Test individual transformation functions""" + + def test_cast_database_mode_true(self): + """Test database mode transformation with True""" + assert migrate_answers.cast_database_mode(True) == 'internal' + + def test_cast_database_mode_false(self): + """Test database mode transformation with False""" + assert migrate_answers.cast_database_mode(False) == 'external' + + def test_cast_database_mode_passthrough(self): + """Test database mode transformation with non-boolean""" + assert migrate_answers.cast_database_mode('already_a_string') == 'already_a_string' From 8fbd25ace240ed9d29becc1bdf3982328210b32e Mon Sep 17 00:00:00 2001 From: akumari Date: Fri, 10 Apr 2026 02:24:01 +0530 Subject: [PATCH 02/15] Rewrite docs to focus on migration workflow --- docs/migrate-command.md | 145 ++++++++++++++++++---------------------- 1 file changed, 66 insertions(+), 79 deletions(-) diff --git a/docs/migrate-command.md b/docs/migrate-command.md index 8abde56ba..4f578f058 100644 --- a/docs/migrate-command.md +++ b/docs/migrate-command.md @@ -1,82 +1,81 @@ -# Migrate Command +# Migrating from foreman-installer to foremanctl ## Overview -The `foremanctl migrate` command converts foreman-installer answer files to the new foremanctl configuration format. +When upgrading from foreman-installer to foremanctl, the `foremanctl migrate` command helps convert your existing configuration to the new format. -## Usage +This guide explains how to migrate your foreman-installer answer files to foremanctl configuration files. -### Basic Usage +## Migration Workflow +1. **Generate the migrated configuration**: + ```bash + foremanctl migrate --output /etc/foreman/config.yaml + ``` + +2. **Review the output** for any warnings about unmapped parameters + +3. **Use the migrated configuration** with foremanctl: + ```bash + foremanctl deploy + ``` + (foremanctl automatically loads configuration from `/etc/foreman/config.yaml`) + +## Command Usage + +### Basic Migration + +Migrate from the default location (reads the currently active scenario): ```bash -# Migrate from default location -foremanctl migrate --output /tmp/new-config.yaml +foremanctl migrate --output /etc/foreman/config.yaml +``` -# Migrate from custom location -foremanctl migrate --answer-file /path/to/answers.yaml --output /tmp/config.yaml +### Custom Answer File -# Migrate from backup -foremanctl migrate --root /backup --output /tmp/config.yaml +Migrate from a specific answer file: +```bash +foremanctl migrate --answer-file /path/to/custom-answers.yaml --output /etc/foreman/config.yaml +``` + +### Output to stdout -# Output to stdout -foremanctl migrate --answer-file /path/to/answers.yaml +Preview the migrated configuration without writing a file: +```bash +foremanctl migrate ``` -### Command Options +## Command Options -- `--answer-file PATH` - Path to the foreman-installer answer file (default: `/etc/foreman-installer/scenarios.d/satellite.yaml`) +- `--answer-file PATH` - Path to the foreman-installer answer file (default: `/etc/foreman-installer/scenarios.d/last_scenario.yaml`) - `--output PATH` - Path for the migrated configuration (default: stdout) -- `--root PATH` - Root directory for finding the answer file, useful for migrations from backups (default: /) - -## How It Works - -1. **Reads** the old YAML answer file -2. **Parses** the nested parameter structure (e.g., `foreman::db_host`) -3. **Maps** old parameter names to new names using a mapping table -4. **Transforms** values where needed (e.g., boolean to string) -5. **Writes** the new configuration file -6. **Reports** any unmappable parameters as warnings (does not fail) - -## Parameter Mapping - -The migration uses a hardcoded mapping table in `src/playbooks/migrate/library/migrate_answers.py`: - -```python -PARAMETER_MAP = { - # Database - ('foreman', 'db_host'): 'database_host', - ('foreman', 'db_port'): 'database_port', - ('foreman', 'db_database'): 'foreman_database_name', - ('foreman', 'db_username'): 'foreman_database_user', - ('foreman', 'db_password'): 'foreman_database_password', - ('foreman', 'db_manage'): ('database_mode', cast_database_mode), - - # Foreman - ('foreman', 'initial_admin_username'): 'foreman_initial_admin_username', - ('foreman', 'initial_admin_password'): 'foreman_initial_admin_password', - - # Certificates - ('foreman', 'server_ssl_cert'): 'server_certificate', - ('foreman', 'server_ssl_key'): 'server_key', - ('foreman', 'server_ssl_ca'): 'ca_certificate', - - # TODO: Add more mappings here... -} +- `--root PATH` - Root directory prefix for finding the answer file (default: `/`) + +The `--root` option is useful when migrating from a backup or mounted filesystem. For example, if you have a backup mounted at `/backup`, use: +```bash +foremanctl migrate --root /backup --output /etc/foreman/config.yaml ``` +This will read from `/backup/etc/foreman-installer/scenarios.d/last_scenario.yaml`. -### Adding New Mappings -To add new parameter mappings: +## Parameter Mappings -1. Open `src/playbooks/migrate/library/migrate_answers.py` -2. Add entries to the `PARAMETER_MAP` dictionary -3. Use format: `(old_module, old_param): new_param` -4. For transformations: `(old_module, old_param): (new_param, transform_function)` -5. To ignore a parameter: `(old_module, old_param): 'IGNORE'` +| Old Parameter | New Parameter | Transformation | +|---------------|---------------|----------------| +| `foreman::db_host` | `database_host` | - | +| `foreman::db_port` | `database_port` | - | +| `foreman::db_database` | `foreman_database_name` | - | +| `foreman::db_username` | `foreman_database_user` | - | +| `foreman::db_password` | `foreman_database_password` | - | +| `foreman::db_manage` | `database_mode` | true→"internal", false→"external" | +| `foreman::initial_admin_username` | `foreman_initial_admin_username` | - | +| `foreman::initial_admin_password` | `foreman_initial_admin_password` | - | +| `foreman::server_ssl_cert` | `server_certificate` | - | +| `foreman::server_ssl_key` | `server_key` | - | +| `foreman::server_ssl_ca` | `ca_certificate` | - | ## Example -### Input (Old Format) +### Input (foreman-installer format) ```yaml foreman: @@ -90,13 +89,12 @@ foreman: initial_admin_password: changeme ``` -### Output (New Format) +### Output (foremanctl format) ```yaml -ca_certificate: /etc/pki/tls/certs/ca.crt database_host: database.example.com -database_mode: internal database_port: 5432 +database_mode: internal foreman_database_name: foreman foreman_database_password: secret123 foreman_database_user: foreman_user @@ -104,25 +102,14 @@ foreman_initial_admin_password: changeme foreman_initial_admin_username: admin ``` -## Testing +## Handling Unmapped Parameters -Run the unit tests: +When the migration completes, you may see warnings like: -```bash -python -m pytest tests/unit/migrate_test.py -v ``` - -Test with a sample file: - -```bash -# Create test file -cat > /tmp/test-answers.yaml < Date: Fri, 10 Apr 2026 02:24:40 +0530 Subject: [PATCH 03/15] Fix default answer file path --- src/playbooks/migrate/migrate.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/playbooks/migrate/migrate.yaml b/src/playbooks/migrate/migrate.yaml index c3f64a66a..950f35200 100644 --- a/src/playbooks/migrate/migrate.yaml +++ b/src/playbooks/migrate/migrate.yaml @@ -6,7 +6,7 @@ gather_facts: false vars: root_dir: "{{ root | default('/') }}" - default_answer_file: "{{ root_dir }}/etc/foreman-installer/scenarios.d/satellite.yaml" + default_answer_file: "{{ root_dir }}/etc/foreman-installer/scenarios.d/last_scenario.yaml" tasks: - name: Run migration migrate_answers: From a00ae49cb51c89aa4bb6dbd88b888fff56e3abb0 Mon Sep 17 00:00:00 2001 From: akumari Date: Fri, 10 Apr 2026 02:25:24 +0530 Subject: [PATCH 04/15] Make migrate command generic for Foreman --- docs/migrate-command.md | 3 +++ src/playbooks/migrate/library/migrate_answers.py | 5 +++-- src/playbooks/migrate/metadata.obsah.yaml | 8 ++++---- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/migrate-command.md b/docs/migrate-command.md index 4f578f058..eaa3913b9 100644 --- a/docs/migrate-command.md +++ b/docs/migrate-command.md @@ -57,6 +57,9 @@ foremanctl migrate --root /backup --output /etc/foreman/config.yaml This will read from `/backup/etc/foreman-installer/scenarios.d/last_scenario.yaml`. +> [!NOTE] +> Unlike other `foremanctl` commands, migrate does not persist parameters between runs. Each migration is independent. + ## Parameter Mappings | Old Parameter | New Parameter | Transformation | diff --git a/src/playbooks/migrate/library/migrate_answers.py b/src/playbooks/migrate/library/migrate_answers.py index 1932678ee..5c79b2f32 100755 --- a/src/playbooks/migrate/library/migrate_answers.py +++ b/src/playbooks/migrate/library/migrate_answers.py @@ -1,4 +1,4 @@ -#!/usr/bin/python +#!/usr/bin/python3 import os import sys @@ -113,7 +113,8 @@ def apply_mappings(old_config): new_key = mapping new_value = old_value - result[new_key] = new_value + if new_value is not None: + result[new_key] = new_value else: if isinstance(old_key, tuple): param_name = '::'.join(old_key) diff --git a/src/playbooks/migrate/metadata.obsah.yaml b/src/playbooks/migrate/metadata.obsah.yaml index d3a14a786..b937f954e 100644 --- a/src/playbooks/migrate/metadata.obsah.yaml +++ b/src/playbooks/migrate/metadata.obsah.yaml @@ -2,14 +2,14 @@ help: | Migrate foreman-installer answer file to foremanctl configuration format. - This command reads the old Satellite 6.18 answer file and converts it to the - new configuration format for Satellite 6.19. Unmappable parameters are reported + This command reads foreman-installer answer files and converts them to the + new foremanctl configuration format. Unmappable parameters are reported as warnings but do not cause the command to fail. variables: answer_file: help: Path to the foreman-installer answer file to migrate. If not specified, attempts to read from the default location. + persist: false output: help: Path where the migrated configuration should be written. If not specified, outputs to stdout. - root: - help: Root directory for finding the answer file (useful for migration from backups). Defaults to /. + persist: false From 8fd740be8d5dcff75617550202ed6decd738fc09 Mon Sep 17 00:00:00 2001 From: akumari Date: Wed, 15 Apr 2026 11:12:19 +0530 Subject: [PATCH 05/15] Removed --root parameter --- docs/migrate-command.md | 8 -------- src/playbooks/migrate/migrate.yaml | 3 +-- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/docs/migrate-command.md b/docs/migrate-command.md index eaa3913b9..cad29473d 100644 --- a/docs/migrate-command.md +++ b/docs/migrate-command.md @@ -48,14 +48,6 @@ foremanctl migrate - `--answer-file PATH` - Path to the foreman-installer answer file (default: `/etc/foreman-installer/scenarios.d/last_scenario.yaml`) - `--output PATH` - Path for the migrated configuration (default: stdout) -- `--root PATH` - Root directory prefix for finding the answer file (default: `/`) - -The `--root` option is useful when migrating from a backup or mounted filesystem. For example, if you have a backup mounted at `/backup`, use: -```bash -foremanctl migrate --root /backup --output /etc/foreman/config.yaml -``` -This will read from `/backup/etc/foreman-installer/scenarios.d/last_scenario.yaml`. - > [!NOTE] > Unlike other `foremanctl` commands, migrate does not persist parameters between runs. Each migration is independent. diff --git a/src/playbooks/migrate/migrate.yaml b/src/playbooks/migrate/migrate.yaml index 950f35200..dc1c788c6 100644 --- a/src/playbooks/migrate/migrate.yaml +++ b/src/playbooks/migrate/migrate.yaml @@ -5,8 +5,7 @@ connection: local gather_facts: false vars: - root_dir: "{{ root | default('/') }}" - default_answer_file: "{{ root_dir }}/etc/foreman-installer/scenarios.d/last_scenario.yaml" + default_answer_file: "/etc/foreman-installer/scenarios.d/last_scenario.yaml" tasks: - name: Run migration migrate_answers: From 6bcd56b8ad51f4a79ee7ecc6a69fed4c867dca1a Mon Sep 17 00:00:00 2001 From: akumari Date: Wed, 15 Apr 2026 11:18:26 +0530 Subject: [PATCH 06/15] Read :answer_file fix --- docs/migrate-command.md | 2 +- src/playbooks/migrate/migrate.yaml | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/docs/migrate-command.md b/docs/migrate-command.md index cad29473d..fc1966df0 100644 --- a/docs/migrate-command.md +++ b/docs/migrate-command.md @@ -46,7 +46,7 @@ foremanctl migrate ## Command Options -- `--answer-file PATH` - Path to the foreman-installer answer file (default: `/etc/foreman-installer/scenarios.d/last_scenario.yaml`) +- `--answer-file PATH` - Path to the foreman-installer answer file. If not specified, reads the currently active scenario and extracts the answer file path from it. - `--output PATH` - Path for the migrated configuration (default: stdout) > [!NOTE] diff --git a/src/playbooks/migrate/migrate.yaml b/src/playbooks/migrate/migrate.yaml index dc1c788c6..a6458edf5 100644 --- a/src/playbooks/migrate/migrate.yaml +++ b/src/playbooks/migrate/migrate.yaml @@ -5,11 +5,27 @@ connection: local gather_facts: false vars: - default_answer_file: "/etc/foreman-installer/scenarios.d/last_scenario.yaml" + default_scenario_file: "/etc/foreman-installer/scenarios.d/last_scenario.yaml" tasks: + - name: Determine answer file to migrate + when: answer_file is not defined + block: + - name: Read scenario file + ansible.builtin.slurp: + src: "{{ default_scenario_file }}" + register: scenario_content + + - name: Parse scenario file + ansible.builtin.set_fact: + scenario_data: "{{ scenario_content['content'] | b64decode | from_yaml }}" + + - name: Extract answer file path from scenario + ansible.builtin.set_fact: + resolved_answer_file: "{{ scenario_data[':answer_file'] }}" + - name: Run migration migrate_answers: - answer_file: "{{ answer_file | default(default_answer_file) }}" + answer_file: "{{ answer_file | default(resolved_answer_file) }}" output: "{{ output | default(omit) }}" register: migration_result From 262c1ed0fcea5e55b6b39d06c69f88967d7f7121 Mon Sep 17 00:00:00 2001 From: akumari Date: Wed, 15 Apr 2026 15:01:45 +0530 Subject: [PATCH 07/15] fix error handling and update docs --- docs/migrate-command.md | 22 ++++++++++++ .../migrate/library/migrate_answers.py | 35 ++++++++----------- src/playbooks/migrate/migrate.yaml | 18 +++++++--- 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/docs/migrate-command.md b/docs/migrate-command.md index fc1966df0..f96427efa 100644 --- a/docs/migrate-command.md +++ b/docs/migrate-command.md @@ -108,3 +108,25 @@ Warning: The following parameters could not be mapped: ``` These parameters need to be manually reviewed and added to the new configuration if needed. Check the [parameters documentation](parameters.md) for equivalent foremanctl parameters. + +## Using the Migrated Configuration + +Once you've generated and reviewed the migrated configuration: + +1. **Save it to the foremanctl parameters file**: + ```bash + # Either generate directly to the parameters file + foremanctl migrate --output /var/lib/foremanctl/parameters.yaml + + # Or copy after review + foremanctl migrate --output /tmp/migrated.yaml + # Review /tmp/migrated.yaml + cp /tmp/migrated.yaml /var/lib/foremanctl/parameters.yaml + ``` + +2. **Deploy using foremanctl**: + ```bash + foremanctl deploy + ``` + + The `foremanctl deploy` command automatically loads configuration from `/var/lib/foremanctl/parameters.yaml`. diff --git a/src/playbooks/migrate/library/migrate_answers.py b/src/playbooks/migrate/library/migrate_answers.py index 5c79b2f32..7f537f625 100755 --- a/src/playbooks/migrate/library/migrate_answers.py +++ b/src/playbooks/migrate/library/migrate_answers.py @@ -13,13 +13,6 @@ def cast_database_mode(value): return value -def cast_certificate_source(value): - """Map certificate management boolean to certificate source.""" - if isinstance(value, bool): - return 'default' if value else 'installer' - return value - - PARAMETER_MAP = { # Database configuration ('foreman', 'db_host'): 'database_host', @@ -47,12 +40,6 @@ def cast_certificate_source(value): def load_answer_file(file_path): """Load and parse YAML answer file.""" - if not os.path.exists(file_path): - raise FileNotFoundError(f"Answer file not found: {file_path}") - - if not os.access(file_path, os.R_OK): - raise PermissionError(f"Cannot read answer file: {file_path}") - with open(file_path, 'r') as f: try: data = yaml.safe_load(f) @@ -129,14 +116,15 @@ def apply_mappings(old_config): def write_output(data, output_path=None): - """Write migrated configuration to file or stdout.""" + """Write migrated configuration to file or return as string.""" yaml_content = yaml.dump(data, default_flow_style=False, sort_keys=True) if output_path: with open(output_path, 'w') as f: f.write(yaml_content) + return None else: - print(yaml_content) + return yaml_content def run_module(): @@ -150,6 +138,7 @@ def run_module(): mapped_count=0, unmappable_count=0, unmappable=[], + output_content='', ) module = AnsibleModule( @@ -166,21 +155,25 @@ def run_module(): result['unmappable_count'] = len(migration_result['unmappable']) result['unmappable'] = migration_result['unmappable'] + # Issue warnings for unmappable parameters + if migration_result['unmappable']: + for param in migration_result['unmappable']: + module.warn(f"Parameter '{param}' could not be mapped and will need manual review") + if not module.check_mode: output_path = module.params.get('output') - write_output(migration_result['mapped'], output_path) + yaml_content = write_output(migration_result['mapped'], output_path) if output_path: result['output_file'] = output_path result['changed'] = True # File was written + else: + # Output to stdout - store in result so Ansible displays it + result['output_content'] = yaml_content module.exit_json(**result) - except FileNotFoundError as e: - module.fail_json(msg=str(e), **result) - except PermissionError as e: - module.fail_json(msg=str(e), **result) - except ValueError as e: + except (FileNotFoundError, PermissionError, ValueError) as e: module.fail_json(msg=str(e), **result) except Exception as e: module.fail_json(msg=f"Unexpected error: {e}", **result) diff --git a/src/playbooks/migrate/migrate.yaml b/src/playbooks/migrate/migrate.yaml index a6458edf5..e257ec3b9 100644 --- a/src/playbooks/migrate/migrate.yaml +++ b/src/playbooks/migrate/migrate.yaml @@ -21,7 +21,12 @@ - name: Extract answer file path from scenario ansible.builtin.set_fact: - resolved_answer_file: "{{ scenario_data[':answer_file'] }}" + resolved_answer_file: "{{ scenario_data[':answer_file'] | default('') }}" + + - name: Fail if no answer file found in scenario + ansible.builtin.fail: + msg: "Scenario file does not contain :answer_file: key" + when: resolved_answer_file == '' - name: Run migration migrate_answers: @@ -29,6 +34,11 @@ output: "{{ output | default(omit) }}" register: migration_result + - name: Display migrated configuration to stdout + when: migration_result.output_content | default('') != '' + ansible.builtin.debug: + msg: "{{ migration_result.output_content }}" + - name: Display migration results ansible.builtin.debug: msg: | @@ -46,8 +56,6 @@ These will need to be added to the mapping table. {% endif %} - {% if output is defined %} - Output written to: {{ output }} - {% else %} - Output written to stdout + {% if migration_result.output_file is defined %} + Output written to: {{ migration_result.output_file }} {% endif %} From 523b8ed4d2128c2d24b74471a5f03a5fcfe7d03f Mon Sep 17 00:00:00 2001 From: akumari Date: Thu, 16 Apr 2026 13:23:53 +0530 Subject: [PATCH 08/15] Centralize file reading logic in migrate_answers.py --- .../migrate/library/migrate_answers.py | 25 +++++++++++++++++-- src/playbooks/migrate/migrate.yaml | 25 +------------------ 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/src/playbooks/migrate/library/migrate_answers.py b/src/playbooks/migrate/library/migrate_answers.py index 7f537f625..4c7750801 100755 --- a/src/playbooks/migrate/library/migrate_answers.py +++ b/src/playbooks/migrate/library/migrate_answers.py @@ -38,6 +38,23 @@ def cast_database_mode(value): IGNORE_PARAMS = {'IGNORE'} +def resolve_answer_file_from_scenario(scenario_file='/etc/foreman-installer/scenarios.d/last_scenario.yaml'): + """Read scenario file and extract answer file path from :answer_file: key.""" + with open(scenario_file, 'r') as f: + try: + scenario_data = yaml.safe_load(f) + if scenario_data is None: + raise ValueError(f"Scenario file {scenario_file} is empty") + + answer_file = scenario_data.get(':answer_file') + if not answer_file: + raise ValueError(f"Scenario file does not contain :answer_file: key") + + return answer_file + except yaml.YAMLError as e: + raise ValueError(f"Invalid YAML in scenario file: {e}") + + def load_answer_file(file_path): """Load and parse YAML answer file.""" with open(file_path, 'r') as f: @@ -129,7 +146,7 @@ def write_output(data, output_path=None): def run_module(): module_args = dict( - answer_file=dict(type='str', required=True), + answer_file=dict(type='str', required=False, default=None), output=dict(type='str', required=False, default=None), ) @@ -147,7 +164,11 @@ def run_module(): ) try: - old_config = load_answer_file(module.params['answer_file']) + answer_file = module.params.get('answer_file') + if not answer_file: + answer_file = resolve_answer_file_from_scenario() + + old_config = load_answer_file(answer_file) migration_result = apply_mappings(old_config) diff --git a/src/playbooks/migrate/migrate.yaml b/src/playbooks/migrate/migrate.yaml index e257ec3b9..ce89b898d 100644 --- a/src/playbooks/migrate/migrate.yaml +++ b/src/playbooks/migrate/migrate.yaml @@ -4,33 +4,10 @@ - localhost connection: local gather_facts: false - vars: - default_scenario_file: "/etc/foreman-installer/scenarios.d/last_scenario.yaml" tasks: - - name: Determine answer file to migrate - when: answer_file is not defined - block: - - name: Read scenario file - ansible.builtin.slurp: - src: "{{ default_scenario_file }}" - register: scenario_content - - - name: Parse scenario file - ansible.builtin.set_fact: - scenario_data: "{{ scenario_content['content'] | b64decode | from_yaml }}" - - - name: Extract answer file path from scenario - ansible.builtin.set_fact: - resolved_answer_file: "{{ scenario_data[':answer_file'] | default('') }}" - - - name: Fail if no answer file found in scenario - ansible.builtin.fail: - msg: "Scenario file does not contain :answer_file: key" - when: resolved_answer_file == '' - - name: Run migration migrate_answers: - answer_file: "{{ answer_file | default(resolved_answer_file) }}" + answer_file: "{{ answer_file | default(omit) }}" output: "{{ output | default(omit) }}" register: migration_result From 9b1867f2dec5d405409b3f16ec793061a73827ff Mon Sep 17 00:00:00 2001 From: akumari Date: Thu, 16 Apr 2026 14:54:35 +0530 Subject: [PATCH 09/15] Fix migrate command to write output files to current dir --- .../migrate/library/migrate_answers.py | 19 +++++++++---- src/playbooks/migrate/migrate.yaml | 28 +++++++------------ 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/playbooks/migrate/library/migrate_answers.py b/src/playbooks/migrate/library/migrate_answers.py index 4c7750801..d017ce1ea 100755 --- a/src/playbooks/migrate/library/migrate_answers.py +++ b/src/playbooks/migrate/library/migrate_answers.py @@ -132,14 +132,18 @@ def apply_mappings(old_config): } -def write_output(data, output_path=None): +def write_output(data, output_path=None, working_directory=None): """Write migrated configuration to file or return as string.""" yaml_content = yaml.dump(data, default_flow_style=False, sort_keys=True) if output_path: - with open(output_path, 'w') as f: + if working_directory and not os.path.isabs(output_path): + absolute_path = os.path.join(working_directory, output_path) + else: + absolute_path = os.path.abspath(output_path) + with open(absolute_path, 'w') as f: f.write(yaml_content) - return None + return absolute_path else: return yaml_content @@ -148,6 +152,7 @@ def run_module(): module_args = dict( answer_file=dict(type='str', required=False, default=None), output=dict(type='str', required=False, default=None), + working_directory=dict(type='str', required=False, default=None), ) result = dict( @@ -183,13 +188,15 @@ def run_module(): if not module.check_mode: output_path = module.params.get('output') - yaml_content = write_output(migration_result['mapped'], output_path) + working_directory = module.params.get('working_directory') if output_path: - result['output_file'] = output_path - result['changed'] = True # File was written + absolute_path = write_output(migration_result['mapped'], output_path, working_directory) + result['output_file'] = absolute_path + result['changed'] = True else: # Output to stdout - store in result so Ansible displays it + yaml_content = write_output(migration_result['mapped'], output_path, working_directory) result['output_content'] = yaml_content module.exit_json(**result) diff --git a/src/playbooks/migrate/migrate.yaml b/src/playbooks/migrate/migrate.yaml index ce89b898d..7e1234970 100644 --- a/src/playbooks/migrate/migrate.yaml +++ b/src/playbooks/migrate/migrate.yaml @@ -9,6 +9,7 @@ migrate_answers: answer_file: "{{ answer_file | default(omit) }}" output: "{{ output | default(omit) }}" + working_directory: "{{ lookup('env', 'PWD') }}" register: migration_result - name: Display migrated configuration to stdout @@ -18,21 +19,12 @@ - name: Display migration results ansible.builtin.debug: - msg: | - Migration completed successfully! - - Mapped parameters: {{ migration_result.mapped_count }} - Unmappable parameters: {{ migration_result.unmappable_count }} - - {% if migration_result.unmappable | length > 0 %} - Warning: The following parameters could not be mapped: - {% for param in migration_result.unmappable %} - - {{ param }} - {% endfor %} - - These will need to be added to the mapping table. - {% endif %} - - {% if migration_result.output_file is defined %} - Output written to: {{ migration_result.output_file }} - {% endif %} + msg: + - "Migration completed successfully!" + - "Mapped parameters: {{ migration_result.mapped_count }}" + - "Unmappable parameters: {{ migration_result.unmappable_count }}" + - "{{ _unmappable_warning if migration_result.unmappable | length > 0 else '' }}" + - "{{ _output_file_msg if migration_result.output_file is defined else '' }}" + vars: + _unmappable_warning: "Warning: {{ migration_result.unmappable | length }} parameter(s) could not be mapped - see warnings above" + _output_file_msg: "Output written to: {{ migration_result.output_file }}" From 630a4186512fe23055be2734ac82ba3fb6c11c49 Mon Sep 17 00:00:00 2001 From: akumari Date: Thu, 16 Apr 2026 15:23:21 +0530 Subject: [PATCH 10/15] Add validation for answer file structure --- .../migrate/library/migrate_answers.py | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/playbooks/migrate/library/migrate_answers.py b/src/playbooks/migrate/library/migrate_answers.py index d017ce1ea..173d5efa6 100755 --- a/src/playbooks/migrate/library/migrate_answers.py +++ b/src/playbooks/migrate/library/migrate_answers.py @@ -61,12 +61,37 @@ def load_answer_file(file_path): try: data = yaml.safe_load(f) if data is None: - return {} + raise ValueError(f"Answer file {file_path} is empty") return data except yaml.YAMLError as e: raise ValueError(f"Invalid YAML in answer file: {e}") +def validate_answer_file(data, file_path): + """ + Validate that the loaded YAML has the expected structure of a foreman-installer answer file. + + Expected structure: Top-level keys should be module names (strings) with nested dictionaries. + Example: {'foreman': {'db_host': 'localhost'}, 'katello': {...}} + """ + if not isinstance(data, dict): + raise ValueError( + f"Answer file {file_path} has invalid structure. " + f"Expected a dictionary but got {type(data).__name__}" + ) + + if len(data) == 0: + raise ValueError(f"Answer file {file_path} is empty (contains no configuration)") + + dict_values = sum(1 for v in data.values() if isinstance(v, dict)) + + if dict_values == 0: + raise ValueError( + f"Answer file {file_path} does not appear to be a valid foreman-installer answer file. " + f"Expected nested module configurations (e.g., 'foreman:', 'katello:') but found flat structure" + ) + + def flatten_nested_dict(nested_dict, parent_key=''): """ Flatten nested dictionary structure from foreman-installer format. @@ -174,6 +199,7 @@ def run_module(): answer_file = resolve_answer_file_from_scenario() old_config = load_answer_file(answer_file) + validate_answer_file(old_config, answer_file) migration_result = apply_mappings(old_config) From 5917b6bc4a30c9261f9ded76a82c57b249561e27 Mon Sep 17 00:00:00 2001 From: akumari Date: Thu, 16 Apr 2026 15:58:27 +0530 Subject: [PATCH 11/15] remove duplicate parameter mappings from migration guide --- docs/{migrate-command.md => migration-guide.md} | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) rename docs/{migrate-command.md => migration-guide.md} (80%) diff --git a/docs/migrate-command.md b/docs/migration-guide.md similarity index 80% rename from docs/migrate-command.md rename to docs/migration-guide.md index f96427efa..bee007ce0 100644 --- a/docs/migrate-command.md +++ b/docs/migration-guide.md @@ -54,22 +54,12 @@ foremanctl migrate ## Parameter Mappings -| Old Parameter | New Parameter | Transformation | -|---------------|---------------|----------------| -| `foreman::db_host` | `database_host` | - | -| `foreman::db_port` | `database_port` | - | -| `foreman::db_database` | `foreman_database_name` | - | -| `foreman::db_username` | `foreman_database_user` | - | -| `foreman::db_password` | `foreman_database_password` | - | -| `foreman::db_manage` | `database_mode` | true→"internal", false→"external" | -| `foreman::initial_admin_username` | `foreman_initial_admin_username` | - | -| `foreman::initial_admin_password` | `foreman_initial_admin_password` | - | -| `foreman::server_ssl_cert` | `server_certificate` | - | -| `foreman::server_ssl_key` | `server_key` | - | -| `foreman::server_ssl_ca` | `ca_certificate` | - | +The migrate command automatically maps foreman-installer parameters to foremanctl parameters. For a complete list of all parameter mappings, see the [Parameters documentation](parameters.md#mapping). ## Example +Below is an example showing how the transformation works: + ### Input (foreman-installer format) ```yaml From 3201abf043c116f553144d31cac29ef22fb6b2fc Mon Sep 17 00:00:00 2001 From: akumari Date: Fri, 17 Apr 2026 15:44:26 +0530 Subject: [PATCH 12/15] update doc with prerequisites and path fixes --- docs/migration-guide.md | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/docs/migration-guide.md b/docs/migration-guide.md index bee007ce0..acccd3dcb 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -6,11 +6,28 @@ When upgrading from foreman-installer to foremanctl, the `foremanctl migrate` co This guide explains how to migrate your foreman-installer answer files to foremanctl configuration files. +## Prerequisites + +Before migrating, ensure the following: + +1. **Foreman deployment using foreman-installer** - You should have an existing Foreman deployment has been installed using foreman-installer and has an answers file to migrate from. + +2. **foremanctl is installed** on the system: + ```bash + # Enable the foremanctl repository + dnf copr enable @theforeman/foremanctl rhel-9-x86_64 + + # Install foremanctl + dnf install foremanctl + ``` + + For more installation options, see the main [README](../README.md#packages). + ## Migration Workflow 1. **Generate the migrated configuration**: ```bash - foremanctl migrate --output /etc/foreman/config.yaml + foremanctl migrate --output /var/lib/foremanctl/parameters.yaml ``` 2. **Review the output** for any warnings about unmapped parameters @@ -19,7 +36,7 @@ This guide explains how to migrate your foreman-installer answer files to forema ```bash foremanctl deploy ``` - (foremanctl automatically loads configuration from `/etc/foreman/config.yaml`) + (foremanctl automatically loads configuration from `/var/lib/foremanctl/parameters.yaml`) ## Command Usage @@ -27,14 +44,14 @@ This guide explains how to migrate your foreman-installer answer files to forema Migrate from the default location (reads the currently active scenario): ```bash -foremanctl migrate --output /etc/foreman/config.yaml +foremanctl migrate --output /var/lib/foremanctl/parameters.yaml ``` ### Custom Answer File Migrate from a specific answer file: ```bash -foremanctl migrate --answer-file /path/to/custom-answers.yaml --output /etc/foreman/config.yaml +foremanctl migrate --answer-file /path/to/custom-answers.yaml --output /var/lib/foremanctl/parameters.yaml ``` ### Output to stdout @@ -79,7 +96,7 @@ foreman: ```yaml database_host: database.example.com database_port: 5432 -database_mode: internal +database_mode: external foreman_database_name: foreman foreman_database_password: secret123 foreman_database_user: foreman_user @@ -91,11 +108,10 @@ foreman_initial_admin_username: admin When the migration completes, you may see warnings like: -``` -Warning: The following parameters could not be mapped: - - katello::enable_ostree - - foreman::some_other_param -``` +> [!WARNING] +> The following parameters could not be mapped: +> - katello::enable_ostree +> - foreman::some_other_param These parameters need to be manually reviewed and added to the new configuration if needed. Check the [parameters documentation](parameters.md) for equivalent foremanctl parameters. From da48e99b5df41ac091ff4b94fd8e79760adbb3be Mon Sep 17 00:00:00 2001 From: akumari Date: Fri, 17 Apr 2026 18:43:16 +0530 Subject: [PATCH 13/15] Updated test to expect ValueError when loading empty YAML --- tests/unit/migrate_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/migrate_test.py b/tests/unit/migrate_test.py index 848945918..1b5324a48 100644 --- a/tests/unit/migrate_test.py +++ b/tests/unit/migrate_test.py @@ -121,14 +121,14 @@ def test_load_valid_yaml(self): os.unlink(temp_file) def test_load_empty_yaml(self): - """Test loading an empty YAML file""" + """Test loading an empty YAML file raises ValueError""" with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: f.write('---\n') temp_file = f.name try: - result = migrate_answers.load_answer_file(temp_file) - assert result == {} + with pytest.raises(ValueError, match="is empty"): + migrate_answers.load_answer_file(temp_file) finally: os.unlink(temp_file) From 18ad6d1114dff0b988c432fd25e15fdb33d8dd6d Mon Sep 17 00:00:00 2001 From: akumari Date: Wed, 22 Apr 2026 12:38:55 +0530 Subject: [PATCH 14/15] move migrate_answers module to standard plugins location --- src/ansible.cfg | 1 + .../migrate/library => plugins/modules}/migrate_answers.py | 0 tests/unit/migrate_test.py | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) rename src/{playbooks/migrate/library => plugins/modules}/migrate_answers.py (100%) diff --git a/src/ansible.cfg b/src/ansible.cfg index 2b4e69983..0af18bcd8 100644 --- a/src/ansible.cfg +++ b/src/ansible.cfg @@ -1,6 +1,7 @@ [defaults] host_key_checking = False roles_path = ./roles +library = ./plugins/modules filter_plugins = ./filter_plugins callback_plugins = ./callback_plugins callback_result_format = yaml diff --git a/src/playbooks/migrate/library/migrate_answers.py b/src/plugins/modules/migrate_answers.py similarity index 100% rename from src/playbooks/migrate/library/migrate_answers.py rename to src/plugins/modules/migrate_answers.py diff --git a/tests/unit/migrate_test.py b/tests/unit/migrate_test.py index 1b5324a48..0d6a84001 100644 --- a/tests/unit/migrate_test.py +++ b/tests/unit/migrate_test.py @@ -4,7 +4,7 @@ import tempfile import yaml -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src/playbooks/migrate/library')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../../src/plugins/modules')) import migrate_answers From 0b538d16102618b78fa667af05694b89ad126c9d Mon Sep 17 00:00:00 2001 From: akumari Date: Wed, 22 Apr 2026 12:43:25 +0530 Subject: [PATCH 15/15] Skip None values from unmappable parameter list --- src/playbooks/migrate/migrate.yaml | 3 +-- src/plugins/modules/migrate_answers.py | 11 ++++++----- tests/unit/migrate_test.py | 17 +++++++++++++++++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/playbooks/migrate/migrate.yaml b/src/playbooks/migrate/migrate.yaml index 7e1234970..6fa2ec577 100644 --- a/src/playbooks/migrate/migrate.yaml +++ b/src/playbooks/migrate/migrate.yaml @@ -1,8 +1,7 @@ --- - name: Migrate foreman-installer answer file to foremanctl format hosts: - - localhost - connection: local + - quadlet gather_facts: false tasks: - name: Run migration diff --git a/src/plugins/modules/migrate_answers.py b/src/plugins/modules/migrate_answers.py index 173d5efa6..6eee062e8 100755 --- a/src/plugins/modules/migrate_answers.py +++ b/src/plugins/modules/migrate_answers.py @@ -145,11 +145,12 @@ def apply_mappings(old_config): if new_value is not None: result[new_key] = new_value else: - if isinstance(old_key, tuple): - param_name = '::'.join(old_key) - else: - param_name = str(old_key) - unmappable.append(param_name) + if old_value is not None: + if isinstance(old_key, tuple): + param_name = '::'.join(old_key) + else: + param_name = str(old_key) + unmappable.append(param_name) return { 'mapped': result, diff --git a/tests/unit/migrate_test.py b/tests/unit/migrate_test.py index 0d6a84001..cf8a3a5f4 100644 --- a/tests/unit/migrate_test.py +++ b/tests/unit/migrate_test.py @@ -104,6 +104,23 @@ def test_mixed_config(self): assert 'foreman::unknown_param' in result['unmappable'] assert len(result['unmappable']) == 1 + def test_skip_none_values_in_unmappable(self): + """Test that None values are not added to unmappable list""" + old_config = { + 'foreman': { + 'db_host': 'localhost', + 'unknown_param': None, + 'another_unknown': 'value' + } + } + + result = migrate_answers.apply_mappings(old_config) + + assert result['mapped']['database_host'] == 'localhost' + assert 'foreman::unknown_param' not in result['unmappable'] + assert 'foreman::another_unknown' in result['unmappable'] + assert len(result['unmappable']) == 1 + class TestFileOperations: """Test file loading and writing"""