diff --git a/Dockerfile b/Dockerfile index a2ee409..3b600db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..165cdaa --- /dev/null +++ b/Makefile @@ -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" diff --git a/README.md b/README.md index 5d29b46..81b80cd 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/artifactapi-login.sh b/artifactapi-login.sh new file mode 100755 index 0000000..99624bd --- /dev/null +++ b/artifactapi-login.sh @@ -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 diff --git a/test/login_test.sh b/test/login_test.sh new file mode 100644 index 0000000..9b5bc88 --- /dev/null +++ b/test/login_test.sh @@ -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 diff --git a/test/stub-entrypoint.sh b/test/stub-entrypoint.sh new file mode 100755 index 0000000..2c03e33 --- /dev/null +++ b/test/stub-entrypoint.sh @@ -0,0 +1,2 @@ +#!/bin/sh +IFS="|"; echo "$*"