Log in to artifactapi with a Vault identity OIDC token #2

Merged
benvin merged 2 commits from benvin/artifactapi-vault-login into main 2026-10-25 19:00:15 +11:00
6 changed files with 107 additions and 0 deletions
+3
View File
@@ -1,6 +1,9 @@
FROM docker.io/woodpeckerci/plugin-docker-buildx:latest
RUN apk add --no-cache curl jq
# buildkit reads this to trust artifactapi's internal-CA registry on push
COPY unkin-ca.crt /etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt
# fallback: system trust store (docker login / other code paths)
COPY unkin-ca.crt /usr/local/share/ca-certificates/unkin-ca.crt
RUN cat /usr/local/share/ca-certificates/unkin-ca.crt >> /etc/ssl/certs/ca-certificates.crt || true
COPY artifactapi-login.sh /usr/local/bin/artifactapi-login.sh
ENTRYPOINT ["/usr/local/bin/artifactapi-login.sh"]
+10
View File
@@ -0,0 +1,10 @@
IMAGE ?= plugin-docker-buildx:test
.PHONY: build test
build:
docker build -t $(IMAGE) .
test: build
docker run --rm -v $(CURDIR):/src:ro,z --entrypoint sh $(IMAGE) /src/test/login_test.sh
test "$$(docker run --rm -v $(CURDIR)/test/stub-entrypoint.sh:/usr/local/bin/dockerd-entrypoint.sh:ro,z $(IMAGE) a 'b c')" = "plugin-docker-buildx|a|b c"
+11
View File
@@ -28,3 +28,14 @@ its own push therefore cannot rely on that trust yet.
Once published, point Woodpecker docker-buildx steps at this image instead of the
upstream one to push to the docker-internal registry.
## artifactapi login
When pushing to `artifactapi.k8s.syd1.au.unkin.net` with no `username`/`password`
or matching `logins` entry, the entrypoint logs in to Vault with the pod's service
account token (`auth/k8s/au/syd1/login`, role `woodpecker_artifactapi_writer`),
reads `identity/oidc/token/artifactapi-writer` and adds it as the registry password.
Override with `vault_addr`, `vault_role` and `vault_oidc_role`. Missing SA token or
a Vault error logs a warning and pushes unauthenticated.
Run `make test` to exercise the login logic inside the built image.
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
set -eu
ARTIFACTAPI_HOST=artifactapi.k8s.syd1.au.unkin.net
warn() { echo "artifactapi-login: $*, pushing unauthenticated" >&2; }
artifactapi_login() {
[ "${PLUGIN_DRY_RUN:-false}" != true ] || return 0
case "${PLUGIN_REGISTRY:-} ${PLUGIN_REPO:-}" in *"$ARTIFACTAPI_HOST"*) ;; *) return 0 ;; esac
case "${PLUGIN_REGISTRY:-}" in *"$ARTIFACTAPI_HOST"*) [ -z "${PLUGIN_USERNAME:-}${PLUGIN_PASSWORD:-}" ] || return 0 ;; esac
case "${PLUGIN_LOGINS:-}" in *"$ARTIFACTAPI_HOST"*) return 0 ;; esac
sa=${SA_TOKEN_FILE:-/var/run/secrets/kubernetes.io/serviceaccount/token}
[ -r "$sa" ] || { warn "no service account token at $sa"; return 0; }
vault=${PLUGIN_VAULT_ADDR:-${VAULT_ADDR:-https://vault.service.consul:8200}}
role=${PLUGIN_VAULT_ROLE:-woodpecker_artifactapi_writer}
oidc_role=${PLUGIN_VAULT_OIDC_ROLE:-artifactapi-writer}
client_token=$(SA_JWT=$(cat "$sa") jq -nc --arg role "$role" '{role:$role,jwt:env.SA_JWT}' |
curl -sfS --max-time 30 -X POST --data @- "$vault/v1/auth/k8s/au/syd1/login" |
jq -er .auth.client_token) || { warn "vault login as $role failed"; return 0; }
token=$(printf 'X-Vault-Token: %s\n' "$client_token" |
curl -sfS --max-time 30 -H @- "$vault/v1/identity/oidc/token/$oidc_role" |
jq -er .data.token) || { warn "reading identity/oidc/token/$oidc_role failed"; return 0; }
PLUGIN_LOGINS=$(printf '%s' "${PLUGIN_LOGINS:-[]}" |
OIDC_JWT=$token jq -c --arg r "$ARTIFACTAPI_HOST" '. + [{registry:$r,username:"woodpecker",password:env.OIDC_JWT}]') ||
{ warn "PLUGIN_LOGINS is not valid JSON"; return 0; }
export PLUGIN_LOGINS
echo "artifactapi-login: logged in to $ARTIFACTAPI_HOST via vault role $role" >&2
}
if [ -z "${ARTIFACTAPI_LOGIN_NOEXEC:-}" ]; then
artifactapi_login
exec /usr/local/bin/dockerd-entrypoint.sh plugin-docker-buildx "$@"
fi
+44
View File
@@ -0,0 +1,44 @@
#!/bin/sh
set -u
ARTIFACTAPI_LOGIN_NOEXEC=1 . "$(dirname "$0")/../artifactapi-login.sh"
SA_TOKEN_FILE=$(mktemp)
echo sa-jwt > "$SA_TOKEN_FILE"
REG=artifactapi.k8s.syd1.au.unkin.net
OK_LOGIN='{"registry":"'$REG'","username":"woodpecker","password":"oidc-jwt"}'
VAULT_FAIL=
no_secret_argv() { case "$*" in *sa-jwt*|*s.client*|*oidc-jwt*) echo "secret in argv: $*" >&2; return 99 ;; esac; }
jq() { no_secret_argv "$@" && command jq "$@"; }
curl() {
[ -z "$VAULT_FAIL" ] || return 22
no_secret_argv "$@" || return
case "$*" in
*auth/k8s/au/syd1/login*) grep -q '"role":"woodpecker_artifactapi_writer","jwt":"sa-jwt"' && echo '{"auth":{"client_token":"s.client"}}' ;;
*"-H @-"*identity/oidc/token/artifactapi-writer) grep -qx 'X-Vault-Token: s.client' && echo '{"data":{"token":"oidc-jwt"}}' ;;
*) return 22 ;;
esac
}
fail=0
check() {
name=$1 want=$2; shift 2
got=$(unset PLUGIN_REGISTRY PLUGIN_REPO PLUGIN_USERNAME PLUGIN_PASSWORD PLUGIN_LOGINS PLUGIN_DRY_RUN
for kv in "$@"; do export "$kv"; done
artifactapi_login 2>/dev/null; printf %s "${PLUGIN_LOGINS:-}")
if [ "$got" = "$want" ]; then echo "ok $name"; else echo "FAIL $name: got [$got] want [$want]"; fail=1; fi
}
check "registry setting logs in" "[$OK_LOGIN]" PLUGIN_REGISTRY=$REG
check "repo on artifactapi logs in" "[$OK_LOGIN]" PLUGIN_REPO=$REG/docker-internal/x
check "existing logins kept" '[{"registry":"r","username":"u","password":"p"},'"$OK_LOGIN]" PLUGIN_REGISTRY=$REG 'PLUGIN_LOGINS=[{"registry":"r","username":"u","password":"p"}]'
check "other registry skipped" "" PLUGIN_REPO=git.unkin.net/unkin/x
check "dry run skipped" "" PLUGIN_REGISTRY=$REG PLUGIN_DRY_RUN=true
check "explicit creds skipped" "" PLUGIN_REGISTRY=$REG PLUGIN_USERNAME=u PLUGIN_PASSWORD=p
check "creds for another registry still log in" "[$OK_LOGIN]" PLUGIN_REGISTRY=git.unkin.net PLUGIN_REPO=$REG/docker-internal/x PLUGIN_USERNAME=u PLUGIN_PASSWORD=p
check "explicit login skipped" '[{"registry":"'$REG'"}]' PLUGIN_REGISTRY=$REG 'PLUGIN_LOGINS=[{"registry":"'$REG'"}]'
check "missing sa token is anonymous" "" PLUGIN_REGISTRY=$REG SA_TOKEN_FILE=/nonexistent
check "vault failure is anonymous" "" PLUGIN_REGISTRY=$REG VAULT_FAIL=1
check "bad role is anonymous" "" PLUGIN_REGISTRY=$REG PLUGIN_VAULT_ROLE=other
exit $fail
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
IFS="|"; echo "$*"