Using user-specific credentials with DNF and JFrog Artifactory
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.