├── .gitignore ├── defaults └── main.yml ├── tests ├── services │ └── test-app │ │ ├── unicode-service.yaml │ │ └── compose.yaml ├── test-playbook.yml └── test_yaml_indent.py ├── .github └── workflows │ ├── release.yml │ ├── test.yml │ ├── claude.yml │ └── claude-code-review.yml ├── meta └── main.yml ├── tasks ├── deploy-config.yml └── main.yml ├── filter_plugins └── yaml_indent.py └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | -------------------------------------------------------------------------------- /defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | docker_compose_generator_output_path: "~" 3 | docker_compose_generator_uid: "1000" 4 | docker_compose_generator_gid: "1000" 5 | services_directory: "{{ playbook_dir }}/services/" 6 | -------------------------------------------------------------------------------- /tests/services/test-app/unicode-service.yaml: -------------------------------------------------------------------------------- 1 | configs: 2 | unicode-config: 3 | content: | 4 | {"description": "日本語テスト", "emoji": "🚀", "café": "latté"} 5 | 6 | services: 7 | unicode-service: 8 | image: alpine:latest 9 | container_name: unicode-service 10 | labels: 11 | - "description=Service with unicode: 日本語" 12 | - "emoji=🎉" 13 | environment: 14 | - "GREETING=Héllo Wörld" 15 | - "JAPANESE=こんにちは" 16 | restart: unless-stopped 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_dispatch: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | env: 9 | GALAXY_USERNAME: IronicBadger 10 | 11 | jobs: 12 | 13 | release: 14 | name: Release 15 | runs-on: ubuntu-latest 16 | steps: 17 | 18 | - name: Set up Python 3. 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: '3.x' 22 | 23 | - name: Install Ansible. 24 | run: pip3 install ansible-core 25 | 26 | # Galaxy uses CamelCase username but GitHub is all lowercase 27 | # This concatenates the versions together to work with 28 | # And triggers an import on Galaxy 29 | - name: Trigger a new import on Galaxy. 30 | run: >- 31 | ansible-galaxy role import --api-key ${{ secrets.GALAXY_API_KEY }} 32 | $(echo ${{ env.GALAXY_USERNAME }}) $(echo ${{ github.repository }} | cut -d/ -f2) 33 | -------------------------------------------------------------------------------- /meta/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | galaxy_info: 3 | role_name: docker_compose_generator 4 | author: Alex Kretzschmar 5 | description: Create a docker-compose.yml file 6 | issue_tracker_url: https://github.com/ironicbadger/ansible-role-create-users/issues 7 | license: GPLv2 8 | min_ansible_version: 2.4 9 | platforms: 10 | - name: EL 11 | versions: 12 | - all 13 | - name: GenericUNIX 14 | versions: 15 | - all 16 | - any 17 | - name: Fedora 18 | versions: 19 | - all 20 | - name: opensuse 21 | versions: 22 | - all 23 | - name: Amazon 24 | versions: 25 | - all 26 | - name: GenericBSD 27 | versions: 28 | - all 29 | - any 30 | - name: FreeBSD 31 | versions: 32 | - all 33 | - name: Ubuntu 34 | versions: 35 | - all 36 | - name: SLES 37 | versions: 38 | - all 39 | - name: GenericLinux 40 | versions: 41 | - all 42 | - any 43 | - name: Debian 44 | versions: 45 | - all 46 | dependencies: [] 47 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test Compose Generator 2 | 3 | on: 4 | push: 5 | branches: [main, master] 6 | pull_request: 7 | branches: [main, master] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.11" 21 | 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install ansible pytest 26 | 27 | - name: Run filter plugin unit tests 28 | run: | 29 | cd tests 30 | python -m pytest test_yaml_indent.py -v 31 | 32 | - name: Run compose generator tests 33 | run: | 34 | cd tests 35 | ansible-playbook test-playbook.yml -v 36 | 37 | - name: Test failed 38 | if: failure() 39 | run: | 40 | echo "::error::Compose generator tests failed! Check the output above for details." 41 | exit 1 42 | -------------------------------------------------------------------------------- /tests/services/test-app/compose.yaml: -------------------------------------------------------------------------------- 1 | configs: 2 | test-config: 3 | content: | 4 | {"key": "value"} 5 | another-config: 6 | file: ./config.json 7 | 8 | networks: 9 | test-network: 10 | driver: bridge 11 | backend: 12 | external: true 13 | 14 | volumes: 15 | test-volume: 16 | driver: local 17 | data-volume: 18 | driver_opts: 19 | type: nfs 20 | o: addr=10.0.0.1,rw 21 | device: ":/path/to/dir" 22 | 23 | secrets: 24 | test-secret: 25 | file: ./secret.txt 26 | db-password: 27 | external: true 28 | 29 | services: 30 | test-service: 31 | image: nginx:latest 32 | container_name: test-service 33 | ports: 34 | - "8080:80" 35 | environment: 36 | - FOO=bar 37 | volumes: 38 | - test-volume:/data 39 | networks: 40 | - test-network 41 | configs: 42 | - source: test-config 43 | target: /etc/config.json 44 | secrets: 45 | - test-secret 46 | restart: unless-stopped 47 | another-service: 48 | image: redis:latest 49 | container_name: another-service 50 | networks: 51 | - backend 52 | volumes: 53 | - data-volume:/data 54 | restart: unless-stopped 55 | -------------------------------------------------------------------------------- /tasks/deploy-config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Read .dest file for {{ config_dir.path | basename }} 3 | set_fact: 4 | config_dest: "{{ lookup('template', config_dir.path + '/.dest') | trim }}" 5 | 6 | - name: Get parent ZFS dataset for {{ config_dir.path | basename }} 7 | shell: "df --output=source {{ config_dest | dirname }} | tail -1" 8 | register: parent_dataset 9 | when: docker_compose_generator_zfs_enabled | default(false) 10 | changed_when: false 11 | 12 | - name: Create ZFS dataset for {{ config_dir.path | basename }} 13 | community.general.zfs: 14 | name: "{{ parent_dataset.stdout }}/{{ config_dest | basename }}" 15 | state: present 16 | when: 17 | - docker_compose_generator_zfs_enabled | default(false) 18 | - parent_dataset.stdout is defined 19 | - parent_dataset.stdout | trim | length > 0 20 | become: true 21 | 22 | - name: Ensure destination directory exists 23 | file: 24 | path: "{{ config_dest }}" 25 | state: directory 26 | owner: "{{ docker_compose_generator_uid }}" 27 | group: "{{ docker_compose_generator_gid }}" 28 | 29 | - name: Find config files to copy (excluding .dest) 30 | find: 31 | paths: "{{ config_dir.path }}" 32 | excludes: ".dest" 33 | file_type: file 34 | delegate_to: localhost 35 | become: false 36 | register: config_files 37 | 38 | - name: Template config files to destination 39 | template: 40 | src: "{{ item.path }}" 41 | dest: "{{ config_dest }}/{{ item.path | basename }}" 42 | owner: "{{ docker_compose_generator_uid }}" 43 | group: "{{ docker_compose_generator_gid }}" 44 | loop: "{{ config_files.files }}" 45 | -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | actions: read # Required for Claude to read CI results on PRs 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | with: 31 | fetch-depth: 1 32 | 33 | - name: Run Claude Code 34 | id: claude 35 | uses: anthropics/claude-code-action@beta 36 | with: 37 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 38 | 39 | # This is an optional setting that allows Claude to read CI results on PRs 40 | additional_permissions: | 41 | actions: read 42 | 43 | # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1) 44 | # model: "claude-opus-4-1-20250805" 45 | 46 | # Optional: Customize the trigger phrase (default: @claude) 47 | # trigger_phrase: "/claude" 48 | 49 | # Optional: Trigger when specific user is assigned to an issue 50 | # assignee_trigger: "claude-bot" 51 | 52 | # Optional: Allow Claude to run specific commands 53 | # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" 54 | 55 | # Optional: Add custom instructions for Claude to customize its behavior for your project 56 | # custom_instructions: | 57 | # Follow our coding standards 58 | # Ensure all new code has tests 59 | # Use TypeScript for new files 60 | 61 | # Optional: Custom environment variables for Claude 62 | # claude_env: | 63 | # NODE_ENV: test 64 | 65 | -------------------------------------------------------------------------------- /filter_plugins/yaml_indent.py: -------------------------------------------------------------------------------- 1 | """Custom filter to indent YAML list items under their parent keys.""" 2 | 3 | 4 | def get_indent(line): 5 | """Return the indentation level (number of leading spaces) of a line.""" 6 | return len(line) - len(line.lstrip()) 7 | 8 | 9 | def is_list_key(line): 10 | """Check if line is a key that could start a list (ends with : but not ::).""" 11 | stripped = line.strip() 12 | return stripped.endswith(':') and not stripped.endswith('::') 13 | 14 | 15 | def is_list_item(line): 16 | """Check if line is a YAML list item (starts with '- ').""" 17 | return line.lstrip().startswith('- ') 18 | 19 | 20 | def indent_yaml_lists(content): 21 | """ 22 | Transform YAML so list items are indented under their parent key. 23 | 24 | Before: 25 | environment: 26 | - FOO=bar 27 | volumes: 28 | - /data:/data 29 | 30 | After: 31 | environment: 32 | - FOO=bar 33 | volumes: 34 | - /data:/data 35 | 36 | Also handles list items that are dicts: 37 | Before: 38 | configs: 39 | - source: test 40 | target: /etc/test 41 | 42 | After: 43 | configs: 44 | - source: test 45 | target: /etc/test 46 | """ 47 | lines = content.split('\n') 48 | result = [] 49 | in_list_block = False 50 | list_base_indent = 0 51 | 52 | for line in lines: 53 | stripped = line.lstrip() 54 | current_indent = get_indent(line) 55 | 56 | # Check if previous line was a key that could start a list 57 | if result and not in_list_block: 58 | prev_line = result[-1] 59 | if is_list_key(prev_line): 60 | prev_indent = get_indent(prev_line) 61 | # If this line is a list item at the same indent level 62 | if is_list_item(line) and current_indent == prev_indent: 63 | in_list_block = True 64 | list_base_indent = prev_indent 65 | 66 | if in_list_block: 67 | # Check if we've left the list block 68 | if stripped and not is_list_item(line) and current_indent <= list_base_indent: 69 | in_list_block = False 70 | result.append(line) 71 | elif is_list_item(line) and current_indent == list_base_indent: 72 | # List item - add 2 spaces 73 | result.append(' ' * (list_base_indent + 2) + stripped) 74 | elif current_indent == list_base_indent and stripped: 75 | # Continuation of list item dict - add 2 spaces 76 | result.append(' ' * (list_base_indent + 2) + stripped) 77 | elif not stripped: 78 | # Empty line 79 | result.append(line) 80 | in_list_block = False 81 | else: 82 | # Nested content - add 2 spaces 83 | result.append(' ' + line) 84 | else: 85 | result.append(line) 86 | 87 | return '\n'.join(result) 88 | 89 | 90 | class FilterModule: 91 | """Ansible filter plugin.""" 92 | 93 | def filters(self): 94 | return { 95 | 'indent_yaml_lists': indent_yaml_lists, 96 | } 97 | -------------------------------------------------------------------------------- /.github/workflows/claude-code-review.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code Review 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | # Optional: Only run on specific file changes 7 | # paths: 8 | # - "src/**/*.ts" 9 | # - "src/**/*.tsx" 10 | # - "src/**/*.js" 11 | # - "src/**/*.jsx" 12 | 13 | jobs: 14 | claude-review: 15 | # Optional: Filter by PR author 16 | # if: | 17 | # github.event.pull_request.user.login == 'external-contributor' || 18 | # github.event.pull_request.user.login == 'new-developer' || 19 | # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' 20 | 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | pull-requests: read 25 | issues: read 26 | id-token: write 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | with: 32 | fetch-depth: 1 33 | 34 | - name: Run Claude Code Review 35 | id: claude-review 36 | uses: anthropics/claude-code-action@beta 37 | with: 38 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 39 | 40 | # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1) 41 | # model: "claude-opus-4-1-20250805" 42 | 43 | # Direct prompt for automated review (no @claude mention needed) 44 | direct_prompt: | 45 | Please review this pull request and provide feedback on: 46 | - Code quality and best practices 47 | - Potential bugs or issues 48 | - Performance considerations 49 | - Security concerns 50 | - Test coverage 51 | 52 | Be constructive and helpful in your feedback. 53 | 54 | # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR 55 | # use_sticky_comment: true 56 | 57 | # Optional: Customize review based on file types 58 | # direct_prompt: | 59 | # Review this PR focusing on: 60 | # - For TypeScript files: Type safety and proper interface usage 61 | # - For API endpoints: Security, input validation, and error handling 62 | # - For React components: Performance, accessibility, and best practices 63 | # - For tests: Coverage, edge cases, and test quality 64 | 65 | # Optional: Different prompts for different authors 66 | # direct_prompt: | 67 | # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && 68 | # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || 69 | # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} 70 | 71 | # Optional: Add specific tools for running tests or linting 72 | # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" 73 | 74 | # Optional: Skip review for certain conditions 75 | # if: | 76 | # !contains(github.event.pull_request.title, '[skip-review]') && 77 | # !contains(github.event.pull_request.title, '[WIP]') 78 | 79 | -------------------------------------------------------------------------------- /tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: ensure destination for compose file exists 3 | file: 4 | path: "{{ docker_compose_generator_output_path }}" 5 | state: directory 6 | 7 | - name: Find all compose files 8 | set_fact: 9 | compose_files: >- 10 | {{ 11 | query('filetree', services_directory + 12 | (docker_compose_hostname | default(inventory_hostname))) | 13 | selectattr('state', 'eq', 'file') | 14 | selectattr('path', 'regex', '.ya?ml$') | 15 | rejectattr('path', 'regex', 'config-[^/]+/') | 16 | list 17 | }} 18 | 19 | - name: Parse all compose files 20 | set_fact: 21 | parsed_compose_files: "{{ parsed_compose_files | default([]) + [lookup('template', item.src) | from_yaml] }}" 22 | loop: "{{ compose_files | sort(attribute='src') }}" 23 | loop_control: 24 | label: "{{ item.src | basename }}" 25 | when: (item.src | dirname | basename) not in (disabled_compose_files | default([])) 26 | no_log: true 27 | 28 | - name: Merge all compose sections 29 | set_fact: 30 | all_configs: "{{ all_configs | default({}) | combine(item.configs | default({})) }}" 31 | all_networks: "{{ all_networks | default({}) | combine(item.networks | default({})) }}" 32 | all_volumes: "{{ all_volumes | default({}) | combine(item.volumes | default({})) }}" 33 | all_secrets: "{{ all_secrets | default({}) | combine(item.secrets | default({})) }}" 34 | all_services: "{{ all_services | default({}) | combine(item.services | default({})) }}" 35 | loop: "{{ parsed_compose_files }}" 36 | loop_control: 37 | label: "merging sections" 38 | no_log: true 39 | 40 | - name: Build combined compose content 41 | set_fact: 42 | combined_compose_raw: | 43 | # Generated by ironicbadger.docker-compose-generator 44 | # badger badger badger mushroom mushrooooom... 45 | 46 | {% if all_configs %} 47 | configs: 48 | {{ all_configs | to_nice_yaml(indent=2) | indent(2) | trim }} 49 | {% endif %} 50 | {% if all_networks %} 51 | networks: 52 | {{ all_networks | to_nice_yaml(indent=2) | indent(2) | trim }} 53 | {% endif %} 54 | {% if all_volumes %} 55 | volumes: 56 | {{ all_volumes | to_nice_yaml(indent=2) | indent(2) | trim }} 57 | {% endif %} 58 | {% if all_secrets %} 59 | secrets: 60 | {{ all_secrets | to_nice_yaml(indent=2) | indent(2) | trim }} 61 | {% endif %} 62 | services: 63 | {{ all_services | to_nice_yaml(indent=2) | indent(2) | trim }} 64 | no_log: true 65 | 66 | - name: Fix list indentation for better readability 67 | set_fact: 68 | combined_compose: "{{ combined_compose_raw | indent_yaml_lists }}" 69 | no_log: true 70 | 71 | - name: Write combined compose file 72 | copy: 73 | content: "{{ combined_compose }}" 74 | dest: "{{ docker_compose_generator_output_path }}/compose.yaml" 75 | owner: "{{ docker_compose_generator_uid }}" 76 | group: "{{ docker_compose_generator_gid }}" 77 | no_log: true 78 | 79 | - name: Find all config directories 80 | find: 81 | paths: "{{ services_directory }}{{ docker_compose_hostname | default(inventory_hostname) }}" 82 | patterns: "config-*" 83 | file_type: directory 84 | recurse: yes 85 | delegate_to: localhost 86 | become: false 87 | register: config_dirs 88 | 89 | - name: Deploy config files from config-* directories 90 | include_tasks: deploy-config.yml 91 | loop: "{{ config_dirs.files }}" 92 | loop_control: 93 | loop_var: config_dir 94 | when: config_dirs.files | length > 0 -------------------------------------------------------------------------------- /tests/test-playbook.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Test docker-compose-generator role 3 | hosts: localhost 4 | connection: local 5 | gather_facts: false 6 | 7 | vars: 8 | services_directory: "{{ playbook_dir }}/services/" 9 | docker_compose_generator_output_path: "{{ playbook_dir }}/output" 10 | docker_compose_generator_uid: "{{ ansible_user_uid }}" 11 | docker_compose_generator_gid: "{{ ansible_user_gid }}" 12 | docker_compose_hostname: "test-app" 13 | 14 | pre_tasks: 15 | - name: Gather minimal facts 16 | setup: 17 | gather_subset: 18 | - "!all" 19 | - user 20 | 21 | roles: 22 | - role: "{{ playbook_dir }}/.." 23 | 24 | post_tasks: 25 | - name: Read generated compose file 26 | slurp: 27 | src: "{{ docker_compose_generator_output_path }}/compose.yaml" 28 | register: generated_compose_raw 29 | 30 | - name: Parse generated compose file 31 | set_fact: 32 | generated_compose: "{{ generated_compose_raw.content | b64decode | from_yaml }}" 33 | 34 | - name: Verify configs section exists 35 | assert: 36 | that: 37 | - generated_compose.configs is defined 38 | - "'test-config' in generated_compose.configs" 39 | - "'another-config' in generated_compose.configs" 40 | fail_msg: "configs section missing or incomplete" 41 | success_msg: "configs section OK" 42 | 43 | - name: Verify networks section exists 44 | assert: 45 | that: 46 | - generated_compose.networks is defined 47 | - "'test-network' in generated_compose.networks" 48 | - "'backend' in generated_compose.networks" 49 | fail_msg: "networks section missing or incomplete" 50 | success_msg: "networks section OK" 51 | 52 | - name: Verify volumes section exists 53 | assert: 54 | that: 55 | - generated_compose.volumes is defined 56 | - "'test-volume' in generated_compose.volumes" 57 | - "'data-volume' in generated_compose.volumes" 58 | fail_msg: "volumes section missing or incomplete" 59 | success_msg: "volumes section OK" 60 | 61 | - name: Verify secrets section exists 62 | assert: 63 | that: 64 | - generated_compose.secrets is defined 65 | - "'test-secret' in generated_compose.secrets" 66 | - "'db-password' in generated_compose.secrets" 67 | fail_msg: "secrets section missing or incomplete" 68 | success_msg: "secrets section OK" 69 | 70 | - name: Verify services section exists 71 | assert: 72 | that: 73 | - generated_compose.services is defined 74 | - "'test-service' in generated_compose.services" 75 | - "'another-service' in generated_compose.services" 76 | fail_msg: "services section missing or incomplete" 77 | success_msg: "services section OK" 78 | 79 | - name: Verify service details preserved 80 | assert: 81 | that: 82 | - generated_compose.services['test-service'].image == 'nginx:latest' 83 | - generated_compose.services['test-service'].container_name == 'test-service' 84 | - generated_compose.services['another-service'].image == 'redis:latest' 85 | fail_msg: "service details not preserved correctly" 86 | success_msg: "service details OK" 87 | 88 | - name: Verify unicode content preserved 89 | assert: 90 | that: 91 | - "'unicode-service' in generated_compose.services" 92 | - "'unicode-config' in generated_compose.configs" 93 | - "generated_compose.services['unicode-service'].labels | length == 2" 94 | fail_msg: "unicode content not preserved correctly" 95 | success_msg: "unicode content OK" 96 | 97 | - name: Verify output is valid YAML by re-parsing 98 | set_fact: 99 | reparsed_compose: "{{ generated_compose_raw.content | b64decode | from_yaml }}" 100 | 101 | - name: Verify re-parsed YAML matches original 102 | assert: 103 | that: 104 | - reparsed_compose.services | length == generated_compose.services | length 105 | fail_msg: "YAML re-parsing produced different result" 106 | success_msg: "YAML validity OK" 107 | 108 | - name: Clean up output directory 109 | file: 110 | path: "{{ docker_compose_generator_output_path }}" 111 | state: absent 112 | 113 | - name: All tests passed 114 | debug: 115 | msg: "All compose generator tests passed successfully!" 116 | -------------------------------------------------------------------------------- /tests/test_yaml_indent.py: -------------------------------------------------------------------------------- 1 | """Unit tests for yaml_indent filter plugin.""" 2 | 3 | import sys 4 | import os 5 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'filter_plugins')) 6 | 7 | import pytest 8 | from yaml_indent import indent_yaml_lists, get_indent, is_list_key, is_list_item 9 | 10 | 11 | class TestHelperFunctions: 12 | """Tests for helper functions.""" 13 | 14 | def test_get_indent_no_indent(self): 15 | assert get_indent("hello") == 0 16 | 17 | def test_get_indent_with_spaces(self): 18 | assert get_indent(" hello") == 2 19 | assert get_indent(" hello") == 4 20 | 21 | def test_get_indent_empty_line(self): 22 | assert get_indent("") == 0 23 | 24 | def test_is_list_key_simple(self): 25 | assert is_list_key("environment:") is True 26 | assert is_list_key(" volumes:") is True 27 | 28 | def test_is_list_key_double_colon(self): 29 | assert is_list_key("foo::") is False 30 | 31 | def test_is_list_key_not_key(self): 32 | assert is_list_key("- item") is False 33 | assert is_list_key("key: value") is False 34 | 35 | def test_is_list_item_simple(self): 36 | assert is_list_item("- item") is True 37 | assert is_list_item(" - indented") is True 38 | 39 | def test_is_list_item_not_list(self): 40 | assert is_list_item("key: value") is False 41 | assert is_list_item("plain text") is False 42 | 43 | 44 | class TestIndentYamlLists: 45 | """Tests for the main indent_yaml_lists function.""" 46 | 47 | def test_simple_list_indentation(self): 48 | input_yaml = """environment: 49 | - FOO=bar 50 | - BAZ=qux""" 51 | expected = """environment: 52 | - FOO=bar 53 | - BAZ=qux""" 54 | assert indent_yaml_lists(input_yaml) == expected 55 | 56 | def test_multiple_lists(self): 57 | input_yaml = """environment: 58 | - FOO=bar 59 | volumes: 60 | - /data:/data""" 61 | expected = """environment: 62 | - FOO=bar 63 | volumes: 64 | - /data:/data""" 65 | assert indent_yaml_lists(input_yaml) == expected 66 | 67 | def test_nested_dict_in_list(self): 68 | input_yaml = """configs: 69 | - source: test 70 | target: /etc/test""" 71 | expected = """configs: 72 | - source: test 73 | target: /etc/test""" 74 | assert indent_yaml_lists(input_yaml) == expected 75 | 76 | def test_empty_input(self): 77 | assert indent_yaml_lists("") == "" 78 | 79 | def test_no_lists(self): 80 | input_yaml = """services: 81 | web: 82 | image: nginx""" 83 | assert indent_yaml_lists(input_yaml) == input_yaml 84 | 85 | def test_already_indented_list(self): 86 | input_yaml = """services: 87 | web: 88 | environment: 89 | - FOO=bar""" 90 | expected = """services: 91 | web: 92 | environment: 93 | - FOO=bar""" 94 | assert indent_yaml_lists(input_yaml) == expected 95 | 96 | def test_deeply_nested(self): 97 | input_yaml = """services: 98 | web: 99 | configs: 100 | - source: app-config 101 | target: /etc/app/config.json""" 102 | expected = """services: 103 | web: 104 | configs: 105 | - source: app-config 106 | target: /etc/app/config.json""" 107 | assert indent_yaml_lists(input_yaml) == expected 108 | 109 | def test_unicode_content(self): 110 | input_yaml = """labels: 111 | - "description=日本語テスト" 112 | - "emoji=🚀" 113 | - "accent=café""" 114 | expected = """labels: 115 | - "description=日本語テスト" 116 | - "emoji=🚀" 117 | - "accent=café""" 118 | assert indent_yaml_lists(input_yaml) == expected 119 | 120 | def test_empty_lines_between_sections(self): 121 | input_yaml = """environment: 122 | - FOO=bar 123 | 124 | volumes: 125 | - /data:/data""" 126 | expected = """environment: 127 | - FOO=bar 128 | 129 | volumes: 130 | - /data:/data""" 131 | assert indent_yaml_lists(input_yaml) == expected 132 | 133 | def test_key_with_value_not_list(self): 134 | input_yaml = """image: nginx:latest 135 | container_name: web 136 | environment: 137 | - FOO=bar""" 138 | expected = """image: nginx:latest 139 | container_name: web 140 | environment: 141 | - FOO=bar""" 142 | assert indent_yaml_lists(input_yaml) == expected 143 | 144 | def test_preserves_double_colon(self): 145 | input_yaml = """weird:: 146 | - item""" 147 | # Double colon should not trigger list indent 148 | assert indent_yaml_lists(input_yaml) == input_yaml 149 | 150 | 151 | if __name__ == "__main__": 152 | pytest.main([__file__, "-v"]) 153 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ansible-role-docker-compose-generator 2 | 3 | This role is designed to ingest directories of `compose.yaml` files and output a sanitised version to a remote host using Ansible. 4 | 5 | > ⚠️ **Warning:** v1 of this role used a completely different data structure. See [v1 vs v2 of this role](#v1-of-this-role-vs-v2) 6 | 7 | I wrote the following [blog post](https://blog.ktz.me/docker-compose-generator-v2-release/) on the v2 release for more info. 8 | 9 | ## Usage 10 | 11 | Import this role into your Ansible setup either as a [git submodule](https://blog.ktz.me/git-submodules-for-fun-and-profit-with-ansible/) or via Ansible Galaxy. 12 | 13 | In the root of your git repo create a `services` directory and populate it as follows: 14 | 15 | ``` 16 | . 17 | └── services 18 | ├── ansible-hostname1 19 | │ ├── librespeed 20 | │ │ └── compose.yaml 21 | │ ├── jellyfin 22 | │ │ └── compose.yaml 23 | └── ansible-hostname2 24 | └── uptime-kuma 25 | └── compose.yaml 26 | ``` 27 | 28 | Each `compose.yaml` file is a standard format file, for example librespeed looks like this: 29 | 30 | ``` 31 | services: 32 | librespeed: 33 | image: lscr.io/linuxserver/librespeed 34 | container_name: librespeed 35 | ports: 36 | - 8008:80 37 | environment: 38 | - "TZ={{ host_timezone }}" 39 | - "PASSWORD={{ testpass }}" 40 | restart: unless-stopped 41 | ``` 42 | 43 | Notice that variable interpolation is supported. The source of these variables can be either an encryped secrets file via Ansible vault (read more about that [here](https://blog.ktz.me/secret-management-with-docker-compose-and-ansible/) - or see [ironicbadger/infra](https://github.com/ironicbadger/infra) for an implemented example), an `env` file you manually place alongside the `compose.yaml` on the remote host (see [docker compose variable interpolation](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/#interpolation-syntax)), or any other standard Ansible variable source. 44 | 45 | Multiple services per compose file are also supported. Useful to run a database alongside an app, for example. 46 | 47 | By default, if a `compose.yaml` file is found it will be included in the automation, and placed into the output `compose.yaml` on the remote host. This file is placed under the `docker_compose_generator_output_path` which is the home folder of the ssh user. The role also supports disabling specific compose files by matching the name of the file against a `host_var` or `group_var` file with the following variable: 48 | 49 | ``` 50 | disabled_compose_files: 51 | - jellyfin 52 | ``` 53 | 54 | ## Custom hostnames 55 | 56 | By default, the role is looking for a directory structure under `services/` which matches your Ansible hostname. If your hostname doesn't match the name of this directory for some reason (maybe it's an IP address, rather than a hostname), you can override the name with the variable: 57 | 58 | ``` 59 | docker_compose_hostname: my-custom-hostname 60 | ``` 61 | 62 | ## Override services directory location 63 | 64 | By default, the role is looking for services by determining where the `playbook_dir` is and appending `services/`. 65 | If your playbooks are for example inside a dedicated playbooks directory you can overwrite the services location by setting `services_directory` either in a task var, group_vars or host_vars. 66 | 67 | ## Config File Deployment 68 | 69 | The role supports deploying configuration files alongside your services using `config-*` directories. This is useful for apps that require config files (like Glance, Prometheus, etc.) that should be managed alongside the compose definition. 70 | 71 | ### Directory Structure 72 | 73 | ``` 74 | services/ 75 | └── ansible-hostname1 76 | └── monitoring 77 | ├── compose.yaml 78 | └── config-glance/ 79 | ├── .dest 80 | └── glance.yml 81 | ``` 82 | 83 | ### How It Works 84 | 85 | 1. Create a `config-` directory alongside your compose files 86 | 2. Add a `.dest` file containing the destination path on the remote host (supports Ansible variable interpolation) 87 | 3. Add your config files - they will be copied as-is (no templating) 88 | 89 | Example `.dest` file: 90 | ``` 91 | {{ appdata_path }}/apps/glance 92 | ``` 93 | 94 | The role will: 95 | - Find all `config-*` directories recursively 96 | - Read the `.dest` file to determine where to copy files 97 | - Create the destination directory with proper ownership 98 | - Copy all files (except `.dest`) to the destination 99 | 100 | ### Optional: ZFS Dataset Creation 101 | 102 | If your target host uses ZFS, you can enable automatic dataset creation for config directories: 103 | 104 | ```yaml 105 | # group_vars or host_vars 106 | docker_compose_generator_zfs_enabled: true 107 | ``` 108 | 109 | When enabled, the role will: 110 | 1. Detect the parent ZFS dataset of the destination path 111 | 2. Create a child dataset for the config directory 112 | 3. The directory is then auto-mounted by ZFS 113 | 114 | This provides snapshot and replication benefits for your app configs. 115 | 116 | ## v1 of this role vs v2 117 | 118 | v1 of this role used a large custom data structure and an ever more complex jinja2 based templating approach. The custom nature of this approach added friction when adding new services and made it difficult to copy/paste from upstream repositories to try things out quickly. 119 | 120 | v2 supports using standalone, native compose files. This makes it much more straightforward to try out new software without needing to 'convert' it to work with the v1 custom data structures. 121 | 122 | If you find any edge cases I've missed for v2, please open an issue or PR. I'd be happy to review. 123 | 124 | Special thanks goes to [u/fuzzymistborn](https://github.com/fuzzymistborn) for the spark for the idea to make this change. As ever, you can find a full working example of my usage of this role over at [ironicbadger/infra](https://github.com/ironicbadger/infra). 125 | --------------------------------------------------------------------------------