<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>ngetchell.com - Posts tagged with &quot;Claude Code&quot;</title><description>All blog posts tagged with &quot;Claude Code&quot;</description><link>https://ngetchell.com/</link><item><title>Testing Ansible Roles with Molecule on Digital Ocean Droplets</title><link>https://ngetchell.com/blog/testing-ansible-roles-with-molecule-on-digital-ocean-droplets/</link><guid isPermaLink="true">https://ngetchell.com/blog/testing-ansible-roles-with-molecule-on-digital-ocean-droplets/</guid><description>For years, I’ve been using Ansible to configure my homelab, blog, and workstations. After testing out Chef and Salt, I’m glad I happened upon Ansible. The tool has grown more and I’ve gotten tons of value out of putting in the effort to learn it. Early on, I would manually test my code to make […]</description><pubDate>Wed, 21 Oct 2026 00:58:34 GMT</pubDate><content:encoded>
&lt;p&gt;For years, I’ve been using Ansible to configure my homelab, blog, and workstations. After testing out Chef and Salt, I’m glad I happened upon Ansible. The tool has grown more and I’ve gotten tons of value out of putting in the effort to learn it. &lt;/p&gt;



&lt;p&gt;Early on, I would manually test my code to make sure whatever I added worked. It was tougher since my work used RHEL-based distributions so I would always check my work against centos as well as debian distros. This grew tiresome and I looked for ways to automate the testing. &lt;/p&gt;



&lt;p&gt;Soon, I found Vagrant, which let me spin up local virtual machines quickly then test my Ansible roles against them to see if they had the desired impact. &lt;/p&gt;



&lt;p&gt;Then, I was able to stand on top of Vagrant with Molecule for testing roles. It allowed you to choose a &lt;em&gt;driver&lt;/em&gt; to set up the machine, apply your role, apply it again to see if it was idempotent, then would tear down the VM and report the results. I even got it all running in my own GitLab instance as part of a merge request. Adding the latest Ubuntu release was a two line change and BOOM, it was now part of your tests. &lt;/p&gt;



&lt;p&gt;It was glorious…..for a short while. Then, a big breaking change came from the molecule developers after I set up testing on all of my roles. My productivity with the tool was never the same.&lt;/p&gt;



&lt;p&gt;I would hard-code the molecule version I needed but the writing was on the wall. The vagrant driver I’d been using was no longer supported and everything would be moving to docker. My issue with that was that lots of my roles configured systemd services and emulating that in a docker container made for a testing nightmare. I could never confirm I was fixing test infrastructure bugs or bugs from my roles. &lt;/p&gt;



&lt;p&gt;Frankly, I was spending more time keeping my test framework up to date than I was developing roles to solve real problems. I left my Molecule tests abandoned in my repository for years. &lt;/p&gt;



&lt;p&gt;Then enters Claude Code. I’ve had lots of experience building useful web apps for myself. Even in languages and frameworks I wasn’t familiar with. &lt;/p&gt;



&lt;p&gt;It got me thinking, is it time to revamp my role testing strategy with Molecule? With the power of the Digital Ocean API, the answer is yes. &lt;/p&gt;



&lt;p&gt;For prerequisites, you’ll need Ansible and Molecule installed via your python package manage of choice. You’ll also need &lt;a href=&quot;https://docs.digitalocean.com/reference/doctl/&quot;&gt;doctl&lt;/a&gt; to control the creation of the DO droplets. &lt;/p&gt;



&lt;p&gt;Below is the Molecule configuration I use to test my ssh_hardening role. &lt;/p&gt;



&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# file: create.yml&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;# harness: Claude Code&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;# model: Opus 5.5&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;---&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;- name: Create DigitalOcean droplets&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  hosts: localhost&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  gather_facts: false&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  vars:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    do_region: &quot;{{ lookup(&apos;ansible.builtin.env&apos;, &apos;DO_REGION&apos;) | default(&apos;nyc3&apos;, true) }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    do_size: &quot;{{ lookup(&apos;ansible.builtin.env&apos;, &apos;DO_SIZE&apos;) | default(&apos;s-1vcpu-1gb&apos;, true) }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    do_project: &quot;{{ lookup(&apos;ansible.builtin.env&apos;, &apos;DO_PROJECT&apos;) | default(&apos;Molecule&apos;, true) }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    do_state_file: &quot;{{ molecule_ephemeral_directory }}/do_state.yml&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    do_key_file: &quot;{{ molecule_ephemeral_directory }}/id_ed25519&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    do_user: molecule&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  tasks:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    # Accepts DIGITALOCEAN_ACCESS_TOKEN or a token saved with `doctl auth init`.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Check DigitalOcean authentication&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.command:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        argv: &amp;amp;#91;doctl, account, get]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      register: do_account&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      changed_when: false&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      failed_when: false&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Assert DigitalOcean authentication works&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.assert:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        that: do_account.rc == 0&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        fail_msg: &amp;gt;-&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          doctl cannot authenticate. Run `doctl auth init` or set DIGITALOCEAN_ACCESS_TOKEN.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          {{ do_account.stderr | default(do_account.msg | default(&apos;&apos;)) }}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: List DigitalOcean projects&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.command:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        argv: &amp;amp;#91;doctl, projects, list, --output=json]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      register: do_projects&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      changed_when: false&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Set project ID&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.set_fact:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        do_project_id: &quot;{{ do_projects.stdout | from_json | selectattr(&apos;name&apos;, &apos;eq&apos;, do_project) | map(attribute=&apos;id&apos;) | first | default(&apos;&apos;) }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Assert project exists&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.assert:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        that: do_project_id | length &amp;gt; 0&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        fail_msg: DigitalOcean project &apos;{{ do_project }}&apos; not found. Create it or set DO_PROJECT.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    # Unique per run so parallel runs (macOS, Linux, CI) do not collide.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Generate run ID&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.set_fact:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        do_run_id: &quot;{{ lookup(&apos;ansible.builtin.password&apos;, &apos;/dev/null&apos;, chars=&amp;amp;#91;&apos;ascii_lowercase&apos;, &apos;digits&apos;], length=8) }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Generate ephemeral SSH key&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.command:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        argv: &amp;amp;#91;ssh-keygen, -q, -t, ed25519, -N, &quot;&quot;, -C, &quot;molecule-{{ do_run_id }}&quot;, -f, &quot;{{ do_key_file }}&quot;]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        creates: &quot;{{ do_key_file }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    # Registering a key stops DigitalOcean from setting and emailing a root password.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Register SSH key with DigitalOcean&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.command:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        argv:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - doctl&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - compute&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - ssh-key&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - import&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - molecule-{{ do_run_id }}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - --public-key-file={{ do_key_file }}.pub&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - --output=json&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      register: do_ssh_key&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      changed_when: true&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    # Write state before droplets exist, so destroy can always clean up.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Write DigitalOcean state&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.copy:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        dest: &quot;{{ do_state_file }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        mode: &quot;0600&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        content: |&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          run_id: {{ do_run_id }}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          ssh_key_id: {{ (do_ssh_key.stdout | from_json | first).id }}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    # ssh_pwauth makes cloud-init write 50-cloud-init.conf with&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    # PasswordAuthentication yes, which the role must override.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Write cloud-init user data&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.copy:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        dest: &quot;{{ molecule_ephemeral_directory }}/user-data.yml&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        mode: &quot;0600&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        content: |&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          #cloud-config&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          ssh_pwauth: true&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          users:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            - name: {{ do_user }}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;              shell: /bin/bash&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;              sudo: ALL=(ALL) NOPASSWD:ALL&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;              ssh_authorized_keys:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                - {{ lookup(&apos;ansible.builtin.file&apos;, do_key_file ~ &apos;.pub&apos;) }}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Create droplets&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.command:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        argv:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - doctl&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - compute&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - droplet&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - create&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - &quot;molecule-{{ item.name }}-{{ do_run_id }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - --image={{ item.image }}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - --size={{ do_size }}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - --region={{ do_region }}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - --project-id={{ do_project_id }}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - --ssh-keys={{ (do_ssh_key.stdout | from_json | first).id }}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - --tag-names=molecule,molecule-{{ do_run_id }}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - --user-data-file={{ molecule_ephemeral_directory }}/user-data.yml&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - --wait&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          - --output=json&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      loop: &quot;{{ molecule_yml.platforms }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      loop_control:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        label: &quot;{{ item.name }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      register: do_droplets&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      changed_when: true&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Write Molecule instance config&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.copy:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        dest: &quot;{{ molecule_instance_config }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        mode: &quot;0600&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        content: &quot;{{ do_instances | to_nice_yaml }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      vars:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        do_instances: &amp;gt;-&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          {%- set out = &amp;amp;#91;] -%}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          {%- for r in do_droplets.results -%}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          {%- set d = r.stdout | from_json | first -%}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          {%- set _ = out.append({&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                &apos;instance&apos;: r.item.name,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                &apos;address&apos;: d.networks.v4 | selectattr(&apos;type&apos;, &apos;eq&apos;, &apos;public&apos;) | map(attribute=&apos;ip_address&apos;) | first,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                &apos;user&apos;: do_user,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                &apos;port&apos;: 22,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;                &apos;identity_file&apos;: do_key_file,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;              }) -%}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          {%- endfor -%}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          {{ out }}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Wait for SSH&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.wait_for:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        host: &quot;{{ item.address }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        port: 22&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        search_regex: OpenSSH&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        timeout: 300&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      loop: &quot;{{ lookup(&apos;ansible.builtin.file&apos;, molecule_instance_config) | from_yaml }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      loop_control:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        label: &quot;{{ item.instance }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;...&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;/p&gt;



&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# file: converge.yml&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;---&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;- name: Converge&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  hosts: all&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  become: true&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  roles:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - ssh_hardening&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;...&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;



&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# file: destroy.yml&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;# harness: Claude Code&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;# model: Opus 5.5&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;---&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;- name: Destroy DigitalOcean droplets&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  hosts: localhost&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  gather_facts: false&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  vars:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    do_state_file: &quot;{{ molecule_ephemeral_directory }}/do_state.yml&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  tasks:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Read DigitalOcean state&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.stat:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        path: &quot;{{ do_state_file }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      register: do_state_stat&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Destroy run resources&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      when: do_state_stat.stat.exists&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      vars:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        do_state: &quot;{{ lookup(&apos;ansible.builtin.file&apos;, do_state_file) | from_yaml }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      block:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        - name: Delete droplets&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          ansible.builtin.command:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            argv:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;              - doctl&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;              - compute&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;              - droplet&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;              - delete&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;              - --tag-name=molecule-{{ do_state.run_id }}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;              - --force&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          register: do_delete&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          changed_when: do_delete.rc == 0&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          failed_when: do_delete.rc != 0 and &apos;not found&apos; not in do_delete.stderr&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        - name: Delete SSH key&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          ansible.builtin.command:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            argv: &amp;amp;#91;doctl, compute, ssh-key, delete, &quot;{{ do_state.ssh_key_id }}&quot;, --force]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          register: do_key_delete&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          changed_when: do_key_delete.rc == 0&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          failed_when: do_key_delete.rc != 0 and &apos;not found&apos; not in do_key_delete.stderr&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        - name: Remove DigitalOcean state&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          ansible.builtin.file:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            path: &quot;{{ item }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            state: absent&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;          loop:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            - &quot;{{ do_state_file }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            - &quot;{{ molecule_ephemeral_directory }}/id_ed25519&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            - &quot;{{ molecule_ephemeral_directory }}/id_ed25519.pub&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;            - &quot;{{ molecule_ephemeral_directory }}/user-data.yml&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Write empty instance config&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.copy:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        dest: &quot;{{ molecule_instance_config }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        content: &quot;&amp;amp;#91;]\n&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        mode: &quot;0600&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;...&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;



&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# file: molecule.yml&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;# harness: Claude Code&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;# model: Opus 5.5&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;---&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;# Droplets are created and destroyed with doctl.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;# Requires doctl, authenticated with `doctl auth init` or DIGITALOCEAN_ACCESS_TOKEN.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;# Optional: DO_REGION (default nyc3), DO_SIZE (default s-1vcpu-1gb),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;# DO_PROJECT (default Molecule; the project must already exist).&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;driver:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  name: default&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  options:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    managed: true&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;# Slugs: doctl compute image list-distribution --public&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;platforms:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  - name: ubuntu2604&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    image: ubuntu-26-04-x64&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  - name: ubuntu2404&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    image: ubuntu-24-04-x64&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  - name: ubuntu2204&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    image: ubuntu-22-04-x64&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  - name: debian13&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    image: debian-13-x64&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;provisioner:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  name: ansible&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  env:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ANSIBLE_ROLES_PATH: ${MOLECULE_PROJECT_DIRECTORY}/roles&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;scenario:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  test_sequence:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - destroy&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - syntax&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - create&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - prepare&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - converge&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - idempotence&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - verify&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - destroy&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;...&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;



&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# file: prepare.yml&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;# harness: Claude Code&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;# model: Opus 5.5&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;---&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;- name: Wait for droplets to finish booting&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  hosts: all&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  gather_facts: false&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  tasks:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Wait for SSH login&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.wait_for_connection:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        timeout: 300&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    # Exit code 2 means done with recoverable errors, which DO images often report.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Wait for cloud-init&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.command: cloud-init status --wait&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      register: cloud_init_status&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      changed_when: false&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      failed_when: cloud_init_status.rc not in &amp;amp;#91;0, 2]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    # Proves the test starts from an insecure baseline.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Assert cloud-init enabled password logins&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.command: grep -Eiqr &apos;^PasswordAuthentication yes&apos; /etc/ssh/sshd_config /etc/ssh/sshd_config.d&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      become: true&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      changed_when: false&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;...&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;



&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# file: verify.yml&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;# harness: Claude Code&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;# model: Opus 5.5&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;---&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;- name: Verify effective sshd configuration&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  hosts: all&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  become: true&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  gather_facts: false&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  vars:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    expected:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      permitrootlogin: &quot;no&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      passwordauthentication: &quot;no&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      kbdinteractiveauthentication: &quot;no&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      authenticationmethods: publickey&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      permitemptypasswords: &quot;no&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      maxauthtries: &quot;3&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      x11forwarding: &quot;no&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      allowtcpforwarding: &quot;no&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      allowagentforwarding: &quot;no&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      permittunnel: &quot;no&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      loglevel: VERBOSE&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  tasks:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    # sshd -T prints the config after includes and defaults are applied.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Read effective sshd configuration&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.command: sshd -T&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      register: sshd_t&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      changed_when: false&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Assert hardened settings are in effect&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.assert:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        that: (item.key ~ &apos; &apos; ~ item.value) in sshd_t.stdout_lines&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        fail_msg: &quot;Expected &apos;{{ item.key }} {{ item.value }}&apos; in sshd -T output.&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        quiet: true&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      loop: &quot;{{ expected | dict2items }}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      loop_control:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        label: &quot;{{ item.key }}&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;



&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# file: cleanup.yml&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;# harness: Claude Code&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;# model: Opus 5.5&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;---&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;# Molecule runs this on failure, before destroy. Show sshd state for debugging.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;- name: Collect sshd diagnostics&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  hosts: all&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  become: true&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  gather_facts: false&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  ignore_unreachable: true&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  tasks:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Read recent sshd logs&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.command: journalctl --unit=ssh --lines=50 --no-pager&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      register: sshd_journal&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      changed_when: false&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      failed_when: false&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    - name: Show recent sshd logs&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      ansible.builtin.debug:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        var: sshd_journal.stdout_lines&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      when: sshd_journal.stdout_lines is defined&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;...&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The destroy file cleans up before and after your tests so you aren’t billed. Create and prepare set up the droplets. Converge actually calls your ansible role then verifier tests your role via assertions. &lt;/p&gt;



&lt;p&gt;AI is exceptionally good at this stuff. I plan on slowing backfilling my roles with proper tests across Ubuntu and Debian droplets to confirm I’m not adding regressions. I’ll also set this up to run in a Forgejo Action when I open up pull requests. &lt;/p&gt;
</content:encoded><category>Ansible</category><category>Claude Code</category><category>Digital Ocean</category><category>Molecule</category></item></channel></rss>