offinfra-onboard: per-repo GHA->ghcr migration tool + f1-stream ghcr pull secret
ADR-0002 tracer bullet (infra#13), per Viktor's go-ahead. Idempotent script: GitHub mirror repo (create/unarchive/visibility), GHA secrets via gh, Forgejo push-mirror (sync_on_commit) + initial sync, Woodpecker mirror registration, renders build.yml/deploy.yml from templates (single-manifest provenance:false, svu semver to Forgejo, ghcr keep-10 retention, Slack notify-failure, manual-event deploy), removes the old in-cluster build pipeline, commits on the Canonical side. f1-stream stack gains the ghcr-credentials imagePullSecret (first consumer). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
3138a0a040
commit
baff3d7477
4 changed files with 344 additions and 0 deletions
205
scripts/offinfra-onboard
Executable file
205
scripts/offinfra-onboard
Executable file
|
|
@ -0,0 +1,205 @@
|
|||
#!/usr/bin/env bash
|
||||
# offinfra-onboard — migrate a Canonical (Forgejo) repo's image build to
|
||||
# GitHub Actions → ghcr.io (ADR-0002, PRD infra#10). Idempotent: re-running
|
||||
# skips every already-done step.
|
||||
#
|
||||
# What it does:
|
||||
# 1. Ensures the GitHub mirror repo exists (right visibility; unarchives).
|
||||
# 2. Sets GHA secrets (WOODPECKER_TOKEN, FORGEJO_GIT_TOKEN, SLACK_WEBHOOK).
|
||||
# 3. Ensures the Forgejo push-mirror (sync_on_commit) + fires an initial sync.
|
||||
# 4. Registers the mirror in Woodpecker (github forge) → deploy repo id.
|
||||
# 5. Renders .github/workflows/build.yml + .woodpecker/deploy.yml into the
|
||||
# clone, removes the old in-cluster build pipeline.
|
||||
# 6. Commits on the FORGEJO side and pushes master (this fires the chain).
|
||||
# 7. Flips the GitHub default branch to master once the mirror has synced.
|
||||
#
|
||||
# Usage:
|
||||
# offinfra-onboard <name> --clone <path> --visibility private|public \
|
||||
# --namespace <ns> --deploy "<deployment>=<container>[,<container>...]" \
|
||||
# [--image <ghcr-image-name>] [--context <docker-context>] \
|
||||
# [--test-steps <yaml-file>] [--dry-run]
|
||||
#
|
||||
# --deploy is repeatable. --test-steps points at a YAML fragment of extra
|
||||
# steps for the lint-and-test job (indented for a `steps:` list); omitted =
|
||||
# a no-op test job.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
TEMPLATES="$SCRIPT_DIR/offinfra-templates"
|
||||
GH_OWNER="ViktorBarzin"
|
||||
FORGEJO_HOST="forgejo.viktorbarzin.me"
|
||||
FORGEJO_LB="10.0.20.203"
|
||||
WP_API="https://ci.viktorbarzin.me/api"
|
||||
|
||||
NAME=${1:?usage: offinfra-onboard <name> [flags]}; shift
|
||||
CLONE="" VISIBILITY="" NAMESPACE="" IMAGE="" CONTEXT="." TEST_STEPS_FILE="" DRY_RUN=0
|
||||
DEPLOYS=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--clone) CLONE=$2; shift 2;;
|
||||
--visibility) VISIBILITY=$2; shift 2;;
|
||||
--namespace) NAMESPACE=$2; shift 2;;
|
||||
--image) IMAGE=$2; shift 2;;
|
||||
--context) CONTEXT=$2; shift 2;;
|
||||
--deploy) DEPLOYS+=("$2"); shift 2;;
|
||||
--test-steps) TEST_STEPS_FILE=$2; shift 2;;
|
||||
--dry-run) DRY_RUN=1; shift;;
|
||||
*) echo "unknown flag: $1" >&2; exit 1;;
|
||||
esac
|
||||
done
|
||||
IMAGE=${IMAGE:-$NAME}
|
||||
[ -n "$CLONE" ] && [ -d "$CLONE/.git" ] || { echo "--clone must point at a git clone" >&2; exit 1; }
|
||||
[ "$VISIBILITY" = "private" ] || [ "$VISIBILITY" = "public" ] || { echo "--visibility private|public" >&2; exit 1; }
|
||||
[ -n "$NAMESPACE" ] || { echo "--namespace required" >&2; exit 1; }
|
||||
[ ${#DEPLOYS[@]} -gt 0 ] || { echo "at least one --deploy required" >&2; exit 1; }
|
||||
|
||||
log() { printf '\033[1m[%s]\033[0m %s\n' "$NAME" "$*"; }
|
||||
run() { if [ "$DRY_RUN" = 1 ]; then echo "DRY: $*"; else "$@"; fi; }
|
||||
|
||||
# --- credentials ---
|
||||
export VAULT_ADDR=${VAULT_ADDR:-https://vault.viktorbarzin.me}
|
||||
WP_TOKEN=$(vault kv get -field=woodpecker_api_token secret/ci/global)
|
||||
SLACK_WEBHOOK=$(vault kv get -field=slack_webhook secret/ci/global)
|
||||
GH_PAT=$(vault kv get -field=github_pat secret/viktor)
|
||||
# Forgejo token: from the clone's forgejo remote URL (the documented contract)
|
||||
FORGEJO_REMOTE=$(git -C "$CLONE" remote -v | awk -v h="$FORGEJO_HOST" '$2 ~ h && $3 == "(push)" {print $1; exit}')
|
||||
[ -n "$FORGEJO_REMOTE" ] || { echo "no forgejo remote in $CLONE" >&2; exit 1; }
|
||||
FORGEJO_TOKEN=$(git -C "$CLONE" remote get-url "$FORGEJO_REMOTE" | sed -n 's#https://[^:]*:\([^@]*\)@.*#\1#p')
|
||||
[ -n "$FORGEJO_TOKEN" ] || { echo "could not extract forgejo token from remote URL" >&2; exit 1; }
|
||||
|
||||
FJ() { curl -sf --resolve "$FORGEJO_HOST:443:$FORGEJO_LB" -H "Authorization: token $FORGEJO_TOKEN" -H 'Content-Type: application/json' "$@"; }
|
||||
WP() { curl -sf -H "Authorization: Bearer $WP_TOKEN" -H 'Content-Type: application/json' "$@"; }
|
||||
|
||||
# --- 1) GitHub mirror repo ---
|
||||
if state=$(gh api "repos/$GH_OWNER/$NAME" --jq '{archived,private}' 2>/dev/null); then
|
||||
archived=$(jq -r .archived <<<"$state"); private=$(jq -r .private <<<"$state")
|
||||
if [ "$archived" = "true" ]; then
|
||||
log "GitHub repo exists but is ARCHIVED — unarchiving"
|
||||
run gh api -X PATCH "repos/$GH_OWNER/$NAME" -F archived=false >/dev/null
|
||||
fi
|
||||
want_private=$([ "$VISIBILITY" = private ] && echo true || echo false)
|
||||
if [ "$private" != "$want_private" ]; then
|
||||
log "setting visibility -> $VISIBILITY"
|
||||
run gh api -X PATCH "repos/$GH_OWNER/$NAME" -F private="$want_private" >/dev/null
|
||||
else
|
||||
log "GitHub repo visibility already $VISIBILITY — SKIP"
|
||||
fi
|
||||
else
|
||||
log "creating GitHub mirror repo ($VISIBILITY)"
|
||||
run gh repo create "$GH_OWNER/$NAME" "--$VISIBILITY" \
|
||||
--description "One-way mirror of forgejo viktor/$NAME — do NOT commit here (ADR-0002)" >/dev/null
|
||||
fi
|
||||
|
||||
# --- 2) GHA secrets ---
|
||||
log "setting GHA secrets (WOODPECKER_TOKEN, FORGEJO_GIT_TOKEN, SLACK_WEBHOOK)"
|
||||
if [ "$DRY_RUN" = 0 ]; then
|
||||
gh secret set WOODPECKER_TOKEN -R "$GH_OWNER/$NAME" --body "$WP_TOKEN"
|
||||
gh secret set FORGEJO_GIT_TOKEN -R "$GH_OWNER/$NAME" --body "$FORGEJO_TOKEN"
|
||||
gh secret set SLACK_WEBHOOK -R "$GH_OWNER/$NAME" --body "$SLACK_WEBHOOK"
|
||||
fi
|
||||
|
||||
# --- 3) Forgejo push-mirror ---
|
||||
mirrors=$(FJ "https://$FORGEJO_HOST/api/v1/repos/viktor/$NAME/push_mirrors" || echo '[]')
|
||||
if printf '%s' "$mirrors" | jq -e --arg a "github.com/$GH_OWNER/$NAME" '.[] | select(.remote_address | contains($a))' >/dev/null 2>&1; then
|
||||
log "push-mirror already configured — SKIP"
|
||||
else
|
||||
log "creating push-mirror -> github.com/$GH_OWNER/$NAME (sync_on_commit)"
|
||||
run FJ -X POST "https://$FORGEJO_HOST/api/v1/repos/viktor/$NAME/push_mirrors" \
|
||||
-d "{\"remote_address\":\"https://github.com/$GH_OWNER/$NAME.git\",\"remote_username\":\"$GH_OWNER\",\"remote_password\":$(jq -Rn --arg p "$GH_PAT" '$p'),\"interval\":\"8h0m0s\",\"sync_on_commit\":true}" >/dev/null
|
||||
fi
|
||||
log "firing initial mirror sync"
|
||||
run FJ -X POST "https://$FORGEJO_HOST/api/v1/repos/viktor/$NAME/push_mirrors-sync" >/dev/null || true
|
||||
|
||||
# --- 4) Woodpecker registration (github forge) ---
|
||||
WP_REPO_ID=$(WP "$WP_API/repos?perPage=100" | jq -r --arg n "$GH_OWNER/$NAME" '.[] | select(.full_name == $n) | .id' | head -1)
|
||||
if [ -n "$WP_REPO_ID" ]; then
|
||||
log "Woodpecker repo already registered (id=$WP_REPO_ID) — SKIP"
|
||||
else
|
||||
GH_REPO_ID=$(gh api "repos/$GH_OWNER/$NAME" --jq .id)
|
||||
log "registering mirror in Woodpecker (forge_remote_id=$GH_REPO_ID)"
|
||||
if [ "$DRY_RUN" = 0 ]; then
|
||||
WP_REPO_ID=$(WP -X POST "$WP_API/repos?forge_remote_id=$GH_REPO_ID" | jq -r .id)
|
||||
else
|
||||
WP_REPO_ID="DRY"
|
||||
fi
|
||||
log "Woodpecker repo id = $WP_REPO_ID"
|
||||
fi
|
||||
|
||||
# --- 5) Render workflow + deploy files into the clone ---
|
||||
DEPLOY_CMDS=""
|
||||
for d in "${DEPLOYS[@]}"; do
|
||||
dep=${d%%=*}; containers=${d#*=}
|
||||
setargs=""
|
||||
IFS=',' read -ra cs <<<"$containers"
|
||||
for c in "${cs[@]}"; do setargs="$setargs $c=\${IMAGE_NAME}:\${IMAGE_TAG}"; done
|
||||
DEPLOY_CMDS="$DEPLOY_CMDS - \"kubectl -n $NAMESPACE set image deployment/$dep$setargs\"\n"
|
||||
DEPLOY_CMDS="$DEPLOY_CMDS - \"kubectl -n $NAMESPACE rollout status deployment/$dep --timeout=300s\"\n"
|
||||
done
|
||||
if [ -n "$TEST_STEPS_FILE" ]; then
|
||||
TEST_STEPS=$(cat "$TEST_STEPS_FILE")
|
||||
else
|
||||
TEST_STEPS=' - run: echo "no test steps configured"'
|
||||
fi
|
||||
|
||||
export T_NAME=$NAME T_IMAGE=$IMAGE T_CONTEXT=$CONTEXT T_WPID=$WP_REPO_ID T_TEST="$TEST_STEPS" T_DEPLOY="$DEPLOY_CMDS"
|
||||
render() { # $1=template $2=dest
|
||||
python3 - "$1" "$2" <<'PYEOF'
|
||||
import os, sys
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
s = open(src).read()
|
||||
s = s.replace('{{NAME}}', os.environ['T_NAME'])
|
||||
s = s.replace('{{IMAGE}}', os.environ['T_IMAGE'])
|
||||
s = s.replace('{{CONTEXT}}', os.environ['T_CONTEXT'])
|
||||
s = s.replace('{{WP_REPO_ID}}', os.environ['T_WPID'])
|
||||
s = s.replace('{{TEST_STEPS}}', os.environ['T_TEST'])
|
||||
s = s.replace('{{DEPLOY_CMDS}}', os.environ['T_DEPLOY'].replace('\\n', '\n').rstrip('\n'))
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
open(dst, 'w').write(s)
|
||||
PYEOF
|
||||
}
|
||||
log "rendering build.yml + deploy.yml"
|
||||
if [ "$DRY_RUN" = 0 ]; then
|
||||
render "$TEMPLATES/build.yml.tmpl" "$CLONE/.github/workflows/build.yml"
|
||||
render "$TEMPLATES/deploy.yml.tmpl" "$CLONE/.woodpecker/deploy.yml"
|
||||
fi
|
||||
|
||||
# --- 6) Remove old in-cluster build pipeline + commit on Forgejo side ---
|
||||
cd "$CLONE"
|
||||
OLD_REMOVED=""
|
||||
for f in .woodpecker.yml .woodpecker/build.yml .woodpecker/build-fallback.yml; do
|
||||
[ -f "$f" ] && { run git rm -q "$f"; OLD_REMOVED="$OLD_REMOVED $f"; }
|
||||
done
|
||||
run git add .github/workflows/build.yml .woodpecker/deploy.yml
|
||||
if git diff --cached --quiet 2>/dev/null; then
|
||||
log "no changes to commit — SKIP (already migrated)"
|
||||
else
|
||||
log "committing + pushing to forgejo master (this fires the chain)"
|
||||
run git commit -q -m "ci: move image build off-infra to GHA -> ghcr (ADR-0002)
|
||||
|
||||
Generated by infra/scripts/offinfra-onboard: GHA builds+tests on the
|
||||
GitHub mirror, pushes ghcr.io/viktorbarzin/$IMAGE, then triggers the
|
||||
Woodpecker deploy (repo $WP_REPO_ID). Old in-cluster build pipeline
|
||||
removed:$OLD_REMOVED
|
||||
|
||||
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
||||
run git push "$FORGEJO_REMOTE" master
|
||||
fi
|
||||
|
||||
# --- 7) GitHub default branch -> master (after mirror sync) ---
|
||||
if [ "$DRY_RUN" = 0 ]; then
|
||||
for i in $(seq 1 24); do
|
||||
if gh api "repos/$GH_OWNER/$NAME/branches/master" >/dev/null 2>&1; then
|
||||
cur=$(gh api "repos/$GH_OWNER/$NAME" --jq .default_branch)
|
||||
[ "$cur" != "master" ] && gh api -X PATCH "repos/$GH_OWNER/$NAME" -F default_branch=master >/dev/null && log "default branch -> master"
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
fi
|
||||
|
||||
log "DONE. Verify the chain:"
|
||||
echo " - GHA run: gh run list -R $GH_OWNER/$NAME --limit 3"
|
||||
echo " - ghcr tags: (token exchange) https://ghcr.io/v2/viktorbarzin/$IMAGE/tags/list"
|
||||
echo " - WP deploy: $WP_API/repos/$WP_REPO_ID/pipelines"
|
||||
echo " - rollout: kubectl -n $NAMESPACE get deploy"
|
||||
echo " - pull secret: ensure the Deployment carries ghcr-credentials (Kyverno allowlist + stack imagePullSecrets)"
|
||||
115
scripts/offinfra-templates/build.yml.tmpl
Normal file
115
scripts/offinfra-templates/build.yml.tmpl
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
name: Build and Push
|
||||
|
||||
# Off-infra build (ADR-0002). Canonical repo is Forgejo viktor/{{NAME}}, which
|
||||
# push-mirrors here; this workflow builds on GitHub-hosted runners, pushes the
|
||||
# image to GHCR, then signals the Woodpecker deploy pipeline (repo {{WP_REPO_ID}})
|
||||
# to roll the cluster — the homelab never sees build IO or registry pushes.
|
||||
#
|
||||
# Committed on the FORGEJO side (the mirror is one-way; commits made on GitHub
|
||||
# are overwritten by the next sync). Generated by infra/scripts/offinfra-onboard.
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
{{TEST_STEPS}}
|
||||
|
||||
build:
|
||||
needs: lint-and-test
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
image_tag: ${{ steps.meta.outputs.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # full history + tags so svu sees the last vX.Y.Z
|
||||
fetch-tags: true
|
||||
# Auto-semver (svu): tag-only, pushed to CANONICAL Forgejo (GitHub tags
|
||||
# would be wiped by the next mirror sync). Best-effort: never blocks the build.
|
||||
- name: Compute + tag semver (svu)
|
||||
env:
|
||||
FORGEJO_GIT_TOKEN: ${{ secrets.FORGEJO_GIT_TOKEN }}
|
||||
run: |
|
||||
set +e
|
||||
git config user.email "ci@viktorbarzin.me"
|
||||
git config user.name "{{NAME}}-ci"
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
curl -sSL https://github.com/caarlos0/svu/releases/download/v3.4.1/svu_3.4.1_linux_amd64.tar.gz | tar -xz svu
|
||||
CUR=$(./svu current 2>/dev/null)
|
||||
NEXT=$(./svu next 2>/dev/null)
|
||||
echo "svu current=[$CUR] next=[$NEXT]"
|
||||
if [ -n "$NEXT" ] && [ "$NEXT" != "$CUR" ]; then
|
||||
git tag "$NEXT" 2>/dev/null
|
||||
git push "https://viktor:${FORGEJO_GIT_TOKEN}@forgejo.viktorbarzin.me/viktor/{{NAME}}.git" "$NEXT" && echo "pushed tag $NEXT to forgejo" || echo "tag push failed (non-blocking)"
|
||||
fi
|
||||
exit 0
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- id: meta
|
||||
run: echo "sha=$(echo ${{ github.sha }} | cut -c1-8)" >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: {{CONTEXT}}
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
# Single-manifest images (no provenance/SBOM attestation children) so
|
||||
# registry retention can never orphan index children (ADR-0002).
|
||||
provenance: false
|
||||
tags: |
|
||||
ghcr.io/viktorbarzin/{{IMAGE}}:${{ steps.meta.outputs.sha }}
|
||||
ghcr.io/viktorbarzin/{{IMAGE}}:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
# Keep the newest ~10 versions on ghcr (latest rides the newest one).
|
||||
- name: ghcr retention (keep 10)
|
||||
uses: actions/delete-package-versions@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
package-name: {{IMAGE}}
|
||||
package-type: container
|
||||
min-versions-to-keep: 10
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Signal Woodpecker (repo {{WP_REPO_ID}} = ViktorBarzin/{{NAME}} mirror) to run
|
||||
# .woodpecker/deploy.yml — kubectl set image in-cluster (agent SA is cluster-admin).
|
||||
- name: Trigger Woodpecker deploy
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
"https://ci.viktorbarzin.me/api/repos/{{WP_REPO_ID}}/pipelines" \
|
||||
-H "Authorization: Bearer ${{ secrets.WOODPECKER_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"branch\":\"master\",\"variables\":{\"IMAGE_TAG\":\"${{ needs.build.outputs.image_tag }}\",\"IMAGE_NAME\":\"ghcr.io/viktorbarzin/{{IMAGE}}\"}}")
|
||||
if [ "$STATUS" -ge 200 ] && [ "$STATUS" -lt 300 ]; then
|
||||
echo "Woodpecker deploy triggered (HTTP $STATUS)"; exit 0
|
||||
fi
|
||||
echo "Attempt $attempt failed (HTTP $STATUS), retrying in 30s..."; sleep 30
|
||||
done
|
||||
echo "Failed to trigger Woodpecker deploy after 3 attempts"; exit 1
|
||||
|
||||
notify-failure:
|
||||
needs: [lint-and-test, build, deploy]
|
||||
if: failure()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Slack notify
|
||||
run: |
|
||||
curl -sf -X POST -H 'Content-Type: application/json' \
|
||||
-d "{\"text\":\":rotating_light: {{NAME}} off-infra build FAILED: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}" \
|
||||
"${{ secrets.SLACK_WEBHOOK }}" || true
|
||||
19
scripts/offinfra-templates/deploy.yml.tmpl
Normal file
19
scripts/offinfra-templates/deploy.yml.tmpl
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Auto-deploy, triggered ONLY by the GitHub Actions build POSTing to the
|
||||
# Woodpecker API (manual event, with IMAGE_TAG + IMAGE_NAME) after a successful
|
||||
# off-infra build+push to GHCR (ADR-0002). event:[manual] (NOT push) so the
|
||||
# Forgejo->GitHub mirror's raw pushes don't fire a spurious deploy.
|
||||
# The woodpecker-agent SA is cluster-admin — no kubeconfig needed.
|
||||
# Generated by infra/scripts/offinfra-onboard.
|
||||
when:
|
||||
- event: manual
|
||||
|
||||
steps:
|
||||
- name: check-vars
|
||||
image: alpine
|
||||
commands:
|
||||
- "[ -n \"$IMAGE_TAG\" ] || (echo 'IMAGE_TAG not set — refusing to deploy'; exit 1)"
|
||||
|
||||
- name: deploy
|
||||
image: bitnami/kubectl:latest
|
||||
commands:
|
||||
{{DEPLOY_CMDS}}
|
||||
|
|
@ -195,6 +195,11 @@ resource "kubernetes_deployment" "f1-stream" {
|
|||
image_pull_secrets {
|
||||
name = "registry-credentials"
|
||||
}
|
||||
# Private ghcr image (ADR-0002 off-infra builds) — cloned into this
|
||||
# namespace by the kyverno sync-ghcr-credentials allowlist policy.
|
||||
image_pull_secrets {
|
||||
name = "ghcr-credentials"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue