2026-04-15 21:15:44 +00:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
# hvault.sh — HashiCorp Vault helper module
|
|
|
|
|
#
|
|
|
|
|
# Typed, audited helpers for Vault KV v2 access so no script re-implements
|
|
|
|
|
# `curl -H "X-Vault-Token: ..."` ad-hoc.
|
|
|
|
|
#
|
|
|
|
|
# Usage: source this file, then call any hvault_* function.
|
|
|
|
|
#
|
|
|
|
|
# Environment:
|
|
|
|
|
# VAULT_ADDR — Vault server address (required, no default)
|
|
|
|
|
# VAULT_TOKEN — auth token (precedence: env > /etc/vault.d/root.token)
|
|
|
|
|
#
|
|
|
|
|
# All functions emit structured JSON errors to stderr on failure.
|
|
|
|
|
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
|
|
|
|
|
# ── Internal helpers ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
# _hvault_err — emit structured JSON error to stderr
|
|
|
|
|
# Args: func_name, message, [detail]
|
|
|
|
|
_hvault_err() {
|
|
|
|
|
local func="$1" msg="$2" detail="${3:-}"
|
2026-04-15 21:27:34 +00:00
|
|
|
jq -n --arg func "$func" --arg msg "$msg" --arg detail "$detail" \
|
|
|
|
|
'{error:true,function:$func,message:$msg,detail:$detail}' >&2
|
2026-04-15 21:15:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# _hvault_resolve_token — resolve VAULT_TOKEN from env or token file
|
|
|
|
|
_hvault_resolve_token() {
|
|
|
|
|
if [ -n "${VAULT_TOKEN:-}" ]; then
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
local token_file="/etc/vault.d/root.token"
|
|
|
|
|
if [ -f "$token_file" ]; then
|
|
|
|
|
VAULT_TOKEN="$(cat "$token_file")"
|
|
|
|
|
export VAULT_TOKEN
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
return 1
|
|
|
|
|
}
|
|
|
|
|
|
fix: [nomad-step-2] S2-fix — 4 bugs block Step 2 verification: kv/ mount missing, VAULT_ADDR, --sops required, template fallback (#912)
Post-Step-2 verification on a fresh LXC uncovered 4 stacked bugs blocking
the `disinto init --backend=nomad --import-env ... --with forgejo` hero
command. Root cause is #1; #2-#4 surface as the operator walks past each.
1. kv/ secret engine never enabled — every policy, role, import write,
and template read references kv/disinto/* and 403s without the mount.
Adds lib/init/nomad/vault-engines.sh (idempotent POST sys/mounts/kv)
wired into `_disinto_init_nomad` before vault-apply-policies.sh.
2. VAULT_ADDR/VAULT_TOKEN not exported in the init process. Extracts the
5-line default-and-resolve block into `_hvault_default_env` in
lib/hvault.sh and sources it from vault-engines.sh, vault-nomad-auth.sh,
vault-apply-policies.sh, vault-apply-roles.sh, and vault-import.sh. One
definition, zero copies — avoids the 5-line sliding-window duplicate
gate that failed PRs #917/#918.
3. vault-import.sh required --sops; spec (#880) says --env alone must
succeed. Flag validation now: --sops requires --age-key, --age-key
requires --sops, --env alone imports only the plaintext half.
4. forgejo.hcl template blocks forever when kv/disinto/shared/forgejo is
absent or missing a key. Adds `error_on_missing_key = false` so the
existing `with ... else ...` fallback emits placeholders instead of
hanging on template-pending.
vault-engines.sh parser uses a while/shift shape distinct from
vault-apply-policies.sh (flat case) and vault-apply-roles.sh (if/elif
ladder) so the three sibling flag parsers hash differently under the
repo-wide duplicate detector.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:10:59 +00:00
|
|
|
# _hvault_default_env — set the local-cluster Vault env if unset
|
|
|
|
|
#
|
|
|
|
|
# Idempotent helper used by every Vault-touching script that runs during
|
|
|
|
|
# `disinto init` (S2). On the local-cluster common case, operators (and
|
|
|
|
|
# the init dispatcher in bin/disinto) have not exported VAULT_ADDR or
|
|
|
|
|
# VAULT_TOKEN — the server is reachable on localhost:8200 and the root
|
|
|
|
|
# token lives at /etc/vault.d/root.token. Scripts must Just Work in that
|
|
|
|
|
# shape.
|
|
|
|
|
#
|
|
|
|
|
# - If VAULT_ADDR is unset, defaults to http://127.0.0.1:8200.
|
|
|
|
|
# - If VAULT_TOKEN is unset, resolves from /etc/vault.d/root.token via
|
|
|
|
|
# _hvault_resolve_token. A missing token file is not an error here —
|
|
|
|
|
# downstream hvault_token_lookup() probes connectivity and emits the
|
|
|
|
|
# operator-facing "VAULT_ADDR + VAULT_TOKEN" diagnostic.
|
|
|
|
|
#
|
|
|
|
|
# Centralised to keep the defaulting stanza in one place — copy-pasting
|
|
|
|
|
# the 5-line block into each init script trips the repo-wide 5-line
|
|
|
|
|
# sliding-window duplicate detector (.woodpecker/detect-duplicates.py).
|
|
|
|
|
_hvault_default_env() {
|
|
|
|
|
VAULT_ADDR="${VAULT_ADDR:-http://127.0.0.1:8200}"
|
|
|
|
|
export VAULT_ADDR
|
|
|
|
|
_hvault_resolve_token || :
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 21:15:44 +00:00
|
|
|
# _hvault_check_prereqs — validate VAULT_ADDR and VAULT_TOKEN are set
|
|
|
|
|
# Args: caller function name
|
|
|
|
|
_hvault_check_prereqs() {
|
|
|
|
|
local caller="$1"
|
|
|
|
|
if [ -z "${VAULT_ADDR:-}" ]; then
|
|
|
|
|
_hvault_err "$caller" "VAULT_ADDR is not set" "export VAULT_ADDR before calling $caller"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
if ! _hvault_resolve_token; then
|
|
|
|
|
_hvault_err "$caller" "VAULT_TOKEN is not set and /etc/vault.d/root.token not found" \
|
|
|
|
|
"export VAULT_TOKEN or write token to /etc/vault.d/root.token"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# _hvault_request — execute a Vault API request
|
|
|
|
|
# Args: method, path, [data]
|
|
|
|
|
# Outputs: response body to stdout
|
|
|
|
|
# Returns: 0 on 2xx, 1 otherwise (error JSON to stderr)
|
|
|
|
|
_hvault_request() {
|
|
|
|
|
local method="$1" path="$2" data="${3:-}"
|
|
|
|
|
local url="${VAULT_ADDR}/v1/${path}"
|
|
|
|
|
local http_code body
|
|
|
|
|
local tmpfile
|
|
|
|
|
tmpfile="$(mktemp)"
|
|
|
|
|
|
|
|
|
|
local curl_args=(
|
|
|
|
|
-s
|
|
|
|
|
-w '%{http_code}'
|
|
|
|
|
-H "X-Vault-Token: ${VAULT_TOKEN}"
|
|
|
|
|
-H "Content-Type: application/json"
|
|
|
|
|
-X "$method"
|
|
|
|
|
-o "$tmpfile"
|
|
|
|
|
)
|
|
|
|
|
if [ -n "$data" ]; then
|
|
|
|
|
curl_args+=(-d "$data")
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
http_code="$(curl "${curl_args[@]}" "$url")" || {
|
|
|
|
|
_hvault_err "_hvault_request" "curl failed" "url=$url"
|
|
|
|
|
rm -f "$tmpfile"
|
|
|
|
|
return 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body="$(cat "$tmpfile")"
|
|
|
|
|
rm -f "$tmpfile"
|
|
|
|
|
|
|
|
|
|
# Check HTTP status — 2xx is success
|
|
|
|
|
case "$http_code" in
|
|
|
|
|
2[0-9][0-9])
|
|
|
|
|
printf '%s' "$body"
|
|
|
|
|
return 0
|
|
|
|
|
;;
|
|
|
|
|
*)
|
|
|
|
|
_hvault_err "_hvault_request" "HTTP $http_code" "$body"
|
|
|
|
|
return 1
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# ── Public API ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-04-16 19:32:21 +00:00
|
|
|
# VAULT_KV_MOUNT — KV v2 mount point (default: "kv")
|
|
|
|
|
# Override with: export VAULT_KV_MOUNT=secret
|
|
|
|
|
# Used by: hvault_kv_get, hvault_kv_put, hvault_kv_list
|
|
|
|
|
: "${VAULT_KV_MOUNT:=kv}"
|
|
|
|
|
|
2026-04-15 21:15:44 +00:00
|
|
|
# hvault_kv_get PATH [KEY]
|
|
|
|
|
# Read a KV v2 secret at PATH, optionally extract a single KEY.
|
|
|
|
|
# Outputs: JSON value (full data object, or single key value)
|
|
|
|
|
hvault_kv_get() {
|
|
|
|
|
local path="${1:-}"
|
|
|
|
|
local key="${2:-}"
|
|
|
|
|
|
|
|
|
|
if [ -z "$path" ]; then
|
|
|
|
|
_hvault_err "hvault_kv_get" "PATH is required" "usage: hvault_kv_get PATH [KEY]"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
_hvault_check_prereqs "hvault_kv_get" || return 1
|
|
|
|
|
|
|
|
|
|
local response
|
2026-04-16 19:32:21 +00:00
|
|
|
response="$(_hvault_request GET "${VAULT_KV_MOUNT}/data/${path}")" || return 1
|
2026-04-15 21:15:44 +00:00
|
|
|
|
|
|
|
|
if [ -n "$key" ]; then
|
2026-04-15 21:27:34 +00:00
|
|
|
printf '%s' "$response" | jq -e -r --arg key "$key" '.data.data[$key]' 2>/dev/null || {
|
2026-04-15 21:15:44 +00:00
|
|
|
_hvault_err "hvault_kv_get" "key not found" "key=$key path=$path"
|
|
|
|
|
return 1
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
printf '%s' "$response" | jq -e '.data.data' 2>/dev/null || {
|
|
|
|
|
_hvault_err "hvault_kv_get" "failed to parse response" "path=$path"
|
|
|
|
|
return 1
|
|
|
|
|
}
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# hvault_kv_put PATH KEY=VAL [KEY=VAL ...]
|
|
|
|
|
# Write a KV v2 secret at PATH. Accepts one or more KEY=VAL pairs.
|
|
|
|
|
hvault_kv_put() {
|
|
|
|
|
local path="${1:-}"
|
|
|
|
|
shift || true
|
|
|
|
|
|
|
|
|
|
if [ -z "$path" ] || [ $# -eq 0 ]; then
|
|
|
|
|
_hvault_err "hvault_kv_put" "PATH and at least one KEY=VAL required" \
|
|
|
|
|
"usage: hvault_kv_put PATH KEY=VAL [KEY=VAL ...]"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
_hvault_check_prereqs "hvault_kv_put" || return 1
|
|
|
|
|
|
2026-04-15 21:27:34 +00:00
|
|
|
# Build JSON payload from KEY=VAL pairs entirely via jq
|
|
|
|
|
local payload='{"data":{}}'
|
2026-04-15 21:15:44 +00:00
|
|
|
for kv in "$@"; do
|
|
|
|
|
local k="${kv%%=*}"
|
|
|
|
|
local v="${kv#*=}"
|
|
|
|
|
if [ "$k" = "$kv" ]; then
|
|
|
|
|
_hvault_err "hvault_kv_put" "invalid KEY=VAL pair" "got: $kv"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
2026-04-15 21:27:34 +00:00
|
|
|
payload="$(printf '%s' "$payload" | jq --arg k "$k" --arg v "$v" '.data[$k] = $v')"
|
2026-04-15 21:15:44 +00:00
|
|
|
done
|
|
|
|
|
|
2026-04-16 19:32:21 +00:00
|
|
|
_hvault_request POST "${VAULT_KV_MOUNT}/data/${path}" "$payload" >/dev/null
|
2026-04-15 21:15:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# hvault_kv_list PATH
|
|
|
|
|
# List keys at a KV v2 path.
|
|
|
|
|
# Outputs: JSON array of key names
|
|
|
|
|
hvault_kv_list() {
|
|
|
|
|
local path="${1:-}"
|
|
|
|
|
|
|
|
|
|
if [ -z "$path" ]; then
|
|
|
|
|
_hvault_err "hvault_kv_list" "PATH is required" "usage: hvault_kv_list PATH"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
_hvault_check_prereqs "hvault_kv_list" || return 1
|
|
|
|
|
|
|
|
|
|
local response
|
2026-04-16 19:32:21 +00:00
|
|
|
response="$(_hvault_request LIST "${VAULT_KV_MOUNT}/metadata/${path}")" || return 1
|
2026-04-15 21:15:44 +00:00
|
|
|
|
|
|
|
|
printf '%s' "$response" | jq -e '.data.keys' 2>/dev/null || {
|
|
|
|
|
_hvault_err "hvault_kv_list" "failed to parse response" "path=$path"
|
|
|
|
|
return 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
fix: [nomad-step-2] S2.3 — vault-nomad-auth.sh (enable JWT auth + roles + nomad workload identity) (#881)
Wires Nomad → Vault via workload identity so jobs can exchange their
short-lived JWT for a Vault token carrying the policies in
vault/policies/ — no shared VAULT_TOKEN in job env.
- `lib/init/nomad/vault-nomad-auth.sh` — idempotent script: enable jwt
auth at path `jwt-nomad`, config JWKS/algs, apply roles, install
server.hcl + SIGHUP nomad on change.
- `tools/vault-apply-roles.sh` — companion sync script (S2.1 sibling);
reads vault/roles.yaml and upserts each Vault role under
auth/jwt-nomad/role/<name> with created/updated/unchanged semantics.
- `vault/roles.yaml` — declarative role→policy→bound_claims map; one
entry per vault/policies/*.hcl. Keeps S2.1 policies and S2.3 role
bindings visible side-by-side at review time.
- `nomad/server.hcl` — adds vault stanza (enabled, address,
default_identity.aud=["vault.io"], ttl=1h).
- `lib/hvault.sh` — new `hvault_get_or_empty` helper shared between
vault-apply-policies.sh, vault-apply-roles.sh, and vault-nomad-auth.sh;
reads a Vault endpoint and distinguishes 200 / 404 / other.
- `vault/policies/AGENTS.md` — extends S2.1 docs with JWT-auth role
naming convention, token shape, and the "add new service" flow.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:44:22 +00:00
|
|
|
# hvault_get_or_empty PATH
|
|
|
|
|
# GET /v1/PATH. On 200, prints the raw response body to stdout (caller
|
|
|
|
|
# parses with jq). On 404, prints nothing and returns 0 — caller treats
|
|
|
|
|
# the empty string as "resource absent, needs create". Any other HTTP
|
|
|
|
|
# status is a hard error: response body is logged to stderr as a
|
|
|
|
|
# structured JSON error and the function returns 1.
|
|
|
|
|
#
|
|
|
|
|
# Used by the sync scripts (tools/vault-apply-*.sh +
|
|
|
|
|
# lib/init/nomad/vault-nomad-auth.sh) to read existing policies, roles,
|
|
|
|
|
# auth-method listings, and per-role configs without triggering errexit
|
|
|
|
|
# on the expected absent-resource case. `_hvault_request` is not a
|
|
|
|
|
# substitute — it treats 404 as a hard error, which is correct for
|
|
|
|
|
# writes but wrong for "does this already exist?" checks.
|
|
|
|
|
#
|
|
|
|
|
# Subshell + EXIT trap: the RETURN trap does NOT fire on set-e abort,
|
|
|
|
|
# so tmpfile cleanup from a function-scoped RETURN trap would leak on
|
|
|
|
|
# jq/curl errors under `set -eo pipefail`. The subshell + EXIT trap
|
|
|
|
|
# is the reliable cleanup boundary.
|
|
|
|
|
hvault_get_or_empty() {
|
|
|
|
|
local path="${1:-}"
|
|
|
|
|
|
|
|
|
|
if [ -z "$path" ]; then
|
|
|
|
|
_hvault_err "hvault_get_or_empty" "PATH is required" \
|
|
|
|
|
"usage: hvault_get_or_empty PATH"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
_hvault_check_prereqs "hvault_get_or_empty" || return 1
|
|
|
|
|
|
|
|
|
|
(
|
|
|
|
|
local tmp http_code
|
|
|
|
|
tmp="$(mktemp)"
|
|
|
|
|
trap 'rm -f "$tmp"' EXIT
|
|
|
|
|
http_code="$(curl -sS -o "$tmp" -w '%{http_code}' \
|
|
|
|
|
-H "X-Vault-Token: ${VAULT_TOKEN}" \
|
|
|
|
|
"${VAULT_ADDR}/v1/${path}")" \
|
|
|
|
|
|| { _hvault_err "hvault_get_or_empty" "curl failed" "path=$path"; exit 1; }
|
|
|
|
|
case "$http_code" in
|
|
|
|
|
2[0-9][0-9]) cat "$tmp" ;;
|
|
|
|
|
404) printf '' ;;
|
|
|
|
|
*) _hvault_err "hvault_get_or_empty" "HTTP $http_code" "$(cat "$tmp")"
|
|
|
|
|
exit 1 ;;
|
|
|
|
|
esac
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 21:15:44 +00:00
|
|
|
# hvault_policy_apply NAME FILE
|
|
|
|
|
# Idempotent policy upsert — create or update a Vault policy.
|
|
|
|
|
hvault_policy_apply() {
|
|
|
|
|
local name="${1:-}"
|
|
|
|
|
local file="${2:-}"
|
|
|
|
|
|
|
|
|
|
if [ -z "$name" ] || [ -z "$file" ]; then
|
|
|
|
|
_hvault_err "hvault_policy_apply" "NAME and FILE are required" \
|
|
|
|
|
"usage: hvault_policy_apply NAME FILE"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
if [ ! -f "$file" ]; then
|
|
|
|
|
_hvault_err "hvault_policy_apply" "policy file not found" "file=$file"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
_hvault_check_prereqs "hvault_policy_apply" || return 1
|
|
|
|
|
|
|
|
|
|
local policy_content
|
|
|
|
|
policy_content="$(cat "$file")"
|
|
|
|
|
local payload
|
|
|
|
|
payload="$(jq -n --arg policy "$policy_content" '{"policy": $policy}')"
|
|
|
|
|
|
|
|
|
|
_hvault_request PUT "sys/policies/acl/${name}" "$payload" >/dev/null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# hvault_jwt_login ROLE JWT
|
|
|
|
|
# Exchange a JWT for a short-lived Vault token.
|
|
|
|
|
# Outputs: client token string
|
|
|
|
|
hvault_jwt_login() {
|
|
|
|
|
local role="${1:-}"
|
|
|
|
|
local jwt="${2:-}"
|
|
|
|
|
|
|
|
|
|
if [ -z "$role" ] || [ -z "$jwt" ]; then
|
|
|
|
|
_hvault_err "hvault_jwt_login" "ROLE and JWT are required" \
|
|
|
|
|
"usage: hvault_jwt_login ROLE JWT"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
# Only need VAULT_ADDR, not VAULT_TOKEN (we're obtaining a token)
|
|
|
|
|
if [ -z "${VAULT_ADDR:-}" ]; then
|
|
|
|
|
_hvault_err "hvault_jwt_login" "VAULT_ADDR is not set"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
local payload
|
|
|
|
|
payload="$(jq -n --arg role "$role" --arg jwt "$jwt" \
|
|
|
|
|
'{"role": $role, "jwt": $jwt}')"
|
|
|
|
|
|
|
|
|
|
local response
|
|
|
|
|
# JWT login does not require an existing token — use curl directly
|
|
|
|
|
local tmpfile http_code
|
|
|
|
|
tmpfile="$(mktemp)"
|
|
|
|
|
http_code="$(curl -s -w '%{http_code}' \
|
|
|
|
|
-H "Content-Type: application/json" \
|
|
|
|
|
-X POST \
|
|
|
|
|
-d "$payload" \
|
|
|
|
|
-o "$tmpfile" \
|
|
|
|
|
"${VAULT_ADDR}/v1/auth/jwt/login")" || {
|
|
|
|
|
_hvault_err "hvault_jwt_login" "curl failed"
|
|
|
|
|
rm -f "$tmpfile"
|
|
|
|
|
return 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
local body
|
|
|
|
|
body="$(cat "$tmpfile")"
|
|
|
|
|
rm -f "$tmpfile"
|
|
|
|
|
|
|
|
|
|
case "$http_code" in
|
|
|
|
|
2[0-9][0-9])
|
|
|
|
|
printf '%s' "$body" | jq -e -r '.auth.client_token' 2>/dev/null || {
|
|
|
|
|
_hvault_err "hvault_jwt_login" "failed to extract client_token" "$body"
|
|
|
|
|
return 1
|
|
|
|
|
}
|
|
|
|
|
;;
|
|
|
|
|
*)
|
|
|
|
|
_hvault_err "hvault_jwt_login" "HTTP $http_code" "$body"
|
|
|
|
|
return 1
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# hvault_token_lookup
|
|
|
|
|
# Returns TTL, policies, and accessor for the current token.
|
|
|
|
|
# Outputs: JSON object with ttl, policies, accessor fields
|
|
|
|
|
hvault_token_lookup() {
|
|
|
|
|
_hvault_check_prereqs "hvault_token_lookup" || return 1
|
|
|
|
|
|
|
|
|
|
local response
|
|
|
|
|
response="$(_hvault_request GET "auth/token/lookup-self")" || return 1
|
|
|
|
|
|
|
|
|
|
printf '%s' "$response" | jq -e '{
|
|
|
|
|
ttl: .data.ttl,
|
|
|
|
|
policies: .data.policies,
|
|
|
|
|
accessor: .data.accessor,
|
|
|
|
|
display_name: .data.display_name
|
|
|
|
|
}' 2>/dev/null || {
|
|
|
|
|
_hvault_err "hvault_token_lookup" "failed to parse token info"
|
|
|
|
|
return 1
|
|
|
|
|
}
|
|
|
|
|
}
|