Did you know that you can pass environment values into dnf and it will use them in your repo files?

I very recently learned this. The thing that it made me realize is that I could use it to pass user-specific credentials! Before seeing this I had been tossing around the idea of writing a custom plugin to get the calling user’s credentials and using them to connect to our JFrog Artifactory instance. So this is easier and involves less custom code. That’s always good.

Alright, so, so far we can defer passing credentials to dnf until runtime instead of having a shared set of credentials that are world-readable on your machine! I know, I know. If it’s your machine, it doesn’t matter, but if you’re sharing a box with some colleagues…

Anyway, we can make our repo file:

[example]
baseurl = https://artifactory.example.org/fedora/$releasever/$basearch/
description = Example DNF Repo
username = $ARTIFACTORY_EXAMPLE_ORG_USER
password = $ARTIFACTORY_EXAMPLE_ORG_PASSWORD
# Important to add this next line unless you want to make people angry.
skip_if_unavailable = 1

So, now we can provide our own username and password, like so:

DNF_VAR_ARTIFACTORY_EXAMPLE_ORG_USER=c0dec0dec0de \
DNF_VAR_ARTIFACTORY_EXAMPLE_ORG_PASSWORD=hunter2 \
sudo dnf install -y example-pkg

That is not my actual password. Seriously. Also, not my real username and not a real JFrog Artifactory instance.

Alright, maybe this isn’t very convenient. Also, at the time of this writing, the version of dnf available in RHEL8 doesn’t support shell-like parameter expansion despite any insistence of the documentation. So, no ability to provide default values to fall back on. Maybe in a few more months that functionality will get back-ported.

Whatever, let’s make this usable. JFrog’s CLI stores your login credentials in plaintext in your home directory. That’s not great, but NFS noroot and restrictive permissions should make that reasonably okay. Strongly prefer using an API key over your password though – particularly if your JFrog instance is set up to federate identities from your corporate network. It’s a JSON file, so that means we’re going to use jq to pull data out.

#!/bin/bash

#
# DNF wrapper
#

# Detect if we're running under `sudo`.
# This is not entirely safe or accurate.
if [ -n "${SUDO_USER}" ]; then
    # Get the home directory for SUDO_USER.
    # Fun fact: ~ expansion of home directories doesn't work with a variable,
    # so we're introducing a layer of indirection to substitute in the value
    # before getting the subshell to expand it.
    USER_HOME=$(bash -c "echo ~${SUDO_USER}")
else
    USER_HOME=${HOME}
fi

config=$(find "${USER_HOME}/.jfrog" -name jfrog-cli.conf.v\* | sort -V | tail -n 1)
if [ -f "${config}" ]; then
    if [ -n "${SUDO_USER}" ]; then
        content=$(sudo -u "${SUDO_USER}" cat "${config}")
    else
        content=$(cat "${config}")
    fi

    servers$(echo "${content}" | jq '.servers | length')
    if [ "${servers}" -gt 0 ]; then
        last_server=$((servers - 1))

        for n in $(eval echo "${0..${last_server}}"); do
            server=$(echo "${content}" | jq -r ".servers[$n] | .url")
            var_prefix=DNF_VAR_$(echo "${server^^}" | sed 's,^HTTPS://,,' | sed 's,/.*$,,' | tr '.-' '__')
            uservar=${var_prefix}_USER
            passvar=${var_prefix}_PASS
            export "${uservar}"="$(echo "${content}" | jq -r ".servers[$n] | .user")"
            export "${passvar}"="$(echo "${content}" | jq -r ".servers[$n] | .password")"
        done
    fi
fi

/usr/bin/dnf "$@"
exit $?

Neat. Stick that in /usr/local/bin and we’re cooking.

Oh. Except that I sometimes use Ansible for configuring systems and I don’t want to have hard-coded or encrypted credentials in my plays. Let’s make a play that we can prepend onto our playbook.

---
- name: Setup initial variables
  ansible.builtin.set_fact:
    credentials: "{{ credentials | default({}) }}"
    dnf_vars: "{{ dnf_vars | default([]) }}"

- name: Extract JFrog Artifactory credentials
  ansible.builtin.set_fact:
    credentials: "{{ credentials | combine([{'key': server.url | urlsplit('hostname'), 'value': {'username': server.user, 'password': server.password}}] | items2dict) }}"
    dnf_vars: "{{ dnf_vars | combine([{'key': ['DNF_VAR_', server.url | urlsplit('hostname') | regex_replace('[-\\.]', '_') | upper, '_USER'] | join, 'value': server.user}, {'key': ['DNF_VAR_', server.url | urlsplit('hostname') | regex_replace('[-\\.]', '_') | upper, '_PASS'] | join, 'value': server.password}] | items2dict) }}"
  loop: "{{ servers }}"
  vars:
    servers: "{{ ['servers'] | map('extract', lookup('ansible.builtin.file', lookup('ansible.builtin.fileglob', '{{ ansible_env.HOME }}/.jfrog/jfrog-cli.conf.v*', wantlist=true) | sort | last | default('/dev/null')) | default('{\"servers\": []}', true) | from_json) | flatten }}"
  loop_control:
    label: "server.url"
    loop_var: "server"

- name: Fail
  ansible.builtin.fail:
    msg: "Failed to get Artifactory credentials. Please login using the JFrog CLI and try again."
  when:
    - required is defined
    - required | bool
    - dnf_vars | length == 0

Okay, that’s kinda gross. But, it’s also something you don’t have to actually look at. Just prepend it to your plays and now you can pass {{ dnf_vars }} as the environment for all your ansible.builtin.package tasks.

---
- name: Extract JFrog Artifactory credentials
  hosts:
    - all
  tasks:
    - name: Extract JFrog Artifactory credentials
      include_tasks: extract_credentials.yaml
      vars:
        required: true

- hosts:
    - all
  environment: "{{ dnf_vars }}"
  roles:
    - your_role_here

Keen-eyed readers may have noticed that the extract_credentials.yaml play also created a credentials dictionary keyed by hostname. If you need to pull artifacts from Artifactory that aren’t governed by DNF, you can pass the same credentials through ansible.builtin.get_url:

---
- name: Get a tarball
  ansible.builtin.get_url:
    url: "{{ item }}"
    url_username: "{{ credentials[item | urlsplit('hostname')].username | default(omit) }}"
    url_password: "{{ credentials[item | urlsplit('hostname')].password | default(omit) }}"
  loop:
    - "https://artifactory.example.org/github/ansible/ansible/archive/refs/tags/v2.16.6.tar.gz"

Let me know on Mastodon what you think and if this helped you.