Java discovery becomes awkward on long-lived Linux estates. One host has a vendor JDK under /opt, another uses RPM alternatives, a third has only a JRE, and a fourth starts WildFly from a systemd unit with an environment file that an interactive shell never reads. A playbook that trusts which java can be perfectly repeatable and consistently wrong.

Start with an ownership contract

Decide whether the role owns Java installation or consumes an existing runtime. Mixing those responsibilities produces surprising upgrades and makes offline environments harder to support. My preferred input contract is:

  1. An explicit inventory value wins.
  2. An application-owned environment file is next, if that is the real service contract.
  3. The system alternative may be used as a discovered default.
  4. Discovery must fail if no supported runtime can be proven.

An operator override such as application_java_home should not be silently replaced by whatever happens to appear first in PATH.

Package presence is evidence, not the answer

ansible.builtin.package_facts is useful for determining which RPMs are installed. It can tell you that one or more Java packages exist and expose their versions. It cannot tell you which binary a systemd unit will execute.

- name: Gather installed package information
  ansible.builtin.package_facts:
    manager: auto

- name: Record installed Java package names
  ansible.builtin.set_fact:
    installed_java_packages: >-
      {{ ansible_facts.packages.keys()
         | select('match', '^(java-|jdk)')
         | list }}
  changed_when: false

This is good diagnostic context and useful when the role owns packages. It should not become a brittle chain of package-name guesses used to manufacture JAVA_HOME.

Resolve the effective executable

When the system alternative is the intended source, ask the operating system for the selected command and resolve the full symlink chain. Avoid parsing human-oriented alternatives --display output if readlink can give the actual target.

- name: Resolve Java selected by the host
  ansible.builtin.command:
    argv:
      - readlink
      - -f
      - /usr/bin/java
  register: discovered_java
  changed_when: false
  failed_when: false

- name: Select effective Java executable
  ansible.builtin.set_fact:
    effective_java_bin: >-
      {{ application_java_home ~ '/bin/java'
         if application_java_home | default('') | length > 0
         else discovered_java.stdout }}
  changed_when: false

If /usr/bin/java does not exist, readlink fails and the next assertion should explain the accepted override. Deriving JAVA_HOME means removing /bin/java from the resolved executable—not taking a fixed number of parent directories from an unresolved symlink.

- name: Derive Java home
  ansible.builtin.set_fact:
    effective_java_home: "{{ effective_java_bin | dirname | dirname }}"
  when: effective_java_bin | length > 0
  changed_when: false

Execute the candidate and parse once

A path existing is not enough. It may reference a removed target, lack execute permission for the service user, have the wrong architecture, or be a runtime version the application does not support.

- name: Read effective Java version
  ansible.builtin.command:
    argv:
      - "{{ effective_java_bin }}"
      - -version
  register: java_version_result
  changed_when: false
  check_mode: false

- name: Extract Java major version
  ansible.builtin.set_fact:
    effective_java_major: >-
      {{ (java_version_result.stderr ~ java_version_result.stdout)
         | regex_search('version "(?:1\\.)?([0-9]+)', '\\1')
         | first
         | int }}
  changed_when: false

- name: Enforce the application runtime contract
  ansible.builtin.assert:
    that:
      - effective_java_major in supported_java_majors
    fail_msg: >-
      Java {{ effective_java_major }} at {{ effective_java_bin }} is unsupported.
      Expected one of {{ supported_java_majors | join(', ') }}.

java -version traditionally writes to standard error, although scripts should safely consider both streams. Test the parser against the vendor outputs present in your estate. Parsing is a compatibility boundary and deserves fixtures like any other input format.

Validate as the service user

Root proving that a binary executes does not prove that the application account can traverse every parent directory or read vendor runtime files.

- name: Validate Java as the application account
  become: true
  become_user: "{{ application_user }}"
  ansible.builtin.command:
    argv: ["{{ effective_java_bin }}", "-version"]
  environment:
    JAVA_HOME: "{{ effective_java_home }}"
  changed_when: false

Then render the same effective_java_home into the systemd environment file or application configuration. Do not rediscover Java independently in a template, handler, startup script, and health check. Four discovery mechanisms eventually produce four answers.

Do not let check mode invent facts

Discovery commands are read-only, but Ansible may skip commands in check mode depending on how they are written and leave registered values incomplete. Mark essential probes with check_mode: false, keep them unchanged, and ensure assertions still run. A dry run that bypasses the runtime contract is not much of a dry run.

One fact, clear provenance

The useful output is not only /usr/lib/jvm/.... Record where it came from: inventory override, application environment, or system alternative. That provenance makes later incidents much easier to understand.

Upstream references: Ansible package facts and facts and variables. For implementation work around this pattern, see Ansible and Linux automation.