diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 9066eaa4f5..60cc3a68d9 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -742,6 +742,8 @@ files: maintainers: resmo $modules/ini_file.py: maintainers: jpmens noseka1 + $modules/toml_file.py: + maintainers: jdrowne $modules/installp.py: keywords: aix efix lpar wpar labels: aix installp diff --git a/plugins/modules/toml_file.py b/plugins/modules/toml_file.py new file mode 100644 index 0000000000..939972e77b --- /dev/null +++ b/plugins/modules/toml_file.py @@ -0,0 +1,973 @@ +#!/usr/bin/python + +# Copyright (c) 2026, Jose Drowne +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +DOCUMENTATION = r""" +module: toml_file +short_description: Manage individual settings in TOML files +version_added: 12.4.0 +extends_documentation_fragment: + - ansible.builtin.files + - community.general.attributes +description: + - Manage (add, remove, change) individual settings in a TOML file without having to manage the file as a whole + with, say, M(ansible.builtin.template) or M(ansible.builtin.assemble). + - Adds missing tables if they do not exist. + - Comments and formatting are preserved using the C(tomlkit) library. +attributes: + check_mode: + support: full + diff_mode: + support: full +options: + path: + description: + - Path to the TOML file; this file is created if required and O(create=true). + type: path + required: true + aliases: [dest] + table: + description: + - Table name in the TOML file. + - Use dotted notation for nested tables (for example, V(server.database)). + - For array of tables (C([[table]])), use bracket notation to specify the index + (for example, V(products[0]) for the first entry, V(products[append]) to append a new entry). + - Nested arrays of tables are supported (for example, V(servers[1].databases[0])). + - If omitted or empty string, the key is set at the document root level. + type: str + key: + description: + - The key to manage within the table (or document root if O(table) is not specified). + - Use dotted notation to address nested keys (for example, V(connection.timeout)). + - May be omitted when removing an entire table with O(state=absent). + type: str + value: + description: + - The value to set for the key. + - JSON types are automatically converted to appropriate TOML types. + - Use O(value_type) to force a specific type. + - Required when O(state=present) and O(key) is specified. + type: raw + value_type: + description: + - Force the value to be a specific TOML type. + type: str + choices: + auto: Automatically detects the type from the input value. + string: Forces the value to be a TOML string. + literal_string: Creates a TOML literal string (single quotes, no escape processing). + multiline_string: Creates a TOML multi-line basic string (triple double quotes). + multiline_literal_string: Creates a TOML multi-line literal string (triple single quotes). + integer: Forces the value to be a TOML integer. + hex_integer: Forces the value to be a TOML hexadecimal integer (for example, V(0xDEADBEEF)). + octal_integer: Forces the value to be a TOML octal integer (for example, V(0o755)). + binary_integer: Forces the value to be a TOML binary integer (for example, V(0b11010110)). + float: Forces the value to be a TOML float. Supports V(inf), V(-inf), and V(nan). + boolean: Forces the value to be a TOML boolean. + datetime: Parses the value as an ISO 8601 offset or local datetime. + date: Parses the value as a local date (for example, V(1979-05-27)). + time: Parses the value as a local time (for example, V(07:32:00)). + array: Ensures the value is a TOML array. + inline_table: Ensures the value is a TOML inline table. + default: auto + state: + description: + - If set to V(absent), the specified key (or entire table if no key is specified) is removed. + - If set to V(present), the specified key is added or updated. + type: str + choices: [absent, present] + default: present + backup: + description: + - Create a backup file including the timestamp information so you can get the original file back + if you somehow clobbered it incorrectly. + type: bool + default: false + create: + description: + - If set to V(false), the module fails if the file does not already exist. + - By default it creates the file if it is missing. + type: bool + default: true + follow: + description: + - This flag indicates that filesystem links, if they exist, should be followed. + - O(follow=true) can modify O(path) when combined with parameters such as O(mode). + type: bool + default: false +requirements: + - tomlkit +notes: + - The C(tomlkit) library preserves comments and formatting in TOML files. + - When O(state=present) and O(key) is specified, O(value) must also be provided. +seealso: + - module: community.general.ini_file +author: + - Jose Drowne (@jdrowne) +""" + +EXAMPLES = r""" +- name: Set server host in TOML file + community.general.toml_file: + path: /etc/myapp/config.toml + table: server + key: host + value: localhost + +- name: Set nested configuration value + community.general.toml_file: + path: /etc/myapp/config.toml + table: server.database + key: port + value: 5432 + value_type: integer + +- name: Set a boolean value + community.general.toml_file: + path: /etc/myapp/config.toml + table: features + key: debug + value: true + value_type: boolean + +- name: Set an array value + community.general.toml_file: + path: /etc/myapp/config.toml + table: server + key: allowed_hosts + value: + - localhost + - 127.0.0.1 + - "::1" + +- name: Set a value at document root level + community.general.toml_file: + path: /etc/myapp/config.toml + key: title + value: My Application + +- name: Remove a key from a table + community.general.toml_file: + path: /etc/myapp/config.toml + table: server + key: deprecated_option + state: absent + +- name: Remove an entire table + community.general.toml_file: + path: /etc/myapp/config.toml + table: obsolete_section + state: absent + +- name: Add entry to array of tables + community.general.toml_file: + path: /etc/myapp/config.toml + table: products[append] + key: name + value: Hammer + +- name: Modify first entry in array of tables + community.general.toml_file: + path: /etc/myapp/config.toml + table: products[0] + key: price + value: 9.99 + +- name: Modify nested array of tables + community.general.toml_file: + path: /etc/myapp/config.toml + table: servers[0].databases[1] + key: name + value: secondary_db + +- name: Set an inline table + community.general.toml_file: + path: /etc/myapp/config.toml + table: database + key: connection + value: + host: localhost + port: 5432 + value_type: inline_table + +- name: Ensure config exists with backup + community.general.toml_file: + path: /etc/myapp/config.toml + table: server + key: port + value: 8080 + backup: true + +- name: Set infinity value + community.general.toml_file: + path: /etc/myapp/config.toml + table: limits + key: max_value + value: inf + value_type: float + +- name: Set a date value + community.general.toml_file: + path: /etc/myapp/config.toml + table: project + key: start_date + value: "2026-01-17" + value_type: date + +- name: Set a time value + community.general.toml_file: + path: /etc/myapp/config.toml + table: schedule + key: daily_backup + value: "03:00:00" + value_type: time + +- name: Set a literal string (no escape processing) + community.general.toml_file: + path: /etc/myapp/config.toml + table: paths + key: regex + value: '\\d+\\.\\d+' + value_type: literal_string + +- name: Set a multiline string + community.general.toml_file: + path: /etc/myapp/config.toml + table: messages + key: welcome + value: | + Welcome to our application! + We hope you enjoy using it. + value_type: multiline_string + +- name: Set a hexadecimal integer + community.general.toml_file: + path: /etc/myapp/config.toml + table: display + key: color + value: 16777215 + value_type: hex_integer + +- name: Set an octal integer (file permissions) + community.general.toml_file: + path: /etc/myapp/config.toml + table: files + key: mode + value: 493 + value_type: octal_integer + +- name: Set a binary integer + community.general.toml_file: + path: /etc/myapp/config.toml + table: flags + key: bits + value: 170 + value_type: binary_integer +""" + +RETURN = r""" +path: + description: The path to the TOML file. + returned: success + type: str + sample: /etc/myapp/config.toml +backup_file: + description: The name of the backup file that was created. + returned: when O(backup=true) and file was modified + type: str + sample: /etc/myapp/config.toml.2026-01-17@12:34:56~ +""" + +import os +import re +import tempfile +import traceback +import typing as t +from dataclasses import dataclass +from datetime import datetime + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.text.converters import to_bytes + +from ansible_collections.community.general.plugins.module_utils import deps + +with deps.declare("tomlkit"): + import tomlkit + from tomlkit.items import AoT, Array, InlineTable, Integer, Table, Trivia + + +class TomlFileError(Exception): + pass + + +if t.TYPE_CHECKING: + # Type alias for array-of-tables index (int for position, "append" for new entry, None for default) + IndexType = t.Union[int, t.Literal["append"], None] # noqa: UP007 + + +@dataclass +class TomlParams: + """Parameters for TOML file operations.""" + + path: str + table: str | None + key: str | None + value: t.Any + value_type: t.Literal[ + "auto", + "string", + "literal_string", + "multiline_string", + "multiline_literal_string", + "integer", + "hex_integer", + "octal_integer", + "binary_integer", + "float", + "boolean", + "datetime", + "date", + "time", + "array", + "inline_table", + ] + state: t.Literal["absent", "present"] + backup: bool + create: bool + follow: bool + + +@dataclass +class NavContext: + """Context for table navigation operations.""" + + parsed: list[tuple[str, IndexType]] + create: bool + + +# ============================================================================= +# Path Parsing Functions +# ============================================================================= + + +def parse_table_path(table_path: str | None) -> list[tuple[str, IndexType]]: + """Parse a table path with optional array indices.""" + if table_path is None or table_path == "": + return [] # Empty list means document root + + result = [] + segment_pattern = re.compile(r"^([^\[\]]+)(?:\[(-?\d+|append)\])?$") + + for segment in table_path.split("."): + match = segment_pattern.match(segment) + if not match: + raise TomlFileError(f"Invalid table path segment: '{segment}'") + name = match.group(1) + index_str = match.group(2) + index: IndexType + if index_str is None: + index = None + elif index_str == "append": + index = "append" + else: + index = int(index_str) + result.append((name, index)) + + return result + + +def _format_path(parsed: list[tuple[str, IndexType]], up_to: int | None = None) -> str: + """Format a parsed path back to string for error messages.""" + if up_to is None: + up_to = len(parsed) + return ".".join(name + (f"[{idx}]" if idx is not None else "") for name, idx in parsed[:up_to]) + + +# ============================================================================= +# Value Conversion Functions (dispatch pattern for O(1) lookup) +# ============================================================================= + + +def _convert_auto(value: t.Any) -> t.Any: + """Auto-detect and convert value type.""" + if isinstance(value, (bool, int, float)): + return value + if isinstance(value, str): + return _try_parse_datetime_string(value) + if isinstance(value, list): + return _convert_list_to_array(value) + if isinstance(value, dict): + return _convert_dict_to_inline_table(value) + return str(value) + + +def _try_parse_datetime_string(value: str) -> str | datetime: + """Try parsing string as datetime, return as-is if not.""" + if "T" in value or " " in value: + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except (ValueError, AttributeError): + pass + return value + + +def _convert_list_to_array(value: list[t.Any]) -> Array: + """Convert Python list to TOML array.""" + arr = tomlkit.array() + for item in value: + arr.append(convert_value(item, "auto")) + return arr + + +def _convert_dict_to_inline_table(value: dict[str, t.Any]) -> InlineTable: + """Convert Python dict to TOML inline table.""" + tbl = tomlkit.inline_table() + for k, v in value.items(): + tbl[k] = convert_value(v, "auto") + return tbl + + +def _convert_string(value: t.Any) -> str: + return str(value) + + +def _convert_literal_string(value: t.Any) -> str: + return tomlkit.string(str(value), literal=True) + + +def _convert_multiline_string(value: t.Any) -> str: + return tomlkit.string(str(value), multiline=True) + + +def _convert_multiline_literal_string(value: t.Any) -> str: + return tomlkit.string(str(value), literal=True, multiline=True) + + +def _convert_integer(value: t.Any) -> int: + return int(value) + + +def _convert_hex_integer(value: t.Any) -> Integer: + int_val = int(value) if isinstance(value, int) else int(str(value), 16) + return Integer(int_val, Trivia(), hex(int_val)) + + +def _convert_octal_integer(value: t.Any) -> Integer: + int_val = int(value) if isinstance(value, int) else int(str(value), 8) + return Integer(int_val, Trivia(), oct(int_val)) + + +def _convert_binary_integer(value: t.Any) -> Integer: + int_val = int(value) if isinstance(value, int) else int(str(value), 2) + return Integer(int_val, Trivia(), bin(int_val)) + + +def _convert_float(value: t.Any) -> float: + if isinstance(value, str): + return float(value.lower()) + return float(value) + + +def _convert_boolean(value: t.Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in ("true", "yes", "1", "on") + return bool(value) + + +def _convert_datetime(value: t.Any) -> datetime: + if isinstance(value, datetime): + return value + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + + +def _convert_date(value: t.Any) -> t.Any: + from datetime import date as date_type + + if isinstance(value, date_type) and not isinstance(value, datetime): + return tomlkit.date(value.isoformat()) + return tomlkit.date(str(value)) + + +def _convert_time(value: t.Any) -> t.Any: + from datetime import time as time_type + + if isinstance(value, time_type): + return tomlkit.time(value.isoformat()) + return tomlkit.time(str(value)) + + +def _convert_array(value: t.Any) -> Array: + if isinstance(value, list): + return _convert_list_to_array(value) + arr = tomlkit.array() + arr.append(convert_value(value, "auto")) + return arr + + +def _convert_inline_table(value: t.Any) -> InlineTable: + if not isinstance(value, dict): + raise ValueError(f"Cannot convert {type(value).__name__} to inline_table") + return _convert_dict_to_inline_table(value) + + +# Dispatch dict for O(1) type lookup +_CONVERTERS = { + "auto": _convert_auto, + "string": _convert_string, + "literal_string": _convert_literal_string, + "multiline_string": _convert_multiline_string, + "multiline_literal_string": _convert_multiline_literal_string, + "integer": _convert_integer, + "hex_integer": _convert_hex_integer, + "octal_integer": _convert_octal_integer, + "binary_integer": _convert_binary_integer, + "float": _convert_float, + "boolean": _convert_boolean, + "datetime": _convert_datetime, + "date": _convert_date, + "time": _convert_time, + "array": _convert_array, + "inline_table": _convert_inline_table, +} + + +def convert_value(value: t.Any, value_type: str = "auto") -> t.Any: + """Convert a value to the appropriate TOML type.""" + converter = _CONVERTERS.get(value_type) + if converter: + return converter(value) + return value + + +# ============================================================================= +# Table Navigation Functions (extracted for reduced nesting) +# ============================================================================= + + +def load_toml_document(path: str, create: bool = True) -> tomlkit.TOMLDocument: + """Load a TOML document from file.""" + if not os.path.exists(path): + if not create: + raise TomlFileError(f"Destination {path} does not exist!") + return tomlkit.document() + + try: + with open(path, encoding="utf-8") as f: + return tomlkit.parse(f.read()) + except Exception as e: + raise TomlFileError(f"Failed to parse TOML file: {e}") from e + + +def _create_new_aot_entry(current: Table | tomlkit.TOMLDocument, name: str) -> Table: + """Create a new array of tables with first entry.""" + new_table = tomlkit.table() + aot = tomlkit.aot() + aot.append(new_table) + current[name] = aot + return new_table + + +def _create_regular_table(current: Table | tomlkit.TOMLDocument, name: str) -> Table: + """Create a regular table.""" + new_table = tomlkit.table() + current[name] = new_table + return new_table + + +def _create_table_segment( + current: Table | tomlkit.TOMLDocument, name: str, index: IndexType, ctx: NavContext, i: int +) -> Table: + """Create a new table segment when name not in current.""" + if not ctx.create: + raise TomlFileError(f"Table '{_format_path(ctx.parsed, i + 1)}' does not exist") + if index == "append": + return _create_new_aot_entry(current, name) + if index is not None: + raise TomlFileError(f"Cannot create table at index {index} - array of tables '{name}' does not exist") + return _create_regular_table(current, name) + + +def _get_aot_default(aot: AoT, ctx: NavContext, i: int) -> Table: + """Get first entry from AoT when no index specified.""" + if len(aot) == 0: + raise TomlFileError(f"Array of tables '{_format_path(ctx.parsed, i + 1)}' is empty") + return aot[0] + + +def _get_aot_at_index(aot: AoT, index: int, ctx: NavContext, i: int) -> Table: + """Get AoT entry at specific index.""" + if 0 <= index < len(aot): + return aot[index] + raise TomlFileError(f"Array of tables index {index} is out of range for '{_format_path(ctx.parsed, i + 1)}'") + + +def _handle_aot_append(aot: AoT, is_last: bool, ctx: NavContext, i: int) -> Table: + """Handle AoT[append] - append a new entry to the array of tables.""" + if not is_last: + raise TomlFileError(f"Cannot use [append] in middle of path '{_format_path(ctx.parsed)}'") + if not ctx.create: + raise TomlFileError("Cannot use [append] with state=absent; use [-1] to access the last entry") + new_table = tomlkit.table() + new_table.trivia.indent = "\n" + aot.append(new_table) + return new_table + + +def _navigate_aot(aot: AoT, index: IndexType, is_last: bool, ctx: NavContext, i: int) -> Table: + """Navigate within an array of tables.""" + if index is None: + return _get_aot_default(aot, ctx, i) + if index == "append": + return _handle_aot_append(aot, is_last, ctx, i) + return _get_aot_at_index(aot, index, ctx, i) + + +def _navigate_existing_segment( + current: Table | tomlkit.TOMLDocument, name: str, index: IndexType, ctx: NavContext, i: int +) -> Table | tomlkit.TOMLDocument: + """Navigate into an existing table segment.""" + item = current[name] + is_last = i == len(ctx.parsed) - 1 + if isinstance(item, AoT): + return _navigate_aot(item, index, is_last, ctx, i) + if isinstance(item, (Table, dict)): + if index is not None: + raise TomlFileError(f"'{_format_path(ctx.parsed, i + 1)}' is not an array of tables, cannot use index") + return item + raise TomlFileError(f"'{_format_path(ctx.parsed, i + 1)}' is not a table") + + +def navigate_to_table( + doc: tomlkit.TOMLDocument, table_path: str | None, create: bool = False +) -> Table | tomlkit.TOMLDocument: + """Navigate to a table in the document.""" + parsed = parse_table_path(table_path) + if not parsed: + return doc + + ctx = NavContext(parsed=parsed, create=create) + current: Table | tomlkit.TOMLDocument = doc + for i, (name, index) in enumerate(parsed): + if name not in current: + current = _create_table_segment(current, name, index, ctx, i) + else: + current = _navigate_existing_segment(current, name, index, ctx, i) + return current + + +# ============================================================================= +# Key Operations +# ============================================================================= + + +def navigate_to_key_parent( + target: Table | tomlkit.TOMLDocument, key: str +) -> tuple[Table | InlineTable | tomlkit.TOMLDocument, str]: + """Navigate to the parent container of a key.""" + parts = key.split(".") + if len(parts) == 1: + return target, key + + parent: Table | InlineTable | tomlkit.TOMLDocument = target + for i, part in enumerate(parts[:-1]): + if part not in parent: + new_table = tomlkit.inline_table() + parent[part] = new_table + parent = new_table + else: + item = parent[part] + if isinstance(item, (Table, InlineTable, dict)): + parent = item + else: + raise TomlFileError(f"'{'.'.join(parts[: i + 1])}' is not a table, cannot navigate further") + + return parent, parts[-1] + + +def _values_equal(a: t.Any, b: t.Any) -> bool: + """Check if two values are equal.""" + if isinstance(a, Array) and isinstance(b, (Array, list)): + if len(a) != len(b): + return False + return all(_values_equal(x, y) for x, y in zip(a, b)) + if isinstance(a, (InlineTable, Table)) and isinstance(b, (InlineTable, Table, dict)): + if len(a) != len(b): + return False + return all(k in b and _values_equal(a[k], b[k]) for k in a) + return a == b + + +def set_key_value(target: Table | tomlkit.TOMLDocument, key: str, value: t.Any, value_type: str = "auto") -> bool: + """Set a key to a value in the target table.""" + parent, final_key = navigate_to_key_parent(target, key) + converted_value = convert_value(value, value_type) + + if final_key in parent: + existing = parent[final_key] + if _values_equal(existing, converted_value): + return False + + parent[final_key] = converted_value + return True + + +def remove_key(target: Table | tomlkit.TOMLDocument, key: str) -> bool: + """Remove a key from the target table.""" + parts = key.split(".") + parent: Table | InlineTable | tomlkit.TOMLDocument = target + for part in parts[:-1]: + if part not in parent: + return False + item = parent[part] + if isinstance(item, (Table, InlineTable, dict)): + parent = item + else: + return False + + final_key = parts[-1] + if final_key in parent: + del parent[final_key] + return True + return False + + +# ============================================================================= +# Table Removal +# ============================================================================= + + +def remove_table(doc: tomlkit.TOMLDocument, table_path: str) -> bool: + """Remove a table from the document.""" + parsed = parse_table_path(table_path) + if not parsed: + raise TomlFileError("Cannot remove document root") + + if len(parsed) == 1: + parent: Table | tomlkit.TOMLDocument = doc + else: + try: + parent_path = _format_path(parsed, len(parsed) - 1) + parent = navigate_to_table(doc, parent_path, create=False) + except TomlFileError: + return False + + name, index = parsed[-1] + if name not in parent: + return False + + item = parent[name] + if isinstance(item, AoT) and index is not None: + return _remove_aot_entry(item, index) + + del parent[name] + return True + + +def _remove_aot_entry(aot: AoT, index: int | t.Literal["append"]) -> bool: + """Remove an entry from an array of tables.""" + if index == "append": + raise TomlFileError("Cannot use [append] with state=absent; use [-1] to remove the last entry") + if index == -1: + if len(aot) > 0: + del aot[-1] + return True + return False + if 0 <= index < len(aot): + del aot[index] + return True + raise TomlFileError(f"Array of tables index {index} out of range") + + +# ============================================================================= +# Main TOML Operations (extracted for reduced nesting and statements) +# ============================================================================= + + +def _init_diff(path: str, diff_enabled: bool, doc: tomlkit.TOMLDocument) -> dict[str, str]: + """Initialize the diff dictionary.""" + diff = { + "before": "", + "after": "", + "before_header": f"{path} (content)", + "after_header": f"{path} (content)", + } + if diff_enabled: + diff["before"] = tomlkit.dumps(doc) + return diff + + +def _handle_present(doc: tomlkit.TOMLDocument, params: TomlParams, original_content: str) -> tuple[bool, str]: + """Handle state=present operations.""" + if params.key: + if params.value is None: + raise TomlFileError("Parameter 'value' is required when state=present and key is specified") + target = navigate_to_table(doc, params.table, create=True) + changed = set_key_value(target, params.key, params.value, params.value_type) + msg = "key added" if changed else "OK" + return changed, msg + + if params.table: + navigate_to_table(doc, params.table, create=True) + new_content = tomlkit.dumps(doc) + if new_content != original_content: + return True, "table added" + return False, "nothing to do" + + +def _handle_absent(doc: tomlkit.TOMLDocument, params: TomlParams) -> tuple[bool, str]: + """Handle state=absent operations.""" + if params.key: + try: + target = navigate_to_table(doc, params.table, create=False) + changed = remove_key(target, params.key) + return changed, "key removed" if changed else "key not found" + except TomlFileError: + return False, "key not found" + + if params.table: + changed = remove_table(doc, params.table) + return changed, "table removed" if changed else "table not found" + return False, "nothing to do" + + +def _write_if_changed( + module: AnsibleModule, target_path: str, doc: tomlkit.TOMLDocument, changed: bool, backup: bool +) -> str | None: + """Write the document to file if changed.""" + if not changed or module.check_mode: + return None + + backup_file = None + if backup and os.path.exists(target_path): + backup_file = module.backup_local(target_path) + + destpath = os.path.dirname(target_path) + if destpath and not os.path.exists(destpath): + os.makedirs(destpath) + + content = tomlkit.dumps(doc) + encoded_content = to_bytes(content) + + try: + tmpfd, tmpfile = tempfile.mkstemp(dir=module.tmpdir) + with os.fdopen(tmpfd, "wb") as f: + f.write(encoded_content) + except OSError: + module.fail_json(msg="Unable to create temporary file", exception=traceback.format_exc()) + + try: + module.atomic_move(tmpfile, os.path.abspath(target_path)) + except OSError: + module.fail_json( + msg=f"Unable to move temporary file {tmpfile} to {target_path}", exception=traceback.format_exc() + ) + + return backup_file + + +def do_toml(module: AnsibleModule, params: TomlParams) -> tuple[bool, str | None, dict[str, str], str]: + """Execute the main TOML file operation.""" + target_path = os.path.realpath(params.path) if params.follow and os.path.islink(params.path) else params.path + doc = load_toml_document(target_path, params.create) + + diff = _init_diff(params.path, module._diff, doc) + original_content = tomlkit.dumps(doc) + + if params.state == "present": + changed, msg = _handle_present(doc, params, original_content) + else: + changed, msg = _handle_absent(doc, params) + + if module._diff: + diff["after"] = tomlkit.dumps(doc) + + backup_file = _write_if_changed(module, target_path, doc, changed, params.backup) + return changed, backup_file, diff, msg + + +# ============================================================================= +# Module Entry Point +# ============================================================================= + + +def main() -> None: + module = AnsibleModule( + argument_spec=dict( + path=dict(type="path", required=True, aliases=["dest"]), + table=dict(type="str"), + key=dict(type="str", no_log=False), + value=dict(type="raw"), + value_type=dict( + type="str", + default="auto", + choices=[ + "auto", + "string", + "literal_string", + "multiline_string", + "multiline_literal_string", + "integer", + "hex_integer", + "octal_integer", + "binary_integer", + "float", + "boolean", + "datetime", + "date", + "time", + "array", + "inline_table", + ], + ), + state=dict(type="str", default="present", choices=["absent", "present"]), + backup=dict(type="bool", default=False), + create=dict(type="bool", default=True), + follow=dict(type="bool", default=False), + ), + add_file_common_args=True, + supports_check_mode=True, + ) + + deps.validate(module) + + params = TomlParams( + path=module.params["path"], + table=module.params["table"], + key=module.params["key"], + value=module.params["value"], + value_type=module.params["value_type"], + state=module.params["state"], + backup=module.params["backup"], + create=module.params["create"], + follow=module.params["follow"], + ) + + try: + changed, backup_file, diff, msg = do_toml(module, params) + except TomlFileError as e: + module.fail_json(msg=str(e)) + + if not module.check_mode and os.path.exists(params.path): + file_args = module.load_file_common_arguments(module.params) + changed = module.set_fs_attributes_if_different(file_args, changed) + + results = dict( + changed=changed, + diff=diff, + msg=msg, + path=params.path, + ) + if backup_file is not None: + results["backup_file"] = backup_file + + module.exit_json(**results) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/toml_file/aliases b/tests/integration/targets/toml_file/aliases new file mode 100644 index 0000000000..12d1d6617e --- /dev/null +++ b/tests/integration/targets/toml_file/aliases @@ -0,0 +1,5 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/posix/2 diff --git a/tests/integration/targets/toml_file/meta/main.yml b/tests/integration/targets/toml_file/meta/main.yml new file mode 100644 index 0000000000..982de6eb03 --- /dev/null +++ b/tests/integration/targets/toml_file/meta/main.yml @@ -0,0 +1,7 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +dependencies: + - setup_remote_tmp_dir diff --git a/tests/integration/targets/toml_file/tasks/00-basic.yml b/tests/integration/targets/toml_file/tasks/00-basic.yml new file mode 100644 index 0000000000..195e7be359 --- /dev/null +++ b/tests/integration/targets/toml_file/tasks/00-basic.yml @@ -0,0 +1,188 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +## Basic tests - create, update, remove key + +- name: test-basic 1 - set "create=false" on non-existing file and fail + toml_file: + path: "{{ non_existing_file }}" + table: server + key: host + value: localhost + create: false + register: result_basic_1 + ignore_errors: true + +- name: test-basic 1 - verify error message + assert: + that: + - result_basic_1 is not changed + - result_basic_1 is failed + - "'does not exist' in result_basic_1.msg" + +- name: test-basic 2 - create a new TOML file with a key + toml_file: + path: "{{ output_file }}" + table: server + key: host + value: localhost + register: result_basic_2 + +- name: test-basic 2 - verify file created + assert: + that: + - result_basic_2 is changed + - result_basic_2.msg == "key added" + +- name: test-basic 2 - read and verify content + slurp: + src: "{{ output_file }}" + register: content_basic_2 + +- name: test-basic 2 - verify TOML content + vars: + expected: | + [server] + host = "localhost" + assert: + that: + - (content_basic_2.content | b64decode) == expected + +- name: test-basic 3 - set the same key again (idempotent) + toml_file: + path: "{{ output_file }}" + table: server + key: host + value: localhost + register: result_basic_3 + +- name: test-basic 3 - verify no change + assert: + that: + - result_basic_3 is not changed + +- name: test-basic 4 - update the key value + toml_file: + path: "{{ output_file }}" + table: server + key: host + value: "192.168.1.1" + register: result_basic_4 + +- name: test-basic 4 - verify change + assert: + that: + - result_basic_4 is changed + +- name: test-basic 4 - read and verify content + slurp: + src: "{{ output_file }}" + register: content_basic_4 + +- name: test-basic 4 - verify updated TOML content + vars: + expected: | + [server] + host = "192.168.1.1" + assert: + that: + - (content_basic_4.content | b64decode) == expected + +- name: test-basic 5 - remove the key + toml_file: + path: "{{ output_file }}" + table: server + key: host + state: absent + register: result_basic_5 + +- name: test-basic 5 - verify removal + assert: + that: + - result_basic_5 is changed + - result_basic_5.msg == "key removed" + +- name: test-basic 5 - read and verify content + slurp: + src: "{{ output_file }}" + register: content_basic_5 + +- name: test-basic 5 - verify key removed from TOML + vars: + expected: | + [server] + assert: + that: + - (content_basic_5.content | b64decode) == expected + +- name: test-basic 6 - remove non-existing key (idempotent) + toml_file: + path: "{{ output_file }}" + table: server + key: nonexistent + state: absent + register: result_basic_6 + +- name: test-basic 6 - verify no change + assert: + that: + - result_basic_6 is not changed + - result_basic_6.msg == "key not found" + +- name: test-basic 7 - set key at document root + toml_file: + path: "{{ output_file }}" + key: title + value: "My Application" + register: result_basic_7 + +- name: test-basic 7 - verify change + assert: + that: + - result_basic_7 is changed + +- name: test-basic 7 - read and verify content + slurp: + src: "{{ output_file }}" + register: content_basic_7 + +- name: test-basic 7 - verify root-level key + vars: + expected: | + title = "My Application" + + [server] + assert: + that: + - (content_basic_7.content | b64decode) == expected + +- name: test-basic 8 - check mode test (no actual change) + toml_file: + path: "{{ output_file }}" + table: server + key: port + value: 8080 + check_mode: true + register: result_basic_8 + +- name: test-basic 8 - verify check mode reports change + assert: + that: + - result_basic_8 is changed + +- name: test-basic 8 - verify file was not actually modified + slurp: + src: "{{ output_file }}" + register: content_basic_8 + +- name: test-basic 8 - verify port not in file + vars: + expected: | + title = "My Application" + + [server] + assert: + that: + - (content_basic_8.content | b64decode) == expected diff --git a/tests/integration/targets/toml_file/tasks/01-tables.yml b/tests/integration/targets/toml_file/tasks/01-tables.yml new file mode 100644 index 0000000000..fda2e26aae --- /dev/null +++ b/tests/integration/targets/toml_file/tasks/01-tables.yml @@ -0,0 +1,135 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +## Table tests - create and remove tables + +- name: test-table 1 - create a table with a key + toml_file: + path: "{{ output_file }}" + table: database + key: host + value: localhost + register: result_table_1 + +- name: test-table 1 - verify change + assert: + that: + - result_table_1 is changed + +- name: test-table 1 - read content + slurp: + src: "{{ output_file }}" + register: content_table_1 + +- name: test-table 1 - verify table created + vars: + expected: | + [database] + host = "localhost" + assert: + that: + - (content_table_1.content | b64decode) == expected + +- name: test-table 2 - add another key to the same table + toml_file: + path: "{{ output_file }}" + table: database + key: port + value: 5432 + register: result_table_2 + +- name: test-table 2 - verify change + assert: + that: + - result_table_2 is changed + +- name: test-table 2 - read content + slurp: + src: "{{ output_file }}" + register: content_table_2 + +- name: test-table 2 - verify key added + vars: + expected: | + [database] + host = "localhost" + port = 5432 + assert: + that: + - (content_table_2.content | b64decode) == expected + +- name: test-table 3 - create a second table + toml_file: + path: "{{ output_file }}" + table: server + key: name + value: myserver + register: result_table_3 + +- name: test-table 3 - verify change + assert: + that: + - result_table_3 is changed + +- name: test-table 3 - read content + slurp: + src: "{{ output_file }}" + register: content_table_3 + +- name: test-table 3 - verify both tables exist + vars: + expected: | + [database] + host = "localhost" + port = 5432 + + [server] + name = "myserver" + assert: + that: + - (content_table_3.content | b64decode) == expected + +- name: test-table 4 - remove entire table + toml_file: + path: "{{ output_file }}" + table: server + state: absent + register: result_table_4 + +- name: test-table 4 - verify change + assert: + that: + - result_table_4 is changed + - result_table_4.msg == "table removed" + +- name: test-table 4 - read content + slurp: + src: "{{ output_file }}" + register: content_table_4 + +- name: test-table 4 - verify table removed + vars: + # Note: tomlkit preserves the blank line that preceded the removed table + expected: |+ + [database] + host = "localhost" + port = 5432 + + assert: + that: + - (content_table_4.content | b64decode) == expected + +- name: test-table 5 - remove non-existing table (idempotent) + toml_file: + path: "{{ output_file }}" + table: nonexistent + state: absent + register: result_table_5 + +- name: test-table 5 - verify no change + assert: + that: + - result_table_5 is not changed + - result_table_5.msg == "table not found" diff --git a/tests/integration/targets/toml_file/tasks/02-nested.yml b/tests/integration/targets/toml_file/tasks/02-nested.yml new file mode 100644 index 0000000000..6f938eb0be --- /dev/null +++ b/tests/integration/targets/toml_file/tasks/02-nested.yml @@ -0,0 +1,148 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +## Nested tests - dotted paths for tables and keys + +- name: test-nested 1 - create nested table with dotted notation + toml_file: + path: "{{ output_file }}" + table: server.database + key: port + value: 5432 + register: result_nested_1 + +- name: test-nested 1 - verify change + assert: + that: + - result_nested_1 is changed + +- name: test-nested 1 - read content + slurp: + src: "{{ output_file }}" + register: content_nested_1 + +- name: test-nested 1 - verify nested table created + vars: + expected: | + [server.database] + port = 5432 + assert: + that: + - (content_nested_1.content | b64decode) == expected + +- name: test-nested 2 - add another key to nested table + toml_file: + path: "{{ output_file }}" + table: server.database + key: name + value: mydb + register: result_nested_2 + +- name: test-nested 2 - verify change + assert: + that: + - result_nested_2 is changed + +- name: test-nested 2 - read content + slurp: + src: "{{ output_file }}" + register: content_nested_2 + +- name: test-nested 2 - verify key added + vars: + expected: | + [server.database] + port = 5432 + name = "mydb" + assert: + that: + - (content_nested_2.content | b64decode) == expected + +- name: test-nested 3 - create deeply nested table + toml_file: + path: "{{ output_file }}" + table: app.config.logging + key: level + value: debug + register: result_nested_3 + +- name: test-nested 3 - verify change + assert: + that: + - result_nested_3 is changed + +- name: test-nested 3 - read content + slurp: + src: "{{ output_file }}" + register: content_nested_3 + +- name: test-nested 3 - verify deeply nested table + vars: + expected: | + [server.database] + port = 5432 + name = "mydb" + + [app.config.logging] + level = "debug" + assert: + that: + - (content_nested_3.content | b64decode) == expected + +- name: test-nested 4 - remove key from nested table + toml_file: + path: "{{ output_file }}" + table: server.database + key: name + state: absent + register: result_nested_4 + +- name: test-nested 4 - verify change + assert: + that: + - result_nested_4 is changed + +- name: test-nested 4 - read content + slurp: + src: "{{ output_file }}" + register: content_nested_4 + +- name: test-nested 4 - verify key removed + vars: + expected: | + [server.database] + port = 5432 + + [app.config.logging] + level = "debug" + assert: + that: + - (content_nested_4.content | b64decode) == expected + +- name: test-nested 5 - remove nested table + toml_file: + path: "{{ output_file }}" + table: server.database + state: absent + register: result_nested_5 + +- name: test-nested 5 - verify change + assert: + that: + - result_nested_5 is changed + +- name: test-nested 5 - read content + slurp: + src: "{{ output_file }}" + register: content_nested_5 + +- name: test-nested 5 - verify table removed + vars: + expected: | + [app.config.logging] + level = "debug" + assert: + that: + - (content_nested_5.content | b64decode) == expected diff --git a/tests/integration/targets/toml_file/tasks/03-arrays.yml b/tests/integration/targets/toml_file/tasks/03-arrays.yml new file mode 100644 index 0000000000..d31e241e95 --- /dev/null +++ b/tests/integration/targets/toml_file/tasks/03-arrays.yml @@ -0,0 +1,141 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +## Array tests - array values + +- name: test-array 1 - set an array value + toml_file: + path: "{{ output_file }}" + table: server + key: ports + value: + - 80 + - 443 + - 8080 + register: result_array_1 + +- name: test-array 1 - verify change + assert: + that: + - result_array_1 is changed + +- name: test-array 1 - read content + slurp: + src: "{{ output_file }}" + register: content_array_1 + +- name: test-array 1 - verify array in TOML + vars: + expected: | + [server] + ports = [80, 443, 8080] + assert: + that: + - (content_array_1.content | b64decode) == expected + +- name: test-array 2 - set the same array again (idempotent) + toml_file: + path: "{{ output_file }}" + table: server + key: ports + value: + - 80 + - 443 + - 8080 + register: result_array_2 + +- name: test-array 2 - verify no change + assert: + that: + - result_array_2 is not changed + +- name: test-array 3 - update array value + toml_file: + path: "{{ output_file }}" + table: server + key: ports + value: + - 80 + - 443 + register: result_array_3 + +- name: test-array 3 - verify change + assert: + that: + - result_array_3 is changed + +- name: test-array 3 - read content + slurp: + src: "{{ output_file }}" + register: content_array_3 + +- name: test-array 3 - verify updated array + vars: + expected: | + [server] + ports = [80, 443] + assert: + that: + - (content_array_3.content | b64decode) == expected + +- name: test-array 4 - set array of strings + toml_file: + path: "{{ output_file }}" + table: server + key: allowed_hosts + value: + - localhost + - "127.0.0.1" + - "::1" + register: result_array_4 + +- name: test-array 4 - verify change + assert: + that: + - result_array_4 is changed + +- name: test-array 4 - read content + slurp: + src: "{{ output_file }}" + register: content_array_4 + +- name: test-array 4 - verify string array + vars: + expected: | + [server] + ports = [80, 443] + allowed_hosts = ["localhost", "127.0.0.1", "::1"] + assert: + that: + - (content_array_4.content | b64decode) == expected + +- name: test-array 5 - set empty array + toml_file: + path: "{{ output_file }}" + table: server + key: empty_list + value: [] + register: result_array_5 + +- name: test-array 5 - verify change + assert: + that: + - result_array_5 is changed + +- name: test-array 5 - read content + slurp: + src: "{{ output_file }}" + register: content_array_5 + +- name: test-array 5 - verify empty array + vars: + expected: | + [server] + ports = [80, 443] + allowed_hosts = ["localhost", "127.0.0.1", "::1"] + empty_list = [] + assert: + that: + - (content_array_5.content | b64decode) == expected diff --git a/tests/integration/targets/toml_file/tasks/04-array-tables.yml b/tests/integration/targets/toml_file/tasks/04-array-tables.yml new file mode 100644 index 0000000000..76acbb64cf --- /dev/null +++ b/tests/integration/targets/toml_file/tasks/04-array-tables.yml @@ -0,0 +1,183 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +## Array of tables tests - [[table]] handling + +- name: test-aot 1 - create first array of tables entry + toml_file: + path: "{{ output_file }}" + table: products[append] + key: name + value: Hammer + register: result_aot_1 + +- name: test-aot 1 - verify change + assert: + that: + - result_aot_1 is changed + +- name: test-aot 1 - read content + slurp: + src: "{{ output_file }}" + register: content_aot_1 + +- name: test-aot 1 - verify array of tables created + vars: + expected: | + [[products]] + name = "Hammer" + assert: + that: + - (content_aot_1.content | b64decode) == expected + +- name: test-aot 2 - add key to existing entry + toml_file: + path: "{{ output_file }}" + table: products[0] + key: price + value: 9.99 + register: result_aot_2 + +- name: test-aot 2 - verify change + assert: + that: + - result_aot_2 is changed + +- name: test-aot 2 - read content + slurp: + src: "{{ output_file }}" + register: content_aot_2 + +- name: test-aot 2 - verify price added + vars: + expected: | + [[products]] + name = "Hammer" + price = 9.99 + assert: + that: + - (content_aot_2.content | b64decode) == expected + +- name: test-aot 3 - append new entry to array of tables + toml_file: + path: "{{ output_file }}" + table: products[append] + key: name + value: Nail + register: result_aot_3 + +- name: test-aot 3 - verify change + assert: + that: + - result_aot_3 is changed + +- name: test-aot 3 - read content + slurp: + src: "{{ output_file }}" + register: content_aot_3 + +- name: test-aot 3 - verify second entry added + vars: + expected: | + [[products]] + name = "Hammer" + price = 9.99 + + [[products]] + name = "Nail" + assert: + that: + - (content_aot_3.content | b64decode) == expected + +- name: test-aot 4 - modify second entry + toml_file: + path: "{{ output_file }}" + table: products[1] + key: price + value: 0.05 + register: result_aot_4 + +- name: test-aot 4 - verify change + assert: + that: + - result_aot_4 is changed + +- name: test-aot 4 - read content + slurp: + src: "{{ output_file }}" + register: content_aot_4 + +- name: test-aot 4 - verify second entry modified + vars: + expected: | + [[products]] + name = "Hammer" + price = 9.99 + + [[products]] + name = "Nail" + price = 0.05 + assert: + that: + - (content_aot_4.content | b64decode) == expected + +- name: test-aot 5 - remove key from first entry + toml_file: + path: "{{ output_file }}" + table: products[0] + key: price + state: absent + register: result_aot_5 + +- name: test-aot 5 - verify change + assert: + that: + - result_aot_5 is changed + +- name: test-aot 5 - read content + slurp: + src: "{{ output_file }}" + register: content_aot_5 + +- name: test-aot 5 - verify key removed from first entry + vars: + expected: | + [[products]] + name = "Hammer" + + [[products]] + name = "Nail" + price = 0.05 + assert: + that: + - (content_aot_5.content | b64decode) == expected + +- name: test-aot 6 - remove last entry from array of tables + toml_file: + path: "{{ output_file }}" + table: products[-1] + state: absent + register: result_aot_6 + +- name: test-aot 6 - verify change + assert: + that: + - result_aot_6 is changed + +- name: test-aot 6 - read content + slurp: + src: "{{ output_file }}" + register: content_aot_6 + +- name: test-aot 6 - verify last entry removed + vars: + # Note: tomlkit preserves the blank line that preceded the removed entry + expected: |+ + [[products]] + name = "Hammer" + + assert: + that: + - (content_aot_6.content | b64decode) == expected diff --git a/tests/integration/targets/toml_file/tasks/05-types.yml b/tests/integration/targets/toml_file/tasks/05-types.yml new file mode 100644 index 0000000000..934c94e9fd --- /dev/null +++ b/tests/integration/targets/toml_file/tasks/05-types.yml @@ -0,0 +1,573 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +## Type tests - explicit type coercion + +## Group 1: Basic types (integer, float, boolean, string) + +- name: test-type 1 - set integer value + toml_file: + path: "{{ output_file }}" + table: types + key: count + value: 42 + value_type: integer + register: result_type_1 + +- name: test-type 1 - verify change + assert: + that: + - result_type_1 is changed + +- name: test-type 1 - read content + slurp: + src: "{{ output_file }}" + register: content_type_1 + +- name: test-type 1 - verify integer + vars: + expected: | + [types] + count = 42 + assert: + that: + - (content_type_1.content | b64decode) == expected + +- name: test-type 2 - set float value + toml_file: + path: "{{ output_file }}" + table: types + key: ratio + value: 3.14159 + value_type: float + register: result_type_2 + +- name: test-type 2 - verify change + assert: + that: + - result_type_2 is changed + +- name: test-type 2 - read content + slurp: + src: "{{ output_file }}" + register: content_type_2 + +- name: test-type 2 - verify float + vars: + expected: | + [types] + count = 42 + ratio = 3.14159 + assert: + that: + - (content_type_2.content | b64decode) == expected + +- name: test-type 3 - set boolean true + toml_file: + path: "{{ output_file }}" + table: types + key: enabled + value: true + value_type: boolean + register: result_type_3 + +- name: test-type 3 - verify change + assert: + that: + - result_type_3 is changed + +- name: test-type 3 - read content + slurp: + src: "{{ output_file }}" + register: content_type_3 + +- name: test-type 3 - verify boolean + vars: + expected: | + [types] + count = 42 + ratio = 3.14159 + enabled = true + assert: + that: + - (content_type_3.content | b64decode) == expected + +- name: test-type 4 - set boolean false + toml_file: + path: "{{ output_file }}" + table: types + key: disabled + value: false + value_type: boolean + register: result_type_4 + +- name: test-type 4 - verify change + assert: + that: + - result_type_4 is changed + +- name: test-type 4 - read content + slurp: + src: "{{ output_file }}" + register: content_type_4 + +- name: test-type 4 - verify boolean false + vars: + expected: | + [types] + count = 42 + ratio = 3.14159 + enabled = true + disabled = false + assert: + that: + - (content_type_4.content | b64decode) == expected + +- name: test-type 5 - set string value (force string) + toml_file: + path: "{{ output_file }}" + table: types + key: number_as_string + value: "12345" + value_type: string + register: result_type_5 + +- name: test-type 5 - verify change + assert: + that: + - result_type_5 is changed + +- name: test-type 5 - read content + slurp: + src: "{{ output_file }}" + register: content_type_5 + +- name: test-type 5 - verify string (should be quoted) + vars: + expected: | + [types] + count = 42 + ratio = 3.14159 + enabled = true + disabled = false + number_as_string = "12345" + assert: + that: + - (content_type_5.content | b64decode) == expected + +## Group 2: Reset and test datetime, inline_table, array + +- name: Reset output file for group 2 + file: + path: "{{ output_file }}" + state: absent + +- name: test-type 6 - set datetime value + toml_file: + path: "{{ output_file }}" + table: types + key: timestamp + value: "2026-01-17T12:00:00" + value_type: datetime + register: result_type_6 + +- name: test-type 6 - verify change + assert: + that: + - result_type_6 is changed + +- name: test-type 6 - read content + slurp: + src: "{{ output_file }}" + register: content_type_6 + +- name: test-type 6 - verify datetime + assert: + that: + - "'timestamp = 2026-01-17' in (content_type_6.content | b64decode)" + +- name: test-type 7 - set inline table + toml_file: + path: "{{ output_file }}" + table: types + key: point + value: + x: 10 + y: 20 + value_type: inline_table + register: result_type_7 + +- name: test-type 7 - verify change + assert: + that: + - result_type_7 is changed + +- name: test-type 7 - read content + slurp: + src: "{{ output_file }}" + register: content_type_7 + +- name: test-type 7 - verify inline table + assert: + that: + - "'point = {' in (content_type_7.content | b64decode)" + - "'x = 10' in (content_type_7.content | b64decode)" + - "'y = 20' in (content_type_7.content | b64decode)" + +- name: test-type 8 - force value to array + toml_file: + path: "{{ output_file }}" + table: types + key: single_as_array + value: single_value + value_type: array + register: result_type_8 + +- name: test-type 8 - verify change + assert: + that: + - result_type_8 is changed + +- name: test-type 8 - read content + slurp: + src: "{{ output_file }}" + register: content_type_8 + +- name: test-type 8 - verify array + assert: + that: + - "'single_as_array = [\"single_value\"]' in (content_type_8.content | b64decode)" + +## Group 3: Reset and test special floats (inf, -inf, nan) + +- name: Reset output file for group 3 + file: + path: "{{ output_file }}" + state: absent + +- name: test-type 9 - set positive infinity + toml_file: + path: "{{ output_file }}" + table: types + key: max_limit + value: inf + value_type: float + register: result_type_9 + +- name: test-type 9 - verify change + assert: + that: + - result_type_9 is changed + +- name: test-type 9 - read content + slurp: + src: "{{ output_file }}" + register: content_type_9 + +- name: test-type 9 - verify infinity in file + vars: + expected: | + [types] + max_limit = inf + assert: + that: + - (content_type_9.content | b64decode) == expected + +- name: test-type 10 - set negative infinity + toml_file: + path: "{{ output_file }}" + table: types + key: min_limit + value: "-inf" + value_type: float + register: result_type_10 + +- name: test-type 10 - verify change + assert: + that: + - result_type_10 is changed + +- name: test-type 10 - read content + slurp: + src: "{{ output_file }}" + register: content_type_10 + +- name: test-type 10 - verify negative infinity in file + vars: + expected: | + [types] + max_limit = inf + min_limit = -inf + assert: + that: + - (content_type_10.content | b64decode) == expected + +- name: test-type 11 - set nan + toml_file: + path: "{{ output_file }}" + table: types + key: undefined + value: nan + value_type: float + register: result_type_11 + +- name: test-type 11 - verify change + assert: + that: + - result_type_11 is changed + +- name: test-type 11 - read content + slurp: + src: "{{ output_file }}" + register: content_type_11 + +- name: test-type 11 - verify nan in file + vars: + expected: | + [types] + max_limit = inf + min_limit = -inf + undefined = nan + assert: + that: + - (content_type_11.content | b64decode) == expected + +## Group 4: Reset and test date and time + +- name: Reset output file for group 4 + file: + path: "{{ output_file }}" + state: absent + +- name: test-type 12 - set local date + toml_file: + path: "{{ output_file }}" + table: types + key: birthday + value: "1990-05-15" + value_type: date + register: result_type_12 + +- name: test-type 12 - verify change + assert: + that: + - result_type_12 is changed + +- name: test-type 12 - read content + slurp: + src: "{{ output_file }}" + register: content_type_12 + +- name: test-type 12 - verify date in file + vars: + expected: | + [types] + birthday = 1990-05-15 + assert: + that: + - (content_type_12.content | b64decode) == expected + +- name: test-type 13 - set local time + toml_file: + path: "{{ output_file }}" + table: types + key: alarm + value: "07:30:00" + value_type: time + register: result_type_13 + +- name: test-type 13 - verify change + assert: + that: + - result_type_13 is changed + +- name: test-type 13 - read content + slurp: + src: "{{ output_file }}" + register: content_type_13 + +- name: test-type 13 - verify time in file + vars: + expected: | + [types] + birthday = 1990-05-15 + alarm = 07:30:00 + assert: + that: + - (content_type_13.content | b64decode) == expected + +## Group 5: Reset and test string variants + +- name: Reset output file for group 5 + file: + path: "{{ output_file }}" + state: absent + +- name: test-type 14 - set literal string + toml_file: + path: "{{ output_file }}" + table: types + key: regex_pattern + value: '\d+\.\d+' + value_type: literal_string + register: result_type_14 + +- name: test-type 14 - verify change + assert: + that: + - result_type_14 is changed + +- name: test-type 14 - read content + slurp: + src: "{{ output_file }}" + register: content_type_14 + +- name: test-type 14 - verify literal string uses single quotes + assert: + that: + - >- + "regex_pattern = '" in (content_type_14.content | b64decode) + +- name: test-type 15 - set multiline string + toml_file: + path: "{{ output_file }}" + table: types + key: description + value: "Line one\nLine two" + value_type: multiline_string + register: result_type_15 + +- name: test-type 15 - verify change + assert: + that: + - result_type_15 is changed + +- name: test-type 15 - read content + slurp: + src: "{{ output_file }}" + register: content_type_15 + +- name: test-type 15 - verify multiline string uses triple quotes + assert: + that: + - "'description = \"\"\"' in (content_type_15.content | b64decode)" + +- name: test-type 16 - set multiline literal string + toml_file: + path: "{{ output_file }}" + table: types + key: raw_block + value: "Raw\ntext\nblock" + value_type: multiline_literal_string + register: result_type_16 + +- name: test-type 16 - verify change + assert: + that: + - result_type_16 is changed + +- name: test-type 16 - read content + slurp: + src: "{{ output_file }}" + register: content_type_16 + +- name: test-type 16 - verify multiline literal string uses triple single quotes + assert: + that: + - "\"raw_block = '''\" in (content_type_16.content | b64decode)" + +## Group 6: Reset and test integer variants + +- name: Reset output file for group 6 + file: + path: "{{ output_file }}" + state: absent + +- name: test-type 17 - set hexadecimal integer + toml_file: + path: "{{ output_file }}" + table: types + key: hex_color + value: 16777215 + value_type: hex_integer + register: result_type_17 + +- name: test-type 17 - verify change + assert: + that: + - result_type_17 is changed + +- name: test-type 17 - read content + slurp: + src: "{{ output_file }}" + register: content_type_17 + +- name: test-type 17 - verify hex integer in file + vars: + expected: | + [types] + hex_color = 0xffffff + assert: + that: + - (content_type_17.content | b64decode) == expected + +- name: test-type 18 - set octal integer + toml_file: + path: "{{ output_file }}" + table: types + key: file_mode + value: 493 + value_type: octal_integer + register: result_type_18 + +- name: test-type 18 - verify change + assert: + that: + - result_type_18 is changed + +- name: test-type 18 - read content + slurp: + src: "{{ output_file }}" + register: content_type_18 + +- name: test-type 18 - verify octal integer in file + vars: + expected: | + [types] + hex_color = 0xffffff + file_mode = 0o755 + assert: + that: + - (content_type_18.content | b64decode) == expected + +- name: test-type 19 - set binary integer + toml_file: + path: "{{ output_file }}" + table: types + key: flags + value: 170 + value_type: binary_integer + register: result_type_19 + +- name: test-type 19 - verify change + assert: + that: + - result_type_19 is changed + +- name: test-type 19 - read content + slurp: + src: "{{ output_file }}" + register: content_type_19 + +- name: test-type 19 - verify binary integer in file + vars: + expected: | + [types] + hex_color = 0xffffff + file_mode = 0o755 + flags = 0b10101010 + assert: + that: + - (content_type_19.content | b64decode) == expected diff --git a/tests/integration/targets/toml_file/tasks/06-comments.yml b/tests/integration/targets/toml_file/tasks/06-comments.yml new file mode 100644 index 0000000000..cea235ecad --- /dev/null +++ b/tests/integration/targets/toml_file/tasks/06-comments.yml @@ -0,0 +1,127 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +## Comment preservation tests + +- name: test-comment 1 - create a TOML file with comments + copy: + dest: "{{ output_file }}" + content: | + # This is a file-level comment + title = "My App" + + # Server configuration + [server] + # The host to bind to + host = "localhost" + # The port to listen on + port = 8080 + +- name: test-comment 2 - modify a value (should preserve comments) + toml_file: + path: "{{ output_file }}" + table: server + key: port + value: 9090 + register: result_comment_2 + +- name: test-comment 2 - verify change + assert: + that: + - result_comment_2 is changed + +- name: test-comment 2 - read content + slurp: + src: "{{ output_file }}" + register: content_comment_2 + +- name: test-comment 2 - verify comments preserved + vars: + expected: | + # This is a file-level comment + title = "My App" + + # Server configuration + [server] + # The host to bind to + host = "localhost" + # The port to listen on + port = 9090 + assert: + that: + - (content_comment_2.content | b64decode) == expected + +- name: test-comment 3 - add a new key (should preserve existing comments) + toml_file: + path: "{{ output_file }}" + table: server + key: timeout + value: 30 + register: result_comment_3 + +- name: test-comment 3 - verify change + assert: + that: + - result_comment_3 is changed + +- name: test-comment 3 - read content + slurp: + src: "{{ output_file }}" + register: content_comment_3 + +- name: test-comment 3 - verify comments still preserved + vars: + expected: | + # This is a file-level comment + title = "My App" + + # Server configuration + [server] + # The host to bind to + host = "localhost" + # The port to listen on + port = 9090 + timeout = 30 + assert: + that: + - (content_comment_3.content | b64decode) == expected + +- name: test-comment 4 - add a new table (should preserve existing comments) + toml_file: + path: "{{ output_file }}" + table: database + key: host + value: db.example.com + register: result_comment_4 + +- name: test-comment 4 - verify change + assert: + that: + - result_comment_4 is changed + +- name: test-comment 4 - read content + slurp: + src: "{{ output_file }}" + register: content_comment_4 + +- name: test-comment 4 - verify original structure preserved + vars: + expected: | + # This is a file-level comment + title = "My App" + + # Server configuration + [server] + # The host to bind to + host = "localhost" + # The port to listen on + port = 9090 + timeout = 30 + + [database] + host = "db.example.com" + assert: + that: + - (content_comment_4.content | b64decode) == expected diff --git a/tests/integration/targets/toml_file/tasks/07-backup.yml b/tests/integration/targets/toml_file/tasks/07-backup.yml new file mode 100644 index 0000000000..140034e93d --- /dev/null +++ b/tests/integration/targets/toml_file/tasks/07-backup.yml @@ -0,0 +1,108 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +## Backup tests + +- name: test-backup 1 - create initial file + toml_file: + path: "{{ output_file }}" + table: server + key: host + value: localhost + register: result_backup_1 + +- name: test-backup 1 - verify no backup file returned + assert: + that: + - result_backup_1 is changed + - "'backup_file' not in result_backup_1" + +- name: test-backup 1 - read content + slurp: + src: "{{ output_file }}" + register: content_backup_1 + +- name: test-backup 1 - verify initial content + vars: + expected: | + [server] + host = "localhost" + assert: + that: + - (content_backup_1.content | b64decode) == expected + +- name: test-backup 2 - modify with backup enabled + toml_file: + path: "{{ output_file }}" + table: server + key: host + value: "192.168.1.1" + backup: true + register: result_backup_2 + +- name: test-backup 2 - verify backup file created + assert: + that: + - result_backup_2 is changed + - "'backup_file' in result_backup_2" + - result_backup_2.backup_file is match(".*test.toml.*~$") + +- name: test-backup 2 - verify backup file exists + stat: + path: "{{ result_backup_2.backup_file }}" + register: stat_backup_2 + +- name: test-backup 2 - assert backup file exists + assert: + that: + - stat_backup_2.stat.exists + +- name: test-backup 2 - read backup file content + slurp: + src: "{{ result_backup_2.backup_file }}" + register: content_backup_2 + +- name: test-backup 2 - verify backup has original content + vars: + expected: | + [server] + host = "localhost" + assert: + that: + - (content_backup_2.content | b64decode) == expected + +- name: test-backup 2 - read current file content + slurp: + src: "{{ output_file }}" + register: content_backup_2_current + +- name: test-backup 2 - verify current file has new content + vars: + expected: | + [server] + host = "192.168.1.1" + assert: + that: + - (content_backup_2_current.content | b64decode) == expected + +- name: test-backup 3 - no change should not create backup + toml_file: + path: "{{ output_file }}" + table: server + key: host + value: "192.168.1.1" + backup: true + register: result_backup_3 + +- name: test-backup 3 - verify no backup when no change + assert: + that: + - result_backup_3 is not changed + - "'backup_file' not in result_backup_3 or result_backup_3.backup_file is none" + +- name: test-backup 4 - cleanup backup file + file: + path: "{{ result_backup_2.backup_file }}" + state: absent diff --git a/tests/integration/targets/toml_file/tasks/main.yml b/tests/integration/targets/toml_file/tasks/main.yml new file mode 100644 index 0000000000..40c9b997c4 --- /dev/null +++ b/tests/integration/targets/toml_file/tasks/main.yml @@ -0,0 +1,81 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +# test code for toml_file module +# Copyright (c) 2026, Jose Drowne +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Install tomlkit + pip: + name: tomlkit + state: present + +- name: Record the output directory + set_fact: + output_file: "{{ remote_tmp_dir }}/test.toml" + non_existing_file: "{{ remote_tmp_dir }}/nonexistent.toml" + +- name: Include tasks + block: + - name: Include tasks to perform basic tests + include_tasks: 00-basic.yml + + - name: Reset output file + file: + path: "{{ output_file }}" + state: absent + + - name: Include tasks to perform table tests + include_tasks: 01-tables.yml + + - name: Reset output file + file: + path: "{{ output_file }}" + state: absent + + - name: Include tasks to perform nested tests + include_tasks: 02-nested.yml + + - name: Reset output file + file: + path: "{{ output_file }}" + state: absent + + - name: Include tasks to perform array tests + include_tasks: 03-arrays.yml + + - name: Reset output file + file: + path: "{{ output_file }}" + state: absent + + - name: Include tasks to perform array of tables tests + include_tasks: 04-array-tables.yml + + - name: Reset output file + file: + path: "{{ output_file }}" + state: absent + + - name: Include tasks to perform type tests + include_tasks: 05-types.yml + + - name: Reset output file + file: + path: "{{ output_file }}" + state: absent + + - name: Include tasks to perform comment preservation tests + include_tasks: 06-comments.yml + + - name: Reset output file + file: + path: "{{ output_file }}" + state: absent + + - name: Include tasks to perform backup tests + include_tasks: 07-backup.yml